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:
parent
94b688355c
commit
458746c9ac
5 changed files with 40 additions and 11 deletions
12
.changeset/serious-geese-admire.md
Normal file
12
.changeset/serious-geese-admire.md
Normal 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.
|
|
@ -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(),
|
||||||
}),
|
}),
|
||||||
|
|
|
@ -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 } =
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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(),
|
||||||
});
|
});
|
||||||
|
|
Loading…
Add table
Reference in a new issue