Microservice to bring 2FA to self hosted PDSes
at main 26 kB view raw
1/// Authentication rules that can be validated against session data 2#[derive(Debug, Clone, PartialEq)] 3pub enum AuthRules { 4 /// Handle must end with the specified suffix 5 HandleEndsWith(String), 6 /// Handle must end with any of the specified suffixes (OR logic) 7 HandleEndsWithAny(Vec<String>), 8 /// DID must exactly match the specified value 9 DidEquals(String), 10 /// DID must match any of the specified values (OR logic) 11 DidEqualsAny(Vec<String>), 12 /// Session must have the specified OAuth scope 13 ScopeEquals(String), 14 /// Session must have ANY of the specified scopes (OR logic) 15 ScopeEqualsAny(Vec<String>), 16 /// Session must have ALL of the specified scopes (AND logic) 17 ScopeEqualsAll(Vec<String>), 18 /// All nested rules must be satisfied (AND logic) 19 All(Vec<AuthRules>), 20 /// At least one nested rule must be satisfied (OR logic) 21 Any(Vec<AuthRules>), 22} 23 24/// Session data used for authentication validation 25#[derive(Debug, Clone)] 26pub struct SessionData { 27 /// The user's DID 28 pub did: String, 29 /// The user's handle 30 pub handle: String, 31 /// OAuth 2.0 scopes granted to this session 32 pub scopes: Vec<String>, 33} 34 35impl AuthRules { 36 /// Validates if the given session data meets the authentication requirements 37 pub fn validate(&self, session_data: &SessionData) -> bool { 38 match self { 39 AuthRules::HandleEndsWith(suffix) => session_data.handle.ends_with(suffix), 40 AuthRules::HandleEndsWithAny(suffixes) => { 41 suffixes.iter().any(|s| session_data.handle.ends_with(s)) 42 } 43 AuthRules::DidEquals(did) => session_data.did == *did, 44 AuthRules::DidEqualsAny(dids) => dids.iter().any(|d| session_data.did == *d), 45 AuthRules::ScopeEquals(scope) => has_scope(&session_data.scopes, scope), 46 AuthRules::ScopeEqualsAny(scopes) => has_any_scope(&session_data.scopes, scopes), 47 AuthRules::ScopeEqualsAll(scopes) => has_all_scopes(&session_data.scopes, scopes), 48 AuthRules::All(rules) => rules.iter().all(|r| r.validate(session_data)), 49 AuthRules::Any(rules) => rules.iter().any(|r| r.validate(session_data)), 50 } 51 } 52} 53 54/// Checks if the session has a specific scope 55pub fn has_scope(scopes: &[String], required_scope: &str) -> bool { 56 scopes.iter().any(|s| s == required_scope) 57} 58 59/// Checks if the session has ANY of the required scopes (OR logic) 60pub fn has_any_scope(scopes: &[String], required_scopes: &[String]) -> bool { 61 required_scopes.iter().any(|req| has_scope(scopes, req)) 62} 63 64/// Checks if the session has ALL of the required scopes (AND logic) 65pub fn has_all_scopes(scopes: &[String], required_scopes: &[String]) -> bool { 66 required_scopes.iter().all(|req| has_scope(scopes, req)) 67} 68 69#[cfg(test)] 70mod tests { 71 use super::*; 72 73 fn test_session(did: &str, handle: &str, scopes: Vec<&str>) -> SessionData { 74 SessionData { 75 did: did.to_string(), 76 handle: handle.to_string(), 77 scopes: scopes.into_iter().map(|s| s.to_string()).collect(), 78 } 79 } 80 81 #[test] 82 fn test_handle_ends_with() { 83 let rules = AuthRules::HandleEndsWith(".blacksky.team".into()); 84 85 let valid = test_session("did:plc:123", "alice.blacksky.team", vec!["atproto"]); 86 assert!(rules.validate(&valid)); 87 88 let invalid = test_session("did:plc:123", "alice.bsky.social", vec!["atproto"]); 89 assert!(!rules.validate(&invalid)); 90 } 91 92 #[test] 93 fn test_handle_ends_with_any() { 94 let rules = AuthRules::HandleEndsWithAny(vec![".blacksky.team".into(), ".bsky.team".into()]); 95 96 let valid1 = test_session("did:plc:123", "alice.blacksky.team", vec!["atproto"]); 97 assert!(rules.validate(&valid1)); 98 99 let valid2 = test_session("did:plc:123", "bob.bsky.team", vec!["atproto"]); 100 assert!(rules.validate(&valid2)); 101 102 let invalid = test_session("did:plc:123", "charlie.bsky.social", vec!["atproto"]); 103 assert!(!rules.validate(&invalid)); 104 } 105 106 #[test] 107 fn test_did_equals() { 108 let rules = AuthRules::DidEquals("did:plc:alice".into()); 109 110 let valid = test_session("did:plc:alice", "alice.bsky.social", vec!["atproto"]); 111 assert!(rules.validate(&valid)); 112 113 let invalid = test_session("did:plc:bob", "bob.bsky.social", vec!["atproto"]); 114 assert!(!rules.validate(&invalid)); 115 } 116 117 #[test] 118 fn test_any_combinator() { 119 let rules = AuthRules::Any(vec![ 120 AuthRules::DidEquals("did:plc:admin".into()), 121 AuthRules::HandleEndsWith(".blacksky.team".into()), 122 ]); 123 124 // First condition met 125 let valid1 = test_session("did:plc:admin", "admin.bsky.social", vec!["atproto"]); 126 assert!(rules.validate(&valid1)); 127 128 // Second condition met 129 let valid2 = test_session("did:plc:user", "user.blacksky.team", vec!["atproto"]); 130 assert!(rules.validate(&valid2)); 131 132 // Neither condition met 133 let invalid = test_session("did:plc:user", "user.bsky.social", vec!["atproto"]); 134 assert!(!rules.validate(&invalid)); 135 } 136 137 #[test] 138 fn test_all_combinator() { 139 let rules = AuthRules::All(vec![ 140 AuthRules::HandleEndsWith(".blacksky.team".into()), 141 AuthRules::DidEqualsAny(vec!["did:plc:alice".into(), "did:plc:bob".into()]), 142 ]); 143 144 // Both conditions met 145 let valid = test_session("did:plc:alice", "alice.blacksky.team", vec!["atproto"]); 146 assert!(rules.validate(&valid)); 147 148 // Handle wrong 149 let invalid1 = test_session("did:plc:alice", "alice.bsky.social", vec!["atproto"]); 150 assert!(!rules.validate(&invalid1)); 151 152 // DID wrong 153 let invalid2 = test_session("did:plc:charlie", "charlie.blacksky.team", vec!["atproto"]); 154 assert!(!rules.validate(&invalid2)); 155 } 156 157 // ======================================================================== 158 // Scope Tests 159 // ======================================================================== 160 161 #[test] 162 fn test_scope_equals() { 163 let rules = AuthRules::ScopeEquals("transition:generic".into()); 164 165 let valid = test_session("did:plc:123", "alice.bsky.social", vec!["atproto", "transition:generic"]); 166 assert!(rules.validate(&valid)); 167 168 let invalid = test_session("did:plc:123", "alice.bsky.social", vec!["atproto"]); 169 assert!(!rules.validate(&invalid)); 170 } 171 172 #[test] 173 fn test_scope_any() { 174 let rules = AuthRules::ScopeEqualsAny(vec![ 175 "transition:generic".into(), 176 "repo:app.bsky.feed.post".into(), 177 ]); 178 179 // Has first scope 180 let valid1 = test_session("did:plc:123", "alice.bsky.social", vec!["atproto", "transition:generic"]); 181 assert!(rules.validate(&valid1)); 182 183 // Has second scope 184 let valid2 = test_session("did:plc:123", "alice.bsky.social", vec!["atproto", "repo:app.bsky.feed.post"]); 185 assert!(rules.validate(&valid2)); 186 187 // Has neither 188 let invalid = test_session("did:plc:123", "alice.bsky.social", vec!["atproto"]); 189 assert!(!rules.validate(&invalid)); 190 } 191 192 #[test] 193 fn test_scope_all() { 194 let rules = AuthRules::ScopeEqualsAll(vec![ 195 "atproto".into(), 196 "transition:generic".into(), 197 ]); 198 199 // Has both scopes 200 let valid = test_session("did:plc:123", "alice.bsky.social", vec!["atproto", "transition:generic"]); 201 assert!(rules.validate(&valid)); 202 203 // Missing one scope 204 let invalid = test_session("did:plc:123", "alice.bsky.social", vec!["atproto"]); 205 assert!(!rules.validate(&invalid)); 206 } 207 208 // ======================================================================== 209 // Combined Rules Tests (Identity + Scope) 210 // ======================================================================== 211 212 #[test] 213 fn test_handle_ends_with_and_scope() { 214 let rules = AuthRules::All(vec![ 215 AuthRules::HandleEndsWith(".blacksky.team".into()), 216 AuthRules::ScopeEquals("transition:generic".into()), 217 ]); 218 219 // Both conditions met 220 let valid = test_session( 221 "did:plc:123", 222 "alice.blacksky.team", 223 vec!["atproto", "transition:generic"], 224 ); 225 assert!(rules.validate(&valid)); 226 227 // Handle correct, scope wrong 228 let invalid1 = test_session("did:plc:123", "alice.blacksky.team", vec!["atproto"]); 229 assert!(!rules.validate(&invalid1)); 230 231 // Scope correct, handle wrong 232 let invalid2 = test_session( 233 "did:plc:123", 234 "alice.bsky.social", 235 vec!["atproto", "transition:generic"], 236 ); 237 assert!(!rules.validate(&invalid2)); 238 } 239 240 #[test] 241 fn test_did_with_scope() { 242 let rules = AuthRules::All(vec![ 243 AuthRules::DidEquals("did:plc:rnpkyqnmsw4ipey6eotbdnnf".into()), 244 AuthRules::ScopeEquals("transition:generic".into()), 245 ]); 246 247 // Both conditions met 248 let valid = test_session( 249 "did:plc:rnpkyqnmsw4ipey6eotbdnnf", 250 "admin.bsky.social", 251 vec!["atproto", "transition:generic"], 252 ); 253 assert!(rules.validate(&valid)); 254 255 // DID correct, scope wrong 256 let invalid1 = test_session( 257 "did:plc:rnpkyqnmsw4ipey6eotbdnnf", 258 "admin.bsky.social", 259 vec!["atproto"], 260 ); 261 assert!(!rules.validate(&invalid1)); 262 263 // Scope correct, DID wrong 264 let invalid2 = test_session( 265 "did:plc:wrongdid", 266 "admin.bsky.social", 267 vec!["atproto", "transition:generic"], 268 ); 269 assert!(!rules.validate(&invalid2)); 270 } 271 272 #[test] 273 fn test_complex_admin_or_moderator_rule() { 274 // Admin DID OR (moderator handle + scope) 275 let rules = AuthRules::Any(vec![ 276 AuthRules::DidEquals("did:plc:rnpkyqnmsw4ipey6eotbdnnf".into()), 277 AuthRules::All(vec![ 278 AuthRules::HandleEndsWith(".mod.team".into()), 279 AuthRules::ScopeEquals("account:email".into()), 280 ]), 281 ]); 282 283 // Admin DID (doesn't need scope) 284 let admin = test_session("did:plc:rnpkyqnmsw4ipey6eotbdnnf", "admin.bsky.social", vec!["atproto"]); 285 assert!(rules.validate(&admin)); 286 287 // Moderator with correct handle and scope 288 let mod_valid = test_session( 289 "did:plc:somemod", 290 "alice.mod.team", 291 vec!["atproto", "account:email"], 292 ); 293 assert!(rules.validate(&mod_valid)); 294 295 // Moderator handle but missing scope 296 let mod_no_scope = test_session("did:plc:somemod", "alice.mod.team", vec!["atproto"]); 297 assert!(!rules.validate(&mod_no_scope)); 298 299 // Has scope but not moderator handle 300 let not_mod = test_session( 301 "did:plc:someuser", 302 "alice.bsky.social", 303 vec!["atproto", "account:email"], 304 ); 305 assert!(!rules.validate(&not_mod)); 306 } 307 308 // ======================================================================== 309 // Additional Coverage Tests 310 // ======================================================================== 311 312 #[test] 313 fn test_did_equals_any() { 314 let rules = AuthRules::DidEqualsAny(vec![ 315 "did:plc:alice123456789012345678".into(), 316 "did:plc:bob12345678901234567890".into(), 317 "did:plc:charlie12345678901234567".into(), 318 ]); 319 320 // First DID matches 321 let valid1 = test_session("did:plc:alice123456789012345678", "alice.bsky.social", vec!["atproto"]); 322 assert!(rules.validate(&valid1)); 323 324 // Second DID matches 325 let valid2 = test_session("did:plc:bob12345678901234567890", "bob.bsky.social", vec!["atproto"]); 326 assert!(rules.validate(&valid2)); 327 328 // Third DID matches 329 let valid3 = test_session("did:plc:charlie12345678901234567", "charlie.bsky.social", vec!["atproto"]); 330 assert!(rules.validate(&valid3)); 331 332 // DID not in list 333 let invalid = test_session("did:plc:unknown1234567890123456", "unknown.bsky.social", vec!["atproto"]); 334 assert!(!rules.validate(&invalid)); 335 336 // Partial match should fail (prefix) 337 let partial = test_session("did:plc:alice", "alice.bsky.social", vec!["atproto"]); 338 assert!(!rules.validate(&partial)); 339 } 340 341 // ======================================================================== 342 // Empty Combinator Edge Cases 343 // ======================================================================== 344 345 #[test] 346 fn test_empty_any_returns_false() { 347 // Any with empty rules should return false (no rule can be satisfied) 348 let rules = AuthRules::Any(vec![]); 349 let session = test_session("did:plc:test12345678901234567", "test.bsky.social", vec!["atproto"]); 350 assert!(!rules.validate(&session)); 351 } 352 353 #[test] 354 fn test_empty_all_returns_true() { 355 // All with empty rules should return true (vacuous truth - all zero rules are satisfied) 356 let rules = AuthRules::All(vec![]); 357 let session = test_session("did:plc:test12345678901234567", "test.bsky.social", vec!["atproto"]); 358 assert!(rules.validate(&session)); 359 } 360 361 // ======================================================================== 362 // Handle Edge Cases 363 // ======================================================================== 364 365 #[test] 366 fn test_handle_exact_suffix_match() { 367 // Handle that IS exactly the suffix should still match 368 let rules = AuthRules::HandleEndsWith(".blacksky.team".into()); 369 370 // Handle is exactly the suffix 371 let exact = test_session("did:plc:test12345678901234567", ".blacksky.team", vec!["atproto"]); 372 assert!(rules.validate(&exact)); 373 374 // Normal case - handle with prefix 375 let normal = test_session("did:plc:test12345678901234567", "alice.blacksky.team", vec!["atproto"]); 376 assert!(rules.validate(&normal)); 377 378 // Empty handle should not match 379 let empty = test_session("did:plc:test12345678901234567", "", vec!["atproto"]); 380 assert!(!rules.validate(&empty)); 381 } 382 383 // ======================================================================== 384 // Deeply Nested Combinators 385 // ======================================================================== 386 387 #[test] 388 fn test_deeply_nested_rules() { 389 // Complex nested rule: Any(All(Any(...), ...), All(...)) 390 // Scenario: (Admin OR VIP) AND (has scope OR team member) 391 // OR 392 // Specific moderator DID 393 let rules = AuthRules::Any(vec![ 394 // Branch 1: Complex nested condition 395 AuthRules::All(vec![ 396 // Must be admin or VIP 397 AuthRules::Any(vec![ 398 AuthRules::DidEquals("did:plc:admin123456789012345".into()), 399 AuthRules::HandleEndsWith(".vip.social".into()), 400 ]), 401 // AND must have scope or be team member 402 AuthRules::Any(vec![ 403 AuthRules::ScopeEquals("transition:generic".into()), 404 AuthRules::HandleEndsWith(".team.internal".into()), 405 ]), 406 ]), 407 // Branch 2: Specific moderator bypass 408 AuthRules::DidEquals("did:plc:moderator12345678901".into()), 409 ]); 410 411 // Admin with required scope - should pass via Branch 1 412 let admin_with_scope = test_session( 413 "did:plc:admin123456789012345", 414 "admin.bsky.social", 415 vec!["atproto", "transition:generic"], 416 ); 417 assert!(rules.validate(&admin_with_scope)); 418 419 // VIP with required scope - should pass via Branch 1 420 let vip_with_scope = test_session( 421 "did:plc:somevip1234567890123", 422 "alice.vip.social", 423 vec!["atproto", "transition:generic"], 424 ); 425 assert!(rules.validate(&vip_with_scope)); 426 427 // Moderator bypass - should pass via Branch 2 428 let moderator = test_session( 429 "did:plc:moderator12345678901", 430 "mod.bsky.social", 431 vec!["atproto"], 432 ); 433 assert!(rules.validate(&moderator)); 434 435 // Admin without scope and not team member - should fail 436 let admin_no_scope = test_session( 437 "did:plc:admin123456789012345", 438 "admin.bsky.social", 439 vec!["atproto"], 440 ); 441 assert!(!rules.validate(&admin_no_scope)); 442 443 // Random user - should fail 444 let random = test_session( 445 "did:plc:random12345678901234", 446 "random.bsky.social", 447 vec!["atproto", "transition:generic"], 448 ); 449 assert!(!rules.validate(&random)); 450 } 451 452 // ======================================================================== 453 // ATProto Scope Specification Tests 454 // ======================================================================== 455 456 #[test] 457 fn test_scope_with_query_params() { 458 // ATProto scopes can have query parameters 459 // These are treated as literal strings (exact matching) 460 461 // blob scope with accept parameter 462 let blob_rules = AuthRules::ScopeEquals("blob?accept=image/*".into()); 463 let has_blob = test_session( 464 "did:plc:test12345678901234567", 465 "test.bsky.social", 466 vec!["atproto", "blob?accept=image/*"], 467 ); 468 assert!(blob_rules.validate(&has_blob)); 469 470 // Different query param should not match 471 let wrong_blob = test_session( 472 "did:plc:test12345678901234567", 473 "test.bsky.social", 474 vec!["atproto", "blob?accept=video/*"], 475 ); 476 assert!(!blob_rules.validate(&wrong_blob)); 477 478 // account:repo with action parameter 479 let account_rules = AuthRules::ScopeEquals("account:repo?action=manage".into()); 480 let has_account = test_session( 481 "did:plc:test12345678901234567", 482 "test.bsky.social", 483 vec!["atproto", "account:repo?action=manage"], 484 ); 485 assert!(account_rules.validate(&has_account)); 486 487 // Multiple query params 488 let multi_param_rules = AuthRules::ScopeEquals("blob?accept=image/*&accept=video/*".into()); 489 let has_multi = test_session( 490 "did:plc:test12345678901234567", 491 "test.bsky.social", 492 vec!["atproto", "blob?accept=image/*&accept=video/*"], 493 ); 494 assert!(multi_param_rules.validate(&has_multi)); 495 } 496 497 #[test] 498 fn test_scope_exact_matching_no_wildcards() { 499 // ATProto spec: Wildcards do NOT work for collections 500 // repo:app.bsky.feed.post should NOT match repo:app.bsky.feed.* 501 // This test verifies our exact matching is correct 502 503 let rules = AuthRules::ScopeEquals("repo:app.bsky.feed.post".into()); 504 505 // Exact match works 506 let exact = test_session( 507 "did:plc:test12345678901234567", 508 "test.bsky.social", 509 vec!["atproto", "repo:app.bsky.feed.post"], 510 ); 511 assert!(rules.validate(&exact)); 512 513 // Wildcard scope in JWT should NOT satisfy specific requirement 514 // (user has repo:app.bsky.feed.* but endpoint requires repo:app.bsky.feed.post) 515 let wildcard = test_session( 516 "did:plc:test12345678901234567", 517 "test.bsky.social", 518 vec!["atproto", "repo:app.bsky.feed.*"], 519 ); 520 assert!(!rules.validate(&wildcard)); 521 522 // Different collection should not match 523 let different = test_session( 524 "did:plc:test12345678901234567", 525 "test.bsky.social", 526 vec!["atproto", "repo:app.bsky.feed.like"], 527 ); 528 assert!(!rules.validate(&different)); 529 530 // Prefix should not match 531 let prefix = test_session( 532 "did:plc:test12345678901234567", 533 "test.bsky.social", 534 vec!["atproto", "repo:app.bsky.feed"], 535 ); 536 assert!(!rules.validate(&prefix)); 537 538 // repo:* should not match specific collection (exact matching) 539 let full_wildcard = test_session( 540 "did:plc:test12345678901234567", 541 "test.bsky.social", 542 vec!["atproto", "repo:*"], 543 ); 544 assert!(!rules.validate(&full_wildcard)); 545 } 546 547 #[test] 548 fn test_scope_case_sensitivity() { 549 // OAuth scopes are case-sensitive per RFC 6749 550 551 let rules = AuthRules::ScopeEquals("atproto".into()); 552 553 // Exact case matches 554 let exact = test_session("did:plc:test12345678901234567", "test.bsky.social", vec!["atproto"]); 555 assert!(rules.validate(&exact)); 556 557 // UPPERCASE should NOT match 558 let upper = test_session("did:plc:test12345678901234567", "test.bsky.social", vec!["ATPROTO"]); 559 assert!(!rules.validate(&upper)); 560 561 // Mixed case should NOT match 562 let mixed = test_session("did:plc:test12345678901234567", "test.bsky.social", vec!["AtProto"]); 563 assert!(!rules.validate(&mixed)); 564 565 // Test with namespaced scope 566 let ns_rules = AuthRules::ScopeEquals("transition:generic".into()); 567 let ns_upper = test_session( 568 "did:plc:test12345678901234567", 569 "test.bsky.social", 570 vec!["TRANSITION:GENERIC"], 571 ); 572 assert!(!ns_rules.validate(&ns_upper)); 573 } 574 575 #[test] 576 fn test_scope_with_wildcards_exact_match() { 577 // Wildcard scopes like blob:*/* and identity:* are stored as literal strings 578 // The middleware does exact matching, so JWT must contain the exact string 579 580 // blob:*/* - full blob wildcard 581 let blob_rules = AuthRules::ScopeEquals("blob:*/*".into()); 582 let has_blob_wildcard = test_session( 583 "did:plc:test12345678901234567", 584 "test.bsky.social", 585 vec!["atproto", "blob:*/*"], 586 ); 587 assert!(blob_rules.validate(&has_blob_wildcard)); 588 589 // Specific blob type should NOT match blob:*/* requirement 590 let has_specific_blob = test_session( 591 "did:plc:test12345678901234567", 592 "test.bsky.social", 593 vec!["atproto", "blob:image/png"], 594 ); 595 assert!(!blob_rules.validate(&has_specific_blob)); 596 597 // identity:* - full identity wildcard 598 let identity_rules = AuthRules::ScopeEquals("identity:*".into()); 599 let has_identity_wildcard = test_session( 600 "did:plc:test12345678901234567", 601 "test.bsky.social", 602 vec!["atproto", "identity:*"], 603 ); 604 assert!(identity_rules.validate(&has_identity_wildcard)); 605 606 // Specific identity scope should NOT match identity:* requirement 607 let has_specific_identity = test_session( 608 "did:plc:test12345678901234567", 609 "test.bsky.social", 610 vec!["atproto", "identity:handle"], 611 ); 612 assert!(!identity_rules.validate(&has_specific_identity)); 613 } 614 615 // ======================================================================== 616 // Scope Helper Function Tests 617 // ======================================================================== 618 619 #[test] 620 fn test_has_scope_helper() { 621 let scopes: Vec<String> = vec![ 622 "atproto".to_string(), 623 "transition:generic".to_string(), 624 "repo:app.bsky.feed.post".to_string(), 625 ]; 626 627 // Present scopes 628 assert!(has_scope(&scopes, "atproto")); 629 assert!(has_scope(&scopes, "transition:generic")); 630 assert!(has_scope(&scopes, "repo:app.bsky.feed.post")); 631 632 // Absent scopes 633 assert!(!has_scope(&scopes, "identity:*")); 634 assert!(!has_scope(&scopes, "")); 635 assert!(!has_scope(&scopes, "ATPROTO")); // Case sensitive 636 637 // Empty scopes list 638 let empty: Vec<String> = vec![]; 639 assert!(!has_scope(&empty, "atproto")); 640 } 641 642 #[test] 643 fn test_has_any_scope_helper() { 644 let scopes: Vec<String> = vec![ 645 "atproto".to_string(), 646 "repo:app.bsky.feed.post".to_string(), 647 ]; 648 649 // Has one of the required scopes 650 let required1 = vec!["transition:generic".to_string(), "atproto".to_string()]; 651 assert!(has_any_scope(&scopes, &required1)); 652 653 // Has none of the required scopes 654 let required2 = vec!["transition:generic".to_string(), "identity:*".to_string()]; 655 assert!(!has_any_scope(&scopes, &required2)); 656 657 // Empty required list - should return false (no scope to match) 658 let empty_required: Vec<String> = vec![]; 659 assert!(!has_any_scope(&scopes, &empty_required)); 660 661 // Empty scopes list 662 let empty_scopes: Vec<String> = vec![]; 663 assert!(!has_any_scope(&empty_scopes, &required1)); 664 } 665 666 #[test] 667 fn test_has_all_scopes_helper() { 668 let scopes: Vec<String> = vec![ 669 "atproto".to_string(), 670 "transition:generic".to_string(), 671 "repo:app.bsky.feed.post".to_string(), 672 ]; 673 674 // Has all required scopes 675 let required1 = vec!["atproto".to_string(), "transition:generic".to_string()]; 676 assert!(has_all_scopes(&scopes, &required1)); 677 678 // Missing one required scope 679 let required2 = vec!["atproto".to_string(), "identity:*".to_string()]; 680 assert!(!has_all_scopes(&scopes, &required2)); 681 682 // Empty required list - should return true (vacuously all zero required are present) 683 let empty_required: Vec<String> = vec![]; 684 assert!(has_all_scopes(&scopes, &empty_required)); 685 686 // Empty scopes list with requirements 687 let empty_scopes: Vec<String> = vec![]; 688 assert!(!has_all_scopes(&empty_scopes, &required1)); 689 690 // Single scope requirement 691 let single = vec!["atproto".to_string()]; 692 assert!(has_all_scopes(&scopes, &single)); 693 } 694}