Skip to content

Commit edc5b15

Browse files
authored
Merge pull request #631 from ForgeRock/par
feat(oidc-client): add-par-support
2 parents 9f6270c + f90e946 commit edc5b15

36 files changed

Lines changed: 7731 additions & 4415 deletions

.changeset/some-shirts-joke.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@forgerock/sdk-request-middleware': minor
3+
'@forgerock/sdk-oidc': minor
4+
'@forgerock/davinci-client': minor
5+
'@forgerock/oidc-client': minor
6+
'am-mock-api': patch
7+
---
8+
9+
Add support for PAR in oidc-client requests for redirect flows

e2e/am-mock-api/src/app/constants.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
*/
1010

1111
export const authPaths = {
12+
par: ['/am/oauth2/realms/root/par'],
1213
tokenExchange: [
1314
'/am/auth/tokenExchange',
1415
'/am/oauth2/realms/root/access_token',

e2e/am-mock-api/src/app/responses.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1348,6 +1348,11 @@ export const recaptchaEnterpriseCallback = {
13481348
],
13491349
};
13501350

1351+
export const parResponse = {
1352+
request_uri: 'urn:ietf:params:oauth:request_uri:mock-par-request-uri',
1353+
expires_in: 60,
1354+
};
1355+
13511356
export const qrCodeCallbacksResponse = {
13521357
authId: 'qrcode-journey-confirmation',
13531358
callbacks: [

e2e/am-mock-api/src/app/routes.auth.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import {
4949
MetadataMarketPlacePingOneEvaluation,
5050
newPiWellKnown,
5151
qrCodeCallbacksResponse,
52+
parResponse,
5253
} from './responses.js';
5354
import initialRegResponse from './response.registration.js';
5455
import {
@@ -664,6 +665,16 @@ export default function (app) {
664665

665666
app.get('/callback', (req, res) => res.status(200).send('ok'));
666667

668+
app.post(authPaths.par, (req, res) => {
669+
if (req.query.scenario === 'error') {
670+
return res.status(400).json({
671+
error: 'invalid_request',
672+
error_description: 'Missing required PAR parameter',
673+
});
674+
}
675+
res.status(201).json(parResponse);
676+
});
677+
667678
app.get('/am/.well-known/oidc-configuration', (req, res) => {
668679
res.send(wellKnownForgeRock);
669680
});

e2e/oidc-app/src/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ <h2>OIDC Client E2E Test Index | Ping Identity JavaScript SDK</h2>
1212
<div id="nav">
1313
<a href="/ping-am/">Ping AM</a>
1414
<a href="/ping-one/">Ping One</a>
15+
<a href="/par/">PAR (Pushed Authorization Request)</a>
1516
</div>
1617
</div>
1718
<script type="module" src="index.ts"></script>

e2e/oidc-app/src/par/index.html

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
<!doctype html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1" />
6+
<title>E2E Test | Ping Identity JavaScript SDK</title>
7+
8+
<style>
9+
#logout {
10+
display: none;
11+
}
12+
#user-info-btn {
13+
display: none;
14+
}
15+
fieldset {
16+
display: inline-flex;
17+
flex-direction: column;
18+
gap: 0.4rem;
19+
margin-bottom: 1rem;
20+
}
21+
</style>
22+
</head>
23+
<body>
24+
<div id="app">
25+
<a href="/">Home</a>
26+
<h1>OIDC App | PAR Login (Pushed Authorization Request)</h1>
27+
<p>
28+
Client: <code>ParClient</code> &mdash; PAR enabled. Authorize params are sent via
29+
back-channel POST to <code>/par</code> first, then a slim URL (<code
30+
>client_id + request_uri</code
31+
>
32+
only) is used for the authorize redirect.
33+
</p>
34+
35+
<h2>Step 1: Establish AM Session (Journey: Login)</h2>
36+
<p>
37+
Background PAR auth requires an existing AM session. Log in via the Login journey first.
38+
</p>
39+
<form id="journey-form">
40+
<fieldset>
41+
<label for="username">User Name</label>
42+
<input id="username" type="text" autocomplete="username" />
43+
<label for="password">Password</label>
44+
<input id="password" type="password" autocomplete="current-password" />
45+
<button type="submit">Login (Journey)</button>
46+
</fieldset>
47+
</form>
48+
<p id="journey-status"></p>
49+
50+
<h2>Step 2: PAR OAuth</h2>
51+
<button id="login-background" disabled>Login (Background &mdash; PAR + iframe)</button>
52+
<button id="login-redirect">Login (Redirect &mdash; PAR slim URL)</button>
53+
<button id="get-tokens">Get Tokens (Local)</button>
54+
<button id="get-tokens-background">Get Tokens (Background)</button>
55+
<button id="renew-tokens">Renew Tokens</button>
56+
<button id="logout">Logout</button>
57+
<button id="user-info-btn">User Info</button>
58+
<button id="revoke">Revoke Token</button>
59+
<a href="/par/">Start Over</a>
60+
</div>
61+
<script type="module" src="./main.ts"></script>
62+
</body>
63+
</html>

e2e/oidc-app/src/par/main.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/*
2+
*
3+
* Copyright © 2025 Ping Identity Corporation. All right reserved.
4+
*
5+
* This software may be modified and distributed under the terms
6+
* of the MIT license. See the LICENSE file for details.
7+
*
8+
*/
9+
import { oidcApp } from '../utils/oidc-app.js';
10+
11+
const AM_BASE = 'https://openam-sdks.forgeblocks.com/am';
12+
const REALM = 'alpha';
13+
14+
const urlParams = new URLSearchParams(window.location.search);
15+
const wellknown = urlParams.get('wellknown');
16+
17+
const config = {
18+
clientId: 'ParClient',
19+
redirectUri: 'http://localhost:8443/par/',
20+
scope: 'openid profile email',
21+
par: true,
22+
serverConfig: {
23+
wellknown:
24+
wellknown ||
25+
'https://openam-sdks.forgeblocks.com/am/oauth2/alpha/.well-known/openid-configuration',
26+
},
27+
};
28+
29+
// Run journey Login to establish an AM session before background PAR auth
30+
async function runLoginJourney(username: string, password: string): Promise<void> {
31+
const authenticateUrl = `${AM_BASE}/json/realms/root/realms/${REALM}/authenticate?authIndexType=service&authIndexValue=Login`;
32+
33+
// Step 1: start the journey
34+
const initRes = await fetch(authenticateUrl, {
35+
method: 'POST',
36+
credentials: 'include',
37+
headers: { 'Content-Type': 'application/json', 'Accept-API-Version': 'resource=2.1' },
38+
body: '{}',
39+
});
40+
const initJson = await initRes.json();
41+
42+
if (initJson.successUrl) return; // already authenticated
43+
44+
// Fill NameCallback + PasswordCallback
45+
for (const cb of initJson.callbacks ?? []) {
46+
if (cb.type === 'NameCallback') cb.input[0].value = username;
47+
if (cb.type === 'PasswordCallback') cb.input[0].value = password;
48+
}
49+
50+
// Step 2: submit credentials
51+
const submitRes = await fetch(authenticateUrl, {
52+
method: 'POST',
53+
credentials: 'include',
54+
headers: { 'Content-Type': 'application/json', 'Accept-API-Version': 'resource=2.1' },
55+
body: JSON.stringify(initJson),
56+
});
57+
const submitJson = await submitRes.json();
58+
59+
if (!submitJson.tokenId && !submitJson.successUrl) {
60+
throw new Error(submitJson.message || 'Login failed');
61+
}
62+
}
63+
64+
const journeyForm = document.getElementById('journey-form') as HTMLFormElement;
65+
const journeyStatus = document.getElementById('journey-status') as HTMLParagraphElement;
66+
const backgroundBtn = document.getElementById('login-background') as HTMLButtonElement;
67+
68+
journeyForm.addEventListener('submit', async (e) => {
69+
e.preventDefault();
70+
const username = (document.getElementById('username') as HTMLInputElement).value;
71+
const password = (document.getElementById('password') as HTMLInputElement).value;
72+
journeyStatus.textContent = 'Logging in…';
73+
try {
74+
await runLoginJourney(username, password);
75+
journeyStatus.textContent = '✓ Session established — background login now available.';
76+
backgroundBtn.disabled = false;
77+
} catch (err) {
78+
journeyStatus.textContent = `✗ ${err instanceof Error ? err.message : 'Login failed'}`;
79+
}
80+
});
81+
82+
oidcApp({ config, urlParams });

e2e/oidc-app/src/utils/oidc-app.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,11 @@ export async function oidcApp({ config, urlParams }) {
4949
const code = urlParams.get('code');
5050
const state = urlParams.get('state');
5151
const piflow = urlParams.get('piflow');
52+
const par = urlParams.get('par') === 'true';
5253

53-
const oidcClient: OidcClient = await oidc({ config });
54+
const oidcClient: OidcClient = await oidc({
55+
config: { ...config, ...(par && { par: true }) },
56+
});
5457
if ('error' in oidcClient) {
5558
displayError(oidcClient);
5659
}

e2e/oidc-app/vite.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { dirname, resolve } from 'path';
44
import { fileURLToPath } from 'url';
55

66
const __dirname = dirname(fileURLToPath(import.meta.url));
7-
const pages = ['ping-am', 'ping-one'];
7+
const pages = ['ping-am', 'ping-one', 'par'];
88
export default defineConfig(() => ({
99
root: __dirname + '/src',
1010
cacheDir: '../../node_modules/.vite/e2e/oidc-app',

e2e/oidc-suites/src/login.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ test('oidc client fails to initialize with bad wellknown', async ({ page }) => {
152152
await page.getByRole('button', { name: 'Login (Background)' }).click();
153153

154154
await expect(page.locator('.error')).toContainText(
155-
'Authorization endpoint not found in wellknown configuration',
155+
'Failed to fetch well-known configuration from:',
156156
);
157157
await expect(page.locator('.error')).toContainText('wellknown_error');
158158
});

0 commit comments

Comments
 (0)