diff --git a/Authorization/Authorization/Presentation/AuthorizationAnalytics.swift b/Authorization/Authorization/Presentation/AuthorizationAnalytics.swift index 6c7a60393..267a7da69 100644 --- a/Authorization/Authorization/Presentation/AuthorizationAnalytics.swift +++ b/Authorization/Authorization/Presentation/AuthorizationAnalytics.swift @@ -12,6 +12,7 @@ public enum LoginMethod: String { case facebook = "Facebook" case google = "Google" case microsoft = "Microsoft" + case oauth2 = "Custom OAuth2" } //sourcery: AutoMockable diff --git a/Authorization/Authorization/Presentation/Login/SignInView.swift b/Authorization/Authorization/Presentation/Login/SignInView.swift index 98fc3305a..32d99fdef 100644 --- a/Authorization/Authorization/Presentation/Login/SignInView.swift +++ b/Authorization/Authorization/Presentation/Login/SignInView.swift @@ -16,137 +16,170 @@ public struct SignInView: View { @ObservedObject private var viewModel: SignInViewModel - public init(viewModel: SignInViewModel) { + private var navigationController: UINavigationController + + public init(viewModel: SignInViewModel, navigationController: UINavigationController) { self.viewModel = viewModel + self.navigationController = navigationController + } + + public func webLogin() async { + await viewModel.login(viewController: self.navigationController) } public var body: some View { - ZStack(alignment: .top) { - VStack { - CoreAssets.authBackground.swiftUIImage - .resizable() - .edgesIgnoringSafeArea(.top) - }.frame(maxWidth: .infinity, maxHeight: 200) - - VStack(alignment: .center) { - CoreAssets.appLogo.swiftUIImage - .resizable() - .frame(maxWidth: 189, maxHeight: 54) - .padding(.vertical, 40) + if viewModel.forceWebLogin { + // On first load, we should bring the user right to the web login + // interface rather than showing this view. + // + // If that login fails or the user escapes back, they'll be brought + // back to the view where any error message will be shown. + Task { + await webLogin() + } + } + return ZStack(alignment: .top) { + if viewModel.forceWebLogin { + Text("") + // Is there an idiomatic way of doing an early return here + // rather than using this big indented else clause? + // Using a return statement seems to break whatever magic + // happens here. + } else { + VStack { + CoreAssets.authBackground.swiftUIImage + .resizable() + .edgesIgnoringSafeArea(.top) + }.frame(maxWidth: .infinity, maxHeight: 200) - ScrollView { - VStack { - VStack(alignment: .leading) { - Text(AuthLocalization.SignIn.logInTitle) - .font(Theme.Fonts.displaySmall) - .foregroundColor(CoreAssets.textPrimary.swiftUIColor) - .padding(.bottom, 4) - Text(AuthLocalization.SignIn.welcomeBack) - .font(Theme.Fonts.titleSmall) - .foregroundColor(CoreAssets.textPrimary.swiftUIColor) - .padding(.bottom, 20) - - Text(AuthLocalization.SignIn.email) - .font(Theme.Fonts.labelLarge) - .foregroundColor(CoreAssets.textPrimary.swiftUIColor) - TextField(AuthLocalization.SignIn.email, text: $email) - .keyboardType(.emailAddress) - .textContentType(.emailAddress) - .autocapitalization(.none) - .autocorrectionDisabled() - .padding(.all, 14) - .background( - Theme.Shapes.textInputShape - .fill(CoreAssets.textInputBackground.swiftUIColor) - ) - .overlay( - Theme.Shapes.textInputShape - .stroke(lineWidth: 1) - .fill(CoreAssets.textInputStroke.swiftUIColor) - ) - - Text(AuthLocalization.SignIn.password) - .font(Theme.Fonts.labelLarge) - .foregroundColor(CoreAssets.textPrimary.swiftUIColor) - .padding(.top, 18) - SecureField(AuthLocalization.SignIn.password, text: $password) - .padding(.all, 14) - .background( - Theme.Shapes.textInputShape - .fill(CoreAssets.textInputBackground.swiftUIColor) - ) - .overlay( - Theme.Shapes.textInputShape - .stroke(lineWidth: 1) - .fill(CoreAssets.textInputStroke.swiftUIColor) - ) - - HStack { - Button(AuthLocalization.SignIn.registerBtn) { - viewModel.analytics.signUpClicked() - viewModel.router.showRegisterScreen() - }.foregroundColor(CoreAssets.accentColor.swiftUIColor) - - Spacer() - - Button(AuthLocalization.SignIn.forgotPassBtn) { - viewModel.analytics.forgotPasswordClicked() - viewModel.router.showForgotPasswordScreen() - }.foregroundColor(CoreAssets.accentColor.swiftUIColor) - } - .padding(.top, 10) - if viewModel.isShowProgress { - HStack(alignment: .center) { - ProgressBar(size: 40, lineWidth: 8) - .padding(20) - }.frame(maxWidth: .infinity) - } else { - StyledButton(AuthLocalization.SignIn.logInBtn) { - Task { - await viewModel.login(username: email, password: password) + VStack(alignment: .center) { + CoreAssets.appLogo.swiftUIImage + .resizable() + .frame(maxWidth: 189, maxHeight: 54) + .padding(.vertical, 40) + + ScrollView { + VStack { + VStack(alignment: .leading) { + Text(AuthLocalization.SignIn.logInTitle) + .font(Theme.Fonts.displaySmall) + .foregroundColor(CoreAssets.textPrimary.swiftUIColor) + .padding(.bottom, 4) + Text(AuthLocalization.SignIn.welcomeBack) + .font(Theme.Fonts.titleSmall) + .foregroundColor(CoreAssets.textPrimary.swiftUIColor) + .padding(.bottom, 20) + if viewModel.config.webLogin { + StyledButton(AuthLocalization.SignIn.logInBtn) { + Task { + await self.webLogin() + } + }.frame(maxWidth: .infinity) + .padding(.top, 40) + } else { + Text(AuthLocalization.SignIn.email) + .font(Theme.Fonts.labelLarge) + .foregroundColor(CoreAssets.textPrimary.swiftUIColor) + TextField(AuthLocalization.SignIn.email, text: $email) + .keyboardType(.emailAddress) + .textContentType(.emailAddress) + .autocapitalization(.none) + .autocorrectionDisabled() + .padding(.all, 14) + .background( + Theme.Shapes.textInputShape + .fill(CoreAssets.textInputBackground.swiftUIColor) + ) + .overlay( + Theme.Shapes.textInputShape + .stroke(lineWidth: 1) + .fill(CoreAssets.textInputStroke.swiftUIColor) + ) + + Text(AuthLocalization.SignIn.password) + .font(Theme.Fonts.labelLarge) + .foregroundColor(CoreAssets.textPrimary.swiftUIColor) + .padding(.top, 18) + SecureField(AuthLocalization.SignIn.password, text: $password) + .padding(.all, 14) + .background( + Theme.Shapes.textInputShape + .fill(CoreAssets.textInputBackground.swiftUIColor) + ) + .overlay( + Theme.Shapes.textInputShape + .stroke(lineWidth: 1) + .fill(CoreAssets.textInputStroke.swiftUIColor) + ) + + HStack { + Button(AuthLocalization.SignIn.registerBtn) { + viewModel.analytics.signUpClicked() + viewModel.router.showRegisterScreen() + }.foregroundColor(CoreAssets.accentColor.swiftUIColor) + + Spacer() + + Button(AuthLocalization.SignIn.forgotPassBtn) { + viewModel.analytics.forgotPasswordClicked() + viewModel.router.showForgotPasswordScreen() + }.foregroundColor(CoreAssets.accentColor.swiftUIColor) } - }.frame(maxWidth: .infinity) - .padding(.top, 40) + .padding(.top, 10) + } + if viewModel.isShowProgress { + HStack(alignment: .center) { + ProgressBar(size: 40, lineWidth: 8) + .padding(20) + }.frame(maxWidth: .infinity) + } else if !viewModel.config.webLogin { + StyledButton(AuthLocalization.SignIn.logInBtn) { + Task { + await viewModel.login(username: email, password: password) + } + }.frame(maxWidth: .infinity) + .padding(.top, 40) + } } + Spacer() } - Spacer() - } - .padding(.horizontal, 24) - .padding(.top, 50) - }.roundedBackground(CoreAssets.background.swiftUIColor) - .scrollAvoidKeyboard(dismissKeyboardByTap: true) - - } - - // MARK: - Alert - if viewModel.showAlert { - VStack { - Text(viewModel.alertMessage ?? "") - .shadowCardStyle(bgColor: CoreAssets.accentColor.swiftUIColor, - textColor: .white) - .padding(.top, 80) - Spacer() + .padding(.horizontal, 24) + .padding(.top, 50) + }.roundedBackground(CoreAssets.background.swiftUIColor) + .scrollAvoidKeyboard(dismissKeyboardByTap: true) } - .transition(.move(edge: .top)) - .onAppear { - doAfter(Theme.Timeout.snackbarMessageLongTimeout) { - viewModel.alertMessage = nil + + // MARK: - Alert + if viewModel.showAlert { + VStack { + Text(viewModel.alertMessage ?? "") + .shadowCardStyle(bgColor: CoreAssets.accentColor.swiftUIColor, + textColor: .white) + .padding(.top, 80) + Spacer() + } - } - } - - // MARK: - Show error - if viewModel.showError { - VStack { - Spacer() - SnackBarView(message: viewModel.errorMessage) - }.transition(.move(edge: .bottom)) + .transition(.move(edge: .top)) .onAppear { doAfter(Theme.Timeout.snackbarMessageLongTimeout) { - viewModel.errorMessage = nil + viewModel.alertMessage = nil } } + } + + // MARK: - Show error + if viewModel.showError { + VStack { + Spacer() + SnackBarView(message: viewModel.errorMessage) + }.transition(.move(edge: .bottom)) + .onAppear { + doAfter(Theme.Timeout.snackbarMessageLongTimeout) { + viewModel.errorMessage = nil + } + } + } } } .background(CoreAssets.background.swiftUIColor.ignoresSafeArea(.all)) @@ -160,15 +193,16 @@ struct SignInView_Previews: PreviewProvider { interactor: AuthInteractor.mock, router: AuthorizationRouterMock(), analytics: AuthorizationAnalyticsMock(), + config: ConfigMock(), validator: Validator() ) - SignInView(viewModel: vm) + SignInView(viewModel: vm, navigationController: UINavigationController()) .preferredColorScheme(.light) .previewDisplayName("SignInView Light") .loadFonts() - SignInView(viewModel: vm) + SignInView(viewModel: vm, navigationController: UINavigationController()) .preferredColorScheme(.dark) .previewDisplayName("SignInView Dark") .loadFonts() diff --git a/Authorization/Authorization/Presentation/Login/SignInViewModel.swift b/Authorization/Authorization/Presentation/Login/SignInViewModel.swift index 91a136958..b3639f66c 100644 --- a/Authorization/Authorization/Presentation/Login/SignInViewModel.swift +++ b/Authorization/Authorization/Presentation/Login/SignInViewModel.swift @@ -9,12 +9,33 @@ import Foundation import Core import SwiftUI import Alamofire +import OAuthSwift +import SafariServices + +private class WebLoginSafariDelegate: NSObject, SFSafariViewControllerDelegate { + private let viewModel: SignInViewModel + public init(viewModel: SignInViewModel) { + self.viewModel = viewModel + } + func safariViewControllerDidFinish(_ controller: SFSafariViewController) { + /* Called when the 'Done' button is hit on the Safari Web view. In this case, + authentication would neither have failed nor succeeded, but we'd be back + at the SignInView. So, we make sure we mark it as attempted so the UI + renders. */ + self.viewModel.markAttempted() + } +} public class SignInViewModel: ObservableObject { @Published private(set) var isShowProgress = false @Published private(set) var showError: Bool = false @Published private(set) var showAlert: Bool = false + @Published private(set) var webLoginAttempted: Bool = false + + var forceWebLogin: Bool { + return config.webLogin && !webLoginAttempted + } var errorMessage: String? { didSet { withAnimation { @@ -29,20 +50,78 @@ public class SignInViewModel: ObservableObject { } } } + var oauthswift: OAuth2Swift? private let interactor: AuthInteractorProtocol let router: AuthorizationRouter + let config: Config let analytics: AuthorizationAnalytics private let validator: Validator + private var safariDelegate: WebLoginSafariDelegate? public init(interactor: AuthInteractorProtocol, router: AuthorizationRouter, analytics: AuthorizationAnalytics, + config: Config, validator: Validator) { self.interactor = interactor self.router = router self.analytics = analytics + self.config = config self.validator = validator + self.webLoginAttempted = false + } + + @MainActor + func login(viewController: UIViewController) async { + /* OAuth web login. Used when we cannot use the built-in login form, + but need to let the LMS redirect us to the authentication provider. + + An example service where this is needed is something like Auth0, which + redirects from the LMS to its own login page. That login page then redirects + back to the LMS for the issuance of a token that can be used for making + requests to the LMS, and then back to the redirect URL for the app. */ + self.safariDelegate = WebLoginSafariDelegate(viewModel: self) + oauthswift = OAuth2Swift( + consumerKey: config.oAuthClientId, + consumerSecret: "", // No secret required + authorizeUrl: "\(config.baseURL)/oauth2/authorize/", + accessTokenUrl: "\(config.baseURL)/oauth2/access_token/", + responseType: "code" + ) + + oauthswift!.allowMissingStateCheck = true + let handler = SafariURLHandler( + viewController: viewController, oauthSwift: oauthswift! + ) + handler.delegate = self.safariDelegate + oauthswift!.authorizeURLHandler = handler + + // Trigger OAuth2 dance + guard let rwURL = URL(string: "\(Bundle.main.bundleIdentifier ?? "")://oauth2Callback") else { return } + oauthswift!.authorize(withCallbackURL: rwURL, scope: "", state: "") { result in + switch result { + case .success(let (credential, _, _)): + Task { + self.webLoginAttempted = true + let user = try await self.interactor.login(credential: credential) + self.analytics.setUserID("\(user.id)") + self.analytics.userLogin(method: .oauth2) + self.router.showMainScreen() + } + // Do your request + case .failure(let error): + self.webLoginAttempted = true + self.isShowProgress = false + self.errorMessage = error.localizedDescription + } + } + } + + public func markAttempted() { + // Hack to get around published observables limitation when handing this model over + // to an outside object. Is there a better way to do this? + self.webLoginAttempted = true } @MainActor diff --git a/Core/Core/Configuration/Config.swift b/Core/Core/Configuration/Config.swift index 7f49fac57..286b27077 100644 --- a/Core/Core/Configuration/Config.swift +++ b/Core/Core/Configuration/Config.swift @@ -11,6 +11,7 @@ public class Config { public let baseURL: URL public let oAuthClientId: String + public let webLogin: Bool public lazy var termsOfUse: URL? = { URL(string: "\(baseURL.description)/tos") @@ -22,12 +23,13 @@ public class Config { public let feedbackEmail = "support@example.com" - public init(baseURL: String, oAuthClientId: String) { + public init(baseURL: String, oAuthClientId: String, webLogin: Bool) { guard let url = URL(string: baseURL) else { fatalError("Ivalid baseURL") } self.baseURL = url self.oAuthClientId = oAuthClientId + self.webLogin = webLogin } } @@ -35,7 +37,7 @@ public class Config { #if DEBUG public class ConfigMock: Config { public convenience init() { - self.init(baseURL: "https://google.com/", oAuthClientId: "client_id") + self.init(baseURL: "https://google.com/", oAuthClientId: "client_id", webLogin: false) } } #endif diff --git a/Core/Core/Data/Repository/AuthRepository.swift b/Core/Core/Data/Repository/AuthRepository.swift index e945c8ea4..ff91f376b 100644 --- a/Core/Core/Data/Repository/AuthRepository.swift +++ b/Core/Core/Data/Repository/AuthRepository.swift @@ -6,8 +6,10 @@ // import Foundation +import OAuthSwift public protocol AuthRepositoryProtocol { + func login(credential: OAuthSwiftCredential) async throws -> User func login(username: String, password: String) async throws -> User func getCookies(force: Bool) async throws func getRegistrationFields() async throws -> [PickerFields] @@ -28,6 +30,17 @@ public class AuthRepository: AuthRepositoryProtocol { self.config = config } + public func login(credential: OAuthSwiftCredential) async throws -> User { + // Login for when we have the accessToken and refreshToken directly, like from web-view + // OAuth logins. + appStorage.cookiesDate = nil + appStorage.accessToken = credential.oauthToken + appStorage.refreshToken = credential.oauthRefreshToken + let user = try await api.requestData(AuthEndpoint.getUserInfo).mapResponse(DataLayer.User.self) + appStorage.user = user + return user.domain + } + public func login(username: String, password: String) async throws -> User { appStorage.cookiesDate = nil let endPoint = AuthEndpoint.getAccessToken( @@ -100,6 +113,11 @@ public class AuthRepository: AuthRepositoryProtocol { // Mark - For testing and SwiftUI preview #if DEBUG class AuthRepositoryMock: AuthRepositoryProtocol { + + func login(credential: OAuthSwiftCredential) async throws -> User { + User(id: 1, username: "User", email: "email@gmail.com", name: "User Name", userAvatar: "") + } + func login(username: String, password: String) async throws -> User { User(id: 1, username: "User", email: "email@gmail.com", name: "User Name", userAvatar: "") } diff --git a/Core/Core/Domain/AuthInteractor.swift b/Core/Core/Domain/AuthInteractor.swift index 94e202364..beb9f6688 100644 --- a/Core/Core/Domain/AuthInteractor.swift +++ b/Core/Core/Domain/AuthInteractor.swift @@ -6,10 +6,12 @@ // import Foundation +import OAuthSwift //sourcery: AutoMockable public protocol AuthInteractorProtocol { @discardableResult + func login(credential: OAuthSwiftCredential) async throws -> User func login(username: String, password: String) async throws -> User func resetPassword(email: String) async throws -> ResetPassword func getCookies(force: Bool) async throws @@ -26,6 +28,11 @@ public class AuthInteractor: AuthInteractorProtocol { self.repository = repository } + @discardableResult + public func login(credential: OAuthSwiftCredential) async throws -> User { + return try await repository.login(credential: credential) + } + @discardableResult public func login(username: String, password: String) async throws -> User { return try await repository.login(username: username, password: password) diff --git a/OpenEdX/AppDelegate.swift b/OpenEdX/AppDelegate.swift index 931f65420..790350114 100644 --- a/OpenEdX/AppDelegate.swift +++ b/OpenEdX/AppDelegate.swift @@ -12,6 +12,7 @@ import FirebaseCore import FirebaseAnalytics import FirebaseCrashlytics import Profile +import OAuthSwift @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { @@ -55,6 +56,15 @@ class AppDelegate: UIResponder, UIApplicationDelegate { return true } + func application( + _ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:] + ) -> Bool { + if url.host == "oauth2Callback" { + OAuthSwift.handle(url: url) + } + return true + } + func application( _ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow? diff --git a/OpenEdX/DI/AppAssembly.swift b/OpenEdX/DI/AppAssembly.swift index 2e1dabda0..f654e5567 100644 --- a/OpenEdX/DI/AppAssembly.swift +++ b/OpenEdX/DI/AppAssembly.swift @@ -105,7 +105,11 @@ class AppAssembly: Assembly { }.inObjectScope(.container) container.register(Config.self) { _ in - Config(baseURL: BuildConfiguration.shared.baseURL, oAuthClientId: BuildConfiguration.shared.clientId) + Config( + baseURL: BuildConfiguration.shared.baseURL, + oAuthClientId: BuildConfiguration.shared.clientId, + webLogin: BuildConfiguration.shared.webLogin + ) }.inObjectScope(.container) container.register(CSSInjector.self) { _ in diff --git a/OpenEdX/DI/ScreenAssembly.swift b/OpenEdX/DI/ScreenAssembly.swift index 4e3d37103..9792045a4 100644 --- a/OpenEdX/DI/ScreenAssembly.swift +++ b/OpenEdX/DI/ScreenAssembly.swift @@ -48,6 +48,7 @@ class ScreenAssembly: Assembly { interactor: r.resolve(AuthInteractorProtocol.self)!, router: r.resolve(AuthorizationRouter.self)!, analytics: r.resolve(AuthorizationAnalytics.self)!, + config: r.resolve(Config.self)!, validator: r.resolve(Validator.self)! ) } diff --git a/OpenEdX/Environment.swift b/OpenEdX/Environment.swift index e89c0bb88..88b3b0d66 100644 --- a/OpenEdX/Environment.swift +++ b/OpenEdX/Environment.swift @@ -46,6 +46,23 @@ class BuildConfiguration { return "PROD_CLIENT_ID" } } + + /* Set this to true if you are using an authentication provider that + requires your learners to visit their login page. In this case, + the existing app interface for login will be ignored, and the + learner will be directed to a web view bringing up the LMS's login + flow, redirecting to your provider as needed. + + Note that in order for this to work, you must add a redirect URL in + your OAuth2 app settings that matches the URI + com.bundle.app://oauth2Callback where com.bundle.app is your app + bundle name. You must also set your Django settings in Open edX to + allow for your bundle name as a protocol for redirects. This setting + can be found within the OAUTH2_PROVIDER dictionary in your settings. + The key, ALLOWED_REDIRECT_URI_SCHEMES, should be set to something + like ['https', 'com.bundle.app'], again, where com.bundle.app is the + bundle name for your app. */ + var webLogin: Bool = false var firebaseOptions: FirebaseOptions { switch environment { diff --git a/OpenEdX/Info.plist b/OpenEdX/Info.plist index b94522839..9b48e2135 100644 --- a/OpenEdX/Info.plist +++ b/OpenEdX/Info.plist @@ -2,6 +2,19 @@ + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + + CFBundleURLSchemes + + ${PRODUCT_BUNDLE_IDENTIFIER} + + + Configuration $(CONFIGURATION) FirebaseAppDelegateProxyEnabled diff --git a/OpenEdX/RouteController.swift b/OpenEdX/RouteController.swift index a4264ca0a..413b3e72e 100644 --- a/OpenEdX/RouteController.swift +++ b/OpenEdX/RouteController.swift @@ -40,7 +40,7 @@ class RouteController: UIViewController { private func showAuthorization() { let controller = SwiftUIHostController( - view: SignInView(viewModel: diContainer.resolve(SignInViewModel.self)!) + view: SignInView(viewModel: diContainer.resolve(SignInViewModel.self)!, navigationController: self.navigation) ) navigation.viewControllers = [controller] present(navigation, animated: false) diff --git a/OpenEdX/Router.swift b/OpenEdX/Router.swift index 0e7e332cb..a161b5407 100644 --- a/OpenEdX/Router.swift +++ b/OpenEdX/Router.swift @@ -62,7 +62,10 @@ public class Router: AuthorizationRouter, } public func showLoginScreen() { - let view = SignInView(viewModel: Container.shared.resolve(SignInViewModel.self)!) + let view = SignInView( + viewModel: Container.shared.resolve(SignInViewModel.self)!, + navigationController: self.navigationController + ) let controller = SwiftUIHostController(view: view) navigationController.setViewControllers([controller], animated: false) } diff --git a/Podfile b/Podfile index 1d97fa467..2ca696069 100644 --- a/Podfile +++ b/Podfile @@ -28,6 +28,7 @@ abstract_target "App" do pod 'SwiftUIIntrospect', '~> 0.8' pod 'Kingfisher', '~> 7.8' pod 'Swinject', '2.8.3' + pod 'OAuthSwift', '~> 2.2.0' end target "Authorization" do diff --git a/Podfile.lock b/Podfile.lock index 5b998cd5e..68fc47fda 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -97,6 +97,7 @@ PODS: - nanopb/encode (= 2.30909.0) - nanopb/decode (2.30909.0) - nanopb/encode (2.30909.0) + - OAuthSwift (2.2.0) - PromisesObjC (2.2.0) - PromisesSwift (2.2.0): - PromisesObjC (= 2.2.0) @@ -116,6 +117,7 @@ DEPENDENCIES: - FirebaseCrashlytics (~> 10.11) - KeychainSwift (~> 20.0) - Kingfisher (~> 7.8) + - OAuthSwift (~> 2.2.0) - SwiftGen (~> 6.6) - SwiftLint (~> 0.5) - SwiftUIIntrospect (~> 0.8) @@ -138,6 +140,7 @@ SPEC REPOS: - KeychainSwift - Kingfisher - nanopb + - OAuthSwift - PromisesObjC - PromisesSwift - Sourcery @@ -171,6 +174,7 @@ SPEC CHECKSUMS: KeychainSwift: 0ce6a4d13f7228054d1a71bb1b500448fb2ab837 Kingfisher: 63f677311d36a3473f6b978584f8a3845d023dc5 nanopb: b552cce312b6c8484180ef47159bc0f65a1f0431 + OAuthSwift: 75efbb5bd9a4b2b71a37bd7e986bf3f55ddd54c6 PromisesObjC: 09985d6d70fbe7878040aa746d78236e6946d2ef PromisesSwift: cf9eb58666a43bbe007302226e510b16c1e10959 Sourcery: 6f5fe49b82b7e02e8c65560cbd52e1be67a1af2e @@ -180,6 +184,6 @@ SPEC CHECKSUMS: SwiftyMocky: c5e96e4ff76ec6dbf5a5941aeb039b5a546954a0 Swinject: 893c9a543000ac2f10ee4cbaf0933c6992c935d5 -PODFILE CHECKSUM: 1639b311802f5d36686512914067b7221ff97a64 +PODFILE CHECKSUM: 7f904e49661dbca074f48a4829503e048cce8fbb COCOAPODS: 1.12.1