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,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
)
)
}
}