| 1 | import Combine |
| 2 | import CryptoKit |
| 3 | import Foundation |
| 4 | import Security |
| 5 | |
| 6 | @MainActor public final class NativeConnection: ObservableObject { |
| 7 | @Published public var serverAddress: String |
| 8 | @Published public private(set) var client: NativeHTTP? |
| 9 | @Published public private(set) var signingIn = false |
| 10 | @Published public private(set) var code = "" |
| 11 | @Published public private(set) var browserURL: URL? |
| 12 | @Published public var error = "" |
| 13 | @Published public private(set) var accounts: [NativeAccount] = [] |
| 14 | @Published public private(set) var activeAccountID: String? |
| 15 | @Published public var accountName = "" |
| 16 | public let callbackScheme: String |
| 17 | private let browser = NativeBrowserSignIn() |
| 18 | private var pendingID: String? |
| 19 | private var task: Task<Void, Never>? |
| 20 | private let accountsKey = "native.accounts" |
| 21 | private let defaults: UserDefaults |
| 22 | |
| 23 | private func reloadAccounts() { |
| 24 | if let data = defaults.data(forKey: accountsKey), |
| 25 | let saved = try? JSONDecoder().decode([NativeAccount].self, from: data) { accounts = saved } |
| 26 | } |
| 27 | |
| 28 | private func persistAccounts() { |
| 29 | if let data = try? JSONEncoder().encode(accounts) { |
| 30 | defaults.set(data, forKey: accountsKey) |
| 31 | } |
| 32 | } |
| 33 | |
| 34 | public func showAccounts() { |
| 35 | reloadAccounts() |
| 36 | cancel() |
| 37 | client = nil |
| 38 | activeAccountID = nil |
| 39 | } |
| 40 | |
| 41 | public func selectAccount(_ account: NativeAccount) { |
| 42 | reloadAccounts() |
| 43 | cancel() |
| 44 | guard accounts.contains(account), (try? NativeHTTP.origin(account.origin.absoluteString)) == account.origin, |
| 45 | let credential = NativeCredentials.read(account.credentialKey) else { |
| 46 | error = "This account needs to sign in again." |
| 47 | return |
| 48 | } |
| 49 | accounts.removeAll { $0.id == account.id } |
| 50 | accounts.insert(account, at: 0) |
| 51 | persistAccounts() |
| 52 | serverAddress = account.origin.absoluteString |
| 53 | activeAccountID = account.id |
| 54 | defaults.set(account.id, forKey: "native.activeAccount") |
| 55 | client = NativeHTTP(origin: account.origin, credential: credential, observer: connectivity.observer(for: account.origin)) |
| 56 | } |
| 57 | |
| 58 | /// Adopts a session returned by a consumer's proof-bound sign-in protocol. |
| 59 | public func rememberSession(_ credential: NativeSession, origin: URL, name: String = "") throws { |
| 60 | guard (try? NativeHTTP.origin(origin.absoluteString)) == origin, |
| 61 | credential.name == (origin.scheme == "https" ? "__Host-rastrillo_session" : "rastrillo_session"), |
| 62 | Self.valid(credential.value) else { throw NativeClientError.message("Invalid sign-in response.") } |
| 63 | let label = name.trimmingCharacters(in: .whitespacesAndNewlines) |
| 64 | let account = NativeAccount(id: UUID().uuidString, origin: origin, |
| 65 | name: label.isEmpty ? (origin.host ?? origin.absoluteString) : label) |
| 66 | try NativeCredentials.save(credential, origin: account.credentialKey) |
| 67 | reloadAccounts() |
| 68 | accounts.insert(account, at: 0) |
| 69 | persistAccounts() |
| 70 | activeAccountID = account.id |
| 71 | defaults.set(account.id, forKey: "native.activeAccount") |
| 72 | defaults.set(origin.absoluteString, forKey: "native.server") |
| 73 | serverAddress = origin.absoluteString |
| 74 | client = NativeHTTP(origin: origin, credential: credential, observer: connectivity.observer(for: origin)) |
| 75 | } |
| 76 | |
| 77 | public func handleOpenURL(_ url: URL) { |
| 78 | guard let pendingID, let components = URLComponents(url: url, resolvingAgainstBaseURL: false), |
| 79 | components.scheme == callbackScheme, components.host == "signin", |
| 80 | components.path.isEmpty, components.user == nil, components.password == nil, |
| 81 | components.port == nil, components.fragment == nil, |
| 82 | components.queryItems == [URLQueryItem(name: "state", value: pendingID)] else { return } |
| 83 | // The URL carries no credential. Only the original verifier can complete |
| 84 | // the running exchange; a forged callback cannot sign the app in. |
| 85 | browser.cancel() |
| 86 | } |
| 87 | |
| 88 | public func signIn() { |
| 89 | signIn { [weak self] url in |
| 90 | guard let self else { return false } |
| 91 | let id = self.pendingID |
| 92 | return self.browser.start(url: url, scheme: self.callbackScheme) { [weak self] callback in |
| 93 | guard let self, self.pendingID == id else { return } |
| 94 | if let callback { self.handleOpenURL(callback) } else { self.cancel() } |
| 95 | } |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | /// Every client this connection makes reports its outcomes here. |
| 100 | public let connectivity: NativeConnectivity |
| 101 | |
| 102 | public init(defaultServer: String, callbackScheme: String? = nil, defaults: UserDefaults = .standard, |
| 103 | connectivity: NativeConnectivity? = nil) { |
| 104 | self.connectivity = connectivity ?? .shared |
| 105 | self.defaults = defaults |
| 106 | self.callbackScheme = callbackScheme ?? "works.oficina.\(URL(string: defaultServer)?.host?.components(separatedBy: ".").first ?? "app")" |
| 107 | serverAddress = defaults.string(forKey: "native.server") ?? defaultServer |
| 108 | if let data = defaults.data(forKey: accountsKey), |
| 109 | let saved = try? JSONDecoder().decode([NativeAccount].self, from: data) { accounts = saved } |
| 110 | if accounts.isEmpty, let origin = try? NativeHTTP.origin(serverAddress), |
| 111 | let credential = NativeCredentials.read(origin.absoluteString) { |
| 112 | let account = NativeAccount(id: UUID().uuidString, origin: origin, name: origin.host ?? origin.absoluteString) |
| 113 | if (try? NativeCredentials.save(credential, origin: account.credentialKey)) != nil { |
| 114 | accounts = [account] |
| 115 | persistAccounts() |
| 116 | NativeCredentials.remove(origin.absoluteString) |
| 117 | } |
| 118 | } |
| 119 | let savedID = defaults.string(forKey: "native.activeAccount") |
| 120 | if let account = accounts.first(where: { $0.id == savedID }) ?? accounts.first { |
| 121 | selectAccount(account) |
| 122 | } |
| 123 | } |
| 124 | |
| 125 | public func signIn(open: @escaping @MainActor (URL) async -> Bool) { |
| 126 | cancel() |
| 127 | error = "" |
| 128 | signingIn = true |
| 129 | task = Task { |
| 130 | do { |
| 131 | let origin = try NativeHTTP.origin(serverAddress) |
| 132 | let http = NativeHTTP(origin: origin, observer: connectivity.observer(for: origin)) |
| 133 | var random = [UInt8](repeating: 0, count: 32) |
| 134 | guard SecRandomCopyBytes(kSecRandomDefault, random.count, &random) == errSecSuccess else { |
| 135 | throw NativeClientError.message("Could not start secure sign-in.") |
| 136 | } |
| 137 | let verifier = Self.token(Data(random)) |
| 138 | let challenge = Self.token(Data(SHA256.hash(data: Data(verifier.utf8)))) |
| 139 | struct Begin: Decodable { let id: String } |
| 140 | let begin: Begin = try await http.request("/native/request", method: "POST", |
| 141 | body: JSONEncoder().encode(["challenge": challenge, "callback": "\(callbackScheme)://signin"])) |
| 142 | guard Self.valid(begin.id) else { throw NativeClientError.message("Invalid sign-in response.") } |
| 143 | try Task.checkCancellation() |
| 144 | pendingID = begin.id |
| 145 | let hex = SHA256.hash(data: Data(begin.id.utf8)).prefix(4).map { String(format: "%02X", $0) }.joined() |
| 146 | code = String(hex.prefix(4)) + "-" + String(hex.suffix(4)) |
| 147 | let url = origin.appendingPathComponent("native/start").appending(queryItems: [URLQueryItem(name: "id", value: begin.id)]) |
| 148 | browserURL = url |
| 149 | guard await open(url) else { throw NativeClientError.message("Could not open your browser.") } |
| 150 | let deadline = Date().addingTimeInterval(300) |
| 151 | while Date() < deadline { |
| 152 | try await Task.sleep(for: .seconds(2)) |
| 153 | let (data, status) = try await http.response("/native/exchange", method: "POST", |
| 154 | body: JSONEncoder().encode(["id": begin.id, "verifier": verifier])) |
| 155 | if status == 202 { continue } |
| 156 | guard status == 200, let credential = try? JSONDecoder().decode(NativeSession.self, from: data), |
| 157 | credential.name == (origin.scheme == "https" ? "__Host-rastrillo_session" : "rastrillo_session"), |
| 158 | Self.valid(credential.value) else { throw NativeClientError.message("Sign-in expired or was refused. Please try again.") } |
| 159 | try Task.checkCancellation() |
| 160 | try rememberSession(credential, origin: origin, name: accountName) |
| 161 | pendingID = nil |
| 162 | browser.cancel() |
| 163 | signingIn = false |
| 164 | browserURL = nil |
| 165 | return |
| 166 | } |
| 167 | throw NativeClientError.message("Sign-in expired. Please try again.") |
| 168 | } catch is CancellationError { return } |
| 169 | catch { |
| 170 | guard !Task.isCancelled else { return } |
| 171 | pendingID = nil |
| 172 | browser.cancel() |
| 173 | self.error = error.localizedDescription |
| 174 | signingIn = false |
| 175 | browserURL = nil |
| 176 | } |
| 177 | } |
| 178 | } |
| 179 | public func cancel() { pendingID = nil; task?.cancel(); task = nil; browser.cancel(); signingIn = false; code = ""; browserURL = nil } |
| 180 | public func signOut() { |
| 181 | reloadAccounts() |
| 182 | cancel() |
| 183 | if let client { |
| 184 | if let activeAccountID { |
| 185 | if let account = accounts.first(where: { $0.id == activeAccountID }) { |
| 186 | NativeCredentials.remove(account.credentialKey) |
| 187 | } |
| 188 | accounts.removeAll { $0.id == activeAccountID } |
| 189 | persistAccounts() |
| 190 | } |
| 191 | defaults.removeObject(forKey: "native.activeAccount") |
| 192 | Task { _ = try? await client.response("/signout", method: "POST", body: Data()) } |
| 193 | } |
| 194 | activeAccountID = nil |
| 195 | client = nil |
| 196 | } |
| 197 | private static func token(_ data: Data) -> String { |
| 198 | data.base64EncodedString().replacingOccurrences(of: "+", with: "-") |
| 199 | .replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "") |
| 200 | } |
| 201 | private static func valid(_ text: String) -> Bool { |
| 202 | guard text.count == 43 else { return false } |
| 203 | let raw = text.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") + "=" |
| 204 | guard let data = Data(base64Encoded: raw), data.count == 32 else { return false } |
| 205 | return token(data) == text |
| 206 | } |
| 207 | } |
| 208 | |