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? 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 = 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 = 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 } }