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:
@@ -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))
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user