import { ErrorResponse, ErrorTimeout, InMemoryWebStorage, User as OidcUser, UserManager, UserManagerSettings, WebStorageStateStore, } from 'oidc-client-ts'; import localForage from 'localforage'; import { track } from '@imtbl/metrics'; import { getRuntimeId } from './utils/runtimeId'; import { AuthConfiguration, IAuthConfiguration } from './config'; import { AuthModuleConfiguration, User, DirectLoginOptions, DeviceTokenResponse, LoginOptions, AuthEventMap, AuthEvents, UserZkEvm, OidcConfiguration, PassportMetadata, IdTokenPayload, isUserZkEvm, ChainAddress, } from './types'; import EmbeddedLoginPrompt from './login/embeddedLoginPrompt'; import TypedEventEmitter from './utils/typedEventEmitter'; import { withMetricsAsync } from './utils/metrics'; import { decodeJwtPayload } from './utils/jwt'; import DeviceCredentialsManager from './storage/device_credentials_manager'; import { PassportError, PassportErrorType, withPassportError } from './errors'; import logger from './utils/logger'; import { isAccessTokenExpiredOrExpiring } from './utils/token'; import { delay, backoffWithJitter } from './utils/retry'; import LoginPopupOverlay from './overlay/loginPopupOverlay'; import { LocalForageAsyncStorage } from './storage/LocalForageAsyncStorage'; import { buildLogoutUrl } from './logout'; const formUrlEncodedHeaders = { 'Content-Type': 'application/x-www-form-urlencoded', }; const parseJsonSafely = (text: string): unknown => { if (!text) { return undefined; } try { return JSON.parse(text); } catch { return undefined; } }; // A transient failure on the silent refresh (network error, timeout, 5xx or rate limit // from the token endpoint) would otherwise destroy a session backed by a still-valid // refresh token. Retry with jittered backoff, and only remove the stored user when the // authorization server definitively rejects the refresh token. // 4 attempts spread over ~3-6s of jittered backoff. Kept deliberately short: the // refresh blocks getAccessToken callers, and rate-limit windows are per-second, so // ~1s spacing already lands retries in a fresh bucket. Outages longer than this are // covered across cycles — exhaustion keeps the user, so the next getAccessToken // call starts a new cycle. const SILENT_REFRESH_MAX_RETRIES = 3; const SILENT_REFRESH_RETRY_DELAY_MS = 1000; // OAuth error codes that are a definitive verdict on the refresh token or client — // the session cannot be recovered by retrying, so the stored user must be removed. // Sources: // - RFC 6749 §5.2 (token endpoint error codes, invalid_request..invalid_scope): // https://datatracker.ietf.org/doc/html/rfc6749#section-5.2 // - OIDC Core §3.1.2.6 (access_denied, login_required, consent_required, // interaction_required, account_selection_required): // https://openid.net/specs/openid-connect-core-1_0.html#AuthError // Anything else (RFC 6749's server_error / temporarily_unavailable, Auth0's // too_many_requests rate-limit code, unknown/custom codes) is treated as transient: // oidc-client-ts throws ErrorResponse for ANY non-OK response whose body has an // `error` field — including 429s and 5xxs — so an allowlist of fatal codes is the // only safe way to avoid destroying a still-valid session. // Auth0 rate limits: https://auth0.com/docs/troubleshoot/customer-support/operational-policies/rate-limit-policy const PERMANENT_OAUTH_ERROR_CODES = new Set([ 'invalid_request', 'invalid_client', 'invalid_grant', 'unauthorized_client', 'unsupported_grant_type', 'invalid_scope', 'access_denied', 'login_required', 'consent_required', 'interaction_required', 'account_selection_required', ]); const isPermanentRefreshError = (error: unknown): boolean => ( error instanceof ErrorResponse && PERMANENT_OAUTH_ERROR_CODES.has(error.error ?? '') ); const extractTokenErrorMessage = ( payload: unknown, fallbackText: string, status: number, ): string => { if (payload && typeof payload === 'object') { const data = payload as Record; const description = data.error_description ?? data.message ?? data.error; if (typeof description === 'string' && description.trim().length > 0) { return description; } } if (fallbackText.trim().length > 0) { return fallbackText; } return `Token request failed with status ${status}`; }; const toChainAddress = (ethAddress: string, userAdminAddress: string): ChainAddress => ({ ethAddress: ethAddress as `0x${string}`, userAdminAddress: userAdminAddress as `0x${string}`, }); const authorizeEndpoint = '/authorize'; const getAuthConfiguration = (config: IAuthConfiguration): UserManagerSettings => { const { authenticationDomain, oidcConfiguration } = config; let store; if (config.crossSdkBridgeEnabled) { store = new LocalForageAsyncStorage('ImmutableSDKPassport', localForage.INDEXEDDB); } else if (typeof window !== 'undefined') { store = window.localStorage; } else { store = new InMemoryWebStorage(); } const userStore = new WebStorageStateStore({ store }); const endSessionEndpoint = buildLogoutUrl({ clientId: oidcConfiguration.clientId, authenticationDomain, logoutRedirectUri: oidcConfiguration.logoutRedirectUri, crossSdkBridgeEnabled: config.crossSdkBridgeEnabled, }); return { authority: authenticationDomain, redirect_uri: oidcConfiguration.redirectUri, popup_redirect_uri: oidcConfiguration.popupRedirectUri || oidcConfiguration.redirectUri, client_id: oidcConfiguration.clientId, metadata: { authorization_endpoint: `${authenticationDomain}/authorize`, token_endpoint: `${authenticationDomain}/oauth/token`, userinfo_endpoint: `${authenticationDomain}/userinfo`, end_session_endpoint: endSessionEndpoint, revocation_endpoint: `${authenticationDomain}/oauth/revoke`, }, automaticSilentRenew: false, scope: oidcConfiguration.scope, userStore, revokeTokenTypes: ['refresh_token'], extraQueryParams: { ...(oidcConfiguration.audience ? { audience: oidcConfiguration.audience } : {}), }, } as UserManagerSettings; }; function base64URLEncode(str: ArrayBuffer | Uint8Array) { return btoa(String.fromCharCode(...new Uint8Array(str))) .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=/g, ''); } async function sha256(buffer: string) { const encoder = new TextEncoder(); const data = encoder.encode(buffer); return window.crypto.subtle.digest('SHA-256', data); } /** * Public-facing Auth class for authentication * Provides login/logout helpers and exposes auth state events */ export class Auth { private readonly config: IAuthConfiguration; private readonly userManager: UserManager; private readonly deviceCredentialsManager: DeviceCredentialsManager; private readonly embeddedLoginPrompt: EmbeddedLoginPrompt; private readonly logoutMode: Exclude; /** * Promise that is used to prevent multiple concurrent calls to the refresh token endpoint. */ private refreshingPromise: Promise | null = null; /** * Event emitter for authentication events (LOGGED_IN, LOGGED_OUT) * Exposed for wallet and passport packages to subscribe to auth state changes */ public readonly eventEmitter: TypedEventEmitter; /** * Create a new Auth instance * * @param config - Auth configuration * * @example * ```typescript * import { Auth } from '@imtbl/auth'; * * const auth = new Auth({ * authenticationDomain: 'https://auth.immutable.com', * passportDomain: 'https://passport.immutable.com', * clientId: 'your-client-id', * redirectUri: 'https://your-app.com/callback', * scope: 'openid profile email transact', * }); * ``` */ constructor(config: AuthModuleConfiguration) { this.config = new AuthConfiguration(config); this.embeddedLoginPrompt = new EmbeddedLoginPrompt(this.config); this.userManager = new UserManager(getAuthConfiguration(this.config)); this.deviceCredentialsManager = new DeviceCredentialsManager(); this.logoutMode = this.config.oidcConfiguration.logoutMode || 'redirect'; this.eventEmitter = new TypedEventEmitter(); track('passport', 'initialise'); } /** * Login the user with extended options * Supports cached sessions, silent login, redirect flow, and direct login * @param options - Extended login options * @returns Promise that resolves with the user or null */ async login(options?: LoginOptions): Promise { return withMetricsAsync(async () => { const { useCachedSession = false, useSilentLogin } = options || {}; let user: User | null = null; // Try to get cached user try { user = await this.getUserInternal(); } catch (error: any) { if (error instanceof Error && !error.message.includes('Unknown or invalid refresh token')) { track('passport', 'login', { error }); } if (useCachedSession) { throw error; } logger.warn('Failed to retrieve a cached user session', error); } // If no cached user, try silent login or regular login if (!user && useSilentLogin) { user = await this.forceUserRefreshInternal(); } else if (!user && !useCachedSession) { if (options?.useRedirectFlow) { await this.loginWithRedirectInternal(options?.directLoginOptions); return null; // Redirect doesn't return user immediately } user = await this.loginWithPopup(options?.directLoginOptions); } // Emit LOGGED_IN event if logged in if (user) { this.handleSuccessfulLogin(user); } return user; }, 'login'); } /** * Login with redirect * Redirects the page for authentication * @param directLoginOptions - Optional direct login options * @returns Promise that resolves when redirect is initiated */ async loginWithRedirect(directLoginOptions?: DirectLoginOptions): Promise { await this.loginWithRedirectInternal(directLoginOptions); } /** * Login callback handler * Call this in your redirect or popup callback page * @returns Promise that resolves with the authenticated user or undefined (for popup flows) */ async loginCallback(): Promise { return withMetricsAsync(async () => { const user = await this.loginCallbackInternal(); if (user) { this.handleSuccessfulLogin(user); } return user; }, 'loginCallback'); } /** * Logout the current user * @returns Promise that resolves when logout is complete */ async logout(): Promise { await withMetricsAsync(async () => { await this.logoutInternal(); this.eventEmitter.emit(AuthEvents.LOGGED_OUT); }, 'logout'); } /** * Get the current authenticated user * @returns Promise that resolves with the user or null if not authenticated */ async getUser(): Promise { return this.getUserInternal(); } /** * Get the current authenticated user or initiate login if needed * @returns Promise that resolves with an authenticated user */ async getUserOrLogin(): Promise { let user: User | null = null; try { user = await this.getUserInternal(); } catch (err) { logger.warn('Failed to retrieve a cached user session', err); } if (user) { return user; } const loggedInUser = await this.loginWithPopup(); this.handleSuccessfulLogin(loggedInUser); return loggedInUser; } /** * Get the current authenticated zkEVM user * @returns Promise that resolves with a zkEVM-capable user */ async getUserZkEvm(): Promise { return this.getUserZkEvmInternal(); } /** * Get the ID token for the current user * @returns Promise that resolves with the ID token or undefined */ async getIdToken(): Promise { return withMetricsAsync(async () => { const user = await this.getUserInternal(); return user?.idToken; }, 'getIdToken'); } /** * Get the access token for the current user * @returns Promise that resolves with the access token or undefined */ async getAccessToken(): Promise { return withMetricsAsync(async () => { const user = await this.getUserInternal(); return user?.accessToken; }, 'getAccessToken'); } /** * Check if user is logged in * @returns Promise that resolves with true if user is logged in */ async isLoggedIn(): Promise { const user = await this.getUser(); return user !== null; } /** * Force a silent user refresh (for silent login) * @returns Promise that resolves with the user or null if refresh fails */ async forceUserRefresh(): Promise { return this.forceUserRefreshInternal(); } /** * Trigger a background user refresh without awaiting the result */ forceUserRefreshInBackground(): void { this.forceUserRefreshInBackgroundInternal(); } /** * Get the PKCE authorization URL for login flow * @param directLoginOptions - Optional direct login options * @param imPassportTraceId - Optional trace ID for tracking * @returns Promise that resolves with the authorization URL */ async loginWithPKCEFlow(directLoginOptions?: DirectLoginOptions, imPassportTraceId?: string): Promise { return withMetricsAsync( async () => this.getPKCEAuthorizationUrl(directLoginOptions, imPassportTraceId), 'loginWithPKCEFlow', ); } /** * Handle the PKCE login callback * @param authorizationCode - The authorization code from the OAuth provider * @param state - The state parameter for CSRF protection * @returns Promise that resolves with the authenticated user */ async loginWithPKCEFlowCallback(authorizationCode: string, state: string): Promise { return withMetricsAsync(async () => { const user = await this.loginWithPKCEFlowCallbackInternal(authorizationCode, state); this.handleSuccessfulLogin(user); return user; }, 'loginWithPKCEFlowCallback'); } /** * Store tokens from device flow and retrieve user * @param tokenResponse - The token response from device flow * @returns Promise that resolves with the authenticated user */ async storeTokens(tokenResponse: DeviceTokenResponse): Promise { return withMetricsAsync(async () => { const user = await this.storeTokensInternal(tokenResponse); this.handleSuccessfulLogin(user); return user; }, 'storeTokens'); } /** * Get the logout URL * @returns Promise that resolves with the logout URL or undefined if not available */ async getLogoutUrl(): Promise { return withMetricsAsync(async () => { await this.userManager.removeUser(); this.eventEmitter.emit(AuthEvents.LOGGED_OUT); const url = await this.getLogoutUrlInternal(); return url || undefined; }, 'getLogoutUrl'); } /** * Handle the silent logout callback * @param url - The URL containing logout information * @returns Promise that resolves when callback is handled */ async logoutSilentCallback(url: string): Promise { return withMetricsAsync(() => this.userManager.signoutSilentCallback(url), 'logoutSilentCallback'); } /** * Get auth configuration * @internal * @returns IAuthConfiguration instance */ getConfig(): IAuthConfiguration { return this.config; } /** * Get the configured OIDC client ID * @returns Promise that resolves with the client ID string */ async getClientId(): Promise { return this.config.oidcConfiguration.clientId; } private handleSuccessfulLogin(user: User): void { this.eventEmitter.emit(AuthEvents.LOGGED_IN, user); } private buildExtraQueryParams( directLoginOptions?: DirectLoginOptions, imPassportTraceId?: string, ): Record { const params: Record = { ...(this.userManager.settings?.extraQueryParams ?? {}), rid: getRuntimeId(), }; if (directLoginOptions) { if (directLoginOptions.directLoginMethod === 'email') { const emailValue = directLoginOptions.email; if (emailValue) { params.direct = directLoginOptions.directLoginMethod; params.email = emailValue; } } else { params.direct = directLoginOptions.directLoginMethod; } if (directLoginOptions.marketingConsentStatus) { params.marketingConsent = directLoginOptions.marketingConsentStatus; } } if (imPassportTraceId) { params.im_passport_trace_id = imPassportTraceId; } return params; } private async loginWithRedirectInternal(directLoginOptions?: DirectLoginOptions): Promise { await this.userManager.clearStaleState(); await withPassportError(async () => { const extraQueryParams = this.buildExtraQueryParams(directLoginOptions); await this.userManager.signinRedirect({ extraQueryParams }); }, PassportErrorType.AUTHENTICATION_ERROR); } private async loginWithPopup(directLoginOptions?: DirectLoginOptions): Promise { return withPassportError(async () => { let directLoginOptionsToUse: DirectLoginOptions | undefined; let imPassportTraceId: string | undefined; if (directLoginOptions) { directLoginOptionsToUse = directLoginOptions; } else if (!this.config.popupOverlayOptions?.disableHeadlessLoginPromptOverlay) { const { imPassportTraceId: embeddedLoginPromptTraceId, ...embeddedLoginPromptDirectLoginOptions } = await this.embeddedLoginPrompt.displayEmbeddedLoginPrompt(); directLoginOptionsToUse = embeddedLoginPromptDirectLoginOptions; imPassportTraceId = embeddedLoginPromptTraceId; } const popupWindowTarget = window.crypto.randomUUID(); const signinPopup = async () => { const extraQueryParams = this.buildExtraQueryParams(directLoginOptionsToUse, imPassportTraceId); return this.userManager.signinPopup({ extraQueryParams, popupWindowFeatures: { width: 410, height: 450, }, popupWindowTarget, // Enable oidc-client-ts native popup close detection (works for initial screen) popupAbortOnClose: true, }); }; return new Promise((resolve, reject) => { signinPopup() .then((oidcUser) => resolve(Auth.mapOidcUserToDomainModel(oidcUser))) .catch((error: unknown) => { if (!(error instanceof Error) || error.message !== 'Attempted to navigate on a disposed window') { reject(error); return; } let popupHasBeenOpened = false; const overlay = new LoginPopupOverlay(this.config.popupOverlayOptions || {}, true); overlay.append( async () => { try { if (!popupHasBeenOpened) { popupHasBeenOpened = true; const oidcUser = await signinPopup(); overlay.remove(); resolve(Auth.mapOidcUserToDomainModel(oidcUser)); } else { window.open('', popupWindowTarget); } } catch (retryError) { overlay.remove(); reject(retryError); } }, () => { overlay.remove(); reject(new Error('Popup closed by user')); }, ); }); }); }, PassportErrorType.AUTHENTICATION_ERROR); } private static mapOidcUserToDomainModel = (oidcUser: OidcUser): User => { let passport: PassportMetadata | undefined; let username: string | undefined; if (oidcUser.id_token) { const idTokenPayload = decodeJwtPayload(oidcUser.id_token); passport = idTokenPayload?.passport; if (idTokenPayload?.username) { username = idTokenPayload?.username; } } const user: User = { expired: oidcUser.expired, idToken: oidcUser.id_token, accessToken: oidcUser.access_token, refreshToken: oidcUser.refresh_token, profile: { sub: oidcUser.profile.sub, email: oidcUser.profile.email, nickname: oidcUser.profile.nickname, username, }, }; if (passport?.zkevm_eth_address && passport?.zkevm_user_admin_address) { user.zkEvm = toChainAddress(passport.zkevm_eth_address, passport.zkevm_user_admin_address); } return user; }; private static mapDeviceTokenResponseToOidcUser = (tokenResponse: DeviceTokenResponse): OidcUser => { const idTokenPayload: IdTokenPayload = decodeJwtPayload(tokenResponse.id_token); return new OidcUser({ id_token: tokenResponse.id_token, access_token: tokenResponse.access_token, refresh_token: tokenResponse.refresh_token, token_type: tokenResponse.token_type, profile: { sub: idTokenPayload.sub, iss: idTokenPayload.iss, aud: idTokenPayload.aud, exp: idTokenPayload.exp, iat: idTokenPayload.iat, email: idTokenPayload.email, nickname: idTokenPayload.nickname, passport: idTokenPayload.passport, ...(idTokenPayload.username ? { username: idTokenPayload.username } : {}), }, }); }; private async loginCallbackInternal(): Promise { return withPassportError(async () => { const oidcUser = await this.userManager.signinCallback(); if (!oidcUser) { return undefined; } return Auth.mapOidcUserToDomainModel(oidcUser); }, PassportErrorType.AUTHENTICATION_ERROR); } private async getPKCEAuthorizationUrl( directLoginOptions?: DirectLoginOptions, imPassportTraceId?: string, ): Promise { const verifier = base64URLEncode(window.crypto.getRandomValues(new Uint8Array(32))); const challenge = base64URLEncode(await sha256(verifier)); const state = base64URLEncode(window.crypto.getRandomValues(new Uint8Array(32))); const { redirectUri, scope, audience, clientId, } = this.config.oidcConfiguration; this.deviceCredentialsManager.savePKCEData({ state, verifier }); const pkceAuthorizationUrl = new URL(authorizeEndpoint, this.config.authenticationDomain); pkceAuthorizationUrl.searchParams.set('response_type', 'code'); pkceAuthorizationUrl.searchParams.set('code_challenge', challenge); pkceAuthorizationUrl.searchParams.set('code_challenge_method', 'S256'); pkceAuthorizationUrl.searchParams.set('client_id', clientId); pkceAuthorizationUrl.searchParams.set('redirect_uri', redirectUri); pkceAuthorizationUrl.searchParams.set('state', state); if (scope) pkceAuthorizationUrl.searchParams.set('scope', scope); if (audience) pkceAuthorizationUrl.searchParams.set('audience', audience); if (directLoginOptions) { if (directLoginOptions.directLoginMethod === 'email') { const emailValue = directLoginOptions.email; if (emailValue) { pkceAuthorizationUrl.searchParams.set('direct', directLoginOptions.directLoginMethod); pkceAuthorizationUrl.searchParams.set('email', emailValue); } } else { pkceAuthorizationUrl.searchParams.set('direct', directLoginOptions.directLoginMethod); } if (directLoginOptions.marketingConsentStatus) { pkceAuthorizationUrl.searchParams.set('marketingConsent', directLoginOptions.marketingConsentStatus); } } if (imPassportTraceId) { pkceAuthorizationUrl.searchParams.set('im_passport_trace_id', imPassportTraceId); } return pkceAuthorizationUrl.toString(); } private async loginWithPKCEFlowCallbackInternal(authorizationCode: string, state: string): Promise { return withPassportError(async () => { const pkceData = this.deviceCredentialsManager.getPKCEData(); if (!pkceData) { throw new Error('No code verifier or state for PKCE'); } if (state !== pkceData.state) { throw new Error('Provided state does not match stored state'); } const tokenResponse = await this.getPKCEToken(authorizationCode, pkceData.verifier); const oidcUser = Auth.mapDeviceTokenResponseToOidcUser(tokenResponse); const user = Auth.mapOidcUserToDomainModel(oidcUser); await this.userManager.storeUser(oidcUser); return user; }, PassportErrorType.AUTHENTICATION_ERROR); } private async getPKCEToken(authorizationCode: string, codeVerifier: string): Promise { const response = await fetch( `${this.config.authenticationDomain}/oauth/token`, { method: 'POST', headers: formUrlEncodedHeaders, body: new URLSearchParams({ client_id: this.config.oidcConfiguration.clientId, grant_type: 'authorization_code', code_verifier: codeVerifier, code: authorizationCode, redirect_uri: this.config.oidcConfiguration.redirectUri, }), }, ); const responseText = await response.text(); const parsedBody = parseJsonSafely(responseText); if (!response.ok) { throw new Error( extractTokenErrorMessage( parsedBody, responseText, response.status, ), ); } if (!parsedBody || typeof parsedBody !== 'object') { throw new Error('Token endpoint returned an invalid response'); } return parsedBody as DeviceTokenResponse; } private async storeTokensInternal(tokenResponse: DeviceTokenResponse): Promise { return withPassportError(async () => { const oidcUser = Auth.mapDeviceTokenResponseToOidcUser(tokenResponse); const user = Auth.mapOidcUserToDomainModel(oidcUser); await this.userManager.storeUser(oidcUser); return user; }, PassportErrorType.AUTHENTICATION_ERROR); } private async logoutInternal(): Promise { await withPassportError(async () => { await this.userManager.revokeTokens(['refresh_token']); if (this.logoutMode === 'silent') { await this.userManager.signoutSilent(); } else { await this.userManager.signoutRedirect(); } }, PassportErrorType.LOGOUT_ERROR); } private async getLogoutUrlInternal(): Promise { const endSessionEndpoint = this.userManager.settings?.metadata?.end_session_endpoint; if (!endSessionEndpoint) { logger.warn('Failed to get logout URL'); return null; } return endSessionEndpoint; } private forceUserRefreshInBackgroundInternal() { this.refreshTokenAndUpdatePromise().catch((error) => { logger.warn('Failed to refresh user token', error); }); } private async forceUserRefreshInternal(): Promise { return this.refreshTokenAndUpdatePromise().catch((error) => { logger.warn('Failed to refresh user token', error); return null; }); } private async signinSilentWithRetry(): Promise { // Retrying only helps when there is a refresh token to exchange — the failure // modes worth absorbing (429s, 5xx, network blips) all live on that exchange. // A silent login without one (no stored session, iframe flow) fails // deterministically, so it gets a single attempt and fails fast. const storedUser = await this.userManager.getUser(); const canRetry = !!storedUser?.refresh_token; const attempt = async (attemptNumber: number): Promise => { try { const oidcUser = await this.userManager.signinSilent(); if (attemptNumber > 1) { track('passport', 'silentRefreshRecovered'); } return oidcUser; } catch (error) { if (!canRetry || isPermanentRefreshError(error) || attemptNumber > SILENT_REFRESH_MAX_RETRIES) { throw error; } logger.warn(`Silent refresh attempt ${attemptNumber} failed, retrying`, error); await delay(backoffWithJitter(SILENT_REFRESH_RETRY_DELAY_MS, attemptNumber)); return attempt(attemptNumber + 1); } }; return attempt(1); } private async refreshTokenAndUpdatePromise(): Promise { if (this.refreshingPromise) { return this.refreshingPromise; } this.refreshingPromise = new Promise((resolve, reject) => { (async () => { try { const newOidcUser = await this.signinSilentWithRetry(); if (newOidcUser) { const user = Auth.mapOidcUserToDomainModel(newOidcUser); // Emit TOKEN_REFRESHED event so consumers (e.g., auth-next-client) can sync // the new tokens to their session. This is critical for refresh token // rotation - without this, the server-side session may have stale tokens. this.eventEmitter.emit(AuthEvents.TOKEN_REFRESHED, user); resolve(user); return; } resolve(null); } catch (err) { let passportErrorType = PassportErrorType.AUTHENTICATION_ERROR; let errorMessage = 'Failed to refresh token'; // Only remove the user when the authorization server definitively rejected // the refresh token. Transient failures (network errors, timeouts, 5xx, rate // limits) have already been retried by signinSilentWithRetry; if they still // fail, keep the stored user so the next call can try again — the refresh // token is still valid. const removeUser = isPermanentRefreshError(err); if (err instanceof ErrorTimeout) { passportErrorType = PassportErrorType.SILENT_LOGIN_ERROR; errorMessage = `${errorMessage}: ${err.message}`; } else if (err instanceof ErrorResponse) { passportErrorType = removeUser ? PassportErrorType.NOT_LOGGED_IN_ERROR : PassportErrorType.AUTHENTICATION_ERROR; errorMessage = `${errorMessage}: ${err.message || err.error_description}`; } else if (err instanceof Error) { errorMessage = `${errorMessage}: ${err.message}`; } else if (typeof err === 'string') { errorMessage = `${errorMessage}: ${err}`; } // Terminal refresh failures were previously invisible (client-side // logger.warn only), which made fleet-wide incidents impossible to see. track('passport', 'silentRefresh', { error: err instanceof Error ? err : new Error(errorMessage), }); if (removeUser) { // Emit USER_REMOVED event BEFORE removing user so consumers can react // (e.g., auth-next-client can clear the NextAuth session) this.eventEmitter.emit(AuthEvents.USER_REMOVED, { reason: 'refresh_failed', error: errorMessage, }); try { await this.userManager.removeUser(); } catch (removeUserError) { if (removeUserError instanceof Error) { errorMessage = `${errorMessage}: Failed to remove user: ${removeUserError.message}`; } } } reject(new PassportError(errorMessage, passportErrorType)); } finally { this.refreshingPromise = null; } })(); }); return this.refreshingPromise; } private async getUserInternal( typeAssertion: (user: User) => user is T = (user: User): user is T => true, ): Promise { if (this.refreshingPromise) { const refreshingUser = await this.refreshingPromise; if (refreshingUser && typeAssertion(refreshingUser)) { return refreshingUser; } return null; } const oidcUser = await this.userManager.getUser(); if (!oidcUser) return null; if (!isAccessTokenExpiredOrExpiring(oidcUser)) { const user = Auth.mapOidcUserToDomainModel(oidcUser); if (user && typeAssertion(user)) { return user; } } if (oidcUser.refresh_token) { const refreshedUser = await this.refreshTokenAndUpdatePromise(); if (refreshedUser && typeAssertion(refreshedUser)) { return refreshedUser; } } return null; } private async getUserZkEvmInternal(): Promise { const user = await this.getUserInternal(isUserZkEvm); if (!user) { throw new Error('Failed to obtain a User with the required ZkEvm attributes'); } return user; } }