Skip to content

Commit b2a28ae

Browse files
feat(auth): Support generate oob code request type VERIFY_AND_CHANGE_EMAIL (#1633)
* Supported generate OOB code from VERIFY_AND_CHANGE_EMAIL request type. * Added integration tests.
1 parent 75407f4 commit b2a28ae

File tree

7 files changed

+201
-14
lines changed

7 files changed

+201
-14
lines changed

etc/firebase-admin.auth.api.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ export abstract class BaseAuth {
5555
generateEmailVerificationLink(email: string, actionCodeSettings?: ActionCodeSettings): Promise<string>;
5656
generatePasswordResetLink(email: string, actionCodeSettings?: ActionCodeSettings): Promise<string>;
5757
generateSignInWithEmailLink(email: string, actionCodeSettings: ActionCodeSettings): Promise<string>;
58+
generateVerifyAndChangeEmailLink(email: string, newEmail: string, actionCodeSettings?: ActionCodeSettings): Promise<string>;
5859
getProviderConfig(providerId: string): Promise<AuthProviderConfig>;
5960
getUser(uid: string): Promise<UserRecord>;
6061
getUserByEmail(email: string): Promise<UserRecord>;

src/auth/auth-api-request.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ export const RESERVED_CLAIMS = [
5959

6060
/** List of supported email action request types. */
6161
export const EMAIL_ACTION_REQUEST_TYPES = [
62-
'PASSWORD_RESET', 'VERIFY_EMAIL', 'EMAIL_SIGNIN',
62+
'PASSWORD_RESET', 'VERIFY_EMAIL', 'EMAIL_SIGNIN', 'VERIFY_AND_CHANGE_EMAIL',
6363
];
6464

6565
/** 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
817817
AuthClientErrorCode.INVALID_EMAIL,
818818
);
819819
}
820+
if (typeof request.newEmail !== 'undefined' && !validator.isEmail(request.newEmail)) {
821+
throw new FirebaseAuthError(
822+
AuthClientErrorCode.INVALID_NEW_EMAIL,
823+
);
824+
}
820825
if (EMAIL_ACTION_REQUEST_TYPES.indexOf(request.requestType) === -1) {
821826
throw new FirebaseAuthError(
822827
AuthClientErrorCode.INVALID_ARGUMENT,
@@ -1599,12 +1604,19 @@ export abstract class AbstractAuthRequestHandler {
15991604
* @param actionCodeSettings - The optional action code setings which defines whether
16001605
* the link is to be handled by a mobile app and the additional state information to be passed in the
16011606
* deep link, etc. Required when requestType == 'EMAIL_SIGNIN'
1607+
* @param newEmail - The email address the account is being updated to.
1608+
* Required only for VERIFY_AND_CHANGE_EMAIL requests.
16021609
* @returns A promise that resolves with the email action link.
16031610
*/
16041611
public getEmailActionLink(
16051612
requestType: string, email: string,
1606-
actionCodeSettings?: ActionCodeSettings): Promise<string> {
1607-
let request = { requestType, email, returnOobLink: true };
1613+
actionCodeSettings?: ActionCodeSettings, newEmail?: string): Promise<string> {
1614+
let request = {
1615+
requestType,
1616+
email,
1617+
returnOobLink: true,
1618+
...(typeof newEmail !== 'undefined') && { newEmail },
1619+
};
16081620
// ActionCodeSettings required for email link sign-in to determine the url where the sign-in will
16091621
// be completed.
16101622
if (typeof actionCodeSettings === 'undefined' && requestType === 'EMAIL_SIGNIN') {
@@ -1623,6 +1635,14 @@ export abstract class AbstractAuthRequestHandler {
16231635
return Promise.reject(e);
16241636
}
16251637
}
1638+
if (requestType === 'VERIFY_AND_CHANGE_EMAIL' && typeof newEmail === 'undefined') {
1639+
return Promise.reject(
1640+
new FirebaseAuthError(
1641+
AuthClientErrorCode.INVALID_ARGUMENT,
1642+
"`newEmail` is required when `requestType` === 'VERIFY_AND_CHANGE_EMAIL'",
1643+
),
1644+
);
1645+
}
16261646
return this.invokeRequestHandler(this.getAuthUrlBuilder(), FIREBASE_AUTH_GET_OOB_CODE, request)
16271647
.then((response: any) => {
16281648
// Return the link.

src/auth/base-auth.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -834,6 +834,35 @@ export abstract class BaseAuth {
834834
return this.authRequestHandler.getEmailActionLink('VERIFY_EMAIL', email, actionCodeSettings);
835835
}
836836

837+
/**
838+
* Generates an out-of-band email action link to verify the user's ownership
839+
* of the specified email. The {@link ActionCodeSettings} object provided
840+
* as an argument to this method defines whether the link is to be handled by a
841+
* mobile app or browser along with additional state information to be passed in
842+
* the deep link, etc.
843+
*
844+
* @param email - The current email account.
845+
* @param newEmail - The email address the account is being updated to.
846+
* @param actionCodeSettings - The action
847+
* code settings. If specified, the state/continue URL is set as the
848+
* "continueUrl" parameter in the email verification link. The default email
849+
* verification landing page will use this to display a link to go back to
850+
* the app if it is installed.
851+
* If the actionCodeSettings is not specified, no URL is appended to the
852+
* action URL.
853+
* The state URL provided must belong to a domain that is authorized
854+
* in the console, or an error will be thrown.
855+
* Mobile app redirects are only applicable if the developer configures
856+
* and accepts the Firebase Dynamic Links terms of service.
857+
* The Android package name and iOS bundle ID are respected only if they
858+
* are configured in the same Firebase Auth project.
859+
* @returns A promise that resolves with the generated link.
860+
*/
861+
public generateVerifyAndChangeEmailLink(email: string, newEmail: string,
862+
actionCodeSettings?: ActionCodeSettings): Promise<string> {
863+
return this.authRequestHandler.getEmailActionLink('VERIFY_AND_CHANGE_EMAIL', email, actionCodeSettings, newEmail);
864+
}
865+
837866
/**
838867
* Generates the out of band email action link to verify the user's ownership
839868
* of the specified email. The {@link ActionCodeSettings} object provided

src/utils/error.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,10 @@ export class AuthClientErrorCode {
452452
code: 'invalid-email',
453453
message: 'The email address is improperly formatted.',
454454
};
455+
public static INVALID_NEW_EMAIL = {
456+
code: 'invalid-new-email',
457+
message: 'The new email address is improperly formatted.',
458+
};
455459
public static INVALID_ENROLLED_FACTORS = {
456460
code: 'invalid-enrolled-factors',
457461
message: 'The enrolled factors must be a valid array of MultiFactorInfo objects.',
@@ -908,6 +912,8 @@ const AUTH_SERVER_TO_CLIENT_CODE: ServerToClientCode = {
908912
INVALID_DURATION: 'INVALID_SESSION_COOKIE_DURATION',
909913
// Invalid email provided.
910914
INVALID_EMAIL: 'INVALID_EMAIL',
915+
// Invalid new email provided.
916+
INVALID_NEW_EMAIL: 'INVALID_NEW_EMAIL',
911917
// Invalid tenant display name. This can be thrown on CreateTenant and UpdateTenant.
912918
INVALID_DISPLAY_NAME: 'INVALID_DISPLAY_NAME',
913919
// Invalid ID token provided.

test/integration/auth.spec.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1073,6 +1073,7 @@ describe('admin.auth', () => {
10731073
describe('Link operations', () => {
10741074
const uid = generateRandomString(20).toLowerCase();
10751075
const email = uid + '@example.com';
1076+
const newEmail = uid + '[email protected]';
10761077
const newPassword = 'newPassword';
10771078
const userData = {
10781079
uid,
@@ -1152,6 +1153,31 @@ describe('admin.auth', () => {
11521153
expect(result.user!.emailVerified).to.be.true;
11531154
});
11541155
});
1156+
1157+
it('generateVerifyAndChangeEmailLink() should return a verification link', function() {
1158+
if (authEmulatorHost) {
1159+
return this.skip(); // Not yet supported in Auth Emulator.
1160+
}
1161+
// Ensure the user's email is verified.
1162+
return getAuth().updateUser(uid, { password: 'password', emailVerified: true })
1163+
.then((userRecord) => {
1164+
expect(userRecord.emailVerified).to.be.true;
1165+
return getAuth().generateVerifyAndChangeEmailLink(email, newEmail, actionCodeSettings);
1166+
})
1167+
.then((link) => {
1168+
const code = getActionCode(link);
1169+
expect(getContinueUrl(link)).equal(actionCodeSettings.url);
1170+
return clientAuth().applyActionCode(code);
1171+
})
1172+
.then(() => {
1173+
return clientAuth().signInWithEmailAndPassword(newEmail, 'password');
1174+
})
1175+
.then((result) => {
1176+
expect(result.user).to.exist;
1177+
expect(result.user!.email).to.equal(newEmail);
1178+
expect(result.user!.emailVerified).to.be.true;
1179+
});
1180+
});
11551181
});
11561182

11571183
describe('Tenant management operations', () => {

test/unit/auth/auth-api-request.spec.ts

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3065,6 +3065,7 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => {
30653065
const path = handler.path('v1', '/accounts:sendOobCode', 'project_id');
30663066
const method = 'POST';
30673067
const email = '[email protected]';
3068+
const newEmail = '[email protected]';
30683069
const actionCodeSettings = {
30693070
url: 'https://www.example.com/path/file?a=1&b=2',
30703071
handleCodeInApp: true,
@@ -3110,12 +3111,14 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => {
31103111
requestType,
31113112
email,
31123113
returnOobLink: true,
3114+
...(requestType === 'VERIFY_AND_CHANGE_EMAIL') && { newEmail },
31133115
}, expectedActionCodeSettingsRequest);
31143116
const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult);
31153117
stubs.push(stub);
31163118

31173119
const requestHandler = handler.init(mockApp);
3118-
return requestHandler.getEmailActionLink(requestType, email, actionCodeSettings)
3120+
return requestHandler.getEmailActionLink(requestType, email, actionCodeSettings,
3121+
(requestType === 'VERIFY_AND_CHANGE_EMAIL') ? newEmail: undefined)
31193122
.then((oobLink: string) => {
31203123
expect(oobLink).to.be.equal(expectedLink);
31213124
expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, method, requestData));
@@ -3124,7 +3127,7 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => {
31243127
});
31253128

31263129
EMAIL_ACTION_REQUEST_TYPES.forEach((requestType) => {
3127-
if (requestType === 'EMAIL_SIGNIN') {
3130+
if (requestType === 'EMAIL_SIGNIN' || requestType === 'VERIFY_AND_CHANGE_EMAIL') {
31283131
return;
31293132
}
31303133
it('should be fulfilled given requestType:' + requestType + ' and no ActionCodeSettings', () => {
@@ -3145,6 +3148,25 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => {
31453148
});
31463149
});
31473150

3151+
it('should be fulfilled given a valid requestType: VERIFY_AND_CHANGE_EMAIL and no ActionCodeSettings', () => {
3152+
const VERIFY_AND_CHANGE_EMAIL = 'VERIFY_AND_CHANGE_EMAIL';
3153+
const requestData = {
3154+
requestType: VERIFY_AND_CHANGE_EMAIL,
3155+
email,
3156+
returnOobLink: true,
3157+
newEmail,
3158+
};
3159+
const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult);
3160+
stubs.push(stub);
3161+
3162+
const requestHandler = handler.init(mockApp);
3163+
return requestHandler.getEmailActionLink(VERIFY_AND_CHANGE_EMAIL, email, undefined, newEmail)
3164+
.then((oobLink: string) => {
3165+
expect(oobLink).to.be.equal(expectedLink);
3166+
expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, method, requestData));
3167+
});
3168+
});
3169+
31483170
it('should be rejected given requestType:EMAIL_SIGNIN and no ActionCodeSettings', () => {
31493171
const invalidRequestType = 'EMAIL_SIGNIN';
31503172
const requestHandler = handler.init(mockApp);
@@ -3153,6 +3175,22 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => {
31533175
.should.eventually.be.rejected.and.have.property('code', 'auth/argument-error');
31543176
});
31553177

3178+
it('should be rejected given requestType: VERIFY_AND_CHANGE and no new Email address', () => {
3179+
const requestHandler = handler.init(mockApp);
3180+
const expectedError = new FirebaseAuthError(
3181+
AuthClientErrorCode.INVALID_ARGUMENT,
3182+
'`newEmail` is required when `requestType` === \'VERIFY_AND_CHANGE_EMAIL\'',
3183+
)
3184+
3185+
return requestHandler.getEmailActionLink('VERIFY_AND_CHANGE_EMAIL', email)
3186+
.then(() => {
3187+
throw new Error('Unexpected success');
3188+
}, (error) => {
3189+
// Invalid argument error should be thrown.
3190+
expect(error).to.deep.include(expectedError);
3191+
});
3192+
});
3193+
31563194
it('should be rejected given an invalid email', () => {
31573195
const invalidEmail = 'invalid';
31583196
const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_EMAIL);
@@ -3167,6 +3205,20 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => {
31673205
});
31683206
});
31693207

3208+
it('should be rejected given an invalid new email', () => {
3209+
const invalidNewEmail = 'invalid';
3210+
const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_NEW_EMAIL);
3211+
3212+
const requestHandler = handler.init(mockApp);
3213+
return requestHandler.getEmailActionLink('VERIFY_AND_CHANGE_EMAIL', email, actionCodeSettings, invalidNewEmail)
3214+
.then(() => {
3215+
throw new Error('Unexpected success');
3216+
}, (error) => {
3217+
// Invalid new email error should be thrown.
3218+
expect(error).to.deep.include(expectedError);
3219+
});
3220+
});
3221+
31703222
it('should be rejected given an invalid request type', () => {
31713223
const invalidRequestType = 'invalid';
31723224
const expectedError = new FirebaseAuthError(

test/unit/auth/auth.spec.ts

Lines changed: 62 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2869,10 +2869,12 @@ AUTH_CONFIGS.forEach((testConfig) => {
28692869
{ api: 'generatePasswordResetLink', requestType: 'PASSWORD_RESET', requiresSettings: false },
28702870
{ api: 'generateEmailVerificationLink', requestType: 'VERIFY_EMAIL', requiresSettings: false },
28712871
{ api: 'generateSignInWithEmailLink', requestType: 'EMAIL_SIGNIN', requiresSettings: true },
2872+
{ api: 'generateVerifyAndChangeEmailLink', requestType: 'VERIFY_AND_CHANGE_EMAIL', requiresSettings: false },
28722873
];
28732874
emailActionFlows.forEach((emailActionFlow) => {
28742875
describe(`${emailActionFlow.api}()`, () => {
28752876
const email = '[email protected]';
2877+
const newEmail = '[email protected]';
28762878
const actionCodeSettings = {
28772879
url: 'https://www.example.com/path/file?a=1&b=2',
28782880
handleCodeInApp: true,
@@ -2898,32 +2900,71 @@ AUTH_CONFIGS.forEach((testConfig) => {
28982900
});
28992901

29002902
it('should be rejected given no email', () => {
2901-
return (auth as any)[emailActionFlow.api](undefined, actionCodeSettings)
2903+
let args: any = [ undefined, actionCodeSettings ];
2904+
if (emailActionFlow.api === 'generateVerifyAndChangeEmailLink') {
2905+
args = [ undefined, newEmail, actionCodeSettings ];
2906+
}
2907+
return (auth as any)[emailActionFlow.api](...args)
29022908
.should.eventually.be.rejected.and.have.property('code', 'auth/invalid-email');
29032909
});
29042910

29052911
it('should be rejected given an invalid email', () => {
2906-
return (auth as any)[emailActionFlow.api]('invalid', actionCodeSettings)
2912+
let args: any = [ 'invalid', actionCodeSettings ];
2913+
if (emailActionFlow.api === 'generateVerifyAndChangeEmailLink') {
2914+
args = [ 'invalid', newEmail, actionCodeSettings ];
2915+
}
2916+
return (auth as any)[emailActionFlow.api](...args)
29072917
.should.eventually.be.rejected.and.have.property('code', 'auth/invalid-email');
29082918
});
29092919

2920+
it('should be rejected given no new email when request type is `generateVerifyAndChangeEmailLink`', () => {
2921+
if (emailActionFlow.api === 'generateVerifyAndChangeEmailLink') {
2922+
return (auth as any)[emailActionFlow.api](email)
2923+
.should.eventually.be.rejected.and.have.property('code', 'auth/argument-error');
2924+
}
2925+
});
2926+
2927+
it('should be rejected given an invalid new email when request type is `generateVerifyAndChangeEmailLink`',
2928+
() => {
2929+
if (emailActionFlow.api === 'generateVerifyAndChangeEmailLink') {
2930+
return (auth as any)[emailActionFlow.api](email, 'invalid')
2931+
.should.eventually.be.rejected.and.have.property('code', 'auth/invalid-new-email');
2932+
}
2933+
});
2934+
29102935
it('should be rejected given an invalid ActionCodeSettings object', () => {
2911-
return (auth as any)[emailActionFlow.api](email, 'invalid')
2936+
let args: any = [ email, 'invalid' ];
2937+
if (emailActionFlow.api === 'generateVerifyAndChangeEmailLink') {
2938+
args = [ email, newEmail, 'invalid' ];
2939+
}
2940+
return (auth as any)[emailActionFlow.api](...args)
29122941
.should.eventually.be.rejected.and.have.property('code', 'auth/argument-error');
29132942
});
29142943

29152944
it('should be rejected given an app which returns null access tokens', () => {
2916-
return (nullAccessTokenAuth as any)[emailActionFlow.api](email, actionCodeSettings)
2945+
let args: any = [ email, actionCodeSettings ];
2946+
if (emailActionFlow.api === 'generateVerifyAndChangeEmailLink') {
2947+
args = [ email, newEmail, actionCodeSettings ];
2948+
}
2949+
return (nullAccessTokenAuth as any)[emailActionFlow.api](...args)
29172950
.should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential');
29182951
});
29192952

29202953
it('should be rejected given an app which returns invalid access tokens', () => {
2921-
return (malformedAccessTokenAuth as any)[emailActionFlow.api](email, actionCodeSettings)
2954+
let args: any = [ email, actionCodeSettings ];
2955+
if (emailActionFlow.api === 'generateVerifyAndChangeEmailLink') {
2956+
args = [ email, newEmail, actionCodeSettings ];
2957+
}
2958+
return (malformedAccessTokenAuth as any)[emailActionFlow.api](...args)
29222959
.should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential');
29232960
});
29242961

29252962
it('should be rejected given an app which fails to generate access tokens', () => {
2926-
return (rejectedPromiseAccessTokenAuth as any)[emailActionFlow.api](email, actionCodeSettings)
2963+
let args: any = [ email, actionCodeSettings ];
2964+
if (emailActionFlow.api === 'generateVerifyAndChangeEmailLink') {
2965+
args = [ email, newEmail, actionCodeSettings ];
2966+
}
2967+
return (rejectedPromiseAccessTokenAuth as any)[emailActionFlow.api](...args)
29272968
.should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential');
29282969
});
29292970

@@ -2932,7 +2973,11 @@ AUTH_CONFIGS.forEach((testConfig) => {
29322973
const getEmailActionLinkStub = sinon.stub(testConfig.RequestHandler.prototype, 'getEmailActionLink')
29332974
.resolves(expectedLink);
29342975
stubs.push(getEmailActionLinkStub);
2935-
return (auth as any)[emailActionFlow.api](email, actionCodeSettings)
2976+
let args: any = [ email, actionCodeSettings ];
2977+
if (emailActionFlow.api === 'generateVerifyAndChangeEmailLink') {
2978+
args = [ email, newEmail, actionCodeSettings ];
2979+
}
2980+
return (auth as any)[emailActionFlow.api](...args)
29362981
.then((actualLink: string) => {
29372982
// Confirm underlying API called with expected parameters.
29382983
expect(getEmailActionLinkStub).to.have.been.calledOnce.and.calledWith(
@@ -2953,7 +2998,11 @@ AUTH_CONFIGS.forEach((testConfig) => {
29532998
const getEmailActionLinkStub = sinon.stub(testConfig.RequestHandler.prototype, 'getEmailActionLink')
29542999
.resolves(expectedLink);
29553000
stubs.push(getEmailActionLinkStub);
2956-
return (auth as any)[emailActionFlow.api](email)
3001+
let args: any = [ email ];
3002+
if (emailActionFlow.api === 'generateVerifyAndChangeEmailLink') {
3003+
args = [ email, newEmail ];
3004+
}
3005+
return (auth as any)[emailActionFlow.api](...args)
29573006
.then((actualLink: string) => {
29583007
// Confirm underlying API called with expected parameters.
29593008
expect(getEmailActionLinkStub).to.have.been.calledOnce.and.calledWith(
@@ -2969,7 +3018,11 @@ AUTH_CONFIGS.forEach((testConfig) => {
29693018
const getEmailActionLinkStub = sinon.stub(testConfig.RequestHandler.prototype, 'getEmailActionLink')
29703019
.rejects(expectedError);
29713020
stubs.push(getEmailActionLinkStub);
2972-
return (auth as any)[emailActionFlow.api](email, actionCodeSettings)
3021+
let args: any = [ email, actionCodeSettings ];
3022+
if (emailActionFlow.api === 'generateVerifyAndChangeEmailLink') {
3023+
args = [ email, newEmail, actionCodeSettings ];
3024+
}
3025+
return (auth as any)[emailActionFlow.api](...args)
29733026
.then(() => {
29743027
throw new Error('Unexpected success');
29753028
}, (error: any) => {

0 commit comments

Comments
 (0)