Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Support web-based LMS OAuth. #65

Open
wants to merge 1 commit into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,16 @@ import Foundation
public enum AuthMethod: Equatable {
case password
case socailAuth(SocialAuthMethod)
case webAuth

public var analyticsValue: String {
switch self {
case .password:
"Password"
case .socailAuth(let socialAuthMethod):
socialAuthMethod.rawValue
case .webAuth:
"WebAuth View"
}
}
}
Expand Down
303 changes: 165 additions & 138 deletions Authorization/Authorization/Presentation/Login/SignInView.swift

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,36 @@ import AuthenticationServices
import FacebookLogin
import GoogleSignIn
import MSAL
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
volodymyr-chekyrta marked this conversation as resolved.
Show resolved Hide resolved

var useWebLogin: Bool {
return config.webLogin
}
var forceWebLogin: Bool {
return config.webLogin && !webLoginAttempted
}
var errorMessage: String? {
didSet {
withAnimation {
Expand All @@ -33,13 +57,16 @@ public class SignInViewModel: ObservableObject {
}
}
}
var oauthswift: OAuth2Swift?

let router: AuthorizationRouter
let config: ConfigProtocol
private let interactor: AuthInteractorProtocol
private let analytics: AuthorizationAnalytics
private let validator: Validator

private var safariDelegate: WebLoginSafariDelegate?

public init(
interactor: AuthInteractorProtocol,
router: AuthorizationRouter,
Expand All @@ -61,6 +88,56 @@ public class SignInViewModel: ObservableObject {
config.google.enabled
}

@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 thing):
Task {
self.webLoginAttempted = true
let user = try await self.interactor.login(credential: thing.credential)
self.analytics.setUserID("\(user.id)")
self.analytics.userLogin(method: .webAuth)
self.router.showMainOrWhatsNewScreen()
}
// Do your request
case .failure(let error):
self.webLoginAttempted = true
self.isShowProgress = false
self.errorMessage = error.localizedDescription
}
}
}

public func markAttempted() {
self.webLoginAttempted = true
}

@MainActor
func login(username: String, password: String) async {
guard validator.isValidUsername(username) else {
Expand Down
6 changes: 6 additions & 0 deletions Core/Core/Configuration/Config/Config.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ public protocol ConfigProtocol {
var baseURL: URL { get }
var oAuthClientId: String { get }
var tokenType: TokenType { get }
var webLogin: Bool { get }
var feedbackEmail: String { get }
var appStoreLink: String { get }
var platformName: String { get }
Expand All @@ -33,6 +34,7 @@ private enum ConfigKeys: String {
case baseURL = "API_HOST_URL"
case oAuthClientID = "OAUTH_CLIENT_ID"
case tokenType = "TOKEN_TYPE"
case webLogin = "WEB_LOGIN"
case feedbackEmailAddress = "FEEDBACK_EMAIL_ADDRESS"
case environmentDisplayName = "ENVIRONMENT_DISPLAY_NAME"
case platformName = "PLATFORM_NAME"
Expand Down Expand Up @@ -122,6 +124,10 @@ extension Config: ConfigProtocol {
return tokenType
}

public var webLogin: Bool {
return bool(for: ConfigKeys.webLogin.rawValue)
}

public var feedbackEmail: String {
return string(for: ConfigKeys.feedbackEmailAddress.rawValue) ?? ""
}
Expand Down
18 changes: 18 additions & 0 deletions Core/Core/Data/Repository/AuthRepository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 login(externalToken: String, backend: String) async throws -> User
func getCookies(force: Bool) async throws
Expand All @@ -29,6 +31,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(
Expand Down Expand Up @@ -130,6 +143,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 protected]", name: "User Name", userAvatar: "")
}

func login(username: String, password: String) async throws -> User {
User(id: 1, username: "User", email: "[email protected]", name: "User Name", userAvatar: "")
}
Expand Down
7 changes: 7 additions & 0 deletions Core/Core/Domain/AuthInteractor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
@discardableResult
func login(externalToken: String, backend: String) async throws -> User
Expand All @@ -27,6 +29,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)
Expand Down
1 change: 1 addition & 0 deletions Core/Core/SwiftGen/Assets.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ public enum CoreAssets {
public static let rotateDevice = ImageAsset(name: "rotateDevice")
public static let sub = ImageAsset(name: "sub")
public static let alarm = ImageAsset(name: "alarm")
public static let appLogo = ImageAsset(name: "appLogo")
public static let arrowLeft = ImageAsset(name: "arrowLeft")
public static let arrowRight16 = ImageAsset(name: "arrowRight16")
public static let certificate = ImageAsset(name: "certificate")
Expand Down
5 changes: 5 additions & 0 deletions OpenEdX/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import GoogleSignIn
import FacebookCore
import MSAL
import Theme
import OAuthSwift

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
Expand Down Expand Up @@ -69,6 +70,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
_ app: UIApplication,
open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]
) -> Bool {
if url.host == "oauth2Callback" {
// TODO: Update to better match the other OAuth redirect behaviors
OAuthSwift.handle(url: url)
}
if let config = Container.shared.resolve(ConfigProtocol.self) {
if config.facebook.enabled {
ApplicationDelegate.shared.application(
Expand Down
13 changes: 13 additions & 0 deletions OpenEdX/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,19 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string></string>
<key>CFBundleURLSchemes</key>
<array>
<string>${PRODUCT_BUNDLE_IDENTIFIER}</string>
</array>
</dict>
</array>
<key>Configuration</key>
<string>$(CONFIGURATION)</string>
<key>FirebaseAppDelegateProxyEnabled</key>
Expand Down
10 changes: 9 additions & 1 deletion OpenEdX/RouteController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,20 @@ class RouteController: UIViewController {
present(navigation, animated: false)
} else {
let controller = UIHostingController(
rootView: SignInView(viewModel: diContainer.resolve(SignInViewModel.self)!)
rootView: SignInView(viewModel: diContainer.resolve(SignInViewModel.self)!, navigationController: self.navigation)
)
navigation.viewControllers = [controller]
present(navigation, animated: false)
}
}

private func showAuthorization() {
let controller = UIHostingController(
rootView: SignInView(viewModel: diContainer.resolve(SignInViewModel.self)!, navigationController: self.navigation)
)
navigation.viewControllers = [controller]
present(navigation, animated: false)
}

private func showMainOrWhatsNewScreen() {
var storage = Container.shared.resolve(WhatsNewStorage.self)!
Expand Down
10 changes: 8 additions & 2 deletions OpenEdX/Router.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,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 = UIHostingController(rootView: view)
navigationController.pushViewController(controller, animated: true)
}
Expand All @@ -94,7 +97,10 @@ public class Router: AuthorizationRouter,
let controller = UIHostingController(rootView: view)
navigationController.setViewControllers([controller], animated: true)
} else {
let view = SignInView(viewModel: Container.shared.resolve(SignInViewModel.self)!)
let view = SignInView(
viewModel: Container.shared.resolve(SignInViewModel.self)!,
navigationController: navigationController
)
let controller = UIHostingController(rootView: view)
navigationController.setViewControllers([controller], animated: false)
}
Expand Down
1 change: 1 addition & 0 deletions Podfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading