UNPKG

@aws-amplify/auth

Version:

Auth category of aws-amplify

207 lines (204 loc) • 9.03 kB
import { Hub } from '@aws-amplify/core'; import { isBrowser, assertTokenProviderConfig, isTokenExpired, AMPLIFY_SYMBOL } from '@aws-amplify/core/internals/utils'; import { assertServiceError } from '../../../errors/utils/assertServiceError.mjs'; import { AuthError } from '../../../errors/AuthError.mjs'; import { oAuthStore } from '../utils/oauth/oAuthStore.mjs'; import { armInflightDeadline, addInflightPromise } from '../utils/oauth/inflightPromise.mjs'; // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 class TokenOrchestrator { constructor() { this.waitForInflightOAuth = isBrowser() ? async () => { // Read-time evaluation of the blocking deadline: absent flag, an // expired deadline, or a flag value other than 'true' all mean // "do not block". An abandoned flow in another tab can therefore // never park token consumers indefinitely. // (`loadOAuthInFlightDeadline` is optional on the OAuthStore // interface for custom-implementation compatibility, but this // singleton is always the concrete DefaultOAuthStore, which // implements it.) const deadline = await oAuthStore.loadOAuthInFlightDeadline(); if (deadline === undefined) { return; } if (this.inflightPromise) { // Keep the backstop aligned with the current deadline for waiters // piggybacking on the existing park. armInflightDeadline(deadline, () => oAuthStore.loadOAuthInFlightDeadline()); return this.inflightPromise; } // when there is valid oauth config and there is an inflight oauth flow, try // to block async calls that require fetching tokens before the oauth flow completes // e.g. getCurrentUser, fetchAuthSession etc. this.inflightPromise = new Promise(resolve => { // Invariant: `this.inflightPromise` is owned by the park lifecycle — // created here and reset by the very resolver that releases it // (drained by the backstop timer, the cross-tab listener, or // in-process completion). Resetting BEFORE resolving closes the // post-release hole where a caller for a NEW flow could observe a // stale, already-resolved promise and skip blocking on it. addInflightPromise(() => { this.inflightPromise = undefined; resolve(); }); // Arm the deadline backstop in the same synchronous step as the // park: it releases this waiter even when the cross-tab release // fired between the deadline read above and this park (that storage // event never re-fires), when storage events are unavailable // (Safari private mode), or when the flow is simply never // completed anywhere. armInflightDeadline(deadline, () => oAuthStore.loadOAuthInFlightDeadline()); }); return this.inflightPromise; } : async () => { // no-op for non-browser environments }; } setAuthConfig(authConfig) { oAuthStore.setAuthConfig(authConfig.Cognito); this.authConfig = authConfig; } setTokenRefresher(tokenRefresher) { this.tokenRefresher = tokenRefresher; } setAuthTokenStore(tokenStore) { this.tokenStore = tokenStore; } getTokenStore() { if (!this.tokenStore) { throw new AuthError({ name: 'EmptyTokenStoreException', message: 'TokenStore not set', }); } return this.tokenStore; } getTokenRefresher() { if (!this.tokenRefresher) { throw new AuthError({ name: 'EmptyTokenRefresherException', message: 'TokenRefresher not set', }); } return this.tokenRefresher; } setClientMetadataProvider(clientMetadataProvider) { this.clientMetadataProvider = clientMetadataProvider; } async getTokens(options) { let tokens; try { assertTokenProviderConfig(this.authConfig?.Cognito); } catch (_err) { // Token provider not configured return null; } await this.waitForInflightOAuth(); // NOTE: `this.inflightPromise` is reset by the resolver registered in // `waitForInflightOAuth` (co-located with the release), NOT here — a reset // here could clobber a newer flow's park created between the release and // this line resuming. tokens = await this.getTokenStore().loadTokens(); const username = await this.getTokenStore().getLastAuthUser(); if (tokens === null) { return null; } const idTokenExpired = !!tokens?.idToken && isTokenExpired({ expiresAt: (tokens.idToken?.payload?.exp ?? 0) * 1000, clockDrift: tokens.clockDrift ?? 0, }); const accessTokenExpired = isTokenExpired({ expiresAt: (tokens.accessToken?.payload?.exp ?? 0) * 1000, clockDrift: tokens.clockDrift ?? 0, }); if (options?.forceRefresh || idTokenExpired || accessTokenExpired) { tokens = await this.refreshTokens({ tokens, username, clientMetadata: options?.clientMetadata ?? (await this.clientMetadataProvider?.()), }); if (tokens === null) { return null; } } return { accessToken: tokens?.accessToken, idToken: tokens?.idToken, signInDetails: tokens?.signInDetails, }; } async refreshTokens({ tokens, username, clientMetadata, }) { try { const { signInDetails } = tokens; const newTokens = await this.getTokenRefresher()({ tokens, authConfig: this.authConfig, username, clientMetadata, }); newTokens.signInDetails = signInDetails; await this.setTokens({ tokens: newTokens }); Hub.dispatch('auth', { event: 'tokenRefresh' }, 'Auth', AMPLIFY_SYMBOL); return newTokens; } catch (err) { return this.handleErrors(err); } } handleErrors(err) { assertServiceError(err); // Only clear tokens for definitive authentication failures // Do NOT clear tokens for transient errors like service issues, rate limits, etc. const shouldClearTokens = this.isAuthenticationError(err); if (shouldClearTokens) { this.clearTokens(); } Hub.dispatch('auth', { event: 'tokenRefresh_failure', data: { error: err }, }, 'Auth', AMPLIFY_SYMBOL); if (err.name.startsWith('NotAuthorizedException')) { return null; } throw err; } isAuthenticationError(err) { // Only clear tokens for errors that definitively indicate the tokens are invalid // and re-authentication is required. All other errors (service errors, rate limits, etc.) // should preserve the tokens to allow for retry. // See: https://github.com/aws-amplify/amplify-js/issues/14534 const authErrorNames = [ 'NotAuthorizedException', // Refresh token is expired or invalid 'TokenRevokedException', // Token was revoked by admin 'UserNotFoundException', // User no longer exists 'PasswordResetRequiredException', // User must reset password 'UserNotConfirmedException', // User account is not confirmed 'RefreshTokenReuseException', // Refresh token invalidated by rotation ]; return authErrorNames.some(errorName => err?.name?.startsWith?.(errorName)); } async setTokens({ tokens }) { return this.getTokenStore().storeTokens(tokens); } async clearTokens() { return this.getTokenStore().clearTokens(); } getDeviceMetadata(username) { return this.getTokenStore().getDeviceMetadata(username); } clearDeviceMetadata(username) { return this.getTokenStore().clearDeviceMetadata(username); } setOAuthMetadata(metadata) { return this.getTokenStore().setOAuthMetadata(metadata); } getOAuthMetadata() { return this.getTokenStore().getOAuthMetadata(); } } export { TokenOrchestrator }; //# sourceMappingURL=TokenOrchestrator.mjs.map