migration

This commit is contained in:
Daniel Arantes Loverde
2026-08-11 13:22:02 -03:00
parent c4d6998595
commit 7702836fe7
231 changed files with 4329 additions and 1958 deletions

View 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
}
}