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 let session = match super::store::get_session(pool, codec, sub).await {
110 Ok(Some(session)) => session,
111 Ok(None) => return Revocation::NoSession,
112 Err(err) => {
113 // Still delete: an unreadable row is exactly the state a sign-out
114 // should clear, and leaving it wedges every later request.
115 let _ = super::store::delete_session(pool, sub).await;
116 return Revocation::Failed(format!("reading the session: {err:#}"));
117 }
118 };
119
120 bounded_then_delete(
121 pool,
122 sub,
123 ctx.deadline,
124 revoke_tokens(pool, http, ctx, &session, now),
125 )
126 .await
127}
128
129/// The revocation request itself. Errors become [`Revocation::Failed`] rather
130/// than propagating: every caller has already committed to signing out.
131async fn revoke_tokens(
132 pool: &sqlx::SqlitePool,
133 http: &reqwest::Client,
134 ctx: &RevokeContext<'_>,
135 session: &OAuthSession,
136 now: i64,
137) -> Revocation {
138 match try_revoke(pool, http, ctx, session, now).await {
139 Ok(()) => Revocation::Revoked,
140 Err(err) => Revocation::Failed(format!("{err:#}")),
141 }
142}
143
144async fn try_revoke(
145 pool: &sqlx::SqlitePool,
146 http: &reqwest::Client,
147 ctx: &RevokeContext<'_>,
148 session: &OAuthSession,
149 now: i64,
150) -> Result<()> {
151 let endpoint = ctx.revocation_endpoint.ok_or_else(|| {
152 anyhow::anyhow!("the authorization server advertises no revocation endpoint")
153 })?;
154
155 let (token, _hint) = token_to_revoke(session);
156 let assertion = match ctx.auth_method {
157 AuthMethod::PrivateKeyJwt => {
158 let key = ctx.client_key.ok_or_else(|| {
159 anyhow::anyhow!("private_key_jwt requires the client signing key")
160 })?;
161 Some(super::client_auth::client_assertion(
162 key,
163 ctx.client_id,
164 &session.issuer,
165 now,
166 )?)
167 }
168 AuthMethod::None => None,
169 };
170 let params = revoke_params(ctx.auth_method, ctx.client_id, assertion.as_deref(), token)?;
171 let form: Vec<(&str, &str)> = params.iter().map(|(k, v)| (*k, v.as_str())).collect();
172
173 // The session's own DPoP key, as for every other call on this grant: the
174 // reference routes revocation through the same `dpopFetch`.
175 let key = super::keys::SigningKey::from_jwk_json(&session.dpop_key_jwk, "session")?;
176 let outcome = super::request::send_with_dpop(
177 http,
178 pool,
179 &super::request::DpopRequest {
180 endpoint: super::dpop::Endpoint::AuthorizationServer,
181 url: endpoint,
182 key: &key,
183 access_token: None,
184 body: super::request::DpopBody::Form(&form),
185 retry: super::request::Retry::Allowed,
186 },
187 )
188 .await?;
189
190 // RFC 7009 §2.2: a 200 also means "we did not recognise that token", which
191 // is success for our purposes — the grant is not usable either way.
192 if !(200..300).contains(&outcome.status) {
193 anyhow::bail!("the revocation endpoint returned status {}", outcome.status);
194 }
195 Ok(())
196}
197
198/// Sign out, discovering the revocation endpoint from the session itself.
199///
200/// The endpoint lives in the authorization server's metadata, which is not
201/// stored on the session — so it has to be fetched. That fetch is done only when
202/// there IS a session to revoke: discovering first and finding nothing to do
203/// would put a network round trip on every logout of a dev-DID or an
204/// already-expired account.
205///
206/// A discovery failure is not fatal. It means the server cannot be told, which
207/// is exactly the case [`sign_out`] already handles by deleting locally anyway.
208pub async fn sign_out_discovering(
209 runtime: &super::runtime::OauthRuntime,
210 http: &reqwest::Client,
211 pool: &sqlx::SqlitePool,
212 sub: &str,
213 now: i64,
214) -> Revocation {
215 let session = match super::store::get_session(pool, &runtime.codec, sub).await {
216 Ok(Some(session)) => session,
217 Ok(None) => return Revocation::NoSession,
218 Err(err) => {
219 // **Delete it anyway.** This early return used to skip the delete,
220 // and `sign_out` was fixed for exactly that while this sibling was
221 // not — the same one-instance-fixed, sibling-missed pattern twice
222 // over.
223 //
224 // An unreadable row is not hypothetical: it is what every row
225 // written before this branch's AAD change now is, and what rotating
226 // `FEATHERREADER_OAUTH_ENCRYPTION_KEY` produces. Leaving it wedges
227 // the account — every repo call reads the same row — and because
228 // `purge_did_data` does not touch the OAuth tables, `POST
229 // /account/delete` relies on this path to clear it. Returning early
230 // here made "delete my account" leave the tokens behind.
231 let _ = super::store::delete_session(pool, sub).await;
232 return Revocation::Failed(format!("reading the session: {err:#}"));
233 }
234 };
235
236 // **Discovery is bounded too**, and separately.
237 //
238 // Bounding only the revocation request left the real wait unbounded:
239 // `discover` makes two guarded fetches, each with its own 30-second timeout,
240 // so an unreachable PDS held a user's sign-out for a minute before the
241 // five-second deadline even began.
242 //
243 // Bounded HERE rather than by wrapping the whole operation, so this function
244 // still ENDS in a call to `sign_out` — which owns the contract that matters
245 // (a bounded attempt, then an unconditional local delete) and is where that
246 // contract is tested. Wrapping instead meant production stopped going
247 // through `sign_out` at all, leaving three invariant tests aimed at a
248 // function nothing called. Worst case is two deadlines, one per phase, which
249 // is what independently bounding each phase costs.
250 let endpoint = match tokio::time::timeout(
251 REVOKE_DEADLINE,
252 // **The missed sibling.** This posts the REFRESH TOKEN to whatever
253 // `revocation_endpoint` comes back, and had no issuer check at all —
254 // while the callback and refresh paths both had one, and the comment
255 // that added them counted "the two instances" of a hole that had three.
256 // A repointed PDS could take the refresh token AND leave the real grant
257 // live, because the local row is deleted either way.
258 super::discovery::discover(
259 http,
260 &session.aud,
261 runtime.auth_method.as_str(),
262 Some(&session.issuer),
263 ),
264 )
265 .await
266 {
267 Ok(Ok(server)) => server.revocation_endpoint,
268 Ok(Err(err)) => {
269 tracing::warn!(%err, %sub, "could not discover the revocation endpoint");
270 None
271 }
272 Err(_) => {
273 tracing::warn!(%sub, "discovering the revocation endpoint timed out");
274 None
275 }
276 };
277
278 sign_out(
279 pool,
280 &runtime.codec,
281 http,
282 &RevokeContext {
283 revocation_endpoint: endpoint.as_deref(),
284 client_id: &runtime.client_id,
285 auth_method: runtime.auth_method,
286 client_key: runtime.client_key.as_ref(),
287 deadline: REVOKE_DEADLINE,
288 },
289 sub,
290 now,
291 )
292 .await
293}
294
295/// Run `attempt` under `deadline`, then delete the local session **whatever
296/// happened** — including when the deadline expired.
297///
298/// The single implementation of the sign-out contract, so there is no second
299/// copy to drift. Separated out from [`sign_out`] so the bound can be tested
300/// against a future that never resolves, rather than against a network address
301/// that may be refused instantly in one environment and hang in another.
302async fn bounded_then_delete<F>(
303 pool: &sqlx::SqlitePool,
304 sub: &str,
305 deadline: std::time::Duration,
306 attempt: F,
307) -> Revocation
308where
309 F: std::future::Future<Output = Revocation>,
310{
311 let outcome = match tokio::time::timeout(deadline, attempt).await {
312 Ok(outcome) => outcome,
313 Err(_) => Revocation::Failed(format!(
314 "revocation did not finish within {deadline:?}; signing out locally anyway"
315 )),
316 };
317
318 // Unconditional, exactly as in `sign_out`: the user asked to be logged out.
319 if let Err(err) = super::store::delete_session(pool, sub).await {
320 return Revocation::Failed(format!("deleting the local session: {err:#}"));
321 }
322 outcome
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328
329 fn session(access: &str, refresh: &str) -> OAuthSession {
330 OAuthSession {
331 sub: "did:plc:ewvi7nxzyoun6zhxrhs64oiz".into(),
332 issuer: "https://pds.example.com".into(),
333 aud: "https://pds.example.com".into(),
334 dpop_key_jwk: r#"{"kty":"EC"}"#.into(),
335 access_token: access.into(),
336 refresh_token: refresh.into(),
337 token_type: "DPoP".into(),
338 granted_scope: "atproto".into(),
339 expires_at: Some(1_700_000_000),
340 }
341 }
342
343 /// **The refresh token is the one that matters.**
344 ///
345 /// Revoking the access token alone ends a session that was going to expire
346 /// within the hour anyway, and leaves live the one credential that can mint
347 /// replacements indefinitely. RFC 7009 §2.1 makes revoking the refresh token
348 /// cover both.
349 #[test]
350 fn the_refresh_token_is_preferred_over_the_access_token() {
351 let session = session("access-abc", "refresh-xyz");
352 let (token, hint) = token_to_revoke(&session);
353 assert_eq!(
354 token, "refresh-xyz",
355 "revoked the access token, leaving the refresh token live"
356 );
357 assert_eq!(hint, "refresh_token");
358 }
359
360 /// A token response may omit `refresh_token` entirely. Then the access token
361 /// is all there is, and revoking it is better than revoking nothing.
362 #[test]
363 fn an_absent_refresh_token_falls_back_to_the_access_token() {
364 let session = session("access-abc", "");
365 let (token, hint) = token_to_revoke(&session);
366 assert_eq!(token, "access-abc");
367 assert_eq!(hint, "access_token");
368 }
369
370 /// A public client sends `client_id` and no assertion — the same rule the
371 /// rest of the client-auth surface follows.
372 #[test]
373 fn a_public_client_sends_the_token_and_its_client_id() {
374 let params = revoke_params(AuthMethod::None, "http://localhost", None, "refresh-xyz")
375 .expect("a public client needs no assertion");
376 assert!(params.contains(&("token", "refresh-xyz".to_string())));
377 assert!(params.contains(&("client_id", "http://localhost".to_string())));
378 assert!(
379 !params
380 .iter()
381 .any(|(k, _)| k.starts_with("client_assertion")),
382 "a public client must not send an assertion it never registered: {params:?}"
383 );
384 }
385
386 /// A confidential client carries its assertion, so revocation authenticates
387 /// the same way PAR and token do.
388 #[test]
389 fn a_confidential_client_carries_its_assertion() {
390 let params = revoke_params(
391 AuthMethod::PrivateKeyJwt,
392 "https://feather-reader.com/oauth/client-metadata.json",
393 Some("the.assertion.jwt"),
394 "refresh-xyz",
395 )
396 .expect("an assertion was supplied");
397 assert!(params.contains(&("client_assertion", "the.assertion.jwt".to_string())));
398 }
399
400 /// Revocation must not authenticate as an unauthenticated request when the
401 /// assertion is missing — that would silently fail at the server and report
402 /// success locally.
403 #[test]
404 fn a_confidential_client_without_an_assertion_is_an_error() {
405 let err = revoke_params(AuthMethod::PrivateKeyJwt, "https://client", None, "tok")
406 .expect_err("must not send an unauthenticated revocation");
407 assert!(format!("{err:#}").contains("requires a client assertion"));
408 }
409
410 // ---- the sign-out invariant -------------------------------------------
411
412 const KEY: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
413 const DID: &str = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
414 const NOW: i64 = 1_700_000_000;
415
416 async fn db() -> (sqlx::SqlitePool, super::super::crypto::Codec) {
417 let pool = crate::store::init_url("sqlite::memory:").await.unwrap();
418 super::super::store::init_schema(&pool).await.unwrap();
419 (pool, super::super::crypto::Codec::new(Some(KEY)).unwrap())
420 }
421
422 /// A real session row, with a real DPoP key so the request gets as far as
423 /// the network rather than failing on key parsing.
424 async fn stored(pool: &sqlx::SqlitePool, codec: &super::super::crypto::Codec) -> OAuthSession {
425 let key = super::super::keys::SigningKey::generate("session");
426 let session = OAuthSession {
427 dpop_key_jwk: key.to_jwk_json().unwrap(),
428 ..session("access-abc", "refresh-xyz")
429 };
430 super::super::store::put_session(pool, codec, &session)
431 .await
432 .unwrap();
433 session
434 }
435
436 /// A short deadline: the property under test is that the wait is BOUNDED,
437 /// and proving that with the production five seconds would make the whole
438 /// suite ten times slower for one assertion.
439 const TEST_DEADLINE: std::time::Duration = std::time::Duration::from_millis(250);
440
441 fn ctx(endpoint: Option<&str>) -> RevokeContext<'_> {
442 RevokeContext {
443 revocation_endpoint: endpoint,
444 client_id: "http://localhost",
445 auth_method: AuthMethod::None,
446 client_key: None,
447 deadline: TEST_DEADLINE,
448 }
449 }
450
451 /// **The session must be gone even when the server could not be told.**
452 ///
453 /// This is the whole reason the delete is unconditional. If revocation
454 /// failing aborted the sign-out, then a PDS that is down — or simply slow —
455 /// would leave a fully usable session in the database of a user who has been
456 /// shown a "signed out" page. The loopback endpoint here is refused by the
457 /// SSRF guard, which is a revocation failure that needs no network.
458 #[tokio::test]
459 async fn signing_out_deletes_the_local_session_even_when_revocation_fails() {
460 let (pool, codec) = db().await;
461 stored(&pool, &codec).await;
462
463 let outcome = sign_out(
464 &pool,
465 &codec,
466 &reqwest::Client::new(),
467 &ctx(Some("http://127.0.0.1/oauth/revoke")),
468 DID,
469 NOW,
470 )
471 .await;
472
473 match &outcome {
474 Revocation::Failed(reason) => assert!(
475 reason.contains("forbidden (internal) address"),
476 "failed BEFORE reaching the network, so this proves nothing about a \
477 revocation failure: {reason}"
478 ),
479 other => panic!("the loopback endpoint must not report success: {other:?}"),
480 }
481 assert!(
482 super::super::store::get_session(&pool, &codec, DID)
483 .await
484 .unwrap()
485 .is_none(),
486 "THE SESSION SURVIVED A FAILED REVOCATION — a signed-out user still has live credentials"
487 );
488 }
489
490 /// A server with no `revocation_endpoint` cannot be told, but the user is
491 /// still signed out locally.
492 #[tokio::test]
493 async fn a_server_without_a_revocation_endpoint_still_signs_out_locally() {
494 let (pool, codec) = db().await;
495 stored(&pool, &codec).await;
496
497 let outcome = sign_out(&pool, &codec, &reqwest::Client::new(), &ctx(None), DID, NOW).await;
498
499 match &outcome {
500 Revocation::Failed(reason) => assert!(
501 reason.contains("no revocation endpoint"),
502 "failed for the wrong reason: {reason}"
503 ),
504 other => panic!("expected a failure, got {other:?}"),
505 }
506 assert!(super::super::store::get_session(&pool, &codec, DID)
507 .await
508 .unwrap()
509 .is_none());
510 }
511
512 /// **A dead authorization server must not hold a sign-out open — and the
513 /// bound must cover DISCOVERY, not just the revocation request.**
514 ///
515 /// Bounding only the request left the real wait unbounded: discovery makes
516 /// two guarded fetches with a 30-second timeout each, so an unreachable PDS
517 /// held the sign-out for a minute before the deadline began. The earlier
518 /// version of this test missed that by exercising `sign_out` rather than the
519 /// function production calls, and it reached the network to do it — so where
520 /// outbound was refused it passed instantly, proving nothing.
521 ///
522 /// Exercised against a future that never resolves, with a short deadline:
523 /// deterministic, no network, and it proves the bound rather than observing
524 /// how long a particular host happens to take to refuse a connection.
525 #[tokio::test]
526 async fn a_hanging_attempt_does_not_hold_the_sign_out_open() {
527 let (pool, codec) = db().await;
528 stored(&pool, &codec).await;
529
530 let started = std::time::Instant::now();
531 let outcome = super::bounded_then_delete(
532 &pool,
533 DID,
534 std::time::Duration::from_millis(50),
535 std::future::pending::<Revocation>(),
536 )
537 .await;
538 assert!(
539 started.elapsed() < std::time::Duration::from_secs(2),
540 "the bound did not fire"
541 );
542
543 match &outcome {
544 Revocation::Failed(reason) => assert!(
545 reason.contains("did not finish within"),
546 "failed for the wrong reason: {reason}"
547 ),
548 other => panic!("a never-resolving attempt must time out, got {other:?}"),
549 }
550 assert!(
551 super::super::store::get_session(&pool, &codec, DID)
552 .await
553 .unwrap()
554 .is_none(),
555 "the session survived a timed-out revocation"
556 );
557 }
558
559 /// **An UNREADABLE session row is still deleted.**
560 ///
561 /// A row whose bound context was altered no longer decrypts, so
562 /// `get_session` returns an error. Returning early without deleting left
563 /// that row in place — and because every repo call reads it, the account
564 /// then failed on every page load with no way out but a sign-out that had
565 /// just refused to clear it.
566 #[tokio::test]
567 async fn an_unreadable_session_is_still_signed_out() {
568 let (pool, codec) = db().await;
569 stored(&pool, &codec).await;
570
571 // Break the AAD binding the way a tampered row would.
572 sqlx::query("UPDATE oauth_session SET issuer = ? WHERE sub = ?")
573 .bind("https://evil.example")
574 .bind(DID)
575 .execute(&pool)
576 .await
577 .unwrap();
578 assert!(
579 super::super::store::get_session(&pool, &codec, DID)
580 .await
581 .is_err(),
582 "precondition: the row must be unreadable"
583 );
584
585 let outcome = sign_out(
586 &pool,
587 &codec,
588 &reqwest::Client::new(),
589 &ctx(Some("https://pds.example.com/oauth/revoke")),
590 DID,
591 NOW,
592 )
593 .await;
594 assert!(matches!(outcome, Revocation::Failed(_)), "got {outcome:?}");
595
596 let still_there: i64 =
597 sqlx::query_scalar("SELECT COUNT(*) FROM oauth_session WHERE sub = ?")
598 .bind(DID)
599 .fetch_one(&pool)
600 .await
601 .unwrap();
602 assert_eq!(
603 still_there, 0,
604 "an unreadable row survived a sign-out, so the account stays wedged"
605 );
606 }
607
608 // ── the function production actually calls ───────────────────────────────
609
610 /// A runtime whose codec matches the test database's, so a stored session is
611 /// readable. Loopback public URL => a public client, so no key file.
612 fn runtime() -> super::super::runtime::OauthRuntime {
613 super::super::runtime::OauthRuntime::new(&crate::config::Config {
614 repo_backend: crate::metrics::Backend::Rust,
615 public_url: "http://127.0.0.1:8080".into(),
616 oauth: crate::config::OauthConfig {
617 encryption_key: Some(KEY.to_string()),
618 ..crate::config::OauthConfig::default()
619 },
620 ..crate::config::Config::default()
621 })
622 .expect("the test runtime must build")
623 }
624
625 /// **`sign_out_discovering` is what `/logout` and `/account/delete` call,
626 /// and it had no test of its own at all.**
627 ///
628 /// A mutation replacing this entire function with `return NoSession` — never
629 /// revoking, never deleting — passed all 575 tests. Every sign-out invariant
630 /// was pinned one layer below, on `sign_out`, which production reaches only
631 /// through this wrapper.
632 ///
633 /// The PDS here is unreachable (loopback, refused by the SSRF guard), which
634 /// is the case that matters: the local row must go even when the server
635 /// cannot be told.
636 #[tokio::test]
637 async fn the_production_sign_out_deletes_the_session() {
638 let (pool, codec) = db().await;
639 stored(&pool, &codec).await;
640
641 let outcome =
642 sign_out_discovering(&runtime(), &reqwest::Client::new(), &pool, DID, NOW).await;
643
644 assert!(
645 matches!(outcome, Revocation::Failed(_)),
646 "an unreachable PDS must not report success: {outcome:?}"
647 );
648 assert!(
649 super::super::store::get_session(&pool, &codec, DID)
650 .await
651 .unwrap()
652 .is_none(),
653 "the production sign-out left the session behind"
654 );
655 }
656
657 /// **An unreadable row is deleted by the production path too.**
658 ///
659 /// `sign_out` was fixed for this; its caller was not. The row is what every
660 /// pre-AAD-change row now is, and what rotating the encryption key produces
661 /// — and since `purge_did_data` does not touch the OAuth tables, `POST
662 /// /account/delete` depends on this path to clear it.
663 #[tokio::test]
664 async fn the_production_sign_out_deletes_an_unreadable_session() {
665 let (pool, codec) = db().await;
666 stored(&pool, &codec).await;
667 sqlx::query("UPDATE oauth_session SET issuer = ? WHERE sub = ?")
668 .bind("https://evil.example")
669 .bind(DID)
670 .execute(&pool)
671 .await
672 .unwrap();
673
674 let outcome =
675 sign_out_discovering(&runtime(), &reqwest::Client::new(), &pool, DID, NOW).await;
676 assert!(matches!(outcome, Revocation::Failed(_)), "got {outcome:?}");
677
678 let rows: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM oauth_session WHERE sub = ?")
679 .bind(DID)
680 .fetch_one(&pool)
681 .await
682 .unwrap();
683 assert_eq!(rows, 0, "account deletion would leave live tokens behind");
684 }
685
686 /// Logging out twice is not an error. The second call has nothing to revoke
687 /// and says so, rather than reporting a failure the caller would log.
688 #[tokio::test]
689 async fn signing_out_without_a_session_is_idempotent() {
690 let (pool, codec) = db().await;
691 let outcome = sign_out(
692 &pool,
693 &codec,
694 &reqwest::Client::new(),
695 &ctx(Some("https://pds.example.com/oauth/revoke")),
696 DID,
697 NOW,
698 )
699 .await;
700 assert_eq!(outcome, Revocation::NoSession);
701 }
702}