Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
2 changes: 2 additions & 0 deletions Data/AuthenticationClient/Sources/AuthenticationClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ public protocol AuthenticationClient {

func restoreState() async throws -> AuthenticationState

func logout() async throws

func authenticate(request: URLRequest) async throws -> URLRequest

func getAuthenticationHeaders() async throws -> [String: String]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import Foundation
import UIKit
#if QURAN_SYNC
Copy link
Collaborator

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.

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

Choose a reason for hiding this comment

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

We need a way to hide these kotlin bridges (e.g. asyncFunction) behind the sync APIs. For example, creating a wrapper in the mobile-sync-spm package.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Basically, I don't want clients to import KMPNativeCoroutinesAsync, MobileSync should be enough, IMO.

} 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 ?? [:])
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

This looks like a missed asyncFunction, right?

}
} 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
}
10 changes: 10 additions & 0 deletions Data/AuthenticationClient/Sources/AuthentincationClientImpl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,16 @@ public final actor AuthenticationClientImpl: AuthenticationClient {
return authenticationState
}

public func logout() async throws {
Copy link
Collaborator

Choose a reason for hiding this comment

The 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")
Expand Down
133 changes: 133 additions & 0 deletions Data/AuthenticationClient/Sources/MobileSyncSession.swift
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?) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

I don't think it's valid to have a SyncSession with a nil configuration

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

Choose a reason for hiding this comment

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

Why are we adding these methods? They add no value and will be confusing when reading the call site


#else

public init(configurations: AuthenticationClientConfiguration?) {
self.configurations = configurations
}

public let configurations: AuthenticationClientConfiguration?

#endif
}
Loading
Loading