diff --git a/.circleci/config.yml b/.circleci/config.yml index d7f4208ad49..16348b676cd 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -453,6 +453,13 @@ jobs: sample_name: with-authenticator spec: ui-amplify-authenticator browser: << parameters.browser >> + - integ_test_js: + test_name: 'Sign In after Sign Up' + framework: react + category: auth + sample_name: auto-signin-after-signup + spec: auto-signin-after-signup + browser: << parameters.browser >> integ_angular_auth: parameters: browser: diff --git a/packages/auth/__tests__/auth-unit-test.ts b/packages/auth/__tests__/auth-unit-test.ts index a877b935538..6911778faa9 100644 --- a/packages/auth/__tests__/auth-unit-test.ts +++ b/packages/auth/__tests__/auth-unit-test.ts @@ -8,6 +8,7 @@ import { CognitoIdToken, CognitoAccessToken, NodeCallback, + ISignUpResult, } from 'amazon-cognito-identity-js'; const MAX_DEVICES: number = 60; @@ -319,6 +320,14 @@ const authOptionsWithHostedUIConfig: AuthOptions = { responseType: 'code', }, }; +const authOptionConfirmationLink: AuthOptions = { + userPoolId: 'awsUserPoolsId', + userPoolWebClientId: 'awsUserPoolsWebClientId', + region: 'region', + identityPoolId: 'awsCognitoIdentityPoolId', + mandatorySignIn: false, + signUpVerificationMethod: 'link', +}; const authOptionsWithClientMetadata: AuthOptions = { userPoolId: 'awsUserPoolsId', @@ -343,6 +352,13 @@ const userPool = new CognitoUserPool({ ClientId: authOptions.userPoolWebClientId, }); +const signUpResult: ISignUpResult = { + user: null, + userConfirmed: true, + userSub: 'userSub', + codeDeliveryDetails: null, +}; + const idToken = new CognitoIdToken({ IdToken: 'idToken' }); const accessToken = new CognitoAccessToken({ AccessToken: 'accessToken' }); @@ -558,6 +574,122 @@ describe('auth unit test', () => { }); }); + describe('autoSignInAfterSignUp', () => { + test('happy case auto confirm', async () => { + const spyon = jest + .spyOn(CognitoUserPool.prototype, 'signUp') + .mockImplementationOnce( + ( + username, + password, + signUpAttributeList, + validationData, + callback, + clientMetadata + ) => { + callback(null, signUpResult); + } + ); + const signInSpyon = jest.spyOn(CognitoUser.prototype, 'authenticateUser'); + const auth = new Auth(authOptions); + const attrs = { + username: 'username', + password: 'password', + attributes: { + email: 'email', + phone_number: 'phone_number', + otherAttrs: 'otherAttrs', + }, + autoSignIn: { enabled: true }, + }; + expect(await auth.signUp(attrs)).toBe(signUpResult); + expect(signInSpyon).toHaveBeenCalledTimes(1); + spyon.mockClear(); + signInSpyon.mockClear(); + }); + + test('happy case confirmation code', async () => { + const spyon = jest.spyOn(CognitoUserPool.prototype, 'signUp'); + const confirmSpyon = jest.spyOn( + CognitoUser.prototype, + 'confirmRegistration' + ); + const signInSpyon = jest.spyOn(CognitoUser.prototype, 'authenticateUser'); + const auth = new Auth(authOptions); + const attrs = { + username: 'username', + password: 'password', + attributes: { + email: 'email', + phone_number: 'phone_number', + otherAttrs: 'otherAttrs', + }, + autoSignIn: { enabled: true }, + }; + expect(await auth.signUp(attrs)).toBe('signUpResult'); + expect(await auth.confirmSignUp('username', 'code')).toBe('Success'); + expect(signInSpyon).toHaveBeenCalledTimes(1); + spyon.mockClear(); + confirmSpyon.mockClear(); + signInSpyon.mockClear(); + }); + + test('happy case confirmation link', async () => { + jest.useFakeTimers(); + const spyon = jest.spyOn(CognitoUserPool.prototype, 'signUp'); + const signInSpyon = jest.spyOn(CognitoUser.prototype, 'authenticateUser'); + const auth = new Auth(authOptionConfirmationLink); + const attrs = { + username: 'username', + password: 'password', + attributes: { + email: 'email', + phone_number: 'phone_number', + otherAttrs: 'otherAttrs', + }, + autoSignIn: { enabled: true }, + }; + expect(await auth.signUp(attrs)).toBe('signUpResult'); + jest.advanceTimersByTime(11000); + expect(signInSpyon).toHaveBeenCalledTimes(2); + spyon.mockClear(); + signInSpyon.mockClear(); + }); + + test('fail confirmation code', async () => { + const spyon = jest.spyOn(CognitoUserPool.prototype, 'signUp'); + const confirmSpyon = jest + .spyOn(CognitoUser.prototype, 'confirmRegistration') + .mockImplementationOnce( + (confirmationCode, forceAliasCreation, callback) => { + callback('err', null); + } + ); + const signInSpyon = jest.spyOn(CognitoUser.prototype, 'authenticateUser'); + const auth = new Auth(authOptions); + const attrs = { + username: 'username', + password: 'password', + attributes: { + email: 'email', + phone_number: 'phone_number', + otherAttrs: 'otherAttrs', + }, + autoSignIn: { enabled: true }, + }; + expect(await auth.signUp(attrs)).toBe('signUpResult'); + try { + await auth.confirmSignUp('username', 'code'); + } catch (e) { + expect(e).toBe('err'); + } + expect(signInSpyon).toHaveBeenCalledTimes(0); + spyon.mockClear(); + confirmSpyon.mockClear(); + signInSpyon.mockClear(); + }); + }); + describe('confirmSignUp', () => { test('happy case', async () => { const spyon = jest.spyOn(CognitoUser.prototype, 'confirmRegistration'); @@ -1517,6 +1649,7 @@ describe('auth unit test', () => { describe('currentSession', () => { afterEach(() => { jest.clearAllMocks(); + jest.useRealTimers(); }); test('happy case', async () => { const auth = new Auth(authOptions); diff --git a/packages/auth/src/Auth.ts b/packages/auth/src/Auth.ts index e82cc8fb301..05e9604d1b4 100644 --- a/packages/auth/src/Auth.ts +++ b/packages/auth/src/Auth.ts @@ -44,6 +44,7 @@ import { browserOrNode, UniversalStorage, urlSafeDecode, + HubCallback, } from '@aws-amplify/core'; import { CookieStorage, @@ -70,6 +71,7 @@ import { default as urlListener } from './urlListener'; import { AuthError, NoUserPoolError } from './Errors'; import { AuthErrorTypes, + AutoSignInOptions, CognitoHostedUIIdentityProvider, IAuthDevice, } from './types/Auth'; @@ -95,6 +97,8 @@ const dispatchAuthEvent = (event: string, data: any, message: string) => { // https://docs.aws.amazon.com/cognito-user-identity-pools/latest/APIReference/API_ListDevices.html#API_ListDevices_RequestSyntax const MAX_DEVICES = 60; +const MAX_AUTOSIGNIN_POLLING_MS = 3 * 60 * 1000; + /** * Provide authentication steps */ @@ -107,6 +111,7 @@ export class AuthClass { private _storageSync; private oAuthFlowInProgress: boolean = false; private pendingSignIn: ReturnType | null; + private autoSignInInitiated: boolean = false; Credentials = Credentials; @@ -257,6 +262,24 @@ export class AuthClass { null, `The Auth category has been configured successfully` ); + + if ( + !this.autoSignInInitiated && + typeof this._storage['getItem'] === 'function' + ) { + const pollingInitiated = this.isTrueStorageValue( + 'amplify-polling-started' + ); + if (pollingInitiated) { + dispatchAuthEvent( + 'autoSignIn_failure', + null, + AuthErrorTypes.AutoSignInError + ); + this._storage.removeItem('amplify-auto-sign-in'); + } + this._storage.removeItem('amplify-polling-started'); + } return this._config; } @@ -295,6 +318,9 @@ export class AuthClass { const attributes: CognitoUserAttribute[] = []; let validationData: CognitoUserAttribute[] = null; let clientMetadata; + let autoSignIn: AutoSignInOptions = { enabled: false }; + let autoSignInValidationData = {}; + let autoSignInClientMetaData: ClientMetaData = {}; if (params && typeof params === 'string') { username = params; @@ -345,6 +371,13 @@ export class AuthClass { ); }); } + + autoSignIn = params.autoSignIn ?? { enabled: false }; + if (autoSignIn.enabled) { + this._storage.setItem('amplify-auto-sign-in', true); + autoSignInValidationData = autoSignIn.validationData ?? {}; + autoSignInClientMetaData = autoSignIn.clientMetaData ?? {}; + } } else { return this.rejectAuthError(AuthErrorTypes.SignUpError); } @@ -379,6 +412,15 @@ export class AuthClass { data, `${username} has signed up successfully` ); + if (autoSignIn.enabled) { + this.handleAutoSignIn( + username, + password, + autoSignInValidationData, + autoSignInClientMetaData, + data + ); + } resolve(data); } }, @@ -387,6 +429,97 @@ export class AuthClass { }); } + private handleAutoSignIn( + username: string, + password: string, + validationData: {}, + clientMetadata: any, + data: any + ) { + this.autoSignInInitiated = true; + const authDetails = new AuthenticationDetails({ + Username: username, + Password: password, + ValidationData: validationData, + ClientMetadata: clientMetadata, + }); + if (data.userConfirmed) { + this.signInAfterUserConfirmed(authDetails); + } else if (this._config.signUpVerificationMethod === 'link') { + this.handleLinkAutoSignIn(authDetails); + } else { + this.handleCodeAutoSignIn(authDetails); + } + } + + private handleCodeAutoSignIn(authDetails: AuthenticationDetails) { + const listenEvent = ({ payload }) => { + if (payload.event === 'confirmSignUp') { + this.signInAfterUserConfirmed(authDetails, listenEvent); + } + }; + Hub.listen('auth', listenEvent); + } + + private handleLinkAutoSignIn(authDetails: AuthenticationDetails) { + this._storage.setItem('amplify-polling-started', true); + const start = Date.now(); + const autoSignInPollingIntervalId = setInterval(() => { + if (Date.now() - start > MAX_AUTOSIGNIN_POLLING_MS) { + clearInterval(autoSignInPollingIntervalId); + dispatchAuthEvent( + 'autoSignIn_failure', + null, + 'Please confirm your account and use your credentials to sign in.' + ); + this._storage.removeItem('amplify-auto-sign-in'); + } else { + this.signInAfterUserConfirmed( + authDetails, + null, + autoSignInPollingIntervalId + ); + } + }, 5000); + } + + private async signInAfterUserConfirmed( + authDetails: AuthenticationDetails, + listenEvent?: HubCallback, + autoSignInPollingIntervalId?: ReturnType + ) { + const user = this.createCognitoUser(authDetails.getUsername()); + try { + await user.authenticateUser( + authDetails, + this.authCallbacks( + user, + value => { + dispatchAuthEvent( + 'autoSignIn', + value, + `${authDetails.getUsername()} has signed in successfully` + ); + if (listenEvent) { + Hub.remove('auth', listenEvent); + } + if (autoSignInPollingIntervalId) { + clearInterval(autoSignInPollingIntervalId); + this._storage.removeItem('amplify-polling-started'); + } + this._storage.removeItem('amplify-auto-sign-in'); + }, + error => { + logger.error(error); + this._storage.removeItem('amplify-auto-sign-in'); + } + ) + ); + } catch (error) { + logger.error(error); + } + } + /** * Send the verification code to confirm sign up * @param {String} username - The username to be confirmed @@ -429,6 +562,20 @@ export class AuthClass { if (err) { reject(err); } else { + dispatchAuthEvent( + 'confirmSignUp', + data, + `${username} has been confirmed successfully` + ); + const autoSignIn = this.isTrueStorageValue('amplify-auto-sign-in'); + if (autoSignIn && !this.autoSignInInitiated) { + dispatchAuthEvent( + 'autoSignIn_failure', + null, + AuthErrorTypes.AutoSignInError + ); + this._storage.removeItem('amplify-auto-sign-in'); + } resolve(data); } }, @@ -437,6 +584,11 @@ export class AuthClass { }); } + private isTrueStorageValue(value: string) { + const item = this._storage.getItem(value); + return item ? item === 'true' : false; + } + /** * Resend the verification code * @param {String} username - The username to be confirmed diff --git a/packages/auth/src/Errors.ts b/packages/auth/src/Errors.ts index 2fc7c79d1b8..dcc34d1d886 100644 --- a/packages/auth/src/Errors.ts +++ b/packages/auth/src/Errors.ts @@ -110,6 +110,9 @@ export const authErrorMessages: AuthErrorMessages = { networkError: { message: AuthErrorStrings.NETWORK_ERROR, }, + autoSignInError: { + message: AuthErrorStrings.AUTOSIGNIN_ERROR, + }, default: { message: AuthErrorStrings.DEFAULT_MSG, }, diff --git a/packages/auth/src/common/AuthErrorStrings.ts b/packages/auth/src/common/AuthErrorStrings.ts index 249a4927610..ae6e20d184d 100644 --- a/packages/auth/src/common/AuthErrorStrings.ts +++ b/packages/auth/src/common/AuthErrorStrings.ts @@ -13,4 +13,5 @@ export enum AuthErrorStrings { NO_USER_SESSION = 'Failed to get the session because the user is empty', NETWORK_ERROR = 'Network Error', DEVICE_CONFIG = 'Device tracking has not been configured in this User Pool', + AUTOSIGNIN_ERROR = 'Please use your credentials to sign in', } diff --git a/packages/auth/src/types/Auth.ts b/packages/auth/src/types/Auth.ts index 17d0d440852..cbc668a1aa2 100644 --- a/packages/auth/src/types/Auth.ts +++ b/packages/auth/src/types/Auth.ts @@ -25,6 +25,7 @@ export interface SignUpParams { attributes?: object; validationData?: { [key: string]: any }; clientMetadata?: { [key: string]: string }; + autoSignIn?: AutoSignInOptions; } export interface AuthCache { @@ -50,6 +51,7 @@ export interface AuthOptions { identityPoolRegion?: string; clientMetadata?: any; endpoint?: string; + signUpVerificationMethod?: 'code' | 'link'; } export enum CognitoHostedUIIdentityProvider { @@ -201,6 +203,7 @@ export enum AuthErrorTypes { Default = 'default', DeviceConfig = 'deviceConfig', NetworkError = 'networkError', + AutoSignInError = 'autoSignInError', } export type AuthErrorMessages = { [key in AuthErrorTypes]: AuthErrorMessage }; @@ -228,6 +231,12 @@ export interface IAuthDevice { name: string; } +export interface AutoSignInOptions { + enabled: boolean; + clientMetaData?: ClientMetaData; + validationData?: { [key: string]: any }; +} + export enum GRAPHQL_AUTH_MODE { API_KEY = 'API_KEY', AWS_IAM = 'AWS_IAM', diff --git a/packages/core/__tests__/parseMobileHubConfig-test.ts b/packages/core/__tests__/parseMobileHubConfig-test.ts index 9df261614be..329ffca688c 100644 --- a/packages/core/__tests__/parseMobileHubConfig-test.ts +++ b/packages/core/__tests__/parseMobileHubConfig-test.ts @@ -46,6 +46,7 @@ describe('Parser', () => { region: '', userPoolId: 'b', userPoolWebClientId: '', + signUpVerificationMethod: 'code', }, Geo: { AmazonLocationService: { diff --git a/packages/core/src/Parser.ts b/packages/core/src/Parser.ts index 91328d9e64f..a7e3c0683fc 100644 --- a/packages/core/src/Parser.ts +++ b/packages/core/src/Parser.ts @@ -25,6 +25,8 @@ export const parseMobileHubConfig = (config): AmplifyConfig => { identityPoolId: config['aws_cognito_identity_pool_id'], identityPoolRegion: config['aws_cognito_region'], mandatorySignIn: config['aws_mandatory_sign_in'] === 'enable', + signUpVerificationMethod: + config['aws_cognito_sign_up_verification_method'] || 'code', }; }