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 1776d23bd2..97618891f3 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 @@ -64,8 +64,11 @@ import com.google.firebase.auth.PhoneAuthOptions; import com.google.firebase.auth.PhoneAuthProvider; import com.google.firebase.auth.PhoneMultiFactorAssertion; +import com.google.firebase.auth.TotpMultiFactorAssertion; +import com.google.firebase.auth.TotpMultiFactorGenerator; import com.google.firebase.auth.PhoneMultiFactorGenerator; import com.google.firebase.auth.PhoneMultiFactorInfo; +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,29 @@ 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 +1308,42 @@ public void finalizeMultiFactorEnrollment( }); } + @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 +1399,75 @@ 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 2c9232e423..b8625ad05a 100644 --- a/packages/auth/ios/RNFBAuth/RNFBAuthModule.m +++ b/packages/auth/ios/RNFBAuth/RNFBAuthModule.m @@ -59,6 +59,7 @@ #if TARGET_OS_IOS static __strong NSMutableDictionary *cachedResolver; static __strong NSMutableDictionary *cachedSessions; +static __strong NSMutableDictionary *cachedTotpSecrets; #endif @implementation RNFBAuthModule @@ -82,6 +83,7 @@ - (id)init { #if TARGET_OS_IOS cachedResolver = [[NSMutableDictionary alloc] init]; cachedSessions = [[NSMutableDictionary alloc] init]; + cachedTotpSecrets = [[NSMutableDictionary alloc] init]; #endif }); return self; @@ -111,6 +113,7 @@ - (void)invalidate { #if TARGET_OS_IOS [cachedResolver removeAllObjects]; [cachedSessions removeAllObjects]; + [cachedTotpSecrets removeAllObjects]; #endif } @@ -968,6 +971,61 @@ - (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(getSession : (FIRApp *)firebaseApp : (RCTPromiseResolveBlock)resolve @@ -986,6 +1044,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 @@ -1015,6 +1093,35 @@ - (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 @@ -1739,18 +1846,29 @@ - (NSDictionary *)firebaseUserToDict:(FIRUser *)user { NSMutableArray *enrolledFactors = [NSMutableArray array]; for (FIRPhoneMultiFactorInfo *hint in hints) { - NSString *enrollmentTime = - [[[NSISO8601DateFormatter alloc] init] stringFromDate:hint.enrollmentDate]; - [enrolledFactors addObject:@{ - @"uid" : hint.UID, - @"factorId" : [self getJSFactorId:(hint.factorID)], - @"displayName" : hint.displayName == nil ? [NSNull null] : hint.displayName, - @"enrollmentTime" : enrollmentTime, - // @deprecated enrollmentDate kept for backwards compatibility, please use enrollmentTime - @"enrollmentDate" : enrollmentTime, - // phoneNumber only present on FIRPhoneMultiFactorInfo - @"phoneNumber" : hint.phoneNumber == nil ? [NSNull null] : hint.phoneNumber, - }]; + NSString *enrollmentTime = + [[[NSISO8601DateFormatter alloc] init] stringFromDate:hint.enrollmentDate]; + if ([hint.factorID isEqualToString:@"phone"]) { + [enrolledFactors addObject:@{ + @"uid" : hint.UID, + @"factorId" : [self getJSFactorId:(hint.factorID)], + @"displayName" : hint.displayName == nil ? [NSNull null] : hint.displayName, + @"enrollmentTime" : enrollmentTime, + // @deprecated enrollmentDate kept for backwards compatibility, please use enrollmentTime + @"enrollmentDate" : enrollmentTime, + // phoneNumber only present on FIRPhoneMultiFactorInfo + @"phoneNumber" : hint.phoneNumber == nil ? [NSNull null] : hint.phoneNumber, + }]; + } else if ([hint.factorID isEqualToString:@"totp"]) { + [enrolledFactors addObject:@{ + @"uid" : hint.UID, + @"factorId" : @"totp", + @"displayName" : hint.displayName == nil ? [NSNull null] : hint.displayName, + @"enrollmentTime" : enrollmentTime, + // @deprecated enrollmentDate kept for backwards compatibility, please use enrollmentTime + @"enrollmentDate" : enrollmentTime, + }]; + } } 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..1e41473c1d --- /dev/null +++ b/packages/auth/lib/TotpMultiFactorGenerator.js @@ -0,0 +1,47 @@ +import { TotpSecret } from "./TotpSecret"; + +/* + * 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 TotpMultiFactorGenerator { + static FACTOR_ID = "totp"; + + constructor() { + throw new Error("`new TotpMultiFactorGenerator()` is not supported on the native Firebase SDKs."); + } + + static 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); + } +} diff --git a/packages/auth/lib/TotpSecret.js b/packages/auth/lib/TotpSecret.js new file mode 100644 index 0000000000..4406983775 --- /dev/null +++ b/packages/auth/lib/TotpSecret.js @@ -0,0 +1,54 @@ +export class TotpSecret { + constructor(secretKey, hashingAlgorithm, codeLength, codeIntervalSeconds, enrollmentCompletionDeadline) { + this.secretKey = secretKey; + this.hashingAlgorithm = hashingAlgorithm; + this.codeLength = codeLength; + this.codeIntervalSeconds = codeIntervalSeconds; + this.enrollmentCompletionDeadline = enrollmentCompletionDeadline; + } + + sessionInfo = null; + auth = null; + /** + * Shared secret key/seed used for enrolling in TOTP MFA and generating OTPs. + */ + secretKey = null; + /** + * Hashing algorithm used. + */ + hashingAlgorithm = null; + /** + * Length of the one-time passwords to be generated. + */ + codeLength = null; + /** + * The interval (in seconds) when the OTP codes should change. + */ + codeIntervalSeconds = null; + /** + * The timestamp (UTC string) by which TOTP enrollment should be completed. + */ + enrollmentCompletionDeadline = 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 QR code URL string. + */ + generateQrCodeUrl(_accountName, _issuer) { + throw new Error("`generateQrCodeUrl` is not supported on the native Firebase SDKs."); + // if (!this.hashingAlgorithm || !this.codeLength) { + // return ""; + // } + + // return ( + // `otpauth://totp/${issuer}:${accountName}?secret=${this.secretKey}&issuer=${issuer}` + + // `&algorithm=${this.hashingAlgorithm}&digits=${this.codeLength}` + // ); + } +} diff --git a/packages/auth/lib/index.d.ts b/packages/auth/lib/index.d.ts index 7df8b9b993..70750b27b2 100644 --- a/packages/auth/lib/index.d.ts +++ b/packages/auth/lib/index.d.ts @@ -275,6 +275,29 @@ export namespace FirebaseAuthTypes { assertion(credential: AuthCredential): MultiFactorAssertion; } + export interface TotpMultiFactorGenerator { + static FACTOR_ID: FactorId.TOTP; + + static assertionForSignIn(uid: string, totpSecret: string): MultiFactorAssertion; + + static assertionForEnrollment(secret: TotpSecret, code: string): MultiFactorAssertion; + + /** + * @param auth - The Auth instance. Only used for native platforms, should be ignored on web. + */ + static 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 +499,7 @@ export namespace FirebaseAuthTypes { */ export enum FactorId { PHONE = 'phone', + TOTP = 'totp', } /** @@ -596,6 +620,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 d9d2859657..c5ab23b240 100644 --- a/packages/auth/lib/index.js +++ b/packages/auth/lib/index.js @@ -32,6 +32,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'; @@ -65,6 +66,7 @@ export { TwitterAuthProvider, FacebookAuthProvider, PhoneMultiFactorGenerator, + TotpMultiFactorGenerator, OAuthProvider, OIDCAuthProvider, PhoneAuthState, @@ -79,6 +81,7 @@ const statics = { TwitterAuthProvider, FacebookAuthProvider, PhoneMultiFactorGenerator, + TotpMultiFactorGenerator, OAuthProvider, OIDCAuthProvider, PhoneAuthState, @@ -326,6 +329,14 @@ 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 4f6b1a6313..38e1270b73 100644 --- a/packages/auth/lib/multiFactor.js +++ b/packages/auth/lib/multiFactor.js @@ -28,14 +28,24 @@ 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 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); + } } }