diff --git a/common/api-review/auth.api.md b/common/api-review/auth.api.md index 8603b7a8ec5..7a02924f98f 100644 --- a/common/api-review/auth.api.md +++ b/common/api-review/auth.api.md @@ -235,6 +235,7 @@ export const AuthErrorCodes: { readonly MISSING_RECAPTCHA_VERSION: "auth/missing-recaptcha-version"; readonly INVALID_RECAPTCHA_VERSION: "auth/invalid-recaptcha-version"; readonly INVALID_REQ_TYPE: "auth/invalid-req-type"; + readonly WEB_OTP_NOT_RETRIEVED: "auth/web-otp-not-retrieved"; }; // @public @@ -281,6 +282,7 @@ export interface Config { // @public export interface ConfirmationResult { confirm(verificationCode: string): Promise; + confirmWithWebOTP(auth: Auth, webOTPTimeoutSeconds: number): Promise; readonly verificationId: string; } @@ -625,6 +627,7 @@ export class PhoneAuthProvider { static readonly PROVIDER_ID: 'phone'; readonly providerId: "phone"; verifyPhoneNumber(phoneOptions: PhoneInfoOptions | string, applicationVerifier: ApplicationVerifier): Promise; + verifyPhoneNumber(phoneOptions: PhoneInfoOptions | string, applicationVerifier: ApplicationVerifier, webOTPTimeoutSeconds: number): Promise; } // @public @@ -776,6 +779,9 @@ export function signInWithEmailLink(auth: Auth, email: string, emailLink?: strin // @public export function signInWithPhoneNumber(auth: Auth, phoneNumber: string, appVerifier: ApplicationVerifier): Promise; +// @public +export function signInWithPhoneNumber(auth: Auth, phoneNumber: string, appVerifier: ApplicationVerifier, webOTPTimeoutSeconds: number): Promise; + // @public export function signInWithPopup(auth: Auth, provider: AuthProvider, resolver?: PopupRedirectResolver): Promise; diff --git a/docs-devsite/auth.confirmationresult.md b/docs-devsite/auth.confirmationresult.md index 5d6a209b12d..18eb591230a 100644 --- a/docs-devsite/auth.confirmationresult.md +++ b/docs-devsite/auth.confirmationresult.md @@ -29,6 +29,7 @@ export interface ConfirmationResult | Method | Description | | --- | --- | | [confirm(verificationCode)](./auth.confirmationresult.md#confirmationresultconfirm) | Finishes a phone number sign-in, link, or reauthentication. | +| [confirmWithWebOTP(auth, webOTPTimeoutSeconds)](./auth.confirmationresult.md#confirmationresultconfirmwithwebotp) | Automatically fetches a verification code from an SMS message. Then, calls (@link confirm(verificationCode)} to finish a phone number sign-in, link, or reauthentication. | ## ConfirmationResult.verificationId @@ -72,3 +73,24 @@ const userCredential = await confirmationResult.confirm(verificationCode); ``` +## ConfirmationResult.confirmWithWebOTP() + +Automatically fetches a verification code from an SMS message. Then, calls (@link confirm(verificationCode)} to finish a phone number sign-in, link, or reauthentication. + +Signature: + +```typescript +confirmWithWebOTP(auth: Auth, webOTPTimeoutSeconds: number): Promise; +``` + +### Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| auth | [Auth](./auth.auth.md#auth_interface) | the current [Auth](./auth.auth.md#auth_interface) instance | +| webOTPTimeoutSeconds | number | Error would be thrown if WebOTP does not resolve within this specified timeout parameter (in seconds). | + +Returns: + +Promise<[UserCredential](./auth.usercredential.md#usercredential_interface) \| undefined> + diff --git a/docs-devsite/auth.md b/docs-devsite/auth.md index 6ff6f007484..1b9479a3c1d 100644 --- a/docs-devsite/auth.md +++ b/docs-devsite/auth.md @@ -44,6 +44,7 @@ Firebase Authentication | [signInWithEmailAndPassword(auth, email, password)](./auth.md#signinwithemailandpassword) | Asynchronously signs in using an email and password. | | [signInWithEmailLink(auth, email, emailLink)](./auth.md#signinwithemaillink) | Asynchronously signs in using an email and sign-in email link. | | [signInWithPhoneNumber(auth, phoneNumber, appVerifier)](./auth.md#signinwithphonenumber) | Asynchronously signs in using a phone number. | +| [signInWithPhoneNumber(auth, phoneNumber, appVerifier, webOTPTimeoutSeconds)](./auth.md#signinwithphonenumber) | Asynchronously signs in using a phone number. | | [signInWithPopup(auth, provider, resolver)](./auth.md#signinwithpopup) | Authenticates a Firebase client using a popup-based OAuth authentication flow. | | [signInWithRedirect(auth, provider, resolver)](./auth.md#signinwithredirect) | Authenticates a Firebase client using a full-page redirect flow. | | [signOut(auth)](./auth.md#signout) | Signs out the current user. | @@ -917,6 +918,45 @@ const credential = await confirmationResult.confirm(verificationCode); ``` +## signInWithPhoneNumber() + +Asynchronously signs in using a phone number. + +This method sends a code via SMS to the given phone number. Then, the method will try to autofill the SMS code for the user and sign the user in. A [UserCredential](./auth.usercredential.md#usercredential_interface) is then returned if the process is successful. If the process failed, is thrown. + +For abuse prevention, this method also requires a [ApplicationVerifier](./auth.applicationverifier.md#applicationverifier_interface). This SDK includes a reCAPTCHA-based implementation, [RecaptchaVerifier](./auth.recaptchaverifier.md#recaptchaverifier_class). This function can work on other platforms that do not support the [RecaptchaVerifier](./auth.recaptchaverifier.md#recaptchaverifier_class) (like React Native), but you need to use a third-party [ApplicationVerifier](./auth.applicationverifier.md#applicationverifier_interface) implementation. + +This method does not work in a Node.js environment. + +Signature: + +```typescript +export declare function signInWithPhoneNumber(auth: Auth, phoneNumber: string, appVerifier: ApplicationVerifier, webOTPTimeoutSeconds: number): Promise; +``` + +### Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| auth | [Auth](./auth.auth.md#auth_interface) | The [Auth](./auth.auth.md#auth_interface) instance. | +| phoneNumber | string | The user's phone number in E.164 format (e.g. +16505550101). | +| appVerifier | [ApplicationVerifier](./auth.applicationverifier.md#applicationverifier_interface) | The [ApplicationVerifier](./auth.applicationverifier.md#applicationverifier_interface). | +| webOTPTimeoutSeconds | number | | + +Returns: + +Promise<[UserCredential](./auth.usercredential.md#usercredential_interface)> + +### Example + + +```javascript +// 'recaptcha-container' is the ID of an element in the DOM. +const applicationVerifier = new firebase.auth.RecaptchaVerifier('recaptcha-container'); +const userCredential = await signInWithPhoneNumber(auth, phoneNumber, applicationVerifier, 10); + +``` + ## signInWithPopup() Authenticates a Firebase client using a popup-based OAuth authentication flow. @@ -1899,6 +1939,7 @@ AUTH_ERROR_CODES_MAP_DO_NOT_USE_INTERNALLY: { readonly MISSING_RECAPTCHA_VERSION: "auth/missing-recaptcha-version"; readonly INVALID_RECAPTCHA_VERSION: "auth/invalid-recaptcha-version"; readonly INVALID_REQ_TYPE: "auth/invalid-req-type"; + readonly WEB_OTP_NOT_RETRIEVED: "auth/web-otp-not-retrieved"; } ``` diff --git a/docs-devsite/auth.phoneauthprovider.md b/docs-devsite/auth.phoneauthprovider.md index a7f53f744d5..98b51fc4578 100644 --- a/docs-devsite/auth.phoneauthprovider.md +++ b/docs-devsite/auth.phoneauthprovider.md @@ -38,10 +38,11 @@ export declare class PhoneAuthProvider | Method | Modifiers | Description | | --- | --- | --- | -| [credential(verificationId, verificationCode)](./auth.phoneauthprovider.md#phoneauthprovidercredential) | static | Creates a phone auth credential, given the verification ID from [PhoneAuthProvider.verifyPhoneNumber()](./auth.phoneauthprovider.md#phoneauthproviderverifyphonenumber) and the code that was sent to the user's mobile device. | +| [credential(verificationId, verificationCode)](./auth.phoneauthprovider.md#phoneauthprovidercredential) | static | Creates a phone auth credential, given the verification ID from and the code that was sent to the user's mobile device. | | [credentialFromError(error)](./auth.phoneauthprovider.md#phoneauthprovidercredentialfromerror) | static | Returns an [AuthCredential](./auth.authcredential.md#authcredential_class) when passed an error. | | [credentialFromResult(userCredential)](./auth.phoneauthprovider.md#phoneauthprovidercredentialfromresult) | static | Generates an [AuthCredential](./auth.authcredential.md#authcredential_class) from a [UserCredential](./auth.usercredential.md#usercredential_interface). | | [verifyPhoneNumber(phoneOptions, applicationVerifier)](./auth.phoneauthprovider.md#phoneauthproviderverifyphonenumber) | | Starts a phone number authentication flow by sending a verification code to the given phone number. | +| [verifyPhoneNumber(phoneOptions, applicationVerifier, webOTPTimeoutSeconds)](./auth.phoneauthprovider.md#phoneauthproviderverifyphonenumber) | | Completes the phone number authentication flow by sending a verification code to the given phone number, automatically retrieving the verification code from the SMS message, and signing the user in. | ## PhoneAuthProvider.(constructor) @@ -91,7 +92,7 @@ readonly providerId: "phone"; ## PhoneAuthProvider.credential() -Creates a phone auth credential, given the verification ID from [PhoneAuthProvider.verifyPhoneNumber()](./auth.phoneauthprovider.md#phoneauthproviderverifyphonenumber) and the code that was sent to the user's mobile device. +Creates a phone auth credential, given the verification ID from and the code that was sent to the user's mobile device. Signature: @@ -103,7 +104,7 @@ static credential(verificationId: string, verificationCode: string): PhoneAuthCr | Parameter | Type | Description | | --- | --- | --- | -| verificationId | string | The verification ID returned from [PhoneAuthProvider.verifyPhoneNumber()](./auth.phoneauthprovider.md#phoneauthproviderverifyphonenumber). | +| verificationId | string | The verification ID returned from . | | verificationCode | string | The verification code sent to the user's mobile device. | Returns: @@ -242,6 +243,48 @@ const userCredential = confirmationResult.confirm(verificationCode); ``` +## PhoneAuthProvider.verifyPhoneNumber() + +Completes the phone number authentication flow by sending a verification code to the given phone number, automatically retrieving the verification code from the SMS message, and signing the user in. + +Signature: + +```typescript +verifyPhoneNumber(phoneOptions: PhoneInfoOptions | string, applicationVerifier: ApplicationVerifier, webOTPTimeoutSeconds: number): Promise; +``` + +### Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| phoneOptions | [PhoneInfoOptions](./auth.md#phoneinfooptions) \| string | | +| applicationVerifier | [ApplicationVerifier](./auth.applicationverifier.md#applicationverifier_interface) | For abuse prevention, this method also requires a [ApplicationVerifier](./auth.applicationverifier.md#applicationverifier_interface). This SDK includes a reCAPTCHA-based implementation, [RecaptchaVerifier](./auth.recaptchaverifier.md#recaptchaverifier_class). | +| webOTPTimeoutSeconds | number | Error would be thrown if WebOTP does not resolve within this specified timeout parameter (in seconds). | + +Returns: + +Promise<[UserCredential](./auth.usercredential.md#usercredential_interface)> + +A Promise for a UserCredential. + +### Example 1 + + +```javascript +const provider = new PhoneAuthProvider(auth); +const userCredential = await provider.verifyPhoneNumber(phoneNumber, applicationVerifier, 10); + +``` + +### Example 2 + +An alternative flow is provided using the `signInWithPhoneNumber` method. + +```javascript +const userCredential = signInWithPhoneNumber(auth, phoneNumber, applicationVerifier, 10); + +``` + ### Example diff --git a/packages/auth/demo/src/index.js b/packages/auth/demo/src/index.js index c0ac8110135..2ab01a8af39 100644 --- a/packages/auth/demo/src/index.js +++ b/packages/auth/demo/src/index.js @@ -73,6 +73,8 @@ import { browserPopupRedirectResolver, connectAuthEmulator, initializeRecaptchaConfig, + signInWithPhoneNumber, + AuthErrorCodes, validatePassword } from '@firebase/auth'; @@ -118,6 +120,7 @@ const providersIcons = { * Returns active user (currentUser or lastUser). * @return {!firebase.User} */ + function activeUser() { const type = $('input[name=toggle-user-selection]:checked').val(); if (type === 'lastUser') { @@ -711,25 +714,25 @@ function clearApplicationVerifier() { /** * Sends a phone number verification code for sign-in. */ -function onSignInVerifyPhoneNumber() { +async function onSignInVerifyPhoneNumber() { const phoneNumber = $('#signin-phone-number').val(); - const provider = new PhoneAuthProvider(auth); // Clear existing reCAPTCHA as an existing reCAPTCHA could be targeted for a // link/re-auth operation. clearApplicationVerifier(); // Initialize a reCAPTCHA application verifier. makeApplicationVerifier('signin-verify-phone-number'); - provider.verifyPhoneNumber(phoneNumber, applicationVerifier).then( - verificationId => { - clearApplicationVerifier(); - $('#signin-phone-verification-id').val(verificationId); - alertSuccess('Phone verification sent!'); - }, - error => { + await signInWithPhoneNumber(auth, phoneNumber, applicationVerifier, 30) + .then(userCredential => { + onAuthUserCredentialSuccess(userCredential); + }) + .catch(e => { clearApplicationVerifier(); - onAuthError(error); - } - ); + onAuthError(e); + if (e.code === `auth/${AuthErrorCodes.WEB_OTP_NOT_RETRIEVED}`) { + const verificationCode = $('#signin-phone-verification-code').val(); + e.confirmationResult.confirm(verificationCode); + } + }); } /** diff --git a/packages/auth/src/core/errors.ts b/packages/auth/src/core/errors.ts index 586a33d3d34..50b2b93d523 100644 --- a/packages/auth/src/core/errors.ts +++ b/packages/auth/src/core/errors.ts @@ -15,9 +15,8 @@ * limitations under the License. */ -import { AuthErrorMap, User } from '../model/public_types'; -import { ErrorFactory, ErrorMap } from '@firebase/util'; - +import { AuthErrorMap, User, ConfirmationResult } from '../model/public_types'; +import { ErrorFactory, ErrorMap, FirebaseError } from '@firebase/util'; import { IdTokenMfaResponse } from '../api/authentication/mfa'; import { AppName } from '../model/auth'; import { AuthCredential } from './credentials'; @@ -133,6 +132,7 @@ export const enum AuthErrorCode { MISSING_RECAPTCHA_VERSION = 'missing-recaptcha-version', INVALID_RECAPTCHA_VERSION = 'invalid-recaptcha-version', INVALID_REQ_TYPE = 'invalid-req-type', + WEB_OTP_NOT_RETRIEVED = 'web-otp-not-retrieved', UNSUPPORTED_PASSWORD_POLICY_SCHEMA_VERSION = 'unsupported-password-policy-schema-version', PASSWORD_DOES_NOT_MEET_REQUIREMENTS = 'password-does-not-meet-requirements' } @@ -383,7 +383,18 @@ function _debugErrorMap(): ErrorMap { 'The reCAPTCHA version is missing when sending request to the backend.', [AuthErrorCode.INVALID_REQ_TYPE]: 'Invalid request parameters.', [AuthErrorCode.INVALID_RECAPTCHA_VERSION]: - 'The reCAPTCHA version is invalid when sending request to the backend.', + 'The reCAPTCHA version sent to the backend is invalid.', + [AuthErrorCode.WEB_OTP_NOT_RETRIEVED]: + 'Web OTP code is not retrieved successfully', + /** + * This is the default error message. + * This message is customized to one of the following depending on the type of error: + * `Web OTP code is not fetched before timeout` + * `The auto-retrieved credential or code is not defined` + * `Web OTP get method failed to retrieve the code` + * `Web OTP code received is incorrect` + * `Web OTP is not supported` + */ [AuthErrorCode.UNSUPPORTED_PASSWORD_POLICY_SCHEMA_VERSION]: 'The password policy received from the backend uses a schema version that is not supported by this version of the Firebase SDK.', [AuthErrorCode.PASSWORD_DOES_NOT_MEET_REQUIREMENTS]: @@ -434,6 +445,10 @@ export interface NamedErrorParams { user?: User; _serverResponse?: object; } +export interface WebOTPError extends FirebaseError { + code: AuthErrorCode.WEB_OTP_NOT_RETRIEVED; + confirmationResult: ConfirmationResult; // Standard ConfirmationResult; for fallback +} /** * @internal @@ -597,5 +612,6 @@ export const AUTH_ERROR_CODES_MAP_DO_NOT_USE_INTERNALLY = { MISSING_CLIENT_TYPE: 'auth/missing-client-type', MISSING_RECAPTCHA_VERSION: 'auth/missing-recaptcha-version', INVALID_RECAPTCHA_VERSION: 'auth/invalid-recaptcha-version', - INVALID_REQ_TYPE: 'auth/invalid-req-type' + INVALID_REQ_TYPE: 'auth/invalid-req-type', + WEB_OTP_NOT_RETRIEVED: 'auth/web-otp-not-retrieved' } as const; diff --git a/packages/auth/src/model/public_types.ts b/packages/auth/src/model/public_types.ts index 0390ba5e30d..b083c4a064f 100644 --- a/packages/auth/src/model/public_types.ts +++ b/packages/auth/src/model/public_types.ts @@ -587,6 +587,19 @@ export interface ConfirmationResult { * @param verificationCode - The code that was sent to the user's mobile device. */ confirm(verificationCode: string): Promise; + /** + * + * Automatically fetches a verification code from an SMS message. Then, calls + * (@link confirm(verificationCode)} to finish a phone number sign-in, link, or reauthentication. + * + * @param auth - the current {@link Auth} instance + * @param webOTPTimeoutSeconds - Error would be thrown if WebOTP does not resolve within this specified timeout parameter (in seconds). + * + */ + confirmWithWebOTP( + auth: Auth, + webOTPTimeoutSeconds: number + ): Promise; } /** diff --git a/packages/auth/src/platform_browser/providers/phone.ts b/packages/auth/src/platform_browser/providers/phone.ts index 2b5c0874b70..8d87606d262 100644 --- a/packages/auth/src/platform_browser/providers/phone.ts +++ b/packages/auth/src/platform_browser/providers/phone.ts @@ -98,14 +98,69 @@ export class PhoneAuthProvider { * @param applicationVerifier - For abuse prevention, this method also requires a * {@link ApplicationVerifier}. This SDK includes a reCAPTCHA-based implementation, * {@link RecaptchaVerifier}. + * @param webOTPTimeoutSeconds - Error would be thrown if WebOTP does not resolve within this specified timeout parameter (in seconds). * * @returns A Promise for a verification ID that can be passed to * {@link PhoneAuthProvider.credential} to identify this flow.. + * */ verifyPhoneNumber( phoneOptions: PhoneInfoOptions | string, applicationVerifier: ApplicationVerifier - ): Promise { + ): Promise; + + /** + * + * Completes the phone number authentication flow by sending a verification code to the + * given phone number, automatically retrieving the verification code from the SMS message, + * and signing the user in. + * + * @example + * ```javascript + * const provider = new PhoneAuthProvider(auth); + * const userCredential = await provider.verifyPhoneNumber(phoneNumber, applicationVerifier, 10); + * ``` + * + * @example + * An alternative flow is provided using the `signInWithPhoneNumber` method. + * ```javascript + * const userCredential = signInWithPhoneNumber(auth, phoneNumber, applicationVerifier, 10); + * ``` + * + * @param phoneInfoOptions - The user's {@link PhoneInfoOptions}. The phone number should be in + * E.164 format (for example, +16505550101). + * @param applicationVerifier - For abuse prevention, this method also requires a + * {@link ApplicationVerifier}. This SDK includes a reCAPTCHA-based implementation, + * {@link RecaptchaVerifier}. + * @param webOTPTimeoutSeconds - Error would be thrown if WebOTP does not resolve within this specified timeout parameter (in seconds). + * + * @returns A Promise for a UserCredential. + */ + verifyPhoneNumber( + phoneOptions: PhoneInfoOptions | string, + applicationVerifier: ApplicationVerifier, + webOTPTimeoutSeconds: number + ): Promise; + + verifyPhoneNumber( + phoneOptions: PhoneInfoOptions | string, + applicationVerifier: ApplicationVerifier, + webOTPTimeoutSeconds?: number + ): Promise { + if (webOTPTimeoutSeconds) { + try { + return _verifyPhoneNumber( + this.auth, + phoneOptions, + getModularInstance( + applicationVerifier as ApplicationVerifierInternal + ), + webOTPTimeoutSeconds + ); + } catch (error) { + throw error; + } + } return _verifyPhoneNumber( this.auth, phoneOptions, diff --git a/packages/auth/src/platform_browser/strategies/phone.test.ts b/packages/auth/src/platform_browser/strategies/phone.test.ts index c545a84f11a..7a119f6a46c 100644 --- a/packages/auth/src/platform_browser/strategies/phone.test.ts +++ b/packages/auth/src/platform_browser/strategies/phone.test.ts @@ -47,6 +47,11 @@ import { use(chaiAsPromised); use(sinonChai); +//interfaces added to provide typescript support for webopt autofill +interface OTPCredential extends Credential { + code?: string; +} + describe('platform_browser/strategies/phone', () => { let auth: TestAuth; let verifier: ApplicationVerifierInternal; @@ -80,12 +85,12 @@ describe('platform_browser/strategies/phone', () => { }); context('ConfirmationResult', () => { - it('result contains verification id baked in', async () => { + it('result contains verification id baked in when webOTP autofill is not used', async () => { const result = await signInWithPhoneNumber(auth, 'number', verifier); expect(result.verificationId).to.eq('session-info'); }); - it('calling #confirm finishes the sign in flow', async () => { + it('calling #confirm finishes the sign in flow when webOTP autofill is not used', async () => { const idTokenResponse: IdTokenResponse = { idToken: 'my-id-token', refreshToken: 'my-refresh-token', @@ -114,6 +119,165 @@ describe('platform_browser/strategies/phone', () => { }); }); }); + + context('UserCredential', () => { + let idTokenResponse: IdTokenResponse; + // This endpoint is called from within the callback, in + // signInWithCredential + let signInEndpoint: fetch.Route; + + beforeEach(() => { + idTokenResponse = { + idToken: 'my-id-token', + refreshToken: 'my-refresh-token', + expiresIn: '1234', + localId: 'uid', + kind: IdTokenResponseKind.CreateAuthUri + }; + + signInEndpoint = mockEndpoint( + Endpoint.SIGN_IN_WITH_PHONE_NUMBER, + idTokenResponse + ); + + mockEndpoint(Endpoint.GET_ACCOUNT_INFO, { + users: [{ localId: 'uid' }] + }); + }); + + it('finishes the sign in flow without calling #confirm if webOTP is used and supported by browser', async () => { + if ('OTPCredential' in window) { + sinon.stub(window.navigator['credentials'], 'get').callsFake(() => { + const otpCred: OTPCredential = { + id: 'uid', + type: 'signIn', + code: '6789' + }; + return Promise.resolve(otpCred); + }); + + const userCred = await signInWithPhoneNumber( + auth, + 'number', + verifier, + 10 + ); + expect(userCred.user.uid).to.eq('uid'); + expect(userCred.operationType).to.eq(OperationType.SIGN_IN); + expect(signInEndpoint.calls[0].request).to.eql({ + sessionInfo: 'session-info', + code: '6789' + }); + } + }); + + it('throws error and allows user to sign-in via #confirm if browser does not support web OTP', async () => { + if (!('OTPCredential' in window)) { + signInWithPhoneNumber(auth, 'number', verifier, 3).catch(error => { + expect(error.code).to.eq(`Web OTP is not supported`); + const userCred = error.confirmationResult.confirm('6789'); + expect(userCred.user.uid).to.eq('uid'); + expect(userCred.operationType).to.eq(OperationType.SIGN_IN); + expect(signInEndpoint.calls[0].request).to.eql({ + sessionInfo: 'session-info', + code: '6789' + }); + }); + } + }); + + it('throws error and allows user to sign-in via #confirm if web OTP code is not fetched before timeout', () => { + if ('OTPCredential' in window) { + sinon + .stub(window.navigator['credentials'], 'get') + .callsFake(async () => { + await setTimeout(() => {}, 10 * 1000); + return Promise.resolve(null); + }); + + signInWithPhoneNumber(auth, 'number', verifier, 3).catch(error => { + expect(error.code).to.eq( + `Web OTP code is not fetched before timeout` + ); + const userCred = error.confirmationResult.confirm('6789'); + expect(userCred.user.uid).to.eq('uid'); + expect(userCred.operationType).to.eq(OperationType.SIGN_IN); + expect(signInEndpoint.calls[0].request).to.eql({ + sessionInfo: 'session-info', + code: '6789' + }); + }); + } + }); + + it('throws error and allows user to sign-in via #confirm if the code failed to be retrieved', () => { + if ('OTPCredential' in window) { + sinon.stub(window.navigator['credentials'], 'get').callsFake(() => { + throw new Error('get method failed!'); + }); + + signInWithPhoneNumber(auth, 'number', verifier, 3).catch(error => { + expect(error.code).to.eq( + `Web OTP get method failed to retrieve the code` + ); + const userCred = error.confirmationResult.confirm('6789'); + expect(userCred.user.uid).to.eq('uid'); + expect(userCred.operationType).to.eq(OperationType.SIGN_IN); + expect(signInEndpoint.calls[0].request).to.eql({ + sessionInfo: 'session-info', + code: '6789' + }); + }); + } + }); + + it('throws error and allows user to sign-in via #confirm if the retrieved code is null', () => { + if ('OTPCredential' in window) { + sinon.stub(window.navigator['credentials'], 'get').callsFake(() => { + return Promise.resolve(null); + }); + + signInWithPhoneNumber(auth, 'number', verifier, 3).catch(error => { + expect(error.code).to.eq( + `the auto-retrieved credential or code is not defined` + ); + const userCred = error.confirmationResult.confirm('6789'); + expect(userCred.user.uid).to.eq('uid'); + expect(userCred.operationType).to.eq(OperationType.SIGN_IN); + expect(signInEndpoint.calls[0].request).to.eql({ + sessionInfo: 'session-info', + code: '6789' + }); + }); + } + }); + + it('throws error and allows user to sign-in via #confirm if the code is incorrect', () => { + if ('OTPCredential' in window) { + sinon.stub(window.navigator['credentials'], 'get').callsFake(() => { + const otpCred: OTPCredential = { + id: 'uid', + type: 'signIn', + code: 'wrongcode' + }; + return Promise.resolve(otpCred); + }); + + signInWithPhoneNumber(auth, 'number', verifier, 3).catch(error => { + expect(error.code).to.eq( + `Web OTP get method failed to retrieve the code` + ); + const userCred = error.confirmationResult.confirm('6789'); + expect(userCred.user.uid).to.eq('uid'); + expect(userCred.operationType).to.eq(OperationType.SIGN_IN); + expect(signInEndpoint.calls[0].request).to.eql({ + sessionInfo: 'session-info', + code: '6789' + }); + }); + } + }); + }); }); describe('linkWithPhoneNumber', () => { @@ -377,6 +541,164 @@ describe('platform_browser/strategies/phone', () => { }); }); + context('WebOTP', () => { + let idTokenResponse: IdTokenResponse; + // This endpoint is called from within the callback, in + // signInWithCredential + let signInEndpoint: fetch.Route; + + beforeEach(() => { + idTokenResponse = { + idToken: 'my-id-token', + refreshToken: 'my-refresh-token', + expiresIn: '1234', + localId: 'uid', + kind: IdTokenResponseKind.CreateAuthUri + }; + + signInEndpoint = mockEndpoint( + Endpoint.SIGN_IN_WITH_PHONE_NUMBER, + idTokenResponse + ); + + mockEndpoint(Endpoint.GET_ACCOUNT_INFO, { + users: [{ localId: 'uid' }] + }); + }); + + it('finishes the sign in flow without calling #confirm if webOTP autofill is used', async () => { + if ('OTPCredential' in window) { + sinon.stub(window.navigator['credentials'], 'get').callsFake(() => { + const otpCred: OTPCredential = { + id: 'uid', + type: 'signIn', + code: '6789' + }; + return Promise.resolve(otpCred); + }); + const userCred = await _verifyPhoneNumber( + auth, + 'number', + verifier, + 10 + ); + expect(userCred.user.uid).to.eq('uid'); + expect(userCred.operationType).to.eq(OperationType.SIGN_IN); + expect(signInEndpoint.calls[0].request).to.eql({ + sessionInfo: 'session-info', + code: '6789' + }); + } + }); + + it('throws error and allows user to sign-in via #confirm if browser does not support web OTP', async () => { + if (!('OTPCredential' in window)) { + _verifyPhoneNumber(auth, 'number', verifier, 3).catch(error => { + expect(error.code).to.eq(`Web OTP is not supported`); + const userCred = error.confirmationResult.confirm('6789'); + expect(userCred.user.uid).to.eq('uid'); + expect(userCred.operationType).to.eq(OperationType.SIGN_IN); + expect(signInEndpoint.calls[0].request).to.eql({ + sessionInfo: 'session-info', + code: '6789' + }); + }); + } + }); + + it('throws error and allows user to sign-in via #confirm if web OTP code is not fetched before timeout', () => { + if ('OTPCredential' in window) { + sinon + .stub(window.navigator['credentials'], 'get') + .callsFake(async () => { + await setTimeout(() => {}, 10 * 1000); + return Promise.resolve(null); + }); + + _verifyPhoneNumber(auth, 'number', verifier, 3).catch(error => { + expect(error.code).to.eq( + `Web OTP code is not fetched before timeout` + ); + const userCred = error.confirmationResult.confirm('6789'); + expect(userCred.user.uid).to.eq('uid'); + expect(userCred.operationType).to.eq(OperationType.SIGN_IN); + expect(signInEndpoint.calls[0].request).to.eql({ + sessionInfo: 'session-info', + code: '6789' + }); + }); + } + }); + + it('throws error and allows user to sign-in via #confirm if the code failed to be retrieved', () => { + if ('OTPCredential' in window) { + sinon.stub(window.navigator['credentials'], 'get').callsFake(() => { + throw new Error('get method failed!'); + }); + + _verifyPhoneNumber(auth, 'number', verifier, 3).catch(error => { + expect(error.code).to.eq( + `Web OTP get method failed to retrieve the code` + ); + const userCred = error.confirmationResult.confirm('6789'); + expect(userCred.user.uid).to.eq('uid'); + expect(userCred.operationType).to.eq(OperationType.SIGN_IN); + expect(signInEndpoint.calls[0].request).to.eql({ + sessionInfo: 'session-info', + code: '6789' + }); + }); + } + }); + + it('throws error and allows user to sign-in via #confirm if the retrieved code is null', () => { + if ('OTPCredential' in window) { + sinon.stub(window.navigator['credentials'], 'get').callsFake(() => { + return Promise.resolve(null); + }); + + _verifyPhoneNumber(auth, 'number', verifier, 3).catch(error => { + expect(error.code).to.eq( + `the auto-retrieved credential or code is not defined` + ); + const userCred = error.confirmationResult.confirm('6789'); + expect(userCred.user.uid).to.eq('uid'); + expect(userCred.operationType).to.eq(OperationType.SIGN_IN); + expect(signInEndpoint.calls[0].request).to.eql({ + sessionInfo: 'session-info', + code: '6789' + }); + }); + } + }); + + it('throws error and allows user to sign-in via #confirm if the code is incorrect', () => { + if ('OTPCredential' in window) { + sinon.stub(window.navigator['credentials'], 'get').callsFake(() => { + const otpCred: OTPCredential = { + id: 'uid', + type: 'signIn', + code: 'wrongcode' + }; + return Promise.resolve(otpCred); + }); + + _verifyPhoneNumber(auth, 'number', verifier, 3).catch(error => { + expect(error.code).to.eq( + `Web OTP get method failed to retrieve the code` + ); + const userCred = error.confirmationResult.confirm('6789'); + expect(userCred.user.uid).to.eq('uid'); + expect(userCred.operationType).to.eq(OperationType.SIGN_IN); + expect(signInEndpoint.calls[0].request).to.eql({ + sessionInfo: 'session-info', + code: '6789' + }); + }); + } + }); + }); + it('throws if the verifier does not return a string', async () => { (verifier.verify as sinon.SinonStub).returns(Promise.resolve(123)); await expect( diff --git a/packages/auth/src/platform_browser/strategies/phone.ts b/packages/auth/src/platform_browser/strategies/phone.ts index 745385c2db9..4e1dd6b599b 100644 --- a/packages/auth/src/platform_browser/strategies/phone.ts +++ b/packages/auth/src/platform_browser/strategies/phone.ts @@ -29,9 +29,9 @@ import { startSignInPhoneMfa } from '../../api/authentication/mfa'; import { sendPhoneVerificationCode } from '../../api/authentication/sms'; import { ApplicationVerifierInternal } from '../../model/application_verifier'; import { PhoneAuthCredential } from '../../core/credentials/phone'; -import { AuthErrorCode } from '../../core/errors'; +import { AuthErrorCode, WebOTPError } from '../../core/errors'; import { _assertLinkedStatus, _link } from '../../core/user/link_unlink'; -import { _assert } from '../../core/util/assert'; +import { _assert, _errorWithCustomMessage } from '../../core/util/assert'; import { AuthInternal } from '../../model/auth'; import { linkWithCredential, @@ -52,6 +52,19 @@ interface OnConfirmationCallback { (credential: PhoneAuthCredential): Promise; } +// interfaces added to provide typescript support for webOTP autofill +interface OTPCredentialRequestOptions extends CredentialRequestOptions { + otp: OTPOptions; +} + +interface OTPOptions { + transport: string[]; +} + +interface OTPCredential extends Credential { + code?: string; +} + class ConfirmationResultImpl implements ConfirmationResult { constructor( readonly verificationId: string, @@ -65,14 +78,91 @@ class ConfirmationResultImpl implements ConfirmationResult { ); return this.onConfirmation(authCredential); } + + async confirmWithWebOTP( + auth: Auth, + webOTPTimeoutSeconds: number + ): Promise { + if ('OTPCredential' in window) { + const abortController = new AbortController(); + const timer = setTimeout(() => { + abortController.abort(); + + const myErr = _errorWithCustomMessage( + auth, + AuthErrorCode.WEB_OTP_NOT_RETRIEVED, + `Web OTP code is not fetched before timeout` + ) as WebOTPError; + myErr.confirmationResult = this; + throw myErr; + }, webOTPTimeoutSeconds * 1000); + + const o: OTPCredentialRequestOptions = { + otp: { transport: ['sms'] }, + signal: abortController.signal + }; + + let code: string = ''; + await ( + window.navigator['credentials'].get(o) as Promise + ) + .then(async content => { + if ( + content === undefined || + content === null || + content.code === undefined + ) { + const myErr = _errorWithCustomMessage( + auth, + AuthErrorCode.WEB_OTP_NOT_RETRIEVED, + `the auto-retrieved credential or code is not defined` + ) as WebOTPError; + myErr.confirmationResult = this; + throw myErr; + } else { + clearTimeout(timer); + code = content.code; + } + }) + .catch(() => { + clearTimeout(timer); + const myErr = _errorWithCustomMessage( + auth, + AuthErrorCode.WEB_OTP_NOT_RETRIEVED, + `Web OTP get method failed to retrieve the code` + ) as WebOTPError; + myErr.confirmationResult = this; + throw myErr; + }); + try { + return this.confirm(code); + } catch { + const myErr = _errorWithCustomMessage( + auth, + AuthErrorCode.WEB_OTP_NOT_RETRIEVED, + `Web OTP code received is incorrect` + ) as WebOTPError; + myErr.confirmationResult = this; + throw myErr; + } + } else { + const myErr = _errorWithCustomMessage( + auth, + AuthErrorCode.WEB_OTP_NOT_RETRIEVED, + `Web OTP is not supported` + ) as WebOTPError; + myErr.confirmationResult = this; + throw myErr; + } + } } /** * Asynchronously signs in using a phone number. * * @remarks - * This method sends a code via SMS to the given - * phone number, and returns a {@link ConfirmationResult}. After the user + * This method sends a code via SMS to the given phone number, + * and returns a {@link ConfirmationResult}. After the user * provides the code sent to their phone, call {@link ConfirmationResult.confirm} * with the code to sign the user in. * @@ -103,16 +193,75 @@ export async function signInWithPhoneNumber( auth: Auth, phoneNumber: string, appVerifier: ApplicationVerifier -): Promise { +): Promise; + +/** + * Asynchronously signs in using a phone number. + * + * @remarks + * This method sends a code via SMS to the given phone number. + * Then, the method will try to autofill the SMS code for the user and + * sign the user in. A {@link UserCredential} is then returned if the process is successful. + * If the process failed, {@link FirebaseError} is thrown. + * + * For abuse prevention, this method also requires a {@link ApplicationVerifier}. + * This SDK includes a reCAPTCHA-based implementation, {@link RecaptchaVerifier}. + * This function can work on other platforms that do not support the + * {@link RecaptchaVerifier} (like React Native), but you need to use a + * third-party {@link ApplicationVerifier} implementation. + * + * This method does not work in a Node.js environment. + * + * @example + * ```javascript + * // 'recaptcha-container' is the ID of an element in the DOM. + * const applicationVerifier = new firebase.auth.RecaptchaVerifier('recaptcha-container'); + * const userCredential = await signInWithPhoneNumber(auth, phoneNumber, applicationVerifier, 10); + * ``` + * + * @param auth - The {@link Auth} instance. + * @param phoneNumber - The user's phone number in E.164 format (e.g. +16505550101). + * @param appVerifier - The {@link ApplicationVerifier}. + * @param webOTPTimtout - Errors would be thrown if WebOTP autofill is used and does not resolve within this specified timeout parameter (milliseconds). + * + * @public + */ +export async function signInWithPhoneNumber( + auth: Auth, + phoneNumber: string, + appVerifier: ApplicationVerifier, + webOTPTimeoutSeconds: number +): Promise; + +export async function signInWithPhoneNumber( + auth: Auth, + phoneNumber: string, + appVerifier: ApplicationVerifier, + webOTPTimeoutSeconds?: number +): Promise { const authInternal = _castAuth(auth); - const verificationId = await _verifyPhoneNumber( - authInternal, - phoneNumber, - getModularInstance(appVerifier as ApplicationVerifierInternal) - ); - return new ConfirmationResultImpl(verificationId, cred => - signInWithCredential(authInternal, cred) - ); + if (webOTPTimeoutSeconds) { + try { + const userCred = await _verifyPhoneNumber( + authInternal, + phoneNumber, + getModularInstance(appVerifier as ApplicationVerifierInternal), + webOTPTimeoutSeconds + ); + return userCred; + } catch (error) { + throw error; + } + } else { + const verificationId = await _verifyPhoneNumber( + authInternal, + phoneNumber, + getModularInstance(appVerifier as ApplicationVerifierInternal) + ); + return new ConfirmationResultImpl(verificationId, cred => + signInWithCredential(authInternal, cred) + ); + } } /** @@ -182,7 +331,21 @@ export async function _verifyPhoneNumber( auth: AuthInternal, options: PhoneInfoOptions | string, verifier: ApplicationVerifierInternal -): Promise { +): Promise; + +export async function _verifyPhoneNumber( + auth: AuthInternal, + options: PhoneInfoOptions | string, + verifier: ApplicationVerifierInternal, + webOTPTimeoutSeconds: number +): Promise; + +export async function _verifyPhoneNumber( + auth: AuthInternal, + options: PhoneInfoOptions | string, + verifier: ApplicationVerifierInternal, + webOTPTimeoutSeconds?: number +): Promise { const recaptchaToken = await verifier.verify(); try { @@ -206,7 +369,7 @@ export async function _verifyPhoneNumber( } else { phoneInfoOptions = options; } - + let verificationId = ''; if ('session' in phoneInfoOptions) { const session = phoneInfoOptions.session as MultiFactorSessionImpl; @@ -223,7 +386,7 @@ export async function _verifyPhoneNumber( recaptchaToken } }); - return response.phoneSessionInfo.sessionInfo; + verificationId = response.phoneSessionInfo.sessionInfo; } else { _assert( session.type === MultiFactorSessionType.SIGN_IN, @@ -241,15 +404,30 @@ export async function _verifyPhoneNumber( recaptchaToken } }); - return response.phoneResponseInfo.sessionInfo; + verificationId = response.phoneResponseInfo.sessionInfo; } } else { const { sessionInfo } = await sendPhoneVerificationCode(auth, { phoneNumber: phoneInfoOptions.phoneNumber, recaptchaToken }); - return sessionInfo; + verificationId = sessionInfo; + } + const authInternal = _castAuth(auth); + const confirmationRes = new ConfirmationResultImpl(verificationId, cred => + signInWithCredential(authInternal, cred) + ); + if (webOTPTimeoutSeconds) { + try { + return confirmationRes.confirmWithWebOTP( + authInternal, + webOTPTimeoutSeconds + ); + } catch (error) { + throw error; + } } + return verificationId; } finally { verifier._reset(); }