A library for ATProtocol identities.

feature: oauth scope types, parsing, and utilities

Signed-off-by: Nick Gerakines <nick.gerakines@gmail.com>

Changed files
+1910 -44
crates
atproto-jetstream
atproto-oauth
atproto-oauth-axum
atproto-record
atproto-xrpcs
+9 -2
crates/atproto-jetstream/src/consumer.rs
··· 153 pub(crate) enum SubscriberSourcedMessage { 154 #[serde(rename = "options_update")] 155 Update { 156 - #[serde(rename = "wantedCollections", skip_serializing_if = "Vec::is_empty", default)] 157 wanted_collections: Vec<String>, 158 159 #[serde(rename = "wantedDids", skip_serializing_if = "Vec::is_empty", default)] ··· 264 // Add wantedCollections if specified (each collection as a separate query parameter) 265 if !self.config.collections.is_empty() && !self.config.require_hello { 266 for collection in &self.config.collections { 267 - query_params.push(format!("wantedCollections={}", urlencoding::encode(collection))); 268 } 269 } 270
··· 153 pub(crate) enum SubscriberSourcedMessage { 154 #[serde(rename = "options_update")] 155 Update { 156 + #[serde( 157 + rename = "wantedCollections", 158 + skip_serializing_if = "Vec::is_empty", 159 + default 160 + )] 161 wanted_collections: Vec<String>, 162 163 #[serde(rename = "wantedDids", skip_serializing_if = "Vec::is_empty", default)] ··· 268 // Add wantedCollections if specified (each collection as a separate query parameter) 269 if !self.config.collections.is_empty() && !self.config.require_hello { 270 for collection in &self.config.collections { 271 + query_params.push(format!( 272 + "wantedCollections={}", 273 + urlencoding::encode(collection) 274 + )); 275 } 276 } 277
+4 -3
crates/atproto-oauth-axum/src/handler_metadata.rs
··· 66 let mut jwks_keys = Vec::new(); 67 for key_data in &oauth_client_config.signing_keys { 68 if let Ok(public_key_data) = to_public(key_data) 69 - && let Ok(jwk) = generate(&public_key_data) { 70 - jwks_keys.push(jwk); 71 - } 72 } 73 (None, Some(WrappedJsonWebKeySet { keys: jwks_keys })) 74 };
··· 66 let mut jwks_keys = Vec::new(); 67 for key_data in &oauth_client_config.signing_keys { 68 if let Ok(public_key_data) = to_public(key_data) 69 + && let Ok(jwk) = generate(&public_key_data) 70 + { 71 + jwks_keys.push(jwk); 72 + } 73 } 74 (None, Some(WrappedJsonWebKeySet { keys: jwks_keys })) 75 };
+4 -3
crates/atproto-oauth/src/dpop.rs
··· 752 // exp (expiration) - validate if present 753 if let Some(exp_value) = claims.get("exp") 754 && let Some(exp) = exp_value.as_u64() 755 - && config.now as u64 >= exp { 756 - return Err(JWTError::TokenExpired.into()); 757 - } 758 759 // 3. VERIFY SIGNATURE 760 let content = format!("{}.{}", encoded_header, encoded_payload);
··· 752 // exp (expiration) - validate if present 753 if let Some(exp_value) = claims.get("exp") 754 && let Some(exp) = exp_value.as_u64() 755 + && config.now as u64 >= exp 756 + { 757 + return Err(JWTError::TokenExpired.into()); 758 + } 759 760 // 3. VERIFY SIGNATURE 761 let content = format!("{}.{}", encoded_header, encoded_payload);
+8 -6
crates/atproto-oauth/src/jwt.rs
··· 215 216 // Validate expiration time if present 217 if let Some(exp) = claims.jose.expiration 218 - && now >= exp { 219 - return Err(JWTError::TokenExpired.into()); 220 - } 221 222 // Validate not-before time if present 223 if let Some(nbf) = claims.jose.not_before 224 - && now < nbf { 225 - return Err(JWTError::TokenNotValidYet.into()); 226 - } 227 228 // Return validated claims 229 Ok(claims)
··· 215 216 // Validate expiration time if present 217 if let Some(exp) = claims.jose.expiration 218 + && now >= exp 219 + { 220 + return Err(JWTError::TokenExpired.into()); 221 + } 222 223 // Validate not-before time if present 224 if let Some(nbf) = claims.jose.not_before 225 + && now < nbf 226 + { 227 + return Err(JWTError::TokenNotValidYet.into()); 228 + } 229 230 // Return validated claims 231 Ok(claims)
+4
crates/atproto-oauth/src/lib.rs
··· 6 #![forbid(unsafe_code)] 7 #![warn(missing_docs)] 8 9 pub mod dpop; 10 /// Base64 encoding and decoding utilities. 11 pub mod encoding; ··· 17 pub mod jwt; 18 /// PKCE (Proof Key for Code Exchange) implementation for OAuth 2.0 security. 19 pub mod pkce; 20 pub mod resources; 21 /// OAuth request storage abstraction for CRUD operations. 22 pub mod storage; ··· 25 pub mod storage_lru; 26 /// OAuth workflow implementation for AT Protocol authorization flows. 27 pub mod workflow;
··· 6 #![forbid(unsafe_code)] 7 #![warn(missing_docs)] 8 9 + /// DPoP (Demonstrating Proof of Possession) implementation for OAuth 2.0. 10 pub mod dpop; 11 /// Base64 encoding and decoding utilities. 12 pub mod encoding; ··· 18 pub mod jwt; 19 /// PKCE (Proof Key for Code Exchange) implementation for OAuth 2.0 security. 20 pub mod pkce; 21 + /// OAuth resource and authorization server management. 22 pub mod resources; 23 /// OAuth request storage abstraction for CRUD operations. 24 pub mod storage; ··· 27 pub mod storage_lru; 28 /// OAuth workflow implementation for AT Protocol authorization flows. 29 pub mod workflow; 30 + /// OAuth 2.0 scope definitions and parsing for AT Protocol. 31 + pub mod scopes;
+1841
crates/atproto-oauth/src/scopes.rs
···
··· 1 + //! AT Protocol OAuth scopes module 2 + //! 3 + //! This module provides comprehensive support for AT Protocol OAuth scopes, 4 + //! including parsing, serialization, normalization, and permission checking. 5 + //! 6 + //! Scopes in AT Protocol follow a prefix-based format with optional query parameters: 7 + //! - `account`: Access to account information (email, repo, status) 8 + //! - `identity`: Access to identity information (handle) 9 + //! - `blob`: Access to blob operations with mime type constraints 10 + //! - `repo`: Repository operations with collection and action constraints 11 + //! - `rpc`: RPC method access with lexicon and audience constraints 12 + //! - `atproto`: Required scope to indicate that other AT Protocol scopes will be used 13 + //! - `transition`: Migration operations (generic or email) 14 + //! 15 + //! Standard OpenID Connect scopes (no suffixes or query parameters): 16 + //! - `openid`: Required for OpenID Connect authentication 17 + //! - `profile`: Access to user profile information 18 + //! - `email`: Access to user email address 19 + 20 + use std::collections::{BTreeMap, BTreeSet}; 21 + use std::fmt; 22 + use std::str::FromStr; 23 + 24 + /// Represents an AT Protocol OAuth scope 25 + #[derive(Debug, Clone, PartialEq, Eq, Hash)] 26 + pub enum Scope { 27 + /// Account scope for accessing account information 28 + Account(AccountScope), 29 + /// Identity scope for accessing identity information 30 + Identity(IdentityScope), 31 + /// Blob scope for blob operations with mime type constraints 32 + Blob(BlobScope), 33 + /// Repository scope for collection operations 34 + Repo(RepoScope), 35 + /// RPC scope for method access 36 + Rpc(RpcScope), 37 + /// AT Protocol scope - required to indicate that other AT Protocol scopes will be used 38 + Atproto, 39 + /// Transition scope for migration operations 40 + Transition(TransitionScope), 41 + /// OpenID Connect scope - required for OpenID Connect authentication 42 + OpenId, 43 + /// Profile scope - access to user profile information 44 + Profile, 45 + /// Email scope - access to user email address 46 + Email, 47 + } 48 + 49 + /// Account scope attributes 50 + #[derive(Debug, Clone, PartialEq, Eq, Hash)] 51 + pub struct AccountScope { 52 + /// The account resource type 53 + pub resource: AccountResource, 54 + /// The action permission level 55 + pub action: AccountAction, 56 + } 57 + 58 + /// Account resource types 59 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 60 + pub enum AccountResource { 61 + /// Email access 62 + Email, 63 + /// Repository access 64 + Repo, 65 + /// Status access 66 + Status, 67 + } 68 + 69 + /// Account action permissions 70 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 71 + pub enum AccountAction { 72 + /// Read-only access 73 + Read, 74 + /// Management access (includes read) 75 + Manage, 76 + } 77 + 78 + /// Identity scope attributes 79 + #[derive(Debug, Clone, PartialEq, Eq, Hash)] 80 + pub enum IdentityScope { 81 + /// Handle access 82 + Handle, 83 + /// All identity access (wildcard) 84 + All, 85 + } 86 + 87 + /// Transition scope types 88 + #[derive(Debug, Clone, PartialEq, Eq, Hash)] 89 + pub enum TransitionScope { 90 + /// Generic transition operations 91 + Generic, 92 + /// Email transition operations 93 + Email, 94 + } 95 + 96 + /// Blob scope with mime type constraints 97 + #[derive(Debug, Clone, PartialEq, Eq, Hash)] 98 + pub struct BlobScope { 99 + /// Accepted mime types 100 + pub accept: BTreeSet<MimePattern>, 101 + } 102 + 103 + /// MIME type pattern for blob scope 104 + #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] 105 + pub enum MimePattern { 106 + /// Match all types 107 + All, 108 + /// Match all subtypes of a type (e.g., "image/*") 109 + TypeWildcard(String), 110 + /// Exact mime type match 111 + Exact(String), 112 + } 113 + 114 + /// Repository scope with collection and action constraints 115 + #[derive(Debug, Clone, PartialEq, Eq, Hash)] 116 + pub struct RepoScope { 117 + /// Collection NSID or wildcard 118 + pub collection: RepoCollection, 119 + /// Allowed actions 120 + pub actions: BTreeSet<RepoAction>, 121 + } 122 + 123 + /// Repository collection identifier 124 + #[derive(Debug, Clone, PartialEq, Eq, Hash)] 125 + pub enum RepoCollection { 126 + /// All collections (wildcard) 127 + All, 128 + /// Specific collection NSID 129 + Nsid(String), 130 + } 131 + 132 + /// Repository actions 133 + #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] 134 + pub enum RepoAction { 135 + /// Create records 136 + Create, 137 + /// Update records 138 + Update, 139 + /// Delete records 140 + Delete, 141 + } 142 + 143 + /// RPC scope with lexicon method and audience constraints 144 + #[derive(Debug, Clone, PartialEq, Eq, Hash)] 145 + pub struct RpcScope { 146 + /// Lexicon methods (NSIDs or wildcard) 147 + pub lxm: BTreeSet<RpcLexicon>, 148 + /// Audiences (DIDs or wildcard) 149 + pub aud: BTreeSet<RpcAudience>, 150 + } 151 + 152 + /// RPC lexicon identifier 153 + #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] 154 + pub enum RpcLexicon { 155 + /// All lexicons (wildcard) 156 + All, 157 + /// Specific lexicon NSID 158 + Nsid(String), 159 + } 160 + 161 + /// RPC audience identifier 162 + #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] 163 + pub enum RpcAudience { 164 + /// All audiences (wildcard) 165 + All, 166 + /// Specific DID 167 + Did(String), 168 + } 169 + 170 + impl Scope { 171 + /// Parse multiple space-separated scopes from a string 172 + /// 173 + /// # Examples 174 + /// ``` 175 + /// # use atproto_oauth::scopes::Scope; 176 + /// let scopes = Scope::parse_multiple("atproto repo:*").unwrap(); 177 + /// assert_eq!(scopes.len(), 2); 178 + /// ``` 179 + pub fn parse_multiple(s: &str) -> Result<Vec<Self>, ParseError> { 180 + if s.trim().is_empty() { 181 + return Ok(Vec::new()); 182 + } 183 + 184 + let mut scopes = Vec::new(); 185 + for scope_str in s.trim().split_whitespace() { 186 + scopes.push(Self::parse(scope_str)?); 187 + } 188 + 189 + Ok(scopes) 190 + } 191 + 192 + /// Parse multiple space-separated scopes and return the minimal set needed 193 + /// 194 + /// This method removes duplicate scopes and scopes that are already granted 195 + /// by other scopes in the list, returning only the minimal set of scopes needed. 196 + /// 197 + /// # Examples 198 + /// ``` 199 + /// # use atproto_oauth::scopes::Scope; 200 + /// // repo:* grants repo:foo.bar, so only repo:* is kept 201 + /// let scopes = Scope::parse_multiple_reduced("atproto repo:foo.bar repo:*").unwrap(); 202 + /// assert_eq!(scopes.len(), 2); // atproto and repo:* 203 + /// ``` 204 + pub fn parse_multiple_reduced(s: &str) -> Result<Vec<Self>, ParseError> { 205 + let all_scopes = Self::parse_multiple(s)?; 206 + 207 + if all_scopes.is_empty() { 208 + return Ok(Vec::new()); 209 + } 210 + 211 + let mut result: Vec<Self> = Vec::new(); 212 + 213 + for scope in all_scopes { 214 + // Check if this scope is already granted by something in the result 215 + let mut is_granted = false; 216 + for existing in &result { 217 + if existing.grants(&scope) && existing != &scope { 218 + is_granted = true; 219 + break; 220 + } 221 + } 222 + 223 + if is_granted { 224 + continue; // Skip this scope, it's already covered 225 + } 226 + 227 + // Check if this scope grants any existing scopes in the result 228 + let mut indices_to_remove = Vec::new(); 229 + for (i, existing) in result.iter().enumerate() { 230 + if scope.grants(existing) && &scope != existing { 231 + indices_to_remove.push(i); 232 + } 233 + } 234 + 235 + // Remove scopes that are granted by the new scope (in reverse order to maintain indices) 236 + for i in indices_to_remove.into_iter().rev() { 237 + result.remove(i); 238 + } 239 + 240 + // Add the new scope if it's not a duplicate 241 + if !result.contains(&scope) { 242 + result.push(scope); 243 + } 244 + } 245 + 246 + Ok(result) 247 + } 248 + 249 + /// Serialize a list of scopes into a space-separated OAuth scopes string 250 + /// 251 + /// The scopes are sorted alphabetically by their string representation to ensure 252 + /// consistent output regardless of input order. 253 + /// 254 + /// # Examples 255 + /// ``` 256 + /// # use atproto_oauth::scopes::Scope; 257 + /// let scopes = vec![ 258 + /// Scope::parse("repo:*").unwrap(), 259 + /// Scope::parse("atproto").unwrap(), 260 + /// Scope::parse("account:email").unwrap(), 261 + /// ]; 262 + /// let result = Scope::serialize_multiple(&scopes); 263 + /// assert_eq!(result, "account:email atproto repo:*"); 264 + /// ``` 265 + pub fn serialize_multiple(scopes: &[Self]) -> String { 266 + if scopes.is_empty() { 267 + return String::new(); 268 + } 269 + 270 + let mut serialized: Vec<String> = scopes.iter().map(|scope| scope.to_string()).collect(); 271 + 272 + serialized.sort(); 273 + serialized.join(" ") 274 + } 275 + 276 + /// Remove a scope from a list of scopes 277 + /// 278 + /// Returns a new vector with all instances of the specified scope removed. 279 + /// If the scope doesn't exist in the list, returns a copy of the original list. 280 + /// 281 + /// # Examples 282 + /// ``` 283 + /// # use atproto_oauth::scopes::Scope; 284 + /// let scopes = vec![ 285 + /// Scope::parse("repo:*").unwrap(), 286 + /// Scope::parse("atproto").unwrap(), 287 + /// Scope::parse("account:email").unwrap(), 288 + /// ]; 289 + /// let to_remove = Scope::parse("atproto").unwrap(); 290 + /// let result = Scope::remove_scope(&scopes, &to_remove); 291 + /// assert_eq!(result.len(), 2); 292 + /// assert!(!result.contains(&to_remove)); 293 + /// ``` 294 + pub fn remove_scope(scopes: &[Self], scope_to_remove: &Self) -> Vec<Self> { 295 + scopes 296 + .iter() 297 + .filter(|s| *s != scope_to_remove) 298 + .cloned() 299 + .collect() 300 + } 301 + 302 + /// Parse a scope from a string 303 + pub fn parse(s: &str) -> Result<Self, ParseError> { 304 + // Determine the prefix first by checking for known prefixes 305 + let prefixes = [ 306 + "account", 307 + "identity", 308 + "blob", 309 + "repo", 310 + "rpc", 311 + "atproto", 312 + "transition", 313 + "openid", 314 + "profile", 315 + "email", 316 + ]; 317 + let mut found_prefix = None; 318 + let mut suffix = None; 319 + 320 + for prefix in &prefixes { 321 + if s.starts_with(prefix) { 322 + let remainder = &s[prefix.len()..]; 323 + if remainder.is_empty() || remainder.starts_with(':') || remainder.starts_with('?') 324 + { 325 + found_prefix = Some(*prefix); 326 + if remainder.starts_with(':') { 327 + suffix = Some(&remainder[1..]); 328 + } else if remainder.starts_with('?') { 329 + suffix = Some(remainder); 330 + } else { 331 + suffix = None; 332 + } 333 + break; 334 + } 335 + } 336 + } 337 + 338 + let prefix = found_prefix.ok_or_else(|| { 339 + // If no known prefix found, extract what looks like a prefix for error reporting 340 + let end = s.find(':').or_else(|| s.find('?')).unwrap_or(s.len()); 341 + ParseError::UnknownPrefix(s[..end].to_string()) 342 + })?; 343 + 344 + match prefix { 345 + "account" => Self::parse_account(suffix), 346 + "identity" => Self::parse_identity(suffix), 347 + "blob" => Self::parse_blob(suffix), 348 + "repo" => Self::parse_repo(suffix), 349 + "rpc" => Self::parse_rpc(suffix), 350 + "atproto" => Self::parse_atproto(suffix), 351 + "transition" => Self::parse_transition(suffix), 352 + "openid" => Self::parse_openid(suffix), 353 + "profile" => Self::parse_profile(suffix), 354 + "email" => Self::parse_email(suffix), 355 + _ => Err(ParseError::UnknownPrefix(prefix.to_string())), 356 + } 357 + } 358 + 359 + fn parse_account(suffix: Option<&str>) -> Result<Self, ParseError> { 360 + let (resource_str, params) = match suffix { 361 + Some(s) => { 362 + if let Some(pos) = s.find('?') { 363 + (&s[..pos], Some(&s[pos + 1..])) 364 + } else { 365 + (s, None) 366 + } 367 + } 368 + None => return Err(ParseError::MissingResource), 369 + }; 370 + 371 + let resource = match resource_str { 372 + "email" => AccountResource::Email, 373 + "repo" => AccountResource::Repo, 374 + "status" => AccountResource::Status, 375 + _ => return Err(ParseError::InvalidResource(resource_str.to_string())), 376 + }; 377 + 378 + let action = if let Some(params) = params { 379 + let parsed_params = parse_query_string(params); 380 + match parsed_params 381 + .get("action") 382 + .and_then(|v| v.first()) 383 + .map(|s| s.as_str()) 384 + { 385 + Some("read") => AccountAction::Read, 386 + Some("manage") => AccountAction::Manage, 387 + Some(other) => return Err(ParseError::InvalidAction(other.to_string())), 388 + None => AccountAction::Read, 389 + } 390 + } else { 391 + AccountAction::Read 392 + }; 393 + 394 + Ok(Scope::Account(AccountScope { resource, action })) 395 + } 396 + 397 + fn parse_identity(suffix: Option<&str>) -> Result<Self, ParseError> { 398 + let scope = match suffix { 399 + Some("handle") => IdentityScope::Handle, 400 + Some("*") => IdentityScope::All, 401 + Some(other) => return Err(ParseError::InvalidResource(other.to_string())), 402 + None => return Err(ParseError::MissingResource), 403 + }; 404 + 405 + Ok(Scope::Identity(scope)) 406 + } 407 + 408 + fn parse_blob(suffix: Option<&str>) -> Result<Self, ParseError> { 409 + let mut accept = BTreeSet::new(); 410 + 411 + match suffix { 412 + Some(s) if s.starts_with('?') => { 413 + let params = parse_query_string(&s[1..]); 414 + if let Some(values) = params.get("accept") { 415 + for value in values { 416 + accept.insert(MimePattern::from_str(value)?); 417 + } 418 + } 419 + } 420 + Some(s) => { 421 + accept.insert(MimePattern::from_str(s)?); 422 + } 423 + None => { 424 + accept.insert(MimePattern::All); 425 + } 426 + } 427 + 428 + if accept.is_empty() { 429 + accept.insert(MimePattern::All); 430 + } 431 + 432 + Ok(Scope::Blob(BlobScope { accept })) 433 + } 434 + 435 + fn parse_repo(suffix: Option<&str>) -> Result<Self, ParseError> { 436 + let (collection_str, params) = match suffix { 437 + Some(s) => { 438 + if let Some(pos) = s.find('?') { 439 + (Some(&s[..pos]), Some(&s[pos + 1..])) 440 + } else { 441 + (Some(s), None) 442 + } 443 + } 444 + None => (None, None), 445 + }; 446 + 447 + let collection = match collection_str { 448 + Some("*") | None => RepoCollection::All, 449 + Some(nsid) => RepoCollection::Nsid(nsid.to_string()), 450 + }; 451 + 452 + let mut actions = BTreeSet::new(); 453 + if let Some(params) = params { 454 + let parsed_params = parse_query_string(params); 455 + if let Some(values) = parsed_params.get("action") { 456 + for value in values { 457 + match value.as_str() { 458 + "create" => { 459 + actions.insert(RepoAction::Create); 460 + } 461 + "update" => { 462 + actions.insert(RepoAction::Update); 463 + } 464 + "delete" => { 465 + actions.insert(RepoAction::Delete); 466 + } 467 + "*" => { 468 + actions.insert(RepoAction::Create); 469 + actions.insert(RepoAction::Update); 470 + actions.insert(RepoAction::Delete); 471 + } 472 + other => return Err(ParseError::InvalidAction(other.to_string())), 473 + } 474 + } 475 + } 476 + } 477 + 478 + if actions.is_empty() { 479 + actions.insert(RepoAction::Create); 480 + actions.insert(RepoAction::Update); 481 + actions.insert(RepoAction::Delete); 482 + } 483 + 484 + Ok(Scope::Repo(RepoScope { 485 + collection, 486 + actions, 487 + })) 488 + } 489 + 490 + fn parse_rpc(suffix: Option<&str>) -> Result<Self, ParseError> { 491 + let mut lxm = BTreeSet::new(); 492 + let mut aud = BTreeSet::new(); 493 + 494 + match suffix { 495 + Some("*") => { 496 + lxm.insert(RpcLexicon::All); 497 + aud.insert(RpcAudience::All); 498 + } 499 + Some(s) if s.starts_with('?') => { 500 + let params = parse_query_string(&s[1..]); 501 + 502 + if let Some(values) = params.get("lxm") { 503 + for value in values { 504 + if value == "*" { 505 + lxm.insert(RpcLexicon::All); 506 + } else { 507 + lxm.insert(RpcLexicon::Nsid(value.to_string())); 508 + } 509 + } 510 + } 511 + 512 + if let Some(values) = params.get("aud") { 513 + for value in values { 514 + if value == "*" { 515 + aud.insert(RpcAudience::All); 516 + } else { 517 + aud.insert(RpcAudience::Did(value.to_string())); 518 + } 519 + } 520 + } 521 + } 522 + Some(s) => { 523 + // Check if there's a query string in the suffix 524 + if let Some(pos) = s.find('?') { 525 + let nsid = &s[..pos]; 526 + let params = parse_query_string(&s[pos + 1..]); 527 + 528 + lxm.insert(RpcLexicon::Nsid(nsid.to_string())); 529 + 530 + if let Some(values) = params.get("aud") { 531 + for value in values { 532 + if value == "*" { 533 + aud.insert(RpcAudience::All); 534 + } else { 535 + aud.insert(RpcAudience::Did(value.to_string())); 536 + } 537 + } 538 + } 539 + } else { 540 + lxm.insert(RpcLexicon::Nsid(s.to_string())); 541 + } 542 + } 543 + None => {} 544 + } 545 + 546 + if lxm.is_empty() { 547 + lxm.insert(RpcLexicon::All); 548 + } 549 + if aud.is_empty() { 550 + aud.insert(RpcAudience::All); 551 + } 552 + 553 + Ok(Scope::Rpc(RpcScope { lxm, aud })) 554 + } 555 + 556 + fn parse_atproto(suffix: Option<&str>) -> Result<Self, ParseError> { 557 + if suffix.is_some() { 558 + return Err(ParseError::InvalidResource( 559 + "atproto scope does not accept suffixes".to_string(), 560 + )); 561 + } 562 + Ok(Scope::Atproto) 563 + } 564 + 565 + fn parse_transition(suffix: Option<&str>) -> Result<Self, ParseError> { 566 + let scope = match suffix { 567 + Some("generic") => TransitionScope::Generic, 568 + Some("email") => TransitionScope::Email, 569 + Some(other) => return Err(ParseError::InvalidResource(other.to_string())), 570 + None => return Err(ParseError::MissingResource), 571 + }; 572 + 573 + Ok(Scope::Transition(scope)) 574 + } 575 + 576 + fn parse_openid(suffix: Option<&str>) -> Result<Self, ParseError> { 577 + if suffix.is_some() { 578 + return Err(ParseError::InvalidResource( 579 + "openid scope does not accept suffixes".to_string(), 580 + )); 581 + } 582 + Ok(Scope::OpenId) 583 + } 584 + 585 + fn parse_profile(suffix: Option<&str>) -> Result<Self, ParseError> { 586 + if suffix.is_some() { 587 + return Err(ParseError::InvalidResource( 588 + "profile scope does not accept suffixes".to_string(), 589 + )); 590 + } 591 + Ok(Scope::Profile) 592 + } 593 + 594 + fn parse_email(suffix: Option<&str>) -> Result<Self, ParseError> { 595 + if suffix.is_some() { 596 + return Err(ParseError::InvalidResource( 597 + "email scope does not accept suffixes".to_string(), 598 + )); 599 + } 600 + Ok(Scope::Email) 601 + } 602 + 603 + /// Convert the scope to its normalized string representation 604 + pub fn to_string_normalized(&self) -> String { 605 + match self { 606 + Scope::Account(scope) => { 607 + let resource = match scope.resource { 608 + AccountResource::Email => "email", 609 + AccountResource::Repo => "repo", 610 + AccountResource::Status => "status", 611 + }; 612 + 613 + match scope.action { 614 + AccountAction::Read => format!("account:{}", resource), 615 + AccountAction::Manage => format!("account:{}?action=manage", resource), 616 + } 617 + } 618 + Scope::Identity(scope) => match scope { 619 + IdentityScope::Handle => "identity:handle".to_string(), 620 + IdentityScope::All => "identity:*".to_string(), 621 + }, 622 + Scope::Blob(scope) => { 623 + if scope.accept.len() == 1 { 624 + if let Some(pattern) = scope.accept.iter().next() { 625 + match pattern { 626 + MimePattern::All => "blob:*/*".to_string(), 627 + MimePattern::TypeWildcard(t) => format!("blob:{}/*", t), 628 + MimePattern::Exact(mime) => format!("blob:{}", mime), 629 + } 630 + } else { 631 + "blob:*/*".to_string() 632 + } 633 + } else { 634 + let mut params = Vec::new(); 635 + for pattern in &scope.accept { 636 + match pattern { 637 + MimePattern::All => params.push("accept=*/*".to_string()), 638 + MimePattern::TypeWildcard(t) => params.push(format!("accept={}/*", t)), 639 + MimePattern::Exact(mime) => params.push(format!("accept={}", mime)), 640 + } 641 + } 642 + params.sort(); 643 + format!("blob?{}", params.join("&")) 644 + } 645 + } 646 + Scope::Repo(scope) => { 647 + let collection = match &scope.collection { 648 + RepoCollection::All => "*", 649 + RepoCollection::Nsid(nsid) => nsid, 650 + }; 651 + 652 + if scope.actions.len() == 3 { 653 + format!("repo:{}", collection) 654 + } else { 655 + let mut params = Vec::new(); 656 + for action in &scope.actions { 657 + match action { 658 + RepoAction::Create => params.push("action=create"), 659 + RepoAction::Update => params.push("action=update"), 660 + RepoAction::Delete => params.push("action=delete"), 661 + } 662 + } 663 + format!("repo:{}?{}", collection, params.join("&")) 664 + } 665 + } 666 + Scope::Rpc(scope) => { 667 + if scope.lxm.len() == 1 668 + && scope.lxm.contains(&RpcLexicon::All) 669 + && scope.aud.len() == 1 670 + && scope.aud.contains(&RpcAudience::All) 671 + { 672 + "rpc:*".to_string() 673 + } else if scope.lxm.len() == 1 674 + && scope.aud.len() == 1 675 + && scope.aud.contains(&RpcAudience::All) 676 + { 677 + if let Some(lxm) = scope.lxm.iter().next() { 678 + match lxm { 679 + RpcLexicon::All => "rpc:*".to_string(), 680 + RpcLexicon::Nsid(nsid) => format!("rpc:{}", nsid), 681 + } 682 + } else { 683 + "rpc:*".to_string() 684 + } 685 + } else { 686 + let mut params = Vec::new(); 687 + 688 + for lxm in &scope.lxm { 689 + match lxm { 690 + RpcLexicon::All => params.push("lxm=*".to_string()), 691 + RpcLexicon::Nsid(nsid) => params.push(format!("lxm={}", nsid)), 692 + } 693 + } 694 + 695 + for aud in &scope.aud { 696 + match aud { 697 + RpcAudience::All => params.push("aud=*".to_string()), 698 + RpcAudience::Did(did) => params.push(format!("aud={}", did)), 699 + } 700 + } 701 + 702 + params.sort(); 703 + 704 + if params.is_empty() { 705 + "rpc:*".to_string() 706 + } else { 707 + format!("rpc?{}", params.join("&")) 708 + } 709 + } 710 + } 711 + Scope::Atproto => "atproto".to_string(), 712 + Scope::Transition(scope) => match scope { 713 + TransitionScope::Generic => "transition:generic".to_string(), 714 + TransitionScope::Email => "transition:email".to_string(), 715 + }, 716 + Scope::OpenId => "openid".to_string(), 717 + Scope::Profile => "profile".to_string(), 718 + Scope::Email => "email".to_string(), 719 + } 720 + } 721 + 722 + /// Check if this scope grants the permissions of another scope 723 + pub fn grants(&self, other: &Scope) -> bool { 724 + match (self, other) { 725 + // Atproto only grants itself (it's a required scope, not a permission grant) 726 + (Scope::Atproto, Scope::Atproto) => true, 727 + (Scope::Atproto, _) => false, 728 + // Nothing else grants atproto 729 + (_, Scope::Atproto) => false, 730 + // Transition scopes only grant themselves 731 + (Scope::Transition(a), Scope::Transition(b)) => a == b, 732 + // Other scopes don't grant transition scopes 733 + (_, Scope::Transition(_)) => false, 734 + (Scope::Transition(_), _) => false, 735 + // OpenID Connect scopes only grant themselves 736 + (Scope::OpenId, Scope::OpenId) => true, 737 + (Scope::OpenId, _) => false, 738 + (_, Scope::OpenId) => false, 739 + (Scope::Profile, Scope::Profile) => true, 740 + (Scope::Profile, _) => false, 741 + (_, Scope::Profile) => false, 742 + (Scope::Email, Scope::Email) => true, 743 + (Scope::Email, _) => false, 744 + (_, Scope::Email) => false, 745 + (Scope::Account(a), Scope::Account(b)) => { 746 + a.resource == b.resource 747 + && match (a.action, b.action) { 748 + (AccountAction::Manage, _) => true, 749 + (AccountAction::Read, AccountAction::Read) => true, 750 + _ => false, 751 + } 752 + } 753 + (Scope::Identity(a), Scope::Identity(b)) => match (a, b) { 754 + (IdentityScope::All, _) => true, 755 + (IdentityScope::Handle, IdentityScope::Handle) => true, 756 + _ => false, 757 + }, 758 + (Scope::Blob(a), Scope::Blob(b)) => { 759 + for b_pattern in &b.accept { 760 + let mut granted = false; 761 + for a_pattern in &a.accept { 762 + if a_pattern.grants(b_pattern) { 763 + granted = true; 764 + break; 765 + } 766 + } 767 + if !granted { 768 + return false; 769 + } 770 + } 771 + true 772 + } 773 + (Scope::Repo(a), Scope::Repo(b)) => { 774 + let collection_match = match (&a.collection, &b.collection) { 775 + (RepoCollection::All, _) => true, 776 + (RepoCollection::Nsid(a_nsid), RepoCollection::Nsid(b_nsid)) => { 777 + a_nsid == b_nsid 778 + } 779 + _ => false, 780 + }; 781 + 782 + if !collection_match { 783 + return false; 784 + } 785 + 786 + b.actions.is_subset(&a.actions) || a.actions.len() == 3 787 + } 788 + (Scope::Rpc(a), Scope::Rpc(b)) => { 789 + let lxm_match = if a.lxm.contains(&RpcLexicon::All) { 790 + true 791 + } else { 792 + b.lxm.iter().all(|b_lxm| match b_lxm { 793 + RpcLexicon::All => false, 794 + RpcLexicon::Nsid(_) => a.lxm.contains(b_lxm), 795 + }) 796 + }; 797 + 798 + let aud_match = if a.aud.contains(&RpcAudience::All) { 799 + true 800 + } else { 801 + b.aud.iter().all(|b_aud| match b_aud { 802 + RpcAudience::All => false, 803 + RpcAudience::Did(_) => a.aud.contains(b_aud), 804 + }) 805 + }; 806 + 807 + lxm_match && aud_match 808 + } 809 + _ => false, 810 + } 811 + } 812 + } 813 + 814 + impl MimePattern { 815 + fn grants(&self, other: &MimePattern) -> bool { 816 + match (self, other) { 817 + (MimePattern::All, _) => true, 818 + (MimePattern::TypeWildcard(a_type), MimePattern::TypeWildcard(b_type)) => { 819 + a_type == b_type 820 + } 821 + (MimePattern::TypeWildcard(a_type), MimePattern::Exact(b_mime)) => { 822 + b_mime.starts_with(&format!("{}/", a_type)) 823 + } 824 + (MimePattern::Exact(a), MimePattern::Exact(b)) => a == b, 825 + _ => false, 826 + } 827 + } 828 + } 829 + 830 + impl FromStr for MimePattern { 831 + type Err = ParseError; 832 + 833 + fn from_str(s: &str) -> Result<Self, Self::Err> { 834 + if s == "*/*" { 835 + Ok(MimePattern::All) 836 + } else if s.ends_with("/*") { 837 + Ok(MimePattern::TypeWildcard(s[..s.len() - 2].to_string())) 838 + } else if s.contains('/') { 839 + Ok(MimePattern::Exact(s.to_string())) 840 + } else { 841 + Err(ParseError::InvalidMimeType(s.to_string())) 842 + } 843 + } 844 + } 845 + 846 + impl FromStr for Scope { 847 + type Err = ParseError; 848 + 849 + fn from_str(s: &str) -> Result<Self, Self::Err> { 850 + Self::parse(s) 851 + } 852 + } 853 + 854 + impl fmt::Display for Scope { 855 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 856 + write!(f, "{}", self.to_string_normalized()) 857 + } 858 + } 859 + 860 + /// Parse a query string into a map of keys to lists of values 861 + fn parse_query_string(query: &str) -> BTreeMap<String, Vec<String>> { 862 + let mut params = BTreeMap::new(); 863 + 864 + for pair in query.split('&') { 865 + if let Some(pos) = pair.find('=') { 866 + let key = &pair[..pos]; 867 + let value = &pair[pos + 1..]; 868 + params 869 + .entry(key.to_string()) 870 + .or_insert_with(Vec::new) 871 + .push(value.to_string()); 872 + } 873 + } 874 + 875 + params 876 + } 877 + 878 + /// Error type for scope parsing 879 + #[derive(Debug, Clone, PartialEq, Eq)] 880 + pub enum ParseError { 881 + /// Unknown scope prefix 882 + UnknownPrefix(String), 883 + /// Missing required resource 884 + MissingResource, 885 + /// Invalid resource type 886 + InvalidResource(String), 887 + /// Invalid action type 888 + InvalidAction(String), 889 + /// Invalid MIME type 890 + InvalidMimeType(String), 891 + } 892 + 893 + impl fmt::Display for ParseError { 894 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 895 + match self { 896 + ParseError::UnknownPrefix(prefix) => write!(f, "Unknown scope prefix: {}", prefix), 897 + ParseError::MissingResource => write!(f, "Missing required resource"), 898 + ParseError::InvalidResource(resource) => write!(f, "Invalid resource: {}", resource), 899 + ParseError::InvalidAction(action) => write!(f, "Invalid action: {}", action), 900 + ParseError::InvalidMimeType(mime) => write!(f, "Invalid MIME type: {}", mime), 901 + } 902 + } 903 + } 904 + 905 + impl std::error::Error for ParseError {} 906 + 907 + #[cfg(test)] 908 + mod tests { 909 + use super::*; 910 + 911 + #[test] 912 + fn test_account_scope_parsing() { 913 + let scope = Scope::parse("account:email").unwrap(); 914 + assert_eq!( 915 + scope, 916 + Scope::Account(AccountScope { 917 + resource: AccountResource::Email, 918 + action: AccountAction::Read, 919 + }) 920 + ); 921 + 922 + let scope = Scope::parse("account:repo?action=manage").unwrap(); 923 + assert_eq!( 924 + scope, 925 + Scope::Account(AccountScope { 926 + resource: AccountResource::Repo, 927 + action: AccountAction::Manage, 928 + }) 929 + ); 930 + 931 + let scope = Scope::parse("account:status?action=read").unwrap(); 932 + assert_eq!( 933 + scope, 934 + Scope::Account(AccountScope { 935 + resource: AccountResource::Status, 936 + action: AccountAction::Read, 937 + }) 938 + ); 939 + } 940 + 941 + #[test] 942 + fn test_identity_scope_parsing() { 943 + let scope = Scope::parse("identity:handle").unwrap(); 944 + assert_eq!(scope, Scope::Identity(IdentityScope::Handle)); 945 + 946 + let scope = Scope::parse("identity:*").unwrap(); 947 + assert_eq!(scope, Scope::Identity(IdentityScope::All)); 948 + } 949 + 950 + #[test] 951 + fn test_blob_scope_parsing() { 952 + let scope = Scope::parse("blob:*/*").unwrap(); 953 + let mut accept = BTreeSet::new(); 954 + accept.insert(MimePattern::All); 955 + assert_eq!(scope, Scope::Blob(BlobScope { accept })); 956 + 957 + let scope = Scope::parse("blob:image/png").unwrap(); 958 + let mut accept = BTreeSet::new(); 959 + accept.insert(MimePattern::Exact("image/png".to_string())); 960 + assert_eq!(scope, Scope::Blob(BlobScope { accept })); 961 + 962 + let scope = Scope::parse("blob?accept=image/png&accept=image/jpeg").unwrap(); 963 + let mut accept = BTreeSet::new(); 964 + accept.insert(MimePattern::Exact("image/png".to_string())); 965 + accept.insert(MimePattern::Exact("image/jpeg".to_string())); 966 + assert_eq!(scope, Scope::Blob(BlobScope { accept })); 967 + 968 + let scope = Scope::parse("blob:image/*").unwrap(); 969 + let mut accept = BTreeSet::new(); 970 + accept.insert(MimePattern::TypeWildcard("image".to_string())); 971 + assert_eq!(scope, Scope::Blob(BlobScope { accept })); 972 + } 973 + 974 + #[test] 975 + fn test_repo_scope_parsing() { 976 + let scope = Scope::parse("repo:*?action=create").unwrap(); 977 + let mut actions = BTreeSet::new(); 978 + actions.insert(RepoAction::Create); 979 + assert_eq!( 980 + scope, 981 + Scope::Repo(RepoScope { 982 + collection: RepoCollection::All, 983 + actions, 984 + }) 985 + ); 986 + 987 + let scope = Scope::parse("repo:foo.bar?action=create&action=update").unwrap(); 988 + let mut actions = BTreeSet::new(); 989 + actions.insert(RepoAction::Create); 990 + actions.insert(RepoAction::Update); 991 + assert_eq!( 992 + scope, 993 + Scope::Repo(RepoScope { 994 + collection: RepoCollection::Nsid("foo.bar".to_string()), 995 + actions, 996 + }) 997 + ); 998 + 999 + let scope = Scope::parse("repo:foo.bar").unwrap(); 1000 + let mut actions = BTreeSet::new(); 1001 + actions.insert(RepoAction::Create); 1002 + actions.insert(RepoAction::Update); 1003 + actions.insert(RepoAction::Delete); 1004 + assert_eq!( 1005 + scope, 1006 + Scope::Repo(RepoScope { 1007 + collection: RepoCollection::Nsid("foo.bar".to_string()), 1008 + actions, 1009 + }) 1010 + ); 1011 + } 1012 + 1013 + #[test] 1014 + fn test_rpc_scope_parsing() { 1015 + let scope = Scope::parse("rpc:*").unwrap(); 1016 + let mut lxm = BTreeSet::new(); 1017 + let mut aud = BTreeSet::new(); 1018 + lxm.insert(RpcLexicon::All); 1019 + aud.insert(RpcAudience::All); 1020 + assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud })); 1021 + 1022 + let scope = Scope::parse("rpc:com.example.service").unwrap(); 1023 + let mut lxm = BTreeSet::new(); 1024 + let mut aud = BTreeSet::new(); 1025 + lxm.insert(RpcLexicon::Nsid("com.example.service".to_string())); 1026 + aud.insert(RpcAudience::All); 1027 + assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud })); 1028 + 1029 + let scope = Scope::parse("rpc:com.example.service?aud=did:example:123").unwrap(); 1030 + let mut lxm = BTreeSet::new(); 1031 + let mut aud = BTreeSet::new(); 1032 + lxm.insert(RpcLexicon::Nsid("com.example.service".to_string())); 1033 + aud.insert(RpcAudience::Did("did:example:123".to_string())); 1034 + assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud })); 1035 + 1036 + let scope = 1037 + Scope::parse("rpc?lxm=com.example.method1&lxm=com.example.method2&aud=did:example:123") 1038 + .unwrap(); 1039 + let mut lxm = BTreeSet::new(); 1040 + let mut aud = BTreeSet::new(); 1041 + lxm.insert(RpcLexicon::Nsid("com.example.method1".to_string())); 1042 + lxm.insert(RpcLexicon::Nsid("com.example.method2".to_string())); 1043 + aud.insert(RpcAudience::Did("did:example:123".to_string())); 1044 + assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud })); 1045 + } 1046 + 1047 + #[test] 1048 + fn test_scope_normalization() { 1049 + let tests = vec![ 1050 + ("account:email", "account:email"), 1051 + ("account:email?action=read", "account:email"), 1052 + ("account:email?action=manage", "account:email?action=manage"), 1053 + ("blob:image/png", "blob:image/png"), 1054 + ( 1055 + "blob?accept=image/jpeg&accept=image/png", 1056 + "blob?accept=image/jpeg&accept=image/png", 1057 + ), 1058 + ("repo:foo.bar", "repo:foo.bar"), 1059 + ("repo:foo.bar?action=create", "repo:foo.bar?action=create"), 1060 + ("rpc:*", "rpc:*"), 1061 + ]; 1062 + 1063 + for (input, expected) in tests { 1064 + let scope = Scope::parse(input).unwrap(); 1065 + assert_eq!(scope.to_string_normalized(), expected); 1066 + } 1067 + } 1068 + 1069 + #[test] 1070 + fn test_account_scope_grants() { 1071 + let manage = Scope::parse("account:email?action=manage").unwrap(); 1072 + let read = Scope::parse("account:email?action=read").unwrap(); 1073 + let other_read = Scope::parse("account:repo?action=read").unwrap(); 1074 + 1075 + assert!(manage.grants(&read)); 1076 + assert!(manage.grants(&manage)); 1077 + assert!(!read.grants(&manage)); 1078 + assert!(read.grants(&read)); 1079 + assert!(!read.grants(&other_read)); 1080 + } 1081 + 1082 + #[test] 1083 + fn test_identity_scope_grants() { 1084 + let all = Scope::parse("identity:*").unwrap(); 1085 + let handle = Scope::parse("identity:handle").unwrap(); 1086 + 1087 + assert!(all.grants(&handle)); 1088 + assert!(all.grants(&all)); 1089 + assert!(!handle.grants(&all)); 1090 + assert!(handle.grants(&handle)); 1091 + } 1092 + 1093 + #[test] 1094 + fn test_blob_scope_grants() { 1095 + let all = Scope::parse("blob:*/*").unwrap(); 1096 + let image_all = Scope::parse("blob:image/*").unwrap(); 1097 + let image_png = Scope::parse("blob:image/png").unwrap(); 1098 + let text_plain = Scope::parse("blob:text/plain").unwrap(); 1099 + 1100 + assert!(all.grants(&image_all)); 1101 + assert!(all.grants(&image_png)); 1102 + assert!(all.grants(&text_plain)); 1103 + assert!(image_all.grants(&image_png)); 1104 + assert!(!image_all.grants(&text_plain)); 1105 + assert!(!image_png.grants(&image_all)); 1106 + } 1107 + 1108 + #[test] 1109 + fn test_repo_scope_grants() { 1110 + let all_all = Scope::parse("repo:*").unwrap(); 1111 + let all_create = Scope::parse("repo:*?action=create").unwrap(); 1112 + let specific_all = Scope::parse("repo:foo.bar").unwrap(); 1113 + let specific_create = Scope::parse("repo:foo.bar?action=create").unwrap(); 1114 + let other_create = Scope::parse("repo:baz.qux?action=create").unwrap(); 1115 + 1116 + assert!(all_all.grants(&all_create)); 1117 + assert!(all_all.grants(&specific_all)); 1118 + assert!(all_all.grants(&specific_create)); 1119 + assert!(all_create.grants(&all_create)); 1120 + assert!(!all_create.grants(&specific_all)); 1121 + assert!(specific_all.grants(&specific_create)); 1122 + assert!(!specific_create.grants(&specific_all)); 1123 + assert!(!specific_create.grants(&other_create)); 1124 + } 1125 + 1126 + #[test] 1127 + fn test_rpc_scope_grants() { 1128 + let all = Scope::parse("rpc:*").unwrap(); 1129 + let specific_lxm = Scope::parse("rpc:com.example.service").unwrap(); 1130 + let specific_both = Scope::parse("rpc:com.example.service?aud=did:example:123").unwrap(); 1131 + 1132 + assert!(all.grants(&specific_lxm)); 1133 + assert!(all.grants(&specific_both)); 1134 + assert!(specific_lxm.grants(&specific_both)); 1135 + assert!(!specific_both.grants(&specific_lxm)); 1136 + assert!(!specific_both.grants(&all)); 1137 + } 1138 + 1139 + #[test] 1140 + fn test_cross_scope_grants() { 1141 + let account = Scope::parse("account:email").unwrap(); 1142 + let identity = Scope::parse("identity:handle").unwrap(); 1143 + 1144 + assert!(!account.grants(&identity)); 1145 + assert!(!identity.grants(&account)); 1146 + } 1147 + 1148 + #[test] 1149 + fn test_parse_errors() { 1150 + assert!(matches!( 1151 + Scope::parse("unknown:test"), 1152 + Err(ParseError::UnknownPrefix(_)) 1153 + )); 1154 + 1155 + assert!(matches!( 1156 + Scope::parse("account"), 1157 + Err(ParseError::MissingResource) 1158 + )); 1159 + 1160 + assert!(matches!( 1161 + Scope::parse("account:invalid"), 1162 + Err(ParseError::InvalidResource(_)) 1163 + )); 1164 + 1165 + assert!(matches!( 1166 + Scope::parse("account:email?action=invalid"), 1167 + Err(ParseError::InvalidAction(_)) 1168 + )); 1169 + } 1170 + 1171 + #[test] 1172 + fn test_query_parameter_sorting() { 1173 + let scope = 1174 + Scope::parse("blob?accept=image/png&accept=application/pdf&accept=image/jpeg").unwrap(); 1175 + let normalized = scope.to_string_normalized(); 1176 + assert!(normalized.contains("accept=application/pdf")); 1177 + assert!(normalized.contains("accept=image/jpeg")); 1178 + assert!(normalized.contains("accept=image/png")); 1179 + let pdf_pos = normalized.find("accept=application/pdf").unwrap(); 1180 + let jpeg_pos = normalized.find("accept=image/jpeg").unwrap(); 1181 + let png_pos = normalized.find("accept=image/png").unwrap(); 1182 + assert!(pdf_pos < jpeg_pos); 1183 + assert!(jpeg_pos < png_pos); 1184 + } 1185 + 1186 + #[test] 1187 + fn test_repo_action_wildcard() { 1188 + let scope = Scope::parse("repo:foo.bar?action=*").unwrap(); 1189 + let mut actions = BTreeSet::new(); 1190 + actions.insert(RepoAction::Create); 1191 + actions.insert(RepoAction::Update); 1192 + actions.insert(RepoAction::Delete); 1193 + assert_eq!( 1194 + scope, 1195 + Scope::Repo(RepoScope { 1196 + collection: RepoCollection::Nsid("foo.bar".to_string()), 1197 + actions, 1198 + }) 1199 + ); 1200 + } 1201 + 1202 + #[test] 1203 + fn test_multiple_blob_accepts() { 1204 + let scope = Scope::parse("blob?accept=image/*&accept=text/plain").unwrap(); 1205 + assert!(scope.grants(&Scope::parse("blob:image/png").unwrap())); 1206 + assert!(scope.grants(&Scope::parse("blob:text/plain").unwrap())); 1207 + assert!(!scope.grants(&Scope::parse("blob:application/json").unwrap())); 1208 + } 1209 + 1210 + #[test] 1211 + fn test_rpc_default_wildcards() { 1212 + let scope = Scope::parse("rpc").unwrap(); 1213 + let mut lxm = BTreeSet::new(); 1214 + let mut aud = BTreeSet::new(); 1215 + lxm.insert(RpcLexicon::All); 1216 + aud.insert(RpcAudience::All); 1217 + assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud })); 1218 + } 1219 + 1220 + #[test] 1221 + fn test_atproto_scope_parsing() { 1222 + let scope = Scope::parse("atproto").unwrap(); 1223 + assert_eq!(scope, Scope::Atproto); 1224 + 1225 + // Atproto should not accept suffixes 1226 + assert!(Scope::parse("atproto:something").is_err()); 1227 + assert!(Scope::parse("atproto?param=value").is_err()); 1228 + } 1229 + 1230 + #[test] 1231 + fn test_transition_scope_parsing() { 1232 + let scope = Scope::parse("transition:generic").unwrap(); 1233 + assert_eq!(scope, Scope::Transition(TransitionScope::Generic)); 1234 + 1235 + let scope = Scope::parse("transition:email").unwrap(); 1236 + assert_eq!(scope, Scope::Transition(TransitionScope::Email)); 1237 + 1238 + // Test invalid transition types 1239 + assert!(matches!( 1240 + Scope::parse("transition:invalid"), 1241 + Err(ParseError::InvalidResource(_)) 1242 + )); 1243 + 1244 + // Test missing suffix 1245 + assert!(matches!( 1246 + Scope::parse("transition"), 1247 + Err(ParseError::MissingResource) 1248 + )); 1249 + 1250 + // Test transition doesn't accept query parameters 1251 + assert!(matches!( 1252 + Scope::parse("transition:generic?param=value"), 1253 + Err(ParseError::InvalidResource(_)) 1254 + )); 1255 + } 1256 + 1257 + #[test] 1258 + fn test_atproto_scope_normalization() { 1259 + let scope = Scope::parse("atproto").unwrap(); 1260 + assert_eq!(scope.to_string_normalized(), "atproto"); 1261 + } 1262 + 1263 + #[test] 1264 + fn test_transition_scope_normalization() { 1265 + let tests = vec![ 1266 + ("transition:generic", "transition:generic"), 1267 + ("transition:email", "transition:email"), 1268 + ]; 1269 + 1270 + for (input, expected) in tests { 1271 + let scope = Scope::parse(input).unwrap(); 1272 + assert_eq!(scope.to_string_normalized(), expected); 1273 + } 1274 + } 1275 + 1276 + #[test] 1277 + fn test_atproto_scope_grants() { 1278 + let atproto = Scope::parse("atproto").unwrap(); 1279 + let account = Scope::parse("account:email").unwrap(); 1280 + let identity = Scope::parse("identity:handle").unwrap(); 1281 + let blob = Scope::parse("blob:image/png").unwrap(); 1282 + let repo = Scope::parse("repo:foo.bar").unwrap(); 1283 + let rpc = Scope::parse("rpc:com.example.service").unwrap(); 1284 + let transition_generic = Scope::parse("transition:generic").unwrap(); 1285 + let transition_email = Scope::parse("transition:email").unwrap(); 1286 + 1287 + // Atproto only grants itself (it's a required scope, not a permission grant) 1288 + assert!(atproto.grants(&atproto)); 1289 + assert!(!atproto.grants(&account)); 1290 + assert!(!atproto.grants(&identity)); 1291 + assert!(!atproto.grants(&blob)); 1292 + assert!(!atproto.grants(&repo)); 1293 + assert!(!atproto.grants(&rpc)); 1294 + assert!(!atproto.grants(&transition_generic)); 1295 + assert!(!atproto.grants(&transition_email)); 1296 + 1297 + // Nothing else grants atproto 1298 + assert!(!account.grants(&atproto)); 1299 + assert!(!identity.grants(&atproto)); 1300 + assert!(!blob.grants(&atproto)); 1301 + assert!(!repo.grants(&atproto)); 1302 + assert!(!rpc.grants(&atproto)); 1303 + assert!(!transition_generic.grants(&atproto)); 1304 + assert!(!transition_email.grants(&atproto)); 1305 + } 1306 + 1307 + #[test] 1308 + fn test_transition_scope_grants() { 1309 + let transition_generic = Scope::parse("transition:generic").unwrap(); 1310 + let transition_email = Scope::parse("transition:email").unwrap(); 1311 + let account = Scope::parse("account:email").unwrap(); 1312 + 1313 + // Transition scopes only grant themselves 1314 + assert!(transition_generic.grants(&transition_generic)); 1315 + assert!(transition_email.grants(&transition_email)); 1316 + assert!(!transition_generic.grants(&transition_email)); 1317 + assert!(!transition_email.grants(&transition_generic)); 1318 + 1319 + // Transition scopes don't grant other scope types 1320 + assert!(!transition_generic.grants(&account)); 1321 + assert!(!transition_email.grants(&account)); 1322 + 1323 + // Other scopes don't grant transition scopes 1324 + assert!(!account.grants(&transition_generic)); 1325 + assert!(!account.grants(&transition_email)); 1326 + } 1327 + 1328 + #[test] 1329 + fn test_parse_multiple() { 1330 + // Test parsing multiple scopes 1331 + let scopes = Scope::parse_multiple("atproto repo:*").unwrap(); 1332 + assert_eq!(scopes.len(), 2); 1333 + assert_eq!(scopes[0], Scope::Atproto); 1334 + assert_eq!( 1335 + scopes[1], 1336 + Scope::Repo(RepoScope { 1337 + collection: RepoCollection::All, 1338 + actions: { 1339 + let mut actions = BTreeSet::new(); 1340 + actions.insert(RepoAction::Create); 1341 + actions.insert(RepoAction::Update); 1342 + actions.insert(RepoAction::Delete); 1343 + actions 1344 + } 1345 + }) 1346 + ); 1347 + 1348 + // Test with more scopes 1349 + let scopes = Scope::parse_multiple("account:email identity:handle blob:image/png").unwrap(); 1350 + assert_eq!(scopes.len(), 3); 1351 + assert!(matches!(scopes[0], Scope::Account(_))); 1352 + assert!(matches!(scopes[1], Scope::Identity(_))); 1353 + assert!(matches!(scopes[2], Scope::Blob(_))); 1354 + 1355 + // Test with complex scopes 1356 + let scopes = Scope::parse_multiple( 1357 + "account:email?action=manage repo:foo.bar?action=create transition:email", 1358 + ) 1359 + .unwrap(); 1360 + assert_eq!(scopes.len(), 3); 1361 + 1362 + // Test empty string 1363 + let scopes = Scope::parse_multiple("").unwrap(); 1364 + assert_eq!(scopes.len(), 0); 1365 + 1366 + // Test whitespace only 1367 + let scopes = Scope::parse_multiple(" ").unwrap(); 1368 + assert_eq!(scopes.len(), 0); 1369 + 1370 + // Test with extra whitespace 1371 + let scopes = Scope::parse_multiple(" atproto repo:* ").unwrap(); 1372 + assert_eq!(scopes.len(), 2); 1373 + 1374 + // Test single scope 1375 + let scopes = Scope::parse_multiple("atproto").unwrap(); 1376 + assert_eq!(scopes.len(), 1); 1377 + assert_eq!(scopes[0], Scope::Atproto); 1378 + 1379 + // Test error propagation 1380 + assert!(Scope::parse_multiple("atproto invalid:scope").is_err()); 1381 + assert!(Scope::parse_multiple("account:invalid repo:*").is_err()); 1382 + } 1383 + 1384 + #[test] 1385 + fn test_parse_multiple_reduced() { 1386 + // Test repo scope reduction - wildcard grants specific 1387 + let scopes = Scope::parse_multiple_reduced("atproto repo:foo.bar repo:*").unwrap(); 1388 + assert_eq!(scopes.len(), 2); 1389 + assert!(scopes.contains(&Scope::Atproto)); 1390 + assert!(scopes.contains(&Scope::Repo(RepoScope { 1391 + collection: RepoCollection::All, 1392 + actions: { 1393 + let mut actions = BTreeSet::new(); 1394 + actions.insert(RepoAction::Create); 1395 + actions.insert(RepoAction::Update); 1396 + actions.insert(RepoAction::Delete); 1397 + actions 1398 + } 1399 + }))); 1400 + 1401 + // Test reverse order - should get same result 1402 + let scopes = Scope::parse_multiple_reduced("atproto repo:* repo:foo.bar").unwrap(); 1403 + assert_eq!(scopes.len(), 2); 1404 + assert!(scopes.contains(&Scope::Atproto)); 1405 + assert!(scopes.contains(&Scope::Repo(RepoScope { 1406 + collection: RepoCollection::All, 1407 + actions: { 1408 + let mut actions = BTreeSet::new(); 1409 + actions.insert(RepoAction::Create); 1410 + actions.insert(RepoAction::Update); 1411 + actions.insert(RepoAction::Delete); 1412 + actions 1413 + } 1414 + }))); 1415 + 1416 + // Test account scope reduction - manage grants read 1417 + let scopes = 1418 + Scope::parse_multiple_reduced("account:email account:email?action=manage").unwrap(); 1419 + assert_eq!(scopes.len(), 1); 1420 + assert_eq!( 1421 + scopes[0], 1422 + Scope::Account(AccountScope { 1423 + resource: AccountResource::Email, 1424 + action: AccountAction::Manage, 1425 + }) 1426 + ); 1427 + 1428 + // Test identity scope reduction - wildcard grants specific 1429 + let scopes = Scope::parse_multiple_reduced("identity:handle identity:*").unwrap(); 1430 + assert_eq!(scopes.len(), 1); 1431 + assert_eq!(scopes[0], Scope::Identity(IdentityScope::All)); 1432 + 1433 + // Test blob scope reduction - wildcard grants specific 1434 + let scopes = Scope::parse_multiple_reduced("blob:image/png blob:image/* blob:*/*").unwrap(); 1435 + assert_eq!(scopes.len(), 1); 1436 + let mut accept = BTreeSet::new(); 1437 + accept.insert(MimePattern::All); 1438 + assert_eq!(scopes[0], Scope::Blob(BlobScope { accept })); 1439 + 1440 + // Test no reduction needed - different scope types 1441 + let scopes = 1442 + Scope::parse_multiple_reduced("account:email identity:handle blob:image/png").unwrap(); 1443 + assert_eq!(scopes.len(), 3); 1444 + 1445 + // Test repo action reduction 1446 + let scopes = 1447 + Scope::parse_multiple_reduced("repo:foo.bar?action=create repo:foo.bar").unwrap(); 1448 + assert_eq!(scopes.len(), 1); 1449 + assert_eq!( 1450 + scopes[0], 1451 + Scope::Repo(RepoScope { 1452 + collection: RepoCollection::Nsid("foo.bar".to_string()), 1453 + actions: { 1454 + let mut actions = BTreeSet::new(); 1455 + actions.insert(RepoAction::Create); 1456 + actions.insert(RepoAction::Update); 1457 + actions.insert(RepoAction::Delete); 1458 + actions 1459 + } 1460 + }) 1461 + ); 1462 + 1463 + // Test RPC scope reduction 1464 + let scopes = Scope::parse_multiple_reduced( 1465 + "rpc:com.example.service?aud=did:example:123 rpc:com.example.service rpc:*", 1466 + ) 1467 + .unwrap(); 1468 + assert_eq!(scopes.len(), 1); 1469 + assert_eq!( 1470 + scopes[0], 1471 + Scope::Rpc(RpcScope { 1472 + lxm: { 1473 + let mut lxm = BTreeSet::new(); 1474 + lxm.insert(RpcLexicon::All); 1475 + lxm 1476 + }, 1477 + aud: { 1478 + let mut aud = BTreeSet::new(); 1479 + aud.insert(RpcAudience::All); 1480 + aud 1481 + } 1482 + }) 1483 + ); 1484 + 1485 + // Test duplicate removal 1486 + let scopes = Scope::parse_multiple_reduced("atproto atproto atproto").unwrap(); 1487 + assert_eq!(scopes.len(), 1); 1488 + assert_eq!(scopes[0], Scope::Atproto); 1489 + 1490 + // Test transition scopes - only grant themselves 1491 + let scopes = Scope::parse_multiple_reduced("transition:generic transition:email").unwrap(); 1492 + assert_eq!(scopes.len(), 2); 1493 + assert!(scopes.contains(&Scope::Transition(TransitionScope::Generic))); 1494 + assert!(scopes.contains(&Scope::Transition(TransitionScope::Email))); 1495 + 1496 + // Test empty input 1497 + let scopes = Scope::parse_multiple_reduced("").unwrap(); 1498 + assert_eq!(scopes.len(), 0); 1499 + 1500 + // Test complex scenario with multiple reductions 1501 + let scopes = Scope::parse_multiple_reduced( 1502 + "account:email?action=manage account:email account:repo account:repo?action=read identity:* identity:handle" 1503 + ).unwrap(); 1504 + assert_eq!(scopes.len(), 3); 1505 + // Should have: account:email?action=manage, account:repo, identity:* 1506 + assert!(scopes.contains(&Scope::Account(AccountScope { 1507 + resource: AccountResource::Email, 1508 + action: AccountAction::Manage, 1509 + }))); 1510 + assert!(scopes.contains(&Scope::Account(AccountScope { 1511 + resource: AccountResource::Repo, 1512 + action: AccountAction::Read, 1513 + }))); 1514 + assert!(scopes.contains(&Scope::Identity(IdentityScope::All))); 1515 + 1516 + // Test that atproto doesn't grant other scopes (per recent change) 1517 + let scopes = Scope::parse_multiple_reduced("atproto account:email repo:*").unwrap(); 1518 + assert_eq!(scopes.len(), 3); 1519 + assert!(scopes.contains(&Scope::Atproto)); 1520 + assert!(scopes.contains(&Scope::Account(AccountScope { 1521 + resource: AccountResource::Email, 1522 + action: AccountAction::Read, 1523 + }))); 1524 + assert!(scopes.contains(&Scope::Repo(RepoScope { 1525 + collection: RepoCollection::All, 1526 + actions: { 1527 + let mut actions = BTreeSet::new(); 1528 + actions.insert(RepoAction::Create); 1529 + actions.insert(RepoAction::Update); 1530 + actions.insert(RepoAction::Delete); 1531 + actions 1532 + } 1533 + }))); 1534 + } 1535 + 1536 + #[test] 1537 + fn test_openid_connect_scope_parsing() { 1538 + // Test OpenID scope 1539 + let scope = Scope::parse("openid").unwrap(); 1540 + assert_eq!(scope, Scope::OpenId); 1541 + 1542 + // Test Profile scope 1543 + let scope = Scope::parse("profile").unwrap(); 1544 + assert_eq!(scope, Scope::Profile); 1545 + 1546 + // Test Email scope 1547 + let scope = Scope::parse("email").unwrap(); 1548 + assert_eq!(scope, Scope::Email); 1549 + 1550 + // Test that they don't accept suffixes 1551 + assert!(Scope::parse("openid:something").is_err()); 1552 + assert!(Scope::parse("profile:something").is_err()); 1553 + assert!(Scope::parse("email:something").is_err()); 1554 + 1555 + // Test that they don't accept query parameters 1556 + assert!(Scope::parse("openid?param=value").is_err()); 1557 + assert!(Scope::parse("profile?param=value").is_err()); 1558 + assert!(Scope::parse("email?param=value").is_err()); 1559 + } 1560 + 1561 + #[test] 1562 + fn test_openid_connect_scope_normalization() { 1563 + let scope = Scope::parse("openid").unwrap(); 1564 + assert_eq!(scope.to_string_normalized(), "openid"); 1565 + 1566 + let scope = Scope::parse("profile").unwrap(); 1567 + assert_eq!(scope.to_string_normalized(), "profile"); 1568 + 1569 + let scope = Scope::parse("email").unwrap(); 1570 + assert_eq!(scope.to_string_normalized(), "email"); 1571 + } 1572 + 1573 + #[test] 1574 + fn test_openid_connect_scope_grants() { 1575 + let openid = Scope::parse("openid").unwrap(); 1576 + let profile = Scope::parse("profile").unwrap(); 1577 + let email = Scope::parse("email").unwrap(); 1578 + let account = Scope::parse("account:email").unwrap(); 1579 + 1580 + // OpenID Connect scopes only grant themselves 1581 + assert!(openid.grants(&openid)); 1582 + assert!(!openid.grants(&profile)); 1583 + assert!(!openid.grants(&email)); 1584 + assert!(!openid.grants(&account)); 1585 + 1586 + assert!(profile.grants(&profile)); 1587 + assert!(!profile.grants(&openid)); 1588 + assert!(!profile.grants(&email)); 1589 + assert!(!profile.grants(&account)); 1590 + 1591 + assert!(email.grants(&email)); 1592 + assert!(!email.grants(&openid)); 1593 + assert!(!email.grants(&profile)); 1594 + assert!(!email.grants(&account)); 1595 + 1596 + // Other scopes don't grant OpenID Connect scopes 1597 + assert!(!account.grants(&openid)); 1598 + assert!(!account.grants(&profile)); 1599 + assert!(!account.grants(&email)); 1600 + } 1601 + 1602 + #[test] 1603 + fn test_parse_multiple_with_openid_connect() { 1604 + let scopes = Scope::parse_multiple("openid profile email atproto").unwrap(); 1605 + assert_eq!(scopes.len(), 4); 1606 + assert_eq!(scopes[0], Scope::OpenId); 1607 + assert_eq!(scopes[1], Scope::Profile); 1608 + assert_eq!(scopes[2], Scope::Email); 1609 + assert_eq!(scopes[3], Scope::Atproto); 1610 + 1611 + // Test with mixed scopes 1612 + let scopes = Scope::parse_multiple("openid account:email profile repo:*").unwrap(); 1613 + assert_eq!(scopes.len(), 4); 1614 + assert!(scopes.contains(&Scope::OpenId)); 1615 + assert!(scopes.contains(&Scope::Profile)); 1616 + } 1617 + 1618 + #[test] 1619 + fn test_parse_multiple_reduced_with_openid_connect() { 1620 + // OpenID Connect scopes don't grant each other, so no reduction 1621 + let scopes = Scope::parse_multiple_reduced("openid profile email openid").unwrap(); 1622 + assert_eq!(scopes.len(), 3); 1623 + assert!(scopes.contains(&Scope::OpenId)); 1624 + assert!(scopes.contains(&Scope::Profile)); 1625 + assert!(scopes.contains(&Scope::Email)); 1626 + 1627 + // Mixed with other scopes 1628 + let scopes = Scope::parse_multiple_reduced( 1629 + "openid account:email account:email?action=manage profile", 1630 + ) 1631 + .unwrap(); 1632 + assert_eq!(scopes.len(), 3); 1633 + assert!(scopes.contains(&Scope::OpenId)); 1634 + assert!(scopes.contains(&Scope::Profile)); 1635 + assert!(scopes.contains(&Scope::Account(AccountScope { 1636 + resource: AccountResource::Email, 1637 + action: AccountAction::Manage, 1638 + }))); 1639 + } 1640 + 1641 + #[test] 1642 + fn test_serialize_multiple() { 1643 + // Test empty list 1644 + let scopes: Vec<Scope> = vec![]; 1645 + assert_eq!(Scope::serialize_multiple(&scopes), ""); 1646 + 1647 + // Test single scope 1648 + let scopes = vec![Scope::Atproto]; 1649 + assert_eq!(Scope::serialize_multiple(&scopes), "atproto"); 1650 + 1651 + // Test multiple scopes - should be sorted alphabetically 1652 + let scopes = vec![ 1653 + Scope::parse("repo:*").unwrap(), 1654 + Scope::Atproto, 1655 + Scope::parse("account:email").unwrap(), 1656 + ]; 1657 + assert_eq!( 1658 + Scope::serialize_multiple(&scopes), 1659 + "account:email atproto repo:*" 1660 + ); 1661 + 1662 + // Test that sorting is consistent regardless of input order 1663 + let scopes = vec![ 1664 + Scope::parse("identity:handle").unwrap(), 1665 + Scope::parse("blob:image/png").unwrap(), 1666 + Scope::parse("account:repo?action=manage").unwrap(), 1667 + ]; 1668 + assert_eq!( 1669 + Scope::serialize_multiple(&scopes), 1670 + "account:repo?action=manage blob:image/png identity:handle" 1671 + ); 1672 + 1673 + // Test with OpenID Connect scopes 1674 + let scopes = vec![Scope::Email, Scope::OpenId, Scope::Profile, Scope::Atproto]; 1675 + assert_eq!( 1676 + Scope::serialize_multiple(&scopes), 1677 + "atproto email openid profile" 1678 + ); 1679 + 1680 + // Test with complex scopes including query parameters 1681 + let scopes = vec![ 1682 + Scope::parse("rpc:com.example.service?aud=did:example:123&lxm=com.example.method") 1683 + .unwrap(), 1684 + Scope::parse("repo:foo.bar?action=create&action=update").unwrap(), 1685 + Scope::parse("blob:image/*?accept=image/png&accept=image/jpeg").unwrap(), 1686 + ]; 1687 + let result = Scope::serialize_multiple(&scopes); 1688 + // The result should be sorted alphabetically 1689 + // Note: RPC scope with query params is serialized as "rpc?aud=...&lxm=..." 1690 + assert!(result.starts_with("blob:")); 1691 + assert!(result.contains(" repo:")); 1692 + assert!(result.contains("rpc?aud=did:example:123&lxm=com.example.service")); 1693 + 1694 + // Test with transition scopes 1695 + let scopes = vec![ 1696 + Scope::Transition(TransitionScope::Email), 1697 + Scope::Transition(TransitionScope::Generic), 1698 + Scope::Atproto, 1699 + ]; 1700 + assert_eq!( 1701 + Scope::serialize_multiple(&scopes), 1702 + "atproto transition:email transition:generic" 1703 + ); 1704 + 1705 + // Test duplicates - they remain in the output (caller's responsibility to dedupe if needed) 1706 + let scopes = vec![ 1707 + Scope::Atproto, 1708 + Scope::Atproto, 1709 + Scope::parse("account:email").unwrap(), 1710 + ]; 1711 + assert_eq!( 1712 + Scope::serialize_multiple(&scopes), 1713 + "account:email atproto atproto" 1714 + ); 1715 + 1716 + // Test normalization is preserved in serialization 1717 + let scopes = vec![Scope::parse("blob?accept=image/png&accept=image/jpeg").unwrap()]; 1718 + // Should normalize query parameters alphabetically 1719 + assert_eq!( 1720 + Scope::serialize_multiple(&scopes), 1721 + "blob?accept=image/jpeg&accept=image/png" 1722 + ); 1723 + } 1724 + 1725 + #[test] 1726 + fn test_serialize_multiple_roundtrip() { 1727 + // Test that parse_multiple and serialize_multiple are inverses (when sorted) 1728 + let original = "account:email atproto blob:image/png identity:handle repo:*"; 1729 + let scopes = Scope::parse_multiple(original).unwrap(); 1730 + let serialized = Scope::serialize_multiple(&scopes); 1731 + assert_eq!(serialized, original); 1732 + 1733 + // Test with complex scopes 1734 + let original = "account:repo?action=manage blob?accept=image/jpeg&accept=image/png rpc:*"; 1735 + let scopes = Scope::parse_multiple(original).unwrap(); 1736 + let serialized = Scope::serialize_multiple(&scopes); 1737 + // Parse again to verify it's valid 1738 + let reparsed = Scope::parse_multiple(&serialized).unwrap(); 1739 + assert_eq!(scopes, reparsed); 1740 + 1741 + // Test with OpenID Connect scopes 1742 + let original = "email openid profile"; 1743 + let scopes = Scope::parse_multiple(original).unwrap(); 1744 + let serialized = Scope::serialize_multiple(&scopes); 1745 + assert_eq!(serialized, original); 1746 + } 1747 + 1748 + #[test] 1749 + fn test_remove_scope() { 1750 + // Test removing a scope that exists 1751 + let scopes = vec![ 1752 + Scope::parse("repo:*").unwrap(), 1753 + Scope::Atproto, 1754 + Scope::parse("account:email").unwrap(), 1755 + ]; 1756 + let to_remove = Scope::Atproto; 1757 + let result = Scope::remove_scope(&scopes, &to_remove); 1758 + assert_eq!(result.len(), 2); 1759 + assert!(!result.contains(&to_remove)); 1760 + assert!(result.contains(&Scope::parse("repo:*").unwrap())); 1761 + assert!(result.contains(&Scope::parse("account:email").unwrap())); 1762 + 1763 + // Test removing a scope that doesn't exist 1764 + let scopes = vec![ 1765 + Scope::parse("repo:*").unwrap(), 1766 + Scope::parse("account:email").unwrap(), 1767 + ]; 1768 + let to_remove = Scope::parse("identity:handle").unwrap(); 1769 + let result = Scope::remove_scope(&scopes, &to_remove); 1770 + assert_eq!(result.len(), 2); 1771 + assert_eq!(result, scopes); 1772 + 1773 + // Test removing from empty list 1774 + let scopes: Vec<Scope> = vec![]; 1775 + let to_remove = Scope::Atproto; 1776 + let result = Scope::remove_scope(&scopes, &to_remove); 1777 + assert_eq!(result.len(), 0); 1778 + 1779 + // Test removing all instances of a duplicate scope 1780 + let scopes = vec![ 1781 + Scope::Atproto, 1782 + Scope::parse("account:email").unwrap(), 1783 + Scope::Atproto, 1784 + Scope::parse("repo:*").unwrap(), 1785 + Scope::Atproto, 1786 + ]; 1787 + let to_remove = Scope::Atproto; 1788 + let result = Scope::remove_scope(&scopes, &to_remove); 1789 + assert_eq!(result.len(), 2); 1790 + assert!(!result.contains(&to_remove)); 1791 + assert!(result.contains(&Scope::parse("account:email").unwrap())); 1792 + assert!(result.contains(&Scope::parse("repo:*").unwrap())); 1793 + 1794 + // Test removing complex scopes with query parameters 1795 + let scopes = vec![ 1796 + Scope::parse("account:email?action=manage").unwrap(), 1797 + Scope::parse("blob?accept=image/png&accept=image/jpeg").unwrap(), 1798 + Scope::parse("rpc:com.example.service?aud=did:example:123").unwrap(), 1799 + ]; 1800 + let to_remove = Scope::parse("blob?accept=image/jpeg&accept=image/png").unwrap(); // Note: normalized order 1801 + let result = Scope::remove_scope(&scopes, &to_remove); 1802 + assert_eq!(result.len(), 2); 1803 + assert!(!result.contains(&to_remove)); 1804 + 1805 + // Test with OpenID Connect scopes 1806 + let scopes = vec![Scope::OpenId, Scope::Profile, Scope::Email, Scope::Atproto]; 1807 + let to_remove = Scope::Profile; 1808 + let result = Scope::remove_scope(&scopes, &to_remove); 1809 + assert_eq!(result.len(), 3); 1810 + assert!(!result.contains(&to_remove)); 1811 + assert!(result.contains(&Scope::OpenId)); 1812 + assert!(result.contains(&Scope::Email)); 1813 + assert!(result.contains(&Scope::Atproto)); 1814 + 1815 + // Test with transition scopes 1816 + let scopes = vec![ 1817 + Scope::Transition(TransitionScope::Generic), 1818 + Scope::Transition(TransitionScope::Email), 1819 + Scope::Atproto, 1820 + ]; 1821 + let to_remove = Scope::Transition(TransitionScope::Email); 1822 + let result = Scope::remove_scope(&scopes, &to_remove); 1823 + assert_eq!(result.len(), 2); 1824 + assert!(!result.contains(&to_remove)); 1825 + assert!(result.contains(&Scope::Transition(TransitionScope::Generic))); 1826 + assert!(result.contains(&Scope::Atproto)); 1827 + 1828 + // Test that only exact matches are removed 1829 + let scopes = vec![ 1830 + Scope::parse("account:email").unwrap(), 1831 + Scope::parse("account:email?action=manage").unwrap(), 1832 + Scope::parse("account:repo").unwrap(), 1833 + ]; 1834 + let to_remove = Scope::parse("account:email").unwrap(); 1835 + let result = Scope::remove_scope(&scopes, &to_remove); 1836 + assert_eq!(result.len(), 2); 1837 + assert!(!result.contains(&Scope::parse("account:email").unwrap())); 1838 + assert!(result.contains(&Scope::parse("account:email?action=manage").unwrap())); 1839 + assert!(result.contains(&Scope::parse("account:repo").unwrap())); 1840 + } 1841 + }
+5 -2
crates/atproto-record/src/bytes.rs
··· 35 use serde::{Deserialize, Serialize}; 36 use serde::{Deserializer, Serializer}; 37 38 - use base64::{Engine, engine::general_purpose::{STANDARD, STANDARD_NO_PAD}}; 39 40 /// Serializes a byte vector to a base64 encoded string. 41 pub fn serialize<S: Serializer>(value: &Vec<u8>, serializer: S) -> Result<S::Ok, S::Error> { ··· 47 /// Handles both padded and unpadded base64 strings for compatibility. 48 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<u8>, D::Error> { 49 let encoded_value = String::deserialize(d)?; 50 - 51 // Try standard base64 with padding first 52 STANDARD 53 .decode(&encoded_value)
··· 35 use serde::{Deserialize, Serialize}; 36 use serde::{Deserializer, Serializer}; 37 38 + use base64::{ 39 + Engine, 40 + engine::general_purpose::{STANDARD, STANDARD_NO_PAD}, 41 + }; 42 43 /// Serializes a byte vector to a base64 encoded string. 44 pub fn serialize<S: Serializer>(value: &Vec<u8>, serializer: S) -> Result<S::Ok, S::Error> { ··· 50 /// Handles both padded and unpadded base64 strings for compatibility. 51 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<u8>, D::Error> { 52 let encoded_value = String::deserialize(d)?; 53 + 54 // Try standard base64 with padding first 55 STANDARD 56 .decode(&encoded_value)
+9 -7
crates/atproto-record/src/lexicon/community_lexicon_attestation.rs
··· 167 eprintln!("TypedSignature deserialization error: {}", e); 168 } 169 } 170 - 171 // Then try as SignatureOrRef 172 let sig_or_ref_result: Result<SignatureOrRef, _> = serde_json::from_str(json_str); 173 match &sig_or_ref_result { ··· 182 eprintln!("SignatureOrRef deserialization error: {}", e); 183 } 184 } 185 - 186 // Try without $type field 187 let json_no_type = r#"{ 188 "issuedAt": "2025-08-19T20:17:17.133Z", ··· 191 "$bytes": "mr9c0MCu3g6SXNQ25JFhzfX1ecYgK9k1Kf6OZI2p2AlQRoQu09dOE7J5uaeilIx/UFCjJErO89C/uBBb9ANmUA" 192 } 193 }"#; 194 - 195 let no_type_result: Result<Signature, _> = serde_json::from_str(json_no_type); 196 match &no_type_result { 197 Ok(sig) => { 198 println!("Signature (no type) OK: issuer={}", sig.issuer); 199 assert_eq!(sig.issuer, "did:web:acudo-dev.smokesignal.tools"); 200 assert_eq!(sig.signature.bytes.len(), 64); 201 - 202 // Now wrap it in TypedLexicon and try as SignatureOrRef 203 let typed = TypedLexicon::new(sig.clone()); 204 let _as_sig_or_ref = SignatureOrRef::Inline(typed); ··· 208 eprintln!("Signature (no type) deserialization error: {}", e); 209 } 210 } 211 - 212 // Check that at least one worked 213 - assert!(typed_sig_result.is_ok() || sig_or_ref_result.is_ok() || no_type_result.is_ok(), 214 - "Failed to deserialize signature in any form"); 215 } 216 217 #[test]
··· 167 eprintln!("TypedSignature deserialization error: {}", e); 168 } 169 } 170 + 171 // Then try as SignatureOrRef 172 let sig_or_ref_result: Result<SignatureOrRef, _> = serde_json::from_str(json_str); 173 match &sig_or_ref_result { ··· 182 eprintln!("SignatureOrRef deserialization error: {}", e); 183 } 184 } 185 + 186 // Try without $type field 187 let json_no_type = r#"{ 188 "issuedAt": "2025-08-19T20:17:17.133Z", ··· 191 "$bytes": "mr9c0MCu3g6SXNQ25JFhzfX1ecYgK9k1Kf6OZI2p2AlQRoQu09dOE7J5uaeilIx/UFCjJErO89C/uBBb9ANmUA" 192 } 193 }"#; 194 + 195 let no_type_result: Result<Signature, _> = serde_json::from_str(json_no_type); 196 match &no_type_result { 197 Ok(sig) => { 198 println!("Signature (no type) OK: issuer={}", sig.issuer); 199 assert_eq!(sig.issuer, "did:web:acudo-dev.smokesignal.tools"); 200 assert_eq!(sig.signature.bytes.len(), 64); 201 + 202 // Now wrap it in TypedLexicon and try as SignatureOrRef 203 let typed = TypedLexicon::new(sig.clone()); 204 let _as_sig_or_ref = SignatureOrRef::Inline(typed); ··· 208 eprintln!("Signature (no type) deserialization error: {}", e); 209 } 210 } 211 + 212 // Check that at least one worked 213 + assert!( 214 + typed_sig_result.is_ok() || sig_or_ref_result.is_ok() || no_type_result.is_ok(), 215 + "Failed to deserialize signature in any form" 216 + ); 217 } 218 219 #[test]
+8 -4
crates/atproto-record/src/lexicon/community_lexicon_calendar_rsvp.rs
··· 333 // First parse as generic JSON to verify structure 334 let json_value: Value = serde_json::from_str(json_str)?; 335 assert_eq!(json_value["$type"], "community.lexicon.calendar.rsvp"); 336 - assert_eq!(json_value["status"], "community.lexicon.calendar.rsvp#going"); 337 - 338 // Deserialize the JSON 339 let typed_rsvp: TypedRsvp = serde_json::from_str(json_str)?; 340 ··· 350 ); 351 352 // Verify the timestamp 353 - let expected_time = Utc.with_ymd_and_hms(2025, 8, 19, 20, 17, 17) 354 .unwrap() 355 .with_nanosecond(133_000_000) 356 .unwrap(); ··· 361 match &typed_rsvp.inner.signatures[0] { 362 SignatureOrRef::Inline(sig) => { 363 assert_eq!(sig.inner.issuer, "did:web:acudo-dev.smokesignal.tools"); 364 - 365 // Verify the issuedAt field if present 366 if let Some(issued_at_value) = sig.inner.extra.get("issuedAt") { 367 assert_eq!(issued_at_value, "2025-08-19T20:17:17.133Z");
··· 333 // First parse as generic JSON to verify structure 334 let json_value: Value = serde_json::from_str(json_str)?; 335 assert_eq!(json_value["$type"], "community.lexicon.calendar.rsvp"); 336 + assert_eq!( 337 + json_value["status"], 338 + "community.lexicon.calendar.rsvp#going" 339 + ); 340 + 341 // Deserialize the JSON 342 let typed_rsvp: TypedRsvp = serde_json::from_str(json_str)?; 343 ··· 353 ); 354 355 // Verify the timestamp 356 + let expected_time = Utc 357 + .with_ymd_and_hms(2025, 8, 19, 20, 17, 17) 358 .unwrap() 359 .with_nanosecond(133_000_000) 360 .unwrap(); ··· 365 match &typed_rsvp.inner.signatures[0] { 366 SignatureOrRef::Inline(sig) => { 367 assert_eq!(sig.inner.issuer, "did:web:acudo-dev.smokesignal.tools"); 368 + 369 // Verify the issuedAt field if present 370 if let Some(issued_at_value) = sig.inner.extra.get("issuedAt") { 371 assert_eq!(issued_at_value, "2025-08-19T20:17:17.133Z");
+18 -17
crates/atproto-xrpcs/src/authorization.rs
··· 139 140 // If not found, try to resolve the subject 141 if did_document.is_none() 142 - && let Some(identity_resolver) = identity_resolver { 143 - did_document = match identity_resolver.resolve(issuer).await { 144 - Ok(value) => { 145 - storage 146 - .store_document(value.clone()) 147 - .await 148 - .map_err(|err| AuthorizationError::DocumentStorageFailed { error: err })?; 149 150 - Some(value) 151 } 152 - Err(err) => { 153 - return Err(AuthorizationError::SubjectResolutionFailed { 154 - issuer: issuer.to_string(), 155 - error: err, 156 - } 157 - .into()); 158 - } 159 - }; 160 - } 161 162 let did_document = did_document.ok_or_else(|| AuthorizationError::DIDDocumentNotFound { 163 issuer: issuer.to_string(),
··· 139 140 // If not found, try to resolve the subject 141 if did_document.is_none() 142 + && let Some(identity_resolver) = identity_resolver 143 + { 144 + did_document = match identity_resolver.resolve(issuer).await { 145 + Ok(value) => { 146 + storage 147 + .store_document(value.clone()) 148 + .await 149 + .map_err(|err| AuthorizationError::DocumentStorageFailed { error: err })?; 150 151 + Some(value) 152 + } 153 + Err(err) => { 154 + return Err(AuthorizationError::SubjectResolutionFailed { 155 + issuer: issuer.to_string(), 156 + error: err, 157 } 158 + .into()); 159 + } 160 + }; 161 + } 162 163 let did_document = did_document.ok_or_else(|| AuthorizationError::DIDDocumentNotFound { 164 issuer: issuer.to_string(),