103 lines
3.0 KiB
Swift
103 lines
3.0 KiB
Swift
import Foundation
|
|
import Combine
|
|
|
|
/// Orchestrates authentication and owns the authenticated session.
|
|
///
|
|
/// **Philosophy:** Auth establishes *account* identity (who you are to the
|
|
/// app) — it never, by itself, reveals anything to other users. Social
|
|
/// identity is a separate, opt-in concern handled by `ProfileImportService`
|
|
/// at reveal time. This separation is what keeps the "anonymous until mutual
|
|
/// consent" promise intact.
|
|
@MainActor
|
|
final class AuthService: ObservableObject {
|
|
|
|
@Published private(set) var state: AuthState = .signedOut
|
|
@Published private(set) var account: Account?
|
|
|
|
private let providers: [AuthProviderID: AuthProvider]
|
|
private let apiClient: APIClient
|
|
private let keychain: KeychainStore
|
|
|
|
init(
|
|
apiClient: APIClient,
|
|
keychain: KeychainStore = KeychainStore(),
|
|
providers: [AuthProviderID: AuthProvider] = [
|
|
.apple: AppleAuthProvider(),
|
|
.google: GoogleAuthProvider()
|
|
]
|
|
) {
|
|
self.apiClient = apiClient
|
|
self.keychain = keychain
|
|
self.providers = providers
|
|
}
|
|
|
|
/// Restore a previously authenticated session from the keychain.
|
|
func restoreSession() async {
|
|
guard let session = keychain.loadSession() else { return }
|
|
state = .authenticated(session)
|
|
account = session.account
|
|
}
|
|
|
|
/// Sign in with a given provider.
|
|
func signIn(with providerID: AuthProviderID) async throws {
|
|
guard let provider = providers[providerID] else {
|
|
throw AuthError.providerUnavailable
|
|
}
|
|
|
|
state = .authenticating
|
|
do {
|
|
let result = try await provider.signIn()
|
|
|
|
// Exchange the provider ID token for a Proximity session token.
|
|
let session = try await apiClient.exchangeToken(result)
|
|
|
|
keychain.save(session)
|
|
state = .authenticated(session)
|
|
account = session.account
|
|
} catch {
|
|
state = .signedOut
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/// Sign out locally and revoke the backend session.
|
|
func signOut() async {
|
|
if case let .authenticated(session) = state {
|
|
await apiClient.revokeSession(session)
|
|
}
|
|
keychain.clearSession()
|
|
state = .signedOut
|
|
account = nil
|
|
}
|
|
}
|
|
|
|
/// The user's authenticated account (account identity only).
|
|
struct Account: Codable, Hashable {
|
|
let id: String
|
|
let providerID: AuthProviderID
|
|
let displayName: String
|
|
let email: String?
|
|
}
|
|
|
|
/// The session state machine.
|
|
enum AuthState: Equatable {
|
|
case signedOut
|
|
case authenticating
|
|
case authenticated(Session)
|
|
|
|
static func == (lhs: AuthState, rhs: AuthState) -> Bool {
|
|
switch (lhs, rhs) {
|
|
case (.signedOut, .signedOut): return true
|
|
case (.authenticating, .authenticating): return true
|
|
case (.authenticated(let a), .authenticated(let b)): return a.token == b.token
|
|
default: return false
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A verified Proximity session.
|
|
struct Session: Codable {
|
|
let token: String
|
|
let account: Account
|
|
}
|