diff --git a/.changeset/serious-geese-admire.md b/.changeset/serious-geese-admire.md new file mode 100644 index 000000000..68ba7b137 --- /dev/null +++ b/.changeset/serious-geese-admire.md @@ -0,0 +1,12 @@ +--- +"@logto/core": patch +--- + +fix Microsoft EntraID OIDC SSO connector invalid authorization code response bug + +- For public organizations access EntraID OIDC applications, the token endpoint returns `expires_in` value type in number. +- For private organization access only applications, the token endpoint returns `expires_in` value type in string. +- Expected `expires_in` value type is number. (See [v2-oauth2-auth-code-flow](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow#successful-response-2) for reference) + +String type `expires_in` value is not supported by the current Microsoft EntraID OIDC connector, a invalid authorization response error will be thrown. +Update the token response guard to handle both number and string type `expires_in` value. Make the SSO connector more robust. diff --git a/packages/core/src/routes/interaction/single-sign-on.ts b/packages/core/src/routes/interaction/single-sign-on.ts index 65e428c9c..0a6b3c04f 100644 --- a/packages/core/src/routes/interaction/single-sign-on.ts +++ b/packages/core/src/routes/interaction/single-sign-on.ts @@ -79,7 +79,7 @@ export default function singleSignOnRoutes( connectorId: z.string(), }), body: z.record(z.unknown()), - status: [200, 404, 422], + status: [200, 404, 422, 500], response: z.object({ redirectTo: z.string(), }), @@ -125,7 +125,7 @@ export default function singleSignOnRoutes( params: z.object({ connectorId: z.string(), }), - status: [200, 404, 403], + status: [200, 404, 403, 500], response: z.object({ redirectTo: z.string(), }), diff --git a/packages/core/src/sso/AzureOidcSsoConnector/index.ts b/packages/core/src/sso/AzureOidcSsoConnector/index.ts index be11e75e8..3255f5fdd 100644 --- a/packages/core/src/sso/AzureOidcSsoConnector/index.ts +++ b/packages/core/src/sso/AzureOidcSsoConnector/index.ts @@ -1,6 +1,7 @@ import { SsoProviderName, SsoProviderType } from '@logto/schemas'; import { conditional } from '@silverhand/essentials'; import camelcaseKeys from 'camelcase-keys'; +import { decodeJwt } from 'jose'; import assertThat from '#src/utils/assert-that.js'; @@ -26,6 +27,7 @@ export class AzureOidcSsoConnector extends OidcSsoConnector { * It is unsafe to trust the unverified email and phone number in Logto's context. As we are using the verified email and phone number to identify the user. * Store extra unverified_email and unverified_phone fields in the user SSO identity profile instead. */ + // eslint-disable-next-line complexity override async getUserInfo( connectorSession: SingleSignOnConnectorSession, data: unknown @@ -43,8 +45,19 @@ export class AzureOidcSsoConnector extends OidcSsoConnector { }) ); + // Need to decode the id token to get the tenant id + const decodeToken = decodeJwt(idToken); + + // For multi-tenancy Azure application, the issuer may contain the tenant id placeholder + // Replace the placeholder with the tid retrieved from the id token + // @see https://learn.microsoft.com/en-us/entra/identity-platform/access-tokens#validation-of-the-signing-key-issuer + const jwtVerifyOptions = + oidcConfig.issuer.includes('{tenantid}') && typeof decodeToken.tid === 'string' + ? { issuer: oidcConfig.issuer.replace('{tenantid}', decodeToken.tid) } + : {}; + // Verify the id token and get the user id - const { sub: id } = await getIdTokenClaims(idToken, oidcConfig, nonce); + const { sub: id } = await getIdTokenClaims(idToken, oidcConfig, nonce, jwtVerifyOptions); // Fetch user info from the userinfo endpoint const { sub, name, picture, email, email_verified, phone, phone_verified, ...rest } = diff --git a/packages/core/src/sso/OidcConnector/utils.ts b/packages/core/src/sso/OidcConnector/utils.ts index e72aea329..0ec57ade9 100644 --- a/packages/core/src/sso/OidcConnector/utils.ts +++ b/packages/core/src/sso/OidcConnector/utils.ts @@ -2,22 +2,22 @@ import { parseJson } from '@logto/connector-kit'; import { assert } from '@silverhand/essentials'; import camelcaseKeys, { type CamelCaseKeys } from 'camelcase-keys'; import { got, HTTPError } from 'got'; -import { jwtVerify, createRemoteJWKSet } from 'jose'; +import { createRemoteJWKSet, jwtVerify, type JWTVerifyOptions } from 'jose'; import { z } from 'zod'; import { + SsoConnectorConfigErrorCodes, SsoConnectorError, SsoConnectorErrorCodes, - SsoConnectorConfigErrorCodes, } from '../types/error.js'; import { + idTokenProfileStandardClaimsGuard, + oidcAuthorizationResponseGuard, + oidcConfigResponseGuard, + oidcTokenResponseGuard, type BaseOidcConfig, type OidcConfigResponse, - oidcConfigResponseGuard, - oidcAuthorizationResponseGuard, - oidcTokenResponseGuard, type OidcTokenResponse, - idTokenProfileStandardClaimsGuard, } from '../types/oidc.js'; export const fetchOidcConfig = async ( @@ -109,12 +109,15 @@ const issuedAtTimeTolerance = 600; // 10 minutes export const getIdTokenClaims = async ( idToken: string, config: BaseOidcConfig, - nonceFromSession?: string + nonceFromSession?: string, + // Allow to pass custom options for jwt.verify + jwtVerifyOptions?: JWTVerifyOptions ) => { try { const { payload } = await jwtVerify(idToken, createRemoteJWKSet(new URL(config.jwksUri)), { issuer: config.issuer, audience: config.clientId, + ...jwtVerifyOptions, }); if (Math.abs((payload.iat ?? 0) - Date.now() / 1000) > issuedAtTimeTolerance) { diff --git a/packages/core/src/sso/types/oidc.ts b/packages/core/src/sso/types/oidc.ts index 6aa9ee358..3125ea020 100644 --- a/packages/core/src/sso/types/oidc.ts +++ b/packages/core/src/sso/types/oidc.ts @@ -57,7 +57,8 @@ export const oidcTokenResponseGuard = z.object({ id_token: z.string(), access_token: z.string().optional(), token_type: z.string().optional(), - expires_in: z.number().optional(), + // Microsoft EntraID may return string type for expires_in + expires_in: z.number().or(z.string()).optional(), refresh_token: z.string().optional(), scope: z.string().optional(), });