From 53bfefac33e06a0fe0bd94743737a78c4f6960ed Mon Sep 17 00:00:00 2001 From: David Date: Fri, 25 Sep 2026 11:36:22 -0400 Subject: [PATCH 1/4] feat: delete the account from the app --- apps/ios/Account.xcconfig | 5 + apps/ios/Origin89/AccountView.swift | 84 +++++- apps/ios/Origin89/Info.plist | 2 + apps/ios/Origin89/Origin89App.swift | 20 +- apps/ios/Origin89/SetupView.swift | 6 +- .../SetupKit/Sources/SetupKit/Account.swift | 68 ++++- .../Sources/SetupKit/AccountDeletion.swift | 100 +++++++ .../Sources/SetupKit/AuthKitClient.swift | 13 +- .../Sources/SetupKit/CloudClient.swift | 155 ++++++++++ .../SetupKit/KeychainEnrolmentStore.swift | 13 + .../Tests/SetupKitTests/CloudTests.swift | 278 ++++++++++++++++++ 11 files changed, 716 insertions(+), 28 deletions(-) create mode 100644 apps/ios/SetupKit/Sources/SetupKit/AccountDeletion.swift create mode 100644 apps/ios/SetupKit/Sources/SetupKit/CloudClient.swift create mode 100644 apps/ios/SetupKit/Tests/SetupKitTests/CloudTests.swift diff --git a/apps/ios/Account.xcconfig b/apps/ios/Account.xcconfig index 99d7506..fdc1567 100644 --- a/apps/ios/Account.xcconfig +++ b/apps/ios/Account.xcconfig @@ -3,3 +3,8 @@ WORKOS_CLIENT_ID[config=Debug] = client_01M3C9M944Y53VA9PJRCW8T5S6 WORKOS_CLIENT_ID[config=Release] = client_01M3CC0Q23FPHK50YW1WXCNZ3S + +// The Origin89 cloud for the same environment: it accepts tokens only from its +// own WorkOS client. Debug has none until a staging Worker is deployed, so +// Debug builds cannot delete an account. `$()` keeps `//` from starting a comment. +CLOUD_BASE_URL[config=Release] = https:/$()/cloud.origin89.com diff --git a/apps/ios/Origin89/AccountView.swift b/apps/ios/Origin89/AccountView.swift index ed1ca2d..74ef7ce 100644 --- a/apps/ios/Origin89/AccountView.swift +++ b/apps/ios/Origin89/AccountView.swift @@ -7,9 +7,14 @@ import SwiftUI /// network work the same signed in or out. struct AccountView: View { let account: Account + /// Nil in a build without a cloud. + let cloud: CloudClient? + let pairings: any AccountPairingStore - @State private var failure: AccountError? + @State private var failure: String? @State private var confirmingSignOut = false + @State private var confirmingDeletion = false + @State private var deleting = false @Environment(\.webAuthenticationSession) private var webAuthenticationSession @Environment(\.dismiss) private var dismiss @@ -21,7 +26,7 @@ struct AccountView: View { case .signedIn(let user): signedIn(user) } if let failure { - Section { Origin89Notice(failure.message, tone: .alarm) } + Section { Origin89Notice(failure, tone: .alarm) } } } .navigationTitle("Account") @@ -39,6 +44,16 @@ struct AccountView: View { "Controllers paired while signed in stay on this phone and come back when you sign in again." ) } + .confirmationDialog( + "Delete your account?", isPresented: $confirmingDeletion, titleVisibility: .visible + ) { + Button("Delete account, keep pairings", role: .destructive) { delete(.keep) } + Button("Delete account and pairings", role: .destructive) { delete(.forget) } + } message: { + Text( + "Your Origin89 account, its sites and memberships are deleted. Controllers are not reset. This phone can keep the pairings made in this account as signed-out pairings, or remove them." + ) + } } @ViewBuilder private var signedOut: some View { @@ -77,36 +92,77 @@ struct AccountView: View { } Section { Button("Sign out", role: .destructive) { confirmingSignOut = true } + .disabled(deleting) } footer: { Text( "Controllers paired while signed in show only in this account. Controllers paired while signed out show in every account." ) } + Section { + Button(role: .destructive) { + confirmingDeletion = true + } label: { + if deleting { + HStack(spacing: 12) { + ProgressView() + Text("Deleting…") + } + } else { + Text("Delete account") + } + } + .disabled(deleting || cloud == nil) + } footer: { + if cloud == nil { + Text("This build has no Origin89 cloud, so the account cannot be deleted from it.") + } else { + Text("You may be asked to sign in again first.") + } + } } private func signIn() { failure = nil Task { do throws(AccountError) { - try await account.signIn { url, scheme throws(AccountError) in - do { - return try await webAuthenticationSession.authenticate( - using: url, callbackURLScheme: scheme, preferredBrowserSession: .ephemeral) - } catch let error as ASWebAuthenticationSessionError where error.code == .canceledLogin { - throw .cancelled - } catch { - throw .unavailable - } - } + try await account.signIn(using: authenticate) } catch { - if error != .cancelled { failure = error } + if error != .cancelled { failure = error.message } } } } private func signOut() { failure = nil - do { try account.signOut() } catch { failure = error } + do { try account.signOut() } catch { failure = error.message } + } + + private func delete(_ kept: DeletedPairings) { + guard let cloud else { return } + failure = nil + deleting = true + Task { + defer { deleting = false } + do throws(CloudError) { + // Signed out once it returns, which closes this sheet. + _ = try await cloud.deleteAccount( + pairings: kept, store: pairings, authenticate: authenticate) + } catch { + if error != .account(.cancelled) { failure = error.message } + } + } + } + + /// The AuthKit page in an ephemeral web session, returning its redirect. + private func authenticate(_ url: URL, _ scheme: String) async throws(AccountError) -> URL { + do { + return try await webAuthenticationSession.authenticate( + using: url, callbackURLScheme: scheme, preferredBrowserSession: .ephemeral) + } catch let error as ASWebAuthenticationSessionError where error.code == .canceledLogin { + throw .cancelled + } catch { + throw .unavailable + } } } diff --git a/apps/ios/Origin89/Info.plist b/apps/ios/Origin89/Info.plist index f56f541..1d2d583 100644 --- a/apps/ios/Origin89/Info.plist +++ b/apps/ios/Origin89/Info.plist @@ -2,6 +2,8 @@ + CloudBaseURL + $(CLOUD_BASE_URL) WorkOSClientID $(WORKOS_CLIENT_ID) NSBonjourServices diff --git a/apps/ios/Origin89/Origin89App.swift b/apps/ios/Origin89/Origin89App.swift index c0d51ab..251e2eb 100644 --- a/apps/ios/Origin89/Origin89App.swift +++ b/apps/ios/Origin89/Origin89App.swift @@ -7,6 +7,7 @@ import os struct Origin89App: App { @State private var account: Account @State private var flow: SetupFlow + private let cloud: CloudClient? @MainActor init() { Self.clearPreviousInstall() @@ -14,6 +15,7 @@ struct Origin89App: App { client: Self.authKit.map { AuthKitClient(configuration: $0) }, store: KeychainAccountSessionStore()) _account = State(initialValue: account) + cloud = Self.cloudConfiguration.map { CloudClient(configuration: $0, account: account) } _flow = State(initialValue: Self.makeFlow(owner: account.owner)) } @@ -42,6 +44,19 @@ struct Origin89App: App { return clientID.flatMap { AuthKitConfiguration(clientID: $0) } } + private static var cloudConfiguration: CloudConfiguration? { + let baseURL = Bundle.main.object(forInfoDictionaryKey: "CloudBaseURL") as? String + return baseURL.flatMap { CloudConfiguration(baseURL: $0) } + } + + private static func lastController(owner: AccountID?) -> DefaultsLastController { + DefaultsLastController( + key: owner.map { "setup.lastController.\($0.rawValue)" } ?? "setup.lastController") + } + + private static let pairings = KeychainAccountPairings( + signedOutLast: lastController(owner: nil), accountLast: { lastController(owner: $0) }) + /// The flow for `owner`'s enrolments: signed out, those made signed out; /// signed in, the account's own too. Each keeps its own last controller. @MainActor private static func makeFlow(owner: AccountID?) -> SetupFlow { @@ -50,8 +65,7 @@ struct Origin89App: App { owner.map { AccountEnrolmentStore(own: KeychainEnrolmentStore(owner: $0), signedOut: signedOut) } ?? signedOut - let lastController = DefaultsLastController( - key: owner.map { "setup.lastController.\($0.rawValue)" } ?? "setup.lastController") + let lastController = lastController(owner: owner) return SetupFlow( factory: RustControllerClientFactory(label: DeviceLabel.current), store: store, @@ -64,7 +78,7 @@ struct Origin89App: App { var body: some Scene { WindowGroup { - SetupView(flow: flow, account: account) + SetupView(flow: flow, account: account, cloud: cloud, pairings: Self.pairings) .id(ObjectIdentifier(flow)) .task { await account.refreshIfExpired() } // Another account sees other enrolments: close this flow's connection diff --git a/apps/ios/Origin89/SetupView.swift b/apps/ios/Origin89/SetupView.swift index 4dcbb62..6e8d37f 100644 --- a/apps/ios/Origin89/SetupView.swift +++ b/apps/ios/Origin89/SetupView.swift @@ -8,6 +8,8 @@ import SwiftUI struct SetupView: View { let flow: SetupFlow let account: Account + let cloud: CloudClient? + let pairings: any AccountPairingStore @State private var windowOpenedAt: Date? @State private var failedDuring: SetupFlow.State? @@ -56,7 +58,9 @@ struct SetupView: View { } } .tint(Color.origin89.action) - .sheet(isPresented: $accountShown) { AccountView(account: account) } + .sheet(isPresented: $accountShown) { + AccountView(account: account, cloud: cloud, pairings: pairings) + } .confirmationDialog( confirming == .all ? "Forget all controllers?" : "Forget this controller?", isPresented: Binding(get: { confirming != nil }, set: { if !$0 { confirming = nil } }), diff --git a/apps/ios/SetupKit/Sources/SetupKit/Account.swift b/apps/ios/SetupKit/Sources/SetupKit/Account.swift index caa9e0e..bdeffc6 100644 --- a/apps/ios/SetupKit/Sources/SetupKit/Account.swift +++ b/apps/ios/SetupKit/Sources/SetupKit/Account.swift @@ -65,6 +65,8 @@ public enum AccountError: Error, Sendable, Equatable { case refused /// The redirect or WorkOS's answer was not what AuthKit sends. case invalidResponse + /// Asked to sign in again, another account signed in. + case differentAccount case keychain(OSStatus) public var message: String { @@ -76,6 +78,7 @@ public enum AccountError: Error, Sendable, Equatable { "The sign-in service could not be reached. Check the connection and try again." case .refused: "Sign-in was refused. Try again." case .invalidResponse: "The sign-in service sent an unexpected answer. Try again." + case .differentAccount: "Sign in with the same account to continue." case .keychain: "This phone could not store or remove the sign-in in its Keychain." } } @@ -139,14 +142,7 @@ public protocol AccountSessionStore: Sendable { guard status == .signedOut else { return } status = .signingIn defer { if status == .signingIn { status = .signedOut } } - let pkce = PKCE.random() - let state = Base64URL.random(bytes: 16) - guard let url = client.authorizationURL(challenge: pkce.challenge, state: state) else { - throw .invalidResponse - } - let callback = try await authenticate(url, AuthKitConfiguration.callbackScheme) - let code = try Self.code(from: callback, state: state) - let session = try await client.authenticate(code: code, verifier: pkce.verifier) + let session = try await authorize(client, reauthenticating: nil, using: authenticate) try store.save(session) self.session = session sessionEnded = false @@ -154,6 +150,62 @@ public protocol AccountSessionStore: Sendable { SetupLog.account.info("signed in") } + /// Sign the signed-in person in again, for a cloud action that needs a + /// recent sign-in. The new session replaces the old one only when it is the + /// same user; another account's is dropped with `differentAccount`. + public func reauthenticate( + using authenticate: (URL, String) async throws(AccountError) -> URL + ) async throws(AccountError) { + guard let client else { throw .notConfigured } + guard case .signedIn(let user) = status else { throw .signedOut } + let operation = generation + let session = try await authorize(client, reauthenticating: user.email, using: authenticate) + guard generation == operation, owner == user.id else { throw .signedOut } + guard session.user.id == user.id else { + SetupLog.account.notice("another account signed in to confirm: dropped") + throw .differentAccount + } + try store.save(session) + generation += 1 + refreshing?.cancel() + refreshing = nil + self.session = session + status = .signedIn(session.user) + SetupLog.account.info("signed in again") + } + + /// End the session of an account the cloud deleted. Its tokens no longer + /// work, so memory is cleared even when the Keychain keeps them: the next + /// refresh is refused and removes them. + func endDeletedSession() { + do { + try store.remove() + } catch { + SetupLog.account.error("the deleted account's session could not be removed from the Keychain") + } + generation += 1 + refreshing?.cancel() + refreshing = nil + session = nil + status = .signedOut + SetupLog.account.info("the account was deleted: signed out") + } + + private func authorize( + _ client: AuthKitClient, reauthenticating email: String?, + using authenticate: (URL, String) async throws(AccountError) -> URL + ) async throws(AccountError) -> AccountSession { + let pkce = PKCE.random() + let state = Base64URL.random(bytes: 16) + guard + let url = client.authorizationURL( + challenge: pkce.challenge, state: state, reauthenticating: email) + else { throw .invalidResponse } + let callback = try await authenticate(url, AuthKitConfiguration.callbackScheme) + let code = try Self.code(from: callback, state: state) + return try await client.authenticate(code: code, verifier: pkce.verifier) + } + /// Remove the session from this phone. Enrolments are not touched. When the /// Keychain keeps the session, the error is thrown and the account stays /// signed in. diff --git a/apps/ios/SetupKit/Sources/SetupKit/AccountDeletion.swift b/apps/ios/SetupKit/Sources/SetupKit/AccountDeletion.swift new file mode 100644 index 0000000..0fb0ab5 --- /dev/null +++ b/apps/ios/SetupKit/Sources/SetupKit/AccountDeletion.swift @@ -0,0 +1,100 @@ +import Foundation + +/// What a deleted account's pairings become on this phone. +public enum DeletedPairings: Sendable, Equatable { + /// They become signed-out pairings, which every account on this phone uses. + case keep + /// They are removed, and each controller needs its setup code again. + case forget +} + +/// The pairings kept for one account, handed over when it is deleted. +public protocol AccountPairingStore: Sendable { + /// Make `owner`'s pairings signed-out ones. One replaces a signed-out + /// pairing for the same controller, since it is the newer. + func keep(_ owner: AccountID) throws + /// Remove `owner`'s pairings. + func forget(_ owner: AccountID) throws +} + +public enum AccountDeletion: Sendable, Equatable { + case deleted + /// The account was deleted and signed out, but its pairings could not be + /// kept or removed. They stay hidden under the deleted account. + case pairingsLeft +} + +extension CloudClient { + /// Delete the signed-in account in the cloud (`DELETE /v1/account`), then + /// hand its pairings over as the person chose and sign out. A stale sign-in + /// asks the person to sign in again through `authenticate`, then retries + /// once. Nothing on this phone changes until the cloud confirms. + public func deleteAccount( + pairings: DeletedPairings, store: any AccountPairingStore, + authenticate: (URL, String) async throws(AccountError) -> URL + ) async throws(CloudError) -> AccountDeletion { + guard let owner = account.owner else { throw .account(.signedOut) } + do { + try await send(.delete, "/v1/account") + } catch .reauthenticationRequired { + do { + try await account.reauthenticate(using: authenticate) + } catch { + throw .account(error) + } + try await send(.delete, "/v1/account") + } + // Deleted in the cloud: the person may have signed out meanwhile. + var outcome = AccountDeletion.deleted + do { + switch pairings { + case .keep: try store.keep(owner) + case .forget: try store.forget(owner) + } + } catch { + SetupLog.account.error( + "the deleted account's pairings could not be handed over: \(String(describing: error), privacy: .public)" + ) + outcome = .pairingsLeft + } + if account.owner == owner { account.endDeletedSession() } + return outcome + } +} + +/// Pairings in the Keychain, with each scope's last controller. +public struct KeychainAccountPairings: AccountPairingStore { + private let signedOutLast: any LastControllerStore + private let accountLast: @Sendable (AccountID) -> any LastControllerStore + + public init( + signedOutLast: any LastControllerStore, + accountLast: @escaping @Sendable (AccountID) -> any LastControllerStore + ) { + self.signedOutLast = signedOutLast + self.accountLast = accountLast + } + + public func keep(_ owner: AccountID) throws { + let own = KeychainEnrolmentStore(owner: owner) + let signedOut = KeychainEnrolmentStore() + for deviceID in own.deviceIDs() { + // Unreadable, as while the phone is locked: keep it rather than lose it. + guard let enrolment = own.load(deviceID: deviceID) else { + throw KeychainEnrolmentStore.Failure(status: errSecInteractionNotAllowed) + } + try signedOut.save(enrolment, deviceID: deviceID) + } + try own.removeAll() + // Relaunching signed out reconnects to the account's last controller + // when no signed-out one is remembered. + let last = accountLast(owner) + if signedOutLast.load() == nil, let deviceID = last.load() { signedOutLast.save(deviceID) } + last.save(nil) + } + + public func forget(_ owner: AccountID) throws { + try KeychainEnrolmentStore(owner: owner).removeAll() + accountLast(owner).save(nil) + } +} diff --git a/apps/ios/SetupKit/Sources/SetupKit/AuthKitClient.swift b/apps/ios/SetupKit/Sources/SetupKit/AuthKitClient.swift index 1562e42..f4e144f 100644 --- a/apps/ios/SetupKit/Sources/SetupKit/AuthKitClient.swift +++ b/apps/ios/SetupKit/Sources/SetupKit/AuthKitClient.swift @@ -88,8 +88,12 @@ public struct AuthKitClient: Sendable { } /// The hosted AuthKit page, which offers every sign-in method enabled in - /// WorkOS. Its redirect carries `code` and `state`. - func authorizationURL(challenge: String, state: String) -> URL? { + /// WorkOS. Its redirect carries `code` and `state`. With `reauthenticating`, + /// AuthKit asks that person to sign in again even with a live session + /// (`max_age=0`), which starts a new one. + func authorizationURL(challenge: String, state: String, reauthenticating email: String? = nil) + -> URL? + { var components = URLComponents( url: configuration.apiBase.appending(path: "user_management/authorize"), resolvingAgainstBaseURL: false) @@ -102,6 +106,11 @@ public struct AuthKitClient: Sendable { URLQueryItem(name: "code_challenge_method", value: "S256"), URLQueryItem(name: "state", value: state), ] + if let email { + components?.queryItems? += [ + URLQueryItem(name: "max_age", value: "0"), URLQueryItem(name: "login_hint", value: email), + ] + } return components?.url } diff --git a/apps/ios/SetupKit/Sources/SetupKit/CloudClient.swift b/apps/ios/SetupKit/Sources/SetupKit/CloudClient.swift new file mode 100644 index 0000000..21f1f0d --- /dev/null +++ b/apps/ios/SetupKit/Sources/SetupKit/CloudClient.swift @@ -0,0 +1,155 @@ +import Foundation + +/// Where the Origin89 cloud API runs. Each cloud accepts tokens only from its +/// own WorkOS environment, so a build pairs it with that environment's client. +public struct CloudConfiguration: Sendable { + public let baseURL: URL + + /// Nil for an empty or non-HTTPS URL, as a build without a cloud has. + public init?(baseURL: String) { + guard let url = URL(string: baseURL), url.scheme == "https", url.host() != nil else { + return nil + } + self.baseURL = url + } +} + +/// What a cloud request can fail with. Server errors map from the contract's +/// `{"error": {"code", "message"}}` (`@origin89/cloud`). +public enum CloudError: Error, Sendable, Equatable { + /// No access token: signed out, or the refresh failed. + case account(AccountError) + /// The cloud could not be reached. + case unreachable + /// `unauthenticated`: the cloud refused the token. + case unauthenticated + /// `reauthentication_required`: the action needs a sign-in within the last + /// five minutes. `Account.reauthenticate` and a retry clear it. + case reauthenticationRequired + /// `not_found`: the site does not exist or the caller is not its owner. + case notFound + /// `generation_linked`: this device and epoch are linked to another site. + case generationLinked + /// `stale_epoch`: a newer epoch of this device is already linked. + case staleEpoch + /// `provider_unavailable`: WorkOS failed. The message says what changed. + case providerUnavailable(String) + /// Any other error answer: `invalid_request`, `internal`, or a code this + /// build does not know. The cloud's message is kept. + case rejected(status: Int, message: String) + /// An answer that is not the contract's. + case invalidResponse + + public var message: String { + switch self { + case .account(let error): error.message + case .unreachable: + "The Origin89 cloud could not be reached. Check the connection and try again." + case .unauthenticated: "The cloud did not accept this sign-in. Sign out and sign in again." + case .reauthenticationRequired: "Sign in again to continue." + case .notFound: "That site was not found." + case .generationLinked: "This controller is already linked to another site." + case .staleEpoch: + "This controller was reset since this phone paired. Reconnect to it, then try again." + case .providerUnavailable(let message), .rejected(_, let message): message + case .invalidResponse: "The cloud sent an unexpected answer. Try again." + } + } +} + +/// Requests to the v1 cloud API with the signed-in account's access token. +@MainActor public struct CloudClient { + public enum Method: String, Sendable { + case get = "GET" + case post = "POST" + case delete = "DELETE" + } + + public let account: Account + private let configuration: CloudConfiguration + private let http: any HTTPSender + + public init( + configuration: CloudConfiguration, account: Account, http: any HTTPSender = URLSessionSender() + ) { + self.configuration = configuration + self.account = account + self.http = http + } + + /// Send `body` as JSON to `path` (such as `/v1/sites`) and decode the + /// answer, which the contract writes in camelCase. + public func send( + _ method: Method, _ path: String, body: (any Encodable)? = nil, as type: Response.Type + ) async throws(CloudError) -> Response { + let data = try await send(method, path, body: body) + do { + return try JSONDecoder().decode(type, from: data) + } catch { + SetupLog.account.error("the cloud answered \(path, privacy: .public) with an unreadable body") + throw .invalidResponse + } + } + + /// Send a request whose answer carries no body the caller needs. + @discardableResult + public func send(_ method: Method, _ path: String, body: (any Encodable)? = nil) + async throws(CloudError) -> Data + { + let token: String + do { + token = try await account.accessToken() + } catch { + throw .account(error) + } + var request = URLRequest(url: configuration.baseURL.appending(path: path)) + request.httpMethod = method.rawValue + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + if let body { + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + do { + request.httpBody = try JSONEncoder().encode(body) + } catch { + throw .invalidResponse + } + } + let data: Data + let response: HTTPURLResponse + do { + (data, response) = try await http.send(request) + } catch { + SetupLog.account.error( + "the cloud could not be reached: \(String(describing: error), privacy: .public)") + throw .unreachable + } + if (200..<300).contains(response.statusCode) { return data } + let failure = Self.failure(status: response.statusCode, data) + SetupLog.account.notice( + "the cloud refused \(method.rawValue, privacy: .public) \(path, privacy: .public): status \(response.statusCode, privacy: .public)" + ) + throw failure + } + + private static func failure(status: Int, _ data: Data) -> CloudError { + struct Body: Decodable { + struct Detail: Decodable { + let code: String + let message: String + } + let error: Detail + } + guard let detail = try? JSONDecoder().decode(Body.self, from: data).error else { + return status >= 500 ? .unreachable : .invalidResponse + } + return switch detail.code { + case "unauthenticated": .unauthenticated + case "reauthentication_required": .reauthenticationRequired + case "not_found": .notFound + case "generation_linked": .generationLinked + case "stale_epoch": .staleEpoch + case "provider_unavailable": .providerUnavailable(detail.message) + default: .rejected(status: status, message: detail.message) + } + } +} diff --git a/apps/ios/SetupKit/Sources/SetupKit/KeychainEnrolmentStore.swift b/apps/ios/SetupKit/Sources/SetupKit/KeychainEnrolmentStore.swift index 6fbe789..741651a 100644 --- a/apps/ios/SetupKit/Sources/SetupKit/KeychainEnrolmentStore.swift +++ b/apps/ios/SetupKit/Sources/SetupKit/KeychainEnrolmentStore.swift @@ -39,6 +39,19 @@ public struct KeychainEnrolmentStore: EnrolmentStore { public func remove(deviceID: String) throws { try delete(item(deviceID)) } + /// The `device_id` of every enrolment under this service, sorted. + public func deviceIDs() -> [String] { + var query = items() + query[kSecMatchLimit as String] = kSecMatchLimitAll + query[kSecReturnAttributes as String] = true + var result: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess else { return [] } + return (result as? [[String: Any]] ?? []).compactMap { + $0[kSecAttrAccount as String] as? String + } + .sorted() + } + /// Every item under this service, including ones a previous install of /// the app left: Keychain items outlive an app delete. public func removeAll() throws { try delete(items()) } diff --git a/apps/ios/SetupKit/Tests/SetupKitTests/CloudTests.swift b/apps/ios/SetupKit/Tests/SetupKitTests/CloudTests.swift new file mode 100644 index 0000000..c694d3b --- /dev/null +++ b/apps/ios/SetupKit/Tests/SetupKitTests/CloudTests.swift @@ -0,0 +1,278 @@ +import Foundation +import Testing + +@testable import SetupKit + +private let now = Date(timeIntervalSince1970: 1_800_000_000) +private let user = AccountUser(id: AccountID("user_01A"), email: "a@example.com") + +private func token(_ tag: String) -> String { + let payload = #"{"exp":\#(Int(now.timeIntervalSince1970 + 300)),"t":"\#(tag)"}"# + return "e30.\(Base64URL.encode(Data(payload.utf8))).sig" +} + +private func sessionJSON(id: String, access: String) -> String { + #""" + {"user":{"id":"\#(id)","email":"a@example.com","first_name":null,"last_name":null}, + "access_token":"\#(access)","refresh_token":"r2"} + """# +} + +private func failure(_ code: String, _ message: String = "From the cloud.") -> String { + #"{"error":{"code":"\#(code)","message":"\#(message)"}}"# +} + +/// Records what deletion hands over, or refuses it. +private final class Pairings: AccountPairingStore, @unchecked Sendable { + struct Refused: Error {} + private let lock = NSLock() + private var handed: [String] = [] + private let refuses: Bool + init(refuses: Bool = false) { self.refuses = refuses } + var calls: [String] { lock.withLock { handed } } + func keep(_ owner: AccountID) throws { + if refuses { throw Refused() } + lock.withLock { handed.append("keep \(owner.rawValue)") } + } + func forget(_ owner: AccountID) throws { + if refuses { throw Refused() } + lock.withLock { handed.append("forget \(owner.rawValue)") } + } +} + +private struct Setup { + let account: Account + let cloud: CloudClient + let cloudHTTP: StubHTTP + let authHTTP: StubHTTP + let session: MemorySessionStore +} + +@MainActor private func setup( + cloud answers: [StubHTTP.Answer], auth: [StubHTTP.Answer] = [], signedIn: Bool = true +) throws -> Setup { + let authHTTP = StubHTTP(auth) + let cloudHTTP = StubHTTP(answers) + let session = MemorySessionStore( + signedIn ? AccountSession(user: user, accessToken: token("old"), refreshToken: "r1") : nil) + let configuration = try #require(AuthKitConfiguration(clientID: "client_01TEST")) + let account = Account( + client: AuthKitClient(configuration: configuration, http: authHTTP), store: session, + now: { now }) + let cloud = CloudClient( + configuration: try #require(CloudConfiguration(baseURL: "https://cloud.example.com")), + account: account, http: cloudHTTP) + return Setup( + account: account, cloud: cloud, cloudHTTP: cloudHTTP, authHTTP: authHTTP, session: session) +} + +/// Answers like AuthKit, echoing the request's `state`, and records the URL. +private final class Sheet: @unchecked Sendable { + private(set) var opened: [URL] = [] + var cancels = false + func authenticate(_ url: URL, _ scheme: String) throws(AccountError) -> URL { + opened.append(url) + if cancels { throw .cancelled } + let state = + URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems? + .first { $0.name == "state" }?.value ?? "" + guard let callback = URL(string: "\(scheme)://auth/callback?code=c&state=\(state)") else { + throw .invalidResponse + } + return callback + } +} + +// MARK: Configuration and requests + +@Test func onlyAnHTTPSCloudIsConfigured() { + #expect( + CloudConfiguration(baseURL: "https://cloud.origin89.com")?.baseURL.host() + == "cloud.origin89.com") + #expect(CloudConfiguration(baseURL: "http://cloud.origin89.com") == nil) + #expect(CloudConfiguration(baseURL: "") == nil) + #expect(CloudConfiguration(baseURL: "https://") == nil) +} + +@Test @MainActor func aRequestCarriesTheAccessTokenAndBody() async throws { + struct Site: Decodable, Equatable { let id: String } + let setup = try setup(cloud: [.status(201, #"{"id":"s1"}"#)]) + let site = try await setup.cloud.send( + .post, "/v1/sites", body: ["name": "Home"], as: Site.self) + #expect(site == Site(id: "s1")) + let request = try #require(setup.cloudHTTP.requests.first) + #expect(request.url?.absoluteString == "https://cloud.example.com/v1/sites") + #expect(request.httpMethod == "POST") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer \(token("old"))") + #expect(setup.cloudHTTP.bodies == [["name": "Home"]]) +} + +@Test(arguments: [ + (401, failure("unauthenticated"), CloudError.unauthenticated), + (401, failure("reauthentication_required"), .reauthenticationRequired), + (404, failure("not_found"), .notFound), + (409, failure("generation_linked"), .generationLinked), + (409, failure("stale_epoch"), .staleEpoch), + ( + 502, failure("provider_unavailable", "Retry to finish."), + .providerUnavailable("Retry to finish.") + ), + ( + 400, failure("invalid_request", "name: Required"), + .rejected(status: 400, message: "name: Required") + ), + (418, failure("a_new_code", "Newer cloud."), .rejected(status: 418, message: "Newer cloud.")), + (500, "", .unreachable), + (400, "", .invalidResponse), +]) +@MainActor func cloudErrorsMapFromTheContract(status: Int, body: String, expected: CloudError) + async throws +{ + let setup = try setup(cloud: [.status(status, body)]) + await #expect(throws: expected) { try await setup.cloud.send(.get, "/v1/sites") } +} + +@Test @MainActor func anUnreachableCloudIsTold() async throws { + let setup = try setup(cloud: [.offline]) + await #expect(throws: CloudError.unreachable) { try await setup.cloud.send(.get, "/v1/sites") } +} + +@Test @MainActor func signedOutSendsNothing() async throws { + let setup = try setup(cloud: [], signedIn: false) + await #expect(throws: CloudError.account(.signedOut)) { + try await setup.cloud.send(.get, "/v1/sites") + } + #expect(setup.cloudHTTP.requests.isEmpty) +} + +@Test @MainActor func aBodyOutsideTheContractIsInvalid() async throws { + struct Site: Decodable { let id: String } + let setup = try setup(cloud: [.status(200, #"{"sites":[]}"#)]) + await #expect(throws: CloudError.invalidResponse) { + try await setup.cloud.send(.get, "/v1/sites", as: Site.self) + } +} + +// MARK: Deletion + +@Test(arguments: [DeletedPairings.keep, .forget]) +@MainActor func deletingHandsThePairingsOverAndSignsOut(pairings: DeletedPairings) async throws { + let setup = try setup(cloud: [.status(204, "")]) + let store = Pairings() + let sheet = Sheet() + let outcome = try await setup.cloud.deleteAccount( + pairings: pairings, store: store, authenticate: sheet.authenticate) + #expect(outcome == .deleted) + #expect(store.calls == [pairings == .keep ? "keep user_01A" : "forget user_01A"]) + #expect(setup.cloudHTTP.requests.map(\.httpMethod) == ["DELETE"]) + #expect(setup.cloudHTTP.requests.first?.url?.path == "/v1/account") + #expect(setup.account.status == .signedOut) + #expect(!setup.account.sessionEnded) + #expect(setup.session.session == nil) + #expect(sheet.opened.isEmpty) +} + +/// The cloud needs a sign-in within five minutes: sign in again, then retry. +@Test @MainActor func aStaleSignInSignsInAgainThenDeletes() async throws { + let fresh = token("fresh") + let setup = try setup( + cloud: [.status(401, failure("reauthentication_required")), .status(204, "")], + auth: [.status(200, sessionJSON(id: "user_01A", access: fresh))]) + let store = Pairings() + let sheet = Sheet() + let outcome = try await setup.cloud.deleteAccount( + pairings: .keep, store: store, authenticate: sheet.authenticate) + #expect(outcome == .deleted) + let opened = try #require(sheet.opened.first) + let items = URLComponents(url: opened, resolvingAgainstBaseURL: false)?.queryItems ?? [] + #expect(items.first { $0.name == "max_age" }?.value == "0") + #expect(items.first { $0.name == "login_hint" }?.value == "a@example.com") + #expect( + setup.cloudHTTP.requests.map { $0.value(forHTTPHeaderField: "Authorization") } + == ["Bearer \(token("old"))", "Bearer \(fresh)"]) + #expect(store.calls == ["keep user_01A"]) +} + +/// Signing in again as someone else must not delete that other account. +@Test @MainActor func anotherAccountSigningInAgainDeletesNothing() async throws { + let setup = try setup( + cloud: [.status(401, failure("reauthentication_required"))], + auth: [.status(200, sessionJSON(id: "user_01B", access: token("b")))]) + let store = Pairings() + await #expect(throws: CloudError.account(.differentAccount)) { + try await setup.cloud.deleteAccount( + pairings: .forget, store: store, authenticate: Sheet().authenticate) + } + #expect(setup.cloudHTTP.requests.count == 1) + #expect(setup.account.status == .signedIn(user)) + #expect(setup.session.session?.accessToken == token("old")) + #expect(store.calls.isEmpty) +} + +@Test @MainActor func cancellingTheNewSignInDeletesNothing() async throws { + let setup = try setup(cloud: [.status(401, failure("reauthentication_required"))]) + let store = Pairings() + let sheet = Sheet() + sheet.cancels = true + await #expect(throws: CloudError.account(.cancelled)) { + try await setup.cloud.deleteAccount( + pairings: .forget, store: store, authenticate: sheet.authenticate) + } + #expect(setup.account.status == .signedIn(user)) + #expect(store.calls.isEmpty) +} + +/// WorkOS failed after the cloud removed its records: nothing changes here, +/// and a retry finishes. +@Test @MainActor func aWorkOSFailureKeepsEverythingForARetry() async throws { + let setup = try setup(cloud: [ + .status(502, failure("provider_unavailable", "Retry to finish.")), .status(204, ""), + ]) + let store = Pairings() + let sheet = Sheet() + await #expect(throws: CloudError.providerUnavailable("Retry to finish.")) { + try await setup.cloud.deleteAccount( + pairings: .forget, store: store, authenticate: sheet.authenticate) + } + #expect(setup.account.status == .signedIn(user)) + #expect(store.calls.isEmpty) + let outcome = try await setup.cloud.deleteAccount( + pairings: .forget, store: store, authenticate: sheet.authenticate) + #expect(outcome == .deleted) + #expect(store.calls == ["forget user_01A"]) +} + +@Test @MainActor func pairingsThatCannotBeHandedOverAreReported() async throws { + let setup = try setup(cloud: [.status(204, "")]) + let outcome = try await setup.cloud.deleteAccount( + pairings: .keep, store: Pairings(refuses: true), authenticate: Sheet().authenticate) + #expect(outcome == .pairingsLeft) + #expect(setup.account.status == .signedOut) +} + +/// The Keychain keeping a deleted account's tokens does not keep it signed in. +@Test @MainActor func aDeletedAccountSignsOutEvenIfTheKeychainKeepsItsSession() async throws { + let authHTTP = StubHTTP([]) + let session = MemorySessionStore( + AccountSession(user: user, accessToken: token("old"), refreshToken: "r1"), + refusesRemovals: true) + let configuration = try #require(AuthKitConfiguration(clientID: "client_01TEST")) + let account = Account( + client: AuthKitClient(configuration: configuration, http: authHTTP), store: session, + now: { now }) + let cloud = CloudClient( + configuration: try #require(CloudConfiguration(baseURL: "https://cloud.example.com")), + account: account, http: StubHTTP([.status(204, "")])) + _ = try await cloud.deleteAccount( + pairings: .keep, store: Pairings(), authenticate: Sheet().authenticate) + #expect(account.status == .signedOut) +} + +@Test @MainActor func signedOutCannotDelete() async throws { + let setup = try setup(cloud: [], signedIn: false) + await #expect(throws: CloudError.account(.signedOut)) { + try await setup.cloud.deleteAccount( + pairings: .keep, store: Pairings(), authenticate: Sheet().authenticate) + } + #expect(setup.cloudHTTP.requests.isEmpty) +} From 2836d8ab97f0a952311880d25adbc6815ef4e8cc Mon Sep 17 00:00:00 2001 From: David Date: Fri, 25 Sep 2026 11:38:49 -0400 Subject: [PATCH 2/4] fix: build the last-controller store off the main actor --- apps/ios/Origin89/Origin89App.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ios/Origin89/Origin89App.swift b/apps/ios/Origin89/Origin89App.swift index 251e2eb..3704559 100644 --- a/apps/ios/Origin89/Origin89App.swift +++ b/apps/ios/Origin89/Origin89App.swift @@ -49,7 +49,7 @@ struct Origin89App: App { return baseURL.flatMap { CloudConfiguration(baseURL: $0) } } - private static func lastController(owner: AccountID?) -> DefaultsLastController { + nonisolated private static func lastController(owner: AccountID?) -> DefaultsLastController { DefaultsLastController( key: owner.map { "setup.lastController.\($0.rawValue)" } ?? "setup.lastController") } From 8fd7b357a70ffa41557b4e6ef09e4d285498bfb2 Mon Sep 17 00:00:00 2001 From: David Date: Fri, 25 Sep 2026 12:14:07 -0400 Subject: [PATCH 3/4] fix: keep pairings safe when deleting the account --- .../Sources/SetupKit/AccountDeletion.swift | 5 +++- .../SetupKit/KeychainEnrolmentStore.swift | 29 ++++++++++++++----- .../Tests/SetupKitTests/CloudTests.swift | 23 +++++++++++++++ 3 files changed, 48 insertions(+), 9 deletions(-) diff --git a/apps/ios/SetupKit/Sources/SetupKit/AccountDeletion.swift b/apps/ios/SetupKit/Sources/SetupKit/AccountDeletion.swift index 0fb0ab5..7462dfd 100644 --- a/apps/ios/SetupKit/Sources/SetupKit/AccountDeletion.swift +++ b/apps/ios/SetupKit/Sources/SetupKit/AccountDeletion.swift @@ -37,11 +37,14 @@ extension CloudClient { do { try await send(.delete, "/v1/account") } catch .reauthenticationRequired { + // Another account signed in meanwhile: never delete it instead. + guard account.owner == owner else { throw .account(.differentAccount) } do { try await account.reauthenticate(using: authenticate) } catch { throw .account(error) } + guard account.owner == owner else { throw .account(.differentAccount) } try await send(.delete, "/v1/account") } // Deleted in the cloud: the person may have signed out meanwhile. @@ -78,7 +81,7 @@ public struct KeychainAccountPairings: AccountPairingStore { public func keep(_ owner: AccountID) throws { let own = KeychainEnrolmentStore(owner: owner) let signedOut = KeychainEnrolmentStore() - for deviceID in own.deviceIDs() { + for deviceID in try own.storedDeviceIDs() { // Unreadable, as while the phone is locked: keep it rather than lose it. guard let enrolment = own.load(deviceID: deviceID) else { throw KeychainEnrolmentStore.Failure(status: errSecInteractionNotAllowed) diff --git a/apps/ios/SetupKit/Sources/SetupKit/KeychainEnrolmentStore.swift b/apps/ios/SetupKit/Sources/SetupKit/KeychainEnrolmentStore.swift index 741651a..577b522 100644 --- a/apps/ios/SetupKit/Sources/SetupKit/KeychainEnrolmentStore.swift +++ b/apps/ios/SetupKit/Sources/SetupKit/KeychainEnrolmentStore.swift @@ -28,24 +28,37 @@ public struct KeychainEnrolmentStore: EnrolmentStore { return result as? Data } + /// Replaces an existing entry in place, so a failed save leaves the older + /// one as it was. public func save(_ enrolment: Data, deviceID: String) throws { - try remove(deviceID: deviceID) - var attributes = item(deviceID) - attributes[kSecValueData as String] = enrolment - attributes[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly - let added = SecItemAdd(attributes as CFDictionary, nil) + let changes: [String: Any] = [ + kSecValueData as String: enrolment, + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly, + ] + let updated = SecItemUpdate(item(deviceID) as CFDictionary, changes as CFDictionary) + if updated == errSecSuccess { return } + guard updated == errSecItemNotFound else { throw Failure(status: updated) } + let added = SecItemAdd(item(deviceID).merging(changes) { $1 } as CFDictionary, nil) guard added == errSecSuccess else { throw Failure(status: added) } } public func remove(deviceID: String) throws { try delete(item(deviceID)) } - /// The `device_id` of every enrolment under this service, sorted. - public func deviceIDs() -> [String] { + /// The `device_id` of every enrolment under this service, sorted; none + /// when the Keychain cannot be read. + public func deviceIDs() -> [String] { (try? storedDeviceIDs()) ?? [] } + + /// The `device_id` of every enrolment under this service, sorted. Throws + /// when the Keychain cannot be read, as while the phone is locked, rather + /// than answering none. + public func storedDeviceIDs() throws -> [String] { var query = items() query[kSecMatchLimit as String] = kSecMatchLimitAll query[kSecReturnAttributes as String] = true var result: CFTypeRef? - guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess else { return [] } + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { return [] } + guard status == errSecSuccess else { throw Failure(status: status) } return (result as? [[String: Any]] ?? []).compactMap { $0[kSecAttrAccount as String] as? String } diff --git a/apps/ios/SetupKit/Tests/SetupKitTests/CloudTests.swift b/apps/ios/SetupKit/Tests/SetupKitTests/CloudTests.swift index c694d3b..9848375 100644 --- a/apps/ios/SetupKit/Tests/SetupKitTests/CloudTests.swift +++ b/apps/ios/SetupKit/Tests/SetupKitTests/CloudTests.swift @@ -209,6 +209,29 @@ private final class Sheet: @unchecked Sendable { #expect(store.calls.isEmpty) } +/// The person switched accounts while the first request was out: the retry +/// must not delete the account that signed in since. +@Test @MainActor func anAccountSwitchDuringTheRequestDeletesNothing() async throws { + let other = AccountUser(id: AccountID("user_01B"), email: "a@example.com") + let setup = try setup( + cloud: [.status(401, failure("reauthentication_required"))], + auth: [.status(200, sessionJSON(id: "user_01B", access: token("b")))]) + let store = Pairings() + let sheet = Sheet() + let deletion = Task { + try await setup.cloud.deleteAccount( + pairings: .forget, store: store, authenticate: sheet.authenticate) + } + while setup.cloudHTTP.requests.isEmpty { await Task.yield() } + try setup.account.signOut() + try await setup.account.signIn(using: sheet.authenticate) + #expect(setup.account.status == .signedIn(other)) + await #expect(throws: CloudError.account(.differentAccount)) { try await deletion.value } + #expect(setup.cloudHTTP.requests.count == 1) + #expect(setup.account.status == .signedIn(other)) + #expect(store.calls.isEmpty) +} + @Test @MainActor func cancellingTheNewSignInDeletesNothing() async throws { let setup = try setup(cloud: [.status(401, failure("reauthentication_required"))]) let store = Pairings() From 7ab2e202728243b494075ac26c8a24b8992c7288 Mon Sep 17 00:00:00 2001 From: David Date: Fri, 25 Sep 2026 12:26:42 -0400 Subject: [PATCH 4/4] test: hold the cloud answer until the account switch ends --- .../Tests/SetupKitTests/AccountTests.swift | 19 +++++++++++++++++++ .../Tests/SetupKitTests/CloudTests.swift | 3 +++ 2 files changed, 22 insertions(+) diff --git a/apps/ios/SetupKit/Tests/SetupKitTests/AccountTests.swift b/apps/ios/SetupKit/Tests/SetupKitTests/AccountTests.swift index 00f8bb9..0656372 100644 --- a/apps/ios/SetupKit/Tests/SetupKitTests/AccountTests.swift +++ b/apps/ios/SetupKit/Tests/SetupKitTests/AccountTests.swift @@ -13,7 +13,19 @@ final class StubHTTP: HTTPSender, @unchecked Sendable { private let lock = NSLock() private var answers: [Answer] private var sent: [URLRequest] = [] + private var holding = false + private var held: [CheckedContinuation] = [] init(_ answers: [Answer]) { self.answers = answers } + /// Keep every answer back until `release()`. + func hold() { lock.withLock { holding = true } } + func release() { + let waiting = lock.withLock { + holding = false + defer { held.removeAll() } + return held + } + for continuation in waiting { continuation.resume() } + } var requests: [URLRequest] { lock.withLock { sent } } /// The JSON body of each request. var bodies: [[String: String]] { @@ -26,6 +38,13 @@ final class StubHTTP: HTTPSender, @unchecked Sendable { sent.append(request) return answers.isEmpty ? nil : answers.removeFirst() } + await withCheckedContinuation { continuation in + let waits = lock.withLock { + if holding { held.append(continuation) } + return holding + } + if !waits { continuation.resume() } + } // Let a concurrent caller reach the account while this one waits. await Task.yield() guard case .status(let code, let body) = answer, let url = request.url, diff --git a/apps/ios/SetupKit/Tests/SetupKitTests/CloudTests.swift b/apps/ios/SetupKit/Tests/SetupKitTests/CloudTests.swift index 9848375..da06a97 100644 --- a/apps/ios/SetupKit/Tests/SetupKitTests/CloudTests.swift +++ b/apps/ios/SetupKit/Tests/SetupKitTests/CloudTests.swift @@ -218,6 +218,8 @@ private final class Sheet: @unchecked Sendable { auth: [.status(200, sessionJSON(id: "user_01B", access: token("b")))]) let store = Pairings() let sheet = Sheet() + // The first answer waits until the switch is done. + setup.cloudHTTP.hold() let deletion = Task { try await setup.cloud.deleteAccount( pairings: .forget, store: store, authenticate: sheet.authenticate) @@ -226,6 +228,7 @@ private final class Sheet: @unchecked Sendable { try setup.account.signOut() try await setup.account.signIn(using: sheet.authenticate) #expect(setup.account.status == .signedIn(other)) + setup.cloudHTTP.release() await #expect(throws: CloudError.account(.differentAccount)) { try await deletion.value } #expect(setup.cloudHTTP.requests.count == 1) #expect(setup.account.status == .signedIn(other))