Skip to content

Commit bace05b

Browse files
committed
feat(davinci-client): enforce PAR when server mandates it via well-known
If require_pushed_authorization_requests is true in the OIDC discovery response, the server will reject any direct authorize request. This change stores the PAR endpoint and mandate flag from the well-known response in the config slice, then performs a PAR POST before the authorize redirect whenever the server requires it.
1 parent d60e979 commit bace05b

6 files changed

Lines changed: 262 additions & 6 deletions

File tree

packages/davinci-client/src/lib/config.slice.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,4 +68,67 @@ describe('The config slice reducers', () => {
6868
},
6969
});
7070
});
71+
72+
it('should store par endpoint when wellknown includes pushed_authorization_request_endpoint', () => {
73+
expect(
74+
configSlice.reducer(undefined, {
75+
type: 'config/set',
76+
payload: {
77+
clientId: '123',
78+
serverConfig: {
79+
wellknown: 'https://base.example.com/as/.well-known/openid-configuration',
80+
},
81+
wellknownResponse: {
82+
authorization_endpoint: 'https://base.example.com/as/authorize',
83+
pushed_authorization_request_endpoint: 'https://base.example.com/as/par',
84+
},
85+
},
86+
}),
87+
).toMatchObject({
88+
endpoints: {
89+
authorize: 'https://base.example.com/as/authorize',
90+
par: 'https://base.example.com/as/par',
91+
},
92+
});
93+
});
94+
95+
it('should store requirePar=true when wellknown requires PAR', () => {
96+
expect(
97+
configSlice.reducer(undefined, {
98+
type: 'config/set',
99+
payload: {
100+
clientId: '123',
101+
serverConfig: {
102+
wellknown: 'https://base.example.com/as/.well-known/openid-configuration',
103+
},
104+
wellknownResponse: {
105+
authorization_endpoint: 'https://base.example.com/as/authorize',
106+
pushed_authorization_request_endpoint: 'https://base.example.com/as/par',
107+
require_pushed_authorization_requests: true,
108+
},
109+
},
110+
}),
111+
).toMatchObject({
112+
endpoints: {
113+
par: 'https://base.example.com/as/par',
114+
requirePar: true,
115+
},
116+
});
117+
});
118+
119+
it('should not include par fields when wellknown omits them', () => {
120+
const result = configSlice.reducer(undefined, {
121+
type: 'config/set',
122+
payload: {
123+
clientId: '123',
124+
serverConfig: { wellknown: 'https://base.example.com/as/.well-known/openid-configuration' },
125+
wellknownResponse: {
126+
authorization_endpoint: 'https://base.example.com/as/authorize',
127+
},
128+
},
129+
});
130+
131+
expect(result.endpoints).not.toHaveProperty('par');
132+
expect(result.endpoints).not.toHaveProperty('requirePar');
133+
});
71134
});

packages/davinci-client/src/lib/config.slice.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ export const configSlice = createSlice({
6060
introspection_endpoint: introspection,
6161
token_endpoint: tokens,
6262
userinfo_endpoint: userinfo,
63+
pushed_authorization_request_endpoint: par,
64+
require_pushed_authorization_requests: requirePar,
6365
} = action.payload.wellknownResponse;
6466

6567
state.endpoints = {
@@ -68,6 +70,8 @@ export const configSlice = createSlice({
6870
introspection,
6971
tokens,
7072
userinfo,
73+
...(par && { par }),
74+
...(requirePar !== undefined && { requirePar }),
7175
};
7276
},
7377
},

packages/davinci-client/src/lib/davinci.api.ts

Lines changed: 57 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { initQuery } from '@forgerock/sdk-request-middleware';
2424
import { createAuthorizeUrl } from '@forgerock/sdk-oidc';
2525

2626
import { handleResponse, transformActionRequest, transformSubmitRequest } from './davinci.utils.js';
27+
import { shouldUsePar, buildParBody, buildParAuthorizeUrl } from './par.utils.js';
2728

2829
import type { logger as loggerFn } from '@forgerock/sdk-logger';
2930
import type { ActionTypes, RequestMiddleware } from '@forgerock/sdk-request-middleware';
@@ -270,23 +271,73 @@ export const davinciApi = createApi({
270271
responseMode: 'pi.flow',
271272
scope: state?.config?.scope,
272273
});
273-
const url = new URL(authorizeUrl);
274-
const existingParams = url.searchParams;
274+
275+
let finalUrl: URL;
276+
277+
if (shouldUsePar(state.config.endpoints)) {
278+
const parEndpoint = state.config.endpoints.par;
279+
if (!parEndpoint) {
280+
return {
281+
error: {
282+
status: 400,
283+
data: 'Server requires PAR but pushed_authorization_request_endpoint is missing from well-known response',
284+
},
285+
};
286+
}
287+
288+
const authParams = new URL(authorizeUrl).searchParams;
289+
const parBody = buildParBody(Object.fromEntries(authParams.entries()));
290+
291+
const parRequest: FetchArgs = {
292+
url: parEndpoint,
293+
credentials: 'include',
294+
method: 'POST',
295+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
296+
body: parBody.toString(),
297+
};
298+
299+
logger.debug('Davinci PAR request', parRequest);
300+
const parResult = await initQuery(parRequest, 'start')
301+
.applyMiddleware(requestMiddleware)
302+
.applyQuery(async (req: FetchArgs) => await baseQuery(req));
303+
304+
if (parResult.error) {
305+
return parResult;
306+
}
307+
308+
const parResponse = parResult.data as { request_uri?: string };
309+
if (!parResponse?.request_uri) {
310+
return {
311+
error: {
312+
status: 400,
313+
data: 'PAR response missing request_uri',
314+
},
315+
};
316+
}
317+
318+
finalUrl = new URL(
319+
buildParAuthorizeUrl(
320+
authorizeEndpoint,
321+
state.config.clientId,
322+
parResponse.request_uri,
323+
),
324+
);
325+
} else {
326+
finalUrl = new URL(authorizeUrl);
327+
}
275328

276329
if (options?.query) {
277330
Object.entries(options.query).forEach(([key, value]) => {
278331
/**
279332
* We use set here because if we have existing params, we want
280333
* to make sure we override them and not add duplicates
281334
*/
282-
existingParams.set(key, String(value));
335+
finalUrl.searchParams.set(key, String(value));
283336
});
284-
285-
url.search = existingParams.toString();
286337
}
287338

288339
const request: FetchArgs = {
289-
url: url.toString(),
340+
url: finalUrl.toString(),
290341
credentials: 'include',
291342
method: 'GET',
292343
headers: {
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/*
2+
* Copyright (c) 2025 Ping Identity Corporation. All rights reserved.
3+
*
4+
* This software may be modified and distributed under the terms
5+
* of the MIT license. See the LICENSE file for details.
6+
*/
7+
import { describe, it, expect } from 'vitest';
8+
import { shouldUsePar, buildParBody, buildParAuthorizeUrl } from './par.utils.js';
9+
import type { Endpoints } from './wellknown.types.js';
10+
11+
const baseEndpoints: Endpoints = {
12+
authorize: 'https://example.com/as/authorize',
13+
issuer: 'https://example.com/as',
14+
introspection: 'https://example.com/as/introspect',
15+
tokens: 'https://example.com/as/token',
16+
userinfo: 'https://example.com/as/userinfo',
17+
};
18+
19+
describe('shouldUsePar', () => {
20+
it('returns false when no PAR endpoint exists', () => {
21+
expect(shouldUsePar(baseEndpoints)).toBe(false);
22+
});
23+
24+
it('returns false when PAR endpoint exists but requirePar is false', () => {
25+
expect(
26+
shouldUsePar({ ...baseEndpoints, par: 'https://example.com/as/par', requirePar: false }),
27+
).toBe(false);
28+
});
29+
30+
it('returns true when requirePar is true, even if par endpoint is absent', () => {
31+
expect(shouldUsePar({ ...baseEndpoints, requirePar: true })).toBe(true);
32+
});
33+
34+
it('returns true when requirePar is true and par endpoint is present', () => {
35+
expect(
36+
shouldUsePar({ ...baseEndpoints, par: 'https://example.com/as/par', requirePar: true }),
37+
).toBe(true);
38+
});
39+
});
40+
41+
describe('buildParBody', () => {
42+
it('encodes all provided params as form-urlencoded', () => {
43+
const body = buildParBody({
44+
client_id: 'myClient',
45+
redirect_uri: 'https://app.example.com/callback',
46+
response_type: 'code',
47+
scope: 'openid profile',
48+
code_challenge: 'abc123',
49+
code_challenge_method: 'S256',
50+
state: 'xyz',
51+
});
52+
53+
expect(body.get('client_id')).toBe('myClient');
54+
expect(body.get('redirect_uri')).toBe('https://app.example.com/callback');
55+
expect(body.get('response_type')).toBe('code');
56+
expect(body.get('scope')).toBe('openid profile');
57+
expect(body.get('code_challenge')).toBe('abc123');
58+
expect(body.get('code_challenge_method')).toBe('S256');
59+
expect(body.get('state')).toBe('xyz');
60+
});
61+
62+
it('omits empty string values', () => {
63+
const body = buildParBody({
64+
client_id: 'myClient',
65+
response_type: 'code',
66+
prompt: '',
67+
response_mode: '',
68+
});
69+
70+
expect(body.has('prompt')).toBe(false);
71+
expect(body.has('response_mode')).toBe(false);
72+
});
73+
});
74+
75+
describe('buildParAuthorizeUrl', () => {
76+
it('builds a minimal authorize URL with client_id and request_uri only', () => {
77+
const url = buildParAuthorizeUrl(
78+
'https://example.com/as/authorize',
79+
'myClient',
80+
'urn:ietf:params:oauth:request_uri:abc123',
81+
);
82+
83+
const parsed = new URL(url);
84+
expect(parsed.origin + parsed.pathname).toBe('https://example.com/as/authorize');
85+
expect(parsed.searchParams.get('client_id')).toBe('myClient');
86+
expect(parsed.searchParams.get('request_uri')).toBe('urn:ietf:params:oauth:request_uri:abc123');
87+
expect(parsed.searchParams.size).toBe(2);
88+
});
89+
});
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/*
2+
* Copyright (c) 2025 Ping Identity Corporation. All rights reserved.
3+
*
4+
* This software may be modified and distributed under the terms
5+
* of the MIT license. See the LICENSE file for details.
6+
*/
7+
import type { Endpoints } from './wellknown.types.js';
8+
9+
/**
10+
* Returns true if the server mandates PAR via require_pushed_authorization_requests.
11+
* A client-side usePar flag cannot override a server mandate (RFC 9126).
12+
*/
13+
export function shouldUsePar(endpoints: Endpoints): boolean {
14+
return endpoints.requirePar === true;
15+
}
16+
17+
/**
18+
* Builds the form-urlencoded body for a PAR request (RFC 9126 §2.1).
19+
* Empty string values are omitted — they carry no meaning on the wire.
20+
*/
21+
export function buildParBody(params: Record<string, string>): URLSearchParams {
22+
const body = new URLSearchParams();
23+
for (const [key, value] of Object.entries(params)) {
24+
if (value !== '') {
25+
body.set(key, value);
26+
}
27+
}
28+
return body;
29+
}
30+
31+
/**
32+
* Builds the authorization URL after a successful PAR response.
33+
* Per RFC 9126 §4, only client_id and request_uri are sent — all other
34+
* authorization parameters were already submitted in the PAR request.
35+
*/
36+
export function buildParAuthorizeUrl(
37+
authorizeEndpoint: string,
38+
clientId: string,
39+
requestUri: string,
40+
): string {
41+
const url = new URL(authorizeEndpoint);
42+
url.searchParams.set('client_id', clientId);
43+
url.searchParams.set('request_uri', requestUri);
44+
return url.toString();
45+
}

packages/davinci-client/src/lib/wellknown.types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,8 @@ export interface Endpoints {
1818
introspection: string;
1919
tokens: string;
2020
userinfo: string;
21+
/** PAR endpoint — present when server advertises pushed_authorization_request_endpoint */
22+
par?: string;
23+
/** Whether the server mandates PAR regardless of client config */
24+
requirePar?: boolean;
2125
}

0 commit comments

Comments
 (0)