diff --git a/etc/firebase-admin.auth.api.md b/etc/firebase-admin.auth.api.md index 16fd6a64e1..2986869ef2 100644 --- a/etc/firebase-admin.auth.api.md +++ b/etc/firebase-admin.auth.api.md @@ -55,6 +55,7 @@ export abstract class BaseAuth { generateEmailVerificationLink(email: string, actionCodeSettings?: ActionCodeSettings): Promise; generatePasswordResetLink(email: string, actionCodeSettings?: ActionCodeSettings): Promise; generateSignInWithEmailLink(email: string, actionCodeSettings: ActionCodeSettings): Promise; + generateVerifyAndChangeEmailLink(email: string, newEmail: string, actionCodeSettings?: ActionCodeSettings): Promise; getProviderConfig(providerId: string): Promise; getUser(uid: string): Promise; getUserByEmail(email: string): Promise; diff --git a/src/auth/auth-api-request.ts b/src/auth/auth-api-request.ts index 13018337da..a962a4f719 100644 --- a/src/auth/auth-api-request.ts +++ b/src/auth/auth-api-request.ts @@ -59,7 +59,7 @@ export const RESERVED_CLAIMS = [ /** List of supported email action request types. */ export const EMAIL_ACTION_REQUEST_TYPES = [ - 'PASSWORD_RESET', 'VERIFY_EMAIL', 'EMAIL_SIGNIN', + 'PASSWORD_RESET', 'VERIFY_EMAIL', 'EMAIL_SIGNIN', 'VERIFY_AND_CHANGE_EMAIL', ]; /** Maximum allowed number of characters in the custom claims payload. */ @@ -817,6 +817,11 @@ const FIREBASE_AUTH_GET_OOB_CODE = new ApiSettings('/accounts:sendOobCode', 'POS AuthClientErrorCode.INVALID_EMAIL, ); } + if (typeof request.newEmail !== 'undefined' && !validator.isEmail(request.newEmail)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_NEW_EMAIL, + ); + } if (EMAIL_ACTION_REQUEST_TYPES.indexOf(request.requestType) === -1) { throw new FirebaseAuthError( AuthClientErrorCode.INVALID_ARGUMENT, @@ -1599,12 +1604,19 @@ export abstract class AbstractAuthRequestHandler { * @param actionCodeSettings - The optional action code setings which defines whether * the link is to be handled by a mobile app and the additional state information to be passed in the * deep link, etc. Required when requestType == 'EMAIL_SIGNIN' + * @param newEmail - The email address the account is being updated to. + * Required only for VERIFY_AND_CHANGE_EMAIL requests. * @returns A promise that resolves with the email action link. */ public getEmailActionLink( requestType: string, email: string, - actionCodeSettings?: ActionCodeSettings): Promise { - let request = { requestType, email, returnOobLink: true }; + actionCodeSettings?: ActionCodeSettings, newEmail?: string): Promise { + let request = { + requestType, + email, + returnOobLink: true, + ...(typeof newEmail !== 'undefined') && { newEmail }, + }; // ActionCodeSettings required for email link sign-in to determine the url where the sign-in will // be completed. if (typeof actionCodeSettings === 'undefined' && requestType === 'EMAIL_SIGNIN') { @@ -1623,6 +1635,14 @@ export abstract class AbstractAuthRequestHandler { return Promise.reject(e); } } + if (requestType === 'VERIFY_AND_CHANGE_EMAIL' && typeof newEmail === 'undefined') { + return Promise.reject( + new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + "`newEmail` is required when `requestType` === 'VERIFY_AND_CHANGE_EMAIL'", + ), + ); + } return this.invokeRequestHandler(this.getAuthUrlBuilder(), FIREBASE_AUTH_GET_OOB_CODE, request) .then((response: any) => { // Return the link. diff --git a/src/auth/base-auth.ts b/src/auth/base-auth.ts index 7119dd2514..6f77e088f8 100644 --- a/src/auth/base-auth.ts +++ b/src/auth/base-auth.ts @@ -834,6 +834,35 @@ export abstract class BaseAuth { return this.authRequestHandler.getEmailActionLink('VERIFY_EMAIL', email, actionCodeSettings); } + /** + * Generates an out-of-band email action link to verify the user's ownership + * of the specified email. The {@link ActionCodeSettings} object provided + * as an argument to this method defines whether the link is to be handled by a + * mobile app or browser along with additional state information to be passed in + * the deep link, etc. + * + * @param email - The current email account. + * @param newEmail - The email address the account is being updated to. + * @param actionCodeSettings - The action + * code settings. If specified, the state/continue URL is set as the + * "continueUrl" parameter in the email verification link. The default email + * verification landing page will use this to display a link to go back to + * the app if it is installed. + * If the actionCodeSettings is not specified, no URL is appended to the + * action URL. + * The state URL provided must belong to a domain that is authorized + * in the console, or an error will be thrown. + * Mobile app redirects are only applicable if the developer configures + * and accepts the Firebase Dynamic Links terms of service. + * The Android package name and iOS bundle ID are respected only if they + * are configured in the same Firebase Auth project. + * @returns A promise that resolves with the generated link. + */ + public generateVerifyAndChangeEmailLink(email: string, newEmail: string, + actionCodeSettings?: ActionCodeSettings): Promise { + return this.authRequestHandler.getEmailActionLink('VERIFY_AND_CHANGE_EMAIL', email, actionCodeSettings, newEmail); + } + /** * Generates the out of band email action link to verify the user's ownership * of the specified email. The {@link ActionCodeSettings} object provided diff --git a/src/utils/error.ts b/src/utils/error.ts index 7989e7ecad..6c74748ed1 100644 --- a/src/utils/error.ts +++ b/src/utils/error.ts @@ -452,6 +452,10 @@ export class AuthClientErrorCode { code: 'invalid-email', message: 'The email address is improperly formatted.', }; + public static INVALID_NEW_EMAIL = { + code: 'invalid-new-email', + message: 'The new email address is improperly formatted.', + }; public static INVALID_ENROLLED_FACTORS = { code: 'invalid-enrolled-factors', message: 'The enrolled factors must be a valid array of MultiFactorInfo objects.', @@ -908,6 +912,8 @@ const AUTH_SERVER_TO_CLIENT_CODE: ServerToClientCode = { INVALID_DURATION: 'INVALID_SESSION_COOKIE_DURATION', // Invalid email provided. INVALID_EMAIL: 'INVALID_EMAIL', + // Invalid new email provided. + INVALID_NEW_EMAIL: 'INVALID_NEW_EMAIL', // Invalid tenant display name. This can be thrown on CreateTenant and UpdateTenant. INVALID_DISPLAY_NAME: 'INVALID_DISPLAY_NAME', // Invalid ID token provided. diff --git a/test/integration/auth.spec.ts b/test/integration/auth.spec.ts index 2308ca6879..e1005d9c4a 100644 --- a/test/integration/auth.spec.ts +++ b/test/integration/auth.spec.ts @@ -1073,6 +1073,7 @@ describe('admin.auth', () => { describe('Link operations', () => { const uid = generateRandomString(20).toLowerCase(); const email = uid + '@example.com'; + const newEmail = uid + 'new@example.com'; const newPassword = 'newPassword'; const userData = { uid, @@ -1152,6 +1153,31 @@ describe('admin.auth', () => { expect(result.user!.emailVerified).to.be.true; }); }); + + it('generateVerifyAndChangeEmailLink() should return a verification link', function() { + if (authEmulatorHost) { + return this.skip(); // Not yet supported in Auth Emulator. + } + // Ensure the user's email is verified. + return getAuth().updateUser(uid, { password: 'password', emailVerified: true }) + .then((userRecord) => { + expect(userRecord.emailVerified).to.be.true; + return getAuth().generateVerifyAndChangeEmailLink(email, newEmail, actionCodeSettings); + }) + .then((link) => { + const code = getActionCode(link); + expect(getContinueUrl(link)).equal(actionCodeSettings.url); + return clientAuth().applyActionCode(code); + }) + .then(() => { + return clientAuth().signInWithEmailAndPassword(newEmail, 'password'); + }) + .then((result) => { + expect(result.user).to.exist; + expect(result.user!.email).to.equal(newEmail); + expect(result.user!.emailVerified).to.be.true; + }); + }); }); describe('Tenant management operations', () => { diff --git a/test/unit/auth/auth-api-request.spec.ts b/test/unit/auth/auth-api-request.spec.ts index f948b27b17..574962df53 100644 --- a/test/unit/auth/auth-api-request.spec.ts +++ b/test/unit/auth/auth-api-request.spec.ts @@ -3065,6 +3065,7 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => { const path = handler.path('v1', '/accounts:sendOobCode', 'project_id'); const method = 'POST'; const email = 'user@example.com'; + const newEmail = 'usernew@example.com'; const actionCodeSettings = { url: 'https://www.example.com/path/file?a=1&b=2', handleCodeInApp: true, @@ -3110,12 +3111,14 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => { requestType, email, returnOobLink: true, + ...(requestType === 'VERIFY_AND_CHANGE_EMAIL') && { newEmail }, }, expectedActionCodeSettingsRequest); const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); stubs.push(stub); const requestHandler = handler.init(mockApp); - return requestHandler.getEmailActionLink(requestType, email, actionCodeSettings) + return requestHandler.getEmailActionLink(requestType, email, actionCodeSettings, + (requestType === 'VERIFY_AND_CHANGE_EMAIL') ? newEmail: undefined) .then((oobLink: string) => { expect(oobLink).to.be.equal(expectedLink); expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, method, requestData)); @@ -3124,7 +3127,7 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => { }); EMAIL_ACTION_REQUEST_TYPES.forEach((requestType) => { - if (requestType === 'EMAIL_SIGNIN') { + if (requestType === 'EMAIL_SIGNIN' || requestType === 'VERIFY_AND_CHANGE_EMAIL') { return; } it('should be fulfilled given requestType:' + requestType + ' and no ActionCodeSettings', () => { @@ -3145,6 +3148,25 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => { }); }); + it('should be fulfilled given a valid requestType: VERIFY_AND_CHANGE_EMAIL and no ActionCodeSettings', () => { + const VERIFY_AND_CHANGE_EMAIL = 'VERIFY_AND_CHANGE_EMAIL'; + const requestData = { + requestType: VERIFY_AND_CHANGE_EMAIL, + email, + returnOobLink: true, + newEmail, + }; + const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult); + stubs.push(stub); + + const requestHandler = handler.init(mockApp); + return requestHandler.getEmailActionLink(VERIFY_AND_CHANGE_EMAIL, email, undefined, newEmail) + .then((oobLink: string) => { + expect(oobLink).to.be.equal(expectedLink); + expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, method, requestData)); + }); + }); + it('should be rejected given requestType:EMAIL_SIGNIN and no ActionCodeSettings', () => { const invalidRequestType = 'EMAIL_SIGNIN'; const requestHandler = handler.init(mockApp); @@ -3153,6 +3175,22 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => { .should.eventually.be.rejected.and.have.property('code', 'auth/argument-error'); }); + it('should be rejected given requestType: VERIFY_AND_CHANGE and no new Email address', () => { + const requestHandler = handler.init(mockApp); + const expectedError = new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + '`newEmail` is required when `requestType` === \'VERIFY_AND_CHANGE_EMAIL\'', + ) + + return requestHandler.getEmailActionLink('VERIFY_AND_CHANGE_EMAIL', email) + .then(() => { + throw new Error('Unexpected success'); + }, (error) => { + // Invalid argument error should be thrown. + expect(error).to.deep.include(expectedError); + }); + }); + it('should be rejected given an invalid email', () => { const invalidEmail = 'invalid'; const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_EMAIL); @@ -3167,6 +3205,20 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => { }); }); + it('should be rejected given an invalid new email', () => { + const invalidNewEmail = 'invalid'; + const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_NEW_EMAIL); + + const requestHandler = handler.init(mockApp); + return requestHandler.getEmailActionLink('VERIFY_AND_CHANGE_EMAIL', email, actionCodeSettings, invalidNewEmail) + .then(() => { + throw new Error('Unexpected success'); + }, (error) => { + // Invalid new email error should be thrown. + expect(error).to.deep.include(expectedError); + }); + }); + it('should be rejected given an invalid request type', () => { const invalidRequestType = 'invalid'; const expectedError = new FirebaseAuthError( diff --git a/test/unit/auth/auth.spec.ts b/test/unit/auth/auth.spec.ts index 1e596c2721..0995a310d4 100644 --- a/test/unit/auth/auth.spec.ts +++ b/test/unit/auth/auth.spec.ts @@ -2869,10 +2869,12 @@ AUTH_CONFIGS.forEach((testConfig) => { { api: 'generatePasswordResetLink', requestType: 'PASSWORD_RESET', requiresSettings: false }, { api: 'generateEmailVerificationLink', requestType: 'VERIFY_EMAIL', requiresSettings: false }, { api: 'generateSignInWithEmailLink', requestType: 'EMAIL_SIGNIN', requiresSettings: true }, + { api: 'generateVerifyAndChangeEmailLink', requestType: 'VERIFY_AND_CHANGE_EMAIL', requiresSettings: false }, ]; emailActionFlows.forEach((emailActionFlow) => { describe(`${emailActionFlow.api}()`, () => { const email = 'user@example.com'; + const newEmail = 'usernew@example.com'; const actionCodeSettings = { url: 'https://www.example.com/path/file?a=1&b=2', handleCodeInApp: true, @@ -2898,32 +2900,71 @@ AUTH_CONFIGS.forEach((testConfig) => { }); it('should be rejected given no email', () => { - return (auth as any)[emailActionFlow.api](undefined, actionCodeSettings) + let args: any = [ undefined, actionCodeSettings ]; + if (emailActionFlow.api === 'generateVerifyAndChangeEmailLink') { + args = [ undefined, newEmail, actionCodeSettings ]; + } + return (auth as any)[emailActionFlow.api](...args) .should.eventually.be.rejected.and.have.property('code', 'auth/invalid-email'); }); it('should be rejected given an invalid email', () => { - return (auth as any)[emailActionFlow.api]('invalid', actionCodeSettings) + let args: any = [ 'invalid', actionCodeSettings ]; + if (emailActionFlow.api === 'generateVerifyAndChangeEmailLink') { + args = [ 'invalid', newEmail, actionCodeSettings ]; + } + return (auth as any)[emailActionFlow.api](...args) .should.eventually.be.rejected.and.have.property('code', 'auth/invalid-email'); }); + it('should be rejected given no new email when request type is `generateVerifyAndChangeEmailLink`', () => { + if (emailActionFlow.api === 'generateVerifyAndChangeEmailLink') { + return (auth as any)[emailActionFlow.api](email) + .should.eventually.be.rejected.and.have.property('code', 'auth/argument-error'); + } + }); + + it('should be rejected given an invalid new email when request type is `generateVerifyAndChangeEmailLink`', + () => { + if (emailActionFlow.api === 'generateVerifyAndChangeEmailLink') { + return (auth as any)[emailActionFlow.api](email, 'invalid') + .should.eventually.be.rejected.and.have.property('code', 'auth/invalid-new-email'); + } + }); + it('should be rejected given an invalid ActionCodeSettings object', () => { - return (auth as any)[emailActionFlow.api](email, 'invalid') + let args: any = [ email, 'invalid' ]; + if (emailActionFlow.api === 'generateVerifyAndChangeEmailLink') { + args = [ email, newEmail, 'invalid' ]; + } + return (auth as any)[emailActionFlow.api](...args) .should.eventually.be.rejected.and.have.property('code', 'auth/argument-error'); }); it('should be rejected given an app which returns null access tokens', () => { - return (nullAccessTokenAuth as any)[emailActionFlow.api](email, actionCodeSettings) + let args: any = [ email, actionCodeSettings ]; + if (emailActionFlow.api === 'generateVerifyAndChangeEmailLink') { + args = [ email, newEmail, actionCodeSettings ]; + } + return (nullAccessTokenAuth as any)[emailActionFlow.api](...args) .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); }); it('should be rejected given an app which returns invalid access tokens', () => { - return (malformedAccessTokenAuth as any)[emailActionFlow.api](email, actionCodeSettings) + let args: any = [ email, actionCodeSettings ]; + if (emailActionFlow.api === 'generateVerifyAndChangeEmailLink') { + args = [ email, newEmail, actionCodeSettings ]; + } + return (malformedAccessTokenAuth as any)[emailActionFlow.api](...args) .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); }); it('should be rejected given an app which fails to generate access tokens', () => { - return (rejectedPromiseAccessTokenAuth as any)[emailActionFlow.api](email, actionCodeSettings) + let args: any = [ email, actionCodeSettings ]; + if (emailActionFlow.api === 'generateVerifyAndChangeEmailLink') { + args = [ email, newEmail, actionCodeSettings ]; + } + return (rejectedPromiseAccessTokenAuth as any)[emailActionFlow.api](...args) .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); }); @@ -2932,7 +2973,11 @@ AUTH_CONFIGS.forEach((testConfig) => { const getEmailActionLinkStub = sinon.stub(testConfig.RequestHandler.prototype, 'getEmailActionLink') .resolves(expectedLink); stubs.push(getEmailActionLinkStub); - return (auth as any)[emailActionFlow.api](email, actionCodeSettings) + let args: any = [ email, actionCodeSettings ]; + if (emailActionFlow.api === 'generateVerifyAndChangeEmailLink') { + args = [ email, newEmail, actionCodeSettings ]; + } + return (auth as any)[emailActionFlow.api](...args) .then((actualLink: string) => { // Confirm underlying API called with expected parameters. expect(getEmailActionLinkStub).to.have.been.calledOnce.and.calledWith( @@ -2953,7 +2998,11 @@ AUTH_CONFIGS.forEach((testConfig) => { const getEmailActionLinkStub = sinon.stub(testConfig.RequestHandler.prototype, 'getEmailActionLink') .resolves(expectedLink); stubs.push(getEmailActionLinkStub); - return (auth as any)[emailActionFlow.api](email) + let args: any = [ email ]; + if (emailActionFlow.api === 'generateVerifyAndChangeEmailLink') { + args = [ email, newEmail ]; + } + return (auth as any)[emailActionFlow.api](...args) .then((actualLink: string) => { // Confirm underlying API called with expected parameters. expect(getEmailActionLinkStub).to.have.been.calledOnce.and.calledWith( @@ -2969,7 +3018,11 @@ AUTH_CONFIGS.forEach((testConfig) => { const getEmailActionLinkStub = sinon.stub(testConfig.RequestHandler.prototype, 'getEmailActionLink') .rejects(expectedError); stubs.push(getEmailActionLinkStub); - return (auth as any)[emailActionFlow.api](email, actionCodeSettings) + let args: any = [ email, actionCodeSettings ]; + if (emailActionFlow.api === 'generateVerifyAndChangeEmailLink') { + args = [ email, newEmail, actionCodeSettings ]; + } + return (auth as any)[emailActionFlow.api](...args) .then(() => { throw new Error('Unexpected success'); }, (error: any) => {