From 98f1e5c7b1cc629aeb7cb1f7865afbd217ef86c4 Mon Sep 17 00:00:00 2001 From: Vatsal Parikh Date: Sun, 7 Jun 2026 18:04:38 -0700 Subject: [PATCH] feat(oidc-client): add oidc session check with response type of id_token and none --- .changeset/brave-foxes-dance.md | 6 + e2e/oidc-app/src/ping-am/index.html | 2 + e2e/oidc-app/src/ping-one/index.html | 2 + e2e/oidc-app/src/utils/oidc-app.ts | 154 ++++-- e2e/oidc-suites/src/session.spec.ts | 246 +++++++++ e2e/oidc-suites/src/utils/login.ts | 17 + .../oidc-client/api-report/oidc-client.api.md | 38 ++ .../api-report/oidc-client.types.api.md | 38 ++ packages/oidc-client/package.json | 3 +- .../oidc-client/src/lib/client.store.test.ts | 58 +- packages/oidc-client/src/lib/client.store.ts | 55 +- packages/oidc-client/src/lib/oidc.api.ts | 98 +++- .../src/lib/session.micros.test.ts | 512 ++++++++++++++++++ .../oidc-client/src/lib/session.micros.ts | 235 ++++++++ packages/oidc-client/src/lib/session.types.ts | 41 ++ packages/oidc-client/src/types.ts | 3 +- .../src/lib/iframe-manager.effects.test.ts | 204 ++++++- .../src/lib/iframe-manager.effects.ts | 76 ++- pnpm-lock.yaml | 11 + pnpm-workspace.yaml | 1 + 20 files changed, 1720 insertions(+), 80 deletions(-) create mode 100644 .changeset/brave-foxes-dance.md create mode 100644 e2e/oidc-suites/src/session.spec.ts create mode 100644 e2e/oidc-suites/src/utils/login.ts create mode 100644 packages/oidc-client/src/lib/session.micros.test.ts create mode 100644 packages/oidc-client/src/lib/session.micros.ts create mode 100644 packages/oidc-client/src/lib/session.types.ts diff --git a/.changeset/brave-foxes-dance.md b/.changeset/brave-foxes-dance.md new file mode 100644 index 0000000000..51fafc7d77 --- /dev/null +++ b/.changeset/brave-foxes-dance.md @@ -0,0 +1,6 @@ +--- +'@forgerock/iframe-manager': minor +'@forgerock/oidc-client': minor +--- + +Add `session.check()` method to oidc client for OIDC prompt=none session verification, with `response_type=none` and `response_type=id_token` support. diff --git a/e2e/oidc-app/src/ping-am/index.html b/e2e/oidc-app/src/ping-am/index.html index 269c9ce9a4..adeaf8d0d5 100644 --- a/e2e/oidc-app/src/ping-am/index.html +++ b/e2e/oidc-app/src/ping-am/index.html @@ -21,6 +21,8 @@

OIDC App | PingAM Login

+ + Start Over diff --git a/e2e/oidc-app/src/ping-one/index.html b/e2e/oidc-app/src/ping-one/index.html index 08c30a4735..e712e67399 100644 --- a/e2e/oidc-app/src/ping-one/index.html +++ b/e2e/oidc-app/src/ping-one/index.html @@ -21,6 +21,8 @@

OIDC App | P1 Login

+ + Start Over diff --git a/e2e/oidc-app/src/utils/oidc-app.ts b/e2e/oidc-app/src/utils/oidc-app.ts index f8565a10c7..534de8b33a 100644 --- a/e2e/oidc-app/src/utils/oidc-app.ts +++ b/e2e/oidc-app/src/utils/oidc-app.ts @@ -10,15 +10,16 @@ import { oidc } from '@forgerock/oidc-client'; import type { AuthorizationError, GenericError, - GetAuthorizationUrlOptions, OauthTokens, OidcClient, + OidcConfig, + SessionCheckOptions, TokenExchangeErrorResponse, } from '@forgerock/oidc-client/types'; let tokenIndex = 0; -function displayError(error) { +function displayError(error: unknown) { const errorEl = document.createElement('div'); errorEl.innerHTML = `

Error: ${JSON.stringify(error, null, 2)}

`; document.body.appendChild(errorEl); @@ -27,25 +28,44 @@ function displayError(error) { function displayTokenResponse( response: OauthTokens | TokenExchangeErrorResponse | GenericError | AuthorizationError, ) { - const appEl = document.getElementById('app'); if ('error' in response || !('accessToken' in response)) { console.error('Token Error:', response); displayError(response); } else { console.log('Token Response:', response); - document.getElementById('logout').style.display = 'block'; - document.getElementById('user-info-btn').style.display = 'block'; - document.getElementById('login-background').style.display = 'none'; - document.getElementById('login-redirect').style.display = 'none'; + const appEl = document.getElementById('app'); + const logoutEl = document.getElementById('logout'); + const userInfoBtnEl = document.getElementById('user-info-btn'); + const loginBackgroundEl = document.getElementById('login-background'); + const loginRedirectEl = document.getElementById('login-redirect'); + + if (logoutEl) { + logoutEl.style.display = 'block'; + } + if (userInfoBtnEl) { + userInfoBtnEl.style.display = 'block'; + } + if (loginBackgroundEl) { + loginBackgroundEl.style.display = 'none'; + } + if (loginRedirectEl) { + loginRedirectEl.style.display = 'none'; + } const tokenInfoEl = document.createElement('div'); tokenInfoEl.innerHTML = `

Access Token: ${response.accessToken}

`; - appEl.appendChild(tokenInfoEl); + appEl?.appendChild(tokenInfoEl); tokenIndex++; } } -export async function oidcApp({ config, urlParams }) { +export async function oidcApp({ + config, + urlParams, +}: { + config: OidcConfig; + urlParams: URLSearchParams; +}) { const code = urlParams.get('code'); const state = urlParams.get('state'); const piflow = urlParams.get('piflow'); @@ -56,20 +76,23 @@ export async function oidcApp({ config, urlParams }) { }); if ('error' in oidcClient) { displayError(oidcClient); + return; } - document.getElementById('login-background').addEventListener('click', async () => { - const authorizeOptions: GetAuthorizationUrlOptions = + document.getElementById('login-background')?.addEventListener('click', async () => { + const authorizeOptions = piflow === 'true' ? { clientId: config.clientId, redirectUri: config.redirectUri, scope: config.scope, responseType: config.responseType ?? 'code', - responseMode: 'pi.flow', + responseMode: 'pi.flow' as const, } : undefined; - const response = await oidcClient.authorize.background(authorizeOptions); + const response = await oidcClient.authorize?.background(authorizeOptions); + + if (!response) return; if ('error' in response) { console.error('Authorization Error:', response); @@ -85,13 +108,16 @@ export async function oidcApp({ config, urlParams }) { // Handle success response from background authorization } else if ('code' in response) { console.log('Authorization Code:', response.code); - const tokenResponse = await oidcClient.token.exchange(response.code, response.state); - displayTokenResponse(tokenResponse); + const tokenResponse = await oidcClient.token?.exchange(response.code, response.state); + if (tokenResponse) { + displayTokenResponse(tokenResponse); + } } }); - document.getElementById('login-redirect').addEventListener('click', async () => { - const authorizeUrl = await oidcClient.authorize.url(); + document.getElementById('login-redirect')?.addEventListener('click', async () => { + const authorizeUrl = await oidcClient.authorize?.url(); + if (!authorizeUrl) return; if (typeof authorizeUrl !== 'string' && 'error' in authorizeUrl) { console.error('Authorization URL Error:', authorizeUrl); displayError(authorizeUrl); @@ -102,23 +128,31 @@ export async function oidcApp({ config, urlParams }) { } }); - document.getElementById('get-tokens').addEventListener('click', async () => { - const response = await oidcClient.token.get(); - displayTokenResponse(response); + document.getElementById('get-tokens')?.addEventListener('click', async () => { + const response = await oidcClient.token?.get(); + if (response) { + displayTokenResponse(response); + } }); - document.getElementById('get-tokens-background').addEventListener('click', async () => { - const response = await oidcClient.token.get({ backgroundRenew: true }); - displayTokenResponse(response); + document.getElementById('get-tokens-background')?.addEventListener('click', async () => { + const response = await oidcClient.token?.get({ backgroundRenew: true }); + if (response) { + displayTokenResponse(response); + } }); - document.getElementById('renew-tokens').addEventListener('click', async () => { - const response = await oidcClient.token.get({ backgroundRenew: true, forceRenew: true }); - displayTokenResponse(response); + document.getElementById('renew-tokens')?.addEventListener('click', async () => { + const response = await oidcClient.token?.get({ backgroundRenew: true, forceRenew: true }); + if (response) { + displayTokenResponse(response); + } }); - document.getElementById('user-info-btn').addEventListener('click', async () => { - const userInfo = await oidcClient.user.info(); + document.getElementById('user-info-btn')?.addEventListener('click', async () => { + const userInfo = await oidcClient.user?.info(); + + if (!userInfo) return; if ('error' in userInfo) { console.error('User Info Error:', userInfo); @@ -129,42 +163,78 @@ export async function oidcApp({ config, urlParams }) { const appEl = document.getElementById('app'); const userInfoEl = document.createElement('div'); userInfoEl.innerHTML = `

User Info: ${JSON.stringify(userInfo, null, 2)}

`; - appEl.appendChild(userInfoEl); + appEl?.appendChild(userInfoEl); } }); - document.getElementById('revoke').addEventListener('click', async () => { - const response = await oidcClient.token.revoke(); + document.getElementById('revoke')?.addEventListener('click', async () => { + const response = await oidcClient.token?.revoke(); + + if (!response) return; if ('error' in response) { console.error('Token Revocation Error:', response); displayError(response); } else { const appEl = document.getElementById('app'); - const userInfoEl = document.createElement('div'); - userInfoEl.innerHTML = `

Token successfully revoked

`; - appEl.appendChild(userInfoEl); + const revokeEl = document.createElement('div'); + revokeEl.innerHTML = `

Token successfully revoked

`; + appEl?.appendChild(revokeEl); } }); - document.getElementById('logout').addEventListener('click', async () => { - const response = await oidcClient.user.logout(); + document.getElementById('logout')?.addEventListener('click', async () => { + const response = await oidcClient.user?.logout(); + + if (!response) return; if ('error' in response) { console.error('Logout Error:', response); displayError(response); } else { console.log('Logout successful'); - document.getElementById('logout').style.display = 'none'; - document.getElementById('user-info-btn').style.display = 'none'; - document.getElementById('login-background').style.display = 'block'; - document.getElementById('login-redirect').style.display = 'block'; + const logoutEl = document.getElementById('logout'); + const userInfoBtnEl = document.getElementById('user-info-btn'); + const loginBackgroundEl = document.getElementById('login-background'); + const loginRedirectEl = document.getElementById('login-redirect'); + + if (logoutEl) { + logoutEl.style.display = 'none'; + } + if (userInfoBtnEl) { + userInfoBtnEl.style.display = 'none'; + } + if (loginBackgroundEl) { + loginBackgroundEl.style.display = 'block'; + } + if (loginRedirectEl) { + loginRedirectEl.style.display = 'block'; + } window.location.assign(window.location.origin + window.location.pathname); } }); + document.getElementById('session-check-btn')?.addEventListener('click', async () => { + const result = await oidcClient.session?.check(); + const appEl = document.getElementById('app'); + const el = document.createElement('div'); + el.innerHTML = `

Session Check (none):

${JSON.stringify(result, null, 2)}
`; + appEl?.appendChild(el); + }); + + document.getElementById('session-check-id-token-btn')?.addEventListener('click', async () => { + const options: SessionCheckOptions = { responseType: 'id_token' }; + const result = await oidcClient.session?.check(options); + const appEl = document.getElementById('app'); + const el = document.createElement('div'); + el.innerHTML = `

Session Check (id_token):

${JSON.stringify(result, null, 2)}
`; + appEl?.appendChild(el); + }); + if (code && state) { - const response = await oidcClient.token.exchange(code, state); - displayTokenResponse(response); + const response = await oidcClient.token?.exchange(code, state); + if (response) { + displayTokenResponse(response); + } } } diff --git a/e2e/oidc-suites/src/session.spec.ts b/e2e/oidc-suites/src/session.spec.ts new file mode 100644 index 0000000000..6ecce9eec7 --- /dev/null +++ b/e2e/oidc-suites/src/session.spec.ts @@ -0,0 +1,246 @@ +/* + * Copyright © 2026 Ping Identity Corporation. All rights 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'; +import { loginPingAm } from './utils/login.js'; + +// The redirect URI the SDK is configured with for the PingAM app +const REDIRECT_URI = 'http://localhost:8443/ping-am/'; + +/** + * Build a minimal, syntactically valid JWT with the given payload. + * The signature segment is fake — this JWT is only used for nonce/sub validation + * in tests where the SDK's own logic reads the payload, not a real IdP. + */ +function makeFakeJwt(payload: Record): string { + const header = btoa(JSON.stringify({ alg: 'RS256', typ: 'JWT' })) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); + const body = btoa(JSON.stringify(payload)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); + return `${header}.${body}.fakesignature`; +} + +test.describe('session.check() tests', () => { + test('session check (none) succeeds after login', async ({ page }) => { + const { navigate } = asyncEvents(page); + await navigate('/ping-am/'); + + // Log in to obtain tokens in storage + await loginPingAm(page, pingAmUsername, pingAmPassword); + + // Intercept the prompt=none authorize request and redirect back to the redirect URI + await page.route('**/authorize**', async (route, request) => { + const url = new URL(request.url()); + if (url.searchParams.get('prompt') === 'none') { + await route.fulfill({ + status: 302, + headers: { Location: REDIRECT_URI }, + }); + } else { + await route.continue(); + } + }); + + await page.getByRole('button', { name: 'Session Check (none)', exact: true }).click(); + await expect(page.locator('#session-check-result')).not.toBeEmpty(); + const resultText = await page.locator('#session-check-result').textContent(); + expect(resultText).not.toContain('"error"'); + }); + + test('session check (none) fails with login_required', async ({ page }) => { + const { navigate } = asyncEvents(page); + await navigate('/ping-am/'); + + // Log in to obtain tokens (id_token_hint requires stored tokens) + await loginPingAm(page, pingAmUsername, pingAmPassword); + + // Intercept and return an error response + await page.route('**/authorize**', async (route, request) => { + const url = new URL(request.url()); + if (url.searchParams.get('prompt') === 'none') { + const redirectUri = url.searchParams.get('redirect_uri') ?? REDIRECT_URI; + await route.fulfill({ + status: 302, + headers: { + Location: `${redirectUri}?error=login_required&error_description=User+not+authenticated`, + }, + }); + } else { + await route.continue(); + } + }); + + await page.getByRole('button', { name: 'Session Check (none)', exact: true }).click(); + await expect(page.locator('#session-check-result')).not.toBeEmpty(); + const resultText = await page.locator('#session-check-result').textContent(); + expect(resultText).toContain('"error": "login_required"'); + }); + + test('session check (none) fails when no session exists', async ({ page }) => { + const { navigate } = asyncEvents(page); + // Navigate without logging in — no tokens in storage, no browser session + await navigate('/ping-am/'); + + await page.getByRole('button', { name: 'Session Check (none)', exact: true }).click(); + await expect(page.locator('#session-check-result')).not.toBeEmpty(); + const resultText = await page.locator('#session-check-result').textContent(); + // PingAM returns login_required or interaction_required when there is no session + expect(resultText).toMatch(/"error": "(login_required|interaction_required)"/); + }); + + test('session check (id_token) succeeds with valid JWT in hash', async ({ page }) => { + const { navigate } = asyncEvents(page); + await navigate('/ping-am/'); + + // Log in to obtain tokens + await loginPingAm(page, pingAmUsername, pingAmPassword); + + // Intercept the prompt=none authorize request; extract nonce and return synthetic JWT + await page.route('**/authorize**', async (route, request) => { + const url = new URL(request.url()); + if (url.searchParams.get('prompt') === 'none') { + const nonce = url.searchParams.get('nonce') ?? ''; + const state = url.searchParams.get('state') ?? ''; + const redirectUri = url.searchParams.get('redirect_uri') ?? REDIRECT_URI; + const jwt = makeFakeJwt({ nonce, sub: 'test-user', iat: Math.floor(Date.now() / 1000) }); + await route.fulfill({ + status: 302, + headers: { + Location: `${redirectUri}#id_token=${jwt}&state=${state}`, + }, + }); + } else { + await route.continue(); + } + }); + + await page.getByRole('button', { name: 'Session Check (id_token)' }).click(); + await expect(page.locator('#session-check-id-token-result')).not.toBeEmpty(); + const resultText = await page.locator('#session-check-id-token-result').textContent(); + expect(resultText).toContain('"claims"'); + expect(resultText).not.toContain('"error"'); + }); + + test('session check (id_token) fails with login_required', async ({ page }) => { + const { navigate } = asyncEvents(page); + await navigate('/ping-am/'); + + // Log in to obtain tokens + await loginPingAm(page, pingAmUsername, pingAmPassword); + + // Intercept and return an error response + await page.route('**/authorize**', async (route, request) => { + const url = new URL(request.url()); + if (url.searchParams.get('prompt') === 'none') { + const redirectUri = url.searchParams.get('redirect_uri') ?? REDIRECT_URI; + await route.fulfill({ + status: 302, + headers: { + Location: `${redirectUri}?error=login_required&error_description=Session+expired`, + }, + }); + } else { + await route.continue(); + } + }); + + await page.getByRole('button', { name: 'Session Check (id_token)' }).click(); + await expect(page.locator('#session-check-id-token-result')).not.toBeEmpty(); + const resultText = await page.locator('#session-check-id-token-result').textContent(); + expect(resultText).toContain('"error": "login_required"'); + }); + + test('session check (none) succeeds even when redirect URI has extra query params', async ({ + page, + }) => { + const { navigate } = asyncEvents(page); + await navigate('/ping-am/'); + + await loginPingAm(page, pingAmUsername, pingAmPassword); + + // none mode resolves by recognising the redirect URI — extra params on the redirect are allowed + await page.route('**/authorize**', async (route, request) => { + const url = new URL(request.url()); + if (url.searchParams.get('prompt') === 'none') { + await route.fulfill({ + status: 302, + headers: { + Location: `${REDIRECT_URI}?session_state=some-opaque-value`, + }, + }); + } else { + await route.continue(); + } + }); + + await page.getByRole('button', { name: 'Session Check (none)', exact: true }).click(); + await expect(page.locator('#session-check-result')).not.toBeEmpty(); + const resultText = await page.locator('#session-check-result').textContent(); + expect(resultText).not.toContain('"error"'); + }); + + test('session check (id_token) fails with nonce_mismatch when JWT contains wrong nonce', async ({ + page, + }) => { + const { navigate } = asyncEvents(page); + await navigate('/ping-am/'); + + await loginPingAm(page, pingAmUsername, pingAmPassword); + + await page.route('**/authorize**', async (route, request) => { + const url = new URL(request.url()); + if (url.searchParams.get('prompt') === 'none') { + const state = url.searchParams.get('state') ?? ''; + const redirectUri = url.searchParams.get('redirect_uri') ?? REDIRECT_URI; + // JWT has a different nonce than what the SDK generated + const jwt = makeFakeJwt({ nonce: 'wrong-nonce', sub: 'test-user' }); + await route.fulfill({ + status: 302, + headers: { + Location: `${redirectUri}#id_token=${jwt}&state=${state}`, + }, + }); + } else { + await route.continue(); + } + }); + + await page.getByRole('button', { name: 'Session Check (id_token)' }).click(); + await expect(page.locator('#session-check-id-token-result')).not.toBeEmpty(); + const resultText = await page.locator('#session-check-id-token-result').textContent(); + expect(resultText).toContain('"error": "nonce_mismatch"'); + }); + + test('session check (none) fails with iframe_timeout when AS does not respond', async ({ + page, + }) => { + const { navigate } = asyncEvents(page); + await navigate('/ping-am/'); + + await loginPingAm(page, pingAmUsername, pingAmPassword); + + // Never fulfill the authorize request — let the iframe time out + await page.route('**/authorize**', async (route, request) => { + const url = new URL(request.url()); + if (url.searchParams.get('prompt') === 'none') { + // intentionally do not call route.fulfill or route.continue + } else { + await route.continue(); + } + }); + + await page.getByRole('button', { name: 'Session Check (none)', exact: true }).click(); + await expect(page.locator('#session-check-result')).not.toBeEmpty(); + const resultText = await page.locator('#session-check-result').textContent(); + expect(resultText).toContain('"error": "iframe_timeout"'); + }); +}); diff --git a/e2e/oidc-suites/src/utils/login.ts b/e2e/oidc-suites/src/utils/login.ts new file mode 100644 index 0000000000..669913980f --- /dev/null +++ b/e2e/oidc-suites/src/utils/login.ts @@ -0,0 +1,17 @@ +/* + * Copyright © 2026 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ +import { expect, type Page } from '@playwright/test'; +import { asyncEvents } from './async-events.js'; + +export async function loginPingAm(page: Page, username: string, password: string) { + const { clickWithRedirect } = asyncEvents(page); + await clickWithRedirect('Login (Background)', '**/am/XUI/**'); + await page.getByLabel('User Name').fill(username); + await page.getByRole('textbox', { name: 'Password' }).fill(password); + await clickWithRedirect('Next', 'http://localhost:8443/ping-am/**'); + await expect(page.locator('#accessToken-0')).not.toBeEmpty(); +} diff --git a/packages/oidc-client/api-report/oidc-client.api.md b/packages/oidc-client/api-report/oidc-client.api.md index e934c022e6..b64c50381b 100644 --- a/packages/oidc-client/api-report/oidc-client.api.md +++ b/packages/oidc-client/api-report/oidc-client.api.md @@ -128,6 +128,12 @@ par: MutationDefinition< { endpoint: string; body: URLSearchParams; }, BaseQueryFn, never, PushAuthorizationResponse, "oidc", unknown>; +sessionCheckIframe: MutationDefinition< { +url: string; +responseType: SessionCheckResponseType; +}, BaseQueryFn, never, { +params: Record; +}, "oidc", unknown>; authorizeIframe: MutationDefinition< { url: string; }, BaseQueryFn, never, AuthorizationSuccess, "oidc", unknown>; @@ -164,6 +170,12 @@ par: MutationDefinition< { endpoint: string; body: URLSearchParams; }, BaseQueryFn, never, PushAuthorizationResponse, "oidc", unknown>; +sessionCheckIframe: MutationDefinition< { +url: string; +responseType: SessionCheckResponseType; +}, BaseQueryFn, never, { +params: Record; +}, "oidc", unknown>; authorizeIframe: MutationDefinition< { url: string; }, BaseQueryFn, never, AuthorizationSuccess, "oidc", unknown>; @@ -265,6 +277,7 @@ export function oidc(input: { authorize?: undefined; token?: undefined; user?: undefined; + session?: undefined; } | { subscribe: (listener: () => void) => Unsubscribe; authorize: { @@ -280,6 +293,9 @@ export function oidc(input: { info: () => Promise; logout: () => Promise; }; + session: { + check: (options?: SessionCheckOptions) => Promise; + }; error?: undefined; type?: undefined; }>; @@ -337,6 +353,28 @@ export type RevokeSuccessResult = { // @public (undocumented) export type RootState = ReturnType; +// @public +export interface SessionCheckOptions { + redirectUri?: string; + responseType?: SessionCheckResponseType; + scope?: string; + subject?: string; +} + +// @public (undocumented) +export const SessionCheckResponseType: { + readonly IdToken: "id_token"; + readonly None: "none"; +}; + +// @public (undocumented) +export type SessionCheckResponseType = (typeof SessionCheckResponseType)[keyof typeof SessionCheckResponseType]; + +// @public (undocumented) +export interface SessionCheckSuccess { + claims?: Record; +} + export { StorageConfig } // @public (undocumented) diff --git a/packages/oidc-client/api-report/oidc-client.types.api.md b/packages/oidc-client/api-report/oidc-client.types.api.md index e934c022e6..b64c50381b 100644 --- a/packages/oidc-client/api-report/oidc-client.types.api.md +++ b/packages/oidc-client/api-report/oidc-client.types.api.md @@ -128,6 +128,12 @@ par: MutationDefinition< { endpoint: string; body: URLSearchParams; }, BaseQueryFn, never, PushAuthorizationResponse, "oidc", unknown>; +sessionCheckIframe: MutationDefinition< { +url: string; +responseType: SessionCheckResponseType; +}, BaseQueryFn, never, { +params: Record; +}, "oidc", unknown>; authorizeIframe: MutationDefinition< { url: string; }, BaseQueryFn, never, AuthorizationSuccess, "oidc", unknown>; @@ -164,6 +170,12 @@ par: MutationDefinition< { endpoint: string; body: URLSearchParams; }, BaseQueryFn, never, PushAuthorizationResponse, "oidc", unknown>; +sessionCheckIframe: MutationDefinition< { +url: string; +responseType: SessionCheckResponseType; +}, BaseQueryFn, never, { +params: Record; +}, "oidc", unknown>; authorizeIframe: MutationDefinition< { url: string; }, BaseQueryFn, never, AuthorizationSuccess, "oidc", unknown>; @@ -265,6 +277,7 @@ export function oidc(input: { authorize?: undefined; token?: undefined; user?: undefined; + session?: undefined; } | { subscribe: (listener: () => void) => Unsubscribe; authorize: { @@ -280,6 +293,9 @@ export function oidc(input: { info: () => Promise; logout: () => Promise; }; + session: { + check: (options?: SessionCheckOptions) => Promise; + }; error?: undefined; type?: undefined; }>; @@ -337,6 +353,28 @@ export type RevokeSuccessResult = { // @public (undocumented) export type RootState = ReturnType; +// @public +export interface SessionCheckOptions { + redirectUri?: string; + responseType?: SessionCheckResponseType; + scope?: string; + subject?: string; +} + +// @public (undocumented) +export const SessionCheckResponseType: { + readonly IdToken: "id_token"; + readonly None: "none"; +}; + +// @public (undocumented) +export type SessionCheckResponseType = (typeof SessionCheckResponseType)[keyof typeof SessionCheckResponseType]; + +// @public (undocumented) +export interface SessionCheckSuccess { + claims?: Record; +} + export { StorageConfig } // @public (undocumented) diff --git a/packages/oidc-client/package.json b/packages/oidc-client/package.json index 4d69dc10c5..16e2dd7fa4 100644 --- a/packages/oidc-client/package.json +++ b/packages/oidc-client/package.json @@ -35,7 +35,8 @@ "@forgerock/sdk-utilities": "workspace:*", "@forgerock/storage": "workspace:*", "@reduxjs/toolkit": "catalog:", - "effect": "catalog:effect" + "effect": "catalog:effect", + "jose": "catalog:" }, "devDependencies": { "@effect/vitest": "catalog:effect", diff --git a/packages/oidc-client/src/lib/client.store.test.ts b/packages/oidc-client/src/lib/client.store.test.ts index d15f8352ba..1d7613701a 100644 --- a/packages/oidc-client/src/lib/client.store.test.ts +++ b/packages/oidc-client/src/lib/client.store.test.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * Copyright © 2025 - 2026 Ping Identity Corporation. All rights reserved. * * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. @@ -718,3 +718,59 @@ describe('authorize.url() with PAR enabled on non-pi.flow server', async () => { expect(parsed.searchParams.has('redirect_uri')).toBe(false); }); }); + +describe('session.check()', async () => { + const config: OidcConfig = { + clientId: '123456789', + redirectUri: 'https://example.com/callback.html', + scope: 'openid profile', + serverConfig: { + wellknown: 'https://api.example.com/wellknown', + }, + responseType: 'code', + }; + + beforeEach(() => { + customStorage.remove(storageKey); + }); + + it('returns a GenericError when no tokens are stored (best-effort: dispatch is attempted)', async () => { + // No tokens in storage — best-effort: id_token_hint is omitted, dispatch still runs. + // In Vitest (no real DOM), the iframe manager cannot run and surfaces a session_check_error. + const oidcClient = await oidc({ config, storage: customStorageConfig }); + if ('error' in oidcClient) throw new Error('Error creating OIDC Client'); + + const result = await oidcClient.session.check(); + + if (!('error' in result)) { + expect.fail('Expected SessionCheckError, got success'); + } + expect(result.error).toBe('session_check_error'); + expect(result.type).toBe('network_error'); + }); + + it('returns wellknown_error when authorization_endpoint is missing from the wellknown config', async () => { + // When authorization_endpoint is missing, initWellknownQuery validates and returns an error, + // so oidc() itself returns a wellknown_error at factory initialization time. + server.use( + http.get('*/wellknown', async () => + HttpResponse.json({ + issuer: 'https://api.example.com/as/issuer', + // authorization_endpoint deliberately omitted — causes wellknown validation to fail + token_endpoint: 'https://api.example.com/as/token', + userinfo_endpoint: 'https://api.example.com/as/userinfo', + introspection_endpoint: 'https://api.example.com/as/introspect', + revocation_endpoint: 'https://api.example.com/as/revoke', + }), + ), + ); + + const result = await oidc({ config, storage: customStorageConfig }); + + // The factory itself surfaces a wellknown_error when the wellknown response is invalid + if (!('error' in result)) { + expect.fail('Expected wellknown_error, got client'); + } + expect(result.type).toBe('wellknown_error'); + }); +}); diff --git a/packages/oidc-client/src/lib/client.store.ts b/packages/oidc-client/src/lib/client.store.ts index 5a79f04ace..922bb6ed0c 100644 --- a/packages/oidc-client/src/lib/client.store.ts +++ b/packages/oidc-client/src/lib/client.store.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * Copyright © 2025 - 2026 Ping Identity Corporation. All rights reserved. * * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. @@ -13,7 +13,10 @@ import { causeIsDie, exitIsFail, exitIsSuccess } from 'effect/Micro'; import { authorizeµ, createParAuthorizeUrlµ } from './authorize.request.js'; import { buildTokenExchangeµ } from './exchange.request.js'; import { createClientStore, createTokenError } from './client.store.utils.js'; +import { isExpiryWithinThreshold } from './token.utils.js'; +import { logoutµ } from './logout.request.js'; import { oidcApi } from './oidc.api.js'; +import { sessionCheckNoneµ, sessionCheckIdTokenµ } from './session.micros.js'; import { wellknownApi, wellknownSelector } from './wellknown.api.js'; import type { ActionTypes, RequestMiddleware } from '@forgerock/sdk-request-middleware'; @@ -32,8 +35,8 @@ import type { import type { OauthTokens, OidcConfig } from './config.types.js'; import type { AuthorizationError, AuthorizationSuccess } from './authorize.request.types.js'; import type { TokenExchangeErrorResponse } from './exchange.types.js'; -import { isExpiryWithinThreshold } from './token.utils.js'; -import { logoutµ } from './logout.request.js'; +import { SessionCheckResponseType } from './session.types.js'; +import type { SessionCheckOptions, SessionCheckSuccess } from './session.types.js'; /** * @function oidc @@ -602,5 +605,51 @@ export async function oidc({ } }, }, + + /** + * An object containing methods for OIDC session management + */ + session: { + /** + * @method check + * @description Checks whether the user has an active session at the authorization server + * using a hidden iframe with prompt=none. Supports response_type=none (default) + * and response_type=id_token. + * @param {SessionCheckOptions} options - Optional parameters for the session check. + * @returns {Promise} - Never throws; returns a typed result. + */ + check: async (options?: SessionCheckOptions): Promise => { + const state = store.getState(); + const wellknown = wellknownSelector(wellknownUrl, state); + + if (!wellknown?.authorization_endpoint) { + return { + error: 'Wellknown missing authorization endpoint', + message: 'Authorization endpoint not found in wellknown configuration', + type: 'wellknown_error', + }; + } + + const micro = + options?.responseType === SessionCheckResponseType.IdToken + ? sessionCheckIdTokenµ(wellknown, config, store, storageClient, log, options) + : sessionCheckNoneµ(wellknown, config, store, storageClient, log, options); + + const result = await Micro.runPromiseExit(micro); + + if (exitIsSuccess(result)) { + return result.value; + } else if (exitIsFail(result)) { + return result.cause.error; + } else { + const defect = causeIsDie(result.cause) ? result.cause.defect : undefined; + return { + error: 'Session check failure', + message: defect instanceof Error ? defect.message : String(defect ?? 'Unknown defect'), + type: 'unknown_error', + }; + } + }, + }, }; } diff --git a/packages/oidc-client/src/lib/oidc.api.ts b/packages/oidc-client/src/lib/oidc.api.ts index 1fee7fd373..fad8fec055 100644 --- a/packages/oidc-client/src/lib/oidc.api.ts +++ b/packages/oidc-client/src/lib/oidc.api.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * Copyright © 2025 - 2026 Ping Identity Corporation. All rights reserved. * * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. @@ -9,6 +9,8 @@ import { fetchBaseQuery, type FetchArgs, type FetchBaseQueryError, + type FetchBaseQueryMeta, + type QueryReturnValue, } from '@reduxjs/toolkit/query'; import type { OidcConfig } from './config.types.js'; import { transformError } from './oidc.api.utils.js'; @@ -24,6 +26,10 @@ import type { TokenExchangeResponse } from './exchange.types.js'; import type { AuthorizationSuccess, AuthorizeSuccessResponse } from './authorize.request.types.js'; import type { UserInfoResponse } from './client.types.js'; import type { PushAuthorizationResponse } from './par.types.js'; +import type { GenericError } from '@forgerock/sdk-types'; +import { SessionCheckResponseType } from './session.types.js'; + +const IFRAME_TIMEOUT_MS = 3000; interface Extras { requestMiddleware: RequestMiddleware[]; @@ -176,6 +182,96 @@ export const oidcApi = createApi({ return response as { data: PushAuthorizationResponse }; }, }), + sessionCheckIframe: builder.mutation< + { params: Record }, + { url: string; responseType: SessionCheckResponseType } + >({ + queryFn: async ({ url, responseType }, api) => { + const { requestMiddleware, logger } = api.extra as Extras; + const isIdToken = responseType === SessionCheckResponseType.IdToken; + const errorParams = ['error', 'error_description']; + + const request: FetchArgs = { url }; + + logger.debug('OIDC session check iframe request', request); + + const response = await initQuery(request, 'authorize') + .applyMiddleware(requestMiddleware) + .applyQuery(async (req: FetchArgs) => { + try { + const timeout = req.timeout ?? IFRAME_TIMEOUT_MS; + const params = isIdToken + ? await iFrameManager().getParamsByRedirect({ + url: req.url, + successParams: ['id_token'], + errorParams, + includeHashParams: true, + timeout, + }) + : await iFrameManager().getParamsByRedirect({ + url: req.url, + resolveOnRedirectUri: new URL(req.url).searchParams.get( + 'redirect_uri', + ) as string, + errorParams, + successParams: [], + timeout, + }); + + if ('error' in params) { + return { + error: { + status: 400, + statusText: 'SESSION_CHECK_ERROR', + data: { + error: params.error, + message: params.error_description ?? 'An error occurred during session check', + type: 'auth_error', + } satisfies GenericError, + }, + }; + } + + return { data: { params } }; + } catch (error) { + const isTimeout = + error instanceof Object && + 'message' in error && + (error as { message: string }).message === 'iframe timed out'; + + return { + error: { + status: 400, + statusText: 'SESSION_CHECK_ERROR', + data: { + error: isTimeout ? 'iframe_timeout' : 'session_check_error', + message: isTimeout + ? 'Session check timed out waiting for iframe response' + : 'An unexpected error occurred during session check', + type: 'network_error', + } satisfies GenericError, + }, + }; + } + }); + + if ('error' in response) { + logger.error('Error in session check iframe', response); + return response as QueryReturnValue< + { params: Record }, + FetchBaseQueryError, + FetchBaseQueryMeta + >; + } + + logger.debug('OIDC session check iframe response', response); + return response as QueryReturnValue< + { params: Record }, + FetchBaseQueryError, + FetchBaseQueryMeta + >; + }, + }), authorizeIframe: builder.mutation({ queryFn: async ({ url }, api) => { const { requestMiddleware, logger } = api.extra as Extras; diff --git a/packages/oidc-client/src/lib/session.micros.test.ts b/packages/oidc-client/src/lib/session.micros.test.ts new file mode 100644 index 0000000000..1904301441 --- /dev/null +++ b/packages/oidc-client/src/lib/session.micros.test.ts @@ -0,0 +1,512 @@ +/* + * Copyright © 2026 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ +import { it, expect } from '@effect/vitest'; +import { Micro } from 'effect'; +import { vi, afterEach, describe } from 'vitest'; +import * as sdkUtilities from '@forgerock/sdk-utilities'; + +import { + buildNoneUrl, + buildIdTokenUrl, + dispatchSessionCheckµ, + readStoredIdTokenµ, + sessionCheckIdTokenµ, + sessionCheckNoneµ, + validateSessionCheckResponseµ, +} from './session.micros.js'; +import { oidcApi } from './oidc.api.js'; +import { SessionCheckResponseType } from './session.types.js'; + +import { logger as loggerFn } from '@forgerock/sdk-logger'; +import type { OidcConfig } from './config.types.js'; +import type { WellknownResponse } from '@forgerock/sdk-types'; +import type { ClientStore } from './client.types.js'; +import type { StorageClient } from '@forgerock/storage'; +import type { OauthTokens } from './config.types.js'; +import type { GenericError } from '@forgerock/sdk-types'; + +// ─── Fixtures ──────────────────────────────────────────────────────────────── + +const clientId = 'test-client-id'; +const redirectUri = 'https://example.com/callback.html'; +const endpoint = 'https://example.com/authorize'; + +const config: OidcConfig = { + clientId, + redirectUri, + scope: 'openid profile', + responseType: 'code', + serverConfig: { + wellknown: 'https://example.com/.well-known/openid-configuration', + }, +}; + +const wellknown: WellknownResponse = { + issuer: 'https://example.com', + authorization_endpoint: endpoint, + token_endpoint: 'https://example.com/token', + userinfo_endpoint: 'https://example.com/userinfo', + end_session_endpoint: 'https://example.com/logout', + introspection_endpoint: 'https://example.com/introspect', + revocation_endpoint: 'https://example.com/revoke', +}; + +const storedTokens: OauthTokens = { + accessToken: 'access-token-abc', + idToken: 'stored-id-token-xyz', + expiresAt: Date.now() + 60000, +}; + +function makeStorageClient(token: OauthTokens | null): StorageClient { + return { + get: vi.fn().mockResolvedValue(token), + set: vi.fn().mockResolvedValue(null), + remove: vi.fn().mockResolvedValue(null), + }; +} + +const log = loggerFn({ level: 'error' }); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function makeDispatchSetup(dispatchResult: unknown) { + const capturedArgs: Array<{ url: string; responseType: string }> = []; + const sentinel = Symbol('dispatch-sentinel'); + vi.spyOn(oidcApi.endpoints.sessionCheckIframe, 'initiate').mockImplementation((arg) => { + capturedArgs.push(arg as { url: string; responseType: string }); + return sentinel as unknown as ReturnType; + }); + + const dispatch = vi.fn().mockResolvedValue(dispatchResult); + const store: ClientStore = { dispatch } as unknown as ClientStore; + + return { capturedArgs, store, dispatch }; +} + +// ─── buildNoneUrl ───────────────────────────────────────────────────────────── + +describe('buildNoneUrl', () => { + it('builds URL with expected params and no state', () => { + const url = buildNoneUrl(endpoint, config, storedTokens.idToken); + const parsed = new URL(url); + + expect(parsed.searchParams.get('prompt')).toBe(SessionCheckResponseType.None); + expect(parsed.searchParams.get('response_type')).toBe(SessionCheckResponseType.None); + expect(parsed.searchParams.get('client_id')).toBe(clientId); + expect(parsed.searchParams.get('redirect_uri')).toBe(redirectUri); + expect(parsed.searchParams.get('id_token_hint')).toBe(storedTokens.idToken); + expect(parsed.searchParams.has('state')).toBe(false); + expect(parsed.searchParams.has('nonce')).toBe(false); + }); + + it('uses options.redirectUri when provided', () => { + const url = buildNoneUrl(endpoint, config, storedTokens.idToken, { + redirectUri: 'https://example.com/custom.html', + }); + const parsed = new URL(url); + expect(parsed.searchParams.get('redirect_uri')).toBe('https://example.com/custom.html'); + }); +}); + +// ─── buildIdTokenUrl ────────────────────────────────────────────────────────── + +describe('buildIdTokenUrl', () => { + it('builds URL with nonce, state, scope, and response_type=id_token', () => { + const knownNonce = 'test-nonce'; + const knownState = 'test-state'; + vi.spyOn(sdkUtilities, 'createRandomString').mockReturnValue(knownNonce); + vi.spyOn(sdkUtilities, 'createState').mockReturnValue(knownState); + + const { url, nonce, state } = buildIdTokenUrl(endpoint, config, null); + const parsed = new URL(url); + + expect(parsed.searchParams.get('response_type')).toBe(SessionCheckResponseType.IdToken); + expect(parsed.searchParams.get('nonce')).toBe(knownNonce); + expect(parsed.searchParams.get('state')).toBe(knownState); + expect(parsed.searchParams.get('scope')).toBe('openid'); + expect(parsed.searchParams.has('id_token_hint')).toBe(false); + expect(nonce).toBe(knownNonce); + expect(state).toBe(knownState); + }); + + it('includes id_token_hint when storedIdToken is present', () => { + const { url } = buildIdTokenUrl(endpoint, config, storedTokens.idToken); + const parsed = new URL(url); + expect(parsed.searchParams.get('id_token_hint')).toBe(storedTokens.idToken); + }); + + it('omits id_token_hint when storedIdToken is null', () => { + const { url } = buildIdTokenUrl(endpoint, config, null); + const parsed = new URL(url); + expect(parsed.searchParams.has('id_token_hint')).toBe(false); + }); + + it('uses options.scope when provided', () => { + const { url } = buildIdTokenUrl(endpoint, config, null, { scope: 'openid profile' }); + const parsed = new URL(url); + expect(parsed.searchParams.get('scope')).toBe('openid profile'); + }); +}); + +// ─── sessionCheckNoneµ ──────────────────────────────────────────────────────── + +it.effect( + 'sessionCheckNoneµ fails with missing_redirect_uri when no redirect URI is configured', + () => + Micro.gen(function* () { + const dispatch = vi.fn(); + const store: ClientStore = { dispatch } as unknown as ClientStore; + const configWithoutRedirectUri: OidcConfig = { ...config, redirectUri: '' }; + + const exit = yield* Micro.exit( + sessionCheckNoneµ(wellknown, configWithoutRedirectUri, store, makeStorageClient(null), log), + ); + + expect(Micro.exitIsFailure(exit)).toBe(true); + if (!Micro.exitIsFailure(exit) || !Micro.causeIsFail(exit.cause)) { + return; + } + expect(exit.cause.error.error).toBe('missing_redirect_uri'); + expect(exit.cause.error.type).toBe('argument_error'); + expect(dispatch).not.toHaveBeenCalled(); + }), +); + +it.effect('sessionCheckNoneµ dispatches and succeeds when token is stored', () => + Micro.gen(function* () { + const { store, dispatch } = makeDispatchSetup({ data: { params: {} } }); + + const exit = yield* Micro.exit( + sessionCheckNoneµ(wellknown, config, store, makeStorageClient(storedTokens), log), + ); + + expect(Micro.exitIsSuccess(exit)).toBe(true); + expect(dispatch).toHaveBeenCalledOnce(); + }), +); + +it.effect('sessionCheckNoneµ dispatches and succeeds when storage is empty (best-effort)', () => + Micro.gen(function* () { + const { store, dispatch } = makeDispatchSetup({ data: { params: {} } }); + + const exit = yield* Micro.exit( + sessionCheckNoneµ(wellknown, config, store, makeStorageClient(null), log), + ); + + expect(Micro.exitIsSuccess(exit)).toBe(true); + expect(dispatch).toHaveBeenCalledOnce(); + }), +); + +// ─── sessionCheckIdTokenµ ───────────────────────────────────────────────────── + +it.effect('sessionCheckIdTokenµ returns claims on valid JWT', () => + Micro.gen(function* () { + const knownNonce = 'test-nonce-value-12345678901234'; + const knownState = 'known-state-value'; + vi.spyOn(sdkUtilities, 'createRandomString').mockReturnValue(knownNonce); + vi.spyOn(sdkUtilities, 'createState').mockReturnValue(knownState); + + const payload = { nonce: knownNonce, sub: 'user1', iat: 1000 }; + const encodedPayload = btoa(JSON.stringify(payload)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); + const validJwt = `header.${encodedPayload}.sig`; + + const { store } = makeDispatchSetup({ + data: { params: { id_token: validJwt, state: knownState } }, + }); + + const exit = yield* Micro.exit( + sessionCheckIdTokenµ(wellknown, config, store, makeStorageClient(storedTokens), log), + ); + + expect(Micro.exitIsSuccess(exit)).toBe(true); + if (!Micro.exitIsSuccess(exit)) { + return; + } + expect(exit.value.claims).toBeDefined(); + expect((exit.value.claims as Record).nonce).toBe(knownNonce); + }), +); + +it.effect('sessionCheckIdTokenµ fails with state_mismatch when response state does not match', () => + Micro.gen(function* () { + vi.spyOn(sdkUtilities, 'createState').mockReturnValue('known-state-value'); + + const { store } = makeDispatchSetup({ + data: { params: { id_token: 'some.jwt.token', state: 'tampered-state' } }, + }); + + const exit = yield* Micro.exit( + sessionCheckIdTokenµ(wellknown, config, store, makeStorageClient(storedTokens), log), + ); + + expect(Micro.exitIsFailure(exit)).toBe(true); + if (!Micro.exitIsFailure(exit) || !Micro.causeIsFail(exit.cause)) { + return; + } + expect(exit.cause.error.error).toBe('state_mismatch'); + expect(exit.cause.error.type).toBe('auth_error'); + }), +); + +it.effect('sessionCheckIdTokenµ fails with no_id_token when iframe returns no id_token param', () => + Micro.gen(function* () { + const knownState = 'known-state-value'; + vi.spyOn(sdkUtilities, 'createState').mockReturnValue(knownState); + + const { store } = makeDispatchSetup({ data: { params: { state: knownState } } }); + + const exit = yield* Micro.exit( + sessionCheckIdTokenµ(wellknown, config, store, makeStorageClient(storedTokens), log), + ); + + expect(Micro.exitIsFailure(exit)).toBe(true); + if (!Micro.exitIsFailure(exit) || !Micro.causeIsFail(exit.cause)) { + return; + } + expect(exit.cause.error.error).toBe('no_id_token'); + expect(exit.cause.error.type).toBe('auth_error'); + }), +); + +// ─── readStoredIdTokenµ ─────────────────────────────────────────────────────── + +it.effect('readStoredIdTokenµ returns idToken string when tokens are stored', () => + Micro.gen(function* () { + const result = yield* readStoredIdTokenµ(makeStorageClient(storedTokens)); + expect(result).toBe(storedTokens.idToken); + }), +); + +it.effect('readStoredIdTokenµ returns null when storage is empty', () => + Micro.gen(function* () { + const result = yield* readStoredIdTokenµ(makeStorageClient(null)); + expect(result).toBeNull(); + }), +); + +it.effect('readStoredIdTokenµ fails with argument_error when storageClient.get rejects', () => + Micro.gen(function* () { + const failingStorage: StorageClient = { + get: vi.fn().mockRejectedValue(new Error('storage unavailable')), + set: vi.fn(), + remove: vi.fn(), + }; + const exit = yield* Micro.exit(readStoredIdTokenµ(failingStorage)); + expect(Micro.exitIsFailure(exit)).toBe(true); + if (!Micro.exitIsFailure(exit) || !Micro.causeIsFail(exit.cause)) { + return; + } + expect(exit.cause.error.type).toBe('argument_error'); + expect(exit.cause.error.error).toBe('storage_error'); + }), +); + +// ─── dispatchSessionCheckµ ──────────────────────────────────────────────────── + +it.effect( + 'dispatchSessionCheckµ succeeds and returns params when dispatch resolves with data', + () => + Micro.gen(function* () { + const params = { state: 'ok' }; + const dispatch = vi.fn().mockResolvedValue({ data: { params } }); + const store: ClientStore = { dispatch } as unknown as ClientStore; + vi.spyOn(oidcApi.endpoints.sessionCheckIframe, 'initiate').mockReturnValue( + Symbol('sentinel') as unknown as ReturnType< + typeof oidcApi.endpoints.sessionCheckIframe.initiate + >, + ); + + const result = yield* dispatchSessionCheckµ( + store, + 'https://example.com/authorize?prompt=none', + SessionCheckResponseType.None, + ); + expect(result).toStrictEqual(params); + }), +); + +it.effect( + 'dispatchSessionCheckµ fails with auth_error when dispatch resolves with an error result', + () => + Micro.gen(function* () { + const errorData: GenericError = { + error: 'login_required', + message: 'User must authenticate', + type: 'auth_error', + }; + const dispatch = vi.fn().mockResolvedValue({ error: { data: errorData } }); + const store: ClientStore = { dispatch } as unknown as ClientStore; + vi.spyOn(oidcApi.endpoints.sessionCheckIframe, 'initiate').mockReturnValue( + Symbol('sentinel') as unknown as ReturnType< + typeof oidcApi.endpoints.sessionCheckIframe.initiate + >, + ); + + const exit = yield* Micro.exit( + dispatchSessionCheckµ( + store, + 'https://example.com/authorize?prompt=none', + SessionCheckResponseType.None, + ), + ); + expect(Micro.exitIsFailure(exit)).toBe(true); + if (!Micro.exitIsFailure(exit) || !Micro.causeIsFail(exit.cause)) { + return; + } + expect(exit.cause.error.error).toBe('login_required'); + expect(exit.cause.error.type).toBe('auth_error'); + }), +); + +it.effect('dispatchSessionCheckµ fails with network_error when dispatch rejects', () => + Micro.gen(function* () { + const dispatch = vi.fn().mockRejectedValue(new Error('network failure')); + const store: ClientStore = { dispatch } as unknown as ClientStore; + vi.spyOn(oidcApi.endpoints.sessionCheckIframe, 'initiate').mockReturnValue( + Symbol('sentinel') as unknown as ReturnType< + typeof oidcApi.endpoints.sessionCheckIframe.initiate + >, + ); + + const exit = yield* Micro.exit( + dispatchSessionCheckµ( + store, + 'https://example.com/authorize?prompt=none', + SessionCheckResponseType.None, + ), + ); + expect(Micro.exitIsFailure(exit)).toBe(true); + if (!Micro.exitIsFailure(exit) || !Micro.causeIsFail(exit.cause)) { + return; + } + expect(exit.cause.error.type).toBe('network_error'); + expect(exit.cause.error.error).toBe('dispatch_error'); + }), +); + +// ─── validateSessionCheckResponseµ ─────────────────────────────────────────── + +function makeJwtWithClaims(claims: Record): string { + const payload = btoa(JSON.stringify(claims)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); + return `header.${payload}.sig`; +} + +it.effect( + 'validateSessionCheckResponseµ succeeds and returns claims when state and nonce match', + () => + Micro.gen(function* () { + const nonce = 'expected-nonce'; + const state = 'expected-state'; + const jwt = makeJwtWithClaims({ nonce, sub: 'user1' }); + const result = yield* validateSessionCheckResponseµ({ id_token: jwt, state }, state, nonce); + expect(result).toMatchObject({ nonce, sub: 'user1' }); + }), +); + +it.effect('validateSessionCheckResponseµ succeeds when state, nonce, and subject all match', () => + Micro.gen(function* () { + const nonce = 'nonce-abc'; + const state = 'state-abc'; + const jwt = makeJwtWithClaims({ nonce, sub: 'user1' }); + const result = yield* validateSessionCheckResponseµ( + { id_token: jwt, state }, + state, + nonce, + 'user1', + ); + expect(result).toMatchObject({ nonce, sub: 'user1' }); + }), +); + +it.effect('validateSessionCheckResponseµ fails with state_mismatch when state does not match', () => + Micro.gen(function* () { + const jwt = makeJwtWithClaims({ nonce: 'nonce', sub: 'user1' }); + const exit = yield* Micro.exit( + validateSessionCheckResponseµ( + { id_token: jwt, state: 'tampered' }, + 'expected-state', + 'nonce', + ), + ); + expect(Micro.exitIsFailure(exit)).toBe(true); + if (!Micro.exitIsFailure(exit) || !Micro.causeIsFail(exit.cause)) { + return; + } + expect(exit.cause.error.error).toBe('state_mismatch'); + expect(exit.cause.error.type).toBe('auth_error'); + }), +); + +it.effect('validateSessionCheckResponseµ fails with no_id_token when id_token is absent', () => + Micro.gen(function* () { + const state = 'expected-state'; + const exit = yield* Micro.exit(validateSessionCheckResponseµ({ state }, state, 'nonce')); + expect(Micro.exitIsFailure(exit)).toBe(true); + if (!Micro.exitIsFailure(exit) || !Micro.causeIsFail(exit.cause)) { + return; + } + expect(exit.cause.error.error).toBe('no_id_token'); + expect(exit.cause.error.type).toBe('auth_error'); + }), +); + +it.effect('validateSessionCheckResponseµ fails with nonce_mismatch when nonce does not match', () => + Micro.gen(function* () { + const state = 'expected-state'; + const jwt = makeJwtWithClaims({ nonce: 'wrong-nonce', sub: 'user1' }); + const exit = yield* Micro.exit( + validateSessionCheckResponseµ({ id_token: jwt, state }, state, 'expected-nonce'), + ); + expect(Micro.exitIsFailure(exit)).toBe(true); + if (!Micro.exitIsFailure(exit) || !Micro.causeIsFail(exit.cause)) { + return; + } + expect(exit.cause.error.error).toBe('nonce_mismatch'); + expect(exit.cause.error.type).toBe('auth_error'); + }), +); + +it.effect('validateSessionCheckResponseµ fails with subject_mismatch when sub does not match', () => + Micro.gen(function* () { + const nonce = 'valid-nonce'; + const state = 'expected-state'; + const jwt = makeJwtWithClaims({ nonce, sub: 'user2' }); + const exit = yield* Micro.exit( + validateSessionCheckResponseµ({ id_token: jwt, state }, state, nonce, 'user1'), + ); + expect(Micro.exitIsFailure(exit)).toBe(true); + if (!Micro.exitIsFailure(exit) || !Micro.causeIsFail(exit.cause)) { + return; + } + expect(exit.cause.error.error).toBe('subject_mismatch'); + expect(exit.cause.error.type).toBe('auth_error'); + }), +); + +it.effect('validateSessionCheckResponseµ fails with invalid_jwt when JWT is malformed', () => + Micro.gen(function* () { + const state = 'expected-state'; + const exit = yield* Micro.exit( + validateSessionCheckResponseµ({ id_token: 'not.a.valid.jwt.payload', state }, state, 'nonce'), + ); + expect(Micro.exitIsFailure(exit)).toBe(true); + if (!Micro.exitIsFailure(exit) || !Micro.causeIsFail(exit.cause)) { + return; + } + expect(exit.cause.error.error).toBe('invalid_jwt'); + expect(exit.cause.error.type).toBe('auth_error'); + }), +); diff --git a/packages/oidc-client/src/lib/session.micros.ts b/packages/oidc-client/src/lib/session.micros.ts new file mode 100644 index 0000000000..199e108e79 --- /dev/null +++ b/packages/oidc-client/src/lib/session.micros.ts @@ -0,0 +1,235 @@ +/* + * Copyright © 2026 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ +import { Micro } from 'effect'; + +import { createRandomString, createState } from '@forgerock/sdk-utilities'; + +import { oidcApi } from './oidc.api.js'; + +import { decodeJwt } from 'jose/jwt/decode'; + +import type { CustomLogger } from '@forgerock/sdk-logger'; +import type { GenericError, WellknownResponse } from '@forgerock/sdk-types'; +import type { StorageClient } from '@forgerock/storage'; +import type { OauthTokens, OidcConfig } from './config.types.js'; +import type { ClientStore } from './client.types.js'; +import { SessionCheckResponseType } from './session.types.js'; +import type { SessionCheckOptions, SessionCheckSuccess } from './session.types.js'; + +// ─── Storage read ───────────────────────────────────────────────────────────── + +export const readStoredIdTokenµ = ( + storageClient: StorageClient, +): Micro.Micro => { + return Micro.tryPromise({ + try: async () => { + const tokens = await storageClient.get(); + return tokens && 'idToken' in tokens ? tokens.idToken : null; + }, + catch: (): GenericError => ({ + error: 'storage_error', + message: 'Failed to read tokens from storage', + type: 'argument_error', + }), + }); +}; + +// ─── Dispatch ──────────────────────────────────────────────────────────────── + +export const dispatchSessionCheckµ = ( + store: ClientStore, + url: string, + responseType: SessionCheckResponseType, +): Micro.Micro, GenericError, never> => { + return Micro.gen(function* () { + const result = yield* Micro.tryPromise({ + try: () => + store.dispatch(oidcApi.endpoints.sessionCheckIframe.initiate({ url, responseType })), + catch: (err): GenericError => ({ + error: 'dispatch_error', + message: err instanceof Error ? err.message : 'Failed to dispatch session check', + type: 'network_error', + }), + }); + + if ('error' in result && result.error) { + const errData = result.error as { + data?: { error?: string; message?: string; type?: string }; + }; + return yield* Micro.fail({ + error: errData.data?.error ?? 'session_check_error', + message: errData.data?.message ?? 'An error occurred during session check', + type: (errData.data?.type as GenericError['type']) ?? 'network_error', + }); + } + + const { params } = (result as { data: { params: Record } }).data; + return params; + }); +}; + +// ─── Response validation ────────────────────────────────────────────────────── + +export const validateSessionCheckResponseµ = ( + iframeParams: Record, + state: string, + nonce: string, + subject?: string, +): Micro.Micro, GenericError, never> => { + return Micro.gen(function* () { + if (iframeParams.state !== state) { + return yield* Micro.fail({ + error: 'state_mismatch', + message: 'State parameter in response does not match the expected value', + type: 'auth_error', + }); + } + + const idToken = iframeParams.id_token; + if (!idToken) { + return yield* Micro.fail({ + error: 'no_id_token', + message: 'No id_token found in iframe response', + type: 'auth_error', + }); + } + + const claims = yield* Micro.try({ + try: () => decodeJwt(idToken), + catch: (): GenericError => ({ + error: 'invalid_jwt', + message: 'Failed to decode id_token JWT payload', + type: 'auth_error', + }), + }); + + if (claims.nonce !== nonce) { + return yield* Micro.fail({ + error: 'nonce_mismatch', + message: 'Nonce in id_token does not match the expected value', + type: 'auth_error', + }); + } + + if (subject !== undefined && claims.sub !== subject) { + return yield* Micro.fail({ + error: 'subject_mismatch', + message: 'Subject claim in id_token does not match the expected value', + type: 'auth_error', + }); + } + + return claims; + }); +}; + +// ─── Param builders ─────────────────────────────────────────────────────────── + +export const buildNoneUrl = ( + endpoint: string, + config: OidcConfig, + storedIdToken: string | null, + options?: SessionCheckOptions, +): string => { + const params = new URLSearchParams({ + prompt: 'none', + response_type: SessionCheckResponseType.None, + client_id: config.clientId, + redirect_uri: options?.redirectUri ?? config.redirectUri, + scope: options?.scope ?? 'openid', + ...(storedIdToken ? { id_token_hint: storedIdToken } : {}), + }); + return `${endpoint}?${params.toString()}`; +}; + +export const buildIdTokenUrl = ( + endpoint: string, + config: OidcConfig, + storedIdToken: string | null, + options?: SessionCheckOptions, +): { url: string; nonce: string; state: string } => { + const nonce = createRandomString(32); + const state = createState(); + const params = new URLSearchParams({ + prompt: 'none', + response_type: SessionCheckResponseType.IdToken, + client_id: config.clientId, + redirect_uri: options?.redirectUri ?? config.redirectUri, + scope: options?.scope ?? 'openid', + nonce, + state, + ...(storedIdToken ? { id_token_hint: storedIdToken } : {}), + }); + return { url: `${endpoint}?${params.toString()}`, nonce, state }; +}; + +// ─── None mode ─────────────────────────────────────────────────────────────── + +export const sessionCheckNoneµ = ( + wellknown: WellknownResponse, + config: OidcConfig, + store: ClientStore, + storageClient: StorageClient, + log: CustomLogger, + options?: SessionCheckOptions, +): Micro.Micro => { + return Micro.gen(function* () { + const storedIdToken = yield* readStoredIdTokenµ(storageClient); + + const redirectUri = options?.redirectUri ?? config.redirectUri; + // none mode resolves by recognising when the iframe lands on the redirect URI. + // An empty redirect_uri means the iframe never matches and silently times out instead of failing fast. + if (!redirectUri) { + return yield* Micro.fail({ + error: 'missing_redirect_uri', + message: 'redirect_uri is required for session check', + type: 'argument_error', + }); + } + + const url = buildNoneUrl(wellknown.authorization_endpoint, config, storedIdToken, options); + log.debug('Session check (none) URL built'); + + yield* dispatchSessionCheckµ(store, url, SessionCheckResponseType.None); + log.debug('Session check (none) completed successfully'); + + return {} satisfies SessionCheckSuccess; + }); +}; + +// ─── IdToken mode ───────────────────────────────────────────────────────────── + +export const sessionCheckIdTokenµ = ( + wellknown: WellknownResponse, + config: OidcConfig, + store: ClientStore, + storageClient: StorageClient, + log: CustomLogger, + options?: SessionCheckOptions, +): Micro.Micro => { + return Micro.gen(function* () { + const storedIdToken = yield* readStoredIdTokenµ(storageClient); + const { url, nonce, state } = buildIdTokenUrl( + wellknown.authorization_endpoint, + config, + storedIdToken, + options, + ); + log.debug('Session check (id_token) URL built'); + + const iframeParams = yield* dispatchSessionCheckµ(store, url, SessionCheckResponseType.IdToken); + const claims = yield* validateSessionCheckResponseµ( + iframeParams, + state, + nonce, + options?.subject, + ); + log.debug('Session check (id_token) completed successfully'); + + return { claims } satisfies SessionCheckSuccess; + }); +}; diff --git a/packages/oidc-client/src/lib/session.types.ts b/packages/oidc-client/src/lib/session.types.ts new file mode 100644 index 0000000000..8fc62aceb0 --- /dev/null +++ b/packages/oidc-client/src/lib/session.types.ts @@ -0,0 +1,41 @@ +/* + * Copyright © 2026 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +// Both a const object (for runtime value access: SessionCheckResponseType.IdToken) and a type +// (for annotations: param: SessionCheckResponseType) are declared under the same name. +// This is the TypeScript const-object + union-type pattern — a tree-shakeable alternative to enums +// that preserves the string literals ('id_token' | 'none') in the compiled output. +export const SessionCheckResponseType = { + IdToken: 'id_token', + None: 'none', +} as const; + +export type SessionCheckResponseType = + (typeof SessionCheckResponseType)[keyof typeof SessionCheckResponseType]; + +/** + * Both modes send id_token_hint if a stored token is available; the AS falls back to the browser + * session cookie if it is absent. This means a session check can succeed even without stored tokens. + * + * - id_token mode: returns a fresh id_token with claims. subject is validated if provided. + * - none mode: returns no claims. Success is detected by the iframe landing on the redirect URI. + */ +export interface SessionCheckOptions { + /** The response type for the session check. Default: SessionCheckResponseType.None */ + responseType?: SessionCheckResponseType; + /** Overrides OidcConfig.redirectUri for the session check request. */ + redirectUri?: string; + /** If provided, the sub claim in the returned id_token must match. id_token mode only. */ + subject?: string; + /** OAuth scope. Default: 'openid'. */ + scope?: string; +} + +export interface SessionCheckSuccess { + /** Decoded id_token payload. Present only in id_token mode. */ + claims?: Record; +} diff --git a/packages/oidc-client/src/types.ts b/packages/oidc-client/src/types.ts index c1e5c71b95..6a17dcbfef 100644 --- a/packages/oidc-client/src/types.ts +++ b/packages/oidc-client/src/types.ts @@ -1,4 +1,4 @@ -/* Copyright (c) 2025 Ping Identity Corporation. All rights reserved. +/* Copyright © 2025 - 2026 Ping Identity Corporation. All rights reserved. * * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. @@ -7,6 +7,7 @@ export * from './lib/client.types.js'; export * from './lib/config.types.js'; export * from './lib/authorize.request.types.js'; export * from './lib/exchange.types.js'; +export * from './lib/session.types.js'; export type { PushAuthorizationResponse } from './lib/par.types.js'; export type { diff --git a/packages/sdk-effects/iframe-manager/src/lib/iframe-manager.effects.test.ts b/packages/sdk-effects/iframe-manager/src/lib/iframe-manager.effects.test.ts index 688e9e481a..d2ebdefeca 100644 --- a/packages/sdk-effects/iframe-manager/src/lib/iframe-manager.effects.test.ts +++ b/packages/sdk-effects/iframe-manager/src/lib/iframe-manager.effects.test.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * Copyright © 2025 - 2026 Ping Identity Corporation. All rights reserved. * * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. @@ -23,41 +23,69 @@ function simulateIframeLoad(iframe: HTMLIFrameElement, href: string): void { describe('iFrameManager', () => { describe('getParamsByRedirect – input validation', () => { - it('throws synchronously when successParams is empty', () => { + it('rejects when successParams is empty (no resolveOnRedirectUri)', async () => { const manager = iFrameManager(); - expect(() => + await expect( manager.getParamsByRedirect({ url: 'https://example.com', timeout: 1000, successParams: [], errorParams: ['error'], }), - ).toThrow('successParams and errorParams must be provided'); + ).rejects.toEqual({ + type: 'internal_error', + message: 'successParams and errorParams must be provided', + }); }); - it('throws synchronously when errorParams is empty', () => { + it('rejects when errorParams is empty (no resolveOnRedirectUri)', async () => { const manager = iFrameManager(); - expect(() => + await expect( manager.getParamsByRedirect({ url: 'https://example.com', timeout: 1000, successParams: ['code'], errorParams: [], }), - ).toThrow('successParams and errorParams must be provided'); + ).rejects.toEqual({ + type: 'internal_error', + message: 'successParams and errorParams must be provided', + }); }); - it('throws synchronously when successParams or errorParams is undefined', () => { + it('rejects when errorParams is empty with resolveOnRedirectUri set', async () => { const manager = iFrameManager(); - expect(() => + await expect( manager.getParamsByRedirect({ url: 'https://example.com', timeout: 1000, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - successParams: undefined as any, - errorParams: ['error'], + successParams: [], + errorParams: [], + resolveOnRedirectUri: 'https://app.example.com/callback', }), - ).toThrow('successParams and errorParams must be provided'); + ).rejects.toEqual({ + type: 'internal_error', + message: 'errorParams must be provided', + }); + }); + + it('does not reject when successParams is empty but resolveOnRedirectUri is set', async () => { + const manager = iFrameManager(); + // The promise should not reject immediately — it will eventually timeout + vi.useFakeTimers(); + const promise = manager.getParamsByRedirect({ + url: 'https://example.com', + timeout: 100, + successParams: [], + errorParams: ['error'], + resolveOnRedirectUri: 'https://app.example.com/callback', + }); + vi.advanceTimersByTime(100); + await expect(promise).rejects.toEqual({ + type: 'internal_error', + message: 'iframe timed out', + }); + vi.useRealTimers(); }); }); @@ -272,4 +300,154 @@ describe('iFrameManager', () => { expect(document.querySelector('iframe')).toBeNull(); }); }); + + describe('getParamsByRedirect – includeHashParams', () => { + afterEach(() => { + document.body.replaceChildren(); + }); + + it('includes hash fragment params when includeHashParams is true', async () => { + const manager = iFrameManager(); + const promise = manager.getParamsByRedirect({ + url: 'https://example.com/start', + timeout: 5000, + successParams: ['id_token'], + errorParams: ['error'], + includeHashParams: true, + }); + + const iframe = document.querySelector('iframe') as HTMLIFrameElement; + simulateIframeLoad( + iframe, + 'https://app.example.com/callback#id_token=eyJhbGciOiJSUzI1NiJ9.test.sig&state=abc', + ); + + const result = await promise; + expect(result.id_token).toBe('eyJhbGciOiJSUzI1NiJ9.test.sig'); + expect(result.state).toBe('abc'); + }); + + it('detects error params in query string even when includeHashParams is true', async () => { + const manager = iFrameManager(); + const promise = manager.getParamsByRedirect({ + url: 'https://example.com/start', + timeout: 5000, + successParams: ['id_token'], + errorParams: ['error'], + includeHashParams: true, + }); + + const iframe = document.querySelector('iframe') as HTMLIFrameElement; + simulateIframeLoad( + iframe, + 'https://app.example.com/callback?error=login_required&error_description=not+logged+in', + ); + + const result = await promise; + expect(result.error).toBe('login_required'); + }); + + it('does not include hash params when includeHashParams is false', async () => { + const manager = iFrameManager(); + const promise = manager.getParamsByRedirect({ + url: 'https://example.com/start', + timeout: 5000, + successParams: ['code'], + errorParams: ['error'], + includeHashParams: false, + }); + + const iframe = document.querySelector('iframe') as HTMLIFrameElement; + // code is in the query string — resolves regardless of hash + simulateIframeLoad(iframe, 'https://app.example.com/callback?code=abc123#unrelated=ignored'); + + const result = await promise; + expect(result.code).toBe('abc123'); + expect(result.unrelated).toBeUndefined(); + }); + }); + + describe('getParamsByRedirect – resolveOnRedirectUri', () => { + afterEach(() => { + document.body.replaceChildren(); + }); + + it('resolves immediately when iframe lands on the redirect URI (exact match)', async () => { + const manager = iFrameManager(); + const promise = manager.getParamsByRedirect({ + url: 'https://as.example.com/authorize', + timeout: 5000, + successParams: [], + errorParams: ['error'], + resolveOnRedirectUri: 'https://app.example.com/callback', + }); + + const iframe = document.querySelector('iframe') as HTMLIFrameElement; + simulateIframeLoad(iframe, 'https://app.example.com/callback?state=abc123'); + + const result = await promise; + expect(result.state).toBe('abc123'); + }); + + it('resolves with parsed query params on redirect URI landing', async () => { + const manager = iFrameManager(); + const promise = manager.getParamsByRedirect({ + url: 'https://as.example.com/authorize', + timeout: 5000, + successParams: [], + errorParams: ['error'], + resolveOnRedirectUri: 'https://app.example.com/callback', + }); + + const iframe = document.querySelector('iframe') as HTMLIFrameElement; + simulateIframeLoad(iframe, 'https://app.example.com/callback?state=xyz&session_state=foo'); + + const result = await promise; + expect(result).toEqual({ state: 'xyz', session_state: 'foo' }); + }); + + it('does not resolve on a path that is a substring of the redirect URI', async () => { + vi.useFakeTimers(); + const manager = iFrameManager(); + const promise = manager.getParamsByRedirect({ + url: 'https://as.example.com/authorize', + timeout: 500, + successParams: [], + errorParams: ['error'], + resolveOnRedirectUri: 'https://app.example.com/callback', + }); + + const iframe = document.querySelector('iframe') as HTMLIFrameElement; + // "/callbacks-leak" shares the prefix "/callback" — must NOT resolve + simulateIframeLoad(iframe, 'https://app.example.com/callbacks-leak?state=evil'); + + vi.advanceTimersByTime(500); + await expect(promise).rejects.toEqual({ + type: 'internal_error', + message: 'iframe timed out', + }); + vi.useRealTimers(); + }); + + it('detects error params before checking redirect URI match', async () => { + const manager = iFrameManager(); + const promise = manager.getParamsByRedirect({ + url: 'https://as.example.com/authorize', + timeout: 5000, + successParams: [], + errorParams: ['error'], + resolveOnRedirectUri: 'https://app.example.com/callback', + }); + + const iframe = document.querySelector('iframe') as HTMLIFrameElement; + // Error lands on the redirect URI — error check fires first + simulateIframeLoad( + iframe, + 'https://app.example.com/callback?error=login_required&error_description=no+session', + ); + + const result = await promise; + expect(result.error).toBe('login_required'); + }); + }); }); diff --git a/packages/sdk-effects/iframe-manager/src/lib/iframe-manager.effects.ts b/packages/sdk-effects/iframe-manager/src/lib/iframe-manager.effects.ts index 031f085c30..1321a2279a 100644 --- a/packages/sdk-effects/iframe-manager/src/lib/iframe-manager.effects.ts +++ b/packages/sdk-effects/iframe-manager/src/lib/iframe-manager.effects.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * Copyright © 2025 - 2026 Ping Identity Corporation. All rights reserved. * * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. @@ -16,6 +16,10 @@ export interface GetParamsFromIFrameOptions { successParams: string[]; /** Array of query parameter keys indicating an error occurred. */ errorParams: string[]; + /** When true, merges URL fragment (hash) params into search params before resolution. Use for response_type=id_token. */ + includeHashParams?: boolean; + /** When set, resolves immediately upon navigating to a URL matching this redirect URI (origin + pathname). Use for response_type=none. */ + resolveOnRedirectUri?: string; } export type ResolvedParams = Record; @@ -44,6 +48,18 @@ function searchParamsToRecord(params: URLSearchParams): ResolvedParams { return result; } +// Compares origin + pathname rather than string prefix to avoid substring/trailing-slash false matches +// (e.g. "/callback" vs "/callbacks-leak") and to ignore appended query/hash params. +function isRedirectUriMatch(currentHref: string, redirectUri: string): boolean { + try { + const current = new URL(currentHref); + const target = new URL(redirectUri); + return current.origin === target.origin && current.pathname === target.pathname; + } catch { + return false; + } +} + /** * Initializes the Iframe Manager effect. * @returns An object containing the API for managing iframe requests. @@ -63,16 +79,24 @@ export function iFrameManager() { */ return { getParamsByRedirect: (options: GetParamsFromIFrameOptions): Promise => { - const { url, timeout, successParams, errorParams } = options; - - if ( - !successParams || - !errorParams || - successParams.length === 0 || - errorParams.length === 0 - ) { - const error = new Error('successParams and errorParams must be provided'); - throw error; + const { url, timeout, successParams, errorParams, includeHashParams, resolveOnRedirectUri } = + options; + + // Without resolveOnRedirectUri, both arrays are required — successParams detects success, + // errorParams detects errors; if either is missing one outcome is undetectable. + if (!resolveOnRedirectUri && (!successParams?.length || !errorParams?.length)) { + return Promise.reject({ + type: 'internal_error', + message: 'successParams and errorParams must be provided', + }); + } + // With resolveOnRedirectUri (response_type=none), success is detected via URI landing so + // successParams:[] is intentional — but errorParams is still required since errors arrive as query params. + if (resolveOnRedirectUri && !errorParams?.length) { + return Promise.reject({ + type: 'internal_error', + message: 'errorParams must be provided', + }); } return new Promise((resolve, reject) => { @@ -109,26 +133,42 @@ export function iFrameManager() { return; // Wait for actual navigation } - const redirectUrl = new URL(currentIframeHref); - const searchParams = redirectUrl.searchParams; - const parsedParams = searchParamsToRecord(searchParams); + const { searchParams, hash } = new URL(currentIframeHref); + // hash is the raw URL fragment including '#' (e.g. "#id_token=eyJ...&state=abc"). + // For response_type=id_token, the token arrives in the fragment instead of the query string. + // slice(1) strips the leading '#', then new URLSearchParams parses "key=value&key=value" + // exactly like a query string — merging both sets into one URLSearchParams for uniform scanning. + const redirectParams = includeHashParams + ? new URLSearchParams([...searchParams, ...new URLSearchParams(hash.slice(1))]) + : searchParams; + const parsedParams = searchParamsToRecord(redirectParams); // 1. Check for Error Parameters - if (hasErrorParams(searchParams, errorParams)) { + if (hasErrorParams(redirectParams, errorParams)) { + cleanup(); + resolve(parsedParams); + return; + } + + // 2. resolveOnRedirectUri mode: resolve as soon as the iframe lands on the redirect URI + if ( + resolveOnRedirectUri && + isRedirectUriMatch(currentIframeHref, resolveOnRedirectUri) + ) { cleanup(); resolve(parsedParams); // Resolve with all parsed params for context return; } - // 2. Check for Success Parameters - if (hasSomeSuccessParams(searchParams, successParams)) { + // 3. Check for Success Parameters + if (hasSomeSuccessParams(redirectParams, successParams)) { cleanup(); resolve(parsedParams); // Resolve with all parsed params return; } /* - * 3. Neither Error nor Success: Intermediate Redirect? + * 4. Neither Error nor Success: Intermediate Redirect? * If neither error nor all required success params are found, * assume it's an intermediate step in the redirect flow. * Do nothing, let the timeout eventually handle non-resolving states diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eeb837145b..a96e3cc08f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,6 +18,9 @@ catalogs: immer: specifier: ^10.1.1 version: 10.2.0 + jose: + specifier: ^6.0.0 + version: 6.2.3 msw: specifier: ^2.5.1 version: 2.12.1 @@ -544,6 +547,9 @@ importers: effect: specifier: catalog:effect version: 3.21.0 + jose: + specifier: 'catalog:' + version: 6.2.3 devDependencies: '@effect/vitest': specifier: catalog:effect @@ -5987,6 +5993,9 @@ packages: jju@1.4.0: resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==} + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -14832,6 +14841,8 @@ snapshots: jju@1.4.0: {} + jose@6.2.3: {} + js-tokens@4.0.0: {} js-tokens@9.0.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 27bb8486f8..bd2d5b6135 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -8,6 +8,7 @@ packages: - 'scratchpad' catalog: '@forgerock/javascript-sdk': '4.9.0' + jose: '^6.0.0' '@reduxjs/toolkit': '^2.8.2' '@types/express': '5.0.6' immer: '^10.1.1'