fix(account): merge durable session refresh
Preserve refresh idempotency and Apple credential revocation handling alongside the consolidated app updates.
This commit is contained in:
@@ -13,7 +13,12 @@ final class AccountAPIClientTests: XCTestCase {
|
||||
let transport = QueueAccountTransport([
|
||||
.init(statusCode: 200, body: try sessionEnvelopeData(expected))
|
||||
])
|
||||
let store = InMemoryAccountSecurityStore()
|
||||
let store = InMemoryAccountSecurityStore(
|
||||
refreshTransaction: AccountRefreshTransaction(
|
||||
refreshTokenDigest: "stale",
|
||||
operationId: UUID()
|
||||
)
|
||||
)
|
||||
let client = AccountAPIClient(
|
||||
baseURL: URL(string: "https://account.test")!,
|
||||
transport: transport,
|
||||
@@ -32,7 +37,9 @@ final class AccountAPIClientTests: XCTestCase {
|
||||
|
||||
XCTAssertEqual(result, expected)
|
||||
let stored = await store.session
|
||||
let refreshTransaction = await store.refreshTransaction
|
||||
XCTAssertEqual(stored, expected)
|
||||
XCTAssertNil(refreshTransaction)
|
||||
let requests = await transport.requests
|
||||
XCTAssertEqual(requests.single?.url?.path, "/v1/auth/apple")
|
||||
XCTAssertNil(requests.single?.value(forHTTPHeaderField: "Authorization"))
|
||||
@@ -156,6 +163,10 @@ final class AccountAPIClientTests: XCTestCase {
|
||||
requests.last?.value(forHTTPHeaderField: "Authorization"),
|
||||
"Bearer access-new"
|
||||
)
|
||||
let refreshRequest = try XCTUnwrap(
|
||||
requests.first { $0.url?.path == "/v1/auth/refresh" }
|
||||
)
|
||||
XCTAssertNotNil(try refreshOperationID(from: refreshRequest))
|
||||
let stored = await store.session
|
||||
let clearCount = await store.clearSessionCount
|
||||
XCTAssertNil(stored)
|
||||
@@ -191,7 +202,66 @@ final class AccountAPIClientTests: XCTestCase {
|
||||
|
||||
XCTAssertEqual(token, "access-fresh")
|
||||
let requests = await transport.requests
|
||||
XCTAssertEqual(requests.single?.url?.path, "/v1/auth/refresh")
|
||||
let refreshRequest = try XCTUnwrap(requests.single)
|
||||
XCTAssertEqual(refreshRequest.url?.path, "/v1/auth/refresh")
|
||||
XCTAssertNotNil(try refreshOperationID(from: refreshRequest))
|
||||
let transaction = await store.refreshTransaction
|
||||
XCTAssertNotNil(transaction)
|
||||
}
|
||||
|
||||
func testRefreshSaveFailureRetainsOldSessionAndReusesPersistedOperationID() async throws {
|
||||
let old = makeAccountSession(accessExpiry: 1_020)
|
||||
let replacement = makeAccountSession(
|
||||
accessToken: "access-recovered",
|
||||
refreshToken: "refresh-recovered"
|
||||
)
|
||||
let transport = QueueAccountTransport([
|
||||
.init(statusCode: 200, body: try sessionEnvelopeData(replacement)),
|
||||
.init(statusCode: 200, body: try sessionEnvelopeData(replacement))
|
||||
])
|
||||
let store = InMemoryAccountSecurityStore(
|
||||
session: old,
|
||||
sessionSaveFailures: 1
|
||||
)
|
||||
let firstClient = AccountAPIClient(
|
||||
baseURL: URL(string: "https://account.test")!,
|
||||
transport: transport,
|
||||
sessionVault: store,
|
||||
now: { Date(timeIntervalSince1970: 1_000) }
|
||||
)
|
||||
|
||||
do {
|
||||
_ = try await firstClient.accessTokenForAuthorizedRequest()
|
||||
XCTFail("Expected the first Keychain commit to fail")
|
||||
} catch let error as AccountAPIError {
|
||||
XCTAssertEqual(error, .secureStorage)
|
||||
}
|
||||
|
||||
let retainedSession = await store.session
|
||||
let retainedTransaction = await store.refreshTransaction
|
||||
let clearCount = await store.clearSessionCount
|
||||
XCTAssertEqual(retainedSession, old)
|
||||
XCTAssertNotNil(retainedTransaction)
|
||||
XCTAssertEqual(clearCount, 0)
|
||||
|
||||
let recreatedClient = AccountAPIClient(
|
||||
baseURL: URL(string: "https://account.test")!,
|
||||
transport: transport,
|
||||
sessionVault: store,
|
||||
now: { Date(timeIntervalSince1970: 1_000) }
|
||||
)
|
||||
let recoveredToken = try await recreatedClient.accessTokenForAuthorizedRequest()
|
||||
|
||||
XCTAssertEqual(recoveredToken, replacement.accessToken)
|
||||
let storedReplacement = await store.session
|
||||
XCTAssertEqual(storedReplacement, replacement)
|
||||
let requests = await transport.requests
|
||||
let refreshRequests = requests.filter { $0.url?.path == "/v1/auth/refresh" }
|
||||
XCTAssertEqual(refreshRequests.count, 2)
|
||||
let firstOperationID = try refreshOperationID(from: refreshRequests[0])
|
||||
let retryOperationID = try refreshOperationID(from: refreshRequests[1])
|
||||
XCTAssertEqual(firstOperationID, retryOperationID)
|
||||
XCTAssertEqual(firstOperationID, retainedTransaction?.operationId)
|
||||
}
|
||||
|
||||
func testRecreatedClientRestoresRetainedSessionAndRefreshesIt() async throws {
|
||||
@@ -271,6 +341,40 @@ final class AccountAPIClientTests: XCTestCase {
|
||||
XCTAssertEqual(stored, replacement)
|
||||
}
|
||||
|
||||
func testConcurrentClientInstancesSharePersistedRefreshOperationID() async throws {
|
||||
let old = makeAccountSession()
|
||||
let replacement = makeAccountSession(
|
||||
accessToken: "access-shared",
|
||||
refreshToken: "refresh-shared"
|
||||
)
|
||||
let transport = RefreshMergingTransport(replacementSession: replacement)
|
||||
let store = InMemoryAccountSecurityStore(session: old)
|
||||
let firstClient = AccountAPIClient(
|
||||
baseURL: URL(string: "https://account.test")!,
|
||||
transport: transport,
|
||||
sessionVault: store
|
||||
)
|
||||
let secondClient = AccountAPIClient(
|
||||
baseURL: URL(string: "https://account.test")!,
|
||||
transport: transport,
|
||||
sessionVault: store
|
||||
)
|
||||
|
||||
async let first = firstClient.account()
|
||||
async let second = secondClient.account()
|
||||
_ = try await (first, second)
|
||||
|
||||
let requests = await transport.requests
|
||||
let refreshRequests = requests.filter { $0.url?.path == "/v1/auth/refresh" }
|
||||
XCTAssertEqual(refreshRequests.count, 2)
|
||||
let operationIDs = try refreshRequests.map {
|
||||
try refreshOperationID(from: $0)
|
||||
}
|
||||
XCTAssertEqual(Set(operationIDs).count, 1)
|
||||
let stored = await store.session
|
||||
XCTAssertEqual(stored, replacement)
|
||||
}
|
||||
|
||||
func testRefreshTokenReuseClearsPrivateSession() async throws {
|
||||
let old = makeAccountSession()
|
||||
let replacement = makeAccountSession(accessToken: "unused", refreshToken: "unused")
|
||||
@@ -300,12 +404,20 @@ final class AccountAPIClientTests: XCTestCase {
|
||||
|
||||
let stored = await store.session
|
||||
let clearCount = await store.clearSessionCount
|
||||
let refreshTransaction = await store.refreshTransaction
|
||||
XCTAssertNil(stored)
|
||||
XCTAssertEqual(clearCount, 1)
|
||||
XCTAssertNil(refreshTransaction)
|
||||
}
|
||||
|
||||
func testLogoutClearsPrivateSessionWhenRevocationIsUnavailable() async throws {
|
||||
let store = InMemoryAccountSecurityStore(session: makeAccountSession())
|
||||
let store = InMemoryAccountSecurityStore(
|
||||
session: makeAccountSession(),
|
||||
refreshTransaction: AccountRefreshTransaction(
|
||||
refreshTokenDigest: "pending",
|
||||
operationId: UUID()
|
||||
)
|
||||
)
|
||||
let transport = QueueAccountTransport([])
|
||||
let client = AccountAPIClient(
|
||||
baseURL: URL(string: "https://account.test")!,
|
||||
@@ -317,9 +429,11 @@ final class AccountAPIClientTests: XCTestCase {
|
||||
|
||||
let stored = await store.session
|
||||
let clearCount = await store.clearSessionCount
|
||||
let refreshTransaction = await store.refreshTransaction
|
||||
let requests = await transport.requests
|
||||
XCTAssertNil(stored)
|
||||
XCTAssertEqual(clearCount, 1)
|
||||
XCTAssertNil(refreshTransaction)
|
||||
XCTAssertEqual(requests.single?.url?.path, "/v1/auth/logout")
|
||||
}
|
||||
|
||||
@@ -532,6 +646,16 @@ final class AccountAPIClientTests: XCTestCase {
|
||||
XCTAssertEqual(requests.count, 2)
|
||||
XCTAssertTrue(requests.allSatisfy { $0.url?.path == "/v1/account" })
|
||||
}
|
||||
|
||||
private func refreshOperationID(from request: URLRequest) throws -> UUID {
|
||||
let body = try XCTUnwrap(request.httpBody)
|
||||
let json = try XCTUnwrap(
|
||||
JSONSerialization.jsonObject(with: body) as? [String: Any]
|
||||
)
|
||||
XCTAssertEqual(json["refreshToken"] as? String, "refresh-old")
|
||||
let rawValue = try XCTUnwrap(json["refreshOperationId"] as? String)
|
||||
return try XCTUnwrap(UUID(uuidString: rawValue))
|
||||
}
|
||||
}
|
||||
|
||||
private extension Array {
|
||||
|
||||
@@ -540,6 +540,36 @@ final class AccountCenterViewModelTests: XCTestCase {
|
||||
XCTAssertEqual(managedGatewayClearCount, 1)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testRevokedAppleCredentialClearsSignedInAccountState() async {
|
||||
let account = AccountSession(
|
||||
accountID: UUID(),
|
||||
createdAtEpochSeconds: 1_700_000_000
|
||||
)
|
||||
let service = AccountServiceSpy(
|
||||
restoredSession: account,
|
||||
snapshot: makeSnapshot(account: account),
|
||||
appleCredentialState: .revoked
|
||||
)
|
||||
let coordinator = AccountSessionCoordinator(
|
||||
dependencies: AccountDependencies(
|
||||
sessionService: service,
|
||||
centerService: service
|
||||
),
|
||||
pendingReferralStore: InMemoryPendingReferralStore()
|
||||
)
|
||||
await coordinator.restoreIfNeeded()
|
||||
XCTAssertTrue(coordinator.isSignedIn)
|
||||
|
||||
await coordinator.validateAppleCredentialState()
|
||||
|
||||
XCTAssertEqual(coordinator.sessionPhase, .signedOut)
|
||||
XCTAssertEqual(coordinator.snapshotPhase, .idle)
|
||||
XCTAssertEqual(coordinator.operationErrorKey, "account.error.sessionExpired")
|
||||
let signOutCount = await service.signOutCount()
|
||||
XCTAssertEqual(signOutCount, 1)
|
||||
}
|
||||
|
||||
private func makeReferral(status: AccountReferralStatus) -> AccountReferral {
|
||||
AccountReferral(
|
||||
id: UUID(),
|
||||
@@ -616,6 +646,7 @@ private actor AccountServiceSpy: AccountSessionServicing, AccountCenterServicing
|
||||
private let signInDelayNanoseconds: UInt64
|
||||
private let accountLoadDelayNanoseconds: UInt64
|
||||
private var remainingRestoreFailures: Int
|
||||
private let storedAppleCredentialState: AccountAppleCredentialState
|
||||
private var redeemed: [String] = []
|
||||
private var centerLoadCount = 0
|
||||
private var logoutCount = 0
|
||||
@@ -634,7 +665,8 @@ private actor AccountServiceSpy: AccountSessionServicing, AccountCenterServicing
|
||||
signOutDelayNanoseconds: UInt64 = 0,
|
||||
signInDelayNanoseconds: UInt64 = 0,
|
||||
restoreFailureCount: Int = 0,
|
||||
accountLoadDelayNanoseconds: UInt64 = 0
|
||||
accountLoadDelayNanoseconds: UInt64 = 0,
|
||||
appleCredentialState: AccountAppleCredentialState = .unknown
|
||||
) {
|
||||
restored = restoredSession
|
||||
signedInAccount = signInSession ?? restoredSession
|
||||
@@ -646,6 +678,7 @@ private actor AccountServiceSpy: AccountSessionServicing, AccountCenterServicing
|
||||
self.signInDelayNanoseconds = signInDelayNanoseconds
|
||||
remainingRestoreFailures = restoreFailureCount
|
||||
self.accountLoadDelayNanoseconds = accountLoadDelayNanoseconds
|
||||
self.storedAppleCredentialState = appleCredentialState
|
||||
}
|
||||
|
||||
func restoreSession() async throws -> AccountSession? {
|
||||
@@ -676,6 +709,10 @@ private actor AccountServiceSpy: AccountSessionServicing, AccountCenterServicing
|
||||
accountDeleteCount += 1
|
||||
}
|
||||
|
||||
func appleCredentialState() async -> AccountAppleCredentialState {
|
||||
storedAppleCredentialState
|
||||
}
|
||||
|
||||
func clearManagedGateway() async {
|
||||
gatewayClearCount += 1
|
||||
}
|
||||
|
||||
@@ -8,12 +8,22 @@ import Foundation
|
||||
|
||||
actor InMemoryAccountSecurityStore: AccountSessionVault, AppAttestKeyStateStoring {
|
||||
private(set) var session: AccountSession?
|
||||
private(set) var refreshTransaction: AccountRefreshTransaction?
|
||||
private(set) var keyState: AppAttestKeyState?
|
||||
private(set) var clearSessionCount = 0
|
||||
private(set) var clearRefreshTransactionCount = 0
|
||||
private var remainingSessionSaveFailures: Int
|
||||
|
||||
init(session: AccountSession? = nil, keyState: AppAttestKeyState? = nil) {
|
||||
init(
|
||||
session: AccountSession? = nil,
|
||||
refreshTransaction: AccountRefreshTransaction? = nil,
|
||||
keyState: AppAttestKeyState? = nil,
|
||||
sessionSaveFailures: Int = 0
|
||||
) {
|
||||
self.session = session
|
||||
self.refreshTransaction = refreshTransaction
|
||||
self.keyState = keyState
|
||||
self.remainingSessionSaveFailures = sessionSaveFailures
|
||||
}
|
||||
|
||||
func loadSession() async throws -> AccountSession? {
|
||||
@@ -21,6 +31,10 @@ actor InMemoryAccountSecurityStore: AccountSessionVault, AppAttestKeyStateStorin
|
||||
}
|
||||
|
||||
func saveSession(_ session: AccountSession) async throws {
|
||||
if remainingSessionSaveFailures > 0 {
|
||||
remainingSessionSaveFailures -= 1
|
||||
throw AccountAPIError.secureStorage
|
||||
}
|
||||
self.session = session
|
||||
}
|
||||
|
||||
@@ -29,6 +43,26 @@ actor InMemoryAccountSecurityStore: AccountSessionVault, AppAttestKeyStateStorin
|
||||
clearSessionCount += 1
|
||||
}
|
||||
|
||||
func beginRefreshTransaction(
|
||||
refreshTokenDigest: String
|
||||
) async throws -> AccountRefreshTransaction {
|
||||
if let refreshTransaction,
|
||||
refreshTransaction.refreshTokenDigest == refreshTokenDigest {
|
||||
return refreshTransaction
|
||||
}
|
||||
let created = AccountRefreshTransaction(
|
||||
refreshTokenDigest: refreshTokenDigest,
|
||||
operationId: UUID()
|
||||
)
|
||||
refreshTransaction = created
|
||||
return created
|
||||
}
|
||||
|
||||
func clearRefreshTransaction() async throws {
|
||||
refreshTransaction = nil
|
||||
clearRefreshTransactionCount += 1
|
||||
}
|
||||
|
||||
func loadAppAttestKeyState() async throws -> AppAttestKeyState? {
|
||||
keyState
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user