replace mock passport flows with live server integration
CI / test (push) Has been cancelled

Switch the app to the real passport enrollment, dashboard, device, alert, and challenge APIs so it can pair with idp.global and act on server-backed state instead of demo data.
This commit is contained in:
2026-04-20 13:21:39 +00:00
parent 271d9657bf
commit a298b5e421
7 changed files with 1136 additions and 48 deletions
+20 -3
View File
@@ -14,6 +14,7 @@ final class AppViewModel: ObservableObject {
@Published var profile: MemberProfile?
@Published var requests: [ApprovalRequest] = []
@Published var notifications: [AppNotification] = []
@Published var devices: [PassportDeviceRecord] = []
@Published var notificationPermission: NotificationPermissionState = .unknown
@Published var selectedSection: AppSection = .inbox
@Published var isBootstrapping = false
@@ -56,7 +57,7 @@ final class AppViewModel: ObservableObject {
}
init(
service: IDPServicing = MockIDPService.shared,
service: IDPServicing = DefaultIDPService.shared,
notificationCoordinator: NotificationCoordinating = NotificationCoordinator(),
appStateStore: AppStateStoring = UserDefaultsAppStateStore(),
launchArguments: [String] = ProcessInfo.processInfo.arguments
@@ -118,6 +119,8 @@ final class AppViewModel: ObservableObject {
if let preferredLaunchSection {
selectedSection = preferredLaunchSection
}
} else if session != nil {
await refreshDashboard()
}
} catch {
if session == nil {
@@ -260,6 +263,8 @@ final class AppViewModel: ObservableObject {
apply(snapshot: snapshot)
persistCurrentState()
errorMessage = nil
} catch let error as AppError {
errorMessage = error.errorDescription
} catch {
errorMessage = "Unable to refresh the dashboard."
}
@@ -282,6 +287,8 @@ final class AppViewModel: ObservableObject {
persistCurrentState()
selectedSection = .inbox
errorMessage = nil
} catch let error as AppError {
errorMessage = error.errorDescription
} catch {
errorMessage = "Unable to create a mock identity check right now."
}
@@ -315,6 +322,8 @@ final class AppViewModel: ObservableObject {
apply(snapshot: snapshot)
persistCurrentState()
errorMessage = nil
} catch let error as AppError {
errorMessage = error.errorDescription
} catch {
errorMessage = "Unable to update the notification."
}
@@ -326,6 +335,7 @@ final class AppViewModel: ObservableObject {
profile = nil
requests = []
notifications = []
devices = []
selectedSection = .inbox
manualPairingPayload = suggestedPairingPayload
isShowingPairingSuccess = false
@@ -371,6 +381,8 @@ final class AppViewModel: ObservableObject {
apply(snapshot: snapshot)
persistCurrentState()
errorMessage = nil
} catch let error as AppError {
errorMessage = error.errorDescription
} catch {
errorMessage = "Unable to update the identity check."
}
@@ -387,7 +399,8 @@ final class AppViewModel: ObservableObject {
snapshot: DashboardSnapshot(
profile: state.profile,
requests: state.requests,
notifications: state.notifications
notifications: state.notifications,
devices: state.devices
)
)
}
@@ -403,7 +416,8 @@ final class AppViewModel: ObservableObject {
session: session,
profile: profile,
requests: requests,
notifications: notifications
notifications: notifications,
devices: devices
)
)
}
@@ -413,6 +427,9 @@ final class AppViewModel: ObservableObject {
self.profile = snapshot.profile
self.requests = snapshot.requests.sorted { $0.createdAt > $1.createdAt }
self.notifications = snapshot.notifications.sorted { $0.sentAt > $1.sentAt }
self.devices = snapshot.devices.sorted {
($0.lastSeenAt ?? .distantPast) > ($1.lastSeenAt ?? .distantPast)
}
}
let profileValue = snapshot.profile
+158
View File
@@ -181,6 +181,19 @@ struct DashboardSnapshot {
let profile: MemberProfile
let requests: [ApprovalRequest]
let notifications: [AppNotification]
let devices: [PassportDeviceRecord]
init(
profile: MemberProfile,
requests: [ApprovalRequest],
notifications: [AppNotification],
devices: [PassportDeviceRecord] = []
) {
self.profile = profile
self.requests = requests
self.notifications = notifications
self.devices = devices
}
}
struct SignInResult {
@@ -217,6 +230,8 @@ struct AuthSession: Identifiable, Hashable, Codable {
let id: UUID
let deviceName: String
let originHost: String
let serverURL: String
let passportDeviceID: String
let pairedAt: Date
let tokenPreview: String
let pairingCode: String
@@ -227,6 +242,8 @@ struct AuthSession: Identifiable, Hashable, Codable {
id: UUID = UUID(),
deviceName: String,
originHost: String,
serverURL: String = "",
passportDeviceID: String = "",
pairedAt: Date,
tokenPreview: String,
pairingCode: String,
@@ -236,12 +253,41 @@ struct AuthSession: Identifiable, Hashable, Codable {
self.id = id
self.deviceName = deviceName
self.originHost = originHost
self.serverURL = serverURL
self.passportDeviceID = passportDeviceID
self.pairedAt = pairedAt
self.tokenPreview = tokenPreview
self.pairingCode = pairingCode
self.pairingTransport = pairingTransport
self.signedGPSPosition = signedGPSPosition
}
private enum CodingKeys: String, CodingKey {
case id
case deviceName
case originHost
case serverURL
case passportDeviceID
case pairedAt
case tokenPreview
case pairingCode
case pairingTransport
case signedGPSPosition
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
deviceName = try container.decode(String.self, forKey: .deviceName)
originHost = try container.decode(String.self, forKey: .originHost)
serverURL = try container.decodeIfPresent(String.self, forKey: .serverURL) ?? "https://\(originHost)"
passportDeviceID = try container.decodeIfPresent(String.self, forKey: .passportDeviceID) ?? ""
pairedAt = try container.decode(Date.self, forKey: .pairedAt)
tokenPreview = try container.decode(String.self, forKey: .tokenPreview)
pairingCode = try container.decode(String.self, forKey: .pairingCode)
pairingTransport = try container.decodeIfPresent(PairingTransport.self, forKey: .pairingTransport) ?? .manual
signedGPSPosition = try container.decodeIfPresent(SignedGPSPosition.self, forKey: .signedGPSPosition)
}
}
enum ApprovalRequestKind: String, CaseIterable, Hashable, Codable {
@@ -320,6 +366,8 @@ enum ApprovalStatus: String, Hashable, Codable {
struct ApprovalRequest: Identifiable, Hashable, Codable {
let id: UUID
let serverID: String
let hintID: String
let title: String
let subtitle: String
let source: String
@@ -327,10 +375,20 @@ struct ApprovalRequest: Identifiable, Hashable, Codable {
let kind: ApprovalRequestKind
let risk: ApprovalRisk
let scopes: [String]
let requiresLocation: Bool
let challenge: String?
let signingPayload: String?
let deviceSummaryText: String?
let locationSummaryText: String?
let networkSummaryText: String?
let ipSummaryText: String?
let expiresAtDate: Date?
var status: ApprovalStatus
init(
id: UUID = UUID(),
serverID: String = UUID().uuidString,
hintID: String = UUID().uuidString,
title: String,
subtitle: String,
source: String,
@@ -338,9 +396,19 @@ struct ApprovalRequest: Identifiable, Hashable, Codable {
kind: ApprovalRequestKind,
risk: ApprovalRisk,
scopes: [String],
requiresLocation: Bool = false,
challenge: String? = nil,
signingPayload: String? = nil,
deviceSummaryText: String? = nil,
locationSummaryText: String? = nil,
networkSummaryText: String? = nil,
ipSummaryText: String? = nil,
expiresAtDate: Date? = nil,
status: ApprovalStatus
) {
self.id = id
self.serverID = serverID
self.hintID = hintID
self.title = title
self.subtitle = subtitle
self.source = source
@@ -348,9 +416,62 @@ struct ApprovalRequest: Identifiable, Hashable, Codable {
self.kind = kind
self.risk = risk
self.scopes = scopes
self.requiresLocation = requiresLocation
self.challenge = challenge
self.signingPayload = signingPayload
self.deviceSummaryText = deviceSummaryText
self.locationSummaryText = locationSummaryText
self.networkSummaryText = networkSummaryText
self.ipSummaryText = ipSummaryText
self.expiresAtDate = expiresAtDate
self.status = status
}
private enum CodingKeys: String, CodingKey {
case id
case serverID
case hintID
case title
case subtitle
case source
case createdAt
case kind
case risk
case scopes
case requiresLocation
case challenge
case signingPayload
case deviceSummaryText
case locationSummaryText
case networkSummaryText
case ipSummaryText
case expiresAtDate
case status
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
serverID = try container.decodeIfPresent(String.self, forKey: .serverID) ?? id.uuidString
hintID = try container.decodeIfPresent(String.self, forKey: .hintID) ?? serverID
title = try container.decode(String.self, forKey: .title)
subtitle = try container.decode(String.self, forKey: .subtitle)
source = try container.decode(String.self, forKey: .source)
createdAt = try container.decode(Date.self, forKey: .createdAt)
kind = try container.decode(ApprovalRequestKind.self, forKey: .kind)
risk = try container.decode(ApprovalRisk.self, forKey: .risk)
scopes = try container.decode([String].self, forKey: .scopes)
requiresLocation = try container.decodeIfPresent(Bool.self, forKey: .requiresLocation) ?? false
challenge = try container.decodeIfPresent(String.self, forKey: .challenge)
signingPayload = try container.decodeIfPresent(String.self, forKey: .signingPayload)
deviceSummaryText = try container.decodeIfPresent(String.self, forKey: .deviceSummaryText)
locationSummaryText = try container.decodeIfPresent(String.self, forKey: .locationSummaryText)
networkSummaryText = try container.decodeIfPresent(String.self, forKey: .networkSummaryText)
ipSummaryText = try container.decodeIfPresent(String.self, forKey: .ipSummaryText)
expiresAtDate = try container.decodeIfPresent(Date.self, forKey: .expiresAtDate)
status = try container.decode(ApprovalStatus.self, forKey: .status)
}
var scopeSummary: String {
if scopes.isEmpty {
return "No proof details listed"
@@ -420,6 +541,8 @@ enum AppNotificationKind: String, Hashable, Codable {
struct AppNotification: Identifiable, Hashable, Codable {
let id: UUID
let serverID: String
let hintID: String
let title: String
let message: String
let sentAt: Date
@@ -428,6 +551,8 @@ struct AppNotification: Identifiable, Hashable, Codable {
init(
id: UUID = UUID(),
serverID: String = UUID().uuidString,
hintID: String = UUID().uuidString,
title: String,
message: String,
sentAt: Date,
@@ -435,12 +560,45 @@ struct AppNotification: Identifiable, Hashable, Codable {
isUnread: Bool
) {
self.id = id
self.serverID = serverID
self.hintID = hintID
self.title = title
self.message = message
self.sentAt = sentAt
self.kind = kind
self.isUnread = isUnread
}
private enum CodingKeys: String, CodingKey {
case id
case serverID
case hintID
case title
case message
case sentAt
case kind
case isUnread
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
serverID = try container.decodeIfPresent(String.self, forKey: .serverID) ?? id.uuidString
hintID = try container.decodeIfPresent(String.self, forKey: .hintID) ?? serverID
title = try container.decode(String.self, forKey: .title)
message = try container.decode(String.self, forKey: .message)
sentAt = try container.decode(Date.self, forKey: .sentAt)
kind = try container.decode(AppNotificationKind.self, forKey: .kind)
isUnread = try container.decode(Bool.self, forKey: .isUnread)
}
}
struct PassportDeviceRecord: Identifiable, Hashable, Codable {
let id: String
let label: String
let platform: String
let lastSeenAt: Date?
let isCurrent: Bool
}
enum AppError: LocalizedError, Equatable {
@@ -13,6 +13,38 @@ struct PersistedAppState: Codable, Equatable {
let profile: MemberProfile
let requests: [ApprovalRequest]
let notifications: [AppNotification]
let devices: [PassportDeviceRecord]
init(
session: AuthSession,
profile: MemberProfile,
requests: [ApprovalRequest],
notifications: [AppNotification],
devices: [PassportDeviceRecord] = []
) {
self.session = session
self.profile = profile
self.requests = requests
self.notifications = notifications
self.devices = devices
}
private enum CodingKeys: String, CodingKey {
case session
case profile
case requests
case notifications
case devices
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
session = try container.decode(AuthSession.self, forKey: .session)
profile = try container.decode(MemberProfile.self, forKey: .profile)
requests = try container.decode([ApprovalRequest].self, forKey: .requests)
notifications = try container.decode([AppNotification].self, forKey: .notifications)
devices = try container.decodeIfPresent([PassportDeviceRecord].self, forKey: .devices) ?? []
}
}
protocol AppStateStoring {
@@ -1,4 +1,17 @@
import Foundation
import CryptoKit
#if canImport(UIKit)
import UIKit
#endif
#if canImport(CoreLocation)
import CoreLocation
#endif
#if canImport(Security)
import Security
#endif
protocol IDPServicing {
func bootstrap() async throws -> BootstrapContext
@@ -11,6 +24,399 @@ protocol IDPServicing {
func markNotificationRead(id: UUID) async throws -> DashboardSnapshot
}
enum DefaultIDPService {
static let shared: IDPServicing = LiveIDPService.shared
}
actor LiveIDPService: IDPServicing {
static let shared = LiveIDPService()
private let appStateStore: AppStateStoring
private let keyStore: PassportKeyStoring
init(
appStateStore: AppStateStoring = UserDefaultsAppStateStore(),
keyStore: PassportKeyStoring = DefaultPassportKeyStore()
) {
self.appStateStore = appStateStore
self.keyStore = keyStore
}
func bootstrap() async throws -> BootstrapContext {
let suggestedPayload = appStateStore.load()?.session.pairingCode ?? ""
return BootstrapContext(suggestedPairingPayload: suggestedPayload)
}
func signIn(with request: PairingAuthenticationRequest) async throws -> SignInResult {
try validateSignedGPSPosition(in: request)
let context = try PairingPayloadParser.parse(request.pairingPayload)
let baseURL = try serverURL(from: context)
let client = LiveTypedRequestClient(baseURL: baseURL)
let keyMaterial = try loadOrCreateKeyMaterial(for: context.originHost)
let signingPayload = try buildEnrollmentSigningPayload(from: context)
let signatureBase64 = try signPayload(signingPayload, with: keyMaterial.privateKeyData)
let enrollmentResponse = try await client.fire(
method: "completePassportEnrollment",
request: CompletePassportEnrollmentRequest(
pairingToken: context.pairingToken,
deviceLabel: DeviceEnvironment.currentDeviceLabel,
platform: DeviceEnvironment.currentPlatform,
publicKeyX963Base64: keyMaterial.publicKeyBase64,
signatureBase64: signatureBase64,
signatureFormat: "der",
appVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String,
capabilities: DeviceEnvironment.capabilities
),
responseType: CompletePassportEnrollmentResponse.self
)
let session = AuthSession(
deviceName: enrollmentResponse.device.data.label,
originHost: context.originHost,
serverURL: baseURL.absoluteString,
passportDeviceID: enrollmentResponse.device.id,
pairedAt: .now,
tokenPreview: context.tokenPreview,
pairingCode: request.pairingPayload,
pairingTransport: request.transport,
signedGPSPosition: request.signedGPSPosition
)
let snapshot = try await dashboardSnapshot(for: session)
return SignInResult(session: session, snapshot: snapshot)
}
func identify(with request: PairingAuthenticationRequest) async throws -> DashboardSnapshot {
guard let state = appStateStore.load() else {
throw AppError.invalidPairingPayload
}
_ = request
return try await dashboardSnapshot(for: state.session)
}
func refreshDashboard() async throws -> DashboardSnapshot {
guard let state = appStateStore.load() else {
throw AppError.invalidPairingPayload
}
return try await dashboardSnapshot(for: state.session)
}
func approveRequest(id: UUID) async throws -> DashboardSnapshot {
guard let state = appStateStore.load(),
let request = state.requests.first(where: { $0.id == id }) else {
throw AppError.requestNotFound
}
guard let signingPayload = request.signingPayload else {
throw AppError.requestNotFound
}
let client = LiveTypedRequestClient(baseURL: URL(string: state.session.serverURL)!)
let signatureBase64 = try signPayload(signingPayload, with: try privateKeyData(for: state.session))
let locationEvidence = request.requiresLocation
? try await CurrentLocationEvidenceProvider.currentLocationEvidenceIfAvailable()
: nil
_ = try await client.fire(
method: "approvePassportChallenge",
request: ApprovePassportChallengeRequest(
challengeId: request.serverID,
deviceId: state.session.passportDeviceID,
signatureBase64: signatureBase64,
signatureFormat: "der",
location: locationEvidence,
nfc: nil
),
responseType: ApprovePassportChallengeResponse.self
)
return try await dashboardSnapshot(for: state.session)
}
func rejectRequest(id: UUID) async throws -> DashboardSnapshot {
guard let state = appStateStore.load(),
let request = state.requests.first(where: { $0.id == id }) else {
throw AppError.requestNotFound
}
let client = LiveTypedRequestClient(baseURL: URL(string: state.session.serverURL)!)
let signedRequest = try signedDeviceRequest(
session: state.session,
action: "rejectPassportChallenge",
signedFields: ["challenge_id=\(request.serverID)"]
)
_ = try await client.fire(
method: "rejectPassportChallenge",
request: RejectPassportChallengeRequest(
deviceId: signedRequest.deviceId,
timestamp: signedRequest.timestamp,
nonce: signedRequest.nonce,
signatureBase64: signedRequest.signatureBase64,
signatureFormat: signedRequest.signatureFormat,
challengeId: request.serverID
),
responseType: RejectPassportChallengeResponse.self
)
return try await dashboardSnapshot(for: state.session)
}
func simulateIncomingRequest() async throws -> DashboardSnapshot {
try await refreshDashboard()
}
func markNotificationRead(id: UUID) async throws -> DashboardSnapshot {
guard let state = appStateStore.load(),
let notification = state.notifications.first(where: { $0.id == id }) else {
return try await refreshDashboard()
}
let client = LiveTypedRequestClient(baseURL: URL(string: state.session.serverURL)!)
let signedRequest = try signedDeviceRequest(
session: state.session,
action: "markPassportAlertSeen",
signedFields: ["hint_id=\(notification.hintID)"]
)
_ = try await client.fire(
method: "markPassportAlertSeen",
request: MarkPassportAlertSeenRequest(
deviceId: signedRequest.deviceId,
timestamp: signedRequest.timestamp,
nonce: signedRequest.nonce,
signatureBase64: signedRequest.signatureBase64,
signatureFormat: signedRequest.signatureFormat,
hintId: notification.hintID
),
responseType: SimpleSuccessResponse.self
)
return try await dashboardSnapshot(for: state.session)
}
private func dashboardSnapshot(for session: AuthSession) async throws -> DashboardSnapshot {
let client = LiveTypedRequestClient(baseURL: URL(string: session.serverURL)!)
let signedRequest = try signedDeviceRequest(session: session, action: "getPassportDashboard", signedFields: [])
let response = try await client.fire(
method: "getPassportDashboard",
request: signedRequest,
responseType: PassportDashboardResponse.self
)
return DashboardSnapshot(
profile: MemberProfile(
name: response.profile.name,
handle: response.profile.handle,
organization: response.profile.organizations.first?.name ?? "No organization",
deviceCount: response.profile.deviceCount,
recoverySummary: response.profile.recoverySummary
),
requests: response.challenges.map { challengeItem in
ApprovalRequest(
serverID: challengeItem.challenge.id,
hintID: challengeItem.challenge.data.notification?.hintId ?? challengeItem.challenge.id,
title: approvalTitle(for: challengeItem.challenge),
subtitle: approvalSubtitle(for: challengeItem.challenge),
source: challengeItem.challenge.data.metadata.audience ?? challengeItem.challenge.data.metadata.originHost ?? "idp.global",
createdAt: Date(millisecondsSince1970: challengeItem.challenge.data.createdAt),
kind: approvalKind(for: challengeItem.challenge),
risk: approvalRisk(for: challengeItem.challenge),
scopes: approvalScopes(for: challengeItem.challenge),
requiresLocation: challengeItem.challenge.data.metadata.requireLocation,
challenge: challengeItem.challenge.data.challenge,
signingPayload: challengeItem.signingPayload,
deviceSummaryText: challengeItem.challenge.data.metadata.deviceLabel,
locationSummaryText: locationSummary(for: challengeItem.challenge),
networkSummaryText: challengeItem.challenge.data.metadata.originHost,
ipSummaryText: nil,
expiresAtDate: Date(millisecondsSince1970: challengeItem.challenge.data.expiresAt),
status: .pending
)
},
notifications: response.alerts.map { alert in
AppNotification(
serverID: alert.id,
hintID: alert.data.notification.hintId,
title: alert.data.title,
message: alert.data.body,
sentAt: Date(millisecondsSince1970: alert.data.createdAt),
kind: notificationKind(for: alert.data.category),
isUnread: alert.data.seenAt == nil
)
},
devices: response.devices.map { device in
PassportDeviceRecord(
id: device.id,
label: device.data.label,
platform: device.data.platform,
lastSeenAt: device.data.lastSeenAt.map(Date.init(millisecondsSince1970:)),
isCurrent: device.id == session.passportDeviceID
)
}
)
}
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 serverURL(from context: PairingPayloadContext) throws -> URL {
guard let url = URL(string: "https://\(context.originHost)") else {
throw AppError.invalidPairingPayload
}
return url
}
private func buildEnrollmentSigningPayload(from context: PairingPayloadContext) throws -> String {
guard let challenge = context.challenge,
let challengeID = context.challengeID else {
throw AppError.invalidPairingPayload
}
return [
"purpose=passport-enrollment",
"origin=\(context.originHost)",
"token=\(context.pairingToken)",
"challenge=\(challenge)",
"challenge_id=\(challengeID)"
].joined(separator: "\n")
}
private func loadOrCreateKeyMaterial(for originHost: String) throws -> StoredPassportKeyMaterial {
if let existing = try keyStore.load(key: originHost) {
return existing
}
let privateKey = P256.Signing.PrivateKey()
let material = StoredPassportKeyMaterial(
privateKeyBase64: privateKey.rawRepresentation.base64EncodedString(),
publicKeyBase64: privateKey.publicKey.x963Representation.base64EncodedString()
)
try keyStore.save(material, key: originHost)
return material
}
private func privateKeyData(for session: AuthSession) throws -> Data {
guard let stored = try keyStore.load(key: session.originHost),
let data = Data(base64Encoded: stored.privateKeyBase64) else {
throw AppError.invalidPairingPayload
}
return data
}
private func signPayload(_ payload: String, with privateKeyData: Data) throws -> String {
let privateKey = try P256.Signing.PrivateKey(rawRepresentation: privateKeyData)
let signature = try privateKey.signature(for: Data(payload.utf8))
return signature.derRepresentation.base64EncodedString()
}
private func signedDeviceRequest(
session: AuthSession,
action: String,
signedFields: [String]
) throws -> SignedDeviceRequest {
let nonce = UUID().uuidString.lowercased()
let timestamp = Int(Date().timeIntervalSince1970 * 1000)
let payload = ([
"purpose=passport-device-request",
"origin=\(session.originHost)",
"action=\(action)",
"device_id=\(session.passportDeviceID)",
"timestamp=\(timestamp)",
"nonce=\(nonce)"
] + signedFields).joined(separator: "\n")
return SignedDeviceRequest(
deviceId: session.passportDeviceID,
timestamp: timestamp,
nonce: nonce,
signatureBase64: try signPayload(payload, with: try privateKeyData(for: session)),
signatureFormat: "der"
)
}
private func approvalKind(for challenge: ServerPassportChallenge) -> ApprovalRequestKind {
switch challenge.data.type {
case "authentication":
return .signIn
case "physical_access":
return .accessGrant
default:
return .elevatedAction
}
}
private func approvalRisk(for challenge: ServerPassportChallenge) -> ApprovalRisk {
if challenge.data.metadata.requireLocation ||
challenge.data.metadata.requireNfc ||
challenge.data.metadata.locationPolicy != nil ||
challenge.data.type != "authentication" {
return .elevated
}
return .routine
}
private func approvalTitle(for challenge: ServerPassportChallenge) -> String {
challenge.data.metadata.notificationTitle ?? challenge.data.type.replacingOccurrences(of: "_", with: " ").capitalized
}
private func approvalSubtitle(for challenge: ServerPassportChallenge) -> String {
if let audience = challenge.data.metadata.audience {
return "Approve this proof for \(audience)."
}
return "Approve this passport challenge on your trusted device."
}
private func approvalScopes(for challenge: ServerPassportChallenge) -> [String] {
var scopes = ["passport:device-proof"]
if challenge.data.metadata.requireLocation {
scopes.append("context:location")
}
if challenge.data.metadata.requireNfc {
scopes.append("context:nfc")
}
if let label = challenge.data.metadata.locationPolicy?.label {
scopes.append("policy:\(label)")
}
return scopes
}
private func locationSummary(for challenge: ServerPassportChallenge) -> String {
if let locationPolicy = challenge.data.metadata.locationPolicy {
return locationPolicy.label ?? "Location within required area"
}
if challenge.data.metadata.requireLocation {
return "Current location required"
}
return "Location not required"
}
private func notificationKind(for category: String) -> AppNotificationKind {
switch category {
case "security":
return .security
case "admin":
return .approval
default:
return .system
}
}
}
actor MockIDPService: IDPServicing {
static let shared = MockIDPService()
@@ -25,6 +431,7 @@ actor MockIDPService: IDPServicing {
private let appStateStore: AppStateStoring
private var requests: [ApprovalRequest] = []
private var notifications: [AppNotification] = []
private var devices: [PassportDeviceRecord] = []
init(appStateStore: AppStateStoring = UserDefaultsAppStateStore()) {
self.appStateStore = appStateStore
@@ -32,9 +439,11 @@ actor MockIDPService: IDPServicing {
if let state = appStateStore.load() {
requests = state.requests.sorted { $0.createdAt > $1.createdAt }
notifications = state.notifications.sorted { $0.sentAt > $1.sentAt }
devices = state.devices
} else {
requests = Self.seedRequests()
notifications = Self.seedNotifications()
devices = Self.seedDevices()
}
}
@@ -198,7 +607,8 @@ actor MockIDPService: IDPServicing {
DashboardSnapshot(
profile: profile,
requests: requests,
notifications: notifications
notifications: notifications,
devices: devices
)
}
@@ -220,6 +630,8 @@ actor MockIDPService: IDPServicing {
return AuthSession(
deviceName: context.deviceName,
originHost: context.originHost,
serverURL: "https://\(context.originHost)",
passportDeviceID: UUID().uuidString,
pairedAt: .now,
tokenPreview: context.tokenPreview,
pairingCode: request.pairingPayload,
@@ -260,11 +672,13 @@ actor MockIDPService: IDPServicing {
guard let state = appStateStore.load() else {
requests = Self.seedRequests()
notifications = Self.seedNotifications()
devices = Self.seedDevices()
return
}
requests = state.requests.sorted { $0.createdAt > $1.createdAt }
notifications = state.notifications.sorted { $0.sentAt > $1.sentAt }
devices = state.devices
}
private func persistSharedStateIfAvailable() {
@@ -275,7 +689,8 @@ actor MockIDPService: IDPServicing {
session: state.session,
profile: state.profile,
requests: requests,
notifications: notifications
notifications: notifications,
devices: devices
)
)
}
@@ -340,4 +755,477 @@ actor MockIDPService: IDPServicing {
)
]
}
private static func seedDevices() -> [PassportDeviceRecord] {
[
PassportDeviceRecord(id: UUID().uuidString, label: "Phil's iPhone", platform: "ios", lastSeenAt: .now, isCurrent: true),
PassportDeviceRecord(id: UUID().uuidString, label: "Phil's iPad Pro", platform: "ipados", lastSeenAt: .now.addingTimeInterval(-60 * 18), isCurrent: false),
PassportDeviceRecord(id: UUID().uuidString, label: "Berlin MacBook Pro", platform: "macos", lastSeenAt: .now.addingTimeInterval(-60 * 74), isCurrent: false)
]
}
}
private struct StoredPassportKeyMaterial: Codable, Equatable {
let privateKeyBase64: String
let publicKeyBase64: String
}
private protocol PassportKeyStoring {
func load(key: String) throws -> StoredPassportKeyMaterial?
func save(_ value: StoredPassportKeyMaterial, key: String) throws
}
private final class DefaultPassportKeyStore: PassportKeyStoring {
private let service = "global.idp.swiftapp.passport-keys"
private let fallbackDefaults = UserDefaults.standard
func load(key: String) throws -> StoredPassportKeyMaterial? {
#if canImport(Security)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecSuccess,
let data = result as? Data {
return try JSONDecoder().decode(StoredPassportKeyMaterial.self, from: data)
}
if status == errSecItemNotFound {
return nil
}
#endif
guard let data = fallbackDefaults.data(forKey: "passport-key-\(key)") else {
return nil
}
return try JSONDecoder().decode(StoredPassportKeyMaterial.self, from: data)
}
func save(_ value: StoredPassportKeyMaterial, key: String) throws {
let data = try JSONEncoder().encode(value)
#if canImport(Security)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key
]
SecItemDelete(query as CFDictionary)
let attributes: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecValueData as String: data
]
SecItemAdd(attributes as CFDictionary, nil)
#endif
fallbackDefaults.set(data, forKey: "passport-key-\(key)")
}
}
private enum DeviceEnvironment {
static var currentPlatform: String {
#if os(iOS)
return "ios"
#elseif os(watchOS)
return "watchos"
#elseif os(macOS)
return "macos"
#else
return "unknown"
#endif
}
static var currentDeviceLabel: String {
#if canImport(UIKit)
return UIDevice.current.name
#elseif os(macOS)
return Host.current().localizedName ?? "Mac Passport"
#elseif os(watchOS)
return "Apple Watch Passport"
#else
return "Swift Passport Device"
#endif
}
static var capabilities: CapabilityRequest {
CapabilityRequest(
gps: true,
nfc: {
#if os(iOS)
return true
#else
return false
#endif
}(),
push: false
)
}
}
private struct LiveTypedRequestClient {
let endpoint: URL
private let session: URLSession = .shared
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
init(baseURL: URL) {
self.endpoint = baseURL.appending(path: "typedrequest")
}
func fire<Request: Encodable, Response: Decodable>(
method: String,
request: Request,
responseType: Response.Type
) async throws -> Response {
let envelope = TypedRequestEnvelope(method: method, request: request, response: nil, correlation: TypedCorrelation(phase: "request"))
return try await send(envelope: envelope, method: method, responseType: responseType)
}
private func send<Request: Encodable, Response: Decodable>(
envelope: TypedRequestEnvelope<Request>,
method: String,
responseType: Response.Type
) async throws -> Response {
var urlRequest = URLRequest(url: endpoint)
urlRequest.httpMethod = "POST"
urlRequest.httpBody = try encoder.encode(envelope)
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
urlRequest.setValue("application/json", forHTTPHeaderField: "Accept")
let (data, response) = try await session.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse,
200 ..< 300 ~= httpResponse.statusCode else {
throw TypedRequestTransportError(message: "Typed request transport failed")
}
let typedResponse = try decoder.decode(TypedResponseEnvelope<Response>.self, from: data)
if let error = typedResponse.error {
throw TypedRequestTransportError(message: error.text)
}
if let retry = typedResponse.retry {
try await Task.sleep(for: .milliseconds(retry.waitForMs))
return try await send(envelope: envelope, method: method, responseType: responseType)
}
guard let responsePayload = typedResponse.response else {
throw TypedRequestTransportError(message: "Typed request returned no response payload")
}
return responsePayload
}
}
private struct TypedCorrelation: Codable {
let id: String
let phase: String
init(id: String = UUID().uuidString, phase: String) {
self.id = id
self.phase = phase
}
}
private struct TypedRequestEnvelope<Request: Encodable>: Encodable {
let method: String
let request: Request
let response: EmptyJSONValue?
let correlation: TypedCorrelation
}
private struct TypedResponseEnvelope<Response: Decodable>: Decodable {
let response: Response?
let error: TypedResponseErrorPayload?
let retry: TypedRetryInstruction?
}
private struct TypedResponseErrorPayload: Decodable {
let text: String
}
private struct TypedRetryInstruction: Decodable {
let waitForMs: Int
}
private struct EmptyJSONValue: Codable {}
private struct TypedRequestTransportError: LocalizedError {
let message: String
var errorDescription: String? { message }
}
private struct SignedDeviceRequest: Encodable {
let deviceId: String
let timestamp: Int
let nonce: String
let signatureBase64: String
let signatureFormat: String
}
private struct CapabilityRequest: Encodable {
let gps: Bool
let nfc: Bool
let push: Bool
}
private struct CompletePassportEnrollmentRequest: Encodable {
let pairingToken: String
let deviceLabel: String
let platform: String
let publicKeyX963Base64: String
let signatureBase64: String
let signatureFormat: String
let appVersion: String?
let capabilities: CapabilityRequest
}
private struct ApprovePassportChallengeRequest: Encodable {
let challengeId: String
let deviceId: String
let signatureBase64: String
let signatureFormat: String
let location: LocationEvidenceRequest?
let nfc: NFCEvidenceRequest?
}
private struct RejectPassportChallengeRequest: Encodable {
let deviceId: String
let timestamp: Int
let nonce: String
let signatureBase64: String
let signatureFormat: String
let challengeId: String
}
private struct MarkPassportAlertSeenRequest: Encodable {
let deviceId: String
let timestamp: Int
let nonce: String
let signatureBase64: String
let signatureFormat: String
let hintId: String
}
private struct LocationEvidenceRequest: Encodable {
let latitude: Double
let longitude: Double
let accuracyMeters: Double
let capturedAt: Int
}
private struct NFCEvidenceRequest: Encodable {
let tagId: String?
let readerId: String?
}
private struct SimpleSuccessResponse: Decodable {
let success: Bool
}
private struct CompletePassportEnrollmentResponse: Decodable {
let device: ServerPassportDevice
}
private struct ApprovePassportChallengeResponse: Decodable {
let success: Bool
}
private struct RejectPassportChallengeResponse: Decodable {
let success: Bool
}
private struct PassportDashboardResponse: Decodable {
struct Profile: Decodable {
let userId: String
let name: String
let handle: String
let organizations: [Organization]
let deviceCount: Int
let recoverySummary: String
}
struct Organization: Decodable {
let id: String
let name: String
}
let profile: Profile
let devices: [ServerPassportDevice]
let challenges: [ServerChallengeItem]
let alerts: [ServerAlert]
}
private struct ServerChallengeItem: Decodable {
let challenge: ServerPassportChallenge
let signingPayload: String
}
private struct ServerPassportDevice: Decodable {
struct DataPayload: Decodable {
let userId: String
let label: String
let platform: String
let status: String
let lastSeenAt: Int?
}
let id: String
let data: DataPayload
}
private struct ServerPassportChallenge: Decodable {
struct DataPayload: Decodable {
struct MetadataPayload: Decodable {
struct LocationPolicyPayload: Decodable {
let mode: String
let label: String?
let latitude: Double
let longitude: Double
let radiusMeters: Double
let maxAccuracyMeters: Double?
}
let originHost: String?
let audience: String?
let notificationTitle: String?
let deviceLabel: String?
let requireLocation: Bool
let requireNfc: Bool
let locationPolicy: LocationPolicyPayload?
}
struct NotificationPayload: Decodable {
let hintId: String
}
let challenge: String
let type: String
let status: String
let metadata: MetadataPayload
let notification: NotificationPayload?
let createdAt: Int
let expiresAt: Int
}
let id: String
let data: DataPayload
}
private struct ServerAlert: Decodable {
struct DataPayload: Decodable {
struct NotificationPayload: Decodable {
let hintId: String
}
let category: String
let title: String
let body: String
let createdAt: Int
let seenAt: Int?
let notification: NotificationPayload
}
let id: String
let data: DataPayload
}
#if canImport(CoreLocation)
@MainActor
private final class CurrentLocationEvidenceProvider: NSObject, @preconcurrency CLLocationManagerDelegate {
private var manager: CLLocationManager?
private var authorizationContinuation: CheckedContinuation<CLAuthorizationStatus, Never>?
private var locationContinuation: CheckedContinuation<CLLocation, Error>?
static func currentLocationEvidenceIfAvailable() async throws -> LocationEvidenceRequest {
let provider = CurrentLocationEvidenceProvider()
return try await provider.currentLocationEvidence()
}
private func currentLocationEvidence() async throws -> LocationEvidenceRequest {
let manager = CLLocationManager()
manager.delegate = self
manager.desiredAccuracy = kCLLocationAccuracyNearestTenMeters
self.manager = manager
switch manager.authorizationStatus {
case .authorizedAlways, .authorizedWhenInUse:
break
case .notDetermined:
let status = await requestAuthorization(using: manager)
guard status == .authorizedAlways || status == .authorizedWhenInUse else {
throw AppError.locationPermissionDenied
}
case .denied, .restricted:
throw AppError.locationPermissionDenied
@unknown default:
throw AppError.locationUnavailable
}
let location = try await requestLocation(using: manager)
return LocationEvidenceRequest(
latitude: location.coordinate.latitude,
longitude: location.coordinate.longitude,
accuracyMeters: location.horizontalAccuracy,
capturedAt: Int(location.timestamp.timeIntervalSince1970 * 1000)
)
}
private func requestAuthorization(using manager: CLLocationManager) async -> CLAuthorizationStatus {
manager.requestWhenInUseAuthorization()
return await withCheckedContinuation { continuation in
authorizationContinuation = continuation
}
}
private func requestLocation(using manager: CLLocationManager) async throws -> CLLocation {
try await withCheckedThrowingContinuation { continuation in
locationContinuation = continuation
manager.requestLocation()
}
}
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
guard let continuation = authorizationContinuation else { return }
let status = manager.authorizationStatus
guard status != .notDetermined else { return }
authorizationContinuation = nil
continuation.resume(returning: status)
}
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let continuation = locationContinuation,
let location = locations.last else {
return
}
authorizationContinuation = nil
locationContinuation = nil
self.manager = nil
continuation.resume(returning: location)
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
guard let continuation = locationContinuation else { return }
authorizationContinuation = nil
locationContinuation = nil
self.manager = nil
continuation.resume(throwing: AppError.locationUnavailable)
}
}
#else
private enum CurrentLocationEvidenceProvider {
static func currentLocationEvidenceIfAvailable() async throws -> LocationEvidenceRequest {
throw AppError.locationUnavailable
}
}
#endif
private extension Date {
init(millisecondsSince1970: Int) {
self = Date(timeIntervalSince1970: TimeInterval(millisecondsSince1970) / 1000)
}
}
@@ -3,6 +3,9 @@ import Foundation
struct PairingPayloadContext: Equatable {
let deviceName: String
let originHost: String
let pairingToken: String
let challenge: String?
let challengeID: String?
let tokenPreview: String
}
@@ -21,6 +24,9 @@ enum PairingPayloadParser {
return PairingPayloadContext(
deviceName: device,
originHost: origin,
pairingToken: token,
challenge: queryItems.first(where: { $0.name == "challenge" })?.value,
challengeID: queryItems.first(where: { $0.name == "challenge_id" })?.value,
tokenPreview: String(token.suffix(6))
)
}
@@ -29,6 +35,9 @@ enum PairingPayloadParser {
return PairingPayloadContext(
deviceName: "Manual Session",
originHost: "code.foss.global",
pairingToken: trimmedPayload,
challenge: nil,
challengeID: nil,
tokenPreview: String(trimmedPayload.suffix(6))
)
}
+5 -19
View File
@@ -12,33 +12,19 @@ extension ApprovalRequest {
}
var locationSummary: String {
"Berlin, DE"
locationSummaryText ?? "Location not required"
}
var deviceSummary: String {
switch kind {
case .signIn:
"Safari on Berlin iPhone"
case .accessGrant:
"Chrome on iPad Pro"
case .elevatedAction:
"Berlin MacBook Pro"
}
deviceSummaryText ?? "Trusted passport device"
}
var networkSummary: String {
switch kind {
case .signIn:
"Home Wi-Fi"
case .accessGrant:
"Shared office Wi-Fi"
case .elevatedAction:
"Ethernet"
}
networkSummaryText ?? source
}
var ipSummary: String {
risk == .elevated ? "84.187.12.44" : "84.187.12.36"
ipSummaryText ?? "n/a"
}
var trustColor: Color {
@@ -66,7 +52,7 @@ extension ApprovalRequest {
}
var expiresAt: Date {
createdAt.addingTimeInterval(risk == .elevated ? 180 : 300)
expiresAtDate ?? createdAt.addingTimeInterval(risk == .elevated ? 180 : 300)
}
}
+22 -24
View File
@@ -231,27 +231,31 @@ struct NotificationCenterView: View {
struct DevicesView: View {
@ObservedObject var model: AppViewModel
@State private var isPairingCodePresented = false
private var devices: [DevicePresentation] {
guard let session else { return [] }
let current = DevicePresentation(
name: session.deviceName,
systemImage: symbolName(for: session.deviceName),
lastSeen: .now,
isCurrent: true,
isTrusted: true
)
if !model.devices.isEmpty {
return model.devices.map { device in
DevicePresentation(
name: device.label,
systemImage: symbolName(for: device.label),
lastSeen: device.lastSeenAt ?? session.pairedAt,
isCurrent: device.isCurrent,
isTrusted: true
)
}
}
let others = [
DevicePresentation(name: "Phil's iPad Pro", systemImage: "ipad", lastSeen: .now.addingTimeInterval(-60 * 18), isCurrent: false, isTrusted: true),
DevicePresentation(name: "Berlin MacBook Pro", systemImage: "laptopcomputer", lastSeen: .now.addingTimeInterval(-60 * 74), isCurrent: false, isTrusted: true),
DevicePresentation(name: "Apple Watch", systemImage: "applewatch", lastSeen: .now.addingTimeInterval(-60 * 180), isCurrent: false, isTrusted: false)
return [
DevicePresentation(
name: session.deviceName,
systemImage: symbolName(for: session.deviceName),
lastSeen: .now,
isCurrent: true,
isTrusted: true
)
]
let count = max((model.profile?.deviceCount ?? 1) - 1, 0)
return [current] + Array(others.prefix(count))
}
private var session: AuthSession? {
@@ -274,10 +278,9 @@ struct DevicesView: View {
Section {
VStack(spacing: 12) {
Button("Pair another device") {
isPairingCodePresented = true
}
.buttonStyle(PrimaryActionStyle())
Text("Start new device pairing from your idp.global web session, then scan the fresh pairing QR in this app.")
.font(.footnote)
.foregroundStyle(.secondary)
Button("Sign out everywhere") {
model.signOut()
@@ -288,11 +291,6 @@ struct DevicesView: View {
}
}
.navigationTitle("Devices")
.sheet(isPresented: $isPairingCodePresented) {
if let session {
OneTimePasscodeSheet(session: session)
}
}
}
private func symbolName(for deviceName: String) -> String {