From be654bc6f3981c78001f8401a9c41a68659302f5 Mon Sep 17 00:00:00 2001 From: David Date: Fri, 25 Sep 2026 10:19:16 -0400 Subject: [PATCH 1/3] feat: add optional WorkOS sign-in with per-account pairings --- apps/ios/Account.xcconfig | 5 + apps/ios/Origin89.xcodeproj/project.pbxproj | 6 + apps/ios/Origin89/AccountView.swift | 115 ++++++ apps/ios/Origin89/Info.plist | 2 + apps/ios/Origin89/Origin89App.swift | 73 +++- apps/ios/Origin89/SetupView.swift | 14 +- .../SetupKit/Sources/SetupKit/Account.swift | 247 +++++++++++++ .../SetupKit/AccountEnrolmentStore.swift | 42 +++ .../Sources/SetupKit/AuthKitClient.swift | 168 +++++++++ .../KeychainAccountSessionStore.swift | 53 +++ .../SetupKit/KeychainEnrolmentStore.swift | 33 +- .../SetupKit/Sources/SetupKit/SetupLog.swift | 3 +- .../SetupKitTests/AccountEnrolmentTests.swift | 118 +++++++ .../Tests/SetupKitTests/AccountTests.swift | 327 ++++++++++++++++++ apps/ios/Signing.xcconfig | 1 + 15 files changed, 1185 insertions(+), 22 deletions(-) create mode 100644 apps/ios/Account.xcconfig create mode 100644 apps/ios/Origin89/AccountView.swift create mode 100644 apps/ios/SetupKit/Sources/SetupKit/Account.swift create mode 100644 apps/ios/SetupKit/Sources/SetupKit/AccountEnrolmentStore.swift create mode 100644 apps/ios/SetupKit/Sources/SetupKit/AuthKitClient.swift create mode 100644 apps/ios/SetupKit/Sources/SetupKit/KeychainAccountSessionStore.swift create mode 100644 apps/ios/SetupKit/Tests/SetupKitTests/AccountEnrolmentTests.swift create mode 100644 apps/ios/SetupKit/Tests/SetupKitTests/AccountTests.swift diff --git a/apps/ios/Account.xcconfig b/apps/ios/Account.xcconfig new file mode 100644 index 0000000..99d7506 --- /dev/null +++ b/apps/ios/Account.xcconfig @@ -0,0 +1,5 @@ +// WorkOS AuthKit client IDs are public and ship in the app. Debug signs in +// against the staging environment, Release against production. + +WORKOS_CLIENT_ID[config=Debug] = client_01M3C9M944Y53VA9PJRCW8T5S6 +WORKOS_CLIENT_ID[config=Release] = client_01M3CC0Q23FPHK50YW1WXCNZ3S diff --git a/apps/ios/Origin89.xcodeproj/project.pbxproj b/apps/ios/Origin89.xcodeproj/project.pbxproj index 79ab76a..10e6230 100644 --- a/apps/ios/Origin89.xcodeproj/project.pbxproj +++ b/apps/ios/Origin89.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ AB8900000000000000000001 /* Origin89UI in Frameworks */ = {isa = PBXBuildFile; productRef = AB8900000000000000000002 /* Origin89UI */; }; AB8900000000000000000009 /* SetupKit in Frameworks */ = {isa = PBXBuildFile; productRef = AB8900000000000000000007 /* SetupKit */; }; AB890000000000000000000A /* SetupCore in Frameworks */ = {isa = PBXBuildFile; productRef = AB8900000000000000000008 /* SetupCore */; }; + AB8900000000000000000015 /* AccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB8900000000000000000014 /* AccountView.swift */; }; AB890000000000000000000C /* SetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB890000000000000000000B /* SetupView.swift */; }; AB890000000000000000000E /* NetworkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB890000000000000000000D /* NetworkView.swift */; }; AB8900000000000000000010 /* CodeScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB890000000000000000000F /* CodeScannerView.swift */; }; @@ -19,6 +20,8 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + AB8900000000000000000014 /* AccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountView.swift; sourceTree = ""; }; + AB8900000000000000000016 /* Account.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Account.xcconfig; sourceTree = ""; }; 291B0A8141E0F76A304D6029 /* Signing.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Signing.xcconfig; sourceTree = ""; }; 8E94264721F66A5291BC0C5B /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; AB890000000000000000000B /* SetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupView.swift; sourceTree = ""; }; @@ -56,6 +59,7 @@ isa = PBXGroup; children = ( F00B56E0DCEE33A9A258AFB6 /* Origin89 */, + AB8900000000000000000016 /* Account.xcconfig */, 291B0A8141E0F76A304D6029 /* Signing.xcconfig */, C6516E3FD84BCA0F3E0309D5 /* Products */, ); @@ -64,6 +68,7 @@ F00B56E0DCEE33A9A258AFB6 /* Origin89 */ = { isa = PBXGroup; children = ( + AB8900000000000000000014 /* AccountView.swift */, 8E94264721F66A5291BC0C5B /* Assets.xcassets */, AB890000000000000000000F /* CodeScannerView.swift */, AB890000000000000000000D /* NetworkView.swift */, @@ -155,6 +160,7 @@ buildActionMask = 2147483647; files = ( D725D6BB1B4CC521C45F1B0E /* Origin89App.swift in Sources */, + AB8900000000000000000015 /* AccountView.swift in Sources */, AB890000000000000000000C /* SetupView.swift in Sources */, AB890000000000000000000E /* NetworkView.swift in Sources */, AB8900000000000000000010 /* CodeScannerView.swift in Sources */, diff --git a/apps/ios/Origin89/AccountView.swift b/apps/ios/Origin89/AccountView.swift new file mode 100644 index 0000000..ed1ca2d --- /dev/null +++ b/apps/ios/Origin89/AccountView.swift @@ -0,0 +1,115 @@ +import AuthenticationServices +import Origin89UI +import SetupKit +import SwiftUI + +/// Optional sign-in. Setup never asks for it: pairing and the controller's +/// network work the same signed in or out. +struct AccountView: View { + let account: Account + + @State private var failure: AccountError? + @State private var confirmingSignOut = false + @Environment(\.webAuthenticationSession) private var webAuthenticationSession + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + Form { + switch account.status { + case .signedOut, .signingIn: signedOut + case .signedIn(let user): signedIn(user) + } + if let failure { + Section { Origin89Notice(failure.message, tone: .alarm) } + } + } + .navigationTitle("Account") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } + } + } + .confirmationDialog( + "Sign out?", isPresented: $confirmingSignOut, titleVisibility: .visible + ) { + Button("Sign out", role: .destructive, action: signOut) + } message: { + Text( + "Controllers paired while signed in stay on this phone and come back when you sign in again." + ) + } + } + + @ViewBuilder private var signedOut: some View { + Section { + if account.sessionEnded { + Origin89Notice("Your session ended. Sign in again to use your account.", tone: .info) + } + Text("An account is optional. Pairing and setting up a controller work without one.") + if !account.isAvailable { Origin89Notice(AccountError.notConfigured.message, tone: .info) } + Button(action: signIn) { + if account.status == .signingIn { + HStack(spacing: 12) { + ProgressView() + Text("Signing in…") + } + } else { + Text("Sign in") + } + } + .disabled(account.status == .signingIn || !account.isAvailable) + } footer: { + Text("Sign in with Apple, Google, a passkey or a code sent by email.") + } + } + + @ViewBuilder private func signedIn(_ user: AccountUser) -> some View { + Section { + if let name = [user.firstName, user.lastName].compactMap(\.self).joined(separator: " ") + .nonEmpty + { + Text(name) + } + Text(user.email).foregroundStyle(.secondary) + } header: { + Text("Signed in") + } + Section { + Button("Sign out", role: .destructive) { confirmingSignOut = true } + } footer: { + Text( + "Controllers paired while signed in show only in this account. Controllers paired while signed out show in every account." + ) + } + } + + 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 + } + } + } catch { + if error != .cancelled { failure = error } + } + } + } + + private func signOut() { + failure = nil + do { try account.signOut() } catch { failure = error } + } +} + +extension String { + fileprivate var nonEmpty: String? { isEmpty ? nil : self } +} diff --git a/apps/ios/Origin89/Info.plist b/apps/ios/Origin89/Info.plist index d8168ea..f56f541 100644 --- a/apps/ios/Origin89/Info.plist +++ b/apps/ios/Origin89/Info.plist @@ -2,6 +2,8 @@ + WorkOSClientID + $(WORKOS_CLIENT_ID) NSBonjourServices _km43._tcp diff --git a/apps/ios/Origin89/Origin89App.swift b/apps/ios/Origin89/Origin89App.swift index 47c4957..c0d51ab 100644 --- a/apps/ios/Origin89/Origin89App.swift +++ b/apps/ios/Origin89/Origin89App.swift @@ -5,32 +5,58 @@ import os @main struct Origin89App: App { - @State private var flow = Self.makeFlow() + @State private var account: Account + @State private var flow: SetupFlow - @MainActor private static func makeFlow() -> SetupFlow { - let store = KeychainEnrolmentStore() - // Keychain items outlive an app delete; user defaults do not. A launch - // with no label suffix yet is a new install: drop what an old one kept. - // A failed removal is tried again on each launch until it succeeds, which - // then also drops any enrolment made in between. + @MainActor init() { + Self.clearPreviousInstall() + let account = Account( + client: Self.authKit.map { AuthKitClient(configuration: $0) }, + store: KeychainAccountSessionStore()) + _account = State(initialValue: account) + _flow = State(initialValue: Self.makeFlow(owner: account.owner)) + } + + /// Keychain items outlive an app delete; user defaults do not. A launch + /// with no label suffix yet is a new install: drop the enrolments and the + /// session an old one kept. A failed removal is tried again on each launch + /// until it succeeds, which then also drops any enrolment made in between. + @MainActor private static func clearPreviousInstall() { let defaults = UserDefaults.standard let pendingKey = "setup.clearPreviousInstall" if DeviceLabel.isNewInstall { defaults.set(true, forKey: pendingKey) } - if defaults.bool(forKey: pendingKey) { - do { - try store.removeAll() - defaults.removeObject(forKey: pendingKey) - } catch { - Logger(subsystem: SetupLog.subsystem, category: "flow").error( - "could not remove enrolments left from a previous install: \(String(describing: error), privacy: .public)" - ) - } + guard defaults.bool(forKey: pendingKey) else { return } + do { + try KeychainEnrolmentStore.removeEveryAccount() + try KeychainAccountSessionStore().remove() + defaults.removeObject(forKey: pendingKey) + } catch { + Logger(subsystem: SetupLog.subsystem, category: "flow").error( + "could not remove what a previous install kept: \(String(describing: error), privacy: .public)" + ) } + } + + private static var authKit: AuthKitConfiguration? { + let clientID = Bundle.main.object(forInfoDictionaryKey: "WorkOSClientID") as? String + return clientID.flatMap { AuthKitConfiguration(clientID: $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 { + let signedOut = KeychainEnrolmentStore() + let store: any EnrolmentStore = + owner.map { + AccountEnrolmentStore(own: KeychainEnrolmentStore(owner: $0), signedOut: signedOut) + } ?? signedOut + let lastController = DefaultsLastController( + key: owner.map { "setup.lastController.\($0.rawValue)" } ?? "setup.lastController") return SetupFlow( factory: RustControllerClientFactory(label: DeviceLabel.current), store: store, transportFactory: { BluetoothTransport(identifiers: .km43, codec: RustFragmentCodec()) }, - lastController: DefaultsLastController(), + lastController: lastController, webSocketFactory: { WebSocketTransport.km43(address: $0) }, addresses: DefaultsControllerAddresses(), browser: NetworkControllerBrowser.km43()) @@ -38,7 +64,18 @@ struct Origin89App: App { var body: some Scene { WindowGroup { - SetupView(flow: flow) + SetupView(flow: flow, account: account) + .id(ObjectIdentifier(flow)) + .task { await account.refreshIfExpired() } + // Another account sees other enrolments: close this flow's connection + // first, then start the account's own flow. + .onChange(of: account.owner) { _, owner in + let previous = flow + Task { + await previous.suspend() + flow = Self.makeFlow(owner: owner) + } + } } } } diff --git a/apps/ios/Origin89/SetupView.swift b/apps/ios/Origin89/SetupView.swift index afa59a1..154b09d 100644 --- a/apps/ios/Origin89/SetupView.swift +++ b/apps/ios/Origin89/SetupView.swift @@ -7,6 +7,7 @@ import SwiftUI /// joins the network the session moves to Wi-Fi when this phone can reach it. struct SetupView: View { let flow: SetupFlow + let account: Account @State private var windowOpenedAt: Date? @State private var failedDuring: SetupFlow.State? @@ -14,6 +15,7 @@ struct SetupView: View { @State private var networkStep: NetworkStep? @State private var confirming: Forget? @State private var forgetFailed = false + @State private var accountShown = false @Environment(\.scenePhase) private var scenePhase private enum Forget: Identifiable { @@ -31,6 +33,15 @@ struct SetupView: View { Button("Start over") { Task { await startOver() } } } } + ToolbarItem(placement: .topBarTrailing) { + Button { + accountShown = true + } label: { + Image( + systemName: account.owner == nil ? "person.crop.circle" : "person.crop.circle.fill") + } + .accessibilityLabel("Account") + } ToolbarItem(placement: .topBarLeading) { Menu { if flow.knownController != nil { @@ -45,6 +56,7 @@ struct SetupView: View { } } .tint(Color.origin89.action) + .sheet(isPresented: $accountShown) { AccountView(account: account) } .confirmationDialog( confirming == .all ? "Forget all controllers?" : "Forget this controller?", isPresented: Binding(get: { confirming != nil }, set: { if !$0 { confirming = nil } }), @@ -56,7 +68,7 @@ struct SetupView: View { } message: { forget in Text( forget == .all - ? "This phone removes every pairing it keeps. Each controller needs its setup code and pairing window again." + ? "This phone removes every pairing it shows here. Each controller needs its setup code and pairing window again. Pairings made under another account stay." : "This phone removes its pairing with this controller. Its setup code and pairing window are needed again." ) } diff --git a/apps/ios/SetupKit/Sources/SetupKit/Account.swift b/apps/ios/SetupKit/Sources/SetupKit/Account.swift new file mode 100644 index 0000000..01b6cc6 --- /dev/null +++ b/apps/ios/SetupKit/Sources/SetupKit/Account.swift @@ -0,0 +1,247 @@ +import Foundation +import Observation + +/// A WorkOS user ID. It scopes the enrolments made while that user is signed in. +public struct AccountID: Hashable, Sendable, Codable { + public let rawValue: String + public init(_ rawValue: String) { self.rawValue = rawValue } + public init(from decoder: any Decoder) throws { + rawValue = try decoder.singleValueContainer().decode(String.self) + } + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } +} + +public struct AccountUser: Sendable, Equatable, Codable { + public let id: AccountID + public let email: String + public let firstName: String? + public let lastName: String? + public init(id: AccountID, email: String, firstName: String? = nil, lastName: String? = nil) { + self.id = id + self.email = email + self.firstName = firstName + self.lastName = lastName + } +} + +/// What WorkOS returns on sign-in and refresh, and what the Keychain keeps. +public struct AccountSession: Sendable, Equatable, Codable { + public let user: AccountUser + public let accessToken: String + public let refreshToken: String + public init(user: AccountUser, accessToken: String, refreshToken: String) { + self.user = user + self.accessToken = accessToken + self.refreshToken = refreshToken + } + + /// When the access token expires, from its `exp` claim. Nil when the token + /// cannot be read, which the account treats as expired. The signature is + /// the cloud's to check; the app only decides when to refresh. + var accessTokenExpiry: Date? { + let parts = accessToken.split(separator: ".") + guard parts.count == 3, let payload = Base64URL.decode(parts[1]), + let claims = try? JSONDecoder().decode(Claims.self, from: payload) + else { return nil } + return Date(timeIntervalSince1970: claims.exp) + } + + private struct Claims: Decodable { let exp: TimeInterval } +} + +public enum AccountError: Error, Sendable, Equatable { + /// This build has no WorkOS client ID. + case notConfigured + /// The person closed the sign-in sheet. + case cancelled + /// No session is signed in. + case signedOut + /// WorkOS could not be reached, or answered with a server error. + case unavailable + /// WorkOS refused the sign-in or the refresh token. + case refused + /// The redirect or WorkOS's answer was not what AuthKit sends. + case invalidResponse + case keychain(OSStatus) + + public var message: String { + switch self { + case .notConfigured: "This build of the app has no sign-in configured." + case .cancelled: "Sign-in was cancelled." + case .signedOut: "Sign in to continue." + case .unavailable: + "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 .keychain: "This phone could not store or remove the sign-in in its Keychain." + } + } +} + +/// Where the session is kept between launches. +public protocol AccountSessionStore: Sendable { + func load() -> AccountSession? + func save(_ session: AccountSession) throws(AccountError) + func remove() throws(AccountError) +} + +/// Optional sign-in. Nothing in setup waits for it: enrolments, pairing and +/// the controller's network work the same signed in or out. Signing in or out +/// changes which account's enrolments the app shows, never the enrolments. +@Observable @MainActor public final class Account { + public enum Status: Sendable, Equatable { + case signedOut, signingIn + case signedIn(AccountUser) + } + + public private(set) var status: Status + /// WorkOS refused the refresh token, so the session ended here. Cleared by + /// the next sign-in. + public private(set) var sessionEnded = false + /// The signed-in user, whose enrolments the app shows. + public var owner: AccountID? { + if case .signedIn(let user) = status { user.id } else { nil } + } + public var isAvailable: Bool { client != nil } + + private let client: AuthKitClient? + private let store: any AccountSessionStore + private let now: @Sendable () -> Date + private var session: AccountSession? + private var refreshing: Task? + /// Bumped by sign-out, so a refresh in flight cannot bring the session back. + private var generation = 0 + + /// A session is refreshed this long before its access token expires. + private static let refreshMargin: TimeInterval = 60 + + public init( + client: AuthKitClient?, store: any AccountSessionStore, + now: @escaping @Sendable () -> Date = Date.init + ) { + self.client = client + self.store = store + self.now = now + let session = store.load() + self.session = session + status = session.map { .signedIn($0.user) } ?? .signedOut + } + + /// Sign in through the AuthKit page. `authenticate` presents it and returns + /// the redirect to `AuthKitConfiguration.callbackScheme`. + public func signIn( + using authenticate: (URL, String) async throws(AccountError) -> URL + ) async throws(AccountError) { + guard let client else { throw .notConfigured } + 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) + try store.save(session) + self.session = session + sessionEnded = false + status = .signedIn(session.user) + SetupLog.account.info("signed in") + } + + /// 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. + public func signOut() throws(AccountError) { + guard session != nil else { return } + try store.remove() + generation += 1 + refreshing?.cancel() + refreshing = nil + session = nil + status = .signedOut + SetupLog.account.info("signed out") + } + + /// An access token valid for at least a minute, refreshed when needed. + /// A refused refresh ends the session; an unreachable WorkOS keeps it. + public func accessToken() async throws(AccountError) -> String { + guard let session else { throw .signedOut } + if let expiry = session.accessTokenExpiry, + expiry.timeIntervalSince(now()) > Self.refreshMargin + { + return session.accessToken + } + return try await refresh().accessToken + } + + /// At launch: refresh an expired session, ending it if WorkOS refuses. + /// Offline, the session stays until a refresh can reach WorkOS. + public func refreshIfExpired() async { + _ = try? await accessToken() + } + + /// One refresh at a time: WorkOS rotates the refresh token, so a second + /// concurrent refresh with the old token would be refused. + private func refresh() async throws(AccountError) -> AccountSession { + guard let client, let session else { throw .signedOut } + let operation = generation + let task: Task + if let refreshing { + task = refreshing + } else { + let refreshToken = session.refreshToken + task = Task { try await client.refresh(refreshToken) } + refreshing = task + } + let result = await task.result + if refreshing == task { refreshing = nil } + guard generation == operation else { throw .signedOut } + switch result { + case .success(let refreshed): + guard self.session != refreshed else { return refreshed } + self.session = refreshed + status = .signedIn(refreshed.user) + do { + try store.save(refreshed) + } catch { + // The kept refresh token is spent: the next launch signs in again. + SetupLog.account.error("the refreshed session could not be kept") + } + return refreshed + case .failure(let error): + let error = error as? AccountError ?? .unavailable + if error == .refused { endSession() } + throw error + } + } + + private func endSession() { + SetupLog.account.notice("WorkOS refused the refresh token: the session ended") + do { + try store.remove() + } catch { + SetupLog.account.error("the ended session could not be removed from the Keychain") + } + generation += 1 + session = nil + sessionEnded = true + status = .signedOut + } + + /// The code from AuthKit's redirect, once its `state` matches. + static func code(from callback: URL, state: String) throws(AccountError) -> String { + guard let items = URLComponents(url: callback, resolvingAgainstBaseURL: false)?.queryItems + else { throw .invalidResponse } + let value = { (name: String) in items.first { $0.name == name }?.value } + guard value("state") == state else { throw .invalidResponse } + if value("error") != nil { throw .refused } + guard let code = value("code"), !code.isEmpty else { throw .invalidResponse } + return code + } +} diff --git a/apps/ios/SetupKit/Sources/SetupKit/AccountEnrolmentStore.swift b/apps/ios/SetupKit/Sources/SetupKit/AccountEnrolmentStore.swift new file mode 100644 index 0000000..ccabf64 --- /dev/null +++ b/apps/ios/SetupKit/Sources/SetupKit/AccountEnrolmentStore.swift @@ -0,0 +1,42 @@ +import Foundation + +/// The enrolments one signed-in account uses: its own, then those made while +/// signed out. Another account's enrolments are neither read nor changed, so +/// signing out or switching accounts leaves every enrolment in place. +/// +/// New enrolments are kept for the account. Forgetting removes what the +/// account can see: its own and the signed-out one for the same controller. +public struct AccountEnrolmentStore: EnrolmentStore { + private let own: any EnrolmentStore + private let signedOut: any EnrolmentStore + + public init(own: any EnrolmentStore, signedOut: any EnrolmentStore) { + self.own = own + self.signedOut = signedOut + } + + public func load(deviceID: String) -> Data? { + own.load(deviceID: deviceID) ?? signedOut.load(deviceID: deviceID) + } + + public func save(_ enrolment: Data, deviceID: String) throws { + try own.save(enrolment, deviceID: deviceID) + } + + /// Both removals are tried; the first error is thrown. + public func remove(deviceID: String) throws { + try Self.both( + { try own.remove(deviceID: deviceID) }, { try signedOut.remove(deviceID: deviceID) }) + } + + public func removeAll() throws { + try Self.both({ try own.removeAll() }, { try signedOut.removeAll() }) + } + + private static func both(_ first: () throws -> Void, _ second: () throws -> Void) throws { + var failure: (any Error)? + do { try first() } catch { failure = error } + do { try second() } catch { failure = failure ?? error } + if let failure { throw failure } + } +} diff --git a/apps/ios/SetupKit/Sources/SetupKit/AuthKitClient.swift b/apps/ios/SetupKit/Sources/SetupKit/AuthKitClient.swift new file mode 100644 index 0000000..21e06eb --- /dev/null +++ b/apps/ios/SetupKit/Sources/SetupKit/AuthKitClient.swift @@ -0,0 +1,168 @@ +import CryptoKit +import Foundation + +/// Where the app signs in: a WorkOS AuthKit application used as a public +/// client with PKCE. No secret ships in the app; the client ID is public. +public struct AuthKitConfiguration: Sendable { + /// The scheme of the redirect registered in WorkOS. It is fixed here, not + /// derived from the bundle identifier, which a local signing setup changes. + public static let callbackScheme = "com.origin89.apps.ios" + + public let clientID: String + public let redirectURI: URL + public let apiBase: URL + + /// Nil for an empty client ID, as a build without one configured has. + public init?(clientID: String, apiBase: URL? = nil) { + var redirect = URLComponents() + redirect.scheme = Self.callbackScheme + redirect.host = "auth" + redirect.path = "/callback" + guard !clientID.isEmpty, let redirectURI = redirect.url, + let apiBase = apiBase ?? URL(string: "https://api.workos.com") + else { return nil } + self.clientID = clientID + self.redirectURI = redirectURI + self.apiBase = apiBase + } +} + +/// Sends one HTTP request. Tests replace URLSession with canned answers. +public protocol HTTPSender: Sendable { + func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) +} + +public struct URLSessionSender: HTTPSender { + private let session: URLSession + public init(session: URLSession = .shared) { self.session = session } + public func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) { + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { throw URLError(.badServerResponse) } + return (data, http) + } +} + +/// A PKCE verifier and its S256 challenge (RFC 7636). +struct PKCE: Sendable, Equatable { + let verifier: String + let challenge: String + + init(verifier: String) { + self.verifier = verifier + challenge = Base64URL.encode(Data(SHA256.hash(data: Data(verifier.utf8)))) + } + + /// A verifier from 32 random bytes, 43 characters once encoded. + static func random() -> PKCE { PKCE(verifier: Base64URL.random(bytes: 32)) } +} + +enum Base64URL { + static func encode(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + + static func decode(_ text: some StringProtocol) -> Data? { + var base64 = text.replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + base64 += String(repeating: "=", count: (4 - base64.count % 4) % 4) + return Data(base64Encoded: base64) + } + + static func random(bytes count: Int) -> String { + var generator = SystemRandomNumberGenerator() + return encode(Data((0.. URL? { + var components = URLComponents( + url: configuration.apiBase.appending(path: "user_management/authorize"), + resolvingAgainstBaseURL: false) + components?.queryItems = [ + URLQueryItem(name: "response_type", value: "code"), + URLQueryItem(name: "client_id", value: configuration.clientID), + URLQueryItem(name: "redirect_uri", value: configuration.redirectURI.absoluteString), + URLQueryItem(name: "provider", value: "authkit"), + URLQueryItem(name: "code_challenge", value: challenge), + URLQueryItem(name: "code_challenge_method", value: "S256"), + URLQueryItem(name: "state", value: state), + ] + return components?.url + } + + /// Exchange the redirect's code with the verifier that produced its challenge. + func authenticate(code: String, verifier: String) async throws(AccountError) -> AccountSession { + try await authenticate([ + "grant_type": "authorization_code", "code": code, "code_verifier": verifier, + ]) + } + + /// A new access token for `refreshToken`. WorkOS rotates the refresh token: + /// only the returned one works afterwards. + func refresh(_ refreshToken: String) async throws(AccountError) -> AccountSession { + try await authenticate(["grant_type": "refresh_token", "refresh_token": refreshToken]) + } + + private func authenticate(_ grant: [String: String]) async throws(AccountError) -> AccountSession + { + var request = URLRequest( + url: configuration.apiBase.appending(path: "user_management/authenticate")) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + var body = grant + body["client_id"] = configuration.clientID + 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( + "WorkOS could not be reached: \(String(describing: error), privacy: .public)") + throw .unavailable + } + switch response.statusCode { + case 200..<300: + do { + return try Self.decoder.decode(AccountSession.self, from: data) + } catch { + SetupLog.account.error("WorkOS answered with an unreadable session") + throw .invalidResponse + } + case 400..<429, 430..<500: + SetupLog.account.notice( + "WorkOS refused the grant: status \(response.statusCode, privacy: .public)") + throw .refused + default: + SetupLog.account.error( + "WorkOS is unavailable: status \(response.statusCode, privacy: .public)") + throw .unavailable + } + } + + private static let decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + return decoder + }() +} diff --git a/apps/ios/SetupKit/Sources/SetupKit/KeychainAccountSessionStore.swift b/apps/ios/SetupKit/Sources/SetupKit/KeychainAccountSessionStore.swift new file mode 100644 index 0000000..21d8396 --- /dev/null +++ b/apps/ios/SetupKit/Sources/SetupKit/KeychainAccountSessionStore.swift @@ -0,0 +1,53 @@ +import Foundation +import Security + +/// The WorkOS session in the Keychain: this device only, readable only while +/// it is unlocked, never synced or restored to another phone. +public struct KeychainAccountSessionStore: AccountSessionStore { + private let service: String + + public init(service: String = "com.origin89.account.session") { self.service = service } + + public func load() -> AccountSession? { + var query = item() + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var result: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, + let data = result as? Data + else { return nil } + return try? JSONDecoder().decode(AccountSession.self, from: data) + } + + public func save(_ session: AccountSession) throws(AccountError) { + let data: Data + do { + data = try JSONEncoder().encode(session) + } catch { + throw .invalidResponse + } + try remove() + var attributes = item() + attributes[kSecValueData as String] = data + attributes[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly + let added = SecItemAdd(attributes as CFDictionary, nil) + guard added == errSecSuccess else { throw .keychain(added) } + } + + public func remove() throws(AccountError) { + let status = SecItemDelete(item() as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw .keychain(status) + } + } + + private func item() -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: "workos", + kSecAttrSynchronizable as String: false, + kSecUseDataProtectionKeychain as String: true, + ] + } +} diff --git a/apps/ios/SetupKit/Sources/SetupKit/KeychainEnrolmentStore.swift b/apps/ios/SetupKit/Sources/SetupKit/KeychainEnrolmentStore.swift index c3dcf48..6fbe789 100644 --- a/apps/ios/SetupKit/Sources/SetupKit/KeychainEnrolmentStore.swift +++ b/apps/ios/SetupKit/Sources/SetupKit/KeychainEnrolmentStore.swift @@ -3,15 +3,21 @@ import Security /// Enrolments in the Keychain: this device only, readable only while it is /// unlocked, never synced or restored to another phone. One generic-password -/// item per controller, its account the `device_id`. +/// item per controller, its account the `device_id`. Enrolments made while +/// signed out share one service; each WorkOS account has its own below it. public struct KeychainEnrolmentStore: EnrolmentStore { public struct Failure: Error, Sendable, Equatable { public let status: OSStatus } + public static let signedOutService = "com.origin89.setup.enrolment" + private let service: String - public init(service: String = "com.origin89.setup.enrolment") { self.service = service } + public init(service: String = Self.signedOutService) { self.service = service } + + /// The enrolments made while `owner` was signed in. + public init(owner: AccountID) { self.init(service: Self.signedOutService + "/" + owner.rawValue) } public func load(deviceID: String) -> Data? { var query = item(deviceID) @@ -37,6 +43,29 @@ public struct KeychainEnrolmentStore: EnrolmentStore { /// the app left: Keychain items outlive an app delete. public func removeAll() throws { try delete(items()) } + /// Every enrolment this app kept, signed out and under every account, + /// including ones a previous install left. + public static func removeEveryAccount() throws { + try KeychainEnrolmentStore().removeAll() + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecUseDataProtectionKeychain as String: true, + kSecMatchLimit as String: kSecMatchLimitAll, + kSecReturnAttributes as String: true, + ] + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw Failure(status: status) + } + let services = Set( + (result as? [[String: Any]] ?? []).compactMap { $0[kSecAttrService as String] as? String } + ) + for service in services where service.hasPrefix(signedOutService + "/") { + try KeychainEnrolmentStore(service: service).removeAll() + } + } + private func delete(_ query: [String: Any]) throws { let status = SecItemDelete(query as CFDictionary) guard status == errSecSuccess || status == errSecItemNotFound else { diff --git a/apps/ios/SetupKit/Sources/SetupKit/SetupLog.swift b/apps/ios/SetupKit/Sources/SetupKit/SetupLog.swift index 52baee4..7b54dc1 100644 --- a/apps/ios/SetupKit/Sources/SetupKit/SetupLog.swift +++ b/apps/ios/SetupKit/Sources/SetupKit/SetupLog.swift @@ -2,10 +2,11 @@ import os /// Setup logs, readable from Console or Xcode on a bench session. They name /// steps, message types, outcomes and error codes; never a payload, a key, -/// the passphrase, an SSID or the setup code. +/// the passphrase, an SSID, the setup code, a token or an email address. public enum SetupLog { public static let subsystem = "com.origin89.apps" static let bluetooth = Logger(subsystem: subsystem, category: "bluetooth") static let webSocket = Logger(subsystem: subsystem, category: "websocket") static let flow = Logger(subsystem: subsystem, category: "flow") + static let account = Logger(subsystem: subsystem, category: "account") } diff --git a/apps/ios/SetupKit/Tests/SetupKitTests/AccountEnrolmentTests.swift b/apps/ios/SetupKit/Tests/SetupKitTests/AccountEnrolmentTests.swift new file mode 100644 index 0000000..27f77de --- /dev/null +++ b/apps/ios/SetupKit/Tests/SetupKitTests/AccountEnrolmentTests.swift @@ -0,0 +1,118 @@ +import Foundation +import Testing + +@testable import SetupKit + +private let deviceID = KeptClient.deviceID +private let signedOutPairing = Data([0x01, 0x10]) +private let accountPairing = Data([0x01, 0x20]) +private let otherPairing = Data([0x01, 0x30]) + +@Test func anAccountSeesItsOwnPairingBeforeTheSignedOutOne() throws { + let own = MemoryEnrolmentStore([deviceID: accountPairing]) + let signedOut = MemoryEnrolmentStore([deviceID: signedOutPairing, "beef": signedOutPairing]) + let store = AccountEnrolmentStore(own: own, signedOut: signedOut) + #expect(store.load(deviceID: deviceID) == accountPairing) + #expect(store.load(deviceID: "beef") == signedOutPairing) + #expect(store.load(deviceID: "none") == nil) +} + +@Test func aPairingWhileSignedInIsKeptForTheAccountOnly() throws { + let own = MemoryEnrolmentStore() + let signedOut = MemoryEnrolmentStore([deviceID: signedOutPairing]) + try AccountEnrolmentStore(own: own, signedOut: signedOut).save(accountPairing, deviceID: deviceID) + #expect(own[deviceID] == accountPairing) + #expect(signedOut[deviceID] == signedOutPairing) +} + +/// Forgetting removes what the account sees; another account's pairing stays. +@Test func forgettingLeavesOtherAccountsAlone() throws { + let own = MemoryEnrolmentStore([deviceID: accountPairing, "beef": accountPairing]) + let signedOut = MemoryEnrolmentStore([deviceID: signedOutPairing]) + let other = MemoryEnrolmentStore([deviceID: otherPairing]) + let store = AccountEnrolmentStore(own: own, signedOut: signedOut) + try store.remove(deviceID: deviceID) + #expect(own[deviceID] == nil) + #expect(own["beef"] == accountPairing) + #expect(signedOut.isEmpty) + try store.removeAll() + #expect(own.isEmpty) + #expect(other[deviceID] == otherPairing) +} + +/// A removal that fails in one store still runs in the other, then throws. +@Test func aFailedRemovalStillRemovesTheRest() { + let own = MemoryEnrolmentStore([deviceID: accountPairing], refusesRemovals: true) + let signedOut = MemoryEnrolmentStore([deviceID: signedOutPairing]) + let store = AccountEnrolmentStore(own: own, signedOut: signedOut) + #expect(throws: MemoryEnrolmentStore.Refused.self) { try store.remove(deviceID: deviceID) } + #expect(signedOut.isEmpty) + #expect(own[deviceID] == accountPairing) +} + +private actor IdleTransport: FrameTransport { + func open() async throws(TransportError) {} + func send(_ frame: Data) async throws(TransportError) {} + func receive() async throws(TransportError) -> Data { Data() } + func close() async {} +} + +private struct ResumingFactory: ControllerClientFactory { + func client(setupCode: String, transport: any FrameTransport) throws(SetupCodeError) + -> any ControllerClient + { KeptClient(.accepts) } + func client( + resuming deviceID: String, from store: any EnrolmentStore, transport: any FrameTransport + ) -> (any ControllerClient)? { + store.load(deviceID: deviceID).map { KeptClient(.accepts, resuming: $0) } + } +} + +@MainActor private func launch(_ store: any EnrolmentStore, _ last: MemoryLastController) + -> SetupFlow +{ + let transport = IdleTransport() + return SetupFlow( + factory: ResumingFactory(), store: store, transportFactory: { transport }, + lastController: last) +} + +/// The acceptance's sign-out case: a pairing made under an account is hidden +/// once it signs out and back when it signs in; nothing is removed or changed. +@Test @MainActor func signingOutAndBackKeepsTheAccountsPairing() async throws { + let own = MemoryEnrolmentStore() + let signedOut = MemoryEnrolmentStore() + let accountLast = MemoryLastController() + let signedOutLast = MemoryLastController() + let accountStore = AccountEnrolmentStore(own: own, signedOut: signedOut) + + let paired = launch(accountStore, accountLast) + try paired.submitCode("valid") + await paired.connect() + await paired.confirmWindowOpened() + #expect(own[deviceID] == KeptClient.paired) + #expect(accountLast.load() == deviceID) + await paired.suspend() + + // Signed out: the account's pairing is not offered. + let hidden = launch(signedOut, signedOutLast) + #expect(hidden.state == .enterCode) + #expect(signedOut.isEmpty) + #expect(own[deviceID] == KeptClient.paired) + + // Signed in again: the relaunch reconnects from the account's pairing. + let back = launch(accountStore, accountLast) + #expect(back.state == .connecting) + #expect(back.resumed) + #expect(back.knownController == deviceID) +} + +/// Pairings made while signed out are the phone's: every account uses them. +@Test @MainActor func anAccountUsesAPairingMadeSignedOut() async throws { + let signedOut = MemoryEnrolmentStore([deviceID: signedOutPairing]) + let flow = launch( + AccountEnrolmentStore(own: MemoryEnrolmentStore(), signedOut: signedOut), + MemoryLastController(deviceID)) + #expect(flow.state == .connecting) + #expect(flow.resumed) +} diff --git a/apps/ios/SetupKit/Tests/SetupKitTests/AccountTests.swift b/apps/ios/SetupKit/Tests/SetupKitTests/AccountTests.swift new file mode 100644 index 0000000..a8dc735 --- /dev/null +++ b/apps/ios/SetupKit/Tests/SetupKitTests/AccountTests.swift @@ -0,0 +1,327 @@ +import CryptoKit +import Foundation +import Testing + +@testable import SetupKit + +/// Answers WorkOS requests in order and records them. +final class StubHTTP: HTTPSender, @unchecked Sendable { + enum Answer { + case status(Int, String) + case offline + } + private let lock = NSLock() + private var answers: [Answer] + private var sent: [URLRequest] = [] + init(_ answers: [Answer]) { self.answers = answers } + var requests: [URLRequest] { lock.withLock { sent } } + /// The JSON body of each request. + var bodies: [[String: String]] { + requests.map { request in + request.httpBody.flatMap { try? JSONDecoder().decode([String: String].self, from: $0) } ?? [:] + } + } + func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) { + let answer: Answer? = lock.withLock { + sent.append(request) + return answers.isEmpty ? nil : answers.removeFirst() + } + // 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, + let response = HTTPURLResponse( + url: url, statusCode: code, httpVersion: nil, headerFields: nil) + else { throw URLError(.notConnectedToInternet) } + return (Data(body.utf8), response) + } +} + +/// Keeps the session in memory, or refuses every save or removal. +final class MemorySessionStore: AccountSessionStore, @unchecked Sendable { + private let lock = NSLock() + private var kept: AccountSession? + private let refusesSaves: Bool + private let refusesRemovals: Bool + init(_ kept: AccountSession? = nil, refusesSaves: Bool = false, refusesRemovals: Bool = false) { + self.kept = kept + self.refusesSaves = refusesSaves + self.refusesRemovals = refusesRemovals + } + var session: AccountSession? { lock.withLock { kept } } + func load() -> AccountSession? { session } + func save(_ session: AccountSession) throws(AccountError) { + if refusesSaves { throw .keychain(errSecInteractionNotAllowed) } + lock.withLock { kept = session } + } + func remove() throws(AccountError) { + if refusesRemovals { throw .keychain(errSecInteractionNotAllowed) } + lock.withLock { kept = nil } + } +} + +private let now = Date(timeIntervalSince1970: 1_800_000_000) +private let user = AccountUser(id: AccountID("user_01A"), email: "a@example.com", firstName: "Ada") + +/// An unsigned JWT with `exp` set `seconds` from `now`. +private func token(expiresIn seconds: TimeInterval, _ tag: String = "") -> String { + let payload = #"{"exp":\#(Int(now.timeIntervalSince1970 + seconds)),"t":"\#(tag)"}"# + return "e30.\(Base64URL.encode(Data(payload.utf8))).sig" +} + +private func sessionJSON(access: String, refresh: String) -> String { + #""" + {"user":{"object":"user","id":"user_01A","email":"a@example.com","first_name":"Ada","last_name":null,"email_verified":true}, + "organization_id":null,"access_token":"\#(access)","refresh_token":"\#(refresh)","authentication_method":"GoogleOAuth"} + """# +} + +@MainActor private func account( + _ http: StubHTTP, _ store: MemorySessionStore, configured: Bool = true +) -> Account { + let client = AuthKitConfiguration(clientID: configured ? "client_01TEST" : "").map { + AuthKitClient(configuration: $0, http: http) + } + return Account(client: client, store: store, now: { now }) +} + +/// Answers like AuthKit: redirects with a code and the request's `state`. +private func redirect(code: String = "code_01", state override: String? = nil, error: String? = nil) + -> (URL, String) async throws(AccountError) -> URL +{ + { url, scheme throws(AccountError) in + let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? [] + let state = override ?? items.first { $0.name == "state" }?.value ?? "" + var components = URLComponents(string: "\(scheme)://auth/callback") + components?.queryItems = + (error.map { [URLQueryItem(name: "error", value: $0)] } + ?? [URLQueryItem(name: "code", value: code)]) + [URLQueryItem(name: "state", value: state)] + guard let callback = components?.url else { throw .invalidResponse } + return callback + } +} + +private func query(_ url: URL?, _ name: String) -> String? { + url.flatMap { URLComponents(url: $0, resolvingAgainstBaseURL: false) }?.queryItems? + .first { $0.name == name }?.value +} + +// MARK: Sign-in + +@Test @MainActor func signingInExchangesTheCodeWithItsVerifier() async throws { + let http = StubHTTP([.status(200, sessionJSON(access: token(expiresIn: 300), refresh: "r1"))]) + let store = MemorySessionStore() + let account = account(http, store) + var opened: URL? + try await account.signIn { url, scheme throws(AccountError) in + opened = url + #expect(scheme == "com.origin89.apps.ios") + return try await redirect()(url, scheme) + } + #expect(account.status == .signedIn(user)) + #expect(account.owner == AccountID("user_01A")) + #expect(store.session?.refreshToken == "r1") + + #expect(opened?.host == "api.workos.com") + #expect(opened?.path == "/user_management/authorize") + #expect(query(opened, "client_id") == "client_01TEST") + #expect(query(opened, "provider") == "authkit") + #expect(query(opened, "redirect_uri") == "com.origin89.apps.ios://auth/callback") + #expect(query(opened, "code_challenge_method") == "S256") + let body = try #require(http.bodies.first) + #expect(http.requests.first?.url?.path == "/user_management/authenticate") + #expect(body["grant_type"] == "authorization_code") + #expect(body["code"] == "code_01") + #expect(body["client_id"] == "client_01TEST") + #expect(body["client_secret"] == nil) + // The verifier sent is the one whose S256 hash the sheet was given. + let verifier = try #require(body["code_verifier"]) + #expect(verifier.count == 43) + #expect(query(opened, "code_challenge") == PKCE(verifier: verifier).challenge) +} + +/// RFC 7636 appendix B. +@Test func theChallengeIsTheVerifiersS256() { + let pkce = PKCE(verifier: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk") + #expect(pkce.challenge == "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM") + #expect(PKCE.random() != PKCE.random()) +} + +@Test @MainActor func cancellingTheSheetLeavesTheAccountSignedOut() async { + let http = StubHTTP([]) + let store = MemorySessionStore() + let account = account(http, store) + await #expect(throws: AccountError.cancelled) { + try await account.signIn { _, _ throws(AccountError) in throw .cancelled } + } + #expect(account.status == .signedOut) + #expect(store.session == nil) + #expect(http.requests.isEmpty) +} + +@Test @MainActor func aRedirectWithAnotherStateIsNotExchanged() async { + let http = StubHTTP([]) + let account = account(http, MemorySessionStore()) + await #expect(throws: AccountError.invalidResponse) { + try await account.signIn(using: redirect(state: "forged")) + } + #expect(account.status == .signedOut) + #expect(http.requests.isEmpty) +} + +@Test @MainActor func aRedirectCarryingAnErrorIsRefused() async { + let http = StubHTTP([]) + let account = account(http, MemorySessionStore()) + await #expect(throws: AccountError.refused) { + try await account.signIn(using: redirect(error: "access_denied")) + } + #expect(http.requests.isEmpty) +} + +@Test @MainActor func aCodeWorkOSRefusesSignsNothingIn() async { + let http = StubHTTP([.status(400, #"{"error":"invalid_grant"}"#)]) + let store = MemorySessionStore() + let account = account(http, store) + await #expect(throws: AccountError.refused) { try await account.signIn(using: redirect()) } + #expect(account.status == .signedOut) + #expect(store.session == nil) +} + +@Test @MainActor func unreadableSessionsAndServerErrorsAreTold() async { + let garbled = account(StubHTTP([.status(200, "{}")]), MemorySessionStore()) + await #expect(throws: AccountError.invalidResponse) { + try await garbled.signIn(using: redirect()) + } + let down = account(StubHTTP([.status(503, "")]), MemorySessionStore()) + await #expect(throws: AccountError.unavailable) { try await down.signIn(using: redirect()) } + let limited = account(StubHTTP([.status(429, "")]), MemorySessionStore()) + await #expect(throws: AccountError.unavailable) { try await limited.signIn(using: redirect()) } +} + +/// A session the Keychain did not keep would vanish on the next launch. +@Test @MainActor func aSessionTheKeychainRefusesIsNotSignedIn() async { + let http = StubHTTP([.status(200, sessionJSON(access: token(expiresIn: 300), refresh: "r1"))]) + let account = account(http, MemorySessionStore(refusesSaves: true)) + await #expect(throws: AccountError.keychain(errSecInteractionNotAllowed)) { + try await account.signIn(using: redirect()) + } + #expect(account.status == .signedOut) +} + +@Test @MainActor func aBuildWithoutAClientIDCannotSignIn() async { + let account = account(StubHTTP([]), MemorySessionStore(), configured: false) + #expect(!account.isAvailable) + await #expect(throws: AccountError.notConfigured) { try await account.signIn(using: redirect()) } +} + +// MARK: Tokens and refresh + +private func signedIn(access: String, refresh: String = "r1") -> AccountSession { + AccountSession(user: user, accessToken: access, refreshToken: refresh) +} + +@Test @MainActor func aLaunchWithAKeptSessionIsSignedIn() async throws { + let access = token(expiresIn: 300) + let http = StubHTTP([]) + let account = account(http, MemorySessionStore(signedIn(access: access))) + #expect(account.status == .signedIn(user)) + #expect(try await account.accessToken() == access) + #expect(http.requests.isEmpty) +} + +@Test @MainActor func anExpiringTokenIsRefreshedAndTheRotatedTokenKept() async throws { + let fresh = token(expiresIn: 300, "fresh") + let http = StubHTTP([.status(200, sessionJSON(access: fresh, refresh: "r2"))]) + // 30 seconds left is inside the refresh margin. + let store = MemorySessionStore(signedIn(access: token(expiresIn: 30))) + let account = account(http, store) + #expect(try await account.accessToken() == fresh) + #expect( + http.bodies == [ + ["grant_type": "refresh_token", "refresh_token": "r1", "client_id": "client_01TEST"] + ]) + #expect(store.session?.refreshToken == "r2") + #expect(try await account.accessToken() == fresh) + #expect(http.requests.count == 1) +} + +@Test @MainActor func anUnreadableTokenCountsAsExpired() async throws { + let fresh = token(expiresIn: 300) + let http = StubHTTP([.status(200, sessionJSON(access: fresh, refresh: "r2"))]) + let account = account(http, MemorySessionStore(signedIn(access: "not-a-jwt"))) + #expect(try await account.accessToken() == fresh) +} + +/// The acceptance's refresh failure: WorkOS refuses the refresh token, so the +/// session ends here and the person is told. +@Test @MainActor func aRefusedRefreshEndsTheSession() async { + let http = StubHTTP([.status(400, #"{"error":"invalid_grant"}"#)]) + let store = MemorySessionStore(signedIn(access: token(expiresIn: -10))) + let account = account(http, store) + await account.refreshIfExpired() + #expect(account.status == .signedOut) + #expect(account.sessionEnded) + #expect(store.session == nil) + await #expect(throws: AccountError.signedOut) { try await account.accessToken() } +} + +/// Offline is not a sign-out: the session stays for the next attempt. +@Test @MainActor func anUnreachableRefreshKeepsTheSession() async throws { + let kept = signedIn(access: token(expiresIn: -10)) + let http = StubHTTP([.offline, .status(502, "")]) + let store = MemorySessionStore(kept) + let account = account(http, store) + await #expect(throws: AccountError.unavailable) { try await account.accessToken() } + await #expect(throws: AccountError.unavailable) { try await account.accessToken() } + #expect(account.status == .signedIn(user)) + #expect(!account.sessionEnded) + #expect(store.session == kept) +} + +/// WorkOS rotates the refresh token, so two refreshes at once would spend it +/// twice and the second would be refused. +@Test @MainActor func concurrentCallersShareOneRefresh() async throws { + let fresh = token(expiresIn: 300) + let http = StubHTTP([ + .status(200, sessionJSON(access: fresh, refresh: "r2")), + .status(400, #"{"error":"invalid_grant"}"#), + ]) + let account = account(http, MemorySessionStore(signedIn(access: token(expiresIn: -10)))) + async let first = account.accessToken() + async let second = account.accessToken() + #expect(try await [first, second] == [fresh, fresh]) + #expect(http.requests.count == 1) + #expect(account.status == .signedIn(user)) +} + +@Test @MainActor func aRefreshFinishingAfterSignOutIsDropped() async throws { + let http = StubHTTP([.status(200, sessionJSON(access: token(expiresIn: 300), refresh: "r2"))]) + let store = MemorySessionStore(signedIn(access: token(expiresIn: -10))) + let account = account(http, store) + let refreshed = Task { try await account.accessToken() } + while http.requests.isEmpty { await Task.yield() } + try account.signOut() + await #expect(throws: AccountError.signedOut) { try await refreshed.value } + #expect(account.status == .signedOut) + #expect(!account.sessionEnded) + #expect(store.session == nil) +} + +// MARK: Sign-out + +@Test @MainActor func signingOutRemovesTheSession() throws { + let store = MemorySessionStore(signedIn(access: token(expiresIn: 300))) + let account = account(StubHTTP([]), store) + try account.signOut() + #expect(account.status == .signedOut) + #expect(account.owner == nil) + #expect(store.session == nil) + // Signed out already: nothing to do. + try account.signOut() +} + +@Test @MainActor func aSessionTheKeychainKeepsStaysSignedIn() { + let store = MemorySessionStore(signedIn(access: token(expiresIn: 300)), refusesRemovals: true) + let account = account(StubHTTP([]), store) + #expect(throws: AccountError.keychain(errSecInteractionNotAllowed)) { try account.signOut() } + #expect(account.status == .signedIn(user)) +} diff --git a/apps/ios/Signing.xcconfig b/apps/ios/Signing.xcconfig index 59c10d9..a18cdc0 100644 --- a/apps/ios/Signing.xcconfig +++ b/apps/ios/Signing.xcconfig @@ -9,4 +9,5 @@ PRODUCT_BUNDLE_IDENTIFIER = com.origin89.apps.ios +#include "Account.xcconfig" #include? "Local.xcconfig" From 5c01ec4ae2fe4c8b00297e66a6109c5753084c7b Mon Sep 17 00:00:00 2001 From: David Date: Fri, 25 Sep 2026 10:35:37 -0400 Subject: [PATCH 2/3] fix: end the session only on invalid_grant --- .../SetupKit/Sources/SetupKit/Account.swift | 8 +++-- .../Sources/SetupKit/AuthKitClient.swift | 16 ++++++++-- .../Tests/SetupKitTests/AccountTests.swift | 29 +++++++++++++++++++ 3 files changed, 48 insertions(+), 5 deletions(-) diff --git a/apps/ios/SetupKit/Sources/SetupKit/Account.swift b/apps/ios/SetupKit/Sources/SetupKit/Account.swift index 01b6cc6..caa9e0e 100644 --- a/apps/ios/SetupKit/Sources/SetupKit/Account.swift +++ b/apps/ios/SetupKit/Sources/SetupKit/Account.swift @@ -216,17 +216,21 @@ public protocol AccountSessionStore: Sendable { return refreshed case .failure(let error): let error = error as? AccountError ?? .unavailable - if error == .refused { endSession() } + if error == .refused { try endSession() } throw error } } - private func endSession() { + /// WorkOS refused the refresh token. The account reports signed out only + /// once the Keychain no longer holds the session, so a relaunch cannot + /// load it as signed in. + private func endSession() throws(AccountError) { SetupLog.account.notice("WorkOS refused the refresh token: the session ended") do { try store.remove() } catch { SetupLog.account.error("the ended session could not be removed from the Keychain") + throw error } generation += 1 session = nil diff --git a/apps/ios/SetupKit/Sources/SetupKit/AuthKitClient.swift b/apps/ios/SetupKit/Sources/SetupKit/AuthKitClient.swift index 21e06eb..1562e42 100644 --- a/apps/ios/SetupKit/Sources/SetupKit/AuthKitClient.swift +++ b/apps/ios/SetupKit/Sources/SetupKit/AuthKitClient.swift @@ -149,10 +149,15 @@ public struct AuthKitClient: Sendable { SetupLog.account.error("WorkOS answered with an unreadable session") throw .invalidResponse } - case 400..<429, 430..<500: - SetupLog.account.notice( - "WorkOS refused the grant: status \(response.statusCode, privacy: .public)") + case 400..<500 where Self.oauthError(in: data) == "invalid_grant": + // The code or refresh token is spent, expired or revoked (RFC 6749 5.2). + SetupLog.account.notice("WorkOS refused the grant") throw .refused + case 400..<500 where response.statusCode != 408 && response.statusCode != 429: + // Not a verdict on the grant: the session is kept. + SetupLog.account.error( + "WorkOS rejected the request: status \(response.statusCode, privacy: .public)") + throw .invalidResponse default: SetupLog.account.error( "WorkOS is unavailable: status \(response.statusCode, privacy: .public)") @@ -160,6 +165,11 @@ public struct AuthKitClient: Sendable { } } + private static func oauthError(in data: Data) -> String? { + struct Body: Decodable { let error: String } + return try? JSONDecoder().decode(Body.self, from: data).error + } + private static let decoder: JSONDecoder = { let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase diff --git a/apps/ios/SetupKit/Tests/SetupKitTests/AccountTests.swift b/apps/ios/SetupKit/Tests/SetupKitTests/AccountTests.swift index a8dc735..10f0304 100644 --- a/apps/ios/SetupKit/Tests/SetupKitTests/AccountTests.swift +++ b/apps/ios/SetupKit/Tests/SetupKitTests/AccountTests.swift @@ -264,6 +264,35 @@ private func signedIn(access: String, refresh: String = "r1") -> AccountSession await #expect(throws: AccountError.signedOut) { try await account.accessToken() } } +/// Only `invalid_grant` says the refresh token is dead; a timeout, a rate +/// limit or another client error keeps the session. +@Test(arguments: [ + (408, ""), (429, ""), (400, #"{"error":"invalid_request"}"#), + (401, #"{"error":"invalid_client"}"#), (403, ""), +]) +@MainActor func anErrorOtherThanInvalidGrantKeepsTheSession(status: Int, body: String) async { + let kept = signedIn(access: token(expiresIn: -10)) + let store = MemorySessionStore(kept) + let account = account(StubHTTP([.status(status, body)]), store) + await #expect(throws: (any Error).self) { try await account.accessToken() } + #expect(account.status == .signedIn(user)) + #expect(!account.sessionEnded) + #expect(store.session == kept) +} + +/// A refused refresh whose session the Keychain keeps is not reported as +/// signed out: a relaunch would load it as signed in. +@Test @MainActor func anEndedSessionTheKeychainKeepsIsReported() async { + let kept = signedIn(access: token(expiresIn: -10)) + let http = StubHTTP([.status(400, #"{"error":"invalid_grant"}"#)]) + let account = account(http, MemorySessionStore(kept, refusesRemovals: true)) + await #expect(throws: AccountError.keychain(errSecInteractionNotAllowed)) { + try await account.accessToken() + } + #expect(account.status == .signedIn(user)) + #expect(!account.sessionEnded) +} + /// Offline is not a sign-out: the session stays for the next attempt. @Test @MainActor func anUnreachableRefreshKeepsTheSession() async throws { let kept = signedIn(access: token(expiresIn: -10)) From 10b00c271ae875d48e837bcbac8dc363b7da8dff Mon Sep 17 00:00:00 2001 From: David Date: Fri, 25 Sep 2026 10:46:57 -0400 Subject: [PATCH 3/3] test: assert the error for each kept-session status --- .../SetupKit/Tests/SetupKitTests/AccountTests.swift | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/ios/SetupKit/Tests/SetupKitTests/AccountTests.swift b/apps/ios/SetupKit/Tests/SetupKitTests/AccountTests.swift index 10f0304..00f8bb9 100644 --- a/apps/ios/SetupKit/Tests/SetupKitTests/AccountTests.swift +++ b/apps/ios/SetupKit/Tests/SetupKitTests/AccountTests.swift @@ -267,14 +267,17 @@ private func signedIn(access: String, refresh: String = "r1") -> AccountSession /// Only `invalid_grant` says the refresh token is dead; a timeout, a rate /// limit or another client error keeps the session. @Test(arguments: [ - (408, ""), (429, ""), (400, #"{"error":"invalid_request"}"#), - (401, #"{"error":"invalid_client"}"#), (403, ""), + (408, "", AccountError.unavailable), (429, "", .unavailable), + (400, #"{"error":"invalid_request"}"#, .invalidResponse), + (401, #"{"error":"invalid_client"}"#, .invalidResponse), (403, "", .invalidResponse), ]) -@MainActor func anErrorOtherThanInvalidGrantKeepsTheSession(status: Int, body: String) async { +@MainActor func anErrorOtherThanInvalidGrantKeepsTheSession( + status: Int, body: String, expected: AccountError +) async { let kept = signedIn(access: token(expiresIn: -10)) let store = MemorySessionStore(kept) let account = account(StubHTTP([.status(status, body)]), store) - await #expect(throws: (any Error).self) { try await account.accessToken() } + await #expect(throws: expected) { try await account.accessToken() } #expect(account.status == .signedIn(user)) #expect(!account.sessionEnded) #expect(store.session == kept)