Skip to content

Commit

Permalink
OAuth Support (#680)
Browse files Browse the repository at this point in the history
* Create OAuthClient

* Create AppAuthOAuthClient

* Add AppAuth dependency and OAuthClient package

* Implement main steps in AppAuthOAuthClient

* Create QuranProfileService package

* Register OAuthClient in Container

* Perform login operation in SettingsFeature

* Perform web authorization call on main thread for BG main thread runtiem warning

* Cleanup and provide documentation

* Add some loggigns

* Cleanup

* Fix linting issues

* Add VLogging as a dependency for oauthclient

* Some minor cleanup in AppAuthOAuthClient

* cleanup
  • Loading branch information
mohannad-hassan authored Dec 25, 2024
1 parent 0f6a996 commit 3d34dad
Show file tree
Hide file tree
Showing 10 changed files with 252 additions and 2 deletions.
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 @@
//
// 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) {
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)
}
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

0 comments on commit 3d34dad

Please sign in to comment.