ReadKeep/readeck/Data/OAuth/OAuthManager.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

153 lines
5.0 KiB
Swift

//
// OAuthManager.swift
// readeck
//
// Created by Ilyas Hallak on 15.12.25.
//
import Foundation
/// Orchestrates the OAuth 2.0 Authorization Code flow with PKCE
class OAuthManager {
private let repository: POAuthRepository
private let logger = Logger.network
// OAuth configuration
static let redirectUri = "readeck://oauth-callback"
static let clientName = "Readeck iOS"
static let scopes = "bookmarks:read bookmarks:write profile:read"
init(repository: POAuthRepository) {
self.repository = repository
}
/// Builds the authorization URL for OAuth flow
/// - Parameters:
/// - endpoint: Server endpoint URL
/// - clientId: OAuth client ID
/// - codeChallenge: PKCE code challenge
/// - state: CSRF protection state
/// - Returns: Authorization URL
func buildAuthorizationURL(
endpoint: String,
clientId: String,
codeChallenge: String,
state: String
) -> URL? {
var components = URLComponents(string: "\(endpoint)/authorize")
components?.queryItems = [
URLQueryItem(name: "client_id", value: clientId),
URLQueryItem(name: "redirect_uri", value: Self.redirectUri),
URLQueryItem(name: "scope", value: Self.scopes),
URLQueryItem(name: "code_challenge", value: codeChallenge),
URLQueryItem(name: "code_challenge_method", value: "S256"),
URLQueryItem(name: "state", value: state),
URLQueryItem(name: "response_type", value: "code")
]
return components?.url
}
/// Starts the OAuth flow by registering a client
/// - Parameters:
/// - endpoint: Server endpoint URL
/// - Returns: Tuple containing (client, PKCE verifier, PKCE challenge, state)
func startOAuthFlow(endpoint: String) async throws -> (client: OAuthClient, verifier: String, challenge: String, state: String) {
logger.info("Starting OAuth flow for endpoint: \(endpoint)")
// Generate PKCE
let (verifier, challenge) = PKCEGenerator.generate()
// Generate CSRF state
let state = UUID().uuidString
// Register OAuth client
let client = try await repository.registerClient(
endpoint: endpoint,
clientName: Self.clientName,
redirectUri: Self.redirectUri
)
logger.info("OAuth client registered with ID: \(client.clientId)")
return (client, verifier, challenge, state)
}
/// Completes the OAuth flow by exchanging the authorization code for a token
/// - Parameters:
/// - endpoint: Server endpoint URL
/// - clientId: OAuth client ID
/// - code: Authorization code from redirect
/// - codeVerifier: PKCE code verifier
/// - receivedState: State from redirect (for CSRF verification)
/// - expectedState: Expected state value
/// - Returns: OAuth access token
func completeOAuthFlow(
endpoint: String,
clientId: String,
code: String,
codeVerifier: String,
receivedState: String,
expectedState: String
) async throws -> OAuthToken {
logger.info("Completing OAuth flow")
// Verify state to prevent CSRF attacks
guard receivedState == expectedState else {
logger.error("OAuth state mismatch - possible CSRF attack")
throw OAuthError.stateMismatch
}
// Exchange code for token
let token = try await repository.exchangeToken(
endpoint: endpoint,
clientId: clientId,
code: code,
codeVerifier: codeVerifier,
redirectUri: Self.redirectUri
)
logger.info("Successfully obtained OAuth access token")
return token
}
/// Parses the OAuth callback URL to extract code and state
/// - Parameter url: Callback URL
/// - Returns: Tuple containing (code, state) if successful, nil otherwise
static func parseCallbackURL(_ url: URL) -> (code: String, state: String)? {
guard url.scheme == "readeck",
url.host == "oauth-callback",
let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let queryItems = components.queryItems else {
return nil
}
let code = queryItems.first(where: { $0.name == "code" })?.value
let state = queryItems.first(where: { $0.name == "state" })?.value
guard let code = code, let state = state else {
return nil
}
return (code, state)
}
}
// MARK: - OAuth Errors
enum OAuthError: LocalizedError {
case stateMismatch
case invalidCallback
case userCancelled
var errorDescription: String? {
switch self {
case .stateMismatch:
return "OAuth state verification failed (possible CSRF attack)"
case .invalidCallback:
return "Invalid OAuth callback URL"
case .userCancelled:
return "OAuth authorization was cancelled by user"
}
}
}