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'