import Combine import CryptoKit import Foundation import Security @MainActor public final class NativeConnection: ObservableObject { @Published public var serverAddress: String @Published public private(set) var client: NativeHTTP? @Published public private(set) var signingIn = false @Published public private(set) var code = "" @Published public private(set) var browserURL: URL? @Published public var error = "" @Published public private(set) var accounts: [NativeAccount] = [] @Published public private(set) var activeAccountID: String? @Published public var accountName = "" public let callbackScheme: String private let browser = NativeBrowserSignIn() private var pendingID: String? private var task: Task? private let accountsKey = "native.accounts" private let defaults: UserDefaults private func reloadAccounts() { if let data = defaults.data(forKey: accountsKey), let saved = try? JSONDecoder().decode([NativeAccount].self, from: data) { accounts = saved } } private func persistAccounts() { if let data = try? JSONEncoder().encode(accounts) { defaults.set(data, forKey: accountsKey) } } public func showAccounts() { reloadAccounts() cancel() client = nil activeAccountID = nil } public func selectAccount(_ account: NativeAccount) { reloadAccounts() cancel() guard accounts.contains(account), (try? NativeHTTP.origin(account.origin.absoluteString)) == account.origin, let credential = NativeCredentials.read(account.credentialKey) else { error = "This account needs to sign in again." return } accounts.removeAll { $0.id == account.id } accounts.insert(account, at: 0) persistAccounts() serverAddress = account.origin.absoluteString activeAccountID = account.id defaults.set(account.id, forKey: "native.activeAccount") client = NativeHTTP(origin: account.origin, credential: credential, observer: connectivity.observer(for: account.origin)) } /// Adopts a session returned by a consumer's proof-bound sign-in protocol. public func rememberSession(_ credential: NativeSession, origin: URL, name: String = "") throws { guard (try? NativeHTTP.origin(origin.absoluteString)) == origin, credential.name == (origin.scheme == "https" ? "__Host-rastrillo_session" : "rastrillo_session"), Self.valid(credential.value) else { throw NativeClientError.message("Invalid sign-in response.") } let label = name.trimmingCharacters(in: .whitespacesAndNewlines) let account = NativeAccount(id: UUID().uuidString, origin: origin, name: label.isEmpty ? (origin.host ?? origin.absoluteString) : label) try NativeCredentials.save(credential, origin: account.credentialKey) reloadAccounts() accounts.insert(account, at: 0) persistAccounts() activeAccountID = account.id defaults.set(account.id, forKey: "native.activeAccount") defaults.set(origin.absoluteString, forKey: "native.server") serverAddress = origin.absoluteString client = NativeHTTP(origin: origin, credential: credential, observer: connectivity.observer(for: origin)) } public func handleOpenURL(_ url: URL) { guard let pendingID, let components = URLComponents(url: url, resolvingAgainstBaseURL: false), components.scheme == callbackScheme, components.host == "signin", components.path.isEmpty, components.user == nil, components.password == nil, components.port == nil, components.fragment == nil, components.queryItems == [URLQueryItem(name: "state", value: pendingID)] else { return } // The URL carries no credential. Only the original verifier can complete // the running exchange; a forged callback cannot sign the app in. browser.cancel() } public func signIn() { signIn { [weak self] url in guard let self else { return false } let id = self.pendingID return self.browser.start(url: url, scheme: self.callbackScheme) { [weak self] callback in guard let self, self.pendingID == id else { return } if let callback { self.handleOpenURL(callback) } else { self.cancel() } } } } /// Every client this connection makes reports its outcomes here. public let connectivity: NativeConnectivity public init(defaultServer: String, callbackScheme: String? = nil, defaults: UserDefaults = .standard, connectivity: NativeConnectivity? = nil) { self.connectivity = connectivity ?? .shared self.defaults = defaults self.callbackScheme = callbackScheme ?? "works.oficina.\(URL(string: defaultServer)?.host?.components(separatedBy: ".").first ?? "app")" serverAddress = defaults.string(forKey: "native.server") ?? defaultServer if let data = defaults.data(forKey: accountsKey), let saved = try? JSONDecoder().decode([NativeAccount].self, from: data) { accounts = saved } if accounts.isEmpty, let origin = try? NativeHTTP.origin(serverAddress), let credential = NativeCredentials.read(origin.absoluteString) { let account = NativeAccount(id: UUID().uuidString, origin: origin, name: origin.host ?? origin.absoluteString) if (try? NativeCredentials.save(credential, origin: account.credentialKey)) != nil { accounts = [account] persistAccounts() NativeCredentials.remove(origin.absoluteString) } } let savedID = defaults.string(forKey: "native.activeAccount") if let account = accounts.first(where: { $0.id == savedID }) ?? accounts.first { selectAccount(account) } } public func signIn(open: @escaping @MainActor (URL) async -> Bool) { cancel() error = "" signingIn = true task = Task { do { let origin = try NativeHTTP.origin(serverAddress) let http = NativeHTTP(origin: origin, observer: connectivity.observer(for: origin)) var random = [UInt8](repeating: 0, count: 32) guard SecRandomCopyBytes(kSecRandomDefault, random.count, &random) == errSecSuccess else { throw NativeClientError.message("Could not start secure sign-in.") } let verifier = Self.token(Data(random)) let challenge = Self.token(Data(SHA256.hash(data: Data(verifier.utf8)))) struct Begin: Decodable { let id: String } let begin: Begin = try await http.request("/native/request", method: "POST", body: JSONEncoder().encode(["challenge": challenge, "callback": "\(callbackScheme)://signin"])) guard Self.valid(begin.id) else { throw NativeClientError.message("Invalid sign-in response.") } try Task.checkCancellation() pendingID = begin.id let hex = SHA256.hash(data: Data(begin.id.utf8)).prefix(4).map { String(format: "%02X", $0) }.joined() code = String(hex.prefix(4)) + "-" + String(hex.suffix(4)) let url = origin.appendingPathComponent("native/start").appending(queryItems: [URLQueryItem(name: "id", value: begin.id)]) browserURL = url guard await open(url) else { throw NativeClientError.message("Could not open your browser.") } let deadline = Date().addingTimeInterval(300) while Date() < deadline { try await Task.sleep(for: .seconds(2)) let (data, status) = try await http.response("/native/exchange", method: "POST", body: JSONEncoder().encode(["id": begin.id, "verifier": verifier])) if status == 202 { continue } guard status == 200, let credential = try? JSONDecoder().decode(NativeSession.self, from: data), credential.name == (origin.scheme == "https" ? "__Host-rastrillo_session" : "rastrillo_session"), Self.valid(credential.value) else { throw NativeClientError.message("Sign-in expired or was refused. Please try again.") } try Task.checkCancellation() try rememberSession(credential, origin: origin, name: accountName) pendingID = nil browser.cancel() signingIn = false browserURL = nil return } throw NativeClientError.message("Sign-in expired. Please try again.") } catch is CancellationError { return } catch { guard !Task.isCancelled else { return } pendingID = nil browser.cancel() self.error = error.localizedDescription signingIn = false browserURL = nil } } } public func cancel() { pendingID = nil; task?.cancel(); task = nil; browser.cancel(); signingIn = false; code = ""; browserURL = nil } public func signOut() { reloadAccounts() cancel() if let client { if let activeAccountID { if let account = accounts.first(where: { $0.id == activeAccountID }) { NativeCredentials.remove(account.credentialKey) } accounts.removeAll { $0.id == activeAccountID } persistAccounts() } defaults.removeObject(forKey: "native.activeAccount") Task { _ = try? await client.response("/signout", method: "POST", body: Data()) } } activeAccountID = nil client = nil } private static func token(_ data: Data) -> String { data.base64EncodedString().replacingOccurrences(of: "+", with: "-") .replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "") } private static func valid(_ text: String) -> Bool { guard text.count == 43 else { return false } let raw = text.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") + "=" guard let data = Data(base64Encoded: raw), data.count == 32 else { return false } return token(data) == text } }