diff --git a/Core/OAuthService/OAuthService.swift b/Core/OAuthService/OAuthService.swift new file mode 100644 index 00000000..14f9d708 --- /dev/null +++ b/Core/OAuthService/OAuthService.swift @@ -0,0 +1,45 @@ +// +// OAuthService.swift +// QuranEngine +// +// Created by Mohannad Hassan on 08/01/2025. +// + +import Foundation +import UIKit + +public enum OAuthServiceError: Error { + case failedToRefreshTokens(Error?) + + case stateDataDecodingError(Error?) + + case failedToDiscoverService(Error?) + + case failedToAuthenticate(Error?) +} + +/// Encapsulates the OAuth state data. Should only be managed and mutated by `OAuthService.` +public protocol OAuthStateData { + var isAuthorized: Bool { get } +} + +/// An abstraction for handling the OAuth flow steps. +/// +/// The service is assumed not to have any internal state. It's the responsibility of the client of this service +/// to hold and persist the state data. Each call to the service returns an updated `OAuthStateData` +/// that reflects the latest state. +public protocol OAuthService { + func login(on viewController: UIViewController) async throws -> OAuthStateData + + func getAccessToken(using data: OAuthStateData) async throws -> (String, OAuthStateData) + + func refreshIfNeeded(data: OAuthStateData) async throws -> OAuthStateData +} + +/// Encodes and decodes the `OAuthStateData`. A convneience to hide the conforming `OAuthStateData` type +/// while preparing the state for persistence. +public protocol OAuthStateDataEncoder { + func encode(_ data: OAuthStateData) throws -> Data + + func decode(_ data: Data) throws -> OAuthStateData +} diff --git a/Data/AuthenticationClient/Sources/AppAuthOAuthService.swift b/Data/AuthenticationClient/Sources/AppAuthOAuthService.swift new file mode 100644 index 00000000..3d5ac443 --- /dev/null +++ b/Data/AuthenticationClient/Sources/AppAuthOAuthService.swift @@ -0,0 +1,158 @@ +// +// AppAuthOAuthService.swift +// QuranEngine +// +// Created by Mohannad Hassan on 08/01/2025. +// + +import AppAuth +import Foundation +import OAuthService +import VLogging + +struct AppAuthStateData: OAuthStateData { + let state: OIDAuthState + + var isAuthorized: Bool { state.isAuthorized } +} + +struct AppAuthStateEncoder: OAuthStateDataEncoder { + func encode(_ data: any OAuthStateData) throws -> Data { + guard let data = data as? AppAuthStateData else { + fatalError() + } + let encoded = try NSKeyedArchiver.archivedData( + withRootObject: data.state, + requiringSecureCoding: true + ) + return encoded + } + + func decode(_ data: Data) throws -> any OAuthStateData { + guard let state = try NSKeyedUnarchiver.unarchivedObject(ofClass: OIDAuthState.self, from: data) else { + throw OAuthServiceError.stateDataDecodingError(nil) + } + return AppAuthStateData(state: state) + } +} + +final class AppAuthOAuthService: OAuthService { + // MARK: Lifecycle + + init(appConfigurations: AuthenticationClientConfiguration) { + self.appConfigurations = appConfigurations + } + + // MARK: Internal + + func login(on viewController: UIViewController) async throws -> any OAuthStateData { + // Quran.com relies on dicovering the service configuration from the issuer, + // and not using a static configuration. + let serviceConfiguration = try await discoverConfiguration(forIssuer: appConfigurations.authorizationIssuerURL) + let state = try await login( + withConfiguration: serviceConfiguration, + appConfiguration: appConfigurations, + on: viewController + ) + return AppAuthStateData(state: state) + } + + func getAccessToken(using data: any OAuthStateData) async throws -> (String, any OAuthStateData) { + guard let data = data as? AppAuthStateData else { + // This should be a fatal error. + fatalError() + } + return try await withCheckedThrowingContinuation { continuation in + data.state.performAction { accessToken, clientID, error in + guard error == nil else { + logger.error("Failed to refresh tokens: \(error!)") + continuation.resume(throwing: OAuthServiceError.failedToRefreshTokens(error)) + return + } + guard let accessToken else { + logger.error("Failed to refresh tokens: No access token returned. An unexpected situation.") + continuation.resume(throwing: OAuthServiceError.failedToRefreshTokens(nil)) + return + } + let updatedData = AppAuthStateData(state: data.state) + continuation.resume(returning: (accessToken, updatedData)) + } + } + } + + func refreshIfNeeded(data: any OAuthStateData) async throws -> any OAuthStateData { + try await getAccessToken(using: data).1 + } + + // MARK: Private + + private let appConfigurations: AuthenticationClientConfiguration + + // Needed mainly for retention. + private var authFlow: (any OIDExternalUserAgentSession)? + + // 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: OAuthServiceError.failedToDiscoverService(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: OAuthServiceError.failedToDiscoverService(nil)) + return + } + logger.info("OAuth configuration fetched successfully") + continuation.resume(returning: configuration) + } + } + } + + private func login( + withConfiguration configuration: OIDServiceConfiguration, + appConfiguration: AuthenticationClientConfiguration, + on viewController: UIViewController + ) async throws -> OIDAuthState { + 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") + return try await withCheckedThrowingContinuation { continuation 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: OAuthServiceError.failedToAuthenticate(error)) + return + } + guard let state else { + logger.error("Error authenticating: no state returned. An unexpected situtation.") + continuation.resume(throwing: OAuthServiceError.failedToAuthenticate(nil)) + return + } + logger.info("OAuth flow completed successfully") + continuation.resume(returning: state) + } + } + } + } +} diff --git a/Data/AuthenticationClient/Sources/AuthenticationClient.swift b/Data/AuthenticationClient/Sources/AuthenticationClient.swift new file mode 100644 index 00000000..0f1a3c7d --- /dev/null +++ b/Data/AuthenticationClient/Sources/AuthenticationClient.swift @@ -0,0 +1,65 @@ +// +// AuthenticationClient.swift +// QuranEngine +// +// Created by Mohannad Hassan on 19/12/2024. +// + +import Foundation +import UIKit + +public enum AuthenticationClientError: Error { + case errorAuthenticating(Error?) + + /// Thrown when an operation, that needs authentication, is attempted while the client + /// hasn't been authenticated or if the client's access has been revoked. + case clientIsNotAuthenticated(Error?) +} + +public enum AuthenticationState: Equatable { + /// No user is currently authenticated, or access has been revoked or is expired. + /// Logging in is availble and is required for further APIs. + case notAuthenticated + + case authenticated +} + +public struct AuthenticationClientConfiguration { + 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 +/// +/// Expected to be configuered with the host app's OAuth configuration before further operations are attempted. +public protocol AuthenticationClient { + /// 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 + + /// Returns `true` if the client is authenticated. + func restoreState() async throws -> Bool + + func authenticate(request: URLRequest) async throws -> URLRequest + + var authenticationState: AuthenticationState { get async } +} + +public enum AuthentincationClientBuilder { + public static func make(withConfigurations config: AuthenticationClientConfiguration) -> AuthenticationClient { + AuthenticationClientImpl(configurations: config) + } +} diff --git a/Data/AuthenticationClient/Sources/AuthentincationClientImpl.swift b/Data/AuthenticationClient/Sources/AuthentincationClientImpl.swift new file mode 100644 index 00000000..15a22cd7 --- /dev/null +++ b/Data/AuthenticationClient/Sources/AuthentincationClientImpl.swift @@ -0,0 +1,147 @@ +// +// AuthentincationClientImpl.swift +// QuranEngine +// +// Created by Mohannad Hassan on 23/12/2024. +// + +import AppAuth +import Combine +import Foundation +import OAuthService +import UIKit +import VLogging + +final actor AuthenticationClientImpl: AuthenticationClient { + // MARK: Lifecycle + + init( + configurations: AuthenticationClientConfiguration, + oauthService: OAuthService, + encoder: OAuthStateDataEncoder, + persistence: Persistence + ) { + self.oauthService = oauthService + self.persistence = persistence + self.encoder = encoder + appConfiguration = configurations + } + + // MARK: Public + + public var authenticationState: AuthenticationState { + stateData?.isAuthorized == true ? .authenticated : .notAuthenticated + } + + public func login(on viewController: UIViewController) async throws { + do { + try persistence.clear() + logger.info("Cleared previous authentication state before login") + } catch { + // If persisting the new state works, this error should be of little concern. + logger.warning("Failed to clear previous authentication state before login: \(error)") + } + + let data: OAuthStateData + do { + data = try await oauthService.login(on: viewController) + stateData = data + logger.info("login succeeded with state. isAuthorized: \(data.isAuthorized)") + persist(data: data) + } catch { + logger.error("Failed to login: \(error)") + throw AuthenticationClientError.errorAuthenticating(error) + } + } + + public func restoreState() async throws -> Bool { + let persistedData: OAuthStateData + do { + if let data = try persistence.retrieve() { + persistedData = try encoder.decode(data) + } else { + logger.info("No previous authentication state found") + return false + } + } catch { + // Aside from requesting the user to share the diagnostic logs, there's no workaround for this. + logger.error("Failed to refresh the authentication state. Will default to unauthenticated: \(error)") + return false + } + + let newData: OAuthStateData + do { + newData = try await oauthService.refreshIfNeeded(data: persistedData) + } catch { + // We'll need to differentiate between two sets of errors here: + // - Connectivity and server errors. These should not change the authentication + // state. Instead, the clients of `AuthenticationClient` should retry. + // - Client errors. These should nullify the authentication state. + // + // For time sakes, we'll treat all errors as the latter. + logger.error("Failed to refresh the authentication state: \(error)") + throw AuthenticationClientError.clientIsNotAuthenticated(error) + } + stateData = newData + persist(data: newData) + return newData.isAuthorized + } + + public func authenticate(request: URLRequest) async throws -> URLRequest { + guard authenticationState == .authenticated, let stateData else { + logger.error("authenticate invoked without client being authenticated") + throw AuthenticationClientError.clientIsNotAuthenticated(nil) + } + let token: String + let data: OAuthStateData + do { + (token, data) = try await oauthService.getAccessToken(using: stateData) + } catch { + logger.error("Failed to get access token. Resetting to non-authenticated state: \(error)") + self.stateData = nil + throw AuthenticationClientError.clientIsNotAuthenticated(error) + } + + persist(data: data) + var request = request + request.setValue(token, forHTTPHeaderField: "x-auth-token") + request.setValue(appConfiguration.clientID, forHTTPHeaderField: "x-client-id") + return request + } + + // MARK: Private + + private let oauthService: OAuthService + private let encoder: OAuthStateDataEncoder + private let persistence: Persistence + + private var stateChangedCancellable: AnyCancellable? + + private var appConfiguration: AuthenticationClientConfiguration + + private var stateData: OAuthStateData? + + private func persist(data: OAuthStateData) { + do { + let data = try encoder.encode(data) + try persistence.persist(state: data) + } catch { + // If this happens, the state will not nullified so to keep the current session usable + // for the user. As for now, no workaround is in hand. + logger.error("Failed to persist authentication state. No workaround in hand.: \(error)") + } + } +} + +extension AuthenticationClientImpl { + public init(configurations: AuthenticationClientConfiguration) { + let service = AppAuthOAuthService(appConfigurations: configurations) + let encoder = AppAuthStateEncoder() + self.init( + configurations: configurations, + oauthService: service, + encoder: encoder, + persistence: KeychainPersistence() + ) + } +} diff --git a/Data/AuthenticationClient/Sources/Persistence.swift b/Data/AuthenticationClient/Sources/Persistence.swift new file mode 100644 index 00000000..8855b29d --- /dev/null +++ b/Data/AuthenticationClient/Sources/Persistence.swift @@ -0,0 +1,101 @@ +// +// Persistence.swift +// QuranEngine +// +// Created by Mohannad Hassan on 28/12/2024. +// + +import Foundation +import VLogging + +enum PersistenceError: Error { + case persistenceFailed + case retrievalFailed +} + +/// An abstraction for secure persistence of the authentication state. +protocol Persistence { + func persist(state: Data) throws + + func retrieve() throws -> Data? + + func clear() throws +} + +final class KeychainPersistence: Persistence { + // MARK: Internal + + func persist(state: Data) throws { + let addquery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: itemKey, + kSecValueData as String: state, + ] + let status = SecItemAdd(addquery as CFDictionary, nil) + if status == errSecDuplicateItem { + logger.info("State already exists, updating") + try update(state: state) + } else if status != errSecSuccess { + logger.error("Failed to persist state -- \(status) status") + throw PersistenceError.persistenceFailed + } + logger.info("State persisted successfully") + } + + func retrieve() throws -> Data? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: itemKey, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { + logger.info("No state found") + return nil + } else if status != errSecSuccess { + logger.error("Failed to retrieve state -- \(status) status") + throw PersistenceError.retrievalFailed + } + guard let data = result as? Data else { + logger.error("Invalid data type found") + throw PersistenceError.retrievalFailed + } + + return data + } + + func clear() throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: itemKey, + ] + + let status = SecItemDelete(query as CFDictionary) + if status != errSecSuccess && status != errSecItemNotFound { + logger.error("Failed to clear state -- \(status) status") + throw PersistenceError.persistenceFailed + } + } + + // MARK: Private + + private let itemKey = "com.quran.oauth.state" + + private func update(state: Data) throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: itemKey, + ] + let attributes: [String: Any] = [ + kSecValueData as String: state, + ] + let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + if status != errSecSuccess { + logger.error("Failed to update state -- \(status) status") + throw PersistenceError.persistenceFailed + } + logger.info("State updated") + } +} diff --git a/Data/AuthenticationClient/Tests/AuthenticationClientTests.swift b/Data/AuthenticationClient/Tests/AuthenticationClientTests.swift new file mode 100644 index 00000000..9896eeb1 --- /dev/null +++ b/Data/AuthenticationClient/Tests/AuthenticationClientTests.swift @@ -0,0 +1,302 @@ +// +// AuthenticationClientTests.swift +// QuranEngine +// +// Created by Mohannad Hassan on 26/12/2024. +// + +import AppAuth +import AsyncUtilitiesForTesting +import Combine +import Foundation +import OAuthService +import XCTest +@testable import AuthenticationClient + +final class AuthenticationClientTests: XCTestCase { + // MARK: Internal + + let configuration = AuthenticationClientConfiguration( + clientID: "client-id", + redirectURL: URL(string: "callback://")!, + scopes: [], + authorizationIssuerURL: URL(string: "https://example.com")! + ) + + override func setUp() { + encoder = OauthStateEncoderMock() + oauthService = OAuthServiceMock() + persistence = PersistenceMock() + sut = AuthenticationClientImpl( + configurations: configuration, + oauthService: oauthService, + encoder: encoder, + persistence: persistence + ) + } + + func testLoginSuccessful() async throws { + persistence.data = Data() + + let state = AutehenticationDataMock() + state.accessToken = "abcd" + oauthService.loginResult = .success(state) + oauthService.accessTokenBehavior = .success("abcd") + + try await sut.login(on: UIViewController()) + + XCTAssertTrue(persistence.clearCalled, "Expected to clear the persistence first") + await AsyncAssertEqual( + await sut.authenticationState, + .authenticated, + "Expected the auth manager to be in authenticated state" + ) + XCTAssertEqual( + try persistence.data.map(encoder.decode(_:)) as? AutehenticationDataMock, + state, + "Expected to persist the new state" + ) + } + + func testLoginFails() async throws { + persistence.data = nil + oauthService.loginResult = .failure(OAuthServiceError.failedToAuthenticate(nil)) + + await AsyncAssertThrows( + try await sut.login(on: UIViewController()), + nil, + "Expected to throw an error" + ) + await AsyncAssertEqual(await sut.authenticationState, .notAuthenticated, "Expected to not be authenticated") + } + + func testRestorationSuccessful() async throws { + let state = AutehenticationDataMock() + state.accessToken = "abcd" + persistence.data = try encoder.encode(state) + oauthService.refreshResult = .success(nil) + + try await AsyncAssertEqual(try await sut.restoreState(), true, "Expected to be signed in successfully") + await AsyncAssertEqual( + await sut.authenticationState, + .authenticated, + "Expected the auth manager to be in authenticated state" + ) + } + + func testRestorationButNotAuthenticated() async throws { + persistence.data = nil + + try await AsyncAssertEqual(try await sut.restoreState(), false, "Expected to not be signed in") + await AsyncAssertEqual(await sut.authenticationState, .notAuthenticated) + } + + func testRestorationFails() async throws { + persistence.retrievalResult = .failure(PersistenceError.retrievalFailed) + + try await AsyncAssertEqual(try await sut.restoreState(), false, "Expected to just return failure") + } + + func testRestorationFailsRefreshingSession() async throws { + let state = AutehenticationDataMock() + state.accessToken = "abcd" + persistence.data = try encoder.encode(state) + oauthService.refreshResult = .failure(OAuthServiceError.failedToRefreshTokens(nil)) + + try await AsyncAssertThrows(await { _ = try await sut.restoreState() }(), nil, "Expected to throw an error") + } + + func testAuthenticatingRequestsWithValidState() async throws { + let state = AutehenticationDataMock() + state.accessToken = "abcd" + persistence.data = try encoder.encode(state) + + oauthService.accessTokenBehavior = .success("abcd") + oauthService.refreshResult = .success(nil) + _ = try await sut.restoreState() + let inputRequest = URLRequest(url: URL(string: "https://example.com")!) + + let result = try await sut.authenticate(request: inputRequest) + + let authHeader = result.allHTTPHeaderFields?.first { $0.key.contains("auth-token") } + XCTAssertNotNil(authHeader, "Expected to return the access token") + XCTAssertTrue(authHeader?.value.contains(state.accessToken!) ?? false, "Expeccted to use the access token") + + let clientIDHeader = result.allHTTPHeaderFields?.first { $0.key.contains("client-id") } + XCTAssertNotNil(clientIDHeader, "Expected to return the client id") + XCTAssertTrue(clientIDHeader?.value.contains(configuration.clientID) ?? false, "Expeccted to use the client id") + } + + func testAuthenticatingRequestFailsGettingToken() async throws { + let state = AutehenticationDataMock() + state.accessToken = "abcd" + persistence.data = try encoder.encode(state) + + oauthService.refreshResult = .success(nil) + _ = try await sut.restoreState() + + let inputRequest = URLRequest(url: URL(string: "https://example.com")!) + + oauthService.accessTokenBehavior = .failure(OAuthServiceError.failedToRefreshTokens(nil)) + + try await AsyncAssertThrows( + await { _ = try await sut.authenticate(request: inputRequest) }(), + nil, + "Expected to throw an error as well." + ) + await AsyncAssertEqual( + await sut.authenticationState, + .notAuthenticated, + "Expected to signal not authenticated state" + ) + } + + func testRefreshedTokens() async throws { + let state = AutehenticationDataMock() + state.accessToken = "abcd" + persistence.data = try encoder.encode(state) + let newState = AutehenticationDataMock() + newState.accessToken = "xyz" + oauthService.refreshResult = .success(newState) + oauthService.accessTokenBehavior = .success("xyz") + _ = try await sut.restoreState() + + let decoded = try persistence.data.map(encoder.decode) as? AutehenticationDataMock + XCTAssertEqual( + decoded?.accessToken, + "xyz", + "Expected to persist the refreshed state" + ) + + let inputRequest = URLRequest(url: URL(string: "https://example.com")!) + let resultRequest = try await sut.authenticate(request: inputRequest) + let authHeader = resultRequest.allHTTPHeaderFields?.first { $0.key.contains("auth-token") } + XCTAssertEqual(authHeader?.value, "xyz", "Expected to use the refreshed access token for the request") + } + + // MARK: Private + + private var sut: AuthenticationClientImpl! + private var oauthService: OAuthServiceMock! + private var encoder: OAuthStateDataEncoder! + private var persistence: PersistenceMock! +} + +private struct OauthStateEncoderMock: OAuthStateDataEncoder { + func encode(_ data: any OAuthStateData) throws -> Data { + guard let data = data as? AutehenticationDataMock else { + fatalError() + } + return try JSONEncoder().encode(data) + } + + func decode(_ data: Data) throws -> any OAuthStateData { + try JSONDecoder().decode(AutehenticationDataMock.self, from: data) + } +} + +private final class OAuthServiceMock: OAuthService { + enum AccessTokenBehavior { + case success(String) + case successWithNewData(String, any OAuthStateData) + case failure(Error) + + func getToken() throws -> String { + switch self { + case .success(let token), .successWithNewData(let token, _): + return token + case .failure(let error): + throw error + } + } + + func getStateData() throws -> (any OAuthStateData)? { + switch self { + case .success: + return nil + case .successWithNewData(_, let data): + return data + case .failure(let error): + throw error + } + } + } + + var accessTokenBehavior: AccessTokenBehavior? + var refreshResult: Result<(any OAuthStateData)?, Error>? + + func getAccessToken(using data: any OAuthStateData) async throws -> (String, any OAuthStateData) { + guard let behavior = accessTokenBehavior else { + fatalError() + } + return (try behavior.getToken(), try behavior.getStateData() ?? data) + } + + var loginResult: Result? + + func login(on viewController: UIViewController) async throws -> any OAuthStateData { + try loginResult!.get() + } + + func refreshIfNeeded(data: any OAuthStateData) async throws -> any OAuthStateData { + guard let refreshResult else { + fatalError() + } + return try refreshResult.get() ?? data + } +} + +private final class AutehenticationDataMock: Equatable, Codable, OAuthStateData { + enum Codingkey: String, CodingKey { + case accessToken + } + + var accessToken: String? { + didSet { + guard oldValue != nil else { return } + } + } + + init() { } + + required init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: Codingkey.self) + accessToken = try container.decode(String.self, forKey: .accessToken) + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: Codingkey.self) + try container.encode(accessToken, forKey: .accessToken) + } + + var isAuthorized: Bool { + accessToken != nil + } + + static func == (lhs: AutehenticationDataMock, rhs: AutehenticationDataMock) -> Bool { + lhs.accessToken == rhs.accessToken + } +} + +private final class PersistenceMock: Persistence { + var clearCalled = false + var retrievalResult: Result? + var data: Data? + + func persist(state: Data) throws { + data = state + } + + func retrieve() throws -> Data? { + if let result = retrievalResult { + retrievalResult = nil + data = try result.get() + } + return data + } + + func clear() throws { + clearCalled = true + data = nil + } +} diff --git a/Data/OAuthClient/Sources/AppAuthOAuthClient.swift b/Data/OAuthClient/Sources/AppAuthOAuthClient.swift deleted file mode 100644 index 122a58d9..00000000 --- a/Data/OAuthClient/Sources/AppAuthOAuthClient.swift +++ /dev/null @@ -1,110 +0,0 @@ -// -// 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) 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() - } - } - } - } -} diff --git a/Data/OAuthClient/Sources/OAuthClient.swift b/Data/OAuthClient/Sources/OAuthClient.swift deleted file mode 100644 index e37bd0a2..00000000 --- a/Data/OAuthClient/Sources/OAuthClient.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// 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 -} diff --git a/Domain/QuranProfileService/Sources/QuranProfileService.swift b/Domain/QuranProfileService/Sources/QuranProfileService.swift index be21737b..5a3256fd 100644 --- a/Domain/QuranProfileService/Sources/QuranProfileService.swift +++ b/Domain/QuranProfileService/Sources/QuranProfileService.swift @@ -5,14 +5,14 @@ // Created by Mohannad Hassan on 23/12/2024. // -import OAuthClient +import AuthenticationClient import UIKit public class QuranProfileService { - private let oauthClient: OAuthClient + private let authenticationClient: AuthenticationClient? - public init(oauthClient: OAuthClient) { - self.oauthClient = oauthClient + public init(authenticationClient: AuthenticationClient?) { + self.authenticationClient = authenticationClient } /// Performs the login flow to Quran.com @@ -20,6 +20,6 @@ public class QuranProfileService { /// - 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) + try await authenticationClient?.login(on: viewController) } } diff --git a/Example/QuranEngineApp/Classes/Container.swift b/Example/QuranEngineApp/Classes/Container.swift index b27a94f9..00ad3286 100644 --- a/Example/QuranEngineApp/Classes/Container.swift +++ b/Example/QuranEngineApp/Classes/Container.swift @@ -7,13 +7,13 @@ import Analytics import AppDependencies +import AuthenticationClient import BatchDownloader import CoreDataModel import CoreDataPersistence import Foundation import LastPagePersistence import NotePersistence -import OAuthClient import PageBookmarkPersistence import ReadingService import UIKit @@ -36,11 +36,11 @@ 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) + private(set) lazy var authenticationClient: (any AuthenticationClient)? = { + guard let configurations = Constant.QuranOAuthAppConfigurations else { + return nil } + let client = AuthentincationClientBuilder.make(withConfigurations: configurations) return client }() @@ -88,5 +88,5 @@ private enum Constant { .appendingPathComponent("databases", isDirectory: true) /// If set, the Quran.com login will be enabled. - static let QuranOAuthAppConfigurations: OAuthAppConfiguration? = nil + static let QuranOAuthAppConfigurations: AuthenticationClientConfiguration? = nil } diff --git a/Features/AppDependencies/AppDependencies.swift b/Features/AppDependencies/AppDependencies.swift index 1f61a541..e34c4ca6 100644 --- a/Features/AppDependencies/AppDependencies.swift +++ b/Features/AppDependencies/AppDependencies.swift @@ -7,11 +7,11 @@ import Analytics import AnnotationsService +import AuthenticationClient import BatchDownloader import Foundation import LastPagePersistence import NotePersistence -import OAuthClient import PageBookmarkPersistence import QuranResources import QuranTextKit @@ -37,7 +37,7 @@ public protocol AppDependencies { var notePersistence: NotePersistence { get } var pageBookmarkPersistence: PageBookmarkPersistence { get } - var oauthClient: OAuthClient { get } + var authenticationClient: (any AuthenticationClient)? { get } } extension AppDependencies { diff --git a/Features/SettingsFeature/SettingsBuilder.swift b/Features/SettingsFeature/SettingsBuilder.swift index 42b01f58..a454c0f5 100644 --- a/Features/SettingsFeature/SettingsBuilder.swift +++ b/Features/SettingsFeature/SettingsBuilder.swift @@ -30,7 +30,7 @@ public struct SettingsBuilder { let viewModel = SettingsRootViewModel( analytics: container.analytics, reviewService: ReviewService(analytics: container.analytics), - quranProfileService: QuranProfileService(oauthClient: container.oauthClient), + quranProfileService: QuranProfileService(authenticationClient: container.authenticationClient), audioDownloadsBuilder: AudioDownloadsBuilder(container: container), translationsListBuilder: TranslationsListBuilder(container: container), readingSelectorBuilder: ReadingSelectorBuilder(container: container), diff --git a/Package.resolved b/Package.resolved index 2e4ee3aa..1e72529d 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,14 @@ { "pins" : [ + { + "identity" : "appauth-ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/openid/AppAuth-iOS", + "state" : { + "revision" : "2781038865a80e2c425a1da12cc1327bcd56501f", + "version" : "1.7.6" + } + }, { "identity" : "combine-schedulers", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 5b677c0e..283e18ba 100644 --- a/Package.swift +++ b/Package.swift @@ -129,6 +129,8 @@ private func coreTargets() -> [[Target]] { target(type, name: "AsyncUtilitiesForTesting", hasTests: false, dependencies: [ .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), ]), + + target(type, name: "OAuthService", hasTests: false, dependencies: []), ] } @@ -301,10 +303,11 @@ private func dataTargets() -> [[Target]] { // MARK: - Quran.com OAuth - target(type, name: "OAuthClient", hasTests: false, dependencies: [ + target(type, name: "AuthenticationClient", hasTests: true, dependencies: [ + "OAuthService", "VLogging", .product(name: "AppAuth", package: "AppAuth-iOS"), - ]), + ], testDependencies: ["AsyncUtilitiesForTesting"]), ] } @@ -470,7 +473,7 @@ private func domainTargets() -> [[Target]] { ]), target(type, name: "QuranProfileService", hasTests: false, dependencies: [ - "OAuthClient", + "AuthenticationClient", ]), ] } @@ -487,7 +490,7 @@ private func featuresTargets() -> [[Target]] { "LastPagePersistence", "ReadingService", "QuranResources", - "OAuthClient", + "AuthenticationClient", ]), target(type, name: "FeaturesSupport", hasTests: false, dependencies: [