Skip to content

Commit 0431f96

Browse files
committed
feat(oidc-client): add no redirect support for response type none
1 parent af2e859 commit 0431f96

11 files changed

Lines changed: 533 additions & 60 deletions

File tree

e2e/oidc-app/src/ping-am/index.html

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ <h1>OIDC App | PingAM Login</h1>
2121
<button id="logout">Logout</button>
2222
<button id="user-info-btn">User Info</button>
2323
<button id="revoke">Revoke Token</button>
24-
<button id="session-check-btn">Session Check (none)</button>
24+
<button id="session-check-btn">Session Check (none, iframe)</button>
25+
<button id="session-check-no-redirect-btn">Session Check (none, no redirect)</button>
2526
<button id="session-check-id-token-btn">Session Check (id_token)</button>
2627
<a href="/ping-am/">Start Over</a>
2728
</div>

e2e/oidc-app/src/ping-one/index.html

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ <h1>OIDC App | P1 Login</h1>
2121
<button id="logout">Logout</button>
2222
<button id="user-info-btn">User Info</button>
2323
<button id="revoke">Revoke Token</button>
24-
<button id="session-check-btn">Session Check (none)</button>
2524
<button id="session-check-id-token-btn">Session Check (id_token)</button>
2625
<a href="/ping-one/">Start Over</a>
2726
</div>

e2e/oidc-app/src/utils/oidc-app.ts

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,14 @@ let tokenIndex = 0;
2121

2222
function displayError(error: unknown) {
2323
const errorEl = document.createElement('div');
24-
errorEl.innerHTML = `<p><strong>Error:</strong> <span class="error">${JSON.stringify(error, null, 2)}</span></p>`;
24+
const p = document.createElement('p');
25+
const strong = document.createElement('strong');
26+
strong.textContent = 'Error:';
27+
const span = document.createElement('span');
28+
span.className = 'error';
29+
span.textContent = JSON.stringify(error, null, 2);
30+
p.append(strong, document.createTextNode(' '), span);
31+
errorEl.appendChild(p);
2532
document.body.appendChild(errorEl);
2633
}
2734

@@ -53,7 +60,14 @@ function displayTokenResponse(
5360
}
5461

5562
const tokenInfoEl = document.createElement('div');
56-
tokenInfoEl.innerHTML = `<p><strong>Access Token:</strong> <span id="accessToken-${tokenIndex}">${response.accessToken}</span></p>`;
63+
const tokenP = document.createElement('p');
64+
const tokenStrong = document.createElement('strong');
65+
tokenStrong.textContent = 'Access Token:';
66+
const tokenSpan = document.createElement('span');
67+
tokenSpan.id = `accessToken-${tokenIndex}`;
68+
tokenSpan.textContent = response.accessToken;
69+
tokenP.append(tokenStrong, document.createTextNode(' '), tokenSpan);
70+
tokenInfoEl.appendChild(tokenP);
5771
appEl?.appendChild(tokenInfoEl);
5872
tokenIndex++;
5973
}
@@ -162,7 +176,14 @@ export async function oidcApp({
162176

163177
const appEl = document.getElementById('app');
164178
const userInfoEl = document.createElement('div');
165-
userInfoEl.innerHTML = `<p><strong>User Info:</strong> <span id="userInfo">${JSON.stringify(userInfo, null, 2)}</span></p>`;
179+
const userInfoP = document.createElement('p');
180+
const userInfoStrong = document.createElement('strong');
181+
userInfoStrong.textContent = 'User Info:';
182+
const userInfoSpan = document.createElement('span');
183+
userInfoSpan.id = 'userInfo';
184+
userInfoSpan.textContent = JSON.stringify(userInfo, null, 2);
185+
userInfoP.append(userInfoStrong, document.createTextNode(' '), userInfoSpan);
186+
userInfoEl.appendChild(userInfoP);
166187
appEl?.appendChild(userInfoEl);
167188
}
168189
});
@@ -178,7 +199,9 @@ export async function oidcApp({
178199
} else {
179200
const appEl = document.getElementById('app');
180201
const revokeEl = document.createElement('div');
181-
revokeEl.innerHTML = `<p>Token successfully revoked</p>`;
202+
const revokeP = document.createElement('p');
203+
revokeP.textContent = 'Token successfully revoked';
204+
revokeEl.appendChild(revokeP);
182205
appEl?.appendChild(revokeEl);
183206
}
184207
});
@@ -218,7 +241,30 @@ export async function oidcApp({
218241
const result = await oidcClient.user?.session();
219242
const appEl = document.getElementById('app');
220243
const el = document.createElement('div');
221-
el.innerHTML = `<p><strong>Session Check (none):</strong></p><pre id="session-check-result">${JSON.stringify(result, null, 2)}</pre>`;
244+
const title = document.createElement('p');
245+
const titleStrong = document.createElement('strong');
246+
titleStrong.textContent = 'Session Check (none, iframe):';
247+
title.appendChild(titleStrong);
248+
const pre = document.createElement('pre');
249+
pre.id = 'session-check-result';
250+
pre.textContent = JSON.stringify(result, null, 2);
251+
el.append(title, pre);
252+
appEl?.appendChild(el);
253+
});
254+
255+
document.getElementById('session-check-no-redirect-btn')?.addEventListener('click', async () => {
256+
const options: SessionCheckOptions = { redirectUri: '' };
257+
const result = await oidcClient.user?.session(options);
258+
const appEl = document.getElementById('app');
259+
const el = document.createElement('div');
260+
const title = document.createElement('p');
261+
const titleStrong = document.createElement('strong');
262+
titleStrong.textContent = 'Session Check (none, no redirect):';
263+
title.appendChild(titleStrong);
264+
const pre = document.createElement('pre');
265+
pre.id = 'session-check-no-redirect-result';
266+
pre.textContent = JSON.stringify(result, null, 2);
267+
el.append(title, pre);
222268
appEl?.appendChild(el);
223269
});
224270

@@ -227,7 +273,14 @@ export async function oidcApp({
227273
const result = await oidcClient.user?.session(options);
228274
const appEl = document.getElementById('app');
229275
const el = document.createElement('div');
230-
el.innerHTML = `<p><strong>Session Check (id_token):</strong></p><pre id="session-check-id-token-result">${JSON.stringify(result, null, 2)}</pre>`;
276+
const title = document.createElement('p');
277+
const titleStrong = document.createElement('strong');
278+
titleStrong.textContent = 'Session Check (id_token):';
279+
title.appendChild(titleStrong);
280+
const pre = document.createElement('pre');
281+
pre.id = 'session-check-id-token-result';
282+
pre.textContent = JSON.stringify(result, null, 2);
283+
el.append(title, pre);
231284
appEl?.appendChild(el);
232285
});
233286

e2e/oidc-suites/src/session.spec.ts

Lines changed: 70 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ test.describe('user.session() tests', () => {
5050
}
5151
});
5252

53-
await page.getByRole('button', { name: 'Session Check (none)', exact: true }).click();
53+
await page.getByRole('button', { name: 'Session Check (none, iframe)', exact: true }).click();
5454
await expect(page.locator('#session-check-result')).not.toBeEmpty();
5555
const resultText = await page.locator('#session-check-result').textContent();
5656
expect(resultText).not.toContain('"error"');
@@ -79,22 +79,22 @@ test.describe('user.session() tests', () => {
7979
}
8080
});
8181

82-
await page.getByRole('button', { name: 'Session Check (none)', exact: true }).click();
82+
await page.getByRole('button', { name: 'Session Check (none, iframe)', exact: true }).click();
8383
await expect(page.locator('#session-check-result')).not.toBeEmpty();
8484
const resultText = await page.locator('#session-check-result').textContent();
8585
expect(resultText).toContain('"error": "login_required"');
8686
});
8787

88-
test('session check (none) fails when no session exists', async ({ page }) => {
88+
test('session check (none) fails with no_id_token_hint when not logged in', async ({ page }) => {
8989
const { navigate } = asyncEvents(page);
90-
// Navigate without logging in — no tokens in storage, no browser session
90+
// Navigate without logging in — no tokens in storage means no id_token_hint to send
9191
await navigate('/ping-am/');
9292

93-
await page.getByRole('button', { name: 'Session Check (none)', exact: true }).click();
93+
await page.getByRole('button', { name: 'Session Check (none, iframe)', exact: true }).click();
9494
await expect(page.locator('#session-check-result')).not.toBeEmpty();
9595
const resultText = await page.locator('#session-check-result').textContent();
96-
// PingAM returns login_required or interaction_required when there is no session
97-
expect(resultText).toMatch(/"error": "(login_required|interaction_required)"/);
96+
// response_type=none requires a stored id_token; fails early before reaching the AS
97+
expect(resultText).toContain('"error": "no_id_token_hint"');
9898
});
9999

100100
test('session check (id_token) succeeds with valid JWT in hash', async ({ page }) => {
@@ -183,7 +183,7 @@ test.describe('user.session() tests', () => {
183183
}
184184
});
185185

186-
await page.getByRole('button', { name: 'Session Check (none)', exact: true }).click();
186+
await page.getByRole('button', { name: 'Session Check (none, iframe)', exact: true }).click();
187187
await expect(page.locator('#session-check-result')).not.toBeEmpty();
188188
const resultText = await page.locator('#session-check-result').textContent();
189189
expect(resultText).not.toContain('"error"');
@@ -221,6 +221,67 @@ test.describe('user.session() tests', () => {
221221
expect(resultText).toContain('"error": "nonce_mismatch"');
222222
});
223223

224+
test('session check (none, no redirect) succeeds after login', async ({ page }) => {
225+
const { navigate } = asyncEvents(page);
226+
await navigate('/ping-am/');
227+
228+
await loginPingAm(page, pingAmUsername, pingAmPassword);
229+
230+
await page.route('**/authorize**', async (route, request) => {
231+
const url = new URL(request.url());
232+
if (url.searchParams.get('prompt') === 'none' && !url.searchParams.has('redirect_uri')) {
233+
await route.fulfill({ status: 204 });
234+
} else {
235+
await route.continue();
236+
}
237+
});
238+
239+
await page.getByRole('button', { name: 'Session Check (none, no redirect)' }).click();
240+
await expect(page.locator('#session-check-no-redirect-result')).not.toBeEmpty();
241+
const resultText = await page.locator('#session-check-no-redirect-result').textContent();
242+
expect(resultText).not.toContain('"error"');
243+
});
244+
245+
test('session check (none, no redirect) fails with login_required', async ({ page }) => {
246+
const { navigate } = asyncEvents(page);
247+
await navigate('/ping-am/');
248+
249+
await loginPingAm(page, pingAmUsername, pingAmPassword);
250+
251+
await page.route('**/authorize**', async (route, request) => {
252+
const url = new URL(request.url());
253+
if (url.searchParams.get('prompt') === 'none' && !url.searchParams.has('redirect_uri')) {
254+
await route.fulfill({
255+
status: 400,
256+
contentType: 'application/json',
257+
body: JSON.stringify({
258+
error: 'login_required',
259+
error_description: 'User not authenticated',
260+
}),
261+
});
262+
} else {
263+
await route.continue();
264+
}
265+
});
266+
267+
await page.getByRole('button', { name: 'Session Check (none, no redirect)' }).click();
268+
await expect(page.locator('#session-check-no-redirect-result')).not.toBeEmpty();
269+
const resultText = await page.locator('#session-check-no-redirect-result').textContent();
270+
expect(resultText).toContain('"error": "login_required"');
271+
});
272+
273+
test('session check (none, no redirect) fails with no_id_token_hint when not logged in', async ({
274+
page,
275+
}) => {
276+
const { navigate } = asyncEvents(page);
277+
await navigate('/ping-am/');
278+
279+
await page.getByRole('button', { name: 'Session Check (none, no redirect)' }).click();
280+
await expect(page.locator('#session-check-no-redirect-result')).not.toBeEmpty();
281+
const resultText = await page.locator('#session-check-no-redirect-result').textContent();
282+
expect(resultText).toContain('"error": "no_id_token_hint"');
283+
});
284+
224285
test('session check (none) fails with iframe_timeout when AS does not respond', async ({
225286
page,
226287
}) => {
@@ -239,7 +300,7 @@ test.describe('user.session() tests', () => {
239300
}
240301
});
241302

242-
await page.getByRole('button', { name: 'Session Check (none)', exact: true }).click();
303+
await page.getByRole('button', { name: 'Session Check (none, iframe)', exact: true }).click();
243304
await expect(page.locator('#session-check-result')).not.toBeEmpty();
244305
const resultText = await page.locator('#session-check-result').textContent();
245306
expect(resultText).toContain('"error": "iframe_timeout"');

packages/oidc-client/README.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,59 @@ Logs out the user by revoking tokens and clearing the storage. Uses the end sess
180180
const logoutResponse = await oidcClient.user.logout();
181181
```
182182

183+
#### `user.session(options?)`
184+
185+
Checks whether the user has an active session at the AS without prompting for login (`prompt=none`).
186+
187+
- **Parameters**: `SessionCheckOptions` (optional)
188+
- **Returns**: `Promise<SessionCheckSuccess | GenericError>`
189+
190+
```js
191+
const session = await oidcClient.user.session();
192+
if ('error' in session) {
193+
// No active session
194+
}
195+
```
196+
197+
##### `responseType: 'none'` (default)
198+
199+
Use when you only need to know if a session is alive — no claims are returned.
200+
201+
Requires a stored `id_token` to send as `id_token_hint`. Fails immediately with `no_id_token_hint` if storage is empty.
202+
203+
How the check runs depends on `redirectUri`:
204+
205+
- **With `redirectUri`** (iframe): a hidden iframe loads the authorization URL. The AS redirects to `redirectUri` on success or appends error params on failure. The `redirectUri` must be same-origin — the browser blocks cross-origin iframe content access.
206+
- **Without `redirectUri`** (fetch): a plain GET to the authorization endpoint — no iframe. The AS returns `204` on success or `400` on failure. Use this when no same-origin callback page is available.
207+
208+
Returns `{ mode: 'none' }` on success.
209+
210+
##### `responseType: 'id_token'`
211+
212+
Use when you need the user's claims back from the session. The AS issues a fresh `id_token` whose decoded claims are returned on success.
213+
214+
Always uses the iframe path — `redirectUri` is required. A stored `id_token` is sent as `id_token_hint` if available but is not required.
215+
216+
`state` and `nonce` are validated before claims are returned. If `subject` is provided, the `sub` claim must also match — otherwise any active session's claims are returned.
217+
218+
```js
219+
const session = await oidcClient.user.session({
220+
responseType: 'id_token',
221+
subject: knownUserId, // optional — omit to get claims for whoever is logged in
222+
});
223+
if (!('error' in session)) {
224+
console.log(session.claims); // JWTPayload
225+
}
226+
```
227+
228+
Returns `{ mode: 'id_token', claims: JWTPayload }` on success.
229+
230+
##### PingOne compatibility note
231+
232+
PingOne SaaS does not support `response_type=none`. Its authorization server only accepts `code`, `token`, and `id_token` as response types — `none` is not listed as an option in the [PingOne application configuration](https://docs.pingidentity.com/pingone/applications/p1_edit_application_oidc.html), the [PingOne response types reference](https://docs.pingidentity.com/pingone/applications/p1_responsetypes.html), the [PingOne Platform API reference](https://developer.pingidentity.com/pingone-api/auth/openid-connect-oauth-2.html), or the [PingOne support for response_mode options](https://developer.pingidentity.com/pingone-api/auth/auth-config-options/browserless-authentication-flow-options.html#pingone-support-for-response_mode-options).
233+
234+
Use `responseType: 'id_token'` when connecting to PingOne.
235+
183236
## Usage Examples
184237

185238
### Redirect-Based Login (`authorize.url()`)

packages/oidc-client/api-report/oidc-client.api.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,11 @@ responseType: SessionCheckResponseType;
135135
}, BaseQueryFn<string | FetchArgs, unknown, FetchBaseQueryError, {}, FetchBaseQueryMeta>, never, {
136136
params: Record<string, string>;
137137
}, "oidc", unknown>;
138+
sessionCheckFetch: MutationDefinition< {
139+
url: string;
140+
}, BaseQueryFn<string | FetchArgs, unknown, FetchBaseQueryError, {}, FetchBaseQueryMeta>, never, {
141+
status: 204;
142+
}, "oidc", unknown>;
138143
authorizeIframe: MutationDefinition< {
139144
url: string;
140145
}, BaseQueryFn<string | FetchArgs, unknown, FetchBaseQueryError, {}, FetchBaseQueryMeta>, never, AuthorizationSuccess, "oidc", unknown>;
@@ -177,6 +182,11 @@ responseType: SessionCheckResponseType;
177182
}, BaseQueryFn<string | FetchArgs, unknown, FetchBaseQueryError, {}, FetchBaseQueryMeta>, never, {
178183
params: Record<string, string>;
179184
}, "oidc", unknown>;
185+
sessionCheckFetch: MutationDefinition< {
186+
url: string;
187+
}, BaseQueryFn<string | FetchArgs, unknown, FetchBaseQueryError, {}, FetchBaseQueryMeta>, never, {
188+
status: 204;
189+
}, "oidc", unknown>;
180190
authorizeIframe: MutationDefinition< {
181191
url: string;
182192
}, BaseQueryFn<string | FetchArgs, unknown, FetchBaseQueryError, {}, FetchBaseQueryMeta>, never, AuthorizationSuccess, "oidc", unknown>;

packages/oidc-client/api-report/oidc-client.types.api.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,11 @@ responseType: SessionCheckResponseType;
135135
}, BaseQueryFn<string | FetchArgs, unknown, FetchBaseQueryError, {}, FetchBaseQueryMeta>, never, {
136136
params: Record<string, string>;
137137
}, "oidc", unknown>;
138+
sessionCheckFetch: MutationDefinition< {
139+
url: string;
140+
}, BaseQueryFn<string | FetchArgs, unknown, FetchBaseQueryError, {}, FetchBaseQueryMeta>, never, {
141+
status: 204;
142+
}, "oidc", unknown>;
138143
authorizeIframe: MutationDefinition< {
139144
url: string;
140145
}, BaseQueryFn<string | FetchArgs, unknown, FetchBaseQueryError, {}, FetchBaseQueryMeta>, never, AuthorizationSuccess, "oidc", unknown>;
@@ -177,6 +182,11 @@ responseType: SessionCheckResponseType;
177182
}, BaseQueryFn<string | FetchArgs, unknown, FetchBaseQueryError, {}, FetchBaseQueryMeta>, never, {
178183
params: Record<string, string>;
179184
}, "oidc", unknown>;
185+
sessionCheckFetch: MutationDefinition< {
186+
url: string;
187+
}, BaseQueryFn<string | FetchArgs, unknown, FetchBaseQueryError, {}, FetchBaseQueryMeta>, never, {
188+
status: 204;
189+
}, "oidc", unknown>;
180190
authorizeIframe: MutationDefinition< {
181191
url: string;
182192
}, BaseQueryFn<string | FetchArgs, unknown, FetchBaseQueryError, {}, FetchBaseQueryMeta>, never, AuthorizationSuccess, "oidc", unknown>;

packages/oidc-client/src/lib/client.store.test.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -734,9 +734,8 @@ describe('user.session()', async () => {
734734
customStorage.remove(storageKey);
735735
});
736736

737-
it('returns a GenericError when no tokens are stored (best-effort: dispatch is attempted)', async () => {
738-
// No tokens in storage — best-effort: id_token_hint is omitted, dispatch still runs.
739-
// In Vitest (no real DOM), the iframe manager cannot run and surfaces a session_check_error.
737+
it('returns a GenericError when no tokens are stored', async () => {
738+
// response_type=none requires a stored id_token; failing before dispatch.
740739
const oidcClient = await oidc({ config, storage: customStorageConfig });
741740
if ('error' in oidcClient) throw new Error('Error creating OIDC Client');
742741

@@ -745,8 +744,8 @@ describe('user.session()', async () => {
745744
if (!('error' in result)) {
746745
expect.fail('Expected SessionCheckError, got success');
747746
}
748-
expect(result.error).toBe('session_check_error');
749-
expect(result.type).toBe('network_error');
747+
expect(result.error).toBe('no_id_token_hint');
748+
expect(result.type).toBe('argument_error');
750749
});
751750

752751
it('returns wellknown_error when authorization_endpoint is missing from the wellknown config', async () => {

0 commit comments

Comments
 (0)