Skip to content

fix(passport): update access token expiry check #2657

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Jul 18, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion packages/passport/sdk/src/Passport.int.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,17 @@ const redirectUri = 'example.com';
const popupRedirectUri = 'example.com';
const logoutRedirectUri = 'example.com';
const clientId = 'clientId123';
const now = Math.floor(Date.now() / 1000);
const oneHourLater = now + 3600;

const mockValidAccessToken = encode({
iss: 'https://example.auth0.com/',
aud: 'https://api.example.com/',
sub: 'sub123',
iat: now,
exp: oneHourLater,
}, 'secret');

const mockOidcUser = {
profile: {
sub: 'sub123',
Expand All @@ -37,13 +48,20 @@ const mockOidcUser = {
},
expired: false,
id_token: mockValidIdToken,
access_token: 'accessToken123',
access_token: mockValidAccessToken,
refresh_token: 'refreshToken123',
};

const mockOidcUserZkevm = {
...mockOidcUser,
id_token: encode({
iss: 'https://example.auth0.com/',
aud: 'clientId123',
sub: 'sub123',
iat: now,
exp: oneHourLater,
email: '[email protected]',
nickname: 'test',
passport: {
zkevm_eth_address: mockUserZkEvm.zkEvm.ethAddress,
zkevm_user_admin_address: mockUserZkEvm.zkEvm.userAdminAddress,
Expand Down
73 changes: 62 additions & 11 deletions packages/passport/sdk/src/authManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import Overlay from './overlay';
import { PassportError, PassportErrorType } from './errors/passportError';
import { PassportConfiguration } from './config';
import { mockUser, mockUserImx, mockUserZkEvm } from './test/mocks';
import { isTokenExpired } from './utils/token';
import { isAccessTokenExpiredOrExpiring } from './utils/token';
import { isUserZkEvm, PassportModuleConfiguration } from './types';

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

const result = await authManager.getUserOrLogin();

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

const result = await authManager.getUserOrLogin();

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

const result = await authManager.getUser();

expect(result).toEqual(mockUser);
});

it('should return null when user has no idToken', async () => {
const userWithoutIdToken = { ...mockOidcUser, id_token: undefined, refresh_token: undefined };
mockGetUser.mockReturnValue(userWithoutIdToken);
// Restore real function behavior for this test
(isAccessTokenExpiredOrExpiring as jest.Mock).mockImplementation(
jest.requireActual('./utils/token').isAccessTokenExpiredOrExpiring,
);

const result = await authManager.getUser();

expect(result).toBeNull();
});

it('should refresh token when access token is expired or expiring', async () => {
const userWithExpiringAccessToken = { ...mockOidcUser };
mockGetUser.mockReturnValue(userWithExpiringAccessToken);
(isAccessTokenExpiredOrExpiring as jest.Mock).mockReturnValue(true);
mockSigninSilent.mockResolvedValue(mockOidcUser);

const result = await authManager.getUser();

expect(mockSigninSilent).toBeCalledTimes(1);
expect(result).toEqual(mockUser);
});

it('should handle user with missing access token', async () => {
const userWithoutAccessToken = { ...mockOidcUser, access_token: undefined };
mockGetUser.mockReturnValue(userWithoutAccessToken);
// Restore real function behavior for this test
(isAccessTokenExpiredOrExpiring as jest.Mock).mockImplementation(
jest.requireActual('./utils/token').isAccessTokenExpiredOrExpiring,
);
mockSigninSilent.mockResolvedValue(mockOidcUser);

const result = await authManager.getUser();

expect(mockSigninSilent).toBeCalledTimes(1);
expect(result).toEqual(mockUser);
});

it('should return user directly when access token is not expired or expiring', async () => {
const freshUser = { ...mockOidcUser };
mockGetUser.mockReturnValue(freshUser);
(isAccessTokenExpiredOrExpiring as jest.Mock).mockReturnValue(false);

const result = await authManager.getUser();

expect(mockSigninSilent).not.toHaveBeenCalled();
expect(result).toEqual(mockUser);
});

it('should call signinSilent and returns user when user token is expired with the refresh token', async () => {
mockGetUser.mockReturnValue(mockOidcExpiredUser);
(isTokenExpired as jest.Mock).mockReturnValue(true);
(isAccessTokenExpiredOrExpiring as jest.Mock).mockReturnValue(true);
mockSigninSilent.mockResolvedValue(mockOidcUser);

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

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

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

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

const result = await authManager.getUser();

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

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

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

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

const result = await authManager.getUser(isUserZkEvm);

Expand All @@ -617,7 +668,7 @@ describe('AuthManager', () => {
zkevm_user_admin_address: mockUserZkEvm.zkEvm.userAdminAddress,
},
});
(isTokenExpired as jest.Mock).mockReturnValue(false);
(isAccessTokenExpiredOrExpiring as jest.Mock).mockReturnValue(false);

const result = await authManager.getUser(isUserZkEvm);

Expand Down
6 changes: 4 additions & 2 deletions packages/passport/sdk/src/authManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { getDetail, Detail } from '@imtbl/metrics';
import localForage from 'localforage';
import DeviceCredentialsManager from './storage/device_credentials_manager';
import logger from './utils/logger';
import { isTokenExpired } from './utils/token';
import { isAccessTokenExpiredOrExpiring } from './utils/token';
import { PassportError, PassportErrorType, withPassportError } from './errors/passportError';
import {
PassportMetadata,
Expand Down Expand Up @@ -458,13 +458,15 @@ export default class AuthManager {
const oidcUser = await this.userManager.getUser();
if (!oidcUser) return null;

if (!isTokenExpired(oidcUser)) {
// if the token is not expired or expiring in 30 seconds or less, return the user
if (!isAccessTokenExpiredOrExpiring(oidcUser)) {
const user = AuthManager.mapOidcUserToDomainModel(oidcUser);
if (user && typeAssertion(user)) {
return user;
}
}

// if the token is expired or expiring in 30 seconds or less, refresh the token
if (oidcUser.refresh_token) {
const user = await this.refreshTokenAndUpdatePromise();
if (user && typeAssertion(user)) {
Expand Down
103 changes: 83 additions & 20 deletions packages/passport/sdk/src/utils/token.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,57 +2,120 @@ import encode from 'jwt-encode';
import {
User as OidcUser,
} from 'oidc-client-ts';
import { isIdTokenExpired, isTokenExpired } from './token';
import { isAccessTokenExpiredOrExpiring } from './token';

const now = Math.floor(Date.now() / 1000);
const oneHourLater = now + 3600;
const oneHourBefore = now - 3600;
const fifteenSecondsLater = now + 15;
const fortyFiveSecondsLater = now + 45;

const mockExpiredIdToken = encode({
iat: oneHourBefore,
exp: oneHourBefore,
}, 'secret');

export const mockValidIdToken = encode({
iat: now,
exp: oneHourLater,
}, 'secret');

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

it('should return true if idToken is expired', () => {
expect(isIdTokenExpired(mockExpiredIdToken)).toBe(true);
describe('isAccessTokenExpiredOrExpiring', () => {
it('should return true if access token is missing', () => {
const user = {
id_token: mockValidIdToken,
access_token: undefined,
} as unknown as OidcUser;
expect(isAccessTokenExpiredOrExpiring(user)).toBe(true);
});

it('should return false if idToken is not expired', () => {
expect(isIdTokenExpired(mockValidIdToken)).toBe(false);
it('should return true if id token is missing', () => {
const mockValidAccessToken = encode({
exp: oneHourLater,
}, 'secret');

const user = {
id_token: undefined,
access_token: mockValidAccessToken,
} as unknown as OidcUser;
expect(isAccessTokenExpiredOrExpiring(user)).toBe(true);
});
});

describe('isTokenExpired', () => {
it('should return true if expired is true', () => {
it('should return true if access token is expired', () => {
const mockExpiredAccessToken = encode({
exp: oneHourBefore,
}, 'secret');

const user = {
id_token: mockValidIdToken,
expired: true,
access_token: mockExpiredAccessToken,
} as unknown as OidcUser;
expect(isTokenExpired(user)).toBe(true);
expect(isAccessTokenExpiredOrExpiring(user)).toBe(true);
});

it('should return false if idToken is valid', () => {
it('should return true if access token is expiring within 30 seconds', () => {
const mockExpiringAccessToken = encode({
exp: fifteenSecondsLater, // Expires in 15 seconds (within 30-second buffer)
}, 'secret');

const user = {
id_token: mockValidIdToken,
expired: false,
access_token: mockExpiringAccessToken,
} as unknown as OidcUser;
expect(isTokenExpired(user)).toBe(false);
expect(isAccessTokenExpiredOrExpiring(user)).toBe(true);
});

it('should return true idToken is expired', () => {
it('should return true if access token is valid but id token is expired', () => {
const user = {
id_token: mockExpiredIdToken,
expired: false,
access_token: mockFreshAccessToken,
} as unknown as OidcUser;
expect(isAccessTokenExpiredOrExpiring(user)).toBe(true);
});

it('should return true if access token is valid but id token is expiring within 30 seconds', () => {
const expiringIdToken = encode({
iat: now,
exp: now + 15, // Expires in 15 seconds
}, 'secret');

const user = {
id_token: expiringIdToken,
access_token: mockFreshAccessToken,
} as unknown as OidcUser;
expect(isAccessTokenExpiredOrExpiring(user)).toBe(true);
});

it('should return false if both tokens are valid and not expiring', () => {
const user = {
id_token: mockValidIdToken,
access_token: mockFreshAccessToken,
} as unknown as OidcUser;
expect(isAccessTokenExpiredOrExpiring(user)).toBe(false);
});

it('should return true if access token is malformed', () => {
const user = {
id_token: mockValidIdToken,
access_token: 'invalid-jwt-token',
} as unknown as OidcUser;
expect(isAccessTokenExpiredOrExpiring(user)).toBe(true);
});

it('should return true if access token has no exp claim (security vulnerability)', () => {
const accessTokenWithoutExp = encode({
iat: now,
sub: 'user123',
}, 'secret');

const user = {
id_token: mockValidIdToken,
access_token: accessTokenWithoutExp,
} as unknown as OidcUser;
expect(isTokenExpired(user)).toBe(true);
expect(isAccessTokenExpiredOrExpiring(user)).toBe(true);
});
});
Loading