diff --git a/Sources/ParaSwift/Auth/ParaManager+OAuth.swift b/Sources/ParaSwift/Auth/ParaManager+OAuth.swift index e5ac09f..d3a2a20 100644 --- a/Sources/ParaSwift/Auth/ParaManager+OAuth.swift +++ b/Sources/ParaSwift/Auth/ParaManager+OAuth.swift @@ -270,6 +270,7 @@ extension ParaManager { // Update session state after successful signup wallets = try await fetchWallets() sessionState = .activeLoggedIn + await persistCurrentSession(reason: "oauth-signup") // Synchronize required wallets for new OAuth users logger.info("Synchronizing required wallets for new OAuth user...") @@ -289,6 +290,7 @@ extension ParaManager { case .done: logger.debug("OAuth flow completed with DONE stage for user ID: \(authState.userId)") sessionState = .activeLoggedIn + await persistCurrentSession(reason: "oauth-done") } } } diff --git a/Sources/ParaSwift/Core/ParaManager+Auth.swift b/Sources/ParaSwift/Core/ParaManager+Auth.swift index 3f706d1..8eb5da2 100644 --- a/Sources/ParaSwift/Core/ParaManager+Auth.swift +++ b/Sources/ParaSwift/Core/ParaManager+Auth.swift @@ -416,6 +416,7 @@ public extension ParaManager { wallets = try await fetchWallets() sessionState = .activeLoggedIn + await persistCurrentSession(reason: "loginWithPasskey") } /// Generate a new passkey for authentication @@ -665,6 +666,7 @@ public extension ParaManager { transmissionKeysharesLoaded = true sessionState = .activeLoggedIn + await persistCurrentSession(reason: "loginExternalWallet") } /// Logs in with an external wallet address (legacy version) @@ -787,6 +789,7 @@ public extension ParaManager { // Then fetch the populated wallets wallets = try await fetchWallets() sessionState = .activeLoggedIn + await persistCurrentSession(reason: "handleLoginWithPassword") } else { // Should only happen if webAuthenticationSession throws internally and presentPasswordUrl catches/returns nil logger.warning("Password login flow seemed to fail (nil result from presentPasswordUrl).") @@ -960,6 +963,7 @@ public extension ParaManager { // Common success path for both methods: Update state and fetch wallets wallets = try await fetchWallets() sessionState = .activeLoggedIn + await persistCurrentSession(reason: "handleSignup") logger.info("Signup successful via \(method.description). Session active.") // Synchronize required wallets for new users diff --git a/Sources/ParaSwift/Core/ParaManager+Wallet.swift b/Sources/ParaSwift/Core/ParaManager+Wallet.swift index b0c703f..90122c6 100644 --- a/Sources/ParaSwift/Core/ParaManager+Wallet.swift +++ b/Sources/ParaSwift/Core/ParaManager+Wallet.swift @@ -26,6 +26,7 @@ public extension ParaManager { // Update the local wallets list with the complete fetched data wallets = allWallets sessionState = .activeLoggedIn // Update state as wallet creation implies login + await persistCurrentSession(reason: "createWallet") logger.debug("Wallet list refreshed after creation. Found \(allWallets.count) wallets.") @@ -97,6 +98,7 @@ public extension ParaManager { logger.info("Created \(newWallets.count) required wallets") // Update local wallets list wallets = try await fetchWallets() + await persistCurrentSession(reason: "synchronizeRequiredWallets") } else { logger.info("No new wallets created - all required types already exist") } diff --git a/Sources/ParaSwift/Core/ParaManager.swift b/Sources/ParaSwift/Core/ParaManager.swift index 1ae440e..2617ac2 100644 --- a/Sources/ParaSwift/Core/ParaManager.swift +++ b/Sources/ParaSwift/Core/ParaManager.swift @@ -23,12 +23,16 @@ public class ParaManager: NSObject, ObservableObject { /// Current state of the Para Manager session. @Published public var sessionState: ParaSessionState = .unknown /// API key for Para services. - public var apiKey: String + public var apiKey: String { + didSet { + sessionPersistence.update(environment: environment, apiKey: apiKey) + } + } /// Para environment configuration. public var environment: ParaEnvironment { didSet { passkeysManager.relyingPartyIdentifier = environment.relyingPartyId - + sessionPersistence.update(environment: environment, apiKey: apiKey) } } @@ -50,6 +54,12 @@ public class ParaManager: NSObject, ObservableObject { /// Track whether transmission keyshares have been loaded for the current session. /// This prevents unnecessary repeated calls to loadTransmissionKeyshares. internal var transmissionKeysharesLoaded = false + /// Controller responsible for persisting session snapshots. + private var sessionPersistence: SessionPersistenceStoring + /// Last serialized session we saved locally to avoid redundant writes. + private var lastPersistedSession: String? + /// Tracks whether we already attempted to restore a stored session. + private var attemptedSessionRestore = false // MARK: - Initialization @@ -59,7 +69,12 @@ public class ParaManager: NSObject, ObservableObject { /// - environment: The Para environment configuration. /// - apiKey: Your Para API key. /// - appScheme: Optional app scheme for authentication callbacks. Defaults to the app's bundle identifier. - public init(environment: ParaEnvironment, apiKey: String, appScheme: String? = nil) { + /// - sessionPersistence: Optional persistence controller for session snapshots. + public init( + environment: ParaEnvironment, + apiKey: String, + appScheme: String? = nil + ) { logger.info("ParaManager init: \(environment.name)") self.environment = environment @@ -67,9 +82,12 @@ public class ParaManager: NSObject, ObservableObject { passkeysManager = PasskeysManager(relyingPartyIdentifier: environment.relyingPartyId) paraWebView = ParaWebView(environment: environment, apiKey: apiKey) self.appScheme = appScheme ?? Bundle.main.bundleIdentifier! + self.sessionPersistence = SessionPersistenceController() super.init() + self.sessionPersistence.update(environment: environment, apiKey: apiKey) + Task { @MainActor in await waitForParaReady() } @@ -106,6 +124,13 @@ public class ParaManager: NSObject, ObservableObject { return } + if !attemptedSessionRestore { + attemptedSessionRestore = true + if await restorePersistedSession() { + return + } + } + if let active = try? await isSessionActive(), active { if let loggedIn = try? await isFullyLoggedIn(), loggedIn { logger.info("Session active and user logged in") @@ -113,6 +138,7 @@ public class ParaManager: NSObject, ObservableObject { self.objectWillChange.send() self.sessionState = .activeLoggedIn } + await persistCurrentSession(reason: "waitForParaReady-activeLoggedIn") } else { logger.info("Session active but user not fully logged in") await MainActor.run { @@ -126,6 +152,7 @@ public class ParaManager: NSObject, ObservableObject { self.objectWillChange.send() self.sessionState = .inactive } + lastPersistedSession = nil } } @@ -230,14 +257,145 @@ public class ParaManager: NSObject, ObservableObject { return try decodeResult(result, expectedType: Bool.self, method: "isSessionActive") } + /// Attempts to refresh the server-side session cookie without reauthenticating. + /// - Returns: `true` when the session was refreshed successfully. + public func keepSessionAlive() async throws -> Bool { + try await ensureWebViewReady() + let result = try await postMessage(method: "keepSessionAlive", payload: EmptyPayload()) + + if let boolResult = result as? Bool { + return boolResult + } + + if let numberResult = result as? NSNumber { + return numberResult.boolValue + } + + if let stringResult = result as? String { + let normalized = stringResult.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if ["true", "1"].contains(normalized) { + return true + } + if ["false", "0"].contains(normalized) { + return false + } + } + + logger.error("Unexpected keepSessionAlive response: \(String(describing: result))") + throw ParaError.bridgeError("Invalid keepSessionAlive response") + } + + private struct ExportSessionArgs: Encodable { + let excludeSigners: Bool + } + /// Export the current session for backup or transfer /// - Returns: Session data as a string - public func exportSession() async throws -> String { + public func exportSession(excludeSigners: Bool = false) async throws -> String { try await ensureWebViewReady() - let result = try await postMessage(method: "exportSession", payload: EmptyPayload()) + let payload = ExportSessionArgs(excludeSigners: excludeSigners) + let result = try await postMessage(method: "exportSession", payload: payload) return try decodeResult(result, expectedType: String.self, method: "exportSession") } + /// Imports a previously exported session. + /// - Parameter serializedSession: The base64-encoded session payload. + public func importSession(_ serializedSession: String) async throws { + let trimmed = serializedSession.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw ParaError.error("Serialized session cannot be empty") + } + + try await ensureWebViewReady() + _ = try await postMessage(method: "importSession", payload: trimmed) + + transmissionKeysharesLoaded = false + do { + try await ensureTransmissionKeysharesLoaded() + } catch { + logger.warning("Failed to reload transmission keyshares after import: \(error.localizedDescription)") + } + + lastPersistedSession = trimmed + } + + /// Restores a session snapshot from persistence if it exists. + /// - Returns: `true` when a snapshot was found and imported successfully. + @discardableResult + public func restorePersistedSession(refreshSession: Bool = true) async -> Bool { + sessionState = .restoring + + do { + guard let snapshot = try await sessionPersistence.load() else { + logger.debug("No persisted session snapshot to restore") + sessionState = .inactive + return false + } + + guard snapshot.environmentName == environment.name, snapshot.apiKey == apiKey else { + logger.warning("Persisted session metadata mismatch; clearing snapshot") + try? await sessionPersistence.clear() + sessionState = .inactive + return false + } + + do { + try await importSession(snapshot.session) + } catch { + logger.error("Failed to import persisted session: \(error.localizedDescription)") + try? await sessionPersistence.clear() + sessionState = .inactive + return false + } + + if refreshSession { + do { + _ = try await touchSession() + } catch { + logger.warning("touchSession failed during restore: \(error.localizedDescription)") + } + } + + var restoredAuthState: AuthState? + if let details = try? await getCurrentUserAuthDetails() { + restoredAuthState = details + } + + do { + let restoredWallets = try await fetchWallets() + wallets = restoredWallets + } catch { + logger.warning("Fetching wallets after restore failed: \(error.localizedDescription)") + } + + let isLoggedIn: Bool + if restoredAuthState?.userId != nil { + isLoggedIn = true + } else if let fullyLoggedIn = try? await isFullyLoggedIn(), fullyLoggedIn { + isLoggedIn = true + } else { + isLoggedIn = false + } + + sessionState = isLoggedIn ? .activeLoggedIn : .active + lastPersistedSession = snapshot.session + logger.info("Session restored from persistence") + await persistCurrentSession(reason: "restorePersistedSession") + return true + } catch SessionPersistenceError.misconfigured { + logger.warning("Session persistence misconfigured; skipping restore") + } catch SessionPersistenceError.keychainError(let status) { + logger.error("Keychain error during session restore: \(status, privacy: .public)") + } catch SessionPersistenceError.decoding(let error) { + logger.error("Failed to decode persisted session: \(error.localizedDescription, privacy: .public)") + } catch { + logger.error("Unexpected restore error: \(error.localizedDescription, privacy: .public)") + } + + sessionState = .inactive + return false + } + /// Logs out the current user and clears all session data public func logout() async throws { _ = try await postMessage(method: "logout", payload: EmptyPayload()) @@ -253,6 +411,43 @@ public class ParaManager: NSObject, ObservableObject { sessionState = .inactive // Reset transmission keyshares flag since we're logging out transmissionKeysharesLoaded = false + lastPersistedSession = nil + try? await sessionPersistence.clear() + } + + /// Persists the current session snapshot if it differs from the last saved copy. + func persistCurrentSession(reason: String) async { + guard paraWebView.isReady else { + logger.debug("Skipping session persist (\(reason)) - web view not ready") + return + } + + do { + let serializedSession = try await exportSession() + if serializedSession == lastPersistedSession { + logger.debug("Skipping session persist (\(reason)) - snapshot unchanged") + return + } + + var userId: String? + if let authDetails = try? await getCurrentUserAuthDetails() { + userId = authDetails.userId + } + + let snapshot = SessionSnapshot( + session: serializedSession, + savedAt: Date(), + environmentName: environment.name, + apiKey: apiKey, + userId: userId + ) + + try await sessionPersistence.save(snapshot: snapshot) + lastPersistedSession = serializedSession + logger.debug("Session snapshot persisted (\(reason))") + } catch { + logger.error("Failed to persist session (\(reason)): \(error.localizedDescription)") + } } /// Ensures transmission keyshares are loaded for the current session. diff --git a/Sources/ParaSwift/Core/ParaSessionState.swift b/Sources/ParaSwift/Core/ParaSessionState.swift index 4963bac..b25dd89 100644 --- a/Sources/ParaSwift/Core/ParaSessionState.swift +++ b/Sources/ParaSwift/Core/ParaSessionState.swift @@ -1,8 +1,9 @@ import Foundation public enum ParaSessionState: Int { - case unknown - case inactive - case active - case activeLoggedIn + case unknown = 0 + case inactive = 1 + case restoring = 2 + case active = 3 + case activeLoggedIn = 4 } diff --git a/Sources/ParaSwift/Core/SessionPersistenceController.swift b/Sources/ParaSwift/Core/SessionPersistenceController.swift new file mode 100644 index 0000000..edbb207 --- /dev/null +++ b/Sources/ParaSwift/Core/SessionPersistenceController.swift @@ -0,0 +1,121 @@ +import Foundation +import os +import Security + +struct SessionSnapshot: Codable { + let session: String + let savedAt: Date + let environmentName: String + let apiKey: String + let userId: String? +} + +protocol SessionPersistenceStoring: AnyObject { + func update(environment: ParaEnvironment, apiKey: String) + func save(snapshot: SessionSnapshot) async throws + func load() async throws -> SessionSnapshot? + func clear() async throws +} + +enum SessionPersistenceError: Error { + case misconfigured + case keychainError(OSStatus) + case decoding(Error) +} + +final class SessionPersistenceController: SessionPersistenceStoring { + private let logger = Logger(subsystem: "com.paraSwift", category: "SessionPersistence") + private let serviceIdentifier: String + private var accountIdentifier: String? + + init(serviceSuffix: String = "session") { + if let bundleId = Bundle.main.bundleIdentifier, !bundleId.isEmpty { + serviceIdentifier = "\(bundleId).para.\(serviceSuffix)" + } else { + serviceIdentifier = "com.paraSwift.\(serviceSuffix)" + } + } + + func update(environment: ParaEnvironment, apiKey: String) { + let envName = environment.name.lowercased() + accountIdentifier = "para.\(envName).\(apiKey)" + } + + func save(snapshot: SessionSnapshot) async throws { + let data = try JSONEncoder().encode(snapshot) + let query = try keychainQuery() + var attributes = query + attributes[kSecValueData as String] = data + attributes[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock + + let status = SecItemAdd(attributes as CFDictionary, nil) + switch status { + case errSecSuccess: + logger.debug("Session snapshot saved to Keychain") + case errSecDuplicateItem: + let updateStatus = SecItemUpdate(query as CFDictionary, [kSecValueData as String: data] as CFDictionary) + if updateStatus != errSecSuccess { + logger.error("Keychain update failed: \(updateStatus, privacy: .public)") + throw SessionPersistenceError.keychainError(updateStatus) + } + logger.debug("Session snapshot updated in Keychain") + default: + logger.error("Keychain save failed: \(status, privacy: .public)") + throw SessionPersistenceError.keychainError(status) + } + } + + func load() async throws -> SessionSnapshot? { + var query = try keychainQuery() + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + if status == errSecItemNotFound { + return nil + } + + guard status == errSecSuccess else { + logger.error("Keychain read failed: \(status, privacy: .public)") + throw SessionPersistenceError.keychainError(status) + } + + guard let data = result as? Data else { + logger.error("Keychain returned unexpected payload") + throw SessionPersistenceError.decoding(NSError(domain: "SessionPersistence", code: -1)) + } + + do { + return try JSONDecoder().decode(SessionSnapshot.self, from: data) + } catch { + logger.error("Failed to decode session snapshot: \(error.localizedDescription, privacy: .public)") + try await clear() + throw SessionPersistenceError.decoding(error) + } + } + + func clear() async throws { + let query = try keychainQuery() + let status = SecItemDelete(query as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + logger.error("Keychain delete failed: \(status, privacy: .public)") + throw SessionPersistenceError.keychainError(status) + } + logger.debug("Session snapshot cleared from Keychain") + } + + private func keychainQuery() throws -> [String: Any] { + guard let accountIdentifier else { + logger.error("SessionPersistenceController misconfigured: missing account identifier") + throw SessionPersistenceError.misconfigured + } + + return [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceIdentifier, + kSecAttrAccount as String: accountIdentifier, + ] + } +}