This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
enum AppSection: String, CaseIterable, Identifiable, Hashable {
|
||||
enum AppSection: String, CaseIterable, Identifiable, Hashable, Codable {
|
||||
case overview
|
||||
case requests
|
||||
case activity
|
||||
@@ -28,7 +28,7 @@ enum AppSection: String, CaseIterable, Identifiable, Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
enum NotificationPermissionState: String, CaseIterable, Identifiable {
|
||||
enum NotificationPermissionState: String, CaseIterable, Identifiable, Codable {
|
||||
case unknown
|
||||
case allowed
|
||||
case provisional
|
||||
@@ -72,7 +72,7 @@ struct BootstrapContext {
|
||||
let suggestedPairingPayload: String
|
||||
}
|
||||
|
||||
enum PairingTransport: String, Hashable {
|
||||
enum PairingTransport: String, Hashable, Codable {
|
||||
case qr
|
||||
case nfc
|
||||
case manual
|
||||
@@ -98,7 +98,7 @@ struct PairingAuthenticationRequest: Hashable {
|
||||
let signedGPSPosition: SignedGPSPosition?
|
||||
}
|
||||
|
||||
struct SignedGPSPosition: Hashable {
|
||||
struct SignedGPSPosition: Hashable, Codable {
|
||||
let latitude: Double
|
||||
let longitude: Double
|
||||
let horizontalAccuracyMeters: Double
|
||||
@@ -185,7 +185,7 @@ struct SignInResult {
|
||||
let snapshot: DashboardSnapshot
|
||||
}
|
||||
|
||||
struct MemberProfile: Identifiable, Hashable {
|
||||
struct MemberProfile: Identifiable, Hashable, Codable {
|
||||
let id: UUID
|
||||
let name: String
|
||||
let handle: String
|
||||
@@ -210,7 +210,7 @@ struct MemberProfile: Identifiable, Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
struct AuthSession: Identifiable, Hashable {
|
||||
struct AuthSession: Identifiable, Hashable, Codable {
|
||||
let id: UUID
|
||||
let deviceName: String
|
||||
let originHost: String
|
||||
@@ -241,7 +241,7 @@ struct AuthSession: Identifiable, Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
enum ApprovalRequestKind: String, CaseIterable, Hashable {
|
||||
enum ApprovalRequestKind: String, CaseIterable, Hashable, Codable {
|
||||
case signIn
|
||||
case accessGrant
|
||||
case elevatedAction
|
||||
@@ -263,7 +263,7 @@ enum ApprovalRequestKind: String, CaseIterable, Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
enum ApprovalRisk: String, Hashable {
|
||||
enum ApprovalRisk: String, Hashable, Codable {
|
||||
case routine
|
||||
case elevated
|
||||
|
||||
@@ -293,7 +293,7 @@ enum ApprovalRisk: String, Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
enum ApprovalStatus: String, Hashable {
|
||||
enum ApprovalStatus: String, Hashable, Codable {
|
||||
case pending
|
||||
case approved
|
||||
case rejected
|
||||
@@ -315,7 +315,7 @@ enum ApprovalStatus: String, Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
struct ApprovalRequest: Identifiable, Hashable {
|
||||
struct ApprovalRequest: Identifiable, Hashable, Codable {
|
||||
let id: UUID
|
||||
let title: String
|
||||
let subtitle: String
|
||||
@@ -382,7 +382,7 @@ struct ApprovalRequest: Identifiable, Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
enum AppNotificationKind: String, Hashable {
|
||||
enum AppNotificationKind: String, Hashable, Codable {
|
||||
case approval
|
||||
case security
|
||||
case system
|
||||
@@ -415,7 +415,7 @@ enum AppNotificationKind: String, Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
struct AppNotification: Identifiable, Hashable {
|
||||
struct AppNotification: Identifiable, Hashable, Codable {
|
||||
let id: UUID
|
||||
let title: String
|
||||
let message: String
|
||||
@@ -440,7 +440,7 @@ struct AppNotification: Identifiable, Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
enum AppError: LocalizedError {
|
||||
enum AppError: LocalizedError, Equatable {
|
||||
case invalidPairingPayload
|
||||
case missingSignedGPSPosition
|
||||
case invalidSignedGPSPosition
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import Foundation
|
||||
|
||||
struct PersistedAppState: Codable, Equatable {
|
||||
let session: AuthSession
|
||||
let profile: MemberProfile
|
||||
let requests: [ApprovalRequest]
|
||||
let notifications: [AppNotification]
|
||||
}
|
||||
|
||||
protocol AppStateStoring {
|
||||
func load() -> PersistedAppState?
|
||||
func save(_ state: PersistedAppState)
|
||||
func clear()
|
||||
}
|
||||
|
||||
final class UserDefaultsAppStateStore: AppStateStoring {
|
||||
private let defaults: UserDefaults
|
||||
private let storageKey: String
|
||||
private let encoder = JSONEncoder()
|
||||
private let decoder = JSONDecoder()
|
||||
|
||||
init(defaults: UserDefaults = .standard, storageKey: String = "persisted-app-state") {
|
||||
self.defaults = defaults
|
||||
self.storageKey = storageKey
|
||||
}
|
||||
|
||||
func load() -> PersistedAppState? {
|
||||
guard let data = defaults.data(forKey: storageKey) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return try? decoder.decode(PersistedAppState.self, from: data)
|
||||
}
|
||||
|
||||
func save(_ state: PersistedAppState) {
|
||||
guard let data = try? encoder.encode(state) else {
|
||||
return
|
||||
}
|
||||
|
||||
defaults.set(data, forKey: storageKey)
|
||||
}
|
||||
|
||||
func clear() {
|
||||
defaults.removeObject(forKey: storageKey)
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,7 @@ actor MockIDPService: IDPServicing {
|
||||
try await Task.sleep(for: .milliseconds(180))
|
||||
|
||||
try validateSignedGPSPosition(in: request)
|
||||
let context = try parsePayloadContext(from: request.pairingPayload)
|
||||
let context = try PairingPayloadParser.parse(request.pairingPayload)
|
||||
notifications.insert(
|
||||
AppNotification(
|
||||
title: "Identity proof completed",
|
||||
@@ -186,7 +186,7 @@ actor MockIDPService: IDPServicing {
|
||||
}
|
||||
|
||||
private func parseSession(from request: PairingAuthenticationRequest) throws -> AuthSession {
|
||||
let context = try parsePayloadContext(from: request.pairingPayload)
|
||||
let context = try PairingPayloadParser.parse(request.pairingPayload)
|
||||
|
||||
return AuthSession(
|
||||
deviceName: context.deviceName,
|
||||
@@ -199,33 +199,6 @@ actor MockIDPService: IDPServicing {
|
||||
)
|
||||
}
|
||||
|
||||
private func parsePayloadContext(from payload: String) throws -> PayloadContext {
|
||||
if let components = URLComponents(string: payload),
|
||||
components.scheme == "idp.global",
|
||||
components.host == "pair" {
|
||||
let queryItems = components.queryItems ?? []
|
||||
let token = queryItems.first(where: { $0.name == "token" })?.value ?? "demo-token"
|
||||
let origin = queryItems.first(where: { $0.name == "origin" })?.value ?? "code.foss.global"
|
||||
let device = queryItems.first(where: { $0.name == "device" })?.value ?? "Web Session"
|
||||
|
||||
return PayloadContext(
|
||||
deviceName: device,
|
||||
originHost: origin,
|
||||
tokenPreview: String(token.suffix(6))
|
||||
)
|
||||
}
|
||||
|
||||
if payload.contains("token") || payload.contains("pair") {
|
||||
return PayloadContext(
|
||||
deviceName: "Manual Session",
|
||||
originHost: "code.foss.global",
|
||||
tokenPreview: String(payload.suffix(6))
|
||||
)
|
||||
}
|
||||
|
||||
throw AppError.invalidPairingPayload
|
||||
}
|
||||
|
||||
private func pairingMessage(for session: AuthSession) -> String {
|
||||
let transportSummary: String
|
||||
switch session.pairingTransport {
|
||||
@@ -246,7 +219,7 @@ actor MockIDPService: IDPServicing {
|
||||
return "\(session.deviceName) is now acting as a passport, \(transportSummary) against \(session.originHost)."
|
||||
}
|
||||
|
||||
private func identificationMessage(for context: PayloadContext, signedGPSPosition: SignedGPSPosition?) -> String {
|
||||
private func identificationMessage(for context: PairingPayloadContext, signedGPSPosition: SignedGPSPosition?) -> String {
|
||||
if let signedGPSPosition {
|
||||
return "A signed GPS proof was sent for \(context.deviceName) on \(context.originHost) from \(signedGPSPosition.coordinateSummary) \(signedGPSPosition.accuracySummary)."
|
||||
}
|
||||
@@ -254,12 +227,6 @@ actor MockIDPService: IDPServicing {
|
||||
return "An identity proof was completed for \(context.deviceName) on \(context.originHost)."
|
||||
}
|
||||
|
||||
private struct PayloadContext {
|
||||
let deviceName: String
|
||||
let originHost: String
|
||||
let tokenPreview: String
|
||||
}
|
||||
|
||||
private static func seedRequests() -> [ApprovalRequest] {
|
||||
[
|
||||
ApprovalRequest(
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
enum OneTimePasscodeGenerator {
|
||||
static func code(for pairingCode: String, at date: Date) -> String {
|
||||
let timeSlot = Int(date.timeIntervalSince1970 / 30)
|
||||
let digest = SHA256.hash(data: Data("\(pairingCode)|\(timeSlot)".utf8))
|
||||
let value = digest.prefix(4).reduce(UInt32(0)) { partialResult, byte in
|
||||
(partialResult << 8) | UInt32(byte)
|
||||
}
|
||||
|
||||
return String(format: "%06d", locale: Locale(identifier: "en_US_POSIX"), Int(value % 1_000_000))
|
||||
}
|
||||
|
||||
static func renewalCountdown(at date: Date) -> Int {
|
||||
let elapsed = Int(date.timeIntervalSince1970) % 30
|
||||
return elapsed == 0 ? 30 : 30 - elapsed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import Foundation
|
||||
|
||||
struct PairingPayloadContext: Equatable {
|
||||
let deviceName: String
|
||||
let originHost: String
|
||||
let tokenPreview: String
|
||||
}
|
||||
|
||||
enum PairingPayloadParser {
|
||||
static func parse(_ payload: String) throws -> PairingPayloadContext {
|
||||
let trimmedPayload = payload.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
if let components = URLComponents(string: trimmedPayload),
|
||||
components.scheme == "idp.global",
|
||||
components.host == "pair" {
|
||||
let queryItems = components.queryItems ?? []
|
||||
let token = queryItems.first(where: { $0.name == "token" })?.value ?? "demo-token"
|
||||
let origin = queryItems.first(where: { $0.name == "origin" })?.value ?? "code.foss.global"
|
||||
let device = queryItems.first(where: { $0.name == "device" })?.value ?? "Web Session"
|
||||
|
||||
return PairingPayloadContext(
|
||||
deviceName: device,
|
||||
originHost: origin,
|
||||
tokenPreview: String(token.suffix(6))
|
||||
)
|
||||
}
|
||||
|
||||
if trimmedPayload.contains("token") || trimmedPayload.contains("pair") {
|
||||
return PairingPayloadContext(
|
||||
deviceName: "Manual Session",
|
||||
originHost: "code.foss.global",
|
||||
tokenPreview: String(trimmedPayload.suffix(6))
|
||||
)
|
||||
}
|
||||
|
||||
throw AppError.invalidPairingPayload
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user