diff --git a/Authorization/Authorization.xcodeproj/project.pbxproj b/Authorization/Authorization.xcodeproj/project.pbxproj index acde4b3e3..e3073af94 100644 --- a/Authorization/Authorization.xcodeproj/project.pbxproj +++ b/Authorization/Authorization.xcodeproj/project.pbxproj @@ -26,6 +26,10 @@ 0770DE6B28D0C035006D8A5D /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 0770DE6D28D0C035006D8A5D /* Localizable.strings */; }; 0770DE7128D0C0E7006D8A5D /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0770DE7028D0C0E7006D8A5D /* Strings.swift */; }; 5FB79D2802949372CDAF08D6 /* Pods_App_Authorization_AuthorizationTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4FAE9B7FD61FF88C9C4FE1E8 /* Pods_App_Authorization_AuthorizationTests.framework */; }; + 99C1654B2C0C4F0600DC384D /* ContainerWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99C1654A2C0C4F0600DC384D /* ContainerWebView.swift */; }; + 99C1654D2C0C4F2F00DC384D /* SSOHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99C1654C2C0C4F2F00DC384D /* SSOHelper.swift */; }; + 99C1654F2C0C4F5900DC384D /* SSOWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99C1654E2C0C4F5900DC384D /* SSOWebView.swift */; }; + 99C165512C0C4F7B00DC384D /* SSOWebViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99C165502C0C4F7B00DC384D /* SSOWebViewModel.swift */; }; BA8B3A322AD5487300D25EF5 /* SocialAuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA8B3A312AD5487300D25EF5 /* SocialAuthView.swift */; }; BADB3F552AD6DFC3004D5CFA /* SocialAuthViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BADB3F542AD6DFC3004D5CFA /* SocialAuthViewModel.swift */; }; DE843D6BB1B9DDA398494890 /* Pods_App_Authorization.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 47BCFB7C19382EECF15131B6 /* Pods_App_Authorization.framework */; }; @@ -77,6 +81,10 @@ 7A84BB166492D4E46FBCF01C /* Pods-App-Authorization-AuthorizationTests.debugdev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Authorization-AuthorizationTests.debugdev.xcconfig"; path = "Target Support Files/Pods-App-Authorization-AuthorizationTests/Pods-App-Authorization-AuthorizationTests.debugdev.xcconfig"; sourceTree = ""; }; 90DFBB75EF40580E180D71C8 /* Pods-App-Authorization.debugdev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Authorization.debugdev.xcconfig"; path = "Target Support Files/Pods-App-Authorization/Pods-App-Authorization.debugdev.xcconfig"; sourceTree = ""; }; 96C85172770225EB81A6D2DA /* Pods-App-Authorization.releasedev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Authorization.releasedev.xcconfig"; path = "Target Support Files/Pods-App-Authorization/Pods-App-Authorization.releasedev.xcconfig"; sourceTree = ""; }; + 99C1654A2C0C4F0600DC384D /* ContainerWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainerWebView.swift; sourceTree = ""; }; + 99C1654C2C0C4F2F00DC384D /* SSOHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSOHelper.swift; sourceTree = ""; }; + 99C1654E2C0C4F5900DC384D /* SSOWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSOWebView.swift; sourceTree = ""; }; + 99C165502C0C4F7B00DC384D /* SSOWebViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSOWebViewModel.swift; sourceTree = ""; }; 9BF6A1004A955E24527FCF0F /* Pods-App-Authorization-AuthorizationTests.releaseprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Authorization-AuthorizationTests.releaseprod.xcconfig"; path = "Target Support Files/Pods-App-Authorization-AuthorizationTests/Pods-App-Authorization-AuthorizationTests.releaseprod.xcconfig"; sourceTree = ""; }; A99D45203C981893C104053A /* Pods-App-Authorization-AuthorizationTests.releasestage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Authorization-AuthorizationTests.releasestage.xcconfig"; path = "Target Support Files/Pods-App-Authorization-AuthorizationTests/Pods-App-Authorization-AuthorizationTests.releasestage.xcconfig"; sourceTree = ""; }; BA8B3A312AD5487300D25EF5 /* SocialAuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocialAuthView.swift; sourceTree = ""; }; @@ -147,6 +155,7 @@ 071009CC28D1E24000344290 /* Presentation */ = { isa = PBXGroup; children = ( + 99C165492C0C4EF000DC384D /* SSO */, BA8B3A302AD5485100D25EF5 /* SocialAuth */, E03261622AE6464A002CA7EB /* Startup */, 020C31BD290AADA700D6DEA2 /* Base */, @@ -268,6 +277,17 @@ path = ../Pods; sourceTree = ""; }; + 99C165492C0C4EF000DC384D /* SSO */ = { + isa = PBXGroup; + children = ( + 99C1654A2C0C4F0600DC384D /* ContainerWebView.swift */, + 99C1654C2C0C4F2F00DC384D /* SSOHelper.swift */, + 99C1654E2C0C4F5900DC384D /* SSOWebView.swift */, + 99C165502C0C4F7B00DC384D /* SSOWebViewModel.swift */, + ); + path = SSO; + sourceTree = ""; + }; BA8B3A302AD5485100D25EF5 /* SocialAuth */ = { isa = PBXGroup; children = ( @@ -502,13 +522,17 @@ 02066B462906D72F00F4307E /* SignUpViewModel.swift in Sources */, E03261642AE64676002CA7EB /* StartupViewModel.swift in Sources */, 02A2ACDB2A4B016100FBBBBB /* AuthorizationAnalytics.swift in Sources */, + 99C165512C0C4F7B00DC384D /* SSOWebViewModel.swift in Sources */, + 99C1654B2C0C4F0600DC384D /* ContainerWebView.swift in Sources */, 025F40E029D1E2FC0064C183 /* ResetPasswordView.swift in Sources */, 020C31CB290BF49900D6DEA2 /* FieldsView.swift in Sources */, 0770DE4E28D0A677006D8A5D /* SignInView.swift in Sources */, 02F3BFE5292533720051930C /* AuthorizationRouter.swift in Sources */, + 99C1654F2C0C4F5900DC384D /* SSOWebView.swift in Sources */, E03261662AE64AF4002CA7EB /* StartupView.swift in Sources */, 071009C728D1DA4F00344290 /* SignInViewModel.swift in Sources */, BA8B3A322AD5487300D25EF5 /* SocialAuthView.swift in Sources */, + 99C1654D2C0C4F2F00DC384D /* SSOHelper.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Authorization/Authorization/Presentation/AuthorizationAnalytics.swift b/Authorization/Authorization/Presentation/AuthorizationAnalytics.swift index 9fcd13b69..9371a1b40 100644 --- a/Authorization/Authorization/Presentation/AuthorizationAnalytics.swift +++ b/Authorization/Authorization/Presentation/AuthorizationAnalytics.swift @@ -10,12 +10,15 @@ import Core public enum AuthMethod: Equatable { case password + case SSO case socailAuth(SocialAuthMethod) public var analyticsValue: String { switch self { case .password: "password" + case .SSO: + "SSO" case .socailAuth(let socialAuthMethod): socialAuthMethod.rawValue } diff --git a/Authorization/Authorization/Presentation/Login/SignInView.swift b/Authorization/Authorization/Presentation/Login/SignInView.swift index 4369f2fab..e706892d0 100644 --- a/Authorization/Authorization/Presentation/Login/SignInView.swift +++ b/Authorization/Authorization/Presentation/Login/SignInView.swift @@ -121,7 +121,7 @@ public struct SignInView: View { .accessibilityIdentifier("password_textfield") HStack { if !viewModel.config.features.startupScreenEnabled { - Button(CoreLocalization.register) { + Button(CoreLocalization.SignIn.registerBtn) { viewModel.router.showRegisterScreen(sourceScreen: viewModel.sourceScreen) } .foregroundColor(Theme.Colors.accentColor) @@ -155,6 +155,13 @@ public struct SignInView: View { .frame(maxWidth: .infinity) .padding(.top, 40) .accessibilityIdentifier("signin_button") + + StyledButton(CoreLocalization.SignIn.logInWithSsoBtn) { + viewModel.router.showSSOWebBrowser(title: CoreLocalization.SignIn.logInBtn) + } + .frame(maxWidth: .infinity) + .padding(.top, 20) + .accessibilityIdentifier("signin_button") } } if viewModel.socialAuthEnabled { diff --git a/Authorization/Authorization/Presentation/Registration/SignUpView.swift b/Authorization/Authorization/Presentation/Registration/SignUpView.swift index 7ec2c8ba5..d4d855e7d 100644 --- a/Authorization/Authorization/Presentation/Registration/SignUpView.swift +++ b/Authorization/Authorization/Presentation/Registration/SignUpView.swift @@ -40,7 +40,7 @@ public struct SignUpView: View { VStack(alignment: .center) { ZStack { HStack { - Text(CoreLocalization.register) + Text(CoreLocalization.SignIn.registerBtn) .titleSettings(color: Theme.Colors.loginNavigationText) .accessibilityIdentifier("register_text") } @@ -64,7 +64,7 @@ public struct SignUpView: View { ScrollView { VStack(alignment: .leading) { - Text(CoreLocalization.register) + Text(CoreLocalization.SignIn.registerBtn) .font(Theme.Fonts.displaySmall) .foregroundColor(Theme.Colors.textPrimary) .padding(.bottom, 4) diff --git a/Authorization/Authorization/Presentation/SSO/ContainerWebView.swift b/Authorization/Authorization/Presentation/SSO/ContainerWebView.swift new file mode 100644 index 000000000..476630cf6 --- /dev/null +++ b/Authorization/Authorization/Presentation/SSO/ContainerWebView.swift @@ -0,0 +1,46 @@ +// +// ContainerWebView.swift +// Authorization +// +// Created by Rawan Matar on 02/06/2024. +// + +import SwiftUI +import Core +import Swinject + +public struct ContainerWebView: View { + + // MARK: - Internal Properties + + let url: String + private var pageTitle: String + @Environment(\.presentationMode) var presentationMode + + // MARK: - Init + + public init(_ url: String, title: String) { + self.url = url + self.pageTitle = title + } + + // MARK: - UI + + public var body: some View { + VStack(alignment: .center) { + NavigationBar( + title: pageTitle, + leftButtonAction: { presentationMode.wrappedValue.dismiss() } + ) + + ZStack { + if !url.isEmpty { + SSOWebView(url: URL(string: url), viewModel: Container.shared.resolve(SSOWebViewModel.self)!) + } else { + EmptyView() + } + } + .accessibilityIdentifier("web_browser") + } + } +} diff --git a/Authorization/Authorization/Presentation/SSO/SSOHelper.swift b/Authorization/Authorization/Presentation/SSO/SSOHelper.swift new file mode 100644 index 000000000..9ce2b993e --- /dev/null +++ b/Authorization/Authorization/Presentation/SSO/SSOHelper.swift @@ -0,0 +1,73 @@ +// +// SSOHelper.swift +// Authorization +// +// Created by Rawan Matar on 02/06/2024. +// + +import Foundation + +// https://developer.apple.com/documentation/ios-ipados-release-notes/foundation-release-notes + +/** + A Helper for some of the SSO preferences. + Keeps data under the UserDefaults. + */ +public class SSOHelper: NSObject { + + public enum UserDefaultKeys: String, CaseIterable { + case cookiePayload + case cookieSignature + case userInfo + + var description: String { + switch self { + case .cookiePayload: + return "edx-jwt-cookie-header-payload" + case .cookieSignature: + return "edx-jwt-cookie-signature" + case .userInfo: + return "edx-user-info" + } + } + } + + // MARK: - Singleton + + public static let shared = SSOHelper() + + // MARK: - Public Properties + + /// Authentication + public var cookiePayload: String? { + get { + let defaults = UserDefaults.standard + return defaults.string(forKey: UserDefaultKeys.cookiePayload.rawValue) + } + set (newValue) { + let defaults = UserDefaults.standard + defaults.set(newValue, forKey: UserDefaultKeys.cookiePayload.rawValue) + } + } + + /// Authentication + public var cookieSignature: String? { + get { + let defaults = UserDefaults.standard + return defaults.string(forKey: UserDefaultKeys.cookieSignature.rawValue) + } + set (newValue) { + let defaults = UserDefaults.standard + defaults.set(newValue, forKey: UserDefaultKeys.cookieSignature.rawValue) + } + } + + // MARK: - Public Methods + + /// Checks if the user is login. + public func cleanAfterSuccesfulLogout() { + cookiePayload = nil + cookieSignature = nil + } +} + diff --git a/Authorization/Authorization/Presentation/SSO/SSOWebView.swift b/Authorization/Authorization/Presentation/SSO/SSOWebView.swift new file mode 100644 index 000000000..aeaae2661 --- /dev/null +++ b/Authorization/Authorization/Presentation/SSO/SSOWebView.swift @@ -0,0 +1,104 @@ +// +// SSOWebView.swift +// Authorization +// +// Created by Rawan Matar on 02/06/2024. +// + + +import SwiftUI +import WebKit +import Core + +public struct SAMLConstants { + + public init() {} + + public let samlLoginSuccess = URL(string: "https://blue.zeitlabs.com/auth/complete/tpa-saml")! +} + +public struct SSOWebView: UIViewRepresentable { + + let url: URL? + + var viewModel: SSOWebViewModel + + public init(url: URL?, viewModel: SSOWebViewModel) { + self.url = url + self.viewModel = viewModel + } + + public func makeUIView(context: Context) -> WKWebView { + let coordinator = makeCoordinator() + let userContentController = WKUserContentController() + userContentController.add(coordinator, name: "bridge") + + let prefs = WKWebpagePreferences() + let config = WKWebViewConfiguration() + prefs.allowsContentJavaScript = true + + config.userContentController = userContentController + config.defaultWebpagePreferences = prefs + config.websiteDataStore = WKWebsiteDataStore.nonPersistent() + + let wkWebView = WKWebView(frame: .zero, configuration: config) + wkWebView.navigationDelegate = coordinator + + guard let currentURL = url else { + return wkWebView + } + let request = URLRequest(url: currentURL) + wkWebView.load(request) + + return wkWebView + } + + public func updateUIView(_ uiView: WKWebView, context: Context) { + + } + + public func makeCoordinator() -> Coordinator { + Coordinator(viewModel: self.viewModel) + } + + public class Coordinator: NSObject, WKScriptMessageHandler, WKNavigationDelegate { + var viewModel: SSOWebViewModel + + init(viewModel: SSOWebViewModel) { + self.viewModel = viewModel + super.init() + } + + // WKScriptMessageHandler + public func userContentController(_ userContentController: WKUserContentController, + didReceive message: WKScriptMessage) { + } + + // WKNavigationDelegate + public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + guard let _ = webView.url?.absoluteString else { + return + } + + } + + public func webView(_ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + guard let url = webView.url?.absoluteString else { + decisionHandler(.allow) + return + } + + if url.contains(SAMLConstants().samlLoginSuccess.absoluteString) { + webView.configuration.websiteDataStore.httpCookieStore.getAllCookies { cookies in + Task { + await self.viewModel.SSOLogin(cookies: cookies) + } + } + } + + decisionHandler(.allow) + } + } +} diff --git a/Authorization/Authorization/Presentation/SSO/SSOWebViewModel.swift b/Authorization/Authorization/Presentation/SSO/SSOWebViewModel.swift new file mode 100644 index 000000000..bb76cf81c --- /dev/null +++ b/Authorization/Authorization/Presentation/SSO/SSOWebViewModel.swift @@ -0,0 +1,117 @@ +// +// SSOWebViewModel.swift +// Authorization +// +// Created by Rawan Matar on 02/06/2024. +// + +import Foundation +import SwiftUI +import Core +import Alamofire +import AuthenticationServices +import FacebookLogin +import GoogleSignIn +import MSAL + +public class SSOWebViewModel: ObservableObject { + + @Published private(set) var isShowProgress = false + @Published private(set) var showError: Bool = false + @Published private(set) var showAlert: Bool = false + let sourceScreen: LogistrationSourceScreen = .default + + var errorMessage: String? { + didSet { + withAnimation { + showError = errorMessage != nil + } + } + } + var alertMessage: String? { + didSet { + withAnimation { + showAlert = alertMessage != nil + } + } + } + + let router: AuthorizationRouter + let config: ConfigProtocol + private let interactor: AuthInteractorProtocol + private let analytics: AuthorizationAnalytics + + public init( + interactor: AuthInteractorProtocol, + router: AuthorizationRouter, + config: ConfigProtocol, + analytics: AuthorizationAnalytics + ) { + self.interactor = interactor + self.router = router + self.config = config + self.analytics = analytics + } + + @MainActor + func SSOLogin(cookies: [HTTPCookie]) async { + guard !cookies.isEmpty else { + errorMessage = "COOKIES EMPTY" + return + } + + isShowProgress = true + for cookie in cookies { + + /// Store cookies in UserDefaults + if cookie.name == SSOHelper.UserDefaultKeys.cookiePayload.description { + SSOHelper.shared.cookiePayload = cookie.value + } + + if cookie.name == SSOHelper.UserDefaultKeys.cookieSignature.description { + SSOHelper.shared.cookieSignature = cookie.value + } + if let signature = SSOHelper.shared.cookieSignature, + let payload = SSOHelper.shared.cookiePayload { + isShowProgress = true + do { + let user = try await interactor.SSOlogin(jwtToken: "\(payload).\(signature)") + analytics.identify(id: "\(user.id)", username: user.username, email: user.email) + analytics.userLogin(method: .SSO) + router.showMainOrWhatsNewScreen(sourceScreen: sourceScreen) + } catch let error { + failure(error, authMethod: .SSO) + } + } + } + } + + @MainActor + private func failure(_ error: Error, authMethod: AuthMethod? = nil) { + isShowProgress = false + if let validationError = error.validationError, + let value = validationError.data?["error_description"] as? String { + if authMethod != .password, validationError.statusCode == 400, let authMethod = authMethod { + errorMessage = AuthLocalization.Error.accountNotRegistered( + authMethod.analyticsValue, + config.platformName + ) + } else if validationError.statusCode == 403 { + errorMessage = AuthLocalization.Error.disabledAccount + } else { + errorMessage = value + } + } else if case APIError.invalidGrant = error { + errorMessage = CoreLocalization.Error.invalidCredentials + } else if error.isInternetError { + errorMessage = CoreLocalization.Error.slowOrNoInternetConnection + } else { + errorMessage = CoreLocalization.Error.unknownError + } + } + + func trackForgotPasswordClicked() { + analytics.forgotPasswordClicked() + } + +} diff --git a/Authorization/Authorization/Presentation/Startup/StartupView.swift b/Authorization/Authorization/Presentation/Startup/StartupView.swift index 88551d824..b78a0bded 100644 --- a/Authorization/Authorization/Presentation/Startup/StartupView.swift +++ b/Authorization/Authorization/Presentation/Startup/StartupView.swift @@ -105,6 +105,8 @@ public struct StartupView: View { switch buttonAction { case .signIn: viewModel.router.showLoginScreen(sourceScreen: .startup) + case .signInWithSSO: + viewModel.router.showLoginScreen(sourceScreen: .startup) case .register: viewModel.router.showRegisterScreen(sourceScreen: .startup) } diff --git a/Core/Core/Configuration/BaseRouter.swift b/Core/Core/Configuration/BaseRouter.swift index e2a2a714b..4f2ae5bba 100644 --- a/Core/Core/Configuration/BaseRouter.swift +++ b/Core/Core/Configuration/BaseRouter.swift @@ -34,6 +34,8 @@ public protocol BaseRouter { func showDiscoveryScreen(searchQuery: String?, sourceScreen: LogistrationSourceScreen) func showWebBrowser(title: String, url: URL) + + func showSSOWebBrowser(title: String) func presentAlert( alertTitle: String, @@ -100,6 +102,8 @@ open class BaseRouterMock: BaseRouter { public func removeLastView(controllers: Int) {} public func showWebBrowser(title: String, url: URL) {} + + public func showSSOWebBrowser(title: String) {} public func presentAlert( alertTitle: String, diff --git a/Core/Core/Configuration/Config/Config.swift b/Core/Core/Configuration/Config/Config.swift index be5fd1941..f5c3f5908 100644 --- a/Core/Core/Configuration/Config/Config.swift +++ b/Core/Core/Configuration/Config/Config.swift @@ -9,6 +9,7 @@ import Foundation public protocol ConfigProtocol { var baseURL: URL { get } + var SSOBaseURL: URL { get } var oAuthClientId: String { get } var tokenType: TokenType { get } var feedbackEmail: String { get } @@ -41,6 +42,7 @@ public enum TokenType: String { private enum ConfigKeys: String { case baseURL = "API_HOST_URL" + case SSOBaseURL = "SSO_URL" case oAuthClientID = "OAUTH_CLIENT_ID" case tokenType = "TOKEN_TYPE" case feedbackEmailAddress = "FEEDBACK_EMAIL_ADDRESS" @@ -120,6 +122,14 @@ extension Config: ConfigProtocol { return url } + public var SSOBaseURL: URL { + guard let urlString = string(for: ConfigKeys.SSOBaseURL.rawValue), + let url = URL(string: urlString) else { + fatalError("Unable to find SSO base url in config.") + } + return url + } + public var oAuthClientId: String { guard let clientID = string(for: ConfigKeys.oAuthClientID.rawValue) else { fatalError("Unable to find OAuth ClientID in config.") @@ -168,6 +178,7 @@ extension Config: ConfigProtocol { public class ConfigMock: Config { private let config: [String: Any] = [ "API_HOST_URL": "https://www.example.com", + "SSO_URL" : "https://www.example.com", "OAUTH_CLIENT_ID": "oauth_client_id", "FEEDBACK_EMAIL_ADDRESS": "example@mail.com", "PLATFORM_NAME": "OpenEdx", diff --git a/Core/Core/Data/Repository/AuthRepository.swift b/Core/Core/Data/Repository/AuthRepository.swift index e4adf93ea..21c79e753 100644 --- a/Core/Core/Data/Repository/AuthRepository.swift +++ b/Core/Core/Data/Repository/AuthRepository.swift @@ -10,6 +10,7 @@ import Foundation public protocol AuthRepositoryProtocol { func login(username: String, password: String) async throws -> User func login(externalToken: String, backend: String) async throws -> User + func SSOlogin(jwtToken: String) async throws -> User func getCookies(force: Bool) async throws func getRegistrationFields() async throws -> [PickerFields] func registerUser(fields: [String: String], isSocial: Bool) async throws -> User @@ -80,6 +81,18 @@ public class AuthRepository: AuthRepositoryProtocol { return user.domain } + public func SSOlogin(jwtToken: String) async throws -> User { + if appStorage.accessToken == nil || + appStorage.refreshToken == nil { + appStorage.accessToken = jwtToken + appStorage.refreshToken = jwtToken + } + + let user = try await api.requestData(AuthEndpoint.getUserInfo).mapResponse(DataLayer.User.self) + appStorage.user = user + return user.domain + } + public func resetPassword(email: String) async throws -> ResetPassword { let response = try await api.requestData(AuthEndpoint.resetPassword(email: email)) .mapResponse(DataLayer.ResetPassword.self) @@ -138,6 +151,10 @@ class AuthRepositoryMock: AuthRepositoryProtocol { User(id: 1, username: "User", email: "email@gmail.com", name: "User Name", userAvatar: "") } + public func SSOlogin(jwtToken: String) async throws -> User { + return User(id: 1, username: "User", email: "email@gmail.com", name: "User Name", userAvatar: "") + } + func resetPassword(email: String) async throws -> ResetPassword { ResetPassword(success: true, responseText: "Success reset") } diff --git a/Core/Core/Domain/AuthInteractor.swift b/Core/Core/Domain/AuthInteractor.swift index 45868cbc9..dd4b1cf49 100644 --- a/Core/Core/Domain/AuthInteractor.swift +++ b/Core/Core/Domain/AuthInteractor.swift @@ -13,6 +13,7 @@ public protocol AuthInteractorProtocol { func login(username: String, password: String) async throws -> User @discardableResult func login(externalToken: String, backend: String) async throws -> User + func SSOlogin(jwtToken: String) async throws -> User func resetPassword(email: String) async throws -> ResetPassword func getCookies(force: Bool) async throws func getRegistrationFields() async throws -> [PickerFields] @@ -37,6 +38,11 @@ public class AuthInteractor: AuthInteractorProtocol { return try await repository.login(externalToken: externalToken, backend: backend) } + @discardableResult + public func SSOlogin(jwtToken: String) async throws -> User { + return try await repository.SSOlogin(jwtToken: jwtToken) + } + public func resetPassword(email: String) async throws -> ResetPassword { try await repository.resetPassword(email: email) } diff --git a/Core/Core/SwiftGen/Strings.swift b/Core/Core/SwiftGen/Strings.swift index 4bd41f9eb..464d9a99d 100644 --- a/Core/Core/SwiftGen/Strings.swift +++ b/Core/Core/SwiftGen/Strings.swift @@ -14,8 +14,6 @@ public enum CoreLocalization { public static let done = CoreLocalization.tr("Localizable", "DONE", fallback: "Done") /// View in Safari public static let openInBrowser = CoreLocalization.tr("Localizable", "OPEN_IN_BROWSER", fallback: "View in Safari") - /// Register - public static let register = CoreLocalization.tr("Localizable", "REGISTER", fallback: "Register") /// The user canceled the sign-in flow. public static let socialSignCanceled = CoreLocalization.tr("Localizable", "SOCIAL_SIGN_CANCELED", fallback: "The user canceled the sign-in flow.") /// Tomorrow @@ -220,6 +218,10 @@ public enum CoreLocalization { public enum SignIn { /// Sign in public static let logInBtn = CoreLocalization.tr("Localizable", "SIGN_IN.LOG_IN_BTN", fallback: "Sign in") + /// Sign in with SSO + public static let logInWithSsoBtn = CoreLocalization.tr("Localizable", "SIGN_IN.LOG_IN_WITH_SSO_BTN", fallback: "Sign in with SSO") + /// Register + public static let registerBtn = CoreLocalization.tr("Localizable", "SIGN_IN.REGISTER_BTN", fallback: "Register") } public enum View { public enum Snackbar { diff --git a/Core/Core/View/Base/LogistrationBottomView.swift b/Core/Core/View/Base/LogistrationBottomView.swift index fc0aa0ef4..faa3aaa35 100644 --- a/Core/Core/View/Base/LogistrationBottomView.swift +++ b/Core/Core/View/Base/LogistrationBottomView.swift @@ -15,14 +15,11 @@ public enum LogistrationSourceScreen: Equatable { case discovery case courseDetail(String, String) case programDetails(String) - - public var value: String? { - return String(describing: self).components(separatedBy: "(").first - } } public enum LogistrationAction { case signIn + case signInWithSSO case register } @@ -34,11 +31,11 @@ public struct LogistrationBottomView: View { public init(_ action: @escaping (LogistrationAction) -> Void) { self.action = action } - + public var body: some View { VStack(alignment: .leading) { HStack(spacing: 24) { - StyledButton(CoreLocalization.register) { + StyledButton(CoreLocalization.SignIn.registerBtn) { action(.register) } .accessibilityIdentifier("logistration_register_button") @@ -54,6 +51,18 @@ public struct LogistrationBottomView: View { ) .frame(width: 100) .accessibilityIdentifier("logistration_signin_button") + + StyledButton( + CoreLocalization.SignIn.logInWithSsoBtn, + action: { + action(.signInWithSSO) + }, + color: Theme.Colors.white, + textColor: Theme.Colors.secondaryButtonTextColor, + borderColor: Theme.Colors.secondaryButtonBorderColor + ) + .frame(width: 100) + .accessibilityIdentifier("logistration_signin_withsso_button") } .padding(.horizontal, isHorizontal ? 0 : 0) } diff --git a/Core/Core/en.lproj/Localizable.strings b/Core/Core/en.lproj/Localizable.strings index b1fda17c5..9eed367a5 100644 --- a/Core/Core/en.lproj/Localizable.strings +++ b/Core/Core/en.lproj/Localizable.strings @@ -114,7 +114,8 @@ "SOCIAL_SIGN_CANCELED" = "The user canceled the sign-in flow."; "SIGN_IN.LOG_IN_BTN" = "Sign in"; -"REGISTER" = "Register"; +"SIGN_IN.REGISTER_BTN" = "Register"; +"SIGN_IN.LOG_IN_WITH_SSO_BTN" = "Sign in with SSO"; "TOMORROW" = "Tomorrow"; "YESTERDAY" = "Yesterday"; diff --git a/Discovery/Discovery/Presentation/NativeDiscovery/CourseDetailsView.swift b/Discovery/Discovery/Presentation/NativeDiscovery/CourseDetailsView.swift index 80864b8fd..043b1abee 100644 --- a/Discovery/Discovery/Presentation/NativeDiscovery/CourseDetailsView.swift +++ b/Discovery/Discovery/Presentation/NativeDiscovery/CourseDetailsView.swift @@ -162,6 +162,13 @@ public struct CourseDetailsView: View { viewModel.courseDetails?.courseTitle ?? "" ) ) + case .signInWithSSO: + viewModel.router.showLoginScreen( + sourceScreen: .courseDetail( + courseID, + viewModel.courseDetails?.courseTitle ?? "" + ) + ) } } } diff --git a/Discovery/Discovery/Presentation/NativeDiscovery/DiscoveryView.swift b/Discovery/Discovery/Presentation/NativeDiscovery/DiscoveryView.swift index b8d9aa860..225e20e58 100644 --- a/Discovery/Discovery/Presentation/NativeDiscovery/DiscoveryView.swift +++ b/Discovery/Discovery/Presentation/NativeDiscovery/DiscoveryView.swift @@ -151,6 +151,8 @@ public struct DiscoveryView: View { viewModel.router.showLoginScreen(sourceScreen: .discovery) case .register: viewModel.router.showRegisterScreen(sourceScreen: .discovery) + case .signInWithSSO: + viewModel.router.showLoginScreen(sourceScreen: .discovery) } } } diff --git a/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebview.swift b/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebview.swift index b69bb3af9..e7105f508 100644 --- a/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebview.swift +++ b/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebview.swift @@ -14,7 +14,7 @@ public enum DiscoveryWebviewType: Equatable { case discovery case courseDetail(String) case programDetail(String) - + var rawValue: String { switch self { case .discovery: @@ -103,7 +103,7 @@ public struct DiscoveryWebview: View { webViewType: discoveryType.rawValue ) .accessibilityIdentifier("discovery_webview") - + if isLoading || viewModel.showProgress { HStack(alignment: .center) { ProgressBar( @@ -115,7 +115,7 @@ public struct DiscoveryWebview: View { } .frame(width: proxy.size.width, height: proxy.size.height) } - + // MARK: - Show Error if viewModel.showError { VStack { @@ -129,19 +129,21 @@ public struct DiscoveryWebview: View { } } } - + if !viewModel.userloggedIn, !isLoading { LogistrationBottomView { buttonAction in switch buttonAction { case .signIn: viewModel.router.showLoginScreen(sourceScreen: sourceScreen) + case .signInWithSSO: + viewModel.router.showLoginScreen(sourceScreen: sourceScreen) case .register: viewModel.router.showRegisterScreen(sourceScreen: sourceScreen) } } } } - + if viewModel.webViewError { FullScreenErrorView( type: viewModel.connectivity.isInternetAvaliable ? .generic : .noInternetWithReload diff --git a/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebviewViewModel.swift b/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebviewViewModel.swift index b9f2bb515..df2330abf 100644 --- a/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebviewViewModel.swift +++ b/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebviewViewModel.swift @@ -15,7 +15,7 @@ public class DiscoveryWebviewViewModel: ObservableObject { @Published private(set) var showProgress = false @Published var showError: Bool = false @Published var webViewError: Bool = false - + var errorMessage: String? { didSet { withAnimation { @@ -137,25 +137,14 @@ extension DiscoveryWebviewViewModel: WebViewNavigationDelegate { } if let url = request.url, outsideLink || capturedLink || externalLink, UIApplication.shared.canOpenURL(url) { - analytics.externalLinkOpen(url: url.absoluteString, screen: sourceScreen.value ?? "") router.presentAlert( alertTitle: DiscoveryLocalization.Alert.leavingAppTitle, alertMessage: DiscoveryLocalization.Alert.leavingAppMessage, positiveAction: CoreLocalization.Webview.Alert.continue, onCloseTapped: { [weak self] in self?.router.dismiss(animated: true) - self?.analytics.externalLinkOpenAction( - url: url.absoluteString, - screen: self?.sourceScreen.value ?? "", - action: "cancel" - ) - }, okTapped: { [weak self] in + }, okTapped: { UIApplication.shared.open(url, options: [:]) - self?.analytics.externalLinkOpenAction( - url: url.absoluteString, - screen: self?.sourceScreen.value ?? "", - action: "continue" - ) }, type: .default(positiveAction: CoreLocalization.Webview.Alert.continue, image: nil) ) return true @@ -249,7 +238,7 @@ extension DiscoveryWebviewViewModel: WebViewNavigationDelegate { private func isValidAppURLScheme(_ url: URL) -> Bool { return url.scheme ?? "" == config.URIScheme } - + public func showWebViewError() { self.webViewError = true } diff --git a/OpenEdX/DI/ScreenAssembly.swift b/OpenEdX/DI/ScreenAssembly.swift index 04ddc0514..744ed87f8 100644 --- a/OpenEdX/DI/ScreenAssembly.swift +++ b/OpenEdX/DI/ScreenAssembly.swift @@ -62,6 +62,14 @@ class ScreenAssembly: Assembly { sourceScreen: sourceScreen ) } + container.register(SSOWebViewModel.self) { r in + SSOWebViewModel( + interactor: r.resolve(AuthInteractorProtocol.self)!, + router: r.resolve(AuthorizationRouter.self)!, + config: r.resolve(ConfigProtocol.self)!, + analytics: r.resolve(AuthorizationAnalytics.self)! + ) + } container.register(SignUpViewModel.self) { r, sourceScreen in SignUpViewModel( interactor: r.resolve(AuthInteractorProtocol.self)!, diff --git a/OpenEdX/Router.swift b/OpenEdX/Router.swift index fce1cca5d..496d322f8 100644 --- a/OpenEdX/Router.swift +++ b/OpenEdX/Router.swift @@ -837,6 +837,16 @@ public class Router: AuthorizationRouter, let controller = UIHostingController(rootView: webBrowser) navigationController.pushViewController(controller, animated: true) } + + public func showSSOWebBrowser(title: String) { + let config = Container.shared.resolve(ConfigProtocol.self)! + let webBrowser = ContainerWebView( + config.SSOBaseURL.absoluteString, + title: title + ) + let controller = UIHostingController(rootView: webBrowser) + navigationController.pushViewController(controller, animated: true) + } } // MARK: BackNavigationProtocol diff --git a/default_config/dev/config.yaml b/default_config/dev/config.yaml index d7cec08d1..96cda953d 100644 --- a/default_config/dev/config.yaml +++ b/default_config/dev/config.yaml @@ -1,4 +1,5 @@ API_HOST_URL: 'http://localhost:8000' +SSO_URL: 'http://localhost:8000' ENVIRONMENT_DISPLAY_NAME: 'Localhost' FEEDBACK_EMAIL_ADDRESS: 'support@example.com' OAUTH_CLIENT_ID: ''