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 all 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
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.
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 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