Add complete OAuth 2.0 Authorization Code Flow with PKCE as alternative to API token authentication, with automatic server detection and graceful fallback to classic login. **OAuth Core (RFC 7636 PKCE):** - PKCEGenerator: S256 challenge generation for secure code exchange - OAuth DTOs: Client registration, token request/response models - OAuthClient, OAuthToken, AuthenticationMethod domain models - API.swift: registerOAuthClient() and exchangeOAuthToken() endpoints - OAuthRepository + POAuthRepository protocol **Browser Integration (ASWebAuthenticationSession):** - OAuthSession: Wraps native authentication session - OAuthFlowCoordinator: Orchestrates 5-phase OAuth flow - readeck:// URL scheme for OAuth callback handling - State verification for CSRF protection - User cancellation handling **Token Management:** - KeychainHelper: OAuth token storage alongside API tokens - TokenProvider: getOAuthToken(), setOAuthToken(), getAuthMethod() - AuthenticationMethod enum to distinguish token types - AuthRepository: loginWithOAuth(), getAuthenticationMethod() - Endpoint persistence in both Keychain and Settings **Server Feature Detection:** - ServerInfo extended with features array and supportsOAuth flag - GET /api/info endpoint integration (backward compatible) - GetServerInfoUseCase with optional endpoint parameter **User Profile Integration:** - ProfileApiClient: Fetch user data via GET /api/profile - UserProfileDto with username, email, provider information - GetUserProfileUseCase: Extract username from profile - Username saved and displayed for OAuth users (like classic auth) **Automatic OAuth Flow (No User Selection):** - OnboardingServerView: 2-phase flow (endpoint → auto-OAuth or classic) - OAuth attempted automatically if server supports it - Fallback to username/password on OAuth failure or unsupported - SettingsServerViewModel: checkServerOAuthSupport(), loginWithOAuth() **Cleanup & Refactoring:** - Remove all #if os(iOS) && !APP_EXTENSION conditionals - Remove LoginMethodSelectionView (no longer needed) - Remove switchToClassicLogin() method - Factories updated with OAuth dependencies **Testing:** - PKCEGeneratorTests: Verify RFC 7636 compliance - ServerInfoTests: Feature detection and backward compatibility - Mock implementations for all OAuth components **Documentation:** - docs/OAuth2-Implementation-Plan.md: Complete implementation guide - openapi.json: Readeck API specification **Scopes Requested:** - bookmarks:read, bookmarks:write, profile:read OAuth users now have full feature parity with classic authentication. Server auto-detects OAuth support via /info endpoint. Seamless UX with browser-based login and automatic fallback.
123 lines
3.8 KiB
Swift
123 lines
3.8 KiB
Swift
//
|
|
// OAuthFlowCoordinator.swift
|
|
// readeck
|
|
//
|
|
// Created by Ilyas Hallak on 16.12.25.
|
|
//
|
|
|
|
import Foundation
|
|
|
|
/// Coordinates the complete OAuth 2.0 flow from start to finish
|
|
@MainActor
|
|
class OAuthFlowCoordinator {
|
|
private let manager: OAuthManager
|
|
private let session: OAuthSession
|
|
private let logger = Logger.network
|
|
|
|
// Temporary storage for OAuth flow state
|
|
private var currentClient: OAuthClient?
|
|
private var currentVerifier: String?
|
|
private var currentState: String?
|
|
private var currentEndpoint: String?
|
|
|
|
init(manager: OAuthManager) {
|
|
self.manager = manager
|
|
self.session = OAuthSession()
|
|
}
|
|
|
|
/// Executes the complete OAuth flow
|
|
/// - Parameter endpoint: Server endpoint URL
|
|
/// - Returns: OAuth access token
|
|
func executeOAuthFlow(endpoint: String) async throws -> OAuthToken {
|
|
logger.info("🔐 Starting OAuth flow for endpoint: \(endpoint)")
|
|
|
|
// Phase 1: Register client and generate PKCE
|
|
logger.info("Phase 1: Registering OAuth client...")
|
|
let (client, verifier, challenge, state) = try await manager.startOAuthFlow(endpoint: endpoint)
|
|
|
|
// Store state for later use
|
|
self.currentClient = client
|
|
self.currentVerifier = verifier
|
|
self.currentState = state
|
|
self.currentEndpoint = endpoint
|
|
|
|
logger.info("✅ Client registered: \(client.clientId)")
|
|
logger.info("🔑 PKCE challenge generated")
|
|
|
|
// Phase 2: Build authorization URL
|
|
guard let authURL = manager.buildAuthorizationURL(
|
|
endpoint: endpoint,
|
|
clientId: client.clientId,
|
|
codeChallenge: challenge,
|
|
state: state
|
|
) else {
|
|
logger.error("Failed to build authorization URL")
|
|
throw OAuthError.invalidCallback
|
|
}
|
|
|
|
logger.info("🌐 Authorization URL: \(authURL.absoluteString)")
|
|
|
|
// Phase 3: Open browser for user authentication
|
|
logger.info("Phase 2: Opening browser for user authentication...")
|
|
let callbackURL = try await withCheckedThrowingContinuation { continuation in
|
|
session.start(
|
|
url: authURL,
|
|
callbackURLScheme: "readeck"
|
|
) { result in
|
|
continuation.resume(with: result)
|
|
}
|
|
}
|
|
|
|
logger.info("✅ Received callback: \(callbackURL.absoluteString)")
|
|
|
|
// Phase 4: Parse callback URL
|
|
guard let (code, receivedState) = OAuthManager.parseCallbackURL(callbackURL) else {
|
|
logger.error("Failed to parse callback URL")
|
|
throw OAuthError.invalidCallback
|
|
}
|
|
|
|
logger.info("📋 Authorization code received")
|
|
logger.info("🔐 State verification...")
|
|
|
|
// Phase 5: Exchange code for token
|
|
guard let savedState = currentState,
|
|
let savedVerifier = currentVerifier,
|
|
let savedClient = currentClient else {
|
|
logger.error("OAuth flow state was lost")
|
|
throw OAuthError.invalidCallback
|
|
}
|
|
|
|
logger.info("Phase 3: Exchanging authorization code for access token...")
|
|
let token = try await manager.completeOAuthFlow(
|
|
endpoint: endpoint,
|
|
clientId: savedClient.clientId,
|
|
code: code,
|
|
codeVerifier: savedVerifier,
|
|
receivedState: receivedState,
|
|
expectedState: savedState
|
|
)
|
|
|
|
logger.info("✅ Access token obtained successfully")
|
|
logger.info("🎉 OAuth flow completed!")
|
|
|
|
// Clean up state
|
|
cleanup()
|
|
|
|
return token
|
|
}
|
|
|
|
/// Cancels the ongoing OAuth flow
|
|
func cancelFlow() {
|
|
logger.info("❌ Cancelling OAuth flow")
|
|
session.cancel()
|
|
cleanup()
|
|
}
|
|
|
|
private func cleanup() {
|
|
currentClient = nil
|
|
currentVerifier = nil
|
|
currentState = nil
|
|
currentEndpoint = nil
|
|
}
|
|
}
|