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:
@@ -130,7 +130,8 @@ private enum AccountAppleAuthorizationPayload {
|
||||
identityToken: identityToken,
|
||||
authorizationCode: authorizationCode,
|
||||
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 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 {}
|
||||
}
|
||||
|
||||
@@ -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<Void, Never>?
|
||||
private var accountRefreshRequestID: UUID?
|
||||
private var sessionEventsTask: Task<Void, Never>?
|
||||
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 {
|
||||
@@ -149,6 +158,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 }
|
||||
@@ -402,18 +419,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 }
|
||||
|
||||
|
||||
@@ -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<AccountSessionEvent> {
|
||||
@@ -213,6 +251,7 @@ private actor LiveAccountService:
|
||||
|
||||
func restoreSession() async throws -> AccountSession? {
|
||||
guard let cachedSession = try await apiClient.currentSession() else {
|
||||
try? await appleUserIdentifiers.clearAppleUserIdentifier()
|
||||
await invalidateManagedGatewaySession()
|
||||
return nil
|
||||
}
|
||||
@@ -222,6 +261,7 @@ private actor LiveAccountService:
|
||||
} catch let error as AccountAPIError {
|
||||
switch error {
|
||||
case .sessionUnavailable, .unauthorized, .refreshTokenReuse:
|
||||
try? await appleUserIdentifiers.clearAppleUserIdentifier()
|
||||
await invalidateManagedGatewaySession()
|
||||
return nil
|
||||
default:
|
||||
@@ -282,6 +322,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()
|
||||
@@ -310,9 +354,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 {
|
||||
@@ -322,11 +368,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()
|
||||
|
||||
@@ -101,6 +101,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
|
||||
@ObservedObject private var speechHistory = SpeechHistoryStore.shared
|
||||
@@ -579,15 +580,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)
|
||||
|
||||
@@ -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
|
||||
@@ -209,6 +212,7 @@ struct MainAppRoot: View {
|
||||
}
|
||||
}
|
||||
Task {
|
||||
await accountSession.validateAppleCredentialState()
|
||||
await AppCloudSync.shared.pullAllIfEnabled()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -828,7 +828,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.";
|
||||
"account.signIn.apple" = "Sign in with Apple";
|
||||
"account.signIn.hint" = "Creates or opens your optional OSG account.";
|
||||
|
||||
@@ -827,7 +827,7 @@
|
||||
"account.loading.session" = "正在检查账号状态…";
|
||||
"account.loading.center" = "正在加载账号…";
|
||||
"account.signedOut.title" = "OSG 账号";
|
||||
"account.signedOut.body" = "登录后可使用托管积分与邀请功能。";
|
||||
"account.signedOut.body" = "注册和邀请好友,都可获赠积分";
|
||||
"account.signedOut.localUnaffected" = "本地功能与自有 API Key 无需账号也可继续使用。";
|
||||
"account.signIn.apple" = "通过 Apple 登录";
|
||||
"account.signIn.hint" = "创建或打开可选的 OSG 账号。";
|
||||
|
||||
Reference in New Issue
Block a user