Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/ios/Account.xcconfig
Original file line number Diff line number Diff line change
Expand Up @@ -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
84 changes: 70 additions & 14 deletions apps/ios/Origin89/AccountView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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")
Expand All @@ -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 {
Expand Down Expand Up @@ -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
}
}
}

Expand Down
2 changes: 2 additions & 0 deletions apps/ios/Origin89/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CloudBaseURL</key>
<string>$(CLOUD_BASE_URL)</string>
<key>WorkOSClientID</key>
<string>$(WORKOS_CLIENT_ID)</string>
<key>NSBonjourServices</key>
Expand Down
20 changes: 17 additions & 3 deletions apps/ios/Origin89/Origin89App.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ import os
struct Origin89App: App {
@State private var account: Account
@State private var flow: SetupFlow
private let cloud: CloudClient?

@MainActor init() {
Self.clearPreviousInstall()
let account = Account(
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))
}

Expand Down Expand Up @@ -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) }
}

nonisolated 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 {
Expand All @@ -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,
Expand All @@ -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
Expand Down
6 changes: 5 additions & 1 deletion apps/ios/Origin89/SetupView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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 } }),
Expand Down
68 changes: 60 additions & 8 deletions apps/ios/SetupKit/Sources/SetupKit/Account.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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."
}
}
Expand Down Expand Up @@ -139,21 +142,70 @@ 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
status = .signedIn(session.user)
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.
Expand Down
Loading