Skip to content
Open
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
3 changes: 3 additions & 0 deletions packages/profile-sync-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added

- Add validated MFA domain types and structured `MfaError` classes with a serialization-safe `mfaCode` ([#10264](https://github.com/MetaMask/core/pull/10264))
- Add `AuthenticationController:getCachedBearerToken` action and `getCachedBearerToken()` method ([#10283](https://github.com/MetaMask/core/pull/10283))
- Returns the cached access token synchronously, and never logs in. It returns `undefined` when the wallet is locked, when the SRP has no session, and when the token is past 90% of its lifetime, which is the point where `getBearerToken` replaces it.
- Callers on hot paths, such as per-RPC-request code, use this so a request never triggers a login or waits on one.
- Add `rampsOrders` to `USER_STORAGE_FEATURE_NAMES` ([#10227](https://github.com/MetaMask/core/pull/10227))

## [32.1.1]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,23 @@ export type AuthenticationControllerGetBearerTokenAction = {
handler: AuthenticationController['getBearerToken'];
};

/**
* Returns the cached access token for the specified SRP, without logging in.
*
* Callers on hot paths use this instead of `getBearerToken` so that a request
* never triggers a login or waits on one. `undefined` means there is no token
* to present right now: the wallet is locked, the SRP has no session, or the
* cached token is close enough to expiry that `getBearerToken` would replace
* it. Refreshing is left to whichever caller next uses `getBearerToken`.
*
* @param entropySourceId - The entropy source ID. Omit for the primary SRP.
* @returns The OIDC access token, or `undefined`.
*/
export type AuthenticationControllerGetCachedBearerTokenAction = {
type: `AuthenticationController:getCachedBearerToken`;
handler: AuthenticationController['getCachedBearerToken'];
};

/**
* Returns the cached session profile, logging in if no session exists.
*
Expand Down Expand Up @@ -142,6 +159,7 @@ export type AuthenticationControllerMethodActions =
| AuthenticationControllerPerformSignOutAction
| AuthenticationControllerClearStateAction
| AuthenticationControllerGetBearerTokenAction
| AuthenticationControllerGetCachedBearerTokenAction
| AuthenticationControllerGetSessionProfileAction
| AuthenticationControllerRefreshCanonicalProfileIdAction
| AuthenticationControllerGetUserProfileLineageAction
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1709,6 +1709,64 @@ describe('AuthenticationController', () => {
});
});

describe('getCachedBearerToken', () => {
it('returns the cached access token', () => {
const { messenger } = createMockAuthenticationMessenger();
const state = mockSignedInState();
const controller = new AuthenticationController({
messenger,
state,
metametrics: createMockAuthMetaMetrics(),
});

expect(controller.getCachedBearerToken()).toBe(
state.srpSessionData?.[MOCK_ENTROPY_SOURCE_IDS[0]]?.token.accessToken,
);

for (const id of MOCK_ENTROPY_SOURCE_IDS) {
expect(controller.getCachedBearerToken(id)).toBe(
state.srpSessionData?.[id]?.token.accessToken,
);
}
});

it('returns undefined when there is no session', () => {
const { messenger } = createMockAuthenticationMessenger();
const controller = new AuthenticationController({
messenger,
metametrics: createMockAuthMetaMetrics(),
});

expect(controller.getCachedBearerToken()).toBeUndefined();
});

it('returns undefined when the wallet is locked', () => {
const { messenger, mockKeyringControllerGetState } =
createMockAuthenticationMessenger();
mockKeyringControllerGetState.mockReturnValue({ isUnlocked: false });
const controller = new AuthenticationController({
messenger,
state: mockSignedInState(),
metametrics: createMockAuthMetaMetrics(),
});

expect(controller.getCachedBearerToken()).toBeUndefined();
});

it('returns undefined when the token is past 90% of its lifetime', () => {
const { messenger } = createMockAuthenticationMessenger();
const state = mockSignedInState({ expiresIn: 3600 });
jest.spyOn(Date, 'now').mockReturnValue(3600 * 1000 * 0.9);
const controller = new AuthenticationController({
messenger,
state,
metametrics: createMockAuthMetaMetrics(),
});

expect(controller.getCachedBearerToken()).toBeUndefined();
});
});

describe('getBearerToken', () => {
it('should throw error if not logged in', async () => {
const metametrics = createMockAuthMetaMetrics();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
JwtBearerAuth,
PairConflictError,
} from '../../sdk/index.js';
import { isFreshLoginResponse } from '../../sdk/utils/is-fresh-login-response.js';
import type { MetaMetricsAuth } from '../../shared/types/services.js';
import {
getHdKeyringEntropySourceIds,
Expand Down Expand Up @@ -147,6 +148,7 @@ const MESSENGER_EXPOSED_METHODS = [
'performSignIn',
'performSignOut',
'getBearerToken',
'getCachedBearerToken',
'getSessionProfile',
'refreshCanonicalProfileId',
'getUserProfileLineage',
Expand Down Expand Up @@ -762,6 +764,33 @@ export class AuthenticationController extends BaseController<
return await this.#auth.getAccessToken(resolvedId);
}

/**
* Returns the cached access token for the specified SRP, without logging in.
*
* Callers on hot paths use this instead of `getBearerToken` so that a request
* never triggers a login or waits on one. `undefined` means there is no token
* to present right now: the wallet is locked, the SRP has no session, or the
* cached token is close enough to expiry that `getBearerToken` would replace
* it. Refreshing is left to whichever caller next uses `getBearerToken`.
*
* @param entropySourceId - The entropy source ID. Omit for the primary SRP.
* @returns The OIDC access token, or `undefined`.
*/
public getCachedBearerToken(entropySourceId?: string): string | undefined {
if (!this.#isUnlocked) {
return undefined;
}

const resolvedId = entropySourceId ?? this.#getPrimaryEntropySourceId();
const session = this.state.srpSessionData?.[resolvedId];
// Same checks as the SRP flow's `getAccessToken`, so this returns a token
// only while that call would return the same one without logging in.
if (!isFreshLoginResponse(session) || !session.profile.canonicalProfileId) {
return undefined;
}
return session.token.accessToken;
}

/**
* Returns the cached session profile, logging in if no session exists.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export type {
AuthenticationControllerPerformSignInAction,
AuthenticationControllerPerformSignOutAction,
AuthenticationControllerGetBearerTokenAction,
AuthenticationControllerGetCachedBearerTokenAction,
AuthenticationControllerGetSessionProfileAction,
AuthenticationControllerRefreshCanonicalProfileIdAction,
AuthenticationControllerGetUserProfileLineageAction,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { SiweMessage } from '@signinwithethereum/siwe';

import { ValidationError } from '../errors.js';
import { validateLoginResponse } from '../utils/validate-login-response.js';
import { isFreshLoginResponse } from '../utils/is-fresh-login-response.js';
import {
SIWE_LOGIN_URL,
authenticate,
Expand Down Expand Up @@ -107,21 +107,9 @@ export class SIWEJwtBearerAuth implements IBaseAuth {
this.#signer = signer;
}

// convert expiresIn from seconds to milliseconds and use 90% of expiresIn
async #getAuthSession(): Promise<LoginResponse | null> {
const auth = await this.#options.storage.getLoginResponse();
if (!validateLoginResponse(auth)) {
return null;
}

const currentTime = Date.now();
const sessionAge = currentTime - auth.token.obtainedAt;
const refreshThreshold = auth.token.expiresIn * 1000 * 0.9;

if (sessionAge < refreshThreshold) {
return auth;
}
return null;
return isFreshLoginResponse(auth) ? auth : null;
}

async #login(): Promise<LoginResponse> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
connectSnap,
isSnapConnected,
} from '../utils/messaging-signing-snap-requests.js';
import { validateLoginResponse } from '../utils/validate-login-response.js';
import { isFreshLoginResponse } from '../utils/is-fresh-login-response.js';
import {
authenticate,
authorizeOIDC,
Expand Down Expand Up @@ -287,12 +287,11 @@ export class SRPJwtBearerAuth implements IBaseAuth {
return res;
}

// convert expiresIn from seconds to milliseconds and use 90% of expiresIn
async #getAuthSession(
entropySourceId?: string,
): Promise<LoginResponse | null> {
const auth = await this.#options.storage.getLoginResponse(entropySourceId);
if (!validateLoginResponse(auth)) {
if (!isFreshLoginResponse(auth)) {
return null;
}

Expand All @@ -301,14 +300,7 @@ export class SRPJwtBearerAuth implements IBaseAuth {
return null;
}

const currentTime = Date.now();
const sessionAge = currentTime - auth.token.obtainedAt;
const refreshThreshold = auth.token.expiresIn * 1000 * 0.9;

if (sessionAge < refreshThreshold) {
return auth;
}
return null;
return auth;
}

async #login(entropySourceId?: string): Promise<LoginResponse> {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import type { LoginResponse } from '../authentication.js';
import { isFreshLoginResponse } from './is-fresh-login-response.js';

/**
* Creates a minimal JWT string with the given payload claims.
* The signature is fake — only the payload matters for expiration checks.
*
* @param payload - The payload claims to include in the JWT.
* @returns A JWT string with the given payload claims.
*/
function createTestJwt(payload: Record<string, unknown>): string {
const header = btoa(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
const body = btoa(JSON.stringify(payload));
return `${header}.${body}.fake-signature`;
}

function createLoginResponse({
expiresIn = 3600,
obtainedAt = Date.now(),
}: {
expiresIn?: number;
obtainedAt?: number;
} = {}): LoginResponse {
return {
profile: {
identifierId: '',
metaMetricsId: '',
profileId: '',
canonicalProfileId: '',
},
token: {
accessToken: createTestJwt({ exp: Math.floor(Date.now() / 1000) + 3600 }),
expiresIn,
obtainedAt,
},
};
}

describe('isFreshLoginResponse()', () => {
it('returns true for a session younger than 90% of expiresIn', () => {
const response = createLoginResponse({
expiresIn: 3600,
obtainedAt: Date.now() - 3600 * 1000 * 0.89,
});
expect(isFreshLoginResponse(response)).toBe(true);
});

it('returns false for a session at or past 90% of expiresIn', () => {
const response = createLoginResponse({
expiresIn: 3600,
obtainedAt: Date.now() - 3600 * 1000 * 0.9,
});
expect(isFreshLoginResponse(response)).toBe(false);
});

it('returns false for input that is not a valid LoginResponse', () => {
expect(isFreshLoginResponse(null)).toBe(false);
expect(isFreshLoginResponse({ profile: {} })).toBe(false);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { LoginResponse } from '../authentication.js';
import { validateLoginResponse } from './validate-login-response.js';

/**
* Fraction of `expiresIn` after which a session is refreshed rather than
* reused, so a token is never presented right at its expiry.
*/
const REFRESH_THRESHOLD = 0.9;

/**
* Checks whether a stored LoginResponse can still be used as is.
*
* Builds on `validateLoginResponse` and additionally requires the session to
* be younger than 90% of `expiresIn` (seconds), which is the point where the
* auth flows log in again instead of reusing it.
*
* @param input - unknown/untyped input
* @returns boolean if input is a valid LoginResponse that needs no refresh
*/
export function isFreshLoginResponse(input: unknown): input is LoginResponse {
if (!validateLoginResponse(input)) {
return false;
}

const sessionAge = Date.now() - input.token.obtainedAt;
return sessionAge < input.token.expiresIn * 1000 * REFRESH_THRESHOLD;
}
Loading