From d568a5d96cd07070e33c6979d06f9f1fdd494aea Mon Sep 17 00:00:00 2001 From: basgys Date: Thu, 17 Sep 2026 16:58:21 +0200 Subject: [PATCH 1/4] feat(profile-sync-controller): add getCachedBearerToken getBearerToken logs in when no valid session exists, so callers on hot paths pay for a login and wait on it. getCachedBearerToken reads the cached token synchronously and returns undefined instead of logging in. --- packages/profile-sync-controller/CHANGELOG.md | 3 + ...nticationController-method-action-types.ts | 18 ++++++ .../AuthenticationController.test.ts | 58 +++++++++++++++++++ .../AuthenticationController.ts | 31 ++++++++++ .../src/controllers/authentication/index.ts | 1 + 5 files changed, 111 insertions(+) diff --git a/packages/profile-sync-controller/CHANGELOG.md b/packages/profile-sync-controller/CHANGELOG.md index 2bc02110e1f..137bcdcda23 100644 --- a/packages/profile-sync-controller/CHANGELOG.md +++ b/packages/profile-sync-controller/CHANGELOG.md @@ -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 ([#10199](https://github.com/MetaMask/core/pull/10199)) + - 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] diff --git a/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController-method-action-types.ts b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController-method-action-types.ts index ed7195a52b8..95bb7c426d2 100644 --- a/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController-method-action-types.ts +++ b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController-method-action-types.ts @@ -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. * @@ -142,6 +159,7 @@ export type AuthenticationControllerMethodActions = | AuthenticationControllerPerformSignOutAction | AuthenticationControllerClearStateAction | AuthenticationControllerGetBearerTokenAction + | AuthenticationControllerGetCachedBearerTokenAction | AuthenticationControllerGetSessionProfileAction | AuthenticationControllerRefreshCanonicalProfileIdAction | AuthenticationControllerGetUserProfileLineageAction diff --git a/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.test.ts b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.test.ts index 95d88c372b0..8e543bbbc82 100644 --- a/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.test.ts +++ b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.test.ts @@ -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(); diff --git a/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts index 30f033c8dbf..c0db9e94108 100644 --- a/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts +++ b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts @@ -147,6 +147,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'performSignIn', 'performSignOut', 'getBearerToken', + 'getCachedBearerToken', 'getSessionProfile', 'refreshCanonicalProfileId', 'getUserProfileLineage', @@ -762,6 +763,36 @@ 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]; + // Mirrors the session checks in the SDK's `getAccessToken`, so this returns + // a token only while that call would return the same one from the cache. + if (!session?.profile.canonicalProfileId) { + return undefined; + } + + const sessionAge = Date.now() - session.token.obtainedAt; + const refreshThreshold = session.token.expiresIn * 1000 * 0.9; + return sessionAge < refreshThreshold ? session.token.accessToken : undefined; + } + /** * Returns the cached session profile, logging in if no session exists. * diff --git a/packages/profile-sync-controller/src/controllers/authentication/index.ts b/packages/profile-sync-controller/src/controllers/authentication/index.ts index 114f26b6816..1b9e40ed933 100644 --- a/packages/profile-sync-controller/src/controllers/authentication/index.ts +++ b/packages/profile-sync-controller/src/controllers/authentication/index.ts @@ -9,6 +9,7 @@ export type { AuthenticationControllerPerformSignInAction, AuthenticationControllerPerformSignOutAction, AuthenticationControllerGetBearerTokenAction, + AuthenticationControllerGetCachedBearerTokenAction, AuthenticationControllerGetSessionProfileAction, AuthenticationControllerRefreshCanonicalProfileIdAction, AuthenticationControllerGetUserProfileLineageAction, From 47d207ad48413744c756656265ce9792c8c4ac3e Mon Sep 17 00:00:00 2001 From: basgys Date: Thu, 17 Sep 2026 16:59:09 +0200 Subject: [PATCH 2/4] docs: correct PR link in changelog --- packages/profile-sync-controller/CHANGELOG.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/profile-sync-controller/CHANGELOG.md b/packages/profile-sync-controller/CHANGELOG.md index 137bcdcda23..3960f7c65c0 100644 --- a/packages/profile-sync-controller/CHANGELOG.md +++ b/packages/profile-sync-controller/CHANGELOG.md @@ -9,8 +9,7 @@ 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 ([#10199](https://github.com/MetaMask/core/pull/10199)) +- 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)) From 2532c8854e6b9d283fa13f979f63cb543ef5dbd9 Mon Sep 17 00:00:00 2001 From: basgys Date: Thu, 17 Sep 2026 17:04:11 +0200 Subject: [PATCH 3/4] refactor(profile-sync-controller): share the session freshness check Both auth flows inlined the 90% expiry rule. isFreshLoginResponse holds it once, and getCachedBearerToken uses it so its checks match the SRP flow, including the JWT exp check it previously skipped. --- .../AuthenticationController.ts | 12 ++-- .../authentication-jwt-bearer/flow-siwe.ts | 16 +---- .../sdk/authentication-jwt-bearer/flow-srp.ts | 14 +---- .../sdk/utils/is-fresh-login-response.test.ts | 60 +++++++++++++++++++ .../src/sdk/utils/is-fresh-login-response.ts | 27 +++++++++ 5 files changed, 97 insertions(+), 32 deletions(-) create mode 100644 packages/profile-sync-controller/src/sdk/utils/is-fresh-login-response.test.ts create mode 100644 packages/profile-sync-controller/src/sdk/utils/is-fresh-login-response.ts diff --git a/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts index c0db9e94108..1eb7b43ff13 100644 --- a/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts +++ b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts @@ -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, @@ -782,15 +783,12 @@ export class AuthenticationController extends BaseController< const resolvedId = entropySourceId ?? this.#getPrimaryEntropySourceId(); const session = this.state.srpSessionData?.[resolvedId]; - // Mirrors the session checks in the SDK's `getAccessToken`, so this returns - // a token only while that call would return the same one from the cache. - if (!session?.profile.canonicalProfileId) { + // 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; } - - const sessionAge = Date.now() - session.token.obtainedAt; - const refreshThreshold = session.token.expiresIn * 1000 * 0.9; - return sessionAge < refreshThreshold ? session.token.accessToken : undefined; + return session.token.accessToken; } /** diff --git a/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/flow-siwe.ts b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/flow-siwe.ts index 28a6210b512..f5cb0cb0551 100644 --- a/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/flow-siwe.ts +++ b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/flow-siwe.ts @@ -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, @@ -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 { 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 { diff --git a/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/flow-srp.ts b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/flow-srp.ts index 6d569a4b3fa..b7ee714ce55 100644 --- a/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/flow-srp.ts +++ b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/flow-srp.ts @@ -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, @@ -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 { const auth = await this.#options.storage.getLoginResponse(entropySourceId); - if (!validateLoginResponse(auth)) { + if (!isFreshLoginResponse(auth)) { return null; } @@ -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 { diff --git a/packages/profile-sync-controller/src/sdk/utils/is-fresh-login-response.test.ts b/packages/profile-sync-controller/src/sdk/utils/is-fresh-login-response.test.ts new file mode 100644 index 00000000000..b6b023ca4d8 --- /dev/null +++ b/packages/profile-sync-controller/src/sdk/utils/is-fresh-login-response.test.ts @@ -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 { + 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); + }); +}); diff --git a/packages/profile-sync-controller/src/sdk/utils/is-fresh-login-response.ts b/packages/profile-sync-controller/src/sdk/utils/is-fresh-login-response.ts new file mode 100644 index 00000000000..fed26ebee9f --- /dev/null +++ b/packages/profile-sync-controller/src/sdk/utils/is-fresh-login-response.ts @@ -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; +} From 9a6220ee9793c6735a70534643ef2dbee0baaf93 Mon Sep 17 00:00:00 2001 From: basgys Date: Thu, 17 Sep 2026 17:07:55 +0200 Subject: [PATCH 4/4] docs: restore changelog entry dropped in rebase --- packages/profile-sync-controller/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/profile-sync-controller/CHANGELOG.md b/packages/profile-sync-controller/CHANGELOG.md index 3960f7c65c0..b762a69f081 100644 --- a/packages/profile-sync-controller/CHANGELOG.md +++ b/packages/profile-sync-controller/CHANGELOG.md @@ -9,6 +9,7 @@ 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.