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:
@@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- **Conversational skill replies**: make non-business replies sound like an ordinary person talking with friends or colleagues, allow one fitting emoji when emotion clearly calls for it, keep business replies naturally professional, and apply the active user-owned learned style only as a bounded wording-and-rhythm layer. / **口语化技能回复**:非商务回复采用普通人与朋友、好友或同事交谈的自然口吻,情绪明确时可合理使用一个表情;商务回复保持自然专业,当前用户自有的学习风格仅作为受限的用词与节奏层应用。
|
- **Conversational skill replies**: make non-business replies sound like an ordinary person talking with friends or colleagues, allow one fitting emoji when emotion clearly calls for it, keep business replies naturally professional, and apply the active user-owned learned style only as a bounded wording-and-rhythm layer. / **口语化技能回复**:非商务回复采用普通人与朋友、好友或同事交谈的自然口吻,情绪明确时可合理使用一个表情;商务回复保持自然专业,当前用户自有的学习风格仅作为受限的用词与节奏层应用。
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
- **Durable account sessions**: persist refresh operation identifiers before network rotation, recover safely after crashes or Keychain write failures, and sign out when Apple reports revoked credentials. / **可靠账号会话**:在网络轮换前持久化刷新操作标识,在崩溃或 Keychain 写入失败后安全恢复,并在 Apple 凭据被撤销时退出登录。
|
||||||
- **Repeatable OOBE practice**: scope each free feature to one use per short-lived guided session, so returning to OOBE starts a fresh four-page experience without misreporting consumed-page conflicts as weak network; also simplify lesson titles, add a voice sample to read aloud, and streamline the completion page. / **可重复的 OOBE 体验**:将每项免费功能限制为每个短期引导会话使用一次,让用户重新进入 OOBE 时可以重新体验四个页面,并避免把页面已完成冲突误报为弱网;同时精简各环节标题,加入语音朗读示例,并简化完成页。
|
- **Repeatable OOBE practice**: scope each free feature to one use per short-lived guided session, so returning to OOBE starts a fresh four-page experience without misreporting consumed-page conflicts as weak network; also simplify lesson titles, add a voice sample to read aloud, and streamline the completion page. / **可重复的 OOBE 体验**:将每项免费功能限制为每个短期引导会话使用一次,让用户重新进入 OOBE 时可以重新体验四个页面,并避免把页面已完成冲突误报为弱网;同时精简各环节标题,加入语音朗读示例,并简化完成页。
|
||||||
- **Managed hotword answers**: run keyboard hotwords with the same DeepSeek thinking policy as hold-to-ask AI, require online search for time-sensitive cards, preserve hotword usage attribution, and distinguish credit, provider, timeout, and gateway failures. / **积分热词回答**:键盘热词现在采用与长按问 AI 相同的 DeepSeek 思考策略,时效内容强制在线搜索,保留热词用量归因,并区分积分、服务商、超时与网关故障。
|
- **Managed hotword answers**: run keyboard hotwords with the same DeepSeek thinking policy as hold-to-ask AI, require online search for time-sensitive cards, preserve hotword usage attribution, and distinguish credit, provider, timeout, and gateway failures. / **积分热词回答**:键盘热词现在采用与长按问 AI 相同的 DeepSeek 思考策略,时效内容强制在线搜索,保留热词用量归因,并区分积分、服务商、超时与网关故障。
|
||||||
|
|
||||||
|
|||||||
@@ -130,7 +130,8 @@ private enum AccountAppleAuthorizationPayload {
|
|||||||
identityToken: identityToken,
|
identityToken: identityToken,
|
||||||
authorizationCode: authorizationCode,
|
authorizationCode: authorizationCode,
|
||||||
nonce: rawNonce,
|
nonce: rawNonce,
|
||||||
displayName: displayName(from: credential.fullName)
|
displayName: displayName(from: credential.fullName),
|
||||||
|
userIdentifier: credential.user
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -245,25 +245,35 @@ struct AppleAuthorizationPayload: Equatable, Sendable {
|
|||||||
let authorizationCode: String
|
let authorizationCode: String
|
||||||
let nonce: String
|
let nonce: String
|
||||||
let displayName: String?
|
let displayName: String?
|
||||||
|
let userIdentifier: String?
|
||||||
|
|
||||||
init(
|
init(
|
||||||
identityToken: String,
|
identityToken: String,
|
||||||
authorizationCode: String,
|
authorizationCode: String,
|
||||||
nonce: String,
|
nonce: String,
|
||||||
displayName: String? = nil
|
displayName: String? = nil,
|
||||||
|
userIdentifier: String? = nil
|
||||||
) {
|
) {
|
||||||
self.identityToken = identityToken
|
self.identityToken = identityToken
|
||||||
self.authorizationCode = authorizationCode
|
self.authorizationCode = authorizationCode
|
||||||
self.nonce = nonce
|
self.nonce = nonce
|
||||||
self.displayName = displayName
|
self.displayName = displayName
|
||||||
|
self.userIdentifier = userIdentifier
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum AccountAppleCredentialState: Equatable, Sendable {
|
||||||
|
case authorized
|
||||||
|
case revoked
|
||||||
|
case unknown
|
||||||
|
}
|
||||||
|
|
||||||
protocol AccountSessionServicing: Sendable {
|
protocol AccountSessionServicing: Sendable {
|
||||||
func restoreSession() async throws -> AccountSession?
|
func restoreSession() async throws -> AccountSession?
|
||||||
func signIn(with payload: AppleAuthorizationPayload) async throws -> AccountSession
|
func signIn(with payload: AppleAuthorizationPayload) async throws -> AccountSession
|
||||||
func signOut() async throws
|
func signOut() async throws
|
||||||
func deleteAccount(with payload: AppleAuthorizationPayload) async throws
|
func deleteAccount(with payload: AppleAuthorizationPayload) async throws
|
||||||
|
func appleCredentialState() async -> AccountAppleCredentialState
|
||||||
func prepareManagedGateway() async throws
|
func prepareManagedGateway() async throws
|
||||||
func clearManagedGateway() async
|
func clearManagedGateway() async
|
||||||
}
|
}
|
||||||
@@ -277,6 +287,10 @@ protocol AccountSessionEventSourcing: Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension AccountSessionServicing {
|
extension AccountSessionServicing {
|
||||||
|
func appleCredentialState() async -> AccountAppleCredentialState {
|
||||||
|
.unknown
|
||||||
|
}
|
||||||
|
|
||||||
func prepareManagedGateway() async throws {}
|
func prepareManagedGateway() async throws {}
|
||||||
func clearManagedGateway() async {}
|
func clearManagedGateway() async {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
// Main-actor state machine for optional account features. Local and BYOK
|
// Main-actor state machine for optional account features. Local and BYOK
|
||||||
// features never consult this coordinator and remain available when signed out.
|
// features never consult this coordinator and remain available when signed out.
|
||||||
|
|
||||||
|
import AuthenticationServices
|
||||||
import Combine
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
import OSGKeyboardShared
|
import OSGKeyboardShared
|
||||||
@@ -58,6 +59,7 @@ final class AccountSessionCoordinator: ObservableObject {
|
|||||||
private var accountRefreshTask: Task<Void, Never>?
|
private var accountRefreshTask: Task<Void, Never>?
|
||||||
private var accountRefreshRequestID: UUID?
|
private var accountRefreshRequestID: UUID?
|
||||||
private var sessionEventsTask: Task<Void, Never>?
|
private var sessionEventsTask: Task<Void, Never>?
|
||||||
|
private var appleCredentialRevocationCancellable: AnyCancellable?
|
||||||
|
|
||||||
init(
|
init(
|
||||||
dependencies: AccountDependencies,
|
dependencies: AccountDependencies,
|
||||||
@@ -100,6 +102,13 @@ final class AccountSessionCoordinator: ObservableObject {
|
|||||||
await self?.handleSessionEvent(event)
|
await self?.handleSessionEvent(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
appleCredentialRevocationCancellable = NotificationCenter.default
|
||||||
|
.publisher(for: ASAuthorizationAppleIDProvider.credentialRevokedNotification)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
await self?.handleAppleCredentialRevocation()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
@@ -149,6 +158,14 @@ final class AccountSessionCoordinator: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateAppleCredentialState() async {
|
||||||
|
guard isSignedIn,
|
||||||
|
await sessionService.appleCredentialState() == .revoked else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await handleAppleCredentialRevocation()
|
||||||
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func handleIncomingURL(_ url: URL) -> Bool {
|
func handleIncomingURL(_ url: URL) -> Bool {
|
||||||
guard let code = ReferralUniversalLink.code(from: url) else { return false }
|
guard let code = ReferralUniversalLink.code(from: url) else { return false }
|
||||||
@@ -402,18 +419,28 @@ final class AccountSessionCoordinator: ObservableObject {
|
|||||||
private func handleSessionEvent(_ event: AccountSessionEvent) async {
|
private func handleSessionEvent(_ event: AccountSessionEvent) async {
|
||||||
switch event {
|
switch event {
|
||||||
case .expired:
|
case .expired:
|
||||||
guard isSignedIn else { return }
|
await transitionToExpiredSession()
|
||||||
await sessionService.clearManagedGateway()
|
|
||||||
await onAccountSignedOut()
|
|
||||||
creditPurchases.endSession()
|
|
||||||
clearAccountRefreshState()
|
|
||||||
referralProfile.endSession()
|
|
||||||
sessionPhase = .signedOut
|
|
||||||
snapshotPhase = .idle
|
|
||||||
operationErrorKey = "account.error.sessionExpired"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func handleAppleCredentialRevocation() async {
|
||||||
|
guard isSignedIn else { return }
|
||||||
|
try? await sessionService.signOut()
|
||||||
|
await transitionToExpiredSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func transitionToExpiredSession() async {
|
||||||
|
guard isSignedIn else { return }
|
||||||
|
await sessionService.clearManagedGateway()
|
||||||
|
await onAccountSignedOut()
|
||||||
|
creditPurchases.endSession()
|
||||||
|
clearAccountRefreshState()
|
||||||
|
referralProfile.endSession()
|
||||||
|
sessionPhase = .signedOut
|
||||||
|
snapshotPhase = .idle
|
||||||
|
operationErrorKey = "account.error.sessionExpired"
|
||||||
|
}
|
||||||
|
|
||||||
private func redeemPendingReferralIfNeeded() async {
|
private func redeemPendingReferralIfNeeded() async {
|
||||||
guard isSignedIn, let code = pendingReferralStore.code else { return }
|
guard isSignedIn, let code = pendingReferralStore.code else { return }
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
// session credentials never enter App Group storage; only a non-secret local
|
// session credentials never enter App Group storage; only a non-secret local
|
||||||
// eligibility marker and scope-limited gateway grants are shared.
|
// eligibility marker and scope-limited gateway grants are shared.
|
||||||
|
|
||||||
|
import AuthenticationServices
|
||||||
import Foundation
|
import Foundation
|
||||||
import OSGKeyboardHostSupport
|
import OSGKeyboardHostSupport
|
||||||
import OSGKeyboardShared
|
import OSGKeyboardShared
|
||||||
@@ -37,7 +38,8 @@ enum LiveAccountDependencyFactory {
|
|||||||
integrity: integrity,
|
integrity: integrity,
|
||||||
grants: grants,
|
grants: grants,
|
||||||
grantStore: grantStore,
|
grantStore: grantStore,
|
||||||
configuration: AppGroupStore()
|
configuration: AppGroupStore(),
|
||||||
|
appleUserIdentifiers: sessionVault
|
||||||
)
|
)
|
||||||
return AccountDependencies(
|
return AccountDependencies(
|
||||||
sessionService: service,
|
sessionService: service,
|
||||||
@@ -164,6 +166,35 @@ actor LiveReferralProfileService: ReferralProfileServicing {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private protocol AppleCredentialStateChecking: Sendable {
|
||||||
|
func state(for userIdentifier: String) async -> AccountAppleCredentialState
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SystemAppleCredentialStateChecker: AppleCredentialStateChecking {
|
||||||
|
func state(for userIdentifier: String) async -> AccountAppleCredentialState {
|
||||||
|
await withCheckedContinuation { continuation in
|
||||||
|
ASAuthorizationAppleIDProvider().getCredentialState(
|
||||||
|
forUserID: userIdentifier
|
||||||
|
) { state, error in
|
||||||
|
guard error == nil else {
|
||||||
|
continuation.resume(returning: .unknown)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch state {
|
||||||
|
case .authorized:
|
||||||
|
continuation.resume(returning: .authorized)
|
||||||
|
case .revoked, .notFound:
|
||||||
|
continuation.resume(returning: .revoked)
|
||||||
|
case .transferred:
|
||||||
|
continuation.resume(returning: .unknown)
|
||||||
|
@unknown default:
|
||||||
|
continuation.resume(returning: .unknown)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private actor LiveAccountService:
|
private actor LiveAccountService:
|
||||||
AccountSessionServicing,
|
AccountSessionServicing,
|
||||||
AccountSessionEventSourcing,
|
AccountSessionEventSourcing,
|
||||||
@@ -174,6 +205,8 @@ private actor LiveAccountService:
|
|||||||
private let grants: GatewayGrantCoordinator
|
private let grants: GatewayGrantCoordinator
|
||||||
private let grantStore: any GatewayGrantCredentialStore
|
private let grantStore: any GatewayGrantCredentialStore
|
||||||
private let configuration: AppGroupStore
|
private let configuration: AppGroupStore
|
||||||
|
private let appleUserIdentifiers: any AppleUserIdentifierStoring
|
||||||
|
private let appleCredentialChecker: any AppleCredentialStateChecking
|
||||||
private let decoder = JSONDecoder()
|
private let decoder = JSONDecoder()
|
||||||
private let encoder = JSONEncoder()
|
private let encoder = JSONEncoder()
|
||||||
|
|
||||||
@@ -182,7 +215,10 @@ private actor LiveAccountService:
|
|||||||
integrity: DeviceIntegrityCoordinator,
|
integrity: DeviceIntegrityCoordinator,
|
||||||
grants: GatewayGrantCoordinator,
|
grants: GatewayGrantCoordinator,
|
||||||
grantStore: any GatewayGrantCredentialStore,
|
grantStore: any GatewayGrantCredentialStore,
|
||||||
configuration: AppGroupStore
|
configuration: AppGroupStore,
|
||||||
|
appleUserIdentifiers: any AppleUserIdentifierStoring,
|
||||||
|
appleCredentialChecker: any AppleCredentialStateChecking =
|
||||||
|
SystemAppleCredentialStateChecker()
|
||||||
) {
|
) {
|
||||||
self.apiClient = apiClient
|
self.apiClient = apiClient
|
||||||
accountCenterLoader = AccountCenterSnapshotLoader(apiClient: apiClient)
|
accountCenterLoader = AccountCenterSnapshotLoader(apiClient: apiClient)
|
||||||
@@ -190,6 +226,8 @@ private actor LiveAccountService:
|
|||||||
self.grants = grants
|
self.grants = grants
|
||||||
self.grantStore = grantStore
|
self.grantStore = grantStore
|
||||||
self.configuration = configuration
|
self.configuration = configuration
|
||||||
|
self.appleUserIdentifiers = appleUserIdentifiers
|
||||||
|
self.appleCredentialChecker = appleCredentialChecker
|
||||||
}
|
}
|
||||||
|
|
||||||
func events() async -> AsyncStream<AccountSessionEvent> {
|
func events() async -> AsyncStream<AccountSessionEvent> {
|
||||||
@@ -213,6 +251,7 @@ private actor LiveAccountService:
|
|||||||
|
|
||||||
func restoreSession() async throws -> AccountSession? {
|
func restoreSession() async throws -> AccountSession? {
|
||||||
guard let cachedSession = try await apiClient.currentSession() else {
|
guard let cachedSession = try await apiClient.currentSession() else {
|
||||||
|
try? await appleUserIdentifiers.clearAppleUserIdentifier()
|
||||||
await invalidateManagedGatewaySession()
|
await invalidateManagedGatewaySession()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -222,6 +261,7 @@ private actor LiveAccountService:
|
|||||||
} catch let error as AccountAPIError {
|
} catch let error as AccountAPIError {
|
||||||
switch error {
|
switch error {
|
||||||
case .sessionUnavailable, .unauthorized, .refreshTokenReuse:
|
case .sessionUnavailable, .unauthorized, .refreshTokenReuse:
|
||||||
|
try? await appleUserIdentifiers.clearAppleUserIdentifier()
|
||||||
await invalidateManagedGatewaySession()
|
await invalidateManagedGatewaySession()
|
||||||
return nil
|
return nil
|
||||||
default:
|
default:
|
||||||
@@ -282,6 +322,10 @@ private actor LiveAccountService:
|
|||||||
)
|
)
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
if let userIdentifier = payload.userIdentifier,
|
||||||
|
!userIdentifier.isEmpty {
|
||||||
|
try? await appleUserIdentifiers.saveAppleUserIdentifier(userIdentifier)
|
||||||
|
}
|
||||||
let account: OSGAccount
|
let account: OSGAccount
|
||||||
do {
|
do {
|
||||||
account = try await apiClient.account()
|
account = try await apiClient.account()
|
||||||
@@ -310,9 +354,11 @@ private actor LiveAccountService:
|
|||||||
try await apiClient.logout()
|
try await apiClient.logout()
|
||||||
} catch {
|
} catch {
|
||||||
try? await grants.clearGrant()
|
try? await grants.clearGrant()
|
||||||
|
try? await appleUserIdentifiers.clearAppleUserIdentifier()
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
try? await grants.clearGrant()
|
try? await grants.clearGrant()
|
||||||
|
try? await appleUserIdentifiers.clearAppleUserIdentifier()
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteAccount(with payload: AppleAuthorizationPayload) async throws {
|
func deleteAccount(with payload: AppleAuthorizationPayload) async throws {
|
||||||
@@ -322,11 +368,20 @@ private actor LiveAccountService:
|
|||||||
nonce: payload.nonce
|
nonce: payload.nonce
|
||||||
)
|
)
|
||||||
try? await grants.clearGrant()
|
try? await grants.clearGrant()
|
||||||
|
try? await appleUserIdentifiers.clearAppleUserIdentifier()
|
||||||
await integrity.clearLocalKeyState()
|
await integrity.clearLocalKeyState()
|
||||||
configuration.setManagedGatewayAccountSessionAvailable(false)
|
configuration.setManagedGatewayAccountSessionAvailable(false)
|
||||||
configuration.setCredentialSource(.byok)
|
configuration.setCredentialSource(.byok)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func appleCredentialState() async -> AccountAppleCredentialState {
|
||||||
|
guard let userIdentifier = try? await appleUserIdentifiers.loadAppleUserIdentifier(),
|
||||||
|
!userIdentifier.isEmpty else {
|
||||||
|
return .unknown
|
||||||
|
}
|
||||||
|
return await appleCredentialChecker.state(for: userIdentifier)
|
||||||
|
}
|
||||||
|
|
||||||
func prepareManagedGateway() async throws {
|
func prepareManagedGateway() async throws {
|
||||||
guard try await apiClient.currentSession() != nil else {
|
guard try await apiClient.currentSession() != nil else {
|
||||||
await invalidateManagedGatewaySession()
|
await invalidateManagedGatewaySession()
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ struct HomeView: View {
|
|||||||
@Environment(\.themePalette) private var palette: ThemePalette
|
@Environment(\.themePalette) private var palette: ThemePalette
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
@ObservedObject private var config = ProviderConfig.shared
|
@ObservedObject private var config = ProviderConfig.shared
|
||||||
@ObservedObject private var speechHistory = SpeechHistoryStore.shared
|
@ObservedObject private var speechHistory = SpeechHistoryStore.shared
|
||||||
@@ -579,15 +580,17 @@ struct HomeView: View {
|
|||||||
|
|
||||||
// MARK: - Header
|
// MARK: - Header
|
||||||
|
|
||||||
// logo 尺寸保持 144:41 比例;小屏进一步缩小,给下方内容让空间。
|
// Logo 保持 144:41 比例,并使用随系统深浅色切换的黑白单色。
|
||||||
private func logoHeader(compact: Bool) -> some View {
|
private func logoHeader(compact: Bool) -> some View {
|
||||||
let logoWidth: CGFloat = compact ? 104 : 124
|
let logoWidth: CGFloat = compact ? 88 : 108
|
||||||
let logoHeight = logoWidth * (41.0 / 144.0)
|
let logoHeight = logoWidth * (41.0 / 144.0)
|
||||||
return VStack(spacing: Spacing.xxl) {
|
return VStack(spacing: Spacing.xxl) {
|
||||||
Image("osglogo")
|
Image("osglogo")
|
||||||
.resizable()
|
.resizable()
|
||||||
|
.renderingMode(.template)
|
||||||
.scaledToFit()
|
.scaledToFit()
|
||||||
.frame(width: logoWidth, height: logoHeight)
|
.frame(width: logoWidth, height: logoHeight)
|
||||||
|
.foregroundStyle(colorScheme == .dark ? Color.white : Color.black)
|
||||||
.accessibilityHidden(true)
|
.accessibilityHidden(true)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
|
|||||||
@@ -151,6 +151,9 @@ struct MainAppRoot: View {
|
|||||||
}
|
}
|
||||||
.task {
|
.task {
|
||||||
await accountSession.restoreIfNeeded()
|
await accountSession.restoreIfNeeded()
|
||||||
|
if scenePhase == .active {
|
||||||
|
await accountSession.validateAppleCredentialState()
|
||||||
|
}
|
||||||
config.reloadFromPersistedStorage()
|
config.reloadFromPersistedStorage()
|
||||||
}
|
}
|
||||||
.onReceive(NotificationCenter.default.publisher(for: .settingsDidSyncFromCloud)) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: .settingsDidSyncFromCloud)) { _ in
|
||||||
@@ -209,6 +212,7 @@ struct MainAppRoot: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Task {
|
Task {
|
||||||
|
await accountSession.validateAppleCredentialState()
|
||||||
await AppCloudSync.shared.pullAllIfEnabled()
|
await AppCloudSync.shared.pullAllIfEnabled()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -828,7 +828,7 @@
|
|||||||
"account.loading.session" = "Checking account status…";
|
"account.loading.session" = "Checking account status…";
|
||||||
"account.loading.center" = "Loading account…";
|
"account.loading.center" = "Loading account…";
|
||||||
"account.signedOut.title" = "Your OSG account";
|
"account.signedOut.title" = "Your OSG account";
|
||||||
"account.signedOut.body" = "Sign in to use managed credits and referrals.";
|
"account.signedOut.body" = "Register and invite friends to earn credits.";
|
||||||
"account.signedOut.localUnaffected" = "On-device features and your own API keys work without an account.";
|
"account.signedOut.localUnaffected" = "On-device features and your own API keys work without an account.";
|
||||||
"account.signIn.apple" = "Sign in with Apple";
|
"account.signIn.apple" = "Sign in with Apple";
|
||||||
"account.signIn.hint" = "Creates or opens your optional OSG account.";
|
"account.signIn.hint" = "Creates or opens your optional OSG account.";
|
||||||
|
|||||||
@@ -827,7 +827,7 @@
|
|||||||
"account.loading.session" = "正在检查账号状态…";
|
"account.loading.session" = "正在检查账号状态…";
|
||||||
"account.loading.center" = "正在加载账号…";
|
"account.loading.center" = "正在加载账号…";
|
||||||
"account.signedOut.title" = "OSG 账号";
|
"account.signedOut.title" = "OSG 账号";
|
||||||
"account.signedOut.body" = "登录后可使用托管积分与邀请功能。";
|
"account.signedOut.body" = "注册和邀请好友,都可获赠积分";
|
||||||
"account.signedOut.localUnaffected" = "本地功能与自有 API Key 无需账号也可继续使用。";
|
"account.signedOut.localUnaffected" = "本地功能与自有 API Key 无需账号也可继续使用。";
|
||||||
"account.signIn.apple" = "通过 Apple 登录";
|
"account.signIn.apple" = "通过 Apple 登录";
|
||||||
"account.signIn.hint" = "创建或打开可选的 OSG 账号。";
|
"account.signIn.hint" = "创建或打开可选的 OSG 账号。";
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
//
|
//
|
||||||
// The single HTTP exit for account, auth, and integrity traffic.
|
// The single HTTP exit for account, auth, and integrity traffic.
|
||||||
|
|
||||||
|
import CryptoKit
|
||||||
import Foundation
|
import Foundation
|
||||||
#if canImport(OSGKeyboardShared)
|
#if canImport(OSGKeyboardShared)
|
||||||
import OSGKeyboardShared
|
import OSGKeyboardShared
|
||||||
@@ -120,6 +121,7 @@ public actor AccountAPIClient {
|
|||||||
)
|
)
|
||||||
let session = try decode(APIDataEnvelope<AccountSession>.self, from: data).data
|
let session = try decode(APIDataEnvelope<AccountSession>.self, from: data).data
|
||||||
try await replaceSession(with: session)
|
try await replaceSession(with: session)
|
||||||
|
try? await sessionVault.clearRefreshTransaction()
|
||||||
return session
|
return session
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,7 +404,9 @@ public actor AccountAPIClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func refreshSession(afterUnauthorizedAccessToken failedToken: String) async throws -> AccountSession {
|
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
|
throw AccountAPIError.sessionUnavailable
|
||||||
}
|
}
|
||||||
if current.accessToken != failedToken {
|
if current.accessToken != failedToken {
|
||||||
@@ -412,11 +416,22 @@ public actor AccountAPIClient {
|
|||||||
return try await finishRefresh(refreshOperation)
|
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(
|
let operation = RefreshOperation(
|
||||||
id: UUID(),
|
id: transaction.operationId,
|
||||||
failedAccessToken: current.accessToken,
|
failedAccessToken: current.accessToken,
|
||||||
task: Task {
|
task: Task {
|
||||||
try await self.requestRefresh(using: current.refreshToken)
|
try await self.requestRefresh(
|
||||||
|
using: current.refreshToken,
|
||||||
|
operationId: transaction.operationId
|
||||||
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
refreshOperation = operation
|
refreshOperation = operation
|
||||||
@@ -426,7 +441,7 @@ public actor AccountAPIClient {
|
|||||||
private func finishRefresh(_ operation: RefreshOperation) async throws -> AccountSession {
|
private func finishRefresh(_ operation: RefreshOperation) async throws -> AccountSession {
|
||||||
do {
|
do {
|
||||||
let replacement = try await operation.task.value
|
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 {
|
if refreshOperation?.id == operation.id {
|
||||||
refreshOperation = nil
|
refreshOperation = nil
|
||||||
}
|
}
|
||||||
@@ -447,7 +462,7 @@ public actor AccountAPIClient {
|
|||||||
if refreshOperation?.id == operation.id {
|
if refreshOperation?.id == operation.id {
|
||||||
refreshOperation = nil
|
refreshOperation = nil
|
||||||
}
|
}
|
||||||
if let current = try? await loadSessionIfNeeded(),
|
if let current = try? await reloadSessionFromVault(),
|
||||||
current.accessToken != operation.failedAccessToken {
|
current.accessToken != operation.failedAccessToken {
|
||||||
return current
|
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(
|
let request = try makeRequest(
|
||||||
endpoint: .refresh,
|
endpoint: .refresh,
|
||||||
body: try encode(RefreshSessionRequest(refreshToken: refreshToken)),
|
body: try encode(
|
||||||
|
RefreshSessionRequest(
|
||||||
|
refreshToken: refreshToken,
|
||||||
|
refreshOperationId: operationId
|
||||||
|
)
|
||||||
|
),
|
||||||
accessToken: nil
|
accessToken: nil
|
||||||
)
|
)
|
||||||
let response = try await send(request)
|
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 {
|
private func replaceSession(with session: AccountSession) async throws {
|
||||||
do {
|
do {
|
||||||
try await sessionVault.saveSession(session)
|
try await sessionVault.saveSession(session)
|
||||||
cachedSession = session
|
cachedSession = session
|
||||||
didLoadSession = true
|
didLoadSession = true
|
||||||
} catch {
|
} catch {
|
||||||
cachedSession = nil
|
|
||||||
didLoadSession = true
|
|
||||||
try? await sessionVault.clearSession()
|
|
||||||
throw AccountAPIError.secureStorage
|
throw AccountAPIError.secureStorage
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -510,9 +541,18 @@ public actor AccountAPIClient {
|
|||||||
private func clearSession() async throws {
|
private func clearSession() async throws {
|
||||||
cachedSession = nil
|
cachedSession = nil
|
||||||
didLoadSession = true
|
didLoadSession = true
|
||||||
|
var storageFailed = false
|
||||||
do {
|
do {
|
||||||
try await sessionVault.clearSession()
|
try await sessionVault.clearSession()
|
||||||
} catch {
|
} catch {
|
||||||
|
storageFailed = true
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
try await sessionVault.clearRefreshTransaction()
|
||||||
|
} catch {
|
||||||
|
storageFailed = true
|
||||||
|
}
|
||||||
|
if storageFailed {
|
||||||
throw AccountAPIError.secureStorage
|
throw AccountAPIError.secureStorage
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -541,6 +581,12 @@ public actor AccountAPIClient {
|
|||||||
invalidationContinuations[id] = nil
|
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(
|
private func makeRequest(
|
||||||
endpoint: Endpoint,
|
endpoint: Endpoint,
|
||||||
body: Data?,
|
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 {
|
public protocol AccountSessionVault: Sendable {
|
||||||
func loadSession() async throws -> AccountSession?
|
func loadSession() async throws -> AccountSession?
|
||||||
func saveSession(_ session: AccountSession) async throws
|
func saveSession(_ session: AccountSession) async throws
|
||||||
func clearSession() async throws
|
func clearSession() async throws
|
||||||
|
func beginRefreshTransaction(
|
||||||
|
refreshTokenDigest: String
|
||||||
|
) async throws -> AccountRefreshTransaction
|
||||||
|
func clearRefreshTransaction() async throws
|
||||||
}
|
}
|
||||||
|
|
||||||
public protocol AppAttestKeyStateStoring: Sendable {
|
public protocol AppAttestKeyStateStoring: Sendable {
|
||||||
@@ -230,6 +244,12 @@ public protocol AppAttestKeyStateStoring: Sendable {
|
|||||||
func clearAppAttestKeyState() async throws
|
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 {
|
public protocol OOBEInstallationIDStoring: Sendable {
|
||||||
func oobeInstallationID() async throws -> UUID
|
func oobeInstallationID() async throws -> UUID
|
||||||
}
|
}
|
||||||
@@ -305,6 +325,7 @@ struct LegacyAPIErrorEnvelope: Codable, Sendable {
|
|||||||
|
|
||||||
struct RefreshSessionRequest: Codable, Sendable {
|
struct RefreshSessionRequest: Codable, Sendable {
|
||||||
let refreshToken: String
|
let refreshToken: String
|
||||||
|
let refreshOperationId: UUID
|
||||||
}
|
}
|
||||||
|
|
||||||
struct DeleteAccountRequest: Codable, Sendable {
|
struct DeleteAccountRequest: Codable, Sendable {
|
||||||
|
|||||||
@@ -40,10 +40,13 @@ public struct HostPrivateAccountKeychainDescriptor: Equatable, Sendable {
|
|||||||
|
|
||||||
public actor HostPrivateAccountKeychain:
|
public actor HostPrivateAccountKeychain:
|
||||||
AccountSessionVault,
|
AccountSessionVault,
|
||||||
|
AppleUserIdentifierStoring,
|
||||||
AppAttestKeyStateStoring,
|
AppAttestKeyStateStoring,
|
||||||
OOBEInstallationIDStoring {
|
OOBEInstallationIDStoring {
|
||||||
private enum Account {
|
private enum Account {
|
||||||
static let session = "account.session"
|
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 appAttestKeyState = "integrity.app-attest-key-state"
|
||||||
static let oobeInstallationID = "oobe.installation-id"
|
static let oobeInstallationID = "oobe.installation-id"
|
||||||
}
|
}
|
||||||
@@ -70,6 +73,39 @@ public actor HostPrivateAccountKeychain:
|
|||||||
try delete(account: Account.session)
|
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? {
|
public func loadAppAttestKeyState() async throws -> AppAttestKeyState? {
|
||||||
try read(AppAttestKeyState.self, account: Account.appAttestKeyState)
|
try read(AppAttestKeyState.self, account: Account.appAttestKeyState)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,12 @@ final class AccountAPIClientTests: XCTestCase {
|
|||||||
let transport = QueueAccountTransport([
|
let transport = QueueAccountTransport([
|
||||||
.init(statusCode: 200, body: try sessionEnvelopeData(expected))
|
.init(statusCode: 200, body: try sessionEnvelopeData(expected))
|
||||||
])
|
])
|
||||||
let store = InMemoryAccountSecurityStore()
|
let store = InMemoryAccountSecurityStore(
|
||||||
|
refreshTransaction: AccountRefreshTransaction(
|
||||||
|
refreshTokenDigest: "stale",
|
||||||
|
operationId: UUID()
|
||||||
|
)
|
||||||
|
)
|
||||||
let client = AccountAPIClient(
|
let client = AccountAPIClient(
|
||||||
baseURL: URL(string: "https://account.test")!,
|
baseURL: URL(string: "https://account.test")!,
|
||||||
transport: transport,
|
transport: transport,
|
||||||
@@ -32,7 +37,9 @@ final class AccountAPIClientTests: XCTestCase {
|
|||||||
|
|
||||||
XCTAssertEqual(result, expected)
|
XCTAssertEqual(result, expected)
|
||||||
let stored = await store.session
|
let stored = await store.session
|
||||||
|
let refreshTransaction = await store.refreshTransaction
|
||||||
XCTAssertEqual(stored, expected)
|
XCTAssertEqual(stored, expected)
|
||||||
|
XCTAssertNil(refreshTransaction)
|
||||||
let requests = await transport.requests
|
let requests = await transport.requests
|
||||||
XCTAssertEqual(requests.single?.url?.path, "/v1/auth/apple")
|
XCTAssertEqual(requests.single?.url?.path, "/v1/auth/apple")
|
||||||
XCTAssertNil(requests.single?.value(forHTTPHeaderField: "Authorization"))
|
XCTAssertNil(requests.single?.value(forHTTPHeaderField: "Authorization"))
|
||||||
@@ -156,6 +163,10 @@ final class AccountAPIClientTests: XCTestCase {
|
|||||||
requests.last?.value(forHTTPHeaderField: "Authorization"),
|
requests.last?.value(forHTTPHeaderField: "Authorization"),
|
||||||
"Bearer access-new"
|
"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 stored = await store.session
|
||||||
let clearCount = await store.clearSessionCount
|
let clearCount = await store.clearSessionCount
|
||||||
XCTAssertNil(stored)
|
XCTAssertNil(stored)
|
||||||
@@ -191,7 +202,66 @@ final class AccountAPIClientTests: XCTestCase {
|
|||||||
|
|
||||||
XCTAssertEqual(token, "access-fresh")
|
XCTAssertEqual(token, "access-fresh")
|
||||||
let requests = await transport.requests
|
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 testConcurrentUnauthorizedRequestsMergeRefreshRotation() async throws {
|
func testConcurrentUnauthorizedRequestsMergeRefreshRotation() async throws {
|
||||||
@@ -226,6 +296,40 @@ final class AccountAPIClientTests: XCTestCase {
|
|||||||
XCTAssertEqual(stored, replacement)
|
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 {
|
func testRefreshTokenReuseClearsPrivateSession() async throws {
|
||||||
let old = makeAccountSession()
|
let old = makeAccountSession()
|
||||||
let replacement = makeAccountSession(accessToken: "unused", refreshToken: "unused")
|
let replacement = makeAccountSession(accessToken: "unused", refreshToken: "unused")
|
||||||
@@ -255,12 +359,20 @@ final class AccountAPIClientTests: XCTestCase {
|
|||||||
|
|
||||||
let stored = await store.session
|
let stored = await store.session
|
||||||
let clearCount = await store.clearSessionCount
|
let clearCount = await store.clearSessionCount
|
||||||
|
let refreshTransaction = await store.refreshTransaction
|
||||||
XCTAssertNil(stored)
|
XCTAssertNil(stored)
|
||||||
XCTAssertEqual(clearCount, 1)
|
XCTAssertEqual(clearCount, 1)
|
||||||
|
XCTAssertNil(refreshTransaction)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testLogoutClearsPrivateSessionWhenRevocationIsUnavailable() async throws {
|
func testLogoutClearsPrivateSessionWhenRevocationIsUnavailable() async throws {
|
||||||
let store = InMemoryAccountSecurityStore(session: makeAccountSession())
|
let store = InMemoryAccountSecurityStore(
|
||||||
|
session: makeAccountSession(),
|
||||||
|
refreshTransaction: AccountRefreshTransaction(
|
||||||
|
refreshTokenDigest: "pending",
|
||||||
|
operationId: UUID()
|
||||||
|
)
|
||||||
|
)
|
||||||
let transport = QueueAccountTransport([])
|
let transport = QueueAccountTransport([])
|
||||||
let client = AccountAPIClient(
|
let client = AccountAPIClient(
|
||||||
baseURL: URL(string: "https://account.test")!,
|
baseURL: URL(string: "https://account.test")!,
|
||||||
@@ -272,9 +384,11 @@ final class AccountAPIClientTests: XCTestCase {
|
|||||||
|
|
||||||
let stored = await store.session
|
let stored = await store.session
|
||||||
let clearCount = await store.clearSessionCount
|
let clearCount = await store.clearSessionCount
|
||||||
|
let refreshTransaction = await store.refreshTransaction
|
||||||
let requests = await transport.requests
|
let requests = await transport.requests
|
||||||
XCTAssertNil(stored)
|
XCTAssertNil(stored)
|
||||||
XCTAssertEqual(clearCount, 1)
|
XCTAssertEqual(clearCount, 1)
|
||||||
|
XCTAssertNil(refreshTransaction)
|
||||||
XCTAssertEqual(requests.single?.url?.path, "/v1/auth/logout")
|
XCTAssertEqual(requests.single?.url?.path, "/v1/auth/logout")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -487,6 +601,16 @@ final class AccountAPIClientTests: XCTestCase {
|
|||||||
XCTAssertEqual(requests.count, 2)
|
XCTAssertEqual(requests.count, 2)
|
||||||
XCTAssertTrue(requests.allSatisfy { $0.url?.path == "/v1/account" })
|
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 {
|
private extension Array {
|
||||||
|
|||||||
@@ -462,6 +462,36 @@ final class AccountCenterViewModelTests: XCTestCase {
|
|||||||
XCTAssertEqual(managedGatewayClearCount, 1)
|
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 {
|
private func makeReferral(status: AccountReferralStatus) -> AccountReferral {
|
||||||
AccountReferral(
|
AccountReferral(
|
||||||
id: UUID(),
|
id: UUID(),
|
||||||
@@ -535,6 +565,7 @@ private actor AccountServiceSpy: AccountSessionServicing, AccountCenterServicing
|
|||||||
private let shouldFailAccountRefresh: Bool
|
private let shouldFailAccountRefresh: Bool
|
||||||
private let signOutDelayNanoseconds: UInt64
|
private let signOutDelayNanoseconds: UInt64
|
||||||
private let accountLoadDelayNanoseconds: UInt64
|
private let accountLoadDelayNanoseconds: UInt64
|
||||||
|
private let storedAppleCredentialState: AccountAppleCredentialState
|
||||||
private var redeemed: [String] = []
|
private var redeemed: [String] = []
|
||||||
private var centerLoadCount = 0
|
private var centerLoadCount = 0
|
||||||
private var logoutCount = 0
|
private var logoutCount = 0
|
||||||
@@ -550,7 +581,8 @@ private actor AccountServiceSpy: AccountSessionServicing, AccountCenterServicing
|
|||||||
shouldFailRedemption: Bool = false,
|
shouldFailRedemption: Bool = false,
|
||||||
shouldFailAccountRefresh: Bool = false,
|
shouldFailAccountRefresh: Bool = false,
|
||||||
signOutDelayNanoseconds: UInt64 = 0,
|
signOutDelayNanoseconds: UInt64 = 0,
|
||||||
accountLoadDelayNanoseconds: UInt64 = 0
|
accountLoadDelayNanoseconds: UInt64 = 0,
|
||||||
|
appleCredentialState: AccountAppleCredentialState = .unknown
|
||||||
) {
|
) {
|
||||||
restored = restoredSession
|
restored = restoredSession
|
||||||
centerSnapshot = snapshot
|
centerSnapshot = snapshot
|
||||||
@@ -559,6 +591,7 @@ private actor AccountServiceSpy: AccountSessionServicing, AccountCenterServicing
|
|||||||
self.shouldFailAccountRefresh = shouldFailAccountRefresh
|
self.shouldFailAccountRefresh = shouldFailAccountRefresh
|
||||||
self.signOutDelayNanoseconds = signOutDelayNanoseconds
|
self.signOutDelayNanoseconds = signOutDelayNanoseconds
|
||||||
self.accountLoadDelayNanoseconds = accountLoadDelayNanoseconds
|
self.accountLoadDelayNanoseconds = accountLoadDelayNanoseconds
|
||||||
|
self.storedAppleCredentialState = appleCredentialState
|
||||||
}
|
}
|
||||||
|
|
||||||
func restoreSession() async throws -> AccountSession? {
|
func restoreSession() async throws -> AccountSession? {
|
||||||
@@ -582,6 +615,10 @@ private actor AccountServiceSpy: AccountSessionServicing, AccountCenterServicing
|
|||||||
accountDeleteCount += 1
|
accountDeleteCount += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func appleCredentialState() async -> AccountAppleCredentialState {
|
||||||
|
storedAppleCredentialState
|
||||||
|
}
|
||||||
|
|
||||||
func clearManagedGateway() async {
|
func clearManagedGateway() async {
|
||||||
gatewayClearCount += 1
|
gatewayClearCount += 1
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,22 @@ import Foundation
|
|||||||
|
|
||||||
actor InMemoryAccountSecurityStore: AccountSessionVault, AppAttestKeyStateStoring {
|
actor InMemoryAccountSecurityStore: AccountSessionVault, AppAttestKeyStateStoring {
|
||||||
private(set) var session: AccountSession?
|
private(set) var session: AccountSession?
|
||||||
|
private(set) var refreshTransaction: AccountRefreshTransaction?
|
||||||
private(set) var keyState: AppAttestKeyState?
|
private(set) var keyState: AppAttestKeyState?
|
||||||
private(set) var clearSessionCount = 0
|
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.session = session
|
||||||
|
self.refreshTransaction = refreshTransaction
|
||||||
self.keyState = keyState
|
self.keyState = keyState
|
||||||
|
self.remainingSessionSaveFailures = sessionSaveFailures
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadSession() async throws -> AccountSession? {
|
func loadSession() async throws -> AccountSession? {
|
||||||
@@ -21,6 +31,10 @@ actor InMemoryAccountSecurityStore: AccountSessionVault, AppAttestKeyStateStorin
|
|||||||
}
|
}
|
||||||
|
|
||||||
func saveSession(_ session: AccountSession) async throws {
|
func saveSession(_ session: AccountSession) async throws {
|
||||||
|
if remainingSessionSaveFailures > 0 {
|
||||||
|
remainingSessionSaveFailures -= 1
|
||||||
|
throw AccountAPIError.secureStorage
|
||||||
|
}
|
||||||
self.session = session
|
self.session = session
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,6 +43,26 @@ actor InMemoryAccountSecurityStore: AccountSessionVault, AppAttestKeyStateStorin
|
|||||||
clearSessionCount += 1
|
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? {
|
func loadAppAttestKeyState() async throws -> AppAttestKeyState? {
|
||||||
keyState
|
keyState
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
--text-tertiary: #8a8a8a;
|
--text-tertiary: #8a8a8a;
|
||||||
--card: #ffffff;
|
--card: #ffffff;
|
||||||
--border: rgba(0, 0, 0, 0.08);
|
--border: rgba(0, 0, 0, 0.08);
|
||||||
|
--media-border: rgba(0, 0, 0, 0.06);
|
||||||
--accent: #2f6fed;
|
--accent: #2f6fed;
|
||||||
--chip-bg: rgba(47, 111, 237, 0.1);
|
--chip-bg: rgba(47, 111, 237, 0.1);
|
||||||
}
|
}
|
||||||
@@ -25,6 +26,7 @@
|
|||||||
--text-tertiary: #7a7a7a;
|
--text-tertiary: #7a7a7a;
|
||||||
--card: #161618;
|
--card: #161618;
|
||||||
--border: rgba(255, 255, 255, 0.1);
|
--border: rgba(255, 255, 255, 0.1);
|
||||||
|
--media-border: rgba(255, 255, 255, 0.08);
|
||||||
--accent: #6b9fff;
|
--accent: #6b9fff;
|
||||||
--chip-bg: rgba(107, 159, 255, 0.16);
|
--chip-bg: rgba(107, 159, 255, 0.16);
|
||||||
}
|
}
|
||||||
@@ -150,7 +152,7 @@
|
|||||||
height: auto;
|
height: auto;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
border: 1px solid var(--border);
|
border: 0.5px solid var(--media-border);
|
||||||
background: #000;
|
background: #000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user