Some checks failed
CI / test (push) Has been cancelled
Bring the SwiftUI app in line with the Apple-native mock and keep pending approvals actionable from Live Activities and watch complications.
344 lines
12 KiB
Swift
344 lines
12 KiB
Swift
import Foundation
|
|
|
|
protocol IDPServicing {
|
|
func bootstrap() async throws -> BootstrapContext
|
|
func signIn(with request: PairingAuthenticationRequest) async throws -> SignInResult
|
|
func identify(with request: PairingAuthenticationRequest) async throws -> DashboardSnapshot
|
|
func refreshDashboard() async throws -> DashboardSnapshot
|
|
func approveRequest(id: UUID) async throws -> DashboardSnapshot
|
|
func rejectRequest(id: UUID) async throws -> DashboardSnapshot
|
|
func simulateIncomingRequest() async throws -> DashboardSnapshot
|
|
func markNotificationRead(id: UUID) async throws -> DashboardSnapshot
|
|
}
|
|
|
|
actor MockIDPService: IDPServicing {
|
|
static let shared = MockIDPService()
|
|
|
|
private let profile = MemberProfile(
|
|
name: "Phil Kunz",
|
|
handle: "phil@idp.global",
|
|
organization: "idp.global",
|
|
deviceCount: 4,
|
|
recoverySummary: "Recovery kit healthy with 2 of 3 backup paths verified."
|
|
)
|
|
|
|
private let appStateStore: AppStateStoring
|
|
private var requests: [ApprovalRequest] = []
|
|
private var notifications: [AppNotification] = []
|
|
|
|
init(appStateStore: AppStateStoring = UserDefaultsAppStateStore()) {
|
|
self.appStateStore = appStateStore
|
|
|
|
if let state = appStateStore.load() {
|
|
requests = state.requests.sorted { $0.createdAt > $1.createdAt }
|
|
notifications = state.notifications.sorted { $0.sentAt > $1.sentAt }
|
|
} else {
|
|
requests = Self.seedRequests()
|
|
notifications = Self.seedNotifications()
|
|
}
|
|
}
|
|
|
|
func bootstrap() async throws -> BootstrapContext {
|
|
restoreSharedState()
|
|
try await Task.sleep(for: .milliseconds(120))
|
|
return BootstrapContext(
|
|
suggestedPairingPayload: "idp.global://pair?token=swiftapp-demo-berlin&origin=code.foss.global&device=Safari%20on%20Berlin%20MBP"
|
|
)
|
|
}
|
|
|
|
func signIn(with request: PairingAuthenticationRequest) async throws -> SignInResult {
|
|
restoreSharedState()
|
|
try await Task.sleep(for: .milliseconds(260))
|
|
|
|
try validateSignedGPSPosition(in: request)
|
|
let session = try parseSession(from: request)
|
|
notifications.insert(
|
|
AppNotification(
|
|
title: "Passport activated",
|
|
message: pairingMessage(for: session),
|
|
sentAt: .now,
|
|
kind: .security,
|
|
isUnread: true
|
|
),
|
|
at: 0
|
|
)
|
|
|
|
persistSharedStateIfAvailable()
|
|
|
|
return SignInResult(
|
|
session: session,
|
|
snapshot: snapshot()
|
|
)
|
|
}
|
|
|
|
func identify(with request: PairingAuthenticationRequest) async throws -> DashboardSnapshot {
|
|
restoreSharedState()
|
|
try await Task.sleep(for: .milliseconds(180))
|
|
|
|
try validateSignedGPSPosition(in: request)
|
|
let context = try PairingPayloadParser.parse(request.pairingPayload)
|
|
notifications.insert(
|
|
AppNotification(
|
|
title: "Identity proof completed",
|
|
message: identificationMessage(for: context, signedGPSPosition: request.signedGPSPosition),
|
|
sentAt: .now,
|
|
kind: .security,
|
|
isUnread: true
|
|
),
|
|
at: 0
|
|
)
|
|
|
|
persistSharedStateIfAvailable()
|
|
|
|
return snapshot()
|
|
}
|
|
|
|
func refreshDashboard() async throws -> DashboardSnapshot {
|
|
restoreSharedState()
|
|
try await Task.sleep(for: .milliseconds(180))
|
|
return snapshot()
|
|
}
|
|
|
|
func approveRequest(id: UUID) async throws -> DashboardSnapshot {
|
|
restoreSharedState()
|
|
try await Task.sleep(for: .milliseconds(150))
|
|
|
|
guard let index = requests.firstIndex(where: { $0.id == id }) else {
|
|
throw AppError.requestNotFound
|
|
}
|
|
|
|
requests[index].status = .approved
|
|
notifications.insert(
|
|
AppNotification(
|
|
title: "Identity verified",
|
|
message: "\(requests[index].title) was completed for \(requests[index].source).",
|
|
sentAt: .now,
|
|
kind: .approval,
|
|
isUnread: true
|
|
),
|
|
at: 0
|
|
)
|
|
|
|
persistSharedStateIfAvailable()
|
|
|
|
return snapshot()
|
|
}
|
|
|
|
func rejectRequest(id: UUID) async throws -> DashboardSnapshot {
|
|
restoreSharedState()
|
|
try await Task.sleep(for: .milliseconds(150))
|
|
|
|
guard let index = requests.firstIndex(where: { $0.id == id }) else {
|
|
throw AppError.requestNotFound
|
|
}
|
|
|
|
requests[index].status = .rejected
|
|
notifications.insert(
|
|
AppNotification(
|
|
title: "Identity proof declined",
|
|
message: "\(requests[index].title) was declined before the session could continue.",
|
|
sentAt: .now,
|
|
kind: .security,
|
|
isUnread: true
|
|
),
|
|
at: 0
|
|
)
|
|
|
|
persistSharedStateIfAvailable()
|
|
|
|
return snapshot()
|
|
}
|
|
|
|
func simulateIncomingRequest() async throws -> DashboardSnapshot {
|
|
restoreSharedState()
|
|
try await Task.sleep(for: .milliseconds(120))
|
|
|
|
let syntheticRequest = ApprovalRequest(
|
|
title: "Prove identity for web sign-in",
|
|
subtitle: "A browser session is asking this passport to prove that it is really you.",
|
|
source: "auth.idp.global",
|
|
createdAt: .now,
|
|
kind: .signIn,
|
|
risk: .routine,
|
|
scopes: ["proof:basic", "client:web", "method:qr"],
|
|
status: .pending
|
|
)
|
|
|
|
requests.insert(syntheticRequest, at: 0)
|
|
notifications.insert(
|
|
AppNotification(
|
|
title: "Fresh identity proof request",
|
|
message: "A new relying party is waiting for your identity proof.",
|
|
sentAt: .now,
|
|
kind: .approval,
|
|
isUnread: true
|
|
),
|
|
at: 0
|
|
)
|
|
|
|
persistSharedStateIfAvailable()
|
|
|
|
return snapshot()
|
|
}
|
|
|
|
func markNotificationRead(id: UUID) async throws -> DashboardSnapshot {
|
|
restoreSharedState()
|
|
try await Task.sleep(for: .milliseconds(80))
|
|
|
|
guard let index = notifications.firstIndex(where: { $0.id == id }) else {
|
|
return snapshot()
|
|
}
|
|
|
|
notifications[index].isUnread = false
|
|
persistSharedStateIfAvailable()
|
|
return snapshot()
|
|
}
|
|
|
|
private func snapshot() -> DashboardSnapshot {
|
|
DashboardSnapshot(
|
|
profile: profile,
|
|
requests: requests,
|
|
notifications: notifications
|
|
)
|
|
}
|
|
|
|
private func validateSignedGPSPosition(in request: PairingAuthenticationRequest) throws {
|
|
if request.transport == .nfc,
|
|
request.signedGPSPosition == nil {
|
|
throw AppError.missingSignedGPSPosition
|
|
}
|
|
|
|
if let signedGPSPosition = request.signedGPSPosition,
|
|
!signedGPSPosition.verified(for: request.pairingPayload) {
|
|
throw AppError.invalidSignedGPSPosition
|
|
}
|
|
}
|
|
|
|
private func parseSession(from request: PairingAuthenticationRequest) throws -> AuthSession {
|
|
let context = try PairingPayloadParser.parse(request.pairingPayload)
|
|
|
|
return AuthSession(
|
|
deviceName: context.deviceName,
|
|
originHost: context.originHost,
|
|
pairedAt: .now,
|
|
tokenPreview: context.tokenPreview,
|
|
pairingCode: request.pairingPayload,
|
|
pairingTransport: request.transport,
|
|
signedGPSPosition: request.signedGPSPosition
|
|
)
|
|
}
|
|
|
|
private func pairingMessage(for session: AuthSession) -> String {
|
|
let transportSummary: String
|
|
switch session.pairingTransport {
|
|
case .qr:
|
|
transportSummary = "activated via QR"
|
|
case .nfc:
|
|
transportSummary = "activated via NFC with a signed GPS position"
|
|
case .manual:
|
|
transportSummary = "activated via manual payload"
|
|
case .preview:
|
|
transportSummary = "activated via preview payload"
|
|
}
|
|
|
|
if let signedGPSPosition = session.signedGPSPosition {
|
|
return "\(session.deviceName) is now acting as a passport, \(transportSummary) against \(session.originHost) from \(signedGPSPosition.coordinateSummary) \(signedGPSPosition.accuracySummary)."
|
|
}
|
|
|
|
return "\(session.deviceName) is now acting as a passport, \(transportSummary) against \(session.originHost)."
|
|
}
|
|
|
|
private func identificationMessage(for context: PairingPayloadContext, signedGPSPosition: SignedGPSPosition?) -> String {
|
|
if let signedGPSPosition {
|
|
return "A signed GPS proof was sent for \(context.deviceName) on \(context.originHost) from \(signedGPSPosition.coordinateSummary) \(signedGPSPosition.accuracySummary)."
|
|
}
|
|
|
|
return "An identity proof was completed for \(context.deviceName) on \(context.originHost)."
|
|
}
|
|
|
|
private func restoreSharedState() {
|
|
guard let state = appStateStore.load() else {
|
|
requests = Self.seedRequests()
|
|
notifications = Self.seedNotifications()
|
|
return
|
|
}
|
|
|
|
requests = state.requests.sorted { $0.createdAt > $1.createdAt }
|
|
notifications = state.notifications.sorted { $0.sentAt > $1.sentAt }
|
|
}
|
|
|
|
private func persistSharedStateIfAvailable() {
|
|
guard let state = appStateStore.load() else { return }
|
|
|
|
appStateStore.save(
|
|
PersistedAppState(
|
|
session: state.session,
|
|
profile: state.profile,
|
|
requests: requests,
|
|
notifications: notifications
|
|
)
|
|
)
|
|
}
|
|
|
|
private static func seedRequests() -> [ApprovalRequest] {
|
|
[
|
|
ApprovalRequest(
|
|
title: "Prove identity for Safari sign-in",
|
|
subtitle: "The portal wants this passport to prove that the browser session is really you.",
|
|
source: "code.foss.global",
|
|
createdAt: .now.addingTimeInterval(-60 * 12),
|
|
kind: .signIn,
|
|
risk: .routine,
|
|
scopes: ["proof:basic", "client:web", "origin:trusted"],
|
|
status: .pending
|
|
),
|
|
ApprovalRequest(
|
|
title: "Prove identity for workstation unlock",
|
|
subtitle: "Your secure workspace is asking for a stronger proof before it unlocks.",
|
|
source: "berlin-mbp.idp.global",
|
|
createdAt: .now.addingTimeInterval(-60 * 42),
|
|
kind: .elevatedAction,
|
|
risk: .elevated,
|
|
scopes: ["proof:high", "client:desktop", "presence:required"],
|
|
status: .pending
|
|
),
|
|
ApprovalRequest(
|
|
title: "Prove identity for CLI session",
|
|
subtitle: "The CLI session asked for proof earlier and was completed from this passport.",
|
|
source: "cli.idp.global",
|
|
createdAt: .now.addingTimeInterval(-60 * 180),
|
|
kind: .signIn,
|
|
risk: .routine,
|
|
scopes: ["proof:basic", "client:cli"],
|
|
status: .approved
|
|
)
|
|
]
|
|
}
|
|
|
|
private static func seedNotifications() -> [AppNotification] {
|
|
[
|
|
AppNotification(
|
|
title: "Two identity checks are waiting",
|
|
message: "One routine web proof and one stronger workstation proof are waiting for this passport.",
|
|
sentAt: .now.addingTimeInterval(-60 * 8),
|
|
kind: .approval,
|
|
isUnread: true
|
|
),
|
|
AppNotification(
|
|
title: "Recovery health check passed",
|
|
message: "Backup recovery channels were verified in the last 24 hours.",
|
|
sentAt: .now.addingTimeInterval(-60 * 95),
|
|
kind: .system,
|
|
isUnread: false
|
|
),
|
|
AppNotification(
|
|
title: "Passport quiet hours active",
|
|
message: "Routine identity checks will be delivered silently until the morning.",
|
|
sentAt: .now.addingTimeInterval(-60 * 220),
|
|
kind: .security,
|
|
isUnread: false
|
|
)
|
|
]
|
|
}
|
|
}
|