-
Notifications
You must be signed in to change notification settings - Fork 162
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
OAuth Support #680
OAuth Support #680
Changes from all commits
452b97a
9979b23
0b485de
9ff61f5
bd080f4
a2d0cdb
ad298de
7172377
f5e5900
38efebd
0a7da60
7825940
9b0d1d1
6edbdc9
12ed7f5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,110 @@ | ||
// | ||
// AppAuthOAuthClient.swift | ||
// QuranEngine | ||
// | ||
// Created by Mohannad Hassan on 23/12/2024. | ||
// | ||
|
||
import AppAuth | ||
import Foundation | ||
import UIKit | ||
import VLogging | ||
|
||
public final class AppAuthOAuthClient: OAuthClient { | ||
// MARK: Lifecycle | ||
|
||
public init() {} | ||
|
||
// MARK: Public | ||
|
||
public func set(appConfiguration: OAuthAppConfiguration) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is it possible to remove this method and just have the configuration set in the init? This way we can make |
||
self.appConfiguration = appConfiguration | ||
} | ||
|
||
public func login(on viewController: UIViewController) async throws { | ||
guard let configuration = appConfiguration else { | ||
logger.error("login invoked without OAuth client configurations being set") | ||
throw OAuthClientError.oauthClientHasNotBeenSet | ||
} | ||
|
||
// Quran.com relies on dicovering the service configuration from the issuer, | ||
// and not using a static configuration. | ||
let serviceConfiguration = try await discoverConfiguration(forIssuer: configuration.authorizationIssuerURL) | ||
try await login( | ||
withConfiguration: serviceConfiguration, | ||
appConfiguration: configuration, | ||
on: viewController | ||
) | ||
} | ||
|
||
// MARK: Private | ||
|
||
// Needed mainly for retention. | ||
private var authFlow: (any OIDExternalUserAgentSession)? | ||
private var appConfiguration: OAuthAppConfiguration? | ||
|
||
// MARK: - Authenication Flow | ||
|
||
private func discoverConfiguration(forIssuer issuer: URL) async throws -> OIDServiceConfiguration { | ||
logger.info("Discovering configuration for OAuth") | ||
return try await withCheckedThrowingContinuation { continuation in | ||
OIDAuthorizationService | ||
.discoverConfiguration(forIssuer: issuer) { configuration, error in | ||
guard error == nil else { | ||
logger.error("Error fetching OAuth configuration: \(error!)") | ||
continuation.resume(throwing: OAuthClientError.errorFetchingConfiguration(error)) | ||
return | ||
} | ||
guard let configuration else { | ||
// This should not happen | ||
logger.error("Error fetching OAuth configuration: no configuration was loaded. An unexpected situtation.") | ||
continuation.resume(throwing: OAuthClientError.errorFetchingConfiguration(nil)) | ||
return | ||
} | ||
logger.info("OAuth configuration fetched successfully") | ||
continuation.resume(returning: configuration) | ||
} | ||
} | ||
} | ||
|
||
private func login( | ||
withConfiguration configuration: OIDServiceConfiguration, | ||
appConfiguration: OAuthAppConfiguration, | ||
on viewController: UIViewController | ||
) async throws { | ||
let scopes = [OIDScopeOpenID, OIDScopeProfile] + appConfiguration.scopes | ||
let request = OIDAuthorizationRequest( | ||
configuration: configuration, | ||
clientId: appConfiguration.clientID, | ||
clientSecret: nil, | ||
scopes: scopes, | ||
redirectURL: appConfiguration.redirectURL, | ||
responseType: OIDResponseTypeCode, | ||
additionalParameters: [:] | ||
) | ||
|
||
logger.info("Starting OAuth flow") | ||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, any Error>) in | ||
DispatchQueue.main.async { | ||
self.authFlow = OIDAuthState.authState( | ||
byPresenting: request, | ||
presenting: viewController | ||
) { [weak self] state, error in | ||
self?.authFlow = nil | ||
guard error == nil else { | ||
logger.error("Error authenticating: \(error!)") | ||
continuation.resume(throwing: OAuthClientError.errorAuthenticating(error)) | ||
return | ||
} | ||
guard let _ = state else { | ||
logger.error("Error authenticating: no state returned. An unexpected situtation.") | ||
continuation.resume(throwing: OAuthClientError.errorAuthenticating(nil)) | ||
return | ||
} | ||
logger.info("OAuth flow completed successfully") | ||
continuation.resume() | ||
} | ||
} | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,44 @@ | ||
// | ||
// OAuthClient.swift | ||
// QuranEngine | ||
// | ||
// Created by Mohannad Hassan on 19/12/2024. | ||
// | ||
|
||
import Foundation | ||
import UIKit | ||
|
||
public enum OAuthClientError: Error { | ||
case oauthClientHasNotBeenSet | ||
case errorFetchingConfiguration(Error?) | ||
case errorAuthenticating(Error?) | ||
} | ||
|
||
public struct OAuthAppConfiguration { | ||
public let clientID: String | ||
public let redirectURL: URL | ||
/// Indicates the Quran.com specific scopes to be requested by the app. | ||
/// The client requests the `offline` and `openid` scopes by default. | ||
public let scopes: [String] | ||
public let authorizationIssuerURL: URL | ||
|
||
public init(clientID: String, redirectURL: URL, scopes: [String], authorizationIssuerURL: URL) { | ||
self.clientID = clientID | ||
self.redirectURL = redirectURL | ||
self.scopes = scopes | ||
self.authorizationIssuerURL = authorizationIssuerURL | ||
} | ||
} | ||
|
||
/// Handles the OAuth flow to Quran.com | ||
/// | ||
/// Note that the connection relies on dicvoering the configuration from the issuer service. | ||
public protocol OAuthClient { | ||
func set(appConfiguration: OAuthAppConfiguration) | ||
|
||
/// Performs the login flow to Quran.com | ||
/// | ||
/// - Parameter viewController: The view controller to be used as base for presenting the login flow. | ||
/// - Returns: Nothing is returned for now. The client may return the profile infromation in the future. | ||
func login(on viewController: UIViewController) async throws | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
// | ||
// QuranProfileService.swift | ||
// QuranEngine | ||
// | ||
// Created by Mohannad Hassan on 23/12/2024. | ||
// | ||
|
||
import OAuthClient | ||
import UIKit | ||
|
||
public class QuranProfileService { | ||
private let oauthClient: OAuthClient | ||
|
||
public init(oauthClient: OAuthClient) { | ||
self.oauthClient = oauthClient | ||
} | ||
|
||
/// Performs the login flow to Quran.com | ||
/// | ||
/// - Parameter viewController: The view controller to be used as base for presenting the login flow. | ||
/// - Returns: Nothing is returned for now. The client may return the profile infromation in the future. | ||
public func login(on viewController: UIViewController) async throws { | ||
try await oauthClient.login(on: viewController) | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -13,6 +13,7 @@ import CoreDataPersistence | |
import Foundation | ||
import LastPagePersistence | ||
import NotePersistence | ||
import OAuthClient | ||
import PageBookmarkPersistence | ||
import ReadingService | ||
import UIKit | ||
|
@@ -35,6 +36,13 @@ class Container: AppDependencies { | |
private(set) lazy var lastPagePersistence: LastPagePersistence = CoreDataLastPagePersistence(stack: coreDataStack) | ||
private(set) lazy var pageBookmarkPersistence: PageBookmarkPersistence = CoreDataPageBookmarkPersistence(stack: coreDataStack) | ||
private(set) lazy var notePersistence: NotePersistence = CoreDataNotePersistence(stack: coreDataStack) | ||
private(set) lazy var oauthClient: any OAuthClient = { | ||
let client = AppAuthOAuthClient() | ||
if let config = Constant.QuranOAuthAppConfigurations { | ||
client.set(appConfiguration: config) | ||
} | ||
Comment on lines
+40
to
+43
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @mohamede1945 I thought of There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Actually, this approach makes more sense to me. However, I’d prefer not to create an OAuthClient at all, rather than creating one with a nil configuration. This way, it’s clear to the consumer that the app doesn’t have OAuth configured. I would also propagate this to the Profile service, ensuring the feature recognizes that there’s no Profile service and doesn’t display login controls. |
||
return client | ||
}() | ||
|
||
private(set) lazy var downloadManager: DownloadManager = { | ||
let configuration = URLSessionConfiguration.background(withIdentifier: "DownloadsBackgroundIdentifier") | ||
|
@@ -78,4 +86,7 @@ private enum Constant { | |
|
||
static let databasesURL = FileManager.documentsURL | ||
.appendingPathComponent("databases", isDirectory: true) | ||
|
||
/// If set, the Quran.com login will be enabled. | ||
static let QuranOAuthAppConfigurations: OAuthAppConfiguration? = nil | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It would be really really great to have some tests to validate the success cases and a couple of failure cases as well, please. This would not only help ensure the code works but also ensure it is testable as we add additional logic in the future, such as the state machine for automatically logging in a user at startup.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Definitely this will be handled next inshaa Allah. I usually go test-first, but for this PR the full responsibilities of the auth client weren't clear yet. As things stand,
AppAuthOAuthClient
is just a wrapper for the library.We have the following:
This structure is going to be the subject of the next PR, and with it, I'll resume the standard testing strategy.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As for the comment on managing the configuration along with the availability of the OAuth client, I'll take that into account while I handle the responsibilities mentioned above.
The Profile service will be facing the features.
The OAuth client will be facing other data clients, which I expect them to be used by the domain services (bookmarks, notes ...).
The question here is where to delegate the responsibility of knowing of the API availability. I think out understanding of this will need to evolve as we handle the cases of data syncing.