/// Authentication rules that can be validated against session data #[derive(Debug, Clone, PartialEq)] pub enum AuthRules { /// Handle must end with the specified suffix HandleEndsWith(String), /// Handle must end with any of the specified suffixes (OR logic) HandleEndsWithAny(Vec), /// DID must exactly match the specified value DidEquals(String), /// DID must match any of the specified values (OR logic) DidEqualsAny(Vec), /// Session must have the specified OAuth scope ScopeEquals(String), /// Session must have ANY of the specified scopes (OR logic) ScopeEqualsAny(Vec), /// Session must have ALL of the specified scopes (AND logic) ScopeEqualsAll(Vec), /// All nested rules must be satisfied (AND logic) All(Vec), /// At least one nested rule must be satisfied (OR logic) Any(Vec), } /// Session data used for authentication validation #[derive(Debug, Clone)] pub struct SessionData { /// The user's DID pub did: String, /// The user's handle pub handle: String, /// OAuth 2.0 scopes granted to this session pub scopes: Vec, } impl AuthRules { /// Validates if the given session data meets the authentication requirements pub fn validate(&self, session_data: &SessionData) -> bool { match self { AuthRules::HandleEndsWith(suffix) => session_data.handle.ends_with(suffix), AuthRules::HandleEndsWithAny(suffixes) => { suffixes.iter().any(|s| session_data.handle.ends_with(s)) } AuthRules::DidEquals(did) => session_data.did == *did, AuthRules::DidEqualsAny(dids) => dids.iter().any(|d| session_data.did == *d), AuthRules::ScopeEquals(scope) => has_scope(&session_data.scopes, scope), AuthRules::ScopeEqualsAny(scopes) => has_any_scope(&session_data.scopes, scopes), AuthRules::ScopeEqualsAll(scopes) => has_all_scopes(&session_data.scopes, scopes), AuthRules::All(rules) => rules.iter().all(|r| r.validate(session_data)), AuthRules::Any(rules) => rules.iter().any(|r| r.validate(session_data)), } } } /// Checks if the session has a specific scope pub fn has_scope(scopes: &[String], required_scope: &str) -> bool { scopes.iter().any(|s| s == required_scope) } /// Checks if the session has ANY of the required scopes (OR logic) pub fn has_any_scope(scopes: &[String], required_scopes: &[String]) -> bool { required_scopes.iter().any(|req| has_scope(scopes, req)) } /// Checks if the session has ALL of the required scopes (AND logic) pub fn has_all_scopes(scopes: &[String], required_scopes: &[String]) -> bool { required_scopes.iter().all(|req| has_scope(scopes, req)) } #[cfg(test)] mod tests { use super::*; fn test_session(did: &str, handle: &str, scopes: Vec<&str>) -> SessionData { SessionData { did: did.to_string(), handle: handle.to_string(), scopes: scopes.into_iter().map(|s| s.to_string()).collect(), } } #[test] fn test_handle_ends_with() { let rules = AuthRules::HandleEndsWith(".blacksky.team".into()); let valid = test_session("did:plc:123", "alice.blacksky.team", vec!["atproto"]); assert!(rules.validate(&valid)); let invalid = test_session("did:plc:123", "alice.bsky.social", vec!["atproto"]); assert!(!rules.validate(&invalid)); } #[test] fn test_handle_ends_with_any() { let rules = AuthRules::HandleEndsWithAny(vec![".blacksky.team".into(), ".bsky.team".into()]); let valid1 = test_session("did:plc:123", "alice.blacksky.team", vec!["atproto"]); assert!(rules.validate(&valid1)); let valid2 = test_session("did:plc:123", "bob.bsky.team", vec!["atproto"]); assert!(rules.validate(&valid2)); let invalid = test_session("did:plc:123", "charlie.bsky.social", vec!["atproto"]); assert!(!rules.validate(&invalid)); } #[test] fn test_did_equals() { let rules = AuthRules::DidEquals("did:plc:alice".into()); let valid = test_session("did:plc:alice", "alice.bsky.social", vec!["atproto"]); assert!(rules.validate(&valid)); let invalid = test_session("did:plc:bob", "bob.bsky.social", vec!["atproto"]); assert!(!rules.validate(&invalid)); } #[test] fn test_any_combinator() { let rules = AuthRules::Any(vec![ AuthRules::DidEquals("did:plc:admin".into()), AuthRules::HandleEndsWith(".blacksky.team".into()), ]); // First condition met let valid1 = test_session("did:plc:admin", "admin.bsky.social", vec!["atproto"]); assert!(rules.validate(&valid1)); // Second condition met let valid2 = test_session("did:plc:user", "user.blacksky.team", vec!["atproto"]); assert!(rules.validate(&valid2)); // Neither condition met let invalid = test_session("did:plc:user", "user.bsky.social", vec!["atproto"]); assert!(!rules.validate(&invalid)); } #[test] fn test_all_combinator() { let rules = AuthRules::All(vec![ AuthRules::HandleEndsWith(".blacksky.team".into()), AuthRules::DidEqualsAny(vec!["did:plc:alice".into(), "did:plc:bob".into()]), ]); // Both conditions met let valid = test_session("did:plc:alice", "alice.blacksky.team", vec!["atproto"]); assert!(rules.validate(&valid)); // Handle wrong let invalid1 = test_session("did:plc:alice", "alice.bsky.social", vec!["atproto"]); assert!(!rules.validate(&invalid1)); // DID wrong let invalid2 = test_session("did:plc:charlie", "charlie.blacksky.team", vec!["atproto"]); assert!(!rules.validate(&invalid2)); } // ======================================================================== // Scope Tests // ======================================================================== #[test] fn test_scope_equals() { let rules = AuthRules::ScopeEquals("transition:generic".into()); let valid = test_session("did:plc:123", "alice.bsky.social", vec!["atproto", "transition:generic"]); assert!(rules.validate(&valid)); let invalid = test_session("did:plc:123", "alice.bsky.social", vec!["atproto"]); assert!(!rules.validate(&invalid)); } #[test] fn test_scope_any() { let rules = AuthRules::ScopeEqualsAny(vec![ "transition:generic".into(), "repo:app.bsky.feed.post".into(), ]); // Has first scope let valid1 = test_session("did:plc:123", "alice.bsky.social", vec!["atproto", "transition:generic"]); assert!(rules.validate(&valid1)); // Has second scope let valid2 = test_session("did:plc:123", "alice.bsky.social", vec!["atproto", "repo:app.bsky.feed.post"]); assert!(rules.validate(&valid2)); // Has neither let invalid = test_session("did:plc:123", "alice.bsky.social", vec!["atproto"]); assert!(!rules.validate(&invalid)); } #[test] fn test_scope_all() { let rules = AuthRules::ScopeEqualsAll(vec![ "atproto".into(), "transition:generic".into(), ]); // Has both scopes let valid = test_session("did:plc:123", "alice.bsky.social", vec!["atproto", "transition:generic"]); assert!(rules.validate(&valid)); // Missing one scope let invalid = test_session("did:plc:123", "alice.bsky.social", vec!["atproto"]); assert!(!rules.validate(&invalid)); } // ======================================================================== // Combined Rules Tests (Identity + Scope) // ======================================================================== #[test] fn test_handle_ends_with_and_scope() { let rules = AuthRules::All(vec![ AuthRules::HandleEndsWith(".blacksky.team".into()), AuthRules::ScopeEquals("transition:generic".into()), ]); // Both conditions met let valid = test_session( "did:plc:123", "alice.blacksky.team", vec!["atproto", "transition:generic"], ); assert!(rules.validate(&valid)); // Handle correct, scope wrong let invalid1 = test_session("did:plc:123", "alice.blacksky.team", vec!["atproto"]); assert!(!rules.validate(&invalid1)); // Scope correct, handle wrong let invalid2 = test_session( "did:plc:123", "alice.bsky.social", vec!["atproto", "transition:generic"], ); assert!(!rules.validate(&invalid2)); } #[test] fn test_did_with_scope() { let rules = AuthRules::All(vec![ AuthRules::DidEquals("did:plc:rnpkyqnmsw4ipey6eotbdnnf".into()), AuthRules::ScopeEquals("transition:generic".into()), ]); // Both conditions met let valid = test_session( "did:plc:rnpkyqnmsw4ipey6eotbdnnf", "admin.bsky.social", vec!["atproto", "transition:generic"], ); assert!(rules.validate(&valid)); // DID correct, scope wrong let invalid1 = test_session( "did:plc:rnpkyqnmsw4ipey6eotbdnnf", "admin.bsky.social", vec!["atproto"], ); assert!(!rules.validate(&invalid1)); // Scope correct, DID wrong let invalid2 = test_session( "did:plc:wrongdid", "admin.bsky.social", vec!["atproto", "transition:generic"], ); assert!(!rules.validate(&invalid2)); } #[test] fn test_complex_admin_or_moderator_rule() { // Admin DID OR (moderator handle + scope) let rules = AuthRules::Any(vec![ AuthRules::DidEquals("did:plc:rnpkyqnmsw4ipey6eotbdnnf".into()), AuthRules::All(vec![ AuthRules::HandleEndsWith(".mod.team".into()), AuthRules::ScopeEquals("account:email".into()), ]), ]); // Admin DID (doesn't need scope) let admin = test_session("did:plc:rnpkyqnmsw4ipey6eotbdnnf", "admin.bsky.social", vec!["atproto"]); assert!(rules.validate(&admin)); // Moderator with correct handle and scope let mod_valid = test_session( "did:plc:somemod", "alice.mod.team", vec!["atproto", "account:email"], ); assert!(rules.validate(&mod_valid)); // Moderator handle but missing scope let mod_no_scope = test_session("did:plc:somemod", "alice.mod.team", vec!["atproto"]); assert!(!rules.validate(&mod_no_scope)); // Has scope but not moderator handle let not_mod = test_session( "did:plc:someuser", "alice.bsky.social", vec!["atproto", "account:email"], ); assert!(!rules.validate(¬_mod)); } // ======================================================================== // Additional Coverage Tests // ======================================================================== #[test] fn test_did_equals_any() { let rules = AuthRules::DidEqualsAny(vec![ "did:plc:alice123456789012345678".into(), "did:plc:bob12345678901234567890".into(), "did:plc:charlie12345678901234567".into(), ]); // First DID matches let valid1 = test_session("did:plc:alice123456789012345678", "alice.bsky.social", vec!["atproto"]); assert!(rules.validate(&valid1)); // Second DID matches let valid2 = test_session("did:plc:bob12345678901234567890", "bob.bsky.social", vec!["atproto"]); assert!(rules.validate(&valid2)); // Third DID matches let valid3 = test_session("did:plc:charlie12345678901234567", "charlie.bsky.social", vec!["atproto"]); assert!(rules.validate(&valid3)); // DID not in list let invalid = test_session("did:plc:unknown1234567890123456", "unknown.bsky.social", vec!["atproto"]); assert!(!rules.validate(&invalid)); // Partial match should fail (prefix) let partial = test_session("did:plc:alice", "alice.bsky.social", vec!["atproto"]); assert!(!rules.validate(&partial)); } // ======================================================================== // Empty Combinator Edge Cases // ======================================================================== #[test] fn test_empty_any_returns_false() { // Any with empty rules should return false (no rule can be satisfied) let rules = AuthRules::Any(vec![]); let session = test_session("did:plc:test12345678901234567", "test.bsky.social", vec!["atproto"]); assert!(!rules.validate(&session)); } #[test] fn test_empty_all_returns_true() { // All with empty rules should return true (vacuous truth - all zero rules are satisfied) let rules = AuthRules::All(vec![]); let session = test_session("did:plc:test12345678901234567", "test.bsky.social", vec!["atproto"]); assert!(rules.validate(&session)); } // ======================================================================== // Handle Edge Cases // ======================================================================== #[test] fn test_handle_exact_suffix_match() { // Handle that IS exactly the suffix should still match let rules = AuthRules::HandleEndsWith(".blacksky.team".into()); // Handle is exactly the suffix let exact = test_session("did:plc:test12345678901234567", ".blacksky.team", vec!["atproto"]); assert!(rules.validate(&exact)); // Normal case - handle with prefix let normal = test_session("did:plc:test12345678901234567", "alice.blacksky.team", vec!["atproto"]); assert!(rules.validate(&normal)); // Empty handle should not match let empty = test_session("did:plc:test12345678901234567", "", vec!["atproto"]); assert!(!rules.validate(&empty)); } // ======================================================================== // Deeply Nested Combinators // ======================================================================== #[test] fn test_deeply_nested_rules() { // Complex nested rule: Any(All(Any(...), ...), All(...)) // Scenario: (Admin OR VIP) AND (has scope OR team member) // OR // Specific moderator DID let rules = AuthRules::Any(vec![ // Branch 1: Complex nested condition AuthRules::All(vec![ // Must be admin or VIP AuthRules::Any(vec![ AuthRules::DidEquals("did:plc:admin123456789012345".into()), AuthRules::HandleEndsWith(".vip.social".into()), ]), // AND must have scope or be team member AuthRules::Any(vec![ AuthRules::ScopeEquals("transition:generic".into()), AuthRules::HandleEndsWith(".team.internal".into()), ]), ]), // Branch 2: Specific moderator bypass AuthRules::DidEquals("did:plc:moderator12345678901".into()), ]); // Admin with required scope - should pass via Branch 1 let admin_with_scope = test_session( "did:plc:admin123456789012345", "admin.bsky.social", vec!["atproto", "transition:generic"], ); assert!(rules.validate(&admin_with_scope)); // VIP with required scope - should pass via Branch 1 let vip_with_scope = test_session( "did:plc:somevip1234567890123", "alice.vip.social", vec!["atproto", "transition:generic"], ); assert!(rules.validate(&vip_with_scope)); // Moderator bypass - should pass via Branch 2 let moderator = test_session( "did:plc:moderator12345678901", "mod.bsky.social", vec!["atproto"], ); assert!(rules.validate(&moderator)); // Admin without scope and not team member - should fail let admin_no_scope = test_session( "did:plc:admin123456789012345", "admin.bsky.social", vec!["atproto"], ); assert!(!rules.validate(&admin_no_scope)); // Random user - should fail let random = test_session( "did:plc:random12345678901234", "random.bsky.social", vec!["atproto", "transition:generic"], ); assert!(!rules.validate(&random)); } // ======================================================================== // ATProto Scope Specification Tests // ======================================================================== #[test] fn test_scope_with_query_params() { // ATProto scopes can have query parameters // These are treated as literal strings (exact matching) // blob scope with accept parameter let blob_rules = AuthRules::ScopeEquals("blob?accept=image/*".into()); let has_blob = test_session( "did:plc:test12345678901234567", "test.bsky.social", vec!["atproto", "blob?accept=image/*"], ); assert!(blob_rules.validate(&has_blob)); // Different query param should not match let wrong_blob = test_session( "did:plc:test12345678901234567", "test.bsky.social", vec!["atproto", "blob?accept=video/*"], ); assert!(!blob_rules.validate(&wrong_blob)); // account:repo with action parameter let account_rules = AuthRules::ScopeEquals("account:repo?action=manage".into()); let has_account = test_session( "did:plc:test12345678901234567", "test.bsky.social", vec!["atproto", "account:repo?action=manage"], ); assert!(account_rules.validate(&has_account)); // Multiple query params let multi_param_rules = AuthRules::ScopeEquals("blob?accept=image/*&accept=video/*".into()); let has_multi = test_session( "did:plc:test12345678901234567", "test.bsky.social", vec!["atproto", "blob?accept=image/*&accept=video/*"], ); assert!(multi_param_rules.validate(&has_multi)); } #[test] fn test_scope_exact_matching_no_wildcards() { // ATProto spec: Wildcards do NOT work for collections // repo:app.bsky.feed.post should NOT match repo:app.bsky.feed.* // This test verifies our exact matching is correct let rules = AuthRules::ScopeEquals("repo:app.bsky.feed.post".into()); // Exact match works let exact = test_session( "did:plc:test12345678901234567", "test.bsky.social", vec!["atproto", "repo:app.bsky.feed.post"], ); assert!(rules.validate(&exact)); // Wildcard scope in JWT should NOT satisfy specific requirement // (user has repo:app.bsky.feed.* but endpoint requires repo:app.bsky.feed.post) let wildcard = test_session( "did:plc:test12345678901234567", "test.bsky.social", vec!["atproto", "repo:app.bsky.feed.*"], ); assert!(!rules.validate(&wildcard)); // Different collection should not match let different = test_session( "did:plc:test12345678901234567", "test.bsky.social", vec!["atproto", "repo:app.bsky.feed.like"], ); assert!(!rules.validate(&different)); // Prefix should not match let prefix = test_session( "did:plc:test12345678901234567", "test.bsky.social", vec!["atproto", "repo:app.bsky.feed"], ); assert!(!rules.validate(&prefix)); // repo:* should not match specific collection (exact matching) let full_wildcard = test_session( "did:plc:test12345678901234567", "test.bsky.social", vec!["atproto", "repo:*"], ); assert!(!rules.validate(&full_wildcard)); } #[test] fn test_scope_case_sensitivity() { // OAuth scopes are case-sensitive per RFC 6749 let rules = AuthRules::ScopeEquals("atproto".into()); // Exact case matches let exact = test_session("did:plc:test12345678901234567", "test.bsky.social", vec!["atproto"]); assert!(rules.validate(&exact)); // UPPERCASE should NOT match let upper = test_session("did:plc:test12345678901234567", "test.bsky.social", vec!["ATPROTO"]); assert!(!rules.validate(&upper)); // Mixed case should NOT match let mixed = test_session("did:plc:test12345678901234567", "test.bsky.social", vec!["AtProto"]); assert!(!rules.validate(&mixed)); // Test with namespaced scope let ns_rules = AuthRules::ScopeEquals("transition:generic".into()); let ns_upper = test_session( "did:plc:test12345678901234567", "test.bsky.social", vec!["TRANSITION:GENERIC"], ); assert!(!ns_rules.validate(&ns_upper)); } #[test] fn test_scope_with_wildcards_exact_match() { // Wildcard scopes like blob:*/* and identity:* are stored as literal strings // The middleware does exact matching, so JWT must contain the exact string // blob:*/* - full blob wildcard let blob_rules = AuthRules::ScopeEquals("blob:*/*".into()); let has_blob_wildcard = test_session( "did:plc:test12345678901234567", "test.bsky.social", vec!["atproto", "blob:*/*"], ); assert!(blob_rules.validate(&has_blob_wildcard)); // Specific blob type should NOT match blob:*/* requirement let has_specific_blob = test_session( "did:plc:test12345678901234567", "test.bsky.social", vec!["atproto", "blob:image/png"], ); assert!(!blob_rules.validate(&has_specific_blob)); // identity:* - full identity wildcard let identity_rules = AuthRules::ScopeEquals("identity:*".into()); let has_identity_wildcard = test_session( "did:plc:test12345678901234567", "test.bsky.social", vec!["atproto", "identity:*"], ); assert!(identity_rules.validate(&has_identity_wildcard)); // Specific identity scope should NOT match identity:* requirement let has_specific_identity = test_session( "did:plc:test12345678901234567", "test.bsky.social", vec!["atproto", "identity:handle"], ); assert!(!identity_rules.validate(&has_specific_identity)); } // ======================================================================== // Scope Helper Function Tests // ======================================================================== #[test] fn test_has_scope_helper() { let scopes: Vec = vec![ "atproto".to_string(), "transition:generic".to_string(), "repo:app.bsky.feed.post".to_string(), ]; // Present scopes assert!(has_scope(&scopes, "atproto")); assert!(has_scope(&scopes, "transition:generic")); assert!(has_scope(&scopes, "repo:app.bsky.feed.post")); // Absent scopes assert!(!has_scope(&scopes, "identity:*")); assert!(!has_scope(&scopes, "")); assert!(!has_scope(&scopes, "ATPROTO")); // Case sensitive // Empty scopes list let empty: Vec = vec![]; assert!(!has_scope(&empty, "atproto")); } #[test] fn test_has_any_scope_helper() { let scopes: Vec = vec![ "atproto".to_string(), "repo:app.bsky.feed.post".to_string(), ]; // Has one of the required scopes let required1 = vec!["transition:generic".to_string(), "atproto".to_string()]; assert!(has_any_scope(&scopes, &required1)); // Has none of the required scopes let required2 = vec!["transition:generic".to_string(), "identity:*".to_string()]; assert!(!has_any_scope(&scopes, &required2)); // Empty required list - should return false (no scope to match) let empty_required: Vec = vec![]; assert!(!has_any_scope(&scopes, &empty_required)); // Empty scopes list let empty_scopes: Vec = vec![]; assert!(!has_any_scope(&empty_scopes, &required1)); } #[test] fn test_has_all_scopes_helper() { let scopes: Vec = vec![ "atproto".to_string(), "transition:generic".to_string(), "repo:app.bsky.feed.post".to_string(), ]; // Has all required scopes let required1 = vec!["atproto".to_string(), "transition:generic".to_string()]; assert!(has_all_scopes(&scopes, &required1)); // Missing one required scope let required2 = vec!["atproto".to_string(), "identity:*".to_string()]; assert!(!has_all_scopes(&scopes, &required2)); // Empty required list - should return true (vacuously all zero required are present) let empty_required: Vec = vec![]; assert!(has_all_scopes(&scopes, &empty_required)); // Empty scopes list with requirements let empty_scopes: Vec = vec![]; assert!(!has_all_scopes(&empty_scopes, &required1)); // Single scope requirement let single = vec!["atproto".to_string()]; assert!(has_all_scopes(&scopes, &single)); } }