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.
122 lines
4.1 KiB
Swift
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
|
|
}
|
|
}
|