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

+ + + + + + + + + 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