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

122 lines
4.1 KiB
Swift

import Foundation
import Security
class KeychainHelper {
static let shared = KeychainHelper()
private init() {}
private static let accessGroup = "8J69P655GN.de.ilyashallak.readeck"
@discardableResult
func saveToken(_ token: String) -> Bool {
saveString(token, forKey: "readeck_token")
}
func loadToken() -> String? {
loadString(forKey: "readeck_token")
}
@discardableResult
func saveEndpoint(_ endpoint: String) -> Bool {
saveString(endpoint, forKey: "readeck_endpoint")
}
func loadEndpoint() -> String? {
loadString(forKey: "readeck_endpoint")
}
@discardableResult
func saveUsername(_ username: String) -> Bool {
saveString(username, forKey: "readeck_username")
}
func loadUsername() -> String? {
loadString(forKey: "readeck_username")
}
@discardableResult
func savePassword(_ password: String) -> Bool {
saveString(password, forKey: "readeck_password")
}
func loadPassword() -> String? {
loadString(forKey: "readeck_password")
}
// MARK: - OAuth Token Storage
// Note: OAuth is only available in the main app target, not in URLShare extension
@discardableResult
func saveOAuthToken(_ token: OAuthToken) -> Bool {
guard let data = try? JSONEncoder().encode(token),
let jsonString = String(data: data, encoding: .utf8) else {
return false
}
return saveString(jsonString, forKey: "readeck_oauth_token")
}
func loadOAuthToken() -> OAuthToken? {
guard let jsonString = loadString(forKey: "readeck_oauth_token"),
let data = jsonString.data(using: .utf8),
let token = try? JSONDecoder().decode(OAuthToken.self, from: data) else {
return nil
}
return token
}
@discardableResult
func saveAuthMethod(_ method: AuthenticationMethod) -> Bool {
saveString(method.rawValue, forKey: "readeck_auth_method")
}
func loadAuthMethod() -> AuthenticationMethod? {
guard let rawValue = loadString(forKey: "readeck_auth_method") else {
return nil
}
return AuthenticationMethod(rawValue: rawValue)
}
@discardableResult
func clearCredentials() -> Bool {
let tokenCleared = saveString("", forKey: "readeck_token")
let endpointCleared = saveString("", forKey: "readeck_endpoint")
let usernameCleared = saveString("", forKey: "readeck_username")
let passwordCleared = saveString("", forKey: "readeck_password")
let oauthTokenCleared = saveString("", forKey: "readeck_oauth_token")
let authMethodCleared = saveString("", forKey: "readeck_auth_method")
return tokenCleared && endpointCleared && usernameCleared && passwordCleared && oauthTokenCleared && authMethodCleared
}
// MARK: - Private generic helpers
@discardableResult
private func saveString(_ value: String, forKey key: String) -> Bool {
guard let data = value.data(using: .utf8) else { return false }
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrAccessGroup as String: KeychainHelper.accessGroup
]
SecItemDelete(query as CFDictionary)
let status = SecItemAdd(query as CFDictionary, nil)
return status == errSecSuccess
}
private func loadString(forKey key: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecAttrAccessGroup as String: KeychainHelper.accessGroup,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecSuccess, let data = result as? Data {
return String(data: data, encoding: .utf8)
}
return nil
}
}