@axa-fr/oidc-client 7.27.17 → 7.27.19

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,147 @@
1
+ // Tests covering the AbortError handling added to initWorkerAsync.
2
+ // See https://github.com/AxaFrance/oidc-client/issues/1675
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+
5
+ import { initWorkerAsync, registrationCache } from './initWorker';
6
+ import { OidcConfiguration } from './types';
7
+
8
+ const SERVICE_WORKER_RELATIVE_URL = '/OidcServiceWorker.js';
9
+
10
+ const buildConfiguration = (overrides: Partial<OidcConfiguration> = {}): OidcConfiguration => {
11
+ return {
12
+ client_id: 'test-client',
13
+ redirect_uri: 'http://localhost/callback',
14
+ scope: 'openid',
15
+ authority: 'http://authority',
16
+ service_worker_relative_url: SERVICE_WORKER_RELATIVE_URL,
17
+ service_worker_activate: () => true,
18
+ ...overrides,
19
+ } as OidcConfiguration;
20
+ };
21
+
22
+ const createAbortError = (): DOMException => {
23
+ // DOMException is available in the test environment (jsdom / happy-dom).
24
+ return new DOMException('The operation was aborted.', 'AbortError');
25
+ };
26
+
27
+ describe('initWorkerAsync AbortError handling', () => {
28
+ let originalNavigator: PropertyDescriptor | undefined;
29
+ let originalWindow: PropertyDescriptor | undefined;
30
+ let warnSpy: ReturnType<typeof vi.spyOn>;
31
+
32
+ beforeEach(() => {
33
+ registrationCache.clear();
34
+ originalNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator');
35
+ originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
36
+ Object.defineProperty(globalThis, 'window', {
37
+ configurable: true,
38
+ value: {},
39
+ });
40
+ Object.defineProperty(globalThis, 'navigator', {
41
+ configurable: true,
42
+ value: {
43
+ serviceWorker: {
44
+ register: vi.fn(),
45
+ ready: Promise.resolve({} as ServiceWorkerRegistration),
46
+ controller: null,
47
+ addEventListener: vi.fn(),
48
+ removeEventListener: vi.fn(),
49
+ },
50
+ },
51
+ });
52
+ warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
53
+ });
54
+
55
+ afterEach(() => {
56
+ registrationCache.clear();
57
+ if (originalNavigator) {
58
+ Object.defineProperty(globalThis, 'navigator', originalNavigator);
59
+ } else {
60
+ delete (globalThis as { navigator?: unknown }).navigator;
61
+ }
62
+ if (originalWindow) {
63
+ Object.defineProperty(globalThis, 'window', originalWindow);
64
+ } else {
65
+ delete (globalThis as { window?: unknown }).window;
66
+ }
67
+ vi.restoreAllMocks();
68
+ });
69
+
70
+ it('returns null when a custom service_worker_register rejects with an AbortError', async () => {
71
+ const abortError = createAbortError();
72
+ const service_worker_register = vi.fn(() => Promise.reject(abortError));
73
+
74
+ const result = await initWorkerAsync(
75
+ buildConfiguration({ service_worker_register }),
76
+ 'default',
77
+ );
78
+
79
+ expect(result).toBeNull();
80
+ expect(service_worker_register).toHaveBeenCalledOnce();
81
+ expect(registrationCache.has(SERVICE_WORKER_RELATIVE_URL)).toBe(false);
82
+ expect(warnSpy).toHaveBeenCalled();
83
+ });
84
+
85
+ it('returns null when navigator.serviceWorker.register rejects with an AbortError', async () => {
86
+ const abortError = createAbortError();
87
+ (navigator.serviceWorker.register as unknown as ReturnType<typeof vi.fn>).mockImplementation(
88
+ () => Promise.reject(abortError),
89
+ );
90
+
91
+ const result = await initWorkerAsync(buildConfiguration(), 'default');
92
+
93
+ expect(result).toBeNull();
94
+ // The cache entry for the SW URL should have been cleared so a later retry is possible.
95
+ expect(Array.from(registrationCache.keys())).toHaveLength(0);
96
+ expect(warnSpy).toHaveBeenCalled();
97
+ });
98
+
99
+ it('also treats plain { name: "AbortError" } rejections as aborts', async () => {
100
+ const plainAbort = { name: 'AbortError', message: 'aborted' };
101
+ const service_worker_register = vi.fn(() => Promise.reject(plainAbort));
102
+
103
+ const result = await initWorkerAsync(
104
+ buildConfiguration({ service_worker_register }),
105
+ 'default',
106
+ );
107
+
108
+ expect(result).toBeNull();
109
+ expect(registrationCache.has(SERVICE_WORKER_RELATIVE_URL)).toBe(false);
110
+ });
111
+
112
+ it('allows a subsequent call to retry registration after an AbortError', async () => {
113
+ const abortError = createAbortError();
114
+ const service_worker_register = vi
115
+ .fn()
116
+ .mockImplementationOnce(() => Promise.reject(abortError))
117
+ // Returning a never-resolving promise on the retry is fine for the assertion below:
118
+ // we only need to confirm that the function was called a second time after the
119
+ // failed cache entry was cleared.
120
+ .mockImplementationOnce(() => new Promise<ServiceWorkerRegistration>(() => {}));
121
+
122
+ const configuration = buildConfiguration({ service_worker_register });
123
+
124
+ const firstResult = await initWorkerAsync(configuration, 'default');
125
+ expect(firstResult).toBeNull();
126
+ expect(service_worker_register).toHaveBeenCalledTimes(1);
127
+
128
+ // Kick off the second call (don't await – the mocked promise never resolves).
129
+ void initWorkerAsync(configuration, 'default');
130
+ // Allow the microtask queue to drain so the registration call is observed.
131
+ await Promise.resolve();
132
+
133
+ expect(service_worker_register).toHaveBeenCalledTimes(2);
134
+ });
135
+
136
+ it('propagates non-AbortError rejections to the caller', async () => {
137
+ const genericError = new Error('boom');
138
+ const service_worker_register = vi.fn(() => Promise.reject(genericError));
139
+
140
+ await expect(
141
+ initWorkerAsync(buildConfiguration({ service_worker_register }), 'default'),
142
+ ).rejects.toBe(genericError);
143
+
144
+ // Non-AbortError rejections keep the cache entry in place (existing behavior).
145
+ expect(registrationCache.has(SERVICE_WORKER_RELATIVE_URL)).toBe(true);
146
+ });
147
+ });
@@ -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 = {
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { isOidcStateError, OidcStateError, OidcStateErrorCode } from './oidcStateError';
4
+
5
+ describe('OidcStateError', () => {
6
+ it('exposes the well-known state error codes', () => {
7
+ expect(OidcStateErrorCode.STATE_MISSING).toBe('STATE_MISSING');
8
+ expect(OidcStateErrorCode.STATE_MISMATCH).toBe('STATE_MISMATCH');
9
+ expect(OidcStateErrorCode.NONCE_MISSING).toBe('NONCE_MISSING');
10
+ });
11
+
12
+ it('is an Error subclass with name "OidcStateError"', () => {
13
+ const err = new OidcStateError(OidcStateErrorCode.STATE_MISSING, 'missing');
14
+ expect(err).toBeInstanceOf(Error);
15
+ expect(err).toBeInstanceOf(OidcStateError);
16
+ expect(err.name).toBe('OidcStateError');
17
+ });
18
+
19
+ it('preserves the code and message passed to the constructor', () => {
20
+ const err = new OidcStateError(OidcStateErrorCode.STATE_MISMATCH, 'mismatch happened');
21
+ expect(err.code).toBe('STATE_MISMATCH');
22
+ expect(err.message).toBe('mismatch happened');
23
+ });
24
+
25
+ it('is detectable via isOidcStateError', () => {
26
+ const err = new OidcStateError(OidcStateErrorCode.NONCE_MISSING, 'nonce missing');
27
+ expect(isOidcStateError(err)).toBe(true);
28
+ expect(isOidcStateError(new Error('boom'))).toBe(false);
29
+ expect(isOidcStateError(null)).toBe(false);
30
+ expect(isOidcStateError(undefined)).toBe(false);
31
+ expect(isOidcStateError({ code: 'STATE_MISSING' })).toBe(false);
32
+ });
33
+ });
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Stable, machine-readable codes for OIDC state / nonce failures occurring
3
+ * between the authorization redirect and the callback handling.
4
+ *
5
+ * These codes let consumers react to specific failure modes without having to
6
+ * pattern-match against error message strings.
7
+ */
8
+ export const OidcStateErrorCode = {
9
+ /** No state was found in storage when handling the callback. */
10
+ STATE_MISSING: 'STATE_MISSING',
11
+ /** The state returned by the server does not match the stored one. */
12
+ STATE_MISMATCH: 'STATE_MISMATCH',
13
+ /** No nonce was found in storage when handling the callback / renewal. */
14
+ NONCE_MISSING: 'NONCE_MISSING',
15
+ } as const;
16
+
17
+ // Companion type that mirrors the const above. This is the standard TS
18
+ // "string-enum-like" pattern; we intentionally reuse the same name so that
19
+ // `OidcStateErrorCode` works as both a value namespace and a type for
20
+ // consumers.
21
+ // eslint-disable-next-line @typescript-eslint/no-redeclare
22
+ export type OidcStateErrorCode = (typeof OidcStateErrorCode)[keyof typeof OidcStateErrorCode];
23
+
24
+ /**
25
+ * Typed error thrown when the OIDC login state or nonce is missing,
26
+ * corrupted, or does not match the value returned by the authorization server.
27
+ *
28
+ * Consumers can use `instanceof OidcStateError` and inspect `code` instead of
29
+ * relying on the (unstable) error message text.
30
+ */
31
+ export class OidcStateError extends Error {
32
+ readonly code: OidcStateErrorCode;
33
+
34
+ constructor(code: OidcStateErrorCode, message: string) {
35
+ super(message);
36
+ this.name = 'OidcStateError';
37
+ this.code = code;
38
+
39
+ // Keep prototype chain intact when transpiled to ES5.
40
+ Object.setPrototypeOf(this, OidcStateError.prototype);
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Type guard for {@link OidcStateError}. Useful in callers that want to react
46
+ * specifically to state/nonce failures.
47
+ */
48
+ export const isOidcStateError = (value: unknown): value is OidcStateError => {
49
+ return value instanceof OidcStateError;
50
+ };
@@ -446,6 +446,19 @@ const synchroniseTokensAsync =
446
446
  );
447
447
 
448
448
  if (tokenResponse.success) {
449
+ // Guard against a missing/corrupted nonce reaching id_token validation.
450
+ // Without this guard, accessing `nonce.nonce` would throw a TypeError
451
+ // when the underlying storage has been cleared (private mode, manual
452
+ // clearing, browser eviction). We prefer a defined SESSION_LOST result
453
+ // so silent renew stays non-throwing for consumers.
454
+ // See https://github.com/AxaFrance/oidc-client/issues/1678
455
+ if (!nonce || !nonce.nonce) {
456
+ updateTokens(null);
457
+ oidc.publishEvent(eventNames.refreshTokensAsync_error, {
458
+ message: 'refresh token: nonce missing from storage',
459
+ });
460
+ return { tokens: null, status: 'SESSION_LOST' };
461
+ }
449
462
  const { isValid, reason } = isTokensOidcValid(
450
463
  tokenResponse.data,
451
464
  nonce.nonce,
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export default '7.27.17';
1
+ export default '7.27.19';