Skip to content

Commit

Permalink
Authentication – Refresh, persistence & API (#681)
Browse files Browse the repository at this point in the history
* 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
mohannad-hassan authored Jan 25, 2025
1 parent 3d34dad commit 313e3a1
Show file tree
Hide file tree
Showing 16 changed files with 951 additions and 137 deletions.
46 changes: 46 additions & 0 deletions Core/OAuthService/OAuthService.swift
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 Core/OAuthServiceAppAuthImpl/OAuthServiceAppAuthImpl.swift
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)
}
}
}
}
}
105 changes: 105 additions & 0 deletions Core/OAuthServiceFake/OAuthServiceFake.swift
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
}
}
Loading

0 comments on commit 313e3a1

Please sign in to comment.