From 4b5d6ad4b9fad72de091473a223d956b239c8673 Mon Sep 17 00:00:00 2001 From: Ryan Bas Date: Wed, 5 Aug 2026 16:27:40 -0600 Subject: [PATCH 1/4] fix(oidc-client): always pass prompt=none on background authorize calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Background authorize flows (both standard and PAR) must include prompt=none so the authorization server does not prompt the user for interaction. The standard flow already enforced this inside createAuthorizeUrlµ; the PAR flow was not injecting it, meaning any background PAR call without an explicit prompt option would silently omit the required parameter. --- .../oidc-client/src/lib/client.store.test.ts | 98 ++++++++++++++++++- packages/oidc-client/src/lib/client.store.ts | 4 +- 2 files changed, 100 insertions(+), 2 deletions(-) diff --git a/packages/oidc-client/src/lib/client.store.test.ts b/packages/oidc-client/src/lib/client.store.test.ts index 57bde90332..f0c282a7b8 100644 --- a/packages/oidc-client/src/lib/client.store.test.ts +++ b/packages/oidc-client/src/lib/client.store.test.ts @@ -1,5 +1,5 @@ /* - * Copyright © 2025 - 2026 Ping Identity Corporation. All rights reserved. + * Copyright (c) 2025 - 2026 Ping Identity Corporation. All rights reserved. * * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. @@ -651,6 +651,14 @@ describe('authorize.background() with PAR enabled', async () => { expect.fail('Expected client, got error'); } + let capturedParBody = ''; + server.use( + http.post('*/as/par', async ({ request }) => { + capturedParBody = await request.text(); + return HttpResponse.json({ request_uri: parRequestUri, expires_in: 60 }, { status: 201 }); + }), + ); + const response = await result.authorize.background({ clientId: configWithPar.clientId, redirectUri: configWithPar.redirectUri, @@ -663,11 +671,99 @@ describe('authorize.background() with PAR enabled', async () => { expect.fail(`Expected success, got error: ${JSON.stringify(response)}`); } + expect(new URLSearchParams(capturedParBody).get('prompt')).toBe('none'); expect(response.code).toBeDefined(); expect(response.state).toBeDefined(); }); }); +describe('authorize.background() prompt=none enforcement', async () => { + beforeEach(() => { + customStorage.remove(storageKey); + }); + + it('background() always includes prompt=none even when options omit it', async () => { + const baseConfig: OidcConfig = { + clientId: '123456789', + redirectUri: 'https://example.com/callback.html', + scope: 'openid profile', + serverConfig: { wellknown: 'https://api.example.com/wellknown' }, + responseType: 'code', + }; + + // PAR enabled: prompt=none must appear in the PAR request body + let capturedParBodyText = ''; + server.use( + http.post('*/as/par', async ({ request }) => { + capturedParBodyText = await request.text(); + return HttpResponse.json({ request_uri: parRequestUri, expires_in: 60 }, { status: 201 }); + }), + ); + + const parClient = await oidc({ + config: { ...baseConfig, par: true }, + storage: customStorageConfig, + }); + if ('error' in parClient) { + expect.fail('Expected client, got error'); + } + + const parResponse = await parClient.authorize.background({ + clientId: baseConfig.clientId, + redirectUri: baseConfig.redirectUri, + scope: baseConfig.scope, + responseType: 'code', + responseMode: 'pi.flow', + // intentionally omitting prompt + }); + + if ('error' in parResponse) { + expect.fail(`Expected success, got error: ${JSON.stringify(parResponse)}`); + } + + expect(new URLSearchParams(capturedParBodyText).get('prompt')).toBe('none'); + + // PAR disabled (standard flow): prompt=none must appear in the authorize POST URL + customStorage.remove(storageKey); + + let capturedAuthorizeUrl = ''; + server.use( + http.post('*/as/authorize', async ({ request }) => { + capturedAuthorizeUrl = request.url; + return HttpResponse.json({ + authorizeResponse: { + code: 123, + state: 'NzUyNDUyMDAxOTMyNDUxNzI1NjkxNDc2MjEyMzUwMjQzMzQyMjE4OQ', + }, + }); + }), + ); + + const standardClient = await oidc({ + config: { ...baseConfig, par: false }, + storage: customStorageConfig, + }); + if ('error' in standardClient) { + expect.fail('Expected client, got error'); + } + + const standardResponse = await standardClient.authorize.background({ + clientId: baseConfig.clientId, + redirectUri: baseConfig.redirectUri, + scope: baseConfig.scope, + responseType: 'code', + responseMode: 'pi.flow', + // intentionally omitting prompt + }); + + if ('error' in standardResponse) { + expect.fail(`Expected success, got error: ${JSON.stringify(standardResponse)}`); + } + + expect(new URL(capturedAuthorizeUrl).searchParams.get('prompt')).toBe('none'); + }); +}); + describe('authorize.url() with PAR enabled on non-pi.flow server', async () => { beforeEach(() => { customStorage.remove(storageKey); diff --git a/packages/oidc-client/src/lib/client.store.ts b/packages/oidc-client/src/lib/client.store.ts index b7824d3178..1974d19598 100644 --- a/packages/oidc-client/src/lib/client.store.ts +++ b/packages/oidc-client/src/lib/client.store.ts @@ -207,8 +207,10 @@ export async function oidc({ }; } + const bgOptions = + options !== undefined ? { ...options, prompt: 'none' as const } : undefined; const result = await Micro.runPromiseExit( - authorizeµ(wellknown, config, log, store, options, useParFlow), + authorizeµ(wellknown, config, log, store, bgOptions, useParFlow), ); if (exitIsSuccess(result)) { From adcfd3eb500c41147d91e329faf7bbeb212b1450 Mon Sep 17 00:00:00 2001 From: Ryan Bas Date: Wed, 5 Aug 2026 16:39:11 -0600 Subject: [PATCH 2/4] chore: add changeset for oidc-client prompt=none background fix --- .changeset/oidc-prompt-none-background.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/oidc-prompt-none-background.md diff --git a/.changeset/oidc-prompt-none-background.md b/.changeset/oidc-prompt-none-background.md new file mode 100644 index 0000000000..641625d4f4 --- /dev/null +++ b/.changeset/oidc-prompt-none-background.md @@ -0,0 +1,5 @@ +--- +'@forgerock/oidc-client': patch +--- + +Always include prompt=none on background authorize calls (both standard and PAR flows) From 485e7358e6834317199814500762e51c64f18058 Mon Sep 17 00:00:00 2001 From: Ryan Bas Date: Thu, 6 Aug 2026 14:27:10 -0600 Subject: [PATCH 3/4] fix(oidc-client): remove forced prompt=none from background authorize --- packages/oidc-client/src/lib/authorize.request.micros.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/oidc-client/src/lib/authorize.request.micros.ts b/packages/oidc-client/src/lib/authorize.request.micros.ts index 43db85a0cf..e4770f83c9 100644 --- a/packages/oidc-client/src/lib/authorize.request.micros.ts +++ b/packages/oidc-client/src/lib/authorize.request.micros.ts @@ -119,10 +119,7 @@ export const createAuthorizeUrlµ = ( ): Micro.Micro<[string, GetAuthorizationUrlOptions], AuthorizationError, never> => { return Micro.tryPromise({ try: async () => - [await createAuthorizeUrl(path, { ...options, prompt: 'none' }), options] as [ - string, - GetAuthorizationUrlOptions, - ], + [await createAuthorizeUrl(path, options), options] as [string, GetAuthorizationUrlOptions], catch: (error): AuthorizationError => ({ error: 'AuthorizationUrlError', error_description: From 9bd12a5f6c6e5bb57586a180270a79941e01311d Mon Sep 17 00:00:00 2001 From: Ryan Bas Date: Thu, 6 Aug 2026 14:57:49 -0600 Subject: [PATCH 4/4] fix(oidc-client): always include prompt=none in background authorization Address PR #748 review comments: - bgOptions always created as object (never undefined) so prompt=none is enforced even when background() is called without arguments - token.get() background renewal also enforces prompt=none via bgAuthorizeOptions - add no-argument background() regression tests for both PAR and standard flows --- .../api-report/davinci-client.api.md | 26 ++++---- .../api-report/davinci-client.types.api.md | 26 ++++---- .../oidc-client/src/lib/client.store.test.ts | 64 +++++++++++++++++++ packages/oidc-client/src/lib/client.store.ts | 15 +++-- 4 files changed, 100 insertions(+), 31 deletions(-) diff --git a/packages/davinci-client/api-report/davinci-client.api.md b/packages/davinci-client/api-report/davinci-client.api.md index ea01559bcd..b9a0588a88 100644 --- a/packages/davinci-client/api-report/davinci-client.api.md +++ b/packages/davinci-client/api-report/davinci-client.api.md @@ -285,13 +285,11 @@ export function davinci(input: { resume: (input: { continueToken: string; }) => Promise; - start: (options?: StartOptions | undefined) => Promise; + start: (options?: StartOptions | undefined) => Promise; update: (collector: T) => Updater; validate: (collector: SingleValueCollectors | ObjectValueCollectors | MultiValueCollectors | AutoCollectors) => Validator; pollStatus: (collector: PollingCollector) => Poller; getClient: () => { - status: "start"; - } | { action: string; collectors: Collectors[]; description?: string; @@ -303,19 +301,21 @@ export function davinci(input: { description?: string; name?: string; status: "error"; + } | { + status: "failure"; + } | { + status: "start"; } | { authorization?: { code?: string; state?: string; }; status: "success"; - } | { - status: "failure"; } | null; getCollectors: () => Collectors[]; getError: () => DaVinciError | null; getErrorCollectors: () => CollectorErrors[]; - getNode: () => ContinueNode | ErrorNode | StartNode | SuccessNode | FailureNode; + getNode: () => ContinueNode | ErrorNode | FailureNode | StartNode | SuccessNode; getServer: () => { _links?: Links; id?: string; @@ -324,8 +324,6 @@ export function davinci(input: { href?: string; eventName?: string; status: "continue"; - } | { - status: "start"; } | { _links?: Links; eventName?: string; @@ -336,20 +334,22 @@ export function davinci(input: { } | { _links?: Links; eventName?: string; + href?: string; id?: string; interactionId?: string; interactionToken?: string; - href?: string; - session?: string; - status: "success"; + status: "failure"; + } | { + status: "start"; } | { _links?: Links; eventName?: string; - href?: string; id?: string; interactionId?: string; interactionToken?: string; - status: "failure"; + href?: string; + session?: string; + status: "success"; } | null; cache: { getLatestResponse: () => ({ diff --git a/packages/davinci-client/api-report/davinci-client.types.api.md b/packages/davinci-client/api-report/davinci-client.types.api.md index 4ae2da4a09..8db5fed719 100644 --- a/packages/davinci-client/api-report/davinci-client.types.api.md +++ b/packages/davinci-client/api-report/davinci-client.types.api.md @@ -285,13 +285,11 @@ export function davinci(input: { resume: (input: { continueToken: string; }) => Promise; - start: (options?: StartOptions | undefined) => Promise; + start: (options?: StartOptions | undefined) => Promise; update: (collector: T) => Updater; validate: (collector: SingleValueCollectors | ObjectValueCollectors | MultiValueCollectors | AutoCollectors) => Validator; pollStatus: (collector: PollingCollector) => Poller; getClient: () => { - status: "start"; - } | { action: string; collectors: Collectors[]; description?: string; @@ -303,19 +301,21 @@ export function davinci(input: { description?: string; name?: string; status: "error"; + } | { + status: "failure"; + } | { + status: "start"; } | { authorization?: { code?: string; state?: string; }; status: "success"; - } | { - status: "failure"; } | null; getCollectors: () => Collectors[]; getError: () => DaVinciError | null; getErrorCollectors: () => CollectorErrors[]; - getNode: () => ContinueNode | ErrorNode | StartNode | SuccessNode | FailureNode; + getNode: () => ContinueNode | ErrorNode | FailureNode | StartNode | SuccessNode; getServer: () => { _links?: Links; id?: string; @@ -324,8 +324,6 @@ export function davinci(input: { href?: string; eventName?: string; status: "continue"; - } | { - status: "start"; } | { _links?: Links; eventName?: string; @@ -336,20 +334,22 @@ export function davinci(input: { } | { _links?: Links; eventName?: string; + href?: string; id?: string; interactionId?: string; interactionToken?: string; - href?: string; - session?: string; - status: "success"; + status: "failure"; + } | { + status: "start"; } | { _links?: Links; eventName?: string; - href?: string; id?: string; interactionId?: string; interactionToken?: string; - status: "failure"; + href?: string; + session?: string; + status: "success"; } | null; cache: { getLatestResponse: () => ({ diff --git a/packages/oidc-client/src/lib/client.store.test.ts b/packages/oidc-client/src/lib/client.store.test.ts index f0c282a7b8..f7cce5aa3d 100644 --- a/packages/oidc-client/src/lib/client.store.test.ts +++ b/packages/oidc-client/src/lib/client.store.test.ts @@ -762,6 +762,70 @@ describe('authorize.background() prompt=none enforcement', async () => { expect(new URL(capturedAuthorizeUrl).searchParams.get('prompt')).toBe('none'); }); + + it('background() with NO argument still includes prompt=none', async () => { + const baseConfig: OidcConfig = { + clientId: '123456789', + redirectUri: 'https://example.com/callback.html', + scope: 'openid profile', + serverConfig: { wellknown: 'https://api.example.com/wellknown' }, + responseType: 'code', + }; + + // PAR flow: capture the PAR request body — do not assert overall success, + // because the post-PAR iframe authorize step fails in jsdom. + let capturedParBodyText = ''; + server.use( + http.post('*/as/par', async ({ request }) => { + capturedParBodyText = await request.text(); + return HttpResponse.json({ request_uri: parRequestUri, expires_in: 60 }, { status: 201 }); + }), + ); + + const parClient = await oidc({ + config: { ...baseConfig, par: true }, + storage: customStorageConfig, + }); + if ('error' in parClient) { + expect.fail('Expected client, got error'); + } + + await parClient.authorize.background(); // overall result may be an error — that is OK + expect(new URLSearchParams(capturedParBodyText).get('prompt')).toBe('none'); + + // Standard flow (no PAR): the SDK uses an iframe GET to the authorize endpoint. + // Capture via GET mock; do not assert overall success. + customStorage.remove(storageKey); + + let capturedAuthorizeUrl = ''; + server.use( + http.get('*/as/authorize', async ({ request }) => { + capturedAuthorizeUrl = request.url; + return new HttpResponse(null, { status: 200 }); + }), + http.post('*/as/authorize', async ({ request }) => { + capturedAuthorizeUrl = request.url; + return HttpResponse.json({ + authorizeResponse: { + code: 123, + state: 'NzUyNDUyMDAxOTMyNDUxNzI1NjkxNDc2MjEyMzUwMjQzMzQyMjE4OQ', + }, + }); + }), + ); + + const standardClient = await oidc({ + config: { ...baseConfig, par: false }, + storage: customStorageConfig, + }); + if ('error' in standardClient) { + expect.fail('Expected client, got error'); + } + + await standardClient.authorize.background(); // overall result may be an error — that is OK + expect(capturedAuthorizeUrl).not.toBe(''); + expect(new URL(capturedAuthorizeUrl).searchParams.get('prompt')).toBe('none'); + }); }); describe('authorize.url() with PAR enabled on non-pi.flow server', async () => { diff --git a/packages/oidc-client/src/lib/client.store.ts b/packages/oidc-client/src/lib/client.store.ts index 1974d19598..dbd80e4221 100644 --- a/packages/oidc-client/src/lib/client.store.ts +++ b/packages/oidc-client/src/lib/client.store.ts @@ -1,5 +1,5 @@ /* - * Copyright © 2025 - 2026 Ping Identity Corporation. All rights reserved. + * Copyright (c) 2025 - 2026 Ping Identity Corporation. All rights reserved. * * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. @@ -207,10 +207,15 @@ export async function oidc({ }; } - const bgOptions = - options !== undefined ? { ...options, prompt: 'none' as const } : undefined; const result = await Micro.runPromiseExit( - authorizeµ(wellknown, config, log, store, bgOptions, useParFlow), + authorizeµ( + wellknown, + config, + log, + store, + { ...(options ?? ({} as GetAuthorizationUrlOptions)), prompt: 'none' as const }, + useParFlow, + ), ); if (exitIsSuccess(result)) { @@ -330,7 +335,7 @@ export async function oidc({ config, log, store, - authorizeOptions, + { ...(authorizeOptions ?? ({} as GetAuthorizationUrlOptions)), prompt: 'none' as const }, useParFlow, ).pipe( Micro.flatMap((response): Micro.Micro => {