Skip to content

Commit 48d3ad0

Browse files
authored
feat(oidc-client): Add a 'login_state_storage' option to allow storing the auth flow state separately to tokens (#1646) (release)
* Add a 'login_state_storage' option to allow storing the auth flow state in a separate storage location to tokens * Fix linting/prettier errors
1 parent 51db2c1 commit 48d3ad0

File tree

11 files changed

+317
-22
lines changed

11 files changed

+317
-22
lines changed

FAQ.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,26 @@ export const configuration = {
103103

104104
If your Service Worker file is already registered on your browser, your need to unregister it. For example from chrome dev tool.
105105

106+
## Two tabs race condition during login with localStorage
107+
108+
When `storage: localStorage` is used without a service worker and two tabs initiate the login flow at the same time, they write the authorization state (`state`, `code_verifier`, `nonce`, login params) to the same `localStorage` keys. Each tab can overwrite the other's values, causing the callback in one tab to fail with a state mismatch error.
109+
110+
The recommended fix is to use the `login_state_storage` option to store authorization flow state in `sessionStorage` (which is isolated per tab) while keeping tokens in `localStorage` so they persist across tabs:
111+
112+
```javascript
113+
export const configuration = {
114+
client_id: 'interactive.public.short',
115+
redirect_uri: window.location.origin + '/authentication/callback',
116+
scope: 'openid profile email api offline_access',
117+
authority: 'https://demo.duendesoftware.com',
118+
service_worker_only: false,
119+
storage: localStorage, // tokens persist across tabs
120+
login_state_storage: sessionStorage, // authorization state is isolated per tab
121+
};
122+
```
123+
124+
This option has no effect when using a service worker, which already handles multi-tab isolation internally.
125+
106126
## Tokens are always refreshed in background every seconds
107127

108128
The @axa-fr/oidc-client automatically refreshes tokens in the background.

packages/oidc-client/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# @axa-fr/oidc-client
1+
# @axa-fr/oidc-client
22

33
[![Continuous Integration](https://github.com/AxaGuilDEv/react-oidc/actions/workflows/npm-publish.yml/badge.svg)](https://github.com/AxaGuilDEv/react-oidc/actions/workflows/npm-publish.yml)
44
[![Quality Gate](https://sonarcloud.io/api/project_badges/measure?project=AxaGuilDEv_react-oidc&metric=alert_status)](https://sonarcloud.io/dashboard?id=AxaGuilDEv_react-oidc) [![Reliability](https://sonarcloud.io/api/project_badges/measure?project=AxaGuilDEv_react-oidc&metric=reliability_rating)](https://sonarcloud.io/component_measures?id=AxaGuilDEv_react-oidc&metric=reliability_rating) [![Security](https://sonarcloud.io/api/project_badges/measure?project=AxaGuilDEv_react-oidc&metric=security_rating)](https://sonarcloud.io/component_measures?id=AxaGuilDEv_react-oidc&metric=security_rating) [![Code Corevage](https://sonarcloud.io/api/project_badges/measure?project=AxaGuilDEv_react-oidc&metric=coverage)](https://sonarcloud.io/component_measures?id=AxaGuilDEv_react-oidc&metric=Coverage) [![Twitter](https://img.shields.io/twitter/follow/GuildDEvOpen?style=social)](https://twitter.com/intent/follow?screen_name=GuildDEvOpen)
@@ -210,6 +210,7 @@ const configuration = {
210210
scope: String.isRequired, // oidc scope (you need to set "offline_access")
211211
authority: String.isRequired,
212212
storage: Storage, // Default sessionStorage, you can set localStorage, but it is not secure
213+
login_state_storage: Storage, // Optional. Storage used only for authorization flow state (state, code_verifier, nonce, login params). Defaults to the value of `storage`. Set to sessionStorage when using storage: localStorage to prevent race conditions when multiple tabs start the login flow simultaneously.
213214
authority_configuration: {
214215
// Optional for providers that do not implement OIDC server auto-discovery via a .wellknown URL
215216
authorization_endpoint: String,
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import { beforeEach, describe, expect, it } from 'vitest';
2+
3+
import { initSession } from './initSession';
4+
5+
const makeStorage = (): Storage => {
6+
const store: Record<string, string> = {};
7+
return {
8+
getItem: (key: string) => store[key] ?? null,
9+
setItem: (key: string, value: string) => {
10+
store[key] = value;
11+
},
12+
removeItem: (key: string) => {
13+
delete store[key];
14+
},
15+
clear: () => {
16+
for (const key of Object.keys(store)) {
17+
delete store[key];
18+
}
19+
},
20+
get length() {
21+
return Object.keys(store).length;
22+
},
23+
key: (index: number) => Object.keys(store)[index] ?? null,
24+
[Symbol.iterator]: function* () {
25+
yield* Object.entries(store);
26+
},
27+
} as unknown as Storage;
28+
};
29+
30+
describe('initSession', () => {
31+
const configName = 'default';
32+
33+
describe('single storage (existing behaviour)', () => {
34+
let storage: Storage;
35+
let session: ReturnType<typeof initSession>;
36+
37+
beforeEach(() => {
38+
storage = makeStorage();
39+
session = initSession(configName, storage);
40+
});
41+
42+
it('stores tokens in storage', () => {
43+
session.setTokens({ accessToken: 'at', idToken: 'id' });
44+
expect(storage[`oidc.${configName}`]).toContain('accessToken');
45+
});
46+
47+
it('stores login params in same storage', () => {
48+
session.setLoginParams({ callbackPath: '/callback', extras: null, scope: 'openid' });
49+
expect(storage[`oidc.login.${configName}`]).toBeTruthy();
50+
});
51+
52+
it('stores state in same storage', async () => {
53+
await session.setStateAsync('abc123');
54+
expect(storage[`oidc.state.${configName}`]).toBe('abc123');
55+
});
56+
57+
it('stores code verifier in same storage', async () => {
58+
await session.setCodeVerifierAsync('verifier');
59+
expect(storage[`oidc.code_verifier.${configName}`]).toBe('verifier');
60+
});
61+
62+
it('stores nonce in same storage', async () => {
63+
await session.setNonceAsync({ nonce: 'nonce-value' });
64+
expect(storage[`oidc.nonce.${configName}`]).toBe('nonce-value');
65+
});
66+
67+
it('clearAsync nulls the tokens entry', async () => {
68+
session.setTokens({ accessToken: 'at' });
69+
await session.clearAsync('LOGGED_OUT');
70+
const stored = JSON.parse(storage[`oidc.${configName}`]);
71+
expect(stored.tokens).toBeNull();
72+
});
73+
});
74+
75+
describe('dual storage — login state in separate storage', () => {
76+
let tokenStorage: Storage;
77+
let loginStateStorage: Storage;
78+
let session: ReturnType<typeof initSession>;
79+
80+
beforeEach(() => {
81+
tokenStorage = makeStorage();
82+
loginStateStorage = makeStorage();
83+
session = initSession(configName, tokenStorage, loginStateStorage);
84+
});
85+
86+
it('stores tokens in tokenStorage, not loginStateStorage', () => {
87+
session.setTokens({ accessToken: 'at', idToken: 'id' });
88+
expect(tokenStorage[`oidc.${configName}`]).toContain('accessToken');
89+
expect(loginStateStorage[`oidc.${configName}`]).toBeUndefined();
90+
});
91+
92+
it('stores login params in loginStateStorage, not tokenStorage', () => {
93+
session.setLoginParams({ callbackPath: '/callback', extras: null, scope: 'openid' });
94+
expect(loginStateStorage[`oidc.login.${configName}`]).toBeTruthy();
95+
expect(tokenStorage[`oidc.login.${configName}`]).toBeUndefined();
96+
});
97+
98+
it('stores state in loginStateStorage, not tokenStorage', async () => {
99+
await session.setStateAsync('abc123');
100+
expect(loginStateStorage[`oidc.state.${configName}`]).toBe('abc123');
101+
expect(tokenStorage[`oidc.state.${configName}`]).toBeUndefined();
102+
});
103+
104+
it('retrieves state from loginStateStorage', async () => {
105+
await session.setStateAsync('state-value');
106+
const retrieved = await session.getStateAsync();
107+
expect(retrieved).toBe('state-value');
108+
});
109+
110+
it('stores code verifier in loginStateStorage, not tokenStorage', async () => {
111+
await session.setCodeVerifierAsync('verifier');
112+
expect(loginStateStorage[`oidc.code_verifier.${configName}`]).toBe('verifier');
113+
expect(tokenStorage[`oidc.code_verifier.${configName}`]).toBeUndefined();
114+
});
115+
116+
it('retrieves code verifier from loginStateStorage', async () => {
117+
await session.setCodeVerifierAsync('cv-value');
118+
const retrieved = await session.getCodeVerifierAsync();
119+
expect(retrieved).toBe('cv-value');
120+
});
121+
122+
it('stores nonce in loginStateStorage, not tokenStorage', async () => {
123+
await session.setNonceAsync({ nonce: 'nonce-value' });
124+
expect(loginStateStorage[`oidc.nonce.${configName}`]).toBe('nonce-value');
125+
expect(tokenStorage[`oidc.nonce.${configName}`]).toBeUndefined();
126+
});
127+
128+
it('retrieves nonce from loginStateStorage', async () => {
129+
await session.setNonceAsync({ nonce: 'nonce-value' });
130+
const { nonce } = await session.getNonceAsync();
131+
expect(nonce).toBe('nonce-value');
132+
});
133+
134+
it('stores session_state in tokenStorage, not loginStateStorage', async () => {
135+
await session.setSessionStateAsync('ss-value');
136+
expect(tokenStorage[`oidc.session_state.${configName}`]).toBe('ss-value');
137+
expect(loginStateStorage[`oidc.session_state.${configName}`]).toBeUndefined();
138+
});
139+
140+
it('clearAsync nulls tokens in tokenStorage', async () => {
141+
session.setTokens({ accessToken: 'at' });
142+
await session.clearAsync('LOGGED_OUT');
143+
const stored = JSON.parse(tokenStorage[`oidc.${configName}`]);
144+
expect(stored.tokens).toBeNull();
145+
});
146+
147+
it('clearAsync removes login state keys from loginStateStorage', async () => {
148+
session.setLoginParams({ callbackPath: '/callback', extras: null, scope: 'openid' });
149+
await session.setStateAsync('abc');
150+
await session.setCodeVerifierAsync('verifier');
151+
await session.setNonceAsync({ nonce: 'n' });
152+
153+
await session.clearAsync('LOGGED_OUT');
154+
155+
expect(loginStateStorage[`oidc.login.${configName}`]).toBeUndefined();
156+
expect(loginStateStorage[`oidc.state.${configName}`]).toBeUndefined();
157+
expect(loginStateStorage[`oidc.code_verifier.${configName}`]).toBeUndefined();
158+
expect(loginStateStorage[`oidc.nonce.${configName}`]).toBeUndefined();
159+
});
160+
161+
it('clearAsync does not remove login state from tokenStorage when storages differ', async () => {
162+
tokenStorage[`oidc.login.${configName}`] = 'should-not-be-touched';
163+
await session.clearAsync('LOGGED_OUT');
164+
expect(tokenStorage[`oidc.login.${configName}`]).toBe('should-not-be-touched');
165+
});
166+
});
167+
168+
describe('two-tab isolation', () => {
169+
it('two sessions sharing tokenStorage but with independent loginStateStorages do not overwrite each other', async () => {
170+
const sharedTokenStorage = makeStorage();
171+
const tab1LoginStorage = makeStorage();
172+
const tab2LoginStorage = makeStorage();
173+
174+
const tab1 = initSession(configName, sharedTokenStorage, tab1LoginStorage);
175+
const tab2 = initSession(configName, sharedTokenStorage, tab2LoginStorage);
176+
177+
await tab1.setStateAsync('state-tab1');
178+
await tab2.setStateAsync('state-tab2');
179+
180+
expect(await tab1.getStateAsync()).toBe('state-tab1');
181+
expect(await tab2.getStateAsync()).toBe('state-tab2');
182+
});
183+
184+
it('two sessions sharing tokenStorage but with independent loginStateStorages have isolated nonces', async () => {
185+
const sharedTokenStorage = makeStorage();
186+
const tab1LoginStorage = makeStorage();
187+
const tab2LoginStorage = makeStorage();
188+
189+
const tab1 = initSession(configName, sharedTokenStorage, tab1LoginStorage);
190+
const tab2 = initSession(configName, sharedTokenStorage, tab2LoginStorage);
191+
192+
await tab1.setNonceAsync({ nonce: 'nonce-tab1' });
193+
await tab2.setNonceAsync({ nonce: 'nonce-tab2' });
194+
195+
const { nonce: nonce1 } = await tab1.getNonceAsync();
196+
const { nonce: nonce2 } = await tab2.getNonceAsync();
197+
expect(nonce1).toBe('nonce-tab1');
198+
expect(nonce2).toBe('nonce-tab2');
199+
});
200+
201+
it('token updates from one tab are visible to the other via shared tokenStorage', async () => {
202+
const sharedTokenStorage = makeStorage();
203+
204+
const tab1 = initSession(configName, sharedTokenStorage, makeStorage());
205+
const tab2 = initSession(configName, sharedTokenStorage, makeStorage());
206+
207+
tab1.setTokens({ accessToken: 'new-at', idToken: 'new-id' });
208+
209+
const tokensJson = tab2.getTokens();
210+
expect(tokensJson).not.toBeNull();
211+
const parsed = JSON.parse(tokensJson!);
212+
expect(parsed.tokens.accessToken).toBe('new-at');
213+
});
214+
});
215+
});

packages/oidc-client/src/initSession.ts

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,19 @@
1-
export const initSession = (configurationName, storage = sessionStorage) => {
1+
export const initSession = (
2+
configurationName,
3+
storage = sessionStorage,
4+
loginStateStorage?: Storage,
5+
) => {
6+
const loginStorage = loginStateStorage ?? storage;
7+
28
const clearAsync = status => {
39
storage[`oidc.${configurationName}`] = JSON.stringify({ tokens: null, status });
410
delete storage[`oidc.${configurationName}.userInfo`];
11+
if (loginStateStorage && loginStateStorage !== storage) {
12+
delete loginStorage[`oidc.login.${configurationName}`];
13+
delete loginStorage[`oidc.state.${configurationName}`];
14+
delete loginStorage[`oidc.code_verifier.${configurationName}`];
15+
delete loginStorage[`oidc.nonce.${configurationName}`];
16+
}
517
return Promise.resolve();
618
};
719

@@ -27,7 +39,7 @@ export const initSession = (configurationName, storage = sessionStorage) => {
2739
};
2840

2941
const setNonceAsync = nonce => {
30-
storage[`oidc.nonce.${configurationName}`] = nonce.nonce;
42+
loginStorage[`oidc.nonce.${configurationName}`] = nonce.nonce;
3143
};
3244

3345
const setDemonstratingProofOfPossessionJwkAsync = (jwk: JsonWebKey) => {
@@ -40,7 +52,7 @@ export const initSession = (configurationName, storage = sessionStorage) => {
4052

4153
const getNonceAsync = async () => {
4254
// @ts-ignore
43-
return { nonce: storage[`oidc.nonce.${configurationName}`] };
55+
return { nonce: loginStorage[`oidc.nonce.${configurationName}`] };
4456
};
4557

4658
const setDemonstratingProofOfPossessionNonce = async (dpopNonce: string) => {
@@ -61,10 +73,10 @@ export const initSession = (configurationName, storage = sessionStorage) => {
6173
const getLoginParamsCache = {};
6274
const setLoginParams = data => {
6375
getLoginParamsCache[configurationName] = data;
64-
storage[`oidc.login.${configurationName}`] = JSON.stringify(data);
76+
loginStorage[`oidc.login.${configurationName}`] = JSON.stringify(data);
6577
};
6678
const getLoginParams = () => {
67-
const dataString = storage[`oidc.login.${configurationName}`];
79+
const dataString = loginStorage[`oidc.login.${configurationName}`];
6880

6981
if (!dataString) {
7082
console.warn(
@@ -80,19 +92,19 @@ export const initSession = (configurationName, storage = sessionStorage) => {
8092
};
8193

8294
const getStateAsync = async () => {
83-
return storage[`oidc.state.${configurationName}`];
95+
return loginStorage[`oidc.state.${configurationName}`];
8496
};
8597

8698
const setStateAsync = async (state: string) => {
87-
storage[`oidc.state.${configurationName}`] = state;
99+
loginStorage[`oidc.state.${configurationName}`] = state;
88100
};
89101

90102
const getCodeVerifierAsync = async () => {
91-
return storage[`oidc.code_verifier.${configurationName}`];
103+
return loginStorage[`oidc.code_verifier.${configurationName}`];
92104
};
93105

94106
const setCodeVerifierAsync = async codeVerifier => {
95-
storage[`oidc.code_verifier.${configurationName}`] = codeVerifier;
107+
loginStorage[`oidc.code_verifier.${configurationName}`] = codeVerifier;
96108
};
97109

98110
return {

packages/oidc-client/src/keepSession.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { eventNames } from './events';
1+
import { eventNames } from './events';
22
import { initSession } from './initSession';
33
import { initWorkerAsync } from './initWorker';
44
import Oidc from './oidc';
@@ -62,7 +62,11 @@ export const tryKeepSessionAsync = async (oidc: Oidc) => {
6262
message: 'service worker is not supported by this browser',
6363
});
6464
}
65-
const session = initSession(oidc.configurationName, configuration.storage ?? sessionStorage);
65+
const session = initSession(
66+
oidc.configurationName,
67+
configuration.storage ?? sessionStorage,
68+
configuration.login_state_storage ?? configuration.storage ?? sessionStorage,
69+
);
6670
const { tokens } = await session.initAsync();
6771
if (tokens) {
6872
// @ts-ignore

packages/oidc-client/src/login.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,11 @@ export const defaultLoginAsync =
6969
serviceWorker.startKeepAliveServiceWorker();
7070
storage = serviceWorker;
7171
} else {
72-
const session = initSession(configurationName, configuration.storage ?? sessionStorage);
72+
const session = initSession(
73+
configurationName,
74+
configuration.storage ?? sessionStorage,
75+
configuration.login_state_storage ?? configuration.storage ?? sessionStorage,
76+
);
7377
session.setLoginParams({ callbackPath: url, extras: originExtras, scope: scope });
7478
await session.setNonceAsync(nonce);
7579
storage = session;
@@ -131,6 +135,7 @@ export const loginCallbackAsync =
131135
const session = initSession(
132136
oidc.configurationName,
133137
configuration.storage ?? sessionStorage,
138+
configuration.login_state_storage ?? configuration.storage ?? sessionStorage,
134139
);
135140
await session.setSessionStateAsync(sessionState);
136141
nonceData = await session.getNonceAsync();
@@ -186,7 +191,11 @@ export const loginCallbackAsync =
186191
const jwk = await generateJwkAsync(window)(
187192
configuration.demonstrating_proof_of_possession_configuration.generateKeyAlgorithm,
188193
);
189-
const session = initSession(oidc.configurationName, configuration.storage);
194+
const session = initSession(
195+
oidc.configurationName,
196+
configuration.storage,
197+
configuration.login_state_storage ?? configuration.storage,
198+
);
190199
await session.setDemonstratingProofOfPossessionJwkAsync(jwk);
191200
headersExtras['DPoP'] = await generateJwtDemonstratingProofOfPossessionAsync(window)(
192201
configuration.demonstrating_proof_of_possession_configuration,
@@ -251,7 +260,11 @@ export const loginCallbackAsync =
251260
);
252261
}
253262
} else {
254-
const session = initSession(oidc.configurationName, configuration.storage);
263+
const session = initSession(
264+
oidc.configurationName,
265+
configuration.storage,
266+
configuration.login_state_storage ?? configuration.storage,
267+
);
255268
loginParams = session.getLoginParams();
256269
if (demonstratingProofOfPossessionNonce) {
257270
await session.setDemonstratingProofOfPossessionNonce(demonstratingProofOfPossessionNonce);

0 commit comments

Comments
 (0)