Skip to content

Commit 219490c

Browse files
authored
fix(passport): update access token expiry check (#2657)
1 parent eac9aa4 commit 219490c

File tree

5 files changed

+192
-46
lines changed

5 files changed

+192
-46
lines changed

packages/passport/sdk/src/Passport.int.test.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,17 @@ const redirectUri = 'example.com';
2929
const popupRedirectUri = 'example.com';
3030
const logoutRedirectUri = 'example.com';
3131
const clientId = 'clientId123';
32+
const now = Math.floor(Date.now() / 1000);
33+
const oneHourLater = now + 3600;
34+
35+
const mockValidAccessToken = encode({
36+
iss: 'https://example.auth0.com/',
37+
aud: 'https://api.example.com/',
38+
sub: 'sub123',
39+
iat: now,
40+
exp: oneHourLater,
41+
}, 'secret');
42+
3243
const mockOidcUser = {
3344
profile: {
3445
sub: 'sub123',
@@ -37,13 +48,20 @@ const mockOidcUser = {
3748
},
3849
expired: false,
3950
id_token: mockValidIdToken,
40-
access_token: 'accessToken123',
51+
access_token: mockValidAccessToken,
4152
refresh_token: 'refreshToken123',
4253
};
4354

4455
const mockOidcUserZkevm = {
4556
...mockOidcUser,
4657
id_token: encode({
58+
iss: 'https://example.auth0.com/',
59+
aud: 'clientId123',
60+
sub: 'sub123',
61+
iat: now,
62+
exp: oneHourLater,
63+
64+
nickname: 'test',
4765
passport: {
4866
zkevm_eth_address: mockUserZkEvm.zkEvm.ethAddress,
4967
zkevm_user_admin_address: mockUserZkEvm.zkEvm.userAdminAddress,

packages/passport/sdk/src/authManager.test.ts

Lines changed: 62 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import Overlay from './overlay';
66
import { PassportError, PassportErrorType } from './errors/passportError';
77
import { PassportConfiguration } from './config';
88
import { mockUser, mockUserImx, mockUserZkEvm } from './test/mocks';
9-
import { isTokenExpired } from './utils/token';
9+
import { isAccessTokenExpiredOrExpiring } from './utils/token';
1010
import { isUserZkEvm, PassportModuleConfiguration } from './types';
1111

1212
jest.mock('jwt-decode');
@@ -352,7 +352,7 @@ describe('AuthManager', () => {
352352
describe('when getUser returns a user', () => {
353353
it('should return the user', async () => {
354354
mockGetUser.mockReturnValue(mockOidcUser);
355-
(isTokenExpired as jest.Mock).mockReturnValue(false);
355+
(isAccessTokenExpiredOrExpiring as jest.Mock).mockReturnValue(false);
356356

357357
const result = await authManager.getUserOrLogin();
358358

@@ -364,7 +364,7 @@ describe('AuthManager', () => {
364364
it('calls attempts to sign in the user using signinPopup', async () => {
365365
mockGetUser.mockRejectedValue(new Error(mockErrorMsg));
366366
mockSigninPopup.mockReturnValue(mockOidcUser);
367-
(isTokenExpired as jest.Mock).mockReturnValue(false);
367+
(isAccessTokenExpiredOrExpiring as jest.Mock).mockReturnValue(false);
368368

369369
const result = await authManager.getUserOrLogin();
370370

@@ -510,16 +510,67 @@ describe('AuthManager', () => {
510510
describe('getUser', () => {
511511
it('should retrieve the user from the userManager and return the domain model', async () => {
512512
mockGetUser.mockReturnValue(mockOidcUser);
513-
(isTokenExpired as jest.Mock).mockReturnValue(false);
513+
(isAccessTokenExpiredOrExpiring as jest.Mock).mockReturnValue(false);
514514

515515
const result = await authManager.getUser();
516516

517517
expect(result).toEqual(mockUser);
518518
});
519519

520+
it('should return null when user has no idToken', async () => {
521+
const userWithoutIdToken = { ...mockOidcUser, id_token: undefined, refresh_token: undefined };
522+
mockGetUser.mockReturnValue(userWithoutIdToken);
523+
// Restore real function behavior for this test
524+
(isAccessTokenExpiredOrExpiring as jest.Mock).mockImplementation(
525+
jest.requireActual('./utils/token').isAccessTokenExpiredOrExpiring,
526+
);
527+
528+
const result = await authManager.getUser();
529+
530+
expect(result).toBeNull();
531+
});
532+
533+
it('should refresh token when access token is expired or expiring', async () => {
534+
const userWithExpiringAccessToken = { ...mockOidcUser };
535+
mockGetUser.mockReturnValue(userWithExpiringAccessToken);
536+
(isAccessTokenExpiredOrExpiring as jest.Mock).mockReturnValue(true);
537+
mockSigninSilent.mockResolvedValue(mockOidcUser);
538+
539+
const result = await authManager.getUser();
540+
541+
expect(mockSigninSilent).toBeCalledTimes(1);
542+
expect(result).toEqual(mockUser);
543+
});
544+
545+
it('should handle user with missing access token', async () => {
546+
const userWithoutAccessToken = { ...mockOidcUser, access_token: undefined };
547+
mockGetUser.mockReturnValue(userWithoutAccessToken);
548+
// Restore real function behavior for this test
549+
(isAccessTokenExpiredOrExpiring as jest.Mock).mockImplementation(
550+
jest.requireActual('./utils/token').isAccessTokenExpiredOrExpiring,
551+
);
552+
mockSigninSilent.mockResolvedValue(mockOidcUser);
553+
554+
const result = await authManager.getUser();
555+
556+
expect(mockSigninSilent).toBeCalledTimes(1);
557+
expect(result).toEqual(mockUser);
558+
});
559+
560+
it('should return user directly when access token is not expired or expiring', async () => {
561+
const freshUser = { ...mockOidcUser };
562+
mockGetUser.mockReturnValue(freshUser);
563+
(isAccessTokenExpiredOrExpiring as jest.Mock).mockReturnValue(false);
564+
565+
const result = await authManager.getUser();
566+
567+
expect(mockSigninSilent).not.toHaveBeenCalled();
568+
expect(result).toEqual(mockUser);
569+
});
570+
520571
it('should call signinSilent and returns user when user token is expired with the refresh token', async () => {
521572
mockGetUser.mockReturnValue(mockOidcExpiredUser);
522-
(isTokenExpired as jest.Mock).mockReturnValue(true);
573+
(isAccessTokenExpiredOrExpiring as jest.Mock).mockReturnValue(true);
523574
mockSigninSilent.mockResolvedValue(mockOidcUser);
524575

525576
const result = await authManager.getUser();
@@ -530,7 +581,7 @@ describe('AuthManager', () => {
530581

531582
it('should reject with an error when signinSilent throws a string', async () => {
532583
mockGetUser.mockReturnValue(mockOidcExpiredUser);
533-
(isTokenExpired as jest.Mock).mockReturnValue(true);
584+
(isAccessTokenExpiredOrExpiring as jest.Mock).mockReturnValue(true);
534585
mockSigninSilent.mockRejectedValue('oops');
535586

536587
await expect(() => authManager.getUser()).rejects.toThrow(
@@ -543,7 +594,7 @@ describe('AuthManager', () => {
543594

544595
it('should return null when the user token is expired without refresh token', async () => {
545596
mockGetUser.mockReturnValue(mockOidcExpiredNoRefreshTokenUser);
546-
(isTokenExpired as jest.Mock).mockReturnValue(true);
597+
(isAccessTokenExpiredOrExpiring as jest.Mock).mockReturnValue(true);
547598

548599
const result = await authManager.getUser();
549600

@@ -553,7 +604,7 @@ describe('AuthManager', () => {
553604

554605
it('should return null when the user token is expired with the refresh token, but signinSilent returns null', async () => {
555606
mockGetUser.mockReturnValue(mockOidcExpiredUser);
556-
(isTokenExpired as jest.Mock).mockReturnValue(true);
607+
(isAccessTokenExpiredOrExpiring as jest.Mock).mockReturnValue(true);
557608
mockSigninSilent.mockResolvedValue(null);
558609
const result = await authManager.getUser();
559610

@@ -584,7 +635,7 @@ describe('AuthManager', () => {
584635
describe('when the user is expired', () => {
585636
it('should only call refresh the token once', async () => {
586637
mockGetUser.mockReturnValue(mockOidcExpiredUser);
587-
(isTokenExpired as jest.Mock).mockReturnValue(true);
638+
(isAccessTokenExpiredOrExpiring as jest.Mock).mockReturnValue(true);
588639
mockSigninSilent.mockReturnValue(mockOidcUser);
589640

590641
await Promise.allSettled([
@@ -600,7 +651,7 @@ describe('AuthManager', () => {
600651
describe('when the user does not meet the type assertion', () => {
601652
it('should return null', async () => {
602653
mockGetUser.mockReturnValue(mockOidcUser);
603-
(isTokenExpired as jest.Mock).mockReturnValue(false);
654+
(isAccessTokenExpiredOrExpiring as jest.Mock).mockReturnValue(false);
604655

605656
const result = await authManager.getUser(isUserZkEvm);
606657

@@ -617,7 +668,7 @@ describe('AuthManager', () => {
617668
zkevm_user_admin_address: mockUserZkEvm.zkEvm.userAdminAddress,
618669
},
619670
});
620-
(isTokenExpired as jest.Mock).mockReturnValue(false);
671+
(isAccessTokenExpiredOrExpiring as jest.Mock).mockReturnValue(false);
621672

622673
const result = await authManager.getUser(isUserZkEvm);
623674

packages/passport/sdk/src/authManager.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { getDetail, Detail } from '@imtbl/metrics';
1313
import localForage from 'localforage';
1414
import DeviceCredentialsManager from './storage/device_credentials_manager';
1515
import logger from './utils/logger';
16-
import { isTokenExpired } from './utils/token';
16+
import { isAccessTokenExpiredOrExpiring } from './utils/token';
1717
import { PassportError, PassportErrorType, withPassportError } from './errors/passportError';
1818
import {
1919
DirectLoginMethod,
@@ -475,13 +475,15 @@ export default class AuthManager {
475475
const oidcUser = await this.userManager.getUser();
476476
if (!oidcUser) return null;
477477

478-
if (!isTokenExpired(oidcUser)) {
478+
// if the token is not expired or expiring in 30 seconds or less, return the user
479+
if (!isAccessTokenExpiredOrExpiring(oidcUser)) {
479480
const user = AuthManager.mapOidcUserToDomainModel(oidcUser);
480481
if (user && typeAssertion(user)) {
481482
return user;
482483
}
483484
}
484485

486+
// if the token is expired or expiring in 30 seconds or less, refresh the token
485487
if (oidcUser.refresh_token) {
486488
const user = await this.refreshTokenAndUpdatePromise();
487489
if (user && typeAssertion(user)) {

packages/passport/sdk/src/utils/token.test.ts

Lines changed: 83 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,57 +2,120 @@ import encode from 'jwt-encode';
22
import {
33
User as OidcUser,
44
} from 'oidc-client-ts';
5-
import { isIdTokenExpired, isTokenExpired } from './token';
5+
import { isAccessTokenExpiredOrExpiring } from './token';
66

77
const now = Math.floor(Date.now() / 1000);
88
const oneHourLater = now + 3600;
99
const oneHourBefore = now - 3600;
10+
const fifteenSecondsLater = now + 15;
11+
const fortyFiveSecondsLater = now + 45;
1012

1113
const mockExpiredIdToken = encode({
1214
iat: oneHourBefore,
1315
exp: oneHourBefore,
1416
}, 'secret');
17+
1518
export const mockValidIdToken = encode({
1619
iat: now,
1720
exp: oneHourLater,
1821
}, 'secret');
1922

20-
describe('isIdTokenExpired', () => {
21-
it('should return false if idToken is undefined', () => {
22-
expect(isIdTokenExpired(undefined)).toBe(false);
23-
});
23+
const mockFreshAccessToken = encode({
24+
exp: fortyFiveSecondsLater, // Expires in 45 seconds (outside 30-second buffer)
25+
}, 'secret');
2426

25-
it('should return true if idToken is expired', () => {
26-
expect(isIdTokenExpired(mockExpiredIdToken)).toBe(true);
27+
describe('isAccessTokenExpiredOrExpiring', () => {
28+
it('should return true if access token is missing', () => {
29+
const user = {
30+
id_token: mockValidIdToken,
31+
access_token: undefined,
32+
} as unknown as OidcUser;
33+
expect(isAccessTokenExpiredOrExpiring(user)).toBe(true);
2734
});
2835

29-
it('should return false if idToken is not expired', () => {
30-
expect(isIdTokenExpired(mockValidIdToken)).toBe(false);
36+
it('should return true if id token is missing', () => {
37+
const mockValidAccessToken = encode({
38+
exp: oneHourLater,
39+
}, 'secret');
40+
41+
const user = {
42+
id_token: undefined,
43+
access_token: mockValidAccessToken,
44+
} as unknown as OidcUser;
45+
expect(isAccessTokenExpiredOrExpiring(user)).toBe(true);
3146
});
32-
});
3347

34-
describe('isTokenExpired', () => {
35-
it('should return true if expired is true', () => {
48+
it('should return true if access token is expired', () => {
49+
const mockExpiredAccessToken = encode({
50+
exp: oneHourBefore,
51+
}, 'secret');
52+
3653
const user = {
3754
id_token: mockValidIdToken,
38-
expired: true,
55+
access_token: mockExpiredAccessToken,
3956
} as unknown as OidcUser;
40-
expect(isTokenExpired(user)).toBe(true);
57+
expect(isAccessTokenExpiredOrExpiring(user)).toBe(true);
4158
});
4259

43-
it('should return false if idToken is valid', () => {
60+
it('should return true if access token is expiring within 30 seconds', () => {
61+
const mockExpiringAccessToken = encode({
62+
exp: fifteenSecondsLater, // Expires in 15 seconds (within 30-second buffer)
63+
}, 'secret');
64+
4465
const user = {
4566
id_token: mockValidIdToken,
46-
expired: false,
67+
access_token: mockExpiringAccessToken,
4768
} as unknown as OidcUser;
48-
expect(isTokenExpired(user)).toBe(false);
69+
expect(isAccessTokenExpiredOrExpiring(user)).toBe(true);
4970
});
5071

51-
it('should return true idToken is expired', () => {
72+
it('should return true if access token is valid but id token is expired', () => {
5273
const user = {
5374
id_token: mockExpiredIdToken,
54-
expired: false,
75+
access_token: mockFreshAccessToken,
76+
} as unknown as OidcUser;
77+
expect(isAccessTokenExpiredOrExpiring(user)).toBe(true);
78+
});
79+
80+
it('should return true if access token is valid but id token is expiring within 30 seconds', () => {
81+
const expiringIdToken = encode({
82+
iat: now,
83+
exp: now + 15, // Expires in 15 seconds
84+
}, 'secret');
85+
86+
const user = {
87+
id_token: expiringIdToken,
88+
access_token: mockFreshAccessToken,
89+
} as unknown as OidcUser;
90+
expect(isAccessTokenExpiredOrExpiring(user)).toBe(true);
91+
});
92+
93+
it('should return false if both tokens are valid and not expiring', () => {
94+
const user = {
95+
id_token: mockValidIdToken,
96+
access_token: mockFreshAccessToken,
97+
} as unknown as OidcUser;
98+
expect(isAccessTokenExpiredOrExpiring(user)).toBe(false);
99+
});
100+
101+
it('should return true if access token is malformed', () => {
102+
const user = {
103+
id_token: mockValidIdToken,
104+
access_token: 'invalid-jwt-token',
105+
} as unknown as OidcUser;
106+
expect(isAccessTokenExpiredOrExpiring(user)).toBe(true);
107+
});
108+
109+
it('should return true if access token has no exp claim (security vulnerability)', () => {
110+
const accessTokenWithoutExp = encode({
111+
iat: now,
112+
sub: 'user123',
113+
}, 'secret');
114+
115+
const user = {
116+
id_token: mockValidIdToken,
117+
access_token: accessTokenWithoutExp,
55118
} as unknown as OidcUser;
56-
expect(isTokenExpired(user)).toBe(true);
119+
expect(isAccessTokenExpiredOrExpiring(user)).toBe(true);
57120
});
58121
});

0 commit comments

Comments
 (0)