ReadKeep/readeck/Data/OAuth/OAuthFlowCoordinator.swift
Ilyas Hallak ec432a037c feat: Implement OAuth 2.0 authentication with PKCE and automatic fallback
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.
2025-12-19 21:56:40 +01:00

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