651 lines
23 KiB
Swift
651 lines
23 KiB
Swift
// LiveAccountServices.swift
|
|
// OSGKeyboard · Main App
|
|
//
|
|
// Adapts the host-private account protocol to the account-center UI. Account
|
|
// session credentials never enter App Group storage; only a non-secret local
|
|
// eligibility marker and scope-limited gateway grants are shared.
|
|
|
|
import Foundation
|
|
import OSGKeyboardHostSupport
|
|
import OSGKeyboardShared
|
|
|
|
@MainActor
|
|
enum LiveAccountDependencyFactory {
|
|
static func make(bundle: Bundle = .main) -> AccountDependencies {
|
|
guard let accessGroup = bundle.object(
|
|
forInfoDictionaryKey: "OSGPrivateKeychainAccessGroup"
|
|
) as? String,
|
|
let descriptor = try? HostPrivateAccountKeychainDescriptor(
|
|
accessGroup: accessGroup
|
|
) else {
|
|
return .unavailable
|
|
}
|
|
|
|
let sessionVault = HostPrivateAccountKeychain(descriptor: descriptor)
|
|
let apiClient = AccountAPIClient(sessionVault: sessionVault)
|
|
HostAnalyticsBearerBridge.shared.install(
|
|
AccountAnalyticsBearerProvider(apiClient: apiClient)
|
|
)
|
|
let integrity = DeviceIntegrityCoordinator(
|
|
apiClient: apiClient,
|
|
keyStateStore: sessionVault
|
|
)
|
|
let grantStore = GatewayGrantKeychainStore()
|
|
let grants = GatewayGrantCoordinator(store: grantStore)
|
|
let service = LiveAccountService(
|
|
apiClient: apiClient,
|
|
integrity: integrity,
|
|
grants: grants,
|
|
grantStore: grantStore,
|
|
configuration: AppGroupStore()
|
|
)
|
|
return AccountDependencies(
|
|
sessionService: service,
|
|
sessionEventSource: service,
|
|
centerService: service,
|
|
referralService: LiveReferralProfileService(apiClient: apiClient)
|
|
)
|
|
}
|
|
}
|
|
|
|
actor AccountCenterSnapshotLoader {
|
|
private let apiClient: AccountAPIClient
|
|
private let decoder = JSONDecoder()
|
|
|
|
init(apiClient: AccountAPIClient) {
|
|
self.apiClient = apiClient
|
|
}
|
|
|
|
func load(
|
|
cachedSnapshot: AccountCenterSnapshot?
|
|
) async throws -> AccountCenterSnapshot {
|
|
async let account = apiClient.account()
|
|
async let balance = resource(CreditBalanceDTO.self, .creditsBalance)
|
|
async let referrals = optionalResource(
|
|
[ReferralBindingDTO].self,
|
|
.referrals(limit: 50),
|
|
diagnosticName: "referrals"
|
|
)
|
|
|
|
let core: (OSGAccount, CreditBalanceDTO)
|
|
do {
|
|
core = try await (account, balance)
|
|
} catch {
|
|
OSGDiag.log(
|
|
"account refresh core failed error=\(AccountDiagnostic.code(for: error))",
|
|
category: "account"
|
|
)
|
|
throw error
|
|
}
|
|
let loadedReferrals = await referrals?.map {
|
|
AccountReferral(
|
|
id: UUID(),
|
|
status: Self.referralStatus($0.rewardStatus),
|
|
createdAtEpochSeconds: Self.epochSeconds($0.boundAt),
|
|
rewardCredits: nil
|
|
)
|
|
} ?? cachedSnapshot?.referrals ?? []
|
|
|
|
return AccountCenterSnapshot(
|
|
account: AccountSession(
|
|
accountID: core.0.id,
|
|
createdAtEpochSeconds: core.0.createdAtEpochSeconds,
|
|
displayName: core.0.displayName
|
|
),
|
|
credits: AccountCreditSummary(
|
|
balance: core.1.balance,
|
|
usedCredits: core.1.lifetimeUsed ?? 0
|
|
),
|
|
referrals: loadedReferrals
|
|
)
|
|
}
|
|
|
|
private func resource<Value: Decodable & Sendable>(
|
|
_ type: Value.Type,
|
|
_ resource: AccountAuthorizedResource
|
|
) async throws -> Value {
|
|
let data = try await apiClient.authorizedResourceData(resource)
|
|
return try decoder.decode(type, from: data)
|
|
}
|
|
|
|
private func optionalResource<Value: Decodable & Sendable>(
|
|
_ type: Value.Type,
|
|
_ resource: AccountAuthorizedResource,
|
|
diagnosticName: String
|
|
) async -> Value? {
|
|
do {
|
|
return try await self.resource(type, resource)
|
|
} catch {
|
|
OSGDiag.log(
|
|
"account refresh optional=\(diagnosticName) fallback=cache "
|
|
+ "error=\(AccountDiagnostic.code(for: error))",
|
|
category: "account"
|
|
)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private static func referralStatus(_ value: String) -> AccountReferralStatus {
|
|
switch value.uppercased() {
|
|
case "REWARDED":
|
|
return .rewarded
|
|
case "INELIGIBLE_BUDGET":
|
|
return .ineligible
|
|
default:
|
|
return .pending
|
|
}
|
|
}
|
|
|
|
private static func epochSeconds(_ value: String) -> Int64? {
|
|
let fractional = ISO8601DateFormatter()
|
|
fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
let standard = ISO8601DateFormatter()
|
|
standard.formatOptions = [.withInternetDateTime]
|
|
let date = fractional.date(from: value) ?? standard.date(from: value)
|
|
return date.map { Int64($0.timeIntervalSince1970) }
|
|
}
|
|
}
|
|
|
|
actor LiveReferralProfileService: ReferralProfileServicing {
|
|
private let apiClient: AccountAPIClient
|
|
private let decoder = JSONDecoder()
|
|
|
|
init(apiClient: AccountAPIClient) {
|
|
self.apiClient = apiClient
|
|
}
|
|
|
|
func loadReferralProfile() async throws -> ReferralProfile {
|
|
let data = try await apiClient.authorizedResourceData(.referralProfile)
|
|
do {
|
|
return try decoder.decode(ReferralProfile.self, from: data)
|
|
} catch {
|
|
throw AccountAPIError.decoding
|
|
}
|
|
}
|
|
}
|
|
|
|
private actor LiveAccountService:
|
|
AccountSessionServicing,
|
|
AccountSessionEventSourcing,
|
|
AccountCenterServicing {
|
|
private let apiClient: AccountAPIClient
|
|
private let accountCenterLoader: AccountCenterSnapshotLoader
|
|
private let integrity: DeviceIntegrityCoordinator
|
|
private let grants: GatewayGrantCoordinator
|
|
private let grantStore: any GatewayGrantCredentialStore
|
|
private let configuration: AppGroupStore
|
|
private let decoder = JSONDecoder()
|
|
private let encoder = JSONEncoder()
|
|
|
|
init(
|
|
apiClient: AccountAPIClient,
|
|
integrity: DeviceIntegrityCoordinator,
|
|
grants: GatewayGrantCoordinator,
|
|
grantStore: any GatewayGrantCredentialStore,
|
|
configuration: AppGroupStore
|
|
) {
|
|
self.apiClient = apiClient
|
|
accountCenterLoader = AccountCenterSnapshotLoader(apiClient: apiClient)
|
|
self.integrity = integrity
|
|
self.grants = grants
|
|
self.grantStore = grantStore
|
|
self.configuration = configuration
|
|
}
|
|
|
|
func events() async -> AsyncStream<AccountSessionEvent> {
|
|
let invalidations = await apiClient.sessionInvalidations()
|
|
return AsyncStream { continuation in
|
|
let task = Task {
|
|
for await invalidation in invalidations {
|
|
switch invalidation {
|
|
case .expired:
|
|
await self.invalidateManagedGatewaySession()
|
|
continuation.yield(.expired)
|
|
}
|
|
}
|
|
continuation.finish()
|
|
}
|
|
continuation.onTermination = { _ in
|
|
task.cancel()
|
|
}
|
|
}
|
|
}
|
|
|
|
func restoreSession() async throws -> AccountSession? {
|
|
guard let cachedSession = try await apiClient.currentSession() else {
|
|
OSGDiag.log("session restore status=not-found", category: "account")
|
|
await invalidateManagedGatewaySession()
|
|
return nil
|
|
}
|
|
OSGDiag.log("session restore status=keychain-found", category: "account")
|
|
let account: OSGAccount?
|
|
do {
|
|
account = try await apiClient.account()
|
|
} catch let error as AccountAPIError {
|
|
switch error {
|
|
case .sessionUnavailable, .unauthorized, .refreshTokenReuse:
|
|
OSGDiag.log(
|
|
"session restore status=rejected "
|
|
+ "error=\(AccountDiagnostic.code(for: error))",
|
|
category: "account"
|
|
)
|
|
await invalidateManagedGatewaySession()
|
|
return nil
|
|
default:
|
|
OSGDiag.log(
|
|
"session restore status=cached-fallback "
|
|
+ "error=\(AccountDiagnostic.code(for: error))",
|
|
category: "account"
|
|
)
|
|
account = nil
|
|
}
|
|
} catch {
|
|
OSGDiag.log(
|
|
"session restore status=cached-fallback "
|
|
+ "error=\(AccountDiagnostic.code(for: error))",
|
|
category: "account"
|
|
)
|
|
account = nil
|
|
}
|
|
let session = try await apiClient.currentSession() ?? cachedSession
|
|
configuration.setManagedGatewayAccountSessionAvailable(true)
|
|
await synchronizeManagedGrant()
|
|
OSGDiag.log(
|
|
"session restore status=restored accountValidated=\(account == nil ? 0 : 1)",
|
|
category: "account"
|
|
)
|
|
return uiSession(
|
|
session,
|
|
createdAtEpochSeconds: account?.createdAtEpochSeconds ?? 0,
|
|
displayName: account?.displayName
|
|
)
|
|
}
|
|
|
|
func signIn(with payload: AppleAuthorizationPayload) async throws -> AccountSession {
|
|
let credential = AppleSignInCredential(
|
|
identityToken: payload.identityToken,
|
|
authorizationCode: payload.authorizationCode
|
|
)
|
|
let evidence: DeviceIntegrityEvidence
|
|
do {
|
|
evidence = try await integrity.evidenceForAppleSignIn(
|
|
credential: credential,
|
|
rawNonce: payload.nonce
|
|
)
|
|
} catch {
|
|
OSGDiag.log(
|
|
"signIn failed stage=integrity error=\(AccountDiagnostic.code(for: error))",
|
|
category: "account"
|
|
)
|
|
throw error
|
|
}
|
|
OSGDiag.log(
|
|
"signIn integrity ready deviceCheck=\(evidence.deviceCheckToken == nil ? 0 : 1) "
|
|
+ "appAttest=\(evidence.appAttest == nil ? 0 : 1)",
|
|
category: "account"
|
|
)
|
|
let session: OSGKeyboardHostSupport.AccountSession
|
|
do {
|
|
session = try await apiClient.signInWithApple(
|
|
AppleSignInRequest(
|
|
identityToken: payload.identityToken,
|
|
authorizationCode: payload.authorizationCode,
|
|
nonce: payload.nonce,
|
|
displayName: payload.displayName,
|
|
deviceCheckToken: evidence.deviceCheckToken,
|
|
appAttest: evidence.appAttest
|
|
)
|
|
)
|
|
} catch {
|
|
OSGDiag.log(
|
|
"signIn failed stage=session error=\(AccountDiagnostic.code(for: error))",
|
|
category: "account"
|
|
)
|
|
throw error
|
|
}
|
|
let account: OSGAccount
|
|
do {
|
|
account = try await apiClient.account()
|
|
} catch {
|
|
OSGDiag.log(
|
|
"signIn failed stage=account error=\(AccountDiagnostic.code(for: error))",
|
|
category: "account"
|
|
)
|
|
throw error
|
|
}
|
|
configuration.setManagedGatewayAccountSessionAvailable(true)
|
|
await synchronizeManagedGrant()
|
|
OSGDiag.log("signIn completed", category: "account")
|
|
return uiSession(
|
|
session,
|
|
createdAtEpochSeconds: account.createdAtEpochSeconds,
|
|
displayName: account.displayName
|
|
)
|
|
}
|
|
|
|
func signOut() async throws {
|
|
configuration.setManagedGatewayAccountSessionAvailable(false)
|
|
configuration.setCredentialSource(.byok)
|
|
await revokeManagedGrantIfPossible()
|
|
do {
|
|
try await apiClient.logout()
|
|
} catch {
|
|
try? await grants.clearGrant()
|
|
throw error
|
|
}
|
|
try? await grants.clearGrant()
|
|
}
|
|
|
|
func deleteAccount(with payload: AppleAuthorizationPayload) async throws {
|
|
try await apiClient.deleteAccount(
|
|
identityToken: payload.identityToken,
|
|
authorizationCode: payload.authorizationCode,
|
|
nonce: payload.nonce
|
|
)
|
|
try? await grants.clearGrant()
|
|
await integrity.clearLocalKeyState()
|
|
configuration.setManagedGatewayAccountSessionAvailable(false)
|
|
configuration.setCredentialSource(.byok)
|
|
}
|
|
|
|
func prepareManagedGateway() async throws {
|
|
guard try await apiClient.currentSession() != nil else {
|
|
await invalidateManagedGatewaySession()
|
|
throw AccountAPIError.sessionUnavailable
|
|
}
|
|
configuration.setManagedGatewayAccountSessionAvailable(true)
|
|
try await ensureManagedGrant()
|
|
}
|
|
|
|
func clearManagedGateway() async {
|
|
await revokeManagedGrantIfPossible()
|
|
}
|
|
|
|
func loadAccountCenter() async throws -> AccountCenterSnapshot {
|
|
try await accountCenterLoader.load(cachedSnapshot: nil)
|
|
}
|
|
|
|
func loadAccountCenter(
|
|
cachedSnapshot: AccountCenterSnapshot?
|
|
) async throws -> AccountCenterSnapshot {
|
|
try await accountCenterLoader.load(cachedSnapshot: cachedSnapshot)
|
|
}
|
|
|
|
func updateDisplayName(_ displayName: String) async throws -> AccountSession {
|
|
let account = try await apiClient.updateAccount(displayName: displayName)
|
|
return AccountSession(
|
|
accountID: account.id,
|
|
createdAtEpochSeconds: account.createdAtEpochSeconds,
|
|
displayName: account.displayName
|
|
)
|
|
}
|
|
|
|
func redeemReferral(code: String) async throws {
|
|
struct Request: Encodable { let code: String }
|
|
let body = try encoder.encode(Request(code: code))
|
|
_ = try await apiClient.authorizedResourceData(.redeemReferral, body: body)
|
|
}
|
|
|
|
func loadCreditProducts() async throws -> [AccountCreditProduct] {
|
|
let products = try await resource([StoreKitProductDTO].self, .storeKitProducts)
|
|
return products.map {
|
|
AccountCreditProduct(productID: $0.productId, credits: $0.credits)
|
|
}
|
|
}
|
|
|
|
func submitCreditTransaction(_ signedTransaction: String) async throws -> AccountCreditPurchase {
|
|
let request = StoreKitTransactionRequestDTO(signedTransaction: signedTransaction)
|
|
let data = try await apiClient.authorizedResourceData(
|
|
.submitStoreKitTransaction,
|
|
body: try encoder.encode(request)
|
|
)
|
|
let purchase = try decoder.decode(StoreKitPurchaseDTO.self, from: data)
|
|
return AccountCreditPurchase(
|
|
transactionID: purchase.transactionId,
|
|
productID: purchase.productId,
|
|
creditsGranted: purchase.creditsGranted,
|
|
balanceAfter: purchase.balanceAfter,
|
|
replayed: purchase.replayed
|
|
)
|
|
}
|
|
|
|
func loadCreditPurchaseHistory(
|
|
limit: Int,
|
|
cursor: String?
|
|
) async throws -> AccountCreditPurchaseHistoryPage {
|
|
let data = try await apiClient.authorizedResourceData(
|
|
.storeKitTransactions(limit: limit, cursor: cursor)
|
|
)
|
|
let response = try decoder.decode(
|
|
StoreKitTransactionHistoryResponseDTO.self,
|
|
from: data
|
|
)
|
|
let items = try response.items.map { item in
|
|
guard let purchasedAt = Self.storeKitPurchaseDate(item.purchasedAt),
|
|
let status = AccountCreditPurchaseStatus(rawValue: item.status) else {
|
|
throw AccountAPIError.decoding
|
|
}
|
|
return AccountCreditPurchaseRecord(
|
|
transactionID: item.transactionId,
|
|
productID: item.productId,
|
|
creditsGranted: item.creditsGranted,
|
|
balanceAfter: item.balanceAfter,
|
|
purchasedAt: purchasedAt,
|
|
status: status
|
|
)
|
|
}
|
|
return AccountCreditPurchaseHistoryPage(
|
|
items: items,
|
|
nextCursor: response.nextCursor
|
|
)
|
|
}
|
|
|
|
private func resource<Value: Decodable & Sendable>(
|
|
_ type: Value.Type,
|
|
_ resource: AccountAuthorizedResource
|
|
) async throws -> Value {
|
|
let data = try await apiClient.authorizedResourceData(resource)
|
|
return try decoder.decode(type, from: data)
|
|
}
|
|
|
|
private static func storeKitPurchaseDate(_ value: String) -> Date? {
|
|
let fractional = ISO8601DateFormatter()
|
|
fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
let standard = ISO8601DateFormatter()
|
|
standard.formatOptions = [.withInternetDateTime]
|
|
return fractional.date(from: value) ?? standard.date(from: value)
|
|
}
|
|
|
|
private func synchronizeManagedGrant() async {
|
|
if configuration.credentialSource == .managed {
|
|
do {
|
|
try await ensureManagedGrant()
|
|
} catch {
|
|
// Keep the user's explicit billing choice. A transient grant,
|
|
// network, or balance failure must surface as a managed-service
|
|
// error instead of silently switching requests to a BYOK key.
|
|
OSGDiag.log(
|
|
"managedGrant synchronization failed "
|
|
+ "errorCategory=\(String(reflecting: type(of: error)))",
|
|
category: "account"
|
|
)
|
|
}
|
|
} else {
|
|
try? await grants.clearGrant()
|
|
}
|
|
}
|
|
|
|
private func ensureManagedGrant() async throws {
|
|
let expected = ManagedGatewayScopePolicy.scopes(
|
|
engineMode: configuration.engineMode
|
|
)
|
|
if let existing = try await grantStore.load(),
|
|
existing.scopes == expected,
|
|
existing.hasUsableRefreshToken() {
|
|
_ = try await grants.accessToken(for: .polish)
|
|
return
|
|
}
|
|
await revokeManagedGrantIfPossible()
|
|
let idempotencyKey = UUID().uuidString
|
|
let accessToken = try await apiClient.accessTokenForAuthorizedRequest()
|
|
do {
|
|
_ = try await grants.createGrant(
|
|
accountAccessToken: accessToken,
|
|
scopes: expected,
|
|
idempotencyKey: idempotencyKey
|
|
)
|
|
} catch ManagedGatewayError.invalidGrant {
|
|
let replacement = try await apiClient.refreshAccessToken(
|
|
afterUnauthorizedAccessToken: accessToken
|
|
)
|
|
_ = try await grants.createGrant(
|
|
accountAccessToken: replacement,
|
|
scopes: expected,
|
|
idempotencyKey: idempotencyKey
|
|
)
|
|
}
|
|
}
|
|
|
|
private func revokeManagedGrantIfPossible() async {
|
|
if let existing = try? await grantStore.load(),
|
|
let grantId = UUID(uuidString: existing.grantId) {
|
|
_ = try? await apiClient.authorizedResourceData(
|
|
.revokeGatewayGrant(grantId)
|
|
)
|
|
}
|
|
try? await grants.clearGrant()
|
|
}
|
|
|
|
private func invalidateManagedGatewaySession() async {
|
|
// Flip the local gate before any await so the extension immediately
|
|
// loses access to cached account grants.
|
|
configuration.setManagedGatewayAccountSessionAvailable(false)
|
|
configuration.setCredentialSource(.byok)
|
|
try? await grants.clearGrant()
|
|
}
|
|
|
|
private func uiSession(
|
|
_ session: OSGKeyboardHostSupport.AccountSession,
|
|
createdAtEpochSeconds: Int64,
|
|
displayName: String?
|
|
) -> AccountSession {
|
|
AccountSession(
|
|
accountID: session.accountId,
|
|
createdAtEpochSeconds: createdAtEpochSeconds,
|
|
displayName: displayName
|
|
)
|
|
}
|
|
|
|
}
|
|
|
|
private enum AccountDiagnostic {
|
|
static func code(for error: Error) -> String {
|
|
guard let error = error as? AccountAPIError else {
|
|
return String(describing: type(of: error))
|
|
}
|
|
switch error {
|
|
case .invalidRequest(let message):
|
|
return invalidRequestCode(for: message)
|
|
case .unauthorized:
|
|
return "unauthorized"
|
|
case .refreshTokenReuse:
|
|
return "refresh-token-reuse"
|
|
case .externalServiceUnavailable(let message):
|
|
return unavailableServiceCode(for: message)
|
|
case .conflict:
|
|
return "conflict"
|
|
case .rateLimited:
|
|
return "rate-limited"
|
|
case let .server(statusCode, code, _):
|
|
return "server-\(statusCode)-\(code)"
|
|
case .transport:
|
|
return "transport"
|
|
case .invalidResponse:
|
|
return "invalid-response"
|
|
case .decoding:
|
|
return "decoding"
|
|
case .secureStorage:
|
|
return "secure-storage"
|
|
case .sessionUnavailable:
|
|
return "session-unavailable"
|
|
case .appleAuthorization:
|
|
return "apple-authorization"
|
|
case .integrityUnavailable:
|
|
return "integrity-unavailable"
|
|
}
|
|
}
|
|
|
|
private static func invalidRequestCode(for message: String) -> String {
|
|
switch message {
|
|
case "DeviceCheck verification failed":
|
|
return "device-check-rejected"
|
|
case "App Attest verification failed":
|
|
return "app-attest-rejected"
|
|
case "identityToken exceeds the maximum length":
|
|
return "identity-token-too-large"
|
|
case "authorizationCode exceeds the maximum length":
|
|
return "authorization-code-too-large"
|
|
case "nonce exceeds the maximum length":
|
|
return "nonce-too-large"
|
|
default:
|
|
return "invalid-request"
|
|
}
|
|
}
|
|
|
|
private static func unavailableServiceCode(for message: String) -> String {
|
|
switch message {
|
|
case "DeviceCheck is temporarily unavailable":
|
|
return "device-check-unavailable"
|
|
case "App Attest is temporarily unavailable":
|
|
return "app-attest-unavailable"
|
|
case "Apple identity verification is temporarily unavailable":
|
|
return "apple-identity-unavailable"
|
|
case "Apple token service is temporarily unavailable":
|
|
return "apple-token-service-unavailable"
|
|
default:
|
|
return "external-service-unavailable"
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct CreditBalanceDTO: Decodable, Sendable {
|
|
let balance: Int64
|
|
let lifetimeUsed: Int64?
|
|
}
|
|
|
|
private struct ReferralBindingDTO: Decodable, Sendable {
|
|
let boundAt: String
|
|
let rewardStatus: String
|
|
}
|
|
|
|
private struct StoreKitProductDTO: Decodable, Sendable {
|
|
let productId: String
|
|
let credits: Int64
|
|
}
|
|
|
|
private struct StoreKitTransactionRequestDTO: Encodable, Sendable {
|
|
let signedTransaction: String
|
|
}
|
|
|
|
private struct StoreKitPurchaseDTO: Decodable, Sendable {
|
|
let transactionId: String
|
|
let productId: String
|
|
let creditsGranted: Int64
|
|
let balanceAfter: Int64
|
|
let replayed: Bool
|
|
}
|
|
|
|
private struct StoreKitTransactionHistoryResponseDTO: Decodable, Sendable {
|
|
let items: [StoreKitTransactionHistoryItemDTO]
|
|
let nextCursor: String?
|
|
}
|
|
|
|
private struct StoreKitTransactionHistoryItemDTO: Decodable, Sendable {
|
|
let transactionId: String
|
|
let productId: String
|
|
let creditsGranted: Int64
|
|
let balanceAfter: Int64
|
|
let purchasedAt: String
|
|
let status: String
|
|
}
|