ReadKeep/readeck/UI/Settings/SettingsServerViewModel.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

164 lines
5.2 KiB
Swift

import Foundation
import Observation
import SwiftUI
@Observable
class SettingsServerViewModel {
// MARK: - Use Cases
private let loginUseCase: PLoginUseCase
private let logoutUseCase: PLogoutUseCase
private let saveServerSettingsUseCase: PSaveServerSettingsUseCase
private let loadSettingsUseCase: PLoadSettingsUseCase
private let getServerInfoUseCase: PGetServerInfoUseCase
private let loginWithOAuthUseCase: PLoginWithOAuthUseCase
private let authRepository: PAuthRepository
// MARK: - Server Settings
var endpoint = ""
var username = ""
var password = ""
var isLoading = false
var isLoggedIn = false
// MARK: - OAuth Support
var serverSupportsOAuth = false
// MARK: - Messages
var errorMessage: String?
var successMessage: String?
private var hasFinishedSetup: Bool {
SettingsRepository().hasFinishedSetup
}
init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
self.loginUseCase = factory.makeLoginUseCase()
self.logoutUseCase = factory.makeLogoutUseCase()
self.saveServerSettingsUseCase = factory.makeSaveServerSettingsUseCase()
self.loadSettingsUseCase = factory.makeLoadSettingsUseCase()
self.getServerInfoUseCase = factory.makeGetServerInfoUseCase()
self.loginWithOAuthUseCase = factory.makeLoginWithOAuthUseCase()
self.authRepository = factory.makeAuthRepository()
}
var isSetupMode: Bool {
!hasFinishedSetup
}
@MainActor
func loadServerSettings() async {
do {
if let settings = try await loadSettingsUseCase.execute() {
endpoint = settings.endpoint ?? ""
username = settings.username ?? ""
password = settings.password ?? ""
isLoggedIn = settings.isLoggedIn
}
} catch {
errorMessage = "Error loading settings"
}
}
@MainActor
func saveServerSettings() async {
guard canLogin else {
errorMessage = "Please fill in all fields."
return
}
clearMessages()
isLoading = true
defer { isLoading = false }
do {
// Normalize endpoint before saving
let normalizedEndpoint = EndpointValidator.normalize(endpoint)
let user = try await loginUseCase.execute(endpoint: normalizedEndpoint, username: username.trimmingCharacters(in: .whitespacesAndNewlines), password: password)
try await saveServerSettingsUseCase.execute(endpoint: normalizedEndpoint, username: username, password: password, token: user.token)
// Update local endpoint with normalized version
endpoint = normalizedEndpoint
isLoggedIn = true
successMessage = "Server settings saved and successfully logged in."
try await SettingsRepository().saveHasFinishedSetup(true)
NotificationCenter.default.post(name: .setupStatusChanged, object: nil)
} catch {
errorMessage = "Connection or login failed: \(error.localizedDescription)"
isLoggedIn = false
}
}
@MainActor
func logout() async {
do {
try await logoutUseCase.execute()
isLoggedIn = false
successMessage = "Logged out"
NotificationCenter.default.post(name: .setupStatusChanged, object: nil)
} catch {
errorMessage = "Error logging out"
}
}
func clearMessages() {
errorMessage = nil
successMessage = nil
}
var canLogin: Bool {
!username.isEmpty && !password.isEmpty
}
// MARK: - OAuth Methods
@MainActor
func checkServerOAuthSupport() async {
guard !endpoint.isEmpty else {
serverSupportsOAuth = false
return
}
let normalizedEndpoint = EndpointValidator.normalize(endpoint)
do {
let serverInfo = try await getServerInfoUseCase.execute(endpoint: normalizedEndpoint)
serverSupportsOAuth = serverInfo.supportsOAuth
} catch {
serverSupportsOAuth = false
}
}
@MainActor
func loginWithOAuth() async {
guard !endpoint.isEmpty else {
errorMessage = "Please enter a server endpoint."
return
}
clearMessages()
isLoading = true
defer { isLoading = false }
do {
let normalizedEndpoint = EndpointValidator.normalize(endpoint)
let token = try await loginWithOAuthUseCase.execute(endpoint: normalizedEndpoint)
// Save OAuth token and mark as logged in
try await authRepository.loginWithOAuth(endpoint: normalizedEndpoint, token: token)
// Update local endpoint with normalized version
endpoint = normalizedEndpoint
isLoggedIn = true
successMessage = "Successfully logged in with OAuth."
try await SettingsRepository().saveHasFinishedSetup(true)
NotificationCenter.default.post(name: .setupStatusChanged, object: nil)
} catch {
errorMessage = "OAuth login failed: \(error.localizedDescription)"
isLoggedIn = false
}
}
}