migration
This commit is contained in:
222
PediFoods/Services/GuestSessionService.swift
Normal file
222
PediFoods/Services/GuestSessionService.swift
Normal file
@@ -0,0 +1,222 @@
|
||||
import Foundation
|
||||
#if os(iOS)
|
||||
import DeviceCheck
|
||||
import CryptoKit
|
||||
#endif
|
||||
|
||||
private struct ChallengeResult: Decodable, Sendable {
|
||||
let challenge: String
|
||||
}
|
||||
|
||||
/// Fields differ by handshake step — Optional properties are omitted from
|
||||
/// the encoded JSON entirely (Codable synthesis uses encodeIfPresent), so
|
||||
/// this one struct covers both the fresh-attestation and assertion payloads.
|
||||
private struct GuestSessionAttestPayload: Encodable, Sendable {
|
||||
let platform: String
|
||||
let deviceId: String
|
||||
let challenge: String
|
||||
let keyId: String?
|
||||
let attestation: String?
|
||||
let assertion: String?
|
||||
}
|
||||
|
||||
private struct GuestSessionPlaceholderPayload: Encodable, Sendable {
|
||||
let platform: String
|
||||
let deviceId: String
|
||||
let attestation: String
|
||||
}
|
||||
|
||||
/// Issues and caches the short-lived (15min) guest JWT used by the pre-login
|
||||
/// public store locator. Deliberately separate from ApiService/TokenStore —
|
||||
/// a guest-session 401 must never be treated as the customer session
|
||||
/// expiring (see docs/plans/app-migrate-atomenta-calls-to-pedifoods-bff.md).
|
||||
///
|
||||
/// iOS uses real App Attest (2-call protocol: challenge, then
|
||||
/// attest-a-fresh-key the first time per device or sign-an-assertion with
|
||||
/// the already-registered key every time after). If the server doesn't
|
||||
/// recognize a previously-registered key (e.g. it lost its in-memory
|
||||
/// registration), the assertion call fails and this falls back to
|
||||
/// re-attesting with a brand new key rather than leaving the guest stuck.
|
||||
actor GuestSessionService {
|
||||
static let shared = GuestSessionService()
|
||||
|
||||
private var cachedToken: String?
|
||||
private var expiresAt: Date?
|
||||
private let client: ApiClient
|
||||
private let store: GuestLocationStore
|
||||
|
||||
// Actors are reentrant across `await` points — without this, two
|
||||
// concurrent callers with no cached token yet (very possible: the
|
||||
// location picker and Home's guest store load can both need a guest
|
||||
// session near launch) would each independently run the App Attest
|
||||
// flow, racing each other's key registration against the server. This
|
||||
// makes every caller share the one in-flight handshake instead.
|
||||
private var inFlightRefresh: Task<String, Error>?
|
||||
|
||||
init(client: ApiClient = ApiClient(), store: GuestLocationStore = .shared) {
|
||||
self.client = client
|
||||
self.store = store
|
||||
}
|
||||
|
||||
func validToken() async throws -> String {
|
||||
if let cachedToken, let expiresAt, expiresAt > Date() {
|
||||
return cachedToken
|
||||
}
|
||||
return try await refreshToken()
|
||||
}
|
||||
|
||||
/// Forces a fresh handshake, used for the silent retry-on-401 flow.
|
||||
func invalidateAndRefresh() async throws -> String {
|
||||
cachedToken = nil
|
||||
expiresAt = nil
|
||||
inFlightRefresh = nil
|
||||
return try await refreshToken()
|
||||
}
|
||||
|
||||
private func refreshToken() async throws -> String {
|
||||
if let inFlightRefresh {
|
||||
return try await inFlightRefresh.value
|
||||
}
|
||||
let task = Task { try await performRefresh() }
|
||||
inFlightRefresh = task
|
||||
defer { inFlightRefresh = nil }
|
||||
return try await task.value
|
||||
}
|
||||
|
||||
private func performRefresh() async throws -> String {
|
||||
#if os(iOS)
|
||||
return try await refreshTokenWithAppAttest()
|
||||
#else
|
||||
return try await refreshTokenWithPlaceholder()
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
private func refreshTokenWithAppAttest() async throws -> String {
|
||||
guard DCAppAttestService.shared.isSupported else {
|
||||
// Simulator can never support App Attest (hardware limitation,
|
||||
// not environment-specific) — server has its own documented
|
||||
// bypass for this case, gated by an admin toggle server-side.
|
||||
let challenge = try await fetchChallenge()
|
||||
return try await handshakeWithSimulatorBypass(challenge: challenge)
|
||||
}
|
||||
|
||||
if let existingKeyId = store.appAttestKeyId {
|
||||
do {
|
||||
let challenge = try await fetchChallenge()
|
||||
return try await handshakeWithAssertion(keyId: existingKeyId, challenge: challenge)
|
||||
} catch let error as NetworkError where isKeyRejectedByServer(error) {
|
||||
// Server explicitly rejected this key (403
|
||||
// APP_ATTEST_VERIFICATION_FAILED — e.g. it lost the
|
||||
// credential registration) — re-attest with a new key. Any
|
||||
// other error (network blip, timeout, decode issue) must
|
||||
// NOT wipe a perfectly valid registered key.
|
||||
store.appAttestKeyId = nil
|
||||
}
|
||||
}
|
||||
|
||||
let challenge = try await fetchChallenge()
|
||||
return try await handshakeWithFreshAttestation(challenge: challenge)
|
||||
}
|
||||
|
||||
private func isKeyRejectedByServer(_ error: NetworkError) -> Bool {
|
||||
if case .httpError(403, _) = error { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
private func fetchChallenge() async throws -> String {
|
||||
let req = ApiRequest(
|
||||
path: "/api/public/attest/challenge",
|
||||
method: "GET",
|
||||
module: .none,
|
||||
requiresAuth: false,
|
||||
queryItems: [URLQueryItem(name: "deviceId", value: store.deviceId)],
|
||||
baseURLOverride: ApiConfig.pediFoodsBFFURL
|
||||
)
|
||||
let envelope: ApiEnvelope<ChallengeResult> = try await client.send(req)
|
||||
guard envelope.error == false, let result = envelope.result else {
|
||||
throw NetworkError.invalidResponse
|
||||
}
|
||||
return result.challenge
|
||||
}
|
||||
|
||||
private func handshakeWithFreshAttestation(challenge: String) async throws -> String {
|
||||
let keyId = try await DCAppAttestService.shared.generateKey()
|
||||
let clientDataHash = Data(SHA256.hash(data: Data(challenge.utf8)))
|
||||
let attestationObject = try await DCAppAttestService.shared.attestKey(keyId, clientDataHash: clientDataHash)
|
||||
|
||||
let payload = GuestSessionAttestPayload(
|
||||
platform: "ios",
|
||||
deviceId: store.deviceId,
|
||||
challenge: challenge,
|
||||
keyId: keyId,
|
||||
attestation: attestationObject.base64EncodedString(),
|
||||
assertion: nil
|
||||
)
|
||||
let token = try await sendSessionRequest(body: JSONEncoder().encode(payload))
|
||||
store.appAttestKeyId = keyId
|
||||
return token
|
||||
}
|
||||
|
||||
/// Simulator can never run real App Attest — server accepts this literal
|
||||
/// bypass value instead, gated by its own admin toggle (403
|
||||
/// SIMULATOR_BYPASS_DISABLED if that toggle is off; not a client bug).
|
||||
private func handshakeWithSimulatorBypass(challenge: String) async throws -> String {
|
||||
let payload = GuestSessionAttestPayload(
|
||||
platform: "ios",
|
||||
deviceId: store.deviceId,
|
||||
challenge: challenge,
|
||||
keyId: nil,
|
||||
attestation: "SIMULATOR_BYPASS",
|
||||
assertion: nil
|
||||
)
|
||||
return try await sendSessionRequest(body: JSONEncoder().encode(payload))
|
||||
}
|
||||
|
||||
private func handshakeWithAssertion(keyId: String, challenge: String) async throws -> String {
|
||||
let clientDataHash = Data(SHA256.hash(data: Data(challenge.utf8)))
|
||||
let assertionObject = try await DCAppAttestService.shared.generateAssertion(keyId, clientDataHash: clientDataHash)
|
||||
|
||||
let payload = GuestSessionAttestPayload(
|
||||
platform: "ios",
|
||||
deviceId: store.deviceId,
|
||||
challenge: challenge,
|
||||
keyId: nil,
|
||||
attestation: nil,
|
||||
assertion: assertionObject.base64EncodedString()
|
||||
)
|
||||
return try await sendSessionRequest(body: JSONEncoder().encode(payload))
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Only reachable on non-iOS builds (this app's macOS target is test-only,
|
||||
/// never a real distribution target — no documented contract for it, so
|
||||
/// this stays a best-effort placeholder rather than matching a real spec.
|
||||
private func refreshTokenWithPlaceholder() async throws -> String {
|
||||
let payload = GuestSessionPlaceholderPayload(
|
||||
platform: "ios",
|
||||
deviceId: store.deviceId,
|
||||
attestation: store.attestationPlaceholder
|
||||
)
|
||||
return try await sendSessionRequest(body: JSONEncoder().encode(payload))
|
||||
}
|
||||
|
||||
private func sendSessionRequest(body: Data) async throws -> String {
|
||||
let req = ApiRequest(
|
||||
path: "/api/public/session",
|
||||
method: "POST",
|
||||
module: .none,
|
||||
requiresAuth: false,
|
||||
body: body,
|
||||
baseURLOverride: ApiConfig.pediFoodsBFFURL
|
||||
)
|
||||
let envelope: ApiEnvelope<GuestSessionResult> = try await client.send(req)
|
||||
guard envelope.error == false, let result = envelope.result else {
|
||||
throw NetworkError.invalidResponse
|
||||
}
|
||||
cachedToken = result.guestToken
|
||||
// Refresh a bit early so a request started near expiry doesn't race the server's own clock.
|
||||
expiresAt = Date().addingTimeInterval(TimeInterval(result.expiresIn) - 30)
|
||||
return result.guestToken
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user