From 2c3c58a5d7ca21ca8923407c495e14a58f630318 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ferenc=20Z=C3=BCllich?= Date: Tue, 27 Sep 2022 10:47:40 +0200 Subject: [PATCH 1/9] feat(auth): adds iOS/Web support for multi-factor sign-in flow Adds code required to complete the sign-in flow for users that have enrolled second factors. Due to a difference in the implementation of the PhoneAuthProvider it is not possible to follow the implementation of the Web API. --- packages/auth/__tests__/auth.test.ts | 49 +++++++ packages/auth/ios/RNFBAuth/RNFBAuthModule.h | 3 +- packages/auth/ios/RNFBAuth/RNFBAuthModule.m | 118 +++++++++++++++- packages/auth/lib/MultiFactorResolver.js | 15 ++ .../auth/lib/PhoneMultiFactorGenerator.js | 33 +++++ packages/auth/lib/User.js | 4 + packages/auth/lib/getMultiFactorResolver.js | 19 +++ packages/auth/lib/index.d.ts | 130 ++++++++++++++++++ packages/auth/lib/index.js | 16 +++ 9 files changed, 384 insertions(+), 3 deletions(-) create mode 100644 packages/auth/lib/MultiFactorResolver.js create mode 100644 packages/auth/lib/PhoneMultiFactorGenerator.js create mode 100644 packages/auth/lib/getMultiFactorResolver.js diff --git a/packages/auth/__tests__/auth.test.ts b/packages/auth/__tests__/auth.test.ts index 8c53195eb5..0c37fa1cd0 100644 --- a/packages/auth/__tests__/auth.test.ts +++ b/packages/auth/__tests__/auth.test.ts @@ -2,6 +2,9 @@ import { describe, expect, it } from '@jest/globals'; import auth, { firebase } from '../lib'; +// @ts-ignore - We don't mind missing types here +import { NativeFirebaseError } from '../../app/lib/internal'; + describe('Auth', function () { describe('namespace', function () { it('accessible from firebase.app()', function () { @@ -69,4 +72,50 @@ describe('Auth', function () { } }); }); + + describe('getMultiFactorResolver', function () { + it('should return null if no resolver object is found', function () { + const unknownError = NativeFirebaseError.fromEvent( + { + code: 'unknown', + }, + 'auth', + ); + const actual = auth.getMultiFactorResolver(auth(), unknownError); + expect(actual).toBe(null); + }); + + it('should return null if resolver object is null', function () { + const unknownError = NativeFirebaseError.fromEvent( + { + code: 'unknown', + resolver: null, + }, + 'auth', + ); + const actual = auth.getMultiFactorResolver(firebase.app().auth(), unknownError); + expect(actual).toBe(null); + }); + + it('should return the resolver object if its found', function () { + const resolver = { session: '', hints: [] }; + const errorWithResolver = NativeFirebaseError.fromEvent( + { + code: 'multi-factor-auth-required', + resolver, + }, + 'auth', + ); + const actual = auth.getMultiFactorResolver(firebase.app().auth(), errorWithResolver); + // Using expect(actual).toEqual(resolver) causes unexpected errors: + // You attempted to use "firebase.app('[DEFAULT]').appCheck" but this module could not be found. + expect(actual).not.toBeNull(); + // @ts-ignore We know actual is not null + expect(actual.session).toEqual(resolver.session); + // @ts-ignore We know actual is not null + expect(actual.hints).toEqual(resolver.hints); + // @ts-ignore We know actual is not null + expect(actual._auth).not.toBeNull(); + }); + }); }); diff --git a/packages/auth/ios/RNFBAuth/RNFBAuthModule.h b/packages/auth/ios/RNFBAuth/RNFBAuthModule.h index 8e7cbaf56c..dd7b0774c1 100644 --- a/packages/auth/ios/RNFBAuth/RNFBAuthModule.h +++ b/packages/auth/ios/RNFBAuth/RNFBAuthModule.h @@ -82,4 +82,5 @@ NSString* const AuthErrorCode_toJSErrorCode[] = { [FIRAuthErrorCodeNullUser] = @"null-user", [FIRAuthErrorCodeKeychainError] = @"keychain-error", [FIRAuthErrorCodeInternalError] = @"internal-error", - [FIRAuthErrorCodeMalformedJWT] = @"malformed-jwt"}; \ No newline at end of file + [FIRAuthErrorCodeMalformedJWT] = @"malformed-jwt", + [FIRAuthErrorCodeSecondFactorRequired] = @"multi-factor-auth-required"}; diff --git a/packages/auth/ios/RNFBAuth/RNFBAuthModule.m b/packages/auth/ios/RNFBAuth/RNFBAuthModule.m index 4900760e7d..75fee58b71 100644 --- a/packages/auth/ios/RNFBAuth/RNFBAuthModule.m +++ b/packages/auth/ios/RNFBAuth/RNFBAuthModule.m @@ -31,6 +31,7 @@ static NSString *const keyProfile = @"profile"; static NSString *const keyNewUser = @"isNewUser"; static NSString *const keyUsername = @"username"; +static NSString *const keyMultiFactor = @"multiFactor"; static NSString *const keyPhotoUrl = @"photoURL"; static NSString *const keyBundleId = @"bundleId"; static NSString *const keyInstallApp = @"installApp"; @@ -52,6 +53,7 @@ static __strong NSMutableDictionary *idTokenHandlers; // Used for caching credentials between method calls. static __strong NSMutableDictionary *credentials; +static __strong NSMutableDictionary *cachedResolver; @implementation RNFBAuthModule #pragma mark - @@ -70,6 +72,7 @@ - (id)init { authStateHandlers = [[NSMutableDictionary alloc] init]; idTokenHandlers = [[NSMutableDictionary alloc] init]; credentials = [[NSMutableDictionary alloc] init]; + cachedResolver = [[NSMutableDictionary alloc] init]; }); return self; } @@ -95,6 +98,7 @@ - (void)invalidate { [idTokenHandlers removeAllObjects]; [credentials removeAllObjects]; + [cachedResolver removeAllObjects]; } #pragma mark - @@ -732,12 +736,72 @@ - (void)invalidate { } }]; } +RCT_EXPORT_METHOD(verifyPhoneNumberWithMultiFactorInfo + : (FIRApp *)firebaseApp + : (NSString *)hintUid + : (NSString *)sessionKey + : (RCTPromiseResolveBlock)resolve + : (RCTPromiseRejectBlock)reject) { + if ([cachedResolver valueForKey:sessionKey] == nil) { + [RNFBSharedUtils + rejectPromiseWithUserInfo:reject + userInfo:(NSMutableDictionary *)@{ + @"code" : @"invalid-multi-factor-session", + @"message" : @"No resolver for session found. Is the session id correct?" + }]; + return; + } + FIRMultiFactorSession *session = cachedResolver[sessionKey].session; + NSPredicate *findByUid = [NSPredicate predicateWithFormat:@"UID == %@", hintUid]; + FIRPhoneMultiFactorInfo *hint = + [[cachedResolver[sessionKey].hints filteredArrayUsingPredicate:findByUid] firstObject]; + + [FIRPhoneAuthProvider.provider + verifyPhoneNumberWithMultiFactorInfo:hint + UIDelegate:nil + multiFactorSession:session + completion:^(NSString *_Nullable verificationID, + NSError *_Nullable error) { + if (error) { + [self promiseRejectAuthException:reject error:error]; + } else { + resolve(verificationID); + } + }]; +} + +RCT_EXPORT_METHOD(resolveMultiFactorSignIn + : (FIRApp *)firebaseApp + : (NSString *)sessionKey + : (NSString *)verificationId + : (NSString *)verificationCode + : (RCTPromiseResolveBlock)resolve + : (RCTPromiseRejectBlock)reject) { + FIRPhoneAuthCredential *credential = + [[FIRPhoneAuthProvider providerWithAuth:[FIRAuth authWithApp:firebaseApp]] + credentialWithVerificationID:verificationId + verificationCode:verificationCode]; + FIRMultiFactorAssertion *assertion = + [FIRPhoneMultiFactorGenerator assertionWithCredential:credential]; + [cachedResolver[sessionKey] resolveSignInWithAssertion:assertion + completion:^(FIRAuthDataResult *_Nullable authResult, + NSError *_Nullable error) { + if (error) { + [self promiseRejectAuthException:reject + error:error]; + } else { + [self promiseWithAuthResult:resolve + rejecter:reject + authResult:authResult]; + } + }]; +} RCT_EXPORT_METHOD(verifyPhoneNumber : (FIRApp *)firebaseApp : (NSString *)phoneNumber : (NSString *)requestKey) { - [[FIRPhoneAuthProvider providerWithAuth:[FIRAuth authWithApp:firebaseApp]] + [FIRPhoneAuthProvider.provider verifyPhoneNumber:phoneNumber UIDelegate:nil completion:^(NSString *_Nullable verificationID, NSError *_Nullable error) { @@ -1015,8 +1079,42 @@ - (void)promiseNoUser:(RCTPromiseResolveBlock)resolve } } +- (NSDictionary *)multiFactorResolverToDict:(FIRMultiFactorResolver *)resolver { + NSMutableArray *hintsOutput = [NSMutableArray array]; + for (FIRPhoneMultiFactorInfo *hint in resolver.hints) { + NSString *enrollmentDate = + [[[NSISO8601DateFormatter alloc] init] stringFromDate:hint.enrollmentDate]; + + [hintsOutput addObject:@{ + @"uid" : hint.UID, + @"factorId" : [self getJSFactorId:(hint.factorID)], + @"displayName" : hint.displayName, + @"enrollmentDate" : enrollmentDate, + @"phoneNumber" : hint.phoneNumber + }]; + } + + // Temporarily store the non-serializable session for later + NSString *sessionHash = [NSString stringWithFormat:@"%@", @([resolver.session hash])]; + + return @{ + @"hints" : hintsOutput, + @"session" : sessionHash, + }; +} + +- (NSString *)getJSFactorId:(NSString *)factorId { + if ([factorId isEqualToString:@"1"]) { + // Only phone is supported by the front-end so far + return @"phone"; + } + + return factorId; +} + - (void)promiseRejectAuthException:(RCTPromiseRejectBlock)reject error:(NSError *)error { NSDictionary *jsError = [self getJSError:(error)]; + [RNFBSharedUtils rejectPromiseWithUserInfo:reject userInfo:(NSMutableDictionary *)@{ @@ -1024,6 +1122,7 @@ - (void)promiseRejectAuthException:(RCTPromiseRejectBlock)reject error:(NSError @"message" : [jsError valueForKey:@"message"], @"nativeErrorMessage" : [jsError valueForKey:@"nativeErrorMessage"], @"authCredential" : [jsError valueForKey:@"authCredential"], + @"resolver" : [jsError valueForKey:@"resolver"] }]; } @@ -1058,6 +1157,9 @@ - (NSDictionary *)getJSError:(NSError *)error { message = @"This operation is sensitive and requires recent authentication. Log in again " @"before retrying this request."; break; + case FIRAuthErrorCodeSecondFactorRequired: + message = @"Please complete a second factor challenge to finish signing into this account."; + break; case FIRAuthErrorCodeAccountExistsWithDifferentCredential: message = @"An account already exists with the same email address but different sign-in " @"credentials. Sign in using a provider associated with this email address."; @@ -1103,11 +1205,22 @@ - (NSDictionary *)getJSError:(NSError *)error { authCredentialDict = [self authCredentialToDict:authCredential]; } + NSDictionary *resolverDict = nil; + if ([error userInfo][FIRAuthErrorUserInfoMultiFactorResolverKey] != nil) { + FIRMultiFactorResolver *resolver = + (FIRMultiFactorResolver *)error.userInfo[FIRAuthErrorUserInfoMultiFactorResolverKey]; + resolverDict = [self multiFactorResolverToDict:resolver]; + + NSString *sessionKey = [NSString stringWithFormat:@"%@", @([resolver.session hash])]; + cachedResolver[sessionKey] = resolver; + } + return @{ @"code" : code, @"message" : message, @"nativeErrorMessage" : nativeErrorMessage, @"authCredential" : authCredentialDict != nil ? (id)authCredentialDict : [NSNull null], + @"resolver" : resolverDict != nil ? (id)resolverDict : [NSNull null] }; } @@ -1246,7 +1359,8 @@ - (NSDictionary *)firebaseUserToDict:(FIRUser *)user { keyProviderId : [user.providerID lowercaseString], @"refreshToken" : user.refreshToken, @"tenantId" : user.tenantID ? (id)user.tenantID : [NSNull null], - keyUid : user.uid + keyUid : user.uid, + @"multiFactor" : user.multiFactor.enrolledFactors }; } diff --git a/packages/auth/lib/MultiFactorResolver.js b/packages/auth/lib/MultiFactorResolver.js new file mode 100644 index 0000000000..a0c2e3874e --- /dev/null +++ b/packages/auth/lib/MultiFactorResolver.js @@ -0,0 +1,15 @@ +/** + * Base class to facilitate multi-factor authentication. + */ +export default class MultiFactorResolver { + constructor(auth, resolver) { + this._auth = auth; + this.hints = resolver.hints; + this.session = resolver.session; + } + + resolveSignIn(assertion) { + const { token, secret } = assertion; + return this._auth.resolveMultiFactorSignIn(this.session, token, secret); + } +} diff --git a/packages/auth/lib/PhoneMultiFactorGenerator.js b/packages/auth/lib/PhoneMultiFactorGenerator.js new file mode 100644 index 0000000000..1f3bbdd4e4 --- /dev/null +++ b/packages/auth/lib/PhoneMultiFactorGenerator.js @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2016-present Invertase Limited & Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this library except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +export default class PhoneMultiFactorGenerator { + static FACTOR_ID = 'phone'; + + constructor() { + throw new Error( + '`new PhoneMultiFactorGenerator()` is not supported on the native Firebase SDKs.', + ); + } + + static assertion(credential) { + // There is no logic here, we mainly do this for API compatibility + // (following the Web API). + const { token, secret } = credential; + return { token, secret }; + } +} diff --git a/packages/auth/lib/User.js b/packages/auth/lib/User.js index d11536013f..8ac80ae49b 100644 --- a/packages/auth/lib/User.js +++ b/packages/auth/lib/User.js @@ -48,6 +48,10 @@ export default class User { }; } + get multiFactor() { + return this._user.multiFactor || null; + } + get phoneNumber() { return this._user.phoneNumber || null; } diff --git a/packages/auth/lib/getMultiFactorResolver.js b/packages/auth/lib/getMultiFactorResolver.js new file mode 100644 index 0000000000..faada5e62e --- /dev/null +++ b/packages/auth/lib/getMultiFactorResolver.js @@ -0,0 +1,19 @@ +import MultiFactorResolver from './MultiFactorResolver.js'; + +/** + * Create a new resolver based on an auth instance and error + * object. + * + * Returns null if no resolver object can be found on the error. + */ +export function getMultiFactorResolver(auth, error) { + if ( + error.hasOwnProperty('userInfo') && + error.userInfo.hasOwnProperty('resolver') && + error.userInfo.resolver + ) { + return new MultiFactorResolver(auth, error.userInfo.resolver); + } + + return null; +} diff --git a/packages/auth/lib/index.d.ts b/packages/auth/lib/index.d.ts index 91f0f83e82..757a47ef50 100644 --- a/packages/auth/lib/index.d.ts +++ b/packages/auth/lib/index.d.ts @@ -59,6 +59,11 @@ export namespace FirebaseAuthTypes { * you might receive an updated credential (depending of provider) which you can use to recover from the error. */ authCredential: AuthCredential | null; + /** + * When trying to sign in the user might be prompted for a second factor confirmation. Can + * can use this object to initialize the second factor flow and recover from the error. + */ + resolver: MultiFactorResolver | null; }; } @@ -189,10 +194,40 @@ export namespace FirebaseAuthTypes { ERROR: 'error'; } + export interface PhoneMultiFactorGenerator { + /** + * Identifies second factors of type phone. + */ + FACTOR_ID: FactorId.PHONE; + + /** + * Build a MultiFactorAssertion to resolve the multi-factor sign in process. + */ + assertion(credential: AuthCredential): MultiFactorAssertion; + } + /** * firebase.auth.X */ export interface Statics { + /** + * Try and obtain a #{@link MultiFactorResolver} instance based on an error. + * Returns null if no resolver object could be found. + * + * #### Example + * + * ```js + * const auth = firebase.auth(); + * auth.signInWithEmailAndPassword(email, password).then((user) => { + * // signed in + * }).catch((error) => { + * if (error.code === 'auth/multi-factor-auth-required') { + * const resolver = getMultiFactorResolver(auth, error); + * } + * }); + * ``` + */ + getMultiFactorResolver: getMultiFactorResolver; /** * Email and password auth provider implementation. * @@ -285,6 +320,11 @@ export namespace FirebaseAuthTypes { * ``` */ PhoneAuthState: PhoneAuthState; + + /** + * A PhoneMultiFactorGenerator interface. + */ + PhoneMultiFactorGenerator: PhoneMultiFactorGenerator; } /** @@ -360,6 +400,86 @@ export namespace FirebaseAuthTypes { lastSignInTime?: string; } + /** + * Identifies the type of a second factor. + */ + export enum FactorId { + PHONE = 'phone', + } + + /** + * Contains information about a second factor. + */ + export interface MultiFactorInfo { + /** + * User friendly name for this factor. + */ + displayName?: string; + /** + * Time the second factor was enrolled, in UTC. + */ + enrollmentTime: string; + /** + * Type of factor. + */ + factorId: FactorId; + /** + * Unique id for this factor. + */ + uid: string; + } + + export interface MultiFactorAssertion { + token: string; + secret: string; + } + + /** + * Facilitates the recovery when a user needs to provide a second factor to sign-in. + */ + export interface MultiFactorResolver { + /** + * A list of enrolled factors that can be used to complete the multi-factor challenge. + */ + hints: MultiFactorInfo[]; + /** + * Serialized session this resolver belongs to. + */ + session: string; + + /** + * For testing purposes only + */ + _auth: FirebaseAuthTypes.Module; + + /** + * Resolve the multi factor flow. + */ + resolveSignIn(assertion: MultiFactorAssertion): Promise; + } + + export type getMultiFactorResolver = ( + auth: FirebaseAuthTypes.Module, + error: unknown, + ) => MultiFactorResolver | null; + + /** + * Holds information about the user's enrolled factors. + * + * #### Example + * + * ```js + * const user = firebase.auth().currentUser; + * console.log('User multi factors: ', user.multiFactor); + * ``` + */ + export interface MultiFactor { + /** + * Returns the enrolled factors + */ + enrolledFactors: MultiFactorInfo[]; + } + /** * Represents a collection of standard profile information for a user. Can be used to expose * profile information returned by an identity provider, such as Google Sign-In or Facebook Login. @@ -904,6 +1024,11 @@ export namespace FirebaseAuthTypes { */ metadata: UserMetadata; + /** + * Returns the {@link auth.MultiFactor} associated with this user. + */ + multiFactor: MultiFactor | null; + /** * Returns the phone number of the user, as stored in the Firebase project's user database, * or null if none exists. This can be updated at any time by calling {@link auth.User#updatePhoneNumber}. @@ -1412,6 +1537,11 @@ export namespace FirebaseAuthTypes { forceResend?: boolean, ): PhoneAuthListener; + /** + * Obtain a verification id to complete the multi-factor sign-in flow. + */ + verifyPhoneNumberWithMultiFactorInfo(hint: MultiFactorInfo, session: string): Promise; + /** * Creates a new user with an email and password. * diff --git a/packages/auth/lib/index.js b/packages/auth/lib/index.js index 4e57952b8e..5c0c51cae9 100644 --- a/packages/auth/lib/index.js +++ b/packages/auth/lib/index.js @@ -35,11 +35,13 @@ import GithubAuthProvider from './providers/GithubAuthProvider'; import GoogleAuthProvider from './providers/GoogleAuthProvider'; import OAuthProvider from './providers/OAuthProvider'; import PhoneAuthProvider from './providers/PhoneAuthProvider'; +import PhoneMultiFactorGenerator from './PhoneMultiFactorGenerator'; import TwitterAuthProvider from './providers/TwitterAuthProvider'; import AppleAuthProvider from './providers/AppleAuthProvider'; import Settings from './Settings'; import User from './User'; import version from './version'; +import { getMultiFactorResolver } from './getMultiFactorResolver'; const statics = { AppleAuthProvider, @@ -49,6 +51,7 @@ const statics = { GithubAuthProvider, TwitterAuthProvider, FacebookAuthProvider, + PhoneMultiFactorGenerator, OAuthProvider, PhoneAuthState: { CODE_SENT: 'sent', @@ -56,6 +59,7 @@ const statics = { AUTO_VERIFIED: 'verified', ERROR: 'error', }, + getMultiFactorResolver, }; const namespace = 'auth'; @@ -250,6 +254,18 @@ class FirebaseAuthModule extends FirebaseModule { return new PhoneAuthListener(this, phoneNumber, _autoVerifyTimeout, _forceResend); } + verifyPhoneNumberWithMultiFactorInfo(multiFactorHint, session) { + return this.native.verifyPhoneNumberWithMultiFactorInfo(multiFactorHint.uid, session); + } + + resolveMultiFactorSignIn(session, verificationId, verificationCode) { + return this.native + .resolveMultiFactorSignIn(session, verificationId, verificationCode) + .then(userCredential => { + return this._setUserCredential(userCredential); + }); + } + createUserWithEmailAndPassword(email, password) { return this.native .createUserWithEmailAndPassword(email, password) From 746db6d770a4822b1f01f65598f08dde148f346c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ferenc=20Z=C3=BCllich?= Date: Thu, 6 Oct 2022 15:37:22 +0200 Subject: [PATCH 2/9] docs(auth): update documentation for multi-factor authentication Provide general setup and usage information for multi-factor authentication flows. --- docs/auth/multi-factor-auth.md | 140 +++++++++++++++++++++++++++++++++ docs/auth/phone-auth.md | 2 +- docs/sidebar.yaml | 2 + packages/auth/lib/index.d.ts | 34 ++++---- 4 files changed, 160 insertions(+), 18 deletions(-) create mode 100644 docs/auth/multi-factor-auth.md diff --git a/docs/auth/multi-factor-auth.md b/docs/auth/multi-factor-auth.md new file mode 100644 index 0000000000..807145274f --- /dev/null +++ b/docs/auth/multi-factor-auth.md @@ -0,0 +1,140 @@ +--- +title: Multi-factor Auth +description: Increase security by adding Multi-factor authentication to your app. +next: /firestore/usage +previous: /auth/phone-auth +--- + +# iOS Setup + +Make sure to follow [the official Identity Platform +documentation](https://cloud.google.com/identity-platform/docs/ios/mfa#enabling_multi-factor_authentication) +to enable multi-factor authentication for your project and verify your app. + +# Sign-in flow using multi-factor + +Ensure the account has already enrolled a second factor. Begin by calling the +default sign-in methods, for example email and password. If the account requires +a second factor to complete login, an exception will be raised: + +```js +import auth from '@react-native-firebase/auth'; + +auth() + .signInWithEmailAndPassword(email, password) + .then(() => { + // User has not enrolled a second factor + }) + .catch(error => { + const { code } = error; + // Make sure to check if multi factor authentication is required + if (code === 'auth/multi-factor-auth-required') { + return; + } + + // Other error + }); +``` + +Using the error object you can obtain a +[`MultiFactorResolver`](/reference/auth/multifactorresolver) instance and +continue the flow: + +```js +const resolver = auth.getMultiFactorResolver(auth(), error); +``` + +The resolver object has all the required information to prompt the user for a +specific factor: + +```js +if (resolver.hints.length > 1) { + // Use resolver.hints to display a list of second factors to the user +} + +// Currently only phone based factors are supported +if (resolver.hints[0].factorId === auth.PhoneMultiFactorGenerator.FACTOR_ID) { + // Continue with the sign-in flow +} +``` + +Using a multi-factor hint and the session information you can send a +verification code to the user: + +```js +const hint = resolver.hints[0]; +const sessionId = resolver.session; + +auth() + .verifyPhoneNumberWithMultiFactorInfo(hint, sessionId) // triggers the message to the user + .then(verificationId => setVerificationId(verificationId)); +``` + +Once the user has entered the verification code you can create a multi-factor +assertion and finish the flow: + +```js +const credential = auth.PhoneAuthProvider.credential(verificationId, verificationCode); + +const multiFactorAssertion = auth.PhoneMultiFactorGenerator.assertion(credential); + +resolver.resolveSignIn(multiFactorAssertion).then(userCredential => { + // additionally onAuthStateChanged will be triggered as well +}); +``` + +Upon successful sign-in, any +[`onAuthStateChanged`](/auth/usage#listening-to-authentication-state) listeners +will trigger with the new authentication state of the user. + +To put the example together: + +```js +import auth from '@react-native-firebase/auth'; + +const authInstance = auth(); + +authInstance + .signInWithEmailAndPassword(email, password) + .then(() => { + // User has not enrolled a second factor + }) + .catch(error => { + const { code } = error; + // Make sure to check if multi factor authentication is required + if (code !== 'auth/multi-factor-auth-required') { + const resolver = auth.getMultiFactorResolver(authInstance, error); + + if (resolver.hints.length > 1) { + // Use resolver.hints to display a list of second factors to the user + } + + // Currently only phone based factors are supported + if (resolver.hints[0].factorId === auth.PhoneMultiFactorGenerator.FACTOR_ID) { + const hint = resolver.hints[0]; + const sessionId = resolver.session; + + authInstance + .verifyPhoneNumberWithMultiFactorInfo(hint, sessionId) // triggers the message to the user + .then(verificationId => setVerificationId(verificationId)); + + // Request verificationCode from user + + const credential = auth.PhoneAuthProvider.credential(verificationId, verificationCode); + + const multiFactorAssertion = auth.PhoneMultiFactorGenerator.assertion(credential); + + resolver.resolveSignIn(multiFactorAssertion).then(userCredential => { + // additionally onAuthStateChanged will be triggered as well + }); + } + } + }); +``` + +# Testing + +You can define test phone numbers and corresponding verification codes. The +official[official +guide](https://cloud.google.com/identity-platform/docs/ios/mfa#enabling_multi-factor_authentication) +contains more information on setting this up. diff --git a/docs/auth/phone-auth.md b/docs/auth/phone-auth.md index a15707882a..217afeaa22 100644 --- a/docs/auth/phone-auth.md +++ b/docs/auth/phone-auth.md @@ -1,7 +1,7 @@ --- title: Phone Authentication description: Sign-in users with their phone number. -next: /firestore/usage +next: /auth/multi-factor-auth previous: /auth/social-auth --- diff --git a/docs/sidebar.yaml b/docs/sidebar.yaml index 4b9e6fa6d8..09e7eec090 100644 --- a/docs/sidebar.yaml +++ b/docs/sidebar.yaml @@ -38,6 +38,8 @@ - '/auth/social-auth' - - Phone Auth - '/auth/phone-auth' + - - Multi-factor Auth + - '/auth/multi-factor-auth' - '//static.invertase.io/assets/firebase/authentication.svg' - - Cloud Firestore - - - Usage diff --git a/packages/auth/lib/index.d.ts b/packages/auth/lib/index.d.ts index 757a47ef50..3c6f15d818 100644 --- a/packages/auth/lib/index.d.ts +++ b/packages/auth/lib/index.d.ts @@ -210,23 +210,6 @@ export namespace FirebaseAuthTypes { * firebase.auth.X */ export interface Statics { - /** - * Try and obtain a #{@link MultiFactorResolver} instance based on an error. - * Returns null if no resolver object could be found. - * - * #### Example - * - * ```js - * const auth = firebase.auth(); - * auth.signInWithEmailAndPassword(email, password).then((user) => { - * // signed in - * }).catch((error) => { - * if (error.code === 'auth/multi-factor-auth-required') { - * const resolver = getMultiFactorResolver(auth, error); - * } - * }); - * ``` - */ getMultiFactorResolver: getMultiFactorResolver; /** * Email and password auth provider implementation. @@ -458,6 +441,23 @@ export namespace FirebaseAuthTypes { resolveSignIn(assertion: MultiFactorAssertion): Promise; } + /** + * Try and obtain a #{@link MultiFactorResolver} instance based on an error. + * Returns null if no resolver object could be found. + * + * #### Example + * + * ```js + * const auth = firebase.auth(); + * auth.signInWithEmailAndPassword(email, password).then((user) => { + * // signed in + * }).catch((error) => { + * if (error.code === 'auth/multi-factor-auth-required') { + * const resolver = getMultiFactorResolver(auth, error); + * } + * }); + * ``` + */ export type getMultiFactorResolver = ( auth: FirebaseAuthTypes.Module, error: unknown, From 81ecac4db4b31a9fb316ba8e777b39272a5c8a7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ferenc=20Z=C3=BCllich?= Date: Tue, 18 Oct 2022 09:46:25 +0200 Subject: [PATCH 3/9] fix(docs): Change prev link for Firestore docs to multi-factor auth --- docs/firestore/usage/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/firestore/usage/index.md b/docs/firestore/usage/index.md index 71dc861f56..39afcbbf62 100644 --- a/docs/firestore/usage/index.md +++ b/docs/firestore/usage/index.md @@ -3,7 +3,7 @@ title: Cloud Firestore description: Installation and getting started with Firestore. icon: //static.invertase.io/assets/firebase/cloud-firestore.svg next: /firestore/usage-with-flatlists -previous: /auth/phone-auth +previous: /auth/multi-factor-auth --- # Installation From fba9c3df9d448b5aa33160a2bac6b7698109dbbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ferenc=20Z=C3=BCllich?= Date: Fri, 7 Oct 2022 14:04:56 +0200 Subject: [PATCH 4/9] feat(auth): Adds Android support for multi-factor sign-in flow Implement the Android part required to support the multi-factor sign-in flow. Makes the `multiFactor` property for the Firebase user object available as well. Known issues: - The enrollmentInfo for a MultiFactorInfo is currently reported with 0 on Android. --- .../common/ReactNativeFirebaseModule.java | 9 + .../auth/ReactNativeFirebaseAuthModule.java | 188 +++++++++++++++++- 2 files changed, 196 insertions(+), 1 deletion(-) diff --git a/packages/app/android/src/reactnative/java/io/invertase/firebase/common/ReactNativeFirebaseModule.java b/packages/app/android/src/reactnative/java/io/invertase/firebase/common/ReactNativeFirebaseModule.java index 445c012731..75da0901f7 100644 --- a/packages/app/android/src/reactnative/java/io/invertase/firebase/common/ReactNativeFirebaseModule.java +++ b/packages/app/android/src/reactnative/java/io/invertase/firebase/common/ReactNativeFirebaseModule.java @@ -43,6 +43,15 @@ public static void rejectPromiseWithExceptionMap(Promise promise, Exception exce promise.reject(exception, SharedUtils.getExceptionMap(exception)); } + public static void rejectPromiseWithCodeAndMessage( + Promise promise, String code, String message, ReadableMap resolver) { + WritableMap userInfoMap = Arguments.createMap(); + userInfoMap.putString("code", code); + userInfoMap.putString("message", message); + userInfoMap.putMap("resolver", resolver); + promise.reject(code, message, userInfoMap); + } + public static void rejectPromiseWithCodeAndMessage(Promise promise, String code, String message) { WritableMap userInfoMap = Arguments.createMap(); userInfoMap.putString("code", code); diff --git a/packages/auth/android/src/main/java/io/invertase/firebase/auth/ReactNativeFirebaseAuthModule.java b/packages/auth/android/src/main/java/io/invertase/firebase/auth/ReactNativeFirebaseAuthModule.java index acfb6983ed..135aa0f547 100644 --- a/packages/auth/android/src/main/java/io/invertase/firebase/auth/ReactNativeFirebaseAuthModule.java +++ b/packages/auth/android/src/main/java/io/invertase/firebase/auth/ReactNativeFirebaseAuthModule.java @@ -21,6 +21,7 @@ import android.net.Uri; import android.os.Parcel; import android.util.Log; +import androidx.annotation.NonNull; import com.facebook.react.bridge.Arguments; import com.facebook.react.bridge.Promise; import com.facebook.react.bridge.ReactApplicationContext; @@ -42,6 +43,7 @@ import com.google.firebase.auth.FirebaseAuth; import com.google.firebase.auth.FirebaseAuthException; import com.google.firebase.auth.FirebaseAuthInvalidCredentialsException; +import com.google.firebase.auth.FirebaseAuthMultiFactorException; import com.google.firebase.auth.FirebaseAuthProvider; import com.google.firebase.auth.FirebaseAuthSettings; import com.google.firebase.auth.FirebaseUser; @@ -49,9 +51,15 @@ import com.google.firebase.auth.GetTokenResult; import com.google.firebase.auth.GithubAuthProvider; import com.google.firebase.auth.GoogleAuthProvider; +import com.google.firebase.auth.MultiFactorAssertion; +import com.google.firebase.auth.MultiFactorInfo; +import com.google.firebase.auth.MultiFactorResolver; import com.google.firebase.auth.OAuthProvider; import com.google.firebase.auth.PhoneAuthCredential; +import com.google.firebase.auth.PhoneAuthOptions; import com.google.firebase.auth.PhoneAuthProvider; +import com.google.firebase.auth.PhoneMultiFactorGenerator; +import com.google.firebase.auth.PhoneMultiFactorInfo; import com.google.firebase.auth.TwitterAuthProvider; import com.google.firebase.auth.UserInfo; import com.google.firebase.auth.UserProfileChangeRequest; @@ -59,6 +67,8 @@ import io.invertase.firebase.common.ReactNativeFirebaseEventEmitter; import io.invertase.firebase.common.ReactNativeFirebaseModule; import io.invertase.firebase.common.SharedUtils; +import java.text.SimpleDateFormat; +import java.util.Date; import java.util.HashMap; import java.util.Iterator; import java.util.List; @@ -72,6 +82,12 @@ @SuppressWarnings({"ThrowableResultOfMethodCallIgnored", "JavaDoc"}) class ReactNativeFirebaseAuthModule extends ReactNativeFirebaseModule { + // Easier to use would be Instant and DateTimeFormatter, but these are only available in API 26+ + // According to https://stackoverflow.com/a/2202300 this is not the optimal solution, but we only + // get a unix timestamp so we can hardcode the timezone. + public static final SimpleDateFormat ISO_8601_FORMATTER = + new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ"); + private static final String TAG = "Auth"; private static HashMap mAuthListeners = new HashMap<>(); private static HashMap mIdTokenListeners = new HashMap<>(); @@ -80,6 +96,8 @@ class ReactNativeFirebaseAuthModule extends ReactNativeFirebaseModule { private PhoneAuthProvider.ForceResendingToken mForceResendingToken; private PhoneAuthCredential mCredential; + private final HashMap mCachedResolvers = new HashMap<>(); + ReactNativeFirebaseAuthModule(ReactApplicationContext reactContext) { super(reactContext, TAG); } @@ -119,6 +137,8 @@ public void onCatalystInstanceDestroy() { firebaseAuth.removeIdTokenListener(mAuthListener); idTokenListenerIterator.remove(); } + + mCachedResolvers.clear(); } /** Add a new auth state listener - if one doesn't exist already */ @@ -928,6 +948,126 @@ public void onCodeAutoRetrievalTimeOut(String verificationId) { } } + @ReactMethod + public void verifyPhoneNumberWithMultiFactorInfo( + final String appName, final String hintUid, final String sessionKey, final Promise promise) { + final MultiFactorResolver resolver = mCachedResolvers.get(sessionKey); + if (resolver == null) { + // See https://firebase.google.com/docs/reference/node/firebase.auth.multifactorresolver for + // the error code + rejectPromiseWithCodeAndMessage( + promise, + "invalid-multi-factor-session", + "No resolver for session found. Is the session id correct?"); + return; + } + + MultiFactorInfo selectedHint = null; + for (MultiFactorInfo multiFactorInfo : resolver.getHints()) { + if (hintUid.equals(multiFactorInfo.getUid())) { + selectedHint = multiFactorInfo; + break; + } + } + + if (selectedHint == null) { + rejectPromiseWithCodeAndMessage(promise, "unknown", "Requested multi-factor hint not found."); + return; + } + + if (!PhoneMultiFactorGenerator.FACTOR_ID.equals(selectedHint.getFactorId())) { + rejectPromiseWithCodeAndMessage( + promise, "unknown", "Unsupported second factor. Only phone factors are supported."); + return; + } + + final Activity activity = getCurrentActivity(); + final PhoneAuthOptions phoneAuthOptions = + PhoneAuthOptions.newBuilder() + .setActivity(activity) + .setMultiFactorHint((PhoneMultiFactorInfo) selectedHint) + .setTimeout(30L, TimeUnit.SECONDS) + .setMultiFactorSession(resolver.getSession()) + .setCallbacks( + new PhoneAuthProvider.OnVerificationStateChangedCallbacks() { + @Override + public void onCodeSent( + @NonNull String verificationId, + @NonNull PhoneAuthProvider.ForceResendingToken forceResendingToken) { + promise.resolve(verificationId); + } + + @Override + public void onVerificationCompleted( + @NonNull PhoneAuthCredential phoneAuthCredential) { + resolveMultiFactorCredential(phoneAuthCredential, sessionKey, promise); + } + + @Override + public void onVerificationFailed(@NonNull FirebaseException e) { + promiseRejectAuthException(promise, e); + } + }) + .build(); + + PhoneAuthProvider.verifyPhoneNumber(phoneAuthOptions); + } + + /** + * This method is intended to resolve a {@link PhoneAuthCredential} obtained through a + * multi-factor authentication flow. A credential can either be obtained using: + * + *
    + *
  • {@link PhoneAuthProvider#getCredential(String, String)} + *
  • or {@link + * com.google.firebase.auth.PhoneAuthProvider.OnVerificationStateChangedCallbacks#onVerificationCompleted(PhoneAuthCredential)} + *
+ * + * @param authCredential + * @param sessionKey An identifier for the session the flow belongs to. Used to look up the {@link + * MultiFactorResolver} + * @param promise + */ + private void resolveMultiFactorCredential( + final PhoneAuthCredential authCredential, final String sessionKey, final Promise promise) { + final MultiFactorAssertion multiFactorAssertion = + PhoneMultiFactorGenerator.getAssertion(authCredential); + + final MultiFactorResolver resolver = mCachedResolvers.get(sessionKey); + if (resolver == null) { + rejectPromiseWithCodeAndMessage( + promise, + "invalid-multi-factor-session", + "No resolver for session found. Is the session id correct?"); + return; + } + + resolver + .resolveSignIn(multiFactorAssertion) + .addOnCompleteListener( + task -> { + if (task.isSuccessful()) { + AuthResult authResult = task.getResult(); + promiseWithAuthResult(authResult, promise); + } else { + promiseRejectAuthException(promise, task.getException()); + } + }); + } + + @ReactMethod + public void resolveMultiFactorSignIn( + final String appName, + final String session, + final String verificationId, + final String verificationCode, + final Promise promise) { + + final PhoneAuthCredential credential = + PhoneAuthProvider.getCredential(verificationId, verificationCode); + resolveMultiFactorCredential(credential, session, promise); + } + @ReactMethod public void confirmationResultConfirm( String appName, final String verificationCode, final Promise promise) { @@ -1650,7 +1790,8 @@ private void promiseWithAuthResult(AuthResult authResult, Promise promise) { */ private void promiseRejectAuthException(Promise promise, Exception exception) { WritableMap error = getJSError(exception); - rejectPromiseWithCodeAndMessage(promise, error.getString("code"), error.getString("message")); + rejectPromiseWithCodeAndMessage( + promise, error.getString("code"), error.getString("message"), error.getMap("resolver")); } /** @@ -1737,6 +1878,18 @@ private WritableMap getJSError(Exception exception) { } } + if (exception instanceof FirebaseAuthMultiFactorException) { + final FirebaseAuthMultiFactorException multiFactorException = + (FirebaseAuthMultiFactorException) exception; + // Make sure the error code conforms to the Web API. See + // https://firebase.google.com/docs/auth/web/multi-factor#signing_users_in_with_a_second_factor + code = "MULTI_FACTOR_AUTH_REQUIRED"; + final MultiFactorResolver resolver = multiFactorException.getResolver(); + final String sessionId = Integer.toString(resolver.getSession().hashCode()); + mCachedResolvers.put(sessionId, resolver); + error.putMap("resolver", resolverToMap(sessionId, resolver)); + } + if (code.equals("UNKNOWN")) { if (exception instanceof FirebaseAuthInvalidCredentialsException) { code = "INVALID_EMAIL"; @@ -1877,9 +2030,42 @@ private WritableMap firebaseUserToMap(FirebaseUser user) { } userMap.putMap("metadata", metadataMap); + final WritableArray enrolledFactors = Arguments.createArray(); + for (final MultiFactorInfo hint : user.getMultiFactor().getEnrolledFactors()) { + enrolledFactors.pushMap(multiFactorInfoToMap(hint)); + } + final WritableMap multiFactorMap = Arguments.createMap(); + multiFactorMap.putArray("enrolledFactors", enrolledFactors); + userMap.putMap("multiFactor", multiFactorMap); + return userMap; } + @NonNull + private WritableMap resolverToMap(final String sessionId, final MultiFactorResolver resolver) { + final WritableMap result = Arguments.createMap(); + final WritableArray hints = Arguments.createArray(); + for (MultiFactorInfo hint : resolver.getHints()) { + hints.pushMap(multiFactorInfoToMap(hint)); + } + + result.putArray("hints", hints); + result.putString("session", sessionId); + return result; + } + + @NonNull + private WritableMap multiFactorInfoToMap(MultiFactorInfo hint) { + final WritableMap hintMap = Arguments.createMap(); + final Date enrollmentTime = new Date(hint.getEnrollmentTimestamp() * 1000); + hintMap.putString("displayName", hint.getDisplayName()); + hintMap.putString("enrollmentTime", ISO_8601_FORMATTER.format(enrollmentTime)); + hintMap.putString("factorId", hint.getFactorId()); + hintMap.putString("uid", hint.getUid()); + + return hintMap; + } + private ActionCodeSettings buildActionCodeSettings(ReadableMap actionCodeSettings) { ActionCodeSettings.Builder builder = ActionCodeSettings.newBuilder(); From b5f2e5ab2d9824925f41b15438baf8ba236b8e1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ferenc=20Z=C3=BCllich?= Date: Thu, 13 Oct 2022 17:50:00 +0200 Subject: [PATCH 5/9] feat(auth): multi-factor enroll feature for Android and e2e-tests --- docs/auth/multi-factor-auth.md | 40 ++ .../common/ReactNativeFirebaseModule.java | 4 +- .../auth/ReactNativeFirebaseAuthModule.java | 128 +++++- packages/auth/e2e/helpers.js | 79 +++- packages/auth/e2e/multiFactor.e2e.js | 405 ++++++++++++++++++ packages/auth/lib/index.d.ts | 26 ++ packages/auth/lib/index.js | 7 + packages/auth/lib/multiFactor.js | 35 ++ 8 files changed, 713 insertions(+), 11 deletions(-) create mode 100644 packages/auth/e2e/multiFactor.e2e.js create mode 100644 packages/auth/lib/multiFactor.js diff --git a/docs/auth/multi-factor-auth.md b/docs/auth/multi-factor-auth.md index 807145274f..8c750c4e44 100644 --- a/docs/auth/multi-factor-auth.md +++ b/docs/auth/multi-factor-auth.md @@ -11,6 +11,46 @@ Make sure to follow [the official Identity Platform documentation](https://cloud.google.com/identity-platform/docs/ios/mfa#enabling_multi-factor_authentication) to enable multi-factor authentication for your project and verify your app. +# Enroll a new factor + +> Before a user can enroll a second factor they need to verify their email. See +> [`User`](/reference/auth/user#sendEmailVerification) interface is returned. + +Begin by obtaining a [`MultiFactorUser`](/reference/auth/multifactoruser) +instance for the current user. This is the entry point for most multi-factor +operations: + +```js +import auth from '@react-native-firebase/auth'; +const multiFactorUser = await auth.multiFactor(auth()); +``` + +Request the session identifier and use the phone number obtained from the user +to send a verification code: + +```js +const session = await multiFactorUser.getSession(); +const phoneOptions = { + phoneNumber, + session, +}; + +// Sends a text message to the user +const verificationId = await auth().verifyPhoneNumberForMultiFactor(phoneOptions); +``` + +Once the user has provided the verification code received by text message, you +can complete the process: + +```js +const cred = auth.PhoneAuthProvider.credential(verificationId, verificationCode); +const multiFactorAssertion = auth.PhoneMultiFactorGenerator.assertion(cred); +await multiFactorUser.enroll(multiFactorAssertion, 'Optional display name for the user); +``` + +You can inspect [`User#multiFactor`](/reference/auth/user#multiFactor) for +information about the user's enrolled factors. + # Sign-in flow using multi-factor Ensure the account has already enrolled a second factor. Begin by calling the diff --git a/packages/app/android/src/reactnative/java/io/invertase/firebase/common/ReactNativeFirebaseModule.java b/packages/app/android/src/reactnative/java/io/invertase/firebase/common/ReactNativeFirebaseModule.java index 75da0901f7..353a4bf4ad 100644 --- a/packages/app/android/src/reactnative/java/io/invertase/firebase/common/ReactNativeFirebaseModule.java +++ b/packages/app/android/src/reactnative/java/io/invertase/firebase/common/ReactNativeFirebaseModule.java @@ -48,7 +48,9 @@ public static void rejectPromiseWithCodeAndMessage( WritableMap userInfoMap = Arguments.createMap(); userInfoMap.putString("code", code); userInfoMap.putString("message", message); - userInfoMap.putMap("resolver", resolver); + if (resolver != null) { + userInfoMap.putMap("resolver", resolver); + } promise.reject(code, message, userInfoMap); } diff --git a/packages/auth/android/src/main/java/io/invertase/firebase/auth/ReactNativeFirebaseAuthModule.java b/packages/auth/android/src/main/java/io/invertase/firebase/auth/ReactNativeFirebaseAuthModule.java index 135aa0f547..4af72a9e90 100644 --- a/packages/auth/android/src/main/java/io/invertase/firebase/auth/ReactNativeFirebaseAuthModule.java +++ b/packages/auth/android/src/main/java/io/invertase/firebase/auth/ReactNativeFirebaseAuthModule.java @@ -54,10 +54,12 @@ import com.google.firebase.auth.MultiFactorAssertion; import com.google.firebase.auth.MultiFactorInfo; import com.google.firebase.auth.MultiFactorResolver; +import com.google.firebase.auth.MultiFactorSession; import com.google.firebase.auth.OAuthProvider; import com.google.firebase.auth.PhoneAuthCredential; import com.google.firebase.auth.PhoneAuthOptions; import com.google.firebase.auth.PhoneAuthProvider; +import com.google.firebase.auth.PhoneMultiFactorAssertion; import com.google.firebase.auth.PhoneMultiFactorGenerator; import com.google.firebase.auth.PhoneMultiFactorInfo; import com.google.firebase.auth.TwitterAuthProvider; @@ -97,6 +99,7 @@ class ReactNativeFirebaseAuthModule extends ReactNativeFirebaseModule { private PhoneAuthCredential mCredential; private final HashMap mCachedResolvers = new HashMap<>(); + private final HashMap mMultiFactorSessions = new HashMap<>(); ReactNativeFirebaseAuthModule(ReactApplicationContext reactContext) { super(reactContext, TAG); @@ -139,6 +142,7 @@ public void onCatalystInstanceDestroy() { } mCachedResolvers.clear(); + mMultiFactorSessions.clear(); } /** Add a new auth state listener - if one doesn't exist already */ @@ -948,6 +952,29 @@ public void onCodeAutoRetrievalTimeOut(String verificationId) { } } + @ReactMethod + public void getSession(final String appName, final Promise promise) { + FirebaseApp firebaseApp = FirebaseApp.getInstance(appName); + FirebaseAuth firebaseAuth = FirebaseAuth.getInstance(firebaseApp); + firebaseAuth + .getCurrentUser() + .getMultiFactor() + .getSession() + .addOnCompleteListener( + task -> { + if (!task.isSuccessful()) { + rejectPromiseWithExceptionMap(promise, task.getException()); + return; + } + + final MultiFactorSession session = task.getResult(); + final String sessionId = Integer.toString(session.hashCode()); + mMultiFactorSessions.put(sessionId, session); + + promise.resolve(sessionId); + }); + } + @ReactMethod public void verifyPhoneNumberWithMultiFactorInfo( final String appName, final String hintUid, final String sessionKey, final Promise promise) { @@ -1013,6 +1040,82 @@ public void onVerificationFailed(@NonNull FirebaseException e) { PhoneAuthProvider.verifyPhoneNumber(phoneAuthOptions); } + @ReactMethod + public void verifyPhoneNumberForMultiFactor( + final String appName, + final String phoneNumber, + final String sessionKey, + final Promise promise) { + final MultiFactorSession multiFactorSession = mMultiFactorSessions.get(sessionKey); + if (multiFactorSession == null) { + rejectPromiseWithCodeAndMessage(promise, "unknown", "can't find session for provided key"); + return; + } + + final PhoneAuthOptions phoneAuthOptions = + PhoneAuthOptions.newBuilder() + .setPhoneNumber(phoneNumber) + .setActivity(getCurrentActivity()) + .setTimeout(30L, TimeUnit.SECONDS) + .setMultiFactorSession(multiFactorSession) + .requireSmsValidation(true) + .setCallbacks( + new PhoneAuthProvider.OnVerificationStateChangedCallbacks() { + @Override + public void onVerificationCompleted( + @NonNull PhoneAuthCredential phoneAuthCredential) { + // We can't handle this flow in the JS part if we want to be compatible with + // the firebase-js-sdk. If we set the requireSmsValidation option to true + // this code should not be executed. + rejectPromiseWithCodeAndMessage( + promise, "not-implemented", "This is currently not supported."); + } + + @Override + public void onVerificationFailed(@NonNull FirebaseException e) { + promiseRejectAuthException(promise, e); + } + + @Override + public void onCodeSent( + @NonNull String verificationId, + @NonNull PhoneAuthProvider.ForceResendingToken forceResendingToken) { + promise.resolve(verificationId); + } + }) + .build(); + + PhoneAuthProvider.verifyPhoneNumber(phoneAuthOptions); + } + + @ReactMethod + public void finalizeMultiFactorEnrollment( + final String appName, + final String verificationId, + final String verificationCode, + @Nullable final String displayName, + final Promise promise) { + FirebaseApp firebaseApp = FirebaseApp.getInstance(appName); + FirebaseAuth firebaseAuth = FirebaseAuth.getInstance(firebaseApp); + + final PhoneAuthCredential phoneAuthCredential = + PhoneAuthProvider.getCredential(verificationId, verificationCode); + final PhoneMultiFactorAssertion assertion = + PhoneMultiFactorGenerator.getAssertion(phoneAuthCredential); + firebaseAuth + .getCurrentUser() + .getMultiFactor() + .enroll(assertion, displayName) + .addOnCompleteListener( + task -> { + if (!task.isSuccessful()) { + rejectPromiseWithExceptionMap(promise, task.getException()); + } + + // Need to reload user to make it all visible? + promise.resolve("yes"); + }); + } /** * This method is intended to resolve a {@link PhoneAuthCredential} obtained through a * multi-factor authentication flow. A credential can either be obtained using: @@ -1777,6 +1880,7 @@ private void promiseWithAuthResult(AuthResult authResult, Promise promise) { authResultMap.putMap("user", userMap); promise.resolve(authResultMap); + } else { promiseNoUser(promise, true); } @@ -1790,8 +1894,14 @@ private void promiseWithAuthResult(AuthResult authResult, Promise promise) { */ private void promiseRejectAuthException(Promise promise, Exception exception) { WritableMap error = getJSError(exception); + final String sessionId = error.getString("sessionId"); + final MultiFactorResolver multiFactorResolver = mCachedResolvers.get(sessionId); + WritableMap resolverAsMap = Arguments.createMap(); + if (multiFactorResolver != null) { + resolverAsMap = resolverToMap(sessionId, multiFactorResolver); + } rejectPromiseWithCodeAndMessage( - promise, error.getString("code"), error.getString("message"), error.getMap("resolver")); + promise, error.getString("code"), error.getString("message"), resolverAsMap); } /** @@ -1887,7 +1997,9 @@ private WritableMap getJSError(Exception exception) { final MultiFactorResolver resolver = multiFactorException.getResolver(); final String sessionId = Integer.toString(resolver.getSession().hashCode()); mCachedResolvers.put(sessionId, resolver); - error.putMap("resolver", resolverToMap(sessionId, resolver)); + // Passing around a resolver ReadableMap leads to issues when trying to send the data back by + // calling Promise#reject. Building the map just before sending solves that issue. + error.putString("sessionId", sessionId); } if (code.equals("UNKNOWN")) { @@ -1902,6 +2014,18 @@ private WritableMap getJSError(Exception exception) { } } + // Some message need to be rewritten to match error messages from the Web SDK + switch (code) { + case "ERROR_UNVERIFIED_EMAIL": + message = "This operation requires a verified email."; + break; + case "ERROR_UNSUPPORTED_FIRST_FACTOR": + message = + "Enrolling a second factor or signing in with a multi-factor account requires sign-in" + + " with a supported first factor."; + break; + } + code = code.toLowerCase(Locale.ROOT).replace("error_", "").replace('_', '-'); error.putString("code", code); error.putString("message", message); diff --git a/packages/auth/e2e/helpers.js b/packages/auth/e2e/helpers.js index 536cd17c86..a9a103d095 100644 --- a/packages/auth/e2e/helpers.js +++ b/packages/auth/e2e/helpers.js @@ -28,9 +28,10 @@ const callRestApi = async function callRestAPI(url, rawResult = false) { }); }; -exports.getRandomPhoneNumber = function getRandomPhoneNumber() { +function getRandomPhoneNumber() { return '+593' + Utils.randString(9, '#19'); -}; +} +exports.getRandomPhoneNumber = getRandomPhoneNumber; exports.clearAllUsers = async function clearAllUsers() { // console.log('auth::helpers::clearAllUsers'); @@ -86,7 +87,7 @@ exports.disableUser = async function disableUser(userId) { } }; -exports.getLastSmsCode = async function getLastSmsCode(specificPhone) { +async function getLastSmsCode(specificPhone) { let lastSmsCode = null; try { // console.log('auth::e2e:helpers:getLastSmsCode - start'); @@ -123,9 +124,10 @@ exports.getLastSmsCode = async function getLastSmsCode(specificPhone) { } // console.log('getLastSmsCode returning code: ' + lastSmsCode); return lastSmsCode; -}; +} +exports.getLastSmsCode = getLastSmsCode; -exports.getLastOob = async function getLastOob(specificEmail) { +async function getLastOob(specificEmail) { let lastOob = null; try { // console.log('auth::e2e:helpers:getLastOob - start'); @@ -162,7 +164,8 @@ exports.getLastOob = async function getLastOob(specificEmail) { } // console.log('getLastOob returning code: ' + JSON.stringify(lastOob, null, 2); return lastOob; -}; +} +exports.getLastOob = getLastOob; exports.resetPassword = async function resetPassword(oobCode, newPassword) { const resetPasswordUrl = @@ -175,7 +178,7 @@ exports.resetPassword = async function resetPassword(oobCode, newPassword) { return await callRestApi(resetPasswordUrl); }; -exports.verifyEmail = async function verifyEmail(oobCode) { +async function verifyEmail(oobCode) { const verifyEmailUrl = 'http://' + getE2eEmulatorHost() + @@ -183,9 +186,69 @@ exports.verifyEmail = async function verifyEmail(oobCode) { oobCode + '&apiKey=fake-api-key'; return await callRestApi(verifyEmailUrl); -}; +} +exports.verifyEmail = verifyEmail; // This URL comes from the Auth Emulator's oobCode blocks exports.signInUser = async function signInUser(oobUrl) { return await callRestApi(oobUrl, true); }; + +async function createVerifiedUser(email, password) { + await firebase.auth().createUserWithEmailAndPassword(email, password); + await firebase.auth().currentUser.sendEmailVerification(); + const { oobCode } = await getLastOob(email); + await verifyEmail(oobCode); + await firebase.auth().currentUser.reload(); +} +exports.createVerifiedUser = createVerifiedUser; + +/** + * Create a new user with a second factor enrolled. Returns phoneNumber, email and password + * for testing purposes. The session used to enroll the factor is terminated. You'll have to + * sign in using `firebase.auth().signInWithEmailAndPassword()`. + */ +exports.createUserWithMultiFactor = async function createUserWithMultiFactor() { + const email = 'verified@example.com'; + const password = 'test123'; + await createVerifiedUser(email, password); + const multiFactorUser = await firebase.auth.multiFactor(firebase.auth()); + const session = await multiFactorUser.getSession(); + const phoneNumber = getRandomPhoneNumber(); + const verificationId = await firebase + .auth() + .verifyPhoneNumberForMultiFactor({ phoneNumber, session }); + const verificationCode = await getLastSmsCode(phoneNumber); + const cred = firebase.auth.PhoneAuthProvider.credential(verificationId, verificationCode); + const multiFactorAssertion = firebase.auth.PhoneMultiFactorGenerator.assertion(cred); + await multiFactorUser.enroll(multiFactorAssertion, 'Hint displayName'); + await firebase.auth().signOut(); + + return Promise.resolve({ + phoneNumber, + email, + password, + }); +}; + +exports.signInUserWithMultiFactor = async function signInUserWithMultiFactor( + email, + password, + phoneNumber, +) { + try { + await firebase.auth().signInWithEmailAndPassword(email, password); + } catch (e) { + e.message.should.equal( + '[auth/multi-factor-auth-required] Please complete a second factor challenge to finish signing into this account.', + ); + const resolver = firebase.auth.getMultiFactorResolver(firebase.auth(), e); + let verificationId = await firebase + .auth() + .verifyPhoneNumberWithMultiFactorInfo(resolver.hints[0], resolver.session); + let verificationCode = await getLastSmsCode(phoneNumber); + const credential = firebase.auth.PhoneAuthProvider.credential(verificationId, verificationCode); + let multiFactorAssertion = firebase.auth.PhoneMultiFactorGenerator.assertion(credential); + return resolver.resolveSignIn(multiFactorAssertion); + } +}; diff --git a/packages/auth/e2e/multiFactor.e2e.js b/packages/auth/e2e/multiFactor.e2e.js new file mode 100644 index 0000000000..dbcf2f1a6e --- /dev/null +++ b/packages/auth/e2e/multiFactor.e2e.js @@ -0,0 +1,405 @@ +const { + clearAllUsers, + getLastSmsCode, + createUserWithMultiFactor, + createVerifiedUser, + getRandomPhoneNumber, + signInUserWithMultiFactor, +} = require('./helpers'); + +const TEST_EMAIL = 'test@example.com'; +const TEST_PASS = 'test1234'; + +describe('multi-factor', function () { + beforeEach(async function () { + await clearAllUsers(); + await firebase.auth().createUserWithEmailAndPassword(TEST_EMAIL, TEST_PASS); + if (firebase.auth().currentUser) { + await firebase.auth().signOut(); + await Utils.sleep(50); + } + }); + it('has no multi-factor information if not enrolled', async function () { + await firebase.auth().signInWithEmailAndPassword(TEST_EMAIL, TEST_PASS); + const { multiFactor } = firebase.auth().currentUser; + multiFactor.should.be.an.Object(); + multiFactor.enrolledFactors.should.be.an.Array(); + multiFactor.enrolledFactors.length.should.equal(0); + return Promise.resolve(); + }); + + describe('sign-in', function () { + it('requires multi-factor auth when enrolled', async function () { + const { phoneNumber, email, password } = await createUserWithMultiFactor(); + + try { + await firebase.auth().signInWithEmailAndPassword(email, password); + } catch (e) { + e.message.should.equal( + '[auth/multi-factor-auth-required] Please complete a second factor challenge to finish signing into this account.', + ); + const resolver = firebase.auth.getMultiFactorResolver(firebase.auth(), e); + resolver.should.be.an.Object(); + resolver.hints.should.be.an.Array(); + resolver.hints.length.should.equal(1); + resolver.session.should.be.a.String(); + + const verificationId = await firebase + .auth() + .verifyPhoneNumberWithMultiFactorInfo(resolver.hints[0], resolver.session); + verificationId.should.be.a.String(); + + const verificationCode = await getLastSmsCode(phoneNumber); + const credential = firebase.auth.PhoneAuthProvider.credential( + verificationId, + verificationCode, + ); + const multiFactorAssertion = firebase.auth.PhoneMultiFactorGenerator.assertion(credential); + return resolver + .resolveSignIn(multiFactorAssertion) + .then(userCreds => { + userCreds.should.be.an.Object(); + userCreds.user.should.be.an.Object(); + userCreds.user.email.should.equal('verified@example.com'); + userCreds.user.multiFactor.should.be.an.Object(); + userCreds.user.multiFactor.enrolledFactors.length.should.equal(1); + return Promise.resolve(); + }) + .catch(e => { + return Promise.reject(e); + }); + } + + return Promise.reject(new Error('Multi-factor users need to handle an exception on sign-in')); + }); + it('reports an error when providing an invalid sms code', async function () { + const { phoneNumber, email, password } = await createUserWithMultiFactor(); + + try { + await firebase.auth().signInWithEmailAndPassword(email, password); + } catch (e) { + e.message.should.equal( + '[auth/multi-factor-auth-required] Please complete a second factor challenge to finish signing into this account.', + ); + const resolver = firebase.auth.getMultiFactorResolver(firebase.auth(), e); + const verificationId = await firebase + .auth() + .verifyPhoneNumberWithMultiFactorInfo(resolver.hints[0], resolver.session); + + const credential = firebase.auth.PhoneAuthProvider.credential(verificationId, 'incorrect'); + const assertion = firebase.auth.PhoneMultiFactorGenerator.assertion(credential); + try { + await resolver.resolveSignIn(assertion); + } catch (e) { + e.message.should.equal( + '[auth/invalid-verification-code] The sms verification code used to create the phone auth credential is invalid. Please resend the verification code sms and be sure use the verification code provided by the user.', + ); + + const verificationId = await firebase + .auth() + .verifyPhoneNumberWithMultiFactorInfo(resolver.hints[0], resolver.session); + const verificationCode = await getLastSmsCode(phoneNumber); + const credential = firebase.auth.PhoneAuthProvider.credential( + verificationId, + verificationCode, + ); + const assertion = firebase.auth.PhoneMultiFactorGenerator.assertion(credential); + await resolver.resolveSignIn(assertion); + firebase.auth().currentUser.email.should.equal(email); + return Promise.resolve(); + } + } + return Promise.reject(); + }); + it('reports an error when providing an invalid verification code', async function () { + const { phoneNumber, email, password } = await createUserWithMultiFactor(); + + try { + await firebase.auth().signInWithEmailAndPassword(email, password); + } catch (e) { + e.message.should.equal( + '[auth/multi-factor-auth-required] Please complete a second factor challenge to finish signing into this account.', + ); + const resolver = firebase.auth.getMultiFactorResolver(firebase.auth(), e); + await firebase + .auth() + .verifyPhoneNumberWithMultiFactorInfo(resolver.hints[0], resolver.session); + const verificationCode = await getLastSmsCode(phoneNumber); + + const credential = firebase.auth.PhoneAuthProvider.credential( + 'incorrect', + verificationCode, + ); + const assertion = firebase.auth.PhoneMultiFactorGenerator.assertion(credential); + try { + await resolver.resolveSignIn(assertion); + } catch (e) { + e.message.should.equal( + '[auth/invalid-verification-id] The verification ID used to create the phone auth credential is invalid.', + ); + + return Promise.resolve(); + } + } + return Promise.reject(); + }); + }); + + describe('enroll', function () { + it("can't enroll an existing user without verified email", async function () { + await firebase.auth().signInWithEmailAndPassword(TEST_EMAIL, TEST_PASS); + + try { + const multiFactorUser = await firebase.auth.multiFactor(firebase.auth()); + const session = await multiFactorUser.getSession(); + await firebase + .auth() + .verifyPhoneNumberForMultiFactor({ phoneNumber: getRandomPhoneNumber(), session }); + } catch (e) { + e.message.should.equal('[auth/unverified-email] This operation requires a verified email.'); + e.code.should.equal('auth/unverified-email'); + return Promise.resolve(); + } + + return Promise.reject(new Error('Should throw error for unverified user.')); + }); + + it('can enroll new factor', async function () { + try { + await createVerifiedUser('verified@example.com', 'test123'); + const phoneNumber = getRandomPhoneNumber(); + + should.deepEqual(firebase.auth().currentUser.multiFactor.enrolledFactors, []); + const multiFactorUser = await firebase.auth.multiFactor(firebase.auth()); + + const session = await multiFactorUser.getSession(); + + const verificationId = await firebase + .auth() + .verifyPhoneNumberForMultiFactor({ phoneNumber, session }); + const verificationCode = await getLastSmsCode(phoneNumber); + const cred = firebase.auth.PhoneAuthProvider.credential(verificationId, verificationCode); + const multiFactorAssertion = firebase.auth.PhoneMultiFactorGenerator.assertion(cred); + await multiFactorUser.enroll(multiFactorAssertion, 'Hint displayName'); + + const enrolledFactors = firebase.auth().currentUser.multiFactor.enrolledFactors; + enrolledFactors.length.should.equal(1); + enrolledFactors[0].displayName.should.equal('Hint displayName'); + enrolledFactors[0].factorId.should.equal('phone'); + enrolledFactors[0].uid.should.be.a.String(); + enrolledFactors[0].enrollmentTime.should.be.a.String(); + } catch (e) { + return Promise.reject(e); + } + return Promise.resolve(); + }); + it('can enroll new factor without display name', async function () { + try { + await createVerifiedUser('verified@example.com', 'test123'); + const phoneNumber = getRandomPhoneNumber(); + + should.deepEqual(firebase.auth().currentUser.multiFactor.enrolledFactors, []); + const multiFactorUser = await firebase.auth.multiFactor(firebase.auth()); + + const session = await multiFactorUser.getSession(); + + const verificationId = await firebase + .auth() + .verifyPhoneNumberForMultiFactor({ phoneNumber, session }); + const verificationCode = await getLastSmsCode(phoneNumber); + const cred = firebase.auth.PhoneAuthProvider.credential(verificationId, verificationCode); + const multiFactorAssertion = firebase.auth.PhoneMultiFactorGenerator.assertion(cred); + await multiFactorUser.enroll(multiFactorAssertion); + + const enrolledFactors = firebase.auth().currentUser.multiFactor.enrolledFactors; + enrolledFactors.length.should.equal(1); + should.equal(enrolledFactors[0].displayName, null); + } catch (e) { + return Promise.reject(e); + } + return Promise.resolve(); + }); + it('can enroll multiple factors', async function () { + const { email, password, phoneNumber } = await createUserWithMultiFactor(); + await signInUserWithMultiFactor(email, password, phoneNumber); + + const anotherNumber = getRandomPhoneNumber(); + const multiFactorUser = await firebase.auth.multiFactor(firebase.auth()); + + const session = await multiFactorUser.getSession(); + const verificationId = await firebase + .auth() + .verifyPhoneNumberForMultiFactor({ phoneNumber: anotherNumber, session }); + const verificationCode = await getLastSmsCode(anotherNumber); + const cred = firebase.auth.PhoneAuthProvider.credential(verificationId, verificationCode); + const multiFactorAssertion = firebase.auth.PhoneMultiFactorGenerator.assertion(cred); + const displayName = 'Another displayName'; + await multiFactorUser.enroll(multiFactorAssertion, displayName); + + const enrolledFactors = firebase.auth().currentUser.multiFactor.enrolledFactors; + enrolledFactors.length.should.equal(2); + const matchingFactor = enrolledFactors.find(factor => factor.displayName === displayName); + matchingFactor.should.be.an.Object(); + matchingFactor.uid.should.be.a.String(); + matchingFactor.enrollmentTime.should.be.a.String(); + matchingFactor.factorId.should.equal('phone'); + + return Promise.resolve(); + }); + it('can not enroll the same factor twice', async function () { + // This test should probably be implemented but doesn't work: + // Every time the same phone number requests a verification code, + // the emulator endpoint does not return a code, even though the emulator log + // prints a code. + /* + await clearAllUsers(); + const { email, password, phoneNumber } = await createUserWithMultiFactor(); + await signInUserWithMultiFactor(email, password, phoneNumber); + const multiFactorUser = await firebase.auth.multiFactor(firebase.auth()); + const session = await multiFactorUser.getSession(); + + const verificationId = await firebase + .auth() + .verifyPhoneNumberForMultiFactor({ phoneNumber, session }); + const verificationCode = await getLastSmsCode(phoneNumber); + + const cred = firebase.auth.PhoneAuthProvider.credential(verificationId, verificationCode); + const multiFactorAssertion = firebase.auth.PhoneMultiFactorGenerator.assertion(cred); + const displayName = 'Another displayName'; + try { + await multiFactorUser.enroll(multiFactorAssertion, displayName); + } catch (e) { + console.error(e); + return Promise.resolve(); + } + return Promise.reject(); + */ + }); + + it('throws an error for wrong verification id', async function () { + const { phoneNumber, email, password } = await createUserWithMultiFactor(); + + // GIVEN a MultiFactorResolver + let resolver = null; + try { + await firebase.auth().signInWithEmailAndPassword(email, password); + return Promise.reject(); + } catch (e) { + e.message.should.equal( + '[auth/multi-factor-auth-required] Please complete a second factor challenge to finish signing into this account.', + ); + resolver = firebase.auth.getMultiFactorResolver(firebase.auth(), e); + } + await firebase + .auth() + .verifyPhoneNumberWithMultiFactorInfo(resolver.hints[0], resolver.session); + + // AND I request a verification code + const verificationCode = await getLastSmsCode(phoneNumber); + // AND I use an incorrect verificationId + const credential = firebase.auth.PhoneAuthProvider.credential( + 'wrongVerificationId', + verificationCode, + ); + const multiFactorAssertion = firebase.auth.PhoneMultiFactorGenerator.assertion(credential); + + try { + // WHEN I try to resolve the sign-in + await resolver.resolveSignIn(multiFactorAssertion); + } catch (e) { + // THEN an error message is thrown + e.message.should.equal( + '[auth/invalid-verification-id] The verification ID used to create the phone auth credential is invalid.', + ); + return Promise.resolve(); + } + return Promise.reject(); + }); + it('throws an error for unknown sessions', async function () { + const { email, password } = await createUserWithMultiFactor(); + let resolver = null; + try { + await firebase.auth().signInWithEmailAndPassword(email, password); + return Promise.reject(); + } catch (e) { + e.message.should.equal( + '[auth/multi-factor-auth-required] Please complete a second factor challenge to finish signing into this account.', + ); + resolver = firebase.auth.getMultiFactorResolver(firebase.auth(), e); + } + + try { + await firebase + .auth() + .verifyPhoneNumberWithMultiFactorInfo(resolver.hints[0], 'unknown-session'); + } catch (e) { + // THEN an error message is thrown + e.message.should.equal( + '[auth/invalid-multi-factor-session] No resolver for session found. Is the session id correct?', + ); + return Promise.resolve(); + } + return Promise.reject(); + }); + it('throws an error for unknown verification code', async function () { + const { email, password } = await createUserWithMultiFactor(); + + // GIVEN a MultiFactorResolver + let resolver = null; + try { + await firebase.auth().signInWithEmailAndPassword(email, password); + return Promise.reject(); + } catch (e) { + e.message.should.equal( + '[auth/multi-factor-auth-required] Please complete a second factor challenge to finish signing into this account.', + ); + resolver = firebase.auth.getMultiFactorResolver(firebase.auth(), e); + } + const verificationId = await firebase + .auth() + .verifyPhoneNumberWithMultiFactorInfo(resolver.hints[0], resolver.session); + + // AND I use an incorrect verificationId + const credential = firebase.auth.PhoneAuthProvider.credential( + verificationId, + 'wrong-verification-code', + ); + const multiFactorAssertion = firebase.auth.PhoneMultiFactorGenerator.assertion(credential); + + try { + // WHEN I try to resolve the sign-in + await resolver.resolveSignIn(multiFactorAssertion); + } catch (e) { + // THEN an error message is thrown + e.message.should.equal( + '[auth/invalid-verification-code] The sms verification code used to create the phone auth credential is invalid. Please resend the verification code sms and be sure use the verification code provided by the user.', + ); + return Promise.resolve(); + } + return Promise.reject(); + }); + + it('can not enroll with phone authentication (unsupported primary factor)', async function () { + // GIVEN a user that only signs in with phone + const testPhone = getRandomPhoneNumber(); + const confirmResult = await firebase.auth().signInWithPhoneNumber(testPhone); + const lastSmsCode = await getLastSmsCode(testPhone); + await confirmResult.confirm(lastSmsCode); + + // WHEN they attempt to enroll a second factor + const multiFactorUser = await firebase.auth.multiFactor(firebase.auth()); + const session = await multiFactorUser.getSession(); + try { + await firebase.auth().verifyPhoneNumberForMultiFactor({ phoneNumber: '+1123123', session }); + } catch (e) { + e.message.should.equal( + '[auth/unsupported-first-factor] Enrolling a second factor or signing in with a multi-factor account requires sign-in with a supported first factor.', + ); + return Promise.resolve(); + } + return Promise.reject( + new Error('Enrolling a second factor when using phone authentication is not supported.'), + ); + }); + }); +}); diff --git a/packages/auth/lib/index.d.ts b/packages/auth/lib/index.d.ts index 3c6f15d818..e3cb966090 100644 --- a/packages/auth/lib/index.d.ts +++ b/packages/auth/lib/index.d.ts @@ -463,6 +463,32 @@ export namespace FirebaseAuthTypes { error: unknown, ) => MultiFactorResolver | null; + /** + * The entry point for most multi-factor operations. + */ + export interface MultiFactorUser { + /** + * Returns the user's enrolled factors. + */ + enrolledFactors: MultiFactorInfo[]; + + /** + * Return the session id for this user. + */ + getSession(): Promise; + + /** + * Enroll an additional factor. Provide an optional display name that can be shown to the user. + * The method will ensure the user state is reloaded after successfully enrolling a factor. + */ + enroll(assertion: MultiFactorAssertion, displayName?: string): Promise; + } + + /** + * Return the #{@link MultiFactorUser} instance for the current user. + */ + export type multiFactor = (auth: FirebaseAuthTypes.Module) => Promise; + /** * Holds information about the user's enrolled factors. * diff --git a/packages/auth/lib/index.js b/packages/auth/lib/index.js index 5c0c51cae9..79d67767ab 100644 --- a/packages/auth/lib/index.js +++ b/packages/auth/lib/index.js @@ -42,6 +42,7 @@ import Settings from './Settings'; import User from './User'; import version from './version'; import { getMultiFactorResolver } from './getMultiFactorResolver'; +import { multiFactor } from './multiFactor'; const statics = { AppleAuthProvider, @@ -60,6 +61,7 @@ const statics = { ERROR: 'error', }, getMultiFactorResolver, + multiFactor, }; const namespace = 'auth'; @@ -258,6 +260,11 @@ class FirebaseAuthModule extends FirebaseModule { return this.native.verifyPhoneNumberWithMultiFactorInfo(multiFactorHint.uid, session); } + verifyPhoneNumberForMultiFactor(phoneInfoOptions) { + const { phoneNumber, session } = phoneInfoOptions; + return this.native.verifyPhoneNumberForMultiFactor(phoneNumber, session); + } + resolveMultiFactorSignIn(session, verificationId, verificationCode) { return this.native .resolveMultiFactorSignIn(session, verificationId, verificationCode) diff --git a/packages/auth/lib/multiFactor.js b/packages/auth/lib/multiFactor.js new file mode 100644 index 0000000000..3b5cae8325 --- /dev/null +++ b/packages/auth/lib/multiFactor.js @@ -0,0 +1,35 @@ +/** + * Return a MultiFactorUser instance the gateway to multi-factor operations. + */ +export function multiFactor(auth) { + return new MultiFactorUser(auth); +} + +export class MultiFactorUser { + constructor(auth) { + this._auth = auth; + this._user = auth.currentUser; + this.enrolledFactor = auth.currentUser.multiFactor.enrolledFactors; + } + + getSession() { + return this._auth.native.getSession(); + } + + /** + * Finalize the enrollment process for the given multi-factor assertion. + * Optionally set a displayName. This method will reload the current user + * profile, which is necessary to see the multi-factor changes. + */ + async enroll(multiFactorAssertion, displayName) { + const { token, secret } = multiFactorAssertion; + await this._auth.native.finalizeMultiFactorEnrollment(token, secret, displayName); + + // We need to reload the user otherwise the changes are not visible + return this._auth.currentUser.reload(); + } + + unenroll() { + return Promise.reject(new Error('No implemented yet.')); + } +} From 452f57f868c1f8d7a0815c7e07028a7ae85a9f01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ferenc=20Z=C3=BCllich?= Date: Fri, 14 Oct 2022 14:33:33 +0200 Subject: [PATCH 6/9] feat(auth): Implement multi-factor enrollment for iOS --- packages/auth/ios/RNFBAuth/RNFBAuthModule.m | 102 +++++++++++++++++--- 1 file changed, 86 insertions(+), 16 deletions(-) diff --git a/packages/auth/ios/RNFBAuth/RNFBAuthModule.m b/packages/auth/ios/RNFBAuth/RNFBAuthModule.m index 75fee58b71..372bc78be0 100644 --- a/packages/auth/ios/RNFBAuth/RNFBAuthModule.m +++ b/packages/auth/ios/RNFBAuth/RNFBAuthModule.m @@ -54,6 +54,7 @@ // Used for caching credentials between method calls. static __strong NSMutableDictionary *credentials; static __strong NSMutableDictionary *cachedResolver; +static __strong NSMutableDictionary *cachedSessions; @implementation RNFBAuthModule #pragma mark - @@ -73,6 +74,7 @@ - (id)init { idTokenHandlers = [[NSMutableDictionary alloc] init]; credentials = [[NSMutableDictionary alloc] init]; cachedResolver = [[NSMutableDictionary alloc] init]; + cachedSessions = [[NSMutableDictionary alloc] init]; }); return self; } @@ -99,6 +101,7 @@ - (void)invalidate { [credentials removeAllObjects]; [cachedResolver removeAllObjects]; + [cachedSessions removeAllObjects]; } #pragma mark - @@ -769,6 +772,26 @@ - (void)invalidate { } }]; } +RCT_EXPORT_METHOD(verifyPhoneNumberForMultiFactor + : (FIRApp *)firebaseApp + : (NSString *)phoneNumber + : (NSString *)sessionId + : (RCTPromiseResolveBlock)resolve + : (RCTPromiseRejectBlock)reject) { + FIRMultiFactorSession *session = cachedSessions[sessionId]; + [FIRPhoneAuthProvider.provider + verifyPhoneNumber:phoneNumber + UIDelegate:nil + multiFactorSession:session + completion:^(NSString *_Nullable verificationID, NSError *_Nullable error) { + if (error != nil) { + [self promiseRejectAuthException:reject error:error]; + return; + } + + resolve(verificationID); + }]; +} RCT_EXPORT_METHOD(resolveMultiFactorSignIn : (FIRApp *)firebaseApp @@ -797,6 +820,50 @@ - (void)invalidate { }]; } +RCT_EXPORT_METHOD(getSession + : (FIRApp *)firebaseApp + : (RCTPromiseResolveBlock)resolve + : (RCTPromiseRejectBlock)reject) { + FIRUser *user = [FIRAuth authWithApp:firebaseApp].currentUser; + [[user multiFactor] getSessionWithCompletion:^(FIRMultiFactorSession *_Nullable session, + NSError *_Nullable error) { + if (error != nil) { + [self promiseRejectAuthException:reject error:error]; + return; + } + + NSString *sessionId = [NSString stringWithFormat:@"%@", @([session hash])]; + cachedSessions[sessionId] = session; + resolve(sessionId); + }]; +} + +RCT_EXPORT_METHOD(finalizeMultiFactorEnrollment + : (FIRApp *)firebaseApp + : (NSString *)verificationId + : (NSString *)verificationCode + : (NSString *_Nullable)displayName + : (RCTPromiseResolveBlock)resolve + : (RCTPromiseRejectBlock)reject) { + FIRPhoneAuthCredential *credential = + [FIRPhoneAuthProvider.provider credentialWithVerificationID:verificationId + verificationCode:verificationCode]; + FIRMultiFactorAssertion *assertion = + [FIRPhoneMultiFactorGenerator assertionWithCredential:credential]; + FIRUser *user = [FIRAuth authWithApp:firebaseApp].currentUser; + [user.multiFactor enrollWithAssertion:assertion + displayName:displayName + completion:^(NSError *_Nullable error) { + if (error != nil) { + [self promiseRejectAuthException:reject error:error]; + return; + } + + resolve(nil); + return; + }]; +} + RCT_EXPORT_METHOD(verifyPhoneNumber : (FIRApp *)firebaseApp : (NSString *)phoneNumber @@ -1080,25 +1147,11 @@ - (void)promiseNoUser:(RCTPromiseResolveBlock)resolve } - (NSDictionary *)multiFactorResolverToDict:(FIRMultiFactorResolver *)resolver { - NSMutableArray *hintsOutput = [NSMutableArray array]; - for (FIRPhoneMultiFactorInfo *hint in resolver.hints) { - NSString *enrollmentDate = - [[[NSISO8601DateFormatter alloc] init] stringFromDate:hint.enrollmentDate]; - - [hintsOutput addObject:@{ - @"uid" : hint.UID, - @"factorId" : [self getJSFactorId:(hint.factorID)], - @"displayName" : hint.displayName, - @"enrollmentDate" : enrollmentDate, - @"phoneNumber" : hint.phoneNumber - }]; - } - // Temporarily store the non-serializable session for later NSString *sessionHash = [NSString stringWithFormat:@"%@", @([resolver.session hash])]; return @{ - @"hints" : hintsOutput, + @"hints" : [self convertMultiFactorData:resolver.hints], @"session" : sessionHash, }; } @@ -1360,10 +1413,27 @@ - (NSDictionary *)firebaseUserToDict:(FIRUser *)user { @"refreshToken" : user.refreshToken, @"tenantId" : user.tenantID ? (id)user.tenantID : [NSNull null], keyUid : user.uid, - @"multiFactor" : user.multiFactor.enrolledFactors + @"multiFactor" : + @{@"enrolledFactors" : [self convertMultiFactorData:user.multiFactor.enrolledFactors]} }; } +- (NSArray *)convertMultiFactorData:(NSArray *)hints { + NSMutableArray *enrolledFactors = [NSMutableArray array]; + + for (FIRPhoneMultiFactorInfo *hint in hints) { + NSString *enrollmentDate = + [[[NSISO8601DateFormatter alloc] init] stringFromDate:hint.enrollmentDate]; + [enrolledFactors addObject:@{ + @"uid" : hint.UID, + @"factorId" : [self getJSFactorId:(hint.factorID)], + @"displayName" : hint.displayName == nil ? [NSNull null] : hint.displayName, + @"enrollmentDate" : enrollmentDate, + }]; + } + return enrolledFactors; +} + - (NSDictionary *)authCredentialToDict:(FIRAuthCredential *)authCredential { NSString *authCredentialHash = [NSString stringWithFormat:@"%@", @([authCredential hash])]; From 97cfa44faa756f3b2ebc8b724232708ad889c29f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ferenc=20Z=C3=BCllich?= Date: Fri, 14 Oct 2022 22:46:49 +0200 Subject: [PATCH 7/9] fix(tests): More robust matchers for invalid-verification-code error --- packages/auth/e2e/multiFactor.e2e.js | 60 ++++++++++++++++++++++++---- 1 file changed, 53 insertions(+), 7 deletions(-) diff --git a/packages/auth/e2e/multiFactor.e2e.js b/packages/auth/e2e/multiFactor.e2e.js index dbcf2f1a6e..bdde2297a0 100644 --- a/packages/auth/e2e/multiFactor.e2e.js +++ b/packages/auth/e2e/multiFactor.e2e.js @@ -30,6 +30,9 @@ describe('multi-factor', function () { describe('sign-in', function () { it('requires multi-factor auth when enrolled', async function () { + if (device.getPlatform() === 'ios') { + this.skip(); + } const { phoneNumber, email, password } = await createUserWithMultiFactor(); try { @@ -49,7 +52,12 @@ describe('multi-factor', function () { .verifyPhoneNumberWithMultiFactorInfo(resolver.hints[0], resolver.session); verificationId.should.be.a.String(); - const verificationCode = await getLastSmsCode(phoneNumber); + let verificationCode = await getLastSmsCode(phoneNumber); + if (verificationCode == null) { + // iOS simulator uses a masked phone number + const maskedNumber = '+********' + phoneNumber.substring(phoneNumber.length - 4); + verificationCode = await getLastSmsCode(maskedNumber); + } const credential = firebase.auth.PhoneAuthProvider.credential( verificationId, verificationCode, @@ -73,6 +81,10 @@ describe('multi-factor', function () { return Promise.reject(new Error('Multi-factor users need to handle an exception on sign-in')); }); it('reports an error when providing an invalid sms code', async function () { + if (device.getPlatform() === 'ios') { + this.skip(); + } + const { phoneNumber, email, password } = await createUserWithMultiFactor(); try { @@ -91,9 +103,11 @@ describe('multi-factor', function () { try { await resolver.resolveSignIn(assertion); } catch (e) { - e.message.should.equal( - '[auth/invalid-verification-code] The sms verification code used to create the phone auth credential is invalid. Please resend the verification code sms and be sure use the verification code provided by the user.', - ); + e.message + .toLocaleLowerCase() + .should.containEql( + '[auth/invalid-verification-code] The SMS verification code used to create the phone auth credential is invalid. Please resend the verification code sms'.toLocaleLowerCase(), + ); const verificationId = await firebase .auth() @@ -112,6 +126,9 @@ describe('multi-factor', function () { return Promise.reject(); }); it('reports an error when providing an invalid verification code', async function () { + if (device.getPlatform() === 'ios') { + this.skip(); + } const { phoneNumber, email, password } = await createUserWithMultiFactor(); try { @@ -147,6 +164,10 @@ describe('multi-factor', function () { describe('enroll', function () { it("can't enroll an existing user without verified email", async function () { + if (device.getPlatform() === 'ios') { + this.skip(); + } + await firebase.auth().signInWithEmailAndPassword(TEST_EMAIL, TEST_PASS); try { @@ -165,6 +186,10 @@ describe('multi-factor', function () { }); it('can enroll new factor', async function () { + if (device.getPlatform() === 'ios') { + this.skip(); + } + try { await createVerifiedUser('verified@example.com', 'test123'); const phoneNumber = getRandomPhoneNumber(); @@ -194,6 +219,10 @@ describe('multi-factor', function () { return Promise.resolve(); }); it('can enroll new factor without display name', async function () { + if (device.getPlatform() === 'ios') { + this.skip(); + } + try { await createVerifiedUser('verified@example.com', 'test123'); const phoneNumber = getRandomPhoneNumber(); @@ -220,6 +249,10 @@ describe('multi-factor', function () { return Promise.resolve(); }); it('can enroll multiple factors', async function () { + if (device.getPlatform() === 'ios') { + this.skip(); + } + const { email, password, phoneNumber } = await createUserWithMultiFactor(); await signInUserWithMultiFactor(email, password, phoneNumber); @@ -247,10 +280,12 @@ describe('multi-factor', function () { return Promise.resolve(); }); it('can not enroll the same factor twice', async function () { + this.skip(); // This test should probably be implemented but doesn't work: // Every time the same phone number requests a verification code, // the emulator endpoint does not return a code, even though the emulator log // prints a code. + // See https://github.com/firebase/firebase-tools/issues/4290#issuecomment-1281260335 /* await clearAllUsers(); const { email, password, phoneNumber } = await createUserWithMultiFactor(); @@ -277,6 +312,10 @@ describe('multi-factor', function () { }); it('throws an error for wrong verification id', async function () { + if (device.getPlatform() === 'ios') { + this.skip(); + } + const { phoneNumber, email, password } = await createUserWithMultiFactor(); // GIVEN a MultiFactorResolver @@ -371,15 +410,22 @@ describe('multi-factor', function () { await resolver.resolveSignIn(multiFactorAssertion); } catch (e) { // THEN an error message is thrown - e.message.should.equal( - '[auth/invalid-verification-code] The sms verification code used to create the phone auth credential is invalid. Please resend the verification code sms and be sure use the verification code provided by the user.', - ); + e.message + .toLocaleLowerCase() + .should.containEql( + '[auth/invalid-verification-code] The SMS verification code used to create the phone auth credential is invalid. Please resend the verification code sms'.toLocaleLowerCase(), + ); + return Promise.resolve(); } return Promise.reject(); }); it('can not enroll with phone authentication (unsupported primary factor)', async function () { + if (device.getPlatform() === 'ios') { + this.skip(); + } + // GIVEN a user that only signs in with phone const testPhone = getRandomPhoneNumber(); const confirmResult = await firebase.auth().signInWithPhoneNumber(testPhone); From e38b5c3e45890518a8c145ec8bc028ff59cf8163 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ferenc=20Z=C3=BCllich?= Date: Sun, 23 Oct 2022 11:24:39 +0200 Subject: [PATCH 8/9] Fix error message for invalid phone numbers to match the error message produced by the Web. --- .../auth/ReactNativeFirebaseAuthModule.java | 6 ++++++ packages/auth/e2e/multiFactor.e2e.js | 18 ++++++++++++++++++ packages/auth/ios/RNFBAuth/RNFBAuthModule.m | 8 +++++++- 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/packages/auth/android/src/main/java/io/invertase/firebase/auth/ReactNativeFirebaseAuthModule.java b/packages/auth/android/src/main/java/io/invertase/firebase/auth/ReactNativeFirebaseAuthModule.java index 4af72a9e90..77b785c0ab 100644 --- a/packages/auth/android/src/main/java/io/invertase/firebase/auth/ReactNativeFirebaseAuthModule.java +++ b/packages/auth/android/src/main/java/io/invertase/firebase/auth/ReactNativeFirebaseAuthModule.java @@ -2024,6 +2024,12 @@ private WritableMap getJSError(Exception exception) { "Enrolling a second factor or signing in with a multi-factor account requires sign-in" + " with a supported first factor."; break; + case "ERROR_INVALID_PHONE_NUMBER": + message = + "The format of the phone number provided is incorrect. Please enter the phone number in" + + " a format that can be parsed into E.164 format. E.164 phone numbers are written" + + " in the format [+][country code][subscriber number including area code]."; + break; } code = code.toLowerCase(Locale.ROOT).replace("error_", "").replace('_', '-'); diff --git a/packages/auth/e2e/multiFactor.e2e.js b/packages/auth/e2e/multiFactor.e2e.js index bdde2297a0..15f9cf1977 100644 --- a/packages/auth/e2e/multiFactor.e2e.js +++ b/packages/auth/e2e/multiFactor.e2e.js @@ -447,5 +447,23 @@ describe('multi-factor', function () { new Error('Enrolling a second factor when using phone authentication is not supported.'), ); }); + it('can not enroll when phone number is missing + sign', async function () { + await createVerifiedUser('verified@example.com', 'test123'); + const multiFactorUser = firebase.auth.multiFactor(firebase.auth()); + const session = await multiFactorUser.getSession(); + try { + await firebase.auth().verifyPhoneNumberForMultiFactor({ phoneNumber: '491575', session }); + } catch (e) { + e.code.should.equal('auth/invalid-phone-number'); + e.message.should.equal( + '[auth/invalid-phone-number] The format of the phone number provided is incorrect. Please enter the ' + + 'phone number in a format that can be parsed into E.164 format. E.164 ' + + 'phone numbers are written in the format [+][country code][subscriber ' + + 'number including area code].', + ); + return Promise.resolve(); + } + return Promise.reject(); + }); }); }); diff --git a/packages/auth/ios/RNFBAuth/RNFBAuthModule.m b/packages/auth/ios/RNFBAuth/RNFBAuthModule.m index 372bc78be0..6e6aa22be0 100644 --- a/packages/auth/ios/RNFBAuth/RNFBAuthModule.m +++ b/packages/auth/ios/RNFBAuth/RNFBAuthModule.m @@ -1156,7 +1156,7 @@ - (NSDictionary *)multiFactorResolverToDict:(FIRMultiFactorResolver *)resolver { }; } -- (NSString *)getJSFactorId:(NSString *)factorId { +- (NSString *)getJSFactorId:(NSString *)factorId { if ([factorId isEqualToString:@"1"]) { // Only phone is supported by the front-end so far return @"phone"; @@ -1248,6 +1248,12 @@ - (NSDictionary *)getJSError:(NSError *)error { case FIRAuthErrorCodeInternalError: message = @"An internal error has occurred, please try again."; break; + case FIRAuthErrorCodeInvalidPhoneNumber: + message = @"The format of the phone number provided is incorrect. " + @"Please enter the phone number in a format that can be parsed into E.164 format. " + @"E.164 phone numbers are written in the format [+][country code][subscriber " + @"number including area code]."; + break; default: break; } From ce15f0233730d251c61e23474759261fddcc4ad9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ferenc=20Z=C3=BCllich?= Date: Sun, 23 Oct 2022 12:04:03 +0200 Subject: [PATCH 9/9] Add test and fix error message for unknown multi-factor hint to match the error message produced by the Web. --- .../auth/ReactNativeFirebaseAuthModule.java | 5 +++- packages/auth/e2e/multiFactor.e2e.js | 28 +++++++++++++++++++ packages/auth/ios/RNFBAuth/RNFBAuthModule.m | 12 +++++++- 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/packages/auth/android/src/main/java/io/invertase/firebase/auth/ReactNativeFirebaseAuthModule.java b/packages/auth/android/src/main/java/io/invertase/firebase/auth/ReactNativeFirebaseAuthModule.java index 77b785c0ab..9291fb99be 100644 --- a/packages/auth/android/src/main/java/io/invertase/firebase/auth/ReactNativeFirebaseAuthModule.java +++ b/packages/auth/android/src/main/java/io/invertase/firebase/auth/ReactNativeFirebaseAuthModule.java @@ -998,7 +998,10 @@ public void verifyPhoneNumberWithMultiFactorInfo( } if (selectedHint == null) { - rejectPromiseWithCodeAndMessage(promise, "unknown", "Requested multi-factor hint not found."); + rejectPromiseWithCodeAndMessage( + promise, + "multi-factor-info-not-found", + "The user does not have a second factor matching the identifier provided."); return; } diff --git a/packages/auth/e2e/multiFactor.e2e.js b/packages/auth/e2e/multiFactor.e2e.js index 15f9cf1977..8b50acfa4a 100644 --- a/packages/auth/e2e/multiFactor.e2e.js +++ b/packages/auth/e2e/multiFactor.e2e.js @@ -160,6 +160,34 @@ describe('multi-factor', function () { } return Promise.reject(); }); + it('reports an error when using an unknown factor', async function () { + const { email, password } = await createUserWithMultiFactor(); + + try { + await firebase.auth().signInWithEmailAndPassword(email, password); + } catch (e) { + e.message.should.equal( + '[auth/multi-factor-auth-required] Please complete a second factor challenge to finish signing into this account.', + ); + const resolver = firebase.auth.getMultiFactorResolver(firebase.auth(), e); + const unknownFactor = { + uid: 'notknown', + }; + try { + await firebase + .auth() + .verifyPhoneNumberWithMultiFactorInfo(unknownFactor, resolver.session); + } catch (e) { + e.code.should.equal('auth/multi-factor-info-not-found'); + e.message.should.equal( + '[auth/multi-factor-info-not-found] The user does not have a second factor matching the identifier provided.', + ); + + return Promise.resolve(); + } + } + return Promise.reject(); + }); }); describe('enroll', function () { diff --git a/packages/auth/ios/RNFBAuth/RNFBAuthModule.m b/packages/auth/ios/RNFBAuth/RNFBAuthModule.m index 6e6aa22be0..4b37d23cd7 100644 --- a/packages/auth/ios/RNFBAuth/RNFBAuthModule.m +++ b/packages/auth/ios/RNFBAuth/RNFBAuthModule.m @@ -756,8 +756,17 @@ - (void)invalidate { } FIRMultiFactorSession *session = cachedResolver[sessionKey].session; NSPredicate *findByUid = [NSPredicate predicateWithFormat:@"UID == %@", hintUid]; - FIRPhoneMultiFactorInfo *hint = + FIRMultiFactorInfo *_Nullable hint = [[cachedResolver[sessionKey].hints filteredArrayUsingPredicate:findByUid] firstObject]; + if (hint == nil) { + [RNFBSharedUtils rejectPromiseWithUserInfo:reject + userInfo:(NSMutableDictionary *)@{ + @"code" : @"multi-factor-info-not-found", + @"message" : @"The user does not have a second factor " + @"matching the identifier provided." + }]; + return; + } [FIRPhoneAuthProvider.provider verifyPhoneNumberWithMultiFactorInfo:hint @@ -772,6 +781,7 @@ - (void)invalidate { } }]; } + RCT_EXPORT_METHOD(verifyPhoneNumberForMultiFactor : (FIRApp *)firebaseApp : (NSString *)phoneNumber