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:
@@ -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 {
|
||||
|
||||
@@ -40,10 +40,13 @@ public struct HostPrivateAccountKeychainDescriptor: Equatable, Sendable {
|
||||
|
||||
public actor HostPrivateAccountKeychain:
|
||||
AccountSessionVault,
|
||||
AppleUserIdentifierStoring,
|
||||
AppAttestKeyStateStoring,
|
||||
OOBEInstallationIDStoring {
|
||||
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"
|
||||
}
|
||||
@@ -70,6 +73,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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user