diff --git a/Cargo.lock b/Cargo.lock index 5aa2ed91..752b8e64 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2365,6 +2365,7 @@ dependencies = [ "http", "jacquard-common", "jacquard-identity", + "jacquard-lexicon", "jose-jwa", "jose-jwk", "k256", diff --git a/crates/jacquard-oauth/Cargo.toml b/crates/jacquard-oauth/Cargo.toml index 887d1faf..b912793e 100644 --- a/crates/jacquard-oauth/Cargo.toml +++ b/crates/jacquard-oauth/Cargo.toml @@ -19,10 +19,12 @@ browser-open = ["dep:webbrowser"] tracing = ["dep:tracing"] websocket = ["jacquard-common/websocket"] streaming = ["jacquard-common/streaming", "dep:n0-future"] +scope-check = ["dep:jacquard-lexicon"] [dependencies] jacquard-common = { version = "0.12.0-beta.1", path = "../jacquard-common", features = ["reqwest-client"] } jacquard-identity = { version = "0.12.0-beta.1", path = "../jacquard-identity" } +jacquard-lexicon = { version = "0.12.0-beta.1", path = "../jacquard-lexicon", optional = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } smol_str = { workspace = true } diff --git a/crates/jacquard-oauth/src/client.rs b/crates/jacquard-oauth/src/client.rs index ba59246b..3e60216c 100644 --- a/crates/jacquard-oauth/src/client.rs +++ b/crates/jacquard-oauth/src/client.rs @@ -296,7 +296,7 @@ where } else { Scopes::empty() }; - let client_data = ClientSessionData { + let mut client_data = ClientSessionData { account_did: token_set.sub.clone(), session_id: auth_req_info.state, host_url: Uri::parse(token_set.aud.as_str())?.to_owned(), @@ -313,8 +313,15 @@ where .unwrap_or_default(), }, token_set, + #[cfg(feature = "scope-check")] + resolved_scopes: None, }; + // TODO: Phase 5 Task 3 - eagerly resolve include scopes + // When scope-check is enabled, iterate the scopes, find any Include scopes, + // resolve them via resolve_permission_set(), and populate resolved_scopes. + // For now, this is left as None. + self.create_session(client_data).await } Err(e) => Err(e.into()), diff --git a/crates/jacquard-oauth/src/request.rs b/crates/jacquard-oauth/src/request.rs index b4088b19..3a56e4d9 100644 --- a/crates/jacquard-oauth/src/request.rs +++ b/crates/jacquard-oauth/src/request.rs @@ -1096,6 +1096,8 @@ mod tests { token_type: crate::types::OAuthTokenType::DPoP, expires_at: None, }, + #[cfg(feature = "scope-check")] + resolved_scopes: None, }; let err = super::refresh(&client, session, &meta).await.unwrap_err(); assert!(matches!(err.kind(), RequestErrorKind::NoRefreshToken)); diff --git a/crates/jacquard-oauth/src/resolver.rs b/crates/jacquard-oauth/src/resolver.rs index 41443041..51998126 100644 --- a/crates/jacquard-oauth/src/resolver.rs +++ b/crates/jacquard-oauth/src/resolver.rs @@ -124,6 +124,30 @@ pub enum ResolverErrorKind { #[error("url parsing error")] #[diagnostic(code(jacquard_oauth::resolver::url))] Uri, + + /// Permission set is not a lexicon def + #[cfg(feature = "scope-check")] + #[error("permission set is not a valid lexicon def")] + #[diagnostic( + code(jacquard_oauth::resolver::not_a_permission_set), + help("ensure the lexicon schema's 'main' def is a permission-set type") + )] + NotAPermissionSet, + + /// Permission set namespace constraint violation + #[cfg(feature = "scope-check")] + #[error("permission set namespace violation: {0}")] + #[diagnostic( + code(jacquard_oauth::resolver::permission_set_namespace), + help("all permissions must be within the owning namespace") + )] + PermissionSetNamespace(SmolStr), + + /// Permission set conversion error + #[cfg(feature = "scope-check")] + #[error("permission set conversion error: {0}")] + #[diagnostic(code(jacquard_oauth::resolver::permission_set_conversion))] + PermissionSetConversion(SmolStr), } impl ResolverError { @@ -257,6 +281,24 @@ impl ResolverError { pub fn http_status(status: StatusCode) -> Self { Self::new(ResolverErrorKind::HttpStatus(status), None) } + + /// Create a "not a permission set" error + #[cfg(feature = "scope-check")] + pub fn not_a_permission_set() -> Self { + Self::new(ResolverErrorKind::NotAPermissionSet, None) + } + + /// Create a permission set namespace violation error + #[cfg(feature = "scope-check")] + pub fn permission_set_namespace(msg: impl Into) -> Self { + Self::new(ResolverErrorKind::PermissionSetNamespace(msg.into()), None) + } + + /// Create a permission set conversion error + #[cfg(feature = "scope-check")] + pub fn permission_set_conversion(msg: impl Into) -> Self { + Self::new(ResolverErrorKind::PermissionSetConversion(msg.into()), None) + } } /// Result type for resolver operations @@ -309,6 +351,16 @@ impl From for ResolverError { } } +#[cfg(feature = "scope-check")] +impl From for ResolverError { + fn from(e: jacquard_identity::lexicon_resolver::LexiconResolutionError) -> Self { + let msg = smol_str::format_smolstr!("{:?}", e); + Self::new(ResolverErrorKind::Transport, Some(Box::new(e))) + .with_context(msg) + .with_help("failed to resolve lexicon schema; check network connectivity") + } +} + // // Deprecated - for compatibility with old TransportError usage // #[allow(deprecated)] // impl From for ResolverError { @@ -764,6 +816,54 @@ pub trait OAuthResolver: IdentityResolver + HttpClient { } } +/// Resolve a permission set NSID into its constituent scopes. +/// +/// Requires both `OAuthResolver` (for identity/HTTP) and +/// `LexiconSchemaResolver` (for lexicon schema fetching, which uses +/// the `nsid_to_schema` cache with 7-day TTL). +#[cfg(feature = "scope-check")] +pub async fn resolve_permission_set( + resolver: &R, + nsid: &jacquard_common::types::nsid::Nsid, + inherited_audience: Option<&jacquard_common::types::did::Did>, +) -> Result>> +where + R: OAuthResolver + jacquard_identity::lexicon_resolver::LexiconSchemaResolver + Sync, + S: jacquard_common::bos::BosStr + Sync, +{ + use jacquard_lexicon::lexicon::{LexUserType, PermissionSetError}; + + // 1. Fetch the lexicon schema (cached via nsid_to_schema). + let schema = resolver.resolve_lexicon_schema(nsid).await?; + + // 2. Extract the "main" def from the LexiconDoc. + let main_def = schema.doc.defs.get("main") + .ok_or_else(|| ResolverError::not_found())?; + + // 3. Downcast to LexPermissionSet. + let perm_set = match main_def { + LexUserType::PermissionSet(ps) => ps, + _ => return Err(ResolverError::not_a_permission_set()), + }; + + // 4. Validate namespace constraints. + perm_set.validate(nsid.as_ref()) + .map_err(|e| match e { + PermissionSetError::EmptyPermissions => { + ResolverError::permission_set_conversion("permission set has empty permissions array") + } + PermissionSetError::NamespaceViolation { nsid: n, resource: r } => { + ResolverError::permission_set_namespace( + smol_str::format_smolstr!("{} references out-of-namespace resource: {}", n, r) + ) + } + })?; + + // 5. Expand to concrete scopes, passing inherited audience for inheritAud. + crate::scopes::expand_permission_set(perm_set, inherited_audience) + .map_err(|e| ResolverError::permission_set_conversion(smol_str::format_smolstr!("{}", e))) +} + /// Fetch and validate the `/.well-known/oauth-authorization-server` document for `server`. /// /// Per RFC 8414 ยง3.3 the `issuer` field in the response must equal the `server` URL exactly; @@ -918,4 +1018,32 @@ mod tests { let issuer_with_path = CowStr::new_static("https://issuer.example.com/path"); assert_ne!(issuer_base, issuer_with_path); } + + #[cfg(feature = "scope-check")] + #[tokio::test] + async fn test_expand_permission_set_exported() { + // This is a simple integration test that verifies expand_permission_set is accessible + use crate::scopes::expand_permission_set; + use jacquard_lexicon::lexicon::{LexPermission, LexPermissionResource, LexPermissionSet}; + use jacquard_common::CowStr; + + let mut perms = Vec::new(); + perms.push(LexPermission::Permission { + resource: LexPermissionResource::Identity { + attr: CowStr::Borrowed("handle"), + }, + }); + + let perm_set = LexPermissionSet { + title: None, + title_lang: None, + detail: None, + detail_lang: None, + permissions: perms, + }; + + let scopes = expand_permission_set(&perm_set, None).expect("should expand permission set"); + assert_eq!(scopes.len(), 1); + assert!(matches!(scopes[0], crate::scopes::Scope::Identity(crate::scopes::IdentityScope::Handle))); + } } diff --git a/crates/jacquard-oauth/src/scopes.rs b/crates/jacquard-oauth/src/scopes.rs index 90f8518a..fa3da7a6 100644 --- a/crates/jacquard-oauth/src/scopes.rs +++ b/crates/jacquard-oauth/src/scopes.rs @@ -2207,6 +2207,23 @@ fn parse_query_string(query: &str) -> BTreeMap> { params } +/// Error type for permission set expansion and conversion +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[non_exhaustive] +pub enum PermissionSetConversionError { + /// Unknown identity attribute in permission set + #[error("unknown identity attribute: {0}")] + UnknownIdentityAttr(String), + + /// Unknown account attribute in permission set + #[error("unknown account attribute: {0}")] + UnknownAccountAttr(String), + + /// Invalid MIME pattern in blob permission + #[error("invalid MIME pattern: {0}")] + InvalidMimePattern(String), +} + /// Error type for scope parsing #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, miette::Diagnostic)] #[non_exhaustive] @@ -2238,9 +2255,137 @@ impl fmt::Display for ParseError { } } +/// Convert a resolved permission set into its constituent scope values. +/// +/// Each permission entry expands to one or more concrete scopes: +/// - Repo: one `Scope::Repo` per collection NSID +/// - Rpc: one `Scope::Rpc` per lxm NSID (with shared aud) +/// - Blob: one `Scope::Blob` with all accept patterns +/// - Identity: `Scope::Identity` based on attr +/// - Account: `Scope::Account` based on attr and action +/// `inherited_audience` is the audience from the `include:` scope's `?aud=` +/// parameter. Passed to RPC permissions with `inherit_aud: true`. +#[cfg(feature = "scope-check")] +pub fn expand_permission_set( + perm_set: &jacquard_lexicon::lexicon::LexPermissionSet<'static>, + inherited_audience: Option<&Did>, +) -> Result>, PermissionSetConversionError> { + use jacquard_lexicon::lexicon::{LexPermission, LexPermissionResource}; + + let mut scopes = Vec::new(); + + for perm in &perm_set.permissions { + let LexPermission::Permission { resource } = perm; + match resource { + LexPermissionResource::Repo { collection, action } => { + let actions = action + .as_ref() + .map(|a| a.iter().copied().collect()) + .unwrap_or_else(|| { + let mut all = BTreeSet::new(); + all.insert(RepoAction::Create); + all.insert(RepoAction::Update); + all.insert(RepoAction::Delete); + all + }); + + for col_nsid in collection { + scopes.push(Scope::Repo(RepoScope { + collection: RepoCollection::Nsid(col_nsid.clone().convert()), + actions: actions.clone(), + })); + } + } + LexPermissionResource::Rpc { lxm, aud, inherit_aud } => { + // Build the audience set based on priority order + let mut aud_set = BTreeSet::new(); + if let Some(explicit_aud) = aud { + aud_set.insert(RpcAudience::Did(explicit_aud.clone().convert())); + } else if inherit_aud.unwrap_or(false) && inherited_audience.is_some() { + aud_set.insert(RpcAudience::Did(inherited_audience.unwrap().clone())); + } else { + aud_set.insert(RpcAudience::All); + } + + // Create one RpcScope with all lxm NSIDs and the resolved audience + let mut lxm_set = BTreeSet::new(); + for lxm_nsid in lxm { + lxm_set.insert(RpcLexicon::Nsid(lxm_nsid.clone().convert())); + } + + if !lxm_set.is_empty() { + scopes.push(Scope::Rpc(RpcScope { + lxm: lxm_set, + aud: aud_set, + })); + } + } + LexPermissionResource::Blob { accept, .. } => { + let mut patterns = BTreeSet::new(); + for mime_type in accept { + let pattern_str = mime_type.as_ref(); + match validate_mime_pattern(pattern_str) { + Ok(kind) => { + // For TypeWildcard, strip the `/*` suffix before storing. + let mime_str = match kind { + MimePatternKind::TypeWildcard => { + SmolStr::new(&pattern_str[..pattern_str.len() - 2]) + } + _ => SmolStr::new(pattern_str), + }; + let pattern = unsafe { MimePattern::unchecked(mime_str, kind) }; + patterns.insert(pattern); + } + Err(_) => { + return Err(PermissionSetConversionError::InvalidMimePattern( + pattern_str.to_string(), + )); + } + } + } + + if !patterns.is_empty() { + scopes.push(Scope::Blob(BlobScope { accept: patterns })); + } + } + LexPermissionResource::Identity { attr } => { + let identity_scope = match attr.as_ref() { + "handle" => IdentityScope::Handle, + "*" => IdentityScope::All, + other => return Err(PermissionSetConversionError::UnknownIdentityAttr(other.to_string())), + }; + scopes.push(Scope::Identity(identity_scope)); + } + LexPermissionResource::Account { attr, action } => { + let resource = match attr.as_ref() { + "email" => AccountResource::Email, + "repo" => AccountResource::Repo, + "status" => AccountResource::Status, + other => return Err(PermissionSetConversionError::UnknownAccountAttr(other.to_string())), + }; + + let act = action + .as_ref() + .and_then(|a| a.first()) + .copied() + .unwrap_or(AccountAction::Read); + + scopes.push(Scope::Account(AccountScope { + resource, + action: act, + })); + } + } + } + + Ok(scopes) +} + #[cfg(test)] mod tests { use super::*; + #[cfg(feature = "scope-check")] + use jacquard_common::CowStr; #[test] fn test_account_scope_parsing() { @@ -3700,4 +3845,367 @@ mod tests { let normalized = scopes.to_normalized_string(); assert_eq!(normalized, "rpc:*"); } + + #[cfg(feature = "scope-check")] + #[test] + fn test_expand_permission_set_repo() { + use jacquard_lexicon::lexicon::{LexPermissionSet, LexPermission, LexPermissionResource}; + + // Create a simple permission set with a repo permission + let mut perms = Vec::new(); + perms.push(LexPermission::Permission { + resource: LexPermissionResource::Repo { + collection: vec![ + Nsid::new_static("app.bsky.feed.post").unwrap(), + Nsid::new_static("app.bsky.graph.follow").unwrap(), + ], + action: Some(vec![RepoAction::Create]), + }, + }); + + let perm_set = LexPermissionSet { + title: None, + title_lang: None, + detail: None, + detail_lang: None, + permissions: perms, + }; + + let scopes = expand_permission_set(&perm_set, None).unwrap(); + assert_eq!(scopes.len(), 2); + + // Check that we got the expected repo scopes + let mut found_post = false; + let mut found_follow = false; + + for scope in &scopes { + if let Scope::Repo(repo_scope) = scope { + if let RepoCollection::Nsid(nsid) = &repo_scope.collection { + if nsid.as_ref() == "app.bsky.feed.post" { + assert_eq!(repo_scope.actions.len(), 1); + assert!(repo_scope.actions.contains(&RepoAction::Create)); + found_post = true; + } else if nsid.as_ref() == "app.bsky.graph.follow" { + assert_eq!(repo_scope.actions.len(), 1); + assert!(repo_scope.actions.contains(&RepoAction::Create)); + found_follow = true; + } + } + } + } + + assert!(found_post, "Expected post scope"); + assert!(found_follow, "Expected follow scope"); + } + + #[cfg(feature = "scope-check")] + #[test] + fn test_expand_permission_set_identity() { + use jacquard_lexicon::lexicon::{LexPermissionSet, LexPermission, LexPermissionResource}; + + let mut perms = Vec::new(); + perms.push(LexPermission::Permission { + resource: LexPermissionResource::Identity { + attr: CowStr::Borrowed("handle"), + }, + }); + + let perm_set = LexPermissionSet { + title: None, + title_lang: None, + detail: None, + detail_lang: None, + permissions: perms, + }; + + let scopes = expand_permission_set(&perm_set, None).unwrap(); + assert_eq!(scopes.len(), 1); + + assert_eq!(scopes[0], Scope::Identity(IdentityScope::Handle)); + } + + #[cfg(feature = "scope-check")] + #[test] + fn test_expand_permission_set_account() { + use jacquard_lexicon::lexicon::{LexPermissionSet, LexPermission, LexPermissionResource}; + + let mut perms = Vec::new(); + perms.push(LexPermission::Permission { + resource: LexPermissionResource::Account { + attr: CowStr::Borrowed("email"), + action: Some(vec![AccountAction::Manage]), + }, + }); + + let perm_set = LexPermissionSet { + title: None, + title_lang: None, + detail: None, + detail_lang: None, + permissions: perms, + }; + + let scopes = expand_permission_set(&perm_set, None).unwrap(); + assert_eq!(scopes.len(), 1); + + assert_eq!( + scopes[0], + Scope::Account(AccountScope { + resource: AccountResource::Email, + action: AccountAction::Manage, + }) + ); + } + + #[cfg(feature = "scope-check")] + #[test] + fn test_expand_permission_set_rpc_with_inherit_aud() { + use jacquard_lexicon::lexicon::{LexPermissionSet, LexPermission, LexPermissionResource}; + + let mut perms = Vec::new(); + perms.push(LexPermission::Permission { + resource: LexPermissionResource::Rpc { + lxm: vec![Nsid::new_static("app.bsky.feed.getTimeline").unwrap()], + aud: None, + inherit_aud: Some(true), + }, + }); + + let perm_set = LexPermissionSet { + title: None, + title_lang: None, + detail: None, + detail_lang: None, + permissions: perms, + }; + + let inherited_did = Did::new_static("did:web:example.com").unwrap(); + let scopes = expand_permission_set(&perm_set, Some(&inherited_did)).unwrap(); + assert_eq!(scopes.len(), 1); + + if let Scope::Rpc(rpc_scope) = &scopes[0] { + assert_eq!(rpc_scope.lxm.len(), 1); + assert_eq!(rpc_scope.aud.len(), 1); + assert!(matches!(rpc_scope.aud.iter().next(), Some(RpcAudience::Did(d)) if d.as_ref() == "did:web:example.com")); + } else { + panic!("Expected Rpc scope"); + } + } + + #[cfg(feature = "scope-check")] + #[test] + fn test_expand_permission_set_rpc_explicit_aud() { + use jacquard_lexicon::lexicon::{LexPermissionSet, LexPermission, LexPermissionResource}; + + let mut perms = Vec::new(); + perms.push(LexPermission::Permission { + resource: LexPermissionResource::Rpc { + lxm: vec![Nsid::new_static("app.bsky.feed.getTimeline").unwrap()], + aud: Some(Did::new_static("did:web:custom.com").unwrap()), + inherit_aud: None, + }, + }); + + let perm_set = LexPermissionSet { + title: None, + title_lang: None, + detail: None, + detail_lang: None, + permissions: perms, + }; + + let scopes = expand_permission_set(&perm_set, None).unwrap(); + assert_eq!(scopes.len(), 1); + + if let Scope::Rpc(rpc_scope) = &scopes[0] { + assert_eq!(rpc_scope.aud.len(), 1); + assert!(matches!(rpc_scope.aud.iter().next(), Some(RpcAudience::Did(d)) if d.as_ref() == "did:web:custom.com")); + } else { + panic!("Expected Rpc scope"); + } + } + + #[cfg(feature = "scope-check")] + #[test] + fn test_expand_permission_set_unknown_identity_attr() { + use jacquard_lexicon::lexicon::{LexPermissionSet, LexPermission, LexPermissionResource}; + + let mut perms = Vec::new(); + perms.push(LexPermission::Permission { + resource: LexPermissionResource::Identity { + attr: CowStr::Borrowed("invalid"), + }, + }); + + let perm_set = LexPermissionSet { + title: None, + title_lang: None, + detail: None, + detail_lang: None, + permissions: perms, + }; + + let result = expand_permission_set(&perm_set, None); + assert!(matches!(result, Err(PermissionSetConversionError::UnknownIdentityAttr(_)))); + } + + #[cfg(feature = "scope-check")] + #[test] + fn test_expand_permission_set_unknown_account_attr() { + use jacquard_lexicon::lexicon::{LexPermissionSet, LexPermission, LexPermissionResource}; + + let mut perms = Vec::new(); + perms.push(LexPermission::Permission { + resource: LexPermissionResource::Account { + attr: CowStr::Borrowed("invalid"), + action: None, + }, + }); + + let perm_set = LexPermissionSet { + title: None, + title_lang: None, + detail: None, + detail_lang: None, + permissions: perms, + }; + + let result = expand_permission_set(&perm_set, None); + assert!(matches!(result, Err(PermissionSetConversionError::UnknownAccountAttr(_)))); + } + + #[cfg(feature = "scope-check")] + #[test] + fn test_expand_permission_set_blob() { + use jacquard_lexicon::lexicon::{LexPermissionSet, LexPermission, LexPermissionResource}; + use jacquard_common::types::blob::MimeType; + + // Test exact type + let mut perms = Vec::new(); + perms.push(LexPermission::Permission { + resource: LexPermissionResource::Blob { + accept: vec![MimeType::new(CowStr::Borrowed("image/png"))], + max_size: None, + }, + }); + + let perm_set = LexPermissionSet { + title: None, + title_lang: None, + detail: None, + detail_lang: None, + permissions: perms, + }; + + let scopes = expand_permission_set(&perm_set, None).expect("should expand blob"); + assert_eq!(scopes.len(), 1); + match &scopes[0] { + Scope::Blob(blob_scope) => { + assert_eq!(blob_scope.accept.len(), 1); + for pattern in &blob_scope.accept { + if let MimePattern::Exact(s) = pattern { + assert_eq!(s.as_ref() as &str, "image/png"); + } else { + panic!("expected Exact pattern"); + } + } + } + _ => panic!("expected Blob scope"), + } + + // Test type wildcard + let mut perms = Vec::new(); + perms.push(LexPermission::Permission { + resource: LexPermissionResource::Blob { + accept: vec![MimeType::new(CowStr::Borrowed("image/*"))], + max_size: None, + }, + }); + + let perm_set = LexPermissionSet { + title: None, + title_lang: None, + detail: None, + detail_lang: None, + permissions: perms, + }; + + let scopes = expand_permission_set(&perm_set, None).expect("should expand blob"); + assert_eq!(scopes.len(), 1); + match &scopes[0] { + Scope::Blob(blob_scope) => { + assert_eq!(blob_scope.accept.len(), 1); + // TypeWildcard should store only the type prefix (e.g., "image") + for pattern in &blob_scope.accept { + if let MimePattern::TypeWildcard(s) = pattern { + assert_eq!(s.as_ref() as &str, "image"); + } else { + panic!("expected TypeWildcard pattern"); + } + } + } + _ => panic!("expected Blob scope"), + } + + // Test all wildcard + let mut perms = Vec::new(); + perms.push(LexPermission::Permission { + resource: LexPermissionResource::Blob { + accept: vec![MimeType::new(CowStr::Borrowed("*/*"))], + max_size: None, + }, + }); + + let perm_set = LexPermissionSet { + title: None, + title_lang: None, + detail: None, + detail_lang: None, + permissions: perms, + }; + + let scopes = expand_permission_set(&perm_set, None).expect("should expand blob"); + assert_eq!(scopes.len(), 1); + match &scopes[0] { + Scope::Blob(blob_scope) => { + assert_eq!(blob_scope.accept.len(), 1); + assert!( + blob_scope + .accept + .iter() + .any(|p| matches!(p, MimePattern::All)) + ); + } + _ => panic!("expected Blob scope"), + } + } + + #[cfg(feature = "scope-check")] + #[test] + fn test_expand_permission_set_blob_invalid_mime() { + use jacquard_common::types::blob::MimeType; + use jacquard_lexicon::lexicon::{LexPermission, LexPermissionResource, LexPermissionSet}; + + let mut perms = Vec::new(); + perms.push(LexPermission::Permission { + resource: LexPermissionResource::Blob { + accept: vec![MimeType::new(CowStr::Borrowed("invalid-mime-type"))], + max_size: None, + }, + }); + + let perm_set = LexPermissionSet { + title: None, + title_lang: None, + detail: None, + detail_lang: None, + permissions: perms, + }; + + let result = expand_permission_set(&perm_set, None); + assert!(matches!( + result, + Err(PermissionSetConversionError::InvalidMimePattern(_)) + )); + } } diff --git a/crates/jacquard-oauth/src/session.rs b/crates/jacquard-oauth/src/session.rs index 388b6380..405cb271 100644 --- a/crates/jacquard-oauth/src/session.rs +++ b/crates/jacquard-oauth/src/session.rs @@ -86,6 +86,13 @@ pub struct ClientSessionData { /// Current token set (access token, refresh token, expiry, etc.). #[serde(flatten)] pub token_set: TokenSet, + + /// Fully expanded scopes with include scopes resolved. + /// Populated eagerly at session creation when `scope-check` is enabled. + /// `None` when `scope-check` is disabled or no include scopes are present. + #[cfg(feature = "scope-check")] + #[serde(skip)] + pub resolved_scopes: Option>>, } impl> IntoStatic for ClientSessionData @@ -95,6 +102,9 @@ where type Output = ClientSessionData; fn into_static(self) -> Self::Output { + #[cfg(feature = "scope-check")] + let resolved_scopes = self.resolved_scopes; + ClientSessionData { authserver_url: self.authserver_url.into_static(), authserver_token_endpoint: self.authserver_token_endpoint.into_static(), @@ -107,6 +117,8 @@ where account_did: self.account_did.into_static(), session_id: self.session_id.into_static(), host_url: self.host_url.clone(), + #[cfg(feature = "scope-check")] + resolved_scopes, } } } diff --git a/crates/jacquard/Cargo.toml b/crates/jacquard/Cargo.toml index 80c275b9..019a1baa 100644 --- a/crates/jacquard/Cargo.toml +++ b/crates/jacquard/Cargo.toml @@ -47,6 +47,7 @@ streaming = [ cache = ["jacquard-identity/cache"] websocket = ["jacquard-common/websocket"] zstd = ["jacquard-common/zstd"] +scope-check = ["jacquard-oauth/scope-check"] diff --git a/crates/jacquard/src/client/token.rs b/crates/jacquard/src/client/token.rs index ed9e9c1d..5a3a95a2 100644 --- a/crates/jacquard/src/client/token.rs +++ b/crates/jacquard/src/client/token.rs @@ -152,6 +152,8 @@ impl From for ClientSessionData { token_type: session.token_type, expires_at: session.expires_at, }, + #[cfg(feature = "scope-check")] + resolved_scopes: None, } } } diff --git a/crates/jacquard/tests/oauth_auto_refresh.rs b/crates/jacquard/tests/oauth_auto_refresh.rs index 5baabfca..5b309ab6 100644 --- a/crates/jacquard/tests/oauth_auto_refresh.rs +++ b/crates/jacquard/tests/oauth_auto_refresh.rs @@ -237,6 +237,8 @@ async fn oauth_xrpc_invalid_token_triggers_refresh_and_retries() { token_type: OAuthTokenType::DPoP, expires_at: None, }, + #[cfg(feature = "scope-check")] + resolved_scopes: None, } .into_static(); let client_arc = client.clone(); @@ -265,6 +267,8 @@ async fn oauth_xrpc_invalid_token_triggers_refresh_and_retries() { token_type: OAuthTokenType::DPoP, expires_at: None, }, + #[cfg(feature = "scope-check")] + resolved_scopes: None, } .into_static(); registry.set(data_store).await.unwrap(); @@ -366,6 +370,8 @@ async fn oauth_xrpc_invalid_token_body_triggers_refresh_and_retries() { token_type: OAuthTokenType::DPoP, expires_at: None, }, + #[cfg(feature = "scope-check")] + resolved_scopes: None, } .into_static(); let client_arc = client.clone();