From c35cebd26b87857787a8efe352cb29f09ec1d668 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Tue, 11 Nov 2025 22:24:41 +0800 Subject: [PATCH 1/3] mics: support no KID in header during OIDC login --- .../identity-oidc-auth-service.ts | 98 ++++++++++++++----- 1 file changed, 76 insertions(+), 22 deletions(-) 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 2464a2af6..e94b7f823 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 @@ -111,34 +111,88 @@ export const identityOidcAuthServiceFactory = ({ requestAgent: identityOidcAuth.oidcDiscoveryUrl.includes("https") ? requestAgent : undefined }); - const { kid } = decodedToken.header as { kid: string }; + const { kid } = decodedToken.header as { kid?: string }; - let oidcSigningKey; - try { - oidcSigningKey = await client.getSigningKey(kid); - } catch (error) { - if (error instanceof Error && error.name === "SigningKeyNotFoundError") { + let tokenData: Record | undefined; + + // If kid is provided, try to get the specific signing key + if (kid) { + let oidcSigningKey; + try { + oidcSigningKey = await client.getSigningKey(kid); + } catch (error) { + if (error instanceof Error && error.name === "SigningKeyNotFoundError") { + throw new UnauthorizedError({ + message: `Access denied: Unable to verify JWT signature. The signing key '${kid}' was not found in the OIDC provider's JWKS endpoint. This may indicate an invalid token or misconfigured OIDC provider.` + }); + } throw new UnauthorizedError({ - message: `Access denied: Unable to verify JWT signature. The signing key '${kid}' was not found in the OIDC provider's JWKS endpoint. This may indicate an invalid token or misconfigured OIDC provider.` + message: `Access denied: Failed to retrieve signing key from OIDC provider: ${error instanceof Error ? error.message : String(error)}` + }); + } + + try { + tokenData = crypto.jwt().verify(oidcJwt, oidcSigningKey.getPublicKey(), { + issuer: identityOidcAuth.boundIssuer + }) as Record; + } catch (error) { + if (error instanceof jwt.JsonWebTokenError) { + throw new UnauthorizedError({ + message: `Access denied: ${error.message}` + }); + } + throw error; + } + } else { + // If kid is not provided, try all available signing keys + let allSigningKeys; + try { + allSigningKeys = await client.getSigningKeys(); + } catch (error) { + throw new UnauthorizedError({ + message: `Access denied: Failed to retrieve signing keys from OIDC provider: ${error instanceof Error ? error.message : String(error)}` + }); + } + + if (!allSigningKeys || allSigningKeys.length === 0) { + throw new UnauthorizedError({ + message: "Access denied: No signing keys available from OIDC provider's JWKS endpoint." + }); + } + + let lastError: Error | null = null; + let verified = false; + + // Try each signing key until one works + for (const signingKey of allSigningKeys) { + try { + tokenData = crypto.jwt().verify(oidcJwt, signingKey.getPublicKey(), { + issuer: identityOidcAuth.boundIssuer + }) as Record; + verified = true; + break; + } catch (error) { + if (error instanceof jwt.JsonWebTokenError) { + lastError = error; + // Continue trying other keys + } else { + throw error; + } + } + } + + if (!verified) { + throw new UnauthorizedError({ + message: `Access denied: Unable to verify JWT signature with any available signing key. ${lastError ? lastError.message : "Invalid token"}` }); } - throw new UnauthorizedError({ - message: `Access denied: Failed to retrieve signing key from OIDC provider: ${error instanceof Error ? error.message : String(error)}` - }); } - let tokenData: Record; - try { - tokenData = crypto.jwt().verify(oidcJwt, oidcSigningKey.getPublicKey(), { - issuer: identityOidcAuth.boundIssuer - }) as Record; - } catch (error) { - if (error instanceof jwt.JsonWebTokenError) { - throw new UnauthorizedError({ - message: `Access denied: ${error.message}` - }); - } - throw error; + // Ensure tokenData was successfully assigned + if (!tokenData) { + throw new UnauthorizedError({ + message: "Access denied: Failed to verify JWT token" + }); } if (identityOidcAuth.boundSubject) { From fd743aca7276852b3e24cc974c0a17424a6e6194 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Tue, 11 Nov 2025 22:41:04 +0800 Subject: [PATCH 2/3] misc: added max keys to try limit --- .../identity-oidc-auth-service.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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 e94b7f823..7245278bf 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 @@ -23,6 +23,7 @@ import { UnauthorizedError } from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; +import { logger } from "@app/lib/logger"; import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics"; import { getValueByDot } from "@app/lib/template/dot-access"; @@ -145,6 +146,10 @@ export const identityOidcAuthServiceFactory = ({ } } else { // If kid is not provided, try all available signing keys + logger.warn( + `OIDC login without KID header [identityId=${identityOidcAuth.identityId}] [orgId=${org.id}] [ip=${requestContext.get("ip")}]` + ); + let allSigningKeys; try { allSigningKeys = await client.getSigningKeys(); @@ -160,6 +165,14 @@ export const identityOidcAuthServiceFactory = ({ }); } + // Limit the number of keys to try to prevent abuse + const MAX_KEYS_TO_TRY = 10; + if (allSigningKeys.length > MAX_KEYS_TO_TRY) { + throw new UnauthorizedError({ + message: `Access denied: OIDC provider has ${allSigningKeys.length} signing keys. Tokens must include 'kid' header when provider has more than ${MAX_KEYS_TO_TRY} keys.` + }); + } + let lastError: Error | null = null; let verified = false; From e2d5b13383b38e721dde2223141e6cb901da7c5c Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Wed, 12 Nov 2025 20:11:04 +0800 Subject: [PATCH 3/3] misc: addressed typescript issue --- .../identity-oidc-auth/identity-oidc-auth-service.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) 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 7245278bf..d9c0b71c9 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 @@ -208,8 +208,10 @@ export const identityOidcAuthServiceFactory = ({ }); } + const verifiedTokenData: Record = tokenData; + if (identityOidcAuth.boundSubject) { - if (!doesFieldValueMatchOidcPolicy(tokenData.sub, identityOidcAuth.boundSubject)) { + if (!doesFieldValueMatchOidcPolicy(verifiedTokenData.sub, identityOidcAuth.boundSubject)) { throw new ForbiddenRequestError({ message: "Access denied: OIDC subject not allowed." }); @@ -220,7 +222,7 @@ export const identityOidcAuthServiceFactory = ({ if ( !identityOidcAuth.boundAudiences .split(", ") - .some((policyValue) => doesAudValueMatchOidcPolicy(tokenData.aud, policyValue)) + .some((policyValue) => doesAudValueMatchOidcPolicy(verifiedTokenData.aud, policyValue)) ) { throw new UnauthorizedError({ message: "Access denied: OIDC audience not allowed." @@ -231,7 +233,7 @@ export const identityOidcAuthServiceFactory = ({ if (identityOidcAuth.boundClaims) { Object.keys(identityOidcAuth.boundClaims).forEach((claimKey) => { const claimValue = (identityOidcAuth.boundClaims as Record)[claimKey]; - const value = getValueByDot(tokenData, claimKey); + const value = getValueByDot(verifiedTokenData, claimKey); if (!value) { throw new UnauthorizedError({ @@ -252,7 +254,7 @@ export const identityOidcAuthServiceFactory = ({ if (identityOidcAuth.claimMetadataMapping) { Object.keys(identityOidcAuth.claimMetadataMapping).forEach((permissionKey) => { const claimKey = (identityOidcAuth.claimMetadataMapping as Record)[permissionKey]; - const value = getValueByDot(tokenData, claimKey); + const value = getValueByDot(verifiedTokenData, claimKey); if (!value) { throw new UnauthorizedError({ message: `Access denied: token has no ${claimKey} field` @@ -316,7 +318,7 @@ export const identityOidcAuthServiceFactory = ({ }); } - return { accessToken, identityOidcAuth, identityAccessToken, identity, oidcTokenData: tokenData }; + return { accessToken, identityOidcAuth, identityAccessToken, identity, oidcTokenData: verifiedTokenData }; } catch (error) { if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { authAttemptCounter.add(1, {