Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 39 additions & 5 deletions AltStore/Managing Apps/AppManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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, Error>) -> Void) -> FetchPairingFileOperation
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
}
Expand Down
111 changes: 102 additions & 9 deletions AltStore/Operations/AuthenticationOperation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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)) }

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -382,6 +471,13 @@ private extension AuthenticationOperation
}
}

func fetchAnisetteData(completionHandler: @escaping (Result<ALTAnisetteData, Swift.Error>) -> 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
Expand All @@ -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))
Expand Down Expand Up @@ -469,8 +564,6 @@ private extension AuthenticationOperation
}
}
}

self.operationQueue.addOperation(fetchAnisetteDataOperation)
}

func fetchTeam(for account: ALTAccount, session: ALTAppleAPISession, completionHandler: @escaping (Result<ALTTeam, Swift.Error>) -> Void)
Expand Down
2 changes: 2 additions & 0 deletions AltStore/Settings/SettingsViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down