1use anyhow::Result;
9
10use super::client_auth::AuthMethod;
11use super::store::OAuthSession;
12
13#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum Revocation {
18 Revoked,
20 NoSession,
22 Failed(String),
24}
25
26pub 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
45pub 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
64const REVOKE_DEADLINE: std::time::Duration = std::time::Duration::from_secs(5);
70
71pub struct RevokeContext<'a> {
74 pub revocation_endpoint: Option<&'a str>,
77 pub client_id: &'a str,
78 pub auth_method: AuthMethod,
79 pub client_key: Option<&'a super::keys::SigningKey>,
81 pub deadline: std::time::Duration,
85}
86
87pub 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
115async 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 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 Ok(None) => return previous.unwrap_or(Revocation::NoSession),
137 Err(err) => {
138 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 Attempt::Changed(outcome) => previous = Some(outcome),
151 }
152 }
153 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
162pub(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
184pub(crate) const ORPHAN_REVOKE_DEADLINE: std::time::Duration = REVOKE_DEADLINE;
186
187async 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 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 if !(200..300).contains(&outcome.status) {
251 anyhow::bail!("the revocation endpoint returned status {}", outcome.status);
252 }
253 Ok(())
254}
255
256pub 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 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
302type EndpointCache = std::sync::Mutex<Option<((String, String), Option<String>)>>;
306
307async 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 .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
351async 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 match tokio::time::timeout(
365 REVOKE_DEADLINE,
366 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
395pub struct RevokeAllReport {
396 pub revoked: Vec<String>,
398 pub no_session: Vec<String>,
400 pub failed: Vec<(String, String)>,
403 pub late: Vec<String>,
409}
410
411pub 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
443pub 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
484const OWN_JWKS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
486const OWN_JWKS_MAX_BYTES: usize = 64 * 1024;
488
489async 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 #[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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
541pub struct PreflightOptions {
542 pub sweep: bool,
545 pub accept_unreadable: bool,
550}
551
552pub 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 fit_to_revoke(runtime)?;
588
589 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 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
665async 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 let mut order: Vec<String> = Vec::new();
680 let mut outcomes: std::collections::HashMap<String, Revocation> = Default::default();
681 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 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 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 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
746const RE_LIST_PASSES: usize = 2;
749
750const MAX_SIGN_OUT_ATTEMPTS: usize = 3;
754
755#[derive(Debug)]
757enum Attempt {
758 Done(Revocation),
761 Changed(Revocation),
764}
765
766async 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 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 #[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 #[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 #[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 #[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 #[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 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 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 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 #[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 #[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 #[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 #[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 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 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 #[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 #[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 #[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 const SUBS: [&str; 3] = [
1200 "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa",
1201 "did:plc:bbbbbbbbbbbbbbbbbbbbbbbb",
1202 "did:plc:cccccccccccccccccccccccc",
1203 ];
1204
1205 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 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 #[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 #[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 #[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 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 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 #[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 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 #[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 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 #[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 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 #[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 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 #[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 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 #[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 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 #[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 #[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 #[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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 #[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 #[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 #[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 #[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 #[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}