From f1e717778bf1c10a8d2103212fcc4827f321b898 Mon Sep 17 00:00:00 2001 From: Evanition <95145810+Evanition@users.noreply.github.com> Date: Fri, 11 Aug 2023 12:09:06 -0700 Subject: [PATCH 1/2] [With interface workaround] Auth Web OTP Retrieval (#7496) --- common/api-review/auth.api.md | 6 + docs-devsite/auth.confirmationresult.md | 22 ++ docs-devsite/auth.md | 41 +++ docs-devsite/auth.phoneauthprovider.md | 49 ++- packages/auth/demo/src/index.js | 31 +- packages/auth/src/core/errors.ts | 26 +- packages/auth/src/model/public_types.ts | 13 + .../src/platform_browser/providers/phone.ts | 57 ++- .../platform_browser/strategies/phone.test.ts | 326 +++++++++++++++++- .../src/platform_browser/strategies/phone.ts | 214 +++++++++++- 10 files changed, 742 insertions(+), 43 deletions(-) diff --git a/common/api-review/auth.api.md b/common/api-review/auth.api.md index 8e915daf731..2d8ecf3c40a 100644 --- a/common/api-review/auth.api.md +++ b/common/api-review/auth.api.md @@ -236,6 +236,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 @@ -282,6 +283,7 @@ export interface Config { // @public export interface ConfirmationResult { confirm(verificationCode: string): Promise; + confirmWithWebOTP(auth: Auth, webOTPTimeoutSeconds: number): Promise; readonly verificationId: string; } @@ -626,6 +628,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 @@ -780,6 +783,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 d32f2492d79..f705af9516c 100644 --- a/docs-devsite/auth.md +++ b/docs-devsite/auth.md @@ -45,6 +45,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. | @@ -939,6 +940,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. @@ -1922,6 +1962,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 7b4a2e0cc01..065eeb9c7f6 100644 --- a/packages/auth/demo/src/index.js +++ b/packages/auth/demo/src/index.js @@ -73,9 +73,11 @@ import { browserPopupRedirectResolver, connectAuthEmulator, initializeRecaptchaConfig, + revokeAccessToken, + AuthErrorCodes, validatePassword, - revokeAccessToken -} from '@firebase/auth'; + signInWithPhoneNumber, + } from '@firebase/auth'; import { config } from './config'; import { @@ -119,6 +121,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') { @@ -717,25 +720,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 66c43b2ca48..892f91f6545 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 @@ -598,5 +613,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(); } From e1a2133b2ab1cecf3ae6defe64db25197a81046d Mon Sep 17 00:00:00 2001 From: renkelvin Date: Tue, 7 Nov 2023 14:02:29 -0800 Subject: [PATCH 2/2] promise race --- common/api-review/auth.api.md | 5 +- packages/auth/demo/public/index.html | 24 ++- packages/auth/demo/src/index.js | 90 ++++++++-- packages/auth/src/model/public_types.ts | 2 + .../src/platform_browser/providers/phone.ts | 5 +- .../src/platform_browser/strategies/phone.ts | 167 +++++++----------- 6 files changed, 160 insertions(+), 133 deletions(-) diff --git a/common/api-review/auth.api.md b/common/api-review/auth.api.md index 2d8ecf3c40a..d170c2cade9 100644 --- a/common/api-review/auth.api.md +++ b/common/api-review/auth.api.md @@ -283,6 +283,8 @@ export interface Config { // @public export interface ConfirmationResult { confirm(verificationCode: string): Promise; + // (undocumented) + confirmed(auth: Auth): Promise; confirmWithWebOTP(auth: Auth, webOTPTimeoutSeconds: number): Promise; readonly verificationId: string; } @@ -783,9 +785,6 @@ 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/packages/auth/demo/public/index.html b/packages/auth/demo/public/index.html index a0899447ea9..763e220cacf 100644 --- a/packages/auth/demo/public/index.html +++ b/packages/auth/demo/public/index.html @@ -386,14 +386,30 @@ + - + + +
+ + + + +
diff --git a/packages/auth/demo/src/index.js b/packages/auth/demo/src/index.js index 065eeb9c7f6..29ba7231e8b 100644 --- a/packages/auth/demo/src/index.js +++ b/packages/auth/demo/src/index.js @@ -76,8 +76,8 @@ import { revokeAccessToken, AuthErrorCodes, validatePassword, - signInWithPhoneNumber, - } from '@firebase/auth'; + signInWithPhoneNumber +} from '@firebase/auth'; import { config } from './config'; import { @@ -717,9 +717,6 @@ function clearApplicationVerifier() { } } -/** - * Sends a phone number verification code for sign-in. - */ async function onSignInVerifyPhoneNumber() { const phoneNumber = $('#signin-phone-number').val(); // Clear existing reCAPTCHA as an existing reCAPTCHA could be targeted for a @@ -727,26 +724,70 @@ async function onSignInVerifyPhoneNumber() { clearApplicationVerifier(); // Initialize a reCAPTCHA application verifier. makeApplicationVerifier('signin-verify-phone-number'); - await signInWithPhoneNumber(auth, phoneNumber, applicationVerifier, 30) - .then(userCredential => { - onAuthUserCredentialSuccess(userCredential); - }) - .catch(e => { + signInWithPhoneNumber(auth, phoneNumber, applicationVerifier).then( + confirmationResult => { + window.confirmationResult = confirmationResult; + } + ); +} + +async function onSignInVerifyPhoneNumberWebOTP() { + const phoneNumber = $('#signin-phone-number').val(); + // 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'); + await signInWithPhoneNumber(auth, phoneNumber, applicationVerifier).then( + confirmationResult => { + window.confirmationResult = confirmationResult; + confirmationResult + .confirmed(auth) + .then(onAuthUserCredentialSuccess, onAuthError); + } + ); +} + +function onSignInConfirmPhoneVerification() { + const verificationCode = $('#signin-phone-verification-code').val(); + const confirmationResult = window.confirmationResult; + confirmationResult + .confirm(verificationCode) + .then(onAuthUserCredentialSuccess, onAuthError); +} + +/** + * Sends a phone number verification code for sign-in. + */ +async function onSignInVerifyPhoneNumberAuthProvider() { + const phoneNumber = $('#signin-phone-number-auth-provider').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-auth-provider'); + provider.verifyPhoneNumber(phoneNumber, applicationVerifier).then( + verificationId => { clearApplicationVerifier(); - onAuthError(e); - if (e.code === `auth/${AuthErrorCodes.WEB_OTP_NOT_RETRIEVED}`) { - const verificationCode = $('#signin-phone-verification-code').val(); - e.confirmationResult.confirm(verificationCode); - } - }); + $('#signin-phone-verification-id-auth-provider').val(verificationId); + alertSuccess('Phone verification sent!'); + }, + error => { + clearApplicationVerifier(); + onAuthError(error); + } + ); } /** * Confirms a phone number verification for sign-in. */ -function onSignInConfirmPhoneVerification() { - const verificationId = $('#signin-phone-verification-id').val(); - const verificationCode = $('#signin-phone-verification-code').val(); +function onSignInConfirmPhoneVerificationAuthProvider() { + const verificationId = $('#signin-phone-verification-id-auth-provider').val(); + const verificationCode = $( + '#signin-phone-verification-code-auth-provider' + ).val(); const credential = PhoneAuthProvider.credential( verificationId, verificationCode @@ -2277,10 +2318,21 @@ function initApp() { $('#sign-in-with-generic-idp-credential').click( onSignInWithGenericIdPCredential ); + $('#signin-verify-phone-number').click(onSignInVerifyPhoneNumber); + $('#signin-verify-phone-number-webotp').click( + onSignInVerifyPhoneNumberWebOTP + ); $('#signin-confirm-phone-verification').click( onSignInConfirmPhoneVerification ); + $('#signin-verify-phone-number-auth-provider').click( + onSignInVerifyPhoneNumberAuthProvider + ); + $('#signin-confirm-phone-verification-auth-provider').click( + onSignInConfirmPhoneVerificationAuthProvider + ); + // On enter click in verification code, complete phone sign-in. This prevents // reCAPTCHA from being re-rendered (default behavior on enter). $('#signin-phone-verification-code').keypress(e => { diff --git a/packages/auth/src/model/public_types.ts b/packages/auth/src/model/public_types.ts index b083c4a064f..134f506910c 100644 --- a/packages/auth/src/model/public_types.ts +++ b/packages/auth/src/model/public_types.ts @@ -600,6 +600,8 @@ export interface ConfirmationResult { auth: Auth, webOTPTimeoutSeconds: number ): Promise; + + confirmed(auth: Auth): Promise; } /** diff --git a/packages/auth/src/platform_browser/providers/phone.ts b/packages/auth/src/platform_browser/providers/phone.ts index 8d87606d262..06f3bdbd92d 100644 --- a/packages/auth/src/platform_browser/providers/phone.ts +++ b/packages/auth/src/platform_browser/providers/phone.ts @@ -152,10 +152,7 @@ export class PhoneAuthProvider { return _verifyPhoneNumber( this.auth, phoneOptions, - getModularInstance( - applicationVerifier as ApplicationVerifierInternal - ), - webOTPTimeoutSeconds + getModularInstance(applicationVerifier as ApplicationVerifierInternal) ); } catch (error) { throw error; diff --git a/packages/auth/src/platform_browser/strategies/phone.ts b/packages/auth/src/platform_browser/strategies/phone.ts index 4e1dd6b599b..d002922f213 100644 --- a/packages/auth/src/platform_browser/strategies/phone.ts +++ b/packages/auth/src/platform_browser/strategies/phone.ts @@ -71,12 +71,59 @@ class ConfirmationResultImpl implements ConfirmationResult { private readonly onConfirmation: OnConfirmationCallback ) {} - confirm(verificationCode: string): Promise { - const authCredential = PhoneAuthCredential._fromVerification( - this.verificationId, - verificationCode - ); - return this.onConfirmation(authCredential); + private confirmInProgress = false; + private confirmResolve: ( + value: UserCredential | PromiseLike + ) => void = () => {}; + private confirmReject: (reason: Error) => void = () => {}; + + // confirm method with minimal changes + async confirm(verificationCode: string): Promise { + this.confirmInProgress = true; + try { + const authCredential = PhoneAuthCredential._fromVerification( + this.verificationId, + verificationCode + ); + const userCredential = await this.onConfirmation(authCredential); + this.confirmResolve(userCredential); // Resolve any waiting confirmed promise + return userCredential; + } catch (error) { + this.confirmReject(error as Error); // Reject any waiting confirmed promise + throw error; + } finally { + this.confirmInProgress = false; + } + } + + // New confirmed method + confirmed(auth: Auth): Promise { + // If confirm is already in progress, we return a promise that will be resolved + // or rejected by the ongoing confirm operation. + if (this.confirmInProgress) { + return new Promise((resolve, reject) => { + this.confirmResolve = resolve; + this.confirmReject = reject; + }); + } else { + // If confirm is not in progress, we race confirmWithWebOTP with a promise + // that can be resolved or rejected by a future confirm call. + const manualConfirmationPromise = new Promise( + (resolve, reject) => { + this.confirmResolve = resolve; + this.confirmReject = reject; + } + ); + + // Immediately invoke confirmWithWebOTP to start the WebOTP process + const webOTPConfirmationPromise = this.confirmWithWebOTP(auth, 30); + + // Race the manual confirmation promise against the WebOTP confirmation promise + return Promise.race([ + manualConfirmationPromise, + webOTPConfirmationPromise + ]); + } } async confirmWithWebOTP( @@ -186,42 +233,6 @@ class ConfirmationResultImpl implements ConfirmationResult { * @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}. - * - * @public - */ -export async function signInWithPhoneNumber( - auth: Auth, - phoneNumber: string, - appVerifier: ApplicationVerifier -): 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 @@ -229,39 +240,17 @@ export async function signInWithPhoneNumber( 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 { + appVerifier: ApplicationVerifier +): Promise { const authInternal = _castAuth(auth); - 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) - ); - } + const verificationId = await _verifyPhoneNumber( + authInternal, + phoneNumber, + getModularInstance(appVerifier as ApplicationVerifierInternal) + ); + return new ConfirmationResultImpl(verificationId, cred => + signInWithCredential(authInternal, cred) + ); } /** @@ -331,21 +320,7 @@ export async function _verifyPhoneNumber( auth: AuthInternal, options: PhoneInfoOptions | string, verifier: ApplicationVerifierInternal -): 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 { +): Promise { const recaptchaToken = await verifier.verify(); try { @@ -413,20 +388,6 @@ export async function _verifyPhoneNumber( }); 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();