Skip to content

Commit 1ee2b2c

Browse files
committed
feat(oidc-client): add-par-support
Add support for par in oidc client.
1 parent 44f9be3 commit 1ee2b2c

25 files changed

Lines changed: 841 additions & 74 deletions

.changeset/some-shirts-joke.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@forgerock/sdk-request-middleware': minor
3+
'@forgerock/sdk-oidc': minor
4+
'@forgerock/davinci-client': minor
5+
'@forgerock/oidc-client': minor
6+
'am-mock-api': patch
7+
---
8+
9+
Add support for PAR in oidc-client requests for redirect flows

e2e/am-mock-api/src/app/constants.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
*/
1010

1111
export const authPaths = {
12+
par: ['/am/oauth2/realms/root/par'],
1213
tokenExchange: [
1314
'/am/auth/tokenExchange',
1415
'/am/oauth2/realms/root/access_token',

e2e/am-mock-api/src/app/responses.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1348,6 +1348,11 @@ export const recaptchaEnterpriseCallback = {
13481348
],
13491349
};
13501350

1351+
export const parResponse = {
1352+
request_uri: 'urn:ietf:params:oauth:request_uri:mock-par-request-uri',
1353+
expires_in: 60,
1354+
};
1355+
13511356
export const qrCodeCallbacksResponse = {
13521357
authId: 'qrcode-journey-confirmation',
13531358
callbacks: [

e2e/am-mock-api/src/app/routes.auth.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import {
4949
MetadataMarketPlacePingOneEvaluation,
5050
newPiWellKnown,
5151
qrCodeCallbacksResponse,
52+
parResponse,
5253
} from './responses.js';
5354
import initialRegResponse from './response.registration.js';
5455
import {
@@ -664,6 +665,10 @@ export default function (app) {
664665

665666
app.get('/callback', (req, res) => res.status(200).send('ok'));
666667

668+
app.post(authPaths.par, (req, res) => {
669+
res.status(201).json(parResponse);
670+
});
671+
667672
app.get('/am/.well-known/oidc-configuration', (req, res) => {
668673
res.send(wellKnownForgeRock);
669674
});

e2e/oidc-app/src/utils/oidc-app.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,11 @@ export async function oidcApp({ config, urlParams }) {
4949
const code = urlParams.get('code');
5050
const state = urlParams.get('state');
5151
const piflow = urlParams.get('piflow');
52+
const par = urlParams.get('par') === 'true';
5253

53-
const oidcClient: OidcClient = await oidc({ config });
54+
const oidcClient: OidcClient = await oidc({
55+
config: { ...config, ...(par && { par: true }) },
56+
});
5457
if ('error' in oidcClient) {
5558
displayError(oidcClient);
5659
}

e2e/oidc-suites/src/par.spec.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/*
2+
*
3+
* Copyright © 2025 Ping Identity Corporation. All right reserved.
4+
*
5+
* This software may be modified and distributed under the terms
6+
* of the MIT license. See the LICENSE file for details.
7+
*
8+
*/
9+
import { test, expect } from '@playwright/test';
10+
import { pingAmUsername, pingAmPassword } from './utils/demo-users.js';
11+
import { asyncEvents } from './utils/async-events.js';
12+
13+
test.describe('PAR (Pushed Authorization Request) login tests', () => {
14+
test('redirect login with PAR enabled obtains access token and uses slim authorize URL', async ({
15+
page,
16+
}) => {
17+
const { clickWithRedirect, navigate } = asyncEvents(page);
18+
19+
const parRequests: string[] = [];
20+
const authorizeRequests: string[] = [];
21+
22+
page.on('request', (request) => {
23+
if (request.method() === 'POST' && request.url().includes('/par')) {
24+
parRequests.push(request.url());
25+
}
26+
if (request.url().includes('/authorize')) {
27+
authorizeRequests.push(request.url());
28+
}
29+
});
30+
31+
await navigate('/ping-am/?par=true');
32+
33+
await clickWithRedirect('Login (Redirect)', '**/am/XUI/**');
34+
35+
await page.getByLabel('User Name').fill(pingAmUsername);
36+
await page.getByRole('textbox', { name: 'Password' }).fill(pingAmPassword);
37+
await clickWithRedirect('Next', 'http://localhost:8443/ping-am/**');
38+
39+
expect(page.url()).toContain('code');
40+
expect(page.url()).toContain('state');
41+
42+
await expect(page.locator('#accessToken-0')).not.toBeEmpty();
43+
44+
// PAR POST was made
45+
expect(parRequests.length).toBeGreaterThan(0);
46+
47+
// Authorize URL only contains client_id + request_uri (not scope/code_challenge)
48+
expect(authorizeRequests.length).toBeGreaterThan(0);
49+
const authorizeUrl = new URL(authorizeRequests[0]);
50+
expect(authorizeUrl.searchParams.has('client_id')).toBe(true);
51+
expect(authorizeUrl.searchParams.has('request_uri')).toBe(true);
52+
expect(authorizeUrl.searchParams.has('scope')).toBe(false);
53+
expect(authorizeUrl.searchParams.has('code_challenge')).toBe(false);
54+
expect(authorizeUrl.searchParams.has('redirect_uri')).toBe(false);
55+
});
56+
});

packages/davinci-client/api-report/davinci-client.api.md

Lines changed: 74 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ export interface CollectorErrors {
178178
}
179179

180180
// @public (undocumented)
181-
export type Collectors = FlowCollector | PasswordCollector | TextCollector | SingleSelectCollector | IdpCollector | SubmitCollector | ActionCollector<'ActionCollector'> | SingleValueCollector<'SingleValueCollector'> | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | ReadOnlyCollector | ValidatedTextCollector | ProtectCollector | PollingCollector | FidoRegistrationCollector | FidoAuthenticationCollector | QrCodeCollector | AgreementCollector | UnknownCollector;
181+
export type Collectors = FlowCollector | PasswordCollector | TextCollector | SingleSelectCollector | IdpCollector | SubmitCollector | ActionCollector<'ActionCollector'> | SingleValueCollector<'SingleValueCollector'> | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | PhoneNumberExtensionCollector | ReadOnlyCollector | ValidatedTextCollector | ProtectCollector | PollingCollector | FidoRegistrationCollector | FidoAuthenticationCollector | QrCodeCollector | AgreementCollector | UnknownCollector;
182182

183183
// @public
184184
export type CollectorValueType<T> = T extends {
@@ -212,7 +212,7 @@ export type CollectorValueType<T> = T extends {
212212
} ? string[] : string | string[] | PhoneNumberInputValue | FidoRegistrationInputValue | FidoAuthenticationInputValue;
213213

214214
// @public (undocumented)
215-
export type ComplexValueFields = DeviceAuthenticationField | DeviceRegistrationField | PhoneNumberField | FidoRegistrationField | FidoAuthenticationField | PollingField;
215+
export type ComplexValueFields = DeviceAuthenticationField | DeviceRegistrationField | PhoneNumberField | PhoneNumberExtensionField | FidoRegistrationField | FidoAuthenticationField | PollingField;
216216

217217
// @public (undocumented)
218218
export interface ContinueNode {
@@ -267,10 +267,10 @@ export function davinci<ActionType extends ActionTypes = ActionTypes>(input: {
267267
resume: (input: {
268268
continueToken: string;
269269
}) => Promise<InternalErrorResponse | NodeStates>;
270-
start: <QueryParams extends OutgoingQueryParams = OutgoingQueryParams>(options?: StartOptions<QueryParams> | undefined) => Promise<ContinueNode | StartNode | ErrorNode | FailureNode | SuccessNode>;
270+
start: <QueryParams extends OutgoingQueryParams = OutgoingQueryParams>(options?: StartOptions<QueryParams> | undefined) => Promise<ContinueNode | ErrorNode | StartNode | SuccessNode | FailureNode>;
271271
update: <T extends SingleValueCollectors | MultiSelectCollector | ObjectValueCollectors | AutoCollectors>(collector: T) => Updater<T>;
272272
validate: (collector: SingleValueCollectors | ObjectValueCollectors | MultiValueCollectors | AutoCollectors) => Validator;
273-
poll: (collector: PollingCollector) => Poller;
273+
pollStatus: (collector: PollingCollector) => Poller;
274274
getClient: () => {
275275
status: "start";
276276
} | {
@@ -285,19 +285,19 @@ export function davinci<ActionType extends ActionTypes = ActionTypes>(input: {
285285
description?: string;
286286
name?: string;
287287
status: "error";
288-
} | {
289-
status: "failure";
290288
} | {
291289
authorization?: {
292290
code?: string;
293291
state?: string;
294292
};
295293
status: "success";
294+
} | {
295+
status: "failure";
296296
} | null;
297297
getCollectors: () => Collectors[];
298298
getError: () => DaVinciError | null;
299299
getErrorCollectors: () => CollectorErrors[];
300-
getNode: () => ContinueNode | StartNode | ErrorNode | FailureNode | SuccessNode;
300+
getNode: () => ContinueNode | ErrorNode | StartNode | SuccessNode | FailureNode;
301301
getServer: () => {
302302
_links?: Links;
303303
id?: string;
@@ -318,20 +318,20 @@ export function davinci<ActionType extends ActionTypes = ActionTypes>(input: {
318318
} | {
319319
_links?: Links;
320320
eventName?: string;
321-
href?: string;
322321
id?: string;
323322
interactionId?: string;
324323
interactionToken?: string;
325-
status: "failure";
324+
href?: string;
325+
session?: string;
326+
status: "success";
326327
} | {
327328
_links?: Links;
328329
eventName?: string;
330+
href?: string;
329331
id?: string;
330332
interactionId?: string;
331333
interactionToken?: string;
332-
href?: string;
333-
session?: string;
334-
status: "success";
334+
status: "failure";
335335
} | null;
336336
cache: {
337337
getLatestResponse: () => ((state: RootState< {
@@ -1035,7 +1035,7 @@ export type InferNoValueCollectorType<T extends NoValueCollectorTypes> = T exten
10351035
export type InferSingleValueCollectorType<T extends SingleValueCollectorTypes> = T extends 'TextCollector' ? TextCollector : T extends 'SingleSelectCollector' ? SingleSelectCollector : T extends 'ValidatedTextCollector' ? ValidatedTextCollector : T extends 'PasswordCollector' ? PasswordCollector : SingleValueCollectorWithValue<'SingleValueCollector'> | SingleValueCollectorNoValue<'SingleValueCollector'>;
10361036

10371037
// @public (undocumented)
1038-
export type InferValueObjectCollectorType<T extends ObjectValueCollectorTypes> = T extends 'DeviceAuthenticationCollector' ? DeviceAuthenticationCollector : T extends 'DeviceRegistrationCollector' ? DeviceRegistrationCollector : T extends 'PhoneNumberCollector' ? PhoneNumberCollector : ObjectOptionsCollectorWithObjectValue<'ObjectValueCollector'> | ObjectOptionsCollectorWithStringValue<'ObjectValueCollector'>;
1038+
export type InferValueObjectCollectorType<T extends ObjectValueCollectorTypes> = T extends 'DeviceAuthenticationCollector' ? DeviceAuthenticationCollector : T extends 'DeviceRegistrationCollector' ? DeviceRegistrationCollector : T extends 'PhoneNumberCollector' ? PhoneNumberCollector : T extends 'PhoneNumberExtensionCollector' ? PhoneNumberExtensionCollector : ObjectOptionsCollectorWithObjectValue<'ObjectValueCollector'> | ObjectOptionsCollectorWithStringValue<'ObjectValueCollector'>;
10391039

10401040
// @public (undocumented)
10411041
export type InitFlow = () => Promise<FlowNode | InternalErrorResponse>;
@@ -1170,8 +1170,8 @@ value: Record<string, unknown>;
11701170
}, string>;
11711171

11721172
// @public
1173-
export const nodeCollectorReducer: Reducer<(TextCollector | SingleSelectCollector | ValidatedTextCollector | PasswordCollector | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | IdpCollector | SubmitCollector | FlowCollector | QrCodeCollectorBase | AgreementCollector | ReadOnlyCollector | UnknownCollector | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector | PollingCollector | ActionCollector<"ActionCollector"> | SingleValueCollector<"SingleValueCollector">)[]> & {
1174-
getInitialState: () => (TextCollector | SingleSelectCollector | ValidatedTextCollector | PasswordCollector | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | IdpCollector | SubmitCollector | FlowCollector | QrCodeCollectorBase | AgreementCollector | ReadOnlyCollector | UnknownCollector | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector | PollingCollector | ActionCollector<"ActionCollector"> | SingleValueCollector<"SingleValueCollector">)[];
1173+
export const nodeCollectorReducer: Reducer<(TextCollector | SingleSelectCollector | ValidatedTextCollector | PasswordCollector | MultiSelectCollector | PhoneNumberExtensionCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | IdpCollector | SubmitCollector | FlowCollector | QrCodeCollectorBase | AgreementCollector | ReadOnlyCollector | UnknownCollector | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector | PollingCollector | ActionCollector<"ActionCollector"> | SingleValueCollector<"SingleValueCollector">)[]> & {
1174+
getInitialState: () => (TextCollector | SingleSelectCollector | ValidatedTextCollector | PasswordCollector | MultiSelectCollector | PhoneNumberExtensionCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | IdpCollector | SubmitCollector | FlowCollector | QrCodeCollectorBase | AgreementCollector | ReadOnlyCollector | UnknownCollector | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector | PollingCollector | ActionCollector<"ActionCollector"> | SingleValueCollector<"SingleValueCollector">)[];
11751175
};
11761176

11771177
// @public (undocumented)
@@ -1283,10 +1283,10 @@ export type ObjectValueAutoCollectorTypes = 'ObjectValueAutoCollector' | 'FidoRe
12831283
export type ObjectValueCollector<T extends ObjectValueCollectorTypes> = ObjectOptionsCollectorWithObjectValue<T> | ObjectOptionsCollectorWithStringValue<T> | ObjectValueCollectorWithObjectValue<T>;
12841284

12851285
// @public (undocumented)
1286-
export type ObjectValueCollectors = DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | ObjectOptionsCollectorWithObjectValue<'ObjectSelectCollector'> | ObjectOptionsCollectorWithStringValue<'ObjectSelectCollector'>;
1286+
export type ObjectValueCollectors = DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | PhoneNumberExtensionCollector | ObjectOptionsCollectorWithObjectValue<'ObjectSelectCollector'> | ObjectOptionsCollectorWithStringValue<'ObjectSelectCollector'>;
12871287

12881288
// @public
1289-
export type ObjectValueCollectorTypes = 'DeviceAuthenticationCollector' | 'DeviceRegistrationCollector' | 'PhoneNumberCollector' | 'ObjectOptionsCollector' | 'ObjectValueCollector' | 'ObjectSelectCollector';
1289+
export type ObjectValueCollectorTypes = 'DeviceAuthenticationCollector' | 'DeviceRegistrationCollector' | 'PhoneNumberCollector' | 'PhoneNumberExtensionCollector' | 'ObjectOptionsCollector' | 'ObjectValueCollector' | 'ObjectSelectCollector';
12901290

12911291
// @public (undocumented)
12921292
export interface ObjectValueCollectorWithObjectValue<T extends ObjectValueCollectorTypes, IV = Record<string, string>, OV = Record<string, string>> {
@@ -1328,13 +1328,68 @@ export type PasswordCollector = SingleValueCollectorNoValue<'PasswordCollector'>
13281328
// @public (undocumented)
13291329
export type PhoneNumberCollector = ObjectValueCollectorWithObjectValue<'PhoneNumberCollector', PhoneNumberInputValue, PhoneNumberOutputValue>;
13301330

1331+
// @public (undocumented)
1332+
export interface PhoneNumberExtensionCollector {
1333+
// (undocumented)
1334+
category: 'ObjectValueCollector';
1335+
// (undocumented)
1336+
error: string | null;
1337+
// (undocumented)
1338+
id: string;
1339+
// (undocumented)
1340+
input: {
1341+
key: string;
1342+
value: PhoneNumberExtensionInputValue;
1343+
type: string;
1344+
validation: (ValidationRequired | ValidationPhoneNumber)[] | null;
1345+
};
1346+
// (undocumented)
1347+
name: string;
1348+
// (undocumented)
1349+
output: {
1350+
key: string;
1351+
label: string;
1352+
type: string;
1353+
extensionLabel: string;
1354+
value: PhoneNumberExtensionOutputValue;
1355+
};
1356+
// (undocumented)
1357+
type: 'PhoneNumberExtensionCollector';
1358+
}
1359+
1360+
// @public (undocumented)
1361+
export type PhoneNumberExtensionField = PhoneNumberField & {
1362+
showExtension: boolean;
1363+
extensionLabel: string;
1364+
};
1365+
1366+
// @public (undocumented)
1367+
export interface PhoneNumberExtensionInputValue {
1368+
// (undocumented)
1369+
countryCode: string;
1370+
// (undocumented)
1371+
extension: string;
1372+
// (undocumented)
1373+
phoneNumber: string;
1374+
}
1375+
1376+
// @public (undocumented)
1377+
export interface PhoneNumberExtensionOutputValue {
1378+
// (undocumented)
1379+
countryCode?: string;
1380+
// (undocumented)
1381+
extension?: string;
1382+
// (undocumented)
1383+
phoneNumber?: string;
1384+
}
1385+
13311386
// @public (undocumented)
13321387
export type PhoneNumberField = {
13331388
type: 'PHONE_NUMBER';
13341389
key: string;
13351390
label: string;
1336-
defaultCountryCode: string | null;
13371391
required: boolean;
1392+
defaultCountryCode: string | null;
13381393
validatePhoneNumber: boolean;
13391394
};
13401395

@@ -1724,7 +1779,7 @@ export type UnknownField = Record<string, unknown>;
17241779
// @public (undocumented)
17251780
export const updateCollectorValues: ActionCreatorWithPayload< {
17261781
id: string;
1727-
value: string | string[] | PhoneNumberInputValue | FidoRegistrationInputValue | FidoAuthenticationInputValue;
1782+
value: string | string[] | PhoneNumberInputValue | PhoneNumberExtensionInputValue | FidoRegistrationInputValue | FidoAuthenticationInputValue;
17281783
index?: number;
17291784
}, string>;
17301785

0 commit comments

Comments
 (0)