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:
@@ -14,6 +14,7 @@ final class AppViewModel: ObservableObject {
|
|||||||
@Published var profile: MemberProfile?
|
@Published var profile: MemberProfile?
|
||||||
@Published var requests: [ApprovalRequest] = []
|
@Published var requests: [ApprovalRequest] = []
|
||||||
@Published var notifications: [AppNotification] = []
|
@Published var notifications: [AppNotification] = []
|
||||||
|
@Published var devices: [PassportDeviceRecord] = []
|
||||||
@Published var notificationPermission: NotificationPermissionState = .unknown
|
@Published var notificationPermission: NotificationPermissionState = .unknown
|
||||||
@Published var selectedSection: AppSection = .inbox
|
@Published var selectedSection: AppSection = .inbox
|
||||||
@Published var isBootstrapping = false
|
@Published var isBootstrapping = false
|
||||||
@@ -56,7 +57,7 @@ final class AppViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
init(
|
init(
|
||||||
service: IDPServicing = MockIDPService.shared,
|
service: IDPServicing = DefaultIDPService.shared,
|
||||||
notificationCoordinator: NotificationCoordinating = NotificationCoordinator(),
|
notificationCoordinator: NotificationCoordinating = NotificationCoordinator(),
|
||||||
appStateStore: AppStateStoring = UserDefaultsAppStateStore(),
|
appStateStore: AppStateStoring = UserDefaultsAppStateStore(),
|
||||||
launchArguments: [String] = ProcessInfo.processInfo.arguments
|
launchArguments: [String] = ProcessInfo.processInfo.arguments
|
||||||
@@ -118,6 +119,8 @@ final class AppViewModel: ObservableObject {
|
|||||||
if let preferredLaunchSection {
|
if let preferredLaunchSection {
|
||||||
selectedSection = preferredLaunchSection
|
selectedSection = preferredLaunchSection
|
||||||
}
|
}
|
||||||
|
} else if session != nil {
|
||||||
|
await refreshDashboard()
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if session == nil {
|
if session == nil {
|
||||||
@@ -260,6 +263,8 @@ final class AppViewModel: ObservableObject {
|
|||||||
apply(snapshot: snapshot)
|
apply(snapshot: snapshot)
|
||||||
persistCurrentState()
|
persistCurrentState()
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
} catch let error as AppError {
|
||||||
|
errorMessage = error.errorDescription
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = "Unable to refresh the dashboard."
|
errorMessage = "Unable to refresh the dashboard."
|
||||||
}
|
}
|
||||||
@@ -282,6 +287,8 @@ final class AppViewModel: ObservableObject {
|
|||||||
persistCurrentState()
|
persistCurrentState()
|
||||||
selectedSection = .inbox
|
selectedSection = .inbox
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
} catch let error as AppError {
|
||||||
|
errorMessage = error.errorDescription
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = "Unable to create a mock identity check right now."
|
errorMessage = "Unable to create a mock identity check right now."
|
||||||
}
|
}
|
||||||
@@ -315,6 +322,8 @@ final class AppViewModel: ObservableObject {
|
|||||||
apply(snapshot: snapshot)
|
apply(snapshot: snapshot)
|
||||||
persistCurrentState()
|
persistCurrentState()
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
} catch let error as AppError {
|
||||||
|
errorMessage = error.errorDescription
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = "Unable to update the notification."
|
errorMessage = "Unable to update the notification."
|
||||||
}
|
}
|
||||||
@@ -326,6 +335,7 @@ final class AppViewModel: ObservableObject {
|
|||||||
profile = nil
|
profile = nil
|
||||||
requests = []
|
requests = []
|
||||||
notifications = []
|
notifications = []
|
||||||
|
devices = []
|
||||||
selectedSection = .inbox
|
selectedSection = .inbox
|
||||||
manualPairingPayload = suggestedPairingPayload
|
manualPairingPayload = suggestedPairingPayload
|
||||||
isShowingPairingSuccess = false
|
isShowingPairingSuccess = false
|
||||||
@@ -371,6 +381,8 @@ final class AppViewModel: ObservableObject {
|
|||||||
apply(snapshot: snapshot)
|
apply(snapshot: snapshot)
|
||||||
persistCurrentState()
|
persistCurrentState()
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
} catch let error as AppError {
|
||||||
|
errorMessage = error.errorDescription
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = "Unable to update the identity check."
|
errorMessage = "Unable to update the identity check."
|
||||||
}
|
}
|
||||||
@@ -387,7 +399,8 @@ final class AppViewModel: ObservableObject {
|
|||||||
snapshot: DashboardSnapshot(
|
snapshot: DashboardSnapshot(
|
||||||
profile: state.profile,
|
profile: state.profile,
|
||||||
requests: state.requests,
|
requests: state.requests,
|
||||||
notifications: state.notifications
|
notifications: state.notifications,
|
||||||
|
devices: state.devices
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -403,7 +416,8 @@ final class AppViewModel: ObservableObject {
|
|||||||
session: session,
|
session: session,
|
||||||
profile: profile,
|
profile: profile,
|
||||||
requests: requests,
|
requests: requests,
|
||||||
notifications: notifications
|
notifications: notifications,
|
||||||
|
devices: devices
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -413,6 +427,9 @@ final class AppViewModel: ObservableObject {
|
|||||||
self.profile = snapshot.profile
|
self.profile = snapshot.profile
|
||||||
self.requests = snapshot.requests.sorted { $0.createdAt > $1.createdAt }
|
self.requests = snapshot.requests.sorted { $0.createdAt > $1.createdAt }
|
||||||
self.notifications = snapshot.notifications.sorted { $0.sentAt > $1.sentAt }
|
self.notifications = snapshot.notifications.sorted { $0.sentAt > $1.sentAt }
|
||||||
|
self.devices = snapshot.devices.sorted {
|
||||||
|
($0.lastSeenAt ?? .distantPast) > ($1.lastSeenAt ?? .distantPast)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let profileValue = snapshot.profile
|
let profileValue = snapshot.profile
|
||||||
|
|||||||
@@ -181,6 +181,19 @@ struct DashboardSnapshot {
|
|||||||
let profile: MemberProfile
|
let profile: MemberProfile
|
||||||
let requests: [ApprovalRequest]
|
let requests: [ApprovalRequest]
|
||||||
let notifications: [AppNotification]
|
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 {
|
struct SignInResult {
|
||||||
@@ -217,6 +230,8 @@ struct AuthSession: Identifiable, Hashable, Codable {
|
|||||||
let id: UUID
|
let id: UUID
|
||||||
let deviceName: String
|
let deviceName: String
|
||||||
let originHost: String
|
let originHost: String
|
||||||
|
let serverURL: String
|
||||||
|
let passportDeviceID: String
|
||||||
let pairedAt: Date
|
let pairedAt: Date
|
||||||
let tokenPreview: String
|
let tokenPreview: String
|
||||||
let pairingCode: String
|
let pairingCode: String
|
||||||
@@ -227,6 +242,8 @@ struct AuthSession: Identifiable, Hashable, Codable {
|
|||||||
id: UUID = UUID(),
|
id: UUID = UUID(),
|
||||||
deviceName: String,
|
deviceName: String,
|
||||||
originHost: String,
|
originHost: String,
|
||||||
|
serverURL: String = "",
|
||||||
|
passportDeviceID: String = "",
|
||||||
pairedAt: Date,
|
pairedAt: Date,
|
||||||
tokenPreview: String,
|
tokenPreview: String,
|
||||||
pairingCode: String,
|
pairingCode: String,
|
||||||
@@ -236,12 +253,41 @@ struct AuthSession: Identifiable, Hashable, Codable {
|
|||||||
self.id = id
|
self.id = id
|
||||||
self.deviceName = deviceName
|
self.deviceName = deviceName
|
||||||
self.originHost = originHost
|
self.originHost = originHost
|
||||||
|
self.serverURL = serverURL
|
||||||
|
self.passportDeviceID = passportDeviceID
|
||||||
self.pairedAt = pairedAt
|
self.pairedAt = pairedAt
|
||||||
self.tokenPreview = tokenPreview
|
self.tokenPreview = tokenPreview
|
||||||
self.pairingCode = pairingCode
|
self.pairingCode = pairingCode
|
||||||
self.pairingTransport = pairingTransport
|
self.pairingTransport = pairingTransport
|
||||||
self.signedGPSPosition = signedGPSPosition
|
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 {
|
enum ApprovalRequestKind: String, CaseIterable, Hashable, Codable {
|
||||||
@@ -320,6 +366,8 @@ enum ApprovalStatus: String, Hashable, Codable {
|
|||||||
|
|
||||||
struct ApprovalRequest: Identifiable, Hashable, Codable {
|
struct ApprovalRequest: Identifiable, Hashable, Codable {
|
||||||
let id: UUID
|
let id: UUID
|
||||||
|
let serverID: String
|
||||||
|
let hintID: String
|
||||||
let title: String
|
let title: String
|
||||||
let subtitle: String
|
let subtitle: String
|
||||||
let source: String
|
let source: String
|
||||||
@@ -327,10 +375,20 @@ struct ApprovalRequest: Identifiable, Hashable, Codable {
|
|||||||
let kind: ApprovalRequestKind
|
let kind: ApprovalRequestKind
|
||||||
let risk: ApprovalRisk
|
let risk: ApprovalRisk
|
||||||
let scopes: [String]
|
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
|
var status: ApprovalStatus
|
||||||
|
|
||||||
init(
|
init(
|
||||||
id: UUID = UUID(),
|
id: UUID = UUID(),
|
||||||
|
serverID: String = UUID().uuidString,
|
||||||
|
hintID: String = UUID().uuidString,
|
||||||
title: String,
|
title: String,
|
||||||
subtitle: String,
|
subtitle: String,
|
||||||
source: String,
|
source: String,
|
||||||
@@ -338,9 +396,19 @@ struct ApprovalRequest: Identifiable, Hashable, Codable {
|
|||||||
kind: ApprovalRequestKind,
|
kind: ApprovalRequestKind,
|
||||||
risk: ApprovalRisk,
|
risk: ApprovalRisk,
|
||||||
scopes: [String],
|
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
|
status: ApprovalStatus
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
|
self.serverID = serverID
|
||||||
|
self.hintID = hintID
|
||||||
self.title = title
|
self.title = title
|
||||||
self.subtitle = subtitle
|
self.subtitle = subtitle
|
||||||
self.source = source
|
self.source = source
|
||||||
@@ -348,9 +416,62 @@ struct ApprovalRequest: Identifiable, Hashable, Codable {
|
|||||||
self.kind = kind
|
self.kind = kind
|
||||||
self.risk = risk
|
self.risk = risk
|
||||||
self.scopes = scopes
|
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
|
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 {
|
var scopeSummary: String {
|
||||||
if scopes.isEmpty {
|
if scopes.isEmpty {
|
||||||
return "No proof details listed"
|
return "No proof details listed"
|
||||||
@@ -420,6 +541,8 @@ enum AppNotificationKind: String, Hashable, Codable {
|
|||||||
|
|
||||||
struct AppNotification: Identifiable, Hashable, Codable {
|
struct AppNotification: Identifiable, Hashable, Codable {
|
||||||
let id: UUID
|
let id: UUID
|
||||||
|
let serverID: String
|
||||||
|
let hintID: String
|
||||||
let title: String
|
let title: String
|
||||||
let message: String
|
let message: String
|
||||||
let sentAt: Date
|
let sentAt: Date
|
||||||
@@ -428,6 +551,8 @@ struct AppNotification: Identifiable, Hashable, Codable {
|
|||||||
|
|
||||||
init(
|
init(
|
||||||
id: UUID = UUID(),
|
id: UUID = UUID(),
|
||||||
|
serverID: String = UUID().uuidString,
|
||||||
|
hintID: String = UUID().uuidString,
|
||||||
title: String,
|
title: String,
|
||||||
message: String,
|
message: String,
|
||||||
sentAt: Date,
|
sentAt: Date,
|
||||||
@@ -435,12 +560,45 @@ struct AppNotification: Identifiable, Hashable, Codable {
|
|||||||
isUnread: Bool
|
isUnread: Bool
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
|
self.serverID = serverID
|
||||||
|
self.hintID = hintID
|
||||||
self.title = title
|
self.title = title
|
||||||
self.message = message
|
self.message = message
|
||||||
self.sentAt = sentAt
|
self.sentAt = sentAt
|
||||||
self.kind = kind
|
self.kind = kind
|
||||||
self.isUnread = isUnread
|
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 {
|
enum AppError: LocalizedError, Equatable {
|
||||||
|
|||||||
@@ -13,6 +13,38 @@ struct PersistedAppState: Codable, Equatable {
|
|||||||
let profile: MemberProfile
|
let profile: MemberProfile
|
||||||
let requests: [ApprovalRequest]
|
let requests: [ApprovalRequest]
|
||||||
let notifications: [AppNotification]
|
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 {
|
protocol AppStateStoring {
|
||||||
|
|||||||
@@ -1,4 +1,17 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
|
#if canImport(UIKit)
|
||||||
|
import UIKit
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if canImport(CoreLocation)
|
||||||
|
import CoreLocation
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if canImport(Security)
|
||||||
|
import Security
|
||||||
|
#endif
|
||||||
|
|
||||||
protocol IDPServicing {
|
protocol IDPServicing {
|
||||||
func bootstrap() async throws -> BootstrapContext
|
func bootstrap() async throws -> BootstrapContext
|
||||||
@@ -11,6 +24,399 @@ protocol IDPServicing {
|
|||||||
func markNotificationRead(id: UUID) async throws -> DashboardSnapshot
|
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 {
|
actor MockIDPService: IDPServicing {
|
||||||
static let shared = MockIDPService()
|
static let shared = MockIDPService()
|
||||||
|
|
||||||
@@ -25,6 +431,7 @@ actor MockIDPService: IDPServicing {
|
|||||||
private let appStateStore: AppStateStoring
|
private let appStateStore: AppStateStoring
|
||||||
private var requests: [ApprovalRequest] = []
|
private var requests: [ApprovalRequest] = []
|
||||||
private var notifications: [AppNotification] = []
|
private var notifications: [AppNotification] = []
|
||||||
|
private var devices: [PassportDeviceRecord] = []
|
||||||
|
|
||||||
init(appStateStore: AppStateStoring = UserDefaultsAppStateStore()) {
|
init(appStateStore: AppStateStoring = UserDefaultsAppStateStore()) {
|
||||||
self.appStateStore = appStateStore
|
self.appStateStore = appStateStore
|
||||||
@@ -32,9 +439,11 @@ actor MockIDPService: IDPServicing {
|
|||||||
if let state = appStateStore.load() {
|
if let state = appStateStore.load() {
|
||||||
requests = state.requests.sorted { $0.createdAt > $1.createdAt }
|
requests = state.requests.sorted { $0.createdAt > $1.createdAt }
|
||||||
notifications = state.notifications.sorted { $0.sentAt > $1.sentAt }
|
notifications = state.notifications.sorted { $0.sentAt > $1.sentAt }
|
||||||
|
devices = state.devices
|
||||||
} else {
|
} else {
|
||||||
requests = Self.seedRequests()
|
requests = Self.seedRequests()
|
||||||
notifications = Self.seedNotifications()
|
notifications = Self.seedNotifications()
|
||||||
|
devices = Self.seedDevices()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,7 +607,8 @@ actor MockIDPService: IDPServicing {
|
|||||||
DashboardSnapshot(
|
DashboardSnapshot(
|
||||||
profile: profile,
|
profile: profile,
|
||||||
requests: requests,
|
requests: requests,
|
||||||
notifications: notifications
|
notifications: notifications,
|
||||||
|
devices: devices
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,6 +630,8 @@ actor MockIDPService: IDPServicing {
|
|||||||
return AuthSession(
|
return AuthSession(
|
||||||
deviceName: context.deviceName,
|
deviceName: context.deviceName,
|
||||||
originHost: context.originHost,
|
originHost: context.originHost,
|
||||||
|
serverURL: "https://\(context.originHost)",
|
||||||
|
passportDeviceID: UUID().uuidString,
|
||||||
pairedAt: .now,
|
pairedAt: .now,
|
||||||
tokenPreview: context.tokenPreview,
|
tokenPreview: context.tokenPreview,
|
||||||
pairingCode: request.pairingPayload,
|
pairingCode: request.pairingPayload,
|
||||||
@@ -260,11 +672,13 @@ actor MockIDPService: IDPServicing {
|
|||||||
guard let state = appStateStore.load() else {
|
guard let state = appStateStore.load() else {
|
||||||
requests = Self.seedRequests()
|
requests = Self.seedRequests()
|
||||||
notifications = Self.seedNotifications()
|
notifications = Self.seedNotifications()
|
||||||
|
devices = Self.seedDevices()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
requests = state.requests.sorted { $0.createdAt > $1.createdAt }
|
requests = state.requests.sorted { $0.createdAt > $1.createdAt }
|
||||||
notifications = state.notifications.sorted { $0.sentAt > $1.sentAt }
|
notifications = state.notifications.sorted { $0.sentAt > $1.sentAt }
|
||||||
|
devices = state.devices
|
||||||
}
|
}
|
||||||
|
|
||||||
private func persistSharedStateIfAvailable() {
|
private func persistSharedStateIfAvailable() {
|
||||||
@@ -275,7 +689,8 @@ actor MockIDPService: IDPServicing {
|
|||||||
session: state.session,
|
session: state.session,
|
||||||
profile: state.profile,
|
profile: state.profile,
|
||||||
requests: requests,
|
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 {
|
struct PairingPayloadContext: Equatable {
|
||||||
let deviceName: String
|
let deviceName: String
|
||||||
let originHost: String
|
let originHost: String
|
||||||
|
let pairingToken: String
|
||||||
|
let challenge: String?
|
||||||
|
let challengeID: String?
|
||||||
let tokenPreview: String
|
let tokenPreview: String
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,6 +24,9 @@ enum PairingPayloadParser {
|
|||||||
return PairingPayloadContext(
|
return PairingPayloadContext(
|
||||||
deviceName: device,
|
deviceName: device,
|
||||||
originHost: origin,
|
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))
|
tokenPreview: String(token.suffix(6))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -29,6 +35,9 @@ enum PairingPayloadParser {
|
|||||||
return PairingPayloadContext(
|
return PairingPayloadContext(
|
||||||
deviceName: "Manual Session",
|
deviceName: "Manual Session",
|
||||||
originHost: "code.foss.global",
|
originHost: "code.foss.global",
|
||||||
|
pairingToken: trimmedPayload,
|
||||||
|
challenge: nil,
|
||||||
|
challengeID: nil,
|
||||||
tokenPreview: String(trimmedPayload.suffix(6))
|
tokenPreview: String(trimmedPayload.suffix(6))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,33 +12,19 @@ extension ApprovalRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var locationSummary: String {
|
var locationSummary: String {
|
||||||
"Berlin, DE"
|
locationSummaryText ?? "Location not required"
|
||||||
}
|
}
|
||||||
|
|
||||||
var deviceSummary: String {
|
var deviceSummary: String {
|
||||||
switch kind {
|
deviceSummaryText ?? "Trusted passport device"
|
||||||
case .signIn:
|
|
||||||
"Safari on Berlin iPhone"
|
|
||||||
case .accessGrant:
|
|
||||||
"Chrome on iPad Pro"
|
|
||||||
case .elevatedAction:
|
|
||||||
"Berlin MacBook Pro"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var networkSummary: String {
|
var networkSummary: String {
|
||||||
switch kind {
|
networkSummaryText ?? source
|
||||||
case .signIn:
|
|
||||||
"Home Wi-Fi"
|
|
||||||
case .accessGrant:
|
|
||||||
"Shared office Wi-Fi"
|
|
||||||
case .elevatedAction:
|
|
||||||
"Ethernet"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var ipSummary: String {
|
var ipSummary: String {
|
||||||
risk == .elevated ? "84.187.12.44" : "84.187.12.36"
|
ipSummaryText ?? "n/a"
|
||||||
}
|
}
|
||||||
|
|
||||||
var trustColor: Color {
|
var trustColor: Color {
|
||||||
@@ -66,7 +52,7 @@ extension ApprovalRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var expiresAt: Date {
|
var expiresAt: Date {
|
||||||
createdAt.addingTimeInterval(risk == .elevated ? 180 : 300)
|
expiresAtDate ?? createdAt.addingTimeInterval(risk == .elevated ? 180 : 300)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -231,27 +231,31 @@ struct NotificationCenterView: View {
|
|||||||
|
|
||||||
struct DevicesView: View {
|
struct DevicesView: View {
|
||||||
@ObservedObject var model: AppViewModel
|
@ObservedObject var model: AppViewModel
|
||||||
@State private var isPairingCodePresented = false
|
|
||||||
|
|
||||||
private var devices: [DevicePresentation] {
|
private var devices: [DevicePresentation] {
|
||||||
guard let session else { return [] }
|
guard let session else { return [] }
|
||||||
|
|
||||||
let current = DevicePresentation(
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
DevicePresentation(
|
||||||
name: session.deviceName,
|
name: session.deviceName,
|
||||||
systemImage: symbolName(for: session.deviceName),
|
systemImage: symbolName(for: session.deviceName),
|
||||||
lastSeen: .now,
|
lastSeen: .now,
|
||||||
isCurrent: true,
|
isCurrent: true,
|
||||||
isTrusted: true
|
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)
|
|
||||||
]
|
]
|
||||||
|
|
||||||
let count = max((model.profile?.deviceCount ?? 1) - 1, 0)
|
|
||||||
return [current] + Array(others.prefix(count))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var session: AuthSession? {
|
private var session: AuthSession? {
|
||||||
@@ -274,10 +278,9 @@ struct DevicesView: View {
|
|||||||
|
|
||||||
Section {
|
Section {
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
Button("Pair another device") {
|
Text("Start new device pairing from your idp.global web session, then scan the fresh pairing QR in this app.")
|
||||||
isPairingCodePresented = true
|
.font(.footnote)
|
||||||
}
|
.foregroundStyle(.secondary)
|
||||||
.buttonStyle(PrimaryActionStyle())
|
|
||||||
|
|
||||||
Button("Sign out everywhere") {
|
Button("Sign out everywhere") {
|
||||||
model.signOut()
|
model.signOut()
|
||||||
@@ -288,11 +291,6 @@ struct DevicesView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Devices")
|
.navigationTitle("Devices")
|
||||||
.sheet(isPresented: $isPairingCodePresented) {
|
|
||||||
if let session {
|
|
||||||
OneTimePasscodeSheet(session: session)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func symbolName(for deviceName: String) -> String {
|
private func symbolName(for deviceName: String) -> String {
|
||||||
|
|||||||
Reference in New Issue
Block a user