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

OAuth Support #680

Merged
merged 15 commits into from
Dec 25, 2024
Merged
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
110 changes: 110 additions & 0 deletions Data/OAuthClient/Sources/AppAuthOAuthClient.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
//
Copy link
Collaborator

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.

Copy link
Collaborator Author

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:

  • Token refresh & persistance
  • Signaling API availability
  • Authenticating the API requests
  • Signaling if OAuth is available (disables/removes the login option)

This structure is going to be the subject of the next PR, and with it, I'll resume the standard testing strategy.

Copy link
Collaborator Author

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.

// 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) {
Copy link
Collaborator

Choose a reason for hiding this comment

The 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 appConfiguration non-optional and remove the need for oauthClientHasNotBeenSet error.

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()
}
}
}
}
}
44 changes: 44 additions & 0 deletions Data/OAuthClient/Sources/OAuthClient.swift
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
}
25 changes: 25 additions & 0 deletions Domain/QuranProfileService/Sources/QuranProfileService.swift
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
@@ -1,5 +1,14 @@
{
"pins" : [
{
"identity" : "appauth-ios",
"kind" : "remoteSourceControl",
"location" : "https://github.com/openid/AppAuth-iOS.git",
"state" : {
"revision" : "2781038865a80e2c425a1da12cc1327bcd56501f",
"version" : "1.7.6"
}
},
{
"identity" : "combine-schedulers",
"kind" : "remoteSourceControl",
Expand Down
11 changes: 11 additions & 0 deletions Example/QuranEngineApp/Classes/Container.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import CoreDataPersistence
import Foundation
import LastPagePersistence
import NotePersistence
import OAuthClient
import PageBookmarkPersistence
import ReadingService
import UIKit
Expand All @@ -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
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mohamede1945 I thought of StartupLauncher for this.

Copy link
Collaborator

Choose a reason for hiding this comment

The 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")
Expand Down Expand Up @@ -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
}
3 changes: 3 additions & 0 deletions Features/AppDependencies/AppDependencies.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import BatchDownloader
import Foundation
import LastPagePersistence
import NotePersistence
import OAuthClient
import PageBookmarkPersistence
import QuranResources
import QuranTextKit
Expand All @@ -35,6 +36,8 @@ public protocol AppDependencies {
var lastPagePersistence: LastPagePersistence { get }
var notePersistence: NotePersistence { get }
var pageBookmarkPersistence: PageBookmarkPersistence { get }

var oauthClient: OAuthClient { get }
}

extension AppDependencies {
Expand Down
2 changes: 2 additions & 0 deletions Features/SettingsFeature/SettingsBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import AppDependencies
import AudioDownloadsFeature
import Localization
import QuranProfileService
import ReadingSelectorFeature
import SettingsService
import SwiftUI
Expand All @@ -29,6 +30,7 @@ public struct SettingsBuilder {
let viewModel = SettingsRootViewModel(
analytics: container.analytics,
reviewService: ReviewService(analytics: container.analytics),
quranProfileService: QuranProfileService(oauthClient: container.oauthClient),
audioDownloadsBuilder: AudioDownloadsBuilder(container: container),
translationsListBuilder: TranslationsListBuilder(container: container),
readingSelectorBuilder: ReadingSelectorBuilder(container: container),
Expand Down
16 changes: 14 additions & 2 deletions Features/SettingsFeature/SettingsRootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,15 @@ struct SettingsRootView: View {
shareApp: { viewModel.shareApp() },
writeReview: { viewModel.writeReview() },
contactUs: { viewModel.contactUs() },
navigateToDiagnotics: { viewModel.navigateToDiagnotics() }
navigateToDiagnotics: { viewModel.navigateToDiagnotics() },
loginAction: { await viewModel.loginToQuranCom() }
)
}
}

private struct SettingsRootViewUI: View {
@Binding var theme: Theme

let audioEnd: String
let navigateToAudioEndSelector: AsyncAction
let navigateToAudioManager: AsyncAction
Expand All @@ -41,6 +43,7 @@ private struct SettingsRootViewUI: View {
let writeReview: AsyncAction
let contactUs: AsyncAction
let navigateToDiagnotics: AsyncAction
let loginAction: AsyncAction

var body: some View {
NoorList {
Expand Down Expand Up @@ -108,6 +111,14 @@ private struct SettingsRootViewUI: View {
)
}

// TODO: Pending translations, and hiding if OAuth is not configured.
NoorBasicSection {
NoorListItem(
title: .text(l("Login with Quran.com")),
action: loginAction
)
}

NoorBasicSection {
NoorListItem(
image: .init(.debug),
Expand Down Expand Up @@ -135,7 +146,8 @@ struct SettingsRootView_Previews: PreviewProvider {
shareApp: {},
writeReview: {},
contactUs: {},
navigateToDiagnotics: {}
navigateToDiagnotics: {},
loginAction: {}
)
}
}
Expand Down
18 changes: 18 additions & 0 deletions Features/SettingsFeature/SettingsRootViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import Localization
import NoorUI
import QuranAudio
import QuranAudioKit
import QuranProfileService
import ReadingSelectorFeature
import SettingsService
import TranslationsFeature
Expand All @@ -26,6 +27,7 @@ final class SettingsRootViewModel: ObservableObject {
init(
analytics: AnalyticsLibrary,
reviewService: ReviewService,
quranProfileService: QuranProfileService,
audioDownloadsBuilder: AudioDownloadsBuilder,
translationsListBuilder: TranslationsListBuilder,
readingSelectorBuilder: ReadingSelectorBuilder,
Expand All @@ -36,6 +38,7 @@ final class SettingsRootViewModel: ObservableObject {
audioEnd = audioPreferences.audioEnd
self.analytics = analytics
self.reviewService = reviewService
self.quranProfileService = quranProfileService
self.audioDownloadsBuilder = audioDownloadsBuilder
self.translationsListBuilder = translationsListBuilder
self.readingSelectorBuilder = readingSelectorBuilder
Expand All @@ -50,6 +53,7 @@ final class SettingsRootViewModel: ObservableObject {

let analytics: AnalyticsLibrary
let reviewService: ReviewService
let quranProfileService: QuranProfileService
let audioDownloadsBuilder: AudioDownloadsBuilder
let translationsListBuilder: TranslationsListBuilder
let readingSelectorBuilder: ReadingSelectorBuilder
Expand Down Expand Up @@ -128,6 +132,20 @@ final class SettingsRootViewModel: ObservableObject {
navigationController?.pushViewController(viewController, animated: true)
}

func loginToQuranCom() async {
logger.info("Settings: Login to Quran.com")
guard let viewController = navigationController else {
return
}
do {
try await quranProfileService.login(on: viewController)
// TODO: Replace with the needed UI changes.
print("Login seems successful")
} catch {
logger.error("Failed to login to Quran.com: \(error)")
}
}

// MARK: Private

private func showSingleChoiceSelector<T: Hashable>(
Expand Down
Loading