Skip to content
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

Authentication – Refresh, persistence & API #681

Merged
merged 63 commits into from
Jan 25, 2025
Merged
Show file tree
Hide file tree
Changes from 58 commits
Commits
Show all changes
63 commits
Select commit Hold shift + click to select a range
1f0adfa
Reanem OAuthClient to AuthentincationDataManager
mohannad-hassan Dec 26, 2024
af28e6e
Create AppAuthCaller to wrap AppAuth calls
mohannad-hassan Dec 26, 2024
17886fb
Refactor to use the new AppAuthCaller
mohannad-hassan Dec 26, 2024
6a2ee35
Create AppAuthOAuthClientTests in OAuthClient
mohannad-hassan Dec 27, 2024
a3f8cca
Wrap OIDAuthState within AuthenticationState
mohannad-hassan Dec 28, 2024
3f1af6b
Persist the tokens after logging in
mohannad-hassan Dec 28, 2024
a3a700d
Provide a way to restoreState for AuthentincationDataManager
mohannad-hassan Dec 30, 2024
b2614b7
Authenitcate rquests in AuthentincationDataManager
mohannad-hassan Dec 30, 2024
986408a
Rename AuthenticationState to AuthenticationData
mohannad-hassan Dec 30, 2024
f601c03
Make AuthentincationDataManager expose an authentication state
mohannad-hassan Dec 30, 2024
1637fc1
Persist updated state
mohannad-hassan Dec 30, 2024
b9b5598
Refresh authentication data manager on launch startup
mohannad-hassan Dec 30, 2024
008a93e
Rename AppAuthOAuthClient to AuthentincationDataManagerImpl
mohannad-hassan Dec 30, 2024
eaf968a
Organize errors and logs
mohannad-hassan Dec 31, 2024
458d7bb
checking in package resolution for AppAuth-iOS
mohannad-hassan Dec 31, 2024
9b71d61
Linting
mohannad-hassan Dec 31, 2024
b8aebcb
Remove configurations
mohannad-hassan Dec 31, 2024
a54ceed
Fix linting issues in LaunchStartup
mohannad-hassan Jan 1, 2025
9a4fd81
Documentation
mohannad-hassan Jan 1, 2025
c0f4c25
Rename some internal types for brevity
mohannad-hassan Jan 1, 2025
a5fa1a8
Rename OAuthClient package to AuthenticationClient
mohannad-hassan Jan 1, 2025
bc85910
Rename AuthentincationDataManager to AuthenticationClient
mohannad-hassan Jan 4, 2025
2fa50e6
Some cleanup in AuthenticationClientTests
mohannad-hassan Jan 4, 2025
90bd841
Revert changes in Features/AppStructureFeature -- Pending integration…
mohannad-hassan Jan 4, 2025
8049a8b
Fix a compilation issue
mohannad-hassan Jan 4, 2025
3cdc724
Convert AuthenticationData to be a protocol
mohannad-hassan Jan 5, 2025
f07289d
Provide configurations to AuthenticationClient on initialization
mohannad-hassan Jan 5, 2025
8f80afa
Some linting issues
mohannad-hassan Jan 5, 2025
4f7000a
Fix typo in Persistence's name
mohannad-hassan Jan 5, 2025
2731407
Fix compilation issues in AuthenticationClientTests
mohannad-hassan Jan 5, 2025
87d8060
Create OAuthService and AppAuthOAuthService
mohannad-hassan Jan 7, 2025
ef596f9
Refactor AuthentincationClientImpl to use the new structure of OAuth …
mohannad-hassan Jan 8, 2025
09a97f1
Remove OAuthCaller and AuthenticationData
mohannad-hassan Jan 8, 2025
5b96158
Refactor AuthenticationClient to assume configurations always set
mohannad-hassan Jan 8, 2025
a316eb1
Revise errors throwb by AppAuthOAuthService
mohannad-hassan Jan 8, 2025
ba45925
Capture some errors in AuthenticationClient
mohannad-hassan Jan 8, 2025
6bb436f
Convert AuthentincationClientImpl to an actor to avoid possible data …
mohannad-hassan Jan 9, 2025
bc1e8f9
Provide coverage for exceptional scenarios
mohannad-hassan Jan 10, 2025
2e2ebe2
PRovide some documentation to OAuthService
mohannad-hassan Jan 10, 2025
d5a4ecb
Move OAuthService to a Core package
mohannad-hassan Jan 10, 2025
d878504
Handle some exceptional scenarios for login in AuthentincationClientImpl
mohannad-hassan Jan 10, 2025
4cfe501
Linting
mohannad-hassan Jan 10, 2025
644cef1
Update AuthenticationClientConfiguration
mohannad-hassan Jan 10, 2025
7edc942
Update data if data already exists in Persistence
mohannad-hassan Jan 17, 2025
f813565
Linting
mohannad-hassan Jan 17, 2025
06786f1
Address some PR comments
mohannad-hassan Jan 20, 2025
4535c99
Refactor KeychainPersistance to use an abstraction for keychain access
mohannad-hassan Jan 21, 2025
7cec053
Extract KeychainAccess and Keychain KeychainAccessFake to SystemDepen…
mohannad-hassan Jan 21, 2025
f159c45
Refactor Persistence to make it keyed
mohannad-hassan Jan 21, 2025
a2f99f8
Extract SecurePersistence in a separate Core package
mohannad-hassan Jan 21, 2025
989f5b4
Linting
mohannad-hassan Jan 21, 2025
353999e
Linting SecurePersistence
mohannad-hassan Jan 21, 2025
4497234
Extract AppAuthOAuthService to a separate Core package
mohannad-hassan Jan 21, 2025
0ccf658
Refactor mocks in AuthenticationClientTests preparing for extraction
mohannad-hassan Jan 22, 2025
08ce882
Extract OAuthServiceFake into a separate Core package
mohannad-hassan Jan 22, 2025
b171b23
Linting
mohannad-hassan Jan 22, 2025
1277091
More linting
mohannad-hassan Jan 22, 2025
b029a44
Make AppAuthStateData an internal declaration
mohannad-hassan Jan 22, 2025
a79386f
Some cleanup
mohannad-hassan Jan 23, 2025
3d52487
Rename AppAuthOAuthService to OAuthServiceAppAuthImpl
mohannad-hassan Jan 23, 2025
d8a1bf6
Linting
mohannad-hassan Jan 23, 2025
950cdd0
Rename SecurityAccessFake to KeychainAccessFake
mohannad-hassan Jan 25, 2025
ebce80b
Move OAuth app configurations from OAuthService to OAuthServiceAppAut…
mohannad-hassan Jan 25, 2025
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
158 changes: 158 additions & 0 deletions Core/AppAuthOAuthService/AppAuthOAuthService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
//
// AppAuthOAuthService.swift
// QuranEngine
//
// Created by Mohannad Hassan on 08/01/2025.
//

import AppAuth
import OAuthService
import UIKit
import VLogging

struct AppAuthStateData: OAuthStateData {
let state: OIDAuthState

public var isAuthorized: Bool { state.isAuthorized }
}

public struct AppAuthStateEncoder: 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 AppAuthOAuthService: OAuthService {
// MARK: Lifecycle

public init(appConfigurations: OAuthServiceConfiguration) {
self.appConfigurations = appConfigurations
}

// MARK: Public

public func login(on viewController: UIViewController) async throws -> any OAuthStateData {
let serviceConfiguration = try await discoverConfiguration(forIssuer: appConfigurations.authorizationIssuerURL)
let state = try await login(
withConfiguration: serviceConfiguration,
appConfiguration: appConfigurations,
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 appConfigurations: OAuthServiceConfiguration

// 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: OAuthServiceConfiguration,
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)
}
}
}
}
}
64 changes: 64 additions & 0 deletions Core/OAuthService/OAuthService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
//
// OAuthService.swift
// QuranEngine
//
// Created by Mohannad Hassan on 08/01/2025.
//

import Foundation
import UIKit

public struct OAuthServiceConfiguration {
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 this needs to be part of the API, does it?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

It's practically a Parameters Object.

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]
/// Quran.com relies on dicovering the service configuration from the issuer,
/// and not using a static configuration.
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
}
}

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
}
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