1use time::OffsetDateTime;
44
45use crate::oauth::decision::{DecisionRecord, DecisionState, DecisionStore, Remembered, Verdict};
46use crate::oauth::par::{ParStore, PushedRequest};
47use crate::oauth::scope::ScopeSet;
48
49pub trait ScopePolicy: Send + Sync {
54 fn ceiling(&self, did: &str, client_id: &str) -> ScopeSet;
59}
60
61#[derive(Debug, Clone, Copy, Default)]
69pub struct GrantAnyScope;
70
71const EVERY_SCOPE: &str = "atproto repo:* rpc:* blob:*/*";
73
74impl ScopePolicy for GrantAnyScope {
75 fn ceiling(&self, _did: &str, _client_id: &str) -> ScopeSet {
76 ScopeSet::parse(EVERY_SCOPE).expect("a constant this crate's own parser accepts")
77 }
78}
79
80#[derive(Debug, Clone)]
105pub struct ConfiguredCeiling(ScopeSet);
106
107#[derive(Debug, Default, Clone, PartialEq, Eq)]
111pub struct DroppedAtoms {
112 pub outside: Vec<String>,
116 pub over_bound: Vec<String>,
120}
121
122impl DroppedAtoms {
123 pub fn is_empty(&self) -> bool {
125 self.outside.is_empty() && self.over_bound.is_empty()
126 }
127}
128
129impl ConfiguredCeiling {
130 pub fn new<'a>(atoms: impl IntoIterator<Item = &'a str>) -> (Self, DroppedAtoms) {
151 let compiled = GrantAnyScope.ceiling("", "");
152 let mut kept: Vec<crate::oauth::scope::Scope> = Vec::new();
153 let mut dropped = DroppedAtoms::default();
154 for atom in atoms {
155 let Ok(parsed) = ScopeSet::parse(atom) else {
156 dropped.outside.push(atom.to_owned());
157 continue;
158 };
159 let (granted, cut) = parsed.narrow(&compiled);
160 if !cut.0.is_empty() || granted.0.is_empty() {
161 dropped.outside.push(atom.to_owned());
162 continue;
163 }
164 if kept.len() + granted.0.len() > crate::oauth::scope::MAX_SCOPE_ATOMS {
165 dropped.over_bound.push(atom.to_owned());
166 continue;
167 }
168 kept.extend(granted.0);
169 }
170 (Self(ScopeSet::new(kept)), dropped)
171 }
172
173 pub fn scopes(&self) -> &ScopeSet {
175 &self.0
176 }
177}
178
179impl ScopePolicy for ConfiguredCeiling {
180 fn ceiling(&self, _did: &str, _client_id: &str) -> ScopeSet {
181 self.0.clone()
182 }
183}
184
185pub const RELOAD_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5);
192
193const _: () = assert!(
197 crate::oauth::par::REQUEST_URI_TTL.whole_seconds()
198 <= crate::oauth::token::AUTHORIZATION_CODE_TTL.whole_seconds()
199);
200
201#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
203pub enum ResponseMode {
204 #[default]
206 Query,
207 Fragment,
209}
210
211impl ResponseMode {
212 pub const ALL: [Self; 2] = [Self::Query, Self::Fragment];
214
215 pub fn as_str(self) -> &'static str {
217 match self {
218 Self::Query => "query",
219 Self::Fragment => "fragment",
220 }
221 }
222
223 pub fn named(raw: &str) -> Option<Self> {
225 Self::ALL.into_iter().find(|mode| mode.as_str() == raw)
226 }
227}
228
229pub fn authorization_response(
240 redirect_uri: &str,
241 mode: ResponseMode,
242 result: &[(&str, &str)],
243 state: Option<&str>,
244 issuer: &str,
245) -> Result<url::Url, url::ParseError> {
246 let mut redirect = url::Url::parse(redirect_uri)?;
247 let pairs = result
248 .iter()
249 .copied()
250 .chain(state.map(|state| ("state", state)))
251 .chain([("iss", issuer)]);
252 match mode {
253 ResponseMode::Query => {
254 redirect.query_pairs_mut().extend_pairs(pairs);
255 }
256 ResponseMode::Fragment => {
257 let fragment = url::form_urlencoded::Serializer::new(String::new())
258 .extend_pairs(pairs)
259 .finish();
260 redirect.set_fragment(Some(&fragment));
261 }
262 }
263 Ok(redirect)
264}
265
266pub fn browser_carries(redirect_uri: &str, mode: ResponseMode) -> bool {
274 mode == ResponseMode::Fragment
275 || url::Url::parse(redirect_uri).map_or(true, |url| !didbot_http::is_loopback(&url))
276}
277
278pub fn confirms_unasked(
291 pushed: &PushedRequest,
292 record: &DecisionRecord,
293 remembered: Option<&Remembered>,
294 loader: &Loader,
295 now: OffsetDateTime,
296) -> bool {
297 pushed.client_metadata.authentication.is_confidential()
298 && browser_carries(&pushed.redirect_uri, pushed.response_mode)
299 && loader.navigation
300 && loader.signed_in_as(&record.account)
301 && record.is_live(now)
302 && record.token.is_some()
303 && remembered.is_some_and(|remembered| {
304 now < remembered.expires_at
305 && record
306 .granted()
307 .0
308 .iter()
309 .all(|atom| remembered.granted.covers(atom))
310 })
311}
312
313#[derive(Debug, Clone, Default)]
315pub struct Loader {
316 pub accounts: Vec<String>,
318 pub navigation: bool,
321}
322
323impl Loader {
324 pub fn signed_in_as(&self, account: &str) -> bool {
326 self.accounts.iter().any(|held| held == account)
327 }
328}
329
330#[derive(Debug, Clone)]
336pub struct AuthorizePending {
337 pub record: DecisionRecord,
340 pub granted: ScopeSet,
342 pub cut: ScopeSet,
345 pub rule: String,
347 pub signed_in: bool,
349 pub held: bool,
352}
353
354impl AuthorizePending {
355 fn of(record: DecisionRecord, signed_in: bool, held: bool) -> Option<Self> {
357 let (granted, cut, rule) = match &record.verdict {
358 Verdict::Allow => (record.requested.clone(), ScopeSet::default(), String::new()),
359 Verdict::Narrow { granted, cut, rule } => (granted.clone(), cut.clone(), rule.clone()),
360 Verdict::Deny { .. } => return None,
361 };
362 Some(Self {
363 record,
364 granted,
365 cut,
366 rule,
367 signed_in,
368 held,
369 })
370 }
371
372 pub fn waiting(&self) -> bool {
374 self.record.state == DecisionState::Pending
375 }
376
377 pub fn reloads(&self) -> bool {
380 self.waiting() || self.held
381 }
382}
383
384#[derive(Debug, Clone)]
386pub enum AuthorizeAnswer {
387 Page(AuthorizePending),
390 Redirect {
393 to: String,
395 account: String,
397 },
398 Denied(Denial),
400}
401
402#[derive(Debug, Clone)]
405pub struct Denial {
406 pushed: PushedRequest,
407 description: &'static str,
408}
409
410impl Denial {
411 pub fn redirect(&self, issuer: &str) -> Result<url::Url, url::ParseError> {
414 authorization_response(
415 &self.pushed.redirect_uri,
416 self.pushed.response_mode,
417 &[
418 ("error", "access_denied"),
419 ("error_description", self.description),
420 ],
421 self.pushed.state.as_deref(),
422 issuer,
423 )
424 }
425}
426
427#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
430pub enum AuthorizeError {
431 #[error("request_uri is not live: unknown or expired")]
434 UnknownRequestUri,
435 #[error("client_id does not match the pushed authorization request")]
438 ClientMismatch,
439}
440
441const DECLINED: &str = "the account declined this authorization request";
443const EXPIRED: &str = "this authorization request expired";
446const REFUSED: &str = "this server refused this authorization request";
448
449pub fn answer(
465 par_store: &dyn ParStore,
466 decisions: &dyn DecisionStore,
467 request_uri: &str,
468 client_id: &str,
469 loader: &Loader,
470 now: OffsetDateTime,
471) -> Result<AuthorizeAnswer, AuthorizeError> {
472 let pushed = par_store
473 .get(request_uri)
474 .ok_or(AuthorizeError::UnknownRequestUri)?;
475 if pushed.client_id != client_id {
476 return Err(AuthorizeError::ClientMismatch);
477 }
478 let record = decisions
479 .get(request_uri)
480 .ok_or(AuthorizeError::UnknownRequestUri)?;
481 let signed_in = loader.signed_in_as(&record.account);
482 let expired = now >= record.expires_at;
483 let held = decisions.holds(request_uri);
484 let page = |record, held| match AuthorizePending::of(record, signed_in, held) {
485 Some(page) => AuthorizeAnswer::Page(page),
486 None => AuthorizeAnswer::Denied(Denial {
487 pushed: pushed.clone(),
488 description: REFUSED,
489 }),
490 };
491 let description = match record.state {
492 DecisionState::Approved if held && expired => {
493 decisions.collect(request_uri);
494 EXPIRED
495 }
496 DecisionState::Approved if held && signed_in && loader.navigation => {
497 return Ok(match decisions.collect(request_uri) {
498 Some(to) => AuthorizeAnswer::Redirect {
499 to,
500 account: record.account,
501 },
502 None => page(record, false),
504 });
505 }
506 DecisionState::Approved => return Ok(page(record, held)),
507 DecisionState::Pending if !expired => return Ok(page(record, false)),
508 DecisionState::Declined => DECLINED,
509 DecisionState::Pending | DecisionState::Expired => EXPIRED,
510 };
511 Ok(AuthorizeAnswer::Denied(Denial {
512 pushed,
513 description,
514 }))
515}
516
517#[cfg(test)]
518mod tests {
519 use super::*;
520 use crate::oauth::client_metadata::{ApplicationType, ClientKey, ClientMetadataDocument};
521 use crate::oauth::consent::ConsentReference;
522 use crate::oauth::decision::{ClientSelf, MemoryDecisionStore};
523 use crate::oauth::par::MemoryParStore;
524
525 const CLIENT_ID: &str = "https://client.example/id.json";
526 const ISSUER: &str = "https://pds.example";
527
528 fn document(client_id: &str) -> ClientMetadataDocument {
529 ClientMetadataDocument {
530 client_id: client_id.to_owned(),
531 application_type: ApplicationType::Web,
532 client_name: None,
533 client_uri: None,
534 logo_uri: None,
535 redirect_uris: vec!["https://client.example/cb".to_owned()],
536 grant_types: vec!["authorization_code".to_owned()],
537 response_types: vec!["code".to_owned()],
538 authentication: crate::oauth::client_auth::ClientAuthentication::public(),
539 scope: None,
540 dpop_bound_access_tokens: true,
541 }
542 }
543
544 fn pushed(client_id: &str, scope: &str) -> PushedRequest {
545 let document = document(client_id);
546 PushedRequest {
547 client_id: client_id.to_owned(),
548 client_key: ClientKey::compute(&document),
549 client_metadata: document,
550 redirect_uri: "https://client.example/cb".to_owned(),
551 scope: ScopeSet::parse(scope).unwrap(),
552 state: Some("xyz".to_owned()),
553 response_mode: ResponseMode::Query,
554 code_challenge: "abc".to_owned(),
555 code_challenge_method: "S256".to_owned(),
556 login_hint: Some("did:web:agent.example".to_owned()),
557 did: "did:web:agent.example".to_owned(),
558 dpop_jkt: crate::oauth::dpop_seam::DpopThumbprint("a-key".to_owned()),
559 expires_at: OffsetDateTime::now_utc() + time::Duration::minutes(2),
560 }
561 }
562
563 fn record(request_uri: &str, verdict: Verdict, token: Option<&str>) -> DecisionRecord {
566 let now = OffsetDateTime::now_utc();
567 DecisionRecord {
568 request_uri: request_uri.to_owned(),
569 account: "did:web:agent.example".to_owned(),
570 client_origin: "https://client.example".to_owned(),
571 client_key: ClientKey("k".to_owned()),
572 first_time: true,
573 client_self: ClientSelf::default(),
574 requested: ScopeSet::parse("atproto").unwrap(),
575 verdict,
576 token: token.map(|t| ConsentReference(t.to_owned())),
577 created_at: now,
578 expires_at: now + time::Duration::minutes(2),
579 state: DecisionState::Pending,
580 sequence: 0,
581 }
582 }
583
584 fn stored(verdict: Verdict) -> (MemoryParStore, MemoryDecisionStore, String) {
586 let par = MemoryParStore::new();
587 let uri = par.push(pushed(CLIENT_ID, "atproto"));
588 let decisions = MemoryDecisionStore::new();
589 let token = (!verdict.is_deny()).then_some("reference");
590 decisions.put(record(&uri, verdict, token)).unwrap();
591 (par, decisions, uri)
592 }
593
594 fn load(par: &MemoryParStore, decisions: &MemoryDecisionStore, uri: &str) -> AuthorizeAnswer {
595 load_by(par, decisions, uri, &Loader::default())
596 }
597
598 fn load_by(
600 par: &MemoryParStore,
601 decisions: &MemoryDecisionStore,
602 uri: &str,
603 loader: &Loader,
604 ) -> AuthorizeAnswer {
605 answer(
606 par,
607 decisions,
608 uri,
609 CLIENT_ID,
610 loader,
611 OffsetDateTime::now_utc(),
612 )
613 .expect("the page answers")
614 }
615
616 fn browser(accounts: &[&str], navigation: bool) -> Loader {
619 Loader {
620 accounts: accounts
621 .iter()
622 .map(|account| (*account).to_owned())
623 .collect(),
624 navigation,
625 }
626 }
627
628 const HELD: &str =
630 "https://client.example/cb?code=c0de&state=xyz&iss=https%3A%2F%2Fpds.example";
631
632 fn sent_to(answer: AuthorizeAnswer) -> Vec<(String, String)> {
634 let AuthorizeAnswer::Denied(denial) = answer else {
635 panic!("expected a denial, got {answer:?}");
636 };
637 denial
638 .redirect(ISSUER)
639 .unwrap()
640 .query_pairs()
641 .map(|(key, value)| (key.into_owned(), value.into_owned()))
642 .collect()
643 }
644
645 #[test]
648 fn a_remembered_approval_confirms_only_when_every_rule_holds() {
649 let now = OffsetDateTime::now_utc();
650 let account = "did:web:agent.example";
651 let mut confidential = pushed(CLIENT_ID, "atproto");
652 confidential
653 .client_metadata
654 .authentication
655 .token_endpoint_auth_method = crate::oauth::client_auth::PRIVATE_KEY_JWT.to_owned();
656 let pending = record("urn:r", Verdict::Allow, Some("reference"));
657 let remembered = Remembered {
658 client_id: CLIENT_ID.to_owned(),
659 granted: ScopeSet::parse("atproto repo:*").unwrap(),
660 expires_at: now + time::Duration::minutes(1),
661 };
662 let signed_in = browser(&[account], true);
663 let confirms = |pushed: &PushedRequest,
664 record: &DecisionRecord,
665 remembered: Option<&Remembered>,
666 loader: &Loader| {
667 confirms_unasked(pushed, record, remembered, loader, now)
668 };
669 assert!(confirms(
670 &confidential,
671 &pending,
672 Some(&remembered),
673 &signed_in
674 ));
675
676 let public = pushed(CLIENT_ID, "atproto");
677 let mut loopback = confidential.clone();
678 loopback.redirect_uri = "http://127.0.0.1/cb".to_owned();
679 let mut answered = pending.clone();
680 answered.state = DecisionState::Approved;
681 let denied = record(
682 "urn:r",
683 Verdict::Deny {
684 reason: "no".to_owned(),
685 rule: "a-policy".to_owned(),
686 },
687 None,
688 );
689 let mut wider = pending.clone();
690 wider.requested = ScopeSet::parse("atproto blob:*/*").unwrap();
691 let lapsed = Remembered {
692 expires_at: now,
693 ..remembered.clone()
694 };
695 let refusals = [
696 (
697 "a public client",
698 confirms(&public, &pending, Some(&remembered), &signed_in),
699 ),
700 (
701 "a loopback sign-in",
702 confirms(&loopback, &pending, Some(&remembered), &signed_in),
703 ),
704 (
705 "a browser signed in as another account",
706 confirms(
707 &confidential,
708 &pending,
709 Some(&remembered),
710 &browser(&["did:web:other.example"], true),
711 ),
712 ),
713 (
714 "a load that is not a navigation",
715 confirms(
716 &confidential,
717 &pending,
718 Some(&remembered),
719 &browser(&[account], false),
720 ),
721 ),
722 (
723 "an answered record",
724 confirms(&confidential, &answered, Some(&remembered), &signed_in),
725 ),
726 (
727 "a verdict that grants nothing",
728 confirms(&confidential, &denied, Some(&remembered), &signed_in),
729 ),
730 (
731 "a wider grant",
732 confirms(&confidential, &wider, Some(&remembered), &signed_in),
733 ),
734 (
735 "no remembered approval",
736 confirms(&confidential, &pending, None, &signed_in),
737 ),
738 (
739 "a lapsed one",
740 confirms(&confidential, &pending, Some(&lapsed), &signed_in),
741 ),
742 ];
743 for (why, confirmed) in refusals {
744 assert!(!confirmed, "{why} confirmed");
745 }
746 }
747
748 #[test]
751 fn the_page_answers_every_reload_until_the_account_answers() {
752 let (par, decisions, uri) = stored(Verdict::Allow);
753 for _ in 0..3 {
754 let AuthorizeAnswer::Page(page) = load(&par, &decisions, &uri) else {
755 panic!("the account has not answered");
756 };
757 assert!(page.waiting());
758 assert_eq!(page.granted.to_string(), "atproto");
759 }
760 }
761
762 #[test]
766 fn an_approval_that_held_nothing_gets_the_page_and_no_code() {
767 let (par, decisions, uri) = stored(Verdict::Allow);
768 decisions
769 .approve(&uri, None)
770 .expect("a live record approves");
771 for loader in [Loader::default(), browser(&["did:web:agent.example"], true)] {
772 let AuthorizeAnswer::Page(page) = load_by(&par, &decisions, &uri, &loader) else {
773 panic!("an approval that held nothing sends the browser nowhere");
774 };
775 assert!(!page.waiting() && !page.reloads());
776 }
777 }
778
779 #[test]
784 fn a_held_code_goes_once_to_a_navigation_signed_in_as_the_account() {
785 let (par, decisions, uri) = stored(Verdict::Allow);
786 decisions
787 .approve(&uri, Some(HELD.to_owned()))
788 .expect("a live record approves");
789 for (loader, signed_in) in [
790 (Loader::default(), false),
791 (browser(&["did:web:someone-else.example"], true), false),
792 (browser(&["did:web:agent.example"], false), true),
793 ] {
794 let AuthorizeAnswer::Page(page) = load_by(&par, &decisions, &uri, &loader) else {
795 panic!("{loader:?} is not the account's browser navigating");
796 };
797 assert!(!page.waiting() && page.held && page.reloads(), "{loader:?}");
798 assert_eq!(page.signed_in, signed_in, "{loader:?}");
799 }
800
801 let account = browser(
802 &["did:web:someone-else.example", "did:web:agent.example"],
803 true,
804 );
805 let AuthorizeAnswer::Redirect { to, account: named } =
806 load_by(&par, &decisions, &uri, &account)
807 else {
808 panic!("the account's browser collects the code");
809 };
810 assert_eq!(to, HELD);
811 assert_eq!(named, "did:web:agent.example");
812 let AuthorizeAnswer::Page(page) = load_by(&par, &decisions, &uri, &account) else {
813 panic!("the code went out once");
814 };
815 assert!(!page.held && !page.reloads());
816 }
817
818 #[test]
821 fn a_held_code_nobody_collected_ends_in_access_denied() {
822 let (par, decisions, uri) = stored(Verdict::Allow);
823 decisions
824 .approve(&uri, Some(HELD.to_owned()))
825 .expect("a live record approves");
826 let expiry = decisions.get(&uri).unwrap().expires_at;
827 let answered = answer(
828 &par,
829 &decisions,
830 &uri,
831 CLIENT_ID,
832 &Loader::default(),
833 expiry,
834 )
835 .expect("the page answers");
836 let pairs = sent_to(answered);
837 assert_eq!(pairs[0], ("error".to_owned(), "access_denied".to_owned()));
838 assert_eq!(pairs[1].1, EXPIRED);
839 assert!(
840 !decisions.holds(&uri),
841 "the code was dropped with the request"
842 );
843 }
844
845 #[test]
848 fn a_decline_an_expiry_and_a_denial_send_access_denied() {
849 let (par, decisions, declined) = stored(Verdict::Allow);
850 decisions.settle(&declined, DecisionState::Declined);
851
852 let expired = par.push(pushed(CLIENT_ID, "atproto"));
853 decisions
854 .put(record(&expired, Verdict::Allow, Some("expired")))
855 .unwrap();
856
857 let denied = par.push(pushed(CLIENT_ID, "atproto"));
858 let deny = Verdict::Deny {
859 reason: "nope".to_owned(),
860 rule: "a-rule".to_owned(),
861 };
862 decisions.put(record(&denied, deny, None)).unwrap();
863
864 let later = OffsetDateTime::now_utc() + time::Duration::minutes(3);
865 for (uri, now, description) in [
866 (&declined, OffsetDateTime::now_utc(), DECLINED),
867 (&expired, later, EXPIRED),
868 (&denied, OffsetDateTime::now_utc(), REFUSED),
869 ] {
870 let answered =
871 answer(&par, &decisions, uri, CLIENT_ID, &Loader::default(), now).unwrap();
872 let pairs = sent_to(answered);
873 let names: Vec<&str> = pairs.iter().map(|(name, _)| name.as_str()).collect();
874 assert_eq!(names, ["error", "error_description", "state", "iss"]);
875 assert_eq!(pairs[0].1, "access_denied");
876 assert_eq!(pairs[1].1, description);
877 assert_eq!(pairs[2].1, "xyz");
878 assert_eq!(pairs[3].1, ISSUER);
879 }
880 }
881
882 #[test]
885 fn a_request_nothing_was_stored_under_is_unknown() {
886 let (par, decisions, _) = stored(Verdict::Allow);
887 let never_existed = "urn:ietf:params:oauth:request_uri:0000000000000000";
888 assert_eq!(
889 answer(
890 &par,
891 &decisions,
892 never_existed,
893 CLIENT_ID,
894 &Loader::default(),
895 OffsetDateTime::now_utc()
896 )
897 .unwrap_err(),
898 AuthorizeError::UnknownRequestUri
899 );
900 }
901
902 #[test]
903 fn a_mismatched_client_id_is_refused() {
904 let (par, decisions, uri) = stored(Verdict::Allow);
905 assert_eq!(
906 answer(
907 &par,
908 &decisions,
909 &uri,
910 "https://someone-else.example/id.json",
911 &Loader::default(),
912 OffsetDateTime::now_utc()
913 )
914 .unwrap_err(),
915 AuthorizeError::ClientMismatch
916 );
917 }
918
919 #[test]
922 fn a_response_goes_in_the_query_or_the_fragment_the_client_named() {
923 let result = [("code", "c0de")];
924 let query = authorization_response(
925 "https://client.example/cb?from=app",
926 ResponseMode::Query,
927 &result,
928 Some("a b&c"),
929 ISSUER,
930 )
931 .unwrap();
932 assert_eq!(
933 query.as_str(),
934 "https://client.example/cb?from=app&code=c0de&state=a+b%26c&iss=https%3A%2F%2Fpds.example"
935 );
936
937 let fragment = authorization_response(
938 "https://client.example/cb?from=app",
939 ResponseMode::Fragment,
940 &result,
941 Some("a b&c"),
942 ISSUER,
943 )
944 .unwrap();
945 assert_eq!(
946 fragment.as_str(),
947 "https://client.example/cb?from=app#code=c0de&state=a+b%26c&iss=https%3A%2F%2Fpds.example"
948 );
949 }
950
951 #[test]
954 fn a_browser_carries_every_code_but_a_loopback_query() {
955 for (redirect_uri, mode, browser) in [
956 ("http://127.0.0.1:4000/cb", ResponseMode::Query, false),
957 ("http://[::1]:4000/cb", ResponseMode::Query, false),
958 ("http://127.0.0.1:4000/cb", ResponseMode::Fragment, true),
959 ("https://client.example/cb", ResponseMode::Query, true),
960 ("com.example.app:/cb", ResponseMode::Query, true),
961 ] {
962 assert_eq!(
963 browser_carries(redirect_uri, mode),
964 browser,
965 "{redirect_uri} {mode:?}"
966 );
967 }
968 }
969
970 #[test]
974 fn a_configured_ceiling_cannot_reach_past_the_compiled_in_one() {
975 let (ceiling, dropped) = ConfiguredCeiling::new([
976 "atproto",
977 "repo:app.bsky.feed.post",
978 "transition:generic",
979 "account:email",
980 "notascope:whatever",
981 ]);
982 let admitted = ceiling.ceiling("did:web:a", "https://c");
983 assert_eq!(admitted.to_string(), "atproto repo:app.bsky.feed.post");
984 assert_eq!(
985 dropped.outside,
986 vec!["transition:generic", "account:email", "notascope:whatever"]
987 );
988 assert!(dropped.over_bound.is_empty());
989 }
990
991 #[test]
997 fn a_configured_ceiling_is_bounded_and_says_what_the_bound_dropped() {
998 let atoms: Vec<String> = (0..crate::oauth::scope::MAX_SCOPE_ATOMS + 10)
999 .map(|n| format!("repo:com.example.c{n}"))
1000 .collect();
1001 let (ceiling, dropped) = ConfiguredCeiling::new(atoms.iter().map(String::as_str));
1002 assert_eq!(
1003 ceiling.scopes().0.len(),
1004 crate::oauth::scope::MAX_SCOPE_ATOMS
1005 );
1006 assert_eq!(dropped.over_bound.len(), 10);
1007 assert!(dropped.outside.is_empty());
1008 assert_eq!(
1009 dropped.over_bound[0],
1010 format!("repo:com.example.c{}", crate::oauth::scope::MAX_SCOPE_ATOMS)
1011 );
1012 }
1013
1014 #[test]
1015 fn a_configured_ceiling_with_nothing_in_it_grants_nothing() {
1016 let (ceiling, _) = ConfiguredCeiling::new(std::iter::empty());
1017 assert!(ceiling.scopes().0.is_empty());
1018 let request = ScopeSet::parse("atproto").unwrap();
1019 let (granted, cut) = request.narrow(&ceiling.ceiling("did:web:a", "https://c"));
1020 assert!(granted.0.is_empty());
1021 assert_eq!(cut, request);
1022 }
1023}