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/index.html b/e2e/oidc-app/src/index.html
index 862f53f9d6..dcc720ee3b 100644
--- a/e2e/oidc-app/src/index.html
+++ b/e2e/oidc-app/src/index.html
@@ -12,6 +12,7 @@
OIDC Client E2E Test Index | Ping Identity JavaScript SDK
diff --git a/e2e/oidc-app/src/par/index.html b/e2e/oidc-app/src/par/index.html
new file mode 100644
index 0000000000..86f24176a5
--- /dev/null
+++ b/e2e/oidc-app/src/par/index.html
@@ -0,0 +1,61 @@
+
+
+
+ E2E Test | Ping Identity JavaScript SDK
+
+
+
+
+
+
Home
+
OIDC App | PAR Login (Pushed Authorization Request)
+
+ Client: ParClient — PAR enabled. Authorize params are sent via
+ back-channel POST to /par first, then a slim URL (client_id + request_uri
+ only) is used for the authorize redirect.
+
+
+
Step 1: Establish AM Session (Journey: Login)
+
+ Background PAR auth requires an existing AM session. Log in via the Login journey first.
+
+
+
+
+
Step 2: PAR OAuth
+
Login (Background — PAR + iframe)
+
Login (Redirect — PAR slim URL)
+
Get Tokens (Local)
+
Get Tokens (Background)
+
Renew Tokens
+
Logout
+
User Info
+
Revoke Token
+
Start Over
+
+
+
+
diff --git a/e2e/oidc-app/src/par/main.ts b/e2e/oidc-app/src/par/main.ts
new file mode 100644
index 0000000000..a94da7fadd
--- /dev/null
+++ b/e2e/oidc-app/src/par/main.ts
@@ -0,0 +1,82 @@
+/*
+ *
+ * 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 { oidcApp } from '../utils/oidc-app.js';
+
+const AM_BASE = 'https://openam-sdks.forgeblocks.com/am';
+const REALM = 'alpha';
+
+const urlParams = new URLSearchParams(window.location.search);
+const wellknown = urlParams.get('wellknown');
+
+const config = {
+ clientId: 'ParClient',
+ redirectUri: 'http://localhost:8443/par/',
+ scope: 'openid profile email',
+ par: true,
+ serverConfig: {
+ wellknown:
+ wellknown ||
+ 'https://openam-sdks.forgeblocks.com/am/oauth2/alpha/.well-known/openid-configuration',
+ },
+};
+
+// Run journey Login to establish an AM session before background PAR auth
+async function runLoginJourney(username: string, password: string): Promise {
+ const authenticateUrl = `${AM_BASE}/json/realms/root/realms/${REALM}/authenticate?authIndexType=service&authIndexValue=Login`;
+
+ // Step 1: start the journey
+ const initRes = await fetch(authenticateUrl, {
+ method: 'POST',
+ credentials: 'include',
+ headers: { 'Content-Type': 'application/json', 'Accept-API-Version': 'resource=2.1' },
+ body: '{}',
+ });
+ const initJson = await initRes.json();
+
+ if (initJson.successUrl) return; // already authenticated
+
+ // Fill NameCallback + PasswordCallback
+ for (const cb of initJson.callbacks ?? []) {
+ if (cb.type === 'NameCallback') cb.input[0].value = username;
+ if (cb.type === 'PasswordCallback') cb.input[0].value = password;
+ }
+
+ // Step 2: submit credentials
+ const submitRes = await fetch(authenticateUrl, {
+ method: 'POST',
+ credentials: 'include',
+ headers: { 'Content-Type': 'application/json', 'Accept-API-Version': 'resource=2.1' },
+ body: JSON.stringify(initJson),
+ });
+ const submitJson = await submitRes.json();
+
+ if (!submitJson.tokenId && !submitJson.successUrl) {
+ throw new Error(submitJson.message || 'Login failed');
+ }
+}
+
+const journeyForm = document.getElementById('journey-form') as HTMLFormElement;
+const journeyStatus = document.getElementById('journey-status') as HTMLParagraphElement;
+const backgroundBtn = document.getElementById('login-background') as HTMLButtonElement;
+
+journeyForm.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const username = (document.getElementById('username') as HTMLInputElement).value;
+ const password = (document.getElementById('password') as HTMLInputElement).value;
+ journeyStatus.textContent = 'Logging in…';
+ try {
+ await runLoginJourney(username, password);
+ journeyStatus.textContent = '✓ Session established — background login now available.';
+ backgroundBtn.disabled = false;
+ } catch (err) {
+ journeyStatus.textContent = `✗ ${err instanceof Error ? err.message : 'Login failed'}`;
+ }
+});
+
+oidcApp({ config, urlParams });
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-app/vite.config.ts b/e2e/oidc-app/vite.config.ts
index d2a956b1a9..c40a554f9f 100644
--- a/e2e/oidc-app/vite.config.ts
+++ b/e2e/oidc-app/vite.config.ts
@@ -4,7 +4,7 @@ import { dirname, resolve } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
-const pages = ['ping-am', 'ping-one'];
+const pages = ['ping-am', 'ping-one', 'par'];
export default defineConfig(() => ({
root: __dirname + '/src',
cacheDir: '../../node_modules/.vite/e2e/oidc-app',
diff --git a/e2e/oidc-suites/src/par.spec.ts b/e2e/oidc-suites/src/par.spec.ts
new file mode 100644
index 0000000000..de224ffba2
--- /dev/null
+++ b/e2e/oidc-suites/src/par.spec.ts
@@ -0,0 +1,87 @@
+/*
+ *
+ * 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';
+
+async function loginJourney(page, username: string, password: string) {
+ await page.getByLabel('User Name').fill(username);
+ await page.getByLabel('Password').fill(password);
+ await page.getByRole('button', { name: 'Login (Journey)' }).click();
+ await expect(page.locator('#journey-status')).toContainText('Session established');
+}
+
+test.describe('PAR (Pushed Authorization Request) login tests', () => {
+ test('background login with PAR enabled (ParClient) obtains access token', async ({ page }) => {
+ const { navigate } = asyncEvents(page);
+
+ const parRequests: string[] = [];
+ page.on('request', (request) => {
+ if (request.method() === 'POST' && request.url().includes('/par')) {
+ parRequests.push(request.url());
+ }
+ });
+
+ await navigate('/par/');
+
+ // Establish AM session via the Login journey before attempting background PAR auth
+ await loginJourney(page, pingAmUsername, pingAmPassword);
+
+ // Background button is now enabled — click and wait for the iframe to return a code
+ await page.getByRole('button', { name: /Login \(Background/ }).click();
+ await expect(page.locator('#accessToken-0')).not.toBeEmpty();
+
+ // PAR POST was made for the background request
+ expect(parRequests.length).toBeGreaterThan(0);
+ });
+
+ test('redirect login with PAR enabled (ParClient) obtains access token and uses slim authorize URL', async ({
+ page,
+ }) => {
+ const { clickWithRedirect, navigate } = asyncEvents(page);
+
+ const parRequests: string[] = [];
+ const parAuthorizeUrls: string[] = [];
+
+ page.on('request', (request) => {
+ if (request.method() === 'POST' && request.url().includes('/par')) {
+ parRequests.push(request.url());
+ }
+ // Capture the slim PAR authorize redirect — has request_uri, not scope
+ if (request.url().includes('/authorize') && request.url().includes('request_uri=')) {
+ parAuthorizeUrls.push(request.url());
+ }
+ });
+
+ await navigate('/par/');
+
+ 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/par/**');
+
+ 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);
+
+ // Slim authorize URL contains only client_id + request_uri (not scope/code_challenge)
+ expect(parAuthorizeUrls.length).toBeGreaterThan(0);
+ const authorizeUrl = new URL(parAuthorizeUrls[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/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..815099d8b0 100644
--- a/packages/oidc-client/src/lib/authorize.request.ts
+++ b/packages/oidc-client/src/lib/authorize.request.ts
@@ -11,6 +11,8 @@ import {
createAuthorizeUrlµ,
buildAuthorizeOptionsµ,
createAuthorizeErrorµ,
+ parAuthorizeµ,
+ toAuthorizationError,
} from './authorize.request.utils.js';
import { oidcApi } from './oidc.api.js';
@@ -20,14 +22,140 @@ import type { AuthorizationError, AuthorizationSuccess } from './authorize.reque
import type { OidcConfig } from './config.types.js';
/**
- * @function authorizeµ
- * @description Creates an authorization URL for the OIDC client.
- * @param {WellKnownResponse} wellknown - The well-known configuration for the OIDC server.
- * @param {OidcConfig} config - The OIDC client configuration.
- * @param {CustomLogger} log - The logger instance for logging debug information.
- * @param {ClientStore} store - The Redux store instance for managing OIDC state.
- * @param {GetAuthorizationUrlOptions} options - Optional parameters for the authorization request.
- * @returns {Micro.Micro} - A micro effect that resolves to the authorization response.
+ * Dispatches the authorize URL to the appropriate endpoint (pi.flow fetch or iframe).
+ * Shared by the standard and PAR authorize paths — both ultimately need to POST a URL
+ * to get back a code+state.
+ */
+function dispatchAuthorizeµ(
+ url: string,
+ options: GetAuthorizationUrlOptions,
+ wellknown: WellknownResponse,
+ store: ClientStore,
+ log: CustomLogger,
+): Micro.Micro {
+ if (options.responseMode === 'pi.flow') {
+ /**
+ * PingOne servers do not support redirection through iframes (X-Frame-Options: DENY).
+ * Use a direct fetch instead.
+ */
+ return Micro.tryPromise({
+ try: () => store.dispatch(oidcApi.endpoints.authorizeFetch.initiate({ url })),
+ catch: (error): AuthorizationError => ({
+ error: 'AUTHORIZE_DISPATCH_ERROR',
+ error_description:
+ error instanceof Error ? error.message : 'Failed to dispatch authorize request',
+ type: 'network_error',
+ }),
+ }).pipe(
+ Micro.flatMap(
+ ({ error, data }): Micro.Micro => {
+ if (error) {
+ if (!('status' in error)) {
+ return Micro.fail({
+ error: error.code || 'Unknown_Error',
+ error_description:
+ error.message || 'An unknown error occurred during authorization',
+ type: 'unknown_error',
+ });
+ }
+
+ if (!('data' in error)) {
+ return Micro.fail({
+ error: 'Unknown_Error',
+ error_description: 'An unknown error occurred during authorization',
+ type: 'unknown_error',
+ });
+ }
+
+ const errorDetails = toAuthorizationError(error.data);
+
+ if ('statusText' in error && error.statusText === 'CONFIGURATION_ERROR') {
+ return Micro.fail(errorDetails);
+ }
+
+ // Remove pi.flow before building a redirect fallback URL
+ const redirectOptions = { ...options };
+ delete redirectOptions.responseMode;
+
+ return createAuthorizeErrorµ(errorDetails, wellknown, redirectOptions);
+ }
+
+ log.debug('Received success response', data);
+
+ if (data.authorizeResponse) {
+ return Micro.succeed(data.authorizeResponse);
+ }
+
+ return Micro.fail({
+ error: 'Unknown_Error',
+ error_description: 'Response schema was not recognized',
+ type: 'unknown_error',
+ });
+ },
+ ),
+ );
+ }
+
+ /**
+ * Traditional iframe-based authorize for PingAM and similar servers.
+ */
+ return Micro.tryPromise({
+ try: () => store.dispatch(oidcApi.endpoints.authorizeIframe.initiate({ url })),
+ catch: (error): AuthorizationError => ({
+ error: 'AUTHORIZE_DISPATCH_ERROR',
+ error_description:
+ error instanceof Error ? error.message : 'Failed to dispatch authorize request',
+ type: 'network_error',
+ }),
+ }).pipe(
+ Micro.flatMap(
+ ({ error, data }): Micro.Micro => {
+ if (error) {
+ if (!('status' in error)) {
+ return Micro.fail({
+ error: error.code || 'Unknown_Error',
+ error_description: error.message || 'An unknown error occurred during authorization',
+ type: 'unknown_error',
+ });
+ }
+
+ if (!('data' in error)) {
+ return Micro.fail({
+ error: 'Unknown_Error',
+ error_description: 'An unknown error occurred during authorization',
+ type: 'unknown_error',
+ });
+ }
+
+ const errorDetails = toAuthorizationError(error.data);
+
+ if ('statusText' in error && error.statusText === 'CONFIGURATION_ERROR') {
+ return Micro.fail(errorDetails);
+ }
+
+ return createAuthorizeErrorµ(errorDetails, wellknown, options);
+ }
+
+ log.debug('Received success response', data);
+
+ if (data) {
+ return Micro.succeed(data);
+ }
+
+ return Micro.fail({
+ error: 'Unknown_Error',
+ error_description: 'Redirect parameters was not recognized',
+ type: 'unknown_error',
+ });
+ },
+ ),
+ );
+}
+
+/**
+ * Background authorization flow. When config.par is enabled, POSTs all authorize params
+ * to the PAR endpoint first, then dispatches the resulting slim URL (client_id + request_uri
+ * only) to the iframe or pi.flow endpoint. Otherwise builds a full authorize URL directly.
*/
export function authorizeµ(
wellknown: WellknownResponse,
@@ -35,145 +163,38 @@ export function authorizeµ(
log: CustomLogger,
store: ClientStore,
options?: GetAuthorizationUrlOptions,
-) {
+ useParFlow = config.par ?? false,
+): Micro.Micro {
+ if (useParFlow) {
+ const isPiFlow = wellknown.response_modes_supported?.includes('pi.flow');
+ const dispatchOptions: GetAuthorizationUrlOptions = {
+ clientId: config.clientId,
+ redirectUri: config.redirectUri,
+ scope: config.scope || 'openid',
+ responseType: config.responseType || 'code',
+ ...(isPiFlow && { responseMode: 'pi.flow' as const }),
+ ...options,
+ };
+
+ return parAuthorizeµ(wellknown, config, store, options).pipe(
+ Micro.tap((url) => log.debug('PAR authorize URL created', url)),
+ Micro.tapError((err) =>
+ Micro.sync(() => log.error(`PAR authorize failed [${err.type}]: ${err.error}`, err)),
+ ),
+ Micro.flatMap((url) => dispatchAuthorizeµ(url, dispatchOptions, wellknown, store, log)),
+ Micro.tapError((err) =>
+ Micro.sync(() => log.error('Error dispatching PAR authorize request', err)),
+ ),
+ );
+ }
+
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))),
- Micro.flatMap(
- ([url, options]): Micro.Micro => {
- if (options.responseMode === 'pi.flow') {
- /**
- * If we support the pi.flow field, this means we are using a PingOne server.
- * PingOne servers do not support redirection through iframes because they
- * set iframe's to DENY.
- *
- * We do not use RTK Query for this because we don't want caching, or store
- * updates, and want the request to be made similar to the iframe method below.
- *
- * This returns a Micro that resolves to the parsed response JSON.
- */
- return Micro.promise(() =>
- store.dispatch(oidcApi.endpoints.authorizeFetch.initiate({ url })),
- ).pipe(
- Micro.flatMap(
- ({ error, data }): Micro.Micro => {
- if (error) {
- // Check for serialized error
- if (!('status' in error)) {
- // This is a network or fetch error, so return it as-is
- return Micro.fail({
- error: error.code || 'Unknown_Error',
- error_description:
- error.message || 'An unknown error occurred during authorization',
- type: 'unknown_error',
- });
- }
-
- // If there is no data, this is an unknown error
- if (!('data' in error)) {
- return Micro.fail({
- error: 'Unknown_Error',
- error_description: 'An unknown error occurred during authorization',
- type: 'unknown_error',
- });
- }
-
- const errorDetails = error.data as AuthorizationError;
-
- // If the error is a configuration issue, return it as-is
- if ('statusText' in error && error.statusText === 'CONFIGURATION_ERROR') {
- return Micro.fail(errorDetails);
- }
-
- // If the error is not a configuration issue, we build a new Authorize URL
- // For redirection, we need to remove `pi.flow` from the options
- const redirectOptions = options;
- delete redirectOptions.responseMode;
-
- // Create an error with a new Authorize URL
- return createAuthorizeErrorµ(errorDetails, wellknown, options);
- }
-
- log.debug('Received success response', data);
-
- if (data.authorizeResponse) {
- // Authorization was successful
- return Micro.succeed(data.authorizeResponse);
- } else {
- // This should never be reached, but just in case
- return Micro.fail({
- error: 'Unknown_Error',
- error_description: 'Response schema was not recognized',
- type: 'unknown_error',
- });
- }
- },
- ),
- );
- } else {
- /**
- * If the response mode is not pi.flow, then we are likely using a traditional
- * redirect based server supporting iframes. An example would be PingAM.
- *
- * This returns a Micro that's either the success URL parameters or error URL
- * parameters.
- */
- return Micro.promise(() =>
- store.dispatch(oidcApi.endpoints.authorizeIframe.initiate({ url })),
- ).pipe(
- Micro.flatMap(
- ({ error, data }): Micro.Micro => {
- if (error) {
- // Check for serialized error
- if (!('status' in error)) {
- // This is a network or fetch error, so return it as-is
- return Micro.fail({
- error: error.code || 'Unknown_Error',
- error_description:
- error.message || 'An unknown error occurred during authorization',
- type: 'unknown_error',
- });
- }
-
- // If there is no data, this is an unknown error
- if (!('data' in error)) {
- return Micro.fail({
- error: 'Unknown_Error',
- error_description: 'An unknown error occurred during authorization',
- type: 'unknown_error',
- });
- }
-
- const errorDetails = error.data as AuthorizationError;
-
- // If the error is a configuration issue, return it as-is
- if ('statusText' in error && error.statusText === 'CONFIGURATION_ERROR') {
- return Micro.fail(errorDetails);
- }
-
- // This is an expected error, so combine error with a new Authorize URL
- return createAuthorizeErrorµ(errorDetails, wellknown, options);
- }
-
- log.debug('Received success response', data);
-
- if (data) {
- // Authorization was successful
- return Micro.succeed(data);
- } else {
- // This should never be reached, but just in case
- return Micro.fail({
- error: 'Unknown_Error',
- error_description: 'Redirect parameters was not recognized',
- type: 'unknown_error',
- });
- }
- },
- ),
- );
- }
- },
+ Micro.flatMap(([url, opts]) => createAuthorizeUrlµ(url, opts)),
+ Micro.tap(([url]) => log.debug('Authorize URL created', url)),
+ Micro.tapError((err) => Micro.sync(() => log.error('Error creating authorize URL', err))),
+ Micro.flatMap(([url, opts]) => dispatchAuthorizeµ(url, opts, wellknown, store, log)),
+ Micro.tapError((err) =>
+ Micro.sync(() => log.error('Error dispatching authorize request', err)),
),
);
}
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..5bfdcb93c6 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,12 @@
*/
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 * as sdkOidc from '@forgerock/sdk-oidc';
+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 +36,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 +89,136 @@ 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 prompt=none includes prompt on slim authorize URL and in PAR body',
+ () =>
+ Micro.gen(function* () {
+ const configWithPar: OidcConfig = { ...config, par: true };
+ const requestUri = 'urn:ietf:params:oauth:request_uri:prompt-none-test';
+
+ vi.stubGlobal('sessionStorage', sessionStorageStub);
+
+ // Spy on buildAuthorizeParams to capture what goes into the PAR POST body
+ const buildParamsSpy = vi.spyOn(sdkOidc, 'buildAuthorizeParams');
+
+ vi.mocked(mockStore.dispatch).mockResolvedValueOnce({
+ data: { request_uri: requestUri, expires_in: 60 },
+ } as unknown as ReturnType);
+
+ const url = yield* parAuthorizeµ(wellknownWithPar, configWithPar, mockStore, {
+ prompt: 'none',
+ });
+
+ // prompt=none must be on the slim authorize URL (RFC-compliant servers read it here)
+ expect(url).toContain('prompt=none');
+ // prompt must also be in the PAR POST body (AM reads it from stored request attributes)
+ const parBodyArg = buildParamsSpy.mock.calls[0][0] as unknown as Record;
+ expect(parBodyArg.prompt).toBe('none');
+ }),
+);
+
+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..8822245eba 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,180 @@ export function handleResponseµ(
return createAuthorizeErrorµ(response, wellknown, options);
}
}
+
+/**
+ * @function toAuthorizationError
+ * @description Safely narrows an unknown value to AuthorizationError shape.
+ * Validates that the data has the required 'error' string field, otherwise returns
+ * a default unknown error response.
+ * @param {unknown} data - The data to validate and narrow
+ * @returns {AuthorizationError}
+ */
+const KNOWN_ERROR_TYPES = new Set([
+ 'auth_error',
+ 'argument_error',
+ 'network_error',
+ 'unknown_error',
+ 'wellknown_error',
+] as const);
+
+function isKnownErrorType(value: unknown): value is AuthorizationError['type'] {
+ return typeof value === 'string' && KNOWN_ERROR_TYPES.has(value as AuthorizationError['type']);
+}
+
+export function toAuthorizationError(data: unknown): AuthorizationError {
+ if (data !== null && typeof data === 'object') {
+ const d = data as Record;
+ if (typeof d['error'] === 'string') {
+ return {
+ error: d['error'],
+ error_description: typeof d['error_description'] === 'string' ? d['error_description'] : '',
+ type: isKnownErrorType(d['type']) ? d['type'] : 'unknown_error',
+ ...(typeof d['redirectUrl'] === 'string' && { redirectUrl: d['redirectUrl'] }),
+ };
+ }
+ }
+ return {
+ error: 'Unknown_Error',
+ error_description: 'Unexpected error response shape',
+ type: 'unknown_error',
+ };
+}
+
+/**
+ * @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 { prompt, ...parBodyOptions } = options ?? {};
+
+ const body = buildAuthorizeParams({
+ ...parBodyOptions,
+ clientId: config.clientId,
+ redirectUri: config.redirectUri,
+ scope: config.scope || 'openid',
+ responseType: config.responseType || 'code',
+ challenge,
+ state: authUrlOptions.state,
+ ...(isPiFlow && { responseMode: 'pi.flow' }),
+ // AM evaluates prompt from the stored PAR body; RFC-compliant servers read it
+ // from the slim authorize URL. Send in both places to satisfy both.
+ ...(prompt && { prompt }),
+ });
+
+ return { body, storeOptions, prompt };
+ },
+ 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, prompt }) =>
+ Micro.tryPromise({
+ try: () => store.dispatch(oidcApi.endpoints.par.initiate({ endpoint: parEndpoint, body })),
+ catch: (error): AuthorizationError => ({
+ error: 'PAR_DISPATCH_ERROR',
+ error_description:
+ error instanceof Error ? error.message : 'Failed to dispatch PAR request',
+ type: 'network_error',
+ }),
+ }).pipe(Micro.map((result) => ({ result, storeOptions, prompt }))),
+ ),
+ Micro.flatMap(
+ ({
+ result: { error, data },
+ storeOptions,
+ prompt,
+ }): 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: () => {
+ // Store PKCE values only after a successful PAR POST so sessionStorage stays clean on failure
+ storeOptions();
+ },
+ catch: (err): AuthorizationError => ({
+ error: 'PAR_STORAGE_ERROR',
+ error_description:
+ err instanceof Error ? err.message : 'Failed to store PAR session options',
+ type: 'unknown_error',
+ }),
+ }).pipe(
+ Micro.andThen(() =>
+ 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');
+ if (prompt) authorizeUrl.searchParams.set('prompt', prompt);
+ 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: 'unknown_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..40ebb64ec4 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, afterEach, afterAll, beforeAll } 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,319 @@ 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');
+ });
+});
+
+describe('PAR factory validation', async () => {
+ it('returns argument_error when wellknown requires PAR but config.par is explicitly false', 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',
+ pushed_authorization_request_endpoint: 'https://api.example.com/as/par',
+ require_pushed_authorization_requests: true,
+ }),
+ ),
+ );
+
+ const configParFalse: OidcConfig = {
+ clientId: '123456789',
+ redirectUri: 'https://example.com/callback.html',
+ scope: 'openid profile',
+ serverConfig: { wellknown: 'https://api.example.com/wellknown' },
+ responseType: 'code',
+ par: false,
+ };
+
+ const result = await oidc({ config: configParFalse, storage: customStorageConfig });
+
+ if (!('error' in result)) {
+ expect.fail('Expected error, got client');
+ }
+ expect(result.type).toBe('argument_error');
+ });
+
+ it('succeeds when wellknown requires PAR and config.par is true', 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',
+ pushed_authorization_request_endpoint: 'https://api.example.com/as/par',
+ require_pushed_authorization_requests: true,
+ }),
+ ),
+ );
+
+ const configParTrue: OidcConfig = {
+ clientId: '123456789',
+ redirectUri: 'https://example.com/callback.html',
+ scope: 'openid profile',
+ serverConfig: { wellknown: 'https://api.example.com/wellknown' },
+ responseType: 'code',
+ par: true,
+ };
+
+ const result = await oidc({ config: configParTrue, storage: customStorageConfig });
+ expect('error' in result).toBe(false);
+ });
+
+ it('succeeds when wellknown requires PAR and config.par is unset (implicit opt-in)', 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',
+ pushed_authorization_request_endpoint: 'https://api.example.com/as/par',
+ require_pushed_authorization_requests: true,
+ }),
+ ),
+ );
+
+ const configParUnset: OidcConfig = {
+ clientId: '123456789',
+ redirectUri: 'https://example.com/callback.html',
+ scope: 'openid profile',
+ serverConfig: { wellknown: 'https://api.example.com/wellknown' },
+ responseType: 'code',
+ };
+
+ const result = await oidc({ config: configParUnset, storage: customStorageConfig });
+ expect('error' in result).toBe(false);
+ });
+
+ it('uses PAR when wellknown requires it and config.par is unset', 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',
+ pushed_authorization_request_endpoint: 'https://api.example.com/as/par',
+ require_pushed_authorization_requests: true,
+ }),
+ ),
+ );
+
+ const configParUnset: OidcConfig = {
+ clientId: '123456789',
+ redirectUri: 'https://example.com/callback.html',
+ scope: 'openid profile',
+ serverConfig: { wellknown: 'https://api.example.com/wellknown' },
+ responseType: 'code',
+ // par deliberately omitted
+ };
+
+ const result = await oidc({ config: configParUnset, storage: customStorageConfig });
+
+ if ('error' in result) {
+ expect.fail('Expected client, got error');
+ }
+
+ const url = await result.authorize.url();
+
+ if (typeof url !== 'string') {
+ expect.fail(`Expected string URL, got: ${JSON.stringify(url)}`);
+ }
+
+ const parsed = new URL(url);
+ expect(parsed.searchParams.has('request_uri')).toBe(true);
+ expect(parsed.searchParams.has('client_id')).toBe(true);
+ expect(parsed.searchParams.has('scope')).toBe(false);
+ expect(parsed.searchParams.has('code_challenge')).toBe(false);
+ });
+});
+
+describe('authorize.background() 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('uses slim PAR authorize URL for pi.flow background request', async () => {
+ const result = await oidc({ config: configWithPar, storage: customStorageConfig });
+
+ if ('error' in result) {
+ expect.fail('Expected client, got error');
+ }
+
+ const response = await result.authorize.background({
+ clientId: configWithPar.clientId,
+ redirectUri: configWithPar.redirectUri,
+ scope: configWithPar.scope,
+ responseType: 'code',
+ responseMode: 'pi.flow',
+ });
+
+ if ('error' in response) {
+ expect.fail(`Expected success, got error: ${JSON.stringify(response)}`);
+ }
+
+ expect(response.code).toBeDefined();
+ expect(response.state).toBeDefined();
+ });
+});
+
+describe('authorize.url() with PAR enabled on non-pi.flow server', async () => {
+ beforeEach(() => {
+ customStorage.remove(storageKey);
+ });
+
+ it('returns slim PAR authorize URL for iframe-based server', 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',
+ 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'],
+ }),
+ ),
+ );
+
+ 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,
+ };
+
+ 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);
+ });
+});
diff --git a/packages/oidc-client/src/lib/client.store.ts b/packages/oidc-client/src/lib/client.store.ts
index da6c3de99c..4108d1b18a 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';
@@ -92,8 +93,22 @@ export async function oidc({
if (error || !data) {
log.error(`Error fetching wellknown config. Please check the URL: ${wellknownUrl}`);
+ return {
+ error: `Failed to fetch well-known configuration from: ${wellknownUrl}`,
+ type: 'wellknown_error',
+ };
+ }
+
+ if (data?.require_pushed_authorization_requests && config.par === false) {
+ return {
+ error:
+ 'The authorization server requires Pushed Authorization Requests (PAR). Set config.par to true or omit it.',
+ type: 'argument_error',
+ };
}
+ const useParFlow = config.par ?? data?.require_pushed_authorization_requests === true;
+
return {
/**
* An object containing methods for the creation, and background use, of the authorization URL
@@ -106,14 +121,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 +131,45 @@ export async function oidc({
};
}
+ if (useParFlow) {
+ const result = await Micro.runPromiseExit(
+ parAuthorizeµ(wellknown, config, store, options).pipe(
+ Micro.tapError((err) =>
+ Micro.sync(() =>
+ log.error(`PAR authorize.url() failed [${err.type}]: ${err.error}`, err),
+ ),
+ ),
+ ),
+ );
+
+ if (exitIsSuccess(result)) {
+ return result.value;
+ } else if (exitIsFail(result)) {
+ const authErr = result.cause.error;
+ return {
+ error: authErr.error,
+ message: authErr.error_description,
+ type: authErr.type,
+ };
+ } 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);
},
@@ -147,7 +193,7 @@ export async function oidc({
}
const result = await Micro.runPromiseExit(
- await authorizeµ(wellknown, config, log, store, options),
+ await authorizeµ(wellknown, config, log, store, options, useParFlow),
);
if (exitIsSuccess(result)) {
@@ -155,9 +201,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 +260,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',
};
}
@@ -277,6 +326,7 @@ export async function oidc({
log,
store,
authorizeOptions,
+ useParFlow,
).pipe(
Micro.flatMap((response): Micro.Micro => {
return buildTokenExchangeµ({
@@ -311,9 +361,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 +459,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 +535,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 +591,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 b0fdeec512..2ed394efe0 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