// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 import { ConsoleLogger as Logger } from './Logger'; import { StorageHelper } from './StorageHelper'; import { makeQuerablePromise } from './JS'; import { FacebookOAuth, GoogleOAuth } from './OAuthHelper'; import { jitteredExponentialRetry } from './Util'; import { ICredentials } from './types'; import { Amplify } from './Amplify'; import { getId, getCredentialsForIdentity } from './AwsClients/CognitoIdentity'; import { parseAWSExports } from './parseAWSExports'; import { Hub } from './Hub'; const logger = new Logger('Credentials'); const CREDENTIALS_TTL = 50 * 60 * 1000; // 50 min, can be modified on config if required in the future const COGNITO_IDENTITY_KEY_PREFIX = 'CognitoIdentityId-'; const AMPLIFY_SYMBOL = ( typeof Symbol !== 'undefined' && typeof Symbol.for === 'function' ? Symbol.for('amplify_default') : '@@amplify_default' ) as Symbol; const dispatchCredentialsEvent = ( event: string, data: any, message: string ) => { Hub.dispatch('core', { event, data, message }, 'Credentials', AMPLIFY_SYMBOL); }; export class CredentialsClass { private _config; private _credentials; private _credentials_source; private _gettingCredPromise = null; private _refreshHandlers = {}; private _storage; private _storageSync; private _identityId; private _nextCredentialsRefresh: number; // Allow `Auth` to be injected for SSR, but Auth isn't a required dependency for Credentials Auth = undefined; constructor(config) { this.configure(config); this._refreshHandlers['google'] = GoogleOAuth.refreshGoogleToken; this._refreshHandlers['facebook'] = FacebookOAuth.refreshFacebookToken; } public getModuleName() { return 'Credentials'; } public getCredSource() { return this._credentials_source; } public configure(config) { if (!config) return this._config || {}; this._config = Object.assign({}, this._config, config); const { refreshHandlers } = this._config; // If the developer has provided an object of refresh handlers, // then we can merge the provided handlers with the current handlers. if (refreshHandlers) { this._refreshHandlers = { ...this._refreshHandlers, ...refreshHandlers, }; } this._storage = this._config.storage; if (!this._storage) { this._storage = new StorageHelper().getStorage(); } this._storageSync = Promise.resolve(); if (typeof this._storage['sync'] === 'function') { this._storageSync = this._storage['sync'](); } dispatchCredentialsEvent( 'credentials_configured', null, `Credentials has been configured successfully` ); return this._config; } public get() { logger.debug('getting credentials'); return this._pickupCredentials(); } // currently we only store the guest identity in local storage private _getCognitoIdentityIdStorageKey(identityPoolId: string) { return `${COGNITO_IDENTITY_KEY_PREFIX}${identityPoolId}`; } private _pickupCredentials() { logger.debug('picking up credentials'); if (!this._gettingCredPromise || !this._gettingCredPromise.isPending()) { logger.debug('getting new cred promise'); this._gettingCredPromise = makeQuerablePromise(this._keepAlive()); } else { logger.debug('getting old cred promise'); } return this._gettingCredPromise; } private async _keepAlive() { logger.debug('checking if credentials exists and not expired'); const cred = this._credentials; if (cred && !this._isExpired(cred) && !this._isPastTTL()) { logger.debug('credentials not changed and not expired, directly return'); return Promise.resolve(cred); } logger.debug('need to get a new credential or refresh the existing one'); // Some use-cases don't require Auth for signing in, but use Credentials for guest users (e.g. Analytics) // Prefer locally scoped `Auth`, but fallback to registered `Amplify.Auth` global otherwise. const { Auth = Amplify.Auth } = this; if (!Auth || typeof Auth.currentUserCredentials !== 'function') { // If Auth module is not imported, do a best effort to get guest credentials return this._setCredentialsForGuest(); } if (!this._isExpired(cred) && this._isPastTTL()) { logger.debug('ttl has passed but token is not yet expired'); try { const user = await Auth.currentUserPoolUser(); const session = await Auth.currentSession(); const refreshToken = session.refreshToken; const refreshRequest = new Promise((res, rej) => { user.refreshSession(refreshToken, (err, data) => { return err ? rej(err) : res(data); }); }); await refreshRequest; // note that rejections will be caught and handled in the catch block. } catch (err) { // should not throw because user might just be on guest access or is authenticated through federation logger.debug('Error attempting to refreshing the session', err); } } return Auth.currentUserCredentials(); } public refreshFederatedToken(federatedInfo) { logger.debug('Getting federated credentials'); const { provider, user, token, identity_id } = federatedInfo; let { expires_at } = federatedInfo; // Make sure expires_at is in millis expires_at = new Date(expires_at).getFullYear() === 1970 ? expires_at * 1000 : expires_at; const that = this; logger.debug('checking if federated jwt token expired'); if (expires_at > new Date().getTime()) { // if not expired logger.debug('token not expired'); return this._setCredentialsFromFederation({ provider, token, user, identity_id, expires_at, }); } else { // if refresh handler exists if ( that._refreshHandlers[provider] && typeof that._refreshHandlers[provider] === 'function' ) { logger.debug('getting refreshed jwt token from federation provider'); return this._providerRefreshWithRetry({ refreshHandler: that._refreshHandlers[provider], provider, user, }); } else { logger.debug('no refresh handler for provider:', provider); this.clear(); return Promise.reject('no refresh handler for provider'); } } } private _providerRefreshWithRetry({ refreshHandler, provider, user }) { const MAX_DELAY_MS = 10 * 1000; // refreshHandler will retry network errors, otherwise it will // return NonRetryableError to break out of jitteredExponentialRetry return jitteredExponentialRetry(refreshHandler, [], MAX_DELAY_MS) .then(data => { logger.debug('refresh federated token sucessfully', data); return this._setCredentialsFromFederation({ provider, token: data.token, user, identity_id: data.identity_id, expires_at: data.expires_at, }); }) .catch(e => { const isNetworkError = typeof e === 'string' && e.toLowerCase().lastIndexOf('network error', e.length) === 0; if (!isNetworkError) { this.clear(); } logger.debug('refresh federated token failed', e); return Promise.reject('refreshing federation token failed: ' + e); }); } private _isExpired(credentials): boolean { if (!credentials) { logger.debug('no credentials for expiration check'); return true; } logger.debug('are these credentials expired?', credentials); const ts = Date.now(); /* returns date object. https://github.com/aws/aws-sdk-js-v3/blob/v1.0.0-beta.1/packages/types/src/credentials.ts#L26 */ const { expiration } = credentials; return expiration.getTime() <= ts; } private _isPastTTL(): boolean { return this._nextCredentialsRefresh <= Date.now(); } private async _setCredentialsForGuest() { logger.debug('setting credentials for guest'); if (!this._config?.identityPoolId) { // If Credentials are not configured thru Auth module, // doing best effort to check if the library was configured this._config = Object.assign( {}, this._config, parseAWSExports(this._config || {}).Auth ); } const { identityPoolId, region, mandatorySignIn, identityPoolRegion } = this._config; if (mandatorySignIn) { return Promise.reject( 'cannot get guest credentials when mandatory signin enabled' ); } if (!identityPoolId) { logger.debug( 'No Cognito Identity pool provided for unauthenticated access' ); return Promise.reject( 'No Cognito Identity pool provided for unauthenticated access' ); } if (!identityPoolRegion && !region) { logger.debug('region is not configured for getting the credentials'); return Promise.reject( 'region is not configured for getting the credentials' ); } const identityId = (this._identityId = await this._getGuestIdentityId()); const cognitoConfig = { region: identityPoolRegion ?? region }; const guestCredentialsProvider = async () => { if (!identityId) { const { IdentityId } = await getId(cognitoConfig, { IdentityPoolId: identityPoolId, }); this._identityId = IdentityId; } const { Credentials } = await getCredentialsForIdentity(cognitoConfig, { IdentityId: this._identityId, }); return { identityId: this._identityId, accessKeyId: Credentials.AccessKeyId, secretAccessKey: Credentials.SecretKey, sessionToken: Credentials.SessionToken, expiration: Credentials.Expiration, }; }; let credentials = guestCredentialsProvider().catch(async err => { throw err; }); return this._loadCredentials(credentials, 'guest', false, null) .then(res => { return res; }) .catch(async e => { // If identity id is deleted in the console, we make one attempt to recreate it // and remove existing id from cache. if ( e.name === 'ResourceNotFoundException' && e.message === `Identity '${identityId}' not found.` ) { logger.debug('Failed to load guest credentials'); await this._removeGuestIdentityId(); const guestCredentialsProvider = async () => { const { IdentityId } = await getId(cognitoConfig, { IdentityPoolId: identityPoolId, }); this._identityId = IdentityId; const { Credentials } = await getCredentialsForIdentity( cognitoConfig, { IdentityId, } ); return { identityId: IdentityId, accessKeyId: Credentials.AccessKeyId, secretAccessKey: Credentials.SecretKey, sessionToken: Credentials.SessionToken, expiration: Credentials.Expiration, }; }; credentials = guestCredentialsProvider().catch(async err => { throw err; }); return this._loadCredentials(credentials, 'guest', false, null); } else { return e; } }); } private _setCredentialsFromFederation(params) { const { provider, token } = params; let { identity_id } = params; const domains = { google: 'accounts.google.com', facebook: 'graph.facebook.com', amazon: 'www.amazon.com', developer: 'cognito-identity.amazonaws.com', }; // Use custom provider url instead of the predefined ones const domain = domains[provider] || provider; if (!domain) { return Promise.reject('You must specify a federated provider'); } const logins = {}; logins[domain] = token; const { identityPoolId, region, identityPoolRegion } = this._config; if (!identityPoolId) { logger.debug('No Cognito Federated Identity pool provided'); return Promise.reject('No Cognito Federated Identity pool provided'); } if (!identityPoolRegion && !region) { logger.debug('region is not configured for getting the credentials'); return Promise.reject( 'region is not configured for getting the credentials' ); } const cognitoConfig = { region: identityPoolRegion ?? region }; const authenticatedCredentialsProvider = async () => { if (!identity_id) { const { IdentityId } = await getId(cognitoConfig, { IdentityPoolId: identityPoolId, Logins: logins, }); identity_id = IdentityId; } const { Credentials } = await getCredentialsForIdentity(cognitoConfig, { IdentityId: identity_id, Logins: logins, }); return { identityId: identity_id, accessKeyId: Credentials.AccessKeyId, secretAccessKey: Credentials.SecretKey, sessionToken: Credentials.SessionToken, expiration: Credentials.Expiration, }; }; const credentials = authenticatedCredentialsProvider().catch(async err => { throw err; }); return this._loadCredentials(credentials, 'federated', true, params); } private _setCredentialsFromSession(session): Promise { logger.debug('set credentials from session'); const idToken = session.getIdToken().getJwtToken(); const { region, userPoolId, identityPoolId, identityPoolRegion } = this._config; if (!identityPoolId) { logger.debug('No Cognito Federated Identity pool provided'); return Promise.reject('No Cognito Federated Identity pool provided'); } if (!identityPoolRegion && !region) { logger.debug('region is not configured for getting the credentials'); return Promise.reject( 'region is not configured for getting the credentials' ); } const key = 'cognito-idp.' + region + '.amazonaws.com/' + userPoolId; const logins = {}; logins[key] = idToken; const cognitoConfig = { region: identityPoolRegion ?? region }; /* Retreiving identityId with GetIdCommand to mimic the behavior in the following code in aws-sdk-v3: https://git.io/JeDxU Note: Retreive identityId from CredentialsProvider once aws-sdk-js v3 supports this. */ const credentialsProvider = async () => { // try to fetch the local stored guest identity, if found, we will associate it with the logins const guestIdentityId = await this._getGuestIdentityId(); let generatedOrRetrievedIdentityId; if (!guestIdentityId) { // for a first-time user, this will return a brand new identity // for a returning user, this will retrieve the previous identity assocaited with the logins const { IdentityId } = await getId(cognitoConfig, { IdentityPoolId: identityPoolId, Logins: logins, }); generatedOrRetrievedIdentityId = IdentityId; } const { Credentials: { AccessKeyId, Expiration, SecretKey, SessionToken }, // single source of truth for the primary identity associated with the logins // only if a guest identity is used for a first-time user, that guest identity will become its primary identity IdentityId: primaryIdentityId, } = await getCredentialsForIdentity(cognitoConfig, { IdentityId: guestIdentityId || generatedOrRetrievedIdentityId, Logins: logins, }); this._identityId = primaryIdentityId; if (guestIdentityId) { // if guestIdentity is found and used by GetCredentialsForIdentity // it will be linked to the logins provided, and disqualified as an unauth identity logger.debug( `The guest identity ${guestIdentityId} has been successfully linked to the logins` ); if (guestIdentityId === primaryIdentityId) { logger.debug( `The guest identity ${guestIdentityId} has become the primary identity` ); } // remove it from local storage to avoid being used as a guest Identity by _setCredentialsForGuest await this._removeGuestIdentityId(); } // https://github.com/aws/aws-sdk-js-v3/blob/main/packages/credential-provider-cognito-identity/src/fromCognitoIdentity.ts#L40 return { accessKeyId: AccessKeyId, secretAccessKey: SecretKey, sessionToken: SessionToken, expiration: Expiration, identityId: primaryIdentityId, }; }; const credentials = credentialsProvider().catch(async err => { throw err; }); return this._loadCredentials(credentials, 'userPool', true, null); } private _loadCredentials( credentials, source, authenticated, info ): Promise { const that = this; return new Promise((res, rej) => { credentials .then(async credentials => { logger.debug('Load credentials successfully', credentials); if (this._identityId && !credentials.identityId) { credentials['identityId'] = this._identityId; } that._credentials = credentials; that._credentials.authenticated = authenticated; that._credentials_source = source; that._nextCredentialsRefresh = new Date().getTime() + CREDENTIALS_TTL; if (source === 'federated') { const user = Object.assign( { id: this._credentials.identityId }, info.user ); const { provider, token, expires_at, identity_id } = info; try { this._storage.setItem( 'aws-amplify-federatedInfo', JSON.stringify({ provider, token, user, expires_at, identity_id, }) ); } catch (e) { logger.debug('Failed to put federated info into auth storage', e); } } if (source === 'guest') { await this._setGuestIdentityId(credentials.identityId); } res(that._credentials); return; }) .catch(err => { if (err) { logger.debug('Failed to load credentials', credentials); logger.debug('Error loading credentials', err); rej(err); return; } }); }); } public set(params, source): Promise { if (source === 'session') { return this._setCredentialsFromSession(params); } else if (source === 'federation') { return this._setCredentialsFromFederation(params); } else if (source === 'guest') { return this._setCredentialsForGuest(); } else { logger.debug('no source specified for setting credentials'); return Promise.reject('invalid source'); } } public async clear() { this._credentials = null; this._credentials_source = null; logger.debug('removing aws-amplify-federatedInfo from storage'); this._storage.removeItem('aws-amplify-federatedInfo'); } /* operations on local stored guest identity */ private async _getGuestIdentityId(): Promise { const { identityPoolId } = this._config; try { await this._storageSync; return this._storage.getItem( this._getCognitoIdentityIdStorageKey(identityPoolId) ); } catch (e) { logger.debug('Failed to get the cached guest identityId', e); } } private async _setGuestIdentityId(identityId: string) { const { identityPoolId } = this._config; try { await this._storageSync; this._storage.setItem( this._getCognitoIdentityIdStorageKey(identityPoolId), identityId ); } catch (e) { logger.debug('Failed to cache guest identityId', e); } } private async _removeGuestIdentityId() { const { identityPoolId } = this._config; logger.debug( `removing ${this._getCognitoIdentityIdStorageKey( identityPoolId )} from storage` ); this._storage.removeItem( this._getCognitoIdentityIdStorageKey(identityPoolId) ); } /** * Compact version of credentials * @param {Object} credentials * @return {Object} - Credentials */ public shear(credentials) { return { accessKeyId: credentials.accessKeyId, sessionToken: credentials.sessionToken, secretAccessKey: credentials.secretAccessKey, identityId: credentials.identityId, authenticated: credentials.authenticated, }; } } export const Credentials = new CredentialsClass(null); Amplify.register(Credentials);