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
61 changes: 61 additions & 0 deletions e2e/oidc-app/src/par/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<!doctype html>
<html>
<head>
<title>E2E Test | Ping Identity JavaScript SDK</title>

Comment on lines +1 to +5
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add charset and viewport meta tags.

The HTML is missing standard meta tags that ensure proper character encoding and responsive rendering. These are essential even for E2E test pages to prevent rendering issues.

🌐 Proposed fix to add meta tags
 <html>
   <head>
+    <meta charset="utf-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <title>E2E Test | Ping Identity JavaScript SDK</title>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<!doctype html>
<html>
<head>
<title>E2E Test | Ping Identity JavaScript SDK</title>
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>E2E Test | Ping Identity JavaScript SDK</title>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e/oidc-app/src/par/index.html` around lines 1 - 5, Add standard meta tags
for character encoding and responsive viewport inside the existing <head>
element (before or after the <title>) in the HTML file: include a meta
charset="utf-8" and a meta name="viewport" content="width=device-width,
initial-scale=1". Update the head block that currently contains the <title> to
insert those two meta tags so the page uses UTF-8 encoding and proper mobile
scaling.

<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' }).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/ }).click();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Incomplete button text selector pattern.

The button selector uses /Login \(Background/ without a closing parenthesis, which differs from the complete button text used on line 16 ('Login (Journey)'). While this partial match may work, it's inconsistent and could match unintended buttons.

🔧 Proposed fix for consistent button selector
-    await page.getByRole('button', { name: /Login \(Background/ }).click();
+    await page.getByRole('button', { name: /Login \(Background\)/ }).click();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
await page.getByRole('button', { name: /Login \(Background/ }).click();
await page.getByRole('button', { name: /Login \(Background\)/ }).click();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e/oidc-suites/src/par.spec.ts` at line 37, The button selector uses an
unterminated regex (/Login \(Background/) which is inconsistent with the full
button text used elsewhere (e.g., 'Login (Journey)'); update the selector in the
call to page.getByRole('button', { name: ... }) to match the complete label —
either supply the exact string "Login (Background)" or a properly escaped/closed
regex like /Login \(Background\)/ so it only targets the intended button.

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

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

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

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

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

await navigate('/par/');

await clickWithRedirect('Login (Redirect', '**/am/XUI/**');
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Incomplete button text selector.

The button text 'Login (Redirect' is missing the closing parenthesis. For consistency with line 16 and clarity, the complete button text should be used.

🔧 Proposed fix for complete button text
-    await clickWithRedirect('Login (Redirect', '**/am/XUI/**');
+    await clickWithRedirect('Login (Redirect)', '**/am/XUI/**');
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
await clickWithRedirect('Login (Redirect', '**/am/XUI/**');
await clickWithRedirect('Login (Redirect)', '**/am/XUI/**');
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e/oidc-suites/src/par.spec.ts` at line 64, The selector string passed to
clickWithRedirect is missing a closing parenthesis causing an incomplete button
text match; update the call to clickWithRedirect (in par.spec.ts) to use the
full button label "Login (Redirect)" so it matches the actual button text
(consistent with the usage on line 16) and ensures the selector finds the
correct element.


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