-
Notifications
You must be signed in to change notification settings - Fork 162
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Authentication – Refresh, persistence & API (#681)
* Create AppAuthCaller to wrap AppAuth calls * Wrap OIDAuthState within AuthenticationData * Persist the tokens after logging in * Provide a way to restoreState for AuthentincationDataManager * Authenticate requests in AuthentincationDataManager * Make AuthentincationDataManager expose an authentication state * Rename AppAuthOAuthClient to AuthentincationDataManagerImpl * checking in package resolution for AppAuth-iOS * Rename AuthentincationDataManager to AuthenticationClient * Create OAuthService and AppAuthOAuthService * Refactor AuthentincationClientImpl to use the new structure of OAuth service * Refactor AuthenticationClient to assume configurations always set * Convert AuthentincationClientImpl to an actor to avoid possible data races * Move OAuthService to a Core package * Update data if data already exists in Persistence * Refactor KeychainPersistance to use an abstraction for keychain access * Extract KeychainAccess and Keychain KeychainAccessFake to SystemDependencies * Extract SecurePersistence in a separate Core package * Extract AppAuthOAuthService to a separate Core package * Extract OAuthServiceFake into a separate Core package * Make AppAuthStateData an internal declaration * Move OAuth app configurations from OAuthService to OAuthServiceAppAuthImpl
- Loading branch information
1 parent
3d34dad
commit 313e3a1
Showing
16 changed files
with
951 additions
and
137 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,46 @@ | ||
// | ||
// 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 { | ||
/// Attempts to discover the authentication services and redirects the user to the authentication service. | ||
func login(on viewController: UIViewController) async throws -> OAuthStateData | ||
|
||
func getAccessToken(using data: OAuthStateData) async throws -> (String, OAuthStateData) | ||
|
||
func refreshAccessTokenIfNeeded(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 | ||
} |
173 changes: 173 additions & 0 deletions
173
Core/OAuthServiceAppAuthImpl/OAuthServiceAppAuthImpl.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,173 @@ | ||
// | ||
// OAuthServiceAppAuthImpl.swift | ||
// QuranEngine | ||
// | ||
// Created by Mohannad Hassan on 08/01/2025. | ||
// | ||
|
||
import AppAuth | ||
import OAuthService | ||
import UIKit | ||
import VLogging | ||
|
||
public struct AppAuthConfiguration { | ||
public let clientID: String | ||
public let redirectURL: URL | ||
/// 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 | ||
} | ||
} | ||
|
||
struct AppAuthStateData: OAuthStateData { | ||
let state: OIDAuthState | ||
|
||
public var isAuthorized: Bool { state.isAuthorized } | ||
} | ||
|
||
public struct OAuthStateEncoderAppAuthImpl: OAuthStateDataEncoder { | ||
public init() { } | ||
|
||
public 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 | ||
} | ||
|
||
public 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) | ||
} | ||
} | ||
|
||
public final class OAuthServiceAppAuthImpl: OAuthService { | ||
// MARK: Lifecycle | ||
|
||
public init(configurations: AppAuthConfiguration) { | ||
self.configurations = configurations | ||
} | ||
|
||
// MARK: Public | ||
|
||
public func login(on viewController: UIViewController) async throws -> any OAuthStateData { | ||
let serviceConfiguration = try await discoverConfiguration(forIssuer: configurations.authorizationIssuerURL) | ||
let state = try await login( | ||
withServiceConfiguration: serviceConfiguration, | ||
appConfiguration: configurations, | ||
on: viewController | ||
) | ||
return AppAuthStateData(state: state) | ||
} | ||
|
||
public 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)) | ||
} | ||
} | ||
} | ||
|
||
public func refreshAccessTokenIfNeeded(data: any OAuthStateData) async throws -> any OAuthStateData { | ||
try await getAccessToken(using: data).1 | ||
} | ||
|
||
// MARK: Private | ||
|
||
private let configurations: AppAuthConfiguration | ||
|
||
// 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( | ||
withServiceConfiguration serviceConfiguration: OIDServiceConfiguration, | ||
appConfiguration: AppAuthConfiguration, | ||
on viewController: UIViewController | ||
) async throws -> OIDAuthState { | ||
let scopes = [OIDScopeOpenID, OIDScopeProfile] + appConfiguration.scopes | ||
let request = OIDAuthorizationRequest( | ||
configuration: serviceConfiguration, | ||
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) | ||
} | ||
} | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,105 @@ | ||
// | ||
// OAuthServiceFake.swift | ||
// QuranEngine | ||
// | ||
// Created by Mohannad Hassan on 22/01/2025. | ||
// | ||
|
||
import OAuthService | ||
import UIKit | ||
|
||
public struct OAuthStateEncoderFake: OAuthStateDataEncoder { | ||
public init() {} | ||
|
||
public func encode(_ data: any OAuthStateData) throws -> Data { | ||
guard let data = data as? OAuthStateDataFake else { | ||
fatalError() | ||
} | ||
return try JSONEncoder().encode(data) | ||
} | ||
|
||
public func decode(_ data: Data) throws -> any OAuthStateData { | ||
try JSONDecoder().decode(OAuthStateDataFake.self, from: data) | ||
} | ||
} | ||
|
||
public final class OAuthServiceFake: OAuthService { | ||
public 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 | ||
} | ||
} | ||
} | ||
|
||
public init() {} | ||
|
||
public var loginResult: Result<OAuthStateData, Error>? | ||
|
||
public func login(on viewController: UIViewController) async throws -> any OAuthStateData { | ||
try loginResult!.get() | ||
} | ||
|
||
public var accessTokenRefreshBehavior: AccessTokenBehavior? | ||
|
||
public func getAccessToken(using data: any OAuthStateData) async throws -> (String, any OAuthStateData) { | ||
guard let behavior = accessTokenRefreshBehavior else { | ||
fatalError() | ||
} | ||
return (try behavior.getToken(), try behavior.getStateData() ?? data) | ||
} | ||
|
||
public func refreshAccessTokenIfNeeded(data: any OAuthStateData) async throws -> any OAuthStateData { | ||
try await getAccessToken(using: data).1 | ||
} | ||
} | ||
|
||
public final class OAuthStateDataFake: Equatable, Codable, OAuthStateData { | ||
enum Codingkey: String, CodingKey { | ||
case accessToken | ||
} | ||
|
||
public var accessToken: String? { | ||
didSet { | ||
guard oldValue != nil else { return } | ||
} | ||
} | ||
|
||
public init() { } | ||
|
||
public required init(from decoder: any Decoder) throws { | ||
let container = try decoder.container(keyedBy: Codingkey.self) | ||
accessToken = try container.decode(String.self, forKey: .accessToken) | ||
} | ||
|
||
public func encode(to encoder: any Encoder) throws { | ||
var container = encoder.container(keyedBy: Codingkey.self) | ||
try container.encode(accessToken, forKey: .accessToken) | ||
} | ||
|
||
public var isAuthorized: Bool { | ||
accessToken != nil | ||
} | ||
|
||
public static func == (lhs: OAuthStateDataFake, rhs: OAuthStateDataFake) -> Bool { | ||
lhs.accessToken == rhs.accessToken | ||
} | ||
} |
Oops, something went wrong.