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.
153 lines
5.0 KiB
Swift
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"
|
|
}
|
|
}
|
|
}
|