fix(account): merge durable session refresh
CI / Validate manifests (push) Has been cancelled
CI / SwiftLint (push) Has been cancelled
CI / iOS / Extension (push) Has been cancelled
CI / macOS (push) Has been cancelled

Preserve refresh idempotency and Apple credential revocation handling alongside the consolidated app updates.
This commit is contained in:
Rocky
2026-08-25 16:44:51 +08:00
16 changed files with 438 additions and 33 deletions
@@ -3,6 +3,7 @@
//
// The single HTTP exit for account, auth, and integrity traffic.
import CryptoKit
import Foundation
#if canImport(OSGKeyboardShared)
import OSGKeyboardShared
@@ -120,6 +121,7 @@ public actor AccountAPIClient {
)
let session = try decode(APIDataEnvelope<AccountSession>.self, from: data).data
try await replaceSession(with: session)
try? await sessionVault.clearRefreshTransaction()
return session
}
@@ -402,7 +404,9 @@ public actor AccountAPIClient {
}
private func refreshSession(afterUnauthorizedAccessToken failedToken: String) async throws -> AccountSession {
guard let current = try await loadSessionIfNeeded() else {
// Refresh always re-reads Keychain so another client instance cannot
// rotate a stale in-memory session with a new operation identifier.
guard let current = try await reloadSessionFromVault() else {
throw AccountAPIError.sessionUnavailable
}
if current.accessToken != failedToken {
@@ -412,11 +416,22 @@ public actor AccountAPIClient {
return try await finishRefresh(refreshOperation)
}
let transaction: AccountRefreshTransaction
do {
transaction = try await sessionVault.beginRefreshTransaction(
refreshTokenDigest: Self.refreshTokenDigest(current.refreshToken)
)
} catch {
throw AccountAPIError.secureStorage
}
let operation = RefreshOperation(
id: UUID(),
id: transaction.operationId,
failedAccessToken: current.accessToken,
task: Task {
try await self.requestRefresh(using: current.refreshToken)
try await self.requestRefresh(
using: current.refreshToken,
operationId: transaction.operationId
)
}
)
refreshOperation = operation
@@ -426,7 +441,7 @@ public actor AccountAPIClient {
private func finishRefresh(_ operation: RefreshOperation) async throws -> AccountSession {
do {
let replacement = try await operation.task.value
guard let current = try await loadSessionIfNeeded() else {
guard let current = try await reloadSessionFromVault() else {
if refreshOperation?.id == operation.id {
refreshOperation = nil
}
@@ -447,7 +462,7 @@ public actor AccountAPIClient {
if refreshOperation?.id == operation.id {
refreshOperation = nil
}
if let current = try? await loadSessionIfNeeded(),
if let current = try? await reloadSessionFromVault(),
current.accessToken != operation.failedAccessToken {
return current
}
@@ -460,10 +475,18 @@ public actor AccountAPIClient {
}
}
private func requestRefresh(using refreshToken: String) async throws -> AccountSession {
private func requestRefresh(
using refreshToken: String,
operationId: UUID
) async throws -> AccountSession {
let request = try makeRequest(
endpoint: .refresh,
body: try encode(RefreshSessionRequest(refreshToken: refreshToken)),
body: try encode(
RefreshSessionRequest(
refreshToken: refreshToken,
refreshOperationId: operationId
)
),
accessToken: nil
)
let response = try await send(request)
@@ -494,15 +517,23 @@ public actor AccountAPIClient {
}
}
private func reloadSessionFromVault() async throws -> AccountSession? {
do {
let session = try await sessionVault.loadSession()
cachedSession = session
didLoadSession = true
return session
} 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
}
}
@@ -510,9 +541,18 @@ public actor AccountAPIClient {
private func clearSession() async throws {
cachedSession = nil
didLoadSession = true
var storageFailed = false
do {
try await sessionVault.clearSession()
} catch {
storageFailed = true
}
do {
try await sessionVault.clearRefreshTransaction()
} catch {
storageFailed = true
}
if storageFailed {
throw AccountAPIError.secureStorage
}
}
@@ -541,6 +581,12 @@ public actor AccountAPIClient {
invalidationContinuations[id] = nil
}
private static func refreshTokenDigest(_ refreshToken: String) -> String {
SHA256.hash(data: Data(refreshToken.utf8))
.map { String(format: "%02x", $0) }
.joined()
}
private func makeRequest(
endpoint: Endpoint,
body: Data?,
@@ -218,10 +218,24 @@ public struct AppAttestKeyState: Codable, Equatable, Sendable {
}
}
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 {
@@ -230,6 +244,12 @@ public protocol AppAttestKeyStateStoring: Sendable {
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
}
@@ -305,6 +325,7 @@ struct LegacyAPIErrorEnvelope: Codable, Sendable {
struct RefreshSessionRequest: Codable, Sendable {
let refreshToken: String
let refreshOperationId: UUID
}
struct DeleteAccountRequest: Codable, Sendable {
@@ -41,6 +41,7 @@ public struct HostPrivateAccountKeychainDescriptor: Equatable, Sendable {
public actor HostPrivateAccountKeychain:
AccountSessionVault,
AppleUserIdentifierStoring,
AppAttestKeyStateStoring,
OOBEInstallationIDStoring {
private static let logger = Logger(
@@ -50,6 +51,8 @@ public actor HostPrivateAccountKeychain:
private enum Account {
static let session = "account.session"
static let refreshTransaction = "account.refresh-transaction"
static let appleUserIdentifier = "account.apple-user-identifier"
static let appAttestKeyState = "integrity.app-attest-key-state"
static let oobeInstallationID = "oobe.installation-id"
}
@@ -85,6 +88,39 @@ public actor HostPrivateAccountKeychain:
try delete(account: Account.session)
}
public func beginRefreshTransaction(
refreshTokenDigest: String
) async throws -> AccountRefreshTransaction {
if let existing = try read(
AccountRefreshTransaction.self,
account: Account.refreshTransaction
), existing.refreshTokenDigest == refreshTokenDigest {
return existing
}
let transaction = AccountRefreshTransaction(
refreshTokenDigest: refreshTokenDigest,
operationId: UUID()
)
try write(transaction, account: Account.refreshTransaction)
return transaction
}
public func clearRefreshTransaction() async throws {
try delete(account: Account.refreshTransaction)
}
public func loadAppleUserIdentifier() async throws -> String? {
try read(String.self, account: Account.appleUserIdentifier)
}
public func saveAppleUserIdentifier(_ userIdentifier: String) async throws {
try write(userIdentifier, account: Account.appleUserIdentifier)
}
public func clearAppleUserIdentifier() async throws {
try delete(account: Account.appleUserIdentifier)
}
public func loadAppAttestKeyState() async throws -> AppAttestKeyState? {
try read(AppAttestKeyState.self, account: Account.appAttestKeyState)
}