-
Notifications
You must be signed in to change notification settings - Fork 192
Integrate mobile sync auth and bookmark sign-in UI #757
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
base: main
Are you sure you want to change the base?
Changes from 5 commits
0249595
b9b7466
974c7d0
700a2bc
20c90c7
33158b5
997cd86
1382001
44fb08d
1e4e99a
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,166 @@ | ||
| import Foundation | ||
| import UIKit | ||
| #if QURAN_SYNC | ||
| import KMPNativeCoroutinesAsync | ||
| import MobileSync | ||
| import VLogging | ||
| #endif | ||
|
|
||
| public final actor AuthenticationClientMobileSyncImpl: AuthenticationClient { | ||
| #if QURAN_SYNC | ||
|
|
||
| public init(session: MobileSyncSession) { | ||
| self.session = session | ||
| } | ||
|
|
||
| public init(configurations: AuthenticationClientConfiguration) { | ||
| session = MobileSyncSession(configurations: configurations) | ||
| } | ||
|
|
||
| // MARK: Public | ||
|
|
||
| public var authenticationState: AuthenticationState { | ||
| guard let authService = session.authService else { | ||
| return .notAuthenticated | ||
| } | ||
| return authService.isLoggedIn() ? .authenticated : .notAuthenticated | ||
| } | ||
|
|
||
| public func login(on _: UIViewController) async throws { | ||
| guard let authService = session.authService else { | ||
| throw AuthenticationClientError.clientIsNotAuthenticated(nil) | ||
| } | ||
|
|
||
| do { | ||
| _ = try await asyncFunction(for: authService.login()) | ||
|
||
| } catch { | ||
| logger.error("Failed to login via mobile sync: \(error)") | ||
| throw AuthenticationClientError.errorAuthenticating(error) | ||
| } | ||
| } | ||
|
|
||
| public func restoreState() async throws -> AuthenticationState { | ||
| guard let authService = session.authService else { | ||
| return .notAuthenticated | ||
| } | ||
|
|
||
| do { | ||
| _ = try await session.continuePendingLoginIfNeeded() | ||
| _ = try await asyncFunction(for: authService.refreshAccessTokenIfNeeded()) | ||
| return authenticationState | ||
| } catch { | ||
| logger.error("Failed to restore mobile sync auth state: \(error)") | ||
| throw AuthenticationClientError.clientIsNotAuthenticated(error) | ||
| } | ||
| } | ||
|
|
||
| public func logout() async throws { | ||
| guard let authService = session.authService else { | ||
| return | ||
| } | ||
|
|
||
| do { | ||
| _ = try await asyncFunction(for: authService.logout()) | ||
| } catch { | ||
| logger.error("Failed to logout via mobile sync: \(error)") | ||
| throw AuthenticationClientError.errorAuthenticating(error) | ||
| } | ||
| } | ||
|
|
||
| public func authenticate(request: URLRequest) async throws -> URLRequest { | ||
| let headers = try await getAuthenticationHeaders() | ||
| var request = request | ||
| for (field, value) in headers { | ||
| request.setValue(value, forHTTPHeaderField: field) | ||
| } | ||
| return request | ||
| } | ||
|
|
||
| public func getAuthenticationHeaders() async throws -> [String: String] { | ||
| guard let authService = session.authService else { | ||
| throw AuthenticationClientError.clientIsNotAuthenticated(nil) | ||
| } | ||
|
|
||
| do { | ||
| return try await withCheckedThrowingContinuation { continuation in | ||
| authService.getAuthHeaders { headers, error in | ||
| if let error { | ||
| continuation.resume(throwing: AuthenticationClientError.clientIsNotAuthenticated(error)) | ||
| } else { | ||
| continuation.resume(returning: headers ?? [:]) | ||
| } | ||
| } | ||
|
||
| } | ||
| } catch let error as AuthenticationClientError { | ||
| throw error | ||
| } catch { | ||
| throw AuthenticationClientError.clientIsNotAuthenticated(error) | ||
| } | ||
| } | ||
|
|
||
| // MARK: Private | ||
|
|
||
| private let session: MobileSyncSession | ||
|
|
||
| #else | ||
|
|
||
| public init(session: MobileSyncSession) { | ||
| if let configurations = session.configurations { | ||
| fallback = AuthenticationClientImpl(configurations: configurations) | ||
| } else { | ||
| fallback = nil | ||
| } | ||
| } | ||
|
|
||
| public init(configurations: AuthenticationClientConfiguration) { | ||
| fallback = AuthenticationClientImpl(configurations: configurations) | ||
| } | ||
|
|
||
| public var authenticationState: AuthenticationState { | ||
| get async { | ||
| guard let fallback else { | ||
| return .notAuthenticated | ||
| } | ||
| return await fallback.authenticationState | ||
| } | ||
| } | ||
|
|
||
| public func login(on viewController: UIViewController) async throws { | ||
| guard let fallback else { | ||
| throw AuthenticationClientError.clientIsNotAuthenticated(nil) | ||
| } | ||
| try await fallback.login(on: viewController) | ||
| } | ||
|
|
||
| public func restoreState() async throws -> AuthenticationState { | ||
| guard let fallback else { | ||
| return .notAuthenticated | ||
| } | ||
| return try await fallback.restoreState() | ||
| } | ||
|
|
||
| public func logout() async throws { | ||
| guard let fallback else { | ||
| return | ||
| } | ||
| try await fallback.logout() | ||
| } | ||
|
|
||
| public func authenticate(request: URLRequest) async throws -> URLRequest { | ||
| guard let fallback else { | ||
| throw AuthenticationClientError.clientIsNotAuthenticated(nil) | ||
| } | ||
| return try await fallback.authenticate(request: request) | ||
| } | ||
|
|
||
| public func getAuthenticationHeaders() async throws -> [String: String] { | ||
| guard let fallback else { | ||
| throw AuthenticationClientError.clientIsNotAuthenticated(nil) | ||
| } | ||
| return try await fallback.getAuthenticationHeaders() | ||
| } | ||
|
|
||
| private let fallback: AuthenticationClientImpl? | ||
|
|
||
| #endif | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -89,6 +89,16 @@ public final actor AuthenticationClientImpl: AuthenticationClient { | |
| return authenticationState | ||
| } | ||
|
|
||
| public func logout() async throws { | ||
|
Collaborator
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. FYI, we need to delete the previous impl in a separate PR inshaa'Allah. |
||
| stateData = nil | ||
| do { | ||
| try persistence.clearData(forKey: Self.persistenceKey) | ||
| } catch { | ||
| logger.error("Failed to clear authentication state on logout: \(error)") | ||
| throw AuthenticationClientError.errorAuthenticating(error) | ||
| } | ||
| } | ||
|
|
||
| public func authenticate(request: URLRequest) async throws -> URLRequest { | ||
| guard authenticationState == .authenticated, let stateData else { | ||
| logger.error("authenticate invoked without client being authenticated") | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,133 @@ | ||
| import Foundation | ||
| #if QURAN_SYNC | ||
| import MobileSync | ||
| #endif | ||
|
|
||
| public final class MobileSyncSession { | ||
| #if QURAN_SYNC | ||
|
|
||
| public init(configurations: AuthenticationClientConfiguration?) { | ||
|
||
| let driverFactory = DriverFactory() | ||
| let environment = Self.makeSynchronizationEnvironment(from: configurations) | ||
| let graph = SharedDependencyGraph.shared.doInit(driverFactory: driverFactory, environment: environment) | ||
| bookmarksRepository = graph.bookmarksRepository | ||
|
|
||
| guard let configurations else { | ||
| authService = nil | ||
| syncService = nil | ||
| oidcAuthRepository = nil | ||
| return | ||
| } | ||
|
|
||
| AuthFlowFactoryProvider.shared.doInitialize() | ||
|
|
||
| let authConfig = Self.makeAuthConfig(from: configurations) | ||
| let json = AuthModule.companion.provideJson() | ||
| let authSettings = AuthModule.companion.provideSettings() | ||
| let authHttpClient = AuthModule.companion.provideHttpClient(json: json, config: authConfig) | ||
| let oidcClient = AuthModule.companion.provideOpenIdConnectClient(config: authConfig, httpClient: authHttpClient) | ||
| let authStorage = AuthStorage(settings: authSettings, json: json) | ||
| let authNetworkDataSource = AuthNetworkDataSource(authConfig: authConfig, httpClient: authHttpClient) | ||
| let logger = KermitLogger.companion.withTag(tag: "quran-ios") | ||
| let oidcAuthRepository = OidcAuthRepository( | ||
| authConfig: authConfig, | ||
| authStorage: authStorage, | ||
| oidcClient: oidcClient, | ||
| networkDataSource: authNetworkDataSource, | ||
| logger: logger | ||
| ) | ||
| let authService = AuthService(authRepository: oidcAuthRepository) | ||
|
|
||
| self.authService = authService | ||
| self.oidcAuthRepository = oidcAuthRepository | ||
| syncService = SyncService( | ||
| authService: authService, | ||
| pipeline: graph.syncService.pipelineForIos, | ||
| environment: environment, | ||
| settings: SyncServiceKt.makeSettings() | ||
| ) | ||
| } | ||
|
|
||
| // MARK: Public | ||
|
|
||
| public let bookmarksRepository: any BookmarksRepository | ||
| public let authService: AuthService? | ||
| public let syncService: SyncService? | ||
|
|
||
| public func continuePendingLoginIfNeeded() async throws -> Bool { | ||
| guard let oidcAuthRepository else { | ||
| return false | ||
| } | ||
|
|
||
| let canContinue: Bool = try await Self.await { continuation in | ||
| oidcAuthRepository.canContinueLogin { result, error in | ||
| if let error { | ||
| continuation.resume(throwing: error) | ||
| } else { | ||
| continuation.resume(returning: result?.boolValue ?? false) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| guard canContinue else { | ||
| return false | ||
| } | ||
|
|
||
| try await Self.awaitVoid { continuation in | ||
| oidcAuthRepository.continueLogin { error in | ||
| if let error { | ||
| continuation.resume(throwing: error) | ||
| } else { | ||
| continuation.resume() | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return true | ||
| } | ||
|
|
||
| // MARK: Private | ||
|
|
||
| private let oidcAuthRepository: OidcAuthRepository? | ||
|
|
||
| private static func makeAuthConfig(from configurations: AuthenticationClientConfiguration) -> AuthConfig { | ||
| AuthConfig( | ||
| usePreProduction: isPreproductionIssuer(configurations.authorizationIssuerURL), | ||
| clientId: configurations.clientID, | ||
| clientSecret: configurations.clientSecret.isEmpty ? nil : configurations.clientSecret, | ||
| redirectUri: configurations.redirectURL.absoluteString, | ||
| postLogoutRedirectUri: configurations.redirectURL.absoluteString, | ||
| scopes: configurations.scopes | ||
| ) | ||
| } | ||
|
|
||
| private static func isPreproductionIssuer(_ url: URL) -> Bool { | ||
| let value = url.absoluteString.lowercased() | ||
| return value.contains("staging") || value.contains("preprod") || value.contains("prelive") || value.contains("dev") | ||
| } | ||
|
|
||
| private static func makeSynchronizationEnvironment(from configurations: AuthenticationClientConfiguration?) -> SynchronizationEnvironment { | ||
| let endpoint = configurations.map { isPreproductionIssuer($0.authorizationIssuerURL) } == true | ||
| ? "https://apis-prelive.quran.foundation/auth" | ||
| : "https://apis.quran.foundation/auth" | ||
| return SynchronizationEnvironment(endPointURL: endpoint) | ||
| } | ||
|
|
||
| private static func await<T>(_ work: @escaping (CheckedContinuation<T, Error>) -> Void) async throws -> T { | ||
| try await withCheckedThrowingContinuation(work) | ||
| } | ||
|
|
||
| private static func awaitVoid(_ work: @escaping (CheckedContinuation<Void, Error>) -> Void) async throws { | ||
| try await withCheckedThrowingContinuation(work) | ||
| } | ||
|
||
|
|
||
| #else | ||
|
|
||
| public init(configurations: AuthenticationClientConfiguration?) { | ||
| self.configurations = configurations | ||
| } | ||
|
|
||
| public let configurations: AuthenticationClientConfiguration? | ||
|
|
||
| #endif | ||
| } | ||
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.
While this looks like a good guard to prevent leaking the new code into the app, but in our situation we don't need that. The drawback is that it complicates the implementation. But it's fine to ship with this new code, we just want to guard the UI and simplify this implementation.