feat(account): add managed credits and cloud gateway

Introduce optional Apple account-backed credits with scoped gateway access while preserving local and BYOK paths. Refresh assistant behavior, tests, privacy disclosures, docs, and the website for the 2.0 experience.
This commit is contained in:
Rocky
2026-08-20 11:43:21 +08:00
parent 0f9280bd00
commit 498f407585
301 changed files with 19221 additions and 10891 deletions
@@ -0,0 +1,584 @@
// AccountAPIClient.swift
// OSGKeyboard · HostSupport
//
// The single HTTP exit for account, auth, and integrity traffic.
import Foundation
public protocol AccountHTTPTransport: Sendable {
func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse)
}
public final class URLSessionAccountHTTPTransport: AccountHTTPTransport, @unchecked Sendable {
private let session: URLSession
public init(session: URLSession = .shared) {
self.session = session
}
public func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse) {
let (data, response) = try await session.data(for: request)
guard let response = response as? HTTPURLResponse else {
throw AccountAPIError.invalidResponse
}
return (data, response)
}
}
public actor AccountAPIClient {
private enum Endpoint {
case appleSignIn
case refresh
case logout
case account
case updateAccount
case deleteAccount
case integrityChallenge
case attest
case assert
var method: String {
switch self {
case .account:
return "GET"
case .updateAccount:
return "PATCH"
case .deleteAccount:
return "DELETE"
default:
return "POST"
}
}
var path: String {
switch self {
case .appleSignIn:
return "/v1/auth/apple"
case .refresh:
return "/v1/auth/refresh"
case .logout:
return "/v1/auth/logout"
case .account, .updateAccount, .deleteAccount:
return "/v1/account"
case .integrityChallenge:
return "/v1/integrity/challenges"
case .attest:
return "/v1/integrity/attest"
case .assert:
return "/v1/integrity/assert"
}
}
}
private struct RefreshOperation {
let id: UUID
let task: Task<AccountSession, Error>
}
private let baseURL: URL
private let transport: any AccountHTTPTransport
private let sessionVault: any AccountSessionVault
private let now: @Sendable () -> Date
private let encoder: JSONEncoder
private let decoder: JSONDecoder
private var cachedSession: AccountSession?
private var didLoadSession = false
private var refreshOperation: RefreshOperation?
public init(
baseURL: URL = URL(string: "https://account.osglab.com")!,
transport: any AccountHTTPTransport = URLSessionAccountHTTPTransport(),
sessionVault: any AccountSessionVault,
now: @escaping @Sendable () -> Date = Date.init
) {
self.baseURL = baseURL
self.transport = transport
self.sessionVault = sessionVault
self.now = now
self.encoder = JSONEncoder()
self.encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
self.decoder = JSONDecoder()
}
@discardableResult
public func signInWithApple(_ request: AppleSignInRequest) async throws -> AccountSession {
let data = try await perform(
endpoint: .appleSignIn,
body: try encode(request),
requiresSession: false
)
let session = try decode(APIDataEnvelope<AccountSession>.self, from: data).data
try await replaceSession(with: session)
return session
}
public func currentSession() async throws -> AccountSession? {
try await loadSessionIfNeeded()
}
public func accessTokenForAuthorizedRequest() async throws -> String {
try await sessionForRequest().accessToken
}
public func refreshAccessToken(
afterUnauthorizedAccessToken failedToken: String
) async throws -> String {
try await refreshSession(afterUnauthorizedAccessToken: failedToken).accessToken
}
public func account() async throws -> OSGAccount {
let data = try await perform(endpoint: .account, body: nil, requiresSession: true)
return try decode(APIDataEnvelope<OSGAccount>.self, from: data).data
}
public func updateAccount(displayName: String) async throws -> OSGAccount {
let data = try await perform(
endpoint: .updateAccount,
body: try encode(UpdateAccountProfileRequest(displayName: displayName)),
requiresSession: true
)
return try decode(APIDataEnvelope<OSGAccount>.self, from: data).data
}
public func authorizedResourceData(
_ resource: AccountAuthorizedResource,
body: Data? = nil
) async throws -> Data {
do {
return try await performAuthorizedResource(
resource,
body: body
)
} catch {
guard resource.method == "GET", Self.isTransient(error) else {
throw error
}
try await Task.sleep(for: .milliseconds(150))
return try await performAuthorizedResource(
resource,
body: body
)
}
}
private func performAuthorizedResource(
_ resource: AccountAuthorizedResource,
body: Data?
) async throws -> Data {
let session = try await sessionForRequest()
let firstRequest = try makeAuthorizedResourceRequest(
resource,
body: body,
accessToken: session.accessToken
)
let firstResponse = try await send(firstRequest)
if firstResponse.response.statusCode == 401 {
let replacement = try await refreshSession(
afterUnauthorizedAccessToken: session.accessToken
)
let retryRequest = try makeAuthorizedResourceRequest(
resource,
body: body,
accessToken: replacement.accessToken
)
let retryResponse = try await send(retryRequest)
if retryResponse.response.statusCode == 401 {
try await clearSession()
}
return try validatedData(retryResponse)
}
return try validatedData(firstResponse)
}
public func logout() async throws {
// Local sign-out is authoritative for the device. Revocation remains
// best-effort so an offline server cannot leave credentials at rest.
_ = try? await perform(endpoint: .logout, body: nil, requiresSession: true)
try await clearSession()
}
func deleteAccount(with request: DeleteAccountRequest) async throws {
_ = try await perform(
endpoint: .deleteAccount,
body: try encode(request),
requiresSession: true
)
try await clearSession()
}
public func deleteAccount(
identityToken: String,
authorizationCode: String,
nonce: String
) async throws {
try await deleteAccount(
with: DeleteAccountRequest(
identityToken: identityToken,
authorizationCode: authorizationCode,
nonce: nonce
)
)
}
public func issueAppAttestChallenge(
purpose: AppAttestChallengePurpose,
keyId: String
) async throws -> AppAttestChallenge {
let data = try await perform(
endpoint: .integrityChallenge,
body: try encode(AppAttestChallengeRequest(purpose: purpose, keyId: keyId)),
requiresSession: false
)
return try decode(AppAttestChallenge.self, from: data)
}
public func submitAttestation(
challenge: AppAttestChallenge,
keyId: String,
attestationObject: Data
) async throws {
let request = AppAttestationRequest(
challengeId: challenge.challengeId,
challenge: challenge.challenge,
keyId: keyId,
attestationObject: attestationObject.base64EncodedString()
)
_ = try await perform(
endpoint: .attest,
body: try encode(request),
requiresSession: false
)
}
@discardableResult
public func submitAssertion(
challenge: AppAttestChallenge,
keyId: String,
assertion: Data,
clientDataHash: Data
) async throws -> Int64 {
let request = AppAssertionRequest(
challengeId: challenge.challengeId,
challenge: challenge.challenge,
keyId: keyId,
assertion: assertion.base64EncodedString(),
clientDataHash: clientDataHash.base64URLEncodedString()
)
let data = try await perform(
endpoint: .assert,
body: try encode(request),
requiresSession: false
)
return try decode(AppAssertionResponse.self, from: data).counter
}
private func perform(
endpoint: Endpoint,
body: Data?,
requiresSession: Bool
) async throws -> Data {
do {
return try await performOnce(
endpoint: endpoint,
body: body,
requiresSession: requiresSession
)
} catch {
guard endpoint.method == "GET", Self.isTransient(error) else {
throw error
}
try await Task.sleep(for: .milliseconds(150))
return try await performOnce(
endpoint: endpoint,
body: body,
requiresSession: requiresSession
)
}
}
private func performOnce(
endpoint: Endpoint,
body: Data?,
requiresSession: Bool
) async throws -> Data {
let session = requiresSession ? try await sessionForRequest() : nil
let firstRequest = try makeRequest(
endpoint: endpoint,
body: body,
accessToken: session?.accessToken
)
let firstResponse = try await send(firstRequest)
if requiresSession, firstResponse.response.statusCode == 401, let session {
let replacement = try await refreshSession(afterUnauthorizedAccessToken: session.accessToken)
let retryRequest = try makeRequest(
endpoint: endpoint,
body: body,
accessToken: replacement.accessToken
)
let retryResponse = try await send(retryRequest)
if retryResponse.response.statusCode == 401 {
try await clearSession()
}
return try validatedData(retryResponse)
}
return try validatedData(firstResponse)
}
private static func isTransient(_ error: Error) -> Bool {
guard let error = error as? AccountAPIError else { return false }
switch error {
case .transport, .externalServiceUnavailable:
return true
case .server(let statusCode, _, _):
return (500..<600).contains(statusCode)
default:
return false
}
}
private func sessionForRequest() async throws -> AccountSession {
guard let session = try await loadSessionIfNeeded() else {
throw AccountAPIError.sessionUnavailable
}
let nowSeconds = Int64(now().timeIntervalSince1970)
guard session.refreshTokenExpiresAtEpochSeconds > nowSeconds else {
try await clearSession()
throw AccountAPIError.unauthorized("The refresh token has expired.")
}
if session.accessTokenExpiresAtEpochSeconds <= nowSeconds + 30 {
return try await refreshSession(afterUnauthorizedAccessToken: session.accessToken)
}
return session
}
private func refreshSession(afterUnauthorizedAccessToken failedToken: String) async throws -> AccountSession {
guard let current = try await loadSessionIfNeeded() else {
throw AccountAPIError.sessionUnavailable
}
if current.accessToken != failedToken {
return current
}
if let refreshOperation {
return try await finishRefresh(refreshOperation)
}
let operation = RefreshOperation(
id: UUID(),
task: Task {
try await self.requestRefresh(using: current.refreshToken)
}
)
refreshOperation = operation
return try await finishRefresh(operation)
}
private func finishRefresh(_ operation: RefreshOperation) async throws -> AccountSession {
do {
let replacement = try await operation.task.value
try await replaceSession(with: replacement)
if refreshOperation?.id == operation.id {
refreshOperation = nil
}
return replacement
} catch {
if refreshOperation?.id == operation.id {
refreshOperation = nil
}
if shouldClearSession(afterRefreshError: error) {
try? await clearSession()
}
throw error
}
}
private func requestRefresh(using refreshToken: String) async throws -> AccountSession {
let request = try makeRequest(
endpoint: .refresh,
body: try encode(RefreshSessionRequest(refreshToken: refreshToken)),
accessToken: nil
)
let response = try await send(request)
let data = try validatedData(response)
return try decode(APIDataEnvelope<AccountSession>.self, from: data).data
}
private func shouldClearSession(afterRefreshError error: Error) -> Bool {
guard let error = error as? AccountAPIError else { return false }
switch error {
case .refreshTokenReuse, .unauthorized:
return true
default:
return false
}
}
private func loadSessionIfNeeded() async throws -> AccountSession? {
if didLoadSession {
return cachedSession
}
do {
cachedSession = try await sessionVault.loadSession()
didLoadSession = true
return cachedSession
} catch {
throw AccountAPIError.secureStorage
}
}
private func replaceSession(with session: AccountSession) async throws {
do {
try await sessionVault.saveSession(session)
cachedSession = session
didLoadSession = true
} catch {
cachedSession = nil
didLoadSession = true
try? await sessionVault.clearSession()
throw AccountAPIError.secureStorage
}
}
private func clearSession() async throws {
cachedSession = nil
didLoadSession = true
do {
try await sessionVault.clearSession()
} catch {
throw AccountAPIError.secureStorage
}
}
private func makeRequest(
endpoint: Endpoint,
body: Data?,
accessToken: String?
) throws -> URLRequest {
guard let url = URL(string: endpoint.path, relativeTo: baseURL)?.absoluteURL else {
throw AccountAPIError.invalidResponse
}
var request = URLRequest(url: url)
request.httpMethod = endpoint.method
request.timeoutInterval = 30
request.setValue("application/json", forHTTPHeaderField: "Accept")
if let body {
request.httpBody = body
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
}
if let accessToken {
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
}
return request
}
private func makeAuthorizedResourceRequest(
_ resource: AccountAuthorizedResource,
body: Data?,
accessToken: String
) throws -> URLRequest {
guard let url = URL(string: resource.path, relativeTo: baseURL)?.absoluteURL,
url.scheme == baseURL.scheme,
url.host == baseURL.host else {
throw AccountAPIError.invalidResponse
}
var request = URLRequest(url: url)
request.httpMethod = resource.method
request.timeoutInterval = 30
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
if resource == .creditsBalance {
// Credit checks must revalidate with the server instead of reusing
// a URL cache entry whose age may exceed the UI freshness window.
request.cachePolicy = .reloadIgnoringLocalCacheData
request.setValue("no-cache", forHTTPHeaderField: "Cache-Control")
}
if let body {
request.httpBody = body
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
}
return request
}
private func send(_ request: URLRequest) async throws -> (data: Data, response: HTTPURLResponse) {
do {
return try await transport.data(for: request)
} catch let error as AccountAPIError {
throw error
} catch {
throw AccountAPIError.transport
}
}
private func validatedData(
_ result: (data: Data, response: HTTPURLResponse)
) throws -> Data {
guard (200..<300).contains(result.response.statusCode) else {
throw mapAPIError(statusCode: result.response.statusCode, data: result.data)
}
return result.data
}
private func mapAPIError(statusCode: Int, data: Data) -> AccountAPIError {
let payload = try? decoder.decode(APIErrorEnvelope.self, from: data).error
let legacyMessage = try? decoder.decode(LegacyAPIErrorEnvelope.self, from: data).error
let code = payload?.code ?? "http_error"
let message = payload?.message
?? legacyMessage
?? "The account service request failed."
switch code {
case "invalid_request":
return .invalidRequest(message)
case "unauthorized":
return .unauthorized(message)
case "refresh_token_reuse":
return .refreshTokenReuse
case "external_service_unavailable":
return .externalServiceUnavailable(message)
case "conflict":
return .conflict(message)
case "rate_limited":
return .rateLimited(message)
default:
return .server(statusCode: statusCode, code: code, message: message)
}
}
private func encode<Value: Encodable>(_ value: Value) throws -> Data {
do {
return try encoder.encode(value)
} catch {
throw AccountAPIError.invalidRequest("The request could not be encoded.")
}
}
private func decode<Value: Decodable>(_ type: Value.Type, from data: Data) throws -> Value {
do {
return try decoder.decode(type, from: data)
} catch {
throw AccountAPIError.decoding
}
}
}
extension Data {
func base64URLEncodedString() -> String {
base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
init?(base64URLEncoded value: String) {
guard value.range(of: #"^[A-Za-z0-9_-]*$"#, options: .regularExpression) != nil else {
return nil
}
let padding = String(repeating: "=", count: (4 - value.count % 4) % 4)
let base64 = value
.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
+ padding
self.init(base64Encoded: base64)
}
}
@@ -0,0 +1,308 @@
// 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 createReferralCode
case redeemReferral
case storeKitProducts
case storeKitTransactions(limit: Int, cursor: String?)
case submitStoreKitTransaction
case revokeGatewayGrant(UUID)
var method: String {
switch self {
case .createReferralCode, .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 .createReferralCode:
return "/v1/referrals/code"
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
}
}
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 protocol AccountSessionVault: Sendable {
func loadSession() async throws -> AccountSession?
func saveSession(_ session: AccountSession) async throws
func clearSession() async throws
}
public protocol AppAttestKeyStateStoring: Sendable {
func loadAppAttestKeyState() async throws -> AppAttestKeyState?
func saveAppAttestKeyState(_ state: AppAttestKeyState) async throws
func clearAppAttestKeyState() async throws
}
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
}
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
}
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
}
@@ -0,0 +1,310 @@
// DeviceIntegrity.swift
// OSGKeyboard · HostSupport
//
// DeviceCheck and App Attest adapters with server-bound canonical payloads.
import CryptoKit
import DeviceCheck
import Foundation
public protocol DeviceCheckTokenProviding: Sendable {
var isSupported: Bool { get }
func generateToken() async throws -> Data
}
public protocol AppAttestProviding: Sendable {
var isSupported: Bool { get }
func generateKey() async throws -> String
func attestKey(_ keyId: String, clientDataHash: Data) async throws -> Data
func generateAssertion(_ keyId: String, clientDataHash: Data) async throws -> Data
}
public final class SystemDeviceCheckProvider: DeviceCheckTokenProviding, @unchecked Sendable {
private let device: DCDevice
public var isSupported: Bool {
device.isSupported
}
public init(device: DCDevice = .current) {
self.device = device
}
public func generateToken() async throws -> Data {
try await withCheckedThrowingContinuation { continuation in
device.generateToken { data, error in
if let data {
continuation.resume(returning: data)
} else {
continuation.resume(throwing: error ?? AccountAPIError.integrityUnavailable)
}
}
}
}
}
public final class SystemAppAttestProvider: AppAttestProviding, @unchecked Sendable {
private let service: DCAppAttestService
public var isSupported: Bool {
service.isSupported
}
public init(service: DCAppAttestService = .shared) {
self.service = service
}
public func generateKey() async throws -> String {
try await withCheckedThrowingContinuation { continuation in
service.generateKey { keyId, error in
if let keyId {
continuation.resume(returning: keyId)
} else {
continuation.resume(throwing: error ?? AccountAPIError.integrityUnavailable)
}
}
}
}
public func attestKey(_ keyId: String, clientDataHash: Data) async throws -> Data {
try await withCheckedThrowingContinuation { continuation in
service.attestKey(keyId, clientDataHash: clientDataHash) { object, error in
if let object {
continuation.resume(returning: object)
} else {
continuation.resume(throwing: error ?? AccountAPIError.integrityUnavailable)
}
}
}
}
public func generateAssertion(_ keyId: String, clientDataHash: Data) async throws -> Data {
try await withCheckedThrowingContinuation { continuation in
service.generateAssertion(keyId, clientDataHash: clientDataHash) { assertion, error in
if let assertion {
continuation.resume(returning: assertion)
} else {
continuation.resume(throwing: error ?? AccountAPIError.integrityUnavailable)
}
}
}
}
}
public struct DeviceIntegrityEvidence: Equatable, Sendable {
public let deviceCheckToken: String?
public let appAttest: AppAttestAssertion?
public init(deviceCheckToken: String?, appAttest: AppAttestAssertion?) {
self.deviceCheckToken = deviceCheckToken
self.appAttest = appAttest
}
}
public enum AppAttestCanonicalPayload {
/// Matches `AppAttestCanonicalPayload.appleSignIn` on the account server.
/// The final `\n` is part of the signed UTF-8 payload.
public static func appleSignIn(
challenge: String,
credential: AppleSignInCredential,
rawNonce: String
) throws -> Data {
guard let challengeBytes = Data(base64URLEncoded: challenge) else {
throw AccountAPIError.invalidResponse
}
let canonicalChallenge = challengeBytes.base64URLEncodedString()
let payload = """
osg-app-attest-v1
purpose=apple-sign-in
challenge=\(canonicalChallenge)
identity_token_sha256=\(digest(credential.identityToken))
authorization_code_sha256=\(digest(credential.authorizationCode))
nonce_sha256=\(digest(rawNonce))
"""
guard let data = payload.data(using: .utf8) else {
throw AccountAPIError.invalidResponse
}
return data
}
static func challengeHash(_ challenge: String) throws -> Data {
guard let challengeBytes = Data(base64URLEncoded: challenge) else {
throw AccountAPIError.invalidResponse
}
return sha256(challengeBytes)
}
static func sha256(_ data: Data) -> Data {
Data(SHA256.hash(data: data))
}
private static func digest(_ value: String) -> String {
sha256(Data(value.utf8)).base64URLEncodedString()
}
}
public actor DeviceIntegrityCoordinator {
private let apiClient: AccountAPIClient
private let deviceCheck: any DeviceCheckTokenProviding
private let appAttest: any AppAttestProviding
private let keyStateStore: any AppAttestKeyStateStoring
public init(
apiClient: AccountAPIClient,
deviceCheck: any DeviceCheckTokenProviding = SystemDeviceCheckProvider(),
appAttest: any AppAttestProviding = SystemAppAttestProvider(),
keyStateStore: any AppAttestKeyStateStoring
) {
self.apiClient = apiClient
self.deviceCheck = deviceCheck
self.appAttest = appAttest
self.keyStateStore = keyStateStore
}
public func evidenceForAppleSignIn(
credential: AppleSignInCredential,
rawNonce: String
) async throws -> DeviceIntegrityEvidence {
async let deviceCheckToken = optionalDeviceCheckToken()
async let assertion = optionalAppleSignInAssertion(
credential: credential,
rawNonce: rawNonce
)
let evidence = await DeviceIntegrityEvidence(
deviceCheckToken: deviceCheckToken,
appAttest: assertion
)
if evidence.deviceCheckToken == nil,
evidence.appAttest == nil,
deviceCheck.isSupported || appAttest.isSupported {
throw AccountAPIError.integrityUnavailable
}
return evidence
}
public func clearLocalKeyState() async {
try? await keyStateStore.clearAppAttestKeyState()
}
private func optionalDeviceCheckToken() async -> String? {
try? await makeDeviceCheckToken()
}
private func optionalAppleSignInAssertion(
credential: AppleSignInCredential,
rawNonce: String
) async -> AppAttestAssertion? {
try? await makeAppleSignInAssertion(
credential: credential,
rawNonce: rawNonce
)
}
private func makeDeviceCheckToken() async throws -> String? {
guard deviceCheck.isSupported else { return nil }
do {
return try await deviceCheck.generateToken().base64EncodedString()
} catch {
throw AccountAPIError.integrityUnavailable
}
}
private func makeAppleSignInAssertion(
credential: AppleSignInCredential,
rawNonce: String,
allowsKeyRecovery: Bool = true
) async throws -> AppAttestAssertion? {
guard appAttest.isSupported else { return nil }
let keyId = try await registeredKeyId()
let challenge = try await apiClient.issueAppAttestChallenge(
purpose: .assertion,
keyId: keyId
)
let payload = try AppAttestCanonicalPayload.appleSignIn(
challenge: challenge.challenge,
credential: credential,
rawNonce: rawNonce
)
let assertion: Data
do {
assertion = try await appAttest.generateAssertion(
keyId,
clientDataHash: AppAttestCanonicalPayload.sha256(payload)
)
} catch where allowsKeyRecovery {
try? await keyStateStore.clearAppAttestKeyState()
return try await makeAppleSignInAssertion(
credential: credential,
rawNonce: rawNonce,
allowsKeyRecovery: false
)
} catch {
throw AccountAPIError.integrityUnavailable
}
return AppAttestAssertion(
keyId: keyId,
challengeId: challenge.challengeId,
challenge: challenge.challenge,
assertion: assertion.base64EncodedString()
)
}
private func registeredKeyId(
allowsKeyRecovery: Bool = true
) async throws -> String {
do {
return try await registerKeyIfNeeded()
} catch where allowsKeyRecovery {
try? await keyStateStore.clearAppAttestKeyState()
return try await registeredKeyId(allowsKeyRecovery: false)
}
}
private func registerKeyIfNeeded() async throws -> String {
var state: AppAttestKeyState
do {
if let existing = try await keyStateStore.loadAppAttestKeyState() {
state = existing
} else {
let keyId = try await appAttest.generateKey()
state = AppAttestKeyState(keyId: keyId, isRegistered: false)
try await keyStateStore.saveAppAttestKeyState(state)
}
} catch let error as AccountAPIError {
throw error
} catch {
throw AccountAPIError.integrityUnavailable
}
if state.isRegistered {
return state.keyId
}
let challenge = try await apiClient.issueAppAttestChallenge(
purpose: .attestation,
keyId: state.keyId
)
let attestationObject: Data
do {
attestationObject = try await appAttest.attestKey(
state.keyId,
clientDataHash: AppAttestCanonicalPayload.challengeHash(challenge.challenge)
)
} catch {
throw AccountAPIError.integrityUnavailable
}
try await apiClient.submitAttestation(
challenge: challenge,
keyId: state.keyId,
attestationObject: attestationObject
)
state = AppAttestKeyState(keyId: state.keyId, isRegistered: true)
do {
try await keyStateStore.saveAppAttestKeyState(state)
} catch {
throw AccountAPIError.secureStorage
}
return state.keyId
}
}
@@ -0,0 +1,149 @@
// HostPrivateAccountKeychain.swift
// OSGKeyboard · HostSupport
//
// Main-app-only storage for OSG account sessions and App Attest key state.
import Foundation
import Security
public struct HostPrivateAccountKeychainDescriptor: Equatable, Sendable {
public static let defaultService = "com.osgkeyboard.ios.account"
public static let hostBundleIdentifier = "com.osgkeyboard.ios"
public let service: String
public let accessGroup: String
public init(
service: String = Self.defaultService,
accessGroup: String
) throws {
let normalizedService = service.trimmingCharacters(in: .whitespacesAndNewlines)
let normalizedAccessGroup = accessGroup.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedService.isEmpty,
normalizedAccessGroup.hasSuffix(".\(Self.hostBundleIdentifier)"),
!normalizedAccessGroup.hasSuffix(".com.osgkeyboard.shared") else {
throw AccountAPIError.secureStorage
}
self.service = normalizedService
self.accessGroup = normalizedAccessGroup
}
/// The prefix is the signed App Identifier Prefix, including or excluding
/// its trailing period. It must come from host-app build configuration.
public static func hostApplication(appIdentifierPrefix: String) throws -> Self {
let prefix = appIdentifierPrefix.hasSuffix(".")
? appIdentifierPrefix
: "\(appIdentifierPrefix)."
return try Self(accessGroup: "\(prefix)\(hostBundleIdentifier)")
}
}
public actor HostPrivateAccountKeychain: AccountSessionVault, AppAttestKeyStateStoring {
private enum Account {
static let session = "account.session"
static let appAttestKeyState = "integrity.app-attest-key-state"
}
let descriptor: HostPrivateAccountKeychainDescriptor
private let encoder: JSONEncoder
private let decoder: JSONDecoder
public init(descriptor: HostPrivateAccountKeychainDescriptor) {
self.descriptor = descriptor
self.encoder = JSONEncoder()
self.decoder = JSONDecoder()
}
public func loadSession() async throws -> AccountSession? {
try read(AccountSession.self, account: Account.session)
}
public func saveSession(_ session: AccountSession) async throws {
try write(session, account: Account.session)
}
public func clearSession() async throws {
try delete(account: Account.session)
}
public func loadAppAttestKeyState() async throws -> AppAttestKeyState? {
try read(AppAttestKeyState.self, account: Account.appAttestKeyState)
}
public func saveAppAttestKeyState(_ state: AppAttestKeyState) async throws {
try write(state, account: Account.appAttestKeyState)
}
public func clearAppAttestKeyState() async throws {
try delete(account: Account.appAttestKeyState)
}
private func read<Value: Decodable>(_ type: Value.Type, account: String) throws -> Value? {
var query = baseQuery(account: account)
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
switch status {
case errSecSuccess:
guard let data = result as? Data else {
throw AccountAPIError.secureStorage
}
do {
return try decoder.decode(type, from: data)
} catch {
throw AccountAPIError.secureStorage
}
case errSecItemNotFound:
return nil
default:
throw AccountAPIError.secureStorage
}
}
private func write<Value: Encodable>(_ value: Value, account: String) throws {
let data: Data
do {
data = try encoder.encode(value)
} catch {
throw AccountAPIError.secureStorage
}
let query = baseQuery(account: account)
let updateStatus = SecItemUpdate(
query as CFDictionary,
[kSecValueData as String: data] as CFDictionary
)
if updateStatus == errSecSuccess {
return
}
guard updateStatus == errSecItemNotFound else {
throw AccountAPIError.secureStorage
}
var addQuery = query
addQuery[kSecValueData as String] = data
addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
guard SecItemAdd(addQuery as CFDictionary, nil) == errSecSuccess else {
throw AccountAPIError.secureStorage
}
}
private func delete(account: String) throws {
let status = SecItemDelete(baseQuery(account: account) as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw AccountAPIError.secureStorage
}
}
private func baseQuery(account: String) -> [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: descriptor.service,
kSecAttrAccount as String: account,
kSecAttrAccessGroup as String: descriptor.accessGroup,
kSecAttrSynchronizable as String: kCFBooleanFalse!
]
}
}
@@ -0,0 +1,182 @@
// SignInWithApple.swift
// OSGKeyboard · HostSupport
//
// Raw-nonce generation and an injectable AuthenticationServices adapter.
import AuthenticationServices
import CryptoKit
import Foundation
import Security
public protocol SecureRandomBytesGenerating: Sendable {
func bytes(count: Int) throws -> Data
}
public struct SystemSecureRandomBytesGenerator: SecureRandomBytesGenerating {
public init() {}
public func bytes(count: Int) throws -> Data {
guard count > 0 else {
throw AccountAPIError.appleAuthorization
}
var data = Data(count: count)
let status = data.withUnsafeMutableBytes { buffer in
SecRandomCopyBytes(kSecRandomDefault, count, buffer.baseAddress!)
}
guard status == errSecSuccess else {
throw AccountAPIError.appleAuthorization
}
return data
}
}
public protocol AppleSignInNonceGenerating: Sendable {
func makeNonce() throws -> AppleSignInNonce
}
public struct AppleSignInNonceGenerator: AppleSignInNonceGenerating {
private let random: any SecureRandomBytesGenerating
public init(random: any SecureRandomBytesGenerating = SystemSecureRandomBytesGenerator()) {
self.random = random
}
public func makeNonce() throws -> AppleSignInNonce {
let rawNonce = try random.bytes(count: 32).base64URLEncodedString()
let digest = SHA256.hash(data: Data(rawNonce.utf8))
let lowercaseHex = digest.map { String(format: "%02x", $0) }.joined()
return AppleSignInNonce(rawValue: rawNonce, sha256Hex: lowercaseHex)
}
}
@MainActor
public protocol AppleAuthorizationProviding: Sendable {
func authorize(nonceSHA256: String) async throws -> AppleSignInCredential
}
@MainActor
public final class AppleAuthorizationControllerProvider:
NSObject,
AppleAuthorizationProviding,
ASAuthorizationControllerDelegate,
ASAuthorizationControllerPresentationContextProviding,
@unchecked Sendable {
public typealias PresentationAnchorProvider = @MainActor @Sendable () -> ASPresentationAnchor
private let presentationAnchorProvider: PresentationAnchorProvider
private var continuation: CheckedContinuation<AppleSignInCredential, Error>?
public init(presentationAnchorProvider: @escaping PresentationAnchorProvider) {
self.presentationAnchorProvider = presentationAnchorProvider
}
public func authorize(nonceSHA256: String) async throws -> AppleSignInCredential {
guard continuation == nil, !nonceSHA256.isEmpty else {
throw AccountAPIError.appleAuthorization
}
let request = ASAuthorizationAppleIDProvider().createRequest()
request.nonce = nonceSHA256
return try await withCheckedThrowingContinuation { continuation in
self.continuation = continuation
let controller = ASAuthorizationController(authorizationRequests: [request])
controller.delegate = self
controller.presentationContextProvider = self
controller.performRequests()
}
}
public func authorizationController(
controller: ASAuthorizationController,
didCompleteWithAuthorization authorization: ASAuthorization
) {
guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential,
let identityTokenData = credential.identityToken,
let authorizationCodeData = credential.authorizationCode,
let identityToken = String(data: identityTokenData, encoding: .utf8),
let authorizationCode = String(data: authorizationCodeData, encoding: .utf8),
!identityToken.isEmpty,
!authorizationCode.isEmpty else {
complete(with: .failure(AccountAPIError.appleAuthorization))
return
}
complete(
with: .success(
AppleSignInCredential(
identityToken: identityToken,
authorizationCode: authorizationCode
)
)
)
}
public func authorizationController(
controller: ASAuthorizationController,
didCompleteWithError error: Error
) {
complete(with: .failure(AccountAPIError.appleAuthorization))
}
public func presentationAnchor(
for controller: ASAuthorizationController
) -> ASPresentationAnchor {
presentationAnchorProvider()
}
private func complete(with result: Result<AppleSignInCredential, Error>) {
let pending = continuation
continuation = nil
pending?.resume(with: result)
}
}
public actor AccountSignInCoordinator {
private let apiClient: AccountAPIClient
private let appleAuthorization: any AppleAuthorizationProviding
private let nonceGenerator: any AppleSignInNonceGenerating
private let integrity: DeviceIntegrityCoordinator
public init(
apiClient: AccountAPIClient,
appleAuthorization: any AppleAuthorizationProviding,
nonceGenerator: any AppleSignInNonceGenerating = AppleSignInNonceGenerator(),
integrity: DeviceIntegrityCoordinator
) {
self.apiClient = apiClient
self.appleAuthorization = appleAuthorization
self.nonceGenerator = nonceGenerator
self.integrity = integrity
}
@discardableResult
public func signIn() async throws -> AccountSession {
let nonce = try nonceGenerator.makeNonce()
let credential = try await appleAuthorization.authorize(nonceSHA256: nonce.sha256Hex)
let evidence = try await integrity.evidenceForAppleSignIn(
credential: credential,
rawNonce: nonce.rawValue
)
return try await apiClient.signInWithApple(
AppleSignInRequest(
identityToken: credential.identityToken,
authorizationCode: credential.authorizationCode,
nonce: nonce.rawValue,
deviceCheckToken: evidence.deviceCheckToken,
appAttest: evidence.appAttest
)
)
}
public func deleteAccount() async throws {
let nonce = try nonceGenerator.makeNonce()
let credential = try await appleAuthorization.authorize(nonceSHA256: nonce.sha256Hex)
try await apiClient.deleteAccount(
with: DeleteAccountRequest(
identityToken: credential.identityToken,
authorizationCode: credential.authorizationCode,
nonce: nonce.rawValue
)
)
}
}