309d743fd3
Persist refresh operation identity across failures and validate revoked Apple credentials so clients recover without unsafe token rotation.
360 lines
11 KiB
Swift
360 lines
11 KiB
Swift
// AccountModels.swift
|
|
// OSGKeyboard · HostSupport
|
|
//
|
|
// Wire models for the host-private OSG account protocol.
|
|
|
|
import Foundation
|
|
|
|
public struct AccountSession: Codable, Equatable, Sendable {
|
|
public let accountId: UUID
|
|
public let tokenType: String
|
|
public let accessToken: String
|
|
public let accessTokenExpiresAtEpochSeconds: Int64
|
|
public let refreshToken: String
|
|
public let refreshTokenExpiresAtEpochSeconds: Int64
|
|
|
|
public init(
|
|
accountId: UUID,
|
|
tokenType: String,
|
|
accessToken: String,
|
|
accessTokenExpiresAtEpochSeconds: Int64,
|
|
refreshToken: String,
|
|
refreshTokenExpiresAtEpochSeconds: Int64
|
|
) {
|
|
self.accountId = accountId
|
|
self.tokenType = tokenType
|
|
self.accessToken = accessToken
|
|
self.accessTokenExpiresAtEpochSeconds = accessTokenExpiresAtEpochSeconds
|
|
self.refreshToken = refreshToken
|
|
self.refreshTokenExpiresAtEpochSeconds = refreshTokenExpiresAtEpochSeconds
|
|
}
|
|
}
|
|
|
|
public struct OSGAccount: Codable, Equatable, Sendable {
|
|
public let id: UUID
|
|
public let createdAtEpochSeconds: Int64
|
|
public let displayName: String?
|
|
|
|
public init(id: UUID, createdAtEpochSeconds: Int64, displayName: String? = nil) {
|
|
self.id = id
|
|
self.createdAtEpochSeconds = createdAtEpochSeconds
|
|
self.displayName = displayName
|
|
}
|
|
}
|
|
|
|
struct UpdateAccountProfileRequest: Codable, Equatable, Sendable {
|
|
let displayName: String
|
|
}
|
|
|
|
/// Exact authenticated resources exposed to the host account center. Keeping
|
|
/// this closed prevents callers from forwarding account tokens to arbitrary
|
|
/// paths or origins.
|
|
public enum AccountAuthorizedResource: Sendable, Equatable {
|
|
case creditsBalance
|
|
case referralProfile
|
|
case referralCampaigns
|
|
case referrals(limit: Int)
|
|
case redeemReferral
|
|
case storeKitProducts
|
|
case storeKitTransactions(limit: Int, cursor: String?)
|
|
case submitStoreKitTransaction
|
|
case revokeGatewayGrant(UUID)
|
|
|
|
var method: String {
|
|
switch self {
|
|
case .redeemReferral, .submitStoreKitTransaction:
|
|
return "POST"
|
|
case .revokeGatewayGrant:
|
|
return "DELETE"
|
|
default:
|
|
return "GET"
|
|
}
|
|
}
|
|
|
|
var path: String {
|
|
switch self {
|
|
case .creditsBalance:
|
|
return "/v1/credits/balance"
|
|
case .referralProfile:
|
|
return "/v1/referrals/me"
|
|
case .referralCampaigns:
|
|
return "/v1/referrals/campaigns"
|
|
case .referrals(let limit):
|
|
return "/v1/referrals?limit=\(min(max(limit, 1), 100))"
|
|
case .redeemReferral:
|
|
return "/v1/referrals/redeem"
|
|
case .storeKitProducts:
|
|
return "/v1/storekit/products"
|
|
case .storeKitTransactions(let limit, let cursor):
|
|
let boundedLimit = min(max(limit, 1), 100)
|
|
var components = URLComponents()
|
|
components.path = "/v1/storekit/transactions"
|
|
components.queryItems = [
|
|
URLQueryItem(name: "limit", value: String(boundedLimit))
|
|
]
|
|
if let cursor {
|
|
components.queryItems?.append(
|
|
URLQueryItem(name: "cursor", value: cursor)
|
|
)
|
|
}
|
|
return components.string
|
|
?? "/v1/storekit/transactions?limit=\(boundedLimit)"
|
|
case .submitStoreKitTransaction:
|
|
return "/v1/storekit/transactions"
|
|
case .revokeGatewayGrant(let grantId):
|
|
return "/v1/gateway/grants/\(grantId.uuidString.lowercased())"
|
|
}
|
|
}
|
|
}
|
|
|
|
public struct AppleSignInCredential: Equatable, Sendable {
|
|
public let identityToken: String
|
|
public let authorizationCode: String
|
|
|
|
public init(identityToken: String, authorizationCode: String) {
|
|
self.identityToken = identityToken
|
|
self.authorizationCode = authorizationCode
|
|
}
|
|
}
|
|
|
|
public struct AppleSignInNonce: Equatable, Sendable {
|
|
public let rawValue: String
|
|
public let sha256Hex: String
|
|
|
|
public init(rawValue: String, sha256Hex: String) {
|
|
self.rawValue = rawValue
|
|
self.sha256Hex = sha256Hex
|
|
}
|
|
}
|
|
|
|
public struct AppleSignInRequest: Codable, Equatable, Sendable {
|
|
public let identityToken: String
|
|
public let authorizationCode: String
|
|
public let nonce: String
|
|
public let displayName: String?
|
|
public let deviceCheckToken: String?
|
|
public let appAttest: AppAttestAssertion?
|
|
|
|
public init(
|
|
identityToken: String,
|
|
authorizationCode: String,
|
|
nonce: String,
|
|
displayName: String? = nil,
|
|
deviceCheckToken: String?,
|
|
appAttest: AppAttestAssertion?
|
|
) {
|
|
self.identityToken = identityToken
|
|
self.authorizationCode = authorizationCode
|
|
self.nonce = nonce
|
|
self.displayName = displayName
|
|
self.deviceCheckToken = deviceCheckToken
|
|
self.appAttest = appAttest
|
|
}
|
|
}
|
|
|
|
public struct AppAttestAssertion: Codable, Equatable, Sendable {
|
|
public let keyId: String
|
|
public let challengeId: UUID
|
|
public let challenge: String
|
|
public let assertion: String
|
|
|
|
public init(keyId: String, challengeId: UUID, challenge: String, assertion: String) {
|
|
self.keyId = keyId
|
|
self.challengeId = challengeId
|
|
self.challenge = challenge
|
|
self.assertion = assertion
|
|
}
|
|
}
|
|
|
|
/// Anonymous OOBE grant request. It intentionally contains no account session,
|
|
/// requested scopes, or mutable feature claim; the server fixes policy to
|
|
/// `polish` + `ai` after validating App Attest.
|
|
public struct OOBEGrantRequest: Codable, Equatable, Sendable {
|
|
public let installationId: UUID
|
|
public let keyId: String
|
|
public let challengeId: UUID
|
|
public let challenge: String
|
|
public let assertion: String
|
|
|
|
public init(
|
|
installationId: UUID,
|
|
keyId: String,
|
|
challengeId: UUID,
|
|
challenge: String,
|
|
assertion: String
|
|
) {
|
|
self.installationId = installationId
|
|
self.keyId = keyId
|
|
self.challengeId = challengeId
|
|
self.challenge = challenge
|
|
self.assertion = assertion
|
|
}
|
|
}
|
|
|
|
public enum AppAttestChallengePurpose: String, Codable, Sendable {
|
|
case attestation
|
|
case assertion
|
|
}
|
|
|
|
public struct AppAttestChallenge: Codable, Equatable, Sendable {
|
|
public let challengeId: UUID
|
|
public let challenge: String
|
|
public let expiresAtEpochSeconds: Int64
|
|
|
|
public init(challengeId: UUID, challenge: String, expiresAtEpochSeconds: Int64) {
|
|
self.challengeId = challengeId
|
|
self.challenge = challenge
|
|
self.expiresAtEpochSeconds = expiresAtEpochSeconds
|
|
}
|
|
}
|
|
|
|
public struct AppAttestKeyState: Codable, Equatable, Sendable {
|
|
public let keyId: String
|
|
public let isRegistered: Bool
|
|
|
|
public init(keyId: String, isRegistered: Bool) {
|
|
self.keyId = keyId
|
|
self.isRegistered = isRegistered
|
|
}
|
|
}
|
|
|
|
public struct AccountRefreshTransaction: Codable, Equatable, Sendable {
|
|
public let refreshTokenDigest: String
|
|
public let operationId: UUID
|
|
|
|
public init(refreshTokenDigest: String, operationId: UUID) {
|
|
self.refreshTokenDigest = refreshTokenDigest
|
|
self.operationId = operationId
|
|
}
|
|
}
|
|
|
|
public protocol AccountSessionVault: Sendable {
|
|
func loadSession() async throws -> AccountSession?
|
|
func saveSession(_ session: AccountSession) async throws
|
|
func clearSession() async throws
|
|
func beginRefreshTransaction(
|
|
refreshTokenDigest: String
|
|
) async throws -> AccountRefreshTransaction
|
|
func clearRefreshTransaction() async throws
|
|
}
|
|
|
|
public protocol AppAttestKeyStateStoring: Sendable {
|
|
func loadAppAttestKeyState() async throws -> AppAttestKeyState?
|
|
func saveAppAttestKeyState(_ state: AppAttestKeyState) async throws
|
|
func clearAppAttestKeyState() async throws
|
|
}
|
|
|
|
public protocol AppleUserIdentifierStoring: Sendable {
|
|
func loadAppleUserIdentifier() async throws -> String?
|
|
func saveAppleUserIdentifier(_ userIdentifier: String) async throws
|
|
func clearAppleUserIdentifier() async throws
|
|
}
|
|
|
|
public protocol OOBEInstallationIDStoring: Sendable {
|
|
func oobeInstallationID() async throws -> UUID
|
|
}
|
|
|
|
public enum AccountAPIError: Error, Equatable, Sendable {
|
|
case invalidRequest(String)
|
|
case unauthorized(String)
|
|
case refreshTokenReuse
|
|
case externalServiceUnavailable(String)
|
|
case conflict(String)
|
|
case rateLimited(String)
|
|
case server(statusCode: Int, code: String, message: String)
|
|
case transport
|
|
case invalidResponse
|
|
case decoding
|
|
case secureStorage
|
|
case sessionUnavailable
|
|
case appleAuthorization
|
|
case integrityUnavailable
|
|
}
|
|
|
|
public enum AccountSessionInvalidation: Sendable {
|
|
case expired
|
|
}
|
|
|
|
extension AccountAPIError: LocalizedError {
|
|
public var errorDescription: String? {
|
|
switch self {
|
|
case .invalidRequest(let message),
|
|
.unauthorized(let message),
|
|
.externalServiceUnavailable(let message),
|
|
.conflict(let message),
|
|
.rateLimited(let message):
|
|
return message
|
|
case .refreshTokenReuse:
|
|
return "The session was revoked because refresh-token reuse was detected."
|
|
case .server(_, _, let message):
|
|
return message
|
|
case .transport:
|
|
return "The account service could not be reached."
|
|
case .invalidResponse:
|
|
return "The account service returned an invalid response."
|
|
case .decoding:
|
|
return "The account service response could not be decoded."
|
|
case .secureStorage:
|
|
return "The private account session could not be stored securely."
|
|
case .sessionUnavailable:
|
|
return "No account session is available."
|
|
case .appleAuthorization:
|
|
return "Sign in with Apple did not return valid credentials."
|
|
case .integrityUnavailable:
|
|
return "Device integrity verification is unavailable."
|
|
}
|
|
}
|
|
}
|
|
|
|
struct APIDataEnvelope<Value: Codable & Sendable>: Codable, Sendable {
|
|
let data: Value
|
|
}
|
|
|
|
struct APIErrorEnvelope: Codable, Sendable {
|
|
let error: APIErrorPayload
|
|
}
|
|
|
|
struct APIErrorPayload: Codable, Sendable {
|
|
let code: String
|
|
let message: String
|
|
}
|
|
|
|
struct LegacyAPIErrorEnvelope: Codable, Sendable {
|
|
let error: String
|
|
}
|
|
|
|
struct RefreshSessionRequest: Codable, Sendable {
|
|
let refreshToken: String
|
|
let refreshOperationId: UUID
|
|
}
|
|
|
|
struct DeleteAccountRequest: Codable, Sendable {
|
|
let identityToken: String
|
|
let authorizationCode: String
|
|
let nonce: String
|
|
}
|
|
|
|
struct AppAttestChallengeRequest: Codable, Sendable {
|
|
let purpose: AppAttestChallengePurpose
|
|
let keyId: String
|
|
}
|
|
|
|
struct AppAttestationRequest: Codable, Sendable {
|
|
let challengeId: UUID
|
|
let challenge: String
|
|
let keyId: String
|
|
let attestationObject: String
|
|
}
|
|
|
|
struct AppAssertionRequest: Codable, Sendable {
|
|
let challengeId: UUID
|
|
let challenge: String
|
|
let keyId: String
|
|
let assertion: String
|
|
let clientDataHash: String
|
|
}
|
|
|
|
struct AppAssertionResponse: Codable, Sendable {
|
|
let counter: Int64
|
|
}
|