···
1
+
//! AT Protocol OAuth scopes module
2
+
//! Derived from https://tangled.org/@smokesignal.events/atproto-identity-rs/raw/main/crates/atproto-oauth/src/scopes.rs
4
+
//! This module provides comprehensive support for AT Protocol OAuth scopes,
5
+
//! including parsing, serialization, normalization, and permission checking.
7
+
//! Scopes in AT Protocol follow a prefix-based format with optional query parameters:
8
+
//! - `account`: Access to account information (email, repo, status)
9
+
//! - `identity`: Access to identity information (handle)
10
+
//! - `blob`: Access to blob operations with mime type constraints
11
+
//! - `repo`: Repository operations with collection and action constraints
12
+
//! - `rpc`: RPC method access with lexicon and audience constraints
13
+
//! - `atproto`: Required scope to indicate that other AT Protocol scopes will be used
14
+
//! - `transition`: Migration operations (generic or email)
16
+
//! Standard OpenID Connect scopes (no suffixes or query parameters):
17
+
//! - `openid`: Required for OpenID Connect authentication
18
+
//! - `profile`: Access to user profile information
19
+
//! - `email`: Access to user email address
21
+
use std::collections::{BTreeMap, BTreeSet};
23
+
use std::str::FromStr;
25
+
use jacquard_common::types::did::Did;
26
+
use jacquard_common::types::nsid::Nsid;
27
+
use jacquard_common::types::string::AtStrError;
28
+
use jacquard_common::{CowStr, IntoStatic};
29
+
use smol_str::{SmolStr, ToSmolStr};
31
+
/// Represents an AT Protocol OAuth scope
32
+
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
33
+
pub enum Scope<'s> {
34
+
/// Account scope for accessing account information
35
+
Account(AccountScope),
36
+
/// Identity scope for accessing identity information
37
+
Identity(IdentityScope),
38
+
/// Blob scope for blob operations with mime type constraints
39
+
Blob(BlobScope<'s>),
40
+
/// Repository scope for collection operations
41
+
Repo(RepoScope<'s>),
42
+
/// RPC scope for method access
44
+
/// AT Protocol scope - required to indicate that other AT Protocol scopes will be used
46
+
/// Transition scope for migration operations
47
+
Transition(TransitionScope),
48
+
/// OpenID Connect scope - required for OpenID Connect authentication
50
+
/// Profile scope - access to user profile information
52
+
/// Email scope - access to user email address
56
+
impl IntoStatic for Scope<'_> {
57
+
type Output = Scope<'static>;
59
+
fn into_static(self) -> Self::Output {
61
+
Scope::Account(scope) => Scope::Account(scope),
62
+
Scope::Identity(scope) => Scope::Identity(scope),
63
+
Scope::Blob(scope) => Scope::Blob(scope.into_static()),
64
+
Scope::Repo(scope) => Scope::Repo(scope.into_static()),
65
+
Scope::Rpc(scope) => Scope::Rpc(scope.into_static()),
66
+
Scope::Atproto => Scope::Atproto,
67
+
Scope::Transition(scope) => Scope::Transition(scope),
68
+
Scope::OpenId => Scope::OpenId,
69
+
Scope::Profile => Scope::Profile,
70
+
Scope::Email => Scope::Email,
75
+
/// Account scope attributes
76
+
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
77
+
pub struct AccountScope {
78
+
/// The account resource type
79
+
pub resource: AccountResource,
80
+
/// The action permission level
81
+
pub action: AccountAction,
84
+
/// Account resource types
85
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
86
+
pub enum AccountResource {
89
+
/// Repository access
95
+
/// Account action permissions
96
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
97
+
pub enum AccountAction {
98
+
/// Read-only access
100
+
/// Management access (includes read)
104
+
/// Identity scope attributes
105
+
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
106
+
pub enum IdentityScope {
109
+
/// All identity access (wildcard)
113
+
/// Transition scope types
114
+
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
115
+
pub enum TransitionScope {
116
+
/// Generic transition operations
118
+
/// Email transition operations
122
+
/// Blob scope with mime type constraints
123
+
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
124
+
pub struct BlobScope<'s> {
125
+
/// Accepted mime types
126
+
pub accept: BTreeSet<MimePattern<'s>>,
129
+
impl IntoStatic for BlobScope<'_> {
130
+
type Output = BlobScope<'static>;
132
+
fn into_static(self) -> Self::Output {
134
+
accept: self.accept.into_iter().map(|p| p.into_static()).collect(),
139
+
/// MIME type pattern for blob scope
140
+
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
141
+
pub enum MimePattern<'s> {
142
+
/// Match all types
144
+
/// Match all subtypes of a type (e.g., "image/*")
145
+
TypeWildcard(CowStr<'s>),
146
+
/// Exact mime type match
150
+
impl IntoStatic for MimePattern<'_> {
151
+
type Output = MimePattern<'static>;
153
+
fn into_static(self) -> Self::Output {
155
+
MimePattern::All => MimePattern::All,
156
+
MimePattern::TypeWildcard(s) => MimePattern::TypeWildcard(s.into_static()),
157
+
MimePattern::Exact(s) => MimePattern::Exact(s.into_static()),
162
+
/// Repository scope with collection and action constraints
163
+
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
164
+
pub struct RepoScope<'s> {
165
+
/// Collection NSID or wildcard
166
+
pub collection: RepoCollection<'s>,
167
+
/// Allowed actions
168
+
pub actions: BTreeSet<RepoAction>,
171
+
impl IntoStatic for RepoScope<'_> {
172
+
type Output = RepoScope<'static>;
174
+
fn into_static(self) -> Self::Output {
176
+
collection: self.collection.into_static(),
177
+
actions: self.actions,
182
+
/// Repository collection identifier
183
+
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
184
+
pub enum RepoCollection<'s> {
185
+
/// All collections (wildcard)
187
+
/// Specific collection NSID
191
+
impl IntoStatic for RepoCollection<'_> {
192
+
type Output = RepoCollection<'static>;
194
+
fn into_static(self) -> Self::Output {
196
+
RepoCollection::All => RepoCollection::All,
197
+
RepoCollection::Nsid(nsid) => RepoCollection::Nsid(nsid.into_static()),
202
+
/// Repository actions
203
+
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
204
+
pub enum RepoAction {
213
+
/// RPC scope with lexicon method and audience constraints
214
+
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
215
+
pub struct RpcScope<'s> {
216
+
/// Lexicon methods (NSIDs or wildcard)
217
+
pub lxm: BTreeSet<RpcLexicon<'s>>,
218
+
/// Audiences (DIDs or wildcard)
219
+
pub aud: BTreeSet<RpcAudience<'s>>,
222
+
impl IntoStatic for RpcScope<'_> {
223
+
type Output = RpcScope<'static>;
225
+
fn into_static(self) -> Self::Output {
227
+
lxm: self.lxm.into_iter().map(|s| s.into_static()).collect(),
228
+
aud: self.aud.into_iter().map(|s| s.into_static()).collect(),
233
+
/// RPC lexicon identifier
234
+
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
235
+
pub enum RpcLexicon<'s> {
236
+
/// All lexicons (wildcard)
238
+
/// Specific lexicon NSID
242
+
impl IntoStatic for RpcLexicon<'_> {
243
+
type Output = RpcLexicon<'static>;
245
+
fn into_static(self) -> Self::Output {
247
+
RpcLexicon::All => RpcLexicon::All,
248
+
RpcLexicon::Nsid(nsid) => RpcLexicon::Nsid(nsid.into_static()),
253
+
/// RPC audience identifier
254
+
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
255
+
pub enum RpcAudience<'s> {
256
+
/// All audiences (wildcard)
262
+
impl IntoStatic for RpcAudience<'_> {
263
+
type Output = RpcAudience<'static>;
265
+
fn into_static(self) -> Self::Output {
267
+
RpcAudience::All => RpcAudience::All,
268
+
RpcAudience::Did(did) => RpcAudience::Did(did.into_static()),
273
+
impl<'s> Scope<'s> {
274
+
/// Parse multiple space-separated scopes from a string
278
+
/// # use jacquard_oauth::scopes::Scope;
279
+
/// let scopes = Scope::parse_multiple("atproto repo:*").unwrap();
280
+
/// assert_eq!(scopes.len(), 2);
282
+
pub fn parse_multiple(s: &'s str) -> Result<Vec<Self>, ParseError> {
283
+
if s.trim().is_empty() {
284
+
return Ok(Vec::new());
287
+
let mut scopes = Vec::new();
288
+
for scope_str in s.split_whitespace() {
289
+
scopes.push(Self::parse(scope_str)?);
295
+
/// Parse multiple space-separated scopes and return the minimal set needed
297
+
/// This method removes duplicate scopes and scopes that are already granted
298
+
/// by other scopes in the list, returning only the minimal set of scopes needed.
302
+
/// # use jacquard_oauth::scopes::Scope;
303
+
/// // repo:* grants repo:foo.bar, so only repo:* is kept
304
+
/// let scopes = Scope::parse_multiple_reduced("atproto repo:app.bsky.feed.post repo:*").unwrap();
305
+
/// assert_eq!(scopes.len(), 2); // atproto and repo:*
307
+
pub fn parse_multiple_reduced(s: &'s str) -> Result<Vec<Self>, ParseError> {
308
+
let all_scopes = Self::parse_multiple(s)?;
310
+
if all_scopes.is_empty() {
311
+
return Ok(Vec::new());
314
+
let mut result: Vec<Self> = Vec::new();
316
+
for scope in all_scopes {
317
+
// Check if this scope is already granted by something in the result
318
+
let mut is_granted = false;
319
+
for existing in &result {
320
+
if existing.grants(&scope) && existing != &scope {
327
+
continue; // Skip this scope, it's already covered
330
+
// Check if this scope grants any existing scopes in the result
331
+
let mut indices_to_remove = Vec::new();
332
+
for (i, existing) in result.iter().enumerate() {
333
+
if scope.grants(existing) && &scope != existing {
334
+
indices_to_remove.push(i);
338
+
// Remove scopes that are granted by the new scope (in reverse order to maintain indices)
339
+
for i in indices_to_remove.into_iter().rev() {
343
+
// Add the new scope if it's not a duplicate
344
+
if !result.contains(&scope) {
345
+
result.push(scope);
352
+
/// Serialize a list of scopes into a space-separated OAuth scopes string
354
+
/// The scopes are sorted alphabetically by their string representation to ensure
355
+
/// consistent output regardless of input order.
359
+
/// # use jacquard_oauth::scopes::Scope;
360
+
/// let scopes = vec![
361
+
/// Scope::parse("repo:*").unwrap(),
362
+
/// Scope::parse("atproto").unwrap(),
363
+
/// Scope::parse("account:email").unwrap(),
365
+
/// let result = Scope::serialize_multiple(&scopes);
366
+
/// assert_eq!(result, "account:email atproto repo:*");
368
+
pub fn serialize_multiple(scopes: &[Self]) -> CowStr<'static> {
369
+
if scopes.is_empty() {
370
+
return CowStr::default();
373
+
let mut serialized: Vec<String> = scopes.iter().map(|scope| scope.to_string()).collect();
376
+
serialized.join(" ").into()
379
+
/// Remove a scope from a list of scopes
381
+
/// Returns a new vector with all instances of the specified scope removed.
382
+
/// If the scope doesn't exist in the list, returns a copy of the original list.
386
+
/// # use jacquard_oauth::scopes::Scope;
387
+
/// let scopes = vec![
388
+
/// Scope::parse("repo:*").unwrap(),
389
+
/// Scope::parse("atproto").unwrap(),
390
+
/// Scope::parse("account:email").unwrap(),
392
+
/// let to_remove = Scope::parse("atproto").unwrap();
393
+
/// let result = Scope::remove_scope(&scopes, &to_remove);
394
+
/// assert_eq!(result.len(), 2);
395
+
/// assert!(!result.contains(&to_remove));
397
+
pub fn remove_scope(scopes: &[Self], scope_to_remove: &Self) -> Vec<Self> {
400
+
.filter(|s| *s != scope_to_remove)
405
+
/// Parse a scope from a string
406
+
pub fn parse(s: &'s str) -> Result<Self, ParseError> {
407
+
// Determine the prefix first by checking for known prefixes
420
+
let mut found_prefix = None;
421
+
let mut suffix = None;
423
+
for prefix in &prefixes {
424
+
if let Some(remainder) = s.strip_prefix(prefix)
425
+
&& (remainder.is_empty()
426
+
|| remainder.starts_with(':')
427
+
|| remainder.starts_with('?'))
429
+
found_prefix = Some(*prefix);
430
+
if let Some(stripped) = remainder.strip_prefix(':') {
431
+
suffix = Some(stripped);
432
+
} else if remainder.starts_with('?') {
433
+
suffix = Some(remainder);
441
+
let prefix = found_prefix.ok_or_else(|| {
442
+
// If no known prefix found, extract what looks like a prefix for error reporting
443
+
let end = s.find(':').or_else(|| s.find('?')).unwrap_or(s.len());
444
+
ParseError::UnknownPrefix(s[..end].to_string())
448
+
"account" => Self::parse_account(suffix),
449
+
"identity" => Self::parse_identity(suffix),
450
+
"blob" => Self::parse_blob(suffix),
451
+
"repo" => Self::parse_repo(suffix),
452
+
"rpc" => Self::parse_rpc(suffix),
453
+
"atproto" => Self::parse_atproto(suffix),
454
+
"transition" => Self::parse_transition(suffix),
455
+
"openid" => Self::parse_openid(suffix),
456
+
"profile" => Self::parse_profile(suffix),
457
+
"email" => Self::parse_email(suffix),
458
+
_ => Err(ParseError::UnknownPrefix(prefix.to_string())),
462
+
fn parse_account(suffix: Option<&'s str>) -> Result<Self, ParseError> {
463
+
let (resource_str, params) = match suffix {
465
+
if let Some(pos) = s.find('?') {
466
+
(&s[..pos], Some(&s[pos + 1..]))
471
+
None => return Err(ParseError::MissingResource),
474
+
let resource = match resource_str {
475
+
"email" => AccountResource::Email,
476
+
"repo" => AccountResource::Repo,
477
+
"status" => AccountResource::Status,
478
+
_ => return Err(ParseError::InvalidResource(resource_str.to_string())),
481
+
let action = if let Some(params) = params {
482
+
let parsed_params = parse_query_string(params);
483
+
match parsed_params
485
+
.and_then(|v| v.first())
486
+
.map(|s| s.as_ref())
488
+
Some("read") => AccountAction::Read,
489
+
Some("manage") => AccountAction::Manage,
490
+
Some(other) => return Err(ParseError::InvalidAction(other.to_string())),
491
+
None => AccountAction::Read,
494
+
AccountAction::Read
497
+
Ok(Scope::Account(AccountScope { resource, action }))
500
+
fn parse_identity(suffix: Option<&'s str>) -> Result<Self, ParseError> {
501
+
let scope = match suffix {
502
+
Some("handle") => IdentityScope::Handle,
503
+
Some("*") => IdentityScope::All,
504
+
Some(other) => return Err(ParseError::InvalidResource(other.to_string())),
505
+
None => return Err(ParseError::MissingResource),
508
+
Ok(Scope::Identity(scope))
511
+
fn parse_blob(suffix: Option<&'s str>) -> Result<Self, ParseError> {
512
+
let mut accept = BTreeSet::new();
515
+
Some(s) if s.starts_with('?') => {
516
+
let params = parse_query_string(&s[1..]);
517
+
if let Some(values) = params.get("accept") {
518
+
for value in values {
519
+
accept.insert(MimePattern::from_str(value)?);
524
+
accept.insert(MimePattern::from_str(s)?);
527
+
accept.insert(MimePattern::All);
531
+
if accept.is_empty() {
532
+
accept.insert(MimePattern::All);
535
+
Ok(Scope::Blob(BlobScope { accept }))
538
+
fn parse_repo(suffix: Option<&'s str>) -> Result<Self, ParseError> {
539
+
let (collection_str, params) = match suffix {
541
+
if let Some(pos) = s.find('?') {
542
+
(Some(&s[..pos]), Some(&s[pos + 1..]))
547
+
None => (None, None),
550
+
let collection = match collection_str {
551
+
Some("*") | None => RepoCollection::All,
552
+
Some(nsid) => RepoCollection::Nsid(Nsid::new(nsid)?),
555
+
let mut actions = BTreeSet::new();
556
+
if let Some(params) = params {
557
+
let parsed_params = parse_query_string(params);
558
+
if let Some(values) = parsed_params.get("action") {
559
+
for value in values {
560
+
match value.as_ref() {
562
+
actions.insert(RepoAction::Create);
565
+
actions.insert(RepoAction::Update);
568
+
actions.insert(RepoAction::Delete);
571
+
actions.insert(RepoAction::Create);
572
+
actions.insert(RepoAction::Update);
573
+
actions.insert(RepoAction::Delete);
575
+
other => return Err(ParseError::InvalidAction(other.to_string())),
581
+
if actions.is_empty() {
582
+
actions.insert(RepoAction::Create);
583
+
actions.insert(RepoAction::Update);
584
+
actions.insert(RepoAction::Delete);
587
+
Ok(Scope::Repo(RepoScope {
593
+
fn parse_rpc(suffix: Option<&'s str>) -> Result<Self, ParseError> {
594
+
let mut lxm = BTreeSet::new();
595
+
let mut aud = BTreeSet::new();
599
+
lxm.insert(RpcLexicon::All);
600
+
aud.insert(RpcAudience::All);
602
+
Some(s) if s.starts_with('?') => {
603
+
let params = parse_query_string(&s[1..]);
605
+
if let Some(values) = params.get("lxm") {
606
+
for value in values {
607
+
if value.as_ref() == "*" {
608
+
lxm.insert(RpcLexicon::All);
610
+
lxm.insert(RpcLexicon::Nsid(Nsid::new(value)?.into_static()));
615
+
if let Some(values) = params.get("aud") {
616
+
for value in values {
617
+
if value.as_ref() == "*" {
618
+
aud.insert(RpcAudience::All);
620
+
aud.insert(RpcAudience::Did(Did::new(value)?.into_static()));
626
+
// Check if there's a query string in the suffix
627
+
if let Some(pos) = s.find('?') {
628
+
let nsid = &s[..pos];
629
+
let params = parse_query_string(&s[pos + 1..]);
631
+
lxm.insert(RpcLexicon::Nsid(Nsid::new(nsid)?.into_static()));
633
+
if let Some(values) = params.get("aud") {
634
+
for value in values {
635
+
if value.as_ref() == "*" {
636
+
aud.insert(RpcAudience::All);
638
+
aud.insert(RpcAudience::Did(Did::new(value)?.into_static()));
643
+
lxm.insert(RpcLexicon::Nsid(Nsid::new(s)?.into_static()));
649
+
if lxm.is_empty() {
650
+
lxm.insert(RpcLexicon::All);
652
+
if aud.is_empty() {
653
+
aud.insert(RpcAudience::All);
656
+
Ok(Scope::Rpc(RpcScope { lxm, aud }))
659
+
fn parse_atproto(suffix: Option<&str>) -> Result<Self, ParseError> {
660
+
if suffix.is_some() {
661
+
return Err(ParseError::InvalidResource(
662
+
"atproto scope does not accept suffixes".to_string(),
668
+
fn parse_transition(suffix: Option<&str>) -> Result<Self, ParseError> {
669
+
let scope = match suffix {
670
+
Some("generic") => TransitionScope::Generic,
671
+
Some("email") => TransitionScope::Email,
672
+
Some(other) => return Err(ParseError::InvalidResource(other.to_string())),
673
+
None => return Err(ParseError::MissingResource),
676
+
Ok(Scope::Transition(scope))
679
+
fn parse_openid(suffix: Option<&str>) -> Result<Self, ParseError> {
680
+
if suffix.is_some() {
681
+
return Err(ParseError::InvalidResource(
682
+
"openid scope does not accept suffixes".to_string(),
688
+
fn parse_profile(suffix: Option<&str>) -> Result<Self, ParseError> {
689
+
if suffix.is_some() {
690
+
return Err(ParseError::InvalidResource(
691
+
"profile scope does not accept suffixes".to_string(),
697
+
fn parse_email(suffix: Option<&str>) -> Result<Self, ParseError> {
698
+
if suffix.is_some() {
699
+
return Err(ParseError::InvalidResource(
700
+
"email scope does not accept suffixes".to_string(),
706
+
/// Convert the scope to its normalized string representation
707
+
pub fn to_string_normalized(&self) -> String {
709
+
Scope::Account(scope) => {
710
+
let resource = match scope.resource {
711
+
AccountResource::Email => "email",
712
+
AccountResource::Repo => "repo",
713
+
AccountResource::Status => "status",
716
+
match scope.action {
717
+
AccountAction::Read => format!("account:{}", resource),
718
+
AccountAction::Manage => format!("account:{}?action=manage", resource),
721
+
Scope::Identity(scope) => match scope {
722
+
IdentityScope::Handle => "identity:handle".to_string(),
723
+
IdentityScope::All => "identity:*".to_string(),
725
+
Scope::Blob(scope) => {
726
+
if scope.accept.len() == 1 {
727
+
if let Some(pattern) = scope.accept.iter().next() {
729
+
MimePattern::All => "blob:*/*".to_string(),
730
+
MimePattern::TypeWildcard(t) => format!("blob:{}/*", t),
731
+
MimePattern::Exact(mime) => format!("blob:{}", mime),
734
+
"blob:*/*".to_string()
737
+
let mut params = Vec::new();
738
+
for pattern in &scope.accept {
740
+
MimePattern::All => params.push("accept=*/*".to_string()),
741
+
MimePattern::TypeWildcard(t) => params.push(format!("accept={}/*", t)),
742
+
MimePattern::Exact(mime) => params.push(format!("accept={}", mime)),
746
+
format!("blob?{}", params.join("&"))
749
+
Scope::Repo(scope) => {
750
+
let collection = match &scope.collection {
751
+
RepoCollection::All => "*",
752
+
RepoCollection::Nsid(nsid) => nsid,
755
+
if scope.actions.len() == 3 {
756
+
format!("repo:{}", collection)
758
+
let mut params = Vec::new();
759
+
for action in &scope.actions {
761
+
RepoAction::Create => params.push("action=create"),
762
+
RepoAction::Update => params.push("action=update"),
763
+
RepoAction::Delete => params.push("action=delete"),
766
+
format!("repo:{}?{}", collection, params.join("&"))
769
+
Scope::Rpc(scope) => {
770
+
if scope.lxm.len() == 1
771
+
&& scope.lxm.contains(&RpcLexicon::All)
772
+
&& scope.aud.len() == 1
773
+
&& scope.aud.contains(&RpcAudience::All)
775
+
"rpc:*".to_string()
776
+
} else if scope.lxm.len() == 1
777
+
&& scope.aud.len() == 1
778
+
&& scope.aud.contains(&RpcAudience::All)
780
+
if let Some(lxm) = scope.lxm.iter().next() {
782
+
RpcLexicon::All => "rpc:*".to_string(),
783
+
RpcLexicon::Nsid(nsid) => format!("rpc:{}", nsid),
786
+
"rpc:*".to_string()
789
+
let mut params = Vec::new();
791
+
for lxm in &scope.lxm {
793
+
RpcLexicon::All => params.push("lxm=*".to_string()),
794
+
RpcLexicon::Nsid(nsid) => params.push(format!("lxm={}", nsid)),
798
+
for aud in &scope.aud {
800
+
RpcAudience::All => params.push("aud=*".to_string()),
801
+
RpcAudience::Did(did) => params.push(format!("aud={}", did)),
807
+
if params.is_empty() {
808
+
"rpc:*".to_string()
810
+
format!("rpc?{}", params.join("&"))
814
+
Scope::Atproto => "atproto".to_string(),
815
+
Scope::Transition(scope) => match scope {
816
+
TransitionScope::Generic => "transition:generic".to_string(),
817
+
TransitionScope::Email => "transition:email".to_string(),
819
+
Scope::OpenId => "openid".to_string(),
820
+
Scope::Profile => "profile".to_string(),
821
+
Scope::Email => "email".to_string(),
825
+
/// Check if this scope grants the permissions of another scope
826
+
pub fn grants(&self, other: &Scope) -> bool {
827
+
match (self, other) {
828
+
// Atproto only grants itself (it's a required scope, not a permission grant)
829
+
(Scope::Atproto, Scope::Atproto) => true,
830
+
(Scope::Atproto, _) => false,
831
+
// Nothing else grants atproto
832
+
(_, Scope::Atproto) => false,
833
+
// Transition scopes only grant themselves
834
+
(Scope::Transition(a), Scope::Transition(b)) => a == b,
835
+
// Other scopes don't grant transition scopes
836
+
(_, Scope::Transition(_)) => false,
837
+
(Scope::Transition(_), _) => false,
838
+
// OpenID Connect scopes only grant themselves
839
+
(Scope::OpenId, Scope::OpenId) => true,
840
+
(Scope::OpenId, _) => false,
841
+
(_, Scope::OpenId) => false,
842
+
(Scope::Profile, Scope::Profile) => true,
843
+
(Scope::Profile, _) => false,
844
+
(_, Scope::Profile) => false,
845
+
(Scope::Email, Scope::Email) => true,
846
+
(Scope::Email, _) => false,
847
+
(_, Scope::Email) => false,
848
+
(Scope::Account(a), Scope::Account(b)) => {
849
+
a.resource == b.resource
851
+
(a.action, b.action),
852
+
(AccountAction::Manage, _) | (AccountAction::Read, AccountAction::Read)
855
+
(Scope::Identity(a), Scope::Identity(b)) => matches!(
857
+
(IdentityScope::All, _) | (IdentityScope::Handle, IdentityScope::Handle)
859
+
(Scope::Blob(a), Scope::Blob(b)) => {
860
+
for b_pattern in &b.accept {
861
+
let mut granted = false;
862
+
for a_pattern in &a.accept {
863
+
if a_pattern.grants(b_pattern) {
874
+
(Scope::Repo(a), Scope::Repo(b)) => {
875
+
let collection_match = match (&a.collection, &b.collection) {
876
+
(RepoCollection::All, _) => true,
877
+
(RepoCollection::Nsid(a_nsid), RepoCollection::Nsid(b_nsid)) => {
883
+
if !collection_match {
887
+
b.actions.is_subset(&a.actions) || a.actions.len() == 3
889
+
(Scope::Rpc(a), Scope::Rpc(b)) => {
890
+
let lxm_match = if a.lxm.contains(&RpcLexicon::All) {
893
+
b.lxm.iter().all(|b_lxm| match b_lxm {
894
+
RpcLexicon::All => false,
895
+
RpcLexicon::Nsid(_) => a.lxm.contains(b_lxm),
899
+
let aud_match = if a.aud.contains(&RpcAudience::All) {
902
+
b.aud.iter().all(|b_aud| match b_aud {
903
+
RpcAudience::All => false,
904
+
RpcAudience::Did(_) => a.aud.contains(b_aud),
908
+
lxm_match && aud_match
915
+
impl MimePattern<'_> {
916
+
fn grants(&self, other: &MimePattern) -> bool {
917
+
match (self, other) {
918
+
(MimePattern::All, _) => true,
919
+
(MimePattern::TypeWildcard(a_type), MimePattern::TypeWildcard(b_type)) => {
922
+
(MimePattern::TypeWildcard(a_type), MimePattern::Exact(b_mime)) => {
923
+
b_mime.starts_with(&format!("{}/", a_type))
925
+
(MimePattern::Exact(a), MimePattern::Exact(b)) => a == b,
931
+
impl FromStr for MimePattern<'_> {
932
+
type Err = ParseError;
934
+
fn from_str(s: &str) -> Result<Self, Self::Err> {
936
+
Ok(MimePattern::All)
937
+
} else if let Some(stripped) = s.strip_suffix("/*") {
938
+
Ok(MimePattern::TypeWildcard(CowStr::Owned(
939
+
stripped.to_smolstr(),
941
+
} else if s.contains('/') {
942
+
Ok(MimePattern::Exact(CowStr::Owned(s.to_smolstr())))
944
+
Err(ParseError::InvalidMimeType(s.to_string()))
949
+
impl FromStr for Scope<'_> {
950
+
type Err = ParseError;
952
+
fn from_str(s: &str) -> Result<Scope<'static>, Self::Err> {
953
+
match Scope::parse(s) {
954
+
Ok(parsed) => Ok(parsed.into_static()),
960
+
impl fmt::Display for Scope<'_> {
961
+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
962
+
write!(f, "{}", self.to_string_normalized())
966
+
/// Parse a query string into a map of keys to lists of values
967
+
fn parse_query_string(query: &str) -> BTreeMap<SmolStr, Vec<CowStr<'static>>> {
968
+
let mut params = BTreeMap::new();
970
+
for pair in query.split('&') {
971
+
if let Some(pos) = pair.find('=') {
972
+
let key = &pair[..pos];
973
+
let value = &pair[pos + 1..];
975
+
.entry(key.to_smolstr())
976
+
.or_insert_with(Vec::new)
977
+
.push(CowStr::Owned(value.to_smolstr()));
984
+
/// Error type for scope parsing
985
+
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, miette::Diagnostic)]
986
+
pub enum ParseError {
987
+
/// Unknown scope prefix
988
+
UnknownPrefix(String),
989
+
/// Missing required resource
991
+
/// Invalid resource type
992
+
InvalidResource(String),
993
+
/// Invalid action type
994
+
InvalidAction(String),
995
+
/// Invalid MIME type
996
+
InvalidMimeType(String),
997
+
ParseError(#[from] AtStrError),
1000
+
impl fmt::Display for ParseError {
1001
+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1003
+
ParseError::UnknownPrefix(prefix) => write!(f, "Unknown scope prefix: {}", prefix),
1004
+
ParseError::MissingResource => write!(f, "Missing required resource"),
1005
+
ParseError::InvalidResource(resource) => write!(f, "Invalid resource: {}", resource),
1006
+
ParseError::InvalidAction(action) => write!(f, "Invalid action: {}", action),
1007
+
ParseError::InvalidMimeType(mime) => write!(f, "Invalid MIME type: {}", mime),
1008
+
ParseError::ParseError(err) => write!(f, "Parse error: {}", err),
1018
+
fn test_account_scope_parsing() {
1019
+
let scope = Scope::parse("account:email").unwrap();
1022
+
Scope::Account(AccountScope {
1023
+
resource: AccountResource::Email,
1024
+
action: AccountAction::Read,
1028
+
let scope = Scope::parse("account:repo?action=manage").unwrap();
1031
+
Scope::Account(AccountScope {
1032
+
resource: AccountResource::Repo,
1033
+
action: AccountAction::Manage,
1037
+
let scope = Scope::parse("account:status?action=read").unwrap();
1040
+
Scope::Account(AccountScope {
1041
+
resource: AccountResource::Status,
1042
+
action: AccountAction::Read,
1048
+
fn test_identity_scope_parsing() {
1049
+
let scope = Scope::parse("identity:handle").unwrap();
1050
+
assert_eq!(scope, Scope::Identity(IdentityScope::Handle));
1052
+
let scope = Scope::parse("identity:*").unwrap();
1053
+
assert_eq!(scope, Scope::Identity(IdentityScope::All));
1057
+
fn test_blob_scope_parsing() {
1058
+
let scope = Scope::parse("blob:*/*").unwrap();
1059
+
let mut accept = BTreeSet::new();
1060
+
accept.insert(MimePattern::All);
1061
+
assert_eq!(scope, Scope::Blob(BlobScope { accept }));
1063
+
let scope = Scope::parse("blob:image/png").unwrap();
1064
+
let mut accept = BTreeSet::new();
1065
+
accept.insert(MimePattern::Exact(CowStr::new_static("image/png")));
1066
+
assert_eq!(scope, Scope::Blob(BlobScope { accept }));
1068
+
let scope = Scope::parse("blob?accept=image/png&accept=image/jpeg").unwrap();
1069
+
let mut accept = BTreeSet::new();
1070
+
accept.insert(MimePattern::Exact(CowStr::new_static("image/png")));
1071
+
accept.insert(MimePattern::Exact(CowStr::new_static("image/jpeg")));
1072
+
assert_eq!(scope, Scope::Blob(BlobScope { accept }));
1074
+
let scope = Scope::parse("blob:image/*").unwrap();
1075
+
let mut accept = BTreeSet::new();
1076
+
accept.insert(MimePattern::TypeWildcard(CowStr::new_static("image")));
1077
+
assert_eq!(scope, Scope::Blob(BlobScope { accept }));
1081
+
fn test_repo_scope_parsing() {
1082
+
let scope = Scope::parse("repo:*?action=create").unwrap();
1083
+
let mut actions = BTreeSet::new();
1084
+
actions.insert(RepoAction::Create);
1087
+
Scope::Repo(RepoScope {
1088
+
collection: RepoCollection::All,
1093
+
let scope = Scope::parse("repo:app.bsky.feed.post?action=create&action=update").unwrap();
1094
+
let mut actions = BTreeSet::new();
1095
+
actions.insert(RepoAction::Create);
1096
+
actions.insert(RepoAction::Update);
1099
+
Scope::Repo(RepoScope {
1100
+
collection: RepoCollection::Nsid(Nsid::new_static("app.bsky.feed.post").unwrap()),
1105
+
let scope = Scope::parse("repo:app.bsky.feed.post").unwrap();
1106
+
let mut actions = BTreeSet::new();
1107
+
actions.insert(RepoAction::Create);
1108
+
actions.insert(RepoAction::Update);
1109
+
actions.insert(RepoAction::Delete);
1112
+
Scope::Repo(RepoScope {
1113
+
collection: RepoCollection::Nsid(Nsid::new_static("app.bsky.feed.post").unwrap()),
1120
+
fn test_rpc_scope_parsing() {
1121
+
let scope = Scope::parse("rpc:*").unwrap();
1122
+
let mut lxm = BTreeSet::new();
1123
+
let mut aud = BTreeSet::new();
1124
+
lxm.insert(RpcLexicon::All);
1125
+
aud.insert(RpcAudience::All);
1126
+
assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud }));
1128
+
let scope = Scope::parse("rpc:com.example.service").unwrap();
1129
+
let mut lxm = BTreeSet::new();
1130
+
let mut aud = BTreeSet::new();
1131
+
lxm.insert(RpcLexicon::Nsid(
1132
+
Nsid::new_static("com.example.service").unwrap(),
1134
+
aud.insert(RpcAudience::All);
1135
+
assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud }));
1138
+
Scope::parse("rpc:com.example.service?aud=did:plc:yfvwmnlztr4dwkb7hwz55r2g").unwrap();
1139
+
let mut lxm = BTreeSet::new();
1140
+
let mut aud = BTreeSet::new();
1141
+
lxm.insert(RpcLexicon::Nsid(
1142
+
Nsid::new_static("com.example.service").unwrap(),
1144
+
aud.insert(RpcAudience::Did(
1145
+
Did::new_static("did:plc:yfvwmnlztr4dwkb7hwz55r2g").unwrap(),
1147
+
assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud }));
1150
+
Scope::parse("rpc?lxm=com.example.method1&lxm=com.example.method2&aud=did:plc:yfvwmnlztr4dwkb7hwz55r2g")
1152
+
let mut lxm = BTreeSet::new();
1153
+
let mut aud = BTreeSet::new();
1154
+
lxm.insert(RpcLexicon::Nsid(
1155
+
Nsid::new_static("com.example.method1").unwrap(),
1157
+
lxm.insert(RpcLexicon::Nsid(
1158
+
Nsid::new_static("com.example.method2").unwrap(),
1160
+
aud.insert(RpcAudience::Did(
1161
+
Did::new_static("did:plc:yfvwmnlztr4dwkb7hwz55r2g").unwrap(),
1163
+
assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud }));
1167
+
fn test_scope_normalization() {
1169
+
("account:email", "account:email"),
1170
+
("account:email?action=read", "account:email"),
1171
+
("account:email?action=manage", "account:email?action=manage"),
1172
+
("blob:image/png", "blob:image/png"),
1174
+
"blob?accept=image/jpeg&accept=image/png",
1175
+
"blob?accept=image/jpeg&accept=image/png",
1177
+
("repo:app.bsky.feed.post", "repo:app.bsky.feed.post"),
1179
+
"repo:app.bsky.feed.post?action=create",
1180
+
"repo:app.bsky.feed.post?action=create",
1182
+
("rpc:*", "rpc:*"),
1185
+
for (input, expected) in tests {
1186
+
let scope = Scope::parse(input).unwrap();
1187
+
assert_eq!(scope.to_string_normalized(), expected);
1192
+
fn test_account_scope_grants() {
1193
+
let manage = Scope::parse("account:email?action=manage").unwrap();
1194
+
let read = Scope::parse("account:email?action=read").unwrap();
1195
+
let other_read = Scope::parse("account:repo?action=read").unwrap();
1197
+
assert!(manage.grants(&read));
1198
+
assert!(manage.grants(&manage));
1199
+
assert!(!read.grants(&manage));
1200
+
assert!(read.grants(&read));
1201
+
assert!(!read.grants(&other_read));
1205
+
fn test_identity_scope_grants() {
1206
+
let all = Scope::parse("identity:*").unwrap();
1207
+
let handle = Scope::parse("identity:handle").unwrap();
1209
+
assert!(all.grants(&handle));
1210
+
assert!(all.grants(&all));
1211
+
assert!(!handle.grants(&all));
1212
+
assert!(handle.grants(&handle));
1216
+
fn test_blob_scope_grants() {
1217
+
let all = Scope::parse("blob:*/*").unwrap();
1218
+
let image_all = Scope::parse("blob:image/*").unwrap();
1219
+
let image_png = Scope::parse("blob:image/png").unwrap();
1220
+
let text_plain = Scope::parse("blob:text/plain").unwrap();
1222
+
assert!(all.grants(&image_all));
1223
+
assert!(all.grants(&image_png));
1224
+
assert!(all.grants(&text_plain));
1225
+
assert!(image_all.grants(&image_png));
1226
+
assert!(!image_all.grants(&text_plain));
1227
+
assert!(!image_png.grants(&image_all));
1231
+
fn test_repo_scope_grants() {
1232
+
let all_all = Scope::parse("repo:*").unwrap();
1233
+
let all_create = Scope::parse("repo:*?action=create").unwrap();
1234
+
let specific_all = Scope::parse("repo:app.bsky.feed.post").unwrap();
1235
+
let specific_create = Scope::parse("repo:app.bsky.feed.post?action=create").unwrap();
1236
+
let other_create = Scope::parse("repo:pub.leaflet.publication?action=create").unwrap();
1238
+
assert!(all_all.grants(&all_create));
1239
+
assert!(all_all.grants(&specific_all));
1240
+
assert!(all_all.grants(&specific_create));
1241
+
assert!(all_create.grants(&all_create));
1242
+
assert!(!all_create.grants(&specific_all));
1243
+
assert!(specific_all.grants(&specific_create));
1244
+
assert!(!specific_create.grants(&specific_all));
1245
+
assert!(!specific_create.grants(&other_create));
1249
+
fn test_rpc_scope_grants() {
1250
+
let all = Scope::parse("rpc:*").unwrap();
1251
+
let specific_lxm = Scope::parse("rpc:com.example.service").unwrap();
1252
+
let specific_both = Scope::parse("rpc:com.example.service?aud=did:example:123").unwrap();
1254
+
assert!(all.grants(&specific_lxm));
1255
+
assert!(all.grants(&specific_both));
1256
+
assert!(specific_lxm.grants(&specific_both));
1257
+
assert!(!specific_both.grants(&specific_lxm));
1258
+
assert!(!specific_both.grants(&all));
1262
+
fn test_cross_scope_grants() {
1263
+
let account = Scope::parse("account:email").unwrap();
1264
+
let identity = Scope::parse("identity:handle").unwrap();
1266
+
assert!(!account.grants(&identity));
1267
+
assert!(!identity.grants(&account));
1271
+
fn test_parse_errors() {
1273
+
Scope::parse("unknown:test"),
1274
+
Err(ParseError::UnknownPrefix(_))
1278
+
Scope::parse("account"),
1279
+
Err(ParseError::MissingResource)
1283
+
Scope::parse("account:invalid"),
1284
+
Err(ParseError::InvalidResource(_))
1288
+
Scope::parse("account:email?action=invalid"),
1289
+
Err(ParseError::InvalidAction(_))
1294
+
fn test_query_parameter_sorting() {
1296
+
Scope::parse("blob?accept=image/png&accept=application/pdf&accept=image/jpeg").unwrap();
1297
+
let normalized = scope.to_string_normalized();
1298
+
assert!(normalized.contains("accept=application/pdf"));
1299
+
assert!(normalized.contains("accept=image/jpeg"));
1300
+
assert!(normalized.contains("accept=image/png"));
1301
+
let pdf_pos = normalized.find("accept=application/pdf").unwrap();
1302
+
let jpeg_pos = normalized.find("accept=image/jpeg").unwrap();
1303
+
let png_pos = normalized.find("accept=image/png").unwrap();
1304
+
assert!(pdf_pos < jpeg_pos);
1305
+
assert!(jpeg_pos < png_pos);
1309
+
fn test_repo_action_wildcard() {
1310
+
let scope = Scope::parse("repo:app.bsky.feed.post?action=*").unwrap();
1311
+
let mut actions = BTreeSet::new();
1312
+
actions.insert(RepoAction::Create);
1313
+
actions.insert(RepoAction::Update);
1314
+
actions.insert(RepoAction::Delete);
1317
+
Scope::Repo(RepoScope {
1318
+
collection: RepoCollection::Nsid(Nsid::new_static("app.bsky.feed.post").unwrap()),
1325
+
fn test_multiple_blob_accepts() {
1326
+
let scope = Scope::parse("blob?accept=image/*&accept=text/plain").unwrap();
1327
+
assert!(scope.grants(&Scope::parse("blob:image/png").unwrap()));
1328
+
assert!(scope.grants(&Scope::parse("blob:text/plain").unwrap()));
1329
+
assert!(!scope.grants(&Scope::parse("blob:application/json").unwrap()));
1333
+
fn test_rpc_default_wildcards() {
1334
+
let scope = Scope::parse("rpc").unwrap();
1335
+
let mut lxm = BTreeSet::new();
1336
+
let mut aud = BTreeSet::new();
1337
+
lxm.insert(RpcLexicon::All);
1338
+
aud.insert(RpcAudience::All);
1339
+
assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud }));
1343
+
fn test_atproto_scope_parsing() {
1344
+
let scope = Scope::parse("atproto").unwrap();
1345
+
assert_eq!(scope, Scope::Atproto);
1347
+
// Atproto should not accept suffixes
1348
+
assert!(Scope::parse("atproto:something").is_err());
1349
+
assert!(Scope::parse("atproto?param=value").is_err());
1353
+
fn test_transition_scope_parsing() {
1354
+
let scope = Scope::parse("transition:generic").unwrap();
1355
+
assert_eq!(scope, Scope::Transition(TransitionScope::Generic));
1357
+
let scope = Scope::parse("transition:email").unwrap();
1358
+
assert_eq!(scope, Scope::Transition(TransitionScope::Email));
1360
+
// Test invalid transition types
1362
+
Scope::parse("transition:invalid"),
1363
+
Err(ParseError::InvalidResource(_))
1366
+
// Test missing suffix
1368
+
Scope::parse("transition"),
1369
+
Err(ParseError::MissingResource)
1372
+
// Test transition doesn't accept query parameters
1374
+
Scope::parse("transition:generic?param=value"),
1375
+
Err(ParseError::InvalidResource(_))
1380
+
fn test_atproto_scope_normalization() {
1381
+
let scope = Scope::parse("atproto").unwrap();
1382
+
assert_eq!(scope.to_string_normalized(), "atproto");
1386
+
fn test_transition_scope_normalization() {
1388
+
("transition:generic", "transition:generic"),
1389
+
("transition:email", "transition:email"),
1392
+
for (input, expected) in tests {
1393
+
let scope = Scope::parse(input).unwrap();
1394
+
assert_eq!(scope.to_string_normalized(), expected);
1399
+
fn test_atproto_scope_grants() {
1400
+
let atproto = Scope::parse("atproto").unwrap();
1401
+
let account = Scope::parse("account:email").unwrap();
1402
+
let identity = Scope::parse("identity:handle").unwrap();
1403
+
let blob = Scope::parse("blob:image/png").unwrap();
1404
+
let repo = Scope::parse("repo:app.bsky.feed.post").unwrap();
1405
+
let rpc = Scope::parse("rpc:com.example.service").unwrap();
1406
+
let transition_generic = Scope::parse("transition:generic").unwrap();
1407
+
let transition_email = Scope::parse("transition:email").unwrap();
1409
+
// Atproto only grants itself (it's a required scope, not a permission grant)
1410
+
assert!(atproto.grants(&atproto));
1411
+
assert!(!atproto.grants(&account));
1412
+
assert!(!atproto.grants(&identity));
1413
+
assert!(!atproto.grants(&blob));
1414
+
assert!(!atproto.grants(&repo));
1415
+
assert!(!atproto.grants(&rpc));
1416
+
assert!(!atproto.grants(&transition_generic));
1417
+
assert!(!atproto.grants(&transition_email));
1419
+
// Nothing else grants atproto
1420
+
assert!(!account.grants(&atproto));
1421
+
assert!(!identity.grants(&atproto));
1422
+
assert!(!blob.grants(&atproto));
1423
+
assert!(!repo.grants(&atproto));
1424
+
assert!(!rpc.grants(&atproto));
1425
+
assert!(!transition_generic.grants(&atproto));
1426
+
assert!(!transition_email.grants(&atproto));
1430
+
fn test_transition_scope_grants() {
1431
+
let transition_generic = Scope::parse("transition:generic").unwrap();
1432
+
let transition_email = Scope::parse("transition:email").unwrap();
1433
+
let account = Scope::parse("account:email").unwrap();
1435
+
// Transition scopes only grant themselves
1436
+
assert!(transition_generic.grants(&transition_generic));
1437
+
assert!(transition_email.grants(&transition_email));
1438
+
assert!(!transition_generic.grants(&transition_email));
1439
+
assert!(!transition_email.grants(&transition_generic));
1441
+
// Transition scopes don't grant other scope types
1442
+
assert!(!transition_generic.grants(&account));
1443
+
assert!(!transition_email.grants(&account));
1445
+
// Other scopes don't grant transition scopes
1446
+
assert!(!account.grants(&transition_generic));
1447
+
assert!(!account.grants(&transition_email));
1451
+
fn test_parse_multiple() {
1452
+
// Test parsing multiple scopes
1453
+
let scopes = Scope::parse_multiple("atproto repo:*").unwrap();
1454
+
assert_eq!(scopes.len(), 2);
1455
+
assert_eq!(scopes[0], Scope::Atproto);
1458
+
Scope::Repo(RepoScope {
1459
+
collection: RepoCollection::All,
1461
+
let mut actions = BTreeSet::new();
1462
+
actions.insert(RepoAction::Create);
1463
+
actions.insert(RepoAction::Update);
1464
+
actions.insert(RepoAction::Delete);
1470
+
// Test with more scopes
1471
+
let scopes = Scope::parse_multiple("account:email identity:handle blob:image/png").unwrap();
1472
+
assert_eq!(scopes.len(), 3);
1473
+
assert!(matches!(scopes[0], Scope::Account(_)));
1474
+
assert!(matches!(scopes[1], Scope::Identity(_)));
1475
+
assert!(matches!(scopes[2], Scope::Blob(_)));
1477
+
// Test with complex scopes
1478
+
let scopes = Scope::parse_multiple(
1479
+
"account:email?action=manage repo:app.bsky.feed.post?action=create transition:email",
1482
+
assert_eq!(scopes.len(), 3);
1484
+
// Test empty string
1485
+
let scopes = Scope::parse_multiple("").unwrap();
1486
+
assert_eq!(scopes.len(), 0);
1488
+
// Test whitespace only
1489
+
let scopes = Scope::parse_multiple(" ").unwrap();
1490
+
assert_eq!(scopes.len(), 0);
1492
+
// Test with extra whitespace
1493
+
let scopes = Scope::parse_multiple(" atproto repo:* ").unwrap();
1494
+
assert_eq!(scopes.len(), 2);
1496
+
// Test single scope
1497
+
let scopes = Scope::parse_multiple("atproto").unwrap();
1498
+
assert_eq!(scopes.len(), 1);
1499
+
assert_eq!(scopes[0], Scope::Atproto);
1501
+
// Test error propagation
1502
+
assert!(Scope::parse_multiple("atproto invalid:scope").is_err());
1503
+
assert!(Scope::parse_multiple("account:invalid repo:*").is_err());
1507
+
fn test_parse_multiple_reduced() {
1508
+
// Test repo scope reduction - wildcard grants specific
1510
+
Scope::parse_multiple_reduced("atproto repo:app.bsky.feed.post repo:*").unwrap();
1511
+
assert_eq!(scopes.len(), 2);
1512
+
assert!(scopes.contains(&Scope::Atproto));
1513
+
assert!(scopes.contains(&Scope::Repo(RepoScope {
1514
+
collection: RepoCollection::All,
1516
+
let mut actions = BTreeSet::new();
1517
+
actions.insert(RepoAction::Create);
1518
+
actions.insert(RepoAction::Update);
1519
+
actions.insert(RepoAction::Delete);
1524
+
// Test reverse order - should get same result
1526
+
Scope::parse_multiple_reduced("atproto repo:* repo:app.bsky.feed.post").unwrap();
1527
+
assert_eq!(scopes.len(), 2);
1528
+
assert!(scopes.contains(&Scope::Atproto));
1529
+
assert!(scopes.contains(&Scope::Repo(RepoScope {
1530
+
collection: RepoCollection::All,
1532
+
let mut actions = BTreeSet::new();
1533
+
actions.insert(RepoAction::Create);
1534
+
actions.insert(RepoAction::Update);
1535
+
actions.insert(RepoAction::Delete);
1540
+
// Test account scope reduction - manage grants read
1542
+
Scope::parse_multiple_reduced("account:email account:email?action=manage").unwrap();
1543
+
assert_eq!(scopes.len(), 1);
1546
+
Scope::Account(AccountScope {
1547
+
resource: AccountResource::Email,
1548
+
action: AccountAction::Manage,
1552
+
// Test identity scope reduction - wildcard grants specific
1553
+
let scopes = Scope::parse_multiple_reduced("identity:handle identity:*").unwrap();
1554
+
assert_eq!(scopes.len(), 1);
1555
+
assert_eq!(scopes[0], Scope::Identity(IdentityScope::All));
1557
+
// Test blob scope reduction - wildcard grants specific
1558
+
let scopes = Scope::parse_multiple_reduced("blob:image/png blob:image/* blob:*/*").unwrap();
1559
+
assert_eq!(scopes.len(), 1);
1560
+
let mut accept = BTreeSet::new();
1561
+
accept.insert(MimePattern::All);
1562
+
assert_eq!(scopes[0], Scope::Blob(BlobScope { accept }));
1564
+
// Test no reduction needed - different scope types
1566
+
Scope::parse_multiple_reduced("account:email identity:handle blob:image/png").unwrap();
1567
+
assert_eq!(scopes.len(), 3);
1569
+
// Test repo action reduction
1570
+
let scopes = Scope::parse_multiple_reduced(
1571
+
"repo:app.bsky.feed.post?action=create repo:app.bsky.feed.post",
1574
+
assert_eq!(scopes.len(), 1);
1577
+
Scope::Repo(RepoScope {
1578
+
collection: RepoCollection::Nsid(Nsid::new_static("app.bsky.feed.post").unwrap()),
1580
+
let mut actions = BTreeSet::new();
1581
+
actions.insert(RepoAction::Create);
1582
+
actions.insert(RepoAction::Update);
1583
+
actions.insert(RepoAction::Delete);
1589
+
// Test RPC scope reduction
1590
+
let scopes = Scope::parse_multiple_reduced(
1591
+
"rpc:com.example.service?aud=did:example:123 rpc:com.example.service rpc:*",
1594
+
assert_eq!(scopes.len(), 1);
1597
+
Scope::Rpc(RpcScope {
1599
+
let mut lxm = BTreeSet::new();
1600
+
lxm.insert(RpcLexicon::All);
1604
+
let mut aud = BTreeSet::new();
1605
+
aud.insert(RpcAudience::All);
1611
+
// Test duplicate removal
1612
+
let scopes = Scope::parse_multiple_reduced("atproto atproto atproto").unwrap();
1613
+
assert_eq!(scopes.len(), 1);
1614
+
assert_eq!(scopes[0], Scope::Atproto);
1616
+
// Test transition scopes - only grant themselves
1617
+
let scopes = Scope::parse_multiple_reduced("transition:generic transition:email").unwrap();
1618
+
assert_eq!(scopes.len(), 2);
1619
+
assert!(scopes.contains(&Scope::Transition(TransitionScope::Generic)));
1620
+
assert!(scopes.contains(&Scope::Transition(TransitionScope::Email)));
1622
+
// Test empty input
1623
+
let scopes = Scope::parse_multiple_reduced("").unwrap();
1624
+
assert_eq!(scopes.len(), 0);
1626
+
// Test complex scenario with multiple reductions
1627
+
let scopes = Scope::parse_multiple_reduced(
1628
+
"account:email?action=manage account:email account:repo account:repo?action=read identity:* identity:handle"
1630
+
assert_eq!(scopes.len(), 3);
1631
+
// Should have: account:email?action=manage, account:repo, identity:*
1632
+
assert!(scopes.contains(&Scope::Account(AccountScope {
1633
+
resource: AccountResource::Email,
1634
+
action: AccountAction::Manage,
1636
+
assert!(scopes.contains(&Scope::Account(AccountScope {
1637
+
resource: AccountResource::Repo,
1638
+
action: AccountAction::Read,
1640
+
assert!(scopes.contains(&Scope::Identity(IdentityScope::All)));
1642
+
// Test that atproto doesn't grant other scopes (per recent change)
1643
+
let scopes = Scope::parse_multiple_reduced("atproto account:email repo:*").unwrap();
1644
+
assert_eq!(scopes.len(), 3);
1645
+
assert!(scopes.contains(&Scope::Atproto));
1646
+
assert!(scopes.contains(&Scope::Account(AccountScope {
1647
+
resource: AccountResource::Email,
1648
+
action: AccountAction::Read,
1650
+
assert!(scopes.contains(&Scope::Repo(RepoScope {
1651
+
collection: RepoCollection::All,
1653
+
let mut actions = BTreeSet::new();
1654
+
actions.insert(RepoAction::Create);
1655
+
actions.insert(RepoAction::Update);
1656
+
actions.insert(RepoAction::Delete);
1663
+
fn test_openid_connect_scope_parsing() {
1664
+
// Test OpenID scope
1665
+
let scope = Scope::parse("openid").unwrap();
1666
+
assert_eq!(scope, Scope::OpenId);
1668
+
// Test Profile scope
1669
+
let scope = Scope::parse("profile").unwrap();
1670
+
assert_eq!(scope, Scope::Profile);
1672
+
// Test Email scope
1673
+
let scope = Scope::parse("email").unwrap();
1674
+
assert_eq!(scope, Scope::Email);
1676
+
// Test that they don't accept suffixes
1677
+
assert!(Scope::parse("openid:something").is_err());
1678
+
assert!(Scope::parse("profile:something").is_err());
1679
+
assert!(Scope::parse("email:something").is_err());
1681
+
// Test that they don't accept query parameters
1682
+
assert!(Scope::parse("openid?param=value").is_err());
1683
+
assert!(Scope::parse("profile?param=value").is_err());
1684
+
assert!(Scope::parse("email?param=value").is_err());
1688
+
fn test_openid_connect_scope_normalization() {
1689
+
let scope = Scope::parse("openid").unwrap();
1690
+
assert_eq!(scope.to_string_normalized(), "openid");
1692
+
let scope = Scope::parse("profile").unwrap();
1693
+
assert_eq!(scope.to_string_normalized(), "profile");
1695
+
let scope = Scope::parse("email").unwrap();
1696
+
assert_eq!(scope.to_string_normalized(), "email");
1700
+
fn test_openid_connect_scope_grants() {
1701
+
let openid = Scope::parse("openid").unwrap();
1702
+
let profile = Scope::parse("profile").unwrap();
1703
+
let email = Scope::parse("email").unwrap();
1704
+
let account = Scope::parse("account:email").unwrap();
1706
+
// OpenID Connect scopes only grant themselves
1707
+
assert!(openid.grants(&openid));
1708
+
assert!(!openid.grants(&profile));
1709
+
assert!(!openid.grants(&email));
1710
+
assert!(!openid.grants(&account));
1712
+
assert!(profile.grants(&profile));
1713
+
assert!(!profile.grants(&openid));
1714
+
assert!(!profile.grants(&email));
1715
+
assert!(!profile.grants(&account));
1717
+
assert!(email.grants(&email));
1718
+
assert!(!email.grants(&openid));
1719
+
assert!(!email.grants(&profile));
1720
+
assert!(!email.grants(&account));
1722
+
// Other scopes don't grant OpenID Connect scopes
1723
+
assert!(!account.grants(&openid));
1724
+
assert!(!account.grants(&profile));
1725
+
assert!(!account.grants(&email));
1729
+
fn test_parse_multiple_with_openid_connect() {
1730
+
let scopes = Scope::parse_multiple("openid profile email atproto").unwrap();
1731
+
assert_eq!(scopes.len(), 4);
1732
+
assert_eq!(scopes[0], Scope::OpenId);
1733
+
assert_eq!(scopes[1], Scope::Profile);
1734
+
assert_eq!(scopes[2], Scope::Email);
1735
+
assert_eq!(scopes[3], Scope::Atproto);
1737
+
// Test with mixed scopes
1738
+
let scopes = Scope::parse_multiple("openid account:email profile repo:*").unwrap();
1739
+
assert_eq!(scopes.len(), 4);
1740
+
assert!(scopes.contains(&Scope::OpenId));
1741
+
assert!(scopes.contains(&Scope::Profile));
1745
+
fn test_parse_multiple_reduced_with_openid_connect() {
1746
+
// OpenID Connect scopes don't grant each other, so no reduction
1747
+
let scopes = Scope::parse_multiple_reduced("openid profile email openid").unwrap();
1748
+
assert_eq!(scopes.len(), 3);
1749
+
assert!(scopes.contains(&Scope::OpenId));
1750
+
assert!(scopes.contains(&Scope::Profile));
1751
+
assert!(scopes.contains(&Scope::Email));
1753
+
// Mixed with other scopes
1754
+
let scopes = Scope::parse_multiple_reduced(
1755
+
"openid account:email account:email?action=manage profile",
1758
+
assert_eq!(scopes.len(), 3);
1759
+
assert!(scopes.contains(&Scope::OpenId));
1760
+
assert!(scopes.contains(&Scope::Profile));
1761
+
assert!(scopes.contains(&Scope::Account(AccountScope {
1762
+
resource: AccountResource::Email,
1763
+
action: AccountAction::Manage,
1768
+
fn test_serialize_multiple() {
1769
+
// Test empty list
1770
+
let scopes: Vec<Scope> = vec![];
1771
+
assert_eq!(Scope::serialize_multiple(&scopes), "");
1773
+
// Test single scope
1774
+
let scopes = vec![Scope::Atproto];
1775
+
assert_eq!(Scope::serialize_multiple(&scopes), "atproto");
1777
+
// Test multiple scopes - should be sorted alphabetically
1778
+
let scopes = vec![
1779
+
Scope::parse("repo:*").unwrap(),
1781
+
Scope::parse("account:email").unwrap(),
1784
+
Scope::serialize_multiple(&scopes),
1785
+
"account:email atproto repo:*"
1788
+
// Test that sorting is consistent regardless of input order
1789
+
let scopes = vec![
1790
+
Scope::parse("identity:handle").unwrap(),
1791
+
Scope::parse("blob:image/png").unwrap(),
1792
+
Scope::parse("account:repo?action=manage").unwrap(),
1795
+
Scope::serialize_multiple(&scopes),
1796
+
"account:repo?action=manage blob:image/png identity:handle"
1799
+
// Test with OpenID Connect scopes
1800
+
let scopes = vec![Scope::Email, Scope::OpenId, Scope::Profile, Scope::Atproto];
1802
+
Scope::serialize_multiple(&scopes),
1803
+
"atproto email openid profile"
1806
+
// Test with complex scopes including query parameters
1807
+
let scopes = vec![
1808
+
Scope::parse("rpc:com.example.service?aud=did:plc:yfvwmnlztr4dwkb7hwz55r2g&lxm=com.example.method")
1810
+
Scope::parse("repo:app.bsky.feed.post?action=create&action=update").unwrap(),
1811
+
Scope::parse("blob:image/*?accept=image/png&accept=image/jpeg").unwrap(),
1813
+
let result = Scope::serialize_multiple(&scopes);
1814
+
// The result should be sorted alphabetically
1815
+
// Note: RPC scope with query params is serialized as "rpc?aud=...&lxm=..."
1816
+
assert!(result.starts_with("blob:"));
1817
+
assert!(result.contains(" repo:"));
1819
+
result.contains("rpc?aud=did:plc:yfvwmnlztr4dwkb7hwz55r2g&lxm=com.example.service")
1822
+
// Test with transition scopes
1823
+
let scopes = vec![
1824
+
Scope::Transition(TransitionScope::Email),
1825
+
Scope::Transition(TransitionScope::Generic),
1829
+
Scope::serialize_multiple(&scopes),
1830
+
"atproto transition:email transition:generic"
1833
+
// Test duplicates - they remain in the output (caller's responsibility to dedupe if needed)
1834
+
let scopes = vec![
1837
+
Scope::parse("account:email").unwrap(),
1840
+
Scope::serialize_multiple(&scopes),
1841
+
"account:email atproto atproto"
1844
+
// Test normalization is preserved in serialization
1845
+
let scopes = vec![Scope::parse("blob?accept=image/png&accept=image/jpeg").unwrap()];
1846
+
// Should normalize query parameters alphabetically
1848
+
Scope::serialize_multiple(&scopes),
1849
+
"blob?accept=image/jpeg&accept=image/png"
1854
+
fn test_serialize_multiple_roundtrip() {
1855
+
// Test that parse_multiple and serialize_multiple are inverses (when sorted)
1856
+
let original = "account:email atproto blob:image/png identity:handle repo:*";
1857
+
let scopes = Scope::parse_multiple(original).unwrap();
1858
+
let serialized = Scope::serialize_multiple(&scopes);
1859
+
assert_eq!(serialized, original);
1861
+
// Test with complex scopes
1862
+
let original = "account:repo?action=manage blob?accept=image/jpeg&accept=image/png rpc:*";
1863
+
let scopes = Scope::parse_multiple(original).unwrap();
1864
+
let serialized = Scope::serialize_multiple(&scopes);
1865
+
// Parse again to verify it's valid
1866
+
let reparsed = Scope::parse_multiple(&serialized).unwrap();
1867
+
assert_eq!(scopes, reparsed);
1869
+
// Test with OpenID Connect scopes
1870
+
let original = "email openid profile";
1871
+
let scopes = Scope::parse_multiple(original).unwrap();
1872
+
let serialized = Scope::serialize_multiple(&scopes);
1873
+
assert_eq!(serialized, original);
1877
+
fn test_remove_scope() {
1878
+
// Test removing a scope that exists
1879
+
let scopes = vec![
1880
+
Scope::parse("repo:*").unwrap(),
1882
+
Scope::parse("account:email").unwrap(),
1884
+
let to_remove = Scope::Atproto;
1885
+
let result = Scope::remove_scope(&scopes, &to_remove);
1886
+
assert_eq!(result.len(), 2);
1887
+
assert!(!result.contains(&to_remove));
1888
+
assert!(result.contains(&Scope::parse("repo:*").unwrap()));
1889
+
assert!(result.contains(&Scope::parse("account:email").unwrap()));
1891
+
// Test removing a scope that doesn't exist
1892
+
let scopes = vec![
1893
+
Scope::parse("repo:*").unwrap(),
1894
+
Scope::parse("account:email").unwrap(),
1896
+
let to_remove = Scope::parse("identity:handle").unwrap();
1897
+
let result = Scope::remove_scope(&scopes, &to_remove);
1898
+
assert_eq!(result.len(), 2);
1899
+
assert_eq!(result, scopes);
1901
+
// Test removing from empty list
1902
+
let scopes: Vec<Scope> = vec![];
1903
+
let to_remove = Scope::Atproto;
1904
+
let result = Scope::remove_scope(&scopes, &to_remove);
1905
+
assert_eq!(result.len(), 0);
1907
+
// Test removing all instances of a duplicate scope
1908
+
let scopes = vec![
1910
+
Scope::parse("account:email").unwrap(),
1912
+
Scope::parse("repo:*").unwrap(),
1915
+
let to_remove = Scope::Atproto;
1916
+
let result = Scope::remove_scope(&scopes, &to_remove);
1917
+
assert_eq!(result.len(), 2);
1918
+
assert!(!result.contains(&to_remove));
1919
+
assert!(result.contains(&Scope::parse("account:email").unwrap()));
1920
+
assert!(result.contains(&Scope::parse("repo:*").unwrap()));
1922
+
// Test removing complex scopes with query parameters
1923
+
let scopes = vec![
1924
+
Scope::parse("account:email?action=manage").unwrap(),
1925
+
Scope::parse("blob?accept=image/png&accept=image/jpeg").unwrap(),
1926
+
Scope::parse("rpc:com.example.service?aud=did:example:123").unwrap(),
1928
+
let to_remove = Scope::parse("blob?accept=image/jpeg&accept=image/png").unwrap(); // Note: normalized order
1929
+
let result = Scope::remove_scope(&scopes, &to_remove);
1930
+
assert_eq!(result.len(), 2);
1931
+
assert!(!result.contains(&to_remove));
1933
+
// Test with OpenID Connect scopes
1934
+
let scopes = vec![Scope::OpenId, Scope::Profile, Scope::Email, Scope::Atproto];
1935
+
let to_remove = Scope::Profile;
1936
+
let result = Scope::remove_scope(&scopes, &to_remove);
1937
+
assert_eq!(result.len(), 3);
1938
+
assert!(!result.contains(&to_remove));
1939
+
assert!(result.contains(&Scope::OpenId));
1940
+
assert!(result.contains(&Scope::Email));
1941
+
assert!(result.contains(&Scope::Atproto));
1943
+
// Test with transition scopes
1944
+
let scopes = vec![
1945
+
Scope::Transition(TransitionScope::Generic),
1946
+
Scope::Transition(TransitionScope::Email),
1949
+
let to_remove = Scope::Transition(TransitionScope::Email);
1950
+
let result = Scope::remove_scope(&scopes, &to_remove);
1951
+
assert_eq!(result.len(), 2);
1952
+
assert!(!result.contains(&to_remove));
1953
+
assert!(result.contains(&Scope::Transition(TransitionScope::Generic)));
1954
+
assert!(result.contains(&Scope::Atproto));
1956
+
// Test that only exact matches are removed
1957
+
let scopes = vec![
1958
+
Scope::parse("account:email").unwrap(),
1959
+
Scope::parse("account:email?action=manage").unwrap(),
1960
+
Scope::parse("account:repo").unwrap(),
1962
+
let to_remove = Scope::parse("account:email").unwrap();
1963
+
let result = Scope::remove_scope(&scopes, &to_remove);
1964
+
assert_eq!(result.len(), 2);
1965
+
assert!(!result.contains(&Scope::parse("account:email").unwrap()));
1966
+
assert!(result.contains(&Scope::parse("account:email?action=manage").unwrap()));
1967
+
assert!(result.contains(&Scope::parse("account:repo").unwrap()));