Skip to content

Commit

Permalink
feat(auth): Support generate oob code request type VERIFY_AND_CHANGE_…
Browse files Browse the repository at this point in the history
…EMAIL (#1633)

* Supported generate OOB code from VERIFY_AND_CHANGE_EMAIL request type.
* Added integration tests.
  • Loading branch information
Xiaoshouzi-gh committed Apr 28, 2022
1 parent 75407f4 commit b2a28ae
Show file tree
Hide file tree
Showing 7 changed files with 201 additions and 14 deletions.
1 change: 1 addition & 0 deletions etc/firebase-admin.auth.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export abstract class BaseAuth {
generateEmailVerificationLink(email: string, actionCodeSettings?: ActionCodeSettings): Promise<string>;
generatePasswordResetLink(email: string, actionCodeSettings?: ActionCodeSettings): Promise<string>;
generateSignInWithEmailLink(email: string, actionCodeSettings: ActionCodeSettings): Promise<string>;
generateVerifyAndChangeEmailLink(email: string, newEmail: string, actionCodeSettings?: ActionCodeSettings): Promise<string>;
getProviderConfig(providerId: string): Promise<AuthProviderConfig>;
getUser(uid: string): Promise<UserRecord>;
getUserByEmail(email: string): Promise<UserRecord>;
Expand Down
26 changes: 23 additions & 3 deletions src/auth/auth-api-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<string> {
let request = { requestType, email, returnOobLink: true };
actionCodeSettings?: ActionCodeSettings, newEmail?: string): Promise<string> {
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') {
Expand All @@ -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.
Expand Down
29 changes: 29 additions & 0 deletions src/auth/base-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
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
Expand Down
6 changes: 6 additions & 0 deletions src/utils/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down Expand Up @@ -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.
Expand Down
26 changes: 26 additions & 0 deletions test/integration/auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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', () => {
Expand Down
56 changes: 54 additions & 2 deletions test/unit/auth/auth-api-request.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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));
Expand All @@ -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', () => {
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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(
Expand Down
71 changes: 62 additions & 9 deletions test/unit/auth/auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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');
});

Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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) => {
Expand Down

0 comments on commit b2a28ae

Please sign in to comment.