From 6284819d6562177f7e2c2b99e0144277d5a23952 Mon Sep 17 00:00:00 2001 From: Mohamed Hegazy Date: Fri, 24 Apr 2026 09:57:49 -0700 Subject: [PATCH 1/2] fix(client/auth): merge consent into existing prompt param instead of duplicating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the AS metadata's authorization_endpoint already includes a prompt query parameter (permitted by RFC 6749 §3.1, which says clients MUST retain it), startAuthorization's offline_access handling appended a second prompt=consent. RFC 6749 §3.1 also states parameters MUST NOT be included more than once; Azure AD enforces this and rejects the request with AADSTS9000411. OIDC Core §3.1.2.1 defines prompt as a space-delimited list, so merge consent into the existing value as a single parameter. Ref: anthropics/claude-code#31089 --- packages/client/src/client/auth.ts | 9 ++++++++- packages/client/test/client/auth.test.ts | 17 +++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 5f55fb7a0..831f40b30 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -1420,7 +1420,14 @@ export async function startAuthorization( // if the request includes the OIDC-only "offline_access" scope, // we need to set the prompt to "consent" to ensure the user is prompted to grant offline access // https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess - authorizationUrl.searchParams.append('prompt', 'consent'); + // RFC 6749 §3.1: the authorization endpoint URI MAY include a query component which MUST be + // retained, and parameters MUST NOT be included more than once. OIDC Core §3.1.2.1 defines + // `prompt` as space-delimited, so merge into any existing value rather than appending a duplicate. + const existingPrompt = authorizationUrl.searchParams.get('prompt'); + authorizationUrl.searchParams.set( + 'prompt', + existingPrompt ? [...new Set([...existingPrompt.split(' '), 'consent'])].join(' ') : 'consent' + ); } if (resource) { diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 04d7f4a3f..8b8a395f7 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -1557,6 +1557,23 @@ describe('OAuth Authorization', () => { expect(authorizationUrl.searchParams.get('prompt')).toBe('consent'); }); + it('merges consent into existing prompt parameter from authorization_endpoint instead of duplicating', async () => { + const { authorizationUrl } = await startAuthorization('https://auth.example.com', { + metadata: { + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize?prompt=select_account', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }, + clientInformation: validClientInfo, + redirectUrl: 'http://localhost:3000/callback', + scope: 'read offline_access' + }); + + expect(authorizationUrl.searchParams.getAll('prompt')).toEqual(['select_account consent']); + }); + it.each([validMetadata, validOpenIdMetadata])('uses metadata authorization_endpoint when provided', async baseMetadata => { const { authorizationUrl } = await startAuthorization('https://auth.example.com', { metadata: baseMetadata, From 7d8ecd38b66d7c2c5f296dd072eec1fcd2e5c244 Mon Sep 17 00:00:00 2001 From: Mohamed Hegazy Date: Fri, 24 Apr 2026 11:30:25 -0700 Subject: [PATCH 2/2] =?UTF-8?q?use=20.set()=20not=20space-delimited=20merg?= =?UTF-8?q?e=20=E2=80=94=20Azure=20rejects=20multi-value=20prompt=20(AADST?= =?UTF-8?q?S90023)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/client/src/client/auth.ts | 13 +++++-------- packages/client/test/client/auth.test.ts | 4 ++-- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 831f40b30..5c26490fb 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -1420,14 +1420,11 @@ export async function startAuthorization( // if the request includes the OIDC-only "offline_access" scope, // we need to set the prompt to "consent" to ensure the user is prompted to grant offline access // https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess - // RFC 6749 §3.1: the authorization endpoint URI MAY include a query component which MUST be - // retained, and parameters MUST NOT be included more than once. OIDC Core §3.1.2.1 defines - // `prompt` as space-delimited, so merge into any existing value rather than appending a duplicate. - const existingPrompt = authorizationUrl.searchParams.get('prompt'); - authorizationUrl.searchParams.set( - 'prompt', - existingPrompt ? [...new Set([...existingPrompt.split(' '), 'consent'])].join(' ') : 'consent' - ); + // Use .set() not .append(): RFC 6749 §3.1 forbids duplicate params, and the + // authorization_endpoint may already include a prompt value. Azure AD also rejects the + // OIDC space-delimited list form (AADSTS90023), so a single value is the only portable + // option; consent is required for offline_access per OIDC §11. + authorizationUrl.searchParams.set('prompt', 'consent'); } if (resource) { diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 8b8a395f7..80d28b057 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -1557,7 +1557,7 @@ describe('OAuth Authorization', () => { expect(authorizationUrl.searchParams.get('prompt')).toBe('consent'); }); - it('merges consent into existing prompt parameter from authorization_endpoint instead of duplicating', async () => { + it('overwrites existing prompt parameter from authorization_endpoint instead of duplicating', async () => { const { authorizationUrl } = await startAuthorization('https://auth.example.com', { metadata: { issuer: 'https://auth.example.com', @@ -1571,7 +1571,7 @@ describe('OAuth Authorization', () => { scope: 'read offline_access' }); - expect(authorizationUrl.searchParams.getAll('prompt')).toEqual(['select_account consent']); + expect(authorizationUrl.searchParams.getAll('prompt')).toEqual(['consent']); }); it.each([validMetadata, validOpenIdMetadata])('uses metadata authorization_endpoint when provided', async baseMetadata => {