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(),
}),
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<T extends IRouterParamContext>(
params: z.object({
connectorId: z.string(),
}),
status: [200, 404, 403],
status: [200, 404, 403, 500],
response: z.object({
redirectTo: z.string(),
}),

View file

@ -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 } =

View file

@ -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) {

View file

@ -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(),
});