From 1ee2b2c6a99aa0c07c615cf0587acbda47a19d0f Mon Sep 17 00:00:00 2001 From: Ryan Bas Date: Mon, 11 May 2026 10:47:32 -0600 Subject: [PATCH] feat(oidc-client): add-par-support Add support for par in oidc client. --- .changeset/some-shirts-joke.md | 9 ++ e2e/am-mock-api/src/app/constants.js | 1 + e2e/am-mock-api/src/app/responses.js | 5 + e2e/am-mock-api/src/app/routes.auth.js | 5 + e2e/oidc-app/src/utils/oidc-app.ts | 5 +- e2e/oidc-suites/src/par.spec.ts | 56 ++++++++ .../api-report/davinci-client.api.md | 93 +++++++++--- .../api-report/davinci-client.types.api.md | 93 +++++++++--- .../oidc-client/api-report/oidc-client.api.md | 18 +++ .../api-report/oidc-client.types.api.md | 18 +++ packages/oidc-client/package.json | 1 + .../oidc-client/src/lib/authorize.request.ts | 12 +- .../src/lib/authorize.request.utils.test.ts | 134 +++++++++++++++++- .../src/lib/authorize.request.utils.ts | 129 ++++++++++++++++- .../oidc-client/src/lib/client.store.test.ts | 98 ++++++++++++- packages/oidc-client/src/lib/client.store.ts | 59 ++++++-- packages/oidc-client/src/lib/config.types.ts | 1 + packages/oidc-client/src/lib/oidc.api.ts | 66 +++++++++ packages/oidc-client/src/lib/par.types.ts | 11 ++ packages/oidc-client/src/types.ts | 1 + packages/oidc-client/tsconfig.lib.json | 3 + .../oidc/src/lib/authorize.effects.ts | 42 ++++-- .../oidc/src/lib/authorize.test.ts | 51 ++++++- .../src/lib/request-mware.derived.ts | 1 + pnpm-lock.yaml | 3 + 25 files changed, 841 insertions(+), 74 deletions(-) create mode 100644 .changeset/some-shirts-joke.md create mode 100644 e2e/oidc-suites/src/par.spec.ts create mode 100644 packages/oidc-client/src/lib/par.types.ts diff --git a/.changeset/some-shirts-joke.md b/.changeset/some-shirts-joke.md new file mode 100644 index 0000000000..f9c2399f3f --- /dev/null +++ b/.changeset/some-shirts-joke.md @@ -0,0 +1,9 @@ +--- +'@forgerock/sdk-request-middleware': minor +'@forgerock/sdk-oidc': minor +'@forgerock/davinci-client': minor +'@forgerock/oidc-client': minor +'am-mock-api': patch +--- + +Add support for PAR in oidc-client requests for redirect flows diff --git a/e2e/am-mock-api/src/app/constants.js b/e2e/am-mock-api/src/app/constants.js index 6e7b8240a8..cdd6edc5a5 100644 --- a/e2e/am-mock-api/src/app/constants.js +++ b/e2e/am-mock-api/src/app/constants.js @@ -9,6 +9,7 @@ */ export const authPaths = { + par: ['/am/oauth2/realms/root/par'], tokenExchange: [ '/am/auth/tokenExchange', '/am/oauth2/realms/root/access_token', diff --git a/e2e/am-mock-api/src/app/responses.js b/e2e/am-mock-api/src/app/responses.js index d7c9e7af41..6127ea6e3d 100644 --- a/e2e/am-mock-api/src/app/responses.js +++ b/e2e/am-mock-api/src/app/responses.js @@ -1348,6 +1348,11 @@ export const recaptchaEnterpriseCallback = { ], }; +export const parResponse = { + request_uri: 'urn:ietf:params:oauth:request_uri:mock-par-request-uri', + expires_in: 60, +}; + export const qrCodeCallbacksResponse = { authId: 'qrcode-journey-confirmation', callbacks: [ diff --git a/e2e/am-mock-api/src/app/routes.auth.js b/e2e/am-mock-api/src/app/routes.auth.js index bcf0e7c2e9..38e75a426d 100644 --- a/e2e/am-mock-api/src/app/routes.auth.js +++ b/e2e/am-mock-api/src/app/routes.auth.js @@ -49,6 +49,7 @@ import { MetadataMarketPlacePingOneEvaluation, newPiWellKnown, qrCodeCallbacksResponse, + parResponse, } from './responses.js'; import initialRegResponse from './response.registration.js'; import { @@ -664,6 +665,10 @@ export default function (app) { app.get('/callback', (req, res) => res.status(200).send('ok')); + app.post(authPaths.par, (req, res) => { + res.status(201).json(parResponse); + }); + app.get('/am/.well-known/oidc-configuration', (req, res) => { res.send(wellKnownForgeRock); }); diff --git a/e2e/oidc-app/src/utils/oidc-app.ts b/e2e/oidc-app/src/utils/oidc-app.ts index 69289580a0..f8565a10c7 100644 --- a/e2e/oidc-app/src/utils/oidc-app.ts +++ b/e2e/oidc-app/src/utils/oidc-app.ts @@ -49,8 +49,11 @@ export async function oidcApp({ config, urlParams }) { const code = urlParams.get('code'); const state = urlParams.get('state'); const piflow = urlParams.get('piflow'); + const par = urlParams.get('par') === 'true'; - const oidcClient: OidcClient = await oidc({ config }); + const oidcClient: OidcClient = await oidc({ + config: { ...config, ...(par && { par: true }) }, + }); if ('error' in oidcClient) { displayError(oidcClient); } diff --git a/e2e/oidc-suites/src/par.spec.ts b/e2e/oidc-suites/src/par.spec.ts new file mode 100644 index 0000000000..300f33ee74 --- /dev/null +++ b/e2e/oidc-suites/src/par.spec.ts @@ -0,0 +1,56 @@ +/* + * + * Copyright © 2025 Ping Identity Corporation. All right reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + * + */ +import { test, expect } from '@playwright/test'; +import { pingAmUsername, pingAmPassword } from './utils/demo-users.js'; +import { asyncEvents } from './utils/async-events.js'; + +test.describe('PAR (Pushed Authorization Request) login tests', () => { + test('redirect login with PAR enabled obtains access token and uses slim authorize URL', async ({ + page, + }) => { + const { clickWithRedirect, navigate } = asyncEvents(page); + + const parRequests: string[] = []; + const authorizeRequests: string[] = []; + + page.on('request', (request) => { + if (request.method() === 'POST' && request.url().includes('/par')) { + parRequests.push(request.url()); + } + if (request.url().includes('/authorize')) { + authorizeRequests.push(request.url()); + } + }); + + await navigate('/ping-am/?par=true'); + + await clickWithRedirect('Login (Redirect)', '**/am/XUI/**'); + + await page.getByLabel('User Name').fill(pingAmUsername); + await page.getByRole('textbox', { name: 'Password' }).fill(pingAmPassword); + await clickWithRedirect('Next', 'http://localhost:8443/ping-am/**'); + + expect(page.url()).toContain('code'); + expect(page.url()).toContain('state'); + + await expect(page.locator('#accessToken-0')).not.toBeEmpty(); + + // PAR POST was made + expect(parRequests.length).toBeGreaterThan(0); + + // Authorize URL only contains client_id + request_uri (not scope/code_challenge) + expect(authorizeRequests.length).toBeGreaterThan(0); + const authorizeUrl = new URL(authorizeRequests[0]); + expect(authorizeUrl.searchParams.has('client_id')).toBe(true); + expect(authorizeUrl.searchParams.has('request_uri')).toBe(true); + expect(authorizeUrl.searchParams.has('scope')).toBe(false); + expect(authorizeUrl.searchParams.has('code_challenge')).toBe(false); + expect(authorizeUrl.searchParams.has('redirect_uri')).toBe(false); + }); +}); diff --git a/packages/davinci-client/api-report/davinci-client.api.md b/packages/davinci-client/api-report/davinci-client.api.md index b2528bf664..d32a61e876 100644 --- a/packages/davinci-client/api-report/davinci-client.api.md +++ b/packages/davinci-client/api-report/davinci-client.api.md @@ -178,7 +178,7 @@ export interface CollectorErrors { } // @public (undocumented) -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; +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; // @public export type CollectorValueType = T extends { @@ -212,7 +212,7 @@ export type CollectorValueType = T extends { } ? string[] : string | string[] | PhoneNumberInputValue | FidoRegistrationInputValue | FidoAuthenticationInputValue; // @public (undocumented) -export type ComplexValueFields = DeviceAuthenticationField | DeviceRegistrationField | PhoneNumberField | FidoRegistrationField | FidoAuthenticationField | PollingField; +export type ComplexValueFields = DeviceAuthenticationField | DeviceRegistrationField | PhoneNumberField | PhoneNumberExtensionField | FidoRegistrationField | FidoAuthenticationField | PollingField; // @public (undocumented) export interface ContinueNode { @@ -267,10 +267,10 @@ 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; - poll: (collector: PollingCollector) => Poller; + pollStatus: (collector: PollingCollector) => Poller; getClient: () => { status: "start"; } | { @@ -285,19 +285,19 @@ export function davinci(input: { description?: string; name?: string; status: "error"; - } | { - status: "failure"; } | { authorization?: { code?: string; state?: string; }; status: "success"; + } | { + status: "failure"; } | null; getCollectors: () => Collectors[]; getError: () => DaVinciError | null; getErrorCollectors: () => CollectorErrors[]; - getNode: () => ContinueNode | StartNode | ErrorNode | FailureNode | SuccessNode; + getNode: () => ContinueNode | ErrorNode | StartNode | SuccessNode | FailureNode; getServer: () => { _links?: Links; id?: string; @@ -318,20 +318,20 @@ export function davinci(input: { } | { _links?: Links; eventName?: string; - href?: string; id?: string; interactionId?: string; interactionToken?: string; - status: "failure"; + href?: string; + session?: string; + status: "success"; } | { _links?: Links; eventName?: string; + href?: string; id?: string; interactionId?: string; interactionToken?: string; - href?: string; - session?: string; - status: "success"; + status: "failure"; } | null; cache: { getLatestResponse: () => ((state: RootState< { @@ -1035,7 +1035,7 @@ export type InferNoValueCollectorType = T exten export type InferSingleValueCollectorType = T extends 'TextCollector' ? TextCollector : T extends 'SingleSelectCollector' ? SingleSelectCollector : T extends 'ValidatedTextCollector' ? ValidatedTextCollector : T extends 'PasswordCollector' ? PasswordCollector : SingleValueCollectorWithValue<'SingleValueCollector'> | SingleValueCollectorNoValue<'SingleValueCollector'>; // @public (undocumented) -export type InferValueObjectCollectorType = T extends 'DeviceAuthenticationCollector' ? DeviceAuthenticationCollector : T extends 'DeviceRegistrationCollector' ? DeviceRegistrationCollector : T extends 'PhoneNumberCollector' ? PhoneNumberCollector : ObjectOptionsCollectorWithObjectValue<'ObjectValueCollector'> | ObjectOptionsCollectorWithStringValue<'ObjectValueCollector'>; +export type InferValueObjectCollectorType = T extends 'DeviceAuthenticationCollector' ? DeviceAuthenticationCollector : T extends 'DeviceRegistrationCollector' ? DeviceRegistrationCollector : T extends 'PhoneNumberCollector' ? PhoneNumberCollector : T extends 'PhoneNumberExtensionCollector' ? PhoneNumberExtensionCollector : ObjectOptionsCollectorWithObjectValue<'ObjectValueCollector'> | ObjectOptionsCollectorWithStringValue<'ObjectValueCollector'>; // @public (undocumented) export type InitFlow = () => Promise; @@ -1170,8 +1170,8 @@ value: Record; }, string>; // @public -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">)[]> & { - getInitialState: () => (TextCollector | SingleSelectCollector | ValidatedTextCollector | PasswordCollector | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | IdpCollector | SubmitCollector | FlowCollector | QrCodeCollectorBase | AgreementCollector | ReadOnlyCollector | UnknownCollector | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector | PollingCollector | ActionCollector<"ActionCollector"> | SingleValueCollector<"SingleValueCollector">)[]; +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">)[]> & { + 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">)[]; }; // @public (undocumented) @@ -1283,10 +1283,10 @@ export type ObjectValueAutoCollectorTypes = 'ObjectValueAutoCollector' | 'FidoRe export type ObjectValueCollector = ObjectOptionsCollectorWithObjectValue | ObjectOptionsCollectorWithStringValue | ObjectValueCollectorWithObjectValue; // @public (undocumented) -export type ObjectValueCollectors = DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | ObjectOptionsCollectorWithObjectValue<'ObjectSelectCollector'> | ObjectOptionsCollectorWithStringValue<'ObjectSelectCollector'>; +export type ObjectValueCollectors = DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | PhoneNumberExtensionCollector | ObjectOptionsCollectorWithObjectValue<'ObjectSelectCollector'> | ObjectOptionsCollectorWithStringValue<'ObjectSelectCollector'>; // @public -export type ObjectValueCollectorTypes = 'DeviceAuthenticationCollector' | 'DeviceRegistrationCollector' | 'PhoneNumberCollector' | 'ObjectOptionsCollector' | 'ObjectValueCollector' | 'ObjectSelectCollector'; +export type ObjectValueCollectorTypes = 'DeviceAuthenticationCollector' | 'DeviceRegistrationCollector' | 'PhoneNumberCollector' | 'PhoneNumberExtensionCollector' | 'ObjectOptionsCollector' | 'ObjectValueCollector' | 'ObjectSelectCollector'; // @public (undocumented) export interface ObjectValueCollectorWithObjectValue, OV = Record> { @@ -1328,13 +1328,68 @@ export type PasswordCollector = SingleValueCollectorNoValue<'PasswordCollector'> // @public (undocumented) export type PhoneNumberCollector = ObjectValueCollectorWithObjectValue<'PhoneNumberCollector', PhoneNumberInputValue, PhoneNumberOutputValue>; +// @public (undocumented) +export interface PhoneNumberExtensionCollector { + // (undocumented) + category: 'ObjectValueCollector'; + // (undocumented) + error: string | null; + // (undocumented) + id: string; + // (undocumented) + input: { + key: string; + value: PhoneNumberExtensionInputValue; + type: string; + validation: (ValidationRequired | ValidationPhoneNumber)[] | null; + }; + // (undocumented) + name: string; + // (undocumented) + output: { + key: string; + label: string; + type: string; + extensionLabel: string; + value: PhoneNumberExtensionOutputValue; + }; + // (undocumented) + type: 'PhoneNumberExtensionCollector'; +} + +// @public (undocumented) +export type PhoneNumberExtensionField = PhoneNumberField & { + showExtension: boolean; + extensionLabel: string; +}; + +// @public (undocumented) +export interface PhoneNumberExtensionInputValue { + // (undocumented) + countryCode: string; + // (undocumented) + extension: string; + // (undocumented) + phoneNumber: string; +} + +// @public (undocumented) +export interface PhoneNumberExtensionOutputValue { + // (undocumented) + countryCode?: string; + // (undocumented) + extension?: string; + // (undocumented) + phoneNumber?: string; +} + // @public (undocumented) export type PhoneNumberField = { type: 'PHONE_NUMBER'; key: string; label: string; - defaultCountryCode: string | null; required: boolean; + defaultCountryCode: string | null; validatePhoneNumber: boolean; }; @@ -1724,7 +1779,7 @@ export type UnknownField = Record; // @public (undocumented) export const updateCollectorValues: ActionCreatorWithPayload< { id: string; -value: string | string[] | PhoneNumberInputValue | FidoRegistrationInputValue | FidoAuthenticationInputValue; +value: string | string[] | PhoneNumberInputValue | PhoneNumberExtensionInputValue | FidoRegistrationInputValue | FidoAuthenticationInputValue; index?: number; }, string>; 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 2321431a0a..1b94e72664 100644 --- a/packages/davinci-client/api-report/davinci-client.types.api.md +++ b/packages/davinci-client/api-report/davinci-client.types.api.md @@ -178,7 +178,7 @@ export interface CollectorErrors { } // @public (undocumented) -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; +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; // @public export type CollectorValueType = T extends { @@ -212,7 +212,7 @@ export type CollectorValueType = T extends { } ? string[] : string | string[] | PhoneNumberInputValue | FidoRegistrationInputValue | FidoAuthenticationInputValue; // @public (undocumented) -export type ComplexValueFields = DeviceAuthenticationField | DeviceRegistrationField | PhoneNumberField | FidoRegistrationField | FidoAuthenticationField | PollingField; +export type ComplexValueFields = DeviceAuthenticationField | DeviceRegistrationField | PhoneNumberField | PhoneNumberExtensionField | FidoRegistrationField | FidoAuthenticationField | PollingField; // @public (undocumented) export interface ContinueNode { @@ -267,10 +267,10 @@ 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; - poll: (collector: PollingCollector) => Poller; + pollStatus: (collector: PollingCollector) => Poller; getClient: () => { status: "start"; } | { @@ -285,19 +285,19 @@ export function davinci(input: { description?: string; name?: string; status: "error"; - } | { - status: "failure"; } | { authorization?: { code?: string; state?: string; }; status: "success"; + } | { + status: "failure"; } | null; getCollectors: () => Collectors[]; getError: () => DaVinciError | null; getErrorCollectors: () => CollectorErrors[]; - getNode: () => ContinueNode | StartNode | ErrorNode | FailureNode | SuccessNode; + getNode: () => ContinueNode | ErrorNode | StartNode | SuccessNode | FailureNode; getServer: () => { _links?: Links; id?: string; @@ -318,20 +318,20 @@ export function davinci(input: { } | { _links?: Links; eventName?: string; - href?: string; id?: string; interactionId?: string; interactionToken?: string; - status: "failure"; + href?: string; + session?: string; + status: "success"; } | { _links?: Links; eventName?: string; + href?: string; id?: string; interactionId?: string; interactionToken?: string; - href?: string; - session?: string; - status: "success"; + status: "failure"; } | null; cache: { getLatestResponse: () => ((state: RootState< { @@ -1032,7 +1032,7 @@ export type InferNoValueCollectorType = T exten export type InferSingleValueCollectorType = T extends 'TextCollector' ? TextCollector : T extends 'SingleSelectCollector' ? SingleSelectCollector : T extends 'ValidatedTextCollector' ? ValidatedTextCollector : T extends 'PasswordCollector' ? PasswordCollector : SingleValueCollectorWithValue<'SingleValueCollector'> | SingleValueCollectorNoValue<'SingleValueCollector'>; // @public (undocumented) -export type InferValueObjectCollectorType = T extends 'DeviceAuthenticationCollector' ? DeviceAuthenticationCollector : T extends 'DeviceRegistrationCollector' ? DeviceRegistrationCollector : T extends 'PhoneNumberCollector' ? PhoneNumberCollector : ObjectOptionsCollectorWithObjectValue<'ObjectValueCollector'> | ObjectOptionsCollectorWithStringValue<'ObjectValueCollector'>; +export type InferValueObjectCollectorType = T extends 'DeviceAuthenticationCollector' ? DeviceAuthenticationCollector : T extends 'DeviceRegistrationCollector' ? DeviceRegistrationCollector : T extends 'PhoneNumberCollector' ? PhoneNumberCollector : T extends 'PhoneNumberExtensionCollector' ? PhoneNumberExtensionCollector : ObjectOptionsCollectorWithObjectValue<'ObjectValueCollector'> | ObjectOptionsCollectorWithStringValue<'ObjectValueCollector'>; // @public (undocumented) export type InitFlow = () => Promise; @@ -1167,8 +1167,8 @@ value: Record; }, string>; // @public -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">)[]> & { - getInitialState: () => (TextCollector | SingleSelectCollector | ValidatedTextCollector | PasswordCollector | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | IdpCollector | SubmitCollector | FlowCollector | QrCodeCollectorBase | AgreementCollector | ReadOnlyCollector | UnknownCollector | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector | PollingCollector | ActionCollector<"ActionCollector"> | SingleValueCollector<"SingleValueCollector">)[]; +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">)[]> & { + 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">)[]; }; // @public (undocumented) @@ -1280,10 +1280,10 @@ export type ObjectValueAutoCollectorTypes = 'ObjectValueAutoCollector' | 'FidoRe export type ObjectValueCollector = ObjectOptionsCollectorWithObjectValue | ObjectOptionsCollectorWithStringValue | ObjectValueCollectorWithObjectValue; // @public (undocumented) -export type ObjectValueCollectors = DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | ObjectOptionsCollectorWithObjectValue<'ObjectSelectCollector'> | ObjectOptionsCollectorWithStringValue<'ObjectSelectCollector'>; +export type ObjectValueCollectors = DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | PhoneNumberExtensionCollector | ObjectOptionsCollectorWithObjectValue<'ObjectSelectCollector'> | ObjectOptionsCollectorWithStringValue<'ObjectSelectCollector'>; // @public -export type ObjectValueCollectorTypes = 'DeviceAuthenticationCollector' | 'DeviceRegistrationCollector' | 'PhoneNumberCollector' | 'ObjectOptionsCollector' | 'ObjectValueCollector' | 'ObjectSelectCollector'; +export type ObjectValueCollectorTypes = 'DeviceAuthenticationCollector' | 'DeviceRegistrationCollector' | 'PhoneNumberCollector' | 'PhoneNumberExtensionCollector' | 'ObjectOptionsCollector' | 'ObjectValueCollector' | 'ObjectSelectCollector'; // @public (undocumented) export interface ObjectValueCollectorWithObjectValue, OV = Record> { @@ -1325,13 +1325,68 @@ export type PasswordCollector = SingleValueCollectorNoValue<'PasswordCollector'> // @public (undocumented) export type PhoneNumberCollector = ObjectValueCollectorWithObjectValue<'PhoneNumberCollector', PhoneNumberInputValue, PhoneNumberOutputValue>; +// @public (undocumented) +export interface PhoneNumberExtensionCollector { + // (undocumented) + category: 'ObjectValueCollector'; + // (undocumented) + error: string | null; + // (undocumented) + id: string; + // (undocumented) + input: { + key: string; + value: PhoneNumberExtensionInputValue; + type: string; + validation: (ValidationRequired | ValidationPhoneNumber)[] | null; + }; + // (undocumented) + name: string; + // (undocumented) + output: { + key: string; + label: string; + type: string; + extensionLabel: string; + value: PhoneNumberExtensionOutputValue; + }; + // (undocumented) + type: 'PhoneNumberExtensionCollector'; +} + +// @public (undocumented) +export type PhoneNumberExtensionField = PhoneNumberField & { + showExtension: boolean; + extensionLabel: string; +}; + +// @public (undocumented) +export interface PhoneNumberExtensionInputValue { + // (undocumented) + countryCode: string; + // (undocumented) + extension: string; + // (undocumented) + phoneNumber: string; +} + +// @public (undocumented) +export interface PhoneNumberExtensionOutputValue { + // (undocumented) + countryCode?: string; + // (undocumented) + extension?: string; + // (undocumented) + phoneNumber?: string; +} + // @public (undocumented) export type PhoneNumberField = { type: 'PHONE_NUMBER'; key: string; label: string; - defaultCountryCode: string | null; required: boolean; + defaultCountryCode: string | null; validatePhoneNumber: boolean; }; @@ -1721,7 +1776,7 @@ export type UnknownField = Record; // @public (undocumented) export const updateCollectorValues: ActionCreatorWithPayload< { id: string; -value: string | string[] | PhoneNumberInputValue | FidoRegistrationInputValue | FidoAuthenticationInputValue; +value: string | string[] | PhoneNumberInputValue | PhoneNumberExtensionInputValue | FidoRegistrationInputValue | FidoAuthenticationInputValue; index?: number; }, string>; diff --git a/packages/oidc-client/api-report/oidc-client.api.md b/packages/oidc-client/api-report/oidc-client.api.md index 283d363dc9..ab5c1d0fbe 100644 --- a/packages/oidc-client/api-report/oidc-client.api.md +++ b/packages/oidc-client/api-report/oidc-client.api.md @@ -123,6 +123,10 @@ oidc: CombinedState< { authorizeFetch: MutationDefinition< { url: string; }, BaseQueryFn, never, AuthorizeSuccessResponse, "oidc", unknown>; +par: MutationDefinition< { +endpoint: string; +body: URLSearchParams; +}, BaseQueryFn, never, PushAuthorizationResponse, "oidc", unknown>; authorizeIframe: MutationDefinition< { url: string; }, BaseQueryFn, never, AuthorizationSuccess, "oidc", unknown>; @@ -155,6 +159,10 @@ oidc: CombinedState< { authorizeFetch: MutationDefinition< { url: string; }, BaseQueryFn, never, AuthorizeSuccessResponse, "oidc", unknown>; +par: MutationDefinition< { +endpoint: string; +body: URLSearchParams; +}, BaseQueryFn, never, PushAuthorizationResponse, "oidc", unknown>; authorizeIframe: MutationDefinition< { url: string; }, BaseQueryFn, never, AuthorizationSuccess, "oidc", unknown>; @@ -281,6 +289,8 @@ export interface OidcConfig extends AsyncLegacyConfigOptions { // (undocumented) clientId: string; // (undocumented) + par?: boolean; + // (undocumented) redirectUri: string; // (undocumented) responseType?: ResponseType_2; @@ -296,6 +306,14 @@ export interface OidcConfig extends AsyncLegacyConfigOptions { // @public (undocumented) export type OptionalAuthorizeOptions = Partial; +// @public (undocumented) +export interface PushAuthorizationResponse { + // (undocumented) + expires_in: number; + // (undocumented) + request_uri: string; +} + export { RequestMiddleware } export { ResponseType_2 as ResponseType } diff --git a/packages/oidc-client/api-report/oidc-client.types.api.md b/packages/oidc-client/api-report/oidc-client.types.api.md index 283d363dc9..ab5c1d0fbe 100644 --- a/packages/oidc-client/api-report/oidc-client.types.api.md +++ b/packages/oidc-client/api-report/oidc-client.types.api.md @@ -123,6 +123,10 @@ oidc: CombinedState< { authorizeFetch: MutationDefinition< { url: string; }, BaseQueryFn, never, AuthorizeSuccessResponse, "oidc", unknown>; +par: MutationDefinition< { +endpoint: string; +body: URLSearchParams; +}, BaseQueryFn, never, PushAuthorizationResponse, "oidc", unknown>; authorizeIframe: MutationDefinition< { url: string; }, BaseQueryFn, never, AuthorizationSuccess, "oidc", unknown>; @@ -155,6 +159,10 @@ oidc: CombinedState< { authorizeFetch: MutationDefinition< { url: string; }, BaseQueryFn, never, AuthorizeSuccessResponse, "oidc", unknown>; +par: MutationDefinition< { +endpoint: string; +body: URLSearchParams; +}, BaseQueryFn, never, PushAuthorizationResponse, "oidc", unknown>; authorizeIframe: MutationDefinition< { url: string; }, BaseQueryFn, never, AuthorizationSuccess, "oidc", unknown>; @@ -281,6 +289,8 @@ export interface OidcConfig extends AsyncLegacyConfigOptions { // (undocumented) clientId: string; // (undocumented) + par?: boolean; + // (undocumented) redirectUri: string; // (undocumented) responseType?: ResponseType_2; @@ -296,6 +306,14 @@ export interface OidcConfig extends AsyncLegacyConfigOptions { // @public (undocumented) export type OptionalAuthorizeOptions = Partial; +// @public (undocumented) +export interface PushAuthorizationResponse { + // (undocumented) + expires_in: number; + // (undocumented) + request_uri: string; +} + export { RequestMiddleware } export { ResponseType_2 as ResponseType } diff --git a/packages/oidc-client/package.json b/packages/oidc-client/package.json index 6a71dd61ad..4d69dc10c5 100644 --- a/packages/oidc-client/package.json +++ b/packages/oidc-client/package.json @@ -32,6 +32,7 @@ "@forgerock/sdk-oidc": "workspace:*", "@forgerock/sdk-request-middleware": "workspace:*", "@forgerock/sdk-types": "workspace:*", + "@forgerock/sdk-utilities": "workspace:*", "@forgerock/storage": "workspace:*", "@reduxjs/toolkit": "catalog:", "effect": "catalog:effect" diff --git a/packages/oidc-client/src/lib/authorize.request.ts b/packages/oidc-client/src/lib/authorize.request.ts index 96af6a9fc0..8f040d00c3 100644 --- a/packages/oidc-client/src/lib/authorize.request.ts +++ b/packages/oidc-client/src/lib/authorize.request.ts @@ -36,10 +36,14 @@ export function authorizeµ( store: ClientStore, options?: GetAuthorizationUrlOptions, ) { - return buildAuthorizeOptionsµ(wellknown, config, options).pipe( - Micro.flatMap(([url, options]) => createAuthorizeUrlµ(url, options)), - Micro.tap((url) => log.debug('Authorize URL created', url)), - Micro.tapError((url) => Micro.sync(() => log.error('Error creating authorize URL', url))), + const urlEffect: Micro.Micro<[string, GetAuthorizationUrlOptions], AuthorizationError, never> = + buildAuthorizeOptionsµ(wellknown, config, options).pipe( + Micro.flatMap(([url, opts]) => createAuthorizeUrlµ(url, opts)), + ); + + return urlEffect.pipe( + Micro.tap(([url]) => log.debug('Authorize URL created', url)), + Micro.tapError((err) => Micro.sync(() => log.error('Error creating authorize URL', err))), Micro.flatMap( ([url, options]): Micro.Micro => { if (options.responseMode === 'pi.flow') { diff --git a/packages/oidc-client/src/lib/authorize.request.utils.test.ts b/packages/oidc-client/src/lib/authorize.request.utils.test.ts index 0a00278a7b..428e56f6a5 100644 --- a/packages/oidc-client/src/lib/authorize.request.utils.test.ts +++ b/packages/oidc-client/src/lib/authorize.request.utils.test.ts @@ -6,9 +6,11 @@ */ import { it, expect } from '@effect/vitest'; import { Micro } from 'effect'; -import { buildAuthorizeOptionsµ } from './authorize.request.utils.js'; -import { OidcConfig } from './config.types.js'; -import { WellknownResponse } from '@forgerock/sdk-types'; +import { vi, afterEach } from 'vitest'; +import { buildAuthorizeOptionsµ, parAuthorizeµ } from './authorize.request.utils.js'; +import type { OidcConfig } from './config.types.js'; +import type { WellknownResponse } from '@forgerock/sdk-types'; +import type { ClientStore } from './client.types.js'; const clientId = '123456789'; const redirectUri = 'https://example.com/callback.html'; @@ -33,6 +35,27 @@ const wellknown: WellknownResponse = { revocation_endpoint: 'https://example.com/revoke', }; +const parEndpoint = 'https://example.com/par'; +const wellknownWithPar: WellknownResponse = { + ...wellknown, + pushed_authorization_request_endpoint: parEndpoint, +}; +const wellknownWithParAndPiFlow: WellknownResponse = { + ...wellknownWithPar, + response_modes_supported: ['pi.flow'], +}; + +const mockStore = { + dispatch: vi.fn(), +} as unknown as ClientStore; + +const sessionStorageStub = { getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() }; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.resetAllMocks(); +}); + it.effect('buildAuthorizeOptionsµ succeeds with BuildAuthorizationData', () => Micro.gen(function* () { const result = yield* buildAuthorizeOptionsµ(wellknown, config); @@ -65,3 +88,108 @@ it.effect('buildAuthorizeOptionsµ with pi.flow succeeds with BuildAuthorization ]); }), ); + +it.effect('parAuthorizeµ fails with wellknown_error when par endpoint is missing', () => + Micro.gen(function* () { + const configWithPar: OidcConfig = { ...config, par: true }; + const result = yield* Micro.exit(parAuthorizeµ(wellknown, configWithPar, mockStore)); + expect(Micro.exitIsFailure(result)).toBe(true); + if (!Micro.exitIsFailure(result)) return; + expect(Micro.causeIsFail(result.cause)).toBe(true); + if (Micro.causeIsFail(result.cause)) { + expect(result.cause.error.type).toBe('wellknown_error'); + expect(result.cause.error.error_description).toBe( + 'PAR endpoint not found in server configuration', + ); + } + }), +); + +it.effect('parAuthorizeµ succeeds and returns slim authorize URL', () => + Micro.gen(function* () { + const configWithPar: OidcConfig = { ...config, par: true }; + const requestUri = 'urn:ietf:params:oauth:request_uri:abc123'; + + vi.stubGlobal('sessionStorage', sessionStorageStub); + vi.mocked(mockStore.dispatch).mockResolvedValueOnce({ + data: { request_uri: requestUri, expires_in: 60 }, + } as unknown as ReturnType); + + const url = yield* parAuthorizeµ(wellknownWithPar, configWithPar, mockStore); + + expect(url).toContain('client_id=123456789'); + expect(url).toContain(`request_uri=${encodeURIComponent(requestUri)}`); + expect(url).not.toContain('scope='); + expect(url).not.toContain('code_challenge='); + expect(sessionStorageStub.setItem).toHaveBeenCalled(); + }), +); + +it.effect('parAuthorizeµ fails with network_error when PAR POST returns error', () => + Micro.gen(function* () { + const configWithPar: OidcConfig = { ...config, par: true }; + + vi.stubGlobal('sessionStorage', sessionStorageStub); + vi.mocked(mockStore.dispatch).mockResolvedValueOnce({ + error: { + status: 400, + statusText: 'PAR_ERROR', + data: { error: 'PAR_ERROR', error_description: 'invalid_client', type: 'network_error' }, + }, + } as unknown as ReturnType); + + const result = yield* Micro.exit(parAuthorizeµ(wellknownWithPar, configWithPar, mockStore)); + + expect(Micro.exitIsFailure(result)).toBe(true); + if (!Micro.exitIsFailure(result)) return; + expect(Micro.causeIsFail(result.cause)).toBe(true); + if (Micro.causeIsFail(result.cause)) { + expect(result.cause.error.type).toBe('network_error'); + } + expect(sessionStorageStub.setItem).not.toHaveBeenCalled(); + }), +); + +it.effect('parAuthorizeµ fails with network_error when PAR response is missing request_uri', () => + Micro.gen(function* () { + const configWithPar: OidcConfig = { ...config, par: true }; + + vi.stubGlobal('sessionStorage', sessionStorageStub); + vi.mocked(mockStore.dispatch).mockResolvedValueOnce({ + data: {}, + } as unknown as ReturnType); + + const result = yield* Micro.exit(parAuthorizeµ(wellknownWithPar, configWithPar, mockStore)); + + expect(Micro.exitIsFailure(result)).toBe(true); + if (!Micro.exitIsFailure(result)) return; + expect(Micro.causeIsFail(result.cause)).toBe(true); + if (Micro.causeIsFail(result.cause)) { + expect(result.cause.error.type).toBe('network_error'); + expect(result.cause.error.error_description).toBe( + "PAR response missing required 'request_uri' field", + ); + } + expect(sessionStorageStub.setItem).not.toHaveBeenCalled(); + }), +); + +it.effect('parAuthorizeµ with pi.flow includes response_mode in slim authorize URL', () => + Micro.gen(function* () { + const configWithPar: OidcConfig = { ...config, par: true }; + const requestUri = 'urn:ietf:params:oauth:request_uri:piflow123'; + + vi.stubGlobal('sessionStorage', sessionStorageStub); + vi.mocked(mockStore.dispatch).mockResolvedValueOnce({ + data: { request_uri: requestUri, expires_in: 60 }, + } as unknown as ReturnType); + + const url = yield* parAuthorizeµ(wellknownWithParAndPiFlow, configWithPar, mockStore); + + expect(url).toContain('client_id=123456789'); + expect(url).toContain(`request_uri=${encodeURIComponent(requestUri)}`); + expect(url).toContain('response_mode=pi.flow'); + expect(url).not.toContain('scope='); + expect(url).not.toContain('code_challenge='); + }), +); diff --git a/packages/oidc-client/src/lib/authorize.request.utils.ts b/packages/oidc-client/src/lib/authorize.request.utils.ts index 96557e22ed..ba205c3d5e 100644 --- a/packages/oidc-client/src/lib/authorize.request.utils.ts +++ b/packages/oidc-client/src/lib/authorize.request.utils.ts @@ -4,7 +4,12 @@ * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. */ -import { createAuthorizeUrl } from '@forgerock/sdk-oidc'; +import { + buildAuthorizeParams, + createAuthorizeUrl, + generateAndStoreAuthUrlValues, +} from '@forgerock/sdk-oidc'; +import { createChallenge } from '@forgerock/sdk-utilities'; import { Micro } from 'effect'; import type { WellknownResponse, GetAuthorizationUrlOptions } from '@forgerock/sdk-types'; @@ -15,6 +20,16 @@ import type { OptionalAuthorizeOptions, } from './authorize.request.types.js'; import type { OidcConfig } from './config.types.js'; +import type { ClientStore } from './client.types.js'; +import { oidcApi } from './oidc.api.js'; + +function isStringRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function hasPushRequestUri(data: unknown): data is { request_uri: string } { + return isStringRecord(data) && typeof data['request_uri'] === 'string'; +} /** * @function buildAuthorizeOptionsµ @@ -130,3 +145,115 @@ export function handleResponseµ( return createAuthorizeErrorµ(response, wellknown, options); } } + +/** + * @function parAuthorizeµ + * @description Performs a Pushed Authorization Request (RFC 9126): POSTs all authorize + * parameters to the PAR endpoint (backchannel), then returns a slim authorize URL + * containing only `client_id` and `request_uri` — keeping sensitive params out of + * the browser's address bar and history. + */ +export function parAuthorizeµ( + wellknown: WellknownResponse, + config: OidcConfig, + store: ClientStore, + options?: OptionalAuthorizeOptions, +): Micro.Micro { + const parEndpoint = wellknown.pushed_authorization_request_endpoint; + + if (!parEndpoint) { + return Micro.fail({ + error: 'PAR endpoint not configured', + error_description: 'PAR endpoint not found in server configuration', + type: 'wellknown_error', + } as const); + } + + const isPiFlow = wellknown.response_modes_supported?.includes('pi.flow'); + + return Micro.tryPromise({ + try: async () => { + const [authUrlOptions, storeOptions] = generateAndStoreAuthUrlValues({ + clientId: config.clientId, + serverConfig: { baseUrl: new URL(wellknown.authorization_endpoint).origin }, + responseType: config.responseType || 'code', + redirectUri: config.redirectUri, + scope: config.scope || 'openid', + ...options, + }); + + const challenge = await createChallenge(authUrlOptions.verifier); + + const body = buildAuthorizeParams({ + ...options, + clientId: config.clientId, + redirectUri: config.redirectUri, + scope: config.scope || 'openid', + responseType: config.responseType || 'code', + challenge, + state: authUrlOptions.state, + ...(isPiFlow && { responseMode: 'pi.flow' }), + }); + + return { body, storeOptions }; + }, + catch: (error): AuthorizationError => ({ + error: 'PAR parameter build failed', + error_description: error instanceof Error ? error.message : 'Failed to build PAR parameters', + type: 'auth_error', + }), + }).pipe( + Micro.flatMap(({ body, storeOptions }) => + Micro.promise(() => + store.dispatch(oidcApi.endpoints.par.initiate({ endpoint: parEndpoint, body })), + ).pipe(Micro.map((result) => ({ result, storeOptions }))), + ), + Micro.flatMap( + ({ + result: { error, data }, + storeOptions, + }): Micro.Micro => { + if (error) { + const serverData = 'data' in error && isStringRecord(error.data) ? error.data : {}; + return Micro.fail({ + error: + typeof serverData['error'] === 'string' ? serverData['error'] : 'PAR request failed', + error_description: + typeof serverData['error_description'] === 'string' + ? serverData['error_description'] + : 'An unknown error occurred during PAR request', + type: 'network_error', + } as const); + } + + if (!hasPushRequestUri(data)) { + return Micro.fail({ + error: 'PAR_ERROR', + error_description: "PAR response missing required 'request_uri' field", + type: 'network_error', + } as const); + } + + const { request_uri: requestUri } = data; + + return Micro.try({ + try: () => { + const authorizeUrl = new URL(wellknown.authorization_endpoint); + authorizeUrl.searchParams.set('client_id', config.clientId); + authorizeUrl.searchParams.set('request_uri', requestUri); + if (isPiFlow) authorizeUrl.searchParams.set('response_mode', 'pi.flow'); + // Store PKCE values only after a successful PAR POST so sessionStorage stays clean on failure + storeOptions(); + return authorizeUrl.toString(); + }, + catch: (err): AuthorizationError => ({ + error: 'PAR_URL_BUILD_ERROR', + error_description: + err instanceof Error ? err.message : 'Failed to build PAR authorize URL', + type: 'auth_error', + }), + }); + }, + ), + ); +} diff --git a/packages/oidc-client/src/lib/client.store.test.ts b/packages/oidc-client/src/lib/client.store.test.ts index 87703d308a..23e2a63e67 100644 --- a/packages/oidc-client/src/lib/client.store.test.ts +++ b/packages/oidc-client/src/lib/client.store.test.ts @@ -7,7 +7,7 @@ import { http, HttpResponse } from 'msw'; import { setupServer } from 'msw/node'; -import { it, expect, describe, vi } from 'vitest'; +import { it, expect, describe, vi, beforeEach } from 'vitest'; import { oidc } from './client.store.js'; @@ -39,6 +39,8 @@ vi.stubGlobal( })(), ); +const parRequestUri = 'urn:ietf:params:oauth:request_uri:test-par-request-uri'; + const server = setupServer( // P1 Revoke http.post('*/as/authorize', async () => { @@ -57,6 +59,9 @@ const server = setupServer( }), ), http.post('*/as/revoke', async () => HttpResponse.json(null, { status: 204 })), + http.post('*/as/par', async () => + HttpResponse.json({ request_uri: parRequestUri, expires_in: 60 }, { status: 201 }), + ), http.get('*/wellknown', async () => HttpResponse.json({ issuer: 'https://api.example.com/as/issuer', @@ -65,6 +70,7 @@ const server = setupServer( userinfo_endpoint: 'https://api.example.com/as/userinfo', introspection_endpoint: 'https://api.example.com/as/introspect', revocation_endpoint: 'https://api.example.com/as/revoke', + pushed_authorization_request_endpoint: 'https://api.example.com/as/par', response_types_supported: ['code', 'token', 'id_token', 'code id_token'], response_modes_supported: ['query', 'fragment', 'form_post', 'pi.flow'], }), @@ -279,3 +285,93 @@ describe('PingOne token get method', async () => { expect(tokens.accessToken).toBe('abcdefghijklmnop'); }); }); + +describe('authorize.url() with PAR enabled', async () => { + const configWithPar: OidcConfig = { + clientId: '123456789', + redirectUri: 'https://example.com/callback.html', + scope: 'openid profile', + serverConfig: { + wellknown: 'https://api.example.com/wellknown', + }, + responseType: 'code', + par: true, + }; + + beforeEach(() => { + customStorage.remove(storageKey); + }); + + it('returns a slim authorize URL with client_id and request_uri only', async () => { + const oidcClient = await oidc({ config: configWithPar, storage: customStorageConfig }); + + if ('error' in oidcClient) { + throw new Error('Error creating OIDC Client'); + } + + const url = await oidcClient.authorize.url(); + + if (typeof url !== 'string') { + expect.fail(`Expected string URL, got: ${JSON.stringify(url)}`); + } + + const parsed = new URL(url); + expect(parsed.searchParams.get('client_id')).toBe('123456789'); + expect(parsed.searchParams.get('request_uri')).toBe(parRequestUri); + expect(parsed.searchParams.has('scope')).toBe(false); + expect(parsed.searchParams.has('code_challenge')).toBe(false); + expect(parsed.searchParams.has('redirect_uri')).toBe(false); + }); + + it('returns wellknown_error when PAR endpoint is missing from wellknown', async () => { + server.use( + http.get('*/wellknown', async () => + HttpResponse.json({ + issuer: 'https://api.example.com/as/issuer', + authorization_endpoint: 'https://api.example.com/as/authorize', + token_endpoint: 'https://api.example.com/as/token', + userinfo_endpoint: 'https://api.example.com/as/userinfo', + introspection_endpoint: 'https://api.example.com/as/introspect', + revocation_endpoint: 'https://api.example.com/as/revoke', + }), + ), + ); + + const oidcClient = await oidc({ config: configWithPar, storage: customStorageConfig }); + + if ('error' in oidcClient) { + throw new Error('Error creating OIDC Client'); + } + + const result = await oidcClient.authorize.url(); + + if (typeof result === 'string') { + expect.fail('Expected error, got URL string'); + } + expect(result.type).toBe('wellknown_error'); + }); + + it('returns network_error when PAR endpoint returns an error', async () => { + server.use( + http.post('*/as/par', async () => + HttpResponse.json( + { error: 'invalid_client', error_description: 'Client authentication failed' }, + { status: 400 }, + ), + ), + ); + + const oidcClient = await oidc({ config: configWithPar, storage: customStorageConfig }); + + if ('error' in oidcClient) { + throw new Error('Error creating OIDC Client'); + } + + const result = await oidcClient.authorize.url(); + + if (typeof result === 'string') { + expect.fail('Expected error, got URL string'); + } + expect(result.type).toBe('network_error'); + }); +}); diff --git a/packages/oidc-client/src/lib/client.store.ts b/packages/oidc-client/src/lib/client.store.ts index da6c3de99c..aa7522a382 100644 --- a/packages/oidc-client/src/lib/client.store.ts +++ b/packages/oidc-client/src/lib/client.store.ts @@ -8,9 +8,10 @@ import { logger as loggerFn } from '@forgerock/sdk-logger'; import { createAuthorizeUrl } from '@forgerock/sdk-oidc'; import { createStorage } from '@forgerock/storage'; import { Micro } from 'effect'; -import { exitIsFail, exitIsSuccess } from 'effect/Micro'; +import { causeIsDie, exitIsFail, exitIsSuccess } from 'effect/Micro'; import { authorizeµ } from './authorize.request.js'; +import { parAuthorizeµ } from './authorize.request.utils.js'; import { buildTokenExchangeµ } from './exchange.request.js'; import { createClientStore, createTokenError } from './client.store.utils.js'; import { oidcApi } from './oidc.api.js'; @@ -106,14 +107,6 @@ export async function oidc({ * @returns {Promise} - Returns a promise that resolves to the authorization URL or an error. */ url: async (options?: GetAuthorizationUrlOptions): Promise => { - const optionsWithDefaults = { - clientId: config.clientId, - redirectUri: config.redirectUri, - scope: config.scope || 'openid', - responseType: config.responseType || 'code', - ...options, - }; - const state = store.getState(); const wellknown = wellknownSelector(wellknownUrl, state); @@ -124,6 +117,34 @@ export async function oidc({ }; } + if (config.par) { + const result = await Micro.runPromiseExit( + parAuthorizeµ(wellknown, config, store, options), + ); + + if (exitIsSuccess(result)) { + return result.value; + } else if (exitIsFail(result)) { + return result.cause.error; + } else { + const defect = causeIsDie(result.cause) ? result.cause.defect : undefined; + return { + error: 'PAR authorization failure', + message: + defect instanceof Error ? defect.message : String(defect ?? 'Unknown defect'), + type: 'auth_error', + }; + } + } + + const optionsWithDefaults = { + clientId: config.clientId, + redirectUri: config.redirectUri, + scope: config.scope || 'openid', + responseType: config.responseType || 'code', + ...options, + }; + return createAuthorizeUrl(wellknown.authorization_endpoint, optionsWithDefaults); }, @@ -155,9 +176,11 @@ export async function oidc({ } else if (exitIsFail(result)) { return result.cause.error; } else { + const defect = causeIsDie(result.cause) ? result.cause.defect : undefined; return { error: 'Authorization failure', - error_description: result.cause.message, + error_description: + defect instanceof Error ? defect.message : String(defect ?? 'Unknown defect'), type: 'auth_error', }; } @@ -212,9 +235,10 @@ export async function oidc({ } else if (exitIsFail(result)) { return result.cause.error; } else { + const defect = causeIsDie(result.cause) ? result.cause.defect : undefined; return { error: 'Token Exchange failure', - message: result.cause.message, + message: defect instanceof Error ? defect.message : String(defect ?? 'Unknown defect'), type: 'exchange_error', }; } @@ -311,9 +335,11 @@ export async function oidc({ } else if (exitIsFail(result)) { return result.cause.error; } else { + const defect = causeIsDie(result.cause) ? result.cause.defect : undefined; return { error: 'Background token renewal failed', - error_description: result.cause.message, + error_description: + defect instanceof Error ? defect.message : String(defect ?? 'Unknown defect'), type: 'auth_error', }; } @@ -407,9 +433,10 @@ export async function oidc({ } else if (exitIsFail(result)) { return result.cause.error; } else { + const defect = causeIsDie(result.cause) ? result.cause.defect : undefined; return { error: 'Token revocation failure', - message: result.cause.message, + message: defect instanceof Error ? defect.message : String(defect ?? 'Unknown defect'), type: 'auth_error', }; } @@ -482,9 +509,10 @@ export async function oidc({ } else if (exitIsFail(result)) { return result.cause.error; } else { + const defect = causeIsDie(result.cause) ? result.cause.defect : undefined; return { error: 'User Info retrieval failure', - message: result.cause.message, + message: defect instanceof Error ? defect.message : String(defect ?? 'Unknown defect'), type: 'auth_error', }; } @@ -537,9 +565,10 @@ export async function oidc({ } else if (exitIsFail(result)) { return result.cause.error; } else { + const defect = causeIsDie(result.cause) ? result.cause.defect : undefined; return { error: 'Logout_Failure', - message: result.cause.message, + message: defect instanceof Error ? defect.message : String(defect ?? 'Unknown defect'), type: 'auth_error', }; } diff --git a/packages/oidc-client/src/lib/config.types.ts b/packages/oidc-client/src/lib/config.types.ts index d560edc9fb..3f25a8fa39 100644 --- a/packages/oidc-client/src/lib/config.types.ts +++ b/packages/oidc-client/src/lib/config.types.ts @@ -16,6 +16,7 @@ export interface OidcConfig extends AsyncLegacyConfigOptions { timeout?: number; }; responseType?: ResponseType; + par?: boolean; } export interface OauthTokens { diff --git a/packages/oidc-client/src/lib/oidc.api.ts b/packages/oidc-client/src/lib/oidc.api.ts index 86f6408d6c..12f7fa0a95 100644 --- a/packages/oidc-client/src/lib/oidc.api.ts +++ b/packages/oidc-client/src/lib/oidc.api.ts @@ -23,6 +23,7 @@ import type { logger as loggerFn } from '@forgerock/sdk-logger'; import type { TokenExchangeResponse } from './exchange.types.js'; import type { AuthorizationSuccess, AuthorizeSuccessResponse } from './authorize.request.types.js'; import type { UserInfoResponse } from './client.types.js'; +import type { PushAuthorizationResponse } from './par.types.js'; interface Extras { requestMiddleware: RequestMiddleware[]; @@ -102,6 +103,71 @@ export const oidcApi = createApi({ return response as { data: AuthorizeSuccessResponse }; }, }), + par: builder.mutation({ + queryFn: async ({ endpoint, body }, api, _, baseQuery) => { + const { requestMiddleware, logger } = api.extra as Extras; + + const request: FetchArgs = { + url: endpoint, + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body, + }; + + logger.debug('OIDC PAR API request', request); + + const response = await initQuery(request, 'par') + .applyMiddleware(requestMiddleware) + .applyQuery(async (req: FetchArgs) => await baseQuery(req)); + + if (response.error) { + const responseError = response.error; + let message: string; + + if ( + responseError.data && + typeof responseError.data === 'object' && + 'error_description' in responseError.data && + typeof responseError.data.error_description === 'string' + ) { + message = responseError.data.error_description; + } else { + message = `Failed to push authorization request: ${responseError.status}`; + } + + logger.error('PAR API error', message); + + response.error.data = { + error: 'PAR_ERROR', + error_description: message, + type: 'network_error', + }; + + return response; + } + + if (!response.data || !('request_uri' in response.data)) { + return { + error: { + status: 'CUSTOM_ERROR', + error: 'PAR_ERROR', + data: { + error: 'PAR_ERROR', + error_description: "PAR response missing required 'request_uri' field", + type: 'network_error', + }, + } as FetchBaseQueryError, + }; + } + + logger.debug('OIDC PAR API response', response); + + return response as { data: PushAuthorizationResponse }; + }, + }), authorizeIframe: builder.mutation({ queryFn: async ({ url }, api) => { const { requestMiddleware, logger } = api.extra as Extras; diff --git a/packages/oidc-client/src/lib/par.types.ts b/packages/oidc-client/src/lib/par.types.ts new file mode 100644 index 0000000000..ae0cf25985 --- /dev/null +++ b/packages/oidc-client/src/lib/par.types.ts @@ -0,0 +1,11 @@ +/* + * Copyright (c) 2025 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. + */ + +export interface PushAuthorizationResponse { + request_uri: string; + expires_in: number; +} diff --git a/packages/oidc-client/src/types.ts b/packages/oidc-client/src/types.ts index 8e6750b0a8..c1e5c71b95 100644 --- a/packages/oidc-client/src/types.ts +++ b/packages/oidc-client/src/types.ts @@ -7,6 +7,7 @@ export * from './lib/client.types.js'; export * from './lib/config.types.js'; export * from './lib/authorize.request.types.js'; export * from './lib/exchange.types.js'; +export type { PushAuthorizationResponse } from './lib/par.types.js'; export type { GenericError, diff --git a/packages/oidc-client/tsconfig.lib.json b/packages/oidc-client/tsconfig.lib.json index d4b07d31d8..d689866941 100644 --- a/packages/oidc-client/tsconfig.lib.json +++ b/packages/oidc-client/tsconfig.lib.json @@ -19,6 +19,9 @@ }, "include": ["src/**/*.ts"], "references": [ + { + "path": "../sdk-utilities/tsconfig.lib.json" + }, { "path": "../sdk-effects/storage/tsconfig.lib.json" }, diff --git a/packages/sdk-effects/oidc/src/lib/authorize.effects.ts b/packages/sdk-effects/oidc/src/lib/authorize.effects.ts index 4881f2dd34..c28c1e79cb 100644 --- a/packages/sdk-effects/oidc/src/lib/authorize.effects.ts +++ b/packages/sdk-effects/oidc/src/lib/authorize.effects.ts @@ -14,6 +14,35 @@ import { generateAndStoreAuthUrlValues } from './state-pkce.effects.js'; import type { GetAuthorizationUrlOptions } from '@forgerock/sdk-types'; +/** + * Builds URLSearchParams for an OAuth2/OIDC authorization request. + * Used for both direct authorize URL construction and PAR body. + * + * Standard OAuth fields always take precedence over any conflicting `query` + * entries — `query` is applied first and then overwritten by the known params. + */ +export function buildAuthorizeParams( + options: GetAuthorizationUrlOptions & { challenge: string; state: string }, +): URLSearchParams { + // Apply caller-supplied extra query params first so that standard OAuth + // fields always win when there is a key collision (e.g. a query.client_id + // cannot hijack the real client_id). + const params = new URLSearchParams(options.query); + + params.set('client_id', options.clientId); + params.set('response_type', options.responseType); + params.set('scope', options.scope); + params.set('redirect_uri', options.redirectUri); + params.set('code_challenge', options.challenge); + params.set('code_challenge_method', 'S256'); + params.set('state', options.state); + + if (options.responseMode) params.set('response_mode', options.responseMode); + if (options.prompt) params.set('prompt', options.prompt); + + return params; +} + /** * @function createAuthorizeUrl - Create authorization URL for initial call to DaVinci * @param baseUrl {string} @@ -39,16 +68,9 @@ export async function createAuthorizeUrl( const challenge = await createChallenge(authorizeUrlOptions.verifier); - const requestParams = new URLSearchParams({ - ...options.query, - code_challenge: challenge, - code_challenge_method: 'S256', - client_id: options.clientId, - prompt: options.prompt || '', - redirect_uri: options.redirectUri, - response_mode: options.responseMode || '', - response_type: options.responseType, - scope: options.scope, + const requestParams = buildAuthorizeParams({ + ...options, + challenge, state: authorizeUrlOptions.state, }); diff --git a/packages/sdk-effects/oidc/src/lib/authorize.test.ts b/packages/sdk-effects/oidc/src/lib/authorize.test.ts index 6dc03c43a7..a66d0e6cb7 100644 --- a/packages/sdk-effects/oidc/src/lib/authorize.test.ts +++ b/packages/sdk-effects/oidc/src/lib/authorize.test.ts @@ -7,7 +7,7 @@ import type { GenerateAndStoreAuthUrlValues } from '@forgerock/sdk-types'; import { describe, expect, it, beforeEach } from 'vitest'; -import { createAuthorizeUrl } from './authorize.effects.js'; +import { buildAuthorizeParams, createAuthorizeUrl } from './authorize.effects.js'; import { getStorageKey } from './state-pkce.effects.js'; const mockSessionStorage = (() => { @@ -136,3 +136,52 @@ describe('createAuthorizeUrl', () => { expect(parsedOptions).toHaveProperty('verifier'); }); }); + +describe('buildAuthorizeParams', () => { + it('returns URLSearchParams with required OAuth fields', () => { + const params = buildAuthorizeParams({ + clientId: 'test-client', + redirectUri: 'https://example.com/cb', + scope: 'openid profile', + responseType: 'code', + challenge: 'abc123challenge', + state: 'randomstate', + }); + + expect(params.get('client_id')).toBe('test-client'); + expect(params.get('redirect_uri')).toBe('https://example.com/cb'); + expect(params.get('scope')).toBe('openid profile'); + expect(params.get('response_type')).toBe('code'); + expect(params.get('code_challenge')).toBe('abc123challenge'); + expect(params.get('code_challenge_method')).toBe('S256'); + expect(params.get('state')).toBe('randomstate'); + }); + + it('includes response_mode when provided', () => { + const params = buildAuthorizeParams({ + clientId: 'test-client', + redirectUri: 'https://example.com/cb', + scope: 'openid', + responseType: 'code', + challenge: 'abc123', + state: 'state1', + responseMode: 'pi.flow', + }); + + expect(params.get('response_mode')).toBe('pi.flow'); + }); + + it('omits optional fields when not provided', () => { + const params = buildAuthorizeParams({ + clientId: 'test-client', + redirectUri: 'https://example.com/cb', + scope: 'openid', + responseType: 'code', + challenge: 'abc123', + state: 'state1', + }); + + expect(params.has('response_mode')).toBe(false); + expect(params.has('prompt')).toBe(false); + }); +}); diff --git a/packages/sdk-effects/sdk-request-middleware/src/lib/request-mware.derived.ts b/packages/sdk-effects/sdk-request-middleware/src/lib/request-mware.derived.ts index 8de893dfe9..a05fb52a4d 100644 --- a/packages/sdk-effects/sdk-request-middleware/src/lib/request-mware.derived.ts +++ b/packages/sdk-effects/sdk-request-middleware/src/lib/request-mware.derived.ts @@ -23,6 +23,7 @@ export const actionTypes = { // OIDC authorize: 'AUTHORIZE', + par: 'PAR', tokenExchange: 'TOKEN_EXCHANGE', revoke: 'REVOKE', userInfo: 'USER_INFO', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index babd5d7de7..2bab8fe7ba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -517,6 +517,9 @@ importers: '@forgerock/sdk-types': specifier: workspace:* version: link:../sdk-types + '@forgerock/sdk-utilities': + specifier: workspace:* + version: link:../sdk-utilities '@forgerock/storage': specifier: workspace:* version: link:../sdk-effects/storage