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

Closed
wants to merge 1 commit into from
Closed
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
@@ -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"
}
}
}
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
@@ -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

var useWebLogin: Bool {
return config.webLogin
}
var forceWebLogin: Bool {
return config.webLogin && !webLoginAttempted
}
var errorMessage: String? {
didSet {
withAnimation {
@@ -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,
@@ -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 {
6 changes: 6 additions & 0 deletions Core/Core/Configuration/Config/Config.swift
Original file line number Diff line number Diff line change
@@ -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 }
@@ -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"
@@ -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) ?? ""
}
18 changes: 18 additions & 0 deletions Core/Core/Data/Repository/AuthRepository.swift
Original file line number Diff line number Diff line change
@@ -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
@@ -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(
@@ -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@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: "")
}
7 changes: 7 additions & 0 deletions Core/Core/Domain/AuthInteractor.swift
Original file line number Diff line number Diff line change
@@ -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
@@ -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)
1 change: 1 addition & 0 deletions Core/Core/SwiftGen/Assets.swift
Original file line number Diff line number Diff line change
@@ -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")
5 changes: 5 additions & 0 deletions OpenEdX/AppDelegate.swift
Original file line number Diff line number Diff line change
@@ -16,6 +16,7 @@ import GoogleSignIn
import FacebookCore
import MSAL
import Theme
import OAuthSwift

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
@@ -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(
13 changes: 13 additions & 0 deletions OpenEdX/Info.plist
Original file line number Diff line number Diff line change
@@ -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>
10 changes: 9 additions & 1 deletion OpenEdX/RouteController.swift
Original file line number Diff line number Diff line change
@@ -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)!
10 changes: 8 additions & 2 deletions OpenEdX/Router.swift
Original file line number Diff line number Diff line change
@@ -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)
}
@@ -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)
}
1 change: 1 addition & 0 deletions Podfile
Original file line number Diff line number Diff line change
@@ -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
70 changes: 37 additions & 33 deletions Podfile.lock
Original file line number Diff line number Diff line change
@@ -1,67 +1,67 @@
PODS:
- Alamofire (5.8.0)
- FirebaseAnalytics (10.15.0):
- FirebaseAnalytics/AdIdSupport (= 10.15.0)
- Alamofire (5.7.1)
- FirebaseAnalytics (10.13.0):
- FirebaseAnalytics/AdIdSupport (= 10.13.0)
- FirebaseCore (~> 10.0)
- FirebaseInstallations (~> 10.0)
- GoogleUtilities/AppDelegateSwizzler (~> 7.11)
- GoogleUtilities/MethodSwizzler (~> 7.11)
- GoogleUtilities/Network (~> 7.11)
- "GoogleUtilities/NSData+zlib (~> 7.11)"
- nanopb (< 2.30910.0, >= 2.30908.0)
- FirebaseAnalytics/AdIdSupport (10.15.0):
- FirebaseAnalytics/AdIdSupport (10.13.0):
- FirebaseCore (~> 10.0)
- FirebaseInstallations (~> 10.0)
- GoogleAppMeasurement (= 10.15.0)
- GoogleAppMeasurement (= 10.13.0)
- GoogleUtilities/AppDelegateSwizzler (~> 7.11)
- GoogleUtilities/MethodSwizzler (~> 7.11)
- GoogleUtilities/Network (~> 7.11)
- "GoogleUtilities/NSData+zlib (~> 7.11)"
- nanopb (< 2.30910.0, >= 2.30908.0)
- FirebaseCore (10.15.0):
- FirebaseCore (10.13.0):
- FirebaseCoreInternal (~> 10.0)
- GoogleUtilities/Environment (~> 7.8)
- GoogleUtilities/Logger (~> 7.8)
- FirebaseCoreExtension (10.15.0):
- FirebaseCoreExtension (10.13.0):
- FirebaseCore (~> 10.0)
- FirebaseCoreInternal (10.15.0):
- FirebaseCoreInternal (10.13.0):
- "GoogleUtilities/NSData+zlib (~> 7.8)"
- FirebaseCrashlytics (10.15.0):
- FirebaseCrashlytics (10.13.0):
- FirebaseCore (~> 10.5)
- FirebaseInstallations (~> 10.0)
- FirebaseSessions (~> 10.5)
- GoogleDataTransport (~> 9.2)
- GoogleUtilities/Environment (~> 7.8)
- nanopb (< 2.30910.0, >= 2.30908.0)
- PromisesObjC (~> 2.1)
- FirebaseInstallations (10.15.0):
- FirebaseInstallations (10.13.0):
- FirebaseCore (~> 10.0)
- GoogleUtilities/Environment (~> 7.8)
- GoogleUtilities/UserDefaults (~> 7.8)
- PromisesObjC (~> 2.1)
- FirebaseSessions (10.15.0):
- FirebaseSessions (10.13.0):
- FirebaseCore (~> 10.5)
- FirebaseCoreExtension (~> 10.0)
- FirebaseInstallations (~> 10.0)
- GoogleDataTransport (~> 9.2)
- GoogleUtilities/Environment (~> 7.10)
- nanopb (< 2.30910.0, >= 2.30908.0)
- PromisesSwift (~> 2.1)
- GoogleAppMeasurement (10.15.0):
- GoogleAppMeasurement/AdIdSupport (= 10.15.0)
- GoogleAppMeasurement (10.13.0):
- GoogleAppMeasurement/AdIdSupport (= 10.13.0)
- GoogleUtilities/AppDelegateSwizzler (~> 7.11)
- GoogleUtilities/MethodSwizzler (~> 7.11)
- GoogleUtilities/Network (~> 7.11)
- "GoogleUtilities/NSData+zlib (~> 7.11)"
- nanopb (< 2.30910.0, >= 2.30908.0)
- GoogleAppMeasurement/AdIdSupport (10.15.0):
- GoogleAppMeasurement/WithoutAdIdSupport (= 10.15.0)
- GoogleAppMeasurement/AdIdSupport (10.13.0):
- GoogleAppMeasurement/WithoutAdIdSupport (= 10.13.0)
- GoogleUtilities/AppDelegateSwizzler (~> 7.11)
- GoogleUtilities/MethodSwizzler (~> 7.11)
- GoogleUtilities/Network (~> 7.11)
- "GoogleUtilities/NSData+zlib (~> 7.11)"
- nanopb (< 2.30910.0, >= 2.30908.0)
- GoogleAppMeasurement/WithoutAdIdSupport (10.15.0):
- GoogleAppMeasurement/WithoutAdIdSupport (10.13.0):
- GoogleUtilities/AppDelegateSwizzler (~> 7.11)
- GoogleUtilities/MethodSwizzler (~> 7.11)
- GoogleUtilities/Network (~> 7.11)
@@ -91,21 +91,22 @@ PODS:
- GoogleUtilities/UserDefaults (7.11.5):
- GoogleUtilities/Logger
- KeychainSwift (20.0.0)
- Kingfisher (7.9.1)
- Kingfisher (7.9.0)
- nanopb (2.30909.0):
- nanopb/decode (= 2.30909.0)
- nanopb/encode (= 2.30909.0)
- nanopb/decode (2.30909.0)
- nanopb/encode (2.30909.0)
- OAuthSwift (2.2.0)
- PromisesObjC (2.3.1)
- PromisesSwift (2.3.1):
- PromisesObjC (= 2.3.1)
- Sourcery (1.8.0):
- Sourcery/CLI-Only (= 1.8.0)
- Sourcery/CLI-Only (1.8.0)
- SwiftGen (6.6.2)
- SwiftLint (0.53.0)
- SwiftUIIntrospect (0.12.0)
- SwiftLint (0.52.4)
- SwiftUIIntrospect (0.11.0)
- SwiftyMocky (4.2.0):
- Sourcery (= 1.8.0)
- Swinject (2.8.3)
@@ -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
@@ -157,29 +160,30 @@ CHECKOUT OPTIONS:
:tag: 4.2.0

SPEC CHECKSUMS:
Alamofire: 0e92e751b3e9e66d7982db43919d01f313b8eb91
FirebaseAnalytics: 47cef43728f81a839cf1306576bdd77ffa2eac7e
FirebaseCore: 2cec518b43635f96afe7ac3a9c513e47558abd2e
FirebaseCoreExtension: d3f1ea3725fb41f56e8fbfb29eeaff54e7ffb8f6
FirebaseCoreInternal: 2f4bee5ed00301b5e56da0849268797a2dd31fb4
FirebaseCrashlytics: a83f26fb922a3fe181eb738fb4dcf0c92bba6455
FirebaseInstallations: cae95cab0f965ce05b805189de1d4c70b11c76fb
FirebaseSessions: ee59a7811bef4c15f65ef6472f3210faa293f9c8
GoogleAppMeasurement: 722db6550d1e6d552b08398b69a975ac61039338
Alamofire: 0123a34370cb170936ae79a8df46cc62b2edeb88
FirebaseAnalytics: 9a12e090ead49f8877ed8132ae920e3cbbd2fcd0
FirebaseCore: 9948a31ff2c6cf323f9b040068201a95d271b68d
FirebaseCoreExtension: ce60f9db46d83944cf444664d6d587474128eeca
FirebaseCoreInternal: b342e37cd4f5b4454ec34308f073420e7920858e
FirebaseCrashlytics: 4679fbc4768fcb4dd6f5533101841d40256d4475
FirebaseInstallations: b28af1b9f997f1a799efe818c94695a3728c352f
FirebaseSessions: 991fb4c20b3505eef125f7cbfa20a5b5b189c2a4
GoogleAppMeasurement: 3ae505b44174bcc0775f5c86cecc5826259fbb1e
GoogleDataTransport: 54dee9d48d14580407f8f5fbf2f496e92437a2f2
GoogleUtilities: 13e2c67ede716b8741c7989e26893d151b2b2084
KeychainSwift: 0ce6a4d13f7228054d1a71bb1b500448fb2ab837
Kingfisher: 1d14e9f59cbe19389f591c929000332bf70efd32
Kingfisher: 59f908b6d2f403b0a3e539debb0eec05cb27002c
nanopb: b552cce312b6c8484180ef47159bc0f65a1f0431
OAuthSwift: 75efbb5bd9a4b2b71a37bd7e986bf3f55ddd54c6
PromisesObjC: c50d2056b5253dadbd6c2bea79b0674bd5a52fa4
PromisesSwift: 28dca69a9c40779916ac2d6985a0192a5cb4a265
Sourcery: 6f5fe49b82b7e02e8c65560cbd52e1be67a1af2e
SwiftGen: 1366a7f71aeef49954ca5a63ba4bef6b0f24138c
SwiftLint: 5ce4d6a8ff83f1b5fd5ad5dbf30965d35af65e44
SwiftUIIntrospect: 89f443402f701a9197e9e54e3c2ed00b10c32e6d
SwiftLint: 1cc5cd61ba9bacb2194e340aeb47a2a37fda00b3
SwiftUIIntrospect: a45aa645cd07ac2b9045e0bfb1d16ea5dae00e67
SwiftyMocky: c5e96e4ff76ec6dbf5a5941aeb039b5a546954a0
Swinject: 893c9a543000ac2f10ee4cbaf0933c6992c935d5

PODFILE CHECKSUM: 544edab2f9ecc4ac18973fb8865f1d0613ec8a28
PODFILE CHECKSUM: e8de3540bc5ee1fcaaa308e04b4c76953fd839af

COCOAPODS: 1.13.0
COCOAPODS: 1.12.1