Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/some-shirts-joke.md
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions e2e/am-mock-api/src/app/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
*/

export const authPaths = {
par: ['/am/oauth2/realms/root/par'],
tokenExchange: [
'/am/auth/tokenExchange',
'/am/oauth2/realms/root/access_token',
Expand Down
5 changes: 5 additions & 0 deletions e2e/am-mock-api/src/app/responses.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
11 changes: 11 additions & 0 deletions e2e/am-mock-api/src/app/routes.auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import {
MetadataMarketPlacePingOneEvaluation,
newPiWellKnown,
qrCodeCallbacksResponse,
parResponse,
} from './responses.js';
import initialRegResponse from './response.registration.js';
import {
Expand Down Expand Up @@ -664,6 +665,16 @@ export default function (app) {

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

app.post(authPaths.par, (req, res) => {
if (req.query.scenario === 'error') {
return res.status(400).json({
error: 'invalid_request',
error_description: 'Missing required PAR parameter',
});
}
res.status(201).json(parResponse);
});

app.get('/am/.well-known/oidc-configuration', (req, res) => {
res.send(wellKnownForgeRock);
});
Expand Down
1 change: 1 addition & 0 deletions e2e/oidc-app/src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ <h2>OIDC Client E2E Test Index | Ping Identity JavaScript SDK</h2>
<div id="nav">
<a href="/ping-am/">Ping AM</a>
<a href="/ping-one/">Ping One</a>
<a href="/par/">PAR (Pushed Authorization Request)</a>
</div>
</div>
<script type="module" src="index.ts"></script>
Expand Down
63 changes: 63 additions & 0 deletions e2e/oidc-app/src/par/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>E2E Test | Ping Identity JavaScript SDK</title>

Comment thread
coderabbitai[bot] marked this conversation as resolved.
<style>
#logout {
display: none;
}
#user-info-btn {
display: none;
}
fieldset {
display: inline-flex;
flex-direction: column;
gap: 0.4rem;
margin-bottom: 1rem;
}
</style>
</head>
<body>
<div id="app">
<a href="/">Home</a>
<h1>OIDC App | PAR Login (Pushed Authorization Request)</h1>
<p>
Client: <code>ParClient</code> &mdash; PAR enabled. Authorize params are sent via
back-channel POST to <code>/par</code> first, then a slim URL (<code
>client_id + request_uri</code
>
only) is used for the authorize redirect.
</p>

<h2>Step 1: Establish AM Session (Journey: Login)</h2>
<p>
Background PAR auth requires an existing AM session. Log in via the Login journey first.
</p>
<form id="journey-form">
<fieldset>
<label for="username">User Name</label>
<input id="username" type="text" autocomplete="username" />
<label for="password">Password</label>
<input id="password" type="password" autocomplete="current-password" />
<button type="submit">Login (Journey)</button>
</fieldset>
</form>
<p id="journey-status"></p>

<h2>Step 2: PAR OAuth</h2>
<button id="login-background" disabled>Login (Background &mdash; PAR + iframe)</button>
<button id="login-redirect">Login (Redirect &mdash; PAR slim URL)</button>
<button id="get-tokens">Get Tokens (Local)</button>
<button id="get-tokens-background">Get Tokens (Background)</button>
<button id="renew-tokens">Renew Tokens</button>
<button id="logout">Logout</button>
<button id="user-info-btn">User Info</button>
<button id="revoke">Revoke Token</button>
<a href="/par/">Start Over</a>
</div>
<script type="module" src="./main.ts"></script>
</body>
</html>
82 changes: 82 additions & 0 deletions e2e/oidc-app/src/par/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/*
*
* Copyright © 2025 Ping Identity Corporation. All right reserved.
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*
*/
import { oidcApp } from '../utils/oidc-app.js';

const AM_BASE = 'https://openam-sdks.forgeblocks.com/am';
const REALM = 'alpha';

const urlParams = new URLSearchParams(window.location.search);
const wellknown = urlParams.get('wellknown');

const config = {
clientId: 'ParClient',
redirectUri: 'http://localhost:8443/par/',
scope: 'openid profile email',
par: true,
serverConfig: {
wellknown:
wellknown ||
'https://openam-sdks.forgeblocks.com/am/oauth2/alpha/.well-known/openid-configuration',
},
};

// Run journey Login to establish an AM session before background PAR auth
async function runLoginJourney(username: string, password: string): Promise<void> {
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 });
5 changes: 4 additions & 1 deletion e2e/oidc-app/src/utils/oidc-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
2 changes: 1 addition & 1 deletion e2e/oidc-app/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion e2e/oidc-suites/src/login.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ test('oidc client fails to initialize with bad wellknown', async ({ page }) => {
await page.getByRole('button', { name: 'Login (Background)' }).click();

await expect(page.locator('.error')).toContainText(
'Authorization endpoint not found in wellknown configuration',
'Failed to fetch well-known configuration from:',
);
await expect(page.locator('.error')).toContainText('wellknown_error');
});
134 changes: 134 additions & 0 deletions e2e/oidc-suites/src/par.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/*
*
* Copyright © 2025 Ping Identity Corporation. All right reserved.
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*
*/
import { test, expect } from '@playwright/test';
import { pingAmUsername, pingAmPassword } from './utils/demo-users.js';
import { asyncEvents } from './utils/async-events.js';

async function loginJourney(page, username: string, password: string) {
await page.getByLabel('User Name').fill(username);
await page.getByLabel('Password').fill(password);
await page.getByRole('button', { name: 'Login (Journey)' }).click();
await expect(page.locator('#journey-status')).toContainText('Session established');
}

// Synthetic PAR error endpoint — intercepted by Playwright before any real network call
const SYNTHETIC_PAR_ERROR_URL = 'http://localhost:8443/synthetic-par-error-endpoint';
// The real wellknown used by the PAR app (intercepted to inject the synthetic PAR endpoint)
const DEFAULT_WELLKNOWN_PATTERN =
'**/openam-sdks.forgeblocks.com/am/oauth2/alpha/.well-known/openid-configuration*';

test.describe('PAR (Pushed Authorization Request) login tests', () => {
test('PAR authorize returns 400 error — SDK surfaces error to the UI without redirecting', async ({
page,
}) => {
const { navigate } = asyncEvents(page);

// Intercept the wellknown to inject our synthetic PAR endpoint URL
await page.route(DEFAULT_WELLKNOWN_PATTERN, async (route) => {
const response = await route.fetch();
const json = await response.json();
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
...json,
pushed_authorization_request_endpoint: SYNTHETIC_PAR_ERROR_URL,
}),
});
});

// Intercept the synthetic PAR endpoint and return a 400 error
await page.route(SYNTHETIC_PAR_ERROR_URL, (route) => {
route.fulfill({
status: 400,
contentType: 'application/json',
body: JSON.stringify({
error: 'invalid_request',
error_description: 'Missing required PAR parameter',
}),
});
});

await navigate('/par/');

// Clicking redirect login triggers PAR → receives 400 → SDK should surface an error
await page.getByRole('button', { name: /^Login \(Redirect\b/ }).click();

// The SDK should surface an error in the UI instead of redirecting away
await expect(page.locator('.error')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.error')).toContainText('PAR_ERROR');
});

test('background login with PAR enabled (ParClient) obtains access token', async ({ page }) => {
const { navigate } = asyncEvents(page);

const parRequests: string[] = [];
page.on('request', (request) => {
if (request.method() === 'POST' && request.url().includes('/par')) {
parRequests.push(request.url());
}
});

await navigate('/par/');

// Establish AM session via the Login journey before attempting background PAR auth
await loginJourney(page, pingAmUsername, pingAmPassword);

// Background button is now enabled — click and wait for the iframe to return a code
await page.getByRole('button', { name: /^Login \(Background\b/ }).click();
await expect(page.locator('#accessToken-0')).not.toBeEmpty();

// PAR POST was made for the background request
expect(parRequests.length).toBeGreaterThan(0);
});

test('redirect login with PAR enabled (ParClient) obtains access token and uses slim authorize URL', async ({
page,
}) => {
const { clickWithRedirect, navigate } = asyncEvents(page);

const parRequests: string[] = [];
const parAuthorizeUrls: string[] = [];

page.on('request', (request) => {
if (request.method() === 'POST' && request.url().includes('/par')) {
parRequests.push(request.url());
}
// Capture the slim PAR authorize redirect — has request_uri, not scope
if (request.url().includes('/authorize') && request.url().includes('request_uri=')) {
parAuthorizeUrls.push(request.url());
}
});

await navigate('/par/');

await clickWithRedirect(/^Login \(Redirect\b/, '**/am/XUI/**');

await page.getByLabel('User Name').fill(pingAmUsername);
await page.getByRole('textbox', { name: 'Password' }).fill(pingAmPassword);
await clickWithRedirect('Next', 'http://localhost:8443/par/**');

expect(page.url()).toContain('code');
expect(page.url()).toContain('state');

await expect(page.locator('#accessToken-0')).not.toBeEmpty();

// PAR POST was made
expect(parRequests.length).toBeGreaterThan(0);

// Slim authorize URL contains only client_id + request_uri (not scope/code_challenge)
expect(parAuthorizeUrls.length).toBeGreaterThan(0);
const authorizeUrl = new URL(parAuthorizeUrls[0]);
expect(authorizeUrl.searchParams.has('client_id')).toBe(true);
expect(authorizeUrl.searchParams.has('request_uri')).toBe(true);
expect(authorizeUrl.searchParams.has('scope')).toBe(false);
expect(authorizeUrl.searchParams.has('code_challenge')).toBe(false);
expect(authorizeUrl.searchParams.has('redirect_uri')).toBe(false);
});
});
Loading
Loading