Refocus app around identity proof flows
This commit is contained in:
@@ -2,7 +2,8 @@ import Foundation
|
||||
|
||||
protocol IDPServicing {
|
||||
func bootstrap() async throws -> BootstrapContext
|
||||
func signIn(withQRCode payload: String) async throws -> SignInResult
|
||||
func signIn(with request: PairingAuthenticationRequest) async throws -> SignInResult
|
||||
func identify(with request: PairingAuthenticationRequest) async throws -> DashboardSnapshot
|
||||
func refreshDashboard() async throws -> DashboardSnapshot
|
||||
func approveRequest(id: UUID) async throws -> DashboardSnapshot
|
||||
func rejectRequest(id: UUID) async throws -> DashboardSnapshot
|
||||
@@ -30,18 +31,19 @@ actor MockIDPService: IDPServicing {
|
||||
func bootstrap() async throws -> BootstrapContext {
|
||||
try await Task.sleep(for: .milliseconds(120))
|
||||
return BootstrapContext(
|
||||
suggestedQRCodePayload: "idp.global://pair?token=swiftapp-demo-berlin&origin=code.foss.global&device=Safari%20on%20Berlin%20MBP"
|
||||
suggestedPairingPayload: "idp.global://pair?token=swiftapp-demo-berlin&origin=code.foss.global&device=Safari%20on%20Berlin%20MBP"
|
||||
)
|
||||
}
|
||||
|
||||
func signIn(withQRCode payload: String) async throws -> SignInResult {
|
||||
func signIn(with request: PairingAuthenticationRequest) async throws -> SignInResult {
|
||||
try await Task.sleep(for: .milliseconds(260))
|
||||
|
||||
let session = try parseSession(from: payload)
|
||||
try validateSignedGPSPosition(in: request)
|
||||
let session = try parseSession(from: request)
|
||||
notifications.insert(
|
||||
AppNotification(
|
||||
title: "New device paired",
|
||||
message: "\(session.deviceName) completed a QR pairing against \(session.originHost).",
|
||||
title: "Passport activated",
|
||||
message: pairingMessage(for: session),
|
||||
sentAt: .now,
|
||||
kind: .security,
|
||||
isUnread: true
|
||||
@@ -55,6 +57,25 @@ actor MockIDPService: IDPServicing {
|
||||
)
|
||||
}
|
||||
|
||||
func identify(with request: PairingAuthenticationRequest) async throws -> DashboardSnapshot {
|
||||
try await Task.sleep(for: .milliseconds(180))
|
||||
|
||||
try validateSignedGPSPosition(in: request)
|
||||
let context = try parsePayloadContext(from: request.pairingPayload)
|
||||
notifications.insert(
|
||||
AppNotification(
|
||||
title: "Identity proof completed",
|
||||
message: identificationMessage(for: context, signedGPSPosition: request.signedGPSPosition),
|
||||
sentAt: .now,
|
||||
kind: .security,
|
||||
isUnread: true
|
||||
),
|
||||
at: 0
|
||||
)
|
||||
|
||||
return snapshot()
|
||||
}
|
||||
|
||||
func refreshDashboard() async throws -> DashboardSnapshot {
|
||||
try await Task.sleep(for: .milliseconds(180))
|
||||
return snapshot()
|
||||
@@ -70,8 +91,8 @@ actor MockIDPService: IDPServicing {
|
||||
requests[index].status = .approved
|
||||
notifications.insert(
|
||||
AppNotification(
|
||||
title: "Request approved",
|
||||
message: "\(requests[index].title) was approved for \(requests[index].source).",
|
||||
title: "Identity verified",
|
||||
message: "\(requests[index].title) was completed for \(requests[index].source).",
|
||||
sentAt: .now,
|
||||
kind: .approval,
|
||||
isUnread: true
|
||||
@@ -92,8 +113,8 @@ actor MockIDPService: IDPServicing {
|
||||
requests[index].status = .rejected
|
||||
notifications.insert(
|
||||
AppNotification(
|
||||
title: "Request rejected",
|
||||
message: "\(requests[index].title) was rejected before token issuance.",
|
||||
title: "Identity proof declined",
|
||||
message: "\(requests[index].title) was declined before the session could continue.",
|
||||
sentAt: .now,
|
||||
kind: .security,
|
||||
isUnread: true
|
||||
@@ -108,21 +129,21 @@ actor MockIDPService: IDPServicing {
|
||||
try await Task.sleep(for: .milliseconds(120))
|
||||
|
||||
let syntheticRequest = ApprovalRequest(
|
||||
title: "Approve SSH certificate issue",
|
||||
subtitle: "CI runner wants a short-lived signing certificate for a deployment pipeline.",
|
||||
source: "deploy.idp.global",
|
||||
title: "Prove identity for web sign-in",
|
||||
subtitle: "A browser session is asking this passport to prove that it is really you.",
|
||||
source: "auth.idp.global",
|
||||
createdAt: .now,
|
||||
kind: .elevatedAction,
|
||||
risk: .elevated,
|
||||
scopes: ["sign:ssh", "ttl:10m", "environment:staging"],
|
||||
kind: .signIn,
|
||||
risk: .routine,
|
||||
scopes: ["proof:basic", "client:web", "method:qr"],
|
||||
status: .pending
|
||||
)
|
||||
|
||||
requests.insert(syntheticRequest, at: 0)
|
||||
notifications.insert(
|
||||
AppNotification(
|
||||
title: "Fresh approval request",
|
||||
message: "A staging deployment is waiting for your approval.",
|
||||
title: "Fresh identity proof request",
|
||||
message: "A new relying party is waiting for your identity proof.",
|
||||
sentAt: .now,
|
||||
kind: .approval,
|
||||
isUnread: true
|
||||
@@ -152,7 +173,33 @@ actor MockIDPService: IDPServicing {
|
||||
)
|
||||
}
|
||||
|
||||
private func parseSession(from payload: String) throws -> AuthSession {
|
||||
private func validateSignedGPSPosition(in request: PairingAuthenticationRequest) throws {
|
||||
if request.transport == .nfc,
|
||||
request.signedGPSPosition == nil {
|
||||
throw AppError.missingSignedGPSPosition
|
||||
}
|
||||
|
||||
if let signedGPSPosition = request.signedGPSPosition,
|
||||
!signedGPSPosition.verified(for: request.pairingPayload) {
|
||||
throw AppError.invalidSignedGPSPosition
|
||||
}
|
||||
}
|
||||
|
||||
private func parseSession(from request: PairingAuthenticationRequest) throws -> AuthSession {
|
||||
let context = try parsePayloadContext(from: request.pairingPayload)
|
||||
|
||||
return AuthSession(
|
||||
deviceName: context.deviceName,
|
||||
originHost: context.originHost,
|
||||
pairedAt: .now,
|
||||
tokenPreview: context.tokenPreview,
|
||||
pairingCode: request.pairingPayload,
|
||||
pairingTransport: request.transport,
|
||||
signedGPSPosition: request.signedGPSPosition
|
||||
)
|
||||
}
|
||||
|
||||
private func parsePayloadContext(from payload: String) throws -> PayloadContext {
|
||||
if let components = URLComponents(string: payload),
|
||||
components.scheme == "idp.global",
|
||||
components.host == "pair" {
|
||||
@@ -161,58 +208,88 @@ actor MockIDPService: IDPServicing {
|
||||
let origin = queryItems.first(where: { $0.name == "origin" })?.value ?? "code.foss.global"
|
||||
let device = queryItems.first(where: { $0.name == "device" })?.value ?? "Web Session"
|
||||
|
||||
return AuthSession(
|
||||
return PayloadContext(
|
||||
deviceName: device,
|
||||
originHost: origin,
|
||||
pairedAt: .now,
|
||||
tokenPreview: String(token.suffix(6)),
|
||||
pairingCode: payload
|
||||
tokenPreview: String(token.suffix(6))
|
||||
)
|
||||
}
|
||||
|
||||
if payload.contains("token") || payload.contains("pair") {
|
||||
return AuthSession(
|
||||
deviceName: "Manual Pairing",
|
||||
return PayloadContext(
|
||||
deviceName: "Manual Session",
|
||||
originHost: "code.foss.global",
|
||||
pairedAt: .now,
|
||||
tokenPreview: String(payload.suffix(6)),
|
||||
pairingCode: payload
|
||||
tokenPreview: String(payload.suffix(6))
|
||||
)
|
||||
}
|
||||
|
||||
throw AppError.invalidQRCode
|
||||
throw AppError.invalidPairingPayload
|
||||
}
|
||||
|
||||
private func pairingMessage(for session: AuthSession) -> String {
|
||||
let transportSummary: String
|
||||
switch session.pairingTransport {
|
||||
case .qr:
|
||||
transportSummary = "activated via QR"
|
||||
case .nfc:
|
||||
transportSummary = "activated via NFC with a signed GPS position"
|
||||
case .manual:
|
||||
transportSummary = "activated via manual payload"
|
||||
case .preview:
|
||||
transportSummary = "activated via preview payload"
|
||||
}
|
||||
|
||||
if let signedGPSPosition = session.signedGPSPosition {
|
||||
return "\(session.deviceName) is now acting as a passport, \(transportSummary) against \(session.originHost) from \(signedGPSPosition.coordinateSummary) \(signedGPSPosition.accuracySummary)."
|
||||
}
|
||||
|
||||
return "\(session.deviceName) is now acting as a passport, \(transportSummary) against \(session.originHost)."
|
||||
}
|
||||
|
||||
private func identificationMessage(for context: PayloadContext, signedGPSPosition: SignedGPSPosition?) -> String {
|
||||
if let signedGPSPosition {
|
||||
return "A signed GPS proof was sent for \(context.deviceName) on \(context.originHost) from \(signedGPSPosition.coordinateSummary) \(signedGPSPosition.accuracySummary)."
|
||||
}
|
||||
|
||||
return "An identity proof was completed for \(context.deviceName) on \(context.originHost)."
|
||||
}
|
||||
|
||||
private struct PayloadContext {
|
||||
let deviceName: String
|
||||
let originHost: String
|
||||
let tokenPreview: String
|
||||
}
|
||||
|
||||
private static func seedRequests() -> [ApprovalRequest] {
|
||||
[
|
||||
ApprovalRequest(
|
||||
title: "Approve Safari sign-in",
|
||||
subtitle: "A browser session from Berlin wants an SSO token for the portal.",
|
||||
title: "Prove identity for Safari sign-in",
|
||||
subtitle: "The portal wants this passport to prove that the browser session is really you.",
|
||||
source: "code.foss.global",
|
||||
createdAt: .now.addingTimeInterval(-60 * 12),
|
||||
kind: .signIn,
|
||||
risk: .routine,
|
||||
scopes: ["openid", "profile", "groups:read"],
|
||||
scopes: ["proof:basic", "client:web", "origin:trusted"],
|
||||
status: .pending
|
||||
),
|
||||
ApprovalRequest(
|
||||
title: "Grant package publish access",
|
||||
subtitle: "The release bot is asking for a scoped publish token.",
|
||||
source: "registry.foss.global",
|
||||
title: "Prove identity for workstation unlock",
|
||||
subtitle: "Your secure workspace is asking for a stronger proof before it unlocks.",
|
||||
source: "berlin-mbp.idp.global",
|
||||
createdAt: .now.addingTimeInterval(-60 * 42),
|
||||
kind: .accessGrant,
|
||||
kind: .elevatedAction,
|
||||
risk: .elevated,
|
||||
scopes: ["packages:write", "ttl:30m"],
|
||||
scopes: ["proof:high", "client:desktop", "presence:required"],
|
||||
status: .pending
|
||||
),
|
||||
ApprovalRequest(
|
||||
title: "Approve CLI login",
|
||||
subtitle: "A terminal session completed QR pairing earlier today.",
|
||||
title: "Prove identity for CLI session",
|
||||
subtitle: "The CLI session asked for proof earlier and was completed from this passport.",
|
||||
source: "cli.idp.global",
|
||||
createdAt: .now.addingTimeInterval(-60 * 180),
|
||||
kind: .signIn,
|
||||
risk: .routine,
|
||||
scopes: ["openid", "profile"],
|
||||
scopes: ["proof:basic", "client:cli"],
|
||||
status: .approved
|
||||
)
|
||||
]
|
||||
@@ -221,8 +298,8 @@ actor MockIDPService: IDPServicing {
|
||||
private static func seedNotifications() -> [AppNotification] {
|
||||
[
|
||||
AppNotification(
|
||||
title: "Two requests are waiting",
|
||||
message: "The queue includes one routine sign-in and one elevated access grant.",
|
||||
title: "Two identity checks are waiting",
|
||||
message: "One routine web proof and one stronger workstation proof are waiting for this passport.",
|
||||
sentAt: .now.addingTimeInterval(-60 * 8),
|
||||
kind: .approval,
|
||||
isUnread: true
|
||||
@@ -235,8 +312,8 @@ actor MockIDPService: IDPServicing {
|
||||
isUnread: false
|
||||
),
|
||||
AppNotification(
|
||||
title: "Quiet hours active on mobile",
|
||||
message: "Routine notifications will be delivered silently until the morning.",
|
||||
title: "Passport quiet hours active",
|
||||
message: "Routine identity checks will be delivered silently until the morning.",
|
||||
sentAt: .now.addingTimeInterval(-60 * 220),
|
||||
kind: .security,
|
||||
isUnread: false
|
||||
|
||||
Reference in New Issue
Block a user