misc: add auth attempt metrics

This commit is contained in:
Sheen Capadngan
2025-10-30 02:05:31 +08:00
parent e40031aeb5
commit 5d83101462
16 changed files with 1701 additions and 1298 deletions
+21 -17
View File
@@ -157,26 +157,30 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => {
metadata: userMetadata metadata: userMetadata
}); });
authAttemptCounter.add(1, { if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
"infisical.user.email": email.toLowerCase(), authAttemptCounter.add(1, {
"infisical.user.id": user.id, "infisical.user.email": email.toLowerCase(),
"infisical.organization.id": organization.id, "infisical.user.id": user.id,
"infisical.organization.name": organization.name, "infisical.organization.id": organization.id,
"infisical.auth.method": AuthAttemptAuthMethod.SAML, "infisical.organization.name": organization.name,
"infisical.auth.result": AuthAttemptAuthResult.SUCCESS, "infisical.auth.method": AuthAttemptAuthMethod.SAML,
"client.address": requestContext.get("ip"), "infisical.auth.result": AuthAttemptAuthResult.SUCCESS,
"user_agent.original": requestContext.get("userAgent") "client.address": requestContext.get("ip"),
}); "user_agent.original": requestContext.get("userAgent")
});
}
cb(null, { isUserCompleted, providerAuthToken }); cb(null, { isUserCompleted, providerAuthToken });
} catch (error) { } catch (error) {
authAttemptCounter.add(1, { if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
"infisical.user.email": email.toLowerCase(), authAttemptCounter.add(1, {
"infisical.auth.method": AuthAttemptAuthMethod.SAML, "infisical.user.email": email.toLowerCase(),
"infisical.auth.result": AuthAttemptAuthResult.FAILURE, "infisical.auth.method": AuthAttemptAuthMethod.SAML,
"client.address": requestContext.get("ip"), "infisical.auth.result": AuthAttemptAuthResult.FAILURE,
"user_agent.original": requestContext.get("userAgent") "client.address": requestContext.get("ip"),
}); "user_agent.original": requestContext.get("userAgent")
});
}
logger.error(error); logger.error(error);
cb(error as Error); cb(error as Error);
@@ -757,29 +757,33 @@ export const oidcConfigServiceFactory = ({
manageGroupMemberships: oidcCfg.manageGroupMemberships manageGroupMemberships: oidcCfg.manageGroupMemberships
}) })
.then(({ isUserCompleted, providerAuthToken, user }) => { .then(({ isUserCompleted, providerAuthToken, user }) => {
authAttemptCounter.add(1, { if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
"infisical.user.email": claims?.email?.toLowerCase(), authAttemptCounter.add(1, {
"infisical.user.id": user.id, "infisical.user.email": claims?.email?.toLowerCase(),
"infisical.organization.id": org.id, "infisical.user.id": user.id,
"infisical.organization.name": org.name, "infisical.organization.id": org.id,
"infisical.auth.method": AuthAttemptAuthMethod.OIDC, "infisical.organization.name": org.name,
"infisical.auth.result": AuthAttemptAuthResult.SUCCESS, "infisical.auth.method": AuthAttemptAuthMethod.OIDC,
"client.address": requestContext.get("ip"), "infisical.auth.result": AuthAttemptAuthResult.SUCCESS,
"user_agent.original": requestContext.get("userAgent") "client.address": requestContext.get("ip"),
}); "user_agent.original": requestContext.get("userAgent")
});
}
cb(null, { isUserCompleted, providerAuthToken }); cb(null, { isUserCompleted, providerAuthToken });
}) })
.catch((error) => { .catch((error) => {
authAttemptCounter.add(1, { if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
"infisical.user.email": claims?.email?.toLowerCase(), authAttemptCounter.add(1, {
"infisical.organization.id": org.id, "infisical.user.email": claims?.email?.toLowerCase(),
"infisical.organization.name": org.name, "infisical.organization.id": org.id,
"infisical.auth.method": AuthAttemptAuthMethod.OIDC, "infisical.organization.name": org.name,
"infisical.auth.result": AuthAttemptAuthResult.FAILURE, "infisical.auth.method": AuthAttemptAuthMethod.OIDC,
"client.address": requestContext.get("ip"), "infisical.auth.result": AuthAttemptAuthResult.FAILURE,
"user_agent.original": requestContext.get("userAgent") "client.address": requestContext.get("ip"),
}); "user_agent.original": requestContext.get("userAgent")
});
}
cb(error); cb(error);
}); });
+13 -1
View File
@@ -8,7 +8,19 @@ export enum AuthAttemptAuthMethod {
OIDC = "oidc", OIDC = "oidc",
GOOGLE = "google", GOOGLE = "google",
GITHUB = "github", GITHUB = "github",
GITLAB = "gitlab" GITLAB = "gitlab",
TOKEN_AUTH = "token-auth",
UNIVERSAL_AUTH = "universal-auth",
KUBERNETES_AUTH = "kubernetes-auth",
GCP_AUTH = "gcp-auth",
ALICLOUD_AUTH = "alicloud-auth",
AWS_AUTH = "aws-auth",
AZURE_AUTH = "azure-auth",
TLS_CERT_AUTH = "tls-cert-auth",
OCI_AUTH = "oci-auth",
OIDC_AUTH = "oidc-auth",
JWT_AUTH = "jwt-auth",
LDAP_AUTH = "ldap-auth"
} }
export enum AuthAttemptAuthResult { export enum AuthAttemptAuthResult {
+63 -53
View File
@@ -76,28 +76,31 @@ export const registerOauthMiddlewares = (server: FastifyZodProvider) => {
orgSlug orgSlug
}); });
authAttemptCounter.add(1, { if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
"infisical.user.email": email, authAttemptCounter.add(1, {
"infisical.user.id": user.id, "infisical.user.email": email,
"infisical.organization.id": orgId, "infisical.user.id": user.id,
"infisical.organization.name": orgName, "infisical.organization.id": orgId,
"infisical.auth.method": AuthAttemptAuthMethod.GOOGLE, "infisical.organization.name": orgName,
"infisical.auth.result": AuthAttemptAuthResult.SUCCESS, "infisical.auth.method": AuthAttemptAuthMethod.GOOGLE,
"client.address": requestContext.get("ip"), "infisical.auth.result": AuthAttemptAuthResult.SUCCESS,
"user_agent.original": requestContext.get("userAgent") "client.address": requestContext.get("ip"),
}); "user_agent.original": requestContext.get("userAgent")
});
}
cb(null, { isUserCompleted, providerAuthToken }); cb(null, { isUserCompleted, providerAuthToken });
} catch (error) { } catch (error) {
logger.error(error); logger.error(error);
authAttemptCounter.add(1, { if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
"infisical.user.email": email, authAttemptCounter.add(1, {
"infisical.auth.method": AuthAttemptAuthMethod.GOOGLE, "infisical.user.email": email,
"infisical.auth.result": AuthAttemptAuthResult.FAILURE, "infisical.auth.method": AuthAttemptAuthMethod.GOOGLE,
"client.address": requestContext.get("ip"), "infisical.auth.result": AuthAttemptAuthResult.FAILURE,
"user_agent.original": requestContext.get("userAgent") "client.address": requestContext.get("ip"),
}); "user_agent.original": requestContext.get("userAgent")
});
}
cb(error as Error, false); cb(error as Error, false);
} }
} }
@@ -144,27 +147,30 @@ export const registerOauthMiddlewares = (server: FastifyZodProvider) => {
callbackPort callbackPort
}); });
authAttemptCounter.add(1, { if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
"infisical.user.email": email, authAttemptCounter.add(1, {
"infisical.user.id": user.id, "infisical.user.email": email,
"infisical.organization.id": orgId, "infisical.user.id": user.id,
"infisical.organization.name": orgName, "infisical.organization.id": orgId,
"infisical.auth.method": AuthAttemptAuthMethod.GITHUB, "infisical.organization.name": orgName,
"infisical.auth.result": AuthAttemptAuthResult.SUCCESS, "infisical.auth.method": AuthAttemptAuthMethod.GITHUB,
"client.address": requestContext.get("ip"), "infisical.auth.result": AuthAttemptAuthResult.SUCCESS,
"user_agent.original": requestContext.get("userAgent") "client.address": requestContext.get("ip"),
}); "user_agent.original": requestContext.get("userAgent")
});
}
done(null, { isUserCompleted, providerAuthToken, externalProviderAccessToken: accessToken }); done(null, { isUserCompleted, providerAuthToken, externalProviderAccessToken: accessToken });
} catch (err) { } catch (err) {
authAttemptCounter.add(1, { if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
"infisical.user.email": email, authAttemptCounter.add(1, {
"infisical.auth.method": AuthAttemptAuthMethod.GITHUB, "infisical.user.email": email,
"infisical.auth.result": AuthAttemptAuthResult.FAILURE, "infisical.auth.method": AuthAttemptAuthMethod.GITHUB,
"client.address": requestContext.get("ip"), "infisical.auth.result": AuthAttemptAuthResult.FAILURE,
"user_agent.original": requestContext.get("userAgent") "client.address": requestContext.get("ip"),
}); "user_agent.original": requestContext.get("userAgent")
});
}
logger.error(err); logger.error(err);
done(err as Error, false); done(err as Error, false);
} }
@@ -204,26 +210,30 @@ export const registerOauthMiddlewares = (server: FastifyZodProvider) => {
callbackPort callbackPort
}); });
authAttemptCounter.add(1, { if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
"infisical.user.email": email, authAttemptCounter.add(1, {
"infisical.user.id": user.id, "infisical.user.email": email,
"infisical.organization.id": orgId, "infisical.user.id": user.id,
"infisical.organization.name": orgName, "infisical.organization.id": orgId,
"infisical.auth.method": AuthAttemptAuthMethod.GITLAB, "infisical.organization.name": orgName,
"infisical.auth.result": AuthAttemptAuthResult.SUCCESS, "infisical.auth.method": AuthAttemptAuthMethod.GITLAB,
"client.address": requestContext.get("ip"), "infisical.auth.result": AuthAttemptAuthResult.SUCCESS,
"user_agent.original": requestContext.get("userAgent") "client.address": requestContext.get("ip"),
}); "user_agent.original": requestContext.get("userAgent")
});
}
return cb(null, { isUserCompleted, providerAuthToken }); return cb(null, { isUserCompleted, providerAuthToken });
} catch (error) { } catch (error) {
authAttemptCounter.add(1, { if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
"infisical.user.email": email, authAttemptCounter.add(1, {
"infisical.auth.method": AuthAttemptAuthMethod.GITLAB, "infisical.user.email": email,
"infisical.auth.result": AuthAttemptAuthResult.FAILURE, "infisical.auth.method": AuthAttemptAuthMethod.GITLAB,
"client.address": requestContext.get("ip"), "infisical.auth.result": AuthAttemptAuthResult.FAILURE,
"user_agent.original": requestContext.get("userAgent") "client.address": requestContext.get("ip"),
}); "user_agent.original": requestContext.get("userAgent")
});
}
logger.error(error); logger.error(error);
cb(error as Error, false); cb(error as Error, false);
+22 -16
View File
@@ -386,6 +386,8 @@ export const authLoginServiceFactory = ({
providerAuthToken?: string; providerAuthToken?: string;
captchaToken?: string; captchaToken?: string;
}) => { }) => {
const appCfg = getConfig();
try { try {
const usersByUsername = await userDAL.findUserEncKeyByUsername({ const usersByUsername = await userDAL.findUserEncKeyByUsername({
username: email username: email
@@ -440,15 +442,17 @@ export const authLoginServiceFactory = ({
organizationId organizationId
}); });
authAttemptCounter.add(1, { if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
"infisical.organization.id": organizationId, authAttemptCounter.add(1, {
"infisical.user.email": email, "infisical.organization.id": organizationId,
"infisical.user.id": userEnc.userId, "infisical.user.email": email,
"infisical.auth.method": AuthAttemptAuthMethod.EMAIL, "infisical.user.id": userEnc.userId,
"infisical.auth.result": AuthAttemptAuthResult.SUCCESS, "infisical.auth.method": AuthAttemptAuthMethod.EMAIL,
"client.address": ip, "infisical.auth.result": AuthAttemptAuthResult.SUCCESS,
"user_agent.original": userAgent "client.address": ip,
}); "user_agent.original": userAgent
});
}
return { return {
tokens: { tokens: {
@@ -458,13 +462,15 @@ export const authLoginServiceFactory = ({
user: userEnc user: userEnc
} as const; } as const;
} catch (error) { } catch (error) {
authAttemptCounter.add(1, { if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
"infisical.user.email": email, authAttemptCounter.add(1, {
"infisical.auth.method": AuthAttemptAuthMethod.EMAIL, "infisical.user.email": email,
"infisical.auth.result": AuthAttemptAuthResult.FAILURE, "infisical.auth.method": AuthAttemptAuthMethod.EMAIL,
"client.address": ip, "infisical.auth.result": AuthAttemptAuthResult.FAILURE,
"user_agent.original": userAgent "client.address": ip,
}); "user_agent.original": userAgent
});
}
throw error; throw error;
} }
@@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { ForbiddenError } from "@casl/ability"; import { ForbiddenError } from "@casl/ability";
import { requestContext } from "@fastify/request-context";
import { AxiosError } from "axios"; import { AxiosError } from "axios";
import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas";
@@ -22,6 +23,7 @@ import {
} from "@app/lib/errors"; } from "@app/lib/errors";
import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip";
import { logger } from "@app/lib/logger"; import { logger } from "@app/lib/logger";
import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics";
import { ActorType, AuthTokenType } from "../auth/auth-type"; import { ActorType, AuthTokenType } from "../auth/auth-type";
import { TIdentityDALFactory } from "../identity/identity-dal"; import { TIdentityDALFactory } from "../identity/identity-dal";
@@ -65,6 +67,7 @@ export const identityAliCloudAuthServiceFactory = ({
orgDAL orgDAL
}: TIdentityAliCloudAuthServiceFactoryDep) => { }: TIdentityAliCloudAuthServiceFactoryDep) => {
const login = async ({ identityId, ...params }: TLoginAliCloudAuthDTO) => { const login = async ({ identityId, ...params }: TLoginAliCloudAuthDTO) => {
const appCfg = getConfig();
const identityAliCloudAuth = await identityAliCloudAuthDAL.findOne({ identityId }); const identityAliCloudAuth = await identityAliCloudAuthDAL.findOne({ identityId });
if (!identityAliCloudAuth) { if (!identityAliCloudAuth) {
throw new NotFoundError({ throw new NotFoundError({
@@ -75,73 +78,103 @@ export const identityAliCloudAuthServiceFactory = ({
const identity = await identityDAL.findById(identityAliCloudAuth.identityId); const identity = await identityDAL.findById(identityAliCloudAuth.identityId);
if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); if (!identity) throw new UnauthorizedError({ message: "Identity not found" });
const requestUrl = new URL("https://sts.aliyuncs.com"); const org = await orgDAL.findById(identity.orgId);
for (const key of Object.keys(params)) { try {
requestUrl.searchParams.set(key, (params as Record<string, string>)[key]); const requestUrl = new URL("https://sts.aliyuncs.com");
}
const { data } = await request.get<TAliCloudGetUserResponse>(requestUrl.toString()).catch((err: AxiosError) => { for (const key of Object.keys(params)) {
logger.error(err.response, "AliCloudIdentityLogin: Failed to authenticate with Alibaba Cloud"); requestUrl.searchParams.set(key, (params as Record<string, string>)[key]);
throw err; }
});
if (identityAliCloudAuth.allowedArns) { const { data } = await request.get<TAliCloudGetUserResponse>(requestUrl.toString()).catch((err: AxiosError) => {
// In the future we could do partial checks for role ARNs logger.error(err.response, "AliCloudIdentityLogin: Failed to authenticate with Alibaba Cloud");
const isAccountAllowed = identityAliCloudAuth.allowedArns.split(",").some((arn) => arn.trim() === data.Arn); throw err;
});
if (!isAccountAllowed) if (identityAliCloudAuth.allowedArns) {
throw new UnauthorizedError({ // In the future we could do partial checks for role ARNs
message: "Access denied: Alibaba Cloud account ARN not allowed." const isAccountAllowed = identityAliCloudAuth.allowedArns.split(",").some((arn) => arn.trim() === data.Arn);
});
}
// Generate the token if (!isAccountAllowed)
const identityAccessToken = await identityAliCloudAuthDAL.transaction(async (tx) => { throw new UnauthorizedError({
await membershipIdentityDAL.update( message: "Access denied: Alibaba Cloud account ARN not allowed."
{ scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, });
{ }
lastLoginAuthMethod: IdentityAuthMethod.ALICLOUD_AUTH,
lastLoginTime: new Date() // Generate the token
}, const identityAccessToken = await identityAliCloudAuthDAL.transaction(async (tx) => {
tx await membershipIdentityDAL.update(
); { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id },
const newToken = await identityAccessTokenDAL.create( {
lastLoginAuthMethod: IdentityAuthMethod.ALICLOUD_AUTH,
lastLoginTime: new Date()
},
tx
);
const newToken = await identityAccessTokenDAL.create(
{
identityId: identityAliCloudAuth.identityId,
isAccessTokenRevoked: false,
accessTokenTTL: identityAliCloudAuth.accessTokenTTL,
accessTokenMaxTTL: identityAliCloudAuth.accessTokenMaxTTL,
accessTokenNumUses: 0,
accessTokenNumUsesLimit: identityAliCloudAuth.accessTokenNumUsesLimit,
authMethod: IdentityAuthMethod.ALICLOUD_AUTH
},
tx
);
return newToken;
});
const accessToken = crypto.jwt().sign(
{ {
identityId: identityAliCloudAuth.identityId, identityId: identityAliCloudAuth.identityId,
isAccessTokenRevoked: false, identityAccessTokenId: identityAccessToken.id,
accessTokenTTL: identityAliCloudAuth.accessTokenTTL, authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN
accessTokenMaxTTL: identityAliCloudAuth.accessTokenMaxTTL, } as TIdentityAccessTokenJwtPayload,
accessTokenNumUses: 0, appCfg.AUTH_SECRET,
accessTokenNumUsesLimit: identityAliCloudAuth.accessTokenNumUsesLimit, Number(identityAccessToken.accessTokenTTL) === 0
authMethod: IdentityAuthMethod.ALICLOUD_AUTH ? undefined
}, : {
tx expiresIn: Number(identityAccessToken.accessTokenTTL)
}
); );
return newToken;
});
const appCfg = getConfig(); if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
const accessToken = crypto.jwt().sign( authAttemptCounter.add(1, {
{ "infisical.identity.id": identityAliCloudAuth.identityId,
identityId: identityAliCloudAuth.identityId, "infisical.identity.name": identity.name,
identityAccessTokenId: identityAccessToken.id, "infisical.organization.id": org.id,
authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN "infisical.organization.name": org.name,
} as TIdentityAccessTokenJwtPayload, "infisical.identity.auth_method": AuthAttemptAuthMethod.ALICLOUD_AUTH,
appCfg.AUTH_SECRET, "infisical.identity.auth_result": AuthAttemptAuthResult.SUCCESS,
Number(identityAccessToken.accessTokenTTL) === 0 "client.address": requestContext.get("ip"),
? undefined "user_agent.original": requestContext.get("userAgent")
: { });
expiresIn: Number(identityAccessToken.accessTokenTTL) }
}
);
return { return {
identityAliCloudAuth, identityAliCloudAuth,
accessToken, accessToken,
identityAccessToken, identityAccessToken,
identity identity
}; };
} catch (error) {
if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
authAttemptCounter.add(1, {
"infisical.identity.id": identityAliCloudAuth.identityId,
"infisical.identity.name": identity.name,
"infisical.organization.id": org.id,
"infisical.organization.name": org.name,
"infisical.identity.auth_method": AuthAttemptAuthMethod.ALICLOUD_AUTH,
"infisical.identity.auth_result": AuthAttemptAuthResult.FAILURE,
"client.address": requestContext.get("ip"),
"user_agent.original": requestContext.get("userAgent")
});
}
throw error;
}
}; };
const attachAliCloudAuth = async ({ const attachAliCloudAuth = async ({
@@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */
import { ForbiddenError } from "@casl/ability"; import { ForbiddenError } from "@casl/ability";
import { requestContext } from "@fastify/request-context";
import axios from "axios"; import axios from "axios";
import RE2 from "re2"; import RE2 from "re2";
@@ -22,6 +23,7 @@ import {
} from "@app/lib/errors"; } from "@app/lib/errors";
import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip";
import { logger } from "@app/lib/logger"; import { logger } from "@app/lib/logger";
import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics";
import { ActorType, AuthTokenType } from "../auth/auth-type"; import { ActorType, AuthTokenType } from "../auth/auth-type";
import { TIdentityDALFactory } from "../identity/identity-dal"; import { TIdentityDALFactory } from "../identity/identity-dal";
@@ -98,6 +100,7 @@ export const identityAwsAuthServiceFactory = ({
orgDAL orgDAL
}: TIdentityAwsAuthServiceFactoryDep) => { }: TIdentityAwsAuthServiceFactoryDep) => {
const login = async ({ identityId, iamHttpRequestMethod, iamRequestBody, iamRequestHeaders }: TLoginAwsAuthDTO) => { const login = async ({ identityId, iamHttpRequestMethod, iamRequestBody, iamRequestHeaders }: TLoginAwsAuthDTO) => {
const appCfg = getConfig();
const identityAwsAuth = await identityAwsAuthDAL.findOne({ identityId }); const identityAwsAuth = await identityAwsAuthDAL.findOne({ identityId });
if (!identityAwsAuth) { if (!identityAwsAuth) {
throw new NotFoundError({ message: "AWS auth method not found for identity, did you configure AWS auth?" }); throw new NotFoundError({ message: "AWS auth method not found for identity, did you configure AWS auth?" });
@@ -106,127 +109,156 @@ export const identityAwsAuthServiceFactory = ({
const identity = await identityDAL.findById(identityAwsAuth.identityId); const identity = await identityDAL.findById(identityAwsAuth.identityId);
if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); if (!identity) throw new UnauthorizedError({ message: "Identity not found" });
const headers: TAwsGetCallerIdentityHeaders = JSON.parse(Buffer.from(iamRequestHeaders, "base64").toString()); const org = await orgDAL.findById(identity.orgId);
const body: string = Buffer.from(iamRequestBody, "base64").toString(); try {
const headers: TAwsGetCallerIdentityHeaders = JSON.parse(Buffer.from(iamRequestHeaders, "base64").toString());
const body: string = Buffer.from(iamRequestBody, "base64").toString();
const authHeader = headers.Authorization || headers.authorization; const authHeader = headers.Authorization || headers.authorization;
const region = authHeader ? awsRegionFromHeader(authHeader) : null; const region = authHeader ? awsRegionFromHeader(authHeader) : null;
if (!isValidAwsRegion(region)) { if (!isValidAwsRegion(region)) {
throw new BadRequestError({ message: "Invalid AWS region" }); throw new BadRequestError({ message: "Invalid AWS region" });
} }
const url = region ? `https://sts.${region}.amazonaws.com` : identityAwsAuth.stsEndpoint; const url = region ? `https://sts.${region}.amazonaws.com` : identityAwsAuth.stsEndpoint;
const { const {
data: { data: {
GetCallerIdentityResponse: { GetCallerIdentityResponse: {
GetCallerIdentityResult: { Account, Arn, UserId } GetCallerIdentityResult: { Account, Arn, UserId }
}
}
}: { data: TGetCallerIdentityResponse } = await axios({
method: iamHttpRequestMethod,
url,
headers,
data: body
});
if (identityAwsAuth.allowedAccountIds) {
// validate if Account is in the list of allowed Account IDs
const isAccountAllowed = identityAwsAuth.allowedAccountIds
.split(",")
.map((accountId) => accountId.trim())
.some((accountId) => accountId === Account);
if (!isAccountAllowed)
throw new UnauthorizedError({
message: "Access denied: AWS account ID not allowed."
});
}
if (identityAwsAuth.allowedPrincipalArns) {
// validate if Arn is in the list of allowed Principal ARNs
const formattedArn = extractPrincipalArn(Arn);
const isArnAllowed = identityAwsAuth.allowedPrincipalArns
.split(",")
.map((principalArn) => principalArn.trim())
.some((principalArn) => {
// convert wildcard ARN to a regular expression: "arn:aws:iam::123456789012:*" -> "^arn:aws:iam::123456789012:.*$"
// considers exact matches + wildcard matches
// heavily validated in router
const regex = new RE2(`^${principalArn.replaceAll("*", ".*")}$`);
return regex.test(formattedArn) || regex.test(extractPrincipalArn(Arn, true));
});
if (!isArnAllowed) {
logger.error(
`AWS Auth Login: AWS principal ARN not allowed [principal-arn=${formattedArn}] [raw-arn=${Arn}] [identity-id=${identity.id}]`
);
throw new UnauthorizedError({
message: `Access denied: AWS principal ARN not allowed. [principal-arn=${formattedArn}]`
});
} }
} }
}: { data: TGetCallerIdentityResponse } = await axios({
method: iamHttpRequestMethod,
url,
headers,
data: body
});
if (identityAwsAuth.allowedAccountIds) { const identityAccessToken = await identityAwsAuthDAL.transaction(async (tx) => {
// validate if Account is in the list of allowed Account IDs await membershipIdentityDAL.update(
{ scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id },
const isAccountAllowed = identityAwsAuth.allowedAccountIds {
.split(",") lastLoginAuthMethod: IdentityAuthMethod.AWS_AUTH,
.map((accountId) => accountId.trim()) lastLoginTime: new Date()
.some((accountId) => accountId === Account); },
tx
if (!isAccountAllowed)
throw new UnauthorizedError({
message: "Access denied: AWS account ID not allowed."
});
}
if (identityAwsAuth.allowedPrincipalArns) {
// validate if Arn is in the list of allowed Principal ARNs
const formattedArn = extractPrincipalArn(Arn);
const isArnAllowed = identityAwsAuth.allowedPrincipalArns
.split(",")
.map((principalArn) => principalArn.trim())
.some((principalArn) => {
// convert wildcard ARN to a regular expression: "arn:aws:iam::123456789012:*" -> "^arn:aws:iam::123456789012:.*$"
// considers exact matches + wildcard matches
// heavily validated in router
const regex = new RE2(`^${principalArn.replaceAll("*", ".*")}$`);
return regex.test(formattedArn) || regex.test(extractPrincipalArn(Arn, true));
});
if (!isArnAllowed) {
logger.error(
`AWS Auth Login: AWS principal ARN not allowed [principal-arn=${formattedArn}] [raw-arn=${Arn}] [identity-id=${identity.id}]`
); );
const newToken = await identityAccessTokenDAL.create(
{
identityId: identityAwsAuth.identityId,
isAccessTokenRevoked: false,
accessTokenTTL: identityAwsAuth.accessTokenTTL,
accessTokenMaxTTL: identityAwsAuth.accessTokenMaxTTL,
accessTokenNumUses: 0,
accessTokenNumUsesLimit: identityAwsAuth.accessTokenNumUsesLimit,
authMethod: IdentityAuthMethod.AWS_AUTH
},
tx
);
return newToken;
});
throw new UnauthorizedError({ const splitArn = extractPrincipalArnEntity(Arn);
message: `Access denied: AWS principal ARN not allowed. [principal-arn=${formattedArn}]` const accessToken = crypto.jwt().sign(
});
}
}
const identityAccessToken = await identityAwsAuthDAL.transaction(async (tx) => {
await membershipIdentityDAL.update(
{ scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id },
{
lastLoginAuthMethod: IdentityAuthMethod.AWS_AUTH,
lastLoginTime: new Date()
},
tx
);
const newToken = await identityAccessTokenDAL.create(
{ {
identityId: identityAwsAuth.identityId, identityId: identityAwsAuth.identityId,
isAccessTokenRevoked: false, identityAccessTokenId: identityAccessToken.id,
accessTokenTTL: identityAwsAuth.accessTokenTTL, authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN,
accessTokenMaxTTL: identityAwsAuth.accessTokenMaxTTL, identityAuth: {
accessTokenNumUses: 0, aws: {
accessTokenNumUsesLimit: identityAwsAuth.accessTokenNumUsesLimit, accountId: Account,
authMethod: IdentityAuthMethod.AWS_AUTH arn: Arn,
}, userId: UserId,
tx
// Derived from ARN
partition: splitArn.Partition,
service: splitArn.Service,
resourceType: splitArn.Type,
resourceName: splitArn.FriendlyName
}
}
} as TIdentityAccessTokenJwtPayload,
appCfg.AUTH_SECRET,
// akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error
Number(identityAccessToken.accessTokenTTL) === 0
? undefined
: {
expiresIn: Number(identityAccessToken.accessTokenTTL)
}
); );
return newToken;
});
const appCfg = getConfig(); if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
const splitArn = extractPrincipalArnEntity(Arn); authAttemptCounter.add(1, {
const accessToken = crypto.jwt().sign( "infisical.identity.id": identityAwsAuth.identityId,
{ "infisical.identity.name": identity.name,
identityId: identityAwsAuth.identityId, "infisical.organization.id": org.id,
identityAccessTokenId: identityAccessToken.id, "infisical.organization.name": org.name,
authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN, "infisical.identity.auth_method": AuthAttemptAuthMethod.AWS_AUTH,
identityAuth: { "infisical.identity.auth_result": AuthAttemptAuthResult.SUCCESS,
aws: { "client.address": requestContext.get("ip"),
accountId: Account, "user_agent.original": requestContext.get("userAgent")
arn: Arn, });
userId: UserId, }
// Derived from ARN return { accessToken, identityAwsAuth, identityAccessToken, identity };
partition: splitArn.Partition, } catch (error) {
service: splitArn.Service, if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
resourceType: splitArn.Type, authAttemptCounter.add(1, {
resourceName: splitArn.FriendlyName "infisical.identity.id": identityAwsAuth.identityId,
} "infisical.identity.name": identity.name,
} "infisical.organization.id": org.id,
} as TIdentityAccessTokenJwtPayload, "infisical.organization.name": org.name,
appCfg.AUTH_SECRET, "infisical.identity.auth_method": AuthAttemptAuthMethod.AWS_AUTH,
// akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error "infisical.identity.auth_result": AuthAttemptAuthResult.FAILURE,
Number(identityAccessToken.accessTokenTTL) === 0 "client.address": requestContext.get("ip"),
? undefined "user_agent.original": requestContext.get("userAgent")
: { });
expiresIn: Number(identityAccessToken.accessTokenTTL) }
} throw error;
); }
return { accessToken, identityAwsAuth, identityAccessToken, identity };
}; };
const attachAwsAuth = async ({ const attachAwsAuth = async ({
@@ -1,4 +1,5 @@
import { ForbiddenError } from "@casl/ability"; import { ForbiddenError } from "@casl/ability";
import { requestContext } from "@fastify/request-context";
import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
@@ -18,6 +19,7 @@ import {
UnauthorizedError UnauthorizedError
} from "@app/lib/errors"; } from "@app/lib/errors";
import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip";
import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics";
import { ActorType, AuthTokenType } from "../auth/auth-type"; import { ActorType, AuthTokenType } from "../auth/auth-type";
import { TIdentityDALFactory } from "../identity/identity-dal"; import { TIdentityDALFactory } from "../identity/identity-dal";
@@ -61,6 +63,7 @@ export const identityAzureAuthServiceFactory = ({
orgDAL orgDAL
}: TIdentityAzureAuthServiceFactoryDep) => { }: TIdentityAzureAuthServiceFactoryDep) => {
const login = async ({ identityId, jwt: azureJwt }: TLoginAzureAuthDTO) => { const login = async ({ identityId, jwt: azureJwt }: TLoginAzureAuthDTO) => {
const appCfg = getConfig();
const identityAzureAuth = await identityAzureAuthDAL.findOne({ identityId }); const identityAzureAuth = await identityAzureAuthDAL.findOne({ identityId });
if (!identityAzureAuth) { if (!identityAzureAuth) {
throw new NotFoundError({ message: "Azure auth method not found for identity, did you configure Azure Auth?" }); throw new NotFoundError({ message: "Azure auth method not found for identity, did you configure Azure Auth?" });
@@ -69,69 +72,99 @@ export const identityAzureAuthServiceFactory = ({
const identity = await identityDAL.findById(identityAzureAuth.identityId); const identity = await identityDAL.findById(identityAzureAuth.identityId);
if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); if (!identity) throw new UnauthorizedError({ message: "Identity not found" });
const azureIdentity = await validateAzureIdentity({ const org = await orgDAL.findById(identity.orgId);
tenantId: identityAzureAuth.tenantId,
resource: identityAzureAuth.resource,
jwt: azureJwt
});
if (azureIdentity.tid !== identityAzureAuth.tenantId) try {
throw new UnauthorizedError({ message: "Tenant ID mismatch" }); const azureIdentity = await validateAzureIdentity({
tenantId: identityAzureAuth.tenantId,
resource: identityAzureAuth.resource,
jwt: azureJwt
});
if (identityAzureAuth.allowedServicePrincipalIds) { if (azureIdentity.tid !== identityAzureAuth.tenantId)
// validate if the service principal id is in the list of allowed service principal ids throw new UnauthorizedError({ message: "Tenant ID mismatch" });
const isServicePrincipalAllowed = identityAzureAuth.allowedServicePrincipalIds if (identityAzureAuth.allowedServicePrincipalIds) {
.split(",") // validate if the service principal id is in the list of allowed service principal ids
.map((servicePrincipalId) => servicePrincipalId.trim())
.some((servicePrincipalId) => servicePrincipalId === azureIdentity.oid);
if (!isServicePrincipalAllowed) { const isServicePrincipalAllowed = identityAzureAuth.allowedServicePrincipalIds
throw new UnauthorizedError({ message: `Service principal '${azureIdentity.oid}' not allowed` }); .split(",")
.map((servicePrincipalId) => servicePrincipalId.trim())
.some((servicePrincipalId) => servicePrincipalId === azureIdentity.oid);
if (!isServicePrincipalAllowed) {
throw new UnauthorizedError({ message: `Service principal '${azureIdentity.oid}' not allowed` });
}
} }
}
const identityAccessToken = await identityAzureAuthDAL.transaction(async (tx) => { const identityAccessToken = await identityAzureAuthDAL.transaction(async (tx) => {
await membershipIdentityDAL.update( await membershipIdentityDAL.update(
{ scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id },
{ {
lastLoginAuthMethod: IdentityAuthMethod.AZURE_AUTH, lastLoginAuthMethod: IdentityAuthMethod.AZURE_AUTH,
lastLoginTime: new Date() lastLoginTime: new Date()
}, },
tx tx
); );
const newToken = await identityAccessTokenDAL.create( const newToken = await identityAccessTokenDAL.create(
{
identityId: identityAzureAuth.identityId,
isAccessTokenRevoked: false,
accessTokenTTL: identityAzureAuth.accessTokenTTL,
accessTokenMaxTTL: identityAzureAuth.accessTokenMaxTTL,
accessTokenNumUses: 0,
accessTokenNumUsesLimit: identityAzureAuth.accessTokenNumUsesLimit,
authMethod: IdentityAuthMethod.AZURE_AUTH
},
tx
);
return newToken;
});
const accessToken = crypto.jwt().sign(
{ {
identityId: identityAzureAuth.identityId, identityId: identityAzureAuth.identityId,
isAccessTokenRevoked: false, identityAccessTokenId: identityAccessToken.id,
accessTokenTTL: identityAzureAuth.accessTokenTTL, authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN
accessTokenMaxTTL: identityAzureAuth.accessTokenMaxTTL, } as TIdentityAccessTokenJwtPayload,
accessTokenNumUses: 0, appCfg.AUTH_SECRET,
accessTokenNumUsesLimit: identityAzureAuth.accessTokenNumUsesLimit, // akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error
authMethod: IdentityAuthMethod.AZURE_AUTH Number(identityAccessToken.accessTokenTTL) === 0
}, ? undefined
tx : {
expiresIn: Number(identityAccessToken.accessTokenTTL)
}
); );
return newToken;
});
const appCfg = getConfig(); if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
const accessToken = crypto.jwt().sign( authAttemptCounter.add(1, {
{ "infisical.identity.id": identityAzureAuth.identityId,
identityId: identityAzureAuth.identityId, "infisical.identity.name": identity.name,
identityAccessTokenId: identityAccessToken.id, "infisical.organization.id": org.id,
authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN "infisical.organization.name": org.name,
} as TIdentityAccessTokenJwtPayload, "infisical.identity.auth_method": AuthAttemptAuthMethod.AZURE_AUTH,
appCfg.AUTH_SECRET, "infisical.identity.auth_result": AuthAttemptAuthResult.SUCCESS,
// akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error "client.address": requestContext.get("ip"),
Number(identityAccessToken.accessTokenTTL) === 0 "user_agent.original": requestContext.get("userAgent")
? undefined });
: { }
expiresIn: Number(identityAccessToken.accessTokenTTL)
}
);
return { accessToken, identityAzureAuth, identityAccessToken, identity }; return { accessToken, identityAzureAuth, identityAccessToken, identity };
} catch (error) {
if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
authAttemptCounter.add(1, {
"infisical.identity.id": identityAzureAuth.identityId,
"infisical.identity.name": identity.name,
"infisical.organization.id": org.id,
"infisical.organization.name": org.name,
"infisical.identity.auth_method": AuthAttemptAuthMethod.AZURE_AUTH,
"infisical.identity.auth_result": AuthAttemptAuthResult.FAILURE,
"client.address": requestContext.get("ip"),
"user_agent.original": requestContext.get("userAgent")
});
}
throw error;
}
}; };
const attachAzureAuth = async ({ const attachAzureAuth = async ({
@@ -1,4 +1,5 @@
import { ForbiddenError } from "@casl/ability"; import { ForbiddenError } from "@casl/ability";
import { requestContext } from "@fastify/request-context";
import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
@@ -18,6 +19,7 @@ import {
UnauthorizedError UnauthorizedError
} from "@app/lib/errors"; } from "@app/lib/errors";
import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip";
import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics";
import { ActorType, AuthTokenType } from "../auth/auth-type"; import { ActorType, AuthTokenType } from "../auth/auth-type";
import { TIdentityDALFactory } from "../identity/identity-dal"; import { TIdentityDALFactory } from "../identity/identity-dal";
@@ -59,6 +61,7 @@ export const identityGcpAuthServiceFactory = ({
orgDAL orgDAL
}: TIdentityGcpAuthServiceFactoryDep) => { }: TIdentityGcpAuthServiceFactoryDep) => {
const login = async ({ identityId, jwt: gcpJwt }: TLoginGcpAuthDTO) => { const login = async ({ identityId, jwt: gcpJwt }: TLoginGcpAuthDTO) => {
const appCfg = getConfig();
const identityGcpAuth = await identityGcpAuthDAL.findOne({ identityId }); const identityGcpAuth = await identityGcpAuthDAL.findOne({ identityId });
if (!identityGcpAuth) { if (!identityGcpAuth) {
throw new NotFoundError({ message: "GCP auth method not found for identity, did you configure GCP auth?" }); throw new NotFoundError({ message: "GCP auth method not found for identity, did you configure GCP auth?" });
@@ -67,108 +70,140 @@ export const identityGcpAuthServiceFactory = ({
const identity = await identityDAL.findById(identityGcpAuth.identityId); const identity = await identityDAL.findById(identityGcpAuth.identityId);
if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); if (!identity) throw new UnauthorizedError({ message: "Identity not found" });
let gcpIdentityDetails: TGcpIdentityDetails; const org = await orgDAL.findById(identity.orgId);
switch (identityGcpAuth.type) { try {
case "gce": { let gcpIdentityDetails: TGcpIdentityDetails;
gcpIdentityDetails = await validateIdTokenIdentity({ switch (identityGcpAuth.type) {
identityId, case "gce": {
jwt: gcpJwt gcpIdentityDetails = await validateIdTokenIdentity({
}); identityId,
break; jwt: gcpJwt
});
break;
}
case "iam": {
gcpIdentityDetails = await validateIamIdentity({
identityId,
jwt: gcpJwt
});
break;
}
default: {
throw new BadRequestError({ message: "Invalid GCP Auth type" });
}
} }
case "iam": {
gcpIdentityDetails = await validateIamIdentity({ if (identityGcpAuth.allowedServiceAccounts) {
identityId, // validate if the service account is in the list of allowed service accounts
jwt: gcpJwt
}); const isServiceAccountAllowed = identityGcpAuth.allowedServiceAccounts
break; .split(",")
.map((serviceAccount) => serviceAccount.trim())
.some((serviceAccount) => serviceAccount === gcpIdentityDetails.email);
if (!isServiceAccountAllowed)
throw new UnauthorizedError({
message: "Access denied: GCP service account not allowed."
});
} }
default: {
throw new BadRequestError({ message: "Invalid GCP Auth type" }); if (
identityGcpAuth.type === "gce" &&
identityGcpAuth.allowedProjects &&
gcpIdentityDetails.computeEngineDetails
) {
// validate if the project that the service account belongs to is in the list of allowed projects
const isProjectAllowed = identityGcpAuth.allowedProjects
.split(",")
.map((project) => project.trim())
.some((project) => project === gcpIdentityDetails.computeEngineDetails?.project_id);
if (!isProjectAllowed)
throw new UnauthorizedError({
message: "Access denied: GCP project not allowed."
});
} }
}
if (identityGcpAuth.allowedServiceAccounts) { if (identityGcpAuth.type === "gce" && identityGcpAuth.allowedZones && gcpIdentityDetails.computeEngineDetails) {
// validate if the service account is in the list of allowed service accounts const isZoneAllowed = identityGcpAuth.allowedZones
.split(",")
.map((zone) => zone.trim())
.some((zone) => zone === gcpIdentityDetails.computeEngineDetails?.zone);
const isServiceAccountAllowed = identityGcpAuth.allowedServiceAccounts if (!isZoneAllowed)
.split(",") throw new UnauthorizedError({
.map((serviceAccount) => serviceAccount.trim()) message: "Access denied: GCP zone not allowed."
.some((serviceAccount) => serviceAccount === gcpIdentityDetails.email); });
}
if (!isServiceAccountAllowed) const identityAccessToken = await identityGcpAuthDAL.transaction(async (tx) => {
throw new UnauthorizedError({ await membershipIdentityDAL.update(
message: "Access denied: GCP service account not allowed." { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id },
}); {
} lastLoginAuthMethod: IdentityAuthMethod.GCP_AUTH,
lastLoginTime: new Date()
if (identityGcpAuth.type === "gce" && identityGcpAuth.allowedProjects && gcpIdentityDetails.computeEngineDetails) { },
// validate if the project that the service account belongs to is in the list of allowed projects tx
);
const isProjectAllowed = identityGcpAuth.allowedProjects const newToken = await identityAccessTokenDAL.create(
.split(",") {
.map((project) => project.trim()) identityId: identityGcpAuth.identityId,
.some((project) => project === gcpIdentityDetails.computeEngineDetails?.project_id); isAccessTokenRevoked: false,
accessTokenTTL: identityGcpAuth.accessTokenTTL,
if (!isProjectAllowed) accessTokenMaxTTL: identityGcpAuth.accessTokenMaxTTL,
throw new UnauthorizedError({ accessTokenNumUses: 0,
message: "Access denied: GCP project not allowed." accessTokenNumUsesLimit: identityGcpAuth.accessTokenNumUsesLimit,
}); authMethod: IdentityAuthMethod.GCP_AUTH
} },
tx
if (identityGcpAuth.type === "gce" && identityGcpAuth.allowedZones && gcpIdentityDetails.computeEngineDetails) { );
const isZoneAllowed = identityGcpAuth.allowedZones return newToken;
.split(",") });
.map((zone) => zone.trim()) const accessToken = crypto.jwt().sign(
.some((zone) => zone === gcpIdentityDetails.computeEngineDetails?.zone);
if (!isZoneAllowed)
throw new UnauthorizedError({
message: "Access denied: GCP zone not allowed."
});
}
const identityAccessToken = await identityGcpAuthDAL.transaction(async (tx) => {
await membershipIdentityDAL.update(
{ scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id },
{
lastLoginAuthMethod: IdentityAuthMethod.GCP_AUTH,
lastLoginTime: new Date()
},
tx
);
const newToken = await identityAccessTokenDAL.create(
{ {
identityId: identityGcpAuth.identityId, identityId: identityGcpAuth.identityId,
isAccessTokenRevoked: false, identityAccessTokenId: identityAccessToken.id,
accessTokenTTL: identityGcpAuth.accessTokenTTL, authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN
accessTokenMaxTTL: identityGcpAuth.accessTokenMaxTTL, } as TIdentityAccessTokenJwtPayload,
accessTokenNumUses: 0, appCfg.AUTH_SECRET,
accessTokenNumUsesLimit: identityGcpAuth.accessTokenNumUsesLimit, // akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error
authMethod: IdentityAuthMethod.GCP_AUTH Number(identityAccessToken.accessTokenTTL) === 0
}, ? undefined
tx : {
expiresIn: Number(identityAccessToken.accessTokenTTL)
}
); );
return newToken;
});
const appCfg = getConfig(); if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
const accessToken = crypto.jwt().sign( authAttemptCounter.add(1, {
{ "infisical.identity.id": identityGcpAuth.identityId,
identityId: identityGcpAuth.identityId, "infisical.identity.name": identity.name,
identityAccessTokenId: identityAccessToken.id, "infisical.organization.id": org.id,
authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN "infisical.organization.name": org.name,
} as TIdentityAccessTokenJwtPayload, "infisical.identity.auth_method": AuthAttemptAuthMethod.GCP_AUTH,
appCfg.AUTH_SECRET, "infisical.identity.auth_result": AuthAttemptAuthResult.SUCCESS,
// akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error "client.address": requestContext.get("ip"),
Number(identityAccessToken.accessTokenTTL) === 0 "user_agent.original": requestContext.get("userAgent")
? undefined });
: { }
expiresIn: Number(identityAccessToken.accessTokenTTL)
}
);
return { accessToken, identityGcpAuth, identityAccessToken, identity }; return { accessToken, identityGcpAuth, identityAccessToken, identity };
} catch (error) {
if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
authAttemptCounter.add(1, {
"infisical.identity.id": identityGcpAuth.identityId,
"infisical.identity.name": identity.name,
"infisical.organization.id": org.id,
"infisical.organization.name": org.name,
"infisical.identity.auth_method": AuthAttemptAuthMethod.GCP_AUTH,
"infisical.identity.auth_result": AuthAttemptAuthResult.FAILURE,
"client.address": requestContext.get("ip"),
"user_agent.original": requestContext.get("userAgent")
});
}
throw error;
}
}; };
const attachGcpAuth = async ({ const attachGcpAuth = async ({
@@ -1,4 +1,5 @@
import { ForbiddenError } from "@casl/ability"; import { ForbiddenError } from "@casl/ability";
import { requestContext } from "@fastify/request-context";
import https from "https"; import https from "https";
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
import { JwksClient } from "jwks-rsa"; import { JwksClient } from "jwks-rsa";
@@ -21,6 +22,7 @@ import {
UnauthorizedError UnauthorizedError
} from "@app/lib/errors"; } from "@app/lib/errors";
import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip";
import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics";
import { getValueByDot } from "@app/lib/template/dot-access"; import { getValueByDot } from "@app/lib/template/dot-access";
import { ActorType, AuthTokenType } from "../auth/auth-type"; import { ActorType, AuthTokenType } from "../auth/auth-type";
@@ -67,6 +69,7 @@ export const identityJwtAuthServiceFactory = ({
orgDAL orgDAL
}: TIdentityJwtAuthServiceFactoryDep) => { }: TIdentityJwtAuthServiceFactoryDep) => {
const login = async ({ identityId, jwt: jwtValue }: TLoginJwtAuthDTO) => { const login = async ({ identityId, jwt: jwtValue }: TLoginJwtAuthDTO) => {
const appCfg = getConfig();
const identityJwtAuth = await identityJwtAuthDAL.findOne({ identityId }); const identityJwtAuth = await identityJwtAuthDAL.findOne({ identityId });
if (!identityJwtAuth) { if (!identityJwtAuth) {
throw new NotFoundError({ message: "JWT auth method not found for identity, did you configure JWT auth?" }); throw new NotFoundError({ message: "JWT auth method not found for identity, did you configure JWT auth?" });
@@ -75,176 +78,205 @@ export const identityJwtAuthServiceFactory = ({
const identity = await identityDAL.findById(identityJwtAuth.identityId); const identity = await identityDAL.findById(identityJwtAuth.identityId);
if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); if (!identity) throw new UnauthorizedError({ message: "Identity not found" });
const { decryptor: orgDataKeyDecryptor } = await kmsService.createCipherPairWithDataKey({ const org = await orgDAL.findById(identity.orgId);
type: KmsDataKey.Organization, try {
orgId: identity.orgId const { decryptor: orgDataKeyDecryptor } = await kmsService.createCipherPairWithDataKey({
}); type: KmsDataKey.Organization,
orgId: identity.orgId
const decodedToken = crypto.jwt().decode(jwtValue, { complete: true });
if (!decodedToken) {
throw new UnauthorizedError({
message: "Invalid JWT"
}); });
}
let tokenData: Record<string, string | boolean | number> = {}; const decodedToken = crypto.jwt().decode(jwtValue, { complete: true });
if (!decodedToken) {
if (identityJwtAuth.configurationType === JwtConfigurationType.JWKS) { throw new UnauthorizedError({
let client: JwksClient; message: "Invalid JWT"
if (identityJwtAuth.jwksUrl.includes("https:")) {
const decryptedJwksCaCert = orgDataKeyDecryptor({
cipherTextBlob: identityJwtAuth.encryptedJwksCaCert
}).toString();
const requestAgent = new https.Agent({ ca: decryptedJwksCaCert, rejectUnauthorized: !!decryptedJwksCaCert });
client = new JwksClient({
jwksUri: identityJwtAuth.jwksUrl,
requestAgent
});
} else {
client = new JwksClient({
jwksUri: identityJwtAuth.jwksUrl
}); });
} }
const { kid } = decodedToken.header as { kid: string }; let tokenData: Record<string, string | boolean | number> = {};
const jwtSigningKey = await client.getSigningKey(kid);
try { if (identityJwtAuth.configurationType === JwtConfigurationType.JWKS) {
tokenData = crypto.jwt().verify(jwtValue, jwtSigningKey.getPublicKey()) as Record<string, string>; let client: JwksClient;
} catch (error) { if (identityJwtAuth.jwksUrl.includes("https:")) {
if (error instanceof jwt.JsonWebTokenError) { const decryptedJwksCaCert = orgDataKeyDecryptor({
throw new UnauthorizedError({ cipherTextBlob: identityJwtAuth.encryptedJwksCaCert
message: `Access denied: ${error.message}` }).toString();
const requestAgent = new https.Agent({ ca: decryptedJwksCaCert, rejectUnauthorized: !!decryptedJwksCaCert });
client = new JwksClient({
jwksUri: identityJwtAuth.jwksUrl,
requestAgent
});
} else {
client = new JwksClient({
jwksUri: identityJwtAuth.jwksUrl
}); });
} }
throw error; const { kid } = decodedToken.header as { kid: string };
} const jwtSigningKey = await client.getSigningKey(kid);
} else {
const decryptedPublicKeys = orgDataKeyDecryptor({ cipherTextBlob: identityJwtAuth.encryptedPublicKeys })
.toString()
.split(",");
const errors: string[] = [];
let isMatchAnyKey = false;
for (const publicKey of decryptedPublicKeys) {
try { try {
tokenData = crypto.jwt().verify(jwtValue, publicKey) as Record<string, string>; tokenData = crypto.jwt().verify(jwtValue, jwtSigningKey.getPublicKey()) as Record<string, string>;
isMatchAnyKey = true;
} catch (error) { } catch (error) {
if (error instanceof jwt.JsonWebTokenError) { if (error instanceof jwt.JsonWebTokenError) {
errors.push(error.message); throw new UnauthorizedError({
message: `Access denied: ${error.message}`
});
}
throw error;
}
} else {
const decryptedPublicKeys = orgDataKeyDecryptor({ cipherTextBlob: identityJwtAuth.encryptedPublicKeys })
.toString()
.split(",");
const errors: string[] = [];
let isMatchAnyKey = false;
for (const publicKey of decryptedPublicKeys) {
try {
tokenData = crypto.jwt().verify(jwtValue, publicKey) as Record<string, string>;
isMatchAnyKey = true;
} catch (error) {
if (error instanceof jwt.JsonWebTokenError) {
errors.push(error.message);
}
} }
} }
}
if (!isMatchAnyKey) { if (!isMatchAnyKey) {
throw new UnauthorizedError({
message: `Access denied: JWT verification failed with all keys. Errors - ${errors.join("; ")}`
});
}
}
if (identityJwtAuth.boundIssuer) {
if (tokenData.iss !== identityJwtAuth.boundIssuer) {
throw new ForbiddenRequestError({
message: "Access denied: issuer mismatch"
});
}
}
if (identityJwtAuth.boundSubject) {
if (!tokenData.sub) {
throw new UnauthorizedError({
message: "Access denied: token has no subject field"
});
}
if (!doesFieldValueMatchJwtPolicy(tokenData.sub, identityJwtAuth.boundSubject)) {
throw new ForbiddenRequestError({
message: "Access denied: subject not allowed"
});
}
}
if (identityJwtAuth.boundAudiences) {
if (!tokenData.aud) {
throw new UnauthorizedError({
message: "Access denied: token has no audience field"
});
}
if (
!identityJwtAuth.boundAudiences
.split(", ")
.some((policyValue) => doesFieldValueMatchJwtPolicy(tokenData.aud, policyValue))
) {
throw new UnauthorizedError({
message: "Access denied: token audience not allowed"
});
}
}
if (identityJwtAuth.boundClaims) {
Object.keys(identityJwtAuth.boundClaims).forEach((claimKey) => {
const claimValue = (identityJwtAuth.boundClaims as Record<string, string>)[claimKey];
const value = getValueByDot(tokenData, claimKey);
if (!value) {
throw new UnauthorizedError({ throw new UnauthorizedError({
message: `Access denied: token has no ${claimKey} field` message: `Access denied: JWT verification failed with all keys. Errors - ${errors.join("; ")}`
});
}
}
if (identityJwtAuth.boundIssuer) {
if (tokenData.iss !== identityJwtAuth.boundIssuer) {
throw new ForbiddenRequestError({
message: "Access denied: issuer mismatch"
});
}
}
if (identityJwtAuth.boundSubject) {
if (!tokenData.sub) {
throw new UnauthorizedError({
message: "Access denied: token has no subject field"
}); });
} }
// handle both single and multi-valued claims if (!doesFieldValueMatchJwtPolicy(tokenData.sub, identityJwtAuth.boundSubject)) {
if (!claimValue.split(", ").some((claimEntry) => doesFieldValueMatchJwtPolicy(value, claimEntry))) { throw new ForbiddenRequestError({
throw new UnauthorizedError({ message: "Access denied: subject not allowed"
message: `Access denied: claim mismatch for field ${claimKey}`
}); });
} }
}
if (identityJwtAuth.boundAudiences) {
if (!tokenData.aud) {
throw new UnauthorizedError({
message: "Access denied: token has no audience field"
});
}
if (
!identityJwtAuth.boundAudiences
.split(", ")
.some((policyValue) => doesFieldValueMatchJwtPolicy(tokenData.aud, policyValue))
) {
throw new UnauthorizedError({
message: "Access denied: token audience not allowed"
});
}
}
if (identityJwtAuth.boundClaims) {
Object.keys(identityJwtAuth.boundClaims).forEach((claimKey) => {
const claimValue = (identityJwtAuth.boundClaims as Record<string, string>)[claimKey];
const value = getValueByDot(tokenData, claimKey);
if (!value) {
throw new UnauthorizedError({
message: `Access denied: token has no ${claimKey} field`
});
}
// handle both single and multi-valued claims
if (!claimValue.split(", ").some((claimEntry) => doesFieldValueMatchJwtPolicy(value, claimEntry))) {
throw new UnauthorizedError({
message: `Access denied: claim mismatch for field ${claimKey}`
});
}
});
}
const identityAccessToken = await identityJwtAuthDAL.transaction(async (tx) => {
await membershipIdentityDAL.update(
{ scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id },
{ lastLoginAuthMethod: IdentityAuthMethod.JWT_AUTH, lastLoginTime: new Date() },
tx
);
const newToken = await identityAccessTokenDAL.create(
{
identityId: identityJwtAuth.identityId,
isAccessTokenRevoked: false,
accessTokenTTL: identityJwtAuth.accessTokenTTL,
accessTokenMaxTTL: identityJwtAuth.accessTokenMaxTTL,
accessTokenNumUses: 0,
accessTokenNumUsesLimit: identityJwtAuth.accessTokenNumUsesLimit,
authMethod: IdentityAuthMethod.JWT_AUTH
},
tx
);
return newToken;
}); });
}
const identityAccessToken = await identityJwtAuthDAL.transaction(async (tx) => { const accessToken = crypto.jwt().sign(
await membershipIdentityDAL.update(
{ scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id },
{ lastLoginAuthMethod: IdentityAuthMethod.JWT_AUTH, lastLoginTime: new Date() },
tx
);
const newToken = await identityAccessTokenDAL.create(
{ {
identityId: identityJwtAuth.identityId, identityId: identityJwtAuth.identityId,
isAccessTokenRevoked: false, identityAccessTokenId: identityAccessToken.id,
accessTokenTTL: identityJwtAuth.accessTokenTTL, authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN
accessTokenMaxTTL: identityJwtAuth.accessTokenMaxTTL, } as TIdentityAccessTokenJwtPayload,
accessTokenNumUses: 0, appCfg.AUTH_SECRET,
accessTokenNumUsesLimit: identityJwtAuth.accessTokenNumUsesLimit, // akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error
authMethod: IdentityAuthMethod.JWT_AUTH Number(identityAccessToken.accessTokenTTL) === 0
}, ? undefined
tx : {
expiresIn: Number(identityAccessToken.accessTokenTTL)
}
); );
return newToken; if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
}); authAttemptCounter.add(1, {
"infisical.identity.id": identityJwtAuth.identityId,
"infisical.identity.name": identity.name,
"infisical.organization.id": org.id,
"infisical.organization.name": org.name,
"infisical.identity.auth_method": AuthAttemptAuthMethod.JWT_AUTH,
"infisical.identity.auth_result": AuthAttemptAuthResult.SUCCESS,
"client.address": requestContext.get("ip"),
"user_agent.original": requestContext.get("userAgent")
});
}
const appCfg = getConfig(); return { accessToken, identityJwtAuth, identityAccessToken, identity };
const accessToken = crypto.jwt().sign( } catch (error) {
{ if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
identityId: identityJwtAuth.identityId, authAttemptCounter.add(1, {
identityAccessTokenId: identityAccessToken.id, "infisical.identity.id": identityJwtAuth.identityId,
authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN "infisical.identity.name": identity.name,
} as TIdentityAccessTokenJwtPayload, "infisical.organization.id": org.id,
appCfg.AUTH_SECRET, "infisical.organization.name": org.name,
// akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error "infisical.identity.auth_method": AuthAttemptAuthMethod.JWT_AUTH,
Number(identityAccessToken.accessTokenTTL) === 0 "infisical.identity.auth_result": AuthAttemptAuthResult.FAILURE,
? undefined "client.address": requestContext.get("ip"),
: { "user_agent.original": requestContext.get("userAgent")
expiresIn: Number(identityAccessToken.accessTokenTTL) });
} }
); throw error;
}
return { accessToken, identityJwtAuth, identityAccessToken, identity };
}; };
const attachJwtAuth = async ({ const attachJwtAuth = async ({
@@ -1,4 +1,5 @@
import { ForbiddenError } from "@casl/ability"; import { ForbiddenError } from "@casl/ability";
import { requestContext } from "@fastify/request-context";
import axios, { AxiosError } from "axios"; import axios, { AxiosError } from "axios";
import https from "https"; import https from "https";
import RE2 from "re2"; import RE2 from "re2";
@@ -37,6 +38,7 @@ import { GatewayHttpProxyActions, GatewayProxyProtocol, withGatewayProxy } from
import { withGatewayV2Proxy } from "@app/lib/gateway-v2/gateway-v2"; import { withGatewayV2Proxy } from "@app/lib/gateway-v2/gateway-v2";
import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip";
import { logger } from "@app/lib/logger"; import { logger } from "@app/lib/logger";
import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics";
import { ActorType, AuthTokenType } from "../auth/auth-type"; import { ActorType, AuthTokenType } from "../auth/auth-type";
import { TIdentityDALFactory } from "../identity/identity-dal"; import { TIdentityDALFactory } from "../identity/identity-dal";
@@ -182,6 +184,7 @@ export const identityKubernetesAuthServiceFactory = ({
}; };
const login = async ({ identityId, jwt: serviceAccountJwt }: TLoginKubernetesAuthDTO) => { const login = async ({ identityId, jwt: serviceAccountJwt }: TLoginKubernetesAuthDTO) => {
const appCfg = getConfig();
const identityKubernetesAuth = await identityKubernetesAuthDAL.findOne({ identityId }); const identityKubernetesAuth = await identityKubernetesAuthDAL.findOne({ identityId });
if (!identityKubernetesAuth) { if (!identityKubernetesAuth) {
throw new NotFoundError({ throw new NotFoundError({
@@ -192,294 +195,328 @@ export const identityKubernetesAuthServiceFactory = ({
const identity = await identityDAL.findById(identityKubernetesAuth.identityId); const identity = await identityDAL.findById(identityKubernetesAuth.identityId);
if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); if (!identity) throw new UnauthorizedError({ message: "Identity not found" });
const { decryptor } = await kmsService.createCipherPairWithDataKey({ const org = await orgDAL.findById(identity.orgId);
type: KmsDataKey.Organization,
orgId: identity.orgId
});
let caCert = ""; try {
if (identityKubernetesAuth.encryptedKubernetesCaCertificate) { const { decryptor } = await kmsService.createCipherPairWithDataKey({
caCert = decryptor({ cipherTextBlob: identityKubernetesAuth.encryptedKubernetesCaCertificate }).toString(); type: KmsDataKey.Organization,
} orgId: identity.orgId
});
const tokenReviewCallbackRaw = async (host = identityKubernetesAuth.kubernetesHost, port?: number) => { let caCert = "";
logger.info({ host, port }, "tokenReviewCallbackRaw: Processing kubernetes token review using raw API"); if (identityKubernetesAuth.encryptedKubernetesCaCertificate) {
caCert = decryptor({ cipherTextBlob: identityKubernetesAuth.encryptedKubernetesCaCertificate }).toString();
if (!host || !identityKubernetesAuth.kubernetesHost) {
throw new BadRequestError({
message: "Kubernetes host is required when token review mode is set to API"
});
} }
let tokenReviewerJwt = ""; const tokenReviewCallbackRaw = async (host = identityKubernetesAuth.kubernetesHost, port?: number) => {
if (identityKubernetesAuth.encryptedKubernetesTokenReviewerJwt) { logger.info({ host, port }, "tokenReviewCallbackRaw: Processing kubernetes token review using raw API");
tokenReviewerJwt = decryptor({
cipherTextBlob: identityKubernetesAuth.encryptedKubernetesTokenReviewerJwt
}).toString();
} else {
// if no token reviewer is provided means the incoming token has to act as reviewer
tokenReviewerJwt = serviceAccountJwt;
}
let servername = identityKubernetesAuth.kubernetesHost; if (!host || !identityKubernetesAuth.kubernetesHost) {
if (servername.startsWith("https://") || servername.startsWith("http://")) { throw new BadRequestError({
servername = new RE2("^https?:\\/\\/").replace(servername, ""); message: "Kubernetes host is required when token review mode is set to API"
} });
}
// get the last colon index, if it has a port, remove it, including the colon let tokenReviewerJwt = "";
const lastColonIndex = servername.lastIndexOf(":"); if (identityKubernetesAuth.encryptedKubernetesTokenReviewerJwt) {
if (lastColonIndex !== -1) { tokenReviewerJwt = decryptor({
servername = servername.substring(0, lastColonIndex); cipherTextBlob: identityKubernetesAuth.encryptedKubernetesTokenReviewerJwt
} }).toString();
} else {
// if no token reviewer is provided means the incoming token has to act as reviewer
tokenReviewerJwt = serviceAccountJwt;
}
const baseUrl = port ? `${host}:${port}` : host; let servername = identityKubernetesAuth.kubernetesHost;
if (servername.startsWith("https://") || servername.startsWith("http://")) {
servername = new RE2("^https?:\\/\\/").replace(servername, "");
}
const res = await axios // get the last colon index, if it has a port, remove it, including the colon
.post<TCreateTokenReviewResponse>( const lastColonIndex = servername.lastIndexOf(":");
`${baseUrl}/apis/authentication.k8s.io/v1/tokenreviews`, if (lastColonIndex !== -1) {
{ servername = servername.substring(0, lastColonIndex);
apiVersion: "authentication.k8s.io/v1", }
kind: "TokenReview",
spec: { const baseUrl = port ? `${host}:${port}` : host;
token: serviceAccountJwt,
...(identityKubernetesAuth.allowedAudience ? { audiences: [identityKubernetesAuth.allowedAudience] } : {}) const res = await axios
} .post<TCreateTokenReviewResponse>(
}, `${baseUrl}/apis/authentication.k8s.io/v1/tokenreviews`,
{ {
headers: { apiVersion: "authentication.k8s.io/v1",
"Content-Type": "application/json", kind: "TokenReview",
Authorization: `Bearer ${tokenReviewerJwt}` spec: {
token: serviceAccountJwt,
...(identityKubernetesAuth.allowedAudience
? { audiences: [identityKubernetesAuth.allowedAudience] }
: {})
}
}, },
signal: AbortSignal.timeout(10000), {
timeout: 10000, headers: {
httpsAgent: new https.Agent({ "Content-Type": "application/json",
ca: caCert, Authorization: `Bearer ${tokenReviewerJwt}`
rejectUnauthorized: Boolean(caCert),
servername
})
}
)
.catch((err) => {
if (err instanceof AxiosError) {
if (err.response) {
const { message } = err?.response?.data as unknown as { message?: string };
if (message) {
throw new UnauthorizedError({
message,
name: "KubernetesTokenReviewRequestError"
});
}
}
}
throw err;
});
return res.data;
};
const tokenReviewCallbackThroughGateway = async (host: string, port?: number) => {
logger.info(
{
host,
port
},
"tokenReviewCallbackThroughGateway: Processing kubernetes token review using gateway"
);
const res = await axios
.post<TCreateTokenReviewResponse>(
`${host}:${port}/apis/authentication.k8s.io/v1/tokenreviews`,
{
apiVersion: "authentication.k8s.io/v1",
kind: "TokenReview",
spec: {
token: serviceAccountJwt,
...(identityKubernetesAuth.allowedAudience ? { audiences: [identityKubernetesAuth.allowedAudience] } : {})
}
},
{
headers: {
"Content-Type": "application/json",
"x-infisical-action": GatewayHttpProxyActions.UseGatewayK8sServiceAccount
},
signal: AbortSignal.timeout(10000),
timeout: 10000
}
)
.catch((err) => {
if (err instanceof AxiosError) {
if (err.response) {
let { message } = err?.response?.data as unknown as { message?: string };
if (!message && typeof err.response.data === "string") {
message = err.response.data;
}
if (message) {
throw new UnauthorizedError({
message,
name: "KubernetesTokenReviewRequestError"
});
}
}
}
throw err;
});
return res.data;
};
let data: TCreateTokenReviewResponse | undefined;
if (identityKubernetesAuth.tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Gateway) {
if (!identityKubernetesAuth.gatewayId && !identityKubernetesAuth.gatewayV2Id) {
throw new BadRequestError({
message: "Gateway ID is required when token review mode is set to Gateway"
});
}
data = await $gatewayProxyWrapper(
{
gatewayId: (identityKubernetesAuth.gatewayV2Id ?? identityKubernetesAuth.gatewayId) as string,
reviewTokenThroughGateway: true
},
tokenReviewCallbackThroughGateway
);
} else if (identityKubernetesAuth.tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Api) {
if (!identityKubernetesAuth.kubernetesHost) {
throw new BadRequestError({
message: "Kubernetes host is required when token review mode is set to API"
});
}
let { kubernetesHost } = identityKubernetesAuth;
if (kubernetesHost.startsWith("https://") || kubernetesHost.startsWith("http://")) {
kubernetesHost = new RE2("^https?:\\/\\/").replace(kubernetesHost, "");
}
const [k8sHost, k8sPort] = kubernetesHost.split(":");
data =
identityKubernetesAuth.gatewayId || identityKubernetesAuth.gatewayV2Id
? await $gatewayProxyWrapper(
{
gatewayId: (identityKubernetesAuth.gatewayV2Id ?? identityKubernetesAuth.gatewayId) as string,
targetHost: k8sHost,
targetPort: k8sPort ? Number(k8sPort) : 443,
reviewTokenThroughGateway: false
}, },
tokenReviewCallbackRaw signal: AbortSignal.timeout(10000),
) timeout: 10000,
: await tokenReviewCallbackRaw(); httpsAgent: new https.Agent({
} else { ca: caCert,
throw new BadRequestError({ rejectUnauthorized: Boolean(caCert),
message: `Invalid token review mode: ${identityKubernetesAuth.tokenReviewMode}` servername
}); })
} }
)
.catch((err) => {
if (err instanceof AxiosError) {
if (err.response) {
const { message } = err?.response?.data as unknown as { message?: string };
if (!data) { if (message) {
throw new BadRequestError({ throw new UnauthorizedError({
message: "Failed to review token" message,
}); name: "KubernetesTokenReviewRequestError"
} });
}
}
}
throw err;
});
if ("error" in data.status) return res.data;
throw new UnauthorizedError({ message: data.status.error, name: "KubernetesTokenReviewError" }); };
// check the response to determine if the token is valid const tokenReviewCallbackThroughGateway = async (host: string, port?: number) => {
if (!(data.status && data.status.authenticated)) logger.info(
throw new UnauthorizedError({ {
message: "Kubernetes token not authenticated", host,
name: "KubernetesTokenReviewError" port
},
"tokenReviewCallbackThroughGateway: Processing kubernetes token review using gateway"
);
const res = await axios
.post<TCreateTokenReviewResponse>(
`${host}:${port}/apis/authentication.k8s.io/v1/tokenreviews`,
{
apiVersion: "authentication.k8s.io/v1",
kind: "TokenReview",
spec: {
token: serviceAccountJwt,
...(identityKubernetesAuth.allowedAudience
? { audiences: [identityKubernetesAuth.allowedAudience] }
: {})
}
},
{
headers: {
"Content-Type": "application/json",
"x-infisical-action": GatewayHttpProxyActions.UseGatewayK8sServiceAccount
},
signal: AbortSignal.timeout(10000),
timeout: 10000
}
)
.catch((err) => {
if (err instanceof AxiosError) {
if (err.response) {
let { message } = err?.response?.data as unknown as { message?: string };
if (!message && typeof err.response.data === "string") {
message = err.response.data;
}
if (message) {
throw new UnauthorizedError({
message,
name: "KubernetesTokenReviewRequestError"
});
}
}
}
throw err;
});
return res.data;
};
let data: TCreateTokenReviewResponse | undefined;
if (identityKubernetesAuth.tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Gateway) {
if (!identityKubernetesAuth.gatewayId && !identityKubernetesAuth.gatewayV2Id) {
throw new BadRequestError({
message: "Gateway ID is required when token review mode is set to Gateway"
});
}
data = await $gatewayProxyWrapper(
{
gatewayId: (identityKubernetesAuth.gatewayV2Id ?? identityKubernetesAuth.gatewayId) as string,
reviewTokenThroughGateway: true
},
tokenReviewCallbackThroughGateway
);
} else if (identityKubernetesAuth.tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Api) {
if (!identityKubernetesAuth.kubernetesHost) {
throw new BadRequestError({
message: "Kubernetes host is required when token review mode is set to API"
});
}
let { kubernetesHost } = identityKubernetesAuth;
if (kubernetesHost.startsWith("https://") || kubernetesHost.startsWith("http://")) {
kubernetesHost = new RE2("^https?:\\/\\/").replace(kubernetesHost, "");
}
const [k8sHost, k8sPort] = kubernetesHost.split(":");
data =
identityKubernetesAuth.gatewayId || identityKubernetesAuth.gatewayV2Id
? await $gatewayProxyWrapper(
{
gatewayId: (identityKubernetesAuth.gatewayV2Id ?? identityKubernetesAuth.gatewayId) as string,
targetHost: k8sHost,
targetPort: k8sPort ? Number(k8sPort) : 443,
reviewTokenThroughGateway: false
},
tokenReviewCallbackRaw
)
: await tokenReviewCallbackRaw();
} else {
throw new BadRequestError({
message: `Invalid token review mode: ${identityKubernetesAuth.tokenReviewMode}`
});
}
if (!data) {
throw new BadRequestError({
message: "Failed to review token"
});
}
if ("error" in data.status)
throw new UnauthorizedError({ message: data.status.error, name: "KubernetesTokenReviewError" });
// check the response to determine if the token is valid
if (!(data.status && data.status.authenticated))
throw new UnauthorizedError({
message: "Kubernetes token not authenticated",
name: "KubernetesTokenReviewError"
});
const { namespace: targetNamespace, name: targetName } = extractK8sUsername(data.status.user.username);
if (identityKubernetesAuth.allowedNamespaces) {
// validate if [targetNamespace] is in the list of allowed namespaces
const isNamespaceAllowed = identityKubernetesAuth.allowedNamespaces
.split(",")
.map((namespace) => namespace.trim())
.some((namespace) => namespace === targetNamespace);
if (!isNamespaceAllowed)
throw new UnauthorizedError({
message: "Access denied: K8s namespace not allowed."
});
}
if (identityKubernetesAuth.allowedNames) {
// validate if [targetName] is in the list of allowed names
const isNameAllowed = identityKubernetesAuth.allowedNames
.split(",")
.map((name) => name.trim())
.some((name) => name === targetName);
if (!isNameAllowed)
throw new UnauthorizedError({
message: "Access denied: K8s name not allowed."
});
}
if (identityKubernetesAuth.allowedAudience) {
// validate if [audience] is in the list of allowed audiences
const isAudienceAllowed = data.status.audiences.some(
(audience) => audience === identityKubernetesAuth.allowedAudience
);
if (!isAudienceAllowed)
throw new UnauthorizedError({
message: "Access denied: K8s audience not allowed."
});
}
const identityAccessToken = await identityKubernetesAuthDAL.transaction(async (tx) => {
await membershipIdentityDAL.update(
{ scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id },
{ lastLoginAuthMethod: IdentityAuthMethod.KUBERNETES_AUTH, lastLoginTime: new Date() },
tx
);
const newToken = await identityAccessTokenDAL.create(
{
identityId: identityKubernetesAuth.identityId,
isAccessTokenRevoked: false,
accessTokenTTL: identityKubernetesAuth.accessTokenTTL,
accessTokenMaxTTL: identityKubernetesAuth.accessTokenMaxTTL,
accessTokenNumUses: 0,
accessTokenNumUsesLimit: identityKubernetesAuth.accessTokenNumUsesLimit,
authMethod: IdentityAuthMethod.KUBERNETES_AUTH
},
tx
);
return newToken;
}); });
const { namespace: targetNamespace, name: targetName } = extractK8sUsername(data.status.user.username); const accessToken = crypto.jwt().sign(
if (identityKubernetesAuth.allowedNamespaces) {
// validate if [targetNamespace] is in the list of allowed namespaces
const isNamespaceAllowed = identityKubernetesAuth.allowedNamespaces
.split(",")
.map((namespace) => namespace.trim())
.some((namespace) => namespace === targetNamespace);
if (!isNamespaceAllowed)
throw new UnauthorizedError({
message: "Access denied: K8s namespace not allowed."
});
}
if (identityKubernetesAuth.allowedNames) {
// validate if [targetName] is in the list of allowed names
const isNameAllowed = identityKubernetesAuth.allowedNames
.split(",")
.map((name) => name.trim())
.some((name) => name === targetName);
if (!isNameAllowed)
throw new UnauthorizedError({
message: "Access denied: K8s name not allowed."
});
}
if (identityKubernetesAuth.allowedAudience) {
// validate if [audience] is in the list of allowed audiences
const isAudienceAllowed = data.status.audiences.some(
(audience) => audience === identityKubernetesAuth.allowedAudience
);
if (!isAudienceAllowed)
throw new UnauthorizedError({
message: "Access denied: K8s audience not allowed."
});
}
const identityAccessToken = await identityKubernetesAuthDAL.transaction(async (tx) => {
await membershipIdentityDAL.update(
{ scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id },
{ lastLoginAuthMethod: IdentityAuthMethod.KUBERNETES_AUTH, lastLoginTime: new Date() },
tx
);
const newToken = await identityAccessTokenDAL.create(
{ {
identityId: identityKubernetesAuth.identityId, identityId: identityKubernetesAuth.identityId,
isAccessTokenRevoked: false, identityAccessTokenId: identityAccessToken.id,
accessTokenTTL: identityKubernetesAuth.accessTokenTTL, authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN,
accessTokenMaxTTL: identityKubernetesAuth.accessTokenMaxTTL, identityAuth: {
accessTokenNumUses: 0, kubernetes: {
accessTokenNumUsesLimit: identityKubernetesAuth.accessTokenNumUsesLimit, namespace: targetNamespace,
authMethod: IdentityAuthMethod.KUBERNETES_AUTH name: targetName
}, }
tx }
} as TIdentityAccessTokenJwtPayload,
appCfg.AUTH_SECRET,
// akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error
Number(identityAccessToken.accessTokenTTL) === 0
? undefined
: {
expiresIn: Number(identityAccessToken.accessTokenTTL)
}
); );
return newToken;
});
const appCfg = getConfig(); if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
const accessToken = crypto.jwt().sign( authAttemptCounter.add(1, {
{ "infisical.identity.id": identityKubernetesAuth.identityId,
identityId: identityKubernetesAuth.identityId, "infisical.identity.name": identity.name,
identityAccessTokenId: identityAccessToken.id, "infisical.organization.id": org.id,
authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN, "infisical.organization.name": org.name,
identityAuth: { "infisical.identity.auth_method": AuthAttemptAuthMethod.KUBERNETES_AUTH,
kubernetes: { "infisical.identity.auth_result": AuthAttemptAuthResult.SUCCESS,
namespace: targetNamespace, "client.address": requestContext.get("ip"),
name: targetName "user_agent.original": requestContext.get("userAgent")
} });
} }
} as TIdentityAccessTokenJwtPayload,
appCfg.AUTH_SECRET,
// akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error
Number(identityAccessToken.accessTokenTTL) === 0
? undefined
: {
expiresIn: Number(identityAccessToken.accessTokenTTL)
}
);
return { accessToken, identityKubernetesAuth, identityAccessToken, identity }; return { accessToken, identityKubernetesAuth, identityAccessToken, identity };
} catch (error) {
if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
authAttemptCounter.add(1, {
"infisical.identity.id": identityKubernetesAuth.identityId,
"infisical.identity.name": identity.name,
"infisical.organization.id": org.id,
"infisical.organization.name": org.name,
"infisical.identity.auth_method": AuthAttemptAuthMethod.KUBERNETES_AUTH,
"infisical.identity.auth_result": AuthAttemptAuthResult.FAILURE,
"client.address": requestContext.get("ip"),
"user_agent.original": requestContext.get("userAgent")
});
}
throw error;
}
}; };
const attachKubernetesAuth = async ({ const attachKubernetesAuth = async ({
@@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { ForbiddenError } from "@casl/ability"; import { ForbiddenError } from "@casl/ability";
import { requestContext } from "@fastify/request-context";
import slugify from "@sindresorhus/slugify"; import slugify from "@sindresorhus/slugify";
import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas";
@@ -29,6 +30,7 @@ import {
} from "@app/lib/errors"; } from "@app/lib/errors";
import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip";
import { logger } from "@app/lib/logger"; import { logger } from "@app/lib/logger";
import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics";
import { ActorType, AuthTokenType } from "../auth/auth-type"; import { ActorType, AuthTokenType } from "../auth/auth-type";
import { TIdentityDALFactory } from "../identity/identity-dal"; import { TIdentityDALFactory } from "../identity/identity-dal";
@@ -151,6 +153,7 @@ export const identityLdapAuthServiceFactory = ({
}; };
const login = async ({ identityId }: TLoginLdapAuthDTO) => { const login = async ({ identityId }: TLoginLdapAuthDTO) => {
const appCfg = getConfig();
const identityLdapAuth = await identityLdapAuthDAL.findOne({ identityId }); const identityLdapAuth = await identityLdapAuthDAL.findOne({ identityId });
if (!identityLdapAuth) { if (!identityLdapAuth) {
@@ -162,6 +165,7 @@ export const identityLdapAuthServiceFactory = ({
const identity = await identityDAL.findById(identityLdapAuth.identityId); const identity = await identityDAL.findById(identityLdapAuth.identityId);
if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); if (!identity) throw new UnauthorizedError({ message: "Identity not found" });
const org = await orgDAL.findById(identity.orgId);
const plan = await licenseService.getPlan(identity.orgId); const plan = await licenseService.getPlan(identity.orgId);
if (!plan.ldap) { if (!plan.ldap) {
throw new BadRequestError({ throw new BadRequestError({
@@ -170,44 +174,72 @@ export const identityLdapAuthServiceFactory = ({
}); });
} }
const identityAccessToken = await identityLdapAuthDAL.transaction(async (tx) => { try {
await membershipIdentityDAL.update( const identityAccessToken = await identityLdapAuthDAL.transaction(async (tx) => {
{ scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, await membershipIdentityDAL.update(
{ lastLoginAuthMethod: IdentityAuthMethod.LDAP_AUTH, lastLoginTime: new Date() }, { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id },
tx { lastLoginAuthMethod: IdentityAuthMethod.LDAP_AUTH, lastLoginTime: new Date() },
); tx
const newToken = await identityAccessTokenDAL.create( );
const newToken = await identityAccessTokenDAL.create(
{
identityId: identityLdapAuth.identityId,
isAccessTokenRevoked: false,
accessTokenTTL: identityLdapAuth.accessTokenTTL,
accessTokenMaxTTL: identityLdapAuth.accessTokenMaxTTL,
accessTokenNumUses: 0,
accessTokenNumUsesLimit: identityLdapAuth.accessTokenNumUsesLimit,
authMethod: IdentityAuthMethod.LDAP_AUTH
},
tx
);
return newToken;
});
const accessToken = crypto.jwt().sign(
{ {
identityId: identityLdapAuth.identityId, identityId: identityLdapAuth.identityId,
isAccessTokenRevoked: false, identityAccessTokenId: identityAccessToken.id,
accessTokenTTL: identityLdapAuth.accessTokenTTL, authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN
accessTokenMaxTTL: identityLdapAuth.accessTokenMaxTTL, } as TIdentityAccessTokenJwtPayload,
accessTokenNumUses: 0, appCfg.AUTH_SECRET,
accessTokenNumUsesLimit: identityLdapAuth.accessTokenNumUsesLimit, // akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error
authMethod: IdentityAuthMethod.LDAP_AUTH Number(identityAccessToken.accessTokenTTL) === 0
}, ? undefined
tx : {
expiresIn: Number(identityAccessToken.accessTokenTTL)
}
); );
return newToken;
});
const appCfg = getConfig(); if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
const accessToken = crypto.jwt().sign( authAttemptCounter.add(1, {
{ "infisical.identity.id": identityLdapAuth.identityId,
identityId: identityLdapAuth.identityId, "infisical.identity.name": identity.name,
identityAccessTokenId: identityAccessToken.id, "infisical.organization.id": org.id,
authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN "infisical.organization.name": org.name,
} as TIdentityAccessTokenJwtPayload, "infisical.identity.auth_method": AuthAttemptAuthMethod.LDAP_AUTH,
appCfg.AUTH_SECRET, "infisical.identity.auth_result": AuthAttemptAuthResult.SUCCESS,
// akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error "client.address": requestContext.get("ip"),
Number(identityAccessToken.accessTokenTTL) === 0 "user_agent.original": requestContext.get("userAgent")
? undefined });
: { }
expiresIn: Number(identityAccessToken.accessTokenTTL)
}
);
return { accessToken, identityLdapAuth, identityAccessToken, identity }; return { accessToken, identityLdapAuth, identityAccessToken, identity };
} catch (error) {
if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
authAttemptCounter.add(1, {
"infisical.identity.id": identityLdapAuth.identityId,
"infisical.identity.name": identity.name,
"infisical.organization.id": org.id,
"infisical.organization.name": org.name,
"infisical.identity.auth_method": AuthAttemptAuthMethod.LDAP_AUTH,
"infisical.identity.auth_result": AuthAttemptAuthResult.FAILURE,
"client.address": requestContext.get("ip"),
"user_agent.original": requestContext.get("userAgent")
});
}
throw error;
}
}; };
const attachLdapAuth = async ({ const attachLdapAuth = async ({
@@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { ForbiddenError } from "@casl/ability"; import { ForbiddenError } from "@casl/ability";
import { requestContext } from "@fastify/request-context";
import { AxiosError } from "axios"; import { AxiosError } from "axios";
import RE2 from "re2"; import RE2 from "re2";
@@ -23,6 +24,7 @@ import {
} from "@app/lib/errors"; } from "@app/lib/errors";
import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip";
import { logger } from "@app/lib/logger"; import { logger } from "@app/lib/logger";
import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics";
import { ActorType, AuthTokenType } from "../auth/auth-type"; import { ActorType, AuthTokenType } from "../auth/auth-type";
import { TIdentityDALFactory } from "../identity/identity-dal"; import { TIdentityDALFactory } from "../identity/identity-dal";
@@ -63,6 +65,7 @@ export const identityOciAuthServiceFactory = ({
orgDAL orgDAL
}: TIdentityOciAuthServiceFactoryDep) => { }: TIdentityOciAuthServiceFactoryDep) => {
const login = async ({ identityId, headers, userOcid }: TLoginOciAuthDTO) => { const login = async ({ identityId, headers, userOcid }: TLoginOciAuthDTO) => {
const appCfg = getConfig();
const identityOciAuth = await identityOciAuthDAL.findOne({ identityId }); const identityOciAuth = await identityOciAuthDAL.findOne({ identityId });
if (!identityOciAuth) { if (!identityOciAuth) {
throw new NotFoundError({ message: "OCI auth method not found for identity, did you configure OCI auth?" }); throw new NotFoundError({ message: "OCI auth method not found for identity, did you configure OCI auth?" });
@@ -71,80 +74,109 @@ export const identityOciAuthServiceFactory = ({
const identity = await identityDAL.findById(identityOciAuth.identityId); const identity = await identityDAL.findById(identityOciAuth.identityId);
if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); if (!identity) throw new UnauthorizedError({ message: "Identity not found" });
// Validate OCI host format. Ensures that the host is in "identity.<region>.oraclecloud.com" format. const org = await orgDAL.findById(identity.orgId);
if (!headers.host || !new RE2("^identity\\.([a-z]{2}-[a-z]+-[1-9])\\.oraclecloud\\.com$").test(headers.host)) { try {
throw new BadRequestError({ // Validate OCI host format. Ensures that the host is in "identity.<region>.oraclecloud.com" format.
message: "Invalid OCI host format. Expected format: identity.<region>.oraclecloud.com" if (!headers.host || !new RE2("^identity\\.([a-z]{2}-[a-z]+-[1-9])\\.oraclecloud\\.com$").test(headers.host)) {
}); throw new BadRequestError({
} message: "Invalid OCI host format. Expected format: identity.<region>.oraclecloud.com"
const { data } = await request
.get<TOciGetUserResponse>(`https://${headers.host}/20160918/users/${userOcid}`, {
headers
})
.catch((err: AxiosError) => {
logger.error(err.response, "OciIdentityLogin: Failed to authenticate with Oracle Cloud");
throw err;
});
if (data.compartmentId !== identityOciAuth.tenancyOcid) {
throw new UnauthorizedError({
message: "Access denied: OCI account isn't part of tenancy."
});
}
if (identityOciAuth.allowedUsernames) {
const isAccountAllowed = identityOciAuth.allowedUsernames.split(",").some((name) => name.trim() === data.name);
if (!isAccountAllowed)
throw new UnauthorizedError({
message: "Access denied: OCI account username not allowed."
}); });
} }
// Generate the token const { data } = await request
const identityAccessToken = await identityOciAuthDAL.transaction(async (tx) => { .get<TOciGetUserResponse>(`https://${headers.host}/20160918/users/${userOcid}`, {
await membershipIdentityDAL.update( headers
{ scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, })
{ lastLoginAuthMethod: IdentityAuthMethod.OCI_AUTH, lastLoginTime: new Date() }, .catch((err: AxiosError) => {
tx logger.error(err.response, "OciIdentityLogin: Failed to authenticate with Oracle Cloud");
); throw err;
const newToken = await identityAccessTokenDAL.create( });
if (data.compartmentId !== identityOciAuth.tenancyOcid) {
throw new UnauthorizedError({
message: "Access denied: OCI account isn't part of tenancy."
});
}
if (identityOciAuth.allowedUsernames) {
const isAccountAllowed = identityOciAuth.allowedUsernames.split(",").some((name) => name.trim() === data.name);
if (!isAccountAllowed)
throw new UnauthorizedError({
message: "Access denied: OCI account username not allowed."
});
}
// Generate the token
const identityAccessToken = await identityOciAuthDAL.transaction(async (tx) => {
await membershipIdentityDAL.update(
{ scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id },
{ lastLoginAuthMethod: IdentityAuthMethod.OCI_AUTH, lastLoginTime: new Date() },
tx
);
const newToken = await identityAccessTokenDAL.create(
{
identityId: identityOciAuth.identityId,
isAccessTokenRevoked: false,
accessTokenTTL: identityOciAuth.accessTokenTTL,
accessTokenMaxTTL: identityOciAuth.accessTokenMaxTTL,
accessTokenNumUses: 0,
accessTokenNumUsesLimit: identityOciAuth.accessTokenNumUsesLimit,
authMethod: IdentityAuthMethod.OCI_AUTH
},
tx
);
return newToken;
});
const accessToken = crypto.jwt().sign(
{ {
identityId: identityOciAuth.identityId, identityId: identityOciAuth.identityId,
isAccessTokenRevoked: false, identityAccessTokenId: identityAccessToken.id,
accessTokenTTL: identityOciAuth.accessTokenTTL, authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN
accessTokenMaxTTL: identityOciAuth.accessTokenMaxTTL, } as TIdentityAccessTokenJwtPayload,
accessTokenNumUses: 0, appCfg.AUTH_SECRET,
accessTokenNumUsesLimit: identityOciAuth.accessTokenNumUsesLimit, Number(identityAccessToken.accessTokenTTL) === 0
authMethod: IdentityAuthMethod.OCI_AUTH ? undefined
}, : {
tx expiresIn: Number(identityAccessToken.accessTokenTTL)
}
); );
return newToken;
});
const appCfg = getConfig(); if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
const accessToken = crypto.jwt().sign( authAttemptCounter.add(1, {
{ "infisical.identity.id": identityOciAuth.identityId,
identityId: identityOciAuth.identityId, "infisical.identity.name": identity.name,
identityAccessTokenId: identityAccessToken.id, "infisical.organization.id": org.id,
authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN "infisical.organization.name": org.name,
} as TIdentityAccessTokenJwtPayload, "infisical.identity.auth_method": AuthAttemptAuthMethod.OCI_AUTH,
appCfg.AUTH_SECRET, "infisical.identity.auth_result": AuthAttemptAuthResult.SUCCESS,
Number(identityAccessToken.accessTokenTTL) === 0 "client.address": requestContext.get("ip"),
? undefined "user_agent.original": requestContext.get("userAgent")
: { });
expiresIn: Number(identityAccessToken.accessTokenTTL) }
}
);
return { return {
identityOciAuth, identityOciAuth,
accessToken, accessToken,
identityAccessToken, identityAccessToken,
identity identity
}; };
} catch (error) {
if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
authAttemptCounter.add(1, {
"infisical.identity.id": identityOciAuth.identityId,
"infisical.identity.name": identity.name,
"infisical.organization.id": org.id,
"infisical.organization.name": org.name,
"infisical.identity.auth_method": AuthAttemptAuthMethod.OCI_AUTH,
"infisical.identity.auth_result": AuthAttemptAuthResult.FAILURE,
"client.address": requestContext.get("ip"),
"user_agent.original": requestContext.get("userAgent")
});
}
throw error;
}
}; };
const attachOciAuth = async ({ const attachOciAuth = async ({
@@ -1,4 +1,5 @@
import { ForbiddenError } from "@casl/ability"; import { ForbiddenError } from "@casl/ability";
import { requestContext } from "@fastify/request-context";
import axios from "axios"; import axios from "axios";
import https from "https"; import https from "https";
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
@@ -22,6 +23,7 @@ import {
UnauthorizedError UnauthorizedError
} from "@app/lib/errors"; } from "@app/lib/errors";
import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip";
import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics";
import { getValueByDot } from "@app/lib/template/dot-access"; import { getValueByDot } from "@app/lib/template/dot-access";
import { ActorType, AuthTokenType } from "../auth/auth-type"; import { ActorType, AuthTokenType } from "../auth/auth-type";
@@ -67,6 +69,7 @@ export const identityOidcAuthServiceFactory = ({
orgDAL orgDAL
}: TIdentityOidcAuthServiceFactoryDep) => { }: TIdentityOidcAuthServiceFactoryDep) => {
const login = async ({ identityId, jwt: oidcJwt }: TLoginOidcAuthDTO) => { const login = async ({ identityId, jwt: oidcJwt }: TLoginOidcAuthDTO) => {
const appCfg = getConfig();
const identityOidcAuth = await identityOidcAuthDAL.findOne({ identityId }); const identityOidcAuth = await identityOidcAuthDAL.findOne({ identityId });
if (!identityOidcAuth) { if (!identityOidcAuth) {
throw new NotFoundError({ message: "OIDC auth method not found for identity, did you configure OIDC auth?" }); throw new NotFoundError({ message: "OIDC auth method not found for identity, did you configure OIDC auth?" });
@@ -75,151 +78,180 @@ export const identityOidcAuthServiceFactory = ({
const identity = await identityDAL.findById(identityOidcAuth.identityId); const identity = await identityDAL.findById(identityOidcAuth.identityId);
if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); if (!identity) throw new UnauthorizedError({ message: "Identity not found" });
const { decryptor } = await kmsService.createCipherPairWithDataKey({ const org = await orgDAL.findById(identity.orgId);
type: KmsDataKey.Organization,
orgId: identity.orgId
});
let caCert = "";
if (identityOidcAuth.encryptedCaCertificate) {
caCert = decryptor({ cipherTextBlob: identityOidcAuth.encryptedCaCertificate }).toString();
}
const requestAgent = new https.Agent({ ca: caCert, rejectUnauthorized: !!caCert });
const { data: discoveryDoc } = await axios.get<{ jwks_uri: string }>(
`${identityOidcAuth.oidcDiscoveryUrl}/.well-known/openid-configuration`,
{
httpsAgent: identityOidcAuth.oidcDiscoveryUrl.includes("https") ? requestAgent : undefined
}
);
const jwksUri = discoveryDoc.jwks_uri;
const decodedToken = crypto.jwt().decode(oidcJwt, { complete: true });
if (!decodedToken) {
throw new UnauthorizedError({
message: "Invalid JWT"
});
}
const client = new JwksClient({
jwksUri,
requestAgent: identityOidcAuth.oidcDiscoveryUrl.includes("https") ? requestAgent : undefined
});
const { kid } = decodedToken.header as { kid: string };
const oidcSigningKey = await client.getSigningKey(kid);
let tokenData: Record<string, string>;
try { try {
tokenData = crypto.jwt().verify(oidcJwt, oidcSigningKey.getPublicKey(), { const { decryptor } = await kmsService.createCipherPairWithDataKey({
issuer: identityOidcAuth.boundIssuer type: KmsDataKey.Organization,
}) as Record<string, string>; orgId: identity.orgId
} catch (error) { });
if (error instanceof jwt.JsonWebTokenError) {
let caCert = "";
if (identityOidcAuth.encryptedCaCertificate) {
caCert = decryptor({ cipherTextBlob: identityOidcAuth.encryptedCaCertificate }).toString();
}
const requestAgent = new https.Agent({ ca: caCert, rejectUnauthorized: !!caCert });
const { data: discoveryDoc } = await axios.get<{ jwks_uri: string }>(
`${identityOidcAuth.oidcDiscoveryUrl}/.well-known/openid-configuration`,
{
httpsAgent: identityOidcAuth.oidcDiscoveryUrl.includes("https") ? requestAgent : undefined
}
);
const jwksUri = discoveryDoc.jwks_uri;
const decodedToken = crypto.jwt().decode(oidcJwt, { complete: true });
if (!decodedToken) {
throw new UnauthorizedError({ throw new UnauthorizedError({
message: `Access denied: ${error.message}` message: "Invalid JWT"
});
}
const client = new JwksClient({
jwksUri,
requestAgent: identityOidcAuth.oidcDiscoveryUrl.includes("https") ? requestAgent : undefined
});
const { kid } = decodedToken.header as { kid: string };
const oidcSigningKey = await client.getSigningKey(kid);
let tokenData: Record<string, string>;
try {
tokenData = crypto.jwt().verify(oidcJwt, oidcSigningKey.getPublicKey(), {
issuer: identityOidcAuth.boundIssuer
}) as Record<string, string>;
} catch (error) {
if (error instanceof jwt.JsonWebTokenError) {
throw new UnauthorizedError({
message: `Access denied: ${error.message}`
});
}
throw error;
}
if (identityOidcAuth.boundSubject) {
if (!doesFieldValueMatchOidcPolicy(tokenData.sub, identityOidcAuth.boundSubject)) {
throw new ForbiddenRequestError({
message: "Access denied: OIDC subject not allowed."
});
}
}
if (identityOidcAuth.boundAudiences) {
if (
!identityOidcAuth.boundAudiences
.split(", ")
.some((policyValue) => doesAudValueMatchOidcPolicy(tokenData.aud, policyValue))
) {
throw new UnauthorizedError({
message: "Access denied: OIDC audience not allowed."
});
}
}
if (identityOidcAuth.boundClaims) {
Object.keys(identityOidcAuth.boundClaims).forEach((claimKey) => {
const claimValue = (identityOidcAuth.boundClaims as Record<string, string>)[claimKey];
const value = getValueByDot(tokenData, claimKey);
if (!value) {
throw new UnauthorizedError({
message: `Access denied: token has no ${claimKey} field`
});
}
// handle both single and multi-valued claims
if (!claimValue.split(", ").some((claimEntry) => doesFieldValueMatchOidcPolicy(value, claimEntry))) {
throw new UnauthorizedError({
message: "Access denied: OIDC claim not allowed."
});
}
});
}
const filteredClaims: Record<string, string> = {};
if (identityOidcAuth.claimMetadataMapping) {
Object.keys(identityOidcAuth.claimMetadataMapping).forEach((permissionKey) => {
const claimKey = (identityOidcAuth.claimMetadataMapping as Record<string, string>)[permissionKey];
const value = getValueByDot(tokenData, claimKey);
if (!value) {
throw new UnauthorizedError({
message: `Access denied: token has no ${claimKey} field`
});
}
filteredClaims[permissionKey] = value.toString();
});
}
const identityAccessToken = await identityOidcAuthDAL.transaction(async (tx) => {
await membershipIdentityDAL.update(
{ scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id },
{ lastLoginAuthMethod: IdentityAuthMethod.OIDC_AUTH, lastLoginTime: new Date() },
tx
);
const newToken = await identityAccessTokenDAL.create(
{
identityId: identityOidcAuth.identityId,
isAccessTokenRevoked: false,
accessTokenTTL: identityOidcAuth.accessTokenTTL,
accessTokenMaxTTL: identityOidcAuth.accessTokenMaxTTL,
accessTokenNumUses: 0,
accessTokenNumUsesLimit: identityOidcAuth.accessTokenNumUsesLimit,
authMethod: IdentityAuthMethod.OIDC_AUTH
},
tx
);
return newToken;
});
const accessToken = crypto.jwt().sign(
{
identityId: identityOidcAuth.identityId,
identityAccessTokenId: identityAccessToken.id,
authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN,
identityAuth: {
oidc: {
claims: filteredClaims
}
}
} as TIdentityAccessTokenJwtPayload,
appCfg.AUTH_SECRET,
// akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error
Number(identityAccessToken.accessTokenTTL) === 0
? undefined
: {
expiresIn: Number(identityAccessToken.accessTokenTTL)
}
);
if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
authAttemptCounter.add(1, {
"infisical.identity.id": identityOidcAuth.identityId,
"infisical.identity.name": identity.name,
"infisical.organization.id": org.id,
"infisical.organization.name": org.name,
"infisical.identity.auth_method": AuthAttemptAuthMethod.OIDC_AUTH,
"infisical.identity.auth_result": AuthAttemptAuthResult.SUCCESS,
"client.address": requestContext.get("ip"),
"user_agent.original": requestContext.get("userAgent")
});
}
return { accessToken, identityOidcAuth, identityAccessToken, identity, oidcTokenData: tokenData };
} catch (error) {
if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
authAttemptCounter.add(1, {
"infisical.identity.id": identityOidcAuth.identityId,
"infisical.identity.name": identity.name,
"infisical.organization.id": org.id,
"infisical.organization.name": org.name,
"infisical.identity.auth_method": AuthAttemptAuthMethod.OIDC_AUTH,
"infisical.identity.auth_result": AuthAttemptAuthResult.FAILURE,
"client.address": requestContext.get("ip"),
"user_agent.original": requestContext.get("userAgent")
}); });
} }
throw error; throw error;
} }
if (identityOidcAuth.boundSubject) {
if (!doesFieldValueMatchOidcPolicy(tokenData.sub, identityOidcAuth.boundSubject)) {
throw new ForbiddenRequestError({
message: "Access denied: OIDC subject not allowed."
});
}
}
if (identityOidcAuth.boundAudiences) {
if (
!identityOidcAuth.boundAudiences
.split(", ")
.some((policyValue) => doesAudValueMatchOidcPolicy(tokenData.aud, policyValue))
) {
throw new UnauthorizedError({
message: "Access denied: OIDC audience not allowed."
});
}
}
if (identityOidcAuth.boundClaims) {
Object.keys(identityOidcAuth.boundClaims).forEach((claimKey) => {
const claimValue = (identityOidcAuth.boundClaims as Record<string, string>)[claimKey];
const value = getValueByDot(tokenData, claimKey);
if (!value) {
throw new UnauthorizedError({
message: `Access denied: token has no ${claimKey} field`
});
}
// handle both single and multi-valued claims
if (!claimValue.split(", ").some((claimEntry) => doesFieldValueMatchOidcPolicy(value, claimEntry))) {
throw new UnauthorizedError({
message: "Access denied: OIDC claim not allowed."
});
}
});
}
const filteredClaims: Record<string, string> = {};
if (identityOidcAuth.claimMetadataMapping) {
Object.keys(identityOidcAuth.claimMetadataMapping).forEach((permissionKey) => {
const claimKey = (identityOidcAuth.claimMetadataMapping as Record<string, string>)[permissionKey];
const value = getValueByDot(tokenData, claimKey);
if (!value) {
throw new UnauthorizedError({
message: `Access denied: token has no ${claimKey} field`
});
}
filteredClaims[permissionKey] = value.toString();
});
}
const identityAccessToken = await identityOidcAuthDAL.transaction(async (tx) => {
await membershipIdentityDAL.update(
{ scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id },
{ lastLoginAuthMethod: IdentityAuthMethod.OIDC_AUTH, lastLoginTime: new Date() },
tx
);
const newToken = await identityAccessTokenDAL.create(
{
identityId: identityOidcAuth.identityId,
isAccessTokenRevoked: false,
accessTokenTTL: identityOidcAuth.accessTokenTTL,
accessTokenMaxTTL: identityOidcAuth.accessTokenMaxTTL,
accessTokenNumUses: 0,
accessTokenNumUsesLimit: identityOidcAuth.accessTokenNumUsesLimit,
authMethod: IdentityAuthMethod.OIDC_AUTH
},
tx
);
return newToken;
});
const appCfg = getConfig();
const accessToken = crypto.jwt().sign(
{
identityId: identityOidcAuth.identityId,
identityAccessTokenId: identityAccessToken.id,
authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN,
identityAuth: {
oidc: {
claims: filteredClaims
}
}
} as TIdentityAccessTokenJwtPayload,
appCfg.AUTH_SECRET,
// akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error
Number(identityAccessToken.accessTokenTTL) === 0
? undefined
: {
expiresIn: Number(identityAccessToken.accessTokenTTL)
}
);
return { accessToken, identityOidcAuth, identityAccessToken, identity, oidcTokenData: tokenData };
}; };
const attachOidcAuth = async ({ const attachOidcAuth = async ({
@@ -1,4 +1,5 @@
import { ForbiddenError } from "@casl/ability"; import { ForbiddenError } from "@casl/ability";
import { requestContext } from "@fastify/request-context";
import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
@@ -19,6 +20,7 @@ import {
UnauthorizedError UnauthorizedError
} from "@app/lib/errors"; } from "@app/lib/errors";
import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip";
import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics";
import { ActorType, AuthTokenType } from "../auth/auth-type"; import { ActorType, AuthTokenType } from "../auth/auth-type";
import { TIdentityDALFactory } from "../identity/identity-dal"; import { TIdentityDALFactory } from "../identity/identity-dal";
@@ -27,6 +29,7 @@ import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identit
import { TKmsServiceFactory } from "../kms/kms-service"; import { TKmsServiceFactory } from "../kms/kms-service";
import { KmsDataKey } from "../kms/kms-types"; import { KmsDataKey } from "../kms/kms-types";
import { TMembershipIdentityDALFactory } from "../membership-identity/membership-identity-dal"; import { TMembershipIdentityDALFactory } from "../membership-identity/membership-identity-dal";
import { TOrgDALFactory } from "../org/org-dal";
import { validateIdentityUpdateForSuperAdminPrivileges } from "../super-admin/super-admin-fns"; import { validateIdentityUpdateForSuperAdminPrivileges } from "../super-admin/super-admin-fns";
import { TIdentityTlsCertAuthDALFactory } from "./identity-tls-cert-auth-dal"; import { TIdentityTlsCertAuthDALFactory } from "./identity-tls-cert-auth-dal";
import { TIdentityTlsCertAuthServiceFactory } from "./identity-tls-cert-auth-types"; import { TIdentityTlsCertAuthServiceFactory } from "./identity-tls-cert-auth-types";
@@ -42,6 +45,7 @@ type TIdentityTlsCertAuthServiceFactoryDep = {
licenseService: Pick<TLicenseServiceFactory, "getPlan">; licenseService: Pick<TLicenseServiceFactory, "getPlan">;
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">; permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">; kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
orgDAL: Pick<TOrgDALFactory, "findById">;
}; };
const parseSubjectDetails = (data: string) => { const parseSubjectDetails = (data: string) => {
@@ -60,9 +64,11 @@ export const identityTlsCertAuthServiceFactory = ({
membershipIdentityDAL, membershipIdentityDAL,
licenseService, licenseService,
permissionService, permissionService,
kmsService kmsService,
orgDAL
}: TIdentityTlsCertAuthServiceFactoryDep): TIdentityTlsCertAuthServiceFactory => { }: TIdentityTlsCertAuthServiceFactoryDep): TIdentityTlsCertAuthServiceFactory => {
const login: TIdentityTlsCertAuthServiceFactory["login"] = async ({ identityId, clientCertificate }) => { const login: TIdentityTlsCertAuthServiceFactory["login"] = async ({ identityId, clientCertificate }) => {
const appCfg = getConfig();
const identityTlsCertAuth = await identityTlsCertAuthDAL.findOne({ identityId }); const identityTlsCertAuth = await identityTlsCertAuthDAL.findOne({ identityId });
if (!identityTlsCertAuth) { if (!identityTlsCertAuth) {
throw new NotFoundError({ throw new NotFoundError({
@@ -73,94 +79,124 @@ export const identityTlsCertAuthServiceFactory = ({
const identity = await identityDAL.findById(identityTlsCertAuth.identityId); const identity = await identityDAL.findById(identityTlsCertAuth.identityId);
if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); if (!identity) throw new UnauthorizedError({ message: "Identity not found" });
const { decryptor } = await kmsService.createCipherPairWithDataKey({ const org = await orgDAL.findById(identity.orgId);
type: KmsDataKey.Organization,
orgId: identity.orgId
});
const caCertificate = decryptor({ try {
cipherTextBlob: identityTlsCertAuth.encryptedCaCertificate const { decryptor } = await kmsService.createCipherPairWithDataKey({
}).toString(); type: KmsDataKey.Organization,
orgId: identity.orgId
const leafCertificate = extractX509CertFromChain(decodeURIComponent(clientCertificate))?.[0];
if (!leafCertificate) {
throw new BadRequestError({ message: "Missing client certificate" });
}
const clientCertificateX509 = new crypto.nativeCrypto.X509Certificate(leafCertificate);
const caCertificateX509 = new crypto.nativeCrypto.X509Certificate(caCertificate);
const isValidCertificate = clientCertificateX509.verify(caCertificateX509.publicKey);
if (!isValidCertificate)
throw new UnauthorizedError({
message: "Access denied: Certificate not issued by the provided CA."
}); });
if (new Date(clientCertificateX509.validTo) < new Date()) { const caCertificate = decryptor({
throw new UnauthorizedError({ cipherTextBlob: identityTlsCertAuth.encryptedCaCertificate
message: "Access denied: Certificate has expired." }).toString();
});
}
if (new Date(clientCertificateX509.validFrom) > new Date()) { const leafCertificate = extractX509CertFromChain(decodeURIComponent(clientCertificate))?.[0];
throw new UnauthorizedError({ if (!leafCertificate) {
message: "Access denied: Certificate not yet valid." throw new BadRequestError({ message: "Missing client certificate" });
}); }
}
const subjectDetails = parseSubjectDetails(clientCertificateX509.subject); const clientCertificateX509 = new crypto.nativeCrypto.X509Certificate(leafCertificate);
if (identityTlsCertAuth.allowedCommonNames) { const caCertificateX509 = new crypto.nativeCrypto.X509Certificate(caCertificate);
const isValidCommonName = identityTlsCertAuth.allowedCommonNames.split(",").includes(subjectDetails.CN);
if (!isValidCommonName) { const isValidCertificate = clientCertificateX509.verify(caCertificateX509.publicKey);
if (!isValidCertificate)
throw new UnauthorizedError({ throw new UnauthorizedError({
message: "Access denied: TLS Certificate Auth common name not allowed." message: "Access denied: Certificate not issued by the provided CA."
});
if (new Date(clientCertificateX509.validTo) < new Date()) {
throw new UnauthorizedError({
message: "Access denied: Certificate has expired."
}); });
} }
}
// Generate the token if (new Date(clientCertificateX509.validFrom) > new Date()) {
const identityAccessToken = await identityTlsCertAuthDAL.transaction(async (tx) => { throw new UnauthorizedError({
await membershipIdentityDAL.update( message: "Access denied: Certificate not yet valid."
{ scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, });
{ lastLoginAuthMethod: IdentityAuthMethod.TLS_CERT_AUTH, lastLoginTime: new Date() }, }
tx
); const subjectDetails = parseSubjectDetails(clientCertificateX509.subject);
const newToken = await identityAccessTokenDAL.create( if (identityTlsCertAuth.allowedCommonNames) {
const isValidCommonName = identityTlsCertAuth.allowedCommonNames.split(",").includes(subjectDetails.CN);
if (!isValidCommonName) {
throw new UnauthorizedError({
message: "Access denied: TLS Certificate Auth common name not allowed."
});
}
}
// Generate the token
const identityAccessToken = await identityTlsCertAuthDAL.transaction(async (tx) => {
await membershipIdentityDAL.update(
{ scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id },
{ lastLoginAuthMethod: IdentityAuthMethod.TLS_CERT_AUTH, lastLoginTime: new Date() },
tx
);
const newToken = await identityAccessTokenDAL.create(
{
identityId: identityTlsCertAuth.identityId,
isAccessTokenRevoked: false,
accessTokenTTL: identityTlsCertAuth.accessTokenTTL,
accessTokenMaxTTL: identityTlsCertAuth.accessTokenMaxTTL,
accessTokenNumUses: 0,
accessTokenNumUsesLimit: identityTlsCertAuth.accessTokenNumUsesLimit,
authMethod: IdentityAuthMethod.TLS_CERT_AUTH
},
tx
);
return newToken;
});
const accessToken = crypto.jwt().sign(
{ {
identityId: identityTlsCertAuth.identityId, identityId: identityTlsCertAuth.identityId,
isAccessTokenRevoked: false, identityAccessTokenId: identityAccessToken.id,
accessTokenTTL: identityTlsCertAuth.accessTokenTTL, authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN
accessTokenMaxTTL: identityTlsCertAuth.accessTokenMaxTTL, } as TIdentityAccessTokenJwtPayload,
accessTokenNumUses: 0, appCfg.AUTH_SECRET,
accessTokenNumUsesLimit: identityTlsCertAuth.accessTokenNumUsesLimit, Number(identityAccessToken.accessTokenTTL) === 0
authMethod: IdentityAuthMethod.TLS_CERT_AUTH ? undefined
}, : {
tx expiresIn: Number(identityAccessToken.accessTokenTTL)
}
); );
return newToken;
});
const appCfg = getConfig(); if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
const accessToken = crypto.jwt().sign( authAttemptCounter.add(1, {
{ "infisical.identity.id": identityTlsCertAuth.identityId,
identityId: identityTlsCertAuth.identityId, "infisical.identity.name": identity.name,
identityAccessTokenId: identityAccessToken.id, "infisical.organization.id": org.id,
authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN "infisical.organization.name": org.name,
} as TIdentityAccessTokenJwtPayload, "infisical.identity.auth_method": AuthAttemptAuthMethod.TLS_CERT_AUTH,
appCfg.AUTH_SECRET, "infisical.identity.auth_result": AuthAttemptAuthResult.SUCCESS,
Number(identityAccessToken.accessTokenTTL) === 0 "client.address": requestContext.get("ip"),
? undefined "user_agent.original": requestContext.get("userAgent")
: { });
expiresIn: Number(identityAccessToken.accessTokenTTL) }
}
);
return { return {
identityTlsCertAuth, identityTlsCertAuth,
accessToken, accessToken,
identityAccessToken, identityAccessToken,
identity identity
}; };
} catch (error) {
if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
authAttemptCounter.add(1, {
"infisical.identity.id": identityTlsCertAuth.identityId,
"infisical.identity.name": identity.name,
"infisical.organization.id": org.id,
"infisical.organization.name": org.name,
"infisical.identity.auth_method": AuthAttemptAuthMethod.TLS_CERT_AUTH,
"infisical.identity.auth_result": AuthAttemptAuthResult.FAILURE,
"client.address": requestContext.get("ip"),
"user_agent.original": requestContext.get("userAgent")
});
}
throw error;
}
}; };
const attachTlsCertAuth: TIdentityTlsCertAuthServiceFactory["attachTlsCertAuth"] = async ({ const attachTlsCertAuth: TIdentityTlsCertAuthServiceFactory["attachTlsCertAuth"] = async ({
@@ -1,4 +1,5 @@
import { ForbiddenError } from "@casl/ability"; import { ForbiddenError } from "@casl/ability";
import { requestContext } from "@fastify/request-context";
import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
@@ -21,6 +22,7 @@ import {
} from "@app/lib/errors"; } from "@app/lib/errors";
import { checkIPAgainstBlocklist, extractIPDetails, isValidIpOrCidr, TIp } from "@app/lib/ip"; import { checkIPAgainstBlocklist, extractIPDetails, isValidIpOrCidr, TIp } from "@app/lib/ip";
import { logger } from "@app/lib/logger"; import { logger } from "@app/lib/logger";
import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics";
import { ActorType, AuthTokenType } from "../auth/auth-type"; import { ActorType, AuthTokenType } from "../auth/auth-type";
import { TIdentityDALFactory } from "../identity/identity-dal"; import { TIdentityDALFactory } from "../identity/identity-dal";
@@ -77,6 +79,7 @@ export const identityUaServiceFactory = ({
identityDAL identityDAL
}: TIdentityUaServiceFactoryDep) => { }: TIdentityUaServiceFactoryDep) => {
const login = async (clientId: string, clientSecret: string, ip: string) => { const login = async (clientId: string, clientSecret: string, ip: string) => {
const appCfg = getConfig();
const identityUa = await identityUaDAL.findOne({ clientId }); const identityUa = await identityUaDAL.findOne({ clientId });
if (!identityUa) { if (!identityUa) {
throw new UnauthorizedError({ throw new UnauthorizedError({
@@ -84,196 +87,226 @@ export const identityUaServiceFactory = ({
}); });
} }
checkIPAgainstBlocklist({ const identity = await identityDAL.findById(identityUa.identityId);
ipAddress: ip, const org = await orgDAL.findById(identity.orgId);
trustedIps: identityUa.clientSecretTrustedIps as TIp[]
});
const LOCKOUT_KEY = `lockout:identity:${identityUa.identityId}:${IdentityAuthMethod.UNIVERSAL_AUTH}:${clientId}`; try {
checkIPAgainstBlocklist({
const lockoutRaw = await keyStore.getItem(LOCKOUT_KEY); ipAddress: ip,
trustedIps: identityUa.clientSecretTrustedIps as TIp[]
let lockout: LockoutObject | undefined;
if (lockoutRaw) {
lockout = JSON.parse(lockoutRaw) as LockoutObject;
}
if (lockout && lockout.lockedOut) {
throw new UnauthorizedError({
message: "This identity auth method is temporarily locked, please try again later"
}); });
}
const clientSecretPrefix = clientSecret.slice(0, 4); const LOCKOUT_KEY = `lockout:identity:${identityUa.identityId}:${IdentityAuthMethod.UNIVERSAL_AUTH}:${clientId}`;
const clientSecretInfo = await identityUaClientSecretDAL.find({
identityUAId: identityUa.id,
isClientSecretRevoked: false,
clientSecretPrefix
});
let validClientSecretInfo: (typeof clientSecretInfo)[0] | null = null; const lockoutRaw = await keyStore.getItem(LOCKOUT_KEY);
for await (const info of clientSecretInfo) {
const isMatch = await crypto.hashing().compareHash(clientSecret, info.clientSecretHash);
if (isMatch) { let lockout: LockoutObject | undefined;
validClientSecretInfo = info; if (lockoutRaw) {
break; lockout = JSON.parse(lockoutRaw) as LockoutObject;
} }
}
if (!validClientSecretInfo) { if (lockout && lockout.lockedOut) {
if (identityUa.lockoutEnabled) { throw new UnauthorizedError({
let lock: Awaited<ReturnType<typeof keyStore.acquireLock>> | undefined; message: "This identity auth method is temporarily locked, please try again later"
try { });
lock = await keyStore.acquireLock([KeyStorePrefixes.IdentityLockoutLock(LOCKOUT_KEY)], 300, { }
retryCount: 3,
retryDelay: 300,
retryJitter: 100
});
// Re-fetch the latest lockout data while holding the lock const clientSecretPrefix = clientSecret.slice(0, 4);
const lockoutRawNew = await keyStore.getItem(LOCKOUT_KEY); const clientSecretInfo = await identityUaClientSecretDAL.find({
if (lockoutRawNew) { identityUAId: identityUa.id,
lockout = JSON.parse(lockoutRawNew) as LockoutObject; isClientSecretRevoked: false,
} else { clientSecretPrefix
lockout = { });
lockedOut: false,
failedAttempts: 0
};
}
if (lockout.lockedOut) { let validClientSecretInfo: (typeof clientSecretInfo)[0] | null = null;
throw new UnauthorizedError({ for await (const info of clientSecretInfo) {
message: "This identity auth method is temporarily locked, please try again later" const isMatch = await crypto.hashing().compareHash(clientSecret, info.clientSecretHash);
});
}
lockout.failedAttempts += 1; if (isMatch) {
if (lockout.failedAttempts >= identityUa.lockoutThreshold) { validClientSecretInfo = info;
lockout.lockedOut = true; break;
}
await keyStore.setItemWithExpiry(
LOCKOUT_KEY,
lockout.lockedOut ? identityUa.lockoutDurationSeconds : identityUa.lockoutCounterResetSeconds,
JSON.stringify(lockout)
);
} catch (e) {
if (lock === undefined) {
logger.info(
`identity login failed to acquire lock [identityId=${identityUa.identityId}] [authMethod=${IdentityAuthMethod.UNIVERSAL_AUTH}]`
);
throw new RateLimitError({ message: "Failed to acquire lock: rate limit exceeded" });
}
throw e;
} finally {
if (lock) {
await lock.release();
}
} }
} }
throw new UnauthorizedError({ message: "Invalid credentials" }); if (!validClientSecretInfo) {
} else if (lockout) { if (identityUa.lockoutEnabled) {
// If credentials are valid, clear any existing lockout record let lock: Awaited<ReturnType<typeof keyStore.acquireLock>> | undefined;
await keyStore.deleteItem(LOCKOUT_KEY); try {
} lock = await keyStore.acquireLock([KeyStorePrefixes.IdentityLockoutLock(LOCKOUT_KEY)], 300, {
retryCount: 3,
retryDelay: 300,
retryJitter: 100
});
const { clientSecretTTL, clientSecretNumUses, clientSecretNumUsesLimit } = validClientSecretInfo; // Re-fetch the latest lockout data while holding the lock
if (Number(clientSecretTTL) > 0) { const lockoutRawNew = await keyStore.getItem(LOCKOUT_KEY);
const clientSecretCreated = new Date(validClientSecretInfo.createdAt); if (lockoutRawNew) {
const ttlInMilliseconds = Number(clientSecretTTL) * 1000; lockout = JSON.parse(lockoutRawNew) as LockoutObject;
const currentDate = new Date(); } else {
const expirationTime = new Date(clientSecretCreated.getTime() + ttlInMilliseconds); lockout = {
lockedOut: false,
failedAttempts: 0
};
}
if (currentDate > expirationTime) { if (lockout.lockedOut) {
throw new UnauthorizedError({
message: "This identity auth method is temporarily locked, please try again later"
});
}
lockout.failedAttempts += 1;
if (lockout.failedAttempts >= identityUa.lockoutThreshold) {
lockout.lockedOut = true;
}
await keyStore.setItemWithExpiry(
LOCKOUT_KEY,
lockout.lockedOut ? identityUa.lockoutDurationSeconds : identityUa.lockoutCounterResetSeconds,
JSON.stringify(lockout)
);
} catch (e) {
if (lock === undefined) {
logger.info(
`identity login failed to acquire lock [identityId=${identityUa.identityId}] [authMethod=${IdentityAuthMethod.UNIVERSAL_AUTH}]`
);
throw new RateLimitError({ message: "Failed to acquire lock: rate limit exceeded" });
}
throw e;
} finally {
if (lock) {
await lock.release();
}
}
}
throw new UnauthorizedError({ message: "Invalid credentials" });
} else if (lockout) {
// If credentials are valid, clear any existing lockout record
await keyStore.deleteItem(LOCKOUT_KEY);
}
const { clientSecretTTL, clientSecretNumUses, clientSecretNumUsesLimit } = validClientSecretInfo;
if (Number(clientSecretTTL) > 0) {
const clientSecretCreated = new Date(validClientSecretInfo.createdAt);
const ttlInMilliseconds = Number(clientSecretTTL) * 1000;
const currentDate = new Date();
const expirationTime = new Date(clientSecretCreated.getTime() + ttlInMilliseconds);
if (currentDate > expirationTime) {
await identityUaClientSecretDAL.updateById(validClientSecretInfo.id, {
isClientSecretRevoked: true
});
throw new UnauthorizedError({
message: "Access denied due to expired client secret"
});
}
}
if (clientSecretNumUsesLimit > 0 && clientSecretNumUses >= clientSecretNumUsesLimit) {
// number of times client secret can be used for
// a login operation reached
await identityUaClientSecretDAL.updateById(validClientSecretInfo.id, { await identityUaClientSecretDAL.updateById(validClientSecretInfo.id, {
isClientSecretRevoked: true isClientSecretRevoked: true
}); });
throw new UnauthorizedError({ throw new UnauthorizedError({
message: "Access denied due to expired client secret" message: "Access denied due to client secret usage limit reached"
}); });
} }
}
if (clientSecretNumUsesLimit > 0 && clientSecretNumUses >= clientSecretNumUsesLimit) { const accessTokenTTLParams =
// number of times client secret can be used for Number(identityUa.accessTokenPeriod) === 0
// a login operation reached ? {
await identityUaClientSecretDAL.updateById(validClientSecretInfo.id, { accessTokenTTL: identityUa.accessTokenTTL,
isClientSecretRevoked: true accessTokenMaxTTL: identityUa.accessTokenMaxTTL
}
: {
accessTokenTTL: identityUa.accessTokenPeriod,
// We set a very large Max TTL for periodic tokens to ensure that clients (even outdated ones) can always renew their token
// without them having to update their SDKs, CLIs, etc. This workaround sets it to 30 years to emulate "forever"
accessTokenMaxTTL: 1000000000
};
const identityAccessToken = await identityUaDAL.transaction(async (tx) => {
const uaClientSecretDoc = await identityUaClientSecretDAL.incrementUsage(validClientSecretInfo!.id, tx);
await membershipIdentityDAL.update(
{ scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id },
{
lastLoginAuthMethod: IdentityAuthMethod.UNIVERSAL_AUTH,
lastLoginTime: new Date()
},
tx
);
const newToken = await identityAccessTokenDAL.create(
{
identityId: identityUa.identityId,
isAccessTokenRevoked: false,
identityUAClientSecretId: uaClientSecretDoc.id,
accessTokenNumUses: 0,
accessTokenNumUsesLimit: identityUa.accessTokenNumUsesLimit,
accessTokenPeriod: identityUa.accessTokenPeriod,
authMethod: IdentityAuthMethod.UNIVERSAL_AUTH,
...accessTokenTTLParams
},
tx
);
return newToken;
}); });
throw new UnauthorizedError({
message: "Access denied due to client secret usage limit reached"
});
}
const accessTokenTTLParams = const accessToken = crypto.jwt().sign(
Number(identityUa.accessTokenPeriod) === 0
? {
accessTokenTTL: identityUa.accessTokenTTL,
accessTokenMaxTTL: identityUa.accessTokenMaxTTL
}
: {
accessTokenTTL: identityUa.accessTokenPeriod,
// We set a very large Max TTL for periodic tokens to ensure that clients (even outdated ones) can always renew their token
// without them having to update their SDKs, CLIs, etc. This workaround sets it to 30 years to emulate "forever"
accessTokenMaxTTL: 1000000000
};
const identity = await identityDAL.findById(identityUa.identityId);
const identityAccessToken = await identityUaDAL.transaction(async (tx) => {
const uaClientSecretDoc = await identityUaClientSecretDAL.incrementUsage(validClientSecretInfo!.id, tx);
await membershipIdentityDAL.update(
{ scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id },
{
lastLoginAuthMethod: IdentityAuthMethod.UNIVERSAL_AUTH,
lastLoginTime: new Date()
},
tx
);
const newToken = await identityAccessTokenDAL.create(
{ {
identityId: identityUa.identityId, identityId: identityUa.identityId,
isAccessTokenRevoked: false, clientSecretId: validClientSecretInfo.id,
identityUAClientSecretId: uaClientSecretDoc.id, identityAccessTokenId: identityAccessToken.id,
accessTokenNumUses: 0, authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN
accessTokenNumUsesLimit: identityUa.accessTokenNumUsesLimit, } as TIdentityAccessTokenJwtPayload,
accessTokenPeriod: identityUa.accessTokenPeriod, appCfg.AUTH_SECRET,
authMethod: IdentityAuthMethod.UNIVERSAL_AUTH, // akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error
...accessTokenTTLParams Number(identityAccessToken.accessTokenTTL) === 0
}, ? undefined
tx : {
expiresIn: Number(identityAccessToken.accessTokenTTL)
}
); );
return newToken; if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
}); authAttemptCounter.add(1, {
"infisical.identity.id": identityUa.identityId,
"infisical.identity.name": identity.name,
"infisical.organization.id": org.id,
"infisical.organization.name": org.name,
"infisical.identity.auth_method": AuthAttemptAuthMethod.UNIVERSAL_AUTH,
"infisical.identity.auth_result": AuthAttemptAuthResult.SUCCESS,
"client.address": requestContext.get("ip"),
"user_agent.original": requestContext.get("userAgent")
});
}
const appCfg = getConfig(); return {
const accessToken = crypto.jwt().sign( accessToken,
{ identityUa,
identityId: identityUa.identityId, validClientSecretInfo,
clientSecretId: validClientSecretInfo.id, identityAccessToken,
identityAccessTokenId: identityAccessToken.id, identity,
authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN ...accessTokenTTLParams
} as TIdentityAccessTokenJwtPayload, };
appCfg.AUTH_SECRET, } catch (error) {
// akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
Number(identityAccessToken.accessTokenTTL) === 0 authAttemptCounter.add(1, {
? undefined "infisical.identity.id": identityUa.identityId,
: { "infisical.identity.name": identity.name,
expiresIn: Number(identityAccessToken.accessTokenTTL) "infisical.organization.id": org.id,
} "infisical.organization.name": org.name,
); "infisical.identity.auth_method": AuthAttemptAuthMethod.UNIVERSAL_AUTH,
"infisical.identity.auth_result": AuthAttemptAuthResult.FAILURE,
return { "client.address": requestContext.get("ip"),
accessToken, "user_agent.original": requestContext.get("userAgent")
identityUa, });
validClientSecretInfo, }
identityAccessToken, throw error;
identity, }
...accessTokenTTLParams
};
}; };
const attachUniversalAuth = async ({ const attachUniversalAuth = async ({