Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(auth): Support generate oob code request type VERIFY_AND_CHANGE_EMAIL #1633

Merged
merged 6 commits into from
Apr 28, 2022
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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
24 changes: 21 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,17 @@ 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: any = { requestType, email, returnOobLink: true };
Xiaoshouzi-gh marked this conversation as resolved.
Show resolved Hide resolved
if (typeof newEmail !== 'undefined') {
request.newEmail = 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 +1633,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 @@ -827,6 +827,35 @@ export abstract class BaseAuth {
return this.authRequestHandler.getEmailActionLink('VERIFY_EMAIL', email, actionCodeSettings);
}

/**
* Generates the out of band email action link to verify the user's ownership
Xiaoshouzi-gh marked this conversation as resolved.
Show resolved Hide resolved
* 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.
Copy link

@renkelvin renkelvin Apr 21, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"email address"

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kept it as email account as the previous public method (verify_email, reset_password etc) uses the same documentation on email paramater. Let me know if you feel strong about email address.

* @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 whitelisted by the
* developer in the console. Otherwise an error is thrown.
Xiaoshouzi-gh marked this conversation as resolved.
Show resolved Hide resolved
* 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 @@ -448,6 +448,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 @@ -904,6 +908,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
74 changes: 73 additions & 1 deletion 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 @@ -3106,6 +3107,9 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => {

EMAIL_ACTION_REQUEST_TYPES.forEach((requestType) => {
it('should be fulfilled given a valid requestType:' + requestType + ' and ActionCodeSettings', () => {
if (requestType === 'VERIFY_AND_CHANGE_EMAIL') {
return;
Xiaoshouzi-gh marked this conversation as resolved.
Show resolved Hide resolved
}
const requestData = deepExtend({
requestType,
email,
Expand All @@ -3123,8 +3127,27 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => {
});
});

it('should be fulfilled given a valid requestType: VERIFY_AND_CHANGE_EMAIL and ActionCodeSettings', () => {
const VERIFY_AND_CHANGE_EMAIL = 'VERIFY_AND_CHANGE_EMAIL';
const requestData = deepExtend({
requestType: VERIFY_AND_CHANGE_EMAIL,
email,
returnOobLink: true,
newEmail
}, expectedActionCodeSettingsRequest);
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, actionCodeSettings, newEmail)
.then((oobLink: string) => {
expect(oobLink).to.be.equal(expectedLink);
expect(stub).to.have.been.calledOnce.and.calledWith(callParams(path, method, requestData));
});
});

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 +3168,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 +3195,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 +3225,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