Skip to content

Commit

Permalink
feat: ID-1424 ZkEvm background token refresh
Browse files Browse the repository at this point in the history
  • Loading branch information
haydenfowler authored Feb 22, 2024
1 parent 10d32b7 commit 061c45b
Show file tree
Hide file tree
Showing 13 changed files with 225 additions and 177 deletions.
3 changes: 2 additions & 1 deletion packages/passport/sdk/src/Passport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
PassportEventMap,
PassportEvents,
PassportModuleConfiguration,
User,
UserProfile,
} from './types';
import { ConfirmationScreen } from './confirmation';
Expand Down Expand Up @@ -160,7 +161,7 @@ export class Passport {
anonymousId?: string;
}): Promise<UserProfile | null> {
const { useCachedSession = false } = options || {};
let user = null;
let user: User | null = null;
try {
user = await this.authManager.getUser();
} catch (error) {
Expand Down
64 changes: 63 additions & 1 deletion packages/passport/sdk/src/authManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { PassportError, PassportErrorType } from './errors/passportError';
import { PassportConfiguration } from './config';
import { mockUser, mockUserImx, mockUserZkEvm } from './test/mocks';
import { isTokenExpired } from './utils/token';
import { PassportModuleConfiguration } from './types';
import { isUserZkEvm, PassportModuleConfiguration } from './types';

jest.mock('jwt-decode');
jest.mock('./utils/token');
Expand Down Expand Up @@ -416,6 +416,68 @@ 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);

const result = await authManager.getUser(isUserZkEvm);

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

describe('when the user does meet the type assertion', () => {
it('should return the user', async () => {
mockGetUser.mockReturnValue(mockOidcUser);
(jwt_decode as jest.Mock).mockReturnValue({
passport: {
zkevm_eth_address: mockUserZkEvm.zkEvm.ethAddress,
zkevm_user_admin_address: mockUserZkEvm.zkEvm.userAdminAddress,
},
});
(isTokenExpired as jest.Mock).mockReturnValue(false);

const result = await authManager.getUser(isUserZkEvm);

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

describe('when the user is refreshing', () => {
it('should return the refreshed used', async () => {
mockSigninSilent.mockReturnValue(mockOidcUser);

authManager.forceUserRefreshInBackground();

const result = await authManager.getUser();
expect(result).toEqual(mockUser);

expect(mockSigninSilent).toBeCalledTimes(1);
expect(mockGetUser).toBeCalledTimes(0);
});
});
});

describe('getUserZkEvm', () => {
it('should throw an error if no user is returned', async () => {
mockGetUser.mockReturnValue(null);

await expect(() => authManager.getUserZkEvm()).rejects.toThrow(
new Error('Failed to obtain a User with the required ZkEvm attributes'),
);
});
});

describe('getUserImx', () => {
it('should throw an error if no user is returned', async () => {
mockGetUser.mockReturnValue(null);

await expect(() => authManager.getUserImx()).rejects.toThrow(
new Error('Failed to obtain a User with the required IMX attributes'),
);
});
});

describe('getDeviceFlowEndSessionEndpoint', () => {
Expand Down
101 changes: 84 additions & 17 deletions packages/passport/sdk/src/authManager.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import {
ErrorResponse,
ErrorTimeout,
InMemoryWebStorage,
User as OidcUser,
UserManager,
Expand All @@ -11,7 +13,7 @@ import * as crypto from 'crypto';
import jwt_decode from 'jwt-decode';
import { getDetail, Detail } from '@imtbl/metrics';
import { isTokenExpired } from './utils/token';
import { PassportErrorType, withPassportError } from './errors/passportError';
import { PassportError, PassportErrorType, withPassportError } from './errors/passportError';
import {
PassportMetadata,
User,
Expand All @@ -21,6 +23,10 @@ import {
DeviceErrorResponse,
IdTokenPayload,
OidcConfiguration,
UserZkEvm,
isUserZkEvm,
UserImx,
isUserImx,
} from './types';
import { PassportConfiguration } from './config';

Expand Down Expand Up @@ -85,10 +91,10 @@ function sha256(buffer: string) {
export default class AuthManager {
private userManager;

private config: PassportConfiguration;

private deviceCredentialsManager: DeviceCredentialsManager;

private readonly config: PassportConfiguration;

private readonly logoutMode: Exclude<OidcConfiguration['logoutMode'], undefined>;

/**
Expand Down Expand Up @@ -179,7 +185,7 @@ export default class AuthManager {
}

public async getUserOrLogin(): Promise<User> {
let user = null;
let user: User | null = null;
try {
user = await this.getUser();
} catch (err) {
Expand Down Expand Up @@ -370,6 +376,13 @@ export default class AuthManager {
return this.userManager.signoutSilentCallback(url);
}

public forceUserRefreshInBackground() {
this.refreshTokenAndUpdatePromise().catch((error) => {
// eslint-disable-next-line no-console
console.warn('Failed to refresh user token', error);
});
}

public async forceUserRefresh(): Promise<User | null> {
return this.refreshTokenAndUpdatePromise();
}
Expand All @@ -391,7 +404,19 @@ export default class AuthManager {
}
resolve(null);
} catch (err) {
reject(err);
let passportErrorType = PassportErrorType.AUTHENTICATION_ERROR;
let errorMessage = 'Failed to refresh token';

if (err instanceof ErrorTimeout) {
passportErrorType = PassportErrorType.SILENT_LOGIN_ERROR;
} else if (err instanceof ErrorResponse) {
passportErrorType = PassportErrorType.NOT_LOGGED_IN_ERROR;
errorMessage = `${err.message}: ${err.error_description}`;
} else if (err instanceof Error) {
errorMessage = err.message;
}

reject(new PassportError(errorMessage, passportErrorType));
} finally {
this.refreshingPromise = null; // Reset the promise after completion
}
Expand All @@ -401,23 +426,65 @@ export default class AuthManager {
}

/**
* Get the user from the cache or refresh the token if it's expired.
* return null if there's no refresh token.
*
* @param typeAssertion {(user: User) => boolean} - Optional. If provided, then the User will be checked against
* the typeAssertion. If the user meets the requirements, then it will be typed as T and returned. If the User
* does NOT meet the type assertion, then execution will continue, and we will attempt to obtain a User that does
* meet the type assertion.
*
* This function will attempt to obtain a User in the following order:
* 1. If the User is currently refreshing, wait for the refresh to complete.
* 2. Attempt to obtain a User from storage that has not expired.
* 3. Attempt to refresh the User if a refresh token is present.
* 4. Return null if no valid User can be obtained.
*/
public async getUser(): Promise<User | null> {
return withPassportError<User | null>(async () => {
const oidcUser = await this.userManager.getUser();
if (!oidcUser) return null;
public async getUser<T extends User>(
typeAssertion: (user: User) => user is T = (user: User): user is T => true,
): Promise<T | null> {
if (this.refreshingPromise) {
const user = await this.refreshingPromise;
if (user && typeAssertion(user)) {
return user;
}

if (!isTokenExpired(oidcUser)) {
return AuthManager.mapOidcUserToDomainModel(oidcUser);
return null;
}

const oidcUser = await this.userManager.getUser();
if (!oidcUser) return null;

if (!isTokenExpired(oidcUser)) {
const user = AuthManager.mapOidcUserToDomainModel(oidcUser);
if (user && typeAssertion(user)) {
return user;
}
}

if (oidcUser.refresh_token) {
return this.refreshTokenAndUpdatePromise();
if (oidcUser.refresh_token) {
const user = await this.refreshTokenAndUpdatePromise();
if (user && typeAssertion(user)) {
return user;
}
}

return null;
}, PassportErrorType.NOT_LOGGED_IN_ERROR);
return null;
}

public async getUserZkEvm(): Promise<UserZkEvm> {
const user = await this.getUser(isUserZkEvm);
if (!user) {
throw new Error('Failed to obtain a User with the required ZkEvm attributes');
}

return user;
}

public async getUserImx(): Promise<UserImx> {
const user = await this.getUser(isUserImx);
if (!user) {
throw new Error('Failed to obtain a User with the required IMX attributes');
}

return user;
}
}
Loading

0 comments on commit 061c45b

Please sign in to comment.