Probe before rejecting a credential

Invidious answers a revoked session with 403, not 401, so the failure
reached recordServerOutcome as a plain HTTP error and fell through the
switch: an account whose session died server-side kept reporting
signedIn forever, and credentialRejected was unreachable on the
commonest backend. The roadmap's rule cuts the other way too — a raw
401/403 alone must never reject, because the failure may be about the
operation (someone else's playlist) rather than the login.

recordServerOutcome now treats .unauthorized and HTTP 401/403 alike as
suspicion, not verdict: after the existing basic-auth-proxy exclusion,
it hands the account to a new CredentialProbeService, which re-asks the
server with a harmless authenticated read — Invidious's auth
subscriptions endpoint with the same SID, Piped's subscriptions with
the same token. Only a probe-level 401/403 marks the account
credentialRejected; success means the original failure was
operation-specific and the account stays signedIn; an unreachable
probe records exactly that, and anything ambiguous leaves the status
alone.

The probe applies its verdict only if the account still exists and
still holds the credential it tested — a replacement or an identity
flip mid-flight drops the answer, and a dropped answer starts no
cooldown, so the login the user just fixed is examined without delay.
One probe per account may be in flight; an answered probe stands for a
cooldown before the next suspicion re-asks.

With rejection finally reachable, the existing machinery engages on
its own: the validators fall back to local data, server mutations
pause, and a successful call or re-sign-in lifts the mark.
This commit is contained in:
Arkadiusz Fal
2026-09-01 23:56:26 +02:00
parent 5a77e93558
commit 9b5d2b001f
4 changed files with 803 additions and 8 deletions

View File

@@ -639,14 +639,25 @@ final class AccountsManager {
accountStatuses[accountID] ?? (identityScope.isResolved ? .credentialMissing : .checking)
}
/// Asked to confirm an auth-shaped failure before anything is concluded
/// from it. `nil` — tests, and the window before wiring completes — means
/// nothing is concluded at all, which is the safe direction.
///
/// Held here as a hook rather than as a dependency because the confirmation
/// needs an API client, and this manager deliberately knows nothing about
/// the network.
@ObservationIgnored var onSuspectedCredentialRejection: (@MainActor (Account) -> Void)?
/// Records what a server call implies about the account that made it.
///
/// Deliberately conservative about rejection: a raw 401 marks the
/// credential only when no reverse-proxy basic auth is configured, because
/// otherwise the 401 may well have come from the proxy. The two-stage probe
/// that disambiguates the rest is Phase 2 work; until then an ambiguous
/// failure leaves the status alone rather than wrongly telling the user to
/// sign in again.
/// Nothing here rejects a credential. A raw 401 or 403 is *suspicion*: the
/// same statuses come back from a reverse proxy, from endpoints a valid
/// session may not touch, and — on Invidious — from a genuinely revoked
/// session, and the request that failed cannot tell them apart. It is
/// handed to `onSuspectedCredentialRejection`, which asks the question again
/// against an endpoint that exists to be authenticated; only that second
/// answer marks the account. Until it arrives the status is left alone,
/// rather than wrongly telling the user to sign in again.
///
/// Shared by the subscription providers and the feed cache: a credential
/// that expires during a feed-only refresh has to mark the account too, or
@@ -667,13 +678,31 @@ final class AccountsManager {
case APIError.cancelled:
return
case APIError.unauthorized:
guard instance(for: account)?.usesBasicAuth == false else { return }
setStatus(.credentialRejected, for: account.id)
suspectRejection(of: account)
case let APIError.httpError(statusCode, _) where statusCode == 401 || statusCode == 403:
// 403 arrives untranslated on purpose: the shared HTTP mapping
// turns only 401 into `.unauthorized`, because unauthenticated call
// sites read 403 as "this instance is blocked" rather than "this
// login is". Both shapes mean the same thing here.
suspectRejection(of: account)
default:
return
}
}
/// Passes an auth-shaped failure on for confirmation, unless the instance
/// itself can explain it.
private func suspectRejection(of account: Account) {
// An instance behind HTTP Basic Auth answers 401 at the proxy for every
// request made to it, whichever account made it — so the status says
// nothing about this credential, and a probe would only be answered by
// the same proxy. An orphaned account, whose instance is unknown, is
// skipped by the same guard: there is nothing to probe against.
guard instance(for: account)?.usesBasicAuth == false else { return }
onSuspectedCredentialRejection?(account)
}
// MARK: - Legacy migration
/// Turns each pre-revamp single-login instance into an account.

View File

@@ -56,6 +56,7 @@ final class AppEnvironment {
let homeInstanceCache: HomeInstanceCache
let invidiousAPI: InvidiousAPI
let pipedAPI: PipedAPI
let credentialProbeService: CredentialProbeService
let subscriptionAccountValidator: SubscriptionAccountValidator
let dataContextValidator: DataContextValidator
let playerControlsLayoutService: PlayerControlsLayoutService
@@ -213,6 +214,45 @@ final class AppEnvironment {
let pipedAPI = PipedAPI(httpClient: client)
self.pipedAPI = pipedAPI
// A 401 or 403 from an ordinary request is never allowed to reject a
// login on its own — it is re-asked here against an endpoint that
// exists to be authenticated, and only that answer marks the account.
let probeService = CredentialProbeService(
accountsManager: accounts,
credentialReader: { [weak accounts, weak invidiousCreds, weak pipedCreds] account in
guard let type = accounts?.instance(for: account)?.type else { return nil }
switch type {
case .invidious: return invidiousCreds?.credential(for: account)
case .piped: return pipedCreds?.credential(for: account)
default: return nil
}
},
transport: { instance, credential in
do {
switch instance.type {
case .invidious:
// GET /api/v1/auth/subscriptions — the cheapest call
// that does nothing except require a session.
_ = try await invidiousAPI.subscriptions(instance: instance, sid: credential)
case .piped:
_ = try await pipedAPI.subscriptions(instance: instance, authToken: credential)
default:
return .indeterminate
}
return .authenticated
} catch {
return CredentialProbeService.classify(error)
}
}
)
self.credentialProbeService = probeService
// Weak: the manager is owned for the life of the app and the probe
// service holds it back, so a strong capture here would be a cycle.
accounts.onSuspectedCredentialRejection = { [weak probeService] account in
probeService?.probeSuspectedRejection(for: account)
}
// Initialize SubscriptionService with all required dependencies
self.subscriptionService = SubscriptionService(
dataManager: dm,
@@ -726,6 +766,13 @@ final class AppEnvironment {
subscriptionService.discardProvider(for: account.id)
HomeInstanceCache.shared.clear(instanceID: account.instanceID, contentType: .feed)
// Reached by both ways a credential is replaced — the user signing in
// again here, and a replacement arriving over iCloud Keychain that the
// fingerprint digest spots — so the probe service's per-account dedup
// is retired here rather than at either call site. A verdict earned by
// the old secret must never delay asking about the new one.
credentialProbeService.credentialDidChange(for: account.id)
if accountsManager.currentContext == .account(account.id) {
SubscriptionFeedCache.shared.handleAccountChange()
}

View File

@@ -0,0 +1,233 @@
//
// CredentialProbeService.swift
// Yattee
//
// Confirms — or refuses to confirm — that a server really rejected an
// account's credential.
//
import Foundation
/// Turns an auth-shaped failure into a verdict about the credential itself.
///
/// A raw 401 or 403 from an ordinary account request is not evidence: Invidious
/// answers 403 `{"error":"Request must be authenticated"}` for a revoked
/// session, but also for endpoints an otherwise valid session may not touch,
/// and a reverse proxy in front of either backend answers 401 for reasons that
/// have nothing to do with the account. Acting on the raw status would tell
/// users with a perfectly good login to sign in again — and drop their reads to
/// local data — on the strength of one unlucky request.
///
/// So the failure only *suspects* rejection. This service asks the question
/// again, deliberately, against an endpoint that exists purely to be
/// authenticated (the subscriptions list), and only that answer is allowed to
/// set `credentialRejected`. Everything else the probe can return maps to a
/// state that lifts by itself.
///
/// The transport and the credential lookup are injected so tests can script the
/// answer without a server, and so this file stays free of any API-client
/// knowledge.
@MainActor
final class CredentialProbeService {
/// What a probe concluded about the credential it was given.
enum Verdict: Equatable {
/// The server accepted it — the original failure was about the
/// operation, not the login.
case authenticated
/// The server refused it. The only verdict that marks an account.
case rejected
/// The server could not be asked. Says nothing about the credential.
case unreachable
/// The probe produced an answer that proves nothing either way — a
/// decoding failure, a 500, a cancelled task. Status is left alone.
case indeterminate
}
/// Performs one authenticated request with the given credential.
typealias Transport = @MainActor (Instance, String) async -> Verdict
/// The credential an account currently holds, or `nil` when there is none
/// to be read — including while the identity scope is unresolved, which is
/// "cannot tell yet" rather than "signed out".
typealias CredentialReader = @MainActor (Account) -> String?
private let accountsManager: AccountsManager
private let credentialReader: CredentialReader
private let transport: Transport
/// How long an *answered* probe stands before another may run for the same
/// account. A failing feed refresh can produce several auth-shaped failures
/// in a row; without this each one would cost a round trip to say the same
/// thing. Injectable so tests do not have to wait a real minute.
private let cooldown: TimeInterval
/// Probes currently in flight, keyed by account.
///
/// Exposed because it is also the only way to await one: the probe is
/// deliberately fire-and-forget from the caller's side — `recordServerOutcome`
/// cannot become async — so tests need a handle to join on.
private(set) var probeTasks: [UUID: Task<Void, Never>] = [:]
/// When a probe last produced an answer *about the credential the account
/// still holds*. A verdict dropped as stale never lands here: it says
/// nothing about what is in place now, so it may not delay asking.
private var lastProbeFinished: [UUID: Date] = [:]
/// Bumped every time an account's credential is replaced.
///
/// A probe carries the value it was spawned under and may only speak while
/// it still matches. Cancellation alone would not do: the transport may not
/// notice it for a while, and comparing the credential is not enough either
/// — a replacement can happen to be the same string, and the comparison
/// races with the gates being cleared.
private var generations: [UUID: Int] = [:]
init(
accountsManager: AccountsManager,
credentialReader: @escaping CredentialReader,
transport: @escaping Transport,
cooldown: TimeInterval = 60
) {
self.accountsManager = accountsManager
self.credentialReader = credentialReader
self.transport = transport
self.cooldown = cooldown
}
// MARK: - Probing
/// Asks the server whether this account's credential is still good.
///
/// Returns immediately; the verdict is applied to `AccountsManager` when the
/// answer arrives, and only if it still describes the same credential on the
/// same account.
func probeSuspectedRejection(for account: Account) {
guard probeTasks[account.id] == nil else { return }
if let finished = lastProbeFinished[account.id],
Date().timeIntervalSince(finished) < cooldown {
return
}
// No credential to test. `refreshAccountStatuses` owns that verdict —
// it can tell a missing credential from an unresolved identity scope,
// and this service deliberately cannot.
guard let credential = credentialReader(account) else { return }
// An account whose instance has not arrived (or is already gone) has
// nothing to probe against.
guard let instance = accountsManager.instance(for: account) else { return }
let accountID = account.id
let generation = generations[accountID, default: 0]
probeTasks[accountID] = Task { [weak self] in
let verdict = await self?.transport(instance, credential)
guard let self else { return }
defer {
// Only if this probe is still the one on record. A credential
// change takes the slot away and a fresh probe may already be
// using it; clearing it here would strand that one's dedup.
if self.generations[accountID, default: 0] == generation {
self.probeTasks[accountID] = nil
}
}
guard let verdict else { return }
// The world may have moved while the probe was out. A verdict about
// a credential that has since been replaced — or read under an
// identity scope that has since flipped, which makes the reader
// return `nil` — describes nothing that is still true, and applying
// it would reject a login the user just fixed.
guard self.generations[accountID, default: 0] == generation,
let current = self.accountsManager.account(id: accountID),
self.credentialReader(current) == credential
else {
LoggingService.shared.debug(
"Dropped a credential probe verdict — the account or its credential changed",
category: .general,
details: "accountID=\(accountID)"
)
return
}
// Stamped only here, on the one path where an answer about the
// credential now in place was actually obtained. A dropped verdict
// describes a secret that is gone, so it may not silence the first
// probe of its replacement — the login the user just fixed is
// exactly the one whose rejection has to be found quickly. Probe
// storms are held off by the in-flight guard regardless.
self.lastProbeFinished[accountID] = Date()
self.apply(verdict, to: accountID)
}
}
/// Retires everything this service knows about an account's *previous*
/// credential.
///
/// Both gates in `probeSuspectedRejection` are keyed by account alone and
/// are checked before the credential is even read, so without this a
/// replacement inherits the old secret's dedup: a finished probe would
/// silence the new login's first failure for the whole cooldown, and one
/// still in flight would silence it until that round trip returned. The
/// replacement is the login the user just fixed — a rejection of *it* is
/// the one worth finding immediately.
///
/// The in-flight probe is cancelled as well as disowned, so a network call
/// nobody will listen to is not left running.
func credentialDidChange(for accountID: UUID) {
generations[accountID, default: 0] += 1
lastProbeFinished[accountID] = nil
probeTasks.removeValue(forKey: accountID)?.cancel()
}
private func apply(_ verdict: Verdict, to accountID: UUID) {
switch verdict {
case .authenticated:
// The credential works; whatever failed was about the request.
accountsManager.setStatus(.signedIn, for: accountID)
case .rejected:
LoggingService.shared.info(
"A credential probe confirmed the server rejects this account's login",
category: .general,
details: "accountID=\(accountID)"
)
accountsManager.setStatus(.credentialRejected, for: accountID)
case .unreachable:
// Recorded rather than ignored so reads fall back to local data,
// and lifted again by the manager's own retry timer.
accountsManager.setStatus(.unreachable, for: accountID)
case .indeterminate:
break
}
}
// MARK: - Classification
/// Maps a probe's outcome onto a verdict.
///
/// `nil` means the request succeeded. Only the two auth statuses reject:
/// 403 is included because Invidious answers it — not 401 — for a session
/// that has been revoked, and it reaches here as a plain `httpError`
/// because the global 403 mapping in `HTTPClient` is shared with
/// unauthenticated call sites that read it as "blocked", not "signed out".
static func classify(_ error: Error?) -> Verdict {
guard let error else { return .authenticated }
switch error {
case APIError.unauthorized:
return .rejected
case let APIError.httpError(statusCode, _) where statusCode == 401 || statusCode == 403:
return .rejected
case APIError.timeout, APIError.noConnection:
return .unreachable
default:
// Cancellation included: a probe that was torn down never asked the
// question, so it may not answer it either.
return .indeterminate
}
}
}