diff --git a/.spellcheck.dict.txt b/.spellcheck.dict.txt index 931357727a..498969c4a2 100644 --- a/.spellcheck.dict.txt +++ b/.spellcheck.dict.txt @@ -198,6 +198,7 @@ TFUA thenable ThreadPoolExecutor timezones +TOTP triaging TurboModule TurboModules diff --git a/docs/auth/multi-factor-auth.md b/docs/auth/multi-factor-auth.md index d8d048e58d..ccfc4d8749 100644 --- a/docs/auth/multi-factor-auth.md +++ b/docs/auth/multi-factor-auth.md @@ -5,16 +5,24 @@ next: /firestore/usage previous: /auth/oidc-auth --- -# iOS Setup +> Before a user can enroll a second factor they need to verify their email. See +> [`User`](/reference/auth/user#sendEmailVerification) interface is returned. + +# TOTP MFA + +The [official guide for Firebase web TOTP authentication](https://firebase.google.com/docs/auth/web/totp-mfa) explains the TOTP process well, including project prerequisites to enable the feature, as well as code examples. + +The API details and usage examples may be combined with the full Phone auth example below to give you an MFA solution that fully supports TOTP or SMS MFA. + +# Phone MFA + +## 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. -# 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. +## Enroll a new factor Begin by obtaining a [`MultiFactorUser`](/reference/auth/multifactoruser) instance for the current user. This is the entry point for most multi-factor @@ -57,7 +65,7 @@ await multiFactorUser.enroll(multiFactorAssertion, 'Optional display name for th You can inspect [`User#multiFactor`](/reference/auth/user#multiFactor) for information about the user's enrolled factors. -# Sign-in flow using multi-factor +## Sign-in flow using phone 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 @@ -103,7 +111,6 @@ 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 === PhoneMultiFactorGenerator.FACTOR_ID) { // Continue with the sign-in flow } @@ -163,7 +170,6 @@ signInWithEmailAndPassword(getAuth(), email, password) // 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 === PhoneMultiFactorGenerator.FACTOR_ID) { const hint = resolver.hints[0]; @@ -185,7 +191,7 @@ signInWithEmailAndPassword(getAuth(), email, password) }); ``` -# Testing +## Testing You can define test phone numbers and corresponding verification codes. The official[official diff --git a/packages/app/lib/internal/NativeFirebaseError.js b/packages/app/lib/internal/NativeFirebaseError.js index b458e51739..5fed6889ba 100644 --- a/packages/app/lib/internal/NativeFirebaseError.js +++ b/packages/app/lib/internal/NativeFirebaseError.js @@ -49,6 +49,18 @@ export default class NativeFirebaseError extends Error { value: userInfo, }); + // Needed for MFA processing of errors on web + Object.defineProperty(this, 'customData', { + enumerable: false, + value: nativeError.customData || null, + }); + + // Needed for MFA processing of errors on web + Object.defineProperty(this, 'operationType', { + enumerable: false, + value: nativeError.operationType || null, + }); + Object.defineProperty(this, 'nativeErrorCode', { enumerable: false, value: userInfo.nativeErrorCode || null, 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 c52a1bed62..e22c8ea421 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 @@ -66,6 +66,9 @@ import com.google.firebase.auth.PhoneMultiFactorAssertion; import com.google.firebase.auth.PhoneMultiFactorGenerator; import com.google.firebase.auth.PhoneMultiFactorInfo; +import com.google.firebase.auth.TotpMultiFactorAssertion; +import com.google.firebase.auth.TotpMultiFactorGenerator; +import com.google.firebase.auth.TotpSecret; import com.google.firebase.auth.TwitterAuthProvider; import com.google.firebase.auth.UserInfo; import com.google.firebase.auth.UserProfileChangeRequest; @@ -107,6 +110,7 @@ class ReactNativeFirebaseAuthModule extends ReactNativeFirebaseModule { private final HashMap mCachedResolvers = new HashMap<>(); private final HashMap mMultiFactorSessions = new HashMap<>(); + private final HashMap mTotpSecrets = new HashMap<>(); // storage for anonymous phone auth credentials, used for linkWithCredentials // https://github.com/invertase/react-native-firebase/issues/4911 @@ -154,6 +158,7 @@ public void invalidate() { mCachedResolvers.clear(); mMultiFactorSessions.clear(); + mTotpSecrets.clear(); } @ReactMethod @@ -1130,6 +1135,26 @@ public void getSession(final String appName, final Promise promise) { }); } + @ReactMethod + public void unenrollMultiFactor( + final String appName, final String factorUID, final Promise promise) { + FirebaseApp firebaseApp = FirebaseApp.getInstance(appName); + FirebaseAuth firebaseAuth = FirebaseAuth.getInstance(firebaseApp); + firebaseAuth + .getCurrentUser() + .getMultiFactor() + .unenroll(factorUID) + .addOnCompleteListener( + task -> { + if (!task.isSuccessful()) { + rejectPromiseWithExceptionMap(promise, task.getException()); + return; + } + + promise.resolve(null); + }); + } + @ReactMethod public void verifyPhoneNumberWithMultiFactorInfo( final String appName, final String hintUid, final String sessionKey, final Promise promise) { @@ -1280,6 +1305,67 @@ public void finalizeMultiFactorEnrollment( }); } + @ReactMethod + public void generateQrCodeUrl( + final String appName, + final String secretKey, + final String account, + final String issuer, + final Promise promise) { + + TotpSecret secret = mTotpSecrets.get(secretKey); + if (secret == null) { + rejectPromiseWithCodeAndMessage( + promise, "invalid-multi-factor-secret", "can't find secret for provided key"); + return; + } + promise.resolve(secret.generateQrCodeUrl(account, issuer)); + } + + @ReactMethod + public void openInOtpApp(final String appName, final String secretKey, final String qrCodeUri) { + TotpSecret secret = mTotpSecrets.get(secretKey); + if (secret != null) { + secret.openInOtpApp(qrCodeUri); + } + } + + @ReactMethod + public void finalizeTotpEnrollment( + final String appName, + final String totpSecret, + final String verificationCode, + @Nullable final String displayName, + final Promise promise) { + + TotpSecret secret = mTotpSecrets.get(totpSecret); + if (secret == null) { + rejectPromiseWithCodeAndMessage( + promise, "invalid-multi-factor-secret", "can't find secret for provided key"); + return; + } + + TotpMultiFactorAssertion assertion = + TotpMultiFactorGenerator.getAssertionForEnrollment(secret, verificationCode); + + FirebaseApp firebaseApp = FirebaseApp.getInstance(appName); + FirebaseAuth firebaseAuth = FirebaseAuth.getInstance(firebaseApp); + + firebaseAuth + .getCurrentUser() + .getMultiFactor() + .enroll(assertion, displayName) + .addOnCompleteListener( + task -> { + if (!task.isSuccessful()) { + rejectPromiseWithExceptionMap(promise, task.getException()); + return; + } + + promise.resolve(null); + }); + } + /** * This method is intended to resolve a {@link PhoneAuthCredential} obtained through a * multi-factor authentication flow. A credential can either be obtained using: @@ -1335,6 +1421,70 @@ public void resolveMultiFactorSignIn( resolveMultiFactorCredential(credential, session, promise); } + @ReactMethod + public void resolveTotpSignIn( + final String appName, + final String sessionKey, + final String uid, + final String oneTimePassword, + final Promise promise) { + + final MultiFactorAssertion assertion = + TotpMultiFactorGenerator.getAssertionForSignIn(uid, oneTimePassword); + + 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; + } + + resolver + .resolveSignIn(assertion) + .addOnCompleteListener( + task -> { + if (task.isSuccessful()) { + AuthResult authResult = task.getResult(); + promiseWithAuthResult(authResult, promise); + } else { + promiseRejectAuthException(promise, task.getException()); + } + }); + } + + @ReactMethod + public void generateTotpSecret( + final String appName, final String sessionKey, final Promise promise) { + + final MultiFactorSession session = mMultiFactorSessions.get(sessionKey); + if (session == null) { + rejectPromiseWithCodeAndMessage( + promise, + "invalid-multi-factor-session", + "No resolver for session found. Is the session id correct?"); + return; + } + + TotpMultiFactorGenerator.generateSecret(session) + .addOnCompleteListener( + task -> { + if (task.isSuccessful()) { + TotpSecret totpSecret = task.getResult(); + String totpSecretKey = totpSecret.getSharedSecretKey(); + mTotpSecrets.put(totpSecretKey, totpSecret); + WritableMap result = Arguments.createMap(); + result.putString("secretKey", totpSecretKey); + promise.resolve(result); + } else { + promiseRejectAuthException(promise, task.getException()); + } + }); + } + @ReactMethod public void confirmationResultConfirm( String appName, final String verificationCode, final Promise promise) { diff --git a/packages/auth/ios/RNFBAuth/RNFBAuthModule.m b/packages/auth/ios/RNFBAuth/RNFBAuthModule.m index c2531c7d22..40ee9aa2e4 100644 --- a/packages/auth/ios/RNFBAuth/RNFBAuthModule.m +++ b/packages/auth/ios/RNFBAuth/RNFBAuthModule.m @@ -58,6 +58,7 @@ #if TARGET_OS_IOS static __strong NSMutableDictionary *cachedResolver; static __strong NSMutableDictionary *cachedSessions; +static __strong NSMutableDictionary *cachedTotpSecrets; #endif @implementation RNFBAuthModule @@ -81,6 +82,7 @@ - (id)init { #if TARGET_OS_IOS cachedResolver = [[NSMutableDictionary alloc] init]; cachedSessions = [[NSMutableDictionary alloc] init]; + cachedTotpSecrets = [[NSMutableDictionary alloc] init]; #endif }); return self; @@ -110,6 +112,7 @@ - (void)invalidate { #if TARGET_OS_IOS [cachedResolver removeAllObjects]; [cachedSessions removeAllObjects]; + [cachedTotpSecrets removeAllObjects]; #endif } @@ -967,6 +970,87 @@ - (void)invalidate { }]; } +RCT_EXPORT_METHOD(resolveTotpSignIn + : (FIRApp *)firebaseApp + : (NSString *)sessionKey + : (NSString *)uid + : (NSString *)oneTimePassword + : (RCTPromiseResolveBlock)resolve + : (RCTPromiseRejectBlock)reject) { + DLog(@"using instance resolve TotpSignIn: %@", firebaseApp.name); + + FIRMultiFactorAssertion *assertion = + [FIRTOTPMultiFactorGenerator assertionForSignInWithEnrollmentID:uid + oneTimePassword:oneTimePassword]; + [cachedResolver[sessionKey] resolveSignInWithAssertion:assertion + completion:^(FIRAuthDataResult *_Nullable authResult, + NSError *_Nullable error) { + DLog(@"authError: %@", error) if (error) { + [self promiseRejectAuthException:reject + error:error]; + } + else { + [self promiseWithAuthResult:resolve + rejecter:reject + authResult:authResult]; + } + }]; +} + +RCT_EXPORT_METHOD(generateTotpSecret + : (FIRApp *)firebaseApp + : (NSString *)sessionKey + : (RCTPromiseResolveBlock)resolve + : (RCTPromiseRejectBlock)reject) { + DLog(@"using instance resolve generateTotpSecret: %@", firebaseApp.name); + + FIRMultiFactorSession *session = cachedSessions[sessionKey]; + DLog(@"using sessionKey: %@", sessionKey); + DLog(@"using session: %@", session); + [FIRTOTPMultiFactorGenerator + generateSecretWithMultiFactorSession:session + completion:^(FIRTOTPSecret *_Nullable totpSecret, + NSError *_Nullable error) { + DLog(@"authError: %@", error) if (error) { + [self promiseRejectAuthException:reject error:error]; + } + else { + NSString *secretKey = totpSecret.sharedSecretKey; + DLog(@"secretKey generated: %@", secretKey); + cachedTotpSecrets[secretKey] = totpSecret; + DLog(@"cachedSecret: %@", cachedTotpSecrets[secretKey]); + resolve(@{ + @"secretKey" : secretKey, + }); + } + }]; +} + +RCT_EXPORT_METHOD(generateQrCodeUrl + : (FIRApp *)firebaseApp + : (NSString *)secretKey + : (NSString *)accountName + : (NSString *)issuer + : (RCTPromiseResolveBlock)resolve + : (RCTPromiseRejectBlock)reject) { + DLog(@"generateQrCodeUrl using instance resolve generateQrCodeUrl: %@", firebaseApp.name); + DLog(@"generateQrCodeUrl using secretKey: %@", secretKey); + FIRTOTPSecret *totpSecret = cachedTotpSecrets[secretKey]; + NSString *url = [totpSecret generateQRCodeURLWithAccountName:accountName issuer:issuer]; + DLog(@"generateQrCodeUrl got QR Code URL %@", url); + resolve(url); +} + +RCT_EXPORT_METHOD(openInOtpApp + : (FIRApp *)firebaseApp + : (NSString *)secretKey + : (NSString *)qrCodeUri) { + DLog(@"generateQrCodeUrl using secretKey: %@", secretKey); + FIRTOTPSecret *totpSecret = cachedTotpSecrets[secretKey]; + DLog(@"openInOtpApp using qrCodeUri: %@", qrCodeUri); + [totpSecret openInOTPAppWithQRCodeURL:qrCodeUri]; +} + RCT_EXPORT_METHOD(getSession : (FIRApp *)firebaseApp : (RCTPromiseResolveBlock)resolve @@ -985,6 +1069,26 @@ - (void)invalidate { }]; } +RCT_EXPORT_METHOD(unenrollMultiFactor + : (FIRApp *)firebaseApp + : (NSString *)factorUID + : (RCTPromiseResolveBlock)resolve + : (RCTPromiseRejectBlock)reject) { + DLog(@"using instance unenrollMultiFactor: %@", firebaseApp.name); + + FIRUser *user = [FIRAuth authWithApp:firebaseApp].currentUser; + [user.multiFactor unenrollWithFactorUID:factorUID + completion:^(NSError *_Nullable error) { + if (error != nil) { + [self promiseRejectAuthException:reject error:error]; + return; + } + + resolve(nil); + return; + }]; +} + RCT_EXPORT_METHOD(finalizeMultiFactorEnrollment : (FIRApp *)firebaseApp : (NSString *)verificationId @@ -1014,6 +1118,37 @@ - (void)invalidate { }]; } +RCT_EXPORT_METHOD(finalizeTotpEnrollment + : (FIRApp *)firebaseApp + : (NSString *)totpSecret + : (NSString *)verificationCode + : (NSString *_Nullable)displayName + : (RCTPromiseResolveBlock)resolve + : (RCTPromiseRejectBlock)reject) { + DLog(@"using instance finalizeTotpEnrollment: %@", firebaseApp.name); + + FIRTOTPSecret *cachedTotpSecret = cachedTotpSecrets[totpSecret]; + DLog(@"using totpSecretKey: %@", totpSecret); + DLog(@"using cachedSecret: %@", cachedTotpSecret); + FIRTOTPMultiFactorAssertion *assertion = + [FIRTOTPMultiFactorGenerator assertionForEnrollmentWithSecret:cachedTotpSecret + oneTimePassword:verificationCode]; + + 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 @@ -1741,12 +1876,12 @@ - (NSDictionary *)firebaseUserToDict:(FIRUser *)user { @"enrollmentDate" : enrollmentTime, } mutableCopy]; - // only support phone mutli factor if ([hint isKindOfClass:[FIRPhoneMultiFactorInfo class]]) { FIRPhoneMultiFactorInfo *phoneHint = (FIRPhoneMultiFactorInfo *)hint; factorDict[@"phoneNumber"] = phoneHint.phoneNumber; - [enrolledFactors addObject:factorDict]; } + + [enrolledFactors addObject:factorDict]; } return enrolledFactors; } diff --git a/packages/auth/lib/MultiFactorResolver.js b/packages/auth/lib/MultiFactorResolver.js index a0c2e3874e..364c5d4ec5 100644 --- a/packages/auth/lib/MultiFactorResolver.js +++ b/packages/auth/lib/MultiFactorResolver.js @@ -9,7 +9,12 @@ export default class MultiFactorResolver { } resolveSignIn(assertion) { - const { token, secret } = assertion; - return this._auth.resolveMultiFactorSignIn(this.session, token, secret); + const { token, secret, uid, verificationCode } = assertion; + + if (token && secret) { + return this._auth.resolveMultiFactorSignIn(this.session, token, secret); + } + + return this._auth.resolveTotpSignIn(this.session, uid, verificationCode); } } diff --git a/packages/auth/lib/TotpMultiFactorGenerator.js b/packages/auth/lib/TotpMultiFactorGenerator.js new file mode 100644 index 0000000000..4f1f4b7f30 --- /dev/null +++ b/packages/auth/lib/TotpMultiFactorGenerator.js @@ -0,0 +1,56 @@ +/* + * 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. + * + */ + +import { isOther } from '@react-native-firebase/app/lib/common'; +import { TotpSecret } from './TotpSecret'; +import { getAuth } from './modular'; + +export default class TotpMultiFactorGenerator { + static FACTOR_ID = 'totp'; + + constructor() { + throw new Error( + '`new TotpMultiFactorGenerator()` is not supported on the native Firebase SDKs.', + ); + } + + static assertionForSignIn(uid, verificationCode) { + if (isOther) { + // we require the web native assertion when using firebase-js-sdk + // as it has functions used by the SDK, a shim won't do + return getAuth().native.assertionForSignIn(uid, verificationCode); + } + return { uid, verificationCode }; + } + + static assertionForEnrollment(totpSecret, verificationCode) { + return { totpSecret: totpSecret.secretKey, verificationCode }; + } + + static async generateSecret(session, auth) { + if (!session) { + throw new Error('Session is required to generate a TOTP secret.'); + } + const { + secretKey, + // Other properties are not publicly exposed in native APIs + // hashingAlgorithm, codeLength, codeIntervalSeconds, enrollmentCompletionDeadline + } = await auth.native.generateTotpSecret(session); + + return new TotpSecret(secretKey, auth); + } +} diff --git a/packages/auth/lib/TotpSecret.js b/packages/auth/lib/TotpSecret.js new file mode 100644 index 0000000000..c200de8aa8 --- /dev/null +++ b/packages/auth/lib/TotpSecret.js @@ -0,0 +1,67 @@ +/* + * 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. + */ + +import { isString } from '@react-native-firebase/app/lib/common'; + +export class TotpSecret { + constructor(secretKey, auth) { + // The native TotpSecret has many more properties, but they are + // internal to the native SDKs, we only maintain the secret in JS layer + this.secretKey = secretKey; + + // we do need a handle to the correct auth instance to generate QR codes etc + this.auth = auth; + } + + /** + * Shared secret key/seed used for enrolling in TOTP MFA and generating OTPs. + */ + secretKey = null; + + /** + * Returns a QR code URL as described in + * https://github.com/google/google-authenticator/wiki/Key-Uri-Format + * This can be displayed to the user as a QR code to be scanned into a TOTP app like Google Authenticator. + * If the optional parameters are unspecified, an accountName of and issuer of are used. + * + * @param accountName the name of the account/app along with a user identifier. + * @param issuer issuer of the TOTP (likely the app name). + * @returns A Promise that resolves to a QR code URL string. + */ + async generateQrCodeUrl(accountName, issuer) { + // accountName and issure are nullable in the API specification but are + // required by tha native SDK. The JS SDK returns '' if they are missing/empty. + if (!isString(accountName) || !isString(issuer) || accountName === '' || issuer === '') { + return ''; + } + return this.auth.native.generateQrCodeUrl(this.secretKey, accountName, issuer); + } + + /** + * Opens the specified QR Code URL in an OTP authenticator app on the device. + * The shared secret key and account name will be populated in the OTP authenticator app. + * The URL uses the otpauth:// scheme and will be opened on an app that handles this scheme, + * if it exists on the device, possibly opening the ecocystem-specific app store with a generic + * query for compatible apps if no app exists on the device. + * + * @param qrCodeUrl the URL to open in the app, from generateQrCodeUrl + */ + openInOtpApp(qrCodeUrl) { + if (isString(qrCodeUrl) && !qrCodeUrl !== '') { + return this.auth.native.openInOtpApp(this.secretKey, qrCodeUrl); + } + } +} diff --git a/packages/auth/lib/getMultiFactorResolver.js b/packages/auth/lib/getMultiFactorResolver.js index faada5e62e..7784ae0873 100644 --- a/packages/auth/lib/getMultiFactorResolver.js +++ b/packages/auth/lib/getMultiFactorResolver.js @@ -1,3 +1,4 @@ +import { isOther } from '@react-native-firebase/app/lib/common'; import MultiFactorResolver from './MultiFactorResolver.js'; /** @@ -7,6 +8,9 @@ import MultiFactorResolver from './MultiFactorResolver.js'; * Returns null if no resolver object can be found on the error. */ export function getMultiFactorResolver(auth, error) { + if (isOther) { + return auth.native.getMultiFactorResolver(error); + } if ( error.hasOwnProperty('userInfo') && error.userInfo.hasOwnProperty('resolver') && diff --git a/packages/auth/lib/index.d.ts b/packages/auth/lib/index.d.ts index 7df8b9b993..0e8f73fbaf 100644 --- a/packages/auth/lib/index.d.ts +++ b/packages/auth/lib/index.d.ts @@ -275,6 +275,78 @@ export namespace FirebaseAuthTypes { assertion(credential: AuthCredential): MultiFactorAssertion; } + /** + * Represents a TOTP secret that is used for enrolling a TOTP second factor. + * Contains the shared secret key and other parameters to generate time-based + * one-time passwords. Implements methods to retrieve the shared secret key, + * generate a QR code URL, and open the QR code URL in an OTP authenticator app. + * + * Differs from standard firebase JS implementation in three ways: + * 1- there is no visibility into ony properties other than the secretKey + * 2- there is an added `openInOtpApp` method supported by native SDKs + * 3- the return value of generateQrCodeUrl is a Promise because react-native bridge is async + * @public + */ + export declare class TotpSecret { + /** used internally to support non-default auth instances */ + private readonly auth; + /** + * Shared secret key/seed used for enrolling in TOTP MFA and generating OTPs. + */ + readonly secretKey: string; + + private constructor(); + + /** + * Returns a QR code URL as described in + * https://github.com/google/google-authenticator/wiki/Key-Uri-Format + * This can be displayed to the user as a QR code to be scanned into a TOTP app like Google Authenticator. + * If the optional parameters are unspecified, an accountName of userEmail and issuer of firebaseAppName are used. + * + * @param accountName the name of the account/app along with a user identifier. + * @param issuer issuer of the TOTP (likely the app name). + * @returns A Promise that resolves to a QR code URL string. + */ + async generateQrCodeUrl(accountName?: string, issuer?: string): Promise; + + /** + * Opens the specified QR Code URL in an OTP authenticator app on the device. + * The shared secret key and account name will be populated in the OTP authenticator app. + * The URL uses the otpauth:// scheme and will be opened on an app that handles this scheme, + * if it exists on the device, possibly opening the ecocystem-specific app store with a generic + * query for compatible apps if no app exists on the device. + * + * @param qrCodeUrl the URL to open in the app, from generateQrCodeUrl + */ + openInOtpApp(qrCodeUrl: string): string; + } + + export interface TotpMultiFactorGenerator { + FACTOR_ID: FactorId.TOTP; + + assertionForSignIn(uid: string, totpSecret: string): MultiFactorAssertion; + + assertionForEnrollment(secret: TotpSecret, code: string): MultiFactorAssertion; + + /** + * @param auth - The Auth instance. Only used for native platforms, should be ignored on web. + */ + generateSecret( + session: FirebaseAuthTypes.MultiFactorSession, + auth: FirebaseAuthTypes.Auth, + ): Promise; + } + + export declare interface MultiFactorError extends AuthError { + /** Details about the MultiFactorError. */ + readonly customData: AuthError['customData'] & { + /** + * The type of operation (sign-in, linking, or re-authentication) that raised the error. + */ + readonly operationType: (typeof OperationType)[keyof typeof OperationType]; + }; + } + /** * firebase.auth.X */ @@ -476,6 +548,7 @@ export namespace FirebaseAuthTypes { */ export enum FactorId { PHONE = 'phone', + TOTP = 'totp', } /** @@ -596,6 +669,12 @@ export namespace FirebaseAuthTypes { * The method will ensure the user state is reloaded after successfully enrolling a factor. */ enroll(assertion: MultiFactorAssertion, displayName?: string): Promise; + + /** + * Unenroll a previously enrolled multi-factor authentication factor. + * @param option The multi-factor option to unenroll. + */ + unenroll(option: MultiFactorInfo | string): Promise; } /** diff --git a/packages/auth/lib/index.js b/packages/auth/lib/index.js index 410ec89dd5..f099f29cdc 100644 --- a/packages/auth/lib/index.js +++ b/packages/auth/lib/index.js @@ -34,6 +34,7 @@ import { import ConfirmationResult from './ConfirmationResult'; import PhoneAuthListener from './PhoneAuthListener'; import PhoneMultiFactorGenerator from './PhoneMultiFactorGenerator'; +import TotpMultiFactorGenerator from './TotpMultiFactorGenerator'; import Settings from './Settings'; import User from './User'; import { getMultiFactorResolver } from './getMultiFactorResolver'; @@ -66,6 +67,7 @@ export { TwitterAuthProvider, FacebookAuthProvider, PhoneMultiFactorGenerator, + TotpMultiFactorGenerator, OAuthProvider, OIDCAuthProvider, PhoneAuthState, @@ -80,6 +82,7 @@ const statics = { TwitterAuthProvider, FacebookAuthProvider, PhoneMultiFactorGenerator, + TotpMultiFactorGenerator, OAuthProvider, OIDCAuthProvider, PhoneAuthState, @@ -324,6 +327,12 @@ class FirebaseAuthModule extends FirebaseModule { }); } + resolveTotpSignIn(session, uid, totpSecret) { + return this.native.resolveTotpSignIn(session, uid, totpSecret).then(userCredential => { + return this._setUserCredential(userCredential); + }); + } + createUserWithEmailAndPassword(email, password) { return this.native .createUserWithEmailAndPassword(email, password) diff --git a/packages/auth/lib/multiFactor.js b/packages/auth/lib/multiFactor.js index 1c8c1369db..bfaca1278f 100644 --- a/packages/auth/lib/multiFactor.js +++ b/packages/auth/lib/multiFactor.js @@ -26,14 +26,25 @@ export class MultiFactorUser { * 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); + const { token, secret, totpSecret, verificationCode } = multiFactorAssertion; + if (token && secret) { + await this._auth.native.finalizeMultiFactorEnrollment(token, secret, displayName); + } else if (totpSecret && verificationCode) { + await this._auth.native.finalizeTotpEnrollment(totpSecret, verificationCode, displayName); + } else { + throw new Error('Invalid multi-factor assertion provided for enrollment.'); + } // We need to reload the user otherwise the changes are not visible + // TODO reload not working on Other platform return reload(this._auth.currentUser); } - unenroll() { - return Promise.reject(new Error('No implemented yet.')); + async unenroll(enrollmentId) { + await this._auth.native.unenrollMultiFactor(enrollmentId); + + if (this._auth.currentUser) { + return reload(this._auth.currentUser); + } } } diff --git a/packages/auth/lib/web/RNFBAuthModule.js b/packages/auth/lib/web/RNFBAuthModule.js index 7a0fb42b92..6f6734df9c 100644 --- a/packages/auth/lib/web/RNFBAuthModule.js +++ b/packages/auth/lib/web/RNFBAuthModule.js @@ -8,6 +8,8 @@ import { sendSignInLinkToEmail, getAdditionalUserInfo, multiFactor, + getMultiFactorResolver, + TotpMultiFactorGenerator, createUserWithEmailAndPassword, signInWithEmailAndPassword, isSignInWithEmailLink, @@ -70,6 +72,15 @@ function rejectPromiseWithCodeAndMessage(code, message) { return rejectPromise(getWebError({ code: `auth/${code}`, message })); } +function rejectWithCodeAndMessage(code, message) { + return Promise.reject( + getWebError({ + code, + message, + }), + ); +} + /** * Returns a structured error object. * @param {error} error The error object. @@ -102,7 +113,9 @@ function userToObject(user) { tenantId: user.tenantId !== null && user.tenantId !== '' ? user.tenantId : null, providerData: user.providerData.map(userInfoToObject), metadata: userMetadataToObject(user.metadata), - multiFactor: multiFactor(user).enrolledFactors.map(multiFactorInfoToObject), + multiFactor: { + enrolledFactors: multiFactor(user).enrolledFactors.map(multiFactorInfoToObject), + }, }; } @@ -222,6 +235,7 @@ const instances = {}; const authStateListeners = {}; const idTokenListeners = {}; const sessionMap = new Map(); +const totpSecretMap = new Map(); let sessionId = 0; // Returns a cached Firestore instance. @@ -441,11 +455,28 @@ export default { * @returns {Promise} - The result of the sign in. */ async signInWithEmailAndPassword(appName, email, password) { - return guard(async () => { - const auth = getCachedAuthInstance(appName); - const credential = await signInWithEmailAndPassword(auth, email, password); + // The default guard / getWebError process doesn't work well here, + // since it creates a new error object that is then passed through + // a native module proxy and gets processed again. + // We need lots of information from the error so that MFA will work + // later if needed. So we handle the error custom here. + // return guard(async () => { + try { + const credential = await signInWithEmailAndPassword( + getCachedAuthInstance(appName), + email, + password, + ); return authResultToObject(credential); - }); + } catch (e) { + e.userInfo = { + code: e.code.split('/')[1], + message: e.message, + customData: e.customData, + }; + throw e; + } + // }); }, /** @@ -991,6 +1022,104 @@ export default { }); }, + /** + * Get a MultiFactorResolver from the underlying SDK + * @param {*} _appName the name of the app to get the auth instance for + * @param {*} uid the uid of the TOTP MFA attempt + * @param {*} code the code from the user TOTP app + * @return TotpMultiFactorAssertion to use for resolving + */ + assertionForSignIn(_appName, uid, code) { + return TotpMultiFactorGenerator.assertionForSignIn(uid, code); + }, + + /** + * Get a MultiFactorResolver from the underlying SDK + * @param {*} appName the name of the app to get the auth instance for + * @param {*} error the MFA error returned from initial factor login attempt + * @return MultiFactorResolver to use for verifying the second factor + */ + getMultiFactorResolver(appName, error) { + return getMultiFactorResolver(getCachedAuthInstance(appName), error); + }, + + /** + * generate a TOTP secret + * @param {*} _appName - The name of the app to get the auth instance for. + * @param {*} session - The MultiFactorSession to associate with the secret + * @returns object with secretKey to associate with TotpSecret + */ + async generateTotpSecret(_appName, session) { + return guard(async () => { + const totpSecret = await TotpMultiFactorGenerator.generateSecret(sessionMap.get(session)); + totpSecretMap.set(totpSecret.secretKey, totpSecret); + return { secretKey: totpSecret.secretKey }; + }); + }, + + /** + * unenroll from TOTP + * @param {*} appName - The name of the app to get the auth instance for. + * @param {*} enrollmentId - The ID to associate with the enrollment + * @returns + */ + async unenrollMultiFactor(appName, enrollmentId) { + return guard(async () => { + const auth = getCachedAuthInstance(appName); + if (auth.currentUser === null) { + return promiseNoUser(true); + } + await multiFactor(auth.currentUser).unenroll(enrollmentId); + }); + }, + + /** + * finalize a TOTP enrollment + * @param {*} appName - The name of the app to get the auth instance for. + * @param {*} secretKey - The secretKey to associate native TotpSecret + * @param {*} verificationCode - The TOTP to verify + * @param {*} displayName - The name to associate as a hint + * @returns + */ + async finalizeTotpEnrollment(appName, secretKey, verificationCode, displayName) { + return guard(async () => { + const auth = getCachedAuthInstance(appName); + if (auth.currentUser === null) { + return promiseNoUser(true); + } + const multiFactorAssertion = TotpMultiFactorGenerator.assertionForEnrollment( + totpSecretMap.get(secretKey), + verificationCode, + ); + await multiFactor(auth.currentUser).enroll(multiFactorAssertion, displayName); + }); + }, + + /** + * generate a TOTP QR Code URL + * @param {*} _appName - The name of the app to get the auth instance for. + * @param {*} secretKey - The secretKey to associate with the TotpSecret + * @param {*} accountName - The account name to use in auth app + * @param {*} issuer - The issuer to use in auth app + * @returns QR Code URL + */ + generateQrCodeUrl(_appName, secretKey, accountName, issuer) { + return totpSecretMap.get(secretKey).generateQrCodeUrl(accountName, issuer); + }, + + /** + * open a QR Code URL in an app directly + * @param {*} appName - The name of the app to get the auth instance for. + * @param {*} qrCodeUrl the URL to open in the app, from generateQrCodeUrl + * @throws Error not supported in this environment + */ + openInOtpApp() { + return rejectWithCodeAndMessage( + 'unsupported', + 'This operation is not supported in this environment.', + ); + }, + /* ---------------------- * other methods * ---------------------- */ diff --git a/tests/ios/Podfile.lock b/tests/ios/Podfile.lock index 95fc531c1d..8e40e7e6b7 100644 --- a/tests/ios/Podfile.lock +++ b/tests/ios/Podfile.lock @@ -202,7 +202,7 @@ PODS: - GTMSessionFetcher/Core (< 6.0, >= 3.4) - fmt (11.0.2) - glog (0.3.5) - - GoogleAdsOnDeviceConversion (2.2.1): + - GoogleAdsOnDeviceConversion (2.3.0): - GoogleUtilities/Logger (~> 8.1) - GoogleUtilities/Network (~> 8.1) - nanopb (~> 3.30910.0) @@ -1803,69 +1803,69 @@ PODS: - Yoga - RNDeviceInfo (14.0.4): - React-Core - - RNFBAnalytics (23.1.1): + - RNFBAnalytics (23.1.2): - FirebaseAnalytics/Core (= 12.1.0) - FirebaseAnalytics/IdentitySupport (= 12.1.0) - GoogleAdsOnDeviceConversion - React-Core - RNFBApp - - RNFBApp (23.1.1): + - RNFBApp (23.1.2): - Firebase/CoreOnly (= 12.1.0) - React-Core - - RNFBAppCheck (23.1.1): + - RNFBAppCheck (23.1.2): - Firebase/AppCheck (= 12.1.0) - React-Core - RNFBApp - - RNFBAppDistribution (23.1.1): + - RNFBAppDistribution (23.1.2): - Firebase/AppDistribution (= 12.1.0) - React-Core - RNFBApp - - RNFBAuth (23.1.1): + - RNFBAuth (23.1.2): - Firebase/Auth (= 12.1.0) - React-Core - RNFBApp - - RNFBCrashlytics (23.1.1): + - RNFBCrashlytics (23.1.2): - Firebase/Crashlytics (= 12.1.0) - FirebaseCoreExtension - React-Core - RNFBApp - - RNFBDatabase (23.1.1): + - RNFBDatabase (23.1.2): - Firebase/Database (= 12.1.0) - React-Core - RNFBApp - - RNFBFirestore (23.1.1): + - RNFBFirestore (23.1.2): - Firebase/Firestore (= 12.1.0) - React-Core - RNFBApp - - RNFBFunctions (23.1.1): + - RNFBFunctions (23.1.2): - Firebase/Functions (= 12.1.0) - React-Core - RNFBApp - - RNFBInAppMessaging (23.1.1): + - RNFBInAppMessaging (23.1.2): - Firebase/InAppMessaging (= 12.1.0) - React-Core - RNFBApp - - RNFBInstallations (23.1.1): + - RNFBInstallations (23.1.2): - Firebase/Installations (= 12.1.0) - React-Core - RNFBApp - - RNFBMessaging (23.1.1): + - RNFBMessaging (23.1.2): - Firebase/Messaging (= 12.1.0) - FirebaseCoreExtension - React-Core - RNFBApp - - RNFBML (23.1.1): + - RNFBML (23.1.2): - React-Core - RNFBApp - - RNFBPerf (23.1.1): + - RNFBPerf (23.1.2): - Firebase/Performance (= 12.1.0) - React-Core - RNFBApp - - RNFBRemoteConfig (23.1.1): + - RNFBRemoteConfig (23.1.2): - Firebase/RemoteConfig (= 12.1.0) - React-Core - RNFBApp - - RNFBStorage (23.1.1): + - RNFBStorage (23.1.2): - Firebase/Storage (= 12.1.0) - React-Core - RNFBApp @@ -2224,7 +2224,7 @@ SPEC CHECKSUMS: FirebaseStorage: 91432ddfb31e83de1e9fa5833b4399b39bc722f7 fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd glog: eb93e2f488219332457c3c4eafd2738ddc7e80b8 - GoogleAdsOnDeviceConversion: 7978b3761ee627e42edbb47d44906a0fa43ed448 + GoogleAdsOnDeviceConversion: 9090c435cde08903e8dd1ba2c77fbec9e46d9afe GoogleAppMeasurement: 61605c4152a142d797383a713ecfa5df98fe46ca GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 @@ -2295,22 +2295,22 @@ SPEC CHECKSUMS: RecaptchaInterop: 11e0b637842dfb48308d242afc3f448062325aba RNCAsyncStorage: 6a8127b6987dc9fbce778669b252b14c8355c7ce RNDeviceInfo: d863506092aef7e7af3a1c350c913d867d795047 - RNFBAnalytics: 778643c319990e6fa2401a699315200b4a6cdcbe - RNFBApp: 053f79dccb3e34415dac9a5167bd33094d8a1441 - RNFBAppCheck: e865a44d72f3bbbb746cfffae8f44bfa0ed7f076 - RNFBAppDistribution: b5fcf12da3fbb4f62489725e8179dc641148507c - RNFBAuth: 21913e0f41fa4359d648c6bffda24aa75fcd0a1a - RNFBCrashlytics: 345d14c2a3ae0594f29a17aad4ce5b1f6d8bcfbb - RNFBDatabase: c1bd44fad79cfb6e919cc8e1d20751eeb0b64728 - RNFBFirestore: 505a793d9c9ad1e66c7e179d8aeab4ad2c1cba18 - RNFBFunctions: e40b25d21478c0aef09b5c70cbbf1d4f062660ed - RNFBInAppMessaging: 23aaf12834e1216672294a5cbcb8aa688bb3507e - RNFBInstallations: 5402ac25b1bf934a151afa2bdcdbb85d9bf4d653 - RNFBMessaging: 0fec4854dc48e377b80e1e9bc54841ed390fef3c - RNFBML: fbcd9b5b93d438b6649c6e7b491d455e28d9654b - RNFBPerf: cfcd0458f5e400cc373f3514792a2ae427157f62 - RNFBRemoteConfig: b4f483801fd650397832f30c3f3b25c8d83fc6ae - RNFBStorage: 111d84a8df90a707a29072b2001dc32734e09680 + RNFBAnalytics: e3ebf6d227ba4afad82067b6cc09c39c2d7ca71c + RNFBApp: 3155b54dc88e7bf8d7aad86c014bed54747f4e0f + RNFBAppCheck: 8e49eaea8e57041f8954918db8c6c3c8c19e358a + RNFBAppDistribution: 2a8bfdb330900cd72feabffcb91e6ef791068cb1 + RNFBAuth: 955c9df234ad94774b03c37d2911ec35620b8c24 + RNFBCrashlytics: e877918706631e3533c2fdedc97b54f5f8956f14 + RNFBDatabase: 1c6b04165029efdf3773d4595c4d173505c8db67 + RNFBFirestore: 29a4ec6fb76277b868dcea87e60cb8009000df58 + RNFBFunctions: 0f2bd0fe886b708f95c5a5eddd372d7aebbc0273 + RNFBInAppMessaging: 89ed4c1a1207a5323a7d947b1447108c9a08fccf + RNFBInstallations: 47a81452a2f2973c7ed756a008742dd785c72232 + RNFBMessaging: 1d82bba6185f157386c6d023079b812e02ced46f + RNFBML: ad2affe812fb1942c5b3715cf1b32e66e0206f32 + RNFBPerf: 5a5c86e3208e136183cfbd0611cb04925b476663 + RNFBRemoteConfig: 0b39796712481bf6cc3cc963c416738a02650148 + RNFBStorage: a1e04aed7c2c53d30c7fa345d789d5208c332bf5 SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 Yoga: 6eb60fc2c0eef63e7d2ef4a56e0a3353534143a2 diff --git a/tests/local-tests/auth/auth-totp-demonstrator.tsx b/tests/local-tests/auth/auth-totp-demonstrator.tsx new file mode 100644 index 0000000000..f505a787dc --- /dev/null +++ b/tests/local-tests/auth/auth-totp-demonstrator.tsx @@ -0,0 +1,586 @@ +/* eslint-disable no-console */ +/* eslint-disable react/react-in-jsx-scope */ +/* + * 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. + * + */ + +import QRCode from 'qrcode-generator'; + +import { + ActivityIndicator, + Image, + Pressable, + StyleSheet, + Text, + TextInput, + View, + ViewStyle, +} from 'react-native'; + +import { + createUserWithEmailAndPassword, + FirebaseAuthTypes, + getAuth, + getMultiFactorResolver, + multiFactor, + onAuthStateChanged, + reload, + sendEmailVerification, + signInWithEmailAndPassword, + signOut, + TotpMultiFactorGenerator, + TotpSecret, +} from '@react-native-firebase/auth'; +import { useEffect, useState } from 'react'; + +const Button = (props: { + style?: ViewStyle; + onPress: () => void; + isLoading?: boolean; + children: string; +}) => { + return ( + + {props.isLoading && } + + {!props.isLoading && {props.children}} + + ); +}; + +export function AuthTOTPDemonstrator() { + const [authReady, setAuthReady] = useState(false); + const [user, setUser] = useState(null); + + useEffect(() => { + const unsubscribe = onAuthStateChanged(getAuth(), user => { + setUser(user); + setAuthReady(true); + }); + + return () => unsubscribe(); + }, []); + + if (!authReady) { + return ( + + Loading... + + ); + } + + if (!user) { + return ; + } + + if (!user.emailVerified) { + return ( + { + setUser(getAuth().currentUser); + }} + /> + ); + } + + return ; +} + +const VerifyEmail = ({ onComplete }: { onComplete: () => void }) => { + const [loading, setIsLoading] = useState(false); + + const handleSendVerification = async () => { + setIsLoading(true); + console.log('should send verification email'); + try { + await sendEmailVerification(getAuth().currentUser!); + } catch (error) { + console.error('error sending verification email', error); + } finally { + setIsLoading(false); + } + }; + + const handleReloadingUser = async () => { + setIsLoading(true); + console.log('should reload user to see if verified now'); + try { + await reload(getAuth().currentUser!); + onComplete(); + console.log('done reloading. verification status ' + getAuth().currentUser?.emailVerified); + } catch (error) { + console.error('error reloading user', error); + } finally { + setIsLoading(false); + } + }; + + return ( + + + Welcome Back + {getAuth().currentUser?.email} + Please Verify Your Email + (check spam for the email if you do not see it) + + + + + signOut(getAuth())}> + Sign Out + + + + ); +}; + +const Login = () => { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [isLoading, setLoading] = useState(false); + + const [mfaError, setMfaError] = useState(); + + const handleLogin = async () => { + try { + setLoading(true); + await signInWithEmailAndPassword(getAuth(), email, password); + console.log('Login successful'); + } catch (error) { + if ((error as { code: string }).code === 'auth/multi-factor-auth-required') { + return setMfaError(error as FirebaseAuthTypes.MultiFactorError); + } + + console.error('Error during login:', error); + } finally { + setLoading(false); + } + }; + + const handleSignUp = async () => { + try { + setLoading(true); + await createUserWithEmailAndPassword(getAuth(), email, password); + console.log('Sign up successful'); + } catch (error) { + console.error('Error during signup:', error); + } finally { + setLoading(false); + } + }; + + if (mfaError) { + return ; + } + + return ( + + + Welcome Back + Please log in to continue + + + + + + + + + + ); +}; + +const MfaLogin = ({ error }: { error: FirebaseAuthTypes.MultiFactorError }) => { + const [resolver, setResolver] = useState(); + const [activeFactor, setActiveFactor] = useState(); + + const [code, setCode] = useState(''); + const [isLoading, setLoading] = useState(false); + + useEffect(() => { + const resolver = getMultiFactorResolver(getAuth(), error); + setResolver(resolver); + setActiveFactor(resolver.hints[0]); + if (resolver.hints.length === 1) { + const hint = resolver.hints[0]; + setActiveFactor(hint); + } + }, [error]); + + const handleConfirm = async () => { + if (!resolver) return; + + try { + setLoading(true); + // For demo, assume only 1 hint and it's totp + const multiFactorAssertion = TotpMultiFactorGenerator.assertionForSignIn( + activeFactor!.uid, + code, + ); + return await resolver.resolveSignIn(multiFactorAssertion); + } catch (error) { + console.error('Error during MFA sign in:', error); + } finally { + setLoading(false); + } + }; + + if (!resolver) { + return null; + } + + // For demo, assume only 1 hint and it's totp + return ( + + + Two-Factor Authentication + + Please enter the verification code from your authenticator app + + + + + + + + + + ); +}; + +const Home = () => { + const [factors, setFactors] = useState(getAuth().currentUser?.multiFactor?.enrolledFactors); + const [addingFactor, setAddingFactor] = useState(false); + const [removingFactor, setRemovingFactor] = useState(false); + + const [totpSecret, setTotpSecret] = useState(null); + + const handleRemoveFactor = async (factor: FirebaseAuthTypes.MultiFactorInfo) => { + try { + const user = getAuth().currentUser; + if (!user) return; + setRemovingFactor(true); + + const multiFactorUser = multiFactor(user); + await multiFactorUser.unenroll(factor.uid); + console.log(`Factor ${factor.factorId} removed successfully`); + } catch (error) { + console.error('Error removing factor:', error); + } finally { + setRemovingFactor(false); + } + + setFactors(getAuth().currentUser?.multiFactor?.enrolledFactors); + }; + + const generateTotpSecret = async () => { + console.log('in generateTotpSecret'); + setAddingFactor(true); + const currentUser = getAuth().currentUser!; + console.log(`got currentUser ${currentUser.email}`); + try { + const multiFactorSession = await multiFactor(currentUser).getSession(); + console.log(`got multiFactorSession`); + setTotpSecret(await TotpMultiFactorGenerator.generateSecret(multiFactorSession, getAuth())); + } catch (error) { + console.error('Error generating TOTP Secret', error); + } finally { + setAddingFactor(false); + } + }; + + if (totpSecret) { + return ( + { + setFactors(getAuth().currentUser?.multiFactor?.enrolledFactors); + setTotpSecret(null); + }} + /> + ); + } + + return ( + + + Welcome! + Logged in as {getAuth().currentUser?.email} + + + Enrolled factors: {factors?.length || 0} + + + {factors?.map(factor => ( + + ))} + + + + signOut(getAuth())}> + Sign Out + + + + ); +}; + +const EnrollTotp = ({ + totpSecret, + onComplete, +}: { + totpSecret: TotpSecret; + onComplete: () => void; +}) => { + const [verificationCode, setVerificationCode] = useState(''); + const [qrCodeUrl, setQrCodeUrl] = useState(''); + const [qrCodeBase64, setQrCodeBase64] = useState(''); + const [isLoading, setLoading] = useState(false); + + useEffect(() => { + totpSecret + .generateQrCodeUrl(getAuth().currentUser?.email, 'RNFB TOTP Demonstrator') + .then(qrURL => { + setQrCodeUrl(qrURL); + const qr = QRCode(0, 'L'); + qr.addData(qrURL); + qr.make(); + const qrDataURL = qr.createDataURL(); + setQrCodeBase64(qrDataURL); + }) + .catch(e => console.error('error generating qa code url ' + e)); + }, []); + + const handleConfirm = async () => { + setLoading(true); + try { + const user = getAuth().currentUser; + if (!user) return; + + const multiFactorAssertion = TotpMultiFactorGenerator.assertionForEnrollment( + totpSecret, + verificationCode, + ); + await multiFactor(user).enroll(multiFactorAssertion, 'Authenticator App'); + onComplete(); + } catch (error) { + console.error('Error enrolling TOTP:', error); + } finally { + setLoading(false); + } + }; + + return ( + + + Enroll Options + {qrCodeBase64 !== '' && ( + <> + a) Scan the QR code + + + )} + + b) Open in app directly. + + + c) Enter the secret manually + + + + Then enter the verification code from your authenticator app. + + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#F8FAFC', + alignItems: 'center', + justifyContent: 'center', + padding: 20, + }, + card: { + backgroundColor: '#FFFFFF', + borderRadius: 20, + padding: 40, + width: '100%', + maxWidth: 400, + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 8, + }, + shadowOpacity: 0.12, + shadowRadius: 24, + elevation: 8, + borderWidth: 1, + borderColor: '#F1F5F9', + }, + title: { + fontSize: 32, + fontWeight: '800', + color: '#0F172A', + textAlign: 'center', + marginBottom: 12, + letterSpacing: -0.5, + }, + subtitle: { + fontSize: 16, + color: '#64748B', + textAlign: 'center', + marginBottom: 20, + lineHeight: 24, + fontWeight: '400', + }, + text: { + fontSize: 16, + color: '#64748B', + textAlign: 'center', + fontWeight: '400', + }, + inputContainer: { + marginBottom: 32, + }, + input: { + backgroundColor: '#FAFBFC', + borderWidth: 2, + borderColor: '#E2E8F0', + borderRadius: 16, + padding: 18, + fontSize: 16, + color: '#0F172A', + marginBottom: 20, + fontWeight: '500', + }, + button: { + backgroundColor: '#2563EB', + borderRadius: 16, + padding: 18, + alignItems: 'center', + shadowColor: '#2563EB', + shadowOffset: { + width: 0, + height: 6, + }, + shadowOpacity: 0.25, + shadowRadius: 12, + elevation: 6, + }, + buttonText: { + color: '#FFFFFF', + fontSize: 17, + fontWeight: '700', + letterSpacing: 0.3, + }, + secondaryButton: { + backgroundColor: '#F8FAFC', + borderWidth: 2, + borderColor: '#E2E8F0', + borderRadius: 16, + padding: 18, + alignItems: 'center', + marginTop: 20, + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.05, + shadowRadius: 4, + elevation: 2, + }, + secondaryButtonText: { + color: '#475569', + fontSize: 17, + fontWeight: '600', + }, +}); diff --git a/tests/local-tests/auth/gcloud-enable-totp-in-project.sh b/tests/local-tests/auth/gcloud-enable-totp-in-project.sh new file mode 100755 index 0000000000..3a97241e32 --- /dev/null +++ b/tests/local-tests/auth/gcloud-enable-totp-in-project.sh @@ -0,0 +1,50 @@ +#!/bin/bash + +# 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. + +# This script will enable TOTP in your project +# +# It assumes: +# - curl is in PATH (should be installed by default on macOS) +# - gcloud is in PATH and you are authenticated (brew install google-cloud-sdk...) +# - you have set PROJECT_ID (otherwise it will use "react-native-firebase-testing") + +if [ -z ${PROJECT_ID+x} ]; then + PROJECT_ID="react-native-firebase-testing" +fi + +if [ -z ${NUM_ADJ_INTERVALS+x} ]; then + NUM_ADJ_INTERVALS=5 +fi + +echo "Enabling MFA and TOTP in gcloud project $PROJECT_ID" +echo "...using ${NUM_ADJ_INTERVALS} as number of adjacent intervals to accept." + +curl -X PATCH "https://identitytoolkit.googleapis.com/admin/v2/projects/${PROJECT_ID}/config?updateMask=mfa" \ + -H "Authorization: Bearer $(gcloud auth print-access-token)" \ + -H "Content-Type: application/json" \ + -H "X-Goog-User-Project: ${PROJECT_ID}" \ + -d \ + "{ + \"mfa\": { + \"state\": \"ENABLED\", + \"providerConfigs\": [{ + \"state\": \"ENABLED\", + \"totpProviderConfig\": { + \"adjacentIntervals\": ${NUM_ADJ_INTERVALS} + } + }] + } + }" \ No newline at end of file diff --git a/tests/local-tests/crash-test.jsx b/tests/local-tests/crash-test.jsx index 99033c593e..4aeeda1f9d 100644 --- a/tests/local-tests/crash-test.jsx +++ b/tests/local-tests/crash-test.jsx @@ -1,4 +1,21 @@ /* eslint-disable react/react-in-jsx-scope */ +/* + * 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. + * + */ + import { Button, StyleSheet, Text, View } from 'react-native'; import { crash, getCrashlytics } from '@react-native-firebase/crashlytics'; diff --git a/tests/local-tests/database/index.js b/tests/local-tests/database/index.js index dc8989f3e9..cec50f190b 100644 --- a/tests/local-tests/database/index.js +++ b/tests/local-tests/database/index.js @@ -1,3 +1,20 @@ +/* + * 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. + * + */ + import React from 'react'; import { Button, Text, View } from 'react-native'; @@ -13,9 +30,10 @@ import { connectDatabaseEmulator, } from '@react-native-firebase/database'; -connectDatabaseEmulator(getDatabase(), '127.0.0.1', 9000); - export function DatabaseOnChildMovedTest() { + // Defer connecting to the emulator until we display + connectDatabaseEmulator(getDatabase(), '127.0.0.1', 9000); + return ( text text text diff --git a/tests/local-tests/firestore/onSnapshotInSync.js b/tests/local-tests/firestore/onSnapshotInSync.js index bc174057cc..34f7f578bc 100644 --- a/tests/local-tests/firestore/onSnapshotInSync.js +++ b/tests/local-tests/firestore/onSnapshotInSync.js @@ -1,4 +1,21 @@ /* eslint-disable no-console */ +/* + * 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. + * + */ + import React, { useEffect } from 'react'; import { Button, Text, View } from 'react-native'; diff --git a/tests/local-tests/index.js b/tests/local-tests/index.js index 9e7a8034e0..125dafac0b 100644 --- a/tests/local-tests/index.js +++ b/tests/local-tests/index.js @@ -1,4 +1,21 @@ /* eslint-disable react/react-in-jsx-scope */ +/* + * 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. + * + */ + import { Button } from 'react-native'; import { useState } from 'react'; @@ -9,6 +26,7 @@ import { AITestComponent } from './ai/ai'; import { DatabaseOnChildMovedTest } from './database'; import { FirestoreOnSnapshotInSyncTest } from './firestore/onSnapshotInSync'; import { VertexAITestComponent } from './vertexai/vertexai'; +import { AuthTOTPDemonstrator } from './auth/auth-totp-demonstrator'; const testComponents = { // List your imported components here... @@ -17,6 +35,7 @@ const testComponents = { 'Database onChildMoved Test': DatabaseOnChildMovedTest, 'Firestore onSnapshotInSync Test': FirestoreOnSnapshotInSyncTest, 'VertexAI Generation Example': VertexAITestComponent, + 'Auth TOTP Demonstrator': AuthTOTPDemonstrator, }; export function TestComponents() { diff --git a/tests/local-tests/vertexai/base-64-media.js b/tests/local-tests/vertexai/base-64-media.js index ec73d7346f..7a3549386c 100644 --- a/tests/local-tests/vertexai/base-64-media.js +++ b/tests/local-tests/vertexai/base-64-media.js @@ -1,3 +1,20 @@ +/* + * 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 const PDF_BASE_64 = ` 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CmVuZG9iagoKOSAwIG9iago8PC9UeXBlL0ZvbnQvU3VidHlwZS9UcnVlVHlwZS9CYXNlRm9udC9CQUFBQUErQXJpYWwtQm9sZE1UCi9GaXJzdENoYXIgMAovTGFzdENoYXIgMTEKL1dpZHRoc1s3NTAgNzIyIDYxMCA4ODkgNTU2IDI3NyA2NjYgNjEwIDMzMyAyNzcgMjc3IDU1NiBdCi9Gb250RGVzY3JpcHRvciA3IDAgUgovVG9Vbmljb2RlIDggMCBSCj4+CmVuZG9iagoKMTAgMCBvYmoKPDwKL0YxIDkgMCBSCj4+CmVuZG9iagoKMTEgMCBvYmoKPDwvRm9udCAxMCAwIFIKL1Byb2NTZXRbL1BERi9UZXh0XT4+CmVuZG9iagoKMSAwIG9iago8PC9UeXBlL1BhZ2UvUGFyZW50IDQgMCBSL1Jlc291cmNlcyAxMSAwIFIvTWVkaWFCb3hbMCAwIDU5NSA4NDJdL0dyb3VwPDwvUy9UcmFuc3BhcmVuY3kvQ1MvRGV2aWNlUkdCL0kgdHJ1ZT4+L0NvbnRlbnRzIDIgMCBSPj4KZW5kb2JqCgoxMiAwIG9iago8PC9Db3VudCAxL0ZpcnN0IDEzIDAgUi9MYXN0IDEzIDAgUgo+PgplbmRvYmoKCjEzIDAgb2JqCjw8L1RpdGxlPEZFRkYwMDQ0MDA3NTAwNkQwMDZEMDA3OTAwMjAwMDUwMDA0NDAwNDYwMDIwMDA2NjAwNjkwMDZDMDA2NT4KL0Rlc3RbMSAwIFIvWFlaIDU2LjcgNzczLjMgMF0vUGFyZW50IDEyIDAgUj4+CmVuZG9iagoKNCAwIG9iago8PC9UeXBlL1BhZ2VzCi9SZXNvdXJjZXMgMTEgMCBSCi9NZWRpYUJveFsgMCAwIDU5NSA4NDIgXQovS2lkc1sgMSAwIFIgXQovQ291bnQgMT4+CmVuZG9iagoKMTQgMCBvYmoKPDwvVHlwZS9DYXRhbG9nL1BhZ2VzIDQgMCBSCi9PdXRsaW5lcyAxMiAwIFIKPj4KZW5kb2JqCgoxNSAwIG9iago8PC9BdXRob3I8RkVGRjAwNDUwMDc2MDA2MTAwNkUwMDY3MDA2NTAwNkMwMDZGMDA3MzAwMjAwMDU2MDA2QzAwNjEwMDYzMDA2ODAwNkYwMDY3MDA2OTAwNjEwMDZFMDA2RTAwNjkwMDczPgovQ3JlYXRvcjxGRUZGMDA1NzAwNzIwMDY5MDA3NDAwNjUwMDcyPgovUHJvZHVjZXI8RkVGRjAwNEYwMDcwMDA2NTAwNkUwMDRGMDA2NjAwNjYwMDY5MDA2MzAwNjUwMDJFMDA2RjAwNzIwMDY3MDAyMDAwMzIwMDJFMDAzMT4KL0NyZWF0aW9uRGF0ZShEOjIwMDcwMjIzMTc1NjM3KzAyJzAwJyk+PgplbmRvYmoKCnhyZWYKMCAxNgowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMTE5OTcgMDAwMDAgbiAKMDAwMDAwMDAxOSAwMDAwMCBuIAowMDAwMDAwMjI0IDAwMDAwIG4gCjAwMDAwMTIzMzAgMDAwMDAgbiAKMDAwMDAwMDI0NCAwMDAwMCBuIAowMDAwMDExMTU0IDAwMDAwIG4gCjAwMDAwMTExNzYgMDAwMDAgbiAKMDAwMDAxMTM2OCAwMDAwMCBuIAowMDAwMDExNzA5IDAwMDAwIG4gCjAwMDAwMTE5MTAgMDAwMDAgbiAKMDAwMDAxMTk0MyAwMDAwMCBuIAowMDAwMDEyMTQwIDAwMDAwIG4gCjAwMDAwMTIxOTYgMDAwMDAgbiAKMDAwMDAxMjQyOSAwMDAwMCBuIAowMDAwMDEyNDk0IDAwMDAwIG4gCnRyYWlsZXIKPDwvU2l6ZSAxNi9Sb290IDE0IDAgUgovSW5mbyAxNSAwIFIKL0lEIFsgPEY3RDc3QjNEMjJCOUY5MjgyOUQ0OUZGNUQ3OEI4RjI4Pgo8RjdENzdCM0QyMkI5RjkyODI5RDQ5RkY1RDc4QjhGMjg+IF0KPj4Kc3RhcnR4cmVmCjEyNzg3CiUlRU9GCg== `; diff --git a/tests/local-tests/vertexai/vertexai.js b/tests/local-tests/vertexai/vertexai.js index 7e8286f985..577f79eccc 100644 --- a/tests/local-tests/vertexai/vertexai.js +++ b/tests/local-tests/vertexai/vertexai.js @@ -1,4 +1,21 @@ /* eslint-disable no-console */ +/* + * 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. + * + */ + import React, { useState } from 'react'; import { Button, View, Text, Pressable } from 'react-native'; diff --git a/tests/package.json b/tests/package.json index f3b47d94f2..79ea584eb2 100644 --- a/tests/package.json +++ b/tests/package.json @@ -30,6 +30,7 @@ "@react-native-firebase/remote-config": "23.1.2", "@react-native-firebase/storage": "23.1.2", "postinstall-postinstall": "2.1.0", + "qrcode-generator": "^2.0.4", "react": "19.0.0", "react-native": "0.78.2", "react-native-device-info": "^14.0.4", diff --git a/yarn.lock b/yarn.lock index 7f451022b2..80e415f7fe 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19650,6 +19650,13 @@ __metadata: languageName: node linkType: hard +"qrcode-generator@npm:^2.0.4": + version: 2.0.4 + resolution: "qrcode-generator@npm:2.0.4" + checksum: 10/c488558bca2869d8837300da299a9653030dbb486ce6113153585b117fa0ab6822a03c8312cc794d41c2e3f8353dc64b950e8a3ffe90f89022593c4e7060cd53 + languageName: node + linkType: hard + "qrcode-terminal@npm:0.11.0": version: 0.11.0 resolution: "qrcode-terminal@npm:0.11.0" @@ -19949,6 +19956,7 @@ __metadata: nyc: "npm:^17.1.0" patch-package: "npm:^8.0.0" postinstall-postinstall: "npm:2.1.0" + qrcode-generator: "npm:^2.0.4" react: "npm:19.0.0" react-native: "npm:0.78.2" react-native-device-info: "npm:^14.0.4"