From 8d6743beda35a59d38c0026beab5e4e3dc873df6 Mon Sep 17 00:00:00 2001 From: Ryan Bas Date: Mon, 11 May 2026 10:47:32 -0600 Subject: [PATCH 01/19] feat(oidc-client): add-par-support Add support for par in oidc client. --- .changeset/some-shirts-joke.md | 9 ++ e2e/am-mock-api/src/app/constants.js | 1 + e2e/am-mock-api/src/app/responses.js | 5 + e2e/am-mock-api/src/app/routes.auth.js | 5 + e2e/oidc-app/src/utils/oidc-app.ts | 5 +- e2e/oidc-suites/src/par.spec.ts | 56 ++++++++ .../oidc-client/api-report/oidc-client.api.md | 18 +++ .../api-report/oidc-client.types.api.md | 18 +++ packages/oidc-client/package.json | 1 + .../oidc-client/src/lib/authorize.request.ts | 12 +- .../src/lib/authorize.request.utils.test.ts | 134 +++++++++++++++++- .../src/lib/authorize.request.utils.ts | 129 ++++++++++++++++- .../oidc-client/src/lib/client.store.test.ts | 98 ++++++++++++- packages/oidc-client/src/lib/client.store.ts | 59 ++++++-- packages/oidc-client/src/lib/config.types.ts | 1 + packages/oidc-client/src/lib/oidc.api.ts | 66 +++++++++ packages/oidc-client/src/lib/par.types.ts | 11 ++ packages/oidc-client/src/types.ts | 1 + packages/oidc-client/tsconfig.lib.json | 3 + .../oidc/src/lib/authorize.effects.ts | 42 ++++-- .../oidc/src/lib/authorize.test.ts | 51 ++++++- .../src/lib/request-mware.derived.ts | 1 + pnpm-lock.yaml | 3 + 23 files changed, 693 insertions(+), 36 deletions(-) create mode 100644 .changeset/some-shirts-joke.md create mode 100644 e2e/oidc-suites/src/par.spec.ts create mode 100644 packages/oidc-client/src/lib/par.types.ts diff --git a/.changeset/some-shirts-joke.md b/.changeset/some-shirts-joke.md new file mode 100644 index 0000000000..f9c2399f3f --- /dev/null +++ b/.changeset/some-shirts-joke.md @@ -0,0 +1,9 @@ +--- +'@forgerock/sdk-request-middleware': minor +'@forgerock/sdk-oidc': minor +'@forgerock/davinci-client': minor +'@forgerock/oidc-client': minor +'am-mock-api': patch +--- + +Add support for PAR in oidc-client requests for redirect flows diff --git a/e2e/am-mock-api/src/app/constants.js b/e2e/am-mock-api/src/app/constants.js index 6e7b8240a8..cdd6edc5a5 100644 --- a/e2e/am-mock-api/src/app/constants.js +++ b/e2e/am-mock-api/src/app/constants.js @@ -9,6 +9,7 @@ */ export const authPaths = { + par: ['/am/oauth2/realms/root/par'], tokenExchange: [ '/am/auth/tokenExchange', '/am/oauth2/realms/root/access_token', diff --git a/e2e/am-mock-api/src/app/responses.js b/e2e/am-mock-api/src/app/responses.js index d7c9e7af41..6127ea6e3d 100644 --- a/e2e/am-mock-api/src/app/responses.js +++ b/e2e/am-mock-api/src/app/responses.js @@ -1348,6 +1348,11 @@ export const recaptchaEnterpriseCallback = { ], }; +export const parResponse = { + request_uri: 'urn:ietf:params:oauth:request_uri:mock-par-request-uri', + expires_in: 60, +}; + export const qrCodeCallbacksResponse = { authId: 'qrcode-journey-confirmation', callbacks: [ diff --git a/e2e/am-mock-api/src/app/routes.auth.js b/e2e/am-mock-api/src/app/routes.auth.js index bcf0e7c2e9..38e75a426d 100644 --- a/e2e/am-mock-api/src/app/routes.auth.js +++ b/e2e/am-mock-api/src/app/routes.auth.js @@ -49,6 +49,7 @@ import { MetadataMarketPlacePingOneEvaluation, newPiWellKnown, qrCodeCallbacksResponse, + parResponse, } from './responses.js'; import initialRegResponse from './response.registration.js'; import { @@ -664,6 +665,10 @@ export default function (app) { app.get('/callback', (req, res) => res.status(200).send('ok')); + app.post(authPaths.par, (req, res) => { + res.status(201).json(parResponse); + }); + app.get('/am/.well-known/oidc-configuration', (req, res) => { res.send(wellKnownForgeRock); }); diff --git a/e2e/oidc-app/src/utils/oidc-app.ts b/e2e/oidc-app/src/utils/oidc-app.ts index 69289580a0..f8565a10c7 100644 --- a/e2e/oidc-app/src/utils/oidc-app.ts +++ b/e2e/oidc-app/src/utils/oidc-app.ts @@ -49,8 +49,11 @@ export async function oidcApp({ config, urlParams }) { const code = urlParams.get('code'); const state = urlParams.get('state'); const piflow = urlParams.get('piflow'); + const par = urlParams.get('par') === 'true'; - const oidcClient: OidcClient = await oidc({ config }); + const oidcClient: OidcClient = await oidc({ + config: { ...config, ...(par && { par: true }) }, + }); if ('error' in oidcClient) { displayError(oidcClient); } diff --git a/e2e/oidc-suites/src/par.spec.ts b/e2e/oidc-suites/src/par.spec.ts new file mode 100644 index 0000000000..300f33ee74 --- /dev/null +++ b/e2e/oidc-suites/src/par.spec.ts @@ -0,0 +1,56 @@ +/* + * + * Copyright © 2025 Ping Identity Corporation. All right reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + * + */ +import { test, expect } from '@playwright/test'; +import { pingAmUsername, pingAmPassword } from './utils/demo-users.js'; +import { asyncEvents } from './utils/async-events.js'; + +test.describe('PAR (Pushed Authorization Request) login tests', () => { + test('redirect login with PAR enabled obtains access token and uses slim authorize URL', async ({ + page, + }) => { + const { clickWithRedirect, navigate } = asyncEvents(page); + + const parRequests: string[] = []; + const authorizeRequests: string[] = []; + + page.on('request', (request) => { + if (request.method() === 'POST' && request.url().includes('/par')) { + parRequests.push(request.url()); + } + if (request.url().includes('/authorize')) { + authorizeRequests.push(request.url()); + } + }); + + await navigate('/ping-am/?par=true'); + + await clickWithRedirect('Login (Redirect)', '**/am/XUI/**'); + + await page.getByLabel('User Name').fill(pingAmUsername); + await page.getByRole('textbox', { name: 'Password' }).fill(pingAmPassword); + await clickWithRedirect('Next', 'http://localhost:8443/ping-am/**'); + + expect(page.url()).toContain('code'); + expect(page.url()).toContain('state'); + + await expect(page.locator('#accessToken-0')).not.toBeEmpty(); + + // PAR POST was made + expect(parRequests.length).toBeGreaterThan(0); + + // Authorize URL only contains client_id + request_uri (not scope/code_challenge) + expect(authorizeRequests.length).toBeGreaterThan(0); + const authorizeUrl = new URL(authorizeRequests[0]); + expect(authorizeUrl.searchParams.has('client_id')).toBe(true); + expect(authorizeUrl.searchParams.has('request_uri')).toBe(true); + expect(authorizeUrl.searchParams.has('scope')).toBe(false); + expect(authorizeUrl.searchParams.has('code_challenge')).toBe(false); + expect(authorizeUrl.searchParams.has('redirect_uri')).toBe(false); + }); +}); diff --git a/packages/oidc-client/api-report/oidc-client.api.md b/packages/oidc-client/api-report/oidc-client.api.md index 283d363dc9..ab5c1d0fbe 100644 --- a/packages/oidc-client/api-report/oidc-client.api.md +++ b/packages/oidc-client/api-report/oidc-client.api.md @@ -123,6 +123,10 @@ oidc: CombinedState< { authorizeFetch: MutationDefinition< { url: string; }, BaseQueryFn, never, AuthorizeSuccessResponse, "oidc", unknown>; +par: MutationDefinition< { +endpoint: string; +body: URLSearchParams; +}, BaseQueryFn, never, PushAuthorizationResponse, "oidc", unknown>; authorizeIframe: MutationDefinition< { url: string; }, BaseQueryFn, never, AuthorizationSuccess, "oidc", unknown>; @@ -155,6 +159,10 @@ oidc: CombinedState< { authorizeFetch: MutationDefinition< { url: string; }, BaseQueryFn, never, AuthorizeSuccessResponse, "oidc", unknown>; +par: MutationDefinition< { +endpoint: string; +body: URLSearchParams; +}, BaseQueryFn, never, PushAuthorizationResponse, "oidc", unknown>; authorizeIframe: MutationDefinition< { url: string; }, BaseQueryFn, never, AuthorizationSuccess, "oidc", unknown>; @@ -281,6 +289,8 @@ export interface OidcConfig extends AsyncLegacyConfigOptions { // (undocumented) clientId: string; // (undocumented) + par?: boolean; + // (undocumented) redirectUri: string; // (undocumented) responseType?: ResponseType_2; @@ -296,6 +306,14 @@ export interface OidcConfig extends AsyncLegacyConfigOptions { // @public (undocumented) export type OptionalAuthorizeOptions = Partial; +// @public (undocumented) +export interface PushAuthorizationResponse { + // (undocumented) + expires_in: number; + // (undocumented) + request_uri: string; +} + export { RequestMiddleware } export { ResponseType_2 as ResponseType } diff --git a/packages/oidc-client/api-report/oidc-client.types.api.md b/packages/oidc-client/api-report/oidc-client.types.api.md index 283d363dc9..ab5c1d0fbe 100644 --- a/packages/oidc-client/api-report/oidc-client.types.api.md +++ b/packages/oidc-client/api-report/oidc-client.types.api.md @@ -123,6 +123,10 @@ oidc: CombinedState< { authorizeFetch: MutationDefinition< { url: string; }, BaseQueryFn, never, AuthorizeSuccessResponse, "oidc", unknown>; +par: MutationDefinition< { +endpoint: string; +body: URLSearchParams; +}, BaseQueryFn, never, PushAuthorizationResponse, "oidc", unknown>; authorizeIframe: MutationDefinition< { url: string; }, BaseQueryFn, never, AuthorizationSuccess, "oidc", unknown>; @@ -155,6 +159,10 @@ oidc: CombinedState< { authorizeFetch: MutationDefinition< { url: string; }, BaseQueryFn, never, AuthorizeSuccessResponse, "oidc", unknown>; +par: MutationDefinition< { +endpoint: string; +body: URLSearchParams; +}, BaseQueryFn, never, PushAuthorizationResponse, "oidc", unknown>; authorizeIframe: MutationDefinition< { url: string; }, BaseQueryFn, never, AuthorizationSuccess, "oidc", unknown>; @@ -281,6 +289,8 @@ export interface OidcConfig extends AsyncLegacyConfigOptions { // (undocumented) clientId: string; // (undocumented) + par?: boolean; + // (undocumented) redirectUri: string; // (undocumented) responseType?: ResponseType_2; @@ -296,6 +306,14 @@ export interface OidcConfig extends AsyncLegacyConfigOptions { // @public (undocumented) export type OptionalAuthorizeOptions = Partial; +// @public (undocumented) +export interface PushAuthorizationResponse { + // (undocumented) + expires_in: number; + // (undocumented) + request_uri: string; +} + export { RequestMiddleware } export { ResponseType_2 as ResponseType } diff --git a/packages/oidc-client/package.json b/packages/oidc-client/package.json index 6a71dd61ad..4d69dc10c5 100644 --- a/packages/oidc-client/package.json +++ b/packages/oidc-client/package.json @@ -32,6 +32,7 @@ "@forgerock/sdk-oidc": "workspace:*", "@forgerock/sdk-request-middleware": "workspace:*", "@forgerock/sdk-types": "workspace:*", + "@forgerock/sdk-utilities": "workspace:*", "@forgerock/storage": "workspace:*", "@reduxjs/toolkit": "catalog:", "effect": "catalog:effect" diff --git a/packages/oidc-client/src/lib/authorize.request.ts b/packages/oidc-client/src/lib/authorize.request.ts index 96af6a9fc0..8f040d00c3 100644 --- a/packages/oidc-client/src/lib/authorize.request.ts +++ b/packages/oidc-client/src/lib/authorize.request.ts @@ -36,10 +36,14 @@ export function authorizeµ( store: ClientStore, options?: GetAuthorizationUrlOptions, ) { - return buildAuthorizeOptionsµ(wellknown, config, options).pipe( - Micro.flatMap(([url, options]) => createAuthorizeUrlµ(url, options)), - Micro.tap((url) => log.debug('Authorize URL created', url)), - Micro.tapError((url) => Micro.sync(() => log.error('Error creating authorize URL', url))), + const urlEffect: Micro.Micro<[string, GetAuthorizationUrlOptions], AuthorizationError, never> = + buildAuthorizeOptionsµ(wellknown, config, options).pipe( + Micro.flatMap(([url, opts]) => createAuthorizeUrlµ(url, opts)), + ); + + return urlEffect.pipe( + Micro.tap(([url]) => log.debug('Authorize URL created', url)), + Micro.tapError((err) => Micro.sync(() => log.error('Error creating authorize URL', err))), Micro.flatMap( ([url, options]): Micro.Micro => { if (options.responseMode === 'pi.flow') { diff --git a/packages/oidc-client/src/lib/authorize.request.utils.test.ts b/packages/oidc-client/src/lib/authorize.request.utils.test.ts index 0a00278a7b..428e56f6a5 100644 --- a/packages/oidc-client/src/lib/authorize.request.utils.test.ts +++ b/packages/oidc-client/src/lib/authorize.request.utils.test.ts @@ -6,9 +6,11 @@ */ import { it, expect } from '@effect/vitest'; import { Micro } from 'effect'; -import { buildAuthorizeOptionsµ } from './authorize.request.utils.js'; -import { OidcConfig } from './config.types.js'; -import { WellknownResponse } from '@forgerock/sdk-types'; +import { vi, afterEach } from 'vitest'; +import { buildAuthorizeOptionsµ, parAuthorizeµ } from './authorize.request.utils.js'; +import type { OidcConfig } from './config.types.js'; +import type { WellknownResponse } from '@forgerock/sdk-types'; +import type { ClientStore } from './client.types.js'; const clientId = '123456789'; const redirectUri = 'https://example.com/callback.html'; @@ -33,6 +35,27 @@ const wellknown: WellknownResponse = { revocation_endpoint: 'https://example.com/revoke', }; +const parEndpoint = 'https://example.com/par'; +const wellknownWithPar: WellknownResponse = { + ...wellknown, + pushed_authorization_request_endpoint: parEndpoint, +}; +const wellknownWithParAndPiFlow: WellknownResponse = { + ...wellknownWithPar, + response_modes_supported: ['pi.flow'], +}; + +const mockStore = { + dispatch: vi.fn(), +} as unknown as ClientStore; + +const sessionStorageStub = { getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() }; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.resetAllMocks(); +}); + it.effect('buildAuthorizeOptionsµ succeeds with BuildAuthorizationData', () => Micro.gen(function* () { const result = yield* buildAuthorizeOptionsµ(wellknown, config); @@ -65,3 +88,108 @@ it.effect('buildAuthorizeOptionsµ with pi.flow succeeds with BuildAuthorization ]); }), ); + +it.effect('parAuthorizeµ fails with wellknown_error when par endpoint is missing', () => + Micro.gen(function* () { + const configWithPar: OidcConfig = { ...config, par: true }; + const result = yield* Micro.exit(parAuthorizeµ(wellknown, configWithPar, mockStore)); + expect(Micro.exitIsFailure(result)).toBe(true); + if (!Micro.exitIsFailure(result)) return; + expect(Micro.causeIsFail(result.cause)).toBe(true); + if (Micro.causeIsFail(result.cause)) { + expect(result.cause.error.type).toBe('wellknown_error'); + expect(result.cause.error.error_description).toBe( + 'PAR endpoint not found in server configuration', + ); + } + }), +); + +it.effect('parAuthorizeµ succeeds and returns slim authorize URL', () => + Micro.gen(function* () { + const configWithPar: OidcConfig = { ...config, par: true }; + const requestUri = 'urn:ietf:params:oauth:request_uri:abc123'; + + vi.stubGlobal('sessionStorage', sessionStorageStub); + vi.mocked(mockStore.dispatch).mockResolvedValueOnce({ + data: { request_uri: requestUri, expires_in: 60 }, + } as unknown as ReturnType); + + const url = yield* parAuthorizeµ(wellknownWithPar, configWithPar, mockStore); + + expect(url).toContain('client_id=123456789'); + expect(url).toContain(`request_uri=${encodeURIComponent(requestUri)}`); + expect(url).not.toContain('scope='); + expect(url).not.toContain('code_challenge='); + expect(sessionStorageStub.setItem).toHaveBeenCalled(); + }), +); + +it.effect('parAuthorizeµ fails with network_error when PAR POST returns error', () => + Micro.gen(function* () { + const configWithPar: OidcConfig = { ...config, par: true }; + + vi.stubGlobal('sessionStorage', sessionStorageStub); + vi.mocked(mockStore.dispatch).mockResolvedValueOnce({ + error: { + status: 400, + statusText: 'PAR_ERROR', + data: { error: 'PAR_ERROR', error_description: 'invalid_client', type: 'network_error' }, + }, + } as unknown as ReturnType); + + const result = yield* Micro.exit(parAuthorizeµ(wellknownWithPar, configWithPar, mockStore)); + + expect(Micro.exitIsFailure(result)).toBe(true); + if (!Micro.exitIsFailure(result)) return; + expect(Micro.causeIsFail(result.cause)).toBe(true); + if (Micro.causeIsFail(result.cause)) { + expect(result.cause.error.type).toBe('network_error'); + } + expect(sessionStorageStub.setItem).not.toHaveBeenCalled(); + }), +); + +it.effect('parAuthorizeµ fails with network_error when PAR response is missing request_uri', () => + Micro.gen(function* () { + const configWithPar: OidcConfig = { ...config, par: true }; + + vi.stubGlobal('sessionStorage', sessionStorageStub); + vi.mocked(mockStore.dispatch).mockResolvedValueOnce({ + data: {}, + } as unknown as ReturnType); + + const result = yield* Micro.exit(parAuthorizeµ(wellknownWithPar, configWithPar, mockStore)); + + expect(Micro.exitIsFailure(result)).toBe(true); + if (!Micro.exitIsFailure(result)) return; + expect(Micro.causeIsFail(result.cause)).toBe(true); + if (Micro.causeIsFail(result.cause)) { + expect(result.cause.error.type).toBe('network_error'); + expect(result.cause.error.error_description).toBe( + "PAR response missing required 'request_uri' field", + ); + } + expect(sessionStorageStub.setItem).not.toHaveBeenCalled(); + }), +); + +it.effect('parAuthorizeµ with pi.flow includes response_mode in slim authorize URL', () => + Micro.gen(function* () { + const configWithPar: OidcConfig = { ...config, par: true }; + const requestUri = 'urn:ietf:params:oauth:request_uri:piflow123'; + + vi.stubGlobal('sessionStorage', sessionStorageStub); + vi.mocked(mockStore.dispatch).mockResolvedValueOnce({ + data: { request_uri: requestUri, expires_in: 60 }, + } as unknown as ReturnType); + + const url = yield* parAuthorizeµ(wellknownWithParAndPiFlow, configWithPar, mockStore); + + expect(url).toContain('client_id=123456789'); + expect(url).toContain(`request_uri=${encodeURIComponent(requestUri)}`); + expect(url).toContain('response_mode=pi.flow'); + expect(url).not.toContain('scope='); + expect(url).not.toContain('code_challenge='); + }), +); diff --git a/packages/oidc-client/src/lib/authorize.request.utils.ts b/packages/oidc-client/src/lib/authorize.request.utils.ts index 96557e22ed..ba205c3d5e 100644 --- a/packages/oidc-client/src/lib/authorize.request.utils.ts +++ b/packages/oidc-client/src/lib/authorize.request.utils.ts @@ -4,7 +4,12 @@ * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. */ -import { createAuthorizeUrl } from '@forgerock/sdk-oidc'; +import { + buildAuthorizeParams, + createAuthorizeUrl, + generateAndStoreAuthUrlValues, +} from '@forgerock/sdk-oidc'; +import { createChallenge } from '@forgerock/sdk-utilities'; import { Micro } from 'effect'; import type { WellknownResponse, GetAuthorizationUrlOptions } from '@forgerock/sdk-types'; @@ -15,6 +20,16 @@ import type { OptionalAuthorizeOptions, } from './authorize.request.types.js'; import type { OidcConfig } from './config.types.js'; +import type { ClientStore } from './client.types.js'; +import { oidcApi } from './oidc.api.js'; + +function isStringRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function hasPushRequestUri(data: unknown): data is { request_uri: string } { + return isStringRecord(data) && typeof data['request_uri'] === 'string'; +} /** * @function buildAuthorizeOptionsµ @@ -130,3 +145,115 @@ export function handleResponseµ( return createAuthorizeErrorµ(response, wellknown, options); } } + +/** + * @function parAuthorizeµ + * @description Performs a Pushed Authorization Request (RFC 9126): POSTs all authorize + * parameters to the PAR endpoint (backchannel), then returns a slim authorize URL + * containing only `client_id` and `request_uri` — keeping sensitive params out of + * the browser's address bar and history. + */ +export function parAuthorizeµ( + wellknown: WellknownResponse, + config: OidcConfig, + store: ClientStore, + options?: OptionalAuthorizeOptions, +): Micro.Micro { + const parEndpoint = wellknown.pushed_authorization_request_endpoint; + + if (!parEndpoint) { + return Micro.fail({ + error: 'PAR endpoint not configured', + error_description: 'PAR endpoint not found in server configuration', + type: 'wellknown_error', + } as const); + } + + const isPiFlow = wellknown.response_modes_supported?.includes('pi.flow'); + + return Micro.tryPromise({ + try: async () => { + const [authUrlOptions, storeOptions] = generateAndStoreAuthUrlValues({ + clientId: config.clientId, + serverConfig: { baseUrl: new URL(wellknown.authorization_endpoint).origin }, + responseType: config.responseType || 'code', + redirectUri: config.redirectUri, + scope: config.scope || 'openid', + ...options, + }); + + const challenge = await createChallenge(authUrlOptions.verifier); + + const body = buildAuthorizeParams({ + ...options, + clientId: config.clientId, + redirectUri: config.redirectUri, + scope: config.scope || 'openid', + responseType: config.responseType || 'code', + challenge, + state: authUrlOptions.state, + ...(isPiFlow && { responseMode: 'pi.flow' }), + }); + + return { body, storeOptions }; + }, + catch: (error): AuthorizationError => ({ + error: 'PAR parameter build failed', + error_description: error instanceof Error ? error.message : 'Failed to build PAR parameters', + type: 'auth_error', + }), + }).pipe( + Micro.flatMap(({ body, storeOptions }) => + Micro.promise(() => + store.dispatch(oidcApi.endpoints.par.initiate({ endpoint: parEndpoint, body })), + ).pipe(Micro.map((result) => ({ result, storeOptions }))), + ), + Micro.flatMap( + ({ + result: { error, data }, + storeOptions, + }): Micro.Micro => { + if (error) { + const serverData = 'data' in error && isStringRecord(error.data) ? error.data : {}; + return Micro.fail({ + error: + typeof serverData['error'] === 'string' ? serverData['error'] : 'PAR request failed', + error_description: + typeof serverData['error_description'] === 'string' + ? serverData['error_description'] + : 'An unknown error occurred during PAR request', + type: 'network_error', + } as const); + } + + if (!hasPushRequestUri(data)) { + return Micro.fail({ + error: 'PAR_ERROR', + error_description: "PAR response missing required 'request_uri' field", + type: 'network_error', + } as const); + } + + const { request_uri: requestUri } = data; + + return Micro.try({ + try: () => { + const authorizeUrl = new URL(wellknown.authorization_endpoint); + authorizeUrl.searchParams.set('client_id', config.clientId); + authorizeUrl.searchParams.set('request_uri', requestUri); + if (isPiFlow) authorizeUrl.searchParams.set('response_mode', 'pi.flow'); + // Store PKCE values only after a successful PAR POST so sessionStorage stays clean on failure + storeOptions(); + return authorizeUrl.toString(); + }, + catch: (err): AuthorizationError => ({ + error: 'PAR_URL_BUILD_ERROR', + error_description: + err instanceof Error ? err.message : 'Failed to build PAR authorize URL', + type: 'auth_error', + }), + }); + }, + ), + ); +} diff --git a/packages/oidc-client/src/lib/client.store.test.ts b/packages/oidc-client/src/lib/client.store.test.ts index 87703d308a..23e2a63e67 100644 --- a/packages/oidc-client/src/lib/client.store.test.ts +++ b/packages/oidc-client/src/lib/client.store.test.ts @@ -7,7 +7,7 @@ import { http, HttpResponse } from 'msw'; import { setupServer } from 'msw/node'; -import { it, expect, describe, vi } from 'vitest'; +import { it, expect, describe, vi, beforeEach } from 'vitest'; import { oidc } from './client.store.js'; @@ -39,6 +39,8 @@ vi.stubGlobal( })(), ); +const parRequestUri = 'urn:ietf:params:oauth:request_uri:test-par-request-uri'; + const server = setupServer( // P1 Revoke http.post('*/as/authorize', async () => { @@ -57,6 +59,9 @@ const server = setupServer( }), ), http.post('*/as/revoke', async () => HttpResponse.json(null, { status: 204 })), + http.post('*/as/par', async () => + HttpResponse.json({ request_uri: parRequestUri, expires_in: 60 }, { status: 201 }), + ), http.get('*/wellknown', async () => HttpResponse.json({ issuer: 'https://api.example.com/as/issuer', @@ -65,6 +70,7 @@ const server = setupServer( userinfo_endpoint: 'https://api.example.com/as/userinfo', introspection_endpoint: 'https://api.example.com/as/introspect', revocation_endpoint: 'https://api.example.com/as/revoke', + pushed_authorization_request_endpoint: 'https://api.example.com/as/par', response_types_supported: ['code', 'token', 'id_token', 'code id_token'], response_modes_supported: ['query', 'fragment', 'form_post', 'pi.flow'], }), @@ -279,3 +285,93 @@ describe('PingOne token get method', async () => { expect(tokens.accessToken).toBe('abcdefghijklmnop'); }); }); + +describe('authorize.url() with PAR enabled', async () => { + const configWithPar: OidcConfig = { + clientId: '123456789', + redirectUri: 'https://example.com/callback.html', + scope: 'openid profile', + serverConfig: { + wellknown: 'https://api.example.com/wellknown', + }, + responseType: 'code', + par: true, + }; + + beforeEach(() => { + customStorage.remove(storageKey); + }); + + it('returns a slim authorize URL with client_id and request_uri only', async () => { + const oidcClient = await oidc({ config: configWithPar, storage: customStorageConfig }); + + if ('error' in oidcClient) { + throw new Error('Error creating OIDC Client'); + } + + const url = await oidcClient.authorize.url(); + + if (typeof url !== 'string') { + expect.fail(`Expected string URL, got: ${JSON.stringify(url)}`); + } + + const parsed = new URL(url); + expect(parsed.searchParams.get('client_id')).toBe('123456789'); + expect(parsed.searchParams.get('request_uri')).toBe(parRequestUri); + expect(parsed.searchParams.has('scope')).toBe(false); + expect(parsed.searchParams.has('code_challenge')).toBe(false); + expect(parsed.searchParams.has('redirect_uri')).toBe(false); + }); + + it('returns wellknown_error when PAR endpoint is missing from wellknown', async () => { + server.use( + http.get('*/wellknown', async () => + HttpResponse.json({ + issuer: 'https://api.example.com/as/issuer', + authorization_endpoint: 'https://api.example.com/as/authorize', + token_endpoint: 'https://api.example.com/as/token', + userinfo_endpoint: 'https://api.example.com/as/userinfo', + introspection_endpoint: 'https://api.example.com/as/introspect', + revocation_endpoint: 'https://api.example.com/as/revoke', + }), + ), + ); + + const oidcClient = await oidc({ config: configWithPar, storage: customStorageConfig }); + + if ('error' in oidcClient) { + throw new Error('Error creating OIDC Client'); + } + + const result = await oidcClient.authorize.url(); + + if (typeof result === 'string') { + expect.fail('Expected error, got URL string'); + } + expect(result.type).toBe('wellknown_error'); + }); + + it('returns network_error when PAR endpoint returns an error', async () => { + server.use( + http.post('*/as/par', async () => + HttpResponse.json( + { error: 'invalid_client', error_description: 'Client authentication failed' }, + { status: 400 }, + ), + ), + ); + + const oidcClient = await oidc({ config: configWithPar, storage: customStorageConfig }); + + if ('error' in oidcClient) { + throw new Error('Error creating OIDC Client'); + } + + const result = await oidcClient.authorize.url(); + + if (typeof result === 'string') { + expect.fail('Expected error, got URL string'); + } + expect(result.type).toBe('network_error'); + }); +}); diff --git a/packages/oidc-client/src/lib/client.store.ts b/packages/oidc-client/src/lib/client.store.ts index da6c3de99c..aa7522a382 100644 --- a/packages/oidc-client/src/lib/client.store.ts +++ b/packages/oidc-client/src/lib/client.store.ts @@ -8,9 +8,10 @@ import { logger as loggerFn } from '@forgerock/sdk-logger'; import { createAuthorizeUrl } from '@forgerock/sdk-oidc'; import { createStorage } from '@forgerock/storage'; import { Micro } from 'effect'; -import { exitIsFail, exitIsSuccess } from 'effect/Micro'; +import { causeIsDie, exitIsFail, exitIsSuccess } from 'effect/Micro'; import { authorizeµ } from './authorize.request.js'; +import { parAuthorizeµ } from './authorize.request.utils.js'; import { buildTokenExchangeµ } from './exchange.request.js'; import { createClientStore, createTokenError } from './client.store.utils.js'; import { oidcApi } from './oidc.api.js'; @@ -106,14 +107,6 @@ export async function oidc({ * @returns {Promise} - Returns a promise that resolves to the authorization URL or an error. */ url: async (options?: GetAuthorizationUrlOptions): Promise => { - const optionsWithDefaults = { - clientId: config.clientId, - redirectUri: config.redirectUri, - scope: config.scope || 'openid', - responseType: config.responseType || 'code', - ...options, - }; - const state = store.getState(); const wellknown = wellknownSelector(wellknownUrl, state); @@ -124,6 +117,34 @@ export async function oidc({ }; } + if (config.par) { + const result = await Micro.runPromiseExit( + parAuthorizeµ(wellknown, config, store, options), + ); + + if (exitIsSuccess(result)) { + return result.value; + } else if (exitIsFail(result)) { + return result.cause.error; + } else { + const defect = causeIsDie(result.cause) ? result.cause.defect : undefined; + return { + error: 'PAR authorization failure', + message: + defect instanceof Error ? defect.message : String(defect ?? 'Unknown defect'), + type: 'auth_error', + }; + } + } + + const optionsWithDefaults = { + clientId: config.clientId, + redirectUri: config.redirectUri, + scope: config.scope || 'openid', + responseType: config.responseType || 'code', + ...options, + }; + return createAuthorizeUrl(wellknown.authorization_endpoint, optionsWithDefaults); }, @@ -155,9 +176,11 @@ export async function oidc({ } else if (exitIsFail(result)) { return result.cause.error; } else { + const defect = causeIsDie(result.cause) ? result.cause.defect : undefined; return { error: 'Authorization failure', - error_description: result.cause.message, + error_description: + defect instanceof Error ? defect.message : String(defect ?? 'Unknown defect'), type: 'auth_error', }; } @@ -212,9 +235,10 @@ export async function oidc({ } else if (exitIsFail(result)) { return result.cause.error; } else { + const defect = causeIsDie(result.cause) ? result.cause.defect : undefined; return { error: 'Token Exchange failure', - message: result.cause.message, + message: defect instanceof Error ? defect.message : String(defect ?? 'Unknown defect'), type: 'exchange_error', }; } @@ -311,9 +335,11 @@ export async function oidc({ } else if (exitIsFail(result)) { return result.cause.error; } else { + const defect = causeIsDie(result.cause) ? result.cause.defect : undefined; return { error: 'Background token renewal failed', - error_description: result.cause.message, + error_description: + defect instanceof Error ? defect.message : String(defect ?? 'Unknown defect'), type: 'auth_error', }; } @@ -407,9 +433,10 @@ export async function oidc({ } else if (exitIsFail(result)) { return result.cause.error; } else { + const defect = causeIsDie(result.cause) ? result.cause.defect : undefined; return { error: 'Token revocation failure', - message: result.cause.message, + message: defect instanceof Error ? defect.message : String(defect ?? 'Unknown defect'), type: 'auth_error', }; } @@ -482,9 +509,10 @@ export async function oidc({ } else if (exitIsFail(result)) { return result.cause.error; } else { + const defect = causeIsDie(result.cause) ? result.cause.defect : undefined; return { error: 'User Info retrieval failure', - message: result.cause.message, + message: defect instanceof Error ? defect.message : String(defect ?? 'Unknown defect'), type: 'auth_error', }; } @@ -537,9 +565,10 @@ export async function oidc({ } else if (exitIsFail(result)) { return result.cause.error; } else { + const defect = causeIsDie(result.cause) ? result.cause.defect : undefined; return { error: 'Logout_Failure', - message: result.cause.message, + message: defect instanceof Error ? defect.message : String(defect ?? 'Unknown defect'), type: 'auth_error', }; } diff --git a/packages/oidc-client/src/lib/config.types.ts b/packages/oidc-client/src/lib/config.types.ts index d560edc9fb..3f25a8fa39 100644 --- a/packages/oidc-client/src/lib/config.types.ts +++ b/packages/oidc-client/src/lib/config.types.ts @@ -16,6 +16,7 @@ export interface OidcConfig extends AsyncLegacyConfigOptions { timeout?: number; }; responseType?: ResponseType; + par?: boolean; } export interface OauthTokens { diff --git a/packages/oidc-client/src/lib/oidc.api.ts b/packages/oidc-client/src/lib/oidc.api.ts index 86f6408d6c..12f7fa0a95 100644 --- a/packages/oidc-client/src/lib/oidc.api.ts +++ b/packages/oidc-client/src/lib/oidc.api.ts @@ -23,6 +23,7 @@ import type { logger as loggerFn } from '@forgerock/sdk-logger'; import type { TokenExchangeResponse } from './exchange.types.js'; import type { AuthorizationSuccess, AuthorizeSuccessResponse } from './authorize.request.types.js'; import type { UserInfoResponse } from './client.types.js'; +import type { PushAuthorizationResponse } from './par.types.js'; interface Extras { requestMiddleware: RequestMiddleware[]; @@ -102,6 +103,71 @@ export const oidcApi = createApi({ return response as { data: AuthorizeSuccessResponse }; }, }), + par: builder.mutation({ + queryFn: async ({ endpoint, body }, api, _, baseQuery) => { + const { requestMiddleware, logger } = api.extra as Extras; + + const request: FetchArgs = { + url: endpoint, + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body, + }; + + logger.debug('OIDC PAR API request', request); + + const response = await initQuery(request, 'par') + .applyMiddleware(requestMiddleware) + .applyQuery(async (req: FetchArgs) => await baseQuery(req)); + + if (response.error) { + const responseError = response.error; + let message: string; + + if ( + responseError.data && + typeof responseError.data === 'object' && + 'error_description' in responseError.data && + typeof responseError.data.error_description === 'string' + ) { + message = responseError.data.error_description; + } else { + message = `Failed to push authorization request: ${responseError.status}`; + } + + logger.error('PAR API error', message); + + response.error.data = { + error: 'PAR_ERROR', + error_description: message, + type: 'network_error', + }; + + return response; + } + + if (!response.data || !('request_uri' in response.data)) { + return { + error: { + status: 'CUSTOM_ERROR', + error: 'PAR_ERROR', + data: { + error: 'PAR_ERROR', + error_description: "PAR response missing required 'request_uri' field", + type: 'network_error', + }, + } as FetchBaseQueryError, + }; + } + + logger.debug('OIDC PAR API response', response); + + return response as { data: PushAuthorizationResponse }; + }, + }), authorizeIframe: builder.mutation({ queryFn: async ({ url }, api) => { const { requestMiddleware, logger } = api.extra as Extras; diff --git a/packages/oidc-client/src/lib/par.types.ts b/packages/oidc-client/src/lib/par.types.ts new file mode 100644 index 0000000000..ae0cf25985 --- /dev/null +++ b/packages/oidc-client/src/lib/par.types.ts @@ -0,0 +1,11 @@ +/* + * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +export interface PushAuthorizationResponse { + request_uri: string; + expires_in: number; +} diff --git a/packages/oidc-client/src/types.ts b/packages/oidc-client/src/types.ts index 8e6750b0a8..c1e5c71b95 100644 --- a/packages/oidc-client/src/types.ts +++ b/packages/oidc-client/src/types.ts @@ -7,6 +7,7 @@ export * from './lib/client.types.js'; export * from './lib/config.types.js'; export * from './lib/authorize.request.types.js'; export * from './lib/exchange.types.js'; +export type { PushAuthorizationResponse } from './lib/par.types.js'; export type { GenericError, diff --git a/packages/oidc-client/tsconfig.lib.json b/packages/oidc-client/tsconfig.lib.json index d4b07d31d8..d689866941 100644 --- a/packages/oidc-client/tsconfig.lib.json +++ b/packages/oidc-client/tsconfig.lib.json @@ -19,6 +19,9 @@ }, "include": ["src/**/*.ts"], "references": [ + { + "path": "../sdk-utilities/tsconfig.lib.json" + }, { "path": "../sdk-effects/storage/tsconfig.lib.json" }, diff --git a/packages/sdk-effects/oidc/src/lib/authorize.effects.ts b/packages/sdk-effects/oidc/src/lib/authorize.effects.ts index 4881f2dd34..c28c1e79cb 100644 --- a/packages/sdk-effects/oidc/src/lib/authorize.effects.ts +++ b/packages/sdk-effects/oidc/src/lib/authorize.effects.ts @@ -14,6 +14,35 @@ import { generateAndStoreAuthUrlValues } from './state-pkce.effects.js'; import type { GetAuthorizationUrlOptions } from '@forgerock/sdk-types'; +/** + * Builds URLSearchParams for an OAuth2/OIDC authorization request. + * Used for both direct authorize URL construction and PAR body. + * + * Standard OAuth fields always take precedence over any conflicting `query` + * entries — `query` is applied first and then overwritten by the known params. + */ +export function buildAuthorizeParams( + options: GetAuthorizationUrlOptions & { challenge: string; state: string }, +): URLSearchParams { + // Apply caller-supplied extra query params first so that standard OAuth + // fields always win when there is a key collision (e.g. a query.client_id + // cannot hijack the real client_id). + const params = new URLSearchParams(options.query); + + params.set('client_id', options.clientId); + params.set('response_type', options.responseType); + params.set('scope', options.scope); + params.set('redirect_uri', options.redirectUri); + params.set('code_challenge', options.challenge); + params.set('code_challenge_method', 'S256'); + params.set('state', options.state); + + if (options.responseMode) params.set('response_mode', options.responseMode); + if (options.prompt) params.set('prompt', options.prompt); + + return params; +} + /** * @function createAuthorizeUrl - Create authorization URL for initial call to DaVinci * @param baseUrl {string} @@ -39,16 +68,9 @@ export async function createAuthorizeUrl( const challenge = await createChallenge(authorizeUrlOptions.verifier); - const requestParams = new URLSearchParams({ - ...options.query, - code_challenge: challenge, - code_challenge_method: 'S256', - client_id: options.clientId, - prompt: options.prompt || '', - redirect_uri: options.redirectUri, - response_mode: options.responseMode || '', - response_type: options.responseType, - scope: options.scope, + const requestParams = buildAuthorizeParams({ + ...options, + challenge, state: authorizeUrlOptions.state, }); diff --git a/packages/sdk-effects/oidc/src/lib/authorize.test.ts b/packages/sdk-effects/oidc/src/lib/authorize.test.ts index 6dc03c43a7..a66d0e6cb7 100644 --- a/packages/sdk-effects/oidc/src/lib/authorize.test.ts +++ b/packages/sdk-effects/oidc/src/lib/authorize.test.ts @@ -7,7 +7,7 @@ import type { GenerateAndStoreAuthUrlValues } from '@forgerock/sdk-types'; import { describe, expect, it, beforeEach } from 'vitest'; -import { createAuthorizeUrl } from './authorize.effects.js'; +import { buildAuthorizeParams, createAuthorizeUrl } from './authorize.effects.js'; import { getStorageKey } from './state-pkce.effects.js'; const mockSessionStorage = (() => { @@ -136,3 +136,52 @@ describe('createAuthorizeUrl', () => { expect(parsedOptions).toHaveProperty('verifier'); }); }); + +describe('buildAuthorizeParams', () => { + it('returns URLSearchParams with required OAuth fields', () => { + const params = buildAuthorizeParams({ + clientId: 'test-client', + redirectUri: 'https://example.com/cb', + scope: 'openid profile', + responseType: 'code', + challenge: 'abc123challenge', + state: 'randomstate', + }); + + expect(params.get('client_id')).toBe('test-client'); + expect(params.get('redirect_uri')).toBe('https://example.com/cb'); + expect(params.get('scope')).toBe('openid profile'); + expect(params.get('response_type')).toBe('code'); + expect(params.get('code_challenge')).toBe('abc123challenge'); + expect(params.get('code_challenge_method')).toBe('S256'); + expect(params.get('state')).toBe('randomstate'); + }); + + it('includes response_mode when provided', () => { + const params = buildAuthorizeParams({ + clientId: 'test-client', + redirectUri: 'https://example.com/cb', + scope: 'openid', + responseType: 'code', + challenge: 'abc123', + state: 'state1', + responseMode: 'pi.flow', + }); + + expect(params.get('response_mode')).toBe('pi.flow'); + }); + + it('omits optional fields when not provided', () => { + const params = buildAuthorizeParams({ + clientId: 'test-client', + redirectUri: 'https://example.com/cb', + scope: 'openid', + responseType: 'code', + challenge: 'abc123', + state: 'state1', + }); + + expect(params.has('response_mode')).toBe(false); + expect(params.has('prompt')).toBe(false); + }); +}); diff --git a/packages/sdk-effects/sdk-request-middleware/src/lib/request-mware.derived.ts b/packages/sdk-effects/sdk-request-middleware/src/lib/request-mware.derived.ts index 8de893dfe9..a05fb52a4d 100644 --- a/packages/sdk-effects/sdk-request-middleware/src/lib/request-mware.derived.ts +++ b/packages/sdk-effects/sdk-request-middleware/src/lib/request-mware.derived.ts @@ -23,6 +23,7 @@ export const actionTypes = { // OIDC authorize: 'AUTHORIZE', + par: 'PAR', tokenExchange: 'TOKEN_EXCHANGE', revoke: 'REVOKE', userInfo: 'USER_INFO', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 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 From bdd1d934fcff4660b281dc058594af28883aa170 Mon Sep 17 00:00:00 2001 From: Ryan Bas Date: Tue, 12 May 2026 12:38:22 -0600 Subject: [PATCH 02/19] fix(oidc-client): implicit PAR opt-in when server requires PAR and config.par is unset --- .../oidc-client/src/lib/authorize.request.ts | 292 +++++++++--------- .../oidc-client/src/lib/client.store.test.ts | 176 ++++++++++- packages/oidc-client/src/lib/client.store.ts | 15 +- scripts/rebase-open-prs.sh | 30 ++ 4 files changed, 362 insertions(+), 151 deletions(-) create mode 100755 scripts/rebase-open-prs.sh diff --git a/packages/oidc-client/src/lib/authorize.request.ts b/packages/oidc-client/src/lib/authorize.request.ts index 8f040d00c3..5de3239004 100644 --- a/packages/oidc-client/src/lib/authorize.request.ts +++ b/packages/oidc-client/src/lib/authorize.request.ts @@ -11,6 +11,7 @@ import { createAuthorizeUrlµ, buildAuthorizeOptionsµ, createAuthorizeErrorµ, + parAuthorizeµ, } from './authorize.request.utils.js'; import { oidcApi } from './oidc.api.js'; @@ -20,14 +21,128 @@ 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.promise(() => + store.dispatch(oidcApi.endpoints.authorizeFetch.initiate({ url })), + ).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 = error.data as AuthorizationError; + + 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.promise(() => + store.dispatch(oidcApi.endpoints.authorizeIframe.initiate({ url })), + ).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 = error.data as AuthorizationError; + + 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,149 +150,30 @@ export function authorizeµ( log: CustomLogger, store: ClientStore, options?: GetAuthorizationUrlOptions, -) { - const urlEffect: Micro.Micro<[string, GetAuthorizationUrlOptions], AuthorizationError, never> = - buildAuthorizeOptionsµ(wellknown, config, options).pipe( - Micro.flatMap(([url, opts]) => createAuthorizeUrlµ(url, opts)), + 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('Error creating PAR authorize URL', err))), + Micro.flatMap((url) => dispatchAuthorizeµ(url, dispatchOptions, wellknown, store, log)), ); + } - return urlEffect.pipe( + return buildAuthorizeOptionsµ(wellknown, config, options).pipe( + 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, 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]) => dispatchAuthorizeµ(url, opts, wellknown, store, log)), ); } diff --git a/packages/oidc-client/src/lib/client.store.test.ts b/packages/oidc-client/src/lib/client.store.test.ts index 23e2a63e67..980f789d0d 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, beforeEach } from 'vitest'; +import { it, expect, describe, vi, beforeEach, afterEach, afterAll, beforeAll } from 'vitest'; import { oidc } from './client.store.js'; @@ -375,3 +375,177 @@ describe('authorize.url() with PAR enabled', async () => { 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(); + }); +}); diff --git a/packages/oidc-client/src/lib/client.store.ts b/packages/oidc-client/src/lib/client.store.ts index aa7522a382..72fae6a74b 100644 --- a/packages/oidc-client/src/lib/client.store.ts +++ b/packages/oidc-client/src/lib/client.store.ts @@ -95,6 +95,16 @@ export async function oidc({ log.error(`Error fetching wellknown config. Please check the URL: ${wellknownUrl}`); } + 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 @@ -117,7 +127,7 @@ export async function oidc({ }; } - if (config.par) { + if (useParFlow) { const result = await Micro.runPromiseExit( parAuthorizeµ(wellknown, config, store, options), ); @@ -168,7 +178,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)) { @@ -301,6 +311,7 @@ export async function oidc({ log, store, authorizeOptions, + useParFlow, ).pipe( Micro.flatMap((response): Micro.Micro => { return buildTokenExchangeµ({ diff --git a/scripts/rebase-open-prs.sh b/scripts/rebase-open-prs.sh new file mode 100755 index 0000000000..f018faca7d --- /dev/null +++ b/scripts/rebase-open-prs.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(git -C "$(dirname "$0")" rev-parse --show-toplevel)" +LOG_FILE="/tmp/rebase-prs.log" + +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE" +} + +cd "$REPO_ROOT" + +log "--- Starting PR rebase check ---" + +# Fetch latest main +git fetch origin main --quiet + +# List open PRs authored by me +PR_JSON=$(gh pr list --author @me --state open --json number,headRefName 2>&1) || { + log "ERROR: gh pr list failed: $PR_JSON" + exit 1 +} + +echo "$PR_JSON" | jq -c '.[]' | while IFS= read -r pr; do + NUMBER=$(echo "$pr" | jq -r '.number') + BRANCH=$(echo "$pr" | jq -r '.headRefName') + log "PR #$NUMBER ($BRANCH): checking..." +done + +log "--- Done ---" From 53ae2b612200c58a825bd4b92e3b6e62fbab79f8 Mon Sep 17 00:00:00 2001 From: Ryan Bas Date: Tue, 12 May 2026 12:40:50 -0600 Subject: [PATCH 03/19] fix: separate gh stderr from JSON, use process substitution for while loop --- scripts/rebase-open-prs.sh | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/scripts/rebase-open-prs.sh b/scripts/rebase-open-prs.sh index f018faca7d..71a46b4af8 100755 --- a/scripts/rebase-open-prs.sh +++ b/scripts/rebase-open-prs.sh @@ -16,15 +16,18 @@ log "--- Starting PR rebase check ---" git fetch origin main --quiet # List open PRs authored by me -PR_JSON=$(gh pr list --author @me --state open --json number,headRefName 2>&1) || { - log "ERROR: gh pr list failed: $PR_JSON" +PR_GH_ERR=$(mktemp) +PR_JSON=$(gh pr list --author @me --state open --json number,headRefName 2>"$PR_GH_ERR") || { + log "ERROR: gh pr list failed: $(cat "$PR_GH_ERR")" + rm -f "$PR_GH_ERR" exit 1 } +rm -f "$PR_GH_ERR" -echo "$PR_JSON" | jq -c '.[]' | while IFS= read -r pr; do +while IFS= read -r pr; do NUMBER=$(echo "$pr" | jq -r '.number') BRANCH=$(echo "$pr" | jq -r '.headRefName') log "PR #$NUMBER ($BRANCH): checking..." -done +done < <(echo "$PR_JSON" | jq -c '.[]') log "--- Done ---" From 43deea92084d3e4f457b7dc411d5035bace351ab Mon Sep 17 00:00:00 2001 From: Ryan Bas Date: Tue, 12 May 2026 12:42:25 -0600 Subject: [PATCH 04/19] fix(oidc-client): inject prompt=none into background PAR authorize --- packages/oidc-client/src/lib/authorize.request.ts | 2 +- scripts/rebase-open-prs.sh | 15 ++++++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/oidc-client/src/lib/authorize.request.ts b/packages/oidc-client/src/lib/authorize.request.ts index 5de3239004..289e11f8f0 100644 --- a/packages/oidc-client/src/lib/authorize.request.ts +++ b/packages/oidc-client/src/lib/authorize.request.ts @@ -163,7 +163,7 @@ export function authorizeµ( ...options, }; - return parAuthorizeµ(wellknown, config, store, options).pipe( + return parAuthorizeµ(wellknown, config, store, { prompt: 'none', ...options }).pipe( Micro.tap((url) => log.debug('PAR authorize URL created', url)), Micro.tapError((err) => Micro.sync(() => log.error('Error creating PAR authorize URL', err))), Micro.flatMap((url) => dispatchAuthorizeµ(url, dispatchOptions, wellknown, store, log)), diff --git a/scripts/rebase-open-prs.sh b/scripts/rebase-open-prs.sh index 71a46b4af8..8fa6791b25 100755 --- a/scripts/rebase-open-prs.sh +++ b/scripts/rebase-open-prs.sh @@ -27,7 +27,20 @@ rm -f "$PR_GH_ERR" while IFS= read -r pr; do NUMBER=$(echo "$pr" | jq -r '.number') BRANCH=$(echo "$pr" | jq -r '.headRefName') - log "PR #$NUMBER ($BRANCH): checking..." + + # Ensure branch exists locally + git fetch origin "$BRANCH" --quiet 2>/dev/null || { + log "PR #$NUMBER ($BRANCH): ERROR — could not fetch branch, skipping" + continue + } + + # Check if origin/main is already an ancestor of this branch (up to date) + if git merge-base --is-ancestor origin/main "origin/$BRANCH" 2>/dev/null; then + log "PR #$NUMBER ($BRANCH): up to date — skipping" + continue + fi + + log "PR #$NUMBER ($BRANCH): behind main — will rebase" done < <(echo "$PR_JSON" | jq -c '.[]') log "--- Done ---" From 0125b23d0ae2058a360543ce60c397d9b7d1e9e7 Mon Sep 17 00:00:00 2001 From: Ryan Bas Date: Tue, 12 May 2026 12:45:05 -0600 Subject: [PATCH 05/19] feat: add clean rebase and force-with-lease push for conflict-free PRs --- .../oidc-client/src/lib/authorize.request.ts | 24 ++++++++++++++----- .../src/lib/authorize.request.utils.ts | 12 +++++++--- scripts/rebase-open-prs.sh | 22 ++++++++++++++++- 3 files changed, 48 insertions(+), 10 deletions(-) diff --git a/packages/oidc-client/src/lib/authorize.request.ts b/packages/oidc-client/src/lib/authorize.request.ts index 289e11f8f0..fc6ed2d290 100644 --- a/packages/oidc-client/src/lib/authorize.request.ts +++ b/packages/oidc-client/src/lib/authorize.request.ts @@ -37,9 +37,15 @@ function dispatchAuthorizeµ( * PingOne servers do not support redirection through iframes (X-Frame-Options: DENY). * Use a direct fetch instead. */ - return Micro.promise(() => - store.dispatch(oidcApi.endpoints.authorizeFetch.initiate({ url })), - ).pipe( + 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) { @@ -92,9 +98,15 @@ function dispatchAuthorizeµ( /** * Traditional iframe-based authorize for PingAM and similar servers. */ - return Micro.promise(() => - store.dispatch(oidcApi.endpoints.authorizeIframe.initiate({ url })), - ).pipe( + 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) { diff --git a/packages/oidc-client/src/lib/authorize.request.utils.ts b/packages/oidc-client/src/lib/authorize.request.utils.ts index ba205c3d5e..7f9ef127dd 100644 --- a/packages/oidc-client/src/lib/authorize.request.utils.ts +++ b/packages/oidc-client/src/lib/authorize.request.utils.ts @@ -204,9 +204,15 @@ export function parAuthorizeµ( }), }).pipe( Micro.flatMap(({ body, storeOptions }) => - Micro.promise(() => - store.dispatch(oidcApi.endpoints.par.initiate({ endpoint: parEndpoint, body })), - ).pipe(Micro.map((result) => ({ result, storeOptions }))), + 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 }))), ), Micro.flatMap( ({ diff --git a/scripts/rebase-open-prs.sh b/scripts/rebase-open-prs.sh index 8fa6791b25..2e4d62e8cb 100755 --- a/scripts/rebase-open-prs.sh +++ b/scripts/rebase-open-prs.sh @@ -40,7 +40,27 @@ while IFS= read -r pr; do continue fi - log "PR #$NUMBER ($BRANCH): behind main — will rebase" + log "PR #$NUMBER ($BRANCH): behind main — rebasing" + + # Check out the branch + git checkout -q "$BRANCH" + + # Attempt rebase + if git rebase origin/main --quiet; then + # Clean rebase — push + if git push origin "$BRANCH" --force-with-lease --quiet; then + log "PR #$NUMBER ($BRANCH): clean rebase — pushed" + else + log "PR #$NUMBER ($BRANCH): ERROR — push rejected (force-with-lease failed), skipping" + fi + else + # Conflicts — handled in next task + log "PR #$NUMBER ($BRANCH): conflicts detected — aborting rebase (conflict resolution not yet implemented)" + git rebase --abort + fi + + # Return to a safe state before next iteration + git checkout -q main 2>/dev/null || git checkout -q - done < <(echo "$PR_JSON" | jq -c '.[]') log "--- Done ---" From fafcc343ffa509e795defce81e83c61892e1695a Mon Sep 17 00:00:00 2001 From: Ryan Bas Date: Tue, 12 May 2026 12:48:02 -0600 Subject: [PATCH 06/19] fix(oidc-client): replace unsafe AuthorizationError casts with toAuthorizationError helper --- .../oidc-client/src/lib/authorize.request.ts | 5 +- .../src/lib/authorize.request.utils.ts | 24 ++++++++++ scripts/rebase-open-prs.sh | 46 +++++++++++++++++-- 3 files changed, 70 insertions(+), 5 deletions(-) diff --git a/packages/oidc-client/src/lib/authorize.request.ts b/packages/oidc-client/src/lib/authorize.request.ts index fc6ed2d290..b51788730b 100644 --- a/packages/oidc-client/src/lib/authorize.request.ts +++ b/packages/oidc-client/src/lib/authorize.request.ts @@ -12,6 +12,7 @@ import { buildAuthorizeOptionsµ, createAuthorizeErrorµ, parAuthorizeµ, + toAuthorizationError, } from './authorize.request.utils.js'; import { oidcApi } from './oidc.api.js'; @@ -66,7 +67,7 @@ function dispatchAuthorizeµ( }); } - const errorDetails = error.data as AuthorizationError; + const errorDetails = toAuthorizationError(error.data); if ('statusText' in error && error.statusText === 'CONFIGURATION_ERROR') { return Micro.fail(errorDetails); @@ -126,7 +127,7 @@ function dispatchAuthorizeµ( }); } - const errorDetails = error.data as AuthorizationError; + const errorDetails = toAuthorizationError(error.data); if ('statusText' in error && error.statusText === 'CONFIGURATION_ERROR') { return Micro.fail(errorDetails); diff --git a/packages/oidc-client/src/lib/authorize.request.utils.ts b/packages/oidc-client/src/lib/authorize.request.utils.ts index 7f9ef127dd..ce4cdcde1a 100644 --- a/packages/oidc-client/src/lib/authorize.request.utils.ts +++ b/packages/oidc-client/src/lib/authorize.request.utils.ts @@ -146,6 +146,30 @@ export function handleResponseµ( } } +/** + * @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} + */ +export function toAuthorizationError(data: unknown): AuthorizationError { + if ( + data !== null && + typeof data === 'object' && + 'error' in data && + typeof (data as Record).error === 'string' + ) { + return data as AuthorizationError; + } + 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 diff --git a/scripts/rebase-open-prs.sh b/scripts/rebase-open-prs.sh index 2e4d62e8cb..70163398c0 100755 --- a/scripts/rebase-open-prs.sh +++ b/scripts/rebase-open-prs.sh @@ -9,6 +9,7 @@ log() { } cd "$REPO_ROOT" +export GIT_EDITOR=true log "--- Starting PR rebase check ---" @@ -54,9 +55,48 @@ while IFS= read -r pr; do log "PR #$NUMBER ($BRANCH): ERROR — push rejected (force-with-lease failed), skipping" fi else - # Conflicts — handled in next task - log "PR #$NUMBER ($BRANCH): conflicts detected — aborting rebase (conflict resolution not yet implemented)" - git rebase --abort + # Conflicts — invoke Claude to resolve + CONFLICTED_FILES=$(git diff --name-only --diff-filter=U | tr '\n' ' ') + log "PR #$NUMBER ($BRANCH): conflicts in files: $CONFLICTED_FILES — invoking Claude" + + CONFLICT_PROMPT="You are resolving git rebase conflicts on branch '$BRANCH' rebasing onto origin/main. + +The following files have conflicts (look for <<<<<<< / ======= / >>>>>>> markers): +$CONFLICTED_FILES + +Working directory: $REPO_ROOT + +For each conflicted file: +1. Read the file and find all conflict markers +2. Understand what both sides are doing — the PR branch intent (below =======) and main's changes (above =======) +3. Write a resolved version that preserves both intents where possible +4. When the two sides are incompatible, prefer the PR branch's version +5. Stage the resolved file with: git add + +Do not run 'git rebase --continue' — the calling script handles that. +Do not push — the calling script handles that. +Resolve ALL conflicted files before finishing." + + if claude --dangerously-skip-permissions --print "$CONFLICT_PROMPT"; then + # Verify nothing is left unresolved + REMAINING=$(git diff --name-only --diff-filter=U 2>/dev/null || echo "") + if [[ -n "$REMAINING" ]]; then + log "PR #$NUMBER ($BRANCH): ERROR — Claude left unresolved conflicts in: $REMAINING — aborting" + git rebase --abort + elif git rebase --continue --quiet; then + if git push origin "$BRANCH" --force-with-lease --quiet; then + log "PR #$NUMBER ($BRANCH): conflicts resolved by Claude — pushed" + else + log "PR #$NUMBER ($BRANCH): ERROR — push rejected after conflict resolution (force-with-lease failed)" + fi + else + log "PR #$NUMBER ($BRANCH): ERROR — git rebase --continue failed after Claude resolution — aborting" + git rebase --abort + fi + else + log "PR #$NUMBER ($BRANCH): ERROR — Claude sub-agent exited non-zero — aborting rebase" + git rebase --abort + fi fi # Return to a safe state before next iteration From 2ab1c45ca22f72530154f4fd0ba5b261c96c7a15 Mon Sep 17 00:00:00 2001 From: Ryan Bas Date: Tue, 12 May 2026 12:49:57 -0600 Subject: [PATCH 07/19] fix: guard against empty CONFLICTED_FILES when rebase fails without markers --- .../src/lib/authorize.request.utils.ts | 31 +++++++++++----- scripts/rebase-open-prs.sh | 37 +++++++++++-------- 2 files changed, 43 insertions(+), 25 deletions(-) diff --git a/packages/oidc-client/src/lib/authorize.request.utils.ts b/packages/oidc-client/src/lib/authorize.request.utils.ts index ce4cdcde1a..a350c04ab2 100644 --- a/packages/oidc-client/src/lib/authorize.request.utils.ts +++ b/packages/oidc-client/src/lib/authorize.request.utils.ts @@ -268,21 +268,34 @@ export function parAuthorizeµ( return Micro.try({ try: () => { - const authorizeUrl = new URL(wellknown.authorization_endpoint); - authorizeUrl.searchParams.set('client_id', config.clientId); - authorizeUrl.searchParams.set('request_uri', requestUri); - if (isPiFlow) authorizeUrl.searchParams.set('response_mode', 'pi.flow'); // Store PKCE values only after a successful PAR POST so sessionStorage stays clean on failure storeOptions(); - return authorizeUrl.toString(); }, catch: (err): AuthorizationError => ({ - error: 'PAR_URL_BUILD_ERROR', + error: 'PAR_STORAGE_ERROR', error_description: - err instanceof Error ? err.message : 'Failed to build PAR authorize URL', - type: 'auth_error', + 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'); + 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/scripts/rebase-open-prs.sh b/scripts/rebase-open-prs.sh index 70163398c0..f01ca3c780 100755 --- a/scripts/rebase-open-prs.sh +++ b/scripts/rebase-open-prs.sh @@ -57,9 +57,13 @@ while IFS= read -r pr; do else # Conflicts — invoke Claude to resolve CONFLICTED_FILES=$(git diff --name-only --diff-filter=U | tr '\n' ' ') - log "PR #$NUMBER ($BRANCH): conflicts in files: $CONFLICTED_FILES — invoking Claude" + if [[ -z "${CONFLICTED_FILES// /}" ]]; then + log "PR #$NUMBER ($BRANCH): ERROR — rebase failed with no conflict markers, aborting" + git rebase --abort 2>/dev/null || true + else + log "PR #$NUMBER ($BRANCH): conflicts in files: $CONFLICTED_FILES — invoking Claude" - CONFLICT_PROMPT="You are resolving git rebase conflicts on branch '$BRANCH' rebasing onto origin/main. + CONFLICT_PROMPT="You are resolving git rebase conflicts on branch '$BRANCH' rebasing onto origin/main. The following files have conflicts (look for <<<<<<< / ======= / >>>>>>> markers): $CONFLICTED_FILES @@ -77,25 +81,26 @@ Do not run 'git rebase --continue' — the calling script handles that. Do not push — the calling script handles that. Resolve ALL conflicted files before finishing." - if claude --dangerously-skip-permissions --print "$CONFLICT_PROMPT"; then - # Verify nothing is left unresolved - REMAINING=$(git diff --name-only --diff-filter=U 2>/dev/null || echo "") - if [[ -n "$REMAINING" ]]; then - log "PR #$NUMBER ($BRANCH): ERROR — Claude left unresolved conflicts in: $REMAINING — aborting" - git rebase --abort - elif git rebase --continue --quiet; then - if git push origin "$BRANCH" --force-with-lease --quiet; then - log "PR #$NUMBER ($BRANCH): conflicts resolved by Claude — pushed" + if claude --dangerously-skip-permissions --print "$CONFLICT_PROMPT"; then + # Verify nothing is left unresolved + REMAINING=$(git diff --name-only --diff-filter=U 2>/dev/null || echo "") + if [[ -n "$REMAINING" ]]; then + log "PR #$NUMBER ($BRANCH): ERROR — Claude left unresolved conflicts in: $REMAINING — aborting" + git rebase --abort + elif git rebase --continue --quiet; then + if git push origin "$BRANCH" --force-with-lease --quiet; then + log "PR #$NUMBER ($BRANCH): conflicts resolved by Claude — pushed" + else + log "PR #$NUMBER ($BRANCH): ERROR — push rejected after conflict resolution (force-with-lease failed)" + fi else - log "PR #$NUMBER ($BRANCH): ERROR — push rejected after conflict resolution (force-with-lease failed)" + log "PR #$NUMBER ($BRANCH): ERROR — git rebase --continue failed after Claude resolution — aborting" + git rebase --abort fi else - log "PR #$NUMBER ($BRANCH): ERROR — git rebase --continue failed after Claude resolution — aborting" + log "PR #$NUMBER ($BRANCH): ERROR — Claude sub-agent exited non-zero — aborting rebase" git rebase --abort fi - else - log "PR #$NUMBER ($BRANCH): ERROR — Claude sub-agent exited non-zero — aborting rebase" - git rebase --abort fi fi From fa8ea847202e35b5e5701c8ccd7a4e0617b547d8 Mon Sep 17 00:00:00 2001 From: Ryan Bas Date: Tue, 12 May 2026 12:54:08 -0600 Subject: [PATCH 08/19] fix(oidc-client): map AuthorizationError to GenericError in authorize.url fail-path --- packages/oidc-client/src/lib/client.store.ts | 7 ++++++- scripts/rebase-open-prs.sh | 6 +++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/oidc-client/src/lib/client.store.ts b/packages/oidc-client/src/lib/client.store.ts index 72fae6a74b..a991ed6a29 100644 --- a/packages/oidc-client/src/lib/client.store.ts +++ b/packages/oidc-client/src/lib/client.store.ts @@ -135,7 +135,12 @@ export async function oidc({ if (exitIsSuccess(result)) { return result.value; } else if (exitIsFail(result)) { - return result.cause.error; + 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 { diff --git a/scripts/rebase-open-prs.sh b/scripts/rebase-open-prs.sh index f01ca3c780..3c7414ae89 100755 --- a/scripts/rebase-open-prs.sh +++ b/scripts/rebase-open-prs.sh @@ -86,7 +86,7 @@ Resolve ALL conflicted files before finishing." REMAINING=$(git diff --name-only --diff-filter=U 2>/dev/null || echo "") if [[ -n "$REMAINING" ]]; then log "PR #$NUMBER ($BRANCH): ERROR — Claude left unresolved conflicts in: $REMAINING — aborting" - git rebase --abort + git rebase --abort 2>/dev/null || true elif git rebase --continue --quiet; then if git push origin "$BRANCH" --force-with-lease --quiet; then log "PR #$NUMBER ($BRANCH): conflicts resolved by Claude — pushed" @@ -95,11 +95,11 @@ Resolve ALL conflicted files before finishing." fi else log "PR #$NUMBER ($BRANCH): ERROR — git rebase --continue failed after Claude resolution — aborting" - git rebase --abort + git rebase --abort 2>/dev/null || true fi else log "PR #$NUMBER ($BRANCH): ERROR — Claude sub-agent exited non-zero — aborting rebase" - git rebase --abort + git rebase --abort 2>/dev/null || true fi fi fi From 7702294d2f884a4ccfd8997a8da4f20ad8023646 Mon Sep 17 00:00:00 2001 From: Ryan Bas Date: Tue, 12 May 2026 12:59:27 -0600 Subject: [PATCH 09/19] fix: guard git checkout to prevent set -e from killing loop on dirty working tree --- packages/oidc-client/src/lib/authorize.request.ts | 6 ++++++ scripts/rebase-open-prs.sh | 8 ++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/oidc-client/src/lib/authorize.request.ts b/packages/oidc-client/src/lib/authorize.request.ts index b51788730b..8dc57cc8cd 100644 --- a/packages/oidc-client/src/lib/authorize.request.ts +++ b/packages/oidc-client/src/lib/authorize.request.ts @@ -180,6 +180,9 @@ export function authorizeµ( Micro.tap((url) => log.debug('PAR authorize URL created', url)), Micro.tapError((err) => Micro.sync(() => log.error('Error creating PAR authorize URL', err))), Micro.flatMap((url) => dispatchAuthorizeµ(url, dispatchOptions, wellknown, store, log)), + Micro.tapError((err) => + Micro.sync(() => log.error('Error dispatching PAR authorize request', err)), + ), ); } @@ -188,5 +191,8 @@ export function authorizeµ( 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/scripts/rebase-open-prs.sh b/scripts/rebase-open-prs.sh index 3c7414ae89..480a96c2ff 100755 --- a/scripts/rebase-open-prs.sh +++ b/scripts/rebase-open-prs.sh @@ -44,7 +44,11 @@ while IFS= read -r pr; do log "PR #$NUMBER ($BRANCH): behind main — rebasing" # Check out the branch - git checkout -q "$BRANCH" + git checkout -q "$BRANCH" 2>/dev/null || { + log "PR #$NUMBER ($BRANCH): ERROR — could not checkout branch (dirty working tree?), skipping" + git checkout -q main 2>/dev/null || git checkout -q - 2>/dev/null || true + continue + } # Attempt rebase if git rebase origin/main --quiet; then @@ -105,7 +109,7 @@ Resolve ALL conflicted files before finishing." fi # Return to a safe state before next iteration - git checkout -q main 2>/dev/null || git checkout -q - + git checkout -q main 2>/dev/null || git checkout -q - 2>/dev/null || true done < <(echo "$PR_JSON" | jq -c '.[]') log "--- Done ---" From 5526f30ee2c49052c1be568b2a54bab222508cd2 Mon Sep 17 00:00:00 2001 From: Ryan Bas Date: Tue, 12 May 2026 13:01:42 -0600 Subject: [PATCH 10/19] test(oidc-client): add test for PAR + non-pi.flow (iframe) server path --- .../oidc-client/src/lib/client.store.test.ts | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/packages/oidc-client/src/lib/client.store.test.ts b/packages/oidc-client/src/lib/client.store.test.ts index 980f789d0d..40ebb64ec4 100644 --- a/packages/oidc-client/src/lib/client.store.test.ts +++ b/packages/oidc-client/src/lib/client.store.test.ts @@ -549,3 +549,55 @@ describe('authorize.background() with PAR enabled', async () => { 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); + }); +}); From 36e316ebb1f7cb80c6ab751857ffd2810cbba692 Mon Sep 17 00:00:00 2001 From: Ryan Bas Date: Tue, 12 May 2026 13:09:18 -0600 Subject: [PATCH 11/19] chore: remove personal rebase script from repo (moved to ~/.local/bin) --- scripts/rebase-open-prs.sh | 115 ------------------------------------- 1 file changed, 115 deletions(-) delete mode 100755 scripts/rebase-open-prs.sh diff --git a/scripts/rebase-open-prs.sh b/scripts/rebase-open-prs.sh deleted file mode 100755 index 480a96c2ff..0000000000 --- a/scripts/rebase-open-prs.sh +++ /dev/null @@ -1,115 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -REPO_ROOT="$(git -C "$(dirname "$0")" rev-parse --show-toplevel)" -LOG_FILE="/tmp/rebase-prs.log" - -log() { - echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE" -} - -cd "$REPO_ROOT" -export GIT_EDITOR=true - -log "--- Starting PR rebase check ---" - -# Fetch latest main -git fetch origin main --quiet - -# List open PRs authored by me -PR_GH_ERR=$(mktemp) -PR_JSON=$(gh pr list --author @me --state open --json number,headRefName 2>"$PR_GH_ERR") || { - log "ERROR: gh pr list failed: $(cat "$PR_GH_ERR")" - rm -f "$PR_GH_ERR" - exit 1 -} -rm -f "$PR_GH_ERR" - -while IFS= read -r pr; do - NUMBER=$(echo "$pr" | jq -r '.number') - BRANCH=$(echo "$pr" | jq -r '.headRefName') - - # Ensure branch exists locally - git fetch origin "$BRANCH" --quiet 2>/dev/null || { - log "PR #$NUMBER ($BRANCH): ERROR — could not fetch branch, skipping" - continue - } - - # Check if origin/main is already an ancestor of this branch (up to date) - if git merge-base --is-ancestor origin/main "origin/$BRANCH" 2>/dev/null; then - log "PR #$NUMBER ($BRANCH): up to date — skipping" - continue - fi - - log "PR #$NUMBER ($BRANCH): behind main — rebasing" - - # Check out the branch - git checkout -q "$BRANCH" 2>/dev/null || { - log "PR #$NUMBER ($BRANCH): ERROR — could not checkout branch (dirty working tree?), skipping" - git checkout -q main 2>/dev/null || git checkout -q - 2>/dev/null || true - continue - } - - # Attempt rebase - if git rebase origin/main --quiet; then - # Clean rebase — push - if git push origin "$BRANCH" --force-with-lease --quiet; then - log "PR #$NUMBER ($BRANCH): clean rebase — pushed" - else - log "PR #$NUMBER ($BRANCH): ERROR — push rejected (force-with-lease failed), skipping" - fi - else - # Conflicts — invoke Claude to resolve - CONFLICTED_FILES=$(git diff --name-only --diff-filter=U | tr '\n' ' ') - if [[ -z "${CONFLICTED_FILES// /}" ]]; then - log "PR #$NUMBER ($BRANCH): ERROR — rebase failed with no conflict markers, aborting" - git rebase --abort 2>/dev/null || true - else - log "PR #$NUMBER ($BRANCH): conflicts in files: $CONFLICTED_FILES — invoking Claude" - - CONFLICT_PROMPT="You are resolving git rebase conflicts on branch '$BRANCH' rebasing onto origin/main. - -The following files have conflicts (look for <<<<<<< / ======= / >>>>>>> markers): -$CONFLICTED_FILES - -Working directory: $REPO_ROOT - -For each conflicted file: -1. Read the file and find all conflict markers -2. Understand what both sides are doing — the PR branch intent (below =======) and main's changes (above =======) -3. Write a resolved version that preserves both intents where possible -4. When the two sides are incompatible, prefer the PR branch's version -5. Stage the resolved file with: git add - -Do not run 'git rebase --continue' — the calling script handles that. -Do not push — the calling script handles that. -Resolve ALL conflicted files before finishing." - - if claude --dangerously-skip-permissions --print "$CONFLICT_PROMPT"; then - # Verify nothing is left unresolved - REMAINING=$(git diff --name-only --diff-filter=U 2>/dev/null || echo "") - if [[ -n "$REMAINING" ]]; then - log "PR #$NUMBER ($BRANCH): ERROR — Claude left unresolved conflicts in: $REMAINING — aborting" - git rebase --abort 2>/dev/null || true - elif git rebase --continue --quiet; then - if git push origin "$BRANCH" --force-with-lease --quiet; then - log "PR #$NUMBER ($BRANCH): conflicts resolved by Claude — pushed" - else - log "PR #$NUMBER ($BRANCH): ERROR — push rejected after conflict resolution (force-with-lease failed)" - fi - else - log "PR #$NUMBER ($BRANCH): ERROR — git rebase --continue failed after Claude resolution — aborting" - git rebase --abort 2>/dev/null || true - fi - else - log "PR #$NUMBER ($BRANCH): ERROR — Claude sub-agent exited non-zero — aborting rebase" - git rebase --abort 2>/dev/null || true - fi - fi - fi - - # Return to a safe state before next iteration - git checkout -q main 2>/dev/null || git checkout -q - 2>/dev/null || true -done < <(echo "$PR_JSON" | jq -c '.[]') - -log "--- Done ---" From 2ea8bbcb61bdbb9a5240c189f1b2c2b9005a492a Mon Sep 17 00:00:00 2001 From: Ryan Bas Date: Wed, 13 May 2026 10:42:53 -0600 Subject: [PATCH 12/19] feat(e2e): add PAR test app for oidc-client e2e coverage Provides the browser-side PAR login fixture used by the OIDC e2e suite. --- e2e/oidc-app/src/par/index.html | 28 ++++++++++++++++++++++++++++ e2e/oidc-app/src/par/main.ts | 27 +++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 e2e/oidc-app/src/par/index.html create mode 100644 e2e/oidc-app/src/par/main.ts diff --git a/e2e/oidc-app/src/par/index.html b/e2e/oidc-app/src/par/index.html new file mode 100644 index 0000000000..63bad55107 --- /dev/null +++ b/e2e/oidc-app/src/par/index.html @@ -0,0 +1,28 @@ + + + + E2E Test | Ping Identity JavaScript SDK + + + + +
+ Home +

OIDC App | PAR Login

+ + + + + + + + + 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..23bc1cb81b --- /dev/null +++ b/e2e/oidc-app/src/par/main.ts @@ -0,0 +1,27 @@ +/* + * + * 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 urlParams = new URLSearchParams(window.location.search); +const clientId = urlParams.get('clientid'); +const wellknown = urlParams.get('wellknown'); + +const config = { + clientId: 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', + }, +}; + +oidcApp({ config, urlParams }); From de05bca5ed745213ecf12e9f14525f066c3aed34 Mon Sep 17 00:00:00 2001 From: Ryan Bas Date: Wed, 13 May 2026 10:54:29 -0600 Subject: [PATCH 13/19] fix(e2e): filter PAR authorize URL by request_uri to avoid SSO redirect false positives AM redirects through multiple /authorize URLs during SSO; filter on request_uri= to isolate the slim PAR-built outbound URL. --- e2e/oidc-suites/src/par.spec.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/e2e/oidc-suites/src/par.spec.ts b/e2e/oidc-suites/src/par.spec.ts index 300f33ee74..3367fad30e 100644 --- a/e2e/oidc-suites/src/par.spec.ts +++ b/e2e/oidc-suites/src/par.spec.ts @@ -17,14 +17,15 @@ test.describe('PAR (Pushed Authorization Request) login tests', () => { const { clickWithRedirect, navigate } = asyncEvents(page); const parRequests: string[] = []; - const authorizeRequests: string[] = []; + const parAuthorizeUrls: string[] = []; page.on('request', (request) => { if (request.method() === 'POST' && request.url().includes('/par')) { parRequests.push(request.url()); } - if (request.url().includes('/authorize')) { - authorizeRequests.push(request.url()); + // Capture only the slim PAR authorize redirect (has request_uri, not scope) + if (request.url().includes('/authorize') && request.url().includes('request_uri=')) { + parAuthorizeUrls.push(request.url()); } }); @@ -44,9 +45,9 @@ test.describe('PAR (Pushed Authorization Request) login tests', () => { // PAR POST was made expect(parRequests.length).toBeGreaterThan(0); - // Authorize URL only contains client_id + request_uri (not scope/code_challenge) - expect(authorizeRequests.length).toBeGreaterThan(0); - const authorizeUrl = new URL(authorizeRequests[0]); + // 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); From a3f968a9ed14db318f8da5a5f3d7b0fa83d930b3 Mon Sep 17 00:00:00 2001 From: Ryan Bas Date: Wed, 13 May 2026 12:11:37 -0600 Subject: [PATCH 14/19] fix(oidc-client): strip prompt from PAR POST body, append to slim authorize URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AM evaluates prompt=none eagerly at push-time — with no active session it returns invalid_request instead of login_required, breaking background PAR auth. Per RFC 9126 intent, prompt belongs on the final redirect URL so the authorization server evaluates it at request-time. Also adds e2e fixtures and tests for background PAR (ParClient) and fixes the redirect test's authorize URL filter to correctly identify the slim PAR URL. --- e2e/oidc-app/vite.config.ts | 2 +- e2e/oidc-suites/src/par.spec.ts | 28 +++++++++++++++++++ .../src/lib/authorize.request.utils.test.ts | 27 ++++++++++++++++++ .../src/lib/authorize.request.utils.ts | 14 +++++++--- 4 files changed, 66 insertions(+), 5 deletions(-) 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 index 3367fad30e..d680f95c92 100644 --- a/e2e/oidc-suites/src/par.spec.ts +++ b/e2e/oidc-suites/src/par.spec.ts @@ -11,6 +11,34 @@ import { pingAmUsername, pingAmPassword } from './utils/demo-users.js'; import { asyncEvents } from './utils/async-events.js'; test.describe('PAR (Pushed Authorization Request) login tests', () => { + test('background login with PAR enabled (ParClient) obtains access token', async ({ page }) => { + const { clickWithRedirect, 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/'); + + await clickWithRedirect('Login (Background)', '**/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 for background request + expect(parRequests.length).toBeGreaterThan(0); + }); + test('redirect login with PAR enabled obtains access token and uses slim authorize URL', async ({ page, }) => { 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 428e56f6a5..0e7727b27a 100644 --- a/packages/oidc-client/src/lib/authorize.request.utils.test.ts +++ b/packages/oidc-client/src/lib/authorize.request.utils.test.ts @@ -7,6 +7,7 @@ import { it, expect } from '@effect/vitest'; import { Micro } from 'effect'; 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'; @@ -174,6 +175,32 @@ it.effect('parAuthorizeµ fails with network_error when PAR response is missing }), ); +it.effect('parAuthorizeµ with prompt=none puts prompt on slim authorize URL, not 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 so AM evaluates it at request-time + expect(url).toContain('prompt=none'); + // prompt must NOT be in the PAR POST body + const parBodyArg = buildParamsSpy.mock.calls[0][0] as unknown as Record; + expect(parBodyArg.prompt).toBeUndefined(); + }), +); + it.effect('parAuthorizeµ with pi.flow includes response_mode in slim authorize URL', () => Micro.gen(function* () { const configWithPar: OidcConfig = { ...config, par: true }; diff --git a/packages/oidc-client/src/lib/authorize.request.utils.ts b/packages/oidc-client/src/lib/authorize.request.utils.ts index a350c04ab2..e9b17523e6 100644 --- a/packages/oidc-client/src/lib/authorize.request.utils.ts +++ b/packages/oidc-client/src/lib/authorize.request.utils.ts @@ -208,8 +208,12 @@ export function parAuthorizeµ( const challenge = await createChallenge(authUrlOptions.verifier); + // prompt is a runtime hint about the current session — evaluated at authorize-time, + // not at PAR push time. Strip it from the body; it goes on the slim authorize URL. + const { prompt, ...parBodyOptions } = options ?? {}; + const body = buildAuthorizeParams({ - ...options, + ...parBodyOptions, clientId: config.clientId, redirectUri: config.redirectUri, scope: config.scope || 'openid', @@ -219,7 +223,7 @@ export function parAuthorizeµ( ...(isPiFlow && { responseMode: 'pi.flow' }), }); - return { body, storeOptions }; + return { body, storeOptions, prompt }; }, catch: (error): AuthorizationError => ({ error: 'PAR parameter build failed', @@ -227,7 +231,7 @@ export function parAuthorizeµ( type: 'auth_error', }), }).pipe( - Micro.flatMap(({ body, storeOptions }) => + Micro.flatMap(({ body, storeOptions, prompt }) => Micro.tryPromise({ try: () => store.dispatch(oidcApi.endpoints.par.initiate({ endpoint: parEndpoint, body })), catch: (error): AuthorizationError => ({ @@ -236,12 +240,13 @@ export function parAuthorizeµ( error instanceof Error ? error.message : 'Failed to dispatch PAR request', type: 'network_error', }), - }).pipe(Micro.map((result) => ({ result, storeOptions }))), + }).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 : {}; @@ -285,6 +290,7 @@ export function parAuthorizeµ( 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 => ({ From 5758a12f8af632548b375a12551598cc9ca7a3e8 Mon Sep 17 00:00:00 2001 From: Ryan Bas Date: Wed, 13 May 2026 12:19:56 -0600 Subject: [PATCH 15/19] chore(e2e): clarify PAR page heading/buttons and add to home page nav --- e2e/oidc-app/src/index.html | 1 + e2e/oidc-app/src/par/index.html | 11 ++++++++--- 2 files changed, 9 insertions(+), 3 deletions(-) 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 index 63bad55107..383cf073c9 100644 --- a/e2e/oidc-app/src/par/index.html +++ b/e2e/oidc-app/src/par/index.html @@ -12,9 +12,14 @@
Home -

OIDC App | PAR Login

- - +

OIDC App | PAR Login (Pushed Authorization Request)

+

+ Client: ParClient — PAR enabled. Authorize params POST back-channel to + /par first; the authorize redirect uses only + client_id + request_uri. +

+ + From 4137d472a7ea264444e87bae38265eaaa4b2a025 Mon Sep 17 00:00:00 2001 From: Ryan Bas Date: Wed, 13 May 2026 12:26:59 -0600 Subject: [PATCH 16/19] fix(oidc-client): send prompt in both PAR body and slim authorize URL AM reads prompt from the stored PAR request body (OAUTH_REQUEST_ATTRIBUTES); RFC-compliant servers read it from the slim authorize URL query param. Sending in both places satisfies both without breaking either path. --- .../src/lib/authorize.request.utils.test.ts | 50 ++++++++++--------- .../src/lib/authorize.request.utils.ts | 5 +- 2 files changed, 29 insertions(+), 26 deletions(-) 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 0e7727b27a..5bfdcb93c6 100644 --- a/packages/oidc-client/src/lib/authorize.request.utils.test.ts +++ b/packages/oidc-client/src/lib/authorize.request.utils.test.ts @@ -175,30 +175,32 @@ it.effect('parAuthorizeµ fails with network_error when PAR response is missing }), ); -it.effect('parAuthorizeµ with prompt=none puts prompt on slim authorize URL, not 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 so AM evaluates it at request-time - expect(url).toContain('prompt=none'); - // prompt must NOT be in the PAR POST body - const parBodyArg = buildParamsSpy.mock.calls[0][0] as unknown as Record; - expect(parBodyArg.prompt).toBeUndefined(); - }), +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', () => diff --git a/packages/oidc-client/src/lib/authorize.request.utils.ts b/packages/oidc-client/src/lib/authorize.request.utils.ts index e9b17523e6..35ca8debc4 100644 --- a/packages/oidc-client/src/lib/authorize.request.utils.ts +++ b/packages/oidc-client/src/lib/authorize.request.utils.ts @@ -208,8 +208,6 @@ export function parAuthorizeµ( const challenge = await createChallenge(authUrlOptions.verifier); - // prompt is a runtime hint about the current session — evaluated at authorize-time, - // not at PAR push time. Strip it from the body; it goes on the slim authorize URL. const { prompt, ...parBodyOptions } = options ?? {}; const body = buildAuthorizeParams({ @@ -221,6 +219,9 @@ export function parAuthorizeµ( 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 }; From e969158118ef7711f4a630e43a61f05af98c8615 Mon Sep 17 00:00:00 2001 From: Ryan Bas Date: Wed, 13 May 2026 12:57:57 -0600 Subject: [PATCH 17/19] feat(e2e): add journey login step to PAR page to establish AM session before background auth --- e2e/oidc-app/src/par/index.html | 36 +++++++++++++++++--- e2e/oidc-app/src/par/main.ts | 59 +++++++++++++++++++++++++++++++-- 2 files changed, 89 insertions(+), 6 deletions(-) diff --git a/e2e/oidc-app/src/par/index.html b/e2e/oidc-app/src/par/index.html index 383cf073c9..86f24176a5 100644 --- a/e2e/oidc-app/src/par/index.html +++ b/e2e/oidc-app/src/par/index.html @@ -7,6 +7,15 @@ #logout { display: none; } + #user-info-btn { + display: none; + } + fieldset { + display: inline-flex; + flex-direction: column; + gap: 0.4rem; + margin-bottom: 1rem; + } @@ -14,11 +23,30 @@ Home

OIDC App | PAR Login (Pushed Authorization Request)

- Client: ParClient — PAR enabled. Authorize params POST back-channel to - /par first; the authorize redirect uses only - client_id + request_uri. + 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

+ diff --git a/e2e/oidc-app/src/par/main.ts b/e2e/oidc-app/src/par/main.ts index 23bc1cb81b..a94da7fadd 100644 --- a/e2e/oidc-app/src/par/main.ts +++ b/e2e/oidc-app/src/par/main.ts @@ -8,12 +8,14 @@ */ 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 clientId = urlParams.get('clientid'); const wellknown = urlParams.get('wellknown'); const config = { - clientId: clientId || 'ParClient', + clientId: 'ParClient', redirectUri: 'http://localhost:8443/par/', scope: 'openid profile email', par: true, @@ -24,4 +26,57 @@ const config = { }, }; +// 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 }); From e7383f6905d77ee83d88f8d779fb7c61a9dceb15 Mon Sep 17 00:00:00 2001 From: Ryan Bas Date: Wed, 13 May 2026 13:01:26 -0600 Subject: [PATCH 18/19] test(e2e): update PAR background test to login via journey first then background auth --- e2e/oidc-suites/src/par.spec.ts | 34 +++++++++++++++++---------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/e2e/oidc-suites/src/par.spec.ts b/e2e/oidc-suites/src/par.spec.ts index d680f95c92..de224ffba2 100644 --- a/e2e/oidc-suites/src/par.spec.ts +++ b/e2e/oidc-suites/src/par.spec.ts @@ -10,12 +10,18 @@ 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 { clickWithRedirect, navigate } = asyncEvents(page); + const { navigate } = asyncEvents(page); const parRequests: string[] = []; - page.on('request', (request) => { if (request.method() === 'POST' && request.url().includes('/par')) { parRequests.push(request.url()); @@ -24,22 +30,18 @@ test.describe('PAR (Pushed Authorization Request) login tests', () => { await navigate('/par/'); - await clickWithRedirect('Login (Background)', '**/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'); + // 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 background request + // PAR POST was made for the background request expect(parRequests.length).toBeGreaterThan(0); }); - test('redirect login with PAR enabled obtains access token and uses slim authorize URL', async ({ + test('redirect login with PAR enabled (ParClient) obtains access token and uses slim authorize URL', async ({ page, }) => { const { clickWithRedirect, navigate } = asyncEvents(page); @@ -51,19 +53,19 @@ test.describe('PAR (Pushed Authorization Request) login tests', () => { if (request.method() === 'POST' && request.url().includes('/par')) { parRequests.push(request.url()); } - // Capture only the slim PAR authorize redirect (has request_uri, not scope) + // 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('/ping-am/?par=true'); + await navigate('/par/'); - await clickWithRedirect('Login (Redirect)', '**/am/XUI/**'); + await clickWithRedirect('Login (Redirect', '**/am/XUI/**'); await page.getByLabel('User Name').fill(pingAmUsername); await page.getByRole('textbox', { name: 'Password' }).fill(pingAmPassword); - await clickWithRedirect('Next', 'http://localhost:8443/ping-am/**'); + await clickWithRedirect('Next', 'http://localhost:8443/par/**'); expect(page.url()).toContain('code'); expect(page.url()).toContain('state'); From 40745d15b4160760692b135c2a4e48e81d33b913 Mon Sep 17 00:00:00 2001 From: Ryan Bas Date: Wed, 13 May 2026 14:00:22 -0600 Subject: [PATCH 19/19] fix(oidc-client): harden error paths from PR review - toAuthorizationError: validate type/error_description fields instead of casting; prevents server-injected strings from bypassing the typed-error contract - client.store factory: return immediately on wellknown fetch failure instead of logging and falling through with undefined data - PAR error logging: include error type+code in log message so PAR_STORAGE_ERROR is not reported as 'Error creating PAR authorize URL'; add tapError to the authorize.url() PAR path which had no logging --- .../oidc-client/src/lib/authorize.request.ts | 6 ++-- .../src/lib/authorize.request.utils.ts | 29 ++++++++++++++----- packages/oidc-client/src/lib/client.store.ts | 12 +++++++- 3 files changed, 37 insertions(+), 10 deletions(-) diff --git a/packages/oidc-client/src/lib/authorize.request.ts b/packages/oidc-client/src/lib/authorize.request.ts index 8dc57cc8cd..815099d8b0 100644 --- a/packages/oidc-client/src/lib/authorize.request.ts +++ b/packages/oidc-client/src/lib/authorize.request.ts @@ -176,9 +176,11 @@ export function authorizeµ( ...options, }; - return parAuthorizeµ(wellknown, config, store, { prompt: 'none', ...options }).pipe( + return parAuthorizeµ(wellknown, config, store, options).pipe( Micro.tap((url) => log.debug('PAR authorize URL created', url)), - Micro.tapError((err) => Micro.sync(() => log.error('Error creating PAR authorize URL', err))), + 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)), diff --git a/packages/oidc-client/src/lib/authorize.request.utils.ts b/packages/oidc-client/src/lib/authorize.request.utils.ts index 35ca8debc4..8822245eba 100644 --- a/packages/oidc-client/src/lib/authorize.request.utils.ts +++ b/packages/oidc-client/src/lib/authorize.request.utils.ts @@ -154,14 +154,29 @@ export function handleResponseµ( * @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' && - 'error' in data && - typeof (data as Record).error === 'string' - ) { - return data as 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', diff --git a/packages/oidc-client/src/lib/client.store.ts b/packages/oidc-client/src/lib/client.store.ts index a991ed6a29..4108d1b18a 100644 --- a/packages/oidc-client/src/lib/client.store.ts +++ b/packages/oidc-client/src/lib/client.store.ts @@ -93,6 +93,10 @@ 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) { @@ -129,7 +133,13 @@ export async function oidc({ if (useParFlow) { const result = await Micro.runPromiseExit( - parAuthorizeµ(wellknown, config, store, options), + 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)) {