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
+184 -1
Diff #1
+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"] }
+182 -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. ··· 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}; ··· 48 49 Atproto, 49 50 /// Transition scope for migration operations 50 51 Transition(TransitionScope), 52 + /// Include scope for referencing permission sets by NSID 53 + Include(IncludeScope<'s>), 51 54 /// OpenID Connect scope - required for OpenID Connect authentication 52 55 OpenId, 53 56 /// Profile scope - access to user profile information ··· 103 106 Scope::Rpc(scope) => Scope::Rpc(scope.into_static()), 104 107 Scope::Atproto => Scope::Atproto, 105 108 Scope::Transition(scope) => Scope::Transition(scope), 109 + Scope::Include(scope) => Scope::Include(scope.into_static()), 106 110 Scope::OpenId => Scope::OpenId, 107 111 Scope::Profile => Scope::Profile, 108 112 Scope::Email => Scope::Email, ··· 157 161 Email, 158 162 } 159 163 164 + /// Include scope for referencing permission sets by NSID 165 + #[derive(Debug, Clone, PartialEq, Eq, Hash)] 166 + pub struct IncludeScope<'s> { 167 + /// The permission set NSID (e.g., `app.example.authFull`) 168 + pub nsid: Nsid<'s>, 169 + /// Optional audience DID (or DID URL) for inherited RPC permissions 170 + pub aud: Option<CowStr<'s>>, 171 + } 172 + 173 + impl IntoStatic for IncludeScope<'_> { 174 + type Output = IncludeScope<'static>; 175 + 176 + fn into_static(self) -> Self::Output { 177 + IncludeScope { 178 + nsid: self.nsid.into_static(), 179 + aud: self.aud.map(|s| s.into_static()), 180 + } 181 + } 182 + } 183 + 160 184 /// Blob scope with mime type constraints 161 185 #[derive(Debug, Clone, PartialEq, Eq, Hash)] 162 186 pub struct BlobScope<'s> { ··· 454 478 "rpc", 455 479 "atproto", 456 480 "transition", 481 + "include", 457 482 "openid", 458 483 "profile", 459 484 "email", ··· 493 518 "rpc" => Self::parse_rpc(suffix), 494 519 "atproto" => Self::parse_atproto(suffix), 495 520 "transition" => Self::parse_transition(suffix), 521 + "include" => Self::parse_include(suffix), 496 522 "openid" => Self::parse_openid(suffix), 497 523 "profile" => Self::parse_profile(suffix), 498 524 "email" => Self::parse_email(suffix), ··· 717 743 Ok(Scope::Transition(scope)) 718 744 } 719 745 746 + fn parse_include(suffix: Option<&'s str>) -> Result<Self, ParseError> { 747 + let (nsid_str, params) = match suffix { 748 + Some(s) => { 749 + if let Some((nsid_str, params)) = s.split_once('?') { 750 + match params { 751 + "" => (nsid_str, None), 752 + _ => (nsid_str, Some(params)), 753 + } 754 + } else { 755 + (s, None) 756 + } 757 + } 758 + None => return Err(ParseError::MissingResource), 759 + }; 760 + 761 + if nsid_str.is_empty() { 762 + return Err(ParseError::MissingResource); 763 + } 764 + 765 + let nsid = Nsid::new(nsid_str)?; 766 + 767 + let aud = if let Some(params) = params { 768 + let parsed_params = parse_query_string(params); 769 + parsed_params 770 + .get("aud") 771 + .and_then(|v| v.first()) 772 + .map(|s| CowStr::Owned(percent_decode_str(s).decode_utf8_lossy().to_smolstr())) 773 + } else { 774 + None 775 + }; 776 + 777 + Ok(Scope::Include(IncludeScope { nsid, aud })) 778 + } 779 + 720 780 fn parse_openid(suffix: Option<&str>) -> Result<Self, ParseError> { 721 781 if suffix.is_some() { 722 782 return Err(ParseError::InvalidResource( ··· 857 917 TransitionScope::Generic => "transition:generic".to_string(), 858 918 TransitionScope::Email => "transition:email".to_string(), 859 919 }, 920 + Scope::Include(scope) => { 921 + if let Some(ref aud) = scope.aud { 922 + let encoded_aud = utf8_percent_encode(aud, SCOPE_AUD_ENCODE).to_string(); 923 + format!("include:{}?aud={}", scope.nsid, encoded_aud) 924 + } else { 925 + format!("include:{}", scope.nsid) 926 + } 927 + } 860 928 Scope::OpenId => "openid".to_string(), 861 929 Scope::Profile => "profile".to_string(), 862 930 Scope::Email => "email".to_string(), ··· 876 944 // Other scopes don't grant transition scopes 877 945 (_, Scope::Transition(_)) => false, 878 946 (Scope::Transition(_), _) => false, 947 + // Include scopes only grant themselves (exact match including aud) 948 + (Scope::Include(a), Scope::Include(b)) => a == b, 949 + // Other scopes don't grant include scopes 950 + (_, Scope::Include(_)) => false, 951 + (Scope::Include(_), _) => false, 879 952 // OpenID Connect scopes only grant themselves 880 953 (Scope::OpenId, Scope::OpenId) => true, 881 954 (Scope::OpenId, _) => false, ··· 1022 1095 params 1023 1096 } 1024 1097 1098 + /// Characters to percent-encode in scope audience values. 1099 + /// 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':'); 1109 + 1025 1110 /// Error type for scope parsing 1026 1111 #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, miette::Diagnostic)] 1027 1112 #[non_exhaustive] ··· 2009 2094 assert!(result.contains(&Scope::parse("account:email?action=manage").unwrap())); 2010 2095 assert!(result.contains(&Scope::parse("account:repo").unwrap())); 2011 2096 } 2097 + 2098 + #[test] 2099 + fn test_include_scope_parsing() { 2100 + // Basic include scope without audience. 2101 + let scope = Scope::parse("include:app.example.authFull").unwrap(); 2102 + assert_eq!( 2103 + scope, 2104 + Scope::Include(IncludeScope { 2105 + nsid: Nsid::new_static("app.example.authFull").unwrap(), 2106 + aud: None, 2107 + }) 2108 + ); 2109 + 2110 + // Include scope with a plain DID audience. 2111 + let scope = 2112 + Scope::parse("include:app.example.authFull?aud=did:web:api.example.com").unwrap(); 2113 + assert_eq!( 2114 + scope, 2115 + Scope::Include(IncludeScope { 2116 + nsid: Nsid::new_static("app.example.authFull").unwrap(), 2117 + aud: Some(CowStr::new_static("did:web:api.example.com")), 2118 + }) 2119 + ); 2120 + 2121 + // Include scope with a URL-encoded audience containing a fragment. 2122 + let scope = 2123 + Scope::parse("include:app.example.authFull?aud=did:web:api.example.com%23svc_chat") 2124 + .unwrap(); 2125 + assert_eq!( 2126 + scope, 2127 + Scope::Include(IncludeScope { 2128 + nsid: Nsid::new_static("app.example.authFull").unwrap(), 2129 + aud: Some(CowStr::Owned( 2130 + "did:web:api.example.com#svc_chat".to_smolstr() 2131 + )), 2132 + }) 2133 + ); 2134 + 2135 + // Missing NSID โ€” no colon at all. 2136 + assert!(matches!( 2137 + Scope::parse("include"), 2138 + Err(ParseError::MissingResource) 2139 + )); 2140 + 2141 + // Empty NSID with query params. 2142 + assert!(matches!( 2143 + Scope::parse("include:?aud=did:example:123"), 2144 + Err(ParseError::MissingResource) 2145 + )); 2146 + } 2147 + 2148 + #[test] 2149 + fn test_include_scope_normalization() { 2150 + // No audience โ€” simple form. 2151 + let scope = Scope::parse("include:com.example.authBasic").unwrap(); 2152 + assert_eq!( 2153 + scope.to_string_normalized(), 2154 + "include:com.example.authBasic" 2155 + ); 2156 + 2157 + // Audience with no special characters. 2158 + let scope = Scope::parse("include:com.example.authBasic?aud=did:plc:xyz123").unwrap(); 2159 + assert_eq!( 2160 + scope.to_string_normalized(), 2161 + "include:com.example.authBasic?aud=did:plc:xyz123" 2162 + ); 2163 + 2164 + // Audience with a fragment โ€” `#` must be percent-encoded. 2165 + let scope = 2166 + Scope::parse("include:app.example.authFull?aud=did:web:api.example.com%23svc_chat") 2167 + .unwrap(); 2168 + assert_eq!( 2169 + scope.to_string_normalized(), 2170 + "include:app.example.authFull?aud=did:web:api.example.com%23svc_chat" 2171 + ); 2172 + } 2173 + 2174 + #[test] 2175 + fn test_include_scope_grants() { 2176 + let include1 = Scope::parse("include:app.example.authFull").unwrap(); 2177 + let include2 = Scope::parse("include:app.example.authBasic").unwrap(); 2178 + let include1_with_aud = 2179 + Scope::parse("include:app.example.authFull?aud=did:plc:xyz").unwrap(); 2180 + let account = Scope::parse("account:email").unwrap(); 2181 + 2182 + // Include scopes only grant themselves โ€” exact match required. 2183 + assert!(include1.grants(&include1)); 2184 + assert!(!include1.grants(&include2)); 2185 + // Same NSID but different aud is not a match. 2186 + assert!(!include1.grants(&include1_with_aud)); 2187 + assert!(include1_with_aud.grants(&include1_with_aud)); 2188 + 2189 + // Include scopes don't grant other scope types, and vice versa. 2190 + assert!(!include1.grants(&account)); 2191 + assert!(!account.grants(&include1)); 2192 + } 2012 2193 }

History

2 rounds 2 comments
sign up or login to add to the discussion
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!