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

222 lines
9.0 KiB
Swift

import CoreData
import Foundation
class CoreDataManager {
static let shared = CoreDataManager()
private var isInMemoryStore = false
private let logger = Logger.data
private init() {}
lazy var persistentContainer: NSPersistentContainer = {
// Try to find the model in the main bundle first, then in extension bundle
guard let modelURL = Bundle.main.url(forResource: "readeck", withExtension: "momd") ??
Bundle(for: CoreDataManager.self).url(forResource: "readeck", withExtension: "momd") else {
logger.error("Could not find Core Data model file")
fatalError("Core Data model 'readeck.xcdatamodeld' not found in bundle")
}
guard let managedObjectModel = NSManagedObjectModel(contentsOf: modelURL) else {
logger.error("Could not load Core Data model from URL: \(modelURL)")
fatalError("Failed to load Core Data model")
}
let container = NSPersistentContainer(name: "readeck", managedObjectModel: managedObjectModel)
// Use App Group container for shared access with extensions
let storeURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.readeck.app")?.appendingPathComponent("readeck.sqlite")
if let storeURL = storeURL {
// Migrate existing database from app container to app group if needed
migrateStoreToAppGroupIfNeeded(targetURL: storeURL)
let storeDescription = NSPersistentStoreDescription(url: storeURL)
container.persistentStoreDescriptions = [storeDescription]
}
container.loadPersistentStores { [weak self] _, error in
if let error = error {
self?.logger.error("Core Data failed to load persistent store: \(error)", file: #file, function: #function, line: #line)
self?.setupInMemoryStore(container: container)
} else {
self?.logger.info("Core Data persistent store loaded successfully")
}
}
// Configure viewContext for better extension support
container.viewContext.automaticallyMergesChangesFromParent = true
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
return container
}()
var context: NSManagedObjectContext {
return persistentContainer.viewContext
}
var mainContext: NSManagedObjectContext {
return persistentContainer.viewContext
}
func newBackgroundContext() -> NSManagedObjectContext {
let context = persistentContainer.newBackgroundContext()
context.automaticallyMergesChangesFromParent = true
return context
}
func save() {
if context.hasChanges {
do {
try context.save()
logger.debug("Core Data context saved successfully")
} catch {
logger.error("Failed to save Core Data context: \(error.localizedDescription)")
}
}
}
func resetDatabase() throws {
logger.warning("⚠️ Resetting Core Data database - ALL DATA WILL BE DELETED")
guard let store = persistentContainer.persistentStoreCoordinator.persistentStores.first else {
throw NSError(domain: "CoreDataManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "No persistent store found"])
}
guard let storeURL = store.url else {
throw NSError(domain: "CoreDataManager", code: -2, userInfo: [NSLocalizedDescriptionKey: "Store URL not found"])
}
// Remove the persistent store
try persistentContainer.persistentStoreCoordinator.remove(store)
// Delete the store files
try FileManager.default.removeItem(at: storeURL)
// Also delete related files (-wal, -shm)
let walURL = storeURL.deletingPathExtension().appendingPathExtension("sqlite-wal")
let shmURL = storeURL.deletingPathExtension().appendingPathExtension("sqlite-shm")
try? FileManager.default.removeItem(at: walURL)
try? FileManager.default.removeItem(at: shmURL)
logger.info("Core Data database files deleted successfully")
}
private func setupInMemoryStore(container: NSPersistentContainer) {
logger.warning("Setting up in-memory Core Data store as fallback")
isInMemoryStore = true
let inMemoryDescription = NSPersistentStoreDescription()
inMemoryDescription.type = NSInMemoryStoreType
container.persistentStoreDescriptions = [inMemoryDescription]
container.loadPersistentStores { [weak self] _, error in
if let error = error {
self?.logger.error("Failed to setup in-memory store: \(error.localizedDescription)")
// Continue with empty container - app will work with reduced functionality
} else {
self?.logger.info("In-memory Core Data store setup successfully")
}
}
}
private func migrateStoreToAppGroupIfNeeded(targetURL: URL) {
let fileManager = FileManager.default
// Check if store already exists in app group
if fileManager.fileExists(atPath: targetURL.path) {
logger.info("Database already exists in app group container")
return
}
// Try multiple possible old locations for database
var searchPaths: [URL] = []
// 1. App's documents directory (most common old location)
if let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first {
searchPaths.append(documentsURL)
}
// 2. App's library directory
if let libraryURL = fileManager.urls(for: .libraryDirectory, in: .userDomainMask).first {
searchPaths.append(libraryURL)
}
// 3. App's support directory
if let supportURL = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first {
searchPaths.append(supportURL)
}
var foundOldStore = false
for searchPath in searchPaths {
let oldStoreURL = searchPath.appendingPathComponent("readeck.sqlite")
if fileManager.fileExists(atPath: oldStoreURL.path) {
logger.info("Found existing database at: \(oldStoreURL.path)")
foundOldStore = true
if migrateFromPath(oldStoreURL: oldStoreURL, targetURL: targetURL) {
break // Successfully migrated, stop searching
}
}
}
if !foundOldStore {
logger.info("No existing database found in any search location - starting fresh")
}
}
private func migrateFromPath(oldStoreURL: URL, targetURL: URL) -> Bool {
let fileManager = FileManager.default
let oldStoreWAL = oldStoreURL.appendingPathExtension("wal")
let oldStoreSHM = oldStoreURL.appendingPathExtension("shm")
logger.info("Migrating existing database from: \(oldStoreURL.path)")
logger.info("Migrating existing database to: \(targetURL.path)")
do {
// Create app group directory if it doesn't exist
let appGroupDirectory = targetURL.deletingLastPathComponent()
try fileManager.createDirectory(at: appGroupDirectory, withIntermediateDirectories: true)
// Copy main database file
try fileManager.copyItem(at: oldStoreURL, to: targetURL)
logger.info("Main database file migrated successfully")
// Copy WAL file if it exists
if fileManager.fileExists(atPath: oldStoreWAL.path) {
let targetWAL = targetURL.appendingPathExtension("wal")
try fileManager.copyItem(at: oldStoreWAL, to: targetWAL)
logger.info("WAL file migrated successfully")
}
// Copy SHM file if it exists
if fileManager.fileExists(atPath: oldStoreSHM.path) {
let targetSHM = targetURL.appendingPathExtension("shm")
try fileManager.copyItem(at: oldStoreSHM, to: targetSHM)
logger.info("SHM file migrated successfully")
}
// Remove old files after successful migration
try fileManager.removeItem(at: oldStoreURL)
if fileManager.fileExists(atPath: oldStoreWAL.path) {
try fileManager.removeItem(at: oldStoreWAL)
}
if fileManager.fileExists(atPath: oldStoreSHM.path) {
try fileManager.removeItem(at: oldStoreSHM)
}
logger.info("Database migration completed successfully")
return true
} catch {
logger.error("Failed to migrate database from \(oldStoreURL.path): \(error.localizedDescription)")
return false
}
}
}