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
+24 -151
Interdiff #0 โ†’ #1
+22 -151
crates/jacquard-oauth/src/scopes.rs
··· 27 27 use jacquard_common::types::nsid::Nsid; 28 28 use jacquard_common::types::string::AtStrError; 29 29 use jacquard_common::{CowStr, IntoStatic}; 30 + use percent_encoding::{NON_ALPHANUMERIC, percent_decode_str, utf8_percent_encode}; 30 31 use serde::de::Visitor; 31 32 use serde::{Deserialize, Serialize}; 32 33 use smol_str::{SmolStr, ToSmolStr}; ··· 745 746 fn parse_include(suffix: Option<&'s str>) -> Result<Self, ParseError> { 746 747 let (nsid_str, params) = match suffix { 747 748 Some(s) => { 748 - if let Some(pos) = s.find('?') { 749 - (&s[..pos], Some(&s[pos + 1..])) 749 + if let Some((nsid_str, params)) = s.split_once('?') { 750 + match params { 751 + "" => (nsid_str, None), 752 + _ => (nsid_str, Some(params)), 753 + } 750 754 } else { 751 755 (s, None) 752 756 } ··· 765 769 parsed_params 766 770 .get("aud") 767 771 .and_then(|v| v.first()) 768 - .map(|s| CowStr::Owned(url_decode(s.as_ref()).to_smolstr())) 772 + .map(|s| CowStr::Owned(percent_decode_str(s).decode_utf8_lossy().to_smolstr())) 769 773 } else { 770 774 None 771 775 }; ··· 915 919 }, 916 920 Scope::Include(scope) => { 917 921 if let Some(ref aud) = scope.aud { 918 - format!("include:{}?aud={}", scope.nsid, url_encode(aud)) 922 + let encoded_aud = utf8_percent_encode(aud, SCOPE_AUD_ENCODE).to_string(); 923 + format!("include:{}?aud={}", scope.nsid, encoded_aud) 919 924 } else { 920 925 format!("include:{}", scope.nsid) 921 926 } ··· 1090 1095 params 1091 1096 } 1092 1097 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 1098 + /// Characters to percent-encode in scope audience values. 1118 1099 /// 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 - } 1100 + /// Encodes every byte that is not an unreserved URI character (`A-Za-z0-9-_.~`) and not `:` 1101 + const SCOPE_AUD_ENCODE: &percent_encoding::AsciiSet = &NON_ALPHANUMERIC 1102 + // Unreserved characters from RFC 3986 1103 + .remove(b'-') 1104 + .remove(b'_') 1105 + .remove(b'.') 1106 + .remove(b'~') 1107 + // `:` is kept unencoded for readability of DIDs and DID URLs such as `did:plc:...` 1108 + .remove(b':'); 1138 1109 1139 1110 /// Error type for scope parsing 1140 1111 #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, miette::Diagnostic)] ··· 2155 2126 scope, 2156 2127 Scope::Include(IncludeScope { 2157 2128 nsid: Nsid::new_static("app.example.authFull").unwrap(), 2158 - aud: Some(CowStr::Owned("did:web:api.example.com#svc_chat".to_smolstr())), 2129 + aud: Some(CowStr::Owned( 2130 + "did:web:api.example.com#svc_chat".to_smolstr() 2131 + )), 2159 2132 }) 2160 2133 ); 2161 2134 ··· 2216 2189 // Include scopes don't grant other scope types, and vice versa. 2217 2190 assert!(!include1.grants(&account)); 2218 2191 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 2192 } 2322 2193 }
+1
Cargo.lock
··· 2647 2647 "n0-future", 2648 2648 "p256", 2649 2649 "p384", 2650 + "percent-encoding", 2650 2651 "rand 0.8.5", 2651 2652 "rouille", 2652 2653 "serde",
+1
crates/jacquard-oauth/Cargo.toml
··· 48 48 n0-future = { workspace = true, optional = true } 49 49 webbrowser = { version = "1", optional = true } 50 50 tracing = { workspace = true, optional = true } 51 + percent-encoding = "2" 51 52 52 53 [target.'cfg(not(target_arch = "wasm32"))'.dependencies] 53 54 tokio = { workspace = true, features = ["rt", "net", "time"] }

History

2 rounds 2 comments
sign up or login to add to the discussion
seqre.dev submitted #1
no conflicts, ready to merge
expand 0 comments
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!