-
Notifications
You must be signed in to change notification settings - Fork 3
feat(oidc-client): add-par-support #631
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
| 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> | ||
|
|
||
| <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> — 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 — PAR + iframe)</button> | ||
| <button id="login-redirect">Login (Redirect — 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> | ||
| 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 }); |
| 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(); | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Incomplete button text selector pattern. The button selector uses 🔧 Proposed fix for consistent button selector- await page.getByRole('button', { name: /Login \(Background/ }).click();
+ await page.getByRole('button', { name: /Login \(Background\)/ }).click();📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||
| 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/**'); | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Incomplete button text selector. The button text 🔧 Proposed fix for complete button text- await clickWithRedirect('Login (Redirect', '**/am/XUI/**');
+ await clickWithRedirect('Login (Redirect)', '**/am/XUI/**');📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||
|
|
||||||
| 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); | ||||||
| }); | ||||||
| }); | ||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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
📝 Committable suggestion
🤖 Prompt for AI Agents