From 8224477290c260e296cb66a58801be72f3f8d5ba Mon Sep 17 00:00:00 2001 From: Izaan Kamal Date: Sun, 13 Sep 2026 10:43:39 -0700 Subject: [PATCH] Reuse the Apple ID sign-in across operations instead of signing in for each one Every operation that needs Apple's developer services performed a full GrandSlam sign-in: the background refresh at launch, View App IDs, and each install, update, or refresh started separately. Apple limits how often an account may sign in, and once the limit is hit it answers with an HTTP 429 page for a while, which AltStore surfaces as a parse error or "Failed to authenticate account". Opening the app and refreshing a few apps individually is enough to trigger it. AuthenticationOperation now keeps the team, certificate, and session from a successful sign-in in an in-memory cache and reuses them for up to fifteen minutes. A reused session gets fresh anisette data, which is the only part that expires quickly and is already refreshed the same way before provisioning profiles are fetched. The token is only reused when the fresh anisette data comes from the same machine identity as the sign-in; after the anisette source changes, such as when Remote AltServer is set up again against a different server, the operation signs in again instead. The database and keychain updates that follow a real sign-in are skipped on reuse because that sign-in already made them. Sign-ins that start while another is in progress now wait for it rather than running concurrently, so they reuse its result. The cache is dropped when a request made with it fails with an ALTAppleAPIError, with an Apple result code AltSign passes through unmapped (such as 1100, "Your session has expired"), or with an Apple response that does not parse as a plist, so the next operation signs in again. It is also dropped when the user signs out. --- AltStore/Managing Apps/AppManager.swift | 44 ++++++- .../Operations/AuthenticationOperation.swift | 111 ++++++++++++++++-- .../Settings/SettingsViewController.swift | 2 + 3 files changed, 143 insertions(+), 14 deletions(-) diff --git a/AltStore/Managing Apps/AppManager.swift b/AltStore/Managing Apps/AppManager.swift index acf01f66e8..d0b3a3067d 100644 --- a/AltStore/Managing Apps/AppManager.swift +++ b/AltStore/Managing Apps/AppManager.swift @@ -57,7 +57,11 @@ class AppManager: ObservableObject }() private weak var requestUDIDAction: UIAlertAction? - + + let authenticationCache = AuthenticationCache() + private weak var pendingAuthenticationOperation: AuthenticationOperation? + private let authenticationLock = NSLock() + private init() { self.operationQueue.name = "com.altstore.AppManager.operationQueue" @@ -348,11 +352,33 @@ extension AppManager completionHandler(result) } authenticationOperation.addDependency(prepareServerOperation) - + + // Wait for a sign-in already in progress so this operation can reuse its session. + self.authenticationLock.lock() + if let pendingOperation = self.pendingAuthenticationOperation, !pendingOperation.isFinished + { + authenticationOperation.addDependency(pendingOperation) + } + self.pendingAuthenticationOperation = authenticationOperation + self.authenticationLock.unlock() + self.run([authenticationOperation], context: context) - + return authenticationOperation } + + /// Drops the reused sign-in after Apple rejects a request made with it, including a + /// result code AltSign passes through unmapped and an HTML error page that fails to + /// parse as a plist. + func invalidateAuthenticationIfNeeded(for error: Error) + { + let nsError = error as NSError + let isAppleError = (nsError.domain == ALTAppleAPIErrorDomain || nsError.domain == ALTUnderlyingAppleAPIErrorDomain) + let isUnparsedAppleResponse = (nsError.domain == NSCocoaErrorDomain && nsError.code == NSPropertyListReadCorruptError) + guard isAppleError || isUnparsedAppleResponse else { return } + + self.authenticationCache.invalidate() + } @discardableResult func fetchPairingFile(context: OperationContext = OperationContext(), completionHandler: @escaping (Result) -> Void) -> FetchPairingFileOperation @@ -734,7 +760,14 @@ extension AppManager } let fetchAppIDsOperation = FetchAppIDsOperation(context: authenticationOperation.context) - fetchAppIDsOperation.resultHandler = completionHandler + fetchAppIDsOperation.resultHandler = { (result) in + if case .failure(let error) = result + { + self.invalidateAuthenticationIfNeeded(for: error) + } + + completionHandler(result) + } fetchAppIDsOperation.addDependency(authenticationOperation) self.run([fetchAppIDsOperation], context: authenticationOperation.context) } @@ -2254,7 +2287,8 @@ private extension AppManager let error = nsError.withLocalizedTitle(localizedTitle) group.set(.failure(error), forAppWithBundleIdentifier: operation.bundleIdentifier) - + + self.invalidateAuthenticationIfNeeded(for: nsError) self.log(error, operation: operation.loggedErrorOperation, app: operation.app) } } diff --git a/AltStore/Operations/AuthenticationOperation.swift b/AltStore/Operations/AuthenticationOperation.swift index 7f43360759..4ac07b6a17 100644 --- a/AltStore/Operations/AuthenticationOperation.swift +++ b/AltStore/Operations/AuthenticationOperation.swift @@ -37,11 +37,56 @@ enum AuthenticationErrorCode: Int, ALTErrorEnum, CaseIterable } } +/// The result of the most recent successful sign-in, kept so later operations can reuse it +/// instead of signing in again. Apple limits how often an account may sign in. +final class AuthenticationCache +{ + struct Entry + { + let team: ALTTeam + let certificate: ALTCertificate + let session: ALTAppleAPISession + let date: Date + } + + /// How long a sign-in is reused before the next operation signs in again. + static let lifetime: TimeInterval = 15 * 60 + + private var entry: Entry? + private let lock = NSLock() + + var validEntry: Entry? { + self.lock.lock() + defer { self.lock.unlock() } + + guard let entry = self.entry, Date().timeIntervalSince(entry.date) < AuthenticationCache.lifetime else { return nil } + return entry + } + + func store(team: ALTTeam, certificate: ALTCertificate, session: ALTAppleAPISession) + { + self.lock.lock() + defer { self.lock.unlock() } + + self.entry = Entry(team: team, certificate: certificate, session: session, date: Date()) + } + + func invalidate() + { + self.lock.lock() + defer { self.lock.unlock() } + + self.entry = nil + } +} + @objc(AuthenticationOperation) class AuthenticationOperation: ResultOperation<(ALTTeam, ALTCertificate, ALTAppleAPISession)>, @unchecked Sendable { let context: AuthenticatedOperationContext - + + private var isReusingSession = false + private var presentingViewController: UIViewController? { return self.context.presentingViewController } @@ -88,8 +133,42 @@ class AuthenticationOperation: ResultOperation<(ALTTeam, ALTCertificate, ALTAppl self.finish(.failure(error)) return } - - // Sign In + + // Reuse the last sign-in while it is valid; only the anisette data must be fresh. + if let entry = AppManager.shared.authenticationCache.validEntry + { + self.fetchAnisetteData { (result) in + guard !self.isCancelled else { return self.finish(.failure(OperationError.cancelled)) } + + switch result + { + case .failure(let error): self.finish(.failure(error)) + case .success(let anisetteData) where anisetteData.machineID == entry.session.anisetteData.machineID: + self.isReusingSession = true + + let session = ALTAppleAPISession(dsid: entry.session.dsid, authToken: entry.session.authToken, anisetteData: anisetteData) + self.context.session = session + self.context.team = entry.team + self.context.certificate = entry.certificate + self.progress.completedUnitCount = self.progress.totalUnitCount + + self.finish(.success((entry.team, entry.certificate, session))) + + case .success: + // The anisette identity changed since the sign-in, so its token no longer matches it. + AppManager.shared.authenticationCache.invalidate() + self.performSignIn() + } + } + + return + } + + self.performSignIn() + } + + private func performSignIn() + { self.signIn() { (result) in guard !self.isCancelled else { return self.finish(.failure(OperationError.cancelled)) } @@ -213,9 +292,19 @@ class AuthenticationOperation: ResultOperation<(ALTTeam, ALTCertificate, ALTAppl switch result { case .failure(let error): Logger.sideload.error("Failed to authenticate account. \(error.localizedDescription, privacy: .public)") - case .success((let team, _, _)): Logger.sideload.notice("Authenticated account for team \(team.identifier, privacy: .public).") + case .success((let team, _, _)) where self.isReusingSession: Logger.sideload.notice("Reused sign-in for team \(team.identifier, privacy: .public).") + case .success((let team, let certificate, let session)): + Logger.sideload.notice("Authenticated account for team \(team.identifier, privacy: .public).") + AppManager.shared.authenticationCache.store(team: team, certificate: certificate, session: session) } - + + if self.isReusingSession + { + // The sign-in this session came from already updated the database and keychain. + super.finish(result) + return + } + let context = DatabaseManager.shared.persistentContainer.newBackgroundContext() context.perform { do @@ -382,6 +471,13 @@ private extension AuthenticationOperation } } + func fetchAnisetteData(completionHandler: @escaping (Result) -> Void) + { + let fetchAnisetteDataOperation = FetchAnisetteDataOperation(context: self.context) + fetchAnisetteDataOperation.resultHandler = completionHandler + self.operationQueue.addOperation(fetchAnisetteDataOperation) + } + func authenticate(appleID: String, password: String, completionHandler: @escaping (Result<(ALTAccount, ALTAppleAPISession), Swift.Error>) -> Void) { self.appleIDEmailAddress = appleID @@ -404,8 +500,7 @@ private extension AuthenticationOperation self.operationQueue.addOperation(resyncOperation) } - let fetchAnisetteDataOperation = FetchAnisetteDataOperation(context: self.context) - fetchAnisetteDataOperation.resultHandler = { (result) in + self.fetchAnisetteData { (result) in switch result { case .failure(let error): completionHandler(.failure(error)) @@ -469,8 +564,6 @@ private extension AuthenticationOperation } } } - - self.operationQueue.addOperation(fetchAnisetteDataOperation) } func fetchTeam(for account: ALTAccount, session: ALTAppleAPISession, completionHandler: @escaping (Result) -> Void) diff --git a/AltStore/Settings/SettingsViewController.swift b/AltStore/Settings/SettingsViewController.swift index cb9e5b995b..99ec825d11 100644 --- a/AltStore/Settings/SettingsViewController.swift +++ b/AltStore/Settings/SettingsViewController.swift @@ -468,6 +468,8 @@ private extension SettingsViewController { func signOut() { + AppManager.shared.authenticationCache.invalidate() + DatabaseManager.shared.signOut { (error) in DispatchQueue.main.async { if let error = error