1use std::sync::Arc;
52
53use didbot_pds::oauth::{
54 ClientAuthKey, GrantError, GrantRequest, Lifetimes, MemoryOAuthGrantStore, MintedGrant,
55 OAuthGrantStore, Rotation, Unkept,
56};
57use didbot_pds::policy::ClientId;
58use time::{Duration, OffsetDateTime};
59
60use crate::oauth::authorize::ScopePolicy;
61use crate::oauth::client_auth::AuthenticatedClient;
62use crate::oauth::consent::{self, ConsentError, ConsentReference, ConsentStore};
63use crate::oauth::decision::{verdict_for, CEILING_RULE};
64use crate::oauth::dpop_seam::{DpopError, DpopThumbprint, DpopVerifier, VerifiedProof};
65use crate::oauth::scope::ScopeSet;
66
67pub const ACCESS_TTL: Duration = Duration::minutes(5);
69
70pub const REFRESH_TTL: Duration = Duration::days(14);
81
82pub const AUTHORIZATION_CODE_TTL: Duration = Duration::minutes(2);
88
89const CODE_BYTES: usize = 32;
91
92#[derive(Debug, Clone)]
98struct AuthorizationCode {
99 did: String,
100 client_id: String,
101 scope: ScopeSet,
102 redirect_uri: String,
103 code_challenge: String,
104 code_challenge_method: String,
105 client_auth_key: Option<ClientAuthKey>,
106 dpop_jkt: DpopThumbprint,
107 expires_at: OffsetDateTime,
108}
109
110pub trait AuthorizationCodeStore: Send + Sync {
113 fn issue(&self, code: AuthorizationCodeRecord) -> String;
115 fn redeem(&self, code: &str) -> Option<AuthorizationCodeRecord>;
120 fn revoke_account(&self, did: &str);
128
129 fn revoke_client(&self, client_id: &str, did: Option<&str>) -> usize;
136}
137
138pub struct AuthorizationCodeRecord {
142 pub did: String,
144 pub client_id: String,
146 pub scope: ScopeSet,
149 pub redirect_uri: String,
152 pub code_challenge: String,
154 pub code_challenge_method: String,
156 pub client_auth_key: Option<ClientAuthKey>,
159 pub dpop_jkt: DpopThumbprint,
162}
163
164impl From<consent::PendingConsent> for AuthorizationCodeRecord {
165 fn from(pending: consent::PendingConsent) -> Self {
166 Self {
167 did: pending.did,
168 client_id: pending.client_id,
169 scope: pending.granted_scope,
170 redirect_uri: pending.redirect_uri,
171 code_challenge: pending.code_challenge,
172 code_challenge_method: pending.code_challenge_method,
173 client_auth_key: pending.client_auth_key,
174 dpop_jkt: pending.dpop_jkt,
175 }
176 }
177}
178
179#[derive(Debug, Default)]
181pub struct MemoryAuthorizationCodeStore {
182 entries: std::sync::RwLock<std::collections::HashMap<String, AuthorizationCode>>,
183}
184
185impl MemoryAuthorizationCodeStore {
186 pub fn new() -> Self {
188 Self::default()
189 }
190
191 #[cfg(test)]
193 pub(crate) fn len(&self) -> usize {
194 self.entries.read().unwrap_or_else(|p| p.into_inner()).len()
195 }
196}
197
198fn random_code() -> String {
199 use rand::rngs::OsRng;
200 use rand::TryRngCore;
201 let mut bytes = [0u8; CODE_BYTES];
202 OsRng
203 .try_fill_bytes(&mut bytes)
204 .expect("the OS random source is available");
205 hex::encode(bytes)
206}
207
208impl AuthorizationCodeStore for MemoryAuthorizationCodeStore {
209 fn issue(&self, record: AuthorizationCodeRecord) -> String {
210 let code = random_code();
211 let now = OffsetDateTime::now_utc();
212 let mut entries = self.entries.write().unwrap_or_else(|p| p.into_inner());
213 entries.retain(|_, entry| now < entry.expires_at);
216 entries.insert(
217 code.clone(),
218 AuthorizationCode {
219 did: record.did,
220 client_id: record.client_id,
221 scope: record.scope,
222 redirect_uri: record.redirect_uri,
223 code_challenge: record.code_challenge,
224 code_challenge_method: record.code_challenge_method,
225 client_auth_key: record.client_auth_key,
226 dpop_jkt: record.dpop_jkt,
227 expires_at: now + AUTHORIZATION_CODE_TTL,
228 },
229 );
230 code
231 }
232
233 fn redeem(&self, code: &str) -> Option<AuthorizationCodeRecord> {
234 let mut entries = self.entries.write().unwrap_or_else(|p| p.into_inner());
235 let record = entries.remove(code)?;
236 if OffsetDateTime::now_utc() >= record.expires_at {
237 return None;
238 }
239 Some(AuthorizationCodeRecord {
240 did: record.did,
241 client_id: record.client_id,
242 scope: record.scope,
243 redirect_uri: record.redirect_uri,
244 code_challenge: record.code_challenge,
245 code_challenge_method: record.code_challenge_method,
246 client_auth_key: record.client_auth_key,
247 dpop_jkt: record.dpop_jkt,
248 })
249 }
250
251 fn revoke_account(&self, did: &str) {
252 self.entries
253 .write()
254 .unwrap_or_else(|p| p.into_inner())
255 .retain(|_, entry| entry.did != did);
256 }
257
258 fn revoke_client(&self, client_id: &str, did: Option<&str>) -> usize {
259 let mut entries = self.entries.write().unwrap_or_else(|p| p.into_inner());
260 let before = entries.len();
261 entries.retain(|_, entry| {
262 !(entry.client_id == client_id && did.is_none_or(|did| entry.did == did))
263 });
264 before - entries.len()
265 }
266}
267
268pub fn confirm_and_issue_code(
275 consent_store: &dyn ConsentStore,
276 code_store: &dyn AuthorizationCodeStore,
277 reference: &ConsentReference,
278 stamped_did: &str,
279) -> Result<String, ConsentError> {
280 let pending = consent::confirm(consent_store, reference, stamped_did)?;
281 Ok(code_store.issue(pending.into()))
282}
283
284#[derive(Debug, Clone, PartialEq, Eq)]
286pub struct OAuthTokens {
287 pub did: String,
289 pub client_id: String,
291 pub scope: ScopeSet,
293 pub access_token: String,
295 pub refresh_token: String,
297 pub access_expires_at: OffsetDateTime,
299 pub refresh_expires_at: OffsetDateTime,
301}
302
303#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
305pub enum TokenError {
306 #[error("grant_type `{0}` is not supported")]
309 UnsupportedGrantType(String),
310 #[error("`{0}` is a required parameter")]
312 MissingParameter(&'static str),
313 #[error("code is unknown, expired, or already redeemed")]
315 InvalidCode,
316 #[error("redirect_uri does not match the one this code was issued for")]
318 RedirectUriMismatch,
319 #[error("code_verifier does not match code_challenge")]
321 PkceFailed,
322 #[error("this code was issued to a sign-in that began under another DPoP key")]
325 CodeKeyMismatch,
326 #[error("client_id does not match the one this code was issued to")]
328 ClientMismatch,
329 #[error(
332 "this login belongs to a confidential client, and the request carries no client_assertion"
333 )]
334 ClientAssertionRequired,
335 #[error("the client authenticated differently from how this login began")]
339 ClientAuthenticationChanged,
340 #[error("DPoP proof was not accepted: {0}")]
342 Dpop(#[from] DpopError),
343 #[error("refresh_token is unknown or expired")]
345 InvalidRefreshToken,
346 #[error("refresh_token was already used; its whole family has been revoked")]
349 RefreshReused,
350 #[error("the DPoP key presented does not match the one this token family is bound to")]
353 DpopKeyMismatch,
354 #[error("this server's policy refuses this client a token")]
358 ClientRefused,
359 #[error("this server could not record the grant, so none was issued")]
363 NotKept,
364}
365
366#[derive(Debug, Clone, PartialEq, Eq)]
369pub struct AccessGrant {
370 pub did: String,
372 pub client_id: ClientId,
374 pub scope: ScopeSet,
377}
378
379pub trait TokenAdmission {
382 fn admits(&self, did: &str, client_id: &ClientId, scope: &ScopeSet) -> bool;
384}
385
386pub struct GateAdmission<'a> {
390 pub gate: &'a dyn didbot_pds::policy::PolicyGate,
392 pub registry: &'a dyn didbot_pds::Registry,
394 pub pds_did: &'a str,
396 pub pds_hostname: &'a str,
398}
399
400impl TokenAdmission for GateAdmission<'_> {
401 fn admits(&self, did: &str, client_id: &ClientId, scope: &ScopeSet) -> bool {
404 let Some(account) = self.registry.account(did) else {
405 return false;
406 };
407 let atoms: Vec<String> = scope.0.iter().map(ToString::to_string).collect();
408 let scopes: Vec<&str> = atoms.iter().map(String::as_str).collect();
409 let subject = didbot_pds::policy::token_subject(
410 account.did.as_str(),
411 account.kind,
412 account.handle.as_deref(),
413 self.pds_did,
414 self.pds_hostname,
415 client_id,
416 &scopes,
417 );
418 let boundary = didbot_pds::policy_bindings::Boundary::of(
419 self.registry
420 .boundary(did)
421 .unwrap_or_else(|_| vec![did.to_owned()]),
422 );
423 let outcome = self.gate.judge_within(&subject, &boundary);
424 let admitted = matches!(outcome, didbot_pds::policy::Outcome::Allow);
425 if !admitted {
426 tracing::info!(account = did, client = %client_id, "policy refused a client a token");
427 }
428 admitted
429 }
430}
431
432pub fn in_force(
440 approved: &ScopeSet,
441 ceiling: &dyn ScopePolicy,
442 did: &str,
443 client_id: &ClientId,
444) -> ScopeSet {
445 let ceiling = ceiling.ceiling(did, client_id.as_str());
446 verdict_for(approved, &ceiling, CEILING_RULE).granted(approved)
447}
448
449#[derive(Debug, Clone, PartialEq, Eq)]
452pub struct Standing {
453 pub in_force: ScopeSet,
456 pub admitted: bool,
459}
460
461#[must_use]
468pub fn standing(
469 grant: &didbot_pds::oauth::Grant,
470 ceiling: &dyn ScopePolicy,
471 admission: &dyn TokenAdmission,
472) -> Standing {
473 let Some(client_id) = stored_client_id(&grant.client_id) else {
474 return Standing {
475 in_force: ScopeSet::default(),
476 admitted: false,
477 };
478 };
479 let in_force = in_force(&approved(grant), ceiling, &grant.did, &client_id);
480 Standing {
481 admitted: admission.admits(&grant.did, &client_id, &in_force),
482 in_force,
483 }
484}
485
486fn stored_client_id(raw: &str) -> Option<ClientId> {
490 let parsed = ClientId::parse(raw);
491 if let Err(error) = &parsed {
492 tracing::warn!(client_id = raw, %error, "refusing an OAuth grant whose client_id policy cannot match");
493 }
494 parsed.ok()
495}
496
497fn approved(grant: &didbot_pds::oauth::Grant) -> ScopeSet {
506 match ScopeSet::parse_grant(&grant.scope) {
507 Ok(scope) => scope,
508 Err(error) => {
509 tracing::warn!(
510 client_id = grant.client_id,
511 %error,
512 "an OAuth grant's stored scope no longer parses; this login authorizes nothing"
513 );
514 ScopeSet::default()
515 }
516 }
517}
518
519fn authenticated_as(
523 client_id: &str,
524 bound: Option<&ClientAuthKey>,
525 presented: Option<&AuthenticatedClient>,
526) -> Result<(), TokenError> {
527 match (bound, presented) {
528 (None, None) => Ok(()),
529 (Some(_), None) => Err(TokenError::ClientAssertionRequired),
530 (None, Some(_)) => Err(TokenError::ClientAuthenticationChanged),
531 (Some(_), Some(presented)) if presented.client_id != client_id => {
532 Err(TokenError::ClientMismatch)
533 }
534 (Some(bound), Some(presented)) if presented.key != *bound => {
535 Err(TokenError::ClientAuthenticationChanged)
536 }
537 (Some(_), Some(_)) => Ok(()),
538 }
539}
540
541fn verify_pkce(code_verifier: &str, code_challenge: &str) -> bool {
545 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
546 use base64::Engine;
547 use sha2::{Digest, Sha256};
548 let digest = Sha256::digest(code_verifier.as_bytes());
549 let computed = URL_SAFE_NO_PAD.encode(digest);
550 computed == code_challenge
555}
556
557pub struct OAuthTokenStore {
566 grants: Arc<dyn OAuthGrantStore>,
567}
568
569impl Default for OAuthTokenStore {
570 fn default() -> Self {
571 Self::new()
572 }
573}
574
575impl OAuthTokenStore {
576 pub fn new() -> Self {
578 Self::with_grants(Arc::new(MemoryOAuthGrantStore::new()))
579 }
580
581 pub fn with_grants(grants: Arc<dyn OAuthGrantStore>) -> Self {
584 Self { grants }
585 }
586
587 fn lifetimes() -> Lifetimes {
590 Lifetimes {
591 access: ACCESS_TTL,
592 refresh: REFRESH_TTL,
593 }
594 }
595
596 fn tokens(minted: MintedGrant, scope: ScopeSet) -> OAuthTokens {
599 OAuthTokens {
600 did: minted.grant.did,
601 client_id: minted.grant.client_id,
602 scope,
603 access_token: minted.access_token,
604 refresh_token: minted.refresh_token,
605 access_expires_at: minted.grant.access_expires_at,
606 refresh_expires_at: minted.grant.refresh_expires_at,
607 }
608 }
609
610 #[allow(clippy::too_many_arguments)]
629 pub fn exchange_code(
630 &self,
631 code_store: &dyn AuthorizationCodeStore,
632 dpop: &dyn DpopVerifier,
633 admission: &dyn TokenAdmission,
634 ceiling: &dyn ScopePolicy,
635 code: &str,
636 redirect_uri: &str,
637 code_verifier: &str,
638 client_id: &str,
639 client: Option<&AuthenticatedClient>,
640 proof: &VerifiedProof,
641 ) -> Result<OAuthTokens, TokenError> {
642 let record = code_store.redeem(code).ok_or(TokenError::InvalidCode)?;
643 if record.client_id != client_id {
644 return Err(TokenError::ClientMismatch);
645 }
646 if proof.thumbprint != record.dpop_jkt {
647 return Err(TokenError::CodeKeyMismatch);
648 }
649 authenticated_as(&record.client_id, record.client_auth_key.as_ref(), client)?;
650 if record.redirect_uri != redirect_uri {
651 return Err(TokenError::RedirectUriMismatch);
652 }
653 if record.code_challenge_method != "S256"
654 || !verify_pkce(code_verifier, &record.code_challenge)
655 {
656 return Err(TokenError::PkceFailed);
657 }
658 dpop.accept(proof)?;
662 let thumbprint = proof.thumbprint.clone();
663 let client = stored_client_id(&record.client_id).ok_or(TokenError::ClientRefused)?;
664 let scope = in_force(&record.scope, ceiling, &record.did, &client);
665 if scope.0.is_empty() || !admission.admits(&record.did, &client, &scope) {
666 return Err(TokenError::ClientRefused);
667 }
668 let minted = self
669 .grants
670 .mint(
671 GrantRequest {
672 did: record.did,
673 client_id: record.client_id,
674 scope: record.scope.to_string(),
675 dpop_thumbprint: thumbprint.0,
676 client_auth_key: record.client_auth_key,
677 },
678 Self::lifetimes(),
679 )
680 .map_err(|_| TokenError::NotKept)?;
681 Ok(Self::tokens(minted, scope))
682 }
683
684 pub fn refresh(
700 &self,
701 dpop: &dyn DpopVerifier,
702 admission: &dyn TokenAdmission,
703 ceiling: &dyn ScopePolicy,
704 refresh_token: &str,
705 client: Option<&AuthenticatedClient>,
706 proof: &VerifiedProof,
707 ) -> Result<OAuthTokens, TokenError> {
708 let thumbprint = proof.thumbprint.clone();
709 if let Some(grant) = self.grants.resolve_refresh(refresh_token, &thumbprint.0) {
710 dpop.accept(proof)?;
716 authenticated_as(&grant.client_id, grant.client_auth_key.as_ref(), client)?;
717 let Some(client) = stored_client_id(&grant.client_id) else {
718 return Err(TokenError::InvalidRefreshToken);
719 };
720 let scope = in_force(&approved(&grant), ceiling, &grant.did, &client);
721 if scope.0.is_empty() || !admission.admits(&grant.did, &client, &scope) {
722 return Err(TokenError::ClientRefused);
723 }
724 }
725 match self
726 .grants
727 .rotate(refresh_token, &thumbprint.0, Self::lifetimes())
728 {
729 Rotation::Rotated(minted) => {
730 let scope = stored_client_id(&minted.grant.client_id)
731 .map(|client| {
732 in_force(
733 &approved(&minted.grant),
734 ceiling,
735 &minted.grant.did,
736 &client,
737 )
738 })
739 .unwrap_or_default();
740 Ok(Self::tokens(*minted, scope))
741 }
742 Rotation::Revoked {
743 error: GrantError::Reused,
744 ..
745 } => Err(TokenError::RefreshReused),
746 Rotation::Revoked { .. } | Rotation::Refused(GrantError::Unknown) => {
747 Err(TokenError::InvalidRefreshToken)
748 }
749 Rotation::Refused(GrantError::KeyMismatch) => Err(TokenError::DpopKeyMismatch),
750 Rotation::Refused(_) => Err(TokenError::InvalidRefreshToken),
751 }
752 }
753
754 pub fn validate_access(
779 &self,
780 access_token: &str,
781 thumbprint: &DpopThumbprint,
782 ceiling: &dyn ScopePolicy,
783 ) -> Option<AccessGrant> {
784 let grant = self.grants.resolve_access(access_token, &thumbprint.0)?;
785 let client_id = stored_client_id(&grant.client_id)?;
786 Some(AccessGrant {
787 scope: in_force(&approved(&grant), ceiling, &grant.did, &client_id),
788 client_id,
789 did: grant.did,
790 })
791 }
792
793 pub fn revoke_account(&self, did: &str) -> Result<usize, Unkept> {
812 let revoked = self.grants.revoke_account(did)?;
813 if !revoked.is_empty() {
814 tracing::info!(
815 did,
816 grants = revoked.len(),
817 "revoked an account's OAuth grants"
818 );
819 }
820 Ok(revoked.len())
821 }
822
823 #[must_use]
827 pub fn live(&self) -> Vec<didbot_pds::oauth::Grant> {
828 self.grants.live()
829 }
830
831 pub fn revoke_client(
846 &self,
847 client_id: &str,
848 did: Option<&str>,
849 ) -> Result<Vec<didbot_pds::oauth::Grant>, Unkept> {
850 self.grants.revoke_client(client_id, did)
851 }
852
853 pub fn revoke_all(&self) {
856 let revoked = self.grants.revoke_all();
857 if !revoked.is_empty() {
858 tracing::info!(grants = revoked.len(), "revoked every OAuth grant");
859 }
860 }
861}
862
863#[cfg(test)]
864mod tests {
865 use super::*;
866 use crate::oauth::authorize::GrantAnyScope;
867
868 struct NoReplay;
873 impl DpopVerifier for NoReplay {
874 fn verify(
875 &self,
876 _proof: &str,
877 _method: &str,
878 _uri: &str,
879 _access_token: Option<&str>,
880 ) -> Result<VerifiedProof, DpopError> {
881 Err(DpopError::Refused("the grants verify nothing".to_owned()))
882 }
883 }
884
885 fn proof(thumbprint: &str) -> VerifiedProof {
888 VerifiedProof::for_key(thumbprint)
889 }
890
891 struct AdmitAll;
893 impl TokenAdmission for AdmitAll {
894 fn admits(&self, _did: &str, _client_id: &ClientId, _scope: &ScopeSet) -> bool {
895 true
896 }
897 }
898
899 struct RefuseAll;
901 impl TokenAdmission for RefuseAll {
902 fn admits(&self, _did: &str, _client_id: &ClientId, _scope: &ScopeSet) -> bool {
903 false
904 }
905 }
906
907 fn code_verifier_and_challenge() -> (String, String) {
908 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
909 use base64::Engine;
910 use sha2::{Digest, Sha256};
911 let verifier = "a-fixed-code-verifier-at-least-43-characters-long".to_owned();
912 let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()));
913 (verifier, challenge)
914 }
915
916 fn issue_code(code_store: &dyn AuthorizationCodeStore, code_challenge: &str) -> String {
917 code_store.issue(AuthorizationCodeRecord {
918 did: "did:web:agent.example".to_owned(),
919 client_id: "https://client.example/id.json".to_owned(),
920 scope: ScopeSet::parse("atproto").unwrap(),
921 redirect_uri: "https://client.example/cb".to_owned(),
922 code_challenge: code_challenge.to_owned(),
923 code_challenge_method: "S256".to_owned(),
924 client_auth_key: None,
925 dpop_jkt: DpopThumbprint("thumb-a".to_owned()),
926 })
927 }
928
929 #[test]
930 fn exchanging_a_code_with_the_right_verifier_mints_a_bound_pair() {
931 let (verifier, challenge) = code_verifier_and_challenge();
932 let codes = MemoryAuthorizationCodeStore::new();
933 let code = issue_code(&codes, &challenge);
934 let store = OAuthTokenStore::new();
935 let tokens = store
936 .exchange_code(
937 &codes,
938 &NoReplay,
939 &AdmitAll,
940 &GrantAnyScope,
941 &code,
942 "https://client.example/cb",
943 &verifier,
944 "https://client.example/id.json",
945 None,
946 &proof("thumb-a"),
947 )
948 .unwrap();
949 assert_eq!(tokens.did, "did:web:agent.example");
950 assert_eq!(
951 store
952 .validate_access(
953 &tokens.access_token,
954 &DpopThumbprint("thumb-a".to_owned()),
955 &GrantAnyScope
956 )
957 .map(|grant| grant.did),
958 Some("did:web:agent.example".to_owned())
959 );
960 }
961
962 #[test]
968 fn validate_access_refuses_the_right_token_with_the_wrong_thumbprint() {
969 let (verifier, challenge) = code_verifier_and_challenge();
970 let codes = MemoryAuthorizationCodeStore::new();
971 let code = issue_code(&codes, &challenge);
972 let store = OAuthTokenStore::new();
973 let tokens = store
974 .exchange_code(
975 &codes,
976 &NoReplay,
977 &AdmitAll,
978 &GrantAnyScope,
979 &code,
980 "https://client.example/cb",
981 &verifier,
982 "https://client.example/id.json",
983 None,
984 &proof("thumb-a"),
985 )
986 .unwrap();
987 assert_eq!(
988 store.validate_access(&tokens.access_token, &DpopThumbprint("thumb-b".to_owned()), &GrantAnyScope).map(|grant| grant.did),
989 None,
990 "a stolen access token presented with a different key's thumbprint must not authenticate"
991 );
992 }
993
994 #[test]
997 fn an_unredeemed_code_is_swept_once_it_has_expired() {
998 let (_, challenge) = code_verifier_and_challenge();
999 let codes = MemoryAuthorizationCodeStore::new();
1000 let abandoned = issue_code(&codes, &challenge);
1001 codes
1002 .entries
1003 .write()
1004 .unwrap()
1005 .get_mut(&abandoned)
1006 .unwrap()
1007 .expires_at = OffsetDateTime::now_utc() - time::Duration::seconds(1);
1008
1009 let fresh = issue_code(&codes, &challenge);
1010 assert_eq!(codes.len(), 1);
1011 assert!(codes.redeem(&fresh).is_some());
1012 assert_eq!(codes.len(), 0);
1013 }
1014
1015 #[test]
1016 fn a_code_is_one_shot() {
1017 let (verifier, challenge) = code_verifier_and_challenge();
1018 let codes = MemoryAuthorizationCodeStore::new();
1019 let code = issue_code(&codes, &challenge);
1020 let store = OAuthTokenStore::new();
1021 let exchange = |c: &str| {
1022 store.exchange_code(
1023 &codes,
1024 &NoReplay,
1025 &AdmitAll,
1026 &GrantAnyScope,
1027 c,
1028 "https://client.example/cb",
1029 &verifier,
1030 "https://client.example/id.json",
1031 None,
1032 &proof("thumb-a"),
1033 )
1034 };
1035 assert!(exchange(&code).is_ok());
1036 assert_eq!(exchange(&code).unwrap_err(), TokenError::InvalidCode);
1037 }
1038
1039 #[test]
1040 fn a_wrong_code_verifier_fails_pkce() {
1041 let (_verifier, challenge) = code_verifier_and_challenge();
1042 let codes = MemoryAuthorizationCodeStore::new();
1043 let code = issue_code(&codes, &challenge);
1044 let store = OAuthTokenStore::new();
1045 let err = store
1046 .exchange_code(
1047 &codes,
1048 &NoReplay,
1049 &AdmitAll,
1050 &GrantAnyScope,
1051 &code,
1052 "https://client.example/cb",
1053 "wrong-verifier",
1054 "https://client.example/id.json",
1055 None,
1056 &proof("thumb-a"),
1057 )
1058 .unwrap_err();
1059 assert_eq!(err, TokenError::PkceFailed);
1060 }
1061
1062 #[test]
1066 fn a_code_is_refused_under_another_key() {
1067 let (verifier, challenge) = code_verifier_and_challenge();
1068 let codes = MemoryAuthorizationCodeStore::new();
1069 let code = issue_code(&codes, &challenge);
1070 let store = OAuthTokenStore::new();
1071 let exchange = |thumbprint: &str| {
1072 store.exchange_code(
1073 &codes,
1074 &NoReplay,
1075 &AdmitAll,
1076 &GrantAnyScope,
1077 &code,
1078 "https://client.example/cb",
1079 &verifier,
1080 "https://client.example/id.json",
1081 None,
1082 &proof(thumbprint),
1083 )
1084 };
1085 assert_eq!(
1086 exchange("thumb-b").unwrap_err(),
1087 TokenError::CodeKeyMismatch
1088 );
1089 assert_eq!(exchange("thumb-a").unwrap_err(), TokenError::InvalidCode);
1090 }
1091
1092 #[test]
1093 fn a_mismatched_redirect_uri_is_refused() {
1094 let (verifier, challenge) = code_verifier_and_challenge();
1095 let codes = MemoryAuthorizationCodeStore::new();
1096 let code = issue_code(&codes, &challenge);
1097 let store = OAuthTokenStore::new();
1098 let err = store
1099 .exchange_code(
1100 &codes,
1101 &NoReplay,
1102 &AdmitAll,
1103 &GrantAnyScope,
1104 &code,
1105 "https://client.example/somewhere-else",
1106 &verifier,
1107 "https://client.example/id.json",
1108 None,
1109 &proof("thumb-a"),
1110 )
1111 .unwrap_err();
1112 assert_eq!(err, TokenError::RedirectUriMismatch);
1113 }
1114
1115 #[test]
1116 fn refresh_rotates_both_tokens_and_the_old_access_token_stops_working() {
1117 let (verifier, challenge) = code_verifier_and_challenge();
1118 let codes = MemoryAuthorizationCodeStore::new();
1119 let code = issue_code(&codes, &challenge);
1120 let store = OAuthTokenStore::new();
1121 let first = store
1122 .exchange_code(
1123 &codes,
1124 &NoReplay,
1125 &AdmitAll,
1126 &GrantAnyScope,
1127 &code,
1128 "https://client.example/cb",
1129 &verifier,
1130 "https://client.example/id.json",
1131 None,
1132 &proof("thumb-a"),
1133 )
1134 .unwrap();
1135 let second = store
1136 .refresh(
1137 &NoReplay,
1138 &AdmitAll,
1139 &GrantAnyScope,
1140 &first.refresh_token,
1141 None,
1142 &proof("thumb-a"),
1143 )
1144 .unwrap();
1145 assert_ne!(first.access_token, second.access_token);
1146 assert_ne!(first.refresh_token, second.refresh_token);
1147 assert_eq!(
1148 store
1149 .validate_access(
1150 &first.access_token,
1151 &DpopThumbprint("thumb-a".to_owned()),
1152 &GrantAnyScope
1153 )
1154 .map(|grant| grant.did),
1155 None
1156 );
1157 assert_eq!(
1158 store
1159 .validate_access(
1160 &second.access_token,
1161 &DpopThumbprint("thumb-a".to_owned()),
1162 &GrantAnyScope
1163 )
1164 .map(|grant| grant.did),
1165 Some("did:web:agent.example".to_owned())
1166 );
1167 }
1168
1169 #[test]
1172 fn revoking_an_account_ends_its_grant_and_its_refresh() {
1173 let (verifier, challenge) = code_verifier_and_challenge();
1174 let codes = MemoryAuthorizationCodeStore::new();
1175 let code = issue_code(&codes, &challenge);
1176 let store = OAuthTokenStore::new();
1177 let tokens = store
1178 .exchange_code(
1179 &codes,
1180 &NoReplay,
1181 &AdmitAll,
1182 &GrantAnyScope,
1183 &code,
1184 "https://client.example/cb",
1185 &verifier,
1186 "https://client.example/id.json",
1187 None,
1188 &proof("thumb-a"),
1189 )
1190 .unwrap();
1191 assert_eq!(
1192 store
1193 .validate_access(
1194 &tokens.access_token,
1195 &DpopThumbprint("thumb-a".to_owned()),
1196 &GrantAnyScope
1197 )
1198 .map(|grant| grant.did),
1199 Some("did:web:agent.example".to_owned())
1200 );
1201
1202 assert_eq!(
1203 store.revoke_account("did:web:agent.example").ok(),
1204 Some(1),
1205 "a memory store keeps every ending"
1206 );
1207
1208 assert_eq!(
1209 store
1210 .validate_access(
1211 &tokens.access_token,
1212 &DpopThumbprint("thumb-a".to_owned()),
1213 &GrantAnyScope
1214 )
1215 .map(|grant| grant.did),
1216 None
1217 );
1218 assert!(store
1219 .refresh(
1220 &NoReplay,
1221 &AdmitAll,
1222 &GrantAnyScope,
1223 &tokens.refresh_token,
1224 None,
1225 &proof("thumb-a"),
1226 )
1227 .is_err());
1228 }
1229
1230 #[test]
1233 fn revoking_an_account_drops_its_unredeemed_codes() {
1234 let (_verifier, challenge) = code_verifier_and_challenge();
1235 let codes = MemoryAuthorizationCodeStore::new();
1236 let code = issue_code(&codes, &challenge);
1237
1238 codes.revoke_account("did:web:agent.example");
1239
1240 assert!(codes.redeem(&code).is_none());
1241 }
1242
1243 #[test]
1244 fn reusing_a_spent_refresh_token_revokes_the_family() {
1245 let (verifier, challenge) = code_verifier_and_challenge();
1246 let codes = MemoryAuthorizationCodeStore::new();
1247 let code = issue_code(&codes, &challenge);
1248 let store = OAuthTokenStore::new();
1249 let first = store
1250 .exchange_code(
1251 &codes,
1252 &NoReplay,
1253 &AdmitAll,
1254 &GrantAnyScope,
1255 &code,
1256 "https://client.example/cb",
1257 &verifier,
1258 "https://client.example/id.json",
1259 None,
1260 &proof("thumb-a"),
1261 )
1262 .unwrap();
1263 let second = store
1264 .refresh(
1265 &NoReplay,
1266 &AdmitAll,
1267 &GrantAnyScope,
1268 &first.refresh_token,
1269 None,
1270 &proof("thumb-a"),
1271 )
1272 .unwrap();
1273 let err = store
1274 .refresh(
1275 &NoReplay,
1276 &AdmitAll,
1277 &GrantAnyScope,
1278 &first.refresh_token,
1279 None,
1280 &proof("thumb-a"),
1281 )
1282 .unwrap_err();
1283 assert_eq!(err, TokenError::RefreshReused);
1284 assert_eq!(
1285 store
1286 .validate_access(
1287 &second.access_token,
1288 &DpopThumbprint("thumb-a".to_owned()),
1289 &GrantAnyScope
1290 )
1291 .map(|grant| grant.did),
1292 None
1293 );
1294 }
1295
1296 #[test]
1297 fn refreshing_with_a_different_dpop_key_is_refused() {
1298 let (verifier, challenge) = code_verifier_and_challenge();
1299 let codes = MemoryAuthorizationCodeStore::new();
1300 let code = issue_code(&codes, &challenge);
1301 let store = OAuthTokenStore::new();
1302 let first = store
1303 .exchange_code(
1304 &codes,
1305 &NoReplay,
1306 &AdmitAll,
1307 &GrantAnyScope,
1308 &code,
1309 "https://client.example/cb",
1310 &verifier,
1311 "https://client.example/id.json",
1312 None,
1313 &proof("thumb-a"),
1314 )
1315 .unwrap();
1316 let err = store
1317 .refresh(
1318 &NoReplay,
1319 &AdmitAll,
1320 &GrantAnyScope,
1321 &first.refresh_token,
1322 None,
1323 &proof("thumb-b"),
1324 )
1325 .unwrap_err();
1326 assert_eq!(err, TokenError::DpopKeyMismatch);
1327 }
1328
1329 fn sign_in(
1332 store: &OAuthTokenStore,
1333 admission: &dyn TokenAdmission,
1334 ) -> Result<OAuthTokens, TokenError> {
1335 let (verifier, challenge) = code_verifier_and_challenge();
1336 let codes = MemoryAuthorizationCodeStore::new();
1337 let code = issue_code(&codes, &challenge);
1338 store.exchange_code(
1339 &codes,
1340 &NoReplay,
1341 admission,
1342 &GrantAnyScope,
1343 &code,
1344 "https://client.example/cb",
1345 &verifier,
1346 "https://client.example/id.json",
1347 None,
1348 &proof("thumb-a"),
1349 )
1350 }
1351
1352 fn refresh_with(
1353 store: &OAuthTokenStore,
1354 admission: &dyn TokenAdmission,
1355 refresh_token: &str,
1356 ) -> Result<OAuthTokens, TokenError> {
1357 store.refresh(
1358 &NoReplay,
1359 admission,
1360 &GrantAnyScope,
1361 refresh_token,
1362 None,
1363 &proof("thumb-a"),
1364 )
1365 }
1366
1367 #[test]
1368 fn a_refused_client_gets_no_token_for_its_code() {
1369 let store = OAuthTokenStore::new();
1370 assert_eq!(
1371 sign_in(&store, &RefuseAll).unwrap_err(),
1372 TokenError::ClientRefused
1373 );
1374 }
1375
1376 #[test]
1379 fn a_refused_refresh_keeps_its_family() {
1380 let store = OAuthTokenStore::new();
1381 let tokens = sign_in(&store, &AdmitAll).unwrap();
1382 assert_eq!(
1383 refresh_with(&store, &RefuseAll, &tokens.refresh_token).unwrap_err(),
1384 TokenError::ClientRefused
1385 );
1386 assert!(refresh_with(&store, &AdmitAll, &tokens.refresh_token).is_ok());
1387 }
1388
1389 #[test]
1392 fn a_replay_ends_its_family_even_under_a_refusal() {
1393 let store = OAuthTokenStore::new();
1394 let first = sign_in(&store, &AdmitAll).unwrap();
1395 let second = refresh_with(&store, &AdmitAll, &first.refresh_token).unwrap();
1396 assert_eq!(
1397 refresh_with(&store, &RefuseAll, &first.refresh_token).unwrap_err(),
1398 TokenError::RefreshReused
1399 );
1400 assert!(
1401 refresh_with(&store, &AdmitAll, &second.refresh_token).is_err(),
1402 "the family is over"
1403 );
1404 }
1405
1406 fn client_key(kid: &str) -> ClientAuthKey {
1408 ClientAuthKey {
1409 kid: kid.to_owned(),
1410 alg: "ES256".to_owned(),
1411 jkt: format!("{kid}-thumbprint"),
1412 }
1413 }
1414
1415 fn authenticated(kid: &str) -> AuthenticatedClient {
1418 AuthenticatedClient {
1419 client_id: "https://client.example/id.json".to_owned(),
1420 key: client_key(kid),
1421 }
1422 }
1423
1424 fn exchange_confidential(
1427 store: &OAuthTokenStore,
1428 presented: Option<&AuthenticatedClient>,
1429 ) -> Result<OAuthTokens, TokenError> {
1430 let (verifier, challenge) = code_verifier_and_challenge();
1431 let codes = MemoryAuthorizationCodeStore::new();
1432 let code = codes.issue(AuthorizationCodeRecord {
1433 did: "did:web:agent.example".to_owned(),
1434 client_id: "https://client.example/id.json".to_owned(),
1435 scope: ScopeSet::parse("atproto").unwrap(),
1436 redirect_uri: "https://client.example/cb".to_owned(),
1437 code_challenge: challenge,
1438 code_challenge_method: "S256".to_owned(),
1439 client_auth_key: Some(client_key("client-key-1")),
1440 dpop_jkt: DpopThumbprint("thumb-a".to_owned()),
1441 });
1442 store.exchange_code(
1443 &codes,
1444 &NoReplay,
1445 &AdmitAll,
1446 &GrantAnyScope,
1447 &code,
1448 "https://client.example/cb",
1449 &verifier,
1450 "https://client.example/id.json",
1451 presented,
1452 &proof("thumb-a"),
1453 )
1454 }
1455
1456 fn refresh_as(
1457 store: &OAuthTokenStore,
1458 refresh_token: &str,
1459 presented: Option<&AuthenticatedClient>,
1460 ) -> Result<OAuthTokens, TokenError> {
1461 store.refresh(
1462 &NoReplay,
1463 &AdmitAll,
1464 &GrantAnyScope,
1465 refresh_token,
1466 presented,
1467 &proof("thumb-a"),
1468 )
1469 }
1470
1471 #[test]
1475 fn a_confidential_code_is_exchanged_only_with_the_key_it_was_pushed_with() {
1476 let store = OAuthTokenStore::new();
1477 assert_eq!(
1478 exchange_confidential(&store, None).unwrap_err(),
1479 TokenError::ClientAssertionRequired
1480 );
1481 assert_eq!(
1482 exchange_confidential(&store, Some(&authenticated("client-key-2"))).unwrap_err(),
1483 TokenError::ClientAuthenticationChanged
1484 );
1485 exchange_confidential(&store, Some(&authenticated("client-key-1")))
1486 .expect("the key the code was pushed with exchanges it");
1487 let live = store.live();
1488 assert_eq!(
1489 live.len(),
1490 1,
1491 "only the authenticated exchange minted a login"
1492 );
1493 assert_eq!(live[0].client_auth_key, Some(client_key("client-key-1")));
1494 }
1495
1496 #[test]
1500 fn a_confidential_login_refreshes_only_with_the_key_it_began_with() {
1501 let store = OAuthTokenStore::new();
1502 let tokens = exchange_confidential(&store, Some(&authenticated("client-key-1")))
1503 .expect("the login begins");
1504 assert_eq!(
1505 refresh_as(&store, &tokens.refresh_token, None).unwrap_err(),
1506 TokenError::ClientAssertionRequired
1507 );
1508 assert_eq!(
1509 refresh_as(
1510 &store,
1511 &tokens.refresh_token,
1512 Some(&authenticated("client-key-2"))
1513 )
1514 .unwrap_err(),
1515 TokenError::ClientAuthenticationChanged
1516 );
1517 let elsewhere = AuthenticatedClient {
1518 client_id: "https://elsewhere.example/id.json".to_owned(),
1519 ..authenticated("client-key-1")
1520 };
1521 assert_eq!(
1522 refresh_as(&store, &tokens.refresh_token, Some(&elsewhere)).unwrap_err(),
1523 TokenError::ClientMismatch
1524 );
1525 refresh_as(
1526 &store,
1527 &tokens.refresh_token,
1528 Some(&authenticated("client-key-1")),
1529 )
1530 .expect("the key the login began with refreshes it");
1531 }
1532
1533 #[test]
1536 fn a_public_login_is_refreshed_without_an_assertion() {
1537 let store = OAuthTokenStore::new();
1538 let tokens = sign_in(&store, &AdmitAll).unwrap();
1539 assert_eq!(
1540 refresh_as(
1541 &store,
1542 &tokens.refresh_token,
1543 Some(&authenticated("client-key-1"))
1544 )
1545 .unwrap_err(),
1546 TokenError::ClientAuthenticationChanged
1547 );
1548 refresh_as(&store, &tokens.refresh_token, None).expect("the refusal rotated nothing");
1549 }
1550
1551 #[test]
1560 fn a_grant_survives_a_restart_with_the_scope_it_was_granted() {
1561 let dir = std::env::temp_dir().join(format!(
1562 "didbot-oauth-durable-{}-{:?}",
1563 std::process::id(),
1564 std::thread::current().id()
1565 ));
1566 let _ = std::fs::remove_dir_all(&dir);
1567 let granted = ScopeSet::parse("atproto repo:com.example.thing?action=create").unwrap();
1568
1569 let issued = {
1570 let durable =
1571 didbot_pds::Durable::open(&dir, Duration::days(30)).expect("the log opens");
1572 let store = OAuthTokenStore::with_grants(durable.oauth_grants());
1573 let (verifier, challenge) = code_verifier_and_challenge();
1574 let codes = MemoryAuthorizationCodeStore::new();
1575 let code = codes.issue(AuthorizationCodeRecord {
1576 did: "did:web:agent.example".to_owned(),
1577 client_id: "https://client.example/id.json".to_owned(),
1578 scope: granted.clone(),
1579 redirect_uri: "https://client.example/cb".to_owned(),
1580 code_challenge: challenge,
1581 code_challenge_method: "S256".to_owned(),
1582 client_auth_key: None,
1583 dpop_jkt: DpopThumbprint("thumb-a".to_owned()),
1584 });
1585 let issued = store
1586 .exchange_code(
1587 &codes,
1588 &NoReplay,
1589 &AdmitAll,
1590 &GrantAnyScope,
1591 &code,
1592 "https://client.example/cb",
1593 &verifier,
1594 "https://client.example/id.json",
1595 None,
1596 &proof("thumb-a"),
1597 )
1598 .expect("the exchange succeeds");
1599 assert_eq!(issued.scope, granted);
1600 durable.wal().sync().expect("the log syncs");
1601 issued
1602 };
1603
1604 let durable = didbot_pds::Durable::open(&dir, Duration::days(30)).expect("the log replays");
1605 let store = OAuthTokenStore::with_grants(durable.oauth_grants());
1606 assert_eq!(
1607 store
1608 .validate_access(
1609 &issued.access_token,
1610 &DpopThumbprint("thumb-a".to_owned()),
1611 &GrantAnyScope
1612 )
1613 .map(|grant| grant.did),
1614 Some("did:web:agent.example".to_owned())
1615 );
1616 assert_eq!(
1617 store
1618 .validate_access(
1619 &issued.access_token,
1620 &DpopThumbprint("thumb-b".to_owned()),
1621 &GrantAnyScope
1622 )
1623 .map(|grant| grant.did),
1624 None,
1625 "a restart must not loosen the DPoP binding"
1626 );
1627 let rotated = store
1628 .refresh(
1629 &NoReplay,
1630 &AdmitAll,
1631 &GrantAnyScope,
1632 &issued.refresh_token,
1633 None,
1634 &proof("thumb-a"),
1635 )
1636 .expect("rotation still works after a restart");
1637 assert_eq!(rotated.scope, granted);
1638 assert_eq!(rotated.did, "did:web:agent.example");
1639 assert_eq!(
1640 store.refresh(
1641 &NoReplay,
1642 &AdmitAll,
1643 &GrantAnyScope,
1644 &issued.refresh_token,
1645 None,
1646 &proof("thumb-a"),
1647 ),
1648 Err(TokenError::RefreshReused),
1649 "the reuse rule survives the restart too"
1650 );
1651
1652 drop(durable);
1653 let _ = std::fs::remove_dir_all(&dir);
1654 }
1655}