@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.
- package/README.md +35 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +245 -205
- package/dist/index.umd.cjs +2 -2
- package/dist/login.d.ts.map +1 -1
- package/dist/login.spec.d.ts +2 -0
- package/dist/login.spec.d.ts.map +1 -0
- package/dist/logout.d.ts +14 -0
- package/dist/logout.d.ts.map +1 -1
- package/dist/oidc.d.ts +20 -0
- package/dist/oidc.d.ts.map +1 -1
- package/dist/oidcClient.d.ts +20 -0
- package/dist/oidcClient.d.ts.map +1 -1
- package/dist/oidcStateError.d.ts +33 -0
- package/dist/oidcStateError.d.ts.map +1 -0
- package/dist/oidcStateError.spec.d.ts +2 -0
- package/dist/oidcStateError.spec.d.ts.map +1 -0
- package/dist/version.d.ts +1 -1
- package/package.json +2 -2
- package/src/index.ts +1 -0
- package/src/login.spec.ts +151 -0
- package/src/login.ts +23 -2
- package/src/logout.spec.ts +208 -1
- package/src/logout.ts +149 -80
- package/src/oidc.ts +30 -1
- package/src/oidcClient.ts +26 -0
- package/src/oidcStateError.spec.ts +33 -0
- package/src/oidcStateError.ts +50 -0
- package/src/renewTokens.ts +13 -0
- package/src/version.ts +1 -1
|
@@ -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
|
-
|
|
160
|
-
|
|
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 = {
|
package/src/logout.spec.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
200
|
+
const oidcExtras = extractExtras(extras, ':oidc');
|
|
201
|
+
const noReload = oidcExtras && oidcExtras['no_reload'] === 'true';
|
|
149
202
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
};
|