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

Open
wants to merge 45 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 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
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
45 changes: 45 additions & 0 deletions Core/OAuthService/OAuthService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
//
// 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 {
func login(on viewController: UIViewController) async throws -> OAuthStateData

func getAccessToken(using data: OAuthStateData) async throws -> (String, OAuthStateData)

func refreshIfNeeded(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
}
158 changes: 158 additions & 0 deletions Data/AuthenticationClient/Sources/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 Foundation
import OAuthService
import VLogging

struct AppAuthStateData: OAuthStateData {
let state: OIDAuthState

var isAuthorized: Bool { state.isAuthorized }
}

struct AppAuthStateEncoder: OAuthStateDataEncoder {
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
}

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

final class AppAuthOAuthService: OAuthService {
// MARK: Lifecycle

init(appConfigurations: AuthenticationClientConfiguration) {
self.appConfigurations = appConfigurations
}

// MARK: Internal

func login(on viewController: UIViewController) async throws -> any OAuthStateData {
// Quran.com relies on dicovering the service configuration from the issuer,
// and not using a static configuration.
let serviceConfiguration = try await discoverConfiguration(forIssuer: appConfigurations.authorizationIssuerURL)
let state = try await login(
withConfiguration: serviceConfiguration,
appConfiguration: appConfigurations,
on: viewController
)
return AppAuthStateData(state: state)
}

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

func refreshIfNeeded(data: any OAuthStateData) async throws -> any OAuthStateData {
try await getAccessToken(using: data).1
}

// MARK: Private

private let appConfigurations: AuthenticationClientConfiguration

// 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: AuthenticationClientConfiguration,
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)
}
}
}
}
}
65 changes: 65 additions & 0 deletions Data/AuthenticationClient/Sources/AuthenticationClient.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
//
// AuthenticationClient.swift
// QuranEngine
//
// Created by Mohannad Hassan on 19/12/2024.
//

import Foundation
import UIKit

public enum AuthenticationClientError: Error {
case errorAuthenticating(Error?)

/// Thrown when an operation, that needs authentication, is attempted while the client
/// hasn't been authenticated or if the client's access has been revoked.
case clientIsNotAuthenticated(Error?)
}

public enum AuthenticationState: Equatable {
/// No user is currently authenticated, or access has been revoked or is expired.
/// Logging in is availble and is required for further APIs.
case notAuthenticated

case authenticated
}

public struct AuthenticationClientConfiguration {
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]
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
}
}

/// Handles the OAuth flow to Quran.com
///
/// Expected to be configuered with the host app's OAuth configuration before further operations are attempted.
public protocol AuthenticationClient {
/// Performs the login flow to Quran.com
///
/// - Parameter viewController: The view controller to be used as base for presenting the login flow.
/// - Returns: Nothing is returned for now. The client may return the profile infromation in the future.
func login(on viewController: UIViewController) async throws

/// Returns `true` if the client is authenticated.
func restoreState() async throws -> Bool

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

var authenticationState: AuthenticationState { get async }
}

public enum AuthentincationClientBuilder {
public static func make(withConfigurations config: AuthenticationClientConfiguration) -> AuthenticationClient {
AuthenticationClientImpl(configurations: config)
}
}
Loading