fix(account): harden session refresh recovery

Persist refresh operation identity across failures and validate revoked Apple credentials so clients recover without unsafe token rotation.
This commit is contained in:
Rocky
2026-08-25 16:43:47 +08:00
parent 57844ce615
commit 309d743fd3
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?,