From 380a8618823f1fa7440cb30059025710ae1d7e5e Mon Sep 17 00:00:00 2001 From: Liubin Jiang <56564857+Xiaoshouzi-gh@users.noreply.github.com> Date: Tue, 19 Nov 2024 10:15:03 -0800 Subject: [PATCH] [Auth] Add MFA view controller and support MFA on Swift sample app (#14111) Co-authored-by: Nick Cooke <36927374+ncooke3@users.noreply.github.com> --- .../project.pbxproj | 8 +- .../CustomViews/MFALoginView.swift | 189 ++++++++++++++++++ .../Models/OtherAuthMethods.swift | 17 ++ .../Utility/LoginDelegate.swift | 3 +- .../ViewControllers/AuthViewController.swift | 90 +++++---- .../ViewControllers/LoginController.swift | 16 +- .../CustomAuthViewController.swift | 2 +- .../OtherAuthViewController.swift | 4 +- .../PasswordlessViewController.swift | 2 +- .../PhoneAuthViewController.swift | 2 +- 10 files changed, 283 insertions(+), 50 deletions(-) create mode 100644 FirebaseAuth/Tests/SampleSwift/AuthenticationExample/CustomViews/MFALoginView.swift diff --git a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample.xcodeproj/project.pbxproj b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample.xcodeproj/project.pbxproj index 2160a626445..feb55512ba4 100644 --- a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample.xcodeproj/project.pbxproj +++ b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample.xcodeproj/project.pbxproj @@ -50,6 +50,7 @@ EA527CAC24A0EE9600ADB9A2 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA527CAB24A0EE9600ADB9A2 /* LoginView.swift */; }; EAB3A1792494433500385291 /* DataSourceProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAB3A1782494433500385291 /* DataSourceProvider.swift */; }; EAB3A17C2494628200385291 /* UserViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAB3A17B2494628200385291 /* UserViewController.swift */; }; + EAD8BD402CE535C400E23E30 /* MFALoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAD8BD3F2CE535C400E23E30 /* MFALoginView.swift */; }; EAE08EB524CF5D09006FA3A5 /* AccountLinkingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAE08EB424CF5D09006FA3A5 /* AccountLinkingViewController.swift */; }; EAE4CBC524855E3A00245E92 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAE4CBC424855E3A00245E92 /* AppDelegate.swift */; }; EAE4CBC724855E3A00245E92 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAE4CBC624855E3A00245E92 /* SceneDelegate.swift */; }; @@ -128,6 +129,7 @@ EA527CAB24A0EE9600ADB9A2 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = ""; }; EAB3A1782494433500385291 /* DataSourceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataSourceProvider.swift; sourceTree = ""; }; EAB3A17B2494628200385291 /* UserViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserViewController.swift; sourceTree = ""; }; + EAD8BD3F2CE535C400E23E30 /* MFALoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MFALoginView.swift; sourceTree = ""; }; EAE08EB424CF5D09006FA3A5 /* AccountLinkingViewController.swift */ = {isa = PBXFileReference; indentWidth = 2; lastKnownFileType = sourcecode.swift; path = AccountLinkingViewController.swift; sourceTree = ""; }; EAE4CBC124855E3A00245E92 /* AuthenticationExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AuthenticationExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; EAE4CBC424855E3A00245E92 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -244,6 +246,7 @@ EA20B47724973BB100B5E581 /* CustomViews */ = { isa = PBXGroup; children = ( + EAD8BD3F2CE535C400E23E30 /* MFALoginView.swift */, EA20B46B2495A9F900B5E581 /* SignedOutView.swift */, EA527CAB24A0EE9600ADB9A2 /* LoginView.swift */, ); @@ -561,6 +564,7 @@ EA20B510249FDB4400B5E581 /* OtherAuthMethods.swift in Sources */, EA12697F29E33A5D00D79E66 /* CryptoUtils.swift in Sources */, EAEBCE11248A9AA000FCEA92 /* Section.swift in Sources */, + EAD8BD402CE535C400E23E30 /* MFALoginView.swift in Sources */, DEC2E5DF2A9583CA0090260A /* AppManager.swift in Sources */, DEC2E5DD2A95331E0090260A /* SettingsViewController.swift in Sources */, EA20B503249C6C3D00B5E581 /* CustomAuthViewController.swift in Sources */, @@ -789,7 +793,7 @@ CODE_SIGN_STYLE = Manual; DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = AuthenticationExample/SwiftApplication.plist; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -812,7 +816,7 @@ CODE_SIGN_STYLE = Manual; DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = AuthenticationExample/SwiftApplication.plist; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/CustomViews/MFALoginView.swift b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/CustomViews/MFALoginView.swift new file mode 100644 index 00000000000..2f9d64f82c3 --- /dev/null +++ b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/CustomViews/MFALoginView.swift @@ -0,0 +1,189 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file 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 FirebaseAuth +import SwiftUI + +struct MFALoginView: View { + @Environment(\.dismiss) private var dismiss + + @State private var factorSelection: MultiFactorInfo? + // This is only needed for phone MFA. + @State private var verificationId: String? + // This is needed for both phone and TOTP MFA. + @State private var verificationCode: String = "" + + private let resolver: MultiFactorResolver + private weak var delegate: (any LoginDelegate)? + + init(resolver: MultiFactorResolver, delegate: (any LoginDelegate)?) { + self.resolver = resolver + self.delegate = delegate + } + + var body: some View { + Text("Choose a second factor to continue.") + .padding(.top) + List(resolver.hints, id: \.self, selection: $factorSelection) { + Text($0.displayName ?? "No display name provided.") + } + .frame(height: 300) + .clipShape(RoundedRectangle(cornerRadius: 15)) + .padding() + + if let factorSelection { + // TODO(ncooke3): This logic handles both phone and TOTP MFA states. Investigate how to make + // more clear with better APIs. + if factorSelection.factorID == PhoneMultiFactorID, verificationId == nil { + MFAViewButton( + text: "Send Verification Code", + accentColor: .white, + backgroundColor: .orange + ) { + Task { await startMfALogin() } + } + .padding() + } else { + TextField("Enter verification code.", text: $verificationCode) + .textFieldStyle(SymbolTextField(symbolName: "lock.circle.fill")) + .padding() + MFAViewButton( + text: "Sign in", + accentColor: .white, + backgroundColor: .orange + ) { + Task { await finishMfALogin() } + } + .padding() + } + } + Spacer() + } +} + +extension MFALoginView { + private func startMfALogin() async { + guard let factorSelection else { return } + switch factorSelection.factorID { + case PhoneMultiFactorID: + await startPhoneMultiFactorSignIn(hint: factorSelection as? PhoneMultiFactorInfo) + case TOTPMultiFactorID: break // TODO(ncooke3): Indicate to user to get verification code. + default: return + } + } + + private func startPhoneMultiFactorSignIn(hint: PhoneMultiFactorInfo?) async { + guard let hint else { return } + do { + verificationId = try await PhoneAuthProvider.provider().verifyPhoneNumber( + with: hint, + uiDelegate: nil, + multiFactorSession: resolver.session + ) + } catch { + print(error) + } + } + + private func finishMfALogin() async { + guard let factorSelection else { return } + switch factorSelection.factorID { + case PhoneMultiFactorID: + await finishPhoneMultiFactorSignIn() + case TOTPMultiFactorID: + await finishTOTPMultiFactorSignIn(hint: factorSelection) + default: return + } + } + + private func finishPhoneMultiFactorSignIn() async { + guard let verificationId else { return } + let credential = PhoneAuthProvider.provider().credential( + withVerificationID: verificationId, + verificationCode: verificationCode + ) + let assertion = PhoneMultiFactorGenerator.assertion(with: credential) + do { + _ = try await resolver.resolveSignIn(with: assertion) + // MFA login was successful. + await MainActor.run { + dismiss() + delegate?.loginDidOccur(resolver: nil) + } + } catch { + print(error) + } + } + + private func finishTOTPMultiFactorSignIn(hint: MultiFactorInfo) async { + // TODO(ncooke3): Disable button if verification code textfield contents is empty. + guard verificationCode.count > 0 else { return } + let assertion = TOTPMultiFactorGenerator.assertionForSignIn( + withEnrollmentID: hint.uid, + oneTimePassword: verificationCode + ) + do { + _ = try await resolver.resolveSignIn(with: assertion) + // MFA login was successful. + await MainActor.run { + dismiss() + delegate?.loginDidOccur(resolver: nil) + } + } catch { + // Wrong or expired OTP. Re-prompt the user. + // TODO(ncooke3): Show error to user. + print(error) + } + } +} + +private struct MFAViewButton: View { + let text: String + let accentColor: Color + let backgroundColor: Color + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack { + Spacer() + Text(text) + .bold() + .accentColor(accentColor) + Spacer() + } + .padding() + .background(backgroundColor) + .cornerRadius(14) + } + } +} + +private struct SymbolTextField: TextFieldStyle { + let symbolName: String + + func _body(configuration: TextField) -> some View { + HStack { + Image(systemName: symbolName) + .foregroundColor(.orange) + .imageScale(.large) + .padding(.leading) + configuration + .padding([.vertical, .trailing]) + } + .background(Color(uiColor: .secondarySystemBackground)) + .cornerRadius(14) + .textInputAutocapitalization(.never) + } +} diff --git a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/Models/OtherAuthMethods.swift b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/Models/OtherAuthMethods.swift index 5566e79bc8d..ed60efeecad 100644 --- a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/Models/OtherAuthMethods.swift +++ b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/Models/OtherAuthMethods.swift @@ -19,6 +19,7 @@ enum OtherAuthMethod: String { case Passwordless = "Email Link/Passwordless" case PhoneNumber = "Phone Auth" case Custom = "a Custom Auth System" + case MfaLogin = "Multifactor Authentication" var navigationTitle: String { "Sign in using \(rawValue)" } @@ -30,6 +31,8 @@ enum OtherAuthMethod: String { return "Enter Phone Number" case .Custom: return "Enter Custom Auth Token" + case .MfaLogin: + return "Choose a Second Factor to Continue" } } @@ -41,6 +44,8 @@ enum OtherAuthMethod: String { return "phone.circle" case .Custom: return "lock.shield" + case .MfaLogin: + return "phone.circle" } } @@ -48,6 +53,8 @@ enum OtherAuthMethod: String { switch self { case .PhoneNumber: return "Example input for +1 (123)456-7890 would be 11234567890" + case .MfaLogin: + return "Enter the index of the selected factor to continue" default: return nil } @@ -61,6 +68,8 @@ enum OtherAuthMethod: String { return "Send Verification Code" case .Custom: return "Login" + case .MfaLogin: + return "Send Verification Code" } } @@ -72,9 +81,17 @@ enum OtherAuthMethod: String { return phoneNumberInfoText case .Custom: return customAuthInfoText + case .MfaLogin: + return mfaLoginInfoText } } + private var mfaLoginInfoText: String { + """ + MFA placeholder + """ + } + private var passwordlessInfoText: String { """ Authenticate users with only their email, \ diff --git a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/Utility/LoginDelegate.swift b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/Utility/LoginDelegate.swift index d1420d8923a..b76daa2a6a3 100644 --- a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/Utility/LoginDelegate.swift +++ b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/Utility/LoginDelegate.swift @@ -12,9 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. +import FirebaseAuth import Foundation /// Delegate for signaling that a successful login with Firebase Auth has occurred protocol LoginDelegate: NSObject { - func loginDidOccur() + func loginDidOccur(resolver: MultiFactorResolver?) } diff --git a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/AuthViewController.swift b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/AuthViewController.swift index e9567a69e9d..7f8b77856f0 100644 --- a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/AuthViewController.swift +++ b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/AuthViewController.swift @@ -27,6 +27,8 @@ import GameKit import GoogleSignIn import UIKit +import SwiftUI + // For Sign in with Apple import AuthenticationServices import CryptoKit @@ -202,54 +204,50 @@ class AuthViewController: UIViewController, DataSourceProviderDelegate { // [END_EXCLUDE] let config = GIDConfiguration(clientID: clientID) GIDSignIn.sharedInstance.configuration = config + Task { + do { + let result = try await GIDSignIn.sharedInstance.signIn(withPresenting: self) + let user = result.user + guard let idToken = user.idToken?.tokenString + else { + // [START_EXCLUDE] + let error = NSError( + domain: "GIDSignInError", + code: -1, + userInfo: [ + NSLocalizedDescriptionKey: "Unexpected sign in result: required authentication data is missing.", + ] + ) + return displayError(error) + // [END_EXCLUDE] + } + let credential = GoogleAuthProvider.credential(withIDToken: idToken, + accessToken: user.accessToken.tokenString) + try await signIn(with: credential) - // Start the sign in flow! - GIDSignIn.sharedInstance.signIn(withPresenting: self) { [unowned self] result, error in - guard error == nil else { - // [START_EXCLUDE] - return displayError(error) - // [END_EXCLUDE] - } - - guard let user = result?.user, - let idToken = user.idToken?.tokenString - else { - // [START_EXCLUDE] - let error = NSError( - domain: "GIDSignInError", - code: -1, - userInfo: [ - NSLocalizedDescriptionKey: "Unexpected sign in result: required authentication data is missing.", - ] - ) + } catch { return displayError(error) - // [END_EXCLUDE] } - let credential = GoogleAuthProvider.credential(withIDToken: idToken, - accessToken: user.accessToken.tokenString) - - // [START_EXCLUDE] - signIn(with: credential) // [END_EXCLUDE] } // [END headless_google_auth] } - func signIn(with credential: AuthCredential) { - // [START signin_google_credential] - AppManager.shared.auth().signIn(with: credential) { result, error in - // [START_EXCLUDE silent] - guard error == nil else { return self.displayError(error) } - // [END_EXCLUDE] - - // At this point, our user is signed in - // [START_EXCLUDE silent] - // so we advance to the User View Controller - self.transitionToUserViewController() - // [END_EXCLUDE] + func signIn(with credential: AuthCredential) async throws { + do { + _ = try await AppManager.shared.auth().signIn(with: credential) + transitionToUserViewController() + } catch { + let authError = error as NSError + if authError.code == AuthErrorCode.secondFactorRequired.rawValue { + let resolver = authError + .userInfo[AuthErrorUserInfoMultiFactorResolverKey] as! MultiFactorResolver + performMfaLoginFlow(resolver: resolver) + } else { + return displayError(error) + } } - // [END signin_google_credential] } // For Sign in with Apple @@ -358,6 +356,14 @@ class AuthViewController: UIViewController, DataSourceProviderDelegate { navigationController?.present(navPhoneAuthController, animated: true) } + private func performMfaLoginFlow(resolver: MultiFactorResolver) { + let mfaLoginController = UIHostingController(rootView: MFALoginView( + resolver: resolver, + delegate: self + )) + present(mfaLoginController, animated: true) + } + private func performAnonymousLoginFlow() { AppManager.shared.auth().signInAnonymously { result, error in guard error == nil else { return self.displayError(error) } @@ -1064,8 +1070,12 @@ class AuthViewController: UIViewController, DataSourceProviderDelegate { // MARK: - LoginDelegate extension AuthViewController: LoginDelegate { - public func loginDidOccur() { - transitionToUserViewController() + public func loginDidOccur(resolver: MultiFactorResolver?) { + if let resolver { + performMfaLoginFlow(resolver: resolver) + } else { + transitionToUserViewController() + } } } diff --git a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/LoginController.swift b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/LoginController.swift index 1ca3c9df197..36befea2bfd 100644 --- a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/LoginController.swift +++ b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/LoginController.swift @@ -64,15 +64,25 @@ class LoginController: UIViewController { private func login(with email: String, password: String) { AppManager.shared.auth().signIn(withEmail: email, password: password) { result, error in - guard error == nil else { return self.displayError(error) } - self.delegate?.loginDidOccur() + if let error { + let authError = error as NSError + if authError.code == AuthErrorCode.secondFactorRequired.rawValue { + let resolver = authError + .userInfo[AuthErrorUserInfoMultiFactorResolverKey] as! MultiFactorResolver + self.delegate?.loginDidOccur(resolver: resolver) + } else { + self.displayError(error) + } + } else { + self.delegate?.loginDidOccur(resolver: nil) + } } } private func createUser(email: String, password: String) { AppManager.shared.auth().createUser(withEmail: email, password: password) { authResult, error in guard error == nil else { return self.displayError(error) } - self.delegate?.loginDidOccur() + self.delegate?.loginDidOccur(resolver: nil) } } diff --git a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/OtherAuthMethodControllers/CustomAuthViewController.swift b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/OtherAuthMethodControllers/CustomAuthViewController.swift index da4248447de..0787a32447d 100644 --- a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/OtherAuthMethodControllers/CustomAuthViewController.swift +++ b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/OtherAuthMethodControllers/CustomAuthViewController.swift @@ -32,7 +32,7 @@ class CustomAuthViewController: OtherAuthViewController { AppManager.shared.auth().signIn(withCustomToken: token) { result, error in guard error == nil else { return self.displayError(error) } self.navigationController?.dismiss(animated: true, completion: { - self.delegate?.loginDidOccur() + self.delegate?.loginDidOccur(resolver: nil) }) } } diff --git a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/OtherAuthMethodControllers/OtherAuthViewController.swift b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/OtherAuthMethodControllers/OtherAuthViewController.swift index 846e1f39d2d..dd2f2a60eb0 100644 --- a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/OtherAuthMethodControllers/OtherAuthViewController.swift +++ b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/OtherAuthMethodControllers/OtherAuthViewController.swift @@ -27,7 +27,7 @@ class OtherAuthViewController: UIViewController { return textField }() - private var textFieldInputLabel: UILabel? + var textFieldInputLabel: UILabel? private lazy var button: UIButton = { let button = UIButton() @@ -113,6 +113,8 @@ class OtherAuthViewController: UIViewController { let label = UILabel() label.font = .systemFont(ofSize: 12) label.textColor = .secondaryLabel + // unlimited line breaks + label.numberOfLines = 0 label.text = text label.alpha = UIDevice.current.orientation.isLandscape ? 0 : 1 label.translatesAutoresizingMaskIntoConstraints = false diff --git a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/OtherAuthMethodControllers/PasswordlessViewController.swift b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/OtherAuthMethodControllers/PasswordlessViewController.swift index d710f323a6a..58609b9d925 100644 --- a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/OtherAuthMethodControllers/PasswordlessViewController.swift +++ b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/OtherAuthMethodControllers/PasswordlessViewController.swift @@ -67,7 +67,7 @@ class PasswordlessViewController: OtherAuthViewController { print("User verified with passwordless email.") self.navigationController?.dismiss(animated: true) { - self.delegate?.loginDidOccur() + self.delegate?.loginDidOccur(resolver: nil) } } else { print("User could not be verified by passwordless email") diff --git a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/OtherAuthMethodControllers/PhoneAuthViewController.swift b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/OtherAuthMethodControllers/PhoneAuthViewController.swift index a4e19077aad..f1a29ded31d 100644 --- a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/OtherAuthMethodControllers/PhoneAuthViewController.swift +++ b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/OtherAuthMethodControllers/PhoneAuthViewController.swift @@ -47,7 +47,7 @@ class PhoneAuthViewController: OtherAuthViewController { AppManager.shared.auth().signIn(with: credential) { result, error in guard error == nil else { return self.displayError(error) } self.navigationController?.dismiss(animated: true, completion: { - self.delegate?.loginDidOccur() + self.delegate?.loginDidOccur(resolver: nil) }) } }