Skip to content

Commit 4149ac4

Browse files
feat: enforce IPSIE session_expiry ceiling on local session lifetime
- Add session_expiry?: number to IdToken type - Add SESSION_EXPIRY_LEEWAY_SECONDS = 30 to constants - Reject at login if session_expiry <= iat - Enforce ceiling on every session read: getUser, getIdTokenClaims, and getTokenSilently all return undefined once ceiling is reached - Skip /oauth/token refresh-token call when ceiling has passed - Pin ceiling at login value across refreshes — refresh response is authoritative for tokens only, never for the ceiling
1 parent 91332b6 commit 4149ac4

4 files changed

Lines changed: 406 additions & 1 deletion

File tree

Lines changed: 338 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,338 @@
1+
import { verify } from '../../src/jwt';
2+
import { MessageChannel } from 'worker_threads';
3+
import * as utils from '../../src/utils';
4+
import * as scope from '../../src/scope';
5+
import { expect } from '@jest/globals';
6+
7+
import { loginWithRedirectFn, setupFn } from './helpers';
8+
import { TEST_CODE_CHALLENGE, nowSeconds } from '../constants';
9+
10+
jest.mock('es-cookie');
11+
jest.mock('../../src/jwt');
12+
jest.mock('../../src/worker/token.worker');
13+
14+
const mockWindow = <any>global;
15+
const mockFetch = <jest.Mock>mockWindow.fetch;
16+
const mockVerify = <jest.Mock>verify;
17+
18+
jest
19+
.spyOn(utils, 'bufferToBase64UrlEncoded')
20+
.mockReturnValue(TEST_CODE_CHALLENGE);
21+
22+
const setup = setupFn(mockVerify);
23+
const loginWithRedirect = loginWithRedirectFn(mockWindow, mockFetch);
24+
25+
describe('Auth0Client', () => {
26+
const oldWindowLocation = window.location;
27+
28+
beforeEach(() => {
29+
delete window.location;
30+
window.location = Object.defineProperties(
31+
{},
32+
{
33+
...Object.getOwnPropertyDescriptors(oldWindowLocation),
34+
assign: {
35+
configurable: true,
36+
value: jest.fn()
37+
}
38+
}
39+
) as Location;
40+
41+
mockWindow.open = jest.fn();
42+
mockWindow.addEventListener = jest.fn();
43+
mockWindow.removeEventListener = jest.fn();
44+
45+
mockWindow.crypto = {
46+
subtle: { digest: () => 'foo' },
47+
getRandomValues() {
48+
return '123';
49+
}
50+
};
51+
52+
mockWindow.MessageChannel = MessageChannel;
53+
mockWindow.Worker = {};
54+
55+
jest.spyOn(scope, 'getUniqueScopes');
56+
sessionStorage.clear();
57+
});
58+
59+
afterEach(() => {
60+
mockFetch.mockReset();
61+
jest.clearAllMocks();
62+
window.location = oldWindowLocation;
63+
});
64+
65+
describe('session_expiry', () => {
66+
describe('login-time validation', () => {
67+
it('throws if session_expiry equals iat', async () => {
68+
const iat = nowSeconds();
69+
const auth0 = setup({}, { iat, session_expiry: iat });
70+
71+
await expect(loginWithRedirect(auth0)).rejects.toThrow(
72+
'Invalid session_expiry: session ceiling is before or at the token issue time.'
73+
);
74+
});
75+
76+
it('throws if session_expiry is before iat', async () => {
77+
const iat = nowSeconds();
78+
const auth0 = setup({}, { iat, session_expiry: iat - 1 });
79+
80+
await expect(loginWithRedirect(auth0)).rejects.toThrow(
81+
'Invalid session_expiry: session ceiling is before or at the token issue time.'
82+
);
83+
});
84+
85+
it('succeeds if session_expiry is after iat', async () => {
86+
const iat = nowSeconds();
87+
const auth0 = setup({}, { iat, session_expiry: iat + 3600 });
88+
89+
await expect(loginWithRedirect(auth0)).resolves.not.toThrow();
90+
});
91+
92+
it('succeeds when session_expiry is absent', async () => {
93+
const auth0 = setup();
94+
95+
await expect(loginWithRedirect(auth0)).resolves.not.toThrow();
96+
});
97+
});
98+
99+
describe('getTokenSilently ceiling enforcement', () => {
100+
it('returns a token when no session_expiry claim is present', async () => {
101+
const auth0 = setup();
102+
await loginWithRedirect(auth0);
103+
mockFetch.mockReset();
104+
105+
const token = await auth0.getTokenSilently();
106+
107+
expect(token).toBeTruthy();
108+
});
109+
110+
it('returns a token when the ceiling has not been reached', async () => {
111+
const iat = nowSeconds();
112+
const sessionExpiry = iat + 3600;
113+
const auth0 = setup(
114+
{ nowProvider: () => (sessionExpiry - 100) * 1000 },
115+
{ iat, session_expiry: sessionExpiry }
116+
);
117+
await loginWithRedirect(auth0);
118+
mockFetch.mockReset();
119+
120+
const token = await auth0.getTokenSilently();
121+
122+
expect(token).toBeTruthy();
123+
});
124+
125+
it('returns undefined when the ceiling has been breached', async () => {
126+
const iat = nowSeconds();
127+
const sessionExpiry = iat + 3600;
128+
const auth0 = setup(
129+
{ nowProvider: () => (sessionExpiry + 100) * 1000 },
130+
{ iat, session_expiry: sessionExpiry }
131+
);
132+
await loginWithRedirect(auth0);
133+
mockFetch.mockReset();
134+
135+
const token = await auth0.getTokenSilently();
136+
137+
expect(token).toBeUndefined();
138+
});
139+
140+
it('returns undefined when within the 30s leeway window', async () => {
141+
const iat = nowSeconds();
142+
const sessionExpiry = iat + 3600;
143+
// nowSeconds = sessionExpiry - 15, which is >= sessionExpiry - 30
144+
const auth0 = setup(
145+
{ nowProvider: () => (sessionExpiry - 15) * 1000 },
146+
{ iat, session_expiry: sessionExpiry }
147+
);
148+
await loginWithRedirect(auth0);
149+
mockFetch.mockReset();
150+
151+
const token = await auth0.getTokenSilently();
152+
153+
expect(token).toBeUndefined();
154+
});
155+
156+
it('returns a token when just outside the 30s leeway window', async () => {
157+
const iat = nowSeconds();
158+
const sessionExpiry = iat + 3600;
159+
// nowSeconds = sessionExpiry - 31, which is < sessionExpiry - 30
160+
const auth0 = setup(
161+
{ nowProvider: () => (sessionExpiry - 31) * 1000 },
162+
{ iat, session_expiry: sessionExpiry }
163+
);
164+
await loginWithRedirect(auth0);
165+
mockFetch.mockReset();
166+
167+
const token = await auth0.getTokenSilently();
168+
169+
expect(token).toBeTruthy();
170+
});
171+
172+
it('does not reach the network on breach', async () => {
173+
const iat = nowSeconds();
174+
const sessionExpiry = iat + 3600;
175+
const auth0 = setup(
176+
{ nowProvider: () => (sessionExpiry + 100) * 1000 },
177+
{ iat, session_expiry: sessionExpiry }
178+
);
179+
await loginWithRedirect(auth0);
180+
mockFetch.mockReset();
181+
182+
await auth0.getTokenSilently();
183+
184+
expect(mockFetch).not.toHaveBeenCalled();
185+
});
186+
187+
it('getUser returns undefined after breach', async () => {
188+
const iat = nowSeconds();
189+
const sessionExpiry = iat + 3600;
190+
const auth0 = setup(
191+
{ nowProvider: () => (sessionExpiry + 100) * 1000 },
192+
{ iat, session_expiry: sessionExpiry }
193+
);
194+
await loginWithRedirect(auth0);
195+
mockFetch.mockReset();
196+
197+
await auth0.getTokenSilently();
198+
const user = await auth0.getUser();
199+
200+
expect(user).toBeUndefined();
201+
});
202+
203+
it('ceiling is enforced after a token refresh that omits the claim', async () => {
204+
const iat = nowSeconds();
205+
const sessionExpiry = iat + 3600;
206+
let currentTime = (iat + 100) * 1000;
207+
208+
const auth0 = setup(
209+
{ nowProvider: () => currentTime },
210+
{ iat, session_expiry: sessionExpiry }
211+
);
212+
213+
// Initial login — ID token carries session_expiry
214+
await loginWithRedirect(auth0);
215+
216+
// Simulate a refresh whose ID token omits session_expiry
217+
mockVerify.mockReturnValueOnce({
218+
claims: { sub: 'me', exp: Date.now() / 1000 + 86400, iat },
219+
user: { sub: 'me' }
220+
});
221+
await loginWithRedirect(auth0);
222+
223+
// Advance clock past the original ceiling
224+
currentTime = (sessionExpiry + 100) * 1000;
225+
mockFetch.mockReset();
226+
227+
const token = await auth0.getTokenSilently();
228+
expect(token).toBeUndefined();
229+
});
230+
231+
it('original ceiling wins when a refresh re-emits a later session_expiry', async () => {
232+
const iat = nowSeconds();
233+
const sessionExpiry = iat + 3600;
234+
let currentTime = (iat + 100) * 1000;
235+
236+
const auth0 = setup(
237+
{ nowProvider: () => currentTime },
238+
{ iat, session_expiry: sessionExpiry }
239+
);
240+
241+
// Initial login — ceiling at iat + 3600
242+
await loginWithRedirect(auth0);
243+
244+
// Refresh re-emits a later session_expiry (iat + 7200) — must be ignored
245+
mockVerify.mockReturnValueOnce({
246+
claims: {
247+
sub: 'me',
248+
exp: Date.now() / 1000 + 86400,
249+
iat,
250+
session_expiry: iat + 7200
251+
},
252+
user: { sub: 'me' }
253+
});
254+
await loginWithRedirect(auth0);
255+
256+
// Advance clock past the original ceiling but before the extended one
257+
currentTime = (sessionExpiry + 100) * 1000;
258+
mockFetch.mockReset();
259+
260+
// Original ceiling must still be enforced
261+
const token = await auth0.getTokenSilently();
262+
expect(token).toBeUndefined();
263+
});
264+
265+
it('does not invent a ceiling when session_expiry was never present', async () => {
266+
// Two logins, neither carries session_expiry
267+
const auth0 = setup();
268+
await loginWithRedirect(auth0);
269+
await loginWithRedirect(auth0);
270+
mockFetch.mockReset();
271+
272+
const token = await auth0.getTokenSilently();
273+
expect(token).toBeTruthy();
274+
});
275+
276+
it('getUser returns undefined on breach without a prior getTokenSilently call', async () => {
277+
const iat = nowSeconds();
278+
const sessionExpiry = iat + 3600;
279+
const auth0 = setup(
280+
{ nowProvider: () => (sessionExpiry + 100) * 1000 },
281+
{ iat, session_expiry: sessionExpiry }
282+
);
283+
await loginWithRedirect(auth0);
284+
mockFetch.mockReset();
285+
286+
// call getUser directly — no getTokenSilently first
287+
const user = await auth0.getUser();
288+
expect(user).toBeUndefined();
289+
});
290+
291+
it('isAuthenticated returns false on breach without a prior getTokenSilently call', async () => {
292+
const iat = nowSeconds();
293+
const sessionExpiry = iat + 3600;
294+
const auth0 = setup(
295+
{ nowProvider: () => (sessionExpiry + 100) * 1000 },
296+
{ iat, session_expiry: sessionExpiry }
297+
);
298+
await loginWithRedirect(auth0);
299+
mockFetch.mockReset();
300+
301+
// call isAuthenticated directly — no getTokenSilently first
302+
const authenticated = await auth0.isAuthenticated();
303+
expect(authenticated).toBe(false);
304+
});
305+
306+
it('getIdTokenClaims returns undefined on breach without a prior getTokenSilently call', async () => {
307+
const iat = nowSeconds();
308+
const sessionExpiry = iat + 3600;
309+
const auth0 = setup(
310+
{ nowProvider: () => (sessionExpiry + 100) * 1000 },
311+
{ iat, session_expiry: sessionExpiry }
312+
);
313+
await loginWithRedirect(auth0);
314+
mockFetch.mockReset();
315+
316+
// call getIdTokenClaims directly — no getTokenSilently first
317+
const claims = await auth0.getIdTokenClaims();
318+
expect(claims).toBeUndefined();
319+
});
320+
321+
it('isAuthenticated returns false after breach', async () => {
322+
const iat = nowSeconds();
323+
const sessionExpiry = iat + 3600;
324+
const auth0 = setup(
325+
{ nowProvider: () => (sessionExpiry + 100) * 1000 },
326+
{ iat, session_expiry: sessionExpiry }
327+
);
328+
await loginWithRedirect(auth0);
329+
mockFetch.mockReset();
330+
331+
await auth0.getTokenSilently();
332+
const authenticated = await auth0.isAuthenticated();
333+
334+
expect(authenticated).toBe(false);
335+
});
336+
});
337+
});
338+
});

0 commit comments

Comments
 (0)