1use anyhow::{bail, Context as _, Result};
15
16use super::runtime::OauthRuntime;
17use super::{client_auth, discovery, dpop, flow, keys, request, store, token};
18
19const MAX_PENDING_SECS: i64 = 600;
26
27pub struct StartedLogin {
30 pub authorize_url: String,
32 pub binding_token: String,
37}
38
39pub async fn start(
46 runtime: &OauthRuntime,
47 http: &reqwest::Client,
48 pool: &sqlx::SqlitePool,
49 subject: &str,
50 now: i64,
51) -> Result<StartedLogin> {
52 start_with(
53 runtime,
54 pool,
55 subject,
56 now,
57 |subject| async move {
58 super::resolve::resolve(&runtime.resolver, http, &subject, &runtime.plc_directory).await
59 },
60 |pds_url, auth_method, expected_issuer| async move {
61 discovery::discover(http, &pds_url, auth_method, expected_issuer.as_deref()).await
62 },
63 |req: ParPost| async move {
64 post_form(http, pool, &req.url, &req.key, &req.params, req.retry).await
65 },
66 )
67 .await
68}
69
70struct ParPost {
76 url: String,
77 params: Vec<(&'static str, String)>,
78 key: std::sync::Arc<keys::SigningKey>,
85 retry: request::Retry,
86}
87
88#[allow(clippy::too_many_arguments)]
109async fn start_with<R, RFut, D, DFut, P, PFut>(
110 runtime: &OauthRuntime,
111 pool: &sqlx::SqlitePool,
112 subject: &str,
113 now: i64,
114 resolve: R,
115 discover: D,
116 push: P,
117) -> Result<StartedLogin>
118where
119 R: FnOnce(String) -> RFut,
120 RFut: std::future::Future<Output = Result<super::resolve::ResolvedAccount>>,
121 D: FnOnce(String, &'static str, Option<String>) -> DFut,
122 DFut: std::future::Future<Output = Result<discovery::AuthorizationServer>>,
123 P: FnOnce(ParPost) -> PFut,
124 PFut: std::future::Future<Output = Result<request::PostOutcome>>,
125{
126 let account = resolve(subject.to_string())
127 .await
128 .with_context(|| format!("resolving {subject:?}"))?;
129
130 let server = discover(account.pds_url.clone(), runtime.auth_method.as_str(), None)
135 .await
136 .with_context(|| {
137 format!(
138 "discovering the authorization server for {}",
139 account.pds_url
140 )
141 })?;
142
143 let session_key = keys::SigningKey::generate("session");
147 let verifier = flow::new_pkce_verifier();
148 let state = flow::new_state();
149 let binding_token = flow::new_binding_token();
150
151 let mut params = flow::par_params(&flow::ParRequest {
152 redirect_uri: &super::metadata::redirect_uri(&runtime.client),
153 scope: runtime.client.scope_str(),
154 state: &state,
155 code_challenge: &flow::pkce_challenge(&verifier),
156 login_hint: Some(subject),
157 });
158 let assertion = client_assertion(runtime, runtime.auth_method, &server.issuer, now)?;
159 params.extend(client_auth::credential_params(
160 runtime.auth_method,
161 &runtime.client_id,
162 assertion.as_deref(),
163 )?);
164
165 let session_key = std::sync::Arc::new(session_key);
168 let outcome = push(ParPost {
169 url: server.par_endpoint.clone(),
170 params,
171 key: std::sync::Arc::clone(&session_key),
172 retry: request::Retry::Allowed,
174 })
175 .await?;
176 let par = accept_par_response(&outcome)?;
177
178 store::put_pending(
179 pool,
180 &runtime.codec,
181 &store::PendingAuth {
182 state: state.clone(),
183 browser_binding_hash: flow::binding_hash(&binding_token),
184 pkce_verifier: verifier,
185 dpop_key_jwk: session_key.to_jwk_json()?,
186 issuer: server.issuer.clone(),
187 pds_url: account.pds_url.clone(),
188 did: account.did.clone(),
189 auth_method: runtime.auth_method.as_str().to_string(),
190 auth_kid: runtime.client_key.as_ref().map(|k| k.kid().to_string()),
191 redirect_uri: super::metadata::redirect_uri(&runtime.client),
192 requested_scope: runtime.client.scope_str().to_string(),
193 request_uri: par.request_uri.clone(),
194 app_return_to: None,
195 expires_at: pending_expiry(now, par.expires_in),
196 },
197 )
198 .await?;
199
200 Ok(StartedLogin {
201 authorize_url: flow::authorize_url(
202 &server.authorization_endpoint,
203 &runtime.client_id,
204 &par.request_uri,
205 )?,
206 binding_token,
207 })
208}
209
210#[derive(Debug)]
215pub struct CompletedLogin {
216 pub did: String,
217 pub handle: Option<String>,
220}
221
222pub async fn complete(
228 runtime: &OauthRuntime,
229 http: &reqwest::Client,
230 pool: &sqlx::SqlitePool,
231 params: &flow::CallbackParams,
232 presented_cookie: Option<&str>,
233 now: i64,
234) -> Result<CompletedLogin> {
235 complete_with(
236 runtime,
237 pool,
238 params,
239 presented_cookie,
240 now,
241 |req: Discovery| async move {
251 discovery::discover(
252 http,
253 &req.pds_url,
254 &req.auth_method,
255 req.expected_issuer.as_deref(),
256 )
257 .await
258 },
259 |req: TokenPost| async move {
260 post_form(http, pool, &req.url, &req.key, &req.params, req.retry).await
261 },
262 |did: String| async move {
263 super::resolve::resolve(&runtime.resolver, http, &did, &runtime.plc_directory).await
264 },
265 )
266 .await
267}
268
269struct Discovery {
272 pds_url: String,
273 auth_method: String,
274 expected_issuer: Option<String>,
278}
279
280struct TokenPost {
282 url: String,
283 params: Vec<(&'static str, String)>,
284 key: std::sync::Arc<keys::SigningKey>,
290 retry: request::Retry,
291}
292
293#[allow(clippy::too_many_arguments)]
313async fn complete_with<D, DFut, P, PFut, R, RFut>(
314 runtime: &OauthRuntime,
315 pool: &sqlx::SqlitePool,
316 params: &flow::CallbackParams,
317 presented_cookie: Option<&str>,
318 now: i64,
319 discover: D,
320 post: P,
321 resolve_handle: R,
322) -> Result<CompletedLogin>
323where
324 D: Fn(Discovery) -> DFut,
325 DFut: std::future::Future<Output = Result<discovery::AuthorizationServer>>,
326 P: Fn(TokenPost) -> PFut,
327 PFut: std::future::Future<Output = Result<request::PostOutcome>>,
328 R: Fn(String) -> RFut,
329 RFut: std::future::Future<Output = Result<super::resolve::ResolvedAccount>>,
330{
331 let (pending, code) =
334 flow::complete_callback(pool, &runtime.codec, params, presented_cookie, now).await?;
335
336 let key = std::sync::Arc::new(
345 keys::SigningKey::from_jwk_json(&pending.dpop_key_jwk, "session")
346 .context("unsealing the login's DPoP key")?,
347 );
348
349 let auth_method: client_auth::AuthMethod = pending
355 .auth_method
356 .parse()
357 .context("the pending login stored an unknown auth method")?;
358
359 let current_redirect = super::metadata::redirect_uri(&runtime.client);
369 if current_redirect != pending.redirect_uri {
370 bail!(
371 "this login was started under a different public URL (redirect {:?}, now {:?}); \
372 the client identity changed mid-flight and the exchange would be rejected",
373 pending.redirect_uri,
374 current_redirect
375 );
376 }
377
378 let server = discover(Discovery {
379 pds_url: pending.pds_url.clone(),
380 auth_method: auth_method.as_str().to_string(),
381 expected_issuer: Some(pending.issuer.clone()),
383 })
384 .await?;
385
386 let token_params = token_exchange_params(runtime, &pending, &code, auth_method, now)?;
403
404 let outcome = post(TokenPost {
405 url: server.token_endpoint.clone(),
406 params: token_params,
407 key: std::sync::Arc::clone(&key),
408 retry: request::Retry::Allowed,
413 })
414 .await?;
415
416 let did = accept_token_response(pool, &runtime.codec, &pending, &outcome, now).await?;
417
418 let handle = match resolve_handle(did.clone()).await {
423 Ok(account) => account.handle,
424 Err(err) => {
425 tracing::warn!(%err, did = %did, "could not resolve a handle for the new session");
426 None
427 }
428 };
429
430 Ok(CompletedLogin { did, handle })
431}
432
433fn pending_expiry(now: i64, par_expires_in: i64) -> i64 {
441 now + par_expires_in.min(MAX_PENDING_SECS)
442}
443
444fn accept_par_response(outcome: &request::PostOutcome) -> Result<flow::ParResponse> {
452 if !outcome.is_success() {
453 bail!(
454 "the pushed authorization request failed with status {}",
455 outcome.status
456 );
457 }
458 flow::parse_par_response(&outcome.json()?)
459}
460
461fn token_exchange_params(
474 runtime: &OauthRuntime,
475 pending: &store::PendingAuth,
476 code: &str,
477 auth_method: client_auth::AuthMethod,
478 now: i64,
479) -> Result<Vec<(&'static str, String)>> {
480 let mut params =
481 token::token_request_params(code, &pending.redirect_uri, &pending.pkce_verifier);
482 let assertion = client_assertion(runtime, auth_method, &pending.issuer, now)?;
483 params.extend(client_auth::credential_params(
484 auth_method,
485 &runtime.client_id,
486 assertion.as_deref(),
487 )?);
488 Ok(params)
489}
490
491async fn accept_token_response(
503 pool: &sqlx::SqlitePool,
504 codec: &super::crypto::Codec,
505 pending: &store::PendingAuth,
506 outcome: &request::PostOutcome,
507 now: i64,
508) -> Result<String> {
509 if !outcome.is_success() {
510 bail!("the token exchange failed with status {}", outcome.status);
511 }
512 let tokens = token::parse_token_response(&outcome.json()?)?;
513
514 if tokens.sub != pending.did {
518 bail!(
519 "the authorization server returned tokens for a different subject than the \
520 login was started for"
521 );
522 }
523
524 store::put_session(
525 pool,
526 codec,
527 &store::OAuthSession {
528 sub: tokens.sub.clone(),
529 issuer: pending.issuer.clone(),
530 aud: pending.pds_url.clone(),
531 dpop_key_jwk: pending.dpop_key_jwk.clone(),
532 access_token: tokens.access_token,
533 refresh_token: tokens.refresh_token.unwrap_or_default(),
534 token_type: tokens.token_type,
535 granted_scope: tokens.granted_scope,
536 expires_at: tokens.expires_in.map(|secs| now + secs),
537 },
538 )
539 .await?;
540
541 Ok(tokens.sub)
542}
543
544fn client_assertion(
546 runtime: &OauthRuntime,
547 method: client_auth::AuthMethod,
548 issuer: &str,
549 now: i64,
550) -> Result<Option<String>> {
551 match method {
552 client_auth::AuthMethod::None => Ok(None),
553 client_auth::AuthMethod::PrivateKeyJwt => {
554 let key = runtime
555 .client_key
556 .as_ref()
557 .context("private_key_jwt is negotiated but no client signing key is loaded")?;
558 Ok(Some(client_auth::client_assertion(
559 key,
560 &runtime.client_id,
561 issuer,
562 now,
563 )?))
564 }
565 }
566}
567
568async fn post_form(
570 http: &reqwest::Client,
571 pool: &sqlx::SqlitePool,
572 url: &str,
573 key: &keys::SigningKey,
574 params: &[(&'static str, String)],
575 retry: request::Retry,
576) -> Result<request::PostOutcome> {
577 let borrowed: Vec<(&str, &str)> = params.iter().map(|(k, v)| (*k, v.as_str())).collect();
578 request::send_with_dpop(
579 http,
580 pool,
581 &request::DpopRequest {
582 endpoint: dpop::Endpoint::AuthorizationServer,
583 url,
584 key,
585 access_token: None,
586 body: request::DpopBody::Form(&borrowed),
587 retry,
588 },
589 )
590 .await
591}
592
593#[cfg(test)]
594mod tests {
595 use super::*;
596
597 const TEST_KEY: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
598 const PUSHED_REDIRECT: &str = "https://feather-reader.com/oauth/callback";
599 const PENDING_ISSUER: &str = "https://auth.example.com";
600
601 const PENDING_DID: &str = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
602
603 fn token_body(sub: &str) -> serde_json::Value {
605 serde_json::json!({
606 "access_token": "at-abc",
607 "token_type": "DPoP",
608 "scope": "atproto",
613 "sub": sub,
614 "expires_in": 3600,
615 "refresh_token": "rt-abc",
616 })
617 }
618
619 fn outcome(status: u16, body: &serde_json::Value) -> request::PostOutcome {
620 request::PostOutcome {
621 status,
622 body: serde_json::to_vec(body).unwrap(),
623 }
624 }
625
626 async fn empty_pool() -> sqlx::SqlitePool {
627 let pool = crate::store::init_url("sqlite::memory:").await.unwrap();
628 crate::oauth::store::init_schema(&pool).await.unwrap();
629 pool
630 }
631
632 #[tokio::test]
649 async fn tokens_for_a_different_subject_are_refused() {
650 let pool = empty_pool().await;
651 let codec = crate::oauth::crypto::Codec::new(Some(TEST_KEY)).unwrap();
652 let pending = pending_auth("unused-hash");
653
654 let hostile = token_body("did:plc:zzzzzzzzzzzzzzzzzzzzzzzz");
655 let err = accept_token_response(
656 &pool,
657 &codec,
658 &pending,
659 &outcome(200, &hostile),
660 1_700_000_000,
661 )
662 .await
663 .expect_err("a token response for another DID must be refused");
664
665 assert!(
666 format!("{err:#}").contains("different subject"),
667 "refused, but not by the subject check: {err:#}",
668 );
669 assert!(
670 crate::oauth::store::get_session(&pool, &codec, "did:plc:zzzzzzzzzzzzzzzzzzzzzzzz")
671 .await
672 .unwrap()
673 .is_none(),
674 "no session may be stored for a subject the login did not start for",
675 );
676 }
677
678 #[tokio::test]
681 async fn tokens_for_the_pending_subject_are_accepted_and_stored() {
682 let pool = empty_pool().await;
683 let codec = crate::oauth::crypto::Codec::new(Some(TEST_KEY)).unwrap();
684 let pending = pending_auth("unused-hash");
685
686 let did = accept_token_response(
687 &pool,
688 &codec,
689 &pending,
690 &outcome(200, &token_body(PENDING_DID)),
691 1_700_000_000,
692 )
693 .await
694 .expect("a token response for the pending DID must be accepted");
695
696 assert_eq!(did, PENDING_DID);
697 let stored = crate::oauth::store::get_session(&pool, &codec, PENDING_DID)
698 .await
699 .unwrap()
700 .expect("the session must be durable before complete() returns");
701 assert_eq!(stored.access_token, "at-abc");
702 assert_eq!(stored.issuer, PENDING_ISSUER);
703 }
704
705 #[tokio::test]
711 async fn a_failed_token_exchange_stores_nothing() {
712 let pool = empty_pool().await;
713 let codec = crate::oauth::crypto::Codec::new(Some(TEST_KEY)).unwrap();
714 let pending = pending_auth("unused-hash");
715
716 let err = accept_token_response(
719 &pool,
720 &codec,
721 &pending,
722 &outcome(400, &token_body(PENDING_DID)),
723 1_700_000_000,
724 )
725 .await
726 .expect_err("a 400 must not yield a session");
727
728 assert!(
729 format!("{err:#}").contains("failed with status 400"),
730 "refused, but not by the status check: {err:#}",
731 );
732 assert!(
733 crate::oauth::store::get_session(&pool, &codec, PENDING_DID)
734 .await
735 .unwrap()
736 .is_none(),
737 "a failed exchange must leave no session behind",
738 );
739 }
740
741 #[test]
749 fn the_token_request_carries_the_pending_rows_pkce_verifier_and_redirect() {
750 let runtime = runtime_at("https://feather-reader.com");
751 let pending = pending_auth("unused-hash");
752
753 let params = token_exchange_params(
754 &runtime,
755 &pending,
756 "the-code",
757 runtime.auth_method,
758 1_700_000_000,
759 )
760 .expect("building the token request");
761 let get = |k: &str| {
762 params
763 .iter()
764 .find(|(name, _)| *name == k)
765 .map(|(_, v)| v.as_str())
766 };
767
768 assert_eq!(
769 get("code_verifier"),
770 Some(pending.pkce_verifier.as_str()),
771 "the verifier must come from the pending row; anything else forfeits PKCE",
772 );
773 assert_eq!(get("code"), Some("the-code"));
774 assert_eq!(
775 get("redirect_uri"),
776 Some(pending.redirect_uri.as_str()),
777 "the redirect must be the one PAR was pushed under",
778 );
779 assert_eq!(get("grant_type"), Some("authorization_code"));
780 }
781
782 #[test]
789 fn a_failed_par_push_is_not_parsed_as_a_grant() {
790 let body = serde_json::json!({ "request_uri": "urn:ietf:params:oauth:request_uri:x", "expires_in": 60 });
794
795 assert!(
796 accept_par_response(&outcome(200, &body)).is_ok(),
797 "the same body at 200 must parse — otherwise this test proves nothing",
798 );
799
800 let err = match accept_par_response(&outcome(400, &body)) {
804 Ok(_) => panic!("a 400 must not yield a request_uri"),
805 Err(err) => err,
806 };
807 assert!(
808 format!("{err:#}").contains("failed with status 400"),
809 "refused, but not by the status check: {err:#}",
810 );
811 }
812
813 #[test]
821 fn the_pending_row_lifetime_is_capped_regardless_of_the_server() {
822 let now = 1_700_000_000;
823
824 assert_eq!(
826 pending_expiry(now, 86_400),
827 now + MAX_PENDING_SECS,
828 "a server must not be able to extend the pending row past our cap",
829 );
830 assert_eq!(pending_expiry(now, 60), now + 60);
834 assert_eq!(
835 pending_expiry(now, MAX_PENDING_SECS),
836 now + MAX_PENDING_SECS
837 );
838 }
839
840 fn fixture_dpop_jwk() -> &'static str {
848 static JWK: std::sync::OnceLock<String> = std::sync::OnceLock::new();
849 JWK.get_or_init(|| {
850 crate::oauth::keys::SigningKey::generate("session")
851 .to_jwk_json()
852 .unwrap()
853 })
854 }
855
856 fn fixture_thumbprint() -> String {
858 crate::oauth::keys::SigningKey::from_jwk_json(fixture_dpop_jwk(), "session")
859 .unwrap()
860 .thumbprint()
861 .unwrap()
862 }
863
864 fn pending_auth(cookie_hash: &str) -> crate::oauth::store::PendingAuth {
866 crate::oauth::store::PendingAuth {
867 state: "state-value".into(),
868 browser_binding_hash: cookie_hash.into(),
869 pkce_verifier: "verifier".into(),
870 dpop_key_jwk: fixture_dpop_jwk().to_string(),
875 issuer: PENDING_ISSUER.into(),
876 pds_url: "https://pds.example.com".into(),
877 did: PENDING_DID.into(),
878 auth_method: "private_key_jwt".into(),
879 auth_kid: None,
880 redirect_uri: PUSHED_REDIRECT.into(),
881 requested_scope: "atproto transition:generic".into(),
882 request_uri: "urn:x".into(),
883 app_return_to: None,
884 expires_at: 2_000_000_000,
885 }
886 }
887
888 async fn pending_login(cookie_hash: &str) -> sqlx::SqlitePool {
891 let pool = empty_pool().await;
892 let codec = crate::oauth::crypto::Codec::new(Some(TEST_KEY)).unwrap();
893 crate::oauth::store::put_pending(&pool, &codec, &pending_auth(cookie_hash))
894 .await
895 .unwrap();
896 pool
897 }
898
899 fn runtime_at(public_url: &str) -> crate::oauth::runtime::OauthRuntime {
901 crate::oauth::runtime::OauthRuntime::new(&crate::config::Config {
902 repo_backend: crate::metrics::Backend::Rust,
903 public_url: public_url.into(),
904 oauth: crate::config::OauthConfig {
905 encryption_key: Some(TEST_KEY.to_string()),
906 key_path: std::env::temp_dir().join(format!(
913 "fr-login-test-key-{}-{:p}.json",
914 std::process::id(),
915 &TEST_KEY as *const _
916 )),
917 plc_directory: "https://plc.invalid".to_string(),
924 ..crate::config::OauthConfig::default()
925 },
926 ..crate::config::Config::default()
927 })
928 .expect("the test runtime must build")
929 }
930
931 const START_PDS: &str = "https://pds.example.com";
934 const START_ISSUER: &str = "https://auth.example.com";
935
936 fn started_account() -> crate::oauth::resolve::ResolvedAccount {
937 crate::oauth::resolve::ResolvedAccount {
938 did: PENDING_DID.into(),
939 pds_url: START_PDS.into(),
940 handle: Some("alice.example.com".into()),
941 }
942 }
943
944 fn started_server() -> discovery::AuthorizationServer {
945 discovery::AuthorizationServer {
946 issuer: START_ISSUER.into(),
947 par_endpoint: format!("{START_ISSUER}/par"),
948 authorization_endpoint: format!("{START_ISSUER}/authorize"),
949 token_endpoint: format!("{START_ISSUER}/token"),
950 revocation_endpoint: None,
951 }
952 }
953
954 fn par_ok() -> request::PostOutcome {
955 request::PostOutcome {
956 status: 201,
957 body: br#"{"request_uri":"urn:ietf:params:oauth:request_uri:abc","expires_in":60}"#
958 .to_vec(),
959 }
960 }
961
962 async fn run_start_against(
970 runtime: &crate::oauth::runtime::OauthRuntime,
971 pool: &sqlx::SqlitePool,
972 now: i64,
973 server: discovery::AuthorizationServer,
974 par: request::PostOutcome,
975 ) -> (
976 Result<StartedLogin>,
977 std::sync::Arc<std::sync::Mutex<Vec<ParPost>>>,
978 ) {
979 let pushed = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
980 let sink = std::sync::Arc::clone(&pushed);
981 let r = start_with(
982 runtime,
983 pool,
984 "alice.example.com",
985 now,
986 |_subject| async move { Ok(started_account()) },
987 move |_pds, _method, expected| async move {
988 assert!(
992 expected.is_none(),
993 "the initial push must not claim a prior issuer, got {expected:?}"
994 );
995 Ok(server)
996 },
997 move |req: ParPost| {
998 let sink = std::sync::Arc::clone(&sink);
999 async move {
1000 sink.lock().unwrap().push(req);
1001 Ok(par)
1002 }
1003 },
1004 )
1005 .await;
1006 (r, pushed)
1007 }
1008
1009 async fn run_start(
1011 runtime: &crate::oauth::runtime::OauthRuntime,
1012 pool: &sqlx::SqlitePool,
1013 now: i64,
1014 ) -> (
1015 Result<StartedLogin>,
1016 std::sync::Arc<std::sync::Mutex<Vec<ParPost>>>,
1017 ) {
1018 run_start_against(runtime, pool, now, started_server(), par_ok()).await
1019 }
1020
1021 fn par_body(status: u16, body: &str) -> request::PostOutcome {
1023 request::PostOutcome {
1024 status,
1025 body: body.as_bytes().to_vec(),
1026 }
1027 }
1028
1029 fn pushed_state(captured: &std::sync::Mutex<Vec<ParPost>>) -> String {
1032 let c = captured.lock().unwrap();
1033 assert_eq!(c.len(), 1, "PAR must be pushed exactly once");
1034 param(&c[0].params, "state")
1035 .expect("state in the push")
1036 .to_string()
1037 }
1038
1039 async fn assert_refused_without_storing(
1045 pool: &sqlx::SqlitePool,
1046 runtime: &crate::oauth::runtime::OauthRuntime,
1047 now: i64,
1048 started: Result<StartedLogin>,
1049 captured: &std::sync::Mutex<Vec<ParPost>>,
1050 what: &str,
1051 ) -> Result<()> {
1052 assert!(started.is_err(), "{what} must fail the login");
1053 let state = pushed_state(captured);
1054 assert!(
1055 crate::oauth::store::take_pending(pool, &runtime.codec, &state, now)
1056 .await?
1057 .is_none(),
1058 "{what} left a pending row behind",
1059 );
1060 Ok(())
1061 }
1062
1063 fn param<'a>(params: &'a [(&'static str, String)], k: &str) -> Option<&'a str> {
1064 params
1065 .iter()
1066 .find(|(n, _)| *n == k)
1067 .map(|(_, v)| v.as_str())
1068 }
1069
1070 #[tokio::test]
1077 async fn a_start_pushes_this_clients_identity_and_stores_what_it_pushed() -> Result<()> {
1078 let pool = empty_pool().await;
1079 let runtime = runtime_at("https://app.example.com");
1080 let now = 1_700_000_000;
1081
1082 let (started, pushed) = run_start(&runtime, &pool, now).await;
1083 let started = started?;
1084 let (url, params, pushed_key_jwk, retry_allowed) = {
1089 let pushed = pushed.lock().unwrap();
1090 assert_eq!(pushed.len(), 1, "PAR must be pushed exactly once");
1091 let r = &pushed[0];
1092 (
1093 r.url.clone(),
1094 r.params.clone(),
1095 r.key.to_jwk_json()?,
1096 matches!(r.retry, request::Retry::Allowed),
1097 )
1098 };
1099 let req = ¶ms;
1100
1101 assert_eq!(url, format!("{START_ISSUER}/par"), "wrong PAR endpoint");
1102 assert_eq!(
1103 param(req, "client_id"),
1104 Some(runtime.client_id.as_str()),
1105 "PAR was pushed under a client_id that is not ours",
1106 );
1107 assert_eq!(
1108 param(req, "redirect_uri"),
1109 Some(crate::oauth::metadata::redirect_uri(&runtime.client).as_str()),
1110 );
1111 assert!(
1112 retry_allowed,
1113 "PAR consumes nothing on rejection and must stay retryable",
1114 );
1115
1116 let assertion = param(req, "client_assertion").expect("a client assertion");
1125 let claims: serde_json::Value = {
1126 use base64::Engine as _;
1127 let payload = assertion.split('.').nth(1).expect("a JWT payload segment");
1128 serde_json::from_slice(
1129 &base64::engine::general_purpose::URL_SAFE_NO_PAD
1130 .decode(payload)
1131 .expect("the payload must be base64url"),
1132 )
1133 .expect("the payload must be JSON")
1134 };
1135 assert_eq!(
1136 claims["aud"].as_str(),
1137 Some(START_ISSUER),
1138 "the client assertion is addressed to the wrong audience: {claims}",
1139 );
1140 assert_eq!(
1141 claims["iss"].as_str(),
1142 Some(runtime.client_id.as_str()),
1143 "the client assertion's issuer is not this client",
1144 );
1145 assert_eq!(
1146 claims["sub"].as_str(),
1147 Some(runtime.client_id.as_str()),
1148 "the client assertion's subject is not this client",
1149 );
1150 assert_eq!(
1151 claims["iat"].as_i64(),
1152 Some(now),
1153 "iat is not the passed now"
1154 );
1155 assert!(
1156 claims["exp"]
1157 .as_i64()
1158 .is_some_and(|e| e > now && e <= now + 300),
1159 "exp must be ahead of iat and short-lived: {claims}",
1160 );
1161
1162 let pending = crate::oauth::store::take_pending(
1164 &pool,
1165 &runtime.codec,
1166 param(req, "state").expect("state in the push"),
1167 now,
1168 )
1169 .await?
1170 .expect("the pending row must exist");
1171
1172 assert_eq!(
1176 param(req, "code_challenge"),
1177 Some(flow::pkce_challenge(&pending.pkce_verifier).as_str()),
1178 "the pushed PKCE challenge does not match the stored verifier",
1179 );
1180
1181 assert_eq!(
1184 pending.dpop_key_jwk, pushed_key_jwk,
1185 "the pending row stores a different DPoP key than PAR was signed under",
1186 );
1187
1188 assert_eq!(
1192 pending.browser_binding_hash,
1193 flow::binding_hash(&started.binding_token),
1194 "the stored binding hash is not of the token handed to the browser",
1195 );
1196
1197 assert_eq!(pending.issuer, START_ISSUER);
1198 assert_eq!(pending.pds_url, START_PDS);
1199 assert_eq!(pending.did, PENDING_DID);
1200 assert_eq!(pending.request_uri, "urn:ietf:params:oauth:request_uri:abc");
1201 assert_eq!(
1202 pending.redirect_uri,
1203 crate::oauth::metadata::redirect_uri(&runtime.client)
1204 );
1205 assert_eq!(pending.requested_scope, runtime.client.scope_str());
1206
1207 assert!(
1210 started
1211 .authorize_url
1212 .starts_with(&format!("{START_ISSUER}/authorize")),
1213 "authorize_url does not point at the discovered endpoint: {}",
1214 started.authorize_url,
1215 );
1216 assert!(
1217 started
1218 .authorize_url
1219 .contains("urn%3Aietf%3Aparams%3Aoauth%3Arequest_uri%3Aabc")
1220 || started
1221 .authorize_url
1222 .contains("request_uri=urn:ietf:params:oauth:request_uri:abc"),
1223 "authorize_url does not carry the issued request_uri: {}",
1224 started.authorize_url,
1225 );
1226 Ok(())
1227 }
1228
1229 #[tokio::test]
1235 async fn a_rejected_par_push_stores_no_pending_row() -> Result<()> {
1236 let pool = empty_pool().await;
1237 let runtime = runtime_at("https://app.example.com");
1238
1239 let captured = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1240 let sink = std::sync::Arc::clone(&captured);
1241 let r = start_with(
1242 &runtime,
1243 &pool,
1244 "alice.example.com",
1245 1_700_000_000,
1246 |_s| async move { Ok(started_account()) },
1247 |_p, _m, _e| async move { Ok(started_server()) },
1248 move |req: ParPost| {
1249 let sink = std::sync::Arc::clone(&sink);
1250 async move {
1251 sink.lock().unwrap().push(req);
1252 Ok(request::PostOutcome {
1253 status: 400,
1254 body: br#"{"error":"invalid_request"}"#.to_vec(),
1255 })
1256 }
1257 },
1258 )
1259 .await;
1260
1261 assert!(r.is_err(), "a 400 from PAR must fail the login");
1262 let state = {
1263 let c = captured.lock().unwrap();
1264 param(&c[0].params, "state").expect("state").to_string()
1265 };
1266 assert!(
1267 crate::oauth::store::take_pending(&pool, &runtime.codec, &state, 1_700_000_000)
1268 .await?
1269 .is_none(),
1270 "a rejected push left a pending row behind",
1271 );
1272 Ok(())
1273 }
1274
1275 #[tokio::test]
1292 async fn a_hostile_request_uri_cannot_smuggle_parameters_into_the_authorize_url() -> Result<()>
1293 {
1294 const SMUGGLED: &str =
1295 "urn:ietf:params:oauth:request_uri:abc&redirect_uri=https://evil.example.com";
1296 let pool = empty_pool().await;
1297 let runtime = runtime_at("https://app.example.com");
1298 let body = serde_json::json!({ "request_uri": SMUGGLED, "expires_in": 60 });
1299 let (started, _) = run_start_against(
1300 &runtime,
1301 &pool,
1302 1_700_000_000,
1303 started_server(),
1304 par_body(201, &body.to_string()),
1305 )
1306 .await;
1307
1308 let url = url::Url::parse(&started?.authorize_url)?;
1309 let pairs: Vec<(String, String)> = url
1310 .query_pairs()
1311 .map(|(k, v)| (k.into_owned(), v.into_owned()))
1312 .collect();
1313 assert_eq!(
1314 pairs.len(),
1315 2,
1316 "the authorize URL gained a parameter from the server's bytes: {pairs:?}",
1317 );
1318 assert_eq!(
1319 pairs
1320 .iter()
1321 .find(|(k, _)| k == "request_uri")
1322 .map(|(_, v)| v.as_str()),
1323 Some(SMUGGLED),
1324 "the request_uri must survive as ONE opaque value, not be split",
1325 );
1326 assert!(
1327 !pairs.iter().any(|(k, _)| k == "redirect_uri"),
1328 "a redirect_uri was smuggled into the authorize URL: {pairs:?}",
1329 );
1330 Ok(())
1331 }
1332
1333 #[tokio::test]
1340 async fn a_par_success_carrying_no_grant_stores_nothing() -> Result<()> {
1341 let pool = empty_pool().await;
1342 let runtime = runtime_at("https://app.example.com");
1343 let now = 1_700_000_000;
1344 let (started, captured) = run_start_against(
1345 &runtime,
1346 &pool,
1347 now,
1348 started_server(),
1349 par_body(200, r#"{"error":"invalid_request"}"#),
1350 )
1351 .await;
1352 assert_refused_without_storing(
1353 &pool,
1354 &runtime,
1355 now,
1356 started,
1357 &captured,
1358 "a 200 with no request_uri",
1359 )
1360 .await
1361 }
1362
1363 #[tokio::test]
1368 async fn a_par_response_that_is_not_json_stores_nothing() -> Result<()> {
1369 let pool = empty_pool().await;
1370 let runtime = runtime_at("https://app.example.com");
1371 let now = 1_700_000_000;
1372 let (started, captured) = run_start_against(
1373 &runtime,
1374 &pool,
1375 now,
1376 started_server(),
1377 par_body(200, "<html><body>502 Bad Gateway</body></html>"),
1378 )
1379 .await;
1380 assert_refused_without_storing(
1381 &pool,
1382 &runtime,
1383 now,
1384 started,
1385 &captured,
1386 "a non-JSON PAR body",
1387 )
1388 .await
1389 }
1390
1391 #[tokio::test]
1398 async fn a_non_positive_par_lifetime_is_refused_rather_than_stored() -> Result<()> {
1399 let runtime = runtime_at("https://app.example.com");
1400 let now = 1_700_000_000;
1401 for expires_in in ["0", "-1"] {
1402 let pool = empty_pool().await;
1403 let body = format!(
1404 r#"{{"request_uri":"urn:ietf:params:oauth:request_uri:abc","expires_in":{expires_in}}}"#
1405 );
1406 let (started, captured) =
1407 run_start_against(&runtime, &pool, now, started_server(), par_body(201, &body))
1408 .await;
1409 assert_refused_without_storing(
1410 &pool,
1411 &runtime,
1412 now,
1413 started,
1414 &captured,
1415 &format!("expires_in={expires_in}"),
1416 )
1417 .await?;
1418 }
1419 Ok(())
1420 }
1421
1422 #[tokio::test]
1430 async fn a_server_cannot_pin_a_pending_login_beyond_the_cap() -> Result<()> {
1431 let pool = empty_pool().await;
1432 let runtime = runtime_at("https://app.example.com");
1433 let now = 1_700_000_000;
1434 let (started, captured) = run_start_against(
1435 &runtime,
1436 &pool,
1437 now,
1438 started_server(),
1439 par_body(
1440 201,
1441 r#"{"request_uri":"urn:ietf:params:oauth:request_uri:abc","expires_in":1000000000}"#,
1442 ),
1443 )
1444 .await;
1445 started?;
1446
1447 let state = pushed_state(&captured);
1448 let pending = crate::oauth::store::take_pending(&pool, &runtime.codec, &state, now)
1449 .await?
1450 .expect("the pending row must exist");
1451 assert_eq!(
1452 pending.expires_at,
1453 now + MAX_PENDING_SECS,
1454 "the server's lifetime was accepted instead of our cap",
1455 );
1456 Ok(())
1457 }
1458
1459 #[tokio::test]
1467 async fn a_non_https_authorization_endpoint_fails_the_login() -> Result<()> {
1468 let pool = empty_pool().await;
1469 let runtime = runtime_at("https://app.example.com");
1470 let downgraded = discovery::AuthorizationServer {
1471 authorization_endpoint: "http://auth.example.com/authorize".into(),
1472 ..started_server()
1473 };
1474 let (started, _) = run_start_against(
1475 &runtime,
1476 &pool,
1477 1_700_000_000,
1478 downgraded,
1479 par_body(
1480 201,
1481 r#"{"request_uri":"urn:ietf:params:oauth:request_uri:abc","expires_in":60}"#,
1482 ),
1483 )
1484 .await;
1485 let Err(err) = started else {
1489 panic!("an http authorization endpoint must fail the login")
1490 };
1491 assert!(
1492 format!("{err:#}").contains("https"),
1493 "the failure should name the scheme, got: {err:#}",
1494 );
1495 Ok(())
1496 }
1497
1498 fn callback_params() -> flow::CallbackParams {
1499 flow::CallbackParams {
1500 code: Some("the-code".into()),
1501 state: Some("state-value".into()),
1502 iss: Some(PENDING_ISSUER.into()),
1503 error: None,
1504 error_description: None,
1505 response: None,
1506 }
1507 }
1508
1509 #[tokio::test]
1530 async fn a_login_started_under_a_different_public_url_is_refused() {
1531 let cookie = flow::new_binding_token();
1532 let pool = pending_login(&flow::binding_hash(&cookie)).await;
1533 let runtime = runtime_at("http://127.0.0.1:8080");
1534
1535 let err = complete(
1536 &runtime,
1537 &reqwest::Client::new(),
1538 &pool,
1539 &callback_params(),
1540 Some(&cookie),
1541 1_700_000_000,
1542 )
1543 .await
1544 .expect_err("a client-identity change mid-flight must refuse the exchange");
1545
1546 let rendered = format!("{err:#}");
1547 assert!(
1548 rendered.contains("started under a different public URL")
1549 && rendered.contains(PUSHED_REDIRECT),
1550 "refused, but not BY the client-identity check — this is the failure mode \
1551 where discovery merely errored instead: {rendered}",
1552 );
1553 }
1554
1555 #[tokio::test]
1568 async fn a_matching_public_url_passes_the_identity_check() {
1569 let cookie = flow::new_binding_token();
1570 let pool = pending_login(&flow::binding_hash(&cookie)).await;
1571 let runtime = runtime_at("https://feather-reader.com");
1572
1573 let err = complete(
1574 &runtime,
1575 &reqwest::Client::new(),
1576 &pool,
1577 &callback_params(),
1578 Some(&cookie),
1579 1_700_000_000,
1580 )
1581 .await
1582 .expect_err("discovery cannot reach pds.example.com from a test");
1583
1584 let rendered = format!("{err:#}");
1585 assert!(
1586 rendered.contains("oauth-protected-resource") && rendered.contains("resolving host"),
1587 "the exchange should have got as far as discovery: {rendered}"
1588 );
1589 assert!(
1590 !rendered.contains("started under a different public URL"),
1591 "a login whose public URL never changed must not be refused as though it \
1592 had; the identity check is rejecting valid logins: {rendered}",
1593 );
1594 }
1595
1596 #[derive(Default)]
1610 struct Calls {
1611 discovered: Vec<(String, Option<String>, String)>,
1614 posted: Vec<PostedCall>,
1615 resolved: Vec<String>,
1616 }
1617 type Log = std::sync::Arc<std::sync::Mutex<Calls>>;
1618
1619 struct PostedCall {
1621 url: String,
1622 params: Vec<(&'static str, String)>,
1623 dpop_thumbprint: String,
1624 retry: request::Retry,
1625 }
1626
1627 const DISCOVERED_TOKEN_ENDPOINT: &str = "https://token.example.net/oauth/v2/token";
1636
1637 fn server_at(issuer: &str) -> crate::oauth::discovery::AuthorizationServer {
1638 crate::oauth::discovery::AuthorizationServer {
1639 issuer: issuer.into(),
1640 par_endpoint: format!("{issuer}/par"),
1641 authorization_endpoint: format!("{issuer}/authorize"),
1642 token_endpoint: DISCOVERED_TOKEN_ENDPOINT.to_string(),
1643 revocation_endpoint: None,
1644 }
1645 }
1646
1647 async fn drive(
1650 pool: &sqlx::SqlitePool,
1651 runtime: &crate::oauth::runtime::OauthRuntime,
1652 params: &flow::CallbackParams,
1653 cookie: Option<&str>,
1654 discovered_issuer: &str,
1655 token_status: u16,
1656 token_body: serde_json::Value,
1657 ) -> (Result<CompletedLogin>, Log) {
1658 let log: Log = Default::default();
1659 let (s1, s2, s3) = (
1660 std::sync::Arc::clone(&log),
1661 std::sync::Arc::clone(&log),
1662 std::sync::Arc::clone(&log),
1663 );
1664 let issuer = discovered_issuer.to_string();
1665 let body = serde_json::to_vec(&token_body).unwrap();
1666 let out = complete_with(
1667 runtime,
1668 pool,
1669 params,
1670 cookie,
1671 1_700_000_000,
1672 move |req| {
1673 let sink = std::sync::Arc::clone(&s1);
1674 let issuer = issuer.clone();
1675 async move {
1676 sink.lock().unwrap().discovered.push((
1677 req.pds_url,
1678 req.expected_issuer,
1679 req.auth_method,
1680 ));
1681 Ok(server_at(&issuer))
1682 }
1683 },
1684 move |req: TokenPost| {
1685 let sink = std::sync::Arc::clone(&s2);
1686 let body = body.clone();
1687 async move {
1688 let tp = req.key.thumbprint().unwrap_or_default();
1691 sink.lock().unwrap().posted.push(PostedCall {
1692 url: req.url,
1693 params: req.params,
1694 dpop_thumbprint: tp,
1695 retry: req.retry,
1696 });
1697 Ok(request::PostOutcome {
1698 status: token_status,
1699 body,
1700 })
1701 }
1702 },
1703 move |did| {
1704 let sink = std::sync::Arc::clone(&s3);
1705 async move {
1706 sink.lock().unwrap().resolved.push(did);
1707 Err(anyhow::anyhow!("handle resolution unavailable in tests"))
1710 }
1711 },
1712 )
1713 .await;
1714 (out, log)
1715 }
1716
1717 #[tokio::test]
1725 async fn a_full_callback_stores_a_session() {
1726 let cookie = flow::new_binding_token();
1727 let pool = pending_login(&flow::binding_hash(&cookie)).await;
1728 let runtime = runtime_at("https://feather-reader.com");
1729 let (out, log) = drive(
1730 &pool,
1731 &runtime,
1732 &callback_params(),
1733 Some(&cookie),
1734 PENDING_ISSUER,
1735 200,
1736 token_body(PENDING_DID),
1737 )
1738 .await;
1739
1740 let done = out.expect("the full callback should complete");
1741 assert_eq!(done.did, PENDING_DID);
1742 assert!(
1743 done.handle.is_none(),
1744 "the handle lookup was expected to fail offline"
1745 );
1746
1747 let (
1750 discoveries,
1751 expected_issuer,
1752 auth_method,
1753 posts,
1754 token_url,
1755 thumbprint,
1756 retry,
1757 token_params,
1758 ) = {
1759 let calls = log.lock().unwrap();
1760 (
1761 calls.discovered.len(),
1762 calls.discovered[0].1.clone(),
1763 calls.discovered[0].2.clone(),
1764 calls.posted.len(),
1765 calls.posted[0].url.clone(),
1766 calls.posted[0].dpop_thumbprint.clone(),
1767 calls.posted[0].retry,
1768 calls.posted[0].params.clone(),
1769 )
1770 };
1771 assert_eq!(discoveries, 1, "discovery ran once");
1772 assert_eq!(
1773 auth_method, "private_key_jwt",
1774 "discovery was told the wrong auth method; `none` would disable the \
1775 token_endpoint_auth_methods_supported check and the server would \
1776 then receive a private_key_jwt assertion it never advertised",
1777 );
1778 assert_eq!(
1779 expected_issuer.as_deref(),
1780 Some(PENDING_ISSUER),
1781 "the mix-up re-check was not armed: discovery got {expected_issuer:?}",
1782 );
1783 assert_eq!(posts, 1, "the token exchange ran once");
1784 assert_eq!(
1785 token_url, DISCOVERED_TOKEN_ENDPOINT,
1786 "the grant went to an endpoint guessed from the issuer rather than \
1787 the one discovery returned",
1788 );
1789 assert_eq!(
1790 retry,
1791 request::Retry::Allowed,
1792 "the token POST must be retryable: a nonce challenge is rejected \
1793 before the grant is processed, so the code is not consumed",
1794 );
1795 assert!(
1800 token_params
1801 .iter()
1802 .any(|(k, v)| *k == "code" && v == "the-code"),
1803 "the token request did not carry the callback's authorization code: {token_params:?}",
1804 );
1805 assert_eq!(
1806 thumbprint,
1807 fixture_thumbprint(),
1808 "the token request was signed under a different key than the one the \
1809 authorization request was bound to",
1810 );
1811
1812 assert_eq!(
1820 log.lock().unwrap().resolved,
1821 vec![PENDING_DID.to_string()],
1822 "the handle lookup did not run exactly once for this subject",
1823 );
1824
1825 let codec = crate::oauth::crypto::Codec::new(Some(TEST_KEY)).unwrap();
1826 let stored = crate::oauth::store::get_session(&pool, &codec, PENDING_DID)
1827 .await
1828 .unwrap()
1829 .expect("no session was stored for a successful login");
1830
1831 let stored_thumbprint = keys::SigningKey::from_jwk_json(&stored.dpop_key_jwk, "session")
1840 .expect("the stored session's DPoP key does not parse")
1841 .thumbprint()
1842 .unwrap();
1843 assert_eq!(
1844 stored_thumbprint,
1845 fixture_thumbprint(),
1846 "the session persisted a different DPoP key than the grant is bound to",
1847 );
1848
1849 assert_eq!(stored.access_token, "at-abc");
1855 assert_eq!(
1856 stored.refresh_token, "rt-abc",
1857 "an empty refresh token stores an un-refreshable session: the first \
1858 refresh presents \"\" and the server's invalid_grant deletes it, \
1859 which is the spurious logout the token module exists to avoid",
1860 );
1861 assert_eq!(stored.token_type, "DPoP");
1862 assert_eq!(
1863 stored.granted_scope, "atproto",
1864 "the session stored the REQUESTED scope, not the granted one — a \
1865 narrowed grant must be visible now rather than as a mystery write \
1866 failure later",
1867 );
1868 assert_eq!(
1869 stored.expires_at,
1870 Some(1_700_000_000 + 3600),
1871 "the session's expiry is not the token's; `None` means `is_stale` is \
1872 never true, so it is never proactively refreshed and simply dies",
1873 );
1874 assert_eq!(stored.issuer, PENDING_ISSUER);
1875 }
1876
1877 #[tokio::test]
1884 async fn a_mismatched_iss_posts_nothing_anywhere() {
1885 let cookie = flow::new_binding_token();
1886 let pool = pending_login(&flow::binding_hash(&cookie)).await;
1887 let runtime = runtime_at("https://feather-reader.com");
1888 let mut params = callback_params();
1889 params.iss = Some("https://evil.example.com".into());
1890
1891 let (out, log) = drive(
1892 &pool,
1893 &runtime,
1894 ¶ms,
1895 Some(&cookie),
1896 PENDING_ISSUER,
1897 200,
1898 token_body(PENDING_DID),
1899 )
1900 .await;
1901
1902 assert!(out.is_err(), "a mismatched iss completed the login");
1903 let calls = log.lock().unwrap();
1904 assert!(
1905 calls.posted.is_empty(),
1906 "the authorization code was posted despite a bad iss: {:?}",
1907 calls.posted.iter().map(|p| &p.url).collect::<Vec<_>>(),
1908 );
1909 assert!(
1910 calls.discovered.is_empty(),
1911 "discovery ran before the iss check",
1912 );
1913 }
1914
1915 #[tokio::test]
1920 async fn a_foreign_browser_posts_nothing_anywhere() {
1921 let cookie = flow::new_binding_token();
1922 let pool = pending_login(&flow::binding_hash(&cookie)).await;
1923 let runtime = runtime_at("https://feather-reader.com");
1924 let (out, log) = drive(
1925 &pool,
1926 &runtime,
1927 &callback_params(),
1928 Some("a-different-browser"),
1929 PENDING_ISSUER,
1930 200,
1931 token_body(PENDING_DID),
1932 )
1933 .await;
1934
1935 assert!(out.is_err(), "a foreign browser completed the login");
1936 let calls = log.lock().unwrap();
1937 assert!(calls.posted.is_empty());
1938 assert!(
1939 calls.discovered.is_empty(),
1940 "discovery ran before the browser binding was checked",
1941 );
1942 }
1943
1944 #[tokio::test]
1953 async fn discovery_is_given_the_stored_issuer_to_expect() {
1954 let cookie = flow::new_binding_token();
1955 let pool = pending_login(&flow::binding_hash(&cookie)).await;
1956 let runtime = runtime_at("https://feather-reader.com");
1957 let (out, log) = drive(
1958 &pool,
1959 &runtime,
1960 &callback_params(),
1961 Some(&cookie),
1962 PENDING_ISSUER,
1963 200,
1964 token_body(PENDING_DID),
1965 )
1966 .await;
1967 assert!(out.is_ok());
1968
1969 let calls = log.lock().unwrap();
1970 assert_eq!(
1971 calls.discovered[0].1.as_deref(),
1972 Some(PENDING_ISSUER),
1973 "discovery was not told which issuer to expect; the mix-up defence \
1974 is disabled from the caller's side",
1975 );
1976 assert_eq!(
1977 calls.discovered[0].0, "https://pds.example.com",
1978 "discovery was pointed at something other than the stored PDS",
1979 );
1980 }
1981
1982 #[tokio::test]
1987 async fn a_replayed_callback_is_refused_and_posts_nothing() {
1988 let cookie = flow::new_binding_token();
1989 let pool = pending_login(&flow::binding_hash(&cookie)).await;
1990 let runtime = runtime_at("https://feather-reader.com");
1991 let first = drive(
1992 &pool,
1993 &runtime,
1994 &callback_params(),
1995 Some(&cookie),
1996 PENDING_ISSUER,
1997 200,
1998 token_body(PENDING_DID),
1999 )
2000 .await;
2001 assert!(first.0.is_ok(), "the first callback should succeed");
2002
2003 let (out, log) = drive(
2004 &pool,
2005 &runtime,
2006 &callback_params(),
2007 Some(&cookie),
2008 PENDING_ISSUER,
2009 200,
2010 token_body(PENDING_DID),
2011 )
2012 .await;
2013 assert!(out.is_err(), "the callback was replayable");
2014 assert!(
2015 log.lock().unwrap().posted.is_empty(),
2016 "a replayed callback still reached the token endpoint",
2017 );
2018 }
2019
2020 #[tokio::test]
2029 async fn a_corrupt_dpop_key_posts_nothing_anywhere() {
2030 let cookie = flow::new_binding_token();
2031 let pool = empty_pool().await;
2032 let codec = crate::oauth::crypto::Codec::new(Some(TEST_KEY)).unwrap();
2033 let mut pending = pending_auth(&flow::binding_hash(&cookie));
2034 pending.dpop_key_jwk = "{\"kty\":\"EC\",\"crv\":\"bogus\"}".into();
2035 crate::oauth::store::put_pending(&pool, &codec, &pending)
2036 .await
2037 .unwrap();
2038
2039 let runtime = runtime_at("https://feather-reader.com");
2040 let (out, log) = drive(
2041 &pool,
2042 &runtime,
2043 &callback_params(),
2044 Some(&cookie),
2045 PENDING_ISSUER,
2046 200,
2047 token_body(PENDING_DID),
2048 )
2049 .await;
2050
2051 assert!(out.is_err(), "a corrupt DPoP key completed the login");
2052 let calls = log.lock().unwrap();
2053 assert!(
2054 calls.posted.is_empty(),
2055 "the authorization code was posted before the DPoP key was checked",
2056 );
2057 assert!(
2058 calls.discovered.is_empty(),
2059 "discovery ran before the DPoP key was checked",
2060 );
2061 }
2062
2063 fn discovery_routes(
2076 pds: &str,
2077 issuer: &str,
2078 ) -> std::collections::HashMap<String, Vec<crate::net::TestResponse>> {
2079 let mut r = std::collections::HashMap::new();
2080 r.insert(
2081 "/.well-known/oauth-protected-resource".to_string(),
2082 vec![crate::net::TestResponse::json(
2083 200,
2084 serde_json::json!({
2085 "resource": pds,
2086 "authorization_servers": [issuer],
2087 })
2088 .to_string(),
2089 )],
2090 );
2091 r.insert(
2092 "/.well-known/oauth-authorization-server".to_string(),
2093 vec![crate::net::TestResponse::json(
2094 200,
2095 serde_json::json!({
2096 "issuer": issuer,
2097 "pushed_authorization_request_endpoint": format!("{issuer}/par"),
2098 "authorization_endpoint": format!("{issuer}/authorize"),
2099 "token_endpoint": format!("{issuer}/token"),
2100 "protected_resources": [pds],
2101 "client_id_metadata_document_supported": true,
2102 "require_pushed_authorization_requests": true,
2103 "authorization_response_iss_parameter_supported": true,
2104 "token_endpoint_auth_methods_supported": ["private_key_jwt", "none"],
2105 "token_endpoint_auth_signing_alg_values_supported": ["ES256"],
2106 "dpop_signing_alg_values_supported": ["ES256"],
2107 "scopes_supported": ["atproto"],
2108 "response_types_supported": ["code"],
2109 "grant_types_supported": ["authorization_code", "refresh_token"],
2110 "code_challenge_methods_supported": ["S256"],
2111 })
2112 .to_string(),
2113 )],
2114 );
2115 r
2116 }
2117
2118 async fn pending_against(pds: &str, issuer: &str, cookie: &str) -> sqlx::SqlitePool {
2120 let pool = empty_pool().await;
2121 let codec = crate::oauth::crypto::Codec::new(Some(TEST_KEY)).unwrap();
2122 let mut pending = pending_auth(&flow::binding_hash(cookie));
2123 pending.pds_url = pds.to_string();
2124 pending.issuer = issuer.to_string();
2125 crate::oauth::store::put_pending(&pool, &codec, &pending)
2126 .await
2127 .unwrap();
2128 pool
2129 }
2130
2131 #[tokio::test]
2143 async fn a_repointed_authorization_server_is_refused_before_the_code_is_posted() {
2144 let cookie = flow::new_binding_token();
2145 let (addr, log) = crate::net::spawn_tls(|addr| {
2146 let port = addr.port();
2147 discovery_routes(
2149 &format!("https://pds-e2e.test:{port}"),
2150 &format!("https://as-evil.test:{port}"),
2151 )
2152 })
2153 .await;
2154 for h in ["pds-e2e.test", "as-e2e.test", "as-evil.test"] {
2155 crate::net::test_host_override(h, addr);
2156 }
2157
2158 let port = addr.port();
2159 let pds = format!("https://pds-e2e.test:{port}");
2160 let honest = format!("https://as-e2e.test:{port}");
2161
2162 let pool = pending_against(&pds, &honest, &cookie).await;
2163 let runtime = runtime_at("https://feather-reader.com");
2164 let mut params = callback_params();
2165 params.iss = Some(honest.clone());
2166
2167 let err = complete(
2168 &runtime,
2169 &reqwest::Client::builder().build().unwrap(),
2170 &pool,
2171 ¶ms,
2172 Some(&cookie),
2173 1_700_000_000,
2174 )
2175 .await
2176 .expect_err("a repointed authorization server completed the login");
2177
2178 let rendered = format!("{err:#}");
2179 let lower = rendered.to_ascii_lowercase();
2183 assert!(
2184 lower.contains("different authorization server")
2185 && rendered.contains("as-evil.test")
2186 && rendered.contains("as-e2e.test"),
2187 "refused, but not by the mix-up check: {rendered}",
2188 );
2189 let seen = log.lock().unwrap().join("\n");
2190 assert!(
2191 !seen.contains("POST /token"),
2192 "the authorization code was posted to a server the user never \
2193 approved:\n{seen}",
2194 );
2195 }
2196
2197 #[tokio::test]
2202 async fn a_well_formed_discovery_over_tls_reaches_the_token_endpoint() {
2203 let cookie = flow::new_binding_token();
2204 let (addr, log) = crate::net::spawn_tls(|addr| {
2205 let port = addr.port();
2206 let pds = format!("https://pds-e2e.test:{port}");
2207 let issuer = format!("https://as-e2e.test:{port}");
2208 let mut r = discovery_routes(&pds, &issuer);
2209 r.insert(
2211 "/token".to_string(),
2212 vec![crate::net::TestResponse::json(
2213 400,
2214 "{\"error\":\"invalid_grant\"}",
2215 )],
2216 );
2217 r
2218 })
2219 .await;
2220 for h in ["pds-e2e.test", "as-e2e.test"] {
2221 crate::net::test_host_override(h, addr);
2222 }
2223
2224 let port = addr.port();
2225 let pds = format!("https://pds-e2e.test:{port}");
2226 let issuer = format!("https://as-e2e.test:{port}");
2227 let pool = pending_against(&pds, &issuer, &cookie).await;
2228 let runtime = runtime_at("https://feather-reader.com");
2229 let mut params = callback_params();
2230 params.iss = Some(issuer.clone());
2231
2232 let _ = complete(
2233 &runtime,
2234 &reqwest::Client::builder().build().unwrap(),
2235 &pool,
2236 ¶ms,
2237 Some(&cookie),
2238 1_700_000_000,
2239 )
2240 .await;
2241
2242 let seen = log.lock().unwrap().join("\n");
2243 assert!(
2244 seen.contains("/.well-known/oauth-protected-resource"),
2245 "discovery never fetched the protected-resource document:\n{seen}",
2246 );
2247 assert!(
2248 seen.contains("POST /token"),
2249 "a well-formed discovery never reached the token endpoint — the \
2250 refusal test above would pass for the wrong reason:\n{seen}",
2251 );
2252 }
2253
2254 fn dpop_proof(raw: &str) -> String {
2256 raw.lines()
2257 .find(|l| l.to_ascii_lowercase().starts_with("dpop:"))
2258 .expect("no DPoP header on the request")[5..]
2259 .trim()
2260 .to_string()
2261 }
2262
2263 fn jwt_part(jwt: &str, idx: usize) -> serde_json::Value {
2265 use base64::Engine as _;
2266 let seg = jwt.split('.').nth(idx).expect("malformed JWT");
2267 let raw = base64::engine::general_purpose::URL_SAFE_NO_PAD
2268 .decode(seg)
2269 .expect("JWT segment is not base64url");
2270 serde_json::from_slice(&raw).expect("JWT segment is not JSON")
2271 }
2272
2273 fn dpop_jwk_from(raw: &str) -> String {
2275 use base64::Engine as _;
2276 let line = raw
2277 .lines()
2278 .find(|l| l.to_ascii_lowercase().starts_with("dpop:"))
2279 .expect("no DPoP header on the token request");
2280 let jwt = line[5..].trim();
2281 let header_b64 = jwt.split('.').next().expect("malformed DPoP proof");
2282 let json = base64::engine::general_purpose::URL_SAFE_NO_PAD
2283 .decode(header_b64)
2284 .expect("DPoP header is not base64url");
2285 let v: serde_json::Value = serde_json::from_slice(&json).expect("DPoP header is not JSON");
2286 v.get("jwk")
2287 .expect("DPoP header carries no jwk")
2288 .to_string()
2289 }
2290
2291 #[tokio::test]
2301 async fn the_token_request_is_signed_under_the_pending_rows_key() {
2302 let cookie = flow::new_binding_token();
2303 let (addr, log) = crate::net::spawn_tls(|addr| {
2304 let port = addr.port();
2305 let mut r = discovery_routes(
2306 &format!("https://pds-e2e.test:{port}"),
2307 &format!("https://as-e2e.test:{port}"),
2308 );
2309 r.insert(
2310 "/token".to_string(),
2311 vec![crate::net::TestResponse::json(
2312 400,
2313 "{\"error\":\"invalid_grant\"}",
2314 )],
2315 );
2316 r
2317 })
2318 .await;
2319 for h in ["pds-e2e.test", "as-e2e.test"] {
2320 crate::net::test_host_override(h, addr);
2321 }
2322 let port = addr.port();
2323 let issuer = format!("https://as-e2e.test:{port}");
2324 let pool = pending_against(&format!("https://pds-e2e.test:{port}"), &issuer, &cookie).await;
2325 let runtime = runtime_at("https://feather-reader.com");
2326 let mut params = callback_params();
2327 params.iss = Some(issuer);
2328
2329 let _ = complete(
2330 &runtime,
2331 &reqwest::Client::builder().build().unwrap(),
2332 &pool,
2333 ¶ms,
2334 Some(&cookie),
2335 1_700_000_000,
2336 )
2337 .await;
2338
2339 let token_req = log
2340 .lock()
2341 .unwrap()
2342 .iter()
2343 .find(|r| r.starts_with("POST /token"))
2344 .cloned()
2345 .expect("the token endpoint was never reached");
2346 let on_the_wire =
2347 keys::SigningKey::public_thumbprint_of(&dpop_jwk_from(&token_req)).unwrap();
2348 assert_eq!(
2349 on_the_wire,
2350 fixture_thumbprint(),
2351 "the token request was signed under a key the authorization request \
2352 was never bound to",
2353 );
2354 }
2355
2356 #[tokio::test]
2367 async fn a_nonce_challenge_on_the_token_endpoint_is_retried() {
2368 let cookie = flow::new_binding_token();
2369 let (addr, log) = crate::net::spawn_tls(|addr| {
2370 let port = addr.port();
2371 let mut r = discovery_routes(
2372 &format!("https://pds-e2e.test:{port}"),
2373 &format!("https://as-e2e.test:{port}"),
2374 );
2375 r.insert(
2376 "/token".to_string(),
2377 vec![
2378 crate::net::TestResponse::json(400, "{\"error\":\"use_dpop_nonce\"}")
2379 .with_header("DPoP-Nonce", "nonce-from-the-server"),
2380 crate::net::TestResponse::json(400, "{\"error\":\"invalid_grant\"}"),
2381 ],
2382 );
2383 r
2384 })
2385 .await;
2386 for h in ["pds-e2e.test", "as-e2e.test"] {
2387 crate::net::test_host_override(h, addr);
2388 }
2389 let port = addr.port();
2390 let issuer = format!("https://as-e2e.test:{port}");
2391 let pool = pending_against(&format!("https://pds-e2e.test:{port}"), &issuer, &cookie).await;
2392 let runtime = runtime_at("https://feather-reader.com");
2393 let mut params = callback_params();
2394 params.iss = Some(issuer);
2395
2396 let _ = complete(
2397 &runtime,
2398 &reqwest::Client::builder().build().unwrap(),
2399 &pool,
2400 ¶ms,
2401 Some(&cookie),
2402 1_700_000_000,
2403 )
2404 .await;
2405
2406 let reqs = log.lock().unwrap().clone();
2407 let token_hits = reqs.iter().filter(|r| r.starts_with("POST /token")).count();
2408 assert_eq!(
2409 token_hits, 2,
2410 "a use_dpop_nonce challenge was not retried; the exchange is marked \
2411 non-retryable somewhere between complete and the wire",
2412 );
2413 let second = reqs
2414 .iter()
2415 .filter(|r| r.starts_with("POST /token"))
2416 .nth(1)
2417 .unwrap();
2418 let claims = jwt_part(&dpop_proof(second), 1);
2422 assert_eq!(
2423 claims.get("nonce").and_then(|v| v.as_str()),
2424 Some("nonce-from-the-server"),
2425 "the retry did not carry the server's nonce; it would be challenged \
2426 again forever",
2427 );
2428 }
2429
2430 #[tokio::test]
2445 async fn the_exchange_uses_the_auth_method_the_login_started_under() {
2446 let cookie = flow::new_binding_token();
2447 let pool = empty_pool().await;
2448 let codec = crate::oauth::crypto::Codec::new(Some(TEST_KEY)).unwrap();
2449 let mut pending = pending_auth(&flow::binding_hash(&cookie));
2450 pending.auth_method = "none".into();
2451 crate::oauth::store::put_pending(&pool, &codec, &pending)
2452 .await
2453 .unwrap();
2454
2455 let runtime = runtime_at("https://feather-reader.com");
2457 assert_eq!(
2458 runtime.auth_method.as_str(),
2459 "private_key_jwt",
2460 "fixture: the runtime must DISAGREE with the pending row",
2461 );
2462
2463 let (out, log) = drive(
2464 &pool,
2465 &runtime,
2466 &callback_params(),
2467 Some(&cookie),
2468 PENDING_ISSUER,
2469 200,
2470 token_body(PENDING_DID),
2471 )
2472 .await;
2473 assert!(out.is_ok(), "the exchange should complete: {out:?}");
2474
2475 let calls = log.lock().unwrap();
2476 assert_eq!(
2477 calls.discovered[0].2, "none",
2478 "discovery was told the runtime's method, not the one PAR was pushed \
2479 under",
2480 );
2481 let params = &calls.posted[0].params;
2482 assert!(
2483 !params.iter().any(|(k, _)| *k == "client_assertion"),
2484 "a private_key_jwt assertion was sent for a login started under \
2485 `none`: {params:?}",
2486 );
2487 }
2488
2489 #[tokio::test]
2501 async fn the_assertion_and_session_audiences_are_distinct_and_correct() {
2502 let cookie = flow::new_binding_token();
2503 let pool = pending_login(&flow::binding_hash(&cookie)).await;
2504 let runtime = runtime_at("https://feather-reader.com");
2505 let (out, log) = drive(
2506 &pool,
2507 &runtime,
2508 &callback_params(),
2509 Some(&cookie),
2510 PENDING_ISSUER,
2511 200,
2512 token_body(PENDING_DID),
2513 )
2514 .await;
2515 assert!(out.is_ok());
2516
2517 let assertion = {
2520 let calls = log.lock().unwrap();
2521 calls.posted[0]
2522 .params
2523 .iter()
2524 .find(|(k, _)| *k == "client_assertion")
2525 .map(|(_, v)| v.clone())
2526 .expect("no client_assertion for a private_key_jwt login")
2527 };
2528 let claims: serde_json::Value = {
2531 use base64::Engine as _;
2532 let seg = assertion.split('.').nth(1).expect("malformed assertion");
2533 let raw = base64::engine::general_purpose::URL_SAFE_NO_PAD
2534 .decode(seg)
2535 .expect("assertion payload is not base64url");
2536 serde_json::from_slice(&raw).expect("assertion payload is not JSON")
2537 };
2538 assert_eq!(
2539 claims.get("aud").and_then(|v| v.as_str()),
2540 Some(PENDING_ISSUER),
2541 "the assertion was minted for the wrong audience; its anti-replay \
2542 binding names a server it was not sent to",
2543 );
2544
2545 let codec = crate::oauth::crypto::Codec::new(Some(TEST_KEY)).unwrap();
2546 let stored = crate::oauth::store::get_session(&pool, &codec, PENDING_DID)
2547 .await
2548 .unwrap()
2549 .expect("session");
2550 assert_eq!(
2551 stored.aud, "https://pds.example.com",
2552 "the session's audience is not the PDS; every later DPoP-bound call \
2553 would carry the wrong `htu`/`aud`",
2554 );
2555 }
2556
2557 #[tokio::test]
2568 async fn an_expired_pending_row_is_refused() {
2569 let cookie = flow::new_binding_token();
2570 let pool = empty_pool().await;
2571 let codec = crate::oauth::crypto::Codec::new(Some(TEST_KEY)).unwrap();
2572 let mut pending = pending_auth(&flow::binding_hash(&cookie));
2573 pending.expires_at = 1_700_000_000 - 3600;
2575 crate::oauth::store::put_pending(&pool, &codec, &pending)
2576 .await
2577 .unwrap();
2578
2579 let runtime = runtime_at("https://feather-reader.com");
2580 let (out, log) = drive(
2581 &pool,
2582 &runtime,
2583 &callback_params(),
2584 Some(&cookie),
2585 PENDING_ISSUER,
2586 200,
2587 token_body(PENDING_DID),
2588 )
2589 .await;
2590
2591 assert!(out.is_err(), "an expired pending row completed a login");
2592 assert!(
2593 log.lock().unwrap().posted.is_empty(),
2594 "the authorization code was posted for an expired pending row",
2595 );
2596 }
2597}