A better Rust ATProto crate
99
fork

Configure Feed

Select the types of activity you want to include in your feed.

[jacquard] migrate Vec<Scope<S>> to Scopes<S> container

- Replace all Vec<Scope<S>> fields with Scopes<S> across jacquard-oauth
- Migrate OAuthState/OAuthSession persistence format (Vec<String> → String)
- Migrate main crate consumers and integration tests
- Retire old utility methods (parse_multiple, serialize_multiple, remove_scope)
- Use expect instead of unwrap_or_default for scope parsing in token refresh

+88 -735
+26 -28
crates/jacquard-oauth/src/atproto.rs
··· 1 1 use std::str::FromStr; 2 2 3 3 use crate::types::OAuthClientMetadata; 4 - use crate::{keyset::Keyset, scopes::Scope}; 4 + use crate::{keyset::Keyset, scopes::{Scope, Scopes}}; 5 5 use jacquard_common::deps::fluent_uri::Uri; 6 6 use jacquard_common::{BosStr, IntoStatic}; 7 7 use serde::{Deserialize, Serialize}; ··· 145 145 /// The grant types this client will use. 146 146 pub grant_types: Vec<GrantType>, 147 147 /// The OAuth scopes this client requests; must include `atproto`. 148 - pub scopes: Vec<Scope<S>>, 148 + pub scopes: Scopes<S>, 149 149 /// URI pointing to the client's JWK Set; mutually exclusive with inline `jwks`. 150 150 pub jwks_uri: Option<Uri<String>>, 151 151 /// Human-readable display name for the client. ··· 160 160 161 161 impl<S> IntoStatic for AtprotoClientMetadata<S> 162 162 where 163 - S: BosStr + IntoStatic + Ord + FromStr, 163 + S: BosStr + IntoStatic + Ord + FromStr + AsRef<str>, 164 164 <S as FromStr>::Err: core::fmt::Debug, 165 - S::Output: BosStr + FromStr + Ord, 165 + S::Output: BosStr + FromStr + Ord + AsRef<str>, 166 166 <S::Output as FromStr>::Err: core::fmt::Debug, 167 167 { 168 168 type Output = AtprotoClientMetadata<S::Output>; ··· 212 212 /// This is a convenience constructor for local development and CLI tools. The resulting 213 213 /// metadata uses `http://localhost` as the `client_id` with both IPv4 and IPv6 loopback 214 214 /// redirect URIs. 215 - pub fn default_localhost() -> Self { 216 - Self::new_localhost( 217 - None, 218 - Some(vec![ 219 - Scope::Atproto, 220 - Scope::Transition(crate::scopes::TransitionScope::Generic), 221 - ]), 222 - ) 215 + pub fn default_localhost() -> Self 216 + where 217 + S: From<SmolStr> + AsRef<str>, 218 + { 219 + let scopes = Scopes::new(SmolStr::new_static("atproto transition:generic")) 220 + .expect("valid scopes") 221 + .convert(); 222 + Self::new_localhost(None, Some(scopes)) 223 223 } 224 224 225 225 /// Create loopback client metadata with optional custom redirect URIs and scopes. ··· 230 230 /// are used. 231 231 pub fn new_localhost( 232 232 redirect_uris: Option<Vec<Uri<String>>>, 233 - scopes: Option<Vec<Scope<S>>>, 234 - ) -> AtprotoClientMetadata<S> { 233 + scopes: Option<Scopes<S>>, 234 + ) -> AtprotoClientMetadata<S> 235 + where 236 + S: From<SmolStr> + AsRef<str>, 237 + { 235 238 // determine client_id 236 239 #[derive(serde::Serialize)] 237 240 struct Parameters { ··· 247 250 }); 248 251 let query = serde_html_form::to_string(Parameters { 249 252 redirect_uri: redir_str, 250 - scope: scopes 251 - .as_ref() 252 - .map(|s| SmolStr::from(Scope::serialize_multiple(s.as_slice()).as_str())), 253 + scope: scopes.as_ref().map(|s| s.to_normalized_string()), 253 254 }) 254 255 .ok(); 255 256 let mut client_id = String::from("http://localhost/"); ··· 258 259 { 259 260 client_id.push_str(&format!("?{query}")); 260 261 } 262 + let default_scopes: Scopes<S> = Scopes::new(SmolStr::new_static("atproto")) 263 + .expect("valid scopes") 264 + .convert(); 261 265 AtprotoClientMetadata { 262 266 client_id: Uri::parse(client_id).unwrap(), 263 267 client_uri: None, ··· 266 270 Uri::parse("http://[::1]".to_string()).unwrap(), 267 271 ]), 268 272 grant_types: vec![GrantType::AuthorizationCode, GrantType::RefreshToken], 269 - scopes: scopes.unwrap_or(vec![Scope::Atproto]), 273 + scopes: scopes.unwrap_or(default_scopes), 270 274 jwks_uri: None, 271 275 client_name: None, 272 276 logo_uri: None, ··· 303 307 if !metadata.grant_types.contains(&GrantType::AuthorizationCode) { 304 308 return Err(Error::InvalidGrantTypes); 305 309 } 306 - if !metadata.scopes.contains(&Scope::Atproto) { 310 + if !metadata.scopes.grants(&Scope::<S>::Atproto) { 307 311 return Err(Error::InvalidScope); 308 312 } 309 313 let (auth_method, jwks_uri, jwks) = if let Some(keyset) = keyset { ··· 342 346 ), 343 347 response_types: vec![S::from_static("code")], 344 348 scope: Some( 345 - S::from_str(Scope::serialize_multiple(metadata.scopes.as_slice()).as_str()).unwrap(), 349 + S::from_str(metadata.scopes.to_normalized_string().as_str()).unwrap(), 346 350 ), 347 351 dpop_bound_access_tokens: Some(true), 348 352 jwks_uri, ··· 370 374 371 375 #[cfg(test)] 372 376 mod tests { 373 - use crate::scopes::TransitionScope; 374 - 375 377 use super::*; 376 378 use elliptic_curve::SecretKey; 377 379 use jose_jwk::{Jwk, Key, Parameters}; ··· 424 426 Uri::parse("http://127.0.0.1/callback".to_string()).unwrap(), 425 427 Uri::parse("http://[::1]/callback".to_string()).unwrap(), 426 428 ]), 427 - Some(vec![ 428 - Scope::Atproto, 429 - Scope::Transition(TransitionScope::Generic), 430 - Scope::parse("account:email").unwrap() 431 - ]) 429 + Some(Scopes::new(SmolStr::from("account:email atproto transition:generic")).unwrap()) 432 430 ), 433 431 &None 434 432 ) ··· 586 584 client_uri: Some(Uri::parse("https://example.com".to_string()).unwrap()), 587 585 redirect_uris: vec![Uri::parse("https://example.com/callback".to_string()).unwrap()], 588 586 grant_types: vec![GrantType::AuthorizationCode], 589 - scopes: vec![Scope::Atproto], 587 + scopes: Scopes::new(SmolStr::new_static("atproto")).unwrap(), 590 588 jwks_uri: None, 591 589 client_name: None, 592 590 logo_uri: None,
+4 -5
crates/jacquard-oauth/src/client.rs
··· 5 5 error::{CallbackError, Result}, 6 6 request::{OAuthMetadata, exchange_code, par}, 7 7 resolver::OAuthResolver, 8 - scopes::Scope, 8 + scopes::Scopes, 9 9 session::{ClientData, ClientSessionData, DpopClientData, SessionRegistry}, 10 10 types::{AuthorizeOptions, CallbackParams}, 11 11 }; ··· 291 291 { 292 292 Ok(token_set) => { 293 293 let scopes = if let Some(scope) = &token_set.scope { 294 - Scope::<SmolStr>::parse_multiple_reduced(scope.as_str()) 295 - .expect("Failed to parse scopes") 296 - .into_static() 294 + Scopes::new(SmolStr::from(scope.as_str())) 295 + .expect("Failed to parse scopes from token response") 297 296 } else { 298 - vec![] 297 + Scopes::empty() 299 298 }; 300 299 let client_data = ClientSessionData { 301 300 account_did: token_set.sub.clone(),
+1 -9
crates/jacquard-oauth/src/loopback.rs
··· 260 260 let redirect = Uri::parse(redirect_uri).unwrap(); 261 261 262 262 let scopes = if opts.scopes.is_empty() { 263 - Some( 264 - self.registry 265 - .client_data 266 - .config 267 - .scopes 268 - .iter() 269 - .cloned() 270 - .collect(), 271 - ) 263 + Some(self.registry.client_data.config.scopes.clone()) 272 264 } else { 273 265 Some(opts.scopes.clone()) 274 266 };
+5 -6
crates/jacquard-oauth/src/request.rs
··· 3 3 use chrono::{TimeDelta, Utc}; 4 4 use http::{Method, Request, StatusCode}; 5 5 use jacquard_common::{ 6 - CowStr, IntoStatic, 6 + CowStr, 7 7 bos::{BosStr, DefaultStr}, 8 8 http_client::HttpClient, 9 9 session::SessionStoreError, ··· 26 26 jose::jwt::{RegisteredClaims, RegisteredClaimsAud}, 27 27 keyset::Keyset, 28 28 resolver::OAuthResolver, 29 - scopes::Scope, 29 + scopes::Scopes, 30 30 session::{ 31 31 AuthRequestData, ClientData, ClientSessionData, DpopClientData, DpopDataSource, DpopReqData, 32 32 }, ··· 577 577 .await?; 578 578 579 579 let scopes = if let Some(scope) = &metadata.client_metadata.scope { 580 - Scope::<SmolStr>::parse_multiple_reduced(scope.as_ref()) 580 + Scopes::new(SmolStr::from(scope.as_ref())) 581 581 .expect("Failed to parse scopes") 582 - .into_static() 583 582 } else { 584 - vec![] 583 + Scopes::empty() 585 584 }; 586 585 let auth_req_data: AuthRequestData = AuthRequestData { 587 586 state: state.into(), ··· 1081 1080 authserver_url: SmolStr::new_static("https://issuer"), 1082 1081 authserver_token_endpoint: SmolStr::new_static("https://issuer/token"), 1083 1082 authserver_revocation_endpoint: None, 1084 - scopes: vec![], 1083 + scopes: Scopes::empty(), 1085 1084 dpop_data: DpopClientData { 1086 1085 dpop_key: crate::utils::generate_key(&[SmolStr::new_static("ES256")]).unwrap(), 1087 1086 dpop_authserver_nonce: SmolStr::default(),
+5 -629
crates/jacquard-oauth/src/scopes.rs
··· 33 33 use serde::de::{Error as DeError, Visitor}; 34 34 use serde::{Deserialize, Serialize}; 35 35 use smallvec::SmallVec; 36 - use smol_str::{SmolStr, SmolStrBuilder, ToSmolStr, format_smolstr}; 36 + use smol_str::{SmolStr, ToSmolStr, format_smolstr}; 37 37 38 38 /// Represents an AT Protocol OAuth scope 39 39 #[derive(Debug, Clone, PartialEq, Eq, Hash)] ··· 514 514 // ============================================================================ 515 515 516 516 /// Byte-range indices for a single scope within a `Scopes` buffer. 517 - #[derive(Debug, Clone)] 517 + #[derive(Debug, Clone, PartialEq, Eq)] 518 518 pub(crate) struct ScopeIndices { 519 519 pub(crate) start: u16, 520 520 pub(crate) end: u16, ··· 522 522 } 523 523 524 524 /// Pre-parsed structure of a scope, storing only byte-range indices into the buffer. 525 - #[derive(Debug, Clone)] 525 + #[derive(Debug, Clone, PartialEq, Eq)] 526 526 pub(crate) enum ScopeInnerIndices { 527 527 Account { 528 528 resource: AccountResource, ··· 604 604 /// Both variants store byte ranges into the buffer. The discriminant 605 605 /// tells `grants()` whether to decode before comparing, and tells 606 606 /// `to_string_normalized()` whether the raw form needs encoding. 607 - #[derive(Debug, Clone)] 607 + #[derive(Debug, Clone, PartialEq, Eq)] 608 608 pub(crate) enum IncludeAudience { 609 609 /// Audience in buffer is already decoded (no percent-encoding). 610 610 /// `grants()` can compare directly. Serialisation must encode `#` → `%23`. ··· 659 659 /// Owns or borrows a single scope string and stores pre-computed byte-range 660 660 /// indices. Typed `Scope<&str>` views are reconstructed on demand from the 661 661 /// shared buffer. 662 - #[derive(Debug, Clone)] 662 + #[derive(Debug, Clone, PartialEq, Eq)] 663 663 pub struct Scopes<S: Bos<str> + AsRef<str> = DefaultStr> { 664 664 buffer: S, 665 665 indices: Vec<ScopeIndices>, ··· 1590 1590 } 1591 1591 } 1592 1592 1593 - /// Parse multiple space-separated scopes from a string 1594 - /// 1595 - /// # Examples 1596 - /// ``` 1597 - /// # use jacquard_oauth::scopes::Scope; 1598 - /// # use smol_str::SmolStr; 1599 - /// let scopes = Scope::<SmolStr>::parse_multiple("atproto repo:*").unwrap(); 1600 - /// assert_eq!(scopes.len(), 2); 1601 - /// ``` 1602 - pub fn parse_multiple<'a>(s: &'a str) -> Result<Vec<Self>, ParseError> 1603 - where 1604 - S: FromStr, 1605 - <S as FromStr>::Err: core::fmt::Debug, 1606 - { 1607 - if s.trim().is_empty() { 1608 - return Ok(Vec::new()); 1609 - } 1610 1593 1611 - let mut scopes = Vec::new(); 1612 - for scope_str in s.split_whitespace() { 1613 - scopes.push(Self::parse(scope_str)?); 1614 - } 1615 - 1616 - Ok(scopes) 1617 - } 1618 - 1619 - /// Parse multiple space-separated scopes and return the minimal set needed 1620 - /// 1621 - /// This method removes duplicate scopes and scopes that are already granted 1622 - /// by other scopes in the list, returning only the minimal set of scopes needed. 1623 - /// 1624 - /// # Examples 1625 - /// ``` 1626 - /// # use jacquard_oauth::scopes::Scope; 1627 - /// # use smol_str::SmolStr; 1628 - /// // repo:* grants repo:foo.bar, so only repo:* is kept 1629 - /// let scopes = Scope::<SmolStr>::parse_multiple_reduced("atproto repo:app.bsky.feed.post repo:*").unwrap(); 1630 - /// assert_eq!(scopes.len(), 2); // atproto and repo:* 1631 - /// ``` 1632 - pub fn parse_multiple_reduced<'a>(s: &'a str) -> Result<Vec<Self>, ParseError> 1633 - where 1634 - S: FromStr, 1635 - <S as FromStr>::Err: core::fmt::Debug, 1636 - { 1637 - let all_scopes = Self::parse_multiple(s)?; 1638 - 1639 - if all_scopes.is_empty() { 1640 - return Ok(Vec::new()); 1641 - } 1642 - 1643 - let mut result: Vec<Self> = Vec::new(); 1644 - 1645 - for scope in all_scopes { 1646 - // Check if this scope is already granted by something in the result 1647 - let mut is_granted = false; 1648 - for existing in &result { 1649 - if existing.grants(&scope) && existing != &scope { 1650 - is_granted = true; 1651 - break; 1652 - } 1653 - } 1654 - 1655 - if is_granted { 1656 - continue; // Skip this scope, it's already covered 1657 - } 1658 - 1659 - // Check if this scope grants any existing scopes in the result 1660 - let mut indices_to_remove = Vec::new(); 1661 - for (i, existing) in result.iter().enumerate() { 1662 - if scope.grants(existing) && &scope != existing { 1663 - indices_to_remove.push(i); 1664 - } 1665 - } 1666 - 1667 - // Remove scopes that are granted by the new scope (in reverse order to maintain indices) 1668 - for i in indices_to_remove.into_iter().rev() { 1669 - result.remove(i); 1670 - } 1671 - 1672 - // Add the new scope if it's not a duplicate 1673 - if !result.contains(&scope) { 1674 - result.push(scope); 1675 - } 1676 - } 1677 - 1678 - Ok(result) 1679 - } 1680 - 1681 - /// Serialize a list of scopes into a space-separated OAuth scopes string 1682 - /// 1683 - /// The scopes are sorted alphabetically by their string representation to ensure 1684 - /// consistent output regardless of input order. 1685 - /// 1686 - /// # Examples 1687 - /// ``` 1688 - /// # use jacquard_oauth::scopes::Scope; 1689 - /// # use smol_str::SmolStr; 1690 - /// let scopes = vec![ 1691 - /// Scope::<SmolStr>::parse("repo:*").unwrap(), 1692 - /// Scope::<SmolStr>::parse("atproto").unwrap(), 1693 - /// Scope::<SmolStr>::parse("account:email").unwrap(), 1694 - /// ]; 1695 - /// let result = Scope::serialize_multiple(&scopes); 1696 - /// assert_eq!(result, "account:email atproto repo:*"); 1697 - /// ``` 1698 - pub fn serialize_multiple(scopes: &[Self]) -> SmolStr { 1699 - if scopes.is_empty() { 1700 - return SmolStr::new_static(""); 1701 - } 1702 - 1703 - let mut serialized: Vec<SmolStr> = scopes 1704 - .iter() 1705 - .map(|scope| scope.to_string_normalized()) 1706 - .collect(); 1707 - 1708 - serialized.sort(); 1709 - let mut builder = SmolStrBuilder::new(); 1710 - for (i, scope) in serialized.iter().enumerate() { 1711 - if i > 0 { 1712 - builder.push_str(" "); 1713 - } 1714 - builder.push_str(scope); 1715 - } 1716 - builder.finish() 1717 - } 1718 - 1719 - /// Remove a scope from a list of scopes 1720 - /// 1721 - /// Returns a new vector with all instances of the specified scope removed. 1722 - /// If the scope doesn't exist in the list, returns a copy of the original list. 1723 - /// 1724 - /// # Examples 1725 - /// ``` 1726 - /// # use jacquard_oauth::scopes::Scope; 1727 - /// # use smol_str::SmolStr; 1728 - /// let scopes = vec![ 1729 - /// Scope::<SmolStr>::parse("repo:*").unwrap(), 1730 - /// Scope::<SmolStr>::parse("atproto").unwrap(), 1731 - /// Scope::<SmolStr>::parse("account:email").unwrap(), 1732 - /// ]; 1733 - /// let to_remove = Scope::<SmolStr>::parse("atproto").unwrap(); 1734 - /// let result = Scope::remove_scope(&scopes, &to_remove); 1735 - /// assert_eq!(result.len(), 2); 1736 - /// assert!(!result.contains(&to_remove)); 1737 - /// ``` 1738 - pub fn remove_scope(scopes: &[Self], scope_to_remove: &Self) -> Vec<Self> 1739 - where 1740 - S: Clone, 1741 - { 1742 - scopes 1743 - .iter() 1744 - .filter(|s| *s != scope_to_remove) 1745 - .cloned() 1746 - .collect() 1747 - } 1748 1594 1749 1595 /// Parse a scope from a string 1750 1596 pub fn parse<'a>(s: &'a str) -> Result<Self, ParseError> ··· 2970 2816 } 2971 2817 2972 2818 #[test] 2973 - fn test_parse_multiple() { 2974 - // Test parsing multiple scopes 2975 - let scopes = Scope::<SmolStr>::parse_multiple("atproto repo:*").unwrap(); 2976 - assert_eq!(scopes.len(), 2); 2977 - assert_eq!(scopes[0], Scope::Atproto); 2978 - assert_eq!( 2979 - scopes[1], 2980 - Scope::Repo(RepoScope { 2981 - collection: RepoCollection::All, 2982 - actions: { 2983 - let mut actions = BTreeSet::new(); 2984 - actions.insert(RepoAction::Create); 2985 - actions.insert(RepoAction::Update); 2986 - actions.insert(RepoAction::Delete); 2987 - actions 2988 - } 2989 - }) 2990 - ); 2991 - 2992 - // Test with more scopes 2993 - let scopes = 2994 - Scope::<SmolStr>::parse_multiple("account:email identity:handle blob:image/png") 2995 - .unwrap(); 2996 - assert_eq!(scopes.len(), 3); 2997 - assert!(matches!(scopes[0], Scope::Account(_))); 2998 - assert!(matches!(scopes[1], Scope::Identity(_))); 2999 - assert!(matches!(scopes[2], Scope::Blob(_))); 3000 - 3001 - // Test with complex scopes 3002 - let scopes = Scope::<SmolStr>::parse_multiple( 3003 - "account:email?action=manage repo:app.bsky.feed.post?action=create transition:email", 3004 - ) 3005 - .unwrap(); 3006 - assert_eq!(scopes.len(), 3); 3007 - 3008 - // Test empty string 3009 - let scopes = Scope::<SmolStr>::parse_multiple("").unwrap(); 3010 - assert_eq!(scopes.len(), 0); 3011 - 3012 - // Test whitespace only 3013 - let scopes = Scope::<SmolStr>::parse_multiple(" ").unwrap(); 3014 - assert_eq!(scopes.len(), 0); 3015 - 3016 - // Test with extra whitespace 3017 - let scopes = Scope::<SmolStr>::parse_multiple(" atproto repo:* ").unwrap(); 3018 - assert_eq!(scopes.len(), 2); 3019 - 3020 - // Test single scope 3021 - let scopes = Scope::<SmolStr>::parse_multiple("atproto").unwrap(); 3022 - assert_eq!(scopes.len(), 1); 3023 - assert_eq!(scopes[0], Scope::Atproto); 3024 - 3025 - // Test error propagation 3026 - assert!(Scope::<SmolStr>::parse_multiple("atproto invalid:scope").is_err()); 3027 - assert!(Scope::<SmolStr>::parse_multiple("account:invalid repo:*").is_err()); 3028 - } 3029 - 3030 - #[test] 3031 - fn test_parse_multiple_reduced() { 3032 - // Test repo scope reduction - wildcard grants specific 3033 - let scopes = 3034 - Scope::<SmolStr>::parse_multiple_reduced("atproto repo:app.bsky.feed.post repo:*") 3035 - .unwrap(); 3036 - assert_eq!(scopes.len(), 2); 3037 - assert!(scopes.contains(&Scope::Atproto)); 3038 - assert!(scopes.contains(&Scope::Repo(RepoScope { 3039 - collection: RepoCollection::All, 3040 - actions: { 3041 - let mut actions = BTreeSet::new(); 3042 - actions.insert(RepoAction::Create); 3043 - actions.insert(RepoAction::Update); 3044 - actions.insert(RepoAction::Delete); 3045 - actions 3046 - } 3047 - }))); 3048 - 3049 - // Test reverse order - should get same result 3050 - let scopes = 3051 - Scope::<SmolStr>::parse_multiple_reduced("atproto repo:* repo:app.bsky.feed.post") 3052 - .unwrap(); 3053 - assert_eq!(scopes.len(), 2); 3054 - assert!(scopes.contains(&Scope::Atproto)); 3055 - assert!(scopes.contains(&Scope::Repo(RepoScope { 3056 - collection: RepoCollection::All, 3057 - actions: { 3058 - let mut actions = BTreeSet::new(); 3059 - actions.insert(RepoAction::Create); 3060 - actions.insert(RepoAction::Update); 3061 - actions.insert(RepoAction::Delete); 3062 - actions 3063 - } 3064 - }))); 3065 - 3066 - // Test account scope reduction - manage grants read 3067 - let scopes = 3068 - Scope::<SmolStr>::parse_multiple_reduced("account:email account:email?action=manage") 3069 - .unwrap(); 3070 - assert_eq!(scopes.len(), 1); 3071 - assert_eq!( 3072 - scopes[0], 3073 - Scope::Account(AccountScope { 3074 - resource: AccountResource::Email, 3075 - action: AccountAction::Manage, 3076 - }) 3077 - ); 3078 - 3079 - // Test identity scope reduction - wildcard grants specific 3080 - let scopes = 3081 - Scope::<SmolStr>::parse_multiple_reduced("identity:handle identity:*").unwrap(); 3082 - assert_eq!(scopes.len(), 1); 3083 - assert_eq!(scopes[0], Scope::Identity(IdentityScope::All)); 3084 - 3085 - // Test blob scope reduction - wildcard grants specific 3086 - let scopes = 3087 - Scope::<SmolStr>::parse_multiple_reduced("blob:image/png blob:image/* blob:*/*") 3088 - .unwrap(); 3089 - assert_eq!(scopes.len(), 1); 3090 - let mut accept = BTreeSet::new(); 3091 - accept.insert(MimePattern::All); 3092 - assert_eq!(scopes[0], Scope::Blob(BlobScope { accept })); 3093 - 3094 - // Test no reduction needed - different scope types 3095 - let scopes = Scope::<SmolStr>::parse_multiple_reduced( 3096 - "account:email identity:handle blob:image/png", 3097 - ) 3098 - .unwrap(); 3099 - assert_eq!(scopes.len(), 3); 3100 - 3101 - // Test repo action reduction 3102 - let scopes = Scope::<SmolStr>::parse_multiple_reduced( 3103 - "repo:app.bsky.feed.post?action=create repo:app.bsky.feed.post", 3104 - ) 3105 - .unwrap(); 3106 - assert_eq!(scopes.len(), 1); 3107 - assert_eq!( 3108 - scopes[0], 3109 - Scope::Repo(RepoScope { 3110 - collection: RepoCollection::Nsid(Nsid::new_owned("app.bsky.feed.post").unwrap()), 3111 - actions: { 3112 - let mut actions = BTreeSet::new(); 3113 - actions.insert(RepoAction::Create); 3114 - actions.insert(RepoAction::Update); 3115 - actions.insert(RepoAction::Delete); 3116 - actions 3117 - } 3118 - }) 3119 - ); 3120 - 3121 - // Test RPC scope reduction 3122 - let scopes = Scope::<SmolStr>::parse_multiple_reduced( 3123 - "rpc:com.example.service?aud=did:example:123 rpc:com.example.service rpc:*", 3124 - ) 3125 - .unwrap(); 3126 - assert_eq!(scopes.len(), 1); 3127 - assert_eq!( 3128 - scopes[0], 3129 - Scope::Rpc(RpcScope { 3130 - lxm: { 3131 - let mut lxm = BTreeSet::new(); 3132 - lxm.insert(RpcLexicon::All); 3133 - lxm 3134 - }, 3135 - aud: { 3136 - let mut aud = BTreeSet::new(); 3137 - aud.insert(RpcAudience::All); 3138 - aud 3139 - } 3140 - }) 3141 - ); 3142 - 3143 - // Test duplicate removal 3144 - let scopes = Scope::<SmolStr>::parse_multiple_reduced("atproto atproto atproto").unwrap(); 3145 - assert_eq!(scopes.len(), 1); 3146 - assert_eq!(scopes[0], Scope::Atproto); 3147 - 3148 - // Test transition scopes - only grant themselves 3149 - let scopes = 3150 - Scope::<SmolStr>::parse_multiple_reduced("transition:generic transition:email") 3151 - .unwrap(); 3152 - assert_eq!(scopes.len(), 2); 3153 - assert!(scopes.contains(&Scope::Transition(TransitionScope::Generic))); 3154 - assert!(scopes.contains(&Scope::Transition(TransitionScope::Email))); 3155 - 3156 - // Test empty input 3157 - let scopes = Scope::<SmolStr>::parse_multiple_reduced("").unwrap(); 3158 - assert_eq!(scopes.len(), 0); 3159 - 3160 - // Test complex scenario with multiple reductions 3161 - let scopes = Scope::<SmolStr>::parse_multiple_reduced( 3162 - "account:email?action=manage account:email account:repo account:repo?action=read identity:* identity:handle" 3163 - ).unwrap(); 3164 - assert_eq!(scopes.len(), 3); 3165 - // Should have: account:email?action=manage, account:repo, identity:* 3166 - assert!(scopes.contains(&Scope::Account(AccountScope { 3167 - resource: AccountResource::Email, 3168 - action: AccountAction::Manage, 3169 - }))); 3170 - assert!(scopes.contains(&Scope::Account(AccountScope { 3171 - resource: AccountResource::Repo, 3172 - action: AccountAction::Read, 3173 - }))); 3174 - assert!(scopes.contains(&Scope::Identity(IdentityScope::All))); 3175 - 3176 - // Test that atproto doesn't grant other scopes (per recent change) 3177 - let scopes = 3178 - Scope::<SmolStr>::parse_multiple_reduced("atproto account:email repo:*").unwrap(); 3179 - assert_eq!(scopes.len(), 3); 3180 - assert!(scopes.contains(&Scope::Atproto)); 3181 - assert!(scopes.contains(&Scope::Account(AccountScope { 3182 - resource: AccountResource::Email, 3183 - action: AccountAction::Read, 3184 - }))); 3185 - assert!(scopes.contains(&Scope::Repo(RepoScope { 3186 - collection: RepoCollection::All, 3187 - actions: { 3188 - let mut actions = BTreeSet::new(); 3189 - actions.insert(RepoAction::Create); 3190 - actions.insert(RepoAction::Update); 3191 - actions.insert(RepoAction::Delete); 3192 - actions 3193 - } 3194 - }))); 3195 - } 3196 - 3197 - #[test] 3198 2819 fn test_openid_connect_scope_parsing() { 3199 2820 // Test OpenID scope 3200 2821 let scope = Scope::<SmolStr>::parse("openid").unwrap(); ··· 3258 2879 assert!(!account.grants(&openid)); 3259 2880 assert!(!account.grants(&profile)); 3260 2881 assert!(!account.grants(&email)); 3261 - } 3262 - 3263 - #[test] 3264 - fn test_parse_multiple_with_openid_connect() { 3265 - let scopes = Scope::<SmolStr>::parse_multiple("openid profile email atproto").unwrap(); 3266 - assert_eq!(scopes.len(), 4); 3267 - assert_eq!(scopes[0], Scope::OpenId); 3268 - assert_eq!(scopes[1], Scope::Profile); 3269 - assert_eq!(scopes[2], Scope::Email); 3270 - assert_eq!(scopes[3], Scope::Atproto); 3271 - 3272 - // Test with mixed scopes 3273 - let scopes = 3274 - Scope::<SmolStr>::parse_multiple("openid account:email profile repo:*").unwrap(); 3275 - assert_eq!(scopes.len(), 4); 3276 - assert!(scopes.contains(&Scope::OpenId)); 3277 - assert!(scopes.contains(&Scope::Profile)); 3278 - } 3279 - 3280 - #[test] 3281 - fn test_parse_multiple_reduced_with_openid_connect() { 3282 - // OpenID Connect scopes don't grant each other, so no reduction 3283 - let scopes = 3284 - Scope::<SmolStr>::parse_multiple_reduced("openid profile email openid").unwrap(); 3285 - assert_eq!(scopes.len(), 3); 3286 - assert!(scopes.contains(&Scope::OpenId)); 3287 - assert!(scopes.contains(&Scope::Profile)); 3288 - assert!(scopes.contains(&Scope::Email)); 3289 - 3290 - // Mixed with other scopes 3291 - let scopes = Scope::<SmolStr>::parse_multiple_reduced( 3292 - "openid account:email account:email?action=manage profile", 3293 - ) 3294 - .unwrap(); 3295 - assert_eq!(scopes.len(), 3); 3296 - assert!(scopes.contains(&Scope::OpenId)); 3297 - assert!(scopes.contains(&Scope::Profile)); 3298 - assert!(scopes.contains(&Scope::Account(AccountScope { 3299 - resource: AccountResource::Email, 3300 - action: AccountAction::Manage, 3301 - }))); 3302 - } 3303 - 3304 - #[test] 3305 - fn test_serialize_multiple() { 3306 - // Test empty list 3307 - let scopes: Vec<Scope> = vec![]; 3308 - assert_eq!(Scope::serialize_multiple(&scopes), ""); 3309 - 3310 - // Test single scope 3311 - let scopes = vec![Scope::Atproto]; 3312 - assert_eq!(Scope::<SmolStr>::serialize_multiple(&scopes), "atproto"); 3313 - 3314 - // Test multiple scopes - should be sorted alphabetically 3315 - let scopes = vec![ 3316 - Scope::<SmolStr>::parse("repo:*").unwrap(), 3317 - Scope::Atproto, 3318 - Scope::parse("account:email").unwrap(), 3319 - ]; 3320 - assert_eq!( 3321 - Scope::serialize_multiple(&scopes), 3322 - "account:email atproto repo:*" 3323 - ); 3324 - 3325 - // Test that sorting is consistent regardless of input order 3326 - let scopes = vec![ 3327 - Scope::<SmolStr>::parse("identity:handle").unwrap(), 3328 - Scope::parse("blob:image/png").unwrap(), 3329 - Scope::parse("account:repo?action=manage").unwrap(), 3330 - ]; 3331 - assert_eq!( 3332 - Scope::serialize_multiple(&scopes), 3333 - "account:repo?action=manage blob:image/png identity:handle" 3334 - ); 3335 - 3336 - // Test with OpenID Connect scopes 3337 - let scopes = vec![Scope::Email, Scope::OpenId, Scope::Profile, Scope::Atproto]; 3338 - assert_eq!( 3339 - Scope::<SmolStr>::serialize_multiple(&scopes), 3340 - "atproto email openid profile" 3341 - ); 3342 - 3343 - // Test with complex scopes including query parameters 3344 - let scopes = vec![ 3345 - Scope::<SmolStr>::parse("rpc:com.example.service?aud=did:plc:yfvwmnlztr4dwkb7hwz55r2g&lxm=com.example.method") 3346 - .unwrap(), 3347 - Scope::parse("repo:app.bsky.feed.post?action=create&action=update").unwrap(), 3348 - Scope::parse("blob:image/*?accept=image/png&accept=image/jpeg").unwrap(), 3349 - ]; 3350 - let result = Scope::serialize_multiple(&scopes); 3351 - // The result should be sorted alphabetically 3352 - // Note: RPC scope with query params is serialized as "rpc?aud=...&lxm=..." 3353 - assert!(result.starts_with("blob:")); 3354 - assert!(result.contains(" repo:")); 3355 - assert!( 3356 - result.contains("rpc?aud=did:plc:yfvwmnlztr4dwkb7hwz55r2g&lxm=com.example.service") 3357 - ); 3358 - 3359 - // Test with transition scopes 3360 - let scopes = vec![ 3361 - Scope::Transition(TransitionScope::Email), 3362 - Scope::Transition(TransitionScope::Generic), 3363 - Scope::Atproto, 3364 - ]; 3365 - assert_eq!( 3366 - Scope::<&str>::serialize_multiple(&scopes), 3367 - "atproto transition:email transition:generic" 3368 - ); 3369 - 3370 - // Test duplicates - they remain in the output (caller's responsibility to dedupe if needed) 3371 - let scopes = vec![ 3372 - Scope::Atproto, 3373 - Scope::Atproto, 3374 - Scope::<SmolStr>::parse("account:email").unwrap(), 3375 - ]; 3376 - assert_eq!( 3377 - Scope::serialize_multiple(&scopes), 3378 - "account:email atproto atproto" 3379 - ); 3380 - 3381 - // Test normalization is preserved in serialization 3382 - let scopes = 3383 - vec![Scope::<SmolStr>::parse("blob?accept=image/png&accept=image/jpeg").unwrap()]; 3384 - // Should normalize query parameters alphabetically 3385 - assert_eq!( 3386 - Scope::serialize_multiple(&scopes), 3387 - "blob?accept=image/jpeg&accept=image/png" 3388 - ); 3389 - } 3390 - 3391 - #[test] 3392 - fn test_serialize_multiple_roundtrip() { 3393 - // Test that parse_multiple and serialize_multiple are inverses (when sorted) 3394 - let original = "account:email atproto blob:image/png identity:handle repo:*"; 3395 - let scopes = Scope::<SmolStr>::parse_multiple(original).unwrap(); 3396 - let serialized = Scope::serialize_multiple(&scopes); 3397 - assert_eq!(serialized, original); 3398 - 3399 - // Test with complex scopes 3400 - let original = "account:repo?action=manage blob?accept=image/jpeg&accept=image/png rpc:*"; 3401 - let scopes = Scope::<SmolStr>::parse_multiple(original).unwrap(); 3402 - let serialized = Scope::serialize_multiple(&scopes); 3403 - // Parse again to verify it's valid 3404 - let reparsed = Scope::parse_multiple(&serialized).unwrap(); 3405 - assert_eq!(scopes, reparsed); 3406 - 3407 - // Test with OpenID Connect scopes 3408 - let original = "email openid profile"; 3409 - let scopes = Scope::<SmolStr>::parse_multiple(original).unwrap(); 3410 - let serialized = Scope::serialize_multiple(&scopes); 3411 - assert_eq!(serialized, original); 3412 - } 3413 - 3414 - #[test] 3415 - fn test_remove_scope() { 3416 - // Test removing a scope that exists 3417 - let scopes = vec![ 3418 - Scope::<SmolStr>::parse("repo:*").unwrap(), 3419 - Scope::Atproto, 3420 - Scope::parse("account:email").unwrap(), 3421 - ]; 3422 - let to_remove = Scope::Atproto; 3423 - let result = Scope::remove_scope(&scopes, &to_remove); 3424 - assert_eq!(result.len(), 2); 3425 - assert!(!result.contains(&to_remove)); 3426 - assert!(result.contains(&Scope::parse("repo:*").unwrap())); 3427 - assert!(result.contains(&Scope::parse("account:email").unwrap())); 3428 - 3429 - // Test removing a scope that doesn't exist 3430 - let scopes = vec![ 3431 - Scope::<SmolStr>::parse("repo:*").unwrap(), 3432 - Scope::parse("account:email").unwrap(), 3433 - ]; 3434 - let to_remove = Scope::parse("identity:handle").unwrap(); 3435 - let result = Scope::remove_scope(&scopes, &to_remove); 3436 - assert_eq!(result.len(), 2); 3437 - assert_eq!(result, scopes); 3438 - 3439 - // Test removing from empty list 3440 - let scopes: Vec<Scope> = vec![]; 3441 - let to_remove = Scope::Atproto; 3442 - let result = Scope::remove_scope(&scopes, &to_remove); 3443 - assert_eq!(result.len(), 0); 3444 - 3445 - // Test removing all instances of a duplicate scope 3446 - let scopes = vec![ 3447 - Scope::Atproto, 3448 - Scope::<SmolStr>::parse("account:email").unwrap(), 3449 - Scope::Atproto, 3450 - Scope::parse("repo:*").unwrap(), 3451 - Scope::Atproto, 3452 - ]; 3453 - let to_remove = Scope::Atproto; 3454 - let result = Scope::remove_scope(&scopes, &to_remove); 3455 - assert_eq!(result.len(), 2); 3456 - assert!(!result.contains(&to_remove)); 3457 - assert!(result.contains(&Scope::parse("account:email").unwrap())); 3458 - assert!(result.contains(&Scope::parse("repo:*").unwrap())); 3459 - 3460 - // Test removing complex scopes with query parameters 3461 - let scopes = vec![ 3462 - Scope::<SmolStr>::parse("account:email?action=manage").unwrap(), 3463 - Scope::parse("blob?accept=image/png&accept=image/jpeg").unwrap(), 3464 - Scope::parse("rpc:com.example.service?aud=did:example:123").unwrap(), 3465 - ]; 3466 - let to_remove = Scope::parse("blob?accept=image/jpeg&accept=image/png").unwrap(); // Note: normalized order 3467 - let result = Scope::remove_scope(&scopes, &to_remove); 3468 - assert_eq!(result.len(), 2); 3469 - assert!(!result.contains(&to_remove)); 3470 - 3471 - // Test with OpenID Connect scopes 3472 - let scopes = vec![Scope::OpenId, Scope::Profile, Scope::Email, Scope::Atproto]; 3473 - let to_remove = Scope::Profile; 3474 - let result = Scope::<&str>::remove_scope(&scopes, &to_remove); 3475 - assert_eq!(result.len(), 3); 3476 - assert!(!result.contains(&to_remove)); 3477 - assert!(result.contains(&Scope::OpenId)); 3478 - assert!(result.contains(&Scope::Email)); 3479 - assert!(result.contains(&Scope::Atproto)); 3480 - 3481 - // Test with transition scopes 3482 - let scopes = vec![ 3483 - Scope::Transition(TransitionScope::Generic), 3484 - Scope::Transition(TransitionScope::Email), 3485 - Scope::Atproto, 3486 - ]; 3487 - let to_remove = Scope::Transition(TransitionScope::Email); 3488 - let result = Scope::<&str>::remove_scope(&scopes, &to_remove); 3489 - assert_eq!(result.len(), 2); 3490 - assert!(!result.contains(&to_remove)); 3491 - assert!(result.contains(&Scope::Transition(TransitionScope::Generic))); 3492 - assert!(result.contains(&Scope::Atproto)); 3493 - 3494 - // Test that only exact matches are removed 3495 - let scopes = vec![ 3496 - Scope::<SmolStr>::parse("account:email").unwrap(), 3497 - Scope::parse("account:email?action=manage").unwrap(), 3498 - Scope::parse("account:repo").unwrap(), 3499 - ]; 3500 - let to_remove = Scope::parse("account:email").unwrap(); 3501 - let result = Scope::remove_scope(&scopes, &to_remove); 3502 - assert_eq!(result.len(), 2); 3503 - assert!(!result.contains(&Scope::parse("account:email").unwrap())); 3504 - assert!(result.contains(&Scope::parse("account:email?action=manage").unwrap())); 3505 - assert!(result.contains(&Scope::parse("account:repo").unwrap())); 3506 2882 } 3507 2883 3508 2884 // ========================================================================
+16 -17
crates/jacquard-oauth/src/session.rs
··· 9 9 keyset::Keyset, 10 10 request::{OAuthMetadata, refresh}, 11 11 resolver::OAuthResolver, 12 - scopes::Scope, 12 + scopes::Scopes, 13 13 types::TokenSet, 14 14 }; 15 15 ··· 51 51 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] 52 52 #[serde(bound( 53 53 serialize = "S: serde::Serialize + BosStr + Ord", 54 - deserialize = "S: serde::Deserialize<'de> + BosStr, Scope<S>: serde::Deserialize<'de>" 54 + deserialize = "S: serde::Deserialize<'de> + BosStr + AsRef<str>" 55 55 ))] 56 56 pub struct ClientSessionData<S: BosStr = DefaultStr> { 57 57 /// DID of the authenticated account; serves as the primary key for session storage ··· 77 77 pub authserver_revocation_endpoint: Option<S>, 78 78 79 79 /// The set of OAuth scopes approved for this session, as returned in the initial token response. 80 - pub scopes: Vec<Scope<S>>, 80 + pub scopes: Scopes<S>, 81 81 82 82 /// DPoP key and nonce state for ongoing requests in this session. 83 83 #[serde(flatten)] ··· 88 88 pub token_set: TokenSet<S>, 89 89 } 90 90 91 - impl<S: BosStr + Ord + IntoStatic> IntoStatic for ClientSessionData<S> 91 + impl<S: BosStr + Ord + IntoStatic + AsRef<str>> IntoStatic for ClientSessionData<S> 92 92 where 93 - S::Output: BosStr + Ord, 93 + S::Output: BosStr + Ord + AsRef<str>, 94 94 { 95 95 type Output = ClientSessionData<S::Output>; 96 96 ··· 111 111 } 112 112 } 113 113 114 - impl<S: BosStr + Ord> ClientSessionData<S> { 114 + impl<S: BosStr + Ord + AsRef<str>> ClientSessionData<S> { 115 115 /// Update this session's token set and, if the new token set includes scopes, replace the scope list. 116 116 /// 117 117 /// Called after a successful token refresh so that any scope changes returned by the server ··· 122 122 /// not be refreshed in place. 123 123 pub fn update_with_tokens(&mut self, token_set: &TokenSet<S>) 124 124 where 125 - S: FromStr + Clone, 125 + S: FromStr + Clone + From<SmolStr> + AsRef<str>, 126 126 S::Err: std::fmt::Debug, 127 127 { 128 - if let Some(Ok(scopes)) = token_set 129 - .scope 130 - .as_ref() 131 - .map(|scope| Scope::<S>::parse_multiple_reduced(scope.as_ref())) 132 - { 133 - self.scopes = scopes.into_iter().map(|s| s.convert()).collect(); 128 + if let Some(scope_str) = token_set.scope.as_ref() { 129 + // Parse scopes from the returned scope string, converting to the appropriate backing type 130 + let scopes_smol = Scopes::new(SmolStr::from(scope_str.as_ref())) 131 + .expect("server returned invalid scopes in token refresh"); 132 + self.scopes = scopes_smol.convert(); 134 133 } 135 134 self.token_set = token_set.clone(); 136 135 } ··· 180 179 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] 181 180 #[serde(bound( 182 181 serialize = "S: serde::Serialize + BosStr + Ord", 183 - deserialize = "S: serde::Deserialize<'de> + BosStr, Scope<S>: serde::Deserialize<'de>" 182 + deserialize = "S: serde::Deserialize<'de> + BosStr + AsRef<str>" 184 183 ))] 185 184 pub struct AuthRequestData<S: BosStr = DefaultStr> { 186 185 /// Random identifier generated for this authorization request; used as the primary key ··· 196 195 pub account_did: Option<Did<S>>, 197 196 198 197 /// OAuth scopes requested for this authorization. 199 - pub scopes: Vec<Scope<S>>, 198 + pub scopes: Scopes<S>, 200 199 201 200 /// The PAR `request_uri` returned by the authorization server; included in the redirect URL. 202 201 pub request_uri: S, ··· 217 216 pub dpop_data: DpopReqData, 218 217 } 219 218 220 - impl<S: BosStr + Ord + IntoStatic> IntoStatic for AuthRequestData<S> 219 + impl<S: BosStr + Ord + IntoStatic + AsRef<str>> IntoStatic for AuthRequestData<S> 221 220 where 222 - S::Output: BosStr + Ord, 221 + S::Output: BosStr + Ord + AsRef<str>, 223 222 { 224 223 type Output = AuthRequestData<S::Output>; 225 224
+11 -7
crates/jacquard-oauth/src/types.rs
··· 4 4 mod response; 5 5 mod token; 6 6 7 - use crate::scopes::Scope; 7 + use crate::scopes::Scopes; 8 8 9 9 pub use self::client_metadata::*; 10 10 pub use self::metadata::*; ··· 59 59 60 60 /// Options for initiating an OAuth authorization request. 61 61 #[derive(Debug)] 62 - pub struct AuthorizeOptions<S: BosStr = DefaultStr> { 62 + pub struct AuthorizeOptions<S: BosStr = DefaultStr> 63 + where 64 + S: AsRef<str>, 65 + { 63 66 /// Override the redirect URI registered in the client metadata. 64 67 pub redirect_uri: Option<Uri<String>>, 65 68 /// Scopes to request. Defaults to an empty list (server-defined defaults apply). 66 - pub scopes: Vec<Scope<S>>, 69 + pub scopes: Scopes<S>, 67 70 /// Optional prompt hint for the authorization server's UI. 68 71 pub prompt: Option<AuthorizeOptionPrompt>, 69 72 /// Opaque client-provided state value, echoed back in the callback for CSRF protection. 70 73 pub state: Option<S>, 71 74 } 72 75 73 - impl<S: BosStr> Default for AuthorizeOptions<S> { 76 + impl<S: BosStr + AsRef<str> + From<SmolStr>> Default for AuthorizeOptions<S> { 74 77 fn default() -> Self { 78 + let empty_scopes: Scopes<S> = Scopes::empty().convert(); 75 79 Self { 76 80 redirect_uri: None, 77 - scopes: vec![], 81 + scopes: empty_scopes, 78 82 prompt: None, 79 83 state: None, 80 84 } 81 85 } 82 86 } 83 87 84 - impl<S: BosStr> AuthorizeOptions<S> { 88 + impl<S: BosStr + AsRef<str>> AuthorizeOptions<S> { 85 89 /// Set the `prompt` parameter sent to the authorization server. 86 90 pub fn with_prompt(mut self, prompt: AuthorizeOptionPrompt) -> Self { 87 91 self.prompt = Some(prompt); ··· 101 105 } 102 106 103 107 /// Set the OAuth scopes to request. 104 - pub fn with_scopes(mut self, scopes: Vec<Scope<S>>) -> Self { 108 + pub fn with_scopes(mut self, scopes: Scopes<S>) -> Self { 105 109 self.scopes = scopes; 106 110 self 107 111 }
+11 -25
crates/jacquard/src/client/token.rs
··· 1 1 use jacquard_common::deps::fluent_uri::Uri; 2 2 use jacquard_common::session::{FileTokenStore, SessionStore, SessionStoreError}; 3 3 use jacquard_common::types::string::{Datetime, Did}; 4 - use jacquard_oauth::scopes::Scope; 4 + use jacquard_oauth::scopes::Scopes; 5 5 use jacquard_oauth::session::{AuthRequestData, ClientSessionData, DpopClientData, DpopReqData}; 6 6 use jacquard_oauth::types::OAuthTokenType; 7 7 use jose_jwk::Key; ··· 59 59 #[serde(skip_serializing_if = "std::option::Option::is_none")] 60 60 authserver_revocation_endpoint: Option<String>, 61 61 62 - /// Granted scopes 63 - scopes: Vec<String>, 62 + /// Granted scopes (space-separated, normalized). 63 + scopes: String, 64 64 65 65 /// Client DPoP key material 66 66 pub dpop_key: Key, ··· 101 101 authserver_revocation_endpoint: data 102 102 .authserver_revocation_endpoint 103 103 .map(|s| AsRef::<str>::as_ref(&s).to_owned()), 104 - scopes: data 105 - .scopes 106 - .into_iter() 107 - .map(|s| String::from(s.to_string_normalized())) 108 - .collect(), 104 + scopes: String::from(data.scopes.to_normalized_string()), 109 105 dpop_key: data.dpop_data.dpop_key, 110 106 dpop_authserver_nonce: AsRef::<str>::as_ref(&data.dpop_data.dpop_authserver_nonce) 111 107 .to_owned(), ··· 139 135 authserver_revocation_endpoint: session 140 136 .authserver_revocation_endpoint 141 137 .map(SmolStr::from), 142 - scopes: session 143 - .scopes 144 - .into_iter() 145 - .map(|s| Scope::parse(&s).unwrap()) 146 - .collect(), 138 + scopes: Scopes::new(SmolStr::from(session.scopes.as_str())) 139 + .expect("stored scopes should be valid"), 147 140 dpop_data: DpopClientData { 148 141 dpop_key: session.dpop_key, 149 142 dpop_authserver_nonce: SmolStr::from(session.dpop_authserver_nonce), ··· 176 169 #[serde(skip_serializing_if = "std::option::Option::is_none")] 177 170 pub account_did: Option<String>, 178 171 179 - /// Requested scopes 180 - pub scopes: Vec<String>, 172 + /// Requested scopes (space-separated, normalized). 173 + pub scopes: String, 181 174 182 175 /// Request URI for the authorization step 183 176 pub request_uri: String, ··· 208 201 account_did: value 209 202 .account_did 210 203 .map(|s| AsRef::<str>::as_ref(&s).to_owned()), 211 - scopes: value 212 - .scopes 213 - .into_iter() 214 - .map(|s| String::from(s.to_string_normalized())) 215 - .collect(), 204 + scopes: String::from(value.scopes.to_normalized_string()), 216 205 request_uri: AsRef::<str>::as_ref(&value.request_uri).to_owned(), 217 206 authserver_token_endpoint: AsRef::<str>::as_ref(&value.authserver_token_endpoint) 218 207 .to_owned(), ··· 239 228 .account_did 240 229 .map(|s| Did::new_owned(s).expect("stored DID should be valid")), 241 230 authserver_revocation_endpoint: value.authserver_revocation_endpoint.map(SmolStr::from), 242 - scopes: value 243 - .scopes 244 - .into_iter() 245 - .map(|s| Scope::parse(&s).unwrap()) 246 - .collect(), 231 + scopes: Scopes::new(SmolStr::from(value.scopes.as_str())) 232 + .expect("stored scopes should be valid"), 247 233 request_uri: SmolStr::from(value.request_uri), 248 234 authserver_token_endpoint: SmolStr::from(value.authserver_token_endpoint), 249 235 pkce_verifier: SmolStr::from(value.pkce_verifier),
+6 -6
crates/jacquard/tests/oauth_auto_refresh.rs
··· 12 12 use jacquard_oauth::atproto::AtprotoClientMetadata; 13 13 use jacquard_oauth::client::OAuthSession; 14 14 use jacquard_oauth::resolver::OAuthResolver; 15 - use jacquard_oauth::scopes::Scope; 15 + use jacquard_oauth::scopes::Scopes; 16 16 use jacquard_oauth::session::SessionRegistry; 17 17 use jacquard_oauth::session::{ClientData, ClientSessionData, DpopClientData}; 18 18 use jacquard_oauth::types::{OAuthAuthorizationServerMetadata, OAuthTokenType, TokenSet}; ··· 211 211 212 212 let client_data = ClientData { 213 213 keyset: None, 214 - config: AtprotoClientMetadata::new_localhost(None, Some(vec![Scope::Atproto])), 214 + config: AtprotoClientMetadata::new_localhost(None, Some(Scopes::new(SmolStr::new_static("atproto")).unwrap())), 215 215 }; 216 216 use jacquard::IntoStatic; 217 217 let session_data = ClientSessionData { ··· 221 221 authserver_url: SmolStr::new_static("https://issuer"), 222 222 authserver_token_endpoint: SmolStr::from("https://issuer/token"), 223 223 authserver_revocation_endpoint: None, 224 - scopes: vec![Scope::Atproto], 224 + scopes: Scopes::new(SmolStr::new_static("atproto")).unwrap(), 225 225 dpop_data: DpopClientData { 226 226 dpop_key: jacquard_oauth::utils::generate_key(&[SmolStr::from("ES256")]).unwrap(), 227 227 dpop_authserver_nonce: SmolStr::from(""), ··· 249 249 authserver_url: SmolStr::new_static("https://issuer"), 250 250 authserver_token_endpoint: SmolStr::from("https://issuer/token"), 251 251 authserver_revocation_endpoint: None, 252 - scopes: vec![Scope::Atproto], 252 + scopes: Scopes::new(SmolStr::new_static("atproto")).unwrap(), 253 253 dpop_data: DpopClientData { 254 254 dpop_key: jacquard_oauth::utils::generate_key(&[SmolStr::from("ES256")]).unwrap(), 255 255 dpop_authserver_nonce: SmolStr::from(""), ··· 340 340 341 341 let client_data = ClientData { 342 342 keyset: None, 343 - config: AtprotoClientMetadata::new_localhost(None, Some(vec![Scope::Atproto])), 343 + config: AtprotoClientMetadata::new_localhost(None, Some(Scopes::new(SmolStr::new_static("atproto")).unwrap())), 344 344 }; 345 345 use jacquard::IntoStatic; 346 346 let session_data = ClientSessionData { ··· 350 350 authserver_url: SmolStr::new_static("https://issuer"), 351 351 authserver_token_endpoint: SmolStr::from("https://issuer/token"), 352 352 authserver_revocation_endpoint: None, 353 - scopes: vec![Scope::Atproto], 353 + scopes: Scopes::new(SmolStr::new_static("atproto")).unwrap(), 354 354 dpop_data: DpopClientData { 355 355 dpop_key: jacquard_oauth::utils::generate_key(&[SmolStr::from("ES256")]).unwrap(), 356 356 dpop_authserver_nonce: SmolStr::from(""),
+3 -3
crates/jacquard/tests/oauth_flow.rs
··· 11 11 use jacquard_oauth::authstore::ClientAuthStore; 12 12 use jacquard_oauth::client::OAuthClient; 13 13 use jacquard_oauth::resolver::OAuthResolver; 14 - use jacquard_oauth::scopes::Scope; 14 + use jacquard_oauth::scopes::Scopes; 15 15 use jacquard_oauth::session::ClientData; 16 16 use smol_str::SmolStr; 17 17 ··· 215 215 216 216 let client_data: ClientData<_> = ClientData { 217 217 keyset: None, 218 - config: AtprotoClientMetadata::new_localhost(None, Some(vec![Scope::Atproto])), 218 + config: AtprotoClientMetadata::new_localhost(None, Some(Scopes::new(SmolStr::new_static("atproto")).unwrap())), 219 219 }; 220 220 let client_arc = client.clone(); 221 221 let oauth = OAuthClient::new_from_resolver(store, (*client_arc).clone(), client_data); ··· 225 225 let mut metadata = jacquard_oauth::request::OAuthMetadata { 226 226 server_metadata, 227 227 client_metadata: jacquard_oauth::atproto::atproto_client_metadata( 228 - &AtprotoClientMetadata::new_localhost(None, Some(vec![Scope::Atproto])), 228 + &AtprotoClientMetadata::new_localhost(None, Some(Scopes::new(SmolStr::new_static("atproto")).unwrap())), 229 229 &None, 230 230 ) 231 231 .unwrap(),