diff --git a/CHANGELOG.md b/CHANGELOG.md index 84dee8d..64aa894 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,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. / **口语化技能回复**:非商务回复采用普通人与朋友、好友或同事交谈的自然口吻,情绪明确时可合理使用一个表情;商务回复保持自然专业,当前用户自有的学习风格仅作为受限的用词与节奏层应用。 ### 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 凭据被撤销时退出登录。 - **Personal dictionary aliases**: generate common speech-recognition mistakes for Home term suggestions through the same concurrency-safe save path used by manual entries. / **个性词库易错词**:首页推荐词现在与手动词条共用并发安全的保存流程,并自动生成常见语音误识别写法。 - **Home service setup guidance**: explain whether user-owned API keys are missing, link directly to the relevant provider settings, and offer OSG credits as the no-key alternative. / **首页服务配置引导**:明确说明缺少哪类自备 API Key,直接跳转到对应服务商设置,并提供无需填写 Key 的 OSG 积分方案。 - **Live keyboard configuration guidance**: apply speech-language changes to an already open keyboard and explain when clipboard capture is paused because Full Access is off. / **键盘实时配置引导**:识别语言变更会同步到已打开的键盘,并在“允许完全访问”关闭导致剪贴板采集暂停时给出说明。 diff --git a/OSGKeyboard/Views/Account/AccountAppleAuthorizationButton.swift b/OSGKeyboard/Views/Account/AccountAppleAuthorizationButton.swift index 4cf387c..8e41804 100644 --- a/OSGKeyboard/Views/Account/AccountAppleAuthorizationButton.swift +++ b/OSGKeyboard/Views/Account/AccountAppleAuthorizationButton.swift @@ -161,7 +161,8 @@ private enum AccountAppleAuthorizationPayload { identityToken: identityToken, authorizationCode: authorizationCode, nonce: rawNonce, - displayName: displayName(from: credential.fullName) + displayName: displayName(from: credential.fullName), + userIdentifier: credential.user ) } diff --git a/OSGKeyboard/Views/Account/AccountModels.swift b/OSGKeyboard/Views/Account/AccountModels.swift index 0a9cc0b..93a6041 100644 --- a/OSGKeyboard/Views/Account/AccountModels.swift +++ b/OSGKeyboard/Views/Account/AccountModels.swift @@ -245,25 +245,35 @@ struct AppleAuthorizationPayload: Equatable, Sendable { let authorizationCode: String let nonce: String let displayName: String? + let userIdentifier: String? init( identityToken: String, authorizationCode: String, nonce: String, - displayName: String? = nil + displayName: String? = nil, + userIdentifier: String? = nil ) { self.identityToken = identityToken self.authorizationCode = authorizationCode self.nonce = nonce self.displayName = displayName + self.userIdentifier = userIdentifier } } +enum AccountAppleCredentialState: Equatable, Sendable { + case authorized + case revoked + case unknown +} + protocol AccountSessionServicing: Sendable { func restoreSession() async throws -> AccountSession? func signIn(with payload: AppleAuthorizationPayload) async throws -> AccountSession func signOut() async throws func deleteAccount(with payload: AppleAuthorizationPayload) async throws + func appleCredentialState() async -> AccountAppleCredentialState func prepareManagedGateway() async throws func clearManagedGateway() async } @@ -277,6 +287,10 @@ protocol AccountSessionEventSourcing: Sendable { } extension AccountSessionServicing { + func appleCredentialState() async -> AccountAppleCredentialState { + .unknown + } + func prepareManagedGateway() async throws {} func clearManagedGateway() async {} } diff --git a/OSGKeyboard/Views/Account/AccountSessionCoordinator.swift b/OSGKeyboard/Views/Account/AccountSessionCoordinator.swift index 539b24b..f33b12d 100644 --- a/OSGKeyboard/Views/Account/AccountSessionCoordinator.swift +++ b/OSGKeyboard/Views/Account/AccountSessionCoordinator.swift @@ -4,6 +4,7 @@ // Main-actor state machine for optional account features. Local and BYOK // features never consult this coordinator and remain available when signed out. +import AuthenticationServices import Combine import Foundation import OSGKeyboardShared @@ -58,6 +59,7 @@ final class AccountSessionCoordinator: ObservableObject { private var accountRefreshTask: Task? private var accountRefreshRequestID: UUID? private var sessionEventsTask: Task? + private var appleCredentialRevocationCancellable: AnyCancellable? init( dependencies: AccountDependencies, @@ -100,6 +102,13 @@ final class AccountSessionCoordinator: ObservableObject { await self?.handleSessionEvent(event) } } + appleCredentialRevocationCancellable = NotificationCenter.default + .publisher(for: ASAuthorizationAppleIDProvider.credentialRevokedNotification) + .sink { [weak self] _ in + Task { @MainActor [weak self] in + await self?.handleAppleCredentialRevocation() + } + } } deinit { @@ -157,6 +166,14 @@ final class AccountSessionCoordinator: ObservableObject { } } + func validateAppleCredentialState() async { + guard isSignedIn, + await sessionService.appleCredentialState() == .revoked else { + return + } + await handleAppleCredentialRevocation() + } + @discardableResult func handleIncomingURL(_ url: URL) -> Bool { guard let code = ReferralUniversalLink.code(from: url) else { return false } @@ -410,18 +427,28 @@ final class AccountSessionCoordinator: ObservableObject { private func handleSessionEvent(_ event: AccountSessionEvent) async { switch event { case .expired: - guard isSignedIn else { return } - await sessionService.clearManagedGateway() - await onAccountSignedOut() - creditPurchases.endSession() - clearAccountRefreshState() - referralProfile.endSession() - sessionPhase = .signedOut - snapshotPhase = .idle - operationErrorKey = "account.error.sessionExpired" + await transitionToExpiredSession() } } + 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 { guard isSignedIn, let code = pendingReferralStore.code else { return } diff --git a/OSGKeyboard/Views/Account/LiveAccountServices.swift b/OSGKeyboard/Views/Account/LiveAccountServices.swift index a76a374..295a04b 100644 --- a/OSGKeyboard/Views/Account/LiveAccountServices.swift +++ b/OSGKeyboard/Views/Account/LiveAccountServices.swift @@ -5,6 +5,7 @@ // session credentials never enter App Group storage; only a non-secret local // eligibility marker and scope-limited gateway grants are shared. +import AuthenticationServices import Foundation import OSGKeyboardHostSupport import OSGKeyboardShared @@ -37,7 +38,8 @@ enum LiveAccountDependencyFactory { integrity: integrity, grants: grants, grantStore: grantStore, - configuration: AppGroupStore() + configuration: AppGroupStore(), + appleUserIdentifiers: sessionVault ) return AccountDependencies( 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: AccountSessionServicing, AccountSessionEventSourcing, @@ -174,6 +205,8 @@ private actor LiveAccountService: private let grants: GatewayGrantCoordinator private let grantStore: any GatewayGrantCredentialStore private let configuration: AppGroupStore + private let appleUserIdentifiers: any AppleUserIdentifierStoring + private let appleCredentialChecker: any AppleCredentialStateChecking private let decoder = JSONDecoder() private let encoder = JSONEncoder() @@ -182,7 +215,10 @@ private actor LiveAccountService: integrity: DeviceIntegrityCoordinator, grants: GatewayGrantCoordinator, grantStore: any GatewayGrantCredentialStore, - configuration: AppGroupStore + configuration: AppGroupStore, + appleUserIdentifiers: any AppleUserIdentifierStoring, + appleCredentialChecker: any AppleCredentialStateChecking = + SystemAppleCredentialStateChecker() ) { self.apiClient = apiClient accountCenterLoader = AccountCenterSnapshotLoader(apiClient: apiClient) @@ -190,6 +226,8 @@ private actor LiveAccountService: self.grants = grants self.grantStore = grantStore self.configuration = configuration + self.appleUserIdentifiers = appleUserIdentifiers + self.appleCredentialChecker = appleCredentialChecker } func events() async -> AsyncStream { @@ -214,6 +252,7 @@ private actor LiveAccountService: func restoreSession() async throws -> AccountSession? { guard let cachedSession = try await apiClient.currentSession() else { OSGDiag.log("session restore status=not-found", category: "account") + try? await appleUserIdentifiers.clearAppleUserIdentifier() await invalidateManagedGatewaySession() return nil } @@ -229,6 +268,7 @@ private actor LiveAccountService: + "error=\(AccountDiagnostic.code(for: error))", category: "account" ) + try? await appleUserIdentifiers.clearAppleUserIdentifier() await invalidateManagedGatewaySession() return nil default: @@ -303,6 +343,10 @@ private actor LiveAccountService: ) throw error } + if let userIdentifier = payload.userIdentifier, + !userIdentifier.isEmpty { + try? await appleUserIdentifiers.saveAppleUserIdentifier(userIdentifier) + } let account: OSGAccount do { account = try await apiClient.account() @@ -331,9 +375,11 @@ private actor LiveAccountService: try await apiClient.logout() } catch { try? await grants.clearGrant() + try? await appleUserIdentifiers.clearAppleUserIdentifier() throw error } try? await grants.clearGrant() + try? await appleUserIdentifiers.clearAppleUserIdentifier() } func deleteAccount(with payload: AppleAuthorizationPayload) async throws { @@ -343,11 +389,20 @@ private actor LiveAccountService: nonce: payload.nonce ) try? await grants.clearGrant() + try? await appleUserIdentifiers.clearAppleUserIdentifier() await integrity.clearLocalKeyState() configuration.setManagedGatewayAccountSessionAvailable(false) 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 { guard try await apiClient.currentSession() != nil else { await invalidateManagedGatewaySession() diff --git a/OSGKeyboard/Views/HomeView.swift b/OSGKeyboard/Views/HomeView.swift index 88fe8c8..605f04f 100644 --- a/OSGKeyboard/Views/HomeView.swift +++ b/OSGKeyboard/Views/HomeView.swift @@ -125,6 +125,7 @@ struct HomeView: View { @Environment(\.themePalette) private var palette: ThemePalette @Environment(\.scenePhase) private var scenePhase @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @Environment(\.colorScheme) private var colorScheme @ObservedObject private var config = ProviderConfig.shared @EnvironmentObject private var flowManager: FlowSessionManager @@ -590,15 +591,17 @@ struct HomeView: View { // MARK: - Header - // logo 尺寸保持 144:41 比例;小屏进一步缩小,给下方内容让空间。 + // Logo 保持 144:41 比例,并使用随系统深浅色切换的黑白单色。 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) return VStack(spacing: Spacing.xxl) { Image("osglogo") .resizable() + .renderingMode(.template) .scaledToFit() .frame(width: logoWidth, height: logoHeight) + .foregroundStyle(colorScheme == .dark ? Color.white : Color.black) .accessibilityHidden(true) } .frame(maxWidth: .infinity) diff --git a/OSGKeyboard/Views/MainAppRoot.swift b/OSGKeyboard/Views/MainAppRoot.swift index 0b24a8e..316dd31 100644 --- a/OSGKeyboard/Views/MainAppRoot.swift +++ b/OSGKeyboard/Views/MainAppRoot.swift @@ -151,6 +151,9 @@ struct MainAppRoot: View { } .task { await accountSession.restoreIfNeeded() + if scenePhase == .active { + await accountSession.validateAppleCredentialState() + } config.reloadFromPersistedStorage() } .onReceive(NotificationCenter.default.publisher(for: .settingsDidSyncFromCloud)) { _ in @@ -215,6 +218,7 @@ struct MainAppRoot: View { } } Task { + await accountSession.validateAppleCredentialState() await AppCloudSync.shared.pullAllIfEnabled() } } diff --git a/OSGKeyboard/en.lproj/Localizable.strings b/OSGKeyboard/en.lproj/Localizable.strings index 5356aa9..59799b1 100644 --- a/OSGKeyboard/en.lproj/Localizable.strings +++ b/OSGKeyboard/en.lproj/Localizable.strings @@ -830,7 +830,7 @@ "account.loading.session" = "Checking account status…"; "account.loading.center" = "Loading 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."; "home.accountRewards.signedOut.title" = "Sign in for free credits"; "home.accountRewards.signedOut.summary" = "Eligible new users receive 1,000 credits; after an invited friend completes qualifying usage, you both receive 1,000 credits."; diff --git a/OSGKeyboard/zh-Hans.lproj/Localizable.strings b/OSGKeyboard/zh-Hans.lproj/Localizable.strings index c6dc9d0..e64ca2d 100644 --- a/OSGKeyboard/zh-Hans.lproj/Localizable.strings +++ b/OSGKeyboard/zh-Hans.lproj/Localizable.strings @@ -829,7 +829,7 @@ "account.loading.session" = "正在检查账号状态…"; "account.loading.center" = "正在加载账号…"; "account.signedOut.title" = "OSG 账号"; -"account.signedOut.body" = "登录后可使用托管积分与邀请功能。"; +"account.signedOut.body" = "注册和邀请好友,都可获赠积分"; "account.signedOut.localUnaffected" = "本地功能与自有 API Key 无需账号也可继续使用。"; "home.accountRewards.signedOut.title" = "登录领免费积分"; "home.accountRewards.signedOut.summary" = "符合资格的新用户可领 1,000 积分;邀请好友完成首次有效使用,双方各得 1,000 积分。"; diff --git a/OSGKeyboardHostSupport/Features/Account/AccountAPIClient.swift b/OSGKeyboardHostSupport/Features/Account/AccountAPIClient.swift index 3168dd9..333f8db 100644 --- a/OSGKeyboardHostSupport/Features/Account/AccountAPIClient.swift +++ b/OSGKeyboardHostSupport/Features/Account/AccountAPIClient.swift @@ -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.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?, diff --git a/OSGKeyboardHostSupport/Features/Account/AccountModels.swift b/OSGKeyboardHostSupport/Features/Account/AccountModels.swift index 8088f0a..7395c6f 100644 --- a/OSGKeyboardHostSupport/Features/Account/AccountModels.swift +++ b/OSGKeyboardHostSupport/Features/Account/AccountModels.swift @@ -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 { diff --git a/OSGKeyboardHostSupport/Features/Account/HostPrivateAccountKeychain.swift b/OSGKeyboardHostSupport/Features/Account/HostPrivateAccountKeychain.swift index 9bf0b52..a5d323c 100644 --- a/OSGKeyboardHostSupport/Features/Account/HostPrivateAccountKeychain.swift +++ b/OSGKeyboardHostSupport/Features/Account/HostPrivateAccountKeychain.swift @@ -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) } diff --git a/OSGKeyboardTests/AccountAPIClientTests.swift b/OSGKeyboardTests/AccountAPIClientTests.swift index 9d3ecfa..cb80ef1 100644 --- a/OSGKeyboardTests/AccountAPIClientTests.swift +++ b/OSGKeyboardTests/AccountAPIClientTests.swift @@ -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 { diff --git a/OSGKeyboardTests/AccountCenterViewModelTests.swift b/OSGKeyboardTests/AccountCenterViewModelTests.swift index 1987ad8..6e60406 100644 --- a/OSGKeyboardTests/AccountCenterViewModelTests.swift +++ b/OSGKeyboardTests/AccountCenterViewModelTests.swift @@ -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 } diff --git a/OSGKeyboardTests/TestSupport/AccountTestDoubles.swift b/OSGKeyboardTests/TestSupport/AccountTestDoubles.swift index 8adb1bb..31665d7 100644 --- a/OSGKeyboardTests/TestSupport/AccountTestDoubles.swift +++ b/OSGKeyboardTests/TestSupport/AccountTestDoubles.swift @@ -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 } diff --git a/docs/osgkeyboardversion.html b/docs/osgkeyboardversion.html index 588bd85..c2498ba 100644 --- a/docs/osgkeyboardversion.html +++ b/docs/osgkeyboardversion.html @@ -14,6 +14,7 @@ --text-tertiary: #8a8a8a; --card: #ffffff; --border: rgba(0, 0, 0, 0.08); + --media-border: rgba(0, 0, 0, 0.06); --accent: #2f6fed; --chip-bg: rgba(47, 111, 237, 0.1); } @@ -25,6 +26,7 @@ --text-tertiary: #7a7a7a; --card: #161618; --border: rgba(255, 255, 255, 0.1); + --media-border: rgba(255, 255, 255, 0.08); --accent: #6b9fff; --chip-bg: rgba(107, 159, 255, 0.16); } @@ -150,7 +152,7 @@ height: auto; margin: 0 auto; border-radius: 14px; - border: 1px solid var(--border); + border: 0.5px solid var(--media-border); background: #000; }