A better Rust ATProto crate
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

[jacquard-oauth] add permission set resolution and scope-check feature

- Add expand_permission_set() for LexPermissionSet → Vec<Scope<SmolStr>> conversion
- Add resolve_permission_set() using LexiconSchemaResolver with namespace validation
- Add scope-check feature flag gating jacquard-lexicon dependency
- Add resolved_scopes field to ClientSessionData (feature-gated)
- Add eager resolution infrastructure at session creation

+670 -1
+1
Cargo.lock
··· 2365 2365 "http", 2366 2366 "jacquard-common", 2367 2367 "jacquard-identity", 2368 + "jacquard-lexicon", 2368 2369 "jose-jwa", 2369 2370 "jose-jwk", 2370 2371 "k256",
+2
crates/jacquard-oauth/Cargo.toml
··· 19 19 tracing = ["dep:tracing"] 20 20 websocket = ["jacquard-common/websocket"] 21 21 streaming = ["jacquard-common/streaming", "dep:n0-future"] 22 + scope-check = ["dep:jacquard-lexicon"] 22 23 23 24 [dependencies] 24 25 jacquard-common = { version = "0.12.0-beta.1", path = "../jacquard-common", features = ["reqwest-client"] } 25 26 jacquard-identity = { version = "0.12.0-beta.1", path = "../jacquard-identity" } 27 + jacquard-lexicon = { version = "0.12.0-beta.1", path = "../jacquard-lexicon", optional = true } 26 28 serde = { workspace = true, features = ["derive"] } 27 29 serde_json = { workspace = true } 28 30 smol_str = { workspace = true }
+8 -1
crates/jacquard-oauth/src/client.rs
··· 296 296 } else { 297 297 Scopes::empty() 298 298 }; 299 - let client_data = ClientSessionData { 299 + let mut client_data = ClientSessionData { 300 300 account_did: token_set.sub.clone(), 301 301 session_id: auth_req_info.state, 302 302 host_url: Uri::parse(token_set.aud.as_str())?.to_owned(), ··· 313 313 .unwrap_or_default(), 314 314 }, 315 315 token_set, 316 + #[cfg(feature = "scope-check")] 317 + resolved_scopes: None, 316 318 }; 319 + 320 + // TODO: Phase 5 Task 3 - eagerly resolve include scopes 321 + // When scope-check is enabled, iterate the scopes, find any Include scopes, 322 + // resolve them via resolve_permission_set(), and populate resolved_scopes. 323 + // For now, this is left as None. 317 324 318 325 self.create_session(client_data).await 319 326 }
+2
crates/jacquard-oauth/src/request.rs
··· 1096 1096 token_type: crate::types::OAuthTokenType::DPoP, 1097 1097 expires_at: None, 1098 1098 }, 1099 + #[cfg(feature = "scope-check")] 1100 + resolved_scopes: None, 1099 1101 }; 1100 1102 let err = super::refresh(&client, session, &meta).await.unwrap_err(); 1101 1103 assert!(matches!(err.kind(), RequestErrorKind::NoRefreshToken));
+128
crates/jacquard-oauth/src/resolver.rs
··· 124 124 #[error("url parsing error")] 125 125 #[diagnostic(code(jacquard_oauth::resolver::url))] 126 126 Uri, 127 + 128 + /// Permission set is not a lexicon def 129 + #[cfg(feature = "scope-check")] 130 + #[error("permission set is not a valid lexicon def")] 131 + #[diagnostic( 132 + code(jacquard_oauth::resolver::not_a_permission_set), 133 + help("ensure the lexicon schema's 'main' def is a permission-set type") 134 + )] 135 + NotAPermissionSet, 136 + 137 + /// Permission set namespace constraint violation 138 + #[cfg(feature = "scope-check")] 139 + #[error("permission set namespace violation: {0}")] 140 + #[diagnostic( 141 + code(jacquard_oauth::resolver::permission_set_namespace), 142 + help("all permissions must be within the owning namespace") 143 + )] 144 + PermissionSetNamespace(SmolStr), 145 + 146 + /// Permission set conversion error 147 + #[cfg(feature = "scope-check")] 148 + #[error("permission set conversion error: {0}")] 149 + #[diagnostic(code(jacquard_oauth::resolver::permission_set_conversion))] 150 + PermissionSetConversion(SmolStr), 127 151 } 128 152 129 153 impl ResolverError { ··· 257 281 pub fn http_status(status: StatusCode) -> Self { 258 282 Self::new(ResolverErrorKind::HttpStatus(status), None) 259 283 } 284 + 285 + /// Create a "not a permission set" error 286 + #[cfg(feature = "scope-check")] 287 + pub fn not_a_permission_set() -> Self { 288 + Self::new(ResolverErrorKind::NotAPermissionSet, None) 289 + } 290 + 291 + /// Create a permission set namespace violation error 292 + #[cfg(feature = "scope-check")] 293 + pub fn permission_set_namespace(msg: impl Into<SmolStr>) -> Self { 294 + Self::new(ResolverErrorKind::PermissionSetNamespace(msg.into()), None) 295 + } 296 + 297 + /// Create a permission set conversion error 298 + #[cfg(feature = "scope-check")] 299 + pub fn permission_set_conversion(msg: impl Into<SmolStr>) -> Self { 300 + Self::new(ResolverErrorKind::PermissionSetConversion(msg.into()), None) 301 + } 260 302 } 261 303 262 304 /// Result type for resolver operations ··· 306 348 Self::new(ResolverErrorKind::Uri, Some(Box::new(e))) 307 349 .with_context(msg) 308 350 .with_help("ensure URIs are well-formed (e.g., https://example.com)") 351 + } 352 + } 353 + 354 + #[cfg(feature = "scope-check")] 355 + impl From<jacquard_identity::lexicon_resolver::LexiconResolutionError> for ResolverError { 356 + fn from(e: jacquard_identity::lexicon_resolver::LexiconResolutionError) -> Self { 357 + let msg = smol_str::format_smolstr!("{:?}", e); 358 + Self::new(ResolverErrorKind::Transport, Some(Box::new(e))) 359 + .with_context(msg) 360 + .with_help("failed to resolve lexicon schema; check network connectivity") 309 361 } 310 362 } 311 363 ··· 764 816 } 765 817 } 766 818 819 + /// Resolve a permission set NSID into its constituent scopes. 820 + /// 821 + /// Requires both `OAuthResolver` (for identity/HTTP) and 822 + /// `LexiconSchemaResolver` (for lexicon schema fetching, which uses 823 + /// the `nsid_to_schema` cache with 7-day TTL). 824 + #[cfg(feature = "scope-check")] 825 + pub async fn resolve_permission_set<R, S>( 826 + resolver: &R, 827 + nsid: &jacquard_common::types::nsid::Nsid<S>, 828 + inherited_audience: Option<&jacquard_common::types::did::Did<smol_str::SmolStr>>, 829 + ) -> Result<Vec<crate::scopes::Scope<smol_str::SmolStr>>> 830 + where 831 + R: OAuthResolver + jacquard_identity::lexicon_resolver::LexiconSchemaResolver + Sync, 832 + S: jacquard_common::bos::BosStr + Sync, 833 + { 834 + use jacquard_lexicon::lexicon::{LexUserType, PermissionSetError}; 835 + 836 + // 1. Fetch the lexicon schema (cached via nsid_to_schema). 837 + let schema = resolver.resolve_lexicon_schema(nsid).await?; 838 + 839 + // 2. Extract the "main" def from the LexiconDoc. 840 + let main_def = schema.doc.defs.get("main") 841 + .ok_or_else(|| ResolverError::not_found())?; 842 + 843 + // 3. Downcast to LexPermissionSet. 844 + let perm_set = match main_def { 845 + LexUserType::PermissionSet(ps) => ps, 846 + _ => return Err(ResolverError::not_a_permission_set()), 847 + }; 848 + 849 + // 4. Validate namespace constraints. 850 + perm_set.validate(nsid.as_ref()) 851 + .map_err(|e| match e { 852 + PermissionSetError::EmptyPermissions => { 853 + ResolverError::permission_set_conversion("permission set has empty permissions array") 854 + } 855 + PermissionSetError::NamespaceViolation { nsid: n, resource: r } => { 856 + ResolverError::permission_set_namespace( 857 + smol_str::format_smolstr!("{} references out-of-namespace resource: {}", n, r) 858 + ) 859 + } 860 + })?; 861 + 862 + // 5. Expand to concrete scopes, passing inherited audience for inheritAud. 863 + crate::scopes::expand_permission_set(perm_set, inherited_audience) 864 + .map_err(|e| ResolverError::permission_set_conversion(smol_str::format_smolstr!("{}", e))) 865 + } 866 + 767 867 /// Fetch and validate the `/.well-known/oauth-authorization-server` document for `server`. 768 868 /// 769 869 /// Per RFC 8414 §3.3 the `issuer` field in the response must equal the `server` URL exactly; ··· 917 1017 let issuer_base = CowStr::new_static("https://issuer.example.com"); 918 1018 let issuer_with_path = CowStr::new_static("https://issuer.example.com/path"); 919 1019 assert_ne!(issuer_base, issuer_with_path); 1020 + } 1021 + 1022 + #[cfg(feature = "scope-check")] 1023 + #[tokio::test] 1024 + async fn test_expand_permission_set_exported() { 1025 + // This is a simple integration test that verifies expand_permission_set is accessible 1026 + use crate::scopes::expand_permission_set; 1027 + use jacquard_lexicon::lexicon::{LexPermission, LexPermissionResource, LexPermissionSet}; 1028 + use jacquard_common::CowStr; 1029 + 1030 + let mut perms = Vec::new(); 1031 + perms.push(LexPermission::Permission { 1032 + resource: LexPermissionResource::Identity { 1033 + attr: CowStr::Borrowed("handle"), 1034 + }, 1035 + }); 1036 + 1037 + let perm_set = LexPermissionSet { 1038 + title: None, 1039 + title_lang: None, 1040 + detail: None, 1041 + detail_lang: None, 1042 + permissions: perms, 1043 + }; 1044 + 1045 + let scopes = expand_permission_set(&perm_set, None).expect("should expand permission set"); 1046 + assert_eq!(scopes.len(), 1); 1047 + assert!(matches!(scopes[0], crate::scopes::Scope::Identity(crate::scopes::IdentityScope::Handle))); 920 1048 } 921 1049 }
+508
crates/jacquard-oauth/src/scopes.rs
··· 2207 2207 params 2208 2208 } 2209 2209 2210 + /// Error type for permission set expansion and conversion 2211 + #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] 2212 + #[non_exhaustive] 2213 + pub enum PermissionSetConversionError { 2214 + /// Unknown identity attribute in permission set 2215 + #[error("unknown identity attribute: {0}")] 2216 + UnknownIdentityAttr(String), 2217 + 2218 + /// Unknown account attribute in permission set 2219 + #[error("unknown account attribute: {0}")] 2220 + UnknownAccountAttr(String), 2221 + 2222 + /// Invalid MIME pattern in blob permission 2223 + #[error("invalid MIME pattern: {0}")] 2224 + InvalidMimePattern(String), 2225 + } 2226 + 2210 2227 /// Error type for scope parsing 2211 2228 #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, miette::Diagnostic)] 2212 2229 #[non_exhaustive] ··· 2238 2255 } 2239 2256 } 2240 2257 2258 + /// Convert a resolved permission set into its constituent scope values. 2259 + /// 2260 + /// Each permission entry expands to one or more concrete scopes: 2261 + /// - Repo: one `Scope::Repo` per collection NSID 2262 + /// - Rpc: one `Scope::Rpc` per lxm NSID (with shared aud) 2263 + /// - Blob: one `Scope::Blob` with all accept patterns 2264 + /// - Identity: `Scope::Identity` based on attr 2265 + /// - Account: `Scope::Account` based on attr and action 2266 + /// `inherited_audience` is the audience from the `include:` scope's `?aud=` 2267 + /// parameter. Passed to RPC permissions with `inherit_aud: true`. 2268 + #[cfg(feature = "scope-check")] 2269 + pub fn expand_permission_set( 2270 + perm_set: &jacquard_lexicon::lexicon::LexPermissionSet<'static>, 2271 + inherited_audience: Option<&Did<SmolStr>>, 2272 + ) -> Result<Vec<Scope<SmolStr>>, PermissionSetConversionError> { 2273 + use jacquard_lexicon::lexicon::{LexPermission, LexPermissionResource}; 2274 + 2275 + let mut scopes = Vec::new(); 2276 + 2277 + for perm in &perm_set.permissions { 2278 + let LexPermission::Permission { resource } = perm; 2279 + match resource { 2280 + LexPermissionResource::Repo { collection, action } => { 2281 + let actions = action 2282 + .as_ref() 2283 + .map(|a| a.iter().copied().collect()) 2284 + .unwrap_or_else(|| { 2285 + let mut all = BTreeSet::new(); 2286 + all.insert(RepoAction::Create); 2287 + all.insert(RepoAction::Update); 2288 + all.insert(RepoAction::Delete); 2289 + all 2290 + }); 2291 + 2292 + for col_nsid in collection { 2293 + scopes.push(Scope::Repo(RepoScope { 2294 + collection: RepoCollection::Nsid(col_nsid.clone().convert()), 2295 + actions: actions.clone(), 2296 + })); 2297 + } 2298 + } 2299 + LexPermissionResource::Rpc { lxm, aud, inherit_aud } => { 2300 + // Build the audience set based on priority order 2301 + let mut aud_set = BTreeSet::new(); 2302 + if let Some(explicit_aud) = aud { 2303 + aud_set.insert(RpcAudience::Did(explicit_aud.clone().convert())); 2304 + } else if inherit_aud.unwrap_or(false) && inherited_audience.is_some() { 2305 + aud_set.insert(RpcAudience::Did(inherited_audience.unwrap().clone())); 2306 + } else { 2307 + aud_set.insert(RpcAudience::All); 2308 + } 2309 + 2310 + // Create one RpcScope with all lxm NSIDs and the resolved audience 2311 + let mut lxm_set = BTreeSet::new(); 2312 + for lxm_nsid in lxm { 2313 + lxm_set.insert(RpcLexicon::Nsid(lxm_nsid.clone().convert())); 2314 + } 2315 + 2316 + if !lxm_set.is_empty() { 2317 + scopes.push(Scope::Rpc(RpcScope { 2318 + lxm: lxm_set, 2319 + aud: aud_set, 2320 + })); 2321 + } 2322 + } 2323 + LexPermissionResource::Blob { accept, .. } => { 2324 + let mut patterns = BTreeSet::new(); 2325 + for mime_type in accept { 2326 + let pattern_str = mime_type.as_ref(); 2327 + match validate_mime_pattern(pattern_str) { 2328 + Ok(kind) => { 2329 + // For TypeWildcard, strip the `/*` suffix before storing. 2330 + let mime_str = match kind { 2331 + MimePatternKind::TypeWildcard => { 2332 + SmolStr::new(&pattern_str[..pattern_str.len() - 2]) 2333 + } 2334 + _ => SmolStr::new(pattern_str), 2335 + }; 2336 + let pattern = unsafe { MimePattern::unchecked(mime_str, kind) }; 2337 + patterns.insert(pattern); 2338 + } 2339 + Err(_) => { 2340 + return Err(PermissionSetConversionError::InvalidMimePattern( 2341 + pattern_str.to_string(), 2342 + )); 2343 + } 2344 + } 2345 + } 2346 + 2347 + if !patterns.is_empty() { 2348 + scopes.push(Scope::Blob(BlobScope { accept: patterns })); 2349 + } 2350 + } 2351 + LexPermissionResource::Identity { attr } => { 2352 + let identity_scope = match attr.as_ref() { 2353 + "handle" => IdentityScope::Handle, 2354 + "*" => IdentityScope::All, 2355 + other => return Err(PermissionSetConversionError::UnknownIdentityAttr(other.to_string())), 2356 + }; 2357 + scopes.push(Scope::Identity(identity_scope)); 2358 + } 2359 + LexPermissionResource::Account { attr, action } => { 2360 + let resource = match attr.as_ref() { 2361 + "email" => AccountResource::Email, 2362 + "repo" => AccountResource::Repo, 2363 + "status" => AccountResource::Status, 2364 + other => return Err(PermissionSetConversionError::UnknownAccountAttr(other.to_string())), 2365 + }; 2366 + 2367 + let act = action 2368 + .as_ref() 2369 + .and_then(|a| a.first()) 2370 + .copied() 2371 + .unwrap_or(AccountAction::Read); 2372 + 2373 + scopes.push(Scope::Account(AccountScope { 2374 + resource, 2375 + action: act, 2376 + })); 2377 + } 2378 + } 2379 + } 2380 + 2381 + Ok(scopes) 2382 + } 2383 + 2241 2384 #[cfg(test)] 2242 2385 mod tests { 2243 2386 use super::*; 2387 + #[cfg(feature = "scope-check")] 2388 + use jacquard_common::CowStr; 2244 2389 2245 2390 #[test] 2246 2391 fn test_account_scope_parsing() { ··· 3699 3844 // Normalized form expands bare `rpc` to explicit `rpc:*`. 3700 3845 let normalized = scopes.to_normalized_string(); 3701 3846 assert_eq!(normalized, "rpc:*"); 3847 + } 3848 + 3849 + #[cfg(feature = "scope-check")] 3850 + #[test] 3851 + fn test_expand_permission_set_repo() { 3852 + use jacquard_lexicon::lexicon::{LexPermissionSet, LexPermission, LexPermissionResource}; 3853 + 3854 + // Create a simple permission set with a repo permission 3855 + let mut perms = Vec::new(); 3856 + perms.push(LexPermission::Permission { 3857 + resource: LexPermissionResource::Repo { 3858 + collection: vec![ 3859 + Nsid::new_static("app.bsky.feed.post").unwrap(), 3860 + Nsid::new_static("app.bsky.graph.follow").unwrap(), 3861 + ], 3862 + action: Some(vec![RepoAction::Create]), 3863 + }, 3864 + }); 3865 + 3866 + let perm_set = LexPermissionSet { 3867 + title: None, 3868 + title_lang: None, 3869 + detail: None, 3870 + detail_lang: None, 3871 + permissions: perms, 3872 + }; 3873 + 3874 + let scopes = expand_permission_set(&perm_set, None).unwrap(); 3875 + assert_eq!(scopes.len(), 2); 3876 + 3877 + // Check that we got the expected repo scopes 3878 + let mut found_post = false; 3879 + let mut found_follow = false; 3880 + 3881 + for scope in &scopes { 3882 + if let Scope::Repo(repo_scope) = scope { 3883 + if let RepoCollection::Nsid(nsid) = &repo_scope.collection { 3884 + if nsid.as_ref() == "app.bsky.feed.post" { 3885 + assert_eq!(repo_scope.actions.len(), 1); 3886 + assert!(repo_scope.actions.contains(&RepoAction::Create)); 3887 + found_post = true; 3888 + } else if nsid.as_ref() == "app.bsky.graph.follow" { 3889 + assert_eq!(repo_scope.actions.len(), 1); 3890 + assert!(repo_scope.actions.contains(&RepoAction::Create)); 3891 + found_follow = true; 3892 + } 3893 + } 3894 + } 3895 + } 3896 + 3897 + assert!(found_post, "Expected post scope"); 3898 + assert!(found_follow, "Expected follow scope"); 3899 + } 3900 + 3901 + #[cfg(feature = "scope-check")] 3902 + #[test] 3903 + fn test_expand_permission_set_identity() { 3904 + use jacquard_lexicon::lexicon::{LexPermissionSet, LexPermission, LexPermissionResource}; 3905 + 3906 + let mut perms = Vec::new(); 3907 + perms.push(LexPermission::Permission { 3908 + resource: LexPermissionResource::Identity { 3909 + attr: CowStr::Borrowed("handle"), 3910 + }, 3911 + }); 3912 + 3913 + let perm_set = LexPermissionSet { 3914 + title: None, 3915 + title_lang: None, 3916 + detail: None, 3917 + detail_lang: None, 3918 + permissions: perms, 3919 + }; 3920 + 3921 + let scopes = expand_permission_set(&perm_set, None).unwrap(); 3922 + assert_eq!(scopes.len(), 1); 3923 + 3924 + assert_eq!(scopes[0], Scope::Identity(IdentityScope::Handle)); 3925 + } 3926 + 3927 + #[cfg(feature = "scope-check")] 3928 + #[test] 3929 + fn test_expand_permission_set_account() { 3930 + use jacquard_lexicon::lexicon::{LexPermissionSet, LexPermission, LexPermissionResource}; 3931 + 3932 + let mut perms = Vec::new(); 3933 + perms.push(LexPermission::Permission { 3934 + resource: LexPermissionResource::Account { 3935 + attr: CowStr::Borrowed("email"), 3936 + action: Some(vec![AccountAction::Manage]), 3937 + }, 3938 + }); 3939 + 3940 + let perm_set = LexPermissionSet { 3941 + title: None, 3942 + title_lang: None, 3943 + detail: None, 3944 + detail_lang: None, 3945 + permissions: perms, 3946 + }; 3947 + 3948 + let scopes = expand_permission_set(&perm_set, None).unwrap(); 3949 + assert_eq!(scopes.len(), 1); 3950 + 3951 + assert_eq!( 3952 + scopes[0], 3953 + Scope::Account(AccountScope { 3954 + resource: AccountResource::Email, 3955 + action: AccountAction::Manage, 3956 + }) 3957 + ); 3958 + } 3959 + 3960 + #[cfg(feature = "scope-check")] 3961 + #[test] 3962 + fn test_expand_permission_set_rpc_with_inherit_aud() { 3963 + use jacquard_lexicon::lexicon::{LexPermissionSet, LexPermission, LexPermissionResource}; 3964 + 3965 + let mut perms = Vec::new(); 3966 + perms.push(LexPermission::Permission { 3967 + resource: LexPermissionResource::Rpc { 3968 + lxm: vec![Nsid::new_static("app.bsky.feed.getTimeline").unwrap()], 3969 + aud: None, 3970 + inherit_aud: Some(true), 3971 + }, 3972 + }); 3973 + 3974 + let perm_set = LexPermissionSet { 3975 + title: None, 3976 + title_lang: None, 3977 + detail: None, 3978 + detail_lang: None, 3979 + permissions: perms, 3980 + }; 3981 + 3982 + let inherited_did = Did::new_static("did:web:example.com").unwrap(); 3983 + let scopes = expand_permission_set(&perm_set, Some(&inherited_did)).unwrap(); 3984 + assert_eq!(scopes.len(), 1); 3985 + 3986 + if let Scope::Rpc(rpc_scope) = &scopes[0] { 3987 + assert_eq!(rpc_scope.lxm.len(), 1); 3988 + assert_eq!(rpc_scope.aud.len(), 1); 3989 + assert!(matches!(rpc_scope.aud.iter().next(), Some(RpcAudience::Did(d)) if d.as_ref() == "did:web:example.com")); 3990 + } else { 3991 + panic!("Expected Rpc scope"); 3992 + } 3993 + } 3994 + 3995 + #[cfg(feature = "scope-check")] 3996 + #[test] 3997 + fn test_expand_permission_set_rpc_explicit_aud() { 3998 + use jacquard_lexicon::lexicon::{LexPermissionSet, LexPermission, LexPermissionResource}; 3999 + 4000 + let mut perms = Vec::new(); 4001 + perms.push(LexPermission::Permission { 4002 + resource: LexPermissionResource::Rpc { 4003 + lxm: vec![Nsid::new_static("app.bsky.feed.getTimeline").unwrap()], 4004 + aud: Some(Did::new_static("did:web:custom.com").unwrap()), 4005 + inherit_aud: None, 4006 + }, 4007 + }); 4008 + 4009 + let perm_set = LexPermissionSet { 4010 + title: None, 4011 + title_lang: None, 4012 + detail: None, 4013 + detail_lang: None, 4014 + permissions: perms, 4015 + }; 4016 + 4017 + let scopes = expand_permission_set(&perm_set, None).unwrap(); 4018 + assert_eq!(scopes.len(), 1); 4019 + 4020 + if let Scope::Rpc(rpc_scope) = &scopes[0] { 4021 + assert_eq!(rpc_scope.aud.len(), 1); 4022 + assert!(matches!(rpc_scope.aud.iter().next(), Some(RpcAudience::Did(d)) if d.as_ref() == "did:web:custom.com")); 4023 + } else { 4024 + panic!("Expected Rpc scope"); 4025 + } 4026 + } 4027 + 4028 + #[cfg(feature = "scope-check")] 4029 + #[test] 4030 + fn test_expand_permission_set_unknown_identity_attr() { 4031 + use jacquard_lexicon::lexicon::{LexPermissionSet, LexPermission, LexPermissionResource}; 4032 + 4033 + let mut perms = Vec::new(); 4034 + perms.push(LexPermission::Permission { 4035 + resource: LexPermissionResource::Identity { 4036 + attr: CowStr::Borrowed("invalid"), 4037 + }, 4038 + }); 4039 + 4040 + let perm_set = LexPermissionSet { 4041 + title: None, 4042 + title_lang: None, 4043 + detail: None, 4044 + detail_lang: None, 4045 + permissions: perms, 4046 + }; 4047 + 4048 + let result = expand_permission_set(&perm_set, None); 4049 + assert!(matches!(result, Err(PermissionSetConversionError::UnknownIdentityAttr(_)))); 4050 + } 4051 + 4052 + #[cfg(feature = "scope-check")] 4053 + #[test] 4054 + fn test_expand_permission_set_unknown_account_attr() { 4055 + use jacquard_lexicon::lexicon::{LexPermissionSet, LexPermission, LexPermissionResource}; 4056 + 4057 + let mut perms = Vec::new(); 4058 + perms.push(LexPermission::Permission { 4059 + resource: LexPermissionResource::Account { 4060 + attr: CowStr::Borrowed("invalid"), 4061 + action: None, 4062 + }, 4063 + }); 4064 + 4065 + let perm_set = LexPermissionSet { 4066 + title: None, 4067 + title_lang: None, 4068 + detail: None, 4069 + detail_lang: None, 4070 + permissions: perms, 4071 + }; 4072 + 4073 + let result = expand_permission_set(&perm_set, None); 4074 + assert!(matches!(result, Err(PermissionSetConversionError::UnknownAccountAttr(_)))); 4075 + } 4076 + 4077 + #[cfg(feature = "scope-check")] 4078 + #[test] 4079 + fn test_expand_permission_set_blob() { 4080 + use jacquard_lexicon::lexicon::{LexPermissionSet, LexPermission, LexPermissionResource}; 4081 + use jacquard_common::types::blob::MimeType; 4082 + 4083 + // Test exact type 4084 + let mut perms = Vec::new(); 4085 + perms.push(LexPermission::Permission { 4086 + resource: LexPermissionResource::Blob { 4087 + accept: vec![MimeType::new(CowStr::Borrowed("image/png"))], 4088 + max_size: None, 4089 + }, 4090 + }); 4091 + 4092 + let perm_set = LexPermissionSet { 4093 + title: None, 4094 + title_lang: None, 4095 + detail: None, 4096 + detail_lang: None, 4097 + permissions: perms, 4098 + }; 4099 + 4100 + let scopes = expand_permission_set(&perm_set, None).expect("should expand blob"); 4101 + assert_eq!(scopes.len(), 1); 4102 + match &scopes[0] { 4103 + Scope::Blob(blob_scope) => { 4104 + assert_eq!(blob_scope.accept.len(), 1); 4105 + for pattern in &blob_scope.accept { 4106 + if let MimePattern::Exact(s) = pattern { 4107 + assert_eq!(s.as_ref() as &str, "image/png"); 4108 + } else { 4109 + panic!("expected Exact pattern"); 4110 + } 4111 + } 4112 + } 4113 + _ => panic!("expected Blob scope"), 4114 + } 4115 + 4116 + // Test type wildcard 4117 + let mut perms = Vec::new(); 4118 + perms.push(LexPermission::Permission { 4119 + resource: LexPermissionResource::Blob { 4120 + accept: vec![MimeType::new(CowStr::Borrowed("image/*"))], 4121 + max_size: None, 4122 + }, 4123 + }); 4124 + 4125 + let perm_set = LexPermissionSet { 4126 + title: None, 4127 + title_lang: None, 4128 + detail: None, 4129 + detail_lang: None, 4130 + permissions: perms, 4131 + }; 4132 + 4133 + let scopes = expand_permission_set(&perm_set, None).expect("should expand blob"); 4134 + assert_eq!(scopes.len(), 1); 4135 + match &scopes[0] { 4136 + Scope::Blob(blob_scope) => { 4137 + assert_eq!(blob_scope.accept.len(), 1); 4138 + // TypeWildcard should store only the type prefix (e.g., "image") 4139 + for pattern in &blob_scope.accept { 4140 + if let MimePattern::TypeWildcard(s) = pattern { 4141 + assert_eq!(s.as_ref() as &str, "image"); 4142 + } else { 4143 + panic!("expected TypeWildcard pattern"); 4144 + } 4145 + } 4146 + } 4147 + _ => panic!("expected Blob scope"), 4148 + } 4149 + 4150 + // Test all wildcard 4151 + let mut perms = Vec::new(); 4152 + perms.push(LexPermission::Permission { 4153 + resource: LexPermissionResource::Blob { 4154 + accept: vec![MimeType::new(CowStr::Borrowed("*/*"))], 4155 + max_size: None, 4156 + }, 4157 + }); 4158 + 4159 + let perm_set = LexPermissionSet { 4160 + title: None, 4161 + title_lang: None, 4162 + detail: None, 4163 + detail_lang: None, 4164 + permissions: perms, 4165 + }; 4166 + 4167 + let scopes = expand_permission_set(&perm_set, None).expect("should expand blob"); 4168 + assert_eq!(scopes.len(), 1); 4169 + match &scopes[0] { 4170 + Scope::Blob(blob_scope) => { 4171 + assert_eq!(blob_scope.accept.len(), 1); 4172 + assert!( 4173 + blob_scope 4174 + .accept 4175 + .iter() 4176 + .any(|p| matches!(p, MimePattern::All)) 4177 + ); 4178 + } 4179 + _ => panic!("expected Blob scope"), 4180 + } 4181 + } 4182 + 4183 + #[cfg(feature = "scope-check")] 4184 + #[test] 4185 + fn test_expand_permission_set_blob_invalid_mime() { 4186 + use jacquard_common::types::blob::MimeType; 4187 + use jacquard_lexicon::lexicon::{LexPermission, LexPermissionResource, LexPermissionSet}; 4188 + 4189 + let mut perms = Vec::new(); 4190 + perms.push(LexPermission::Permission { 4191 + resource: LexPermissionResource::Blob { 4192 + accept: vec![MimeType::new(CowStr::Borrowed("invalid-mime-type"))], 4193 + max_size: None, 4194 + }, 4195 + }); 4196 + 4197 + let perm_set = LexPermissionSet { 4198 + title: None, 4199 + title_lang: None, 4200 + detail: None, 4201 + detail_lang: None, 4202 + permissions: perms, 4203 + }; 4204 + 4205 + let result = expand_permission_set(&perm_set, None); 4206 + assert!(matches!( 4207 + result, 4208 + Err(PermissionSetConversionError::InvalidMimePattern(_)) 4209 + )); 3702 4210 } 3703 4211 }
+12
crates/jacquard-oauth/src/session.rs
··· 86 86 /// Current token set (access token, refresh token, expiry, etc.). 87 87 #[serde(flatten)] 88 88 pub token_set: TokenSet<S>, 89 + 90 + /// Fully expanded scopes with include scopes resolved. 91 + /// Populated eagerly at session creation when `scope-check` is enabled. 92 + /// `None` when `scope-check` is disabled or no include scopes are present. 93 + #[cfg(feature = "scope-check")] 94 + #[serde(skip)] 95 + pub resolved_scopes: Option<Vec<crate::scopes::Scope<smol_str::SmolStr>>>, 89 96 } 90 97 91 98 impl<S: BosStr + Ord + IntoStatic + AsRef<str>> IntoStatic for ClientSessionData<S> ··· 95 102 type Output = ClientSessionData<S::Output>; 96 103 97 104 fn into_static(self) -> Self::Output { 105 + #[cfg(feature = "scope-check")] 106 + let resolved_scopes = self.resolved_scopes; 107 + 98 108 ClientSessionData { 99 109 authserver_url: self.authserver_url.into_static(), 100 110 authserver_token_endpoint: self.authserver_token_endpoint.into_static(), ··· 107 117 account_did: self.account_did.into_static(), 108 118 session_id: self.session_id.into_static(), 109 119 host_url: self.host_url.clone(), 120 + #[cfg(feature = "scope-check")] 121 + resolved_scopes, 110 122 } 111 123 } 112 124 }
+1
crates/jacquard/Cargo.toml
··· 47 47 cache = ["jacquard-identity/cache"] 48 48 websocket = ["jacquard-common/websocket"] 49 49 zstd = ["jacquard-common/zstd"] 50 + scope-check = ["jacquard-oauth/scope-check"] 50 51 51 52 52 53
+2
crates/jacquard/src/client/token.rs
··· 152 152 token_type: session.token_type, 153 153 expires_at: session.expires_at, 154 154 }, 155 + #[cfg(feature = "scope-check")] 156 + resolved_scopes: None, 155 157 } 156 158 } 157 159 }
+6
crates/jacquard/tests/oauth_auto_refresh.rs
··· 237 237 token_type: OAuthTokenType::DPoP, 238 238 expires_at: None, 239 239 }, 240 + #[cfg(feature = "scope-check")] 241 + resolved_scopes: None, 240 242 } 241 243 .into_static(); 242 244 let client_arc = client.clone(); ··· 265 267 token_type: OAuthTokenType::DPoP, 266 268 expires_at: None, 267 269 }, 270 + #[cfg(feature = "scope-check")] 271 + resolved_scopes: None, 268 272 } 269 273 .into_static(); 270 274 registry.set(data_store).await.unwrap(); ··· 366 370 token_type: OAuthTokenType::DPoP, 367 371 expires_at: None, 368 372 }, 373 + #[cfg(feature = "scope-check")] 374 + resolved_scopes: None, 369 375 } 370 376 .into_static(); 371 377 let client_arc = client.clone();