@axa-fr/oidc-client 7.27.16 → 7.27.18

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.
@@ -0,0 +1,151 @@
1
+ // Tests for the guards added to loginCallbackAsync to surface missing /
2
+ // mismatched state and missing nonce as typed `OidcStateError` instead of a
3
+ // generic TypeError. See https://github.com/AxaFrance/oidc-client/issues/1678
4
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
5
+
6
+ import { ILOidcLocation } from './location';
7
+ import { loginCallbackAsync } from './login';
8
+ import { OidcStateError, OidcStateErrorCode } from './oidcStateError';
9
+
10
+ const makeStorage = (): Storage => {
11
+ const store: Record<string, string> = {};
12
+ return {
13
+ getItem: (key: string) => store[key] ?? null,
14
+ setItem: (key: string, value: string) => {
15
+ store[key] = value;
16
+ },
17
+ removeItem: (key: string) => {
18
+ delete store[key];
19
+ },
20
+ clear: () => {
21
+ for (const key of Object.keys(store)) {
22
+ delete store[key];
23
+ }
24
+ },
25
+ get length() {
26
+ return Object.keys(store).length;
27
+ },
28
+ key: (index: number) => Object.keys(store)[index] ?? null,
29
+ [Symbol.iterator]: function* () {
30
+ yield* Object.entries(store);
31
+ },
32
+ } as unknown as Storage;
33
+ };
34
+
35
+ class FakeLocation implements ILOidcLocation {
36
+ constructor(private currentHref: string) {}
37
+ open(): void {}
38
+ reload(): void {}
39
+ getCurrentHref(): string {
40
+ return this.currentHref;
41
+ }
42
+ getPath(): string {
43
+ return '/callback';
44
+ }
45
+ getOrigin(): string {
46
+ return 'http://localhost:4200';
47
+ }
48
+ }
49
+
50
+ const buildOidc = ({ href, storage }: { href: string; storage: Storage }) => {
51
+ const publishedEvents: Array<{ name: string; data: unknown }> = [];
52
+ const configurationName = 'default';
53
+ const configuration = {
54
+ client_id: 'interactive.public.short',
55
+ redirect_uri: 'http://localhost:4200/authentication/callback',
56
+ silent_redirect_uri: 'http://localhost:4200/authentication/silent-callback',
57
+ scope: 'openid profile email',
58
+ authority: 'http://api',
59
+ refresh_time_before_tokens_expiration_in_second: 70,
60
+ token_request_timeout: 30000,
61
+ authority_configuration: null,
62
+ storage,
63
+ login_state_storage: storage,
64
+ // no service_worker_relative_url -> initWorkerAsync returns null
65
+ };
66
+ const oidc: any = {
67
+ configuration,
68
+ configurationName,
69
+ location: new FakeLocation(href),
70
+ publishEvent: (name: string, data: unknown) => {
71
+ publishedEvents.push({ name, data });
72
+ },
73
+ initAsync: vi.fn(async () => ({
74
+ issuer: 'http://api',
75
+ authorizationEndpoint: 'http://api/connect/authorize',
76
+ tokenEndpoint: 'http://api/connect/token',
77
+ checkSessionIframe: 'http://api/connect/checksession',
78
+ })),
79
+ startCheckSessionAsync: vi.fn(async () => undefined),
80
+ };
81
+ return { oidc, publishedEvents };
82
+ };
83
+
84
+ describe('loginCallbackAsync — state/nonce guards (issue #1678)', () => {
85
+ let storage: Storage;
86
+
87
+ beforeEach(() => {
88
+ storage = makeStorage();
89
+ });
90
+
91
+ it('throws OidcStateError(STATE_MISSING) when stored state is missing but callback URL contains state', async () => {
92
+ // No `oidc.state.default` written into storage at all (simulates a
93
+ // private-browsing tab, manual storage clear, or browser eviction
94
+ // between the authorize redirect and the callback).
95
+ const href = 'http://localhost:4200/authentication/callback?code=abc&state=server-state-value';
96
+ const { oidc, publishedEvents } = buildOidc({ href, storage });
97
+
98
+ await expect(loginCallbackAsync(oidc)()).rejects.toBeInstanceOf(OidcStateError);
99
+ await expect(loginCallbackAsync(oidc)()).rejects.toMatchObject({
100
+ code: OidcStateErrorCode.STATE_MISSING,
101
+ });
102
+
103
+ // The error must also be published as a loginCallbackAsync_error event
104
+ // so listeners (incl. the React provider) can react to it.
105
+ const errorEvent = publishedEvents.find(e => e.name === 'loginCallbackAsync_error');
106
+ expect(errorEvent).toBeDefined();
107
+ expect(errorEvent!.data).toBeInstanceOf(OidcStateError);
108
+ });
109
+
110
+ it('throws OidcStateError(STATE_MISMATCH) when the stored state differs from the returned one', async () => {
111
+ storage[`oidc.state.default`] = 'stored-state-value';
112
+ storage[`oidc.nonce.default`] = 'stored-nonce-value';
113
+ const href =
114
+ 'http://localhost:4200/authentication/callback?code=abc&state=different-state-value';
115
+ const { oidc } = buildOidc({ href, storage });
116
+
117
+ await expect(loginCallbackAsync(oidc)()).rejects.toBeInstanceOf(OidcStateError);
118
+ await expect(loginCallbackAsync(oidc)()).rejects.toMatchObject({
119
+ code: OidcStateErrorCode.STATE_MISMATCH,
120
+ });
121
+ });
122
+
123
+ it('throws OidcStateError(NONCE_MISSING) when state is valid but nonce is missing from storage', async () => {
124
+ storage[`oidc.state.default`] = 'matching-state';
125
+ // No oidc.nonce.default written -> getNonceAsync returns { nonce: undefined }
126
+ const href = 'http://localhost:4200/authentication/callback?code=abc&state=matching-state';
127
+ const { oidc } = buildOidc({ href, storage });
128
+
129
+ await expect(loginCallbackAsync(oidc)()).rejects.toBeInstanceOf(OidcStateError);
130
+ await expect(loginCallbackAsync(oidc)()).rejects.toMatchObject({
131
+ code: OidcStateErrorCode.NONCE_MISSING,
132
+ });
133
+ });
134
+
135
+ it('does not throw a generic TypeError when state and nonce are both missing', async () => {
136
+ // Regression: before the fix, a missing nonce would surface as
137
+ // "Cannot read properties of undefined (reading 'nonce')" when reaching
138
+ // isTokensOidcValid(..., nonceData.nonce, ...).
139
+ const href = 'http://localhost:4200/authentication/callback?code=abc&state=server-state-value';
140
+ const { oidc } = buildOidc({ href, storage });
141
+
142
+ let caught: unknown;
143
+ try {
144
+ await loginCallbackAsync(oidc)();
145
+ } catch (e) {
146
+ caught = e;
147
+ }
148
+ expect(caught).toBeInstanceOf(OidcStateError);
149
+ expect(caught).not.toBeInstanceOf(TypeError);
150
+ });
151
+ });
package/src/login.ts CHANGED
@@ -5,6 +5,7 @@ import { initWorkerAsync } from './initWorker.js';
5
5
  import { generateJwkAsync, generateJwtDemonstratingProofOfPossessionAsync } from './jwt';
6
6
  import { ILOidcLocation } from './location';
7
7
  import Oidc from './oidc';
8
+ import { OidcStateError, OidcStateErrorCode } from './oidcStateError.js';
8
9
  import { isTokensOidcValid } from './parseTokens.js';
9
10
  import { performAuthorizationRequestAsync, performFirstTokenRequestAsync } from './requests.js';
10
11
  import { getParseQueryStringFromLocation } from './route-utils.js';
@@ -156,8 +157,28 @@ export const loginCallbackAsync =
156
157
  `Issuer not valid (expected: ${oidcServerConfiguration.issuer}, received: ${queryParams.iss})`,
157
158
  );
158
159
  }
159
- if (queryParams.state && queryParams.state !== state) {
160
- throw new Error(`State not valid (expected: ${state}, received: ${queryParams.state})`);
160
+ // Surface missing / mismatched login state as a typed, identifiable error
161
+ // rather than a generic TypeError when later dereferencing nonceData.nonce.
162
+ // See https://github.com/AxaFrance/oidc-client/issues/1678
163
+ if (queryParams.state) {
164
+ if (!state) {
165
+ throw new OidcStateError(
166
+ OidcStateErrorCode.STATE_MISSING,
167
+ 'OIDC state is missing from storage. The login state may have been cleared between the authorization redirect and the callback (e.g., private browsing, storage cleared, or browser eviction).',
168
+ );
169
+ }
170
+ if (queryParams.state !== state) {
171
+ throw new OidcStateError(
172
+ OidcStateErrorCode.STATE_MISMATCH,
173
+ `OIDC state does not match the stored one (expected: ${state}, received: ${queryParams.state}).`,
174
+ );
175
+ }
176
+ }
177
+ if (!nonceData || !nonceData.nonce) {
178
+ throw new OidcStateError(
179
+ OidcStateErrorCode.NONCE_MISSING,
180
+ 'OIDC nonce is missing from storage. The login state may have been cleared between the authorization redirect and the callback (e.g., private browsing, storage cleared, or browser eviction).',
181
+ );
161
182
  }
162
183
 
163
184
  const data = {
@@ -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
  };