Skip to content

Commit 66aa34d

Browse files
committed
feat: Support web-based LMS OAuth.
1 parent 1d1fdec commit 66aa34d

15 files changed

Lines changed: 319 additions & 125 deletions

File tree

Authorization/Authorization/Presentation/AuthorizationAnalytics.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ public enum LoginMethod: String {
1212
case facebook = "Facebook"
1313
case google = "Google"
1414
case microsoft = "Microsoft"
15+
case oauth2 = "Custom OAuth2"
1516
}
1617

1718
//sourcery: AutoMockable

Authorization/Authorization/Presentation/Login/SignInView.swift

Lines changed: 152 additions & 118 deletions
Large diffs are not rendered by default.

Authorization/Authorization/Presentation/Login/SignInViewModel.swift

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,33 @@ import Foundation
99
import Core
1010
import SwiftUI
1111
import Alamofire
12+
import OAuthSwift
13+
import SafariServices
14+
15+
private class WebLoginSafariDelegate: NSObject, SFSafariViewControllerDelegate {
16+
private let viewModel: SignInViewModel
17+
public init(viewModel: SignInViewModel) {
18+
self.viewModel = viewModel
19+
}
20+
func safariViewControllerDidFinish(_ controller: SFSafariViewController) {
21+
/* Called when the 'Done' button is hit on the Safari Web view. In this case,
22+
authentication would neither have failed nor succeeded, but we'd be back
23+
at the SignInView. So, we make sure we mark it as attempted so the UI
24+
renders. */
25+
self.viewModel.markAttempted()
26+
}
27+
}
1228

1329
public class SignInViewModel: ObservableObject {
1430

1531
@Published private(set) var isShowProgress = false
1632
@Published private(set) var showError: Bool = false
1733
@Published private(set) var showAlert: Bool = false
34+
@Published private(set) var webLoginAttempted: Bool = false
35+
36+
var forceWebLogin: Bool {
37+
return config.webLogin && !webLoginAttempted
38+
}
1839
var errorMessage: String? {
1940
didSet {
2041
withAnimation {
@@ -29,20 +50,78 @@ public class SignInViewModel: ObservableObject {
2950
}
3051
}
3152
}
53+
var oauthswift: OAuth2Swift?
3254

3355
private let interactor: AuthInteractorProtocol
3456
let router: AuthorizationRouter
57+
let config: Config
3558
let analytics: AuthorizationAnalytics
3659
private let validator: Validator
60+
private var safariDelegate: WebLoginSafariDelegate?
3761

3862
public init(interactor: AuthInteractorProtocol,
3963
router: AuthorizationRouter,
4064
analytics: AuthorizationAnalytics,
65+
config: Config,
4166
validator: Validator) {
4267
self.interactor = interactor
4368
self.router = router
4469
self.analytics = analytics
70+
self.config = config
4571
self.validator = validator
72+
self.webLoginAttempted = false
73+
}
74+
75+
@MainActor
76+
func login(viewController: UIViewController) async {
77+
/* OAuth web login. Used when we cannot use the built-in login form,
78+
but need to let the LMS redirect us to the authentication provider.
79+
80+
An example service where this is needed is something like Auth0, which
81+
redirects from the LMS to its own login page. That login page then redirects
82+
back to the LMS for the issuance of a token that can be used for making
83+
requests to the LMS, and then back to the redirect URL for the app. */
84+
self.safariDelegate = WebLoginSafariDelegate(viewModel: self)
85+
oauthswift = OAuth2Swift(
86+
consumerKey: config.oAuthClientId,
87+
consumerSecret: "", // No secret required
88+
authorizeUrl: "\(config.baseURL)/oauth2/authorize/",
89+
accessTokenUrl: "\(config.baseURL)/oauth2/access_token/",
90+
responseType: "code"
91+
)
92+
93+
oauthswift!.allowMissingStateCheck = true
94+
let handler = SafariURLHandler(
95+
viewController: viewController, oauthSwift: oauthswift!
96+
)
97+
handler.delegate = self.safariDelegate
98+
oauthswift!.authorizeURLHandler = handler
99+
100+
// Trigger OAuth2 dance
101+
guard let rwURL = URL(string: "\(Bundle.main.bundleIdentifier ?? "")://oauth2Callback") else { return }
102+
oauthswift!.authorize(withCallbackURL: rwURL, scope: "", state: "") { result in
103+
switch result {
104+
case .success(let (credential, _, _)):
105+
Task {
106+
self.webLoginAttempted = true
107+
let user = try await self.interactor.login(credential: credential)
108+
self.analytics.setUserID("\(user.id)")
109+
self.analytics.userLogin(method: .oauth2)
110+
self.router.showMainScreen()
111+
}
112+
// Do your request
113+
case .failure(let error):
114+
self.webLoginAttempted = true
115+
self.isShowProgress = false
116+
self.errorMessage = error.localizedDescription
117+
}
118+
}
119+
}
120+
121+
public func markAttempted() {
122+
// Hack to get around published observables limitation when handing this model over
123+
// to an outside object. Is there a better way to do this?
124+
self.webLoginAttempted = true
46125
}
47126

48127
@MainActor

Core/Core/Configuration/Config.swift

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ public class Config {
1111

1212
public let baseURL: URL
1313
public let oAuthClientId: String
14+
public let webLogin: Bool
1415

1516
public lazy var termsOfUse: URL? = {
1617
URL(string: "\(baseURL.description)/tos")
@@ -20,22 +21,23 @@ public class Config {
2021
URL(string: "\(baseURL.description)/privacy")
2122
}()
2223

23-
public let feedbackEmail = "support@example.com"
24+
public let feedbackEmail = "contact@yam-edu.com"
2425

25-
public init(baseURL: String, oAuthClientId: String) {
26+
public init(baseURL: String, oAuthClientId: String, webLogin: Bool) {
2627
guard let url = URL(string: baseURL) else {
2728
fatalError("Ivalid baseURL")
2829
}
2930
self.baseURL = url
3031
self.oAuthClientId = oAuthClientId
32+
self.webLogin = webLogin
3133
}
3234
}
3335

3436
// Mark - For testing and SwiftUI preview
3537
#if DEBUG
3638
public class ConfigMock: Config {
3739
public convenience init() {
38-
self.init(baseURL: "https://google.com/", oAuthClientId: "client_id")
40+
self.init(baseURL: "https://google.com/", oAuthClientId: "client_id", webLogin: false)
3941
}
4042
}
4143
#endif

Core/Core/Data/Repository/AuthRepository.swift

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@
66
//
77

88
import Foundation
9+
import OAuthSwift
910

1011
public protocol AuthRepositoryProtocol {
12+
func login(credential: OAuthSwiftCredential) async throws -> User
1113
func login(username: String, password: String) async throws -> User
1214
func getCookies(force: Bool) async throws
1315
func getRegistrationFields() async throws -> [PickerFields]
@@ -28,6 +30,17 @@ public class AuthRepository: AuthRepositoryProtocol {
2830
self.config = config
2931
}
3032

33+
public func login(credential: OAuthSwiftCredential) async throws -> User {
34+
// Login for when we have the accessToken and refreshToken directly, like from web-view
35+
// OAuth logins.
36+
appStorage.cookiesDate = nil
37+
appStorage.accessToken = credential.oauthToken
38+
appStorage.refreshToken = credential.oauthRefreshToken
39+
let user = try await api.requestData(AuthEndpoint.getUserInfo).mapResponse(DataLayer.User.self)
40+
appStorage.user = user
41+
return user.domain
42+
}
43+
3144
public func login(username: String, password: String) async throws -> User {
3245
appStorage.cookiesDate = nil
3346
let endPoint = AuthEndpoint.getAccessToken(
@@ -100,6 +113,11 @@ public class AuthRepository: AuthRepositoryProtocol {
100113
// Mark - For testing and SwiftUI preview
101114
#if DEBUG
102115
class AuthRepositoryMock: AuthRepositoryProtocol {
116+
117+
func login(credential: OAuthSwiftCredential) async throws -> User {
118+
User(id: 1, username: "User", email: "email@gmail.com", name: "User Name", userAvatar: "")
119+
}
120+
103121
func login(username: String, password: String) async throws -> User {
104122
User(id: 1, username: "User", email: "email@gmail.com", name: "User Name", userAvatar: "")
105123
}

Core/Core/Domain/AuthInteractor.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@
66
//
77

88
import Foundation
9+
import OAuthSwift
910

1011
//sourcery: AutoMockable
1112
public protocol AuthInteractorProtocol {
1213
@discardableResult
14+
func login(credential: OAuthSwiftCredential) async throws -> User
1315
func login(username: String, password: String) async throws -> User
1416
func resetPassword(email: String) async throws -> ResetPassword
1517
func getCookies(force: Bool) async throws
@@ -26,6 +28,11 @@ public class AuthInteractor: AuthInteractorProtocol {
2628
self.repository = repository
2729
}
2830

31+
@discardableResult
32+
public func login(credential: OAuthSwiftCredential) async throws -> User {
33+
return try await repository.login(credential: credential)
34+
}
35+
2936
@discardableResult
3037
public func login(username: String, password: String) async throws -> User {
3138
return try await repository.login(username: username, password: password)

OpenEdX/AppDelegate.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import FirebaseCore
1212
import FirebaseAnalytics
1313
import FirebaseCrashlytics
1414
import Profile
15+
import OAuthSwift
1516

1617
@UIApplicationMain
1718
class AppDelegate: UIResponder, UIApplicationDelegate {
@@ -55,6 +56,15 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
5556
return true
5657
}
5758

59+
func application(
60+
_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]
61+
) -> Bool {
62+
if url.host == "oauth2Callback" {
63+
OAuthSwift.handle(url: url)
64+
}
65+
return true
66+
}
67+
5868
func application(
5969
_ application: UIApplication,
6070
supportedInterfaceOrientationsFor window: UIWindow?

OpenEdX/DI/AppAssembly.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,11 @@ class AppAssembly: Assembly {
105105
}.inObjectScope(.container)
106106

107107
container.register(Config.self) { _ in
108-
Config(baseURL: BuildConfiguration.shared.baseURL, oAuthClientId: BuildConfiguration.shared.clientId)
108+
Config(
109+
baseURL: BuildConfiguration.shared.baseURL,
110+
oAuthClientId: BuildConfiguration.shared.clientId,
111+
webLogin: BuildConfiguration.shared.webLogin
112+
)
109113
}.inObjectScope(.container)
110114

111115
container.register(CSSInjector.self) { _ in

OpenEdX/DI/ScreenAssembly.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ class ScreenAssembly: Assembly {
4848
interactor: r.resolve(AuthInteractorProtocol.self)!,
4949
router: r.resolve(AuthorizationRouter.self)!,
5050
analytics: r.resolve(AuthorizationAnalytics.self)!,
51+
config: r.resolve(Config.self)!,
5152
validator: r.resolve(Validator.self)!
5253
)
5354
}

OpenEdX/Environment.swift

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,23 @@ class BuildConfiguration {
4646
return "PROD_CLIENT_ID"
4747
}
4848
}
49+
50+
/* Set this to true if you are using an authentication provider that
51+
requires your learners to visit their login page. In this case,
52+
the existing app interface for login will be ignored, and the
53+
learner will be directed to a web view bringing up the LMS's login
54+
flow, redirecting to your provider as needed.
55+
56+
Note that in order for this to work, you must add a redirect URL in
57+
your OAuth2 app settings that matches the URI
58+
com.bundle.app://oauth2Callback where com.bundle.app is your app
59+
bundle name. You must also set your Django settings in Open edX to
60+
allow for your bundle name as a protocol for redirects. This setting
61+
can be found within the OAUTH2_PROVIDER dictionary in your settings.
62+
The key, ALLOWED_REDIRECT_URI_SCHEMES, should be set to something
63+
like ['https', 'com.bundle.app'], again, where com.bundle.app is the
64+
bundle name for your app. */
65+
var webLogin: Bool = false
4966

5067
var firebaseOptions: FirebaseOptions {
5168
switch environment {

0 commit comments

Comments
 (0)