0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-02-17 22:04:19 -05:00

fix(core): fix azure oidc sso connector authorization error (#5912)

* fix(core): fix azure oidc sso connector authorization error

fix azure oidc sso connector authorization error

* chore: add changeset

add changeset

* chore: update changeset

update changeset

* fix(core): dynamicly verify multi-tenant azure oidc issuer

dynamicly verify multi-tenant azure oidc issuer
This commit is contained in:
simeng-li 2024-05-29 13:30:56 +08:00 committed by GitHub
parent 94b688355c
commit 458746c9ac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 40 additions and 11 deletions

View file

@ -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.

View file

@ -79,7 +79,7 @@ export default function singleSignOnRoutes<T extends IRouterParamContext>(
connectorId: z.string(), connectorId: z.string(),
}), }),
body: z.record(z.unknown()), body: z.record(z.unknown()),
status: [200, 404, 422], status: [200, 404, 422, 500],
response: z.object({ response: z.object({
redirectTo: z.string(), redirectTo: z.string(),
}), }),
@ -125,7 +125,7 @@ export default function singleSignOnRoutes<T extends IRouterParamContext>(
params: z.object({ params: z.object({
connectorId: z.string(), connectorId: z.string(),
}), }),
status: [200, 404, 403], status: [200, 404, 403, 500],
response: z.object({ response: z.object({
redirectTo: z.string(), redirectTo: z.string(),
}), }),

View file

@ -1,6 +1,7 @@
import { SsoProviderName, SsoProviderType } from '@logto/schemas'; import { SsoProviderName, SsoProviderType } from '@logto/schemas';
import { conditional } from '@silverhand/essentials'; import { conditional } from '@silverhand/essentials';
import camelcaseKeys from 'camelcase-keys'; import camelcaseKeys from 'camelcase-keys';
import { decodeJwt } from 'jose';
import assertThat from '#src/utils/assert-that.js'; 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. * 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. * Store extra unverified_email and unverified_phone fields in the user SSO identity profile instead.
*/ */
// eslint-disable-next-line complexity
override async getUserInfo( override async getUserInfo(
connectorSession: SingleSignOnConnectorSession, connectorSession: SingleSignOnConnectorSession,
data: unknown 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 // 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 // Fetch user info from the userinfo endpoint
const { sub, name, picture, email, email_verified, phone, phone_verified, ...rest } = const { sub, name, picture, email, email_verified, phone, phone_verified, ...rest } =

View file

@ -2,22 +2,22 @@ import { parseJson } from '@logto/connector-kit';
import { assert } from '@silverhand/essentials'; import { assert } from '@silverhand/essentials';
import camelcaseKeys, { type CamelCaseKeys } from 'camelcase-keys'; import camelcaseKeys, { type CamelCaseKeys } from 'camelcase-keys';
import { got, HTTPError } from 'got'; import { got, HTTPError } from 'got';
import { jwtVerify, createRemoteJWKSet } from 'jose'; import { createRemoteJWKSet, jwtVerify, type JWTVerifyOptions } from 'jose';
import { z } from 'zod'; import { z } from 'zod';
import { import {
SsoConnectorConfigErrorCodes,
SsoConnectorError, SsoConnectorError,
SsoConnectorErrorCodes, SsoConnectorErrorCodes,
SsoConnectorConfigErrorCodes,
} from '../types/error.js'; } from '../types/error.js';
import { import {
idTokenProfileStandardClaimsGuard,
oidcAuthorizationResponseGuard,
oidcConfigResponseGuard,
oidcTokenResponseGuard,
type BaseOidcConfig, type BaseOidcConfig,
type OidcConfigResponse, type OidcConfigResponse,
oidcConfigResponseGuard,
oidcAuthorizationResponseGuard,
oidcTokenResponseGuard,
type OidcTokenResponse, type OidcTokenResponse,
idTokenProfileStandardClaimsGuard,
} from '../types/oidc.js'; } from '../types/oidc.js';
export const fetchOidcConfig = async ( export const fetchOidcConfig = async (
@ -109,12 +109,15 @@ const issuedAtTimeTolerance = 600; // 10 minutes
export const getIdTokenClaims = async ( export const getIdTokenClaims = async (
idToken: string, idToken: string,
config: BaseOidcConfig, config: BaseOidcConfig,
nonceFromSession?: string nonceFromSession?: string,
// Allow to pass custom options for jwt.verify
jwtVerifyOptions?: JWTVerifyOptions
) => { ) => {
try { try {
const { payload } = await jwtVerify(idToken, createRemoteJWKSet(new URL(config.jwksUri)), { const { payload } = await jwtVerify(idToken, createRemoteJWKSet(new URL(config.jwksUri)), {
issuer: config.issuer, issuer: config.issuer,
audience: config.clientId, audience: config.clientId,
...jwtVerifyOptions,
}); });
if (Math.abs((payload.iat ?? 0) - Date.now() / 1000) > issuedAtTimeTolerance) { if (Math.abs((payload.iat ?? 0) - Date.now() / 1000) > issuedAtTimeTolerance) {

View file

@ -57,7 +57,8 @@ export const oidcTokenResponseGuard = z.object({
id_token: z.string(), id_token: z.string(),
access_token: z.string().optional(), access_token: z.string().optional(),
token_type: 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(), refresh_token: z.string().optional(),
scope: z.string().optional(), scope: z.string().optional(),
}); });