Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/oidc-prompt-none-background.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@forgerock/oidc-client': patch
---

Always include prompt=none on background authorize calls (both standard and PAR flows)
26 changes: 13 additions & 13 deletions packages/davinci-client/api-report/davinci-client.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,13 +285,11 @@ export function davinci<ActionType extends ActionTypes = ActionTypes>(input: {
resume: (input: {
continueToken: string;
}) => Promise<InternalErrorResponse | NodeStates>;
start: <QueryParams extends OutgoingQueryParams = OutgoingQueryParams>(options?: StartOptions<QueryParams> | undefined) => Promise<ContinueNode | ErrorNode | StartNode | SuccessNode | FailureNode>;
start: <QueryParams extends OutgoingQueryParams = OutgoingQueryParams>(options?: StartOptions<QueryParams> | undefined) => Promise<ContinueNode | ErrorNode | FailureNode | StartNode | SuccessNode>;
update: <T extends SingleValueCollectors | MultiSelectCollector | ObjectValueCollectors | AutoCollectors>(collector: T) => Updater<T>;
validate: (collector: SingleValueCollectors | ObjectValueCollectors | MultiValueCollectors | AutoCollectors) => Validator;
pollStatus: (collector: PollingCollector) => Poller;
getClient: () => {
status: "start";
} | {
action: string;
collectors: Collectors[];
description?: string;
Expand All @@ -303,19 +301,21 @@ export function davinci<ActionType extends ActionTypes = ActionTypes>(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;
Expand All @@ -324,8 +324,6 @@ export function davinci<ActionType extends ActionTypes = ActionTypes>(input: {
href?: string;
eventName?: string;
status: "continue";
} | {
status: "start";
} | {
_links?: Links;
eventName?: string;
Expand All @@ -336,20 +334,22 @@ export function davinci<ActionType extends ActionTypes = ActionTypes>(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: () => ({
Expand Down
26 changes: 13 additions & 13 deletions packages/davinci-client/api-report/davinci-client.types.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,13 +285,11 @@ export function davinci<ActionType extends ActionTypes = ActionTypes>(input: {
resume: (input: {
continueToken: string;
}) => Promise<InternalErrorResponse | NodeStates>;
start: <QueryParams extends OutgoingQueryParams = OutgoingQueryParams>(options?: StartOptions<QueryParams> | undefined) => Promise<ContinueNode | ErrorNode | StartNode | SuccessNode | FailureNode>;
start: <QueryParams extends OutgoingQueryParams = OutgoingQueryParams>(options?: StartOptions<QueryParams> | undefined) => Promise<ContinueNode | ErrorNode | FailureNode | StartNode | SuccessNode>;
update: <T extends SingleValueCollectors | MultiSelectCollector | ObjectValueCollectors | AutoCollectors>(collector: T) => Updater<T>;
validate: (collector: SingleValueCollectors | ObjectValueCollectors | MultiValueCollectors | AutoCollectors) => Validator;
pollStatus: (collector: PollingCollector) => Poller;
getClient: () => {
status: "start";
} | {
action: string;
collectors: Collectors[];
description?: string;
Expand All @@ -303,19 +301,21 @@ export function davinci<ActionType extends ActionTypes = ActionTypes>(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;
Expand All @@ -324,8 +324,6 @@ export function davinci<ActionType extends ActionTypes = ActionTypes>(input: {
href?: string;
eventName?: string;
status: "continue";
} | {
status: "start";
} | {
_links?: Links;
eventName?: string;
Expand All @@ -336,20 +334,22 @@ export function davinci<ActionType extends ActionTypes = ActionTypes>(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: () => ({
Expand Down
5 changes: 1 addition & 4 deletions packages/oidc-client/src/lib/authorize.request.micros.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Comment thread
coderabbitai[bot] marked this conversation as resolved.
catch: (error): AuthorizationError => ({
error: 'AuthorizationUrlError',
error_description:
Expand Down
162 changes: 161 additions & 1 deletion packages/oidc-client/src/lib/client.store.test.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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,
Expand All @@ -663,11 +671,163 @@ 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');
});

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 () => {
beforeEach(() => {
customStorage.remove(storageKey);
Expand Down
13 changes: 10 additions & 3 deletions packages/oidc-client/src/lib/client.store.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -208,7 +208,14 @@ export async function oidc<ActionType extends ActionTypes = ActionTypes>({
}

const result = await Micro.runPromiseExit(
authorizeµ(wellknown, config, log, store, options, useParFlow),
authorizeµ(
wellknown,
config,
log,
store,
{ ...(options ?? ({} as GetAuthorizationUrlOptions)), prompt: 'none' as const },
useParFlow,
),
);

if (exitIsSuccess(result)) {
Expand Down Expand Up @@ -328,7 +335,7 @@ export async function oidc<ActionType extends ActionTypes = ActionTypes>({
config,
log,
store,
authorizeOptions,
{ ...(authorizeOptions ?? ({} as GetAuthorizationUrlOptions)), prompt: 'none' as const },
useParFlow,
).pipe(
Micro.flatMap((response): Micro.Micro<OauthTokens, TokenExchangeErrorResponse, never> => {
Expand Down