A better Rust ATProto crate

Add support for include OAuth scope #8

open opened by seqre.dev targeting main

This change expands Scope to support include scope. I also changed the source to https://tangled.org/ngerakines.me/atproto-crates, which is where the active code actually lives now.

The url_decode / url_encode can be removed if percent-encoding is fine to be added as dependency.

Full disclosure, the change was mostly written with Claude but I reviewed the code.

Labels

None yet.

Participants 2
AT URI
at://did:plc:ismzbuk5gulhlif4ryudy4v3/sh.tangled.repo.pull/3mhqmgc2tvo22
+311 -1
Diff #0
+311 -1
crates/jacquard-oauth/src/scopes.rs
··· 1 1 //! AT Protocol OAuth scopes 2 2 //! 3 - //! Derived from <https://tangled.org/smokesignal.events/atproto-identity-rs/raw/main/crates/atproto-oauth/src/scopes.rs> 3 + //! Derived from <https://tangled.org/ngerakines.me/atproto-crates/raw/main/crates/atproto-oauth/src/scopes.rs> 4 4 //! 5 5 //! This module provides comprehensive support for AT Protocol OAuth scopes, 6 6 //! including parsing, serialization, normalization, and permission checking. ··· 48 48 Atproto, 49 49 /// Transition scope for migration operations 50 50 Transition(TransitionScope), 51 + /// Include scope for referencing permission sets by NSID 52 + Include(IncludeScope<'s>), 51 53 /// OpenID Connect scope - required for OpenID Connect authentication 52 54 OpenId, 53 55 /// Profile scope - access to user profile information ··· 103 105 Scope::Rpc(scope) => Scope::Rpc(scope.into_static()), 104 106 Scope::Atproto => Scope::Atproto, 105 107 Scope::Transition(scope) => Scope::Transition(scope), 108 + Scope::Include(scope) => Scope::Include(scope.into_static()), 106 109 Scope::OpenId => Scope::OpenId, 107 110 Scope::Profile => Scope::Profile, 108 111 Scope::Email => Scope::Email, ··· 157 160 Email, 158 161 } 159 162 163 + /// Include scope for referencing permission sets by NSID 164 + #[derive(Debug, Clone, PartialEq, Eq, Hash)] 165 + pub struct IncludeScope<'s> { 166 + /// The permission set NSID (e.g., `app.example.authFull`) 167 + pub nsid: Nsid<'s>, 168 + /// Optional audience DID (or DID URL) for inherited RPC permissions 169 + pub aud: Option<CowStr<'s>>, 170 + } 171 + 172 + impl IntoStatic for IncludeScope<'_> { 173 + type Output = IncludeScope<'static>; 174 + 175 + fn into_static(self) -> Self::Output { 176 + IncludeScope { 177 + nsid: self.nsid.into_static(), 178 + aud: self.aud.map(|s| s.into_static()), 179 + } 180 + } 181 + } 182 + 160 183 /// Blob scope with mime type constraints 161 184 #[derive(Debug, Clone, PartialEq, Eq, Hash)] 162 185 pub struct BlobScope<'s> { ··· 454 477 "rpc", 455 478 "atproto", 456 479 "transition", 480 + "include", 457 481 "openid", 458 482 "profile", 459 483 "email", ··· 493 517 "rpc" => Self::parse_rpc(suffix), 494 518 "atproto" => Self::parse_atproto(suffix), 495 519 "transition" => Self::parse_transition(suffix), 520 + "include" => Self::parse_include(suffix), 496 521 "openid" => Self::parse_openid(suffix), 497 522 "profile" => Self::parse_profile(suffix), 498 523 "email" => Self::parse_email(suffix), ··· 717 742 Ok(Scope::Transition(scope)) 718 743 } 719 744 745 + fn parse_include(suffix: Option<&'s str>) -> Result<Self, ParseError> { 746 + let (nsid_str, params) = match suffix { 747 + Some(s) => { 748 + if let Some(pos) = s.find('?') { 749 + (&s[..pos], Some(&s[pos + 1..])) 750 + } else { 751 + (s, None) 752 + } 753 + } 754 + None => return Err(ParseError::MissingResource), 755 + }; 756 + 757 + if nsid_str.is_empty() { 758 + return Err(ParseError::MissingResource); 759 + } 760 + 761 + let nsid = Nsid::new(nsid_str)?; 762 + 763 + let aud = if let Some(params) = params { 764 + let parsed_params = parse_query_string(params); 765 + parsed_params 766 + .get("aud") 767 + .and_then(|v| v.first()) 768 + .map(|s| CowStr::Owned(url_decode(s.as_ref()).to_smolstr())) 769 + } else { 770 + None 771 + }; 772 + 773 + Ok(Scope::Include(IncludeScope { nsid, aud })) 774 + } 775 + 720 776 fn parse_openid(suffix: Option<&str>) -> Result<Self, ParseError> { 721 777 if suffix.is_some() { 722 778 return Err(ParseError::InvalidResource( ··· 857 913 TransitionScope::Generic => "transition:generic".to_string(), 858 914 TransitionScope::Email => "transition:email".to_string(), 859 915 }, 916 + Scope::Include(scope) => { 917 + if let Some(ref aud) = scope.aud { 918 + format!("include:{}?aud={}", scope.nsid, url_encode(aud)) 919 + } else { 920 + format!("include:{}", scope.nsid) 921 + } 922 + } 860 923 Scope::OpenId => "openid".to_string(), 861 924 Scope::Profile => "profile".to_string(), 862 925 Scope::Email => "email".to_string(), ··· 876 939 // Other scopes don't grant transition scopes 877 940 (_, Scope::Transition(_)) => false, 878 941 (Scope::Transition(_), _) => false, 942 + // Include scopes only grant themselves (exact match including aud) 943 + (Scope::Include(a), Scope::Include(b)) => a == b, 944 + // Other scopes don't grant include scopes 945 + (_, Scope::Include(_)) => false, 946 + (Scope::Include(_), _) => false, 879 947 // OpenID Connect scopes only grant themselves 880 948 (Scope::OpenId, Scope::OpenId) => true, 881 949 (Scope::OpenId, _) => false, ··· 1022 1090 params 1023 1091 } 1024 1092 1093 + /// Decode a percent-encoded string 1094 + fn url_decode(s: &str) -> String { 1095 + let mut result = String::with_capacity(s.len()); 1096 + let mut chars = s.chars().peekable(); 1097 + 1098 + while let Some(c) = chars.next() { 1099 + if c == '%' { 1100 + let hex: String = chars.by_ref().take(2).collect(); 1101 + if hex.len() == 2 1102 + && let Ok(byte) = u8::from_str_radix(&hex, 16) 1103 + { 1104 + result.push(byte as char); 1105 + continue; 1106 + } 1107 + result.push('%'); 1108 + result.push_str(&hex); 1109 + } else { 1110 + result.push(c); 1111 + } 1112 + } 1113 + 1114 + result 1115 + } 1116 + 1117 + /// Encode a string for use in a URL query parameter 1118 + /// 1119 + /// Allows unreserved characters plus `:` (needed for DIDs and DID URLs). 1120 + fn url_encode(s: &str) -> String { 1121 + let mut result = String::with_capacity(s.len() * 3); 1122 + 1123 + for c in s.chars() { 1124 + match c { 1125 + 'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' | ':' => { 1126 + result.push(c); 1127 + } 1128 + _ => { 1129 + for byte in c.to_string().as_bytes() { 1130 + result.push_str(&format!("%{:02X}", byte)); 1131 + } 1132 + } 1133 + } 1134 + } 1135 + 1136 + result 1137 + } 1138 + 1025 1139 /// Error type for scope parsing 1026 1140 #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, miette::Diagnostic)] 1027 1141 #[non_exhaustive] ··· 2009 2123 assert!(result.contains(&Scope::parse("account:email?action=manage").unwrap())); 2010 2124 assert!(result.contains(&Scope::parse("account:repo").unwrap())); 2011 2125 } 2126 + 2127 + #[test] 2128 + fn test_include_scope_parsing() { 2129 + // Basic include scope without audience. 2130 + let scope = Scope::parse("include:app.example.authFull").unwrap(); 2131 + assert_eq!( 2132 + scope, 2133 + Scope::Include(IncludeScope { 2134 + nsid: Nsid::new_static("app.example.authFull").unwrap(), 2135 + aud: None, 2136 + }) 2137 + ); 2138 + 2139 + // Include scope with a plain DID audience. 2140 + let scope = 2141 + Scope::parse("include:app.example.authFull?aud=did:web:api.example.com").unwrap(); 2142 + assert_eq!( 2143 + scope, 2144 + Scope::Include(IncludeScope { 2145 + nsid: Nsid::new_static("app.example.authFull").unwrap(), 2146 + aud: Some(CowStr::new_static("did:web:api.example.com")), 2147 + }) 2148 + ); 2149 + 2150 + // Include scope with a URL-encoded audience containing a fragment. 2151 + let scope = 2152 + Scope::parse("include:app.example.authFull?aud=did:web:api.example.com%23svc_chat") 2153 + .unwrap(); 2154 + assert_eq!( 2155 + scope, 2156 + Scope::Include(IncludeScope { 2157 + nsid: Nsid::new_static("app.example.authFull").unwrap(), 2158 + aud: Some(CowStr::Owned("did:web:api.example.com#svc_chat".to_smolstr())), 2159 + }) 2160 + ); 2161 + 2162 + // Missing NSID โ€” no colon at all. 2163 + assert!(matches!( 2164 + Scope::parse("include"), 2165 + Err(ParseError::MissingResource) 2166 + )); 2167 + 2168 + // Empty NSID with query params. 2169 + assert!(matches!( 2170 + Scope::parse("include:?aud=did:example:123"), 2171 + Err(ParseError::MissingResource) 2172 + )); 2173 + } 2174 + 2175 + #[test] 2176 + fn test_include_scope_normalization() { 2177 + // No audience โ€” simple form. 2178 + let scope = Scope::parse("include:com.example.authBasic").unwrap(); 2179 + assert_eq!( 2180 + scope.to_string_normalized(), 2181 + "include:com.example.authBasic" 2182 + ); 2183 + 2184 + // Audience with no special characters. 2185 + let scope = Scope::parse("include:com.example.authBasic?aud=did:plc:xyz123").unwrap(); 2186 + assert_eq!( 2187 + scope.to_string_normalized(), 2188 + "include:com.example.authBasic?aud=did:plc:xyz123" 2189 + ); 2190 + 2191 + // Audience with a fragment โ€” `#` must be percent-encoded. 2192 + let scope = 2193 + Scope::parse("include:app.example.authFull?aud=did:web:api.example.com%23svc_chat") 2194 + .unwrap(); 2195 + assert_eq!( 2196 + scope.to_string_normalized(), 2197 + "include:app.example.authFull?aud=did:web:api.example.com%23svc_chat" 2198 + ); 2199 + } 2200 + 2201 + #[test] 2202 + fn test_include_scope_grants() { 2203 + let include1 = Scope::parse("include:app.example.authFull").unwrap(); 2204 + let include2 = Scope::parse("include:app.example.authBasic").unwrap(); 2205 + let include1_with_aud = 2206 + Scope::parse("include:app.example.authFull?aud=did:plc:xyz").unwrap(); 2207 + let account = Scope::parse("account:email").unwrap(); 2208 + 2209 + // Include scopes only grant themselves โ€” exact match required. 2210 + assert!(include1.grants(&include1)); 2211 + assert!(!include1.grants(&include2)); 2212 + // Same NSID but different aud is not a match. 2213 + assert!(!include1.grants(&include1_with_aud)); 2214 + assert!(include1_with_aud.grants(&include1_with_aud)); 2215 + 2216 + // Include scopes don't grant other scope types, and vice versa. 2217 + assert!(!include1.grants(&account)); 2218 + assert!(!account.grants(&include1)); 2219 + 2220 + // Include scopes don't grant atproto or transition. 2221 + let atproto = Scope::parse("atproto").unwrap(); 2222 + let transition = Scope::parse("transition:generic").unwrap(); 2223 + assert!(!include1.grants(&atproto)); 2224 + assert!(!include1.grants(&transition)); 2225 + assert!(!atproto.grants(&include1)); 2226 + assert!(!transition.grants(&include1)); 2227 + } 2228 + 2229 + #[test] 2230 + fn test_parse_multiple_with_include() { 2231 + let scopes = Scope::parse_multiple("atproto include:app.example.auth repo:*").unwrap(); 2232 + assert_eq!(scopes.len(), 3); 2233 + assert_eq!(scopes[0], Scope::Atproto); 2234 + assert!(matches!(scopes[1], Scope::Include(_))); 2235 + assert!(matches!(scopes[2], Scope::Repo(_))); 2236 + 2237 + // URL-encoded audience in a multi-scope string. 2238 + let scopes = Scope::parse_multiple( 2239 + "include:app.example.auth?aud=did:web:api.example.com%23svc account:email", 2240 + ) 2241 + .unwrap(); 2242 + assert_eq!(scopes.len(), 2); 2243 + if let Scope::Include(ref inc) = scopes[0] { 2244 + assert_eq!(inc.nsid.as_str(), "app.example.auth"); 2245 + assert_eq!(inc.aud.as_deref(), Some("did:web:api.example.com#svc")); 2246 + } else { 2247 + panic!("Expected Include scope"); 2248 + } 2249 + } 2250 + 2251 + #[test] 2252 + fn test_parse_multiple_reduced_with_include() { 2253 + // Duplicates are removed; distinct NSIDs are preserved. 2254 + let scopes = Scope::parse_multiple_reduced( 2255 + "include:app.example.auth include:app.example.other include:app.example.auth", 2256 + ) 2257 + .unwrap(); 2258 + assert_eq!(scopes.len(), 2); 2259 + assert!(scopes.contains(&Scope::Include(IncludeScope { 2260 + nsid: Nsid::new_static("app.example.auth").unwrap(), 2261 + aud: None, 2262 + }))); 2263 + assert!(scopes.contains(&Scope::Include(IncludeScope { 2264 + nsid: Nsid::new_static("app.example.other").unwrap(), 2265 + aud: None, 2266 + }))); 2267 + 2268 + // Same NSID with different audiences are treated as distinct scopes. 2269 + let scopes = Scope::parse_multiple_reduced( 2270 + "include:app.example.auth include:app.example.auth?aud=did:plc:xyz", 2271 + ) 2272 + .unwrap(); 2273 + assert_eq!(scopes.len(), 2); 2274 + } 2275 + 2276 + #[test] 2277 + fn test_serialize_multiple_with_include() { 2278 + let scopes = vec![ 2279 + Scope::parse("repo:*").unwrap(), 2280 + Scope::parse("include:app.example.authFull").unwrap(), 2281 + Scope::Atproto, 2282 + ]; 2283 + let result = Scope::serialize_multiple(&scopes); 2284 + assert_eq!(result, "atproto include:app.example.authFull repo:*"); 2285 + 2286 + // Fragment in audience is percent-encoded during serialization. 2287 + let scopes = vec![Scope::Include(IncludeScope { 2288 + nsid: Nsid::new_static("app.example.auth").unwrap(), 2289 + aud: Some(CowStr::Owned("did:web:api.example.com#svc".to_smolstr())), 2290 + })]; 2291 + let result = Scope::serialize_multiple(&scopes); 2292 + assert_eq!( 2293 + result, 2294 + "include:app.example.auth?aud=did:web:api.example.com%23svc" 2295 + ); 2296 + } 2297 + 2298 + #[test] 2299 + fn test_remove_scope_with_include() { 2300 + let scopes = vec![ 2301 + Scope::Atproto, 2302 + Scope::parse("include:app.example.auth").unwrap(), 2303 + Scope::parse("account:email").unwrap(), 2304 + ]; 2305 + let to_remove = Scope::parse("include:app.example.auth").unwrap(); 2306 + let result = Scope::remove_scope(&scopes, &to_remove); 2307 + assert_eq!(result.len(), 2); 2308 + assert!(!result.contains(&to_remove)); 2309 + assert!(result.contains(&Scope::Atproto)); 2310 + } 2311 + 2312 + #[test] 2313 + fn test_include_scope_roundtrip() { 2314 + // parse โ†’ serialize โ†’ parse must be idempotent. 2315 + let original = 2316 + "include:com.example.authBasicFeatures?aud=did:web:api.example.com%23svc_appview"; 2317 + let scope = Scope::parse(original).unwrap(); 2318 + let serialized = scope.to_string_normalized(); 2319 + let reparsed = Scope::parse(&serialized).unwrap(); 2320 + assert_eq!(scope, reparsed); 2321 + } 2012 2322 }

History

2 rounds 2 comments
sign up or login to add to the discussion
no conflicts, ready to merge
expand 0 comments
seqre.dev submitted #0
expand 2 comments

a bit inefficient but workable. please remove claude's bad manual url decode/encode implementation, in favour of proper usage of libraries that i know exist in the workspace.

I'm planning to rewrite this module at some point here, to properly borrow from its input if able. but this will work for the moment.

I used percent-encoding instead of hand-rolling encoding functions and cleaned up the code and tests.

If there's anything else you'd like improved please let me know, I'm happy to modify it!