Skip to content

Commit

Permalink
feat: Support web-based LMS OAuth.
Browse files Browse the repository at this point in the history
  • Loading branch information
Kelketek committed Aug 17, 2023
1 parent 1d1fdec commit 66aa34d
Show file tree
Hide file tree
Showing 15 changed files with 319 additions and 125 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ public enum LoginMethod: String {
case facebook = "Facebook"
case google = "Google"
case microsoft = "Microsoft"
case oauth2 = "Custom OAuth2"
}

//sourcery: AutoMockable
Expand Down
270 changes: 152 additions & 118 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 @@ -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 {
Expand All @@ -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
Expand Down
8 changes: 5 additions & 3 deletions Core/Core/Configuration/Config.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -20,22 +21,23 @@ public class Config {
URL(string: "\(baseURL.description)/privacy")
}()

public let feedbackEmail = "support@example.com"
public let feedbackEmail = "contact@yam-edu.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
}
}

// Mark - For testing and SwiftUI preview
#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
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 getCookies(force: Bool) async throws
func getRegistrationFields() async throws -> [PickerFields]
Expand All @@ -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(
Expand Down Expand Up @@ -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 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
func resetPassword(email: String) async throws -> ResetPassword
func getCookies(force: Bool) async throws
Expand All @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions OpenEdX/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import FirebaseCore
import FirebaseAnalytics
import FirebaseCrashlytics
import Profile
import OAuthSwift

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
Expand Down Expand Up @@ -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?
Expand Down
6 changes: 5 additions & 1 deletion OpenEdX/DI/AppAssembly.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions OpenEdX/DI/ScreenAssembly.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)!
)
}
Expand Down
17 changes: 17 additions & 0 deletions OpenEdX/Environment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
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
2 changes: 1 addition & 1 deletion OpenEdX/RouteController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 4 additions & 1 deletion OpenEdX/Router.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
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
6 changes: 5 additions & 1 deletion Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -138,6 +140,7 @@ SPEC REPOS:
- KeychainSwift
- Kingfisher
- nanopb
- OAuthSwift
- PromisesObjC
- PromisesSwift
- Sourcery
Expand Down Expand Up @@ -171,6 +174,7 @@ SPEC CHECKSUMS:
KeychainSwift: 0ce6a4d13f7228054d1a71bb1b500448fb2ab837
Kingfisher: 63f677311d36a3473f6b978584f8a3845d023dc5
nanopb: b552cce312b6c8484180ef47159bc0f65a1f0431
OAuthSwift: 75efbb5bd9a4b2b71a37bd7e986bf3f55ddd54c6
PromisesObjC: 09985d6d70fbe7878040aa746d78236e6946d2ef
PromisesSwift: cf9eb58666a43bbe007302226e510b16c1e10959
Sourcery: 6f5fe49b82b7e02e8c65560cbd52e1be67a1af2e
Expand All @@ -180,6 +184,6 @@ SPEC CHECKSUMS:
SwiftyMocky: c5e96e4ff76ec6dbf5a5941aeb039b5a546954a0
Swinject: 893c9a543000ac2f10ee4cbaf0933c6992c935d5

PODFILE CHECKSUM: 1639b311802f5d36686512914067b7221ff97a64
PODFILE CHECKSUM: 7f904e49661dbca074f48a4829503e048cce8fbb

COCOAPODS: 1.12.1

0 comments on commit 66aa34d

Please sign in to comment.