rastrillo / native Public

Clone
git clone https://amadan.net/rastrillo/native

Plain git — no account needed to clone.

Download

Download this file

1import Combine
2import CryptoKit
3import Foundation
4import 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