@axa-fr/oidc-client 7.27.15 → 7.27.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,8 +2,9 @@
2
2
 
3
3
  import { describe, expect, it, vi } from 'vitest';
4
4
 
5
+ import { eventNames } from './events';
5
6
  import { ILOidcLocation } from './location';
6
- import { logoutAsync } from './logout';
7
+ import { clearSessionAsync, logoutAsync } from './logout';
7
8
 
8
9
  describe('Logout test suite', () => {
9
10
  const expectedFinalUrl =
@@ -138,4 +139,210 @@ describe('Logout test suite', () => {
138
139
  expect(finalUrl).toBe(expectedFinalUrl);
139
140
  },
140
141
  );
142
+
143
+ it('navigates to end_session_endpoint before clearing the local session (issue #1677)', async () => {
144
+ // This is the regression test for the race described in issue #1677.
145
+ // `OidcProvider`/`OidcSecure` watches `oidc.tokens`; if `destroyAsync`
146
+ // runs before `oicLocation.open`, the React tree can briefly observe a
147
+ // null token state and kick off a new auth flow before the navigation
148
+ // to the IdP's end-session endpoint commits.
149
+ const configuration = {
150
+ client_id: 'interactive.public.short',
151
+ authority: 'http://api',
152
+ logout_tokens_to_invalidate: ['access_token', 'refresh_token'],
153
+ };
154
+
155
+ const callOrder: string[] = [];
156
+
157
+ const mockFetchFn = vi.fn().mockImplementation(() => {
158
+ callOrder.push('revoke');
159
+ return Promise.resolve({ status: 200 });
160
+ });
161
+
162
+ const oidc: any = {
163
+ configuration,
164
+ tokens: {
165
+ idToken: 'abcd',
166
+ accessToken: 'abcd',
167
+ refreshToken: 'abdc',
168
+ idTokenPayload: { sub: 'sub-123' },
169
+ },
170
+ isLoggingOut: false,
171
+ initAsync: () =>
172
+ Promise.resolve({
173
+ revocationEndpoint: 'http://api/connect/revocation',
174
+ endSessionEndpoint: 'http://api/connect/endsession',
175
+ }),
176
+ destroyAsync: vi.fn().mockImplementation(() => {
177
+ callOrder.push('destroyAsync');
178
+ oidc.tokens = null;
179
+ return Promise.resolve();
180
+ }),
181
+ logoutSameTabAsync: () => Promise.resolve(),
182
+ publishEvent: (name: string) => callOrder.push(`publishEvent:${name}`),
183
+ };
184
+
185
+ const oidcDatabase = { default: oidc };
186
+
187
+ let navigatedUrl = '';
188
+ class OidcLocationMock implements ILOidcLocation {
189
+ open(url: string): void {
190
+ callOrder.push('open');
191
+ navigatedUrl = url;
192
+ }
193
+ getCurrentHref() {
194
+ return '';
195
+ }
196
+ getPath() {
197
+ return '';
198
+ }
199
+ reload() {
200
+ callOrder.push('reload');
201
+ }
202
+ getOrigin() {
203
+ return 'http://localhost:4200';
204
+ }
205
+ }
206
+
207
+ await logoutAsync(
208
+ oidc,
209
+ oidcDatabase,
210
+ mockFetchFn,
211
+ console,
212
+ new OidcLocationMock(),
213
+ )('/logged_out', null);
214
+
215
+ // Revocation comes first (so tokens are still valid when revoked),
216
+ // navigation comes second (page starts unloading), and only then we
217
+ // clear local state and broadcast `logout_from_same_tab`.
218
+ expect(callOrder[0]).toBe('revoke');
219
+ expect(callOrder[1]).toBe('revoke');
220
+ expect(callOrder[2]).toBe('open');
221
+ expect(callOrder.indexOf('destroyAsync')).toBeGreaterThan(callOrder.indexOf('open'));
222
+ expect(callOrder.indexOf(`publishEvent:${eventNames.logout_from_same_tab}`)).toBeGreaterThan(
223
+ callOrder.indexOf('open'),
224
+ );
225
+
226
+ // The id_token_hint must still be present on the navigation URL, even
227
+ // though local tokens have been cleared by `destroyAsync` afterwards.
228
+ expect(navigatedUrl).toContain('id_token_hint=abcd');
229
+ expect(navigatedUrl).toContain(
230
+ 'post_logout_redirect_uri=http%3A%2F%2Flocalhost%3A4200%2Flogged_out',
231
+ );
232
+
233
+ // The flag stays set after the call returns: the page is expected to be
234
+ // unloading and any UI re-render that briefly observes `tokens === null`
235
+ // should skip starting a new login flow.
236
+ expect(oidc.isLoggingOut).toBe(true);
237
+ });
238
+
239
+ it('resets isLoggingOut and does not navigate when no_reload is requested', async () => {
240
+ const configuration = {
241
+ client_id: 'interactive.public.short',
242
+ authority: 'http://api',
243
+ logout_tokens_to_invalidate: [],
244
+ };
245
+
246
+ const events: string[] = [];
247
+ let navigated = false;
248
+ const oidc: any = {
249
+ configuration,
250
+ tokens: {
251
+ idToken: 'abcd',
252
+ accessToken: 'abcd',
253
+ refreshToken: 'abdc',
254
+ idTokenPayload: { sub: 'sub-123' },
255
+ },
256
+ isLoggingOut: false,
257
+ initAsync: () =>
258
+ Promise.resolve({
259
+ revocationEndpoint: 'http://api/connect/revocation',
260
+ endSessionEndpoint: 'http://api/connect/endsession',
261
+ }),
262
+ destroyAsync: () => Promise.resolve(),
263
+ logoutSameTabAsync: () => Promise.resolve(),
264
+ publishEvent: (name: string) => events.push(name),
265
+ };
266
+ const oidcDatabase = { default: oidc };
267
+
268
+ class OidcLocationMock implements ILOidcLocation {
269
+ open() {
270
+ navigated = true;
271
+ }
272
+ getCurrentHref() {
273
+ return '';
274
+ }
275
+ getPath() {
276
+ return '';
277
+ }
278
+ reload() {
279
+ navigated = true;
280
+ }
281
+ getOrigin() {
282
+ return 'http://localhost:4200';
283
+ }
284
+ }
285
+
286
+ await logoutAsync(
287
+ oidc,
288
+ oidcDatabase,
289
+ vi.fn(),
290
+ console,
291
+ new OidcLocationMock(),
292
+ )('/logged_out', { 'no_reload:oidc': 'true' });
293
+
294
+ expect(navigated).toBe(false);
295
+ expect(events).toContain(eventNames.logout_from_same_tab);
296
+ expect(oidc.isLoggingOut).toBe(false);
297
+ });
298
+
299
+ describe('clearSessionAsync', () => {
300
+ it('clears the local session and emits logout_from_same_tab without contacting the IdP', async () => {
301
+ const events: { name: string; data: unknown }[] = [];
302
+ const oidc: any = {
303
+ configuration: { client_id: 'interactive.public.short' },
304
+ tokens: {
305
+ idToken: 'abcd',
306
+ accessToken: 'abcd',
307
+ refreshToken: 'abdc',
308
+ idTokenPayload: { sub: 'sub-123' },
309
+ },
310
+ destroyAsync: vi.fn().mockImplementation(status => {
311
+ oidc.tokens = null;
312
+ events.push({ name: 'destroyAsync', data: status });
313
+ return Promise.resolve();
314
+ }),
315
+ logoutSameTabAsync: vi.fn().mockResolvedValue(undefined),
316
+ publishEvent: (name: string, data: unknown) => events.push({ name, data }),
317
+ };
318
+ const oidcDatabase = { default: oidc };
319
+
320
+ await clearSessionAsync(oidc, oidcDatabase)();
321
+
322
+ expect(oidc.destroyAsync).toHaveBeenCalledWith('LOGGED_OUT');
323
+ expect(oidc.tokens).toBeNull();
324
+ expect(events.some(e => e.name === eventNames.logout_from_same_tab)).toBe(true);
325
+ });
326
+
327
+ it('calls logoutSameTabAsync for sibling OIDC clients registered in the same tab', async () => {
328
+ const oidc: any = {
329
+ configuration: { client_id: 'interactive.public.short' },
330
+ tokens: {
331
+ idToken: 'abcd',
332
+ accessToken: 'abcd',
333
+ refreshToken: 'abdc',
334
+ idTokenPayload: { sub: 'sub-123' },
335
+ },
336
+ destroyAsync: () => Promise.resolve(),
337
+ logoutSameTabAsync: vi.fn().mockResolvedValue(undefined),
338
+ publishEvent: () => undefined,
339
+ };
340
+ const sibling: any = { configuration: { client_id: 'other' } };
341
+ const oidcDatabase = { default: oidc, other: sibling };
342
+
343
+ await clearSessionAsync(oidc, oidcDatabase)();
344
+
345
+ expect(oidc.logoutSameTabAsync).toHaveBeenCalledWith('interactive.public.short', 'sub-123');
346
+ });
347
+ });
141
348
  });
package/src/logout.ts CHANGED
@@ -59,6 +59,58 @@ export const destroyAsync = oidc => async status => {
59
59
  oidc.userInfo = null;
60
60
  };
61
61
 
62
+ /**
63
+ * Clears the local OIDC session (tokens, user info, service-worker storage)
64
+ * and broadcasts `logout_from_same_tab` to any other OIDC clients registered
65
+ * in the same tab.
66
+ *
67
+ * It is intentionally decoupled from `logoutAsync`: callers that want to drop
68
+ * the local session without contacting the identity provider — for example a
69
+ * service-worker-only flow, a SPA-only logout, or an error-recovery path —
70
+ * can use this helper directly. `logoutAsync` itself calls it as the very
71
+ * last step, after the browser navigation to `end_session_endpoint` has been
72
+ * scheduled, so that the React tree never observes a transient "no tokens"
73
+ * state before the page is unloaded.
74
+ */
75
+ export const clearSessionAsync = (oidc, oidcDatabase) => async () => {
76
+ const sub = oidc.tokens?.idTokenPayload?.sub ?? null;
77
+ await oidc.destroyAsync('LOGGED_OUT');
78
+ for (const [, itemOidc] of Object.entries(oidcDatabase)) {
79
+ if (itemOidc !== oidc) {
80
+ // @ts-ignore
81
+ await oidc.logoutSameTabAsync(oidc.configuration.client_id, sub);
82
+ } else {
83
+ oidc.publishEvent(eventNames.logout_from_same_tab, {});
84
+ }
85
+ }
86
+ };
87
+
88
+ const buildEndSessionUrl = (
89
+ endSessionEndpoint: string,
90
+ endPointExtras: StringMap,
91
+ idToken: string,
92
+ postLogoutRedirectUri: string | null,
93
+ ): string => {
94
+ if (!('id_token_hint' in endPointExtras)) {
95
+ endPointExtras['id_token_hint'] = idToken;
96
+ }
97
+ if (!('post_logout_redirect_uri' in endPointExtras) && postLogoutRedirectUri !== null) {
98
+ endPointExtras['post_logout_redirect_uri'] = postLogoutRedirectUri;
99
+ }
100
+ let queryString = '';
101
+ for (const [key, value] of Object.entries(endPointExtras)) {
102
+ if (value !== null && value !== undefined) {
103
+ if (queryString === '') {
104
+ queryString += '?';
105
+ } else {
106
+ queryString += '&';
107
+ }
108
+ queryString += `${key}=${encodeURIComponent(value)}`;
109
+ }
110
+ }
111
+ return `${endSessionEndpoint}${queryString}`;
112
+ };
113
+
62
114
  export const logoutAsync =
63
115
  (oidc, oidcDatabase, fetch, console, oicLocation: ILOidcLocation) =>
64
116
  async (callbackPathOrUrl: string | null | undefined = undefined, extras: StringMap = null) => {
@@ -79,94 +131,111 @@ export const logoutAsync =
79
131
  if (callbackPathOrUrl) {
80
132
  isUri = callbackPathOrUrl.includes('https://') || callbackPathOrUrl.includes('http://');
81
133
  }
82
- const url = isUri ? callbackPathOrUrl : oicLocation.getOrigin() + path;
134
+ const postLogoutRedirectUri =
135
+ callbackPathOrUrl === null
136
+ ? null
137
+ : isUri
138
+ ? callbackPathOrUrl
139
+ : oicLocation.getOrigin() + path;
140
+ // Capture identifiers from the live session *before* any clear happens, so the
141
+ // values stay valid no matter when we drop local state.
83
142
  // @ts-ignore
84
143
  const idToken = oidc.tokens ? oidc.tokens.idToken : '';
144
+
145
+ // Mark the instance as "logout in progress" so consumers (OidcSecure, route
146
+ // guards, silent renew, 401 retry interceptors, …) can back off from
147
+ // triggering a new auth flow during the window between us clearing the
148
+ // local session and the browser actually navigating away.
149
+ oidc.isLoggingOut = true;
150
+
85
151
  try {
86
- const revocationEndpoint = oidcServerConfiguration.revocationEndpoint;
87
- if (revocationEndpoint) {
88
- const promises = [];
89
- const accessToken = oidc.tokens ? oidc.tokens.accessToken : null;
90
- if (
91
- accessToken &&
92
- configuration.logout_tokens_to_invalidate.includes(oidcLogoutTokens.access_token)
93
- ) {
94
- const revokeAccessTokenExtras = extractExtras(extras, ':revoke_access_token');
95
- const revokeAccessTokenPromise = performRevocationRequestAsync(fetch)(
96
- revocationEndpoint,
97
- accessToken,
98
- TOKEN_TYPE.access_token,
99
- configuration.client_id,
100
- revokeAccessTokenExtras,
101
- );
102
- promises.push(revokeAccessTokenPromise);
103
- }
104
- const refreshToken = oidc.tokens ? oidc.tokens.refreshToken : null;
105
- if (
106
- refreshToken &&
107
- configuration.logout_tokens_to_invalidate.includes(oidcLogoutTokens.refresh_token)
108
- ) {
109
- const revokeAccessTokenExtras = extractExtras(extras, ':revoke_refresh_token');
110
- const revokeRefreshTokenPromise = performRevocationRequestAsync(fetch)(
111
- revocationEndpoint,
112
- refreshToken,
113
- TOKEN_TYPE.refresh_token,
114
- configuration.client_id,
115
- revokeAccessTokenExtras,
116
- );
117
- promises.push(revokeRefreshTokenPromise);
118
- }
119
- if (promises.length > 0) {
120
- await Promise.all(promises);
152
+ try {
153
+ const revocationEndpoint = oidcServerConfiguration.revocationEndpoint;
154
+ if (revocationEndpoint) {
155
+ const promises = [];
156
+ const accessToken = oidc.tokens ? oidc.tokens.accessToken : null;
157
+ if (
158
+ accessToken &&
159
+ configuration.logout_tokens_to_invalidate.includes(oidcLogoutTokens.access_token)
160
+ ) {
161
+ const revokeAccessTokenExtras = extractExtras(extras, ':revoke_access_token');
162
+ const revokeAccessTokenPromise = performRevocationRequestAsync(fetch)(
163
+ revocationEndpoint,
164
+ accessToken,
165
+ TOKEN_TYPE.access_token,
166
+ configuration.client_id,
167
+ revokeAccessTokenExtras,
168
+ );
169
+ promises.push(revokeAccessTokenPromise);
170
+ }
171
+ const refreshToken = oidc.tokens ? oidc.tokens.refreshToken : null;
172
+ if (
173
+ refreshToken &&
174
+ configuration.logout_tokens_to_invalidate.includes(oidcLogoutTokens.refresh_token)
175
+ ) {
176
+ const revokeAccessTokenExtras = extractExtras(extras, ':revoke_refresh_token');
177
+ const revokeRefreshTokenPromise = performRevocationRequestAsync(fetch)(
178
+ revocationEndpoint,
179
+ refreshToken,
180
+ TOKEN_TYPE.refresh_token,
181
+ configuration.client_id,
182
+ revokeAccessTokenExtras,
183
+ );
184
+ promises.push(revokeRefreshTokenPromise);
185
+ }
186
+ if (promises.length > 0) {
187
+ // Revocation must be awaited *before* navigation, so a cancelled
188
+ // navigation can never leave valid tokens behind both in storage
189
+ // and on the authorization server.
190
+ await Promise.all(promises);
191
+ }
121
192
  }
193
+ } catch (exception) {
194
+ console.warn(
195
+ 'logoutAsync: error when revoking tokens, if the error persist, you ay configure property logout_tokens_to_invalidate from configuration to avoid this error',
196
+ );
197
+ console.warn(exception);
122
198
  }
123
- } catch (exception) {
124
- console.warn(
125
- 'logoutAsync: error when revoking tokens, if the error persist, you ay configure property logout_tokens_to_invalidate from configuration to avoid this error',
126
- );
127
- console.warn(exception);
128
- }
129
- const sub = oidc.tokens?.idTokenPayload?.sub ?? null;
130
-
131
- await oidc.destroyAsync('LOGGED_OUT');
132
- for (const [, itemOidc] of Object.entries(oidcDatabase)) {
133
- if (itemOidc !== oidc) {
134
- // @ts-ignore
135
- await oidc.logoutSameTabAsync(oidc.configuration.client_id, sub);
136
- } else {
137
- oidc.publishEvent(eventNames.logout_from_same_tab, {});
138
- }
139
- }
140
-
141
- const oidcExtras = extractExtras(extras, ':oidc');
142
- const noReload = oidcExtras && oidcExtras['no_reload'] === 'true';
143
-
144
- if (noReload) {
145
- return;
146
- }
147
199
 
148
- const endPointExtras = keepExtras(extras);
200
+ const oidcExtras = extractExtras(extras, ':oidc');
201
+ const noReload = oidcExtras && oidcExtras['no_reload'] === 'true';
149
202
 
150
- if (oidcServerConfiguration.endSessionEndpoint) {
151
- if (!('id_token_hint' in endPointExtras)) {
152
- endPointExtras['id_token_hint'] = idToken;
153
- }
154
- if (!('post_logout_redirect_uri' in endPointExtras) && callbackPathOrUrl !== null) {
155
- endPointExtras['post_logout_redirect_uri'] = url;
203
+ if (noReload) {
204
+ // No navigation happens here: this branch is essentially a "clear local
205
+ // session" call dressed as a logout. We can drop state immediately and
206
+ // reset the flag since the call returns normally to the caller.
207
+ await clearSessionAsync(oidc, oidcDatabase)();
208
+ oidc.isLoggingOut = false;
209
+ return;
156
210
  }
157
- let queryString = '';
158
- for (const [key, value] of Object.entries(endPointExtras)) {
159
- if (value !== null && value !== undefined) {
160
- if (queryString === '') {
161
- queryString += '?';
162
- } else {
163
- queryString += '&';
164
- }
165
- queryString += `${key}=${encodeURIComponent(value)}`;
166
- }
211
+
212
+ // Navigate to the end-session endpoint (or reload) *before* clearing the
213
+ // local session. This closes the race where `OidcProvider` /
214
+ // `OidcSecure` / silent-renew timers observe a null `tokens` and kick
215
+ // off a new auth flow in the window between local clear and navigation.
216
+ const endPointExtras = keepExtras(extras);
217
+ if (oidcServerConfiguration.endSessionEndpoint) {
218
+ const endSessionUrl = buildEndSessionUrl(
219
+ oidcServerConfiguration.endSessionEndpoint,
220
+ endPointExtras,
221
+ idToken,
222
+ postLogoutRedirectUri,
223
+ );
224
+ oicLocation.open(endSessionUrl);
225
+ } else {
226
+ oicLocation.reload();
167
227
  }
168
- oicLocation.open(`${oidcServerConfiguration.endSessionEndpoint}${queryString}`);
169
- } else {
170
- oicLocation.reload();
228
+
229
+ // Now that navigation has been scheduled, drop the local session. By the
230
+ // time React re-renders against the null tokens the page is already
231
+ // unloading; if for any reason it is not (e.g. navigation cancelled by a
232
+ // `beforeunload` handler) the `isLoggingOut` flag stays set so guards
233
+ // still know not to start a fresh auth flow.
234
+ await clearSessionAsync(oidc, oidcDatabase)();
235
+ } catch (exception) {
236
+ // If anything went wrong, reset the flag so the app is not stuck in a
237
+ // "logging out forever" state.
238
+ oidc.isLoggingOut = false;
239
+ throw exception;
171
240
  }
172
241
  };
package/src/oidc.ts CHANGED
@@ -12,7 +12,7 @@ import {
12
12
  import { tryKeepSessionAsync } from './keepSession';
13
13
  import { ILOidcLocation, OidcLocation } from './location';
14
14
  import { defaultLoginAsync, loginCallbackAsync } from './login.js';
15
- import { destroyAsync, logoutAsync } from './logout.js';
15
+ import { clearSessionAsync, destroyAsync, logoutAsync } from './logout.js';
16
16
  import { TokenRenewMode, Tokens } from './parseTokens.js';
17
17
  import { autoRenewTokens, renewTokensAndStartTimerAsync } from './renewTokens.js';
18
18
  import { fetchFromIssuer } from './requests.js';
@@ -99,6 +99,14 @@ export class Oidc {
99
99
  public checkSessionIFrame: CheckSessionIFrame;
100
100
  public getFetch: () => Fetch;
101
101
  public location: ILOidcLocation;
102
+ /**
103
+ * `true` while {@link logoutAsync} is executing or has scheduled a
104
+ * navigation to the identity provider's end-session endpoint that has not
105
+ * yet committed. Consumers (UI guards, silent-renew handlers, 401 retry
106
+ * interceptors, …) should check this flag and skip starting a new auth
107
+ * flow when it is set, even if `tokens` is null.
108
+ */
109
+ public isLoggingOut = false;
102
110
  constructor(
103
111
  configuration: OidcConfiguration,
104
112
  configurationName = 'default',
@@ -183,13 +191,38 @@ export class Oidc {
183
191
  return oidcFactory(getFetch, location)(configuration, name);
184
192
  };
185
193
 
186
- static get(name = 'default') {
187
- const isInsideBrowser = typeof process === 'undefined';
188
- if (!Object.prototype.hasOwnProperty.call(oidcDatabase, name) && isInsideBrowser) {
194
+ /**
195
+ * Retrieve an existing OIDC instance previously initialized via
196
+ * {@link Oidc.getOrCreate}.
197
+ *
198
+ * Since issue #1679, this method no longer throws when the requested
199
+ * configuration has not been initialized; it returns `null` instead.
200
+ * This makes hooks such as `useOidc`, `useOidcUser` and `useOidcIdToken`
201
+ * safe to call outside of an `<OidcProvider>` (e.g. in unit tests or
202
+ * Storybook stories).
203
+ *
204
+ * Use {@link Oidc.getOrThrow} to preserve the previous fail-fast
205
+ * behaviour.
206
+ */
207
+ static get(name = 'default'): Oidc | null {
208
+ if (!Object.prototype.hasOwnProperty.call(oidcDatabase, name)) {
209
+ return null;
210
+ }
211
+ return oidcDatabase[name];
212
+ }
213
+
214
+ /**
215
+ * Retrieve an existing OIDC instance, throwing an explicit error if it
216
+ * has not been initialized. This preserves the historical (pre-#1679)
217
+ * fail-fast behaviour of `Oidc.get`.
218
+ */
219
+ static getOrThrow(name = 'default'): Oidc {
220
+ const oidc = Oidc.get(name);
221
+ if (!oidc) {
189
222
  throw Error(`OIDC library does seem initialized.
190
223
  Please checkout that you are using OIDC hook inside a <OidcProvider configurationName="${name}"></OidcProvider> component.`);
191
224
  }
192
- return oidcDatabase[name];
225
+ return oidc;
193
226
  }
194
227
 
195
228
  static eventNames = eventNames;
@@ -452,6 +485,27 @@ Please checkout that you are using OIDC hook inside a <OidcProvider configuratio
452
485
  return await destroyAsync(this)(status);
453
486
  }
454
487
 
488
+ /**
489
+ * Drops the local OIDC session (tokens, user info, service-worker storage)
490
+ * and broadcasts `logout_from_same_tab`, without contacting the identity
491
+ * provider's `end_session_endpoint` and without revoking tokens.
492
+ *
493
+ * Use this for SPA-only logouts, service-worker-only flows, or
494
+ * error-recovery paths where a full IdP logout is not needed or not
495
+ * desirable. For a standard OIDC RP-initiated logout use
496
+ * {@link logoutAsync} instead.
497
+ */
498
+ clearSessionPromise: Promise<void> = null;
499
+ async clearSessionAsync(): Promise<void> {
500
+ if (this.clearSessionPromise) {
501
+ return this.clearSessionPromise;
502
+ }
503
+ this.clearSessionPromise = clearSessionAsync(this, oidcDatabase)();
504
+ return this.clearSessionPromise.finally(() => {
505
+ this.clearSessionPromise = null;
506
+ });
507
+ }
508
+
455
509
  async logoutSameTabAsync(clientId: string, sub: any) {
456
510
  // @ts-ignore
457
511
  if (
@@ -0,0 +1,34 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { Oidc } from './oidc.js';
4
+ import { OidcClient } from './oidcClient.js';
5
+
6
+ describe('OidcClient.get (issue #1679)', () => {
7
+ it('returns null when no configuration has been initialized', () => {
8
+ expect(OidcClient.get('unknown-configuration-1679')).toBeNull();
9
+ });
10
+
11
+ it('does not throw when no configuration has been initialized', () => {
12
+ expect(() => OidcClient.get('another-unknown-configuration-1679')).not.toThrow();
13
+ });
14
+ });
15
+
16
+ describe('OidcClient.getOrThrow (issue #1679)', () => {
17
+ it('throws an explicit error when configuration has not been initialized', () => {
18
+ expect(() => OidcClient.getOrThrow('missing-configuration-1679')).toThrow(
19
+ /OIDC library does seem initialized/,
20
+ );
21
+ });
22
+ });
23
+
24
+ describe('Oidc.get (issue #1679)', () => {
25
+ it('returns null when no configuration has been initialized', () => {
26
+ expect(Oidc.get('unknown-oidc-1679')).toBeNull();
27
+ });
28
+
29
+ it('Oidc.getOrThrow throws when configuration has not been initialized', () => {
30
+ expect(() => Oidc.getOrThrow('missing-oidc-1679')).toThrow(
31
+ /OIDC library does seem initialized/,
32
+ );
33
+ });
34
+ });
package/src/oidcClient.ts CHANGED
@@ -38,8 +38,28 @@ export class OidcClient {
38
38
  return new OidcClient(Oidc.getOrCreate(getFetch, location)(configuration, name));
39
39
  };
40
40
 
41
- static get(name = 'default'): OidcClient {
42
- return new OidcClient(Oidc.get(name));
41
+ /**
42
+ * Retrieve an existing {@link OidcClient} by configuration name.
43
+ *
44
+ * Since issue #1679, this method returns `null` when the requested
45
+ * configuration has not been initialized, instead of throwing. This
46
+ * allows React hooks to be used safely outside of `<OidcProvider>`.
47
+ *
48
+ * Use {@link OidcClient.getOrThrow} to preserve the previous fail-fast
49
+ * behaviour.
50
+ */
51
+ static get(name = 'default'): OidcClient | null {
52
+ const oidc = Oidc.get(name);
53
+ return oidc ? new OidcClient(oidc) : null;
54
+ }
55
+
56
+ /**
57
+ * Retrieve an existing {@link OidcClient}, throwing if it has not been
58
+ * initialized. Equivalent to the pre-#1679 behaviour of
59
+ * {@link OidcClient.get}.
60
+ */
61
+ static getOrThrow(name = 'default'): OidcClient {
62
+ return new OidcClient(Oidc.getOrThrow(name));
43
63
  }
44
64
 
45
65
  static eventNames = Oidc.eventNames;
@@ -64,6 +84,32 @@ export class OidcClient {
64
84
  return this._oidc.logoutAsync(callbackPathOrUrl, extras);
65
85
  }
66
86
 
87
+ /**
88
+ * Drops the local OIDC session (tokens, user info, service-worker storage)
89
+ * and notifies same-tab listeners via the `logout_from_same_tab` event,
90
+ * without contacting the identity provider's `end_session_endpoint` and
91
+ * without revoking tokens.
92
+ *
93
+ * Use this for SPA-only logouts, service-worker-only flows, or
94
+ * error-recovery paths. For a standard OIDC RP-initiated logout (with
95
+ * token revocation and navigation to the IdP's end-session endpoint) use
96
+ * {@link logoutAsync} instead.
97
+ */
98
+ clearSessionAsync(): Promise<void> {
99
+ return this._oidc.clearSessionAsync();
100
+ }
101
+
102
+ /**
103
+ * `true` while a logout flow is in progress: between the moment
104
+ * {@link logoutAsync} starts and the moment the browser navigates away to
105
+ * the identity provider's end-session endpoint. UI guards and silent-renew
106
+ * handlers should check this flag to avoid kicking off a new auth flow
107
+ * during that window.
108
+ */
109
+ get isLoggingOut(): boolean {
110
+ return this._oidc.isLoggingOut === true;
111
+ }
112
+
67
113
  silentLoginCallbackAsync(): Promise<void> {
68
114
  return this._oidc.silentLoginCallbackAsync();
69
115
  }
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export default '7.27.15';
1
+ export default '7.27.17';