diff --git a/.changeset/id-token-claim-authority.md b/.changeset/id-token-claim-authority.md index 5dc0024516..76769950ab 100644 --- a/.changeset/id-token-claim-authority.md +++ b/.changeset/id-token-claim-authority.md @@ -5,7 +5,7 @@ ID tokens now use `acr: "0"`, indicating that authentication did not meet ISO/IEC 29115 level 1, and OpenID discovery advertises only `"0"`. Because `acr_values` is voluntary, requests for other classes continue instead of -failing. Essential `claims.id_token.acr` requests still fail when their -required `value` or `values` cannot be met. +failing. Essential `claims.id_token.acr` requests in OpenID Connect flows still +fail when their required `value` or `values` cannot be met. `customIdTokenClaims`, extension ID-token claims, and per-issuance `idTokenClaims` can no longer set OIDC/JWT protocol claims such as issuer, subject, audience, token lifetime, nonce, session or hash binding, `auth_time`, `acr`, `amr`, or `azp`. Namespaced custom claims still appear in ID tokens. diff --git a/docs/content/docs/plugins/oauth-provider.mdx b/docs/content/docs/plugins/oauth-provider.mdx index f46fb2a300..c61febabc2 100644 --- a/docs/content/docs/plugins/oauth-provider.mdx +++ b/docs/content/docs/plugins/oauth-provider.mdx @@ -1721,7 +1721,7 @@ The metadata endpoint can be customized so that the publicized scopes and claims All scopes inside the advertisedMetadata section MUST be listed in `scopes` otherwise initialization will fail. -Better Auth advertises `acr_values_supported: ["0"]`. In OIDC Core, `"0"` means the authentication did not meet ISO/IEC 29115 level 1. Custom ACR policies are not currently supported. Because `acr_values` is voluntary, requests for other classes continue and the ID token reports `acr: "0"`. An essential `claims.id_token.acr` request fails when its `value` or `values` does not include `"0"`. +Better Auth advertises `acr_values_supported: ["0"]`. In OIDC Core, `"0"` means the authentication did not meet ISO/IEC 29115 level 1. Custom ACR policies are not currently supported. Because `acr_values` is voluntary, requests for other classes continue and the ID token reports `acr: "0"`. In an OpenID Connect request, an essential `claims.id_token.acr` request fails when its `value` or `values` does not include `"0"`. #### Scopes diff --git a/packages/oauth-provider/src/authorize.test.ts b/packages/oauth-provider/src/authorize.test.ts index ed2bbd6681..40cf755a1c 100644 --- a/packages/oauth-provider/src/authorize.test.ts +++ b/packages/oauth-provider/src/authorize.test.ts @@ -538,13 +538,17 @@ describe("oauth authorize - acr_values (OIDC Core 1.0 §3.1.2.1)", async () => { }); }); - function authorizeUrl(acrValues: string, acrClaim?: AcrClaimRequest) { + function authorizeUrl( + acrValues: string, + acrClaim?: AcrClaimRequest, + scope = "openid", + ) { if (!oauthClient?.client_id) throw new Error("beforeAll not run properly"); const url = new URL(`${authServerBaseUrl}/api/auth/oauth2/authorize`); url.searchParams.set("client_id", oauthClient.client_id); url.searchParams.set("redirect_uri", redirectUri); url.searchParams.set("response_type", "code"); - url.searchParams.set("scope", "openid"); + url.searchParams.set("scope", scope); url.searchParams.set("state", "acr-state"); url.searchParams.set("code_challenge", generateRandomString(43)); url.searchParams.set("code_challenge_method", "S256"); @@ -558,9 +562,13 @@ describe("oauth authorize - acr_values (OIDC Core 1.0 §3.1.2.1)", async () => { return url.toString(); } - async function redirectFor(acrValues: string, acrClaim?: AcrClaimRequest) { + async function redirectFor( + acrValues: string, + acrClaim?: AcrClaimRequest, + scope?: string, + ) { let location = ""; - await authenticatedClient.$fetch(authorizeUrl(acrValues, acrClaim), { + await authenticatedClient.$fetch(authorizeUrl(acrValues, acrClaim, scope), { onError(context) { location = context.response.headers.get("Location") || ""; }, @@ -594,6 +602,20 @@ describe("oauth authorize - acr_values (OIDC Core 1.0 §3.1.2.1)", async () => { expect(callbackRedirect.searchParams.get("code")).toBeNull(); }); + it("ignores essential ID Token ACR claims in OAuth-only requests", async () => { + const location = await redirectFor( + "1", + { essential: true, value: "1" }, + "profile", + ); + const callbackRedirect = new URL(location); + + expect(callbackRedirect.searchParams.get("error")).toBeNull(); + expect(callbackRedirect.searchParams.get("code")).toEqual( + expect.any(String), + ); + }); + it("accepts the current ACR in an essential values request", async () => { const location = await redirectFor("1", { essential: true, diff --git a/packages/oauth-provider/src/authorize.ts b/packages/oauth-provider/src/authorize.ts index aecefd9a3e..fc3cf34944 100644 --- a/packages/oauth-provider/src/authorize.ts +++ b/packages/oauth-provider/src/authorize.ts @@ -515,19 +515,6 @@ export async function authorizeEndpoint( getErrorURL(ctx, "invalid_redirect", "invalid redirect uri"), ); } - if (!canSatisfyEssentialAcrRequest(query.claims, LEVEL_0_ACR)) { - return handleRedirect( - ctx, - formatErrorURL( - query.redirect_uri, - "access_denied", - "essential acr requirement cannot be met", - query.state, - getIssuer(ctx, opts), - ), - ); - } - // Check for invalid scopes if requested from query let requestedScopes = query.scope?.split(" ").filter((s) => s); if (requestedScopes) { @@ -553,6 +540,21 @@ export async function authorizeEndpoint( requestedScopes = client.scopes ?? opts.scopes ?? []; query.scope = requestedScopes.join(" "); } + if ( + requestedScopes.includes("openid") && + !canSatisfyEssentialAcrRequest(query.claims, LEVEL_0_ACR) + ) { + return handleRedirect( + ctx, + formatErrorURL( + query.redirect_uri, + "access_denied", + "essential acr requirement cannot be met", + query.state, + getIssuer(ctx, opts), + ), + ); + } const requestedUserInfoClaims = getRequestedUserInfoClaims( query.claims, getSupportedClaims(opts),