Skip to main content

feather_reader/oauth/
revoke.rs

1//! RFC 7009 token revocation — what "log out" actually means at the PDS.
2//!
3//! Without this, signing out only drops the local row: the refresh token stays
4//! live at the authorization server until it expires on its own, so a stolen
5//! copy of the database still yields a working session long after the user
6//! believes they are out. The sidecar revoked; the Rust path has to as well.
7
8use anyhow::Result;
9
10use super::client_auth::AuthMethod;
11use super::store::OAuthSession;
12
13/// What happened at the authorization server. Never an `Err` at the call site:
14/// the caller has already decided to sign the user out, and the question is only
15/// whether the server was told too.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum Revocation {
18    /// The server accepted the revocation.
19    Revoked,
20    /// There was no session to revoke — logout is idempotent.
21    NoSession,
22    /// The attempt failed. The local row is gone regardless.
23    Failed(String),
24}
25
26/// Which of a session's two tokens to present, and its `token_type_hint`.
27///
28/// **The refresh token, whenever there is one.** RFC 7009 §2.1: revoking a
29/// refresh token SHOULD also invalidate every access token issued under the same
30/// grant, so one call ends the whole thing. Revoking the access token alone
31/// leaves the refresh token live, and a refresh token is precisely what turns a
32/// stale database dump back into a working session.
33///
34/// The reference is split on this — `oauth-session.js` `signOut()` revokes the
35/// access token while `session-getter.js` revokes `refresh_token ?? access_token`
36/// — and this follows the stronger of the two.
37pub fn token_to_revoke(session: &OAuthSession) -> (&str, &'static str) {
38    if session.refresh_token.is_empty() {
39        (&session.access_token, "access_token")
40    } else {
41        (&session.refresh_token, "refresh_token")
42    }
43}
44
45/// The form body for a revocation request, client credentials included.
46///
47/// No `token_type_hint` is sent, matching the reference. It is optional in RFC
48/// 7009, and a server that cannot find the token under the hinted type MUST
49/// search the other — so the hint can only save the server a lookup, never
50/// change the outcome.
51pub fn revoke_params(
52    method: AuthMethod,
53    client_id: &str,
54    assertion: Option<&str>,
55    token: &str,
56) -> Result<Vec<(&'static str, String)>> {
57    let mut params = vec![("token", token.to_string())];
58    params.extend(super::client_auth::credential_params(
59        method, client_id, assertion,
60    )?);
61    Ok(params)
62}
63
64/// Longest a sign-out will wait on the authorization server.
65///
66/// Sign-out is a foreground action a user is watching. Revocation is
67/// best-effort by design, so the local delete must not be held behind an
68/// unbounded wait on a server that may be down.
69const REVOKE_DEADLINE: std::time::Duration = std::time::Duration::from_secs(5);
70
71/// What a revocation needs beyond the session itself. Mirrors
72/// [`super::session::RefreshContext`]; `aud` is the issuer for both.
73pub struct RevokeContext<'a> {
74    /// From the authorization server's metadata. Absent when the server
75    /// advertises none, which is itself a reason revocation cannot happen.
76    pub revocation_endpoint: Option<&'a str>,
77    pub client_id: &'a str,
78    pub auth_method: AuthMethod,
79    /// The confidential client's signing key; unused by the dev client.
80    pub client_key: Option<&'a super::keys::SigningKey>,
81    /// Longest to wait on the authorization server before giving up and signing
82    /// out locally. Injectable so the deadline can be TESTED without a
83    /// five-second test.
84    pub deadline: std::time::Duration,
85}
86
87/// Sign a subject out: tell the authorization server, then drop the local row.
88///
89/// **The local row goes regardless of what the server says.** The user asked to
90/// be logged out; a server that is down, slow, or has already forgotten the
91/// grant must not leave a usable session sitting in our database. The reference
92/// encodes the same ordering as `try { revoke } finally { delStored }`, and the
93/// failure it guards against is the worse one: reporting an error to the user
94/// while their credentials stay live locally.
95///
96/// Revocation is attempted FIRST, because it needs the tokens the delete
97/// destroys — but it is BOUNDED. The whole design already treats a failed
98/// revocation as acceptable, so making the user wait out a dead PDS's timeouts
99/// to reach a delete that happens regardless is the wrong trade. Past the
100/// deadline the attempt is abandoned and the local session goes.
101pub async fn sign_out(
102    pool: &sqlx::SqlitePool,
103    codec: &super::crypto::Codec,
104    http: &reqwest::Client,
105    ctx: &RevokeContext<'_>,
106    sub: &str,
107    now: i64,
108) -> Revocation {
109    sign_out_with(pool, codec, sub, ctx.deadline, |session| async move {
110        revoke_tokens(pool, http, ctx, &session, now).await
111    })
112    .await
113}
114
115/// [`sign_out`] with the revocation injected, so a row rotated mid-sign-out
116/// can be simulated deterministically.
117async fn sign_out_with<F, Fut>(
118    pool: &sqlx::SqlitePool,
119    codec: &super::crypto::Codec,
120    sub: &str,
121    deadline: std::time::Duration,
122    mut revoke: F,
123) -> Revocation
124where
125    F: FnMut(OAuthSession) -> Fut,
126    Fut: std::future::Future<Output = Revocation>,
127{
128    // The outcome of the last attempt whose row then changed under it.
129    let mut previous: Option<Revocation> = None;
130    for _ in 0..MAX_SIGN_OUT_ATTEMPTS {
131        let (session, version) = match super::store::get_session_versioned(pool, codec, sub).await {
132            Ok(Some(read)) => read,
133            // Gone — first time round, there was nothing to sign out; after
134            // a `Changed`, someone else (a concurrent `/logout`) deleted it
135            // once we had revoked what we read, so report that revocation.
136            Ok(None) => return previous.unwrap_or(Revocation::NoSession),
137            Err(err) => {
138                // Still delete: an unreadable row is exactly the state a
139                // sign-out should clear, and leaving it wedges every later
140                // request. No refresh can rotate a row nothing can read.
141                let _ = super::store::delete_session(pool, sub).await;
142                return Revocation::Failed(format!("reading the session: {err:#}"));
143            }
144        };
145
146        match bounded_then_delete(pool, sub, &version, deadline, revoke(session)).await {
147            Attempt::Done(outcome) => return outcome,
148            // Rotated (or removed) while we revoked: read again and revoke the
149            // tokens that are actually on record now.
150            Attempt::Changed(outcome) => previous = Some(outcome),
151        }
152    }
153    // Still rotating. Leave the newest tokens IN PLACE — deleting them would
154    // drop a live token unrevoked with no record left to retry from — and say
155    // so, so the operator's sweep (or the user's next sign-out) can finish it.
156    Revocation::Failed(format!(
157        "the session kept changing while it was being signed out ({MAX_SIGN_OUT_ATTEMPTS} \
158         attempts, each overtaken by a refresh); its newest tokens were left in place"
159    ))
160}
161
162/// Revoke a token set that has NO local row to delete — bounded by
163/// `ctx.deadline`, best-effort, never touching the store's sessions.
164///
165/// For tokens obtained for a session that was signed out while they were being
166/// obtained: a refresh that finds its row deleted holds a live grant nobody
167/// will ever use, and dropping it would leave it live at the PDS until expiry.
168pub(crate) async fn revoke_orphaned(
169    pool: &sqlx::SqlitePool,
170    http: &reqwest::Client,
171    ctx: &RevokeContext<'_>,
172    session: &OAuthSession,
173    now: i64,
174) -> Revocation {
175    match tokio::time::timeout(ctx.deadline, revoke_tokens(pool, http, ctx, session, now)).await {
176        Ok(outcome) => outcome,
177        Err(_) => Revocation::Failed(format!(
178            "revocation did not finish within {:?}",
179            ctx.deadline
180        )),
181    }
182}
183
184/// The deadline a background revocation (not a user's sign-out) waits for.
185pub(crate) const ORPHAN_REVOKE_DEADLINE: std::time::Duration = REVOKE_DEADLINE;
186
187/// The revocation request itself. Errors become [`Revocation::Failed`] rather
188/// than propagating: every caller has already committed to signing out.
189async fn revoke_tokens(
190    pool: &sqlx::SqlitePool,
191    http: &reqwest::Client,
192    ctx: &RevokeContext<'_>,
193    session: &OAuthSession,
194    now: i64,
195) -> Revocation {
196    match try_revoke(pool, http, ctx, session, now).await {
197        Ok(()) => Revocation::Revoked,
198        Err(err) => Revocation::Failed(format!("{err:#}")),
199    }
200}
201
202async fn try_revoke(
203    pool: &sqlx::SqlitePool,
204    http: &reqwest::Client,
205    ctx: &RevokeContext<'_>,
206    session: &OAuthSession,
207    now: i64,
208) -> Result<()> {
209    let endpoint = ctx.revocation_endpoint.ok_or_else(|| {
210        anyhow::anyhow!("the authorization server advertises no revocation endpoint")
211    })?;
212
213    let (token, _hint) = token_to_revoke(session);
214    let assertion = match ctx.auth_method {
215        AuthMethod::PrivateKeyJwt => {
216            let key = ctx.client_key.ok_or_else(|| {
217                anyhow::anyhow!("private_key_jwt requires the client signing key")
218            })?;
219            Some(super::client_auth::client_assertion(
220                key,
221                ctx.client_id,
222                &session.issuer,
223                now,
224            )?)
225        }
226        AuthMethod::None => None,
227    };
228    let params = revoke_params(ctx.auth_method, ctx.client_id, assertion.as_deref(), token)?;
229    let form: Vec<(&str, &str)> = params.iter().map(|(k, v)| (*k, v.as_str())).collect();
230
231    // The session's own DPoP key, as for every other call on this grant: the
232    // reference routes revocation through the same `dpopFetch`.
233    let key = super::keys::SigningKey::from_jwk_json(&session.dpop_key_jwk, "session")?;
234    let outcome = super::request::send_with_dpop(
235        http,
236        pool,
237        &super::request::DpopRequest {
238            endpoint: super::dpop::Endpoint::AuthorizationServer,
239            url: endpoint,
240            key: &key,
241            access_token: None,
242            body: super::request::DpopBody::Form(&form),
243            retry: super::request::Retry::Allowed,
244        },
245    )
246    .await?;
247
248    // RFC 7009 §2.2: a 200 also means "we did not recognise that token", which
249    // is success for our purposes — the grant is not usable either way.
250    if !(200..300).contains(&outcome.status) {
251        anyhow::bail!("the revocation endpoint returned status {}", outcome.status);
252    }
253    Ok(())
254}
255
256/// Sign out, discovering the revocation endpoint from the session itself.
257///
258/// The endpoint lives in the authorization server's metadata, which is not
259/// stored on the session — so it has to be fetched. That fetch is done only when
260/// there IS a session to revoke: discovering first and finding nothing to do
261/// would put a network round trip on every logout of a dev-DID or an
262/// already-expired account.
263///
264/// A discovery failure is not fatal. It means the server cannot be told, which
265/// is exactly the case [`sign_out`] already handles by deleting locally anyway.
266///
267/// **Discovery follows the row, not the first read.** The sign-out re-reads a
268/// row that changed under it (see `sign_out_with`), and the re-read can be a
269/// DIFFERENT grant — a re-login after a PDS migration, at another issuer.
270/// Presenting its refresh token to the endpoint discovered for the old issuer
271/// would hand it to a different authorization server (the leak the
272/// issuer-checked discovery exists to prevent), which answers 200 for a token
273/// it does not know: reported revoked, row deleted, grant live. So every
274/// token goes only to the endpoint discovered for ITS OWN `(aud, issuer)`
275/// (`revoke_at_own_issuer`).
276pub async fn sign_out_discovering(
277    runtime: &super::runtime::OauthRuntime,
278    http: &reqwest::Client,
279    pool: &sqlx::SqlitePool,
280    sub: &str,
281    now: i64,
282) -> Revocation {
283    let cache = EndpointCache::default();
284    let cache = &cache;
285    // Unreadable rows, absent rows, the bounded attempt and the
286    // compare-and-delete are all `sign_out_with`'s — the same contract
287    // `sign_out` runs, tested there. The deadline covers discovery AND the
288    // revocation (each bounded on its own to REVOKE_DEADLINE inside), the same
289    // worst case as when the two were bounded one after the other.
290    sign_out_with(
291        pool,
292        &runtime.codec,
293        sub,
294        2 * REVOKE_DEADLINE,
295        |session| async move {
296            revoke_at_own_issuer(runtime, http, pool, cache, &session, now).await
297        },
298    )
299    .await
300}
301
302/// The revocation endpoint last discovered, and the `(aud, issuer)` it was
303/// discovered FOR. A token is only ever presented to the endpoint of its own
304/// pair.
305type EndpointCache = std::sync::Mutex<Option<((String, String), Option<String>)>>;
306
307/// Revoke `session`'s tokens at the revocation endpoint of ITS OWN issuer —
308/// discovering it (issuer-checked) unless `cache` already holds the endpoint
309/// for exactly this `(aud, issuer)`.
310async fn revoke_at_own_issuer(
311    runtime: &super::runtime::OauthRuntime,
312    http: &reqwest::Client,
313    pool: &sqlx::SqlitePool,
314    cache: &EndpointCache,
315    session: &OAuthSession,
316    now: i64,
317) -> Revocation {
318    let key = (session.aud.clone(), session.issuer.clone());
319    let cached = cache
320        .lock()
321        .unwrap_or_else(|p| p.into_inner())
322        .as_ref()
323        // ONLY an endpoint discovered for this very (aud, issuer). A row
324        // re-read at another issuer is re-discovered, never sent here.
325        .filter(|(for_pair, _)| *for_pair == key)
326        .map(|(_, endpoint)| endpoint.clone());
327    let endpoint = match cached {
328        Some(endpoint) => endpoint,
329        None => {
330            let endpoint = discover_revocation_endpoint(runtime, http, session).await;
331            *cache.lock().unwrap_or_else(|p| p.into_inner()) = Some((key, endpoint.clone()));
332            endpoint
333        }
334    };
335    revoke_tokens(
336        pool,
337        http,
338        &RevokeContext {
339            revocation_endpoint: endpoint.as_deref(),
340            client_id: &runtime.client_id,
341            auth_method: runtime.auth_method,
342            client_key: runtime.client_key.as_ref(),
343            deadline: REVOKE_DEADLINE,
344        },
345        session,
346        now,
347    )
348    .await
349}
350
351/// Discover `session`'s revocation endpoint, issuer-checked and bounded.
352/// `None` when it cannot be (which the caller treats as "cannot tell the
353/// server", and signs out locally anyway).
354async fn discover_revocation_endpoint(
355    runtime: &super::runtime::OauthRuntime,
356    http: &reqwest::Client,
357    session: &OAuthSession,
358) -> Option<String> {
359    let sub = &session.sub;
360    // **Discovery is bounded too**, and separately: `discover` makes two
361    // guarded fetches, each with its own 30-second timeout, so an unreachable
362    // PDS held a user's sign-out for a minute before the revocation's own
363    // deadline even began.
364    match tokio::time::timeout(
365        REVOKE_DEADLINE,
366        // **The missed sibling.** This posts the REFRESH TOKEN to whatever
367        // `revocation_endpoint` comes back, and had no issuer check at all —
368        // while the callback and refresh paths both had one, and the comment
369        // that added them counted "the two instances" of a hole that had three.
370        // A repointed PDS could take the refresh token AND leave the real grant
371        // live, because the local row is deleted either way.
372        super::discovery::discover(
373            http,
374            &session.aud,
375            runtime.auth_method.as_str(),
376            Some(&session.issuer),
377        ),
378    )
379    .await
380    {
381        Ok(Ok(server)) => server.revocation_endpoint,
382        Ok(Err(err)) => {
383            tracing::warn!(%err, %sub, "could not discover the revocation endpoint");
384            None
385        }
386        Err(_) => {
387            tracing::warn!(%sub, "discovering the revocation endpoint timed out");
388            None
389        }
390    }
391}
392
393/// What an operator revoke-all did, per subject DID.
394#[derive(Debug, Clone, Default, PartialEq, Eq)]
395pub struct RevokeAllReport {
396    /// Revoked at the authorization server, and the local row deleted.
397    pub revoked: Vec<String>,
398    /// Listed, but gone by the time it was signed out (a concurrent `/logout`).
399    pub no_session: Vec<String>,
400    /// The server could not be told, with the reason. The local row is deleted
401    /// regardless, so these tokens may stay live at the PDS until they expire.
402    pub failed: Vec<(String, String)>,
403    /// Subjects ABSENT from the first listing and found by a re-list — sessions
404    /// created while the walk ran (a login on the still-serving app). A row
405    /// that was there from the start and merely retried is not one of these.
406    /// Each was signed out too; its outcome is in the lists above, which hold
407    /// ONE final outcome per DID (a later pass overwrites an earlier one).
408    pub late: Vec<String>,
409}
410
411/// Sign EVERY stored session out — the operator's fleet-wide revoke (#257).
412///
413/// Before this existed, a teardown on the `rust` backend wiped
414/// `FEATHERREADER_DB` and with it every refresh token, unrevoked, leaving each
415/// live at its PDS until it expired. This walks the store and runs each subject
416/// through [`sign_out_discovering`] — the same function `/logout` and
417/// `/account/delete` use, so it inherits that function's contract: a bounded
418/// attempt at the server, then a local delete (of the version it revoked)
419/// whatever the server said, including for a row that no longer decrypts.
420///
421/// **A failure does not stop the walk.** Every later session would otherwise be
422/// left neither revoked nor deleted. Failures are collected with their reasons
423/// so the operator can see which tokens may still be live.
424///
425/// Sequential on purpose: each sign-out is already deadline-bounded, and this
426/// runs once, at a teardown, where a predictable request rate against each PDS
427/// matters more than finishing a few seconds sooner.
428///
429/// Only listing the sessions can fail as a whole — in which case nothing has
430/// been revoked or deleted, and the caller must not proceed to a wipe.
431pub async fn revoke_all(
432    runtime: &super::runtime::OauthRuntime,
433    http: &reqwest::Client,
434    pool: &sqlx::SqlitePool,
435    clock: impl FnMut() -> i64,
436) -> Result<RevokeAllReport> {
437    revoke_all_with(pool, clock, |sub, now| async move {
438        sign_out_discovering(runtime, http, pool, &sub, now).await
439    })
440    .await
441}
442
443/// Whether `runtime` is the production client — the only one that can revoke
444/// production's sessions. The first check [`preflight`] makes.
445///
446/// An incomplete environment does not fail to build a runtime; it builds the
447/// WRONG one. Without `FEATHERREADER_PUBLIC_URL`, `Config` falls back to
448/// localhost, which is not production-like, so the production checks
449/// (encryption key included) never run and the runtime comes up as atproto's
450/// public dev client — possibly with the pass-through `Null` codec. Revoking
451/// with that sends every token under the wrong `client_id` or fails to decrypt
452/// every row, and each sign-out deletes its row anyway. So a run that will
453/// touch stored sessions requires all three: the confidential client (a
454/// non-loopback public URL), a real encryption codec, and the loaded key.
455///
456/// The `Null` codec needs its own check: it "decrypts" a ciphertext by
457/// returning it unchanged, so the decrypt pre-flight would count production's
458/// encrypted rows as readable.
459pub fn fit_to_revoke(runtime: &super::runtime::OauthRuntime) -> Result<()> {
460    let mut missing = Vec::new();
461    if runtime.auth_method != AuthMethod::PrivateKeyJwt {
462        missing.push(
463            "the confidential client (FEATHERREADER_PUBLIC_URL is loopback or unset, so this \
464             would revoke as the public dev client)",
465        );
466    }
467    if matches!(runtime.codec, super::crypto::Codec::Null) {
468        missing.push("an encryption key (FEATHERREADER_OAUTH_ENCRYPTION_KEY is unset)");
469    }
470    if runtime.client_key.is_none() {
471        missing.push("the signing key (FEATHERREADER_OAUTH_KEY_PATH)");
472    }
473    if missing.is_empty() {
474        Ok(())
475    } else {
476        anyhow::bail!(
477            "not the production OAuth client — missing {}. Run this inside the app's own \
478             environment",
479            missing.join("; ")
480        )
481    }
482}
483
484/// Longest the pre-flight waits for the app's own JWKS.
485const OWN_JWKS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
486/// Largest JWKS document the pre-flight will read. Ours is one key, < 1 KiB.
487const OWN_JWKS_MAX_BYTES: usize = 64 * 1024;
488
489/// Fetch the app's OWN JWKS for the pre-flight.
490///
491/// **A plain client, not the SSRF guard.** The URL is the operator's own
492/// configuration (`FEATHERREADER_PUBLIC_URL`), not attacker input, and a
493/// self-host with split-horizon DNS serves it on a LAN or loopback address —
494/// exactly what the guard refuses, so the main pass could never pass. What
495/// the guard is for is still kept where it matters here: https only (the key
496/// comparison means nothing over a rewritable channel), no redirects, no
497/// proxy, a timeout, and a size cap.
498async fn fetch_own_jwks(url: &str) -> Result<serde_json::Value> {
499    use anyhow::Context as _;
500    let parsed = url::Url::parse(url).with_context(|| format!("parsing {url}"))?;
501    if parsed.scheme() != "https" {
502        anyhow::bail!("the app's JWKS URL {url} must be https");
503    }
504    let builder = reqwest::Client::builder()
505        .user_agent(crate::USER_AGENT)
506        .redirect(reqwest::redirect::Policy::none())
507        .no_proxy()
508        .timeout(OWN_JWKS_TIMEOUT);
509    // The test CA, so a loopback TLS test server can stand in for the app.
510    // `#[cfg(test)]`: absent from release builds.
511    #[cfg(test)]
512    let builder = builder.add_root_certificate(
513        reqwest::Certificate::from_pem(crate::net::test_pki().ca_pem.as_bytes())
514            .context("parsing the test CA")?,
515    );
516    let client = builder.build().context("building the JWKS client")?;
517    let mut resp = client
518        .get(parsed)
519        .send()
520        .await
521        .with_context(|| format!("fetching {url}"))?;
522    if !resp.status().is_success() {
523        anyhow::bail!("{url} answered {}", resp.status());
524    }
525    let mut body = Vec::new();
526    while let Some(chunk) = resp
527        .chunk()
528        .await
529        .with_context(|| format!("reading {url}"))?
530    {
531        if body.len() + chunk.len() > OWN_JWKS_MAX_BYTES {
532            anyhow::bail!("{url} is larger than {OWN_JWKS_MAX_BYTES} bytes");
533        }
534        body.extend_from_slice(&chunk);
535    }
536    serde_json::from_slice(&body).with_context(|| format!("{url} is not JSON"))
537}
538
539/// Operator choices for [`preflight`], from the command line.
540#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
541pub struct PreflightOptions {
542    /// `--sweep`: the post-stop pass. The app is stopped, so its JWKS cannot
543    /// be fetched; that is tolerated. A fetched MISMATCH still refuses.
544    pub sweep: bool,
545    /// `--accept-unreadable`: proceed when NO stored row decrypts. Relaxes
546    /// that one check only; the client and signing-key checks still apply.
547    /// The unreadable rows are deleted and reported failed — their tokens
548    /// cannot be revoked by anyone and live until they expire.
549    pub accept_unreadable: bool,
550}
551
552/// Prove, BEFORE anything is signed out, that this process holds the
553/// production client's real secrets — not merely a codec and a key file.
554///
555/// Every sign-out deletes its row whatever the PDS says, so a run with the
556/// wrong secrets does not fail safe: it deletes every row unrevoked.
557///
558/// * **The encryption key.** A wrong or rotated
559///   `FEATHERREADER_OAUTH_ENCRYPTION_KEY` decrypts nothing. If there are rows
560///   and NONE decrypts, refuse. (Some readable and some not is a real store
561///   with some unreadable rows; those cannot be revoked by anyone, and are
562///   reported as failed by the walk.)
563/// * **The signing key.** A relative `FEATHERREADER_OAUTH_KEY_PATH` run from
564///   the wrong directory can load SOME key — one no PDS has. Its public half
565///   must be in the JWKS the app actually serves at `jwks_url`. A mismatch
566///   always refuses. A JWKS that cannot be fetched refuses too, except on the
567///   post-stop `sweep`, where the app is stopped and cannot serve it.
568///
569/// With no sessions stored there is nothing to protect, and no check (or
570/// network request) is made.
571pub async fn preflight(
572    runtime: &super::runtime::OauthRuntime,
573    pool: &sqlx::SqlitePool,
574    jwks_url: &str,
575    opts: PreflightOptions,
576) -> Result<()> {
577    let PreflightOptions {
578        sweep,
579        accept_unreadable,
580    } = opts;
581    let subs = super::store::list_session_subs(pool).await?;
582    if subs.is_empty() {
583        return Ok(());
584    }
585
586    // The client: confidential, a real codec, a loaded key.
587    fit_to_revoke(runtime)?;
588
589    // The encryption key: at least one row must decrypt.
590    let mut readable = 0usize;
591    for sub in &subs {
592        if matches!(
593            super::store::get_session(pool, &runtime.codec, sub).await,
594            Ok(Some(_))
595        ) {
596            readable += 1;
597        }
598    }
599    if readable == 0 {
600        if !accept_unreadable {
601            anyhow::bail!(
602                "none of the {} stored session(s) decrypts with this \
603                 FEATHERREADER_OAUTH_ENCRYPTION_KEY — it is not the key the app wrote them \
604                 with (wrong, or rotated). Signing out would delete every row unrevoked. \
605                 If you KNOW every row is unreadable for a legitimate reason (only pre-AAD \
606                 rows; the key was rotated with no logins since), re-run with \
607                 --accept-unreadable: those tokens cannot be revoked by anyone and stay live \
608                 until they expire",
609                subs.len()
610            );
611        }
612        tracing::warn!(
613            stored = subs.len(),
614            "--accept-unreadable: NO stored session decrypts. Every row will be deleted \
615             UNREVOKED and reported failed — those tokens stay live at their PDS until they \
616             expire. The client and signing-key checks still apply."
617        );
618    }
619
620    // The signing key: its public half must be what the app serves.
621    let key = runtime
622        .client_key
623        .as_ref()
624        .ok_or_else(|| anyhow::anyhow!("no client signing key is loaded"))?;
625    let ours = key.thumbprint()?;
626    let served = match fetch_own_jwks(jwks_url).await {
627        Ok(doc) => doc,
628        Err(err) if sweep => {
629            tracing::warn!(
630                %err,
631                "sweep: the app's JWKS is unreachable (expected once the app is stopped); \
632                 the signing key was checked on the main pass"
633            );
634            return Ok(());
635        }
636        Err(err) => {
637            return Err(err.context(format!(
638                "could not fetch the app's JWKS at {jwks_url} to confirm the signing key. \
639                 The main pass runs while the app is serving, so this should be reachable; \
640                 refusing rather than signing with a key no PDS may know"
641            )));
642        }
643    };
644    let matches = served
645        .get("keys")
646        .and_then(serde_json::Value::as_array)
647        .into_iter()
648        .flatten()
649        .any(|jwk| {
650            jwk.get("kid").and_then(serde_json::Value::as_str) == Some(key.kid())
651                && super::keys::SigningKey::public_thumbprint_of(&jwk.to_string()).ok()
652                    == Some(ours.clone())
653        });
654    if !matches {
655        anyhow::bail!(
656            "the loaded signing key (FEATHERREADER_OAUTH_KEY_PATH, kid {:?}) is not the key \
657             the app serves at {jwks_url}. Every client assertion would be rejected and every \
658             row deleted unrevoked. Point FEATHERREADER_OAUTH_KEY_PATH at the app's own key",
659            key.kid()
660        );
661    }
662    Ok(())
663}
664
665/// [`revoke_all`] with the per-session sign-out injected, so sessions that
666/// appear DURING the walk can be simulated.
667async fn revoke_all_with<S, Fut>(
668    pool: &sqlx::SqlitePool,
669    mut clock: impl FnMut() -> i64,
670    mut sign_out: S,
671) -> Result<RevokeAllReport>
672where
673    S: FnMut(String, i64) -> Fut,
674    Fut: std::future::Future<Output = Revocation>,
675{
676    // The FINAL outcome per DID, in first-seen order: a later pass overwrites
677    // an earlier one, so a DID that failed (left in place) and was then revoked
678    // by the re-list is reported revoked — once — and not also failed.
679    let mut order: Vec<String> = Vec::new();
680    let mut outcomes: std::collections::HashMap<String, Revocation> = Default::default();
681    // DIDs absent from the first listing: these appeared DURING the walk.
682    let mut late: Vec<String> = Vec::new();
683
684    let mut subs = super::store::list_session_subs(pool).await?;
685    let initial: std::collections::HashSet<String> = subs.iter().cloned().collect();
686    // The first walk, then up to RE_LIST_PASSES more over whatever is stored
687    // AFTER it: sessions created (a login on the still-serving app) or left in
688    // place (one that kept rotating) while the walk ran. Defence in depth — the
689    // refresh no longer resurrects a deleted row, but a login legitimately
690    // creates one.
691    for pass in 0..=RE_LIST_PASSES {
692        for sub in subs {
693            if !outcomes.contains_key(&sub) {
694                order.push(sub.clone());
695                if !initial.contains(&sub) {
696                    late.push(sub.clone());
697                }
698            }
699            // Read the clock PER SESSION. `now` becomes the client assertion's
700            // `iat`, and an assertion lives only 60 s: one timestamp taken at
701            // the start would be expired for every session reached after the
702            // first minute, so those revocations would all be rejected.
703            let now = clock();
704            let outcome = sign_out(sub.clone(), now).await;
705            outcomes.insert(sub, outcome);
706        }
707        subs = super::store::list_session_subs(pool).await?;
708        if subs.is_empty() {
709            break;
710        }
711        if pass == RE_LIST_PASSES {
712            // Out of passes and the store is still not empty: these sessions
713            // are still on record, and possibly live, whatever the last
714            // attempt said. A failure keeps its own reason.
715            for sub in subs {
716                if !outcomes.contains_key(&sub) {
717                    order.push(sub.clone());
718                }
719                let entry = outcomes.entry(sub).or_insert(Revocation::NoSession);
720                if !matches!(entry, Revocation::Failed(_)) {
721                    *entry = Revocation::Failed(format!(
722                        "still stored after {} passes (a login or refresh keeps \
723                         re-creating it); not signed out",
724                        RE_LIST_PASSES + 1
725                    ));
726                }
727            }
728            break;
729        }
730    }
731
732    let mut report = RevokeAllReport {
733        late,
734        ..RevokeAllReport::default()
735    };
736    for sub in order {
737        match outcomes.remove(&sub) {
738            Some(Revocation::Revoked) => report.revoked.push(sub),
739            Some(Revocation::NoSession) | None => report.no_session.push(sub),
740            Some(Revocation::Failed(reason)) => report.failed.push((sub, reason)),
741        }
742    }
743    Ok(report)
744}
745
746/// Extra walks [`revoke_all`] makes over sessions that appeared during the
747/// previous one.
748const RE_LIST_PASSES: usize = 2;
749
750/// How many times a sign-out re-reads and re-revokes a session that a
751/// concurrent refresh keeps rotating, before giving up and leaving the newest
752/// tokens on record.
753const MAX_SIGN_OUT_ATTEMPTS: usize = 3;
754
755/// What one bounded attempt ended in.
756#[derive(Debug)]
757enum Attempt {
758    /// The row that was revoked has been deleted (or the delete failed, which
759    /// is reported in the outcome). Final.
760    Done(Revocation),
761    /// The row was rewritten or removed after it was read, so the revoked token
762    /// was not the one on record. Nothing was deleted.
763    Changed(Revocation),
764}
765
766/// Run `attempt` under `deadline`, then delete the local session — **whatever
767/// the server said**, including when the deadline expired — provided the row
768/// still holds the `version` that was revoked.
769///
770/// The single implementation of the sign-out contract, so there is no second
771/// copy to drift. Separated out from [`sign_out`] so the bound can be tested
772/// against a future that never resolves, rather than against a network address
773/// that may be refused instantly in one environment and hang in another.
774///
775/// **Compare-and-delete, not delete.** A refresh can rotate the row between
776/// the read and here (the live app refreshes under an in-process lock that
777/// neither the operator's revoke-all nor a racing `/logout` holds). An
778/// unconditional delete then removed the ROTATED token, which was never
779/// revoked; the caller now re-reads and revokes that one instead.
780async fn bounded_then_delete<F>(
781    pool: &sqlx::SqlitePool,
782    sub: &str,
783    version: &super::store::SessionVersion,
784    deadline: std::time::Duration,
785    attempt: F,
786) -> Attempt
787where
788    F: std::future::Future<Output = Revocation>,
789{
790    let outcome = match tokio::time::timeout(deadline, attempt).await {
791        Ok(outcome) => outcome,
792        Err(_) => Revocation::Failed(format!(
793            "revocation did not finish within {deadline:?}; signing out locally anyway"
794        )),
795    };
796
797    // Unconditional on the OUTCOME — the user asked to be logged out — but
798    // conditional on the row being the one whose tokens were just revoked.
799    match super::store::delete_session_if_unchanged(pool, sub, version).await {
800        Ok(true) => Attempt::Done(outcome),
801        Ok(false) => Attempt::Changed(outcome),
802        Err(err) => Attempt::Done(Revocation::Failed(format!(
803            "deleting the local session: {err:#}"
804        ))),
805    }
806}
807
808#[cfg(test)]
809mod tests {
810    use super::*;
811
812    fn session(access: &str, refresh: &str) -> OAuthSession {
813        OAuthSession {
814            sub: "did:plc:ewvi7nxzyoun6zhxrhs64oiz".into(),
815            issuer: "https://pds.example.com".into(),
816            aud: "https://pds.example.com".into(),
817            dpop_key_jwk: r#"{"kty":"EC"}"#.into(),
818            access_token: access.into(),
819            refresh_token: refresh.into(),
820            token_type: "DPoP".into(),
821            granted_scope: "atproto".into(),
822            expires_at: Some(1_700_000_000),
823        }
824    }
825
826    /// **The refresh token is the one that matters.**
827    ///
828    /// Revoking the access token alone ends a session that was going to expire
829    /// within the hour anyway, and leaves live the one credential that can mint
830    /// replacements indefinitely. RFC 7009 §2.1 makes revoking the refresh token
831    /// cover both.
832    #[test]
833    fn the_refresh_token_is_preferred_over_the_access_token() {
834        let session = session("access-abc", "refresh-xyz");
835        let (token, hint) = token_to_revoke(&session);
836        assert_eq!(
837            token, "refresh-xyz",
838            "revoked the access token, leaving the refresh token live"
839        );
840        assert_eq!(hint, "refresh_token");
841    }
842
843    /// A token response may omit `refresh_token` entirely. Then the access token
844    /// is all there is, and revoking it is better than revoking nothing.
845    #[test]
846    fn an_absent_refresh_token_falls_back_to_the_access_token() {
847        let session = session("access-abc", "");
848        let (token, hint) = token_to_revoke(&session);
849        assert_eq!(token, "access-abc");
850        assert_eq!(hint, "access_token");
851    }
852
853    /// A public client sends `client_id` and no assertion — the same rule the
854    /// rest of the client-auth surface follows.
855    #[test]
856    fn a_public_client_sends_the_token_and_its_client_id() {
857        let params = revoke_params(AuthMethod::None, "http://localhost", None, "refresh-xyz")
858            .expect("a public client needs no assertion");
859        assert!(params.contains(&("token", "refresh-xyz".to_string())));
860        assert!(params.contains(&("client_id", "http://localhost".to_string())));
861        assert!(
862            !params
863                .iter()
864                .any(|(k, _)| k.starts_with("client_assertion")),
865            "a public client must not send an assertion it never registered: {params:?}"
866        );
867    }
868
869    /// A confidential client carries its assertion, so revocation authenticates
870    /// the same way PAR and token do.
871    #[test]
872    fn a_confidential_client_carries_its_assertion() {
873        let params = revoke_params(
874            AuthMethod::PrivateKeyJwt,
875            "https://feather-reader.com/oauth/client-metadata.json",
876            Some("the.assertion.jwt"),
877            "refresh-xyz",
878        )
879        .expect("an assertion was supplied");
880        assert!(params.contains(&("client_assertion", "the.assertion.jwt".to_string())));
881    }
882
883    /// Revocation must not authenticate as an unauthenticated request when the
884    /// assertion is missing — that would silently fail at the server and report
885    /// success locally.
886    #[test]
887    fn a_confidential_client_without_an_assertion_is_an_error() {
888        let err = revoke_params(AuthMethod::PrivateKeyJwt, "https://client", None, "tok")
889            .expect_err("must not send an unauthenticated revocation");
890        assert!(format!("{err:#}").contains("requires a client assertion"));
891    }
892
893    // ---- the sign-out invariant -------------------------------------------
894
895    const KEY: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
896    const DID: &str = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
897    const NOW: i64 = 1_700_000_000;
898
899    async fn db() -> (sqlx::SqlitePool, super::super::crypto::Codec) {
900        let pool = crate::store::init_url("sqlite::memory:").await.unwrap();
901        super::super::store::init_schema(&pool).await.unwrap();
902        (pool, super::super::crypto::Codec::new(Some(KEY)).unwrap())
903    }
904
905    /// A real session row, with a real DPoP key so the request gets as far as
906    /// the network rather than failing on key parsing.
907    async fn stored(pool: &sqlx::SqlitePool, codec: &super::super::crypto::Codec) -> OAuthSession {
908        let key = super::super::keys::SigningKey::generate("session");
909        let session = OAuthSession {
910            dpop_key_jwk: key.to_jwk_json().unwrap(),
911            ..session("access-abc", "refresh-xyz")
912        };
913        super::super::store::put_session(pool, codec, &session)
914            .await
915            .unwrap();
916        session
917    }
918
919    /// A short deadline: the property under test is that the wait is BOUNDED,
920    /// and proving that with the production five seconds would make the whole
921    /// suite ten times slower for one assertion.
922    const TEST_DEADLINE: std::time::Duration = std::time::Duration::from_millis(250);
923
924    fn ctx(endpoint: Option<&str>) -> RevokeContext<'_> {
925        RevokeContext {
926            revocation_endpoint: endpoint,
927            client_id: "http://localhost",
928            auth_method: AuthMethod::None,
929            client_key: None,
930            deadline: TEST_DEADLINE,
931        }
932    }
933
934    /// **The session must be gone even when the server could not be told.**
935    ///
936    /// This is the whole reason the delete is unconditional. If revocation
937    /// failing aborted the sign-out, then a PDS that is down — or simply slow —
938    /// would leave a fully usable session in the database of a user who has been
939    /// shown a "signed out" page. The loopback endpoint here is refused by the
940    /// SSRF guard, which is a revocation failure that needs no network.
941    #[tokio::test]
942    async fn signing_out_deletes_the_local_session_even_when_revocation_fails() {
943        let (pool, codec) = db().await;
944        stored(&pool, &codec).await;
945
946        let outcome = sign_out(
947            &pool,
948            &codec,
949            &reqwest::Client::new(),
950            &ctx(Some("http://127.0.0.1/oauth/revoke")),
951            DID,
952            NOW,
953        )
954        .await;
955
956        match &outcome {
957            Revocation::Failed(reason) => assert!(
958                reason.contains("forbidden (internal) address"),
959                "failed BEFORE reaching the network, so this proves nothing about a \
960                 revocation failure: {reason}"
961            ),
962            other => panic!("the loopback endpoint must not report success: {other:?}"),
963        }
964        assert!(
965            super::super::store::get_session(&pool, &codec, DID)
966                .await
967                .unwrap()
968                .is_none(),
969            "THE SESSION SURVIVED A FAILED REVOCATION — a signed-out user still has live credentials"
970        );
971    }
972
973    /// A server with no `revocation_endpoint` cannot be told, but the user is
974    /// still signed out locally.
975    #[tokio::test]
976    async fn a_server_without_a_revocation_endpoint_still_signs_out_locally() {
977        let (pool, codec) = db().await;
978        stored(&pool, &codec).await;
979
980        let outcome = sign_out(&pool, &codec, &reqwest::Client::new(), &ctx(None), DID, NOW).await;
981
982        match &outcome {
983            Revocation::Failed(reason) => assert!(
984                reason.contains("no revocation endpoint"),
985                "failed for the wrong reason: {reason}"
986            ),
987            other => panic!("expected a failure, got {other:?}"),
988        }
989        assert!(super::super::store::get_session(&pool, &codec, DID)
990            .await
991            .unwrap()
992            .is_none());
993    }
994
995    /// **A dead authorization server must not hold a sign-out open — and the
996    /// bound must cover DISCOVERY, not just the revocation request.**
997    ///
998    /// Bounding only the request left the real wait unbounded: discovery makes
999    /// two guarded fetches with a 30-second timeout each, so an unreachable PDS
1000    /// held the sign-out for a minute before the deadline began. The earlier
1001    /// version of this test missed that by exercising `sign_out` rather than the
1002    /// function production calls, and it reached the network to do it — so where
1003    /// outbound was refused it passed instantly, proving nothing.
1004    ///
1005    /// Exercised against a future that never resolves, with a short deadline:
1006    /// deterministic, no network, and it proves the bound rather than observing
1007    /// how long a particular host happens to take to refuse a connection.
1008    #[tokio::test]
1009    async fn a_hanging_attempt_does_not_hold_the_sign_out_open() {
1010        let (pool, codec) = db().await;
1011        stored(&pool, &codec).await;
1012
1013        let (_, version) = super::super::store::get_session_versioned(&pool, &codec, DID)
1014            .await
1015            .unwrap()
1016            .unwrap();
1017        let started = std::time::Instant::now();
1018        let attempt = super::bounded_then_delete(
1019            &pool,
1020            DID,
1021            &version,
1022            std::time::Duration::from_millis(50),
1023            std::future::pending::<Revocation>(),
1024        )
1025        .await;
1026        assert!(
1027            started.elapsed() < std::time::Duration::from_secs(2),
1028            "the bound did not fire"
1029        );
1030
1031        let Attempt::Done(outcome) = attempt else {
1032            panic!("the unchanged row was not deleted: {attempt:?}");
1033        };
1034        match &outcome {
1035            Revocation::Failed(reason) => assert!(
1036                reason.contains("did not finish within"),
1037                "failed for the wrong reason: {reason}"
1038            ),
1039            other => panic!("a never-resolving attempt must time out, got {other:?}"),
1040        }
1041        assert!(
1042            super::super::store::get_session(&pool, &codec, DID)
1043                .await
1044                .unwrap()
1045                .is_none(),
1046            "the session survived a timed-out revocation"
1047        );
1048    }
1049
1050    /// **An UNREADABLE session row is still deleted.**
1051    ///
1052    /// A row whose bound context was altered no longer decrypts, so
1053    /// `get_session` returns an error. Returning early without deleting left
1054    /// that row in place — and because every repo call reads it, the account
1055    /// then failed on every page load with no way out but a sign-out that had
1056    /// just refused to clear it.
1057    #[tokio::test]
1058    async fn an_unreadable_session_is_still_signed_out() {
1059        let (pool, codec) = db().await;
1060        stored(&pool, &codec).await;
1061
1062        // Break the AAD binding the way a tampered row would.
1063        sqlx::query("UPDATE oauth_session SET issuer = ? WHERE sub = ?")
1064            .bind("https://evil.example")
1065            .bind(DID)
1066            .execute(&pool)
1067            .await
1068            .unwrap();
1069        assert!(
1070            super::super::store::get_session(&pool, &codec, DID)
1071                .await
1072                .is_err(),
1073            "precondition: the row must be unreadable"
1074        );
1075
1076        let outcome = sign_out(
1077            &pool,
1078            &codec,
1079            &reqwest::Client::new(),
1080            &ctx(Some("https://pds.example.com/oauth/revoke")),
1081            DID,
1082            NOW,
1083        )
1084        .await;
1085        assert!(matches!(outcome, Revocation::Failed(_)), "got {outcome:?}");
1086
1087        let still_there: i64 =
1088            sqlx::query_scalar("SELECT COUNT(*) FROM oauth_session WHERE sub = ?")
1089                .bind(DID)
1090                .fetch_one(&pool)
1091                .await
1092                .unwrap();
1093        assert_eq!(
1094            still_there, 0,
1095            "an unreadable row survived a sign-out, so the account stays wedged"
1096        );
1097    }
1098
1099    // ── the function production actually calls ───────────────────────────────
1100
1101    /// A runtime whose codec matches the test database's, so a stored session is
1102    /// readable. Loopback public URL => a public client, so no key file.
1103    fn runtime() -> super::super::runtime::OauthRuntime {
1104        super::super::runtime::OauthRuntime::new(&crate::config::Config {
1105            repo_backend: crate::metrics::Backend::Rust,
1106            public_url: "http://127.0.0.1:8080".into(),
1107            oauth: crate::config::OauthConfig {
1108                encryption_key: Some(KEY.to_string()),
1109                ..crate::config::OauthConfig::default()
1110            },
1111            ..crate::config::Config::default()
1112        })
1113        .expect("the test runtime must build")
1114    }
1115
1116    /// **`sign_out_discovering` is what `/logout` and `/account/delete` call,
1117    /// and it had no test of its own at all.**
1118    ///
1119    /// A mutation replacing this entire function with `return NoSession` — never
1120    /// revoking, never deleting — passed all 575 tests. Every sign-out invariant
1121    /// was pinned one layer below, on `sign_out`, which production reaches only
1122    /// through this wrapper.
1123    ///
1124    /// The PDS here is unreachable (loopback, refused by the SSRF guard), which
1125    /// is the case that matters: the local row must go even when the server
1126    /// cannot be told.
1127    #[tokio::test]
1128    async fn the_production_sign_out_deletes_the_session() {
1129        let (pool, codec) = db().await;
1130        stored(&pool, &codec).await;
1131
1132        let outcome =
1133            sign_out_discovering(&runtime(), &reqwest::Client::new(), &pool, DID, NOW).await;
1134
1135        assert!(
1136            matches!(outcome, Revocation::Failed(_)),
1137            "an unreachable PDS must not report success: {outcome:?}"
1138        );
1139        assert!(
1140            super::super::store::get_session(&pool, &codec, DID)
1141                .await
1142                .unwrap()
1143                .is_none(),
1144            "the production sign-out left the session behind"
1145        );
1146    }
1147
1148    /// **An unreadable row is deleted by the production path too.**
1149    ///
1150    /// `sign_out` was fixed for this; its caller was not. The row is what every
1151    /// pre-AAD-change row now is, and what rotating the encryption key produces
1152    /// — and since `purge_did_data` does not touch the OAuth tables, `POST
1153    /// /account/delete` depends on this path to clear it.
1154    #[tokio::test]
1155    async fn the_production_sign_out_deletes_an_unreadable_session() {
1156        let (pool, codec) = db().await;
1157        stored(&pool, &codec).await;
1158        sqlx::query("UPDATE oauth_session SET issuer = ? WHERE sub = ?")
1159            .bind("https://evil.example")
1160            .bind(DID)
1161            .execute(&pool)
1162            .await
1163            .unwrap();
1164
1165        let outcome =
1166            sign_out_discovering(&runtime(), &reqwest::Client::new(), &pool, DID, NOW).await;
1167        assert!(matches!(outcome, Revocation::Failed(_)), "got {outcome:?}");
1168
1169        let rows: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM oauth_session WHERE sub = ?")
1170            .bind(DID)
1171            .fetch_one(&pool)
1172            .await
1173            .unwrap();
1174        assert_eq!(rows, 0, "account deletion would leave live tokens behind");
1175    }
1176
1177    /// Logging out twice is not an error. The second call has nothing to revoke
1178    /// and says so, rather than reporting a failure the caller would log.
1179    #[tokio::test]
1180    async fn signing_out_without_a_session_is_idempotent() {
1181        let (pool, codec) = db().await;
1182        let outcome = sign_out(
1183            &pool,
1184            &codec,
1185            &reqwest::Client::new(),
1186            &ctx(Some("https://pds.example.com/oauth/revoke")),
1187            DID,
1188            NOW,
1189        )
1190        .await;
1191        assert_eq!(outcome, Revocation::NoSession);
1192    }
1193
1194    // ── the operator revoke-all ──────────────────────────────────────────────
1195
1196    /// The three subjects the revoke-all tests store, in the order
1197    /// `list_session_subs` returns them (sorted), which is the order the
1198    /// revocation requests are made in.
1199    const SUBS: [&str; 3] = [
1200        "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa",
1201        "did:plc:bbbbbbbbbbbbbbbbbbbbbbbb",
1202        "did:plc:cccccccccccccccccccccccc",
1203    ];
1204
1205    /// A PDS + authorization server on one real-TLS loopback server, whose
1206    /// metadata advertises `/revoke`, answering successive revocations with
1207    /// `revoke_replies` in turn (the last repeats).
1208    async fn revoking_server(
1209        revoke_replies: Vec<crate::net::TestResponse>,
1210    ) -> (
1211        String,
1212        String,
1213        std::sync::Arc<std::sync::Mutex<Vec<String>>>,
1214    ) {
1215        let (addr, log) = crate::net::spawn_tls(move |addr| {
1216            let port = addr.port();
1217            let pds = format!("https://pds-e2e.test:{port}");
1218            let issuer = format!("https://as-e2e.test:{port}");
1219            let mut r = std::collections::HashMap::new();
1220            r.insert(
1221                "/.well-known/oauth-protected-resource".to_string(),
1222                vec![crate::net::TestResponse::json(
1223                    200,
1224                    serde_json::json!({
1225                        "resource": pds,
1226                        "authorization_servers": [issuer],
1227                    })
1228                    .to_string(),
1229                )],
1230            );
1231            r.insert(
1232                "/.well-known/oauth-authorization-server".to_string(),
1233                vec![crate::net::TestResponse::json(
1234                    200,
1235                    serde_json::json!({
1236                        "issuer": issuer,
1237                        "pushed_authorization_request_endpoint": format!("{issuer}/par"),
1238                        "authorization_endpoint": format!("{issuer}/authorize"),
1239                        "token_endpoint": format!("{issuer}/token"),
1240                        "revocation_endpoint": format!("{issuer}/revoke"),
1241                        "protected_resources": [pds],
1242                        "client_id_metadata_document_supported": true,
1243                        "require_pushed_authorization_requests": true,
1244                        "authorization_response_iss_parameter_supported": true,
1245                        "token_endpoint_auth_methods_supported": ["private_key_jwt", "none"],
1246                        "token_endpoint_auth_signing_alg_values_supported": ["ES256"],
1247                        "dpop_signing_alg_values_supported": ["ES256"],
1248                        "scopes_supported": ["atproto"],
1249                        "response_types_supported": ["code"],
1250                        "grant_types_supported": ["authorization_code", "refresh_token"],
1251                        "code_challenge_methods_supported": ["S256"],
1252                    })
1253                    .to_string(),
1254                )],
1255            );
1256            r.insert("/revoke".to_string(), revoke_replies);
1257            r
1258        })
1259        .await;
1260        for h in ["pds-e2e.test", "as-e2e.test"] {
1261            crate::net::test_host_override(h, addr);
1262        }
1263        let port = addr.port();
1264        (
1265            format!("https://pds-e2e.test:{port}"),
1266            format!("https://as-e2e.test:{port}"),
1267            log,
1268        )
1269    }
1270
1271    /// Store one readable session per subject in [`SUBS`], against `pds`/`issuer`.
1272    async fn store_sessions(
1273        pool: &sqlx::SqlitePool,
1274        codec: &super::super::crypto::Codec,
1275        pds: &str,
1276        issuer: &str,
1277    ) {
1278        for sub in SUBS {
1279            let key = super::super::keys::SigningKey::generate("session");
1280            let session = OAuthSession {
1281                sub: sub.into(),
1282                issuer: issuer.into(),
1283                aud: pds.into(),
1284                dpop_key_jwk: key.to_jwk_json().unwrap(),
1285                ..session("access-abc", &format!("refresh-{sub}"))
1286            };
1287            super::super::store::put_session(pool, codec, &session)
1288                .await
1289                .unwrap();
1290        }
1291    }
1292
1293    async fn session_rows(pool: &sqlx::SqlitePool) -> i64 {
1294        sqlx::query_scalar("SELECT COUNT(*) FROM oauth_session")
1295            .fetch_one(pool)
1296            .await
1297            .unwrap()
1298    }
1299
1300    fn revoke_requests(log: &std::sync::Mutex<Vec<String>>) -> Vec<String> {
1301        log.lock()
1302            .unwrap()
1303            .iter()
1304            .filter(|r| r.starts_with("POST /revoke"))
1305            .cloned()
1306            .collect()
1307    }
1308
1309    /// **Every stored session is revoked at its server, and every row goes.**
1310    ///
1311    /// The operator path a teardown runs before wiping the database. Each
1312    /// revocation must actually reach the server — a revoke-all that only
1313    /// deleted rows would leave every refresh token live at the PDS, which is
1314    /// the bug this exists to close (#257).
1315    #[tokio::test]
1316    async fn revoke_all_revokes_every_session_at_its_server() {
1317        let (pool, codec) = db().await;
1318        let (pds, issuer, log) =
1319            revoking_server(vec![crate::net::TestResponse::json(200, "{}")]).await;
1320        store_sessions(&pool, &codec, &pds, &issuer).await;
1321
1322        let report = revoke_all(&runtime(), &reqwest::Client::new(), &pool, || NOW)
1323            .await
1324            .expect("listing the sessions");
1325
1326        let requests = revoke_requests(&log);
1327        assert_eq!(
1328            requests.len(),
1329            3,
1330            "one revocation request per session:\n{requests:#?}"
1331        );
1332        for sub in SUBS {
1333            assert!(
1334                requests
1335                    .iter()
1336                    .any(|r| r.contains(&format!("token=refresh-{}", sub.replace(':', "%3A")))),
1337                "{sub}'s refresh token was never presented:\n{requests:#?}"
1338            );
1339        }
1340        assert_eq!(report.revoked, SUBS.map(String::from).to_vec());
1341        assert!(report.failed.is_empty(), "{:?}", report.failed);
1342        assert!(report.no_session.is_empty());
1343        assert_eq!(session_rows(&pool).await, 0, "rows survived a revoke-all");
1344    }
1345
1346    /// **One server failing does not stop the rest — and its row still goes.**
1347    ///
1348    /// Aborting at the first failure would leave every later session neither
1349    /// revoked nor deleted. The failure is reported with its reason so the
1350    /// operator knows which tokens may still be live.
1351    #[tokio::test]
1352    async fn one_failed_revocation_is_reported_and_the_rest_still_revoked() {
1353        let (pool, codec) = db().await;
1354        let (pds, issuer, log) = revoking_server(vec![
1355            crate::net::TestResponse::json(200, "{}"),
1356            crate::net::TestResponse::json(500, "{}"),
1357            crate::net::TestResponse::json(200, "{}"),
1358        ])
1359        .await;
1360        store_sessions(&pool, &codec, &pds, &issuer).await;
1361
1362        let report = revoke_all(&runtime(), &reqwest::Client::new(), &pool, || NOW)
1363            .await
1364            .expect("listing the sessions");
1365
1366        assert_eq!(revoke_requests(&log).len(), 3, "a failure stopped the walk");
1367        assert!(
1368            report.late.is_empty(),
1369            "the first walk missed sessions a re-list had to find: {:?}",
1370            report.late
1371        );
1372        assert_eq!(
1373            report.revoked,
1374            vec![SUBS[0].to_string(), SUBS[2].to_string()]
1375        );
1376        assert_eq!(report.failed.len(), 1, "{:?}", report.failed);
1377        assert_eq!(report.failed[0].0, SUBS[1]);
1378        assert!(
1379            report.failed[0].1.contains("status 500"),
1380            "the reason was lost: {}",
1381            report.failed[0].1
1382        );
1383        assert_eq!(
1384            session_rows(&pool).await,
1385            0,
1386            "the failed session's row survived — the wipe would be the only thing removing it"
1387        );
1388    }
1389
1390    /// An unreadable row is reported as a failure AND deleted; an empty store
1391    /// is an empty report.
1392    #[tokio::test]
1393    async fn an_unreadable_row_fails_and_is_deleted_and_an_empty_store_is_empty() {
1394        let (pool, codec) = db().await;
1395        let report = revoke_all(&runtime(), &reqwest::Client::new(), &pool, || NOW)
1396            .await
1397            .unwrap();
1398        assert_eq!(report, RevokeAllReport::default());
1399
1400        stored(&pool, &codec).await;
1401        sqlx::query("UPDATE oauth_session SET issuer = ? WHERE sub = ?")
1402            .bind("https://evil.example")
1403            .bind(DID)
1404            .execute(&pool)
1405            .await
1406            .unwrap();
1407
1408        let report = revoke_all(&runtime(), &reqwest::Client::new(), &pool, || NOW)
1409            .await
1410            .unwrap();
1411        assert!(report.revoked.is_empty());
1412        assert_eq!(report.failed.len(), 1);
1413        assert_eq!(report.failed[0].0, DID);
1414        assert!(
1415            report.failed[0].1.contains("reading the session"),
1416            "{}",
1417            report.failed[0].1
1418        );
1419        assert_eq!(session_rows(&pool).await, 0, "the unreadable row survived");
1420    }
1421
1422    /// A CONFIDENTIAL client runtime (non-loopback public URL), with its
1423    /// signing key in a per-test temp file rather than the working directory.
1424    fn confidential_runtime(tag: &str) -> super::super::runtime::OauthRuntime {
1425        super::super::runtime::OauthRuntime::new(&crate::config::Config {
1426            repo_backend: crate::metrics::Backend::Rust,
1427            public_url: "https://feather-reader.com".into(),
1428            oauth: crate::config::OauthConfig {
1429                encryption_key: Some(KEY.to_string()),
1430                key_path: std::env::temp_dir().join(format!(
1431                    "fr-revoke-test-key-{}-{tag}.json",
1432                    std::process::id()
1433                )),
1434                plc_directory: "https://plc.invalid".to_string(),
1435                ..crate::config::OauthConfig::default()
1436            },
1437            ..crate::config::Config::default()
1438        })
1439        .expect("the confidential test runtime must build")
1440    }
1441
1442    /// The `iat` of the client assertion in one recorded revocation request.
1443    fn assertion_iat(raw: &str) -> i64 {
1444        use base64::Engine as _;
1445        let body = raw.split("\r\n\r\n").nth(1).expect("no request body");
1446        let jwt = body
1447            .split('&')
1448            .find_map(|kv| kv.strip_prefix("client_assertion="))
1449            .unwrap_or_else(|| panic!("no client_assertion in {body}"));
1450        let payload = jwt.split('.').nth(1).expect("malformed assertion");
1451        let json: serde_json::Value = serde_json::from_slice(
1452            &base64::engine::general_purpose::URL_SAFE_NO_PAD
1453                .decode(payload)
1454                .expect("assertion payload is not base64url"),
1455        )
1456        .expect("assertion payload is not JSON");
1457        json["iat"].as_i64().expect("assertion has no iat")
1458    }
1459
1460    /// **Each revocation's client assertion is minted at ITS OWN time.**
1461    ///
1462    /// A client assertion lives 60 s (`exp = iat + 60`). Taking one `now` at
1463    /// the start of the walk and reusing it meant that once the walk passed a
1464    /// minute — a few hundred sessions, or a handful of slow PDSes at the
1465    /// five-second deadline — every later assertion was already expired when
1466    /// sent. Each of those revocations is rejected as `invalid_client`, the row
1467    /// is deleted anyway, and the teardown proceeds over live tokens.
1468    ///
1469    /// The clock here advances 100 s per reading, so a stale `now` shows up as
1470    /// three identical `iat`s.
1471    #[tokio::test]
1472    async fn each_revocation_takes_the_time_afresh() {
1473        let (pool, codec) = db().await;
1474        let (pds, issuer, log) =
1475            revoking_server(vec![crate::net::TestResponse::json(200, "{}")]).await;
1476        store_sessions(&pool, &codec, &pds, &issuer).await;
1477
1478        let mut tick = 0;
1479        let clock = || {
1480            let t = NOW + tick * 100;
1481            tick += 1;
1482            t
1483        };
1484        let report = revoke_all(
1485            &confidential_runtime("clock"),
1486            &reqwest::Client::new(),
1487            &pool,
1488            clock,
1489        )
1490        .await
1491        .expect("listing the sessions");
1492        assert_eq!(report.revoked.len(), 3, "{report:?}");
1493
1494        let iats: Vec<i64> = revoke_requests(&log)
1495            .iter()
1496            .map(|r| assertion_iat(r))
1497            .collect();
1498        assert_eq!(
1499            iats,
1500            vec![NOW, NOW + 100, NOW + 200],
1501            "the assertions reused one timestamp — later ones would be expired on arrival"
1502        );
1503    }
1504
1505    // ── a refresh racing the sign-out ────────────────────────────────────────
1506
1507    /// Store a session with refresh token `refresh` under a fresh codec — what
1508    /// the live app's refresh does, from its own process, with the same key.
1509    async fn rotate_to(pool: &sqlx::SqlitePool, refresh: &str) {
1510        let codec = super::super::crypto::Codec::new(Some(KEY)).unwrap();
1511        let key = super::super::keys::SigningKey::generate("session");
1512        let session = OAuthSession {
1513            dpop_key_jwk: key.to_jwk_json().unwrap(),
1514            ..session("access-rotated", refresh)
1515        };
1516        super::super::store::put_session(pool, &codec, &session)
1517            .await
1518            .unwrap();
1519    }
1520
1521    type Seen = std::sync::Arc<std::sync::Mutex<Vec<String>>>;
1522
1523    /// **A refresh that rotates the row mid-sign-out gets ITS token revoked
1524    /// too — and the new token's row is never deleted unrevoked.**
1525    ///
1526    /// The live app refreshes under an in-process lock the operator's
1527    /// revoke-all (a separate process) cannot take. The sign-out read R1; the
1528    /// app rotated to R2; the PDS answers 200 for the already-rotated R1; and
1529    /// an unconditional delete then removed the row holding R2 — reported as
1530    /// revoked, never revoked, and gone from the record the post-stop sweep
1531    /// reads. The same interleaving reaches `/logout` against a request's
1532    /// background refresh, which takes that lock while `/logout` does not.
1533    #[tokio::test]
1534    async fn a_session_rotated_mid_sign_out_has_the_new_token_revoked_too() {
1535        let (pool, codec) = db().await;
1536        stored(&pool, &codec).await;
1537        let seen: Seen = Default::default();
1538
1539        let outcome = sign_out_with(&pool, &codec, DID, TEST_DEADLINE, |s| {
1540            let (pool, seen) = (pool.clone(), seen.clone());
1541            async move {
1542                let first = {
1543                    let mut v = seen.lock().unwrap();
1544                    v.push(s.refresh_token.clone());
1545                    v.len() == 1
1546                };
1547                if first {
1548                    // The app's refresh lands between our read and our delete.
1549                    rotate_to(&pool, "refresh-R2").await;
1550                }
1551                Revocation::Revoked
1552            }
1553        })
1554        .await;
1555
1556        assert_eq!(
1557            *seen.lock().unwrap(),
1558            vec!["refresh-xyz".to_string(), "refresh-R2".to_string()],
1559            "the rotated token was never presented for revocation"
1560        );
1561        assert_eq!(outcome, Revocation::Revoked);
1562        assert!(
1563            super::super::store::get_session(&pool, &codec, DID)
1564                .await
1565                .unwrap()
1566                .is_none(),
1567            "the session survived a sign-out that revoked every version of it"
1568        );
1569    }
1570
1571    /// A row that keeps rotating is given up on after a bounded number of
1572    /// attempts — reported as a failure, and the LATEST token left on record
1573    /// (so a later pass can revoke it) rather than deleted unrevoked.
1574    #[tokio::test]
1575    async fn a_session_that_keeps_rotating_is_reported_and_left_in_place() {
1576        let (pool, codec) = db().await;
1577        stored(&pool, &codec).await;
1578        let seen: Seen = Default::default();
1579
1580        let outcome = sign_out_with(&pool, &codec, DID, TEST_DEADLINE, |s| {
1581            let (pool, seen) = (pool.clone(), seen.clone());
1582            async move {
1583                let n = {
1584                    let mut v = seen.lock().unwrap();
1585                    v.push(s.refresh_token.clone());
1586                    v.len()
1587                };
1588                rotate_to(&pool, &format!("refresh-R{}", n + 1)).await;
1589                Revocation::Revoked
1590            }
1591        })
1592        .await;
1593
1594        let attempts = seen.lock().unwrap().len();
1595        assert_eq!(attempts, 3, "the retries were not bounded at 3");
1596        match &outcome {
1597            Revocation::Failed(reason) => assert!(
1598                reason.contains("kept changing"),
1599                "failed for the wrong reason: {reason}"
1600            ),
1601            other => panic!("a still-rotating session must not report success: {other:?}"),
1602        }
1603        let left = super::super::store::get_session(&pool, &codec, DID)
1604            .await
1605            .unwrap()
1606            .expect("the newest token was deleted unrevoked");
1607        assert_eq!(left.refresh_token, "refresh-R4");
1608    }
1609
1610    // ── sessions that appear during the walk ─────────────────────────────────
1611
1612    async fn insert_raw(pool: &sqlx::SqlitePool, sub: &str) {
1613        sqlx::query(
1614            "INSERT OR REPLACE INTO oauth_session (sub, issuer, aud, dpop_key_jwk, \
1615             access_token, refresh_token, token_type, granted_scope, expires_at) \
1616             VALUES (?, 'https://as.invalid', 'https://pds.invalid', 'x', 'x', 'x', \
1617             'DPoP', 'atproto', NULL)",
1618        )
1619        .bind(sub)
1620        .execute(pool)
1621        .await
1622        .unwrap();
1623    }
1624
1625    /// **A session created during the walk is found and signed out too.**
1626    ///
1627    /// The main pass runs while the app still serves (it must — the PDSes
1628    /// fetch our client metadata to authenticate the revocation), so a login
1629    /// can land behind the walk's cursor. Defence in depth: after the walk the
1630    /// store is listed again, and anything new is walked as well.
1631    #[tokio::test]
1632    async fn a_session_created_during_the_walk_is_signed_out_by_a_re_list() {
1633        let (pool, _) = db().await;
1634        insert_raw(&pool, SUBS[0]).await;
1635        let calls: Seen = Default::default();
1636
1637        let report = revoke_all_with(
1638            &pool,
1639            || NOW,
1640            |sub, _| {
1641                let (pool, calls) = (pool.clone(), calls.clone());
1642                async move {
1643                    let first = {
1644                        let mut c = calls.lock().unwrap();
1645                        c.push(sub.clone());
1646                        c.len() == 1
1647                    };
1648                    if first {
1649                        // A user logs in while the first session is revoked.
1650                        insert_raw(&pool, SUBS[1]).await;
1651                    }
1652                    super::super::store::delete_session(&pool, &sub)
1653                        .await
1654                        .unwrap();
1655                    Revocation::Revoked
1656                }
1657            },
1658        )
1659        .await
1660        .unwrap();
1661
1662        assert_eq!(
1663            *calls.lock().unwrap(),
1664            vec![SUBS[0].to_string(), SUBS[1].to_string()],
1665            "the session created mid-walk was never signed out"
1666        );
1667        assert_eq!(report.late, vec![SUBS[1].to_string()]);
1668        assert_eq!(report.revoked.len(), 2);
1669        assert_eq!(session_rows(&pool).await, 0);
1670    }
1671
1672    /// The re-list is bounded: a store that keeps refilling is walked at most
1673    /// twice more, and what remains is reported as FAILED (so the exit code
1674    /// and the teardown say so) rather than looping forever.
1675    #[tokio::test]
1676    async fn the_re_list_is_bounded_and_reports_what_remains() {
1677        let (pool, _) = db().await;
1678        insert_raw(&pool, SUBS[0]).await;
1679        let calls: Seen = Default::default();
1680
1681        let report = revoke_all_with(
1682            &pool,
1683            || NOW,
1684            |sub, _| {
1685                let calls = calls.clone();
1686                async move {
1687                    calls.lock().unwrap().push(sub);
1688                    // Never removed: it keeps coming back.
1689                    Revocation::Revoked
1690                }
1691            },
1692        )
1693        .await
1694        .unwrap();
1695
1696        assert_eq!(
1697            calls.lock().unwrap().len(),
1698            3,
1699            "not bounded at 1 + 2 passes"
1700        );
1701        let still = report
1702            .failed
1703            .iter()
1704            .find(|(sub, _)| sub == SUBS[0])
1705            .expect("a session still stored after every pass was not reported");
1706        assert!(still.1.contains("still stored"), "{}", still.1);
1707        assert_eq!(
1708            report.failed.len(),
1709            1,
1710            "one entry per DID: {:?}",
1711            report.failed
1712        );
1713        assert!(
1714            report.revoked.is_empty(),
1715            "a DID still stored at the end was ALSO reported revoked: {:?}",
1716            report.revoked
1717        );
1718        assert!(
1719            report.late.is_empty(),
1720            "a row present from the start is not one that appeared during the walk"
1721        );
1722    }
1723
1724    /// **The report is the FINAL outcome per DID.** A session that fails in
1725    /// the first pass (left in place — it kept rotating) and is revoked by the
1726    /// re-list is revoked: reporting it as failed too made the run exit 3 and
1727    /// warn that its tokens "may stay live" when they had been revoked. And a
1728    /// row there from the start is not "late".
1729    #[tokio::test]
1730    async fn a_did_that_fails_then_succeeds_is_reported_revoked_only() {
1731        let (pool, _) = db().await;
1732        insert_raw(&pool, SUBS[0]).await;
1733        let calls: Seen = Default::default();
1734
1735        let report = revoke_all_with(
1736            &pool,
1737            || NOW,
1738            |sub, _| {
1739                let (pool, calls) = (pool.clone(), calls.clone());
1740                async move {
1741                    let n = {
1742                        let mut c = calls.lock().unwrap();
1743                        c.push(sub.clone());
1744                        c.len()
1745                    };
1746                    if n == 1 {
1747                        // Kept rotating: left in place, reported failed.
1748                        return Revocation::Failed("kept changing".into());
1749                    }
1750                    super::super::store::delete_session(&pool, &sub)
1751                        .await
1752                        .unwrap();
1753                    Revocation::Revoked
1754                }
1755            },
1756        )
1757        .await
1758        .unwrap();
1759
1760        assert_eq!(calls.lock().unwrap().len(), 2);
1761        assert_eq!(report.revoked, vec![SUBS[0].to_string()]);
1762        assert!(
1763            report.failed.is_empty(),
1764            "a DID revoked by the re-list is still reported failed: {:?}",
1765            report.failed
1766        );
1767        assert!(
1768            report.late.is_empty(),
1769            "mislabelled as late: {:?}",
1770            report.late
1771        );
1772    }
1773
1774    /// **One failure does not end the first walk.** Each failing session here
1775    /// is still deleted (as a real sign-out does), so a walk that stopped at
1776    /// the first failure and left the rest to the re-list would get through
1777    /// only three of four sessions before running out of passes — and report
1778    /// the fourth "still stored", never attempted.
1779    #[tokio::test]
1780    async fn every_session_is_attempted_in_the_first_walk_despite_failures() {
1781        let (pool, _) = db().await;
1782        let subs = [
1783            "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa",
1784            "did:plc:bbbbbbbbbbbbbbbbbbbbbbbb",
1785            "did:plc:cccccccccccccccccccccccc",
1786            "did:plc:dddddddddddddddddddddddd",
1787        ];
1788        for sub in subs {
1789            insert_raw(&pool, sub).await;
1790        }
1791
1792        let report = revoke_all_with(
1793            &pool,
1794            || NOW,
1795            |sub, _| {
1796                let pool = pool.clone();
1797                async move {
1798                    super::super::store::delete_session(&pool, &sub)
1799                        .await
1800                        .unwrap();
1801                    Revocation::Failed("the PDS said no".into())
1802                }
1803            },
1804        )
1805        .await
1806        .unwrap();
1807
1808        assert_eq!(report.failed.len(), 4, "{:?}", report.failed);
1809        assert!(
1810            report
1811                .failed
1812                .iter()
1813                .all(|(_, reason)| reason == "the PDS said no"),
1814            "a session was never attempted: {:?}",
1815            report.failed
1816        );
1817    }
1818
1819    /// A DID that fails in every pass is ONE failed entry, with the last
1820    /// reason — not one per pass.
1821    #[tokio::test]
1822    async fn a_did_that_fails_every_pass_is_one_failed_entry() {
1823        let (pool, _) = db().await;
1824        insert_raw(&pool, SUBS[0]).await;
1825
1826        let report = revoke_all_with(
1827            &pool,
1828            || NOW,
1829            |_, _| async { Revocation::Failed("kept changing".into()) },
1830        )
1831        .await
1832        .unwrap();
1833
1834        assert_eq!(
1835            report.failed.len(),
1836            1,
1837            "the same DID was counted once per pass: {:?}",
1838            report.failed
1839        );
1840        assert_eq!(report.failed[0].0, SUBS[0]);
1841        assert!(report.revoked.is_empty() && report.late.is_empty());
1842    }
1843
1844    /// A row that disappears mid-sign-out (a concurrent `/logout`) is not an
1845    /// error and not retried: the token that was read has been revoked, and
1846    /// there is nothing left to delete.
1847    #[tokio::test]
1848    async fn a_session_deleted_mid_sign_out_reports_the_revocation() {
1849        let (pool, codec) = db().await;
1850        stored(&pool, &codec).await;
1851        let seen: Seen = Default::default();
1852
1853        let outcome = sign_out_with(&pool, &codec, DID, TEST_DEADLINE, |s| {
1854            let (pool, seen) = (pool.clone(), seen.clone());
1855            async move {
1856                seen.lock().unwrap().push(s.refresh_token.clone());
1857                super::super::store::delete_session(&pool, DID)
1858                    .await
1859                    .unwrap();
1860                Revocation::Revoked
1861            }
1862        })
1863        .await;
1864        assert_eq!(outcome, Revocation::Revoked);
1865        assert_eq!(seen.lock().unwrap().len(), 1, "retried a row that was gone");
1866    }
1867
1868    // ── the pre-flight: the secrets are the production ones ──────────────────
1869
1870    /// A JWKS server over real TLS serving `doc` at `/oauth/jwks.json` (or
1871    /// nothing, when `doc` is `None`). Returns the URL to check.
1872    async fn jwks_server(doc: Option<String>) -> String {
1873        let (addr, _log) = crate::net::spawn_tls(move |_| {
1874            let mut r = std::collections::HashMap::new();
1875            if let Some(doc) = doc {
1876                r.insert(
1877                    "/oauth/jwks.json".to_string(),
1878                    vec![crate::net::TestResponse::json(200, doc)],
1879                );
1880            }
1881            r
1882        })
1883        .await;
1884        // `localhost`: the pre-flight's own-JWKS client does plain DNS (no
1885        // test override), and the leaf certificate covers this name.
1886        format!("https://localhost:{}/oauth/jwks.json", addr.port())
1887    }
1888
1889    async fn pool_with_readable_session(codec_key: &str) -> sqlx::SqlitePool {
1890        let (pool, _) = db().await;
1891        let codec = super::super::crypto::Codec::new(Some(codec_key)).unwrap();
1892        stored(&pool, &codec).await;
1893        pool
1894    }
1895
1896    /// **A wrong (or rotated) encryption key is refused before anything is
1897    /// signed out.** It decrypts nothing, so every sign-out would hit an
1898    /// unreadable row — and delete it. The run "completed", the teardown
1899    /// wiped, and every token was dropped unrevoked.
1900    #[tokio::test]
1901    async fn a_wrong_encryption_key_is_refused_by_the_preflight() {
1902        let rt = confidential_runtime("wrongenc");
1903        let other = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
1904        let pool = pool_with_readable_session(other).await;
1905        let url = jwks_server(Some(
1906            rt.client_key
1907                .as_ref()
1908                .unwrap()
1909                .jwks_document()
1910                .unwrap()
1911                .to_string(),
1912        ))
1913        .await;
1914
1915        let err = preflight(
1916            &rt,
1917            &pool,
1918            &url,
1919            PreflightOptions {
1920                sweep: false,
1921                accept_unreadable: false,
1922            },
1923        )
1924        .await
1925        .expect_err("a key that decrypts nothing was accepted");
1926        assert!(format!("{err:#}").contains("ENCRYPTION_KEY"), "{err:#}");
1927        assert_eq!(
1928            session_rows(&pool).await,
1929            1,
1930            "the preflight deleted something"
1931        );
1932    }
1933
1934    /// Some rows readable and some not is a real store with a few unreadable
1935    /// rows (which the walk reports as failed) — not a wrong key.
1936    #[tokio::test]
1937    async fn some_unreadable_rows_do_not_fail_the_preflight() {
1938        let rt = confidential_runtime("someunread");
1939        let pool = pool_with_readable_session(KEY).await;
1940        insert_raw(&pool, SUBS[1]).await;
1941        let url = jwks_server(Some(
1942            rt.client_key
1943                .as_ref()
1944                .unwrap()
1945                .jwks_document()
1946                .unwrap()
1947                .to_string(),
1948        ))
1949        .await;
1950        preflight(
1951            &rt,
1952            &pool,
1953            &url,
1954            PreflightOptions {
1955                sweep: false,
1956                accept_unreadable: false,
1957            },
1958        )
1959        .await
1960        .expect("one readable row proves the key");
1961    }
1962
1963    /// The loaded signing key must be the one the app SERVES. A different
1964    /// key — a relative key path resolved in the wrong directory — signs
1965    /// assertions no PDS can verify, and every row would be deleted unrevoked.
1966    /// Refused on the sweep too: a fetched mismatch is never "expected".
1967    #[tokio::test]
1968    async fn a_signing_key_the_app_does_not_serve_is_refused() {
1969        let rt = confidential_runtime("wrongsig");
1970        let pool = pool_with_readable_session(KEY).await;
1971        let stranger = super::super::keys::SigningKey::generate(super::super::runtime::CLIENT_KID);
1972        let url = jwks_server(Some(stranger.jwks_document().unwrap().to_string())).await;
1973
1974        for sweep in [false, true] {
1975            let err = preflight(
1976                &rt,
1977                &pool,
1978                &url,
1979                PreflightOptions {
1980                    sweep,
1981                    accept_unreadable: false,
1982                },
1983            )
1984            .await
1985            .expect_err("a signing key the PDSes have never seen was accepted");
1986            assert!(
1987                format!("{err:#}").contains("is not the key the app serves"),
1988                "{err:#}"
1989            );
1990        }
1991        assert_eq!(session_rows(&pool).await, 1);
1992    }
1993
1994    /// The matching key passes.
1995    #[tokio::test]
1996    async fn the_served_signing_key_passes_the_preflight() {
1997        let rt = confidential_runtime("rightsig");
1998        let pool = pool_with_readable_session(KEY).await;
1999        let url = jwks_server(Some(
2000            rt.client_key
2001                .as_ref()
2002                .unwrap()
2003                .jwks_document()
2004                .unwrap()
2005                .to_string(),
2006        ))
2007        .await;
2008        preflight(
2009            &rt,
2010            &pool,
2011            &url,
2012            PreflightOptions {
2013                sweep: false,
2014                accept_unreadable: false,
2015            },
2016        )
2017        .await
2018        .expect("the served key was refused");
2019    }
2020
2021    /// An unreachable JWKS refuses the main pass (the app is meant to be up)
2022    /// but not the post-stop sweep (it is meant to be down).
2023    #[tokio::test]
2024    async fn an_unreachable_jwks_refuses_the_main_pass_but_not_the_sweep() {
2025        let rt = confidential_runtime("nojwks");
2026        let pool = pool_with_readable_session(KEY).await;
2027        let url = jwks_server(None).await;
2028
2029        let err = preflight(
2030            &rt,
2031            &pool,
2032            &url,
2033            PreflightOptions {
2034                sweep: false,
2035                accept_unreadable: false,
2036            },
2037        )
2038        .await
2039        .expect_err("an unverifiable signing key was accepted on the main pass");
2040        assert!(format!("{err:#}").contains("could not fetch"), "{err:#}");
2041        preflight(
2042            &rt,
2043            &pool,
2044            &url,
2045            PreflightOptions {
2046                sweep: true,
2047                accept_unreadable: false,
2048            },
2049        )
2050        .await
2051        .expect("the sweep cannot reach a stopped app's JWKS, and must not need to");
2052    }
2053
2054    /// **The `Null` codec is refused even though everything else passes.** It
2055    /// "decrypts" by returning the stored value unchanged, so every row looks
2056    /// readable to the decrypt check; the served key matches. Only the fitness
2057    /// check stands between it and revoking with ciphertext for tokens.
2058    #[tokio::test]
2059    async fn a_null_codec_runtime_is_refused_even_when_the_rest_passes() {
2060        let rt = super::super::runtime::OauthRuntime::new(&crate::config::Config {
2061            repo_backend: crate::metrics::Backend::Rust,
2062            public_url: "https://feather-reader.com".into(),
2063            oauth: crate::config::OauthConfig {
2064                encryption_key: None,
2065                key_path: std::env::temp_dir().join(format!(
2066                    "fr-revoke-test-key-{}-nullcodec.json",
2067                    std::process::id()
2068                )),
2069                plc_directory: "https://plc.invalid".to_string(),
2070                ..crate::config::OauthConfig::default()
2071            },
2072            ..crate::config::Config::default()
2073        })
2074        .unwrap();
2075        assert!(matches!(rt.codec, super::super::crypto::Codec::Null));
2076        let (pool, _) = db().await;
2077        stored(&pool, &rt.codec).await;
2078        let url = jwks_server(Some(
2079            rt.client_key
2080                .as_ref()
2081                .unwrap()
2082                .jwks_document()
2083                .unwrap()
2084                .to_string(),
2085        ))
2086        .await;
2087
2088        let err = preflight(
2089            &rt,
2090            &pool,
2091            &url,
2092            PreflightOptions {
2093                sweep: false,
2094                accept_unreadable: false,
2095            },
2096        )
2097        .await
2098        .expect_err("the Null codec was accepted");
2099        assert!(format!("{err:#}").contains("encryption key"), "{err:#}");
2100    }
2101
2102    /// With nothing stored there is nothing to protect: no check, no request.
2103    #[tokio::test]
2104    async fn an_empty_store_needs_no_preflight() {
2105        let rt = confidential_runtime("emptypre");
2106        let (pool, _) = db().await;
2107        preflight(
2108            &rt,
2109            &pool,
2110            "https://unreachable.invalid/oauth/jwks.json",
2111            PreflightOptions::default(),
2112        )
2113        .await
2114        .expect("an empty store was refused");
2115    }
2116
2117    /// **A session that moves to another issuer mid sign-out has its new
2118    /// token presented ONLY to the new issuer.**
2119    ///
2120    /// The re-read after a compare-and-delete mismatch can be a different
2121    /// grant (a re-login after a PDS migration). Reusing the endpoint
2122    /// discovered for the first read posted the new refresh token to the OLD
2123    /// authorization server — a credential leak to a server that never issued
2124    /// it — which answers 200 for an unknown token: reported revoked, row
2125    /// deleted, the real grant left live. Two fake authorization servers; the
2126    /// old one must never see the new token.
2127    #[tokio::test]
2128    async fn a_session_that_moves_issuer_mid_sign_out_is_revoked_at_its_own_issuer() {
2129        let (pool, codec) = db().await;
2130        let ok = || vec![crate::net::TestResponse::json(200, "{}")];
2131        let (pds_a, iss_a, log_a) = revoking_server(ok()).await;
2132        let (pds_b, iss_b, log_b) = revoking_server(ok()).await;
2133        assert_ne!(iss_a, iss_b, "precondition: two distinct issuers");
2134
2135        let at = |pds: &str, iss: &str, refresh: &str| OAuthSession {
2136            sub: DID.into(),
2137            issuer: iss.into(),
2138            aud: pds.into(),
2139            dpop_key_jwk: super::super::keys::SigningKey::generate("session")
2140                .to_jwk_json()
2141                .unwrap(),
2142            ..session("access", refresh)
2143        };
2144        super::super::store::put_session(&pool, &codec, &at(&pds_a, &iss_a, "refresh-A"))
2145            .await
2146            .unwrap();
2147        let moved = at(&pds_b, &iss_b, "refresh-B");
2148
2149        let (rt, http, cache) = (runtime(), reqwest::Client::new(), EndpointCache::default());
2150        let (rt, http, cache, pool_ref) = (&rt, &http, &cache, &pool);
2151        let first = std::sync::atomic::AtomicBool::new(true);
2152        let (first, moved) = (&first, &moved);
2153        let outcome = sign_out_with(
2154            &pool,
2155            &codec,
2156            DID,
2157            std::time::Duration::from_secs(10),
2158            |s| async move {
2159                let r = revoke_at_own_issuer(rt, http, pool_ref, cache, &s, NOW).await;
2160                if first.swap(false, std::sync::atomic::Ordering::SeqCst) {
2161                    // A re-login at the new PDS lands between read and delete.
2162                    let codec = super::super::crypto::Codec::new(Some(KEY)).unwrap();
2163                    super::super::store::put_session(pool_ref, &codec, moved)
2164                        .await
2165                        .unwrap();
2166                }
2167                r
2168            },
2169        )
2170        .await;
2171
2172        let a = revoke_requests(&log_a).join("\n");
2173        let b = revoke_requests(&log_b).join("\n");
2174        assert!(
2175            a.contains("token=refresh-A"),
2176            "the first grant was never revoked:\n{a}"
2177        );
2178        assert!(
2179            !a.contains("refresh-B"),
2180            "the NEW grant's refresh token was sent to the OLD authorization server:\n{a}"
2181        );
2182        assert!(
2183            b.contains("token=refresh-B"),
2184            "the new grant was never revoked at its own issuer:\n{b}"
2185        );
2186        assert_eq!(outcome, Revocation::Revoked);
2187        assert_eq!(session_rows(&pool).await, 0);
2188    }
2189
2190    /// The app's JWKS served on `https://localhost:PORT` — loopback, which the
2191    /// SSRF guard refuses. Returns the URL.
2192    async fn local_jwks(doc: String) -> String {
2193        let (addr, _log) = crate::net::spawn_tls(move |_| {
2194            let mut r = std::collections::HashMap::new();
2195            r.insert(
2196                "/oauth/jwks.json".to_string(),
2197                vec![crate::net::TestResponse::json(200, doc)],
2198            );
2199            r
2200        })
2201        .await;
2202        format!("https://localhost:{}/oauth/jwks.json", addr.port())
2203    }
2204
2205    /// **The app's own JWKS is operator config, not attacker input.** A
2206    /// self-host with split-horizon DNS (its hostname resolving to a LAN or
2207    /// loopback address from inside) serves it on exactly the addresses the
2208    /// SSRF guard refuses — so with the guarded fetcher the main pass could
2209    /// never pass. It is fetched with a plain, bounded, https-only client.
2210    #[tokio::test]
2211    async fn a_jwks_on_a_loopback_address_passes_the_preflight() {
2212        let rt = confidential_runtime("loopjwks");
2213        let pool = pool_with_readable_session(KEY).await;
2214        let url = local_jwks(
2215            rt.client_key
2216                .as_ref()
2217                .unwrap()
2218                .jwks_document()
2219                .unwrap()
2220                .to_string(),
2221        )
2222        .await;
2223        preflight(&rt, &pool, &url, PreflightOptions::default())
2224            .await
2225            .expect("the app's own JWKS on loopback was refused");
2226    }
2227
2228    /// https only: the key check is worthless over a channel anyone on the
2229    /// path can rewrite.
2230    #[tokio::test]
2231    async fn a_plain_http_jwks_url_is_refused() {
2232        let rt = confidential_runtime("httpjwks");
2233        let pool = pool_with_readable_session(KEY).await;
2234        let err = preflight(
2235            &rt,
2236            &pool,
2237            "http://localhost:9/oauth/jwks.json",
2238            PreflightOptions::default(),
2239        )
2240        .await
2241        .expect_err("an http JWKS was accepted");
2242        assert!(format!("{err:#}").contains("https"), "{err:#}");
2243    }
2244
2245    /// **`--accept-unreadable` proceeds past "no row decrypts" — and only
2246    /// that.** The operator's way out for a store that is legitimately all
2247    /// unreadable (only pre-AAD rows; the key rotated with no logins since).
2248    #[tokio::test]
2249    async fn accept_unreadable_passes_an_all_unreadable_store() {
2250        let rt = confidential_runtime("acceptok");
2251        let (pool, _) = db().await;
2252        insert_raw(&pool, SUBS[0]).await;
2253        insert_raw(&pool, SUBS[1]).await;
2254        let url = local_jwks(
2255            rt.client_key
2256                .as_ref()
2257                .unwrap()
2258                .jwks_document()
2259                .unwrap()
2260                .to_string(),
2261        )
2262        .await;
2263
2264        let refused = preflight(&rt, &pool, &url, PreflightOptions::default())
2265            .await
2266            .expect_err("without the flag an all-unreadable store must be refused");
2267        assert!(
2268            format!("{refused:#}").contains("--accept-unreadable"),
2269            "{refused:#}"
2270        );
2271
2272        let accept = PreflightOptions {
2273            sweep: false,
2274            accept_unreadable: true,
2275        };
2276        preflight(&rt, &pool, &url, accept)
2277            .await
2278            .expect("the override did not override");
2279        assert_eq!(
2280            session_rows(&pool).await,
2281            2,
2282            "the preflight deleted something"
2283        );
2284    }
2285
2286    /// The override does NOT relax the signing-key check …
2287    #[tokio::test]
2288    async fn accept_unreadable_does_not_bypass_the_signing_key_check() {
2289        let rt = confidential_runtime("acceptsig");
2290        let (pool, _) = db().await;
2291        insert_raw(&pool, SUBS[0]).await;
2292        let stranger = super::super::keys::SigningKey::generate(super::super::runtime::CLIENT_KID);
2293        let url = local_jwks(stranger.jwks_document().unwrap().to_string()).await;
2294        let err = preflight(
2295            &rt,
2296            &pool,
2297            &url,
2298            PreflightOptions {
2299                sweep: false,
2300                accept_unreadable: true,
2301            },
2302        )
2303        .await
2304        .expect_err("--accept-unreadable bypassed the signing-key check");
2305        assert!(
2306            format!("{err:#}").contains("is not the key the app serves"),
2307            "{err:#}"
2308        );
2309    }
2310
2311    /// … nor the production-client check.
2312    #[tokio::test]
2313    async fn accept_unreadable_does_not_bypass_the_client_check() {
2314        let (pool, _) = db().await;
2315        insert_raw(&pool, SUBS[0]).await;
2316        let err = preflight(
2317            &runtime(),
2318            &pool,
2319            "https://localhost:9/oauth/jwks.json",
2320            PreflightOptions {
2321                sweep: true,
2322                accept_unreadable: true,
2323            },
2324        )
2325        .await
2326        .expect_err("--accept-unreadable bypassed the client check");
2327        assert!(format!("{err:#}").contains("public dev client"), "{err:#}");
2328    }
2329}