diff --git a/backend/src/@types/fastify-request-context.d.ts b/backend/src/@types/fastify-request-context.d.ts deleted file mode 100644 index fc8d94e07..000000000 --- a/backend/src/@types/fastify-request-context.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -import "@fastify/request-context"; - -declare module "@fastify/request-context" { - interface RequestContextData { - reqId: string; - } -} diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 85a19b676..99c7c4184 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -100,6 +100,12 @@ import { TWorkflowIntegrationServiceFactory } from "@app/services/workflow-integ declare module "@fastify/request-context" { interface RequestContextData { reqId: string; + identityAuthInfo?: { + identityId: string; + oidc?: { + claims: Record; + }; + }; } } diff --git a/backend/src/db/migrations/20250314145202_identity-oidc-claim-mapping.ts b/backend/src/db/migrations/20250314145202_identity-oidc-claim-mapping.ts new file mode 100644 index 000000000..482c5c4eb --- /dev/null +++ b/backend/src/db/migrations/20250314145202_identity-oidc-claim-mapping.ts @@ -0,0 +1,21 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasMappingField = await knex.schema.hasColumn(TableName.IdentityOidcAuth, "claimMetadataMapping"); + if (!hasMappingField) { + await knex.schema.alterTable(TableName.IdentityOidcAuth, (t) => { + t.jsonb("claimMetadataMapping"); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasMappingField = await knex.schema.hasColumn(TableName.IdentityOidcAuth, "claimMetadataMapping"); + if (hasMappingField) { + await knex.schema.alterTable(TableName.IdentityOidcAuth, (t) => { + t.dropColumn("claimMetadataMapping"); + }); + } +} diff --git a/backend/src/db/schemas/identity-oidc-auths.ts b/backend/src/db/schemas/identity-oidc-auths.ts index ebde5e7dc..03bfcf40a 100644 --- a/backend/src/db/schemas/identity-oidc-auths.ts +++ b/backend/src/db/schemas/identity-oidc-auths.ts @@ -26,7 +26,8 @@ export const IdentityOidcAuthsSchema = z.object({ boundSubject: z.string().nullable().optional(), createdAt: z.date(), updatedAt: z.date(), - encryptedCaCertificate: zodBuffer.nullable().optional() + encryptedCaCertificate: zodBuffer.nullable().optional(), + claimMetadataMapping: z.unknown().nullable().optional() }); export type TIdentityOidcAuths = z.infer; diff --git a/backend/src/db/schemas/secret-sharing.ts b/backend/src/db/schemas/secret-sharing.ts index 3406b4d63..24ea26677 100644 --- a/backend/src/db/schemas/secret-sharing.ts +++ b/backend/src/db/schemas/secret-sharing.ts @@ -12,7 +12,6 @@ import { TImmutableDBKeys } from "./models"; export const SecretSharingSchema = z.object({ id: z.string().uuid(), encryptedValue: z.string().nullable().optional(), - type: z.string(), iv: z.string().nullable().optional(), tag: z.string().nullable().optional(), hashedHex: z.string().nullable().optional(), @@ -27,7 +26,8 @@ export const SecretSharingSchema = z.object({ lastViewedAt: z.date().nullable().optional(), password: z.string().nullable().optional(), encryptedSecret: zodBuffer.nullable().optional(), - identifier: z.string().nullable().optional() + identifier: z.string().nullable().optional(), + type: z.string().default("share") }); export type TSecretSharing = z.infer; diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index a8f96bb73..9244e569e 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -978,6 +978,7 @@ interface AddIdentityOidcAuthEvent { boundIssuer: string; boundAudiences: string; boundClaims: Record; + claimMetadataMapping: Record; boundSubject: string; accessTokenTTL: number; accessTokenMaxTTL: number; @@ -1002,6 +1003,7 @@ interface UpdateIdentityOidcAuthEvent { boundIssuer?: string; boundAudiences?: string; boundClaims?: Record; + claimMetadataMapping?: Record; boundSubject?: string; accessTokenTTL?: number; accessTokenMaxTTL?: number; diff --git a/backend/src/ee/services/permission/permission-service.ts b/backend/src/ee/services/permission/permission-service.ts index ddaf55d9d..6b36234ca 100644 --- a/backend/src/ee/services/permission/permission-service.ts +++ b/backend/src/ee/services/permission/permission-service.ts @@ -1,5 +1,6 @@ import { createMongoAbility, MongoAbility, RawRuleOf } from "@casl/ability"; import { PackRule, unpackRules } from "@casl/ability/extra"; +import { requestContext } from "@fastify/request-context"; import { MongoQuery } from "@ucast/mongo2js"; import handlebars from "handlebars"; @@ -317,14 +318,17 @@ export const permissionServiceFactory = ({ const rules = buildProjectPermissionRules(rolePermissions.concat(additionalPrivileges)); const templatedRules = handlebars.compile(JSON.stringify(rules), { data: false }); - const metadataKeyValuePair = escapeHandlebarsMissingMetadata( - objectify( - identityProjectPermission.metadata, - (i) => i.key, - (i) => i.value - ) - ); - + const identityAuthInfo = requestContext.get("identityAuthInfo"); + const unescapedMetadata = objectify( + identityProjectPermission.metadata, + (i) => i.key, + (i) => i.value + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ) as Record; + if (identityAuthInfo?.identityId === identityId && identityAuthInfo) { + unescapedMetadata.auth = identityAuthInfo; + } + const metadataKeyValuePair = escapeHandlebarsMissingMetadata(unescapedMetadata); const interpolateRules = templatedRules( { identity: { diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index fe893d5ab..b1f55c4d3 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -329,6 +329,7 @@ export const OIDC_AUTH = { boundIssuer: "The unique identifier of the identity provider issuing the JWT.", boundAudiences: "The list of intended recipients.", boundClaims: "The attributes that should be present in the JWT for it to be valid.", + claimMetadataMapping: "The attributes that should be present in the permission metadata from the JWT.", boundSubject: "The expected principal that is the subject of the JWT.", accessTokenTrustedIps: "The IPs or CIDR ranges that access tokens can be used from.", accessTokenTTL: "The lifetime for an access token in seconds.", @@ -342,6 +343,7 @@ export const OIDC_AUTH = { boundIssuer: "The new unique identifier of the identity provider issuing the JWT.", boundAudiences: "The new list of intended recipients.", boundClaims: "The new attributes that should be present in the JWT for it to be valid.", + claimMetadataMapping: "The new attributes that should be present in the permission metadata from the JWT.", boundSubject: "The new expected principal that is the subject of the JWT.", accessTokenTrustedIps: "The new IPs or CIDR ranges that access tokens can be used from.", accessTokenTTL: "The new lifetime for an access token in seconds.", diff --git a/backend/src/lib/template/dot-access.ts b/backend/src/lib/template/dot-access.ts new file mode 100644 index 000000000..ec3208feb --- /dev/null +++ b/backend/src/lib/template/dot-access.ts @@ -0,0 +1,34 @@ +/** + * Safely retrieves a value from a nested object using dot notation path + */ +export const getStringValueByDot = ( + obj: Record | null | undefined, + path: string, + defaultValue?: string +): string | undefined => { + // Handle null or undefined input + if (!obj) { + return defaultValue; + } + + const parts = path.split("."); + let current: unknown = obj; + + for (const part of parts) { + const isObject = typeof current === "object" && !Array.isArray(current) && current !== null; + if (!isObject) { + return defaultValue; + } + if (!Object.hasOwn(current as object, part)) { + // Check if the property exists as an own property + return defaultValue; + } + current = (current as Record)[part]; + } + + if (typeof current !== "string") { + return defaultValue; + } + + return current; +}; diff --git a/backend/src/server/plugins/auth/inject-identity.ts b/backend/src/server/plugins/auth/inject-identity.ts index 9d239a405..9005fbf96 100644 --- a/backend/src/server/plugins/auth/inject-identity.ts +++ b/backend/src/server/plugins/auth/inject-identity.ts @@ -1,3 +1,4 @@ +import { requestContext } from "@fastify/request-context"; import { FastifyRequest } from "fastify"; import fp from "fastify-plugin"; import jwt, { JwtPayload } from "jsonwebtoken"; @@ -137,6 +138,10 @@ export const injectIdentity = fp(async (server: FastifyZodProvider) => { identityName: identity.name, authMethod: null }; + requestContext.set("identityAuthInfo", { + identityId: identity.identityId, + oidc: token?.identityAuth?.oidc + }); break; } case AuthMode.SERVICE_TOKEN: { diff --git a/backend/src/server/routes/v1/identity-oidc-auth-router.ts b/backend/src/server/routes/v1/identity-oidc-auth-router.ts index 7ce0b05b7..a27c12daf 100644 --- a/backend/src/server/routes/v1/identity-oidc-auth-router.ts +++ b/backend/src/server/routes/v1/identity-oidc-auth-router.ts @@ -23,6 +23,7 @@ const IdentityOidcAuthResponseSchema = IdentityOidcAuthsSchema.pick({ boundIssuer: true, boundAudiences: true, boundClaims: true, + claimMetadataMapping: true, boundSubject: true, createdAt: true, updatedAt: true @@ -104,6 +105,7 @@ export const registerIdentityOidcAuthRouter = async (server: FastifyZodProvider) boundIssuer: z.string().min(1).describe(OIDC_AUTH.ATTACH.boundIssuer), boundAudiences: validateOidcAuthAudiencesField.describe(OIDC_AUTH.ATTACH.boundAudiences), boundClaims: validateOidcBoundClaimsField.describe(OIDC_AUTH.ATTACH.boundClaims), + claimMetadataMapping: validateOidcBoundClaimsField.describe(OIDC_AUTH.ATTACH.claimMetadataMapping), boundSubject: z.string().optional().default("").describe(OIDC_AUTH.ATTACH.boundSubject), accessTokenTrustedIps: z .object({ @@ -161,6 +163,7 @@ export const registerIdentityOidcAuthRouter = async (server: FastifyZodProvider) boundIssuer: identityOidcAuth.boundIssuer, boundAudiences: identityOidcAuth.boundAudiences, boundClaims: identityOidcAuth.boundClaims as Record, + claimMetadataMapping: identityOidcAuth.claimMetadataMapping as Record, boundSubject: identityOidcAuth.boundSubject as string, accessTokenTTL: identityOidcAuth.accessTokenTTL, accessTokenMaxTTL: identityOidcAuth.accessTokenMaxTTL, @@ -200,6 +203,7 @@ export const registerIdentityOidcAuthRouter = async (server: FastifyZodProvider) boundIssuer: z.string().min(1).describe(OIDC_AUTH.UPDATE.boundIssuer), boundAudiences: validateOidcAuthAudiencesField.describe(OIDC_AUTH.UPDATE.boundAudiences), boundClaims: validateOidcBoundClaimsField.describe(OIDC_AUTH.UPDATE.boundClaims), + claimMetadataMapping: validateOidcBoundClaimsField.describe(OIDC_AUTH.UPDATE.claimMetadataMapping), boundSubject: z.string().optional().default("").describe(OIDC_AUTH.UPDATE.boundSubject), accessTokenTrustedIps: z .object({ @@ -258,6 +262,7 @@ export const registerIdentityOidcAuthRouter = async (server: FastifyZodProvider) boundIssuer: identityOidcAuth.boundIssuer, boundAudiences: identityOidcAuth.boundAudiences, boundClaims: identityOidcAuth.boundClaims as Record, + claimMetadataMapping: identityOidcAuth.claimMetadataMapping as Record, boundSubject: identityOidcAuth.boundSubject as string, accessTokenTTL: identityOidcAuth.accessTokenTTL, accessTokenMaxTTL: identityOidcAuth.accessTokenMaxTTL, diff --git a/backend/src/services/identity-access-token/identity-access-token-types.ts b/backend/src/services/identity-access-token/identity-access-token-types.ts index 86967df76..c97d2f40a 100644 --- a/backend/src/services/identity-access-token/identity-access-token-types.ts +++ b/backend/src/services/identity-access-token/identity-access-token-types.ts @@ -7,4 +7,9 @@ export type TIdentityAccessTokenJwtPayload = { clientSecretId: string; identityAccessTokenId: string; authTokenType: string; + identityAuth: { + oidc?: { + claims: Record; + }; + }; }; diff --git a/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts b/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts index 555dc00a6..9844b337f 100644 --- a/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts +++ b/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts @@ -11,6 +11,7 @@ import { validatePermissionBoundary } from "@app/lib/casl/boundary"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; +import { getStringValueByDot } from "@app/lib/template/dot-access"; import { ActorType, AuthTokenType } from "../auth/auth-type"; import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; @@ -177,8 +178,9 @@ export const identityJwtAuthServiceFactory = ({ if (identityJwtAuth.boundClaims) { Object.keys(identityJwtAuth.boundClaims).forEach((claimKey) => { const claimValue = (identityJwtAuth.boundClaims as Record)[claimKey]; + const value = getStringValueByDot(tokenData, claimKey) || ""; - if (!tokenData[claimKey]) { + if (!value) { throw new UnauthorizedError({ message: `Access denied: token has no ${claimKey} field` }); diff --git a/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts b/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts index 3c947f504..cb641af4d 100644 --- a/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts +++ b/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts @@ -12,6 +12,7 @@ import { validatePermissionBoundary } from "@app/lib/casl/boundary"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; +import { getStringValueByDot } from "@app/lib/template/dot-access"; import { ActorType, AuthTokenType } from "../auth/auth-type"; import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; @@ -77,7 +78,7 @@ export const identityOidcAuthServiceFactory = ({ const { data: discoveryDoc } = await axios.get<{ jwks_uri: string }>( `${identityOidcAuth.oidcDiscoveryUrl}/.well-known/openid-configuration`, { - httpsAgent: requestAgent + httpsAgent: identityOidcAuth.oidcDiscoveryUrl.includes("https") ? requestAgent : undefined } ); const jwksUri = discoveryDoc.jwks_uri; @@ -91,7 +92,7 @@ export const identityOidcAuthServiceFactory = ({ const client = new JwksClient({ jwksUri, - requestAgent + requestAgent: identityOidcAuth.oidcDiscoveryUrl.includes("https") ? requestAgent : undefined }); const { kid } = decodedToken.header; @@ -108,7 +109,6 @@ export const identityOidcAuthServiceFactory = ({ message: `Access denied: ${error.message}` }); } - throw error; } @@ -135,10 +135,16 @@ export const identityOidcAuthServiceFactory = ({ if (identityOidcAuth.boundClaims) { Object.keys(identityOidcAuth.boundClaims).forEach((claimKey) => { const claimValue = (identityOidcAuth.boundClaims as Record)[claimKey]; + const value = getStringValueByDot(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(tokenData[claimKey], claimEntry)) - ) { + if (!claimValue.split(", ").some((claimEntry) => doesFieldValueMatchOidcPolicy(value, claimEntry))) { throw new UnauthorizedError({ message: "Access denied: OIDC claim not allowed." }); @@ -146,6 +152,20 @@ export const identityOidcAuthServiceFactory = ({ }); } + const filteredClaims: Record = {}; + if (identityOidcAuth.claimMetadataMapping) { + Object.keys(identityOidcAuth.claimMetadataMapping).forEach((permissionKey) => { + const claimKey = (identityOidcAuth.claimMetadataMapping as Record)[permissionKey]; + const value = getStringValueByDot(tokenData, claimKey) || ""; + if (!value) { + throw new UnauthorizedError({ + message: `Access denied: token has no ${claimKey} field` + }); + } + filteredClaims[permissionKey] = value; + }); + } + const identityAccessToken = await identityOidcAuthDAL.transaction(async (tx) => { const newToken = await identityAccessTokenDAL.create( { @@ -167,7 +187,12 @@ export const identityOidcAuthServiceFactory = ({ { identityId: identityOidcAuth.identityId, identityAccessTokenId: identityAccessToken.id, - authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN + 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 @@ -188,6 +213,7 @@ export const identityOidcAuthServiceFactory = ({ boundIssuer, boundAudiences, boundClaims, + claimMetadataMapping, boundSubject, accessTokenTTL, accessTokenMaxTTL, @@ -254,6 +280,7 @@ export const identityOidcAuthServiceFactory = ({ boundIssuer, boundAudiences, boundClaims, + claimMetadataMapping, boundSubject, accessTokenMaxTTL, accessTokenTTL, @@ -274,6 +301,7 @@ export const identityOidcAuthServiceFactory = ({ boundIssuer, boundAudiences, boundClaims, + claimMetadataMapping, boundSubject, accessTokenTTL, accessTokenMaxTTL, @@ -335,6 +363,7 @@ export const identityOidcAuthServiceFactory = ({ boundIssuer, boundAudiences, boundClaims, + claimMetadataMapping, boundSubject, accessTokenMaxTTL, accessTokenTTL, diff --git a/backend/src/services/identity-oidc-auth/identity-oidc-auth-types.ts b/backend/src/services/identity-oidc-auth/identity-oidc-auth-types.ts index 761f68aa7..7f9f62296 100644 --- a/backend/src/services/identity-oidc-auth/identity-oidc-auth-types.ts +++ b/backend/src/services/identity-oidc-auth/identity-oidc-auth-types.ts @@ -7,6 +7,7 @@ export type TAttachOidcAuthDTO = { boundIssuer: string; boundAudiences: string; boundClaims: Record; + claimMetadataMapping: Record; boundSubject: string; accessTokenTTL: number; accessTokenMaxTTL: number; @@ -21,6 +22,7 @@ export type TUpdateOidcAuthDTO = { boundIssuer?: string; boundAudiences?: string; boundClaims?: Record; + claimMetadataMapping?: Record; boundSubject?: string; accessTokenTTL?: number; accessTokenMaxTTL?: number; diff --git a/backend/src/services/identity/identity-dal.ts b/backend/src/services/identity/identity-dal.ts index 8b7fccab3..c4d0b6307 100644 --- a/backend/src/services/identity/identity-dal.ts +++ b/backend/src/services/identity/identity-dal.ts @@ -1,7 +1,7 @@ import { TDbClient } from "@app/db"; import { TableName, TIdentities } from "@app/db/schemas"; -import { ormify, selectAllTableCols } from "@app/lib/knex"; import { DatabaseError } from "@app/lib/errors"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; export type TIdentityDALFactory = ReturnType; diff --git a/backend/src/services/super-admin/super-admin-service.ts b/backend/src/services/super-admin/super-admin-service.ts index bf10d67ab..1f343b75f 100644 --- a/backend/src/services/super-admin/super-admin-service.ts +++ b/backend/src/services/super-admin/super-admin-service.ts @@ -7,6 +7,7 @@ import { getConfig } from "@app/lib/config/env"; import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; import { getUserPrivateKey } from "@app/lib/crypto/srp"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { TIdentityDALFactory } from "@app/services/identity/identity-dal"; import { TAuthLoginFactory } from "../auth/auth-login-service"; import { AuthMethod } from "../auth/auth-type"; @@ -20,7 +21,6 @@ import { TUserAliasDALFactory } from "../user-alias/user-alias-dal"; import { UserAliasType } from "../user-alias/user-alias-types"; import { TSuperAdminDALFactory } from "./super-admin-dal"; import { LoginMethod, TAdminGetIdentitiesDTO, TAdminGetUsersDTO, TAdminSignUpDTO } from "./super-admin-types"; -import { TIdentityDALFactory } from "@app/services/identity/identity-dal"; type TSuperAdminServiceFactoryDep = { identityDAL: Pick;