From 00ca223b5ffcab9aa9034437771043ec43688985 Mon Sep 17 00:00:00 2001 From: Lewis Date: Wed, 22 Jul 2026 23:27:01 +0300 Subject: [PATCH 01/39] identity: force did:web signkey check to #atproto verification Lewis: May this revision serve well! --- crates/tranquil-api/src/identity/did.rs | 9 +- crates/tranquil-pds/tests/did_web.rs | 130 ++++++++++++++++++++++++ 2 files changed, 137 insertions(+), 2 deletions(-) diff --git a/crates/tranquil-api/src/identity/did.rs b/crates/tranquil-api/src/identity/did.rs index b2d2551..3d4e7e8 100644 --- a/crates/tranquil-api/src/identity/did.rs +++ b/crates/tranquil-api/src/identity/did.rs @@ -425,10 +425,15 @@ pub async fn verify_did_web( let expected_multibase = expected_signing_key .strip_prefix("did:key:") .ok_or(DidWebVerifyError::InvalidSigningKey)?; + let did_prefixed_key_id = format!("{}#atproto", did); let has_matching_key = verification_methods.iter().any(|vm| { - vm["publicKeyMultibase"] + let is_atproto_method = vm["id"] .as_str() - .is_some_and(|pk| pk == expected_multibase) + .is_some_and(|id| id == "#atproto" || id == did_prefixed_key_id); + is_atproto_method + && vm["publicKeyMultibase"] + .as_str() + .is_some_and(|pk| pk == expected_multibase) }); if !has_matching_key { return Err(DidWebVerifyError::KeyMismatch( diff --git a/crates/tranquil-pds/tests/did_web.rs b/crates/tranquil-pds/tests/did_web.rs index 8b43518..d3e0286 100644 --- a/crates/tranquil-pds/tests/did_web.rs +++ b/crates/tranquil-pds/tests/did_web.rs @@ -175,6 +175,136 @@ async fn test_external_did_web_no_local_doc() { ); } +async fn reserve_signing_key(client: &reqwest::Client, base: &str, did: &str) -> String { + let res = client + .post(format!("{}/xrpc/com.atproto.server.reserveSigningKey", base)) + .json(&json!({ "did": did })) + .send() + .await + .expect("Failed to reserve signing key"); + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.expect("Response wasn't JSON"); + body["signingKey"] + .as_str() + .expect("No signingKey returned") + .to_string() +} + +async fn assert_reserved_key_placement_rejected( + build_methods: impl FnOnce(&str, &str, &str) -> Value, +) { + let client = client(); + let base = base_url().await; + let mock_server = MockServer::start().await; + let mock_uri = mock_server.uri(); + let mock_addr = mock_uri.trim_start_matches("http://"); + let did = format!("did:web:{}", mock_addr.replace(":", "%3A")); + let handle = format!("wm{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); + let pds_endpoint = common::pds_endpoint(); + + let signing_key = reserve_signing_key(&client, base, &did).await; + let signing_multibase = signing_key + .strip_prefix("did:key:") + .expect("signingKey should start with did:key:"); + + let decoy_did = format!( + "did:web:{}.nel.pet", + &uuid::Uuid::new_v4().simple().to_string()[..12] + ); + let decoy_key = reserve_signing_key(&client, base, &decoy_did).await; + let decoy_multibase = decoy_key + .strip_prefix("did:key:") + .expect("decoy signingKey should start with did:key:"); + + let did_doc = json!({ + "@context": ["https://www.w3.org/ns/did/v1"], + "id": did, + "verificationMethod": build_methods(signing_multibase, decoy_multibase, &did), + "service": [{ + "id": "#atproto_pds", + "type": "AtprotoPersonalDataServer", + "serviceEndpoint": pds_endpoint + }] + }); + Mock::given(method("GET")) + .and(path("/.well-known/did.json")) + .respond_with(ResponseTemplate::new(200).set_body_json(did_doc)) + .mount(&mock_server) + .await; + + let payload = json!({ + "handle": handle, + "email": format!("{}@nel.pet", handle), + "password": "Testpass123!", + "didType": "web-external", + "did": did, + "signingKey": signing_key + }); + let res = client + .post(format!("{}/xrpc/com.atproto.server.createAccount", base)) + .json(&payload) + .send() + .await + .expect("Failed to send request"); + assert_ne!( + res.status(), + StatusCode::OK, + "createAccount must reject a did:web doc whose #atproto method isn't the reserved signing key" + ); + let body: Value = res.json().await.expect("Response was not JSON"); + let message = body["message"] + .as_str() + .or_else(|| body["error"].as_str()) + .unwrap_or(""); + assert!( + message.contains("reserved signing key"), + "error should report signing-key mismatch, got: {:?}", + body + ); +} + +#[tokio::test] +async fn test_external_did_web_signing_key_under_wrong_method_rejected() { + assert_reserved_key_placement_rejected(|signing_multibase, decoy_multibase, did| { + json!([ + { + "id": format!("{}#atproto", did), + "type": "Multikey", + "controller": did, + "publicKeyMultibase": decoy_multibase + }, + { + "id": format!("{}#atproto_reserved", did), + "type": "Multikey", + "controller": did, + "publicKeyMultibase": signing_multibase + } + ]) + }) + .await; +} + +#[tokio::test] +async fn test_external_did_web_signing_key_under_foreign_atproto_fragment_rejected() { + assert_reserved_key_placement_rejected(|signing_multibase, decoy_multibase, did| { + json!([ + { + "id": "did:web:squid.nel.pet#atproto", + "type": "Multikey", + "controller": did, + "publicKeyMultibase": signing_multibase + }, + { + "id": format!("{}#atproto", did), + "type": "Multikey", + "controller": did, + "publicKeyMultibase": decoy_multibase + } + ]) + }) + .await; +} + #[tokio::test] async fn test_plc_operations_blocked_for_did_web() { let client = client(); -- 2.51.2 From f17adc6f88a2667f1ef727f239f7ef2e36f33ddd Mon Sep 17 00:00:00 2001 From: Trezy Date: Sun, 19 Jul 2026 07:30:47 -0500 Subject: [PATCH 02/39] refactor: use tranquil-scopes instead of bespoke scope handling in delegation auth Signed-off-by: Trezy --- Cargo.lock | 6 + crates/tranquil-pds/src/delegation/scopes.rs | 122 +------------ crates/tranquil-scopes/src/coverage.rs | 183 +++++++++++++++++++ crates/tranquil-scopes/src/lib.rs | 2 + 4 files changed, 201 insertions(+), 112 deletions(-) create mode 100644 crates/tranquil-scopes/src/coverage.rs diff --git a/Cargo.lock b/Cargo.lock index 40a2ea2..85df977 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7698,6 +7698,7 @@ dependencies = [ "totp-rs", "tranquil-config", "tranquil-crypto", + "tranquil-types", "urlencoding", "uuid", ] @@ -7825,6 +7826,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tracing", + "tranquil-types", "unicode-segmentation", "urlencoding", "wiremock", @@ -8034,6 +8036,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tracing", + "tranquil-types", "urlencoding", ] @@ -8189,13 +8192,16 @@ dependencies = [ name = "tranquil-types" version = "0.6.5" dependencies = [ + "base64 0.22.1", "chrono", "cid", "jacquard-common", + "rand 0.8.5", "serde", "serde_json", "sqlx", "thiserror 2.0.18", + "uuid", ] [[package]] diff --git a/crates/tranquil-pds/src/delegation/scopes.rs b/crates/tranquil-pds/src/delegation/scopes.rs index 07b0196..0f857e8 100644 --- a/crates/tranquil-pds/src/delegation/scopes.rs +++ b/crates/tranquil-pds/src/delegation/scopes.rs @@ -1,5 +1,7 @@ use std::collections::HashSet; +use tranquil_scopes::{covers, parse_scope}; + pub use tranquil_db_traits::{ DbScope as ValidatedDelegationScope, InvalidScopeError as InvalidDelegationScopeError, }; @@ -46,12 +48,13 @@ pub const SCOPE_PRESETS: &[ScopePreset] = &[ pub fn intersect_scopes(requested: &str, granted: &str) -> String { let requested_set: HashSet<&str> = requested.split_whitespace().collect(); - let granted_set: HashSet<&str> = granted.split_whitespace().collect(); + let granted_parsed: Vec = + granted.split_whitespace().map(parse_scope).collect(); let mut scopes: Vec<&str> = requested_set .iter() .filter(|requested_scope| { - **requested_scope != "atproto" && any_granted_covers(requested_scope, &granted_set) + **requested_scope != "atproto" && any_granted_covers(requested_scope, &granted_parsed) }) .copied() .chain(requested_set.contains("atproto").then_some("atproto")) @@ -60,81 +63,9 @@ pub fn intersect_scopes(requested: &str, granted: &str) -> String { scopes.join(" ") } -fn any_granted_covers(requested: &str, granted: &HashSet<&str>) -> bool { - granted - .iter() - .any(|granted_scope| scope_covers(granted_scope, requested)) -} - -fn scope_covers(granted: &str, requested: &str) -> bool { - if granted == requested { - return true; - } - - let (granted_base, granted_params) = split_scope(granted); - let (requested_base, requested_params) = split_scope(requested); - - let base_matches = if granted_base.ends_with(":*") - && requested_base.starts_with(&granted_base[..granted_base.len() - 1]) - { - true - } else if let Some(prefix) = granted_base.strip_suffix(".*") - && requested_base.starts_with(prefix) - && requested_base.len() > prefix.len() - { - true - } else { - granted_base == requested_base - }; - - if !base_matches { - return false; - } - - match (granted_params, requested_params) { - (None, _) => true, - (Some(_), None) => true, - (Some(gp), Some(rp)) => params_cover(gp, rp), - } -} - -fn params_cover(granted_params: &str, requested_params: &str) -> bool { - let granted_kv: HashSet<(&str, &str)> = granted_params - .split('&') - .filter_map(|pair| pair.split_once('=')) - .collect(); - let requested_kv: HashSet<(&str, &str)> = requested_params - .split('&') - .filter_map(|pair| pair.split_once('=')) - .collect(); - - let granted_keys: HashSet<&str> = granted_kv.iter().map(|(k, _)| *k).collect(); - let requested_keys: HashSet<&str> = requested_kv.iter().map(|(k, _)| *k).collect(); - - requested_keys.iter().all(|key| { - if !granted_keys.contains(key) { - return false; - } - let requested_values: HashSet<&str> = requested_kv - .iter() - .filter(|(k, _)| k == key) - .map(|(_, v)| *v) - .collect(); - let granted_values: HashSet<&str> = granted_kv - .iter() - .filter(|(k, _)| k == key) - .map(|(_, v)| *v) - .collect(); - requested_values.is_subset(&granted_values) - }) -} - -fn split_scope(scope: &str) -> (&str, Option<&str>) { - if let Some(idx) = scope.find('?') { - (&scope[..idx], Some(&scope[idx + 1..])) - } else { - (scope, None) - } +fn any_granted_covers(requested: &str, granted: &[tranquil_scopes::ParsedScope]) -> bool { + let requested_parsed = parse_scope(requested); + granted.iter().any(|g| covers(g, &requested_parsed)) } #[cfg(test)] @@ -280,12 +211,12 @@ mod tests { } #[test] - fn test_intersect_granted_with_params_covers_requested_no_params() { + fn test_intersect_partial_action_grant_drops_actionless_request() { let result = intersect_scopes( "repo:app.bsky.feed.post", "repo:*?action=create&action=delete", ); - assert_eq!(result, "repo:app.bsky.feed.post"); + assert_eq!(result, ""); } #[test] @@ -297,39 +228,6 @@ mod tests { assert_eq!(result, "repo:*?action=create"); } - #[test] - fn test_scope_covers_base_only() { - assert!(scope_covers("repo:*", "repo:app.bsky.feed.post")); - assert!(scope_covers( - "repo:*", - "repo:app.bsky.feed.post?action=create" - )); - assert!(!scope_covers("blob:*/*", "repo:app.bsky.feed.post")); - } - - #[test] - fn test_scope_covers_params() { - assert!(scope_covers("repo:*?action=create", "repo:*?action=create")); - assert!(!scope_covers( - "repo:*?action=create", - "repo:*?action=delete" - )); - assert!(scope_covers( - "repo:*?action=create&action=delete", - "repo:*?action=create" - )); - assert!(!scope_covers( - "repo:*?action=create", - "repo:*?action=create&action=delete" - )); - } - - #[test] - fn test_scope_covers_no_granted_params_means_all() { - assert!(scope_covers("repo:*", "repo:*?action=create")); - assert!(scope_covers("repo:*", "repo:*?action=delete")); - } - #[test] fn test_validate_scopes_valid() { assert!(ValidatedDelegationScope::new("atproto").is_ok()); diff --git a/crates/tranquil-scopes/src/coverage.rs b/crates/tranquil-scopes/src/coverage.rs new file mode 100644 index 0000000..9a4c1af --- /dev/null +++ b/crates/tranquil-scopes/src/coverage.rs @@ -0,0 +1,183 @@ +use crate::parser::{ + AccountAction, AccountAttr, AccountScope, BlobScope, IdentityAttr, IdentityScope, ParsedScope, + RepoScope, RpcScope, +}; + +/// Returns true if the `granted` scope authorizes everything the `requested` scope asks for. +/// Typed replacement for the old string-prefix `scope_covers`. +pub fn covers(granted: &ParsedScope, requested: &ParsedScope) -> bool { + use ParsedScope::*; + match (granted, requested) { + (Atproto, Atproto) => true, + (TransitionGeneric, TransitionGeneric) => true, + (TransitionChat, TransitionChat) => true, + (TransitionEmail, TransitionEmail) => true, + (Repo(g), Repo(r)) => repo_covers(g, r), + (Blob(g), Blob(r)) => blob_covers(g, r), + (Rpc(g), Rpc(r)) => rpc_covers(g, r), + (Account(g), Account(r)) => account_covers(g, r), + (Identity(g), Identity(r)) => identity_covers(g, r), + (Include(g), Include(r)) => g.nsid == r.nsid && g.aud == r.aud, + (Unknown(g), Unknown(r)) => g == r, + _ => false, + } +} + +fn repo_covers(g: &RepoScope, r: &RepoScope) -> bool { + let collection_ok = match &g.collection { + None => true, // repo:* — any collection + Some(gc) => match &r.collection { + None => false, // a specific grant cannot cover a wildcard request + Some(rc) => match gc.strip_suffix(".*") { + Some(prefix) => rc.starts_with(prefix) && rc.as_bytes().get(prefix.len()) == Some(&b'.'), + None => gc == rc, + }, + }, + }; + // parse_scope expands a missing ?action to all three actions, so subset is exact. + collection_ok && r.actions.is_subset(&g.actions) +} + +fn blob_covers(g: &BlobScope, r: &BlobScope) -> bool { + if g.accept.is_empty() || g.accept.contains("*/*") { + return true; + } + // every accept pattern the request wants must be matched by the grant + !r.accept.is_empty() && r.accept.iter().all(|pat| g.matches_mime(pat)) +} + +fn rpc_covers(g: &RpcScope, r: &RpcScope) -> bool { + let lxm_ok = match &g.lxm { + None => true, + Some(gl) if gl == "*" => true, + Some(gl) => r.lxm.as_deref() == Some(gl.as_str()), + }; + let aud_ok = match &g.aud { + None => true, + Some(ga) if ga == "*" => true, + Some(ga) => r.aud.as_deref() == Some(ga.as_str()), + }; + lxm_ok && aud_ok +} + +fn account_covers(g: &AccountScope, r: &AccountScope) -> bool { + let attr_ok = g.attr == AccountAttr::Wildcard || g.attr == r.attr; + let action_ok = match (g.action, r.action) { + (AccountAction::Manage, _) => true, // manage ⊇ read + (AccountAction::Read, AccountAction::Read) => true, + (AccountAction::Read, AccountAction::Manage) => false, + }; + attr_ok && action_ok +} + +fn identity_covers(g: &IdentityScope, r: &IdentityScope) -> bool { + g.attr == IdentityAttr::Wildcard || g.attr == r.attr +} + +#[cfg(test)] +mod tests { + use super::covers; + use crate::parser::parse_scope; + + fn c(granted: &str, requested: &str) -> bool { + covers(&parse_scope(granted), &parse_scope(requested)) + } + + #[test] + fn repo_wildcard_covers_specific() { + assert!(c("repo:*", "repo:app.bsky.feed.post")); + assert!(c("repo:*", "repo:app.bsky.feed.post?action=create")); + } + + #[test] + fn repo_specific_does_not_cover_wildcard() { + assert!(!c("repo:app.bsky.feed.post", "repo:*")); + } + + #[test] + fn repo_action_subset_required() { + // granted no ?action == all three actions + assert!(c("repo:*", "repo:*?action=create")); + assert!(!c("repo:*?action=create", "repo:*?action=delete")); + assert!(c("repo:*?action=create&action=delete", "repo:*?action=create")); + assert!(!c("repo:*?action=create", "repo:*?action=create&action=delete")); + } + + #[test] + fn repo_partial_action_grant_does_not_cover_actionless_request() { + // SECURITY: actionless requested repo == all three actions; a create+delete + // grant must NOT cover it (previously the string engine wrongly did). + assert!(!c("repo:*?action=create&action=delete", "repo:app.bsky.feed.post")); + } + + #[test] + fn repo_collection_prefix_wildcard() { + assert!(c("repo:app.bsky.*", "repo:app.bsky.feed.post")); + assert!(!c("repo:app.bsky.*", "repo:app.bsky")); + assert!(!c("repo:app.bsky.*", "repo:com.example.foo")); + } + + #[test] + fn blob_wildcard_covers_all() { + assert!(c("blob:*/*", "blob:image/png")); + assert!(c("blob:*/*", "blob:*/*")); + } + + #[test] + fn blob_type_prefix() { + assert!(c("blob:image/*", "blob:image/png")); + assert!(!c("blob:image/*", "blob:video/mp4")); + } + + #[test] + fn rpc_wildcards() { + assert!(c("rpc:*?aud=did:web:x", "rpc:app.bsky.getX?aud=did:web:x")); + assert!(c("rpc:app.bsky.getX?aud=*", "rpc:app.bsky.getX?aud=did:web:x")); + assert!(!c("rpc:app.bsky.getX?aud=did:web:x", "rpc:app.bsky.getY?aud=did:web:x")); + } + + #[test] + fn account_manage_covers_read_and_wildcard_attr() { + assert!(c("account:*?action=manage", "account:email?action=read")); + assert!(c("account:*?action=manage", "account:email?action=manage")); + assert!(!c("account:email?action=read", "account:email?action=manage")); + } + + #[test] + fn identity_wildcard_covers_handle() { + assert!(c("identity:*", "identity:handle")); + assert!(!c("identity:handle", "identity:*")); + } + + #[test] + fn cross_type_never_covers() { + assert!(!c("repo:*", "blob:*/*")); + assert!(!c("blob:*/*", "repo:app.bsky.feed.post")); + } + + #[test] + fn atproto_and_transition_self_cover() { + assert!(c("atproto", "atproto")); + assert!(c("transition:generic", "transition:generic")); + assert!(!c("transition:generic", "atproto")); + } + + #[test] + fn repo_prefix_wildcard_respects_dot_boundary() { + assert!(!c("repo:app.bsky.*", "repo:app.bskyEXTRA")); + assert!(c("repo:app.bsky.*", "repo:app.bsky.feed.post")); + assert!(!c("repo:app.bsky.*", "repo:app.bsky")); // no trailing segment + } + + #[test] + fn include_exact_match_only() { + assert!(c("include:io.x.set", "include:io.x.set")); + assert!(!c("include:io.x.set", "include:io.y.set")); + } + + #[test] + fn unknown_exact_match_only() { + assert!(c("weird:token", "weird:token")); + assert!(!c("weird:token", "other:token")); + } +} diff --git a/crates/tranquil-scopes/src/lib.rs b/crates/tranquil-scopes/src/lib.rs index 9b9a5c9..fd01786 100644 --- a/crates/tranquil-scopes/src/lib.rs +++ b/crates/tranquil-scopes/src/lib.rs @@ -1,9 +1,11 @@ +mod coverage; mod definitions; mod error; mod parser; mod permission_set; mod permissions; +pub use coverage::covers; pub use definitions::{ SCOPE_DEFINITIONS, ScopeCategory, ScopeDefinition, format_scope_for_display, get_required_scopes, get_scope_definition, is_valid_scope, -- 2.51.2 From ecdda4c555508ec1e1f17a764c9bd00da9c90be3 Mon Sep 17 00:00:00 2001 From: Trezy Date: Sun, 19 Jul 2026 10:57:03 -0500 Subject: [PATCH 03/39] feat: cache expanded permission sets --- crates/tranquil-pds/src/cache_keys.rs | 7 + crates/tranquil-pds/src/oauth/mod.rs | 2 + .../src/oauth/permission_set_resolver.rs | 182 ++++++++++++++++++ crates/tranquil-scopes/src/lib.rs | 5 +- crates/tranquil-scopes/src/permission_set.rs | 166 +++++++++++++--- 5 files changed, 334 insertions(+), 28 deletions(-) create mode 100644 crates/tranquil-pds/src/oauth/permission_set_resolver.rs diff --git a/crates/tranquil-pds/src/cache_keys.rs b/crates/tranquil-pds/src/cache_keys.rs index 9fd03d5..6625577 100644 --- a/crates/tranquil-pds/src/cache_keys.rs +++ b/crates/tranquil-pds/src/cache_keys.rs @@ -39,3 +39,10 @@ pub fn scope_ref_key(cid: &CidLink) -> String { pub fn auto_verify_sent_key(did: &Did) -> String { format!("auto_verify_sent:{}", did) } + +pub fn permission_set_key(nsid: &str, aud: Option<&str>) -> String { + match aud { + Some(a) => format!("permset:{}:{}", nsid, a), + None => format!("permset:{}", nsid), + } +} diff --git a/crates/tranquil-pds/src/oauth/mod.rs b/crates/tranquil-pds/src/oauth/mod.rs index 53707c3..d5ba187 100644 --- a/crates/tranquil-pds/src/oauth/mod.rs +++ b/crates/tranquil-pds/src/oauth/mod.rs @@ -1,5 +1,6 @@ pub mod client; pub mod db; +pub mod permission_set_resolver; pub mod scopes; pub mod verify; @@ -20,6 +21,7 @@ pub use tranquil_oauth::{ compute_pkce_challenge, verify_client_auth, }; +pub use permission_set_resolver::expand_scopes; pub use scopes::{AccountAction, AccountAttr, RepoAction, ScopeError, ScopePermissions}; pub use verify::{ OAuthAuthError, OAuthUser, VerifyResult, generate_dpop_nonce, verify_oauth_access_token, diff --git a/crates/tranquil-pds/src/oauth/permission_set_resolver.rs b/crates/tranquil-pds/src/oauth/permission_set_resolver.rs new file mode 100644 index 0000000..2f51d8a --- /dev/null +++ b/crates/tranquil-pds/src/oauth/permission_set_resolver.rs @@ -0,0 +1,182 @@ +use crate::cache::Cache; +use crate::cache_keys::permission_set_key; +use serde::{Deserialize, Serialize}; +use std::time::Duration; +use tranquil_scopes::{ + fetch_and_expand, parse_include_scope, ExpansionOutcome, FailedSet, ResolveFailure, + ResolvedSetGroup, ScopeExpansionError, +}; +use tranquil_types::Nsid; + +#[derive(Serialize, Deserialize)] +struct CachedPermissionSet { + scope: String, + title: Option, + detail: Option, +} + +const PERMISSION_SET_CACHE_TTL_SECS: u64 = 24 * 60 * 60; + +pub async fn expand_scopes(cache: &dyn Cache, scope_string: &str) -> ExpansionOutcome { + let mut outcome = ExpansionOutcome::default(); + for tok in scope_string.split_whitespace() { + match tok.strip_prefix("include:") { + None => outcome.passthrough.push(tok.to_string()), + Some(rest) => { + let (nsid, aud) = parse_include_scope(rest); + match resolve_one(cache, nsid, aud).await { + Ok(group) => outcome.sets.push(group), + Err(reason) => outcome.failures.push(FailedSet { + nsid: nsid.to_string(), + aud: aud.map(str::to_string), + reason, + }), + } + } + } + } + outcome +} + +async fn resolve_one( + cache: &dyn Cache, + nsid: &str, + aud: Option<&str>, +) -> Result { + let key = permission_set_key(nsid, aud); + // Cache hit + if let Some(json) = cache.get(&key).await + && let Ok(v) = serde_json::from_str::(&json) + { + return Ok(group_from(nsid, aud, v.scope, v.title, v.detail)); + } + // Miss → network fetch → cache + let parsed = Nsid::new(nsid).map_err(|_| ResolveFailure::Invalid)?; + match fetch_and_expand(&parsed, aud).await { + Ok(fetched) => { + let stored = CachedPermissionSet { + scope: fetched.expanded.clone(), + title: fetched.title.clone(), + detail: fetched.detail.clone(), + }; + if let Ok(json) = serde_json::to_string(&stored) { + let _ = cache + .set(&key, &json, Duration::from_secs(PERMISSION_SET_CACHE_TTL_SECS)) + .await; + } + Ok(group_from(nsid, aud, fetched.expanded, fetched.title, fetched.detail)) + } + Err(e) => Err(map_err(&e)), + } +} + +fn group_from( + nsid: &str, + aud: Option<&str>, + scope: String, + title: Option, + detail: Option, +) -> ResolvedSetGroup { + ResolvedSetGroup { + nsid: nsid.to_string(), + aud: aud.map(str::to_string), + title, + detail, + expanded: scope.split_whitespace().map(str::to_string).collect(), + } +} + +fn map_err(e: &ScopeExpansionError) -> ResolveFailure { + use ScopeExpansionError as E; + match e { + E::InvalidNsid(_) | E::UnexpectedType(_) | E::EmptyPermissions | E::MissingDefinition(_) => { + ResolveFailure::Invalid + } + E::DnsResolution(_) | E::HttpFailed(_) | E::DidResolution(_) => ResolveFailure::NetworkError, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cache::{Cache, CacheError}; + use std::collections::HashMap; + use std::sync::Mutex; + use std::time::Duration; + + #[derive(Default)] + struct MapCache(Mutex>); + + #[async_trait::async_trait] + impl Cache for MapCache { + async fn get(&self, key: &str) -> Option { + self.0.lock().unwrap().get(key).cloned() + } + async fn set(&self, key: &str, value: &str, _ttl: Duration) -> Result<(), CacheError> { + self.0 + .lock() + .unwrap() + .insert(key.to_string(), value.to_string()); + Ok(()) + } + async fn delete(&self, key: &str) -> Result<(), CacheError> { + self.0.lock().unwrap().remove(key); + Ok(()) + } + async fn get_bytes(&self, _key: &str) -> Option> { + None + } + async fn set_bytes(&self, _k: &str, _v: &[u8], _t: Duration) -> Result<(), CacheError> { + Ok(()) + } + } + + fn seed(cache: &MapCache, nsid: &str, scope: &str) { + let key = crate::cache_keys::permission_set_key(nsid, None); + let val = serde_json::to_string(&CachedPermissionSet { + scope: scope.to_string(), + title: Some("Basic".into()), + detail: None, + }) + .unwrap(); + cache.0.lock().unwrap().insert(key, val); + } + + #[tokio::test] + async fn cache_hit_expands_without_network() { + let cache = MapCache::default(); + seed( + &cache, + "io.atcr.authFullApp", + "repo:io.atcr.manifest?action=create identity:*", + ); + let out = expand_scopes(&cache, "atproto include:io.atcr.authFullApp").await; + assert!(out.failures.is_empty()); + assert_eq!(out.passthrough, vec!["atproto".to_string()]); + assert_eq!(out.sets.len(), 1); + assert_eq!(out.sets[0].nsid, "io.atcr.authFullApp"); + assert!( + out.flat_scopes() + .iter() + .any(|s| s == "repo:io.atcr.manifest?action=create") + ); + } + + #[tokio::test] + async fn passthrough_scopes_untouched() { + let cache = MapCache::default(); + let out = expand_scopes(&cache, "atproto repo:app.bsky.feed.post?action=create").await; + assert!(out.failures.is_empty()); + assert!(out.sets.is_empty()); + assert_eq!(out.flat_scopes().len(), 2); + } + + #[tokio::test] + async fn cache_miss_unresolvable_is_a_failure() { + let cache = MapCache::default(); // empty → miss → network fetch of a fake NSID fails fast + let out = expand_scopes(&cache, "include:nonexistent.fake.permissionSet").await; + assert_eq!(out.sets.len(), 0); + assert_eq!(out.failures.len(), 1); + assert_eq!(out.failures[0].nsid, "nonexistent.fake.permissionSet"); + } +} diff --git a/crates/tranquil-scopes/src/lib.rs b/crates/tranquil-scopes/src/lib.rs index fd01786..ec35354 100644 --- a/crates/tranquil-scopes/src/lib.rs +++ b/crates/tranquil-scopes/src/lib.rs @@ -15,5 +15,8 @@ pub use parser::{ AccountAction, AccountAttr, AccountScope, BlobScope, IdentityAttr, IdentityScope, IncludeScope, ParsedScope, RepoAction, RepoScope, RpcScope, parse_scope, parse_scope_string, }; -pub use permission_set::{ScopeExpansionError, expand_include_scopes}; +pub use permission_set::{ + ExpansionOutcome, FailedSet, FetchedSet, ResolveFailure, ResolvedSetGroup, + ScopeExpansionError, expand_include_scopes, fetch_and_expand, parse_include_scope, +}; pub use permissions::ScopePermissions; diff --git a/crates/tranquil-scopes/src/permission_set.rs b/crates/tranquil-scopes/src/permission_set.rs index 7c60469..a19b342 100644 --- a/crates/tranquil-scopes/src/permission_set.rs +++ b/crates/tranquil-scopes/src/permission_set.rs @@ -26,6 +26,50 @@ pub enum ScopeExpansionError { EmptyPermissions, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ResolveFailure { + NotFound, + NetworkError, + Invalid, +} + +#[derive(Debug, Clone)] +pub struct FailedSet { + pub nsid: String, + pub aud: Option, + pub reason: ResolveFailure, +} + +#[derive(Debug, Clone)] +pub struct ResolvedSetGroup { + pub nsid: String, + pub aud: Option, + pub title: Option, + pub detail: Option, + pub expanded: Vec, +} + +#[derive(Debug, Clone, Default)] +pub struct ExpansionOutcome { + pub passthrough: Vec, + pub sets: Vec, + pub failures: Vec, +} + +impl ExpansionOutcome { + pub fn flat_scopes(&self) -> Vec { + let mut out = self.passthrough.clone(); + for s in &self.sets { + out.extend(s.expanded.iter().cloned()); + } + out + } + /// Space-joined `flat_scopes`. + pub fn to_scope_string(&self) -> String { + self.flat_scopes().join(" ") + } +} + static LEXICON_CACHE: LazyLock>> = LazyLock::new(|| RwLock::new(HashMap::new())); @@ -63,6 +107,10 @@ struct LexiconDoc { struct LexiconDef { #[serde(rename = "type")] def_type: String, + #[serde(default)] + title: Option, + #[serde(default)] + detail: Option, permissions: Option>, } @@ -98,7 +146,7 @@ pub async fn expand_include_scopes(scope_string: &str) -> Result (&str, Option<&str>) { +pub fn parse_include_scope(rest: &str) -> (&str, Option<&str>) { rest.split_once('?') .map(|(nsid, params)| { let aud = params.split('&').find_map(|p| p.strip_prefix("aud=")); @@ -126,47 +174,60 @@ async fn expand_permission_set( } } - let lexicon = fetch_lexicon_via_atproto(nsid).await?; + let fetched = fetch_and_expand(nsid, aud).await?; + let expanded = fetched.expanded; + { + let mut cache = LEXICON_CACHE.write().await; + cache.insert( + cache_key, + CachedLexicon { + expanded_scope: expanded.clone(), + cached_at: std::time::Instant::now(), + }, + ); + } + + debug!(nsid = %nsid, expanded = %expanded, "Successfully expanded permission set"); + Ok(expanded) +} + +pub struct FetchedSet { + pub expanded: String, + pub title: Option, + pub detail: Option, +} + +pub async fn fetch_and_expand( + nsid: &Nsid, + aud: Option<&str>, +) -> Result { + let lexicon = fetch_lexicon_via_atproto(nsid).await?; let main_def = lexicon .defs .get("main") .ok_or(ScopeExpansionError::MissingDefinition("main".to_string()))?; - if main_def.def_type != "permission-set" { return Err(ScopeExpansionError::UnexpectedType( main_def.def_type.clone(), )); } - - let permissions = - main_def - .permissions - .as_ref() - .ok_or(ScopeExpansionError::MissingDefinition( - "permissions".to_string(), - ))?; - + let permissions = main_def + .permissions + .as_ref() + .ok_or(ScopeExpansionError::MissingDefinition( + "permissions".to_string(), + ))?; let namespace_authority = extract_namespace_authority(nsid); let expanded = build_expanded_scopes(permissions, aud, &namespace_authority); - if expanded.is_empty() { return Err(ScopeExpansionError::EmptyPermissions); } - - { - let mut cache = LEXICON_CACHE.write().await; - cache.insert( - cache_key, - CachedLexicon { - expanded_scope: expanded.clone(), - cached_at: std::time::Instant::now(), - }, - ); - } - - debug!(nsid = %nsid, expanded = %expanded, "Successfully expanded permission set"); - Ok(expanded) + Ok(FetchedSet { + expanded, + title: main_def.title.clone(), + detail: main_def.detail.clone(), + }) } async fn fetch_lexicon_via_atproto(nsid: &Nsid) -> Result { @@ -678,4 +739,55 @@ mod tests { "bookmarks.lexicon.community" ); } + + #[test] + fn expansion_outcome_flat_scopes_and_string() { + let out = ExpansionOutcome { + passthrough: vec!["atproto".into()], + sets: vec![ResolvedSetGroup { + nsid: "io.atcr.authFullApp".into(), + aud: None, + title: Some("T".into()), + detail: None, + expanded: vec![ + "repo:io.atcr.manifest?action=create".into(), + "rpc:io.atcr.getManifest".into(), + ], + }], + failures: vec![FailedSet { + nsid: "nonexistent.fake.permissionSet".into(), + aud: None, + reason: ResolveFailure::NotFound, + }], + }; + let flat = out.flat_scopes(); + assert_eq!( + flat, + vec![ + "atproto", + "repo:io.atcr.manifest?action=create", + "rpc:io.atcr.getManifest" + ] + ); + assert_eq!( + out.to_scope_string(), + "atproto repo:io.atcr.manifest?action=create rpc:io.atcr.getManifest" + ); + } + + #[test] + fn test_lexicon_def_captures_title_and_detail() { + let json = serde_json::json!({ + "defs": { "main": { + "type": "permission-set", + "title": "Basic App", + "detail": "Posts and interactions", + "permissions": [{ "resource": "repo", "collection": ["io.atcr.manifest"] }] + }} + }); + let doc: LexiconDoc = serde_json::from_value(json).unwrap(); + let main = doc.defs.get("main").unwrap(); + assert_eq!(main.title.as_deref(), Some("Basic App")); + assert_eq!(main.detail.as_deref(), Some("Posts and interactions")); + } } -- 2.51.2 From 348ac887fc054f38137b6286d1ff710ef76bf338 Mon Sep 17 00:00:00 2001 From: Trezy Date: Sun, 19 Jul 2026 16:49:15 -0500 Subject: [PATCH 04/39] fix: use JWT scopes as source-of-truth for access and refresh tokens Signed-off-by: Trezy --- Cargo.lock | 2 + crates/tranquil-oauth-server/Cargo.toml | 4 + .../src/endpoints/authorize/consent.rs | 73 +- .../src/endpoints/authorize/mod.rs | 3 +- .../endpoints/authorize/scope_resolution.rs | 80 ++ .../src/endpoints/token/grants.rs | 121 ++- .../src/endpoints/token/introspect.rs | 8 +- crates/tranquil-pds/src/oauth/verify.rs | 2 +- .../tests/oauth_permission_sets.rs | 695 ++++++++++++++++++ 9 files changed, 923 insertions(+), 65 deletions(-) create mode 100644 crates/tranquil-oauth-server/src/endpoints/authorize/scope_resolution.rs create mode 100644 crates/tranquil-pds/tests/oauth_permission_sets.rs diff --git a/Cargo.lock b/Cargo.lock index 85df977..922a0fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7859,6 +7859,7 @@ dependencies = [ name = "tranquil-oauth-server" version = "0.6.5" dependencies = [ + "async-trait", "axum", "base64 0.22.1", "bcrypt", @@ -7882,6 +7883,7 @@ dependencies = [ "tranquil-crypto", "tranquil-db-traits", "tranquil-pds", + "tranquil-scopes", "tranquil-types", "urlencoding", "uuid", diff --git a/crates/tranquil-oauth-server/Cargo.toml b/crates/tranquil-oauth-server/Cargo.toml index f57cbe1..eab1e37 100644 --- a/crates/tranquil-oauth-server/Cargo.toml +++ b/crates/tranquil-oauth-server/Cargo.toml @@ -11,6 +11,7 @@ tranquil-types = { workspace = true } tranquil-config = { workspace = true } tranquil-crypto = { workspace = true } tranquil-db-traits = { workspace = true } +tranquil-scopes = { workspace = true } axum = { workspace = true } base64 = { workspace = true } @@ -33,3 +34,6 @@ tracing = { workspace = true } urlencoding = { workspace = true } uuid = { workspace = true } webauthn-rs = { workspace = true } + +[dev-dependencies] +async-trait = { workspace = true } diff --git a/crates/tranquil-oauth-server/src/endpoints/authorize/consent.rs b/crates/tranquil-oauth-server/src/endpoints/authorize/consent.rs index 7b4176c..0e5bdbd 100644 --- a/crates/tranquil-oauth-server/src/endpoints/authorize/consent.rs +++ b/crates/tranquil-oauth-server/src/endpoints/authorize/consent.rs @@ -116,26 +116,30 @@ pub async fn consent_get( None }; - let effective_scope_str = if let Some(ref grant) = delegation_grant { - tranquil_pds::delegation::intersect_scopes( - requested_scope_str, - grant.granted_scopes.as_str(), - ) - } else { - requested_scope_str.to_string() + let authority = match delegation_grant.as_ref() { + Some(grant) => scope_resolution::Authority::Delegated(grant.granted_scopes.as_str()), + None => scope_resolution::Authority::FullSelf, }; - - let expanded_scope_str = match expand_include_scopes(&effective_scope_str).await { - Ok(s) => s, - Err(e) => { - return json_error( - StatusCode::BAD_REQUEST, - "invalid_scope", - &format!("Failed to expand permission set: {e}"), - ); - } - }; - let requested_scopes: Vec<&str> = expanded_scope_str.split_whitespace().collect(); + let effective = scope_resolution::resolve_effective_scopes( + &*state.cache, + requested_scope_str, + authority, + ) + .await; + if !effective.outcome.failures.is_empty() { + let names: Vec = effective + .outcome + .failures + .iter() + .map(|f| f.nsid.clone()) + .collect(); + return json_error( + StatusCode::BAD_REQUEST, + "invalid_scope", + &format!("Could not resolve permission set(s): {}", names.join(", ")), + ); + } + let requested_scopes: Vec<&str> = effective.resolved.split_whitespace().collect(); let preferences = state .repos .oauth @@ -342,15 +346,30 @@ pub async fn consent_post( None => None, }; - let effective_scope_str = if let Some(ref grant) = delegation_grant { - tranquil_pds::delegation::intersect_scopes( - original_scope_str, - grant.granted_scopes.as_str(), - ) - } else { - original_scope_str.to_string() + let authority = match delegation_grant.as_ref() { + Some(grant) => scope_resolution::Authority::Delegated(grant.granted_scopes.as_str()), + None => scope_resolution::Authority::FullSelf, }; - let requested_scopes: Vec<&str> = effective_scope_str.split_whitespace().collect(); + let effective = scope_resolution::resolve_effective_scopes( + &*state.cache, + original_scope_str, + authority, + ) + .await; + if !effective.outcome.failures.is_empty() { + let names: Vec = effective + .outcome + .failures + .iter() + .map(|f| f.nsid.clone()) + .collect(); + return json_error( + StatusCode::BAD_REQUEST, + "invalid_scope", + &format!("Could not resolve permission set(s): {}", names.join(", ")), + ); + } + let requested_scopes: Vec<&str> = effective.resolved.split_whitespace().collect(); let atproto_was_requested = requested_scopes.contains(&"atproto"); if atproto_was_requested && !form.approved_scopes.contains(&"atproto".to_string()) { return json_error( diff --git a/crates/tranquil-oauth-server/src/endpoints/authorize/mod.rs b/crates/tranquil-oauth-server/src/endpoints/authorize/mod.rs index 6eb1c3c..2b37337 100644 --- a/crates/tranquil-oauth-server/src/endpoints/authorize/mod.rs +++ b/crates/tranquil-oauth-server/src/endpoints/authorize/mod.rs @@ -15,7 +15,7 @@ use tranquil_pds::auth::{BareLoginIdentifier, NormalizedLoginIdentifier}; use tranquil_pds::comms::comms_repo::enqueue_2fa_code; use tranquil_pds::oauth::{ AuthFlow, ClientMetadataCache, DeviceData, DeviceId, OAuthError, Prompt, SessionId, - db::should_show_consent, scopes::expand_include_scopes, + db::should_show_consent, }; use tranquil_pds::rate_limit::{ OAuthAuthorizeLimit, OAuthRateLimited, OAuthRegisterCompleteLimit, TotpVerifyLimit, @@ -300,6 +300,7 @@ mod consent; mod login; mod passkey; mod registration; +pub mod scope_resolution; mod two_factor; pub use consent::*; diff --git a/crates/tranquil-oauth-server/src/endpoints/authorize/scope_resolution.rs b/crates/tranquil-oauth-server/src/endpoints/authorize/scope_resolution.rs new file mode 100644 index 0000000..e09089e --- /dev/null +++ b/crates/tranquil-oauth-server/src/endpoints/authorize/scope_resolution.rs @@ -0,0 +1,80 @@ +use tranquil_pds::cache::Cache; +use tranquil_pds::delegation::intersect_scopes; +use tranquil_pds::oauth::permission_set_resolver::expand_scopes; +use tranquil_scopes::ExpansionOutcome; + +pub enum Authority<'a> { + FullSelf, + Delegated(&'a str), +} + +pub struct EffectiveScopes { + pub resolved: String, + pub outcome: ExpansionOutcome, +} + +pub async fn resolve_effective_scopes( + cache: &dyn Cache, + requested: &str, + authority: Authority<'_>, +) -> EffectiveScopes { + let outcome = expand_scopes(cache, requested).await; + let expanded = outcome.to_scope_string(); + let resolved = match authority { + Authority::FullSelf => expanded, + Authority::Delegated(granted) => intersect_scopes(&expanded, granted), + }; + EffectiveScopes { resolved, outcome } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + use std::sync::Mutex; + use std::time::Duration; + use tranquil_pds::cache::{Cache, CacheError}; + + #[derive(Default)] + struct MapCache(Mutex>); + #[async_trait::async_trait] + impl Cache for MapCache { + async fn get(&self, k: &str) -> Option { self.0.lock().unwrap().get(k).cloned() } + async fn set(&self, k: &str, v: &str, _t: Duration) -> Result<(), CacheError> { + self.0.lock().unwrap().insert(k.into(), v.into()); Ok(()) + } + async fn delete(&self, k: &str) -> Result<(), CacheError> { self.0.lock().unwrap().remove(k); Ok(()) } + async fn get_bytes(&self, _k: &str) -> Option> { None } + async fn set_bytes(&self, _k: &str, _v: &[u8], _t: Duration) -> Result<(), CacheError> { Ok(()) } + } + + fn cache_with(nsid: &str, scopes: &str) -> MapCache { + let c = MapCache::default(); + let key = tranquil_pds::cache_keys::permission_set_key(nsid, None); + let json = serde_json::json!({ "scope": scopes, "title": null, "detail": null }).to_string(); + c.0.lock().unwrap().insert(key, json); + c + } + + #[tokio::test] + async fn full_self_keeps_all_expanded() { + let c = cache_with("io.atcr.authFullApp", "repo:io.atcr.manifest?action=create identity:*"); + let eff = resolve_effective_scopes(&c, "atproto include:io.atcr.authFullApp", Authority::FullSelf).await; + assert!(eff.resolved.contains("atproto")); + assert!(eff.resolved.contains("repo:io.atcr.manifest?action=create")); + assert!(eff.resolved.contains("identity:*")); + assert!(eff.outcome.failures.is_empty()); + } + + #[tokio::test] + async fn delegated_intersects_expanded_against_grant() { + let c = cache_with("io.atcr.authFullApp", "repo:io.atcr.manifest?action=create identity:*"); + let eff = resolve_effective_scopes( + &c, "atproto include:io.atcr.authFullApp", + Authority::Delegated("atproto repo:* blob:*/* account:*?action=manage"), + ).await; + assert!(eff.resolved.contains("atproto")); + assert!(eff.resolved.contains("repo:io.atcr.manifest?action=create")); + assert!(!eff.resolved.contains("identity")); + } +} diff --git a/crates/tranquil-oauth-server/src/endpoints/token/grants.rs b/crates/tranquil-oauth-server/src/endpoints/token/grants.rs index 275a754..8359359 100644 --- a/crates/tranquil-oauth-server/src/endpoints/token/grants.rs +++ b/crates/tranquil-oauth-server/src/endpoints/token/grants.rs @@ -7,12 +7,10 @@ use axum::http::{HeaderMap, Method}; use chrono::{Duration, Utc}; use tranquil_db_traits::RefreshTokenLookup; use tranquil_pds::config::AuthConfig; -use tranquil_pds::delegation::intersect_scopes; use tranquil_pds::oauth::{ AuthFlow, ClientAuth, ClientMetadataCache, DPoPVerifier, OAuthError, RefreshToken, TokenData, TokenId, db::{enforce_token_limit_for_user, lookup_refresh_token}, - scopes::expand_include_scopes, verify_client_auth, }; use tranquil_pds::state::AppState; @@ -132,46 +130,54 @@ pub async fn handle_authorization_code_grant( let refresh_token = RefreshToken::generate(); let now = Utc::now(); - let (raw_scope, controller_did) = if let Some(ref controller) = authorized.controller_did { + let controller_did = authorized.controller_did.clone(); + let requested_scope = authorized.parameters.scope.clone(); + + let granted_scopes: Option = if let Some(ref controller) = controller_did { let grant = state .repos .delegation .get_delegation(&did, controller) .await .ok() - .flatten(); - let granted_scopes = match grant { - Some(g) => g.granted_scopes, - None => { - return Err(OAuthError::InvalidGrant( - "Delegation grant not found or revoked".to_string(), - )); - } - }; - let requested = authorized.parameters.scope.as_deref().unwrap_or("atproto"); - let intersected = intersect_scopes(requested, granted_scopes.as_str()); - (Some(intersected), Some(controller.clone())) + .flatten() + .ok_or_else(|| { + OAuthError::InvalidGrant("Delegation grant not found or revoked".to_string()) + })?; + Some(grant.granted_scopes.as_str().to_string()) } else { - (authorized.parameters.scope.clone(), None) + None }; - - let final_scope = if let Some(ref scope) = raw_scope { - if scope.contains("include:") { - Some(expand_include_scopes(scope).await.map_err(|e| { - OAuthError::InvalidScope(format!("Failed to expand permission set: {e}")) - })?) - } else { - raw_scope - } - } else { - raw_scope + let authority = match granted_scopes.as_deref() { + Some(g) => crate::endpoints::authorize::scope_resolution::Authority::Delegated(g), + None => crate::endpoints::authorize::scope_resolution::Authority::FullSelf, }; + let requested_for_resolve = requested_scope.as_deref().unwrap_or("atproto"); + let effective = crate::endpoints::authorize::scope_resolution::resolve_effective_scopes( + &*state.cache, + requested_for_resolve, + authority, + ) + .await; + if !effective.outcome.failures.is_empty() { + let names: Vec = effective + .outcome + .failures + .iter() + .map(|f| f.nsid.clone()) + .collect(); + return Err(OAuthError::InvalidScope(format!( + "Could not resolve permission set(s): {}", + names.join(", ") + ))); + } + let resolved_scope = effective.resolved; let access_token = create_access_token_with_delegation( &token_id, &did, dpop_jkt.as_ref(), - final_scope.as_deref(), + Some(resolved_scope.as_str()), controller_did.as_ref(), )?; let stored_client_auth = authorized.client_auth.unwrap_or(ClientAuth::None); @@ -195,7 +201,7 @@ pub async fn handle_authorization_code_grant( details: None, code: None, current_refresh_token: Some(refresh_token.clone()), - scope: final_scope.clone(), + scope: requested_scope.clone(), controller_did: controller_did.clone(), }; state @@ -237,12 +243,57 @@ pub async fn handle_authorization_code_grant( }, expires_in: ACCESS_TOKEN_EXPIRY_SECONDS, refresh_token: Some(refresh_token), - scope: final_scope, + scope: Some(resolved_scope.clone()), sub: Some(did), }), )) } +async fn recompute_resolved_scope( + state: &AppState, + token_data: &TokenData, +) -> Result { + let requested = token_data.scope.as_deref().unwrap_or("atproto"); + let granted_scopes: Option = if let Some(ref controller) = token_data.controller_did { + let grant = state + .repos + .delegation + .get_delegation(&token_data.did, controller) + .await + .ok() + .flatten() + .ok_or_else(|| { + OAuthError::InvalidGrant("Delegation grant not found or revoked".to_string()) + })?; + Some(grant.granted_scopes.as_str().to_string()) + } else { + None + }; + let authority = match granted_scopes.as_deref() { + Some(g) => crate::endpoints::authorize::scope_resolution::Authority::Delegated(g), + None => crate::endpoints::authorize::scope_resolution::Authority::FullSelf, + }; + let effective = crate::endpoints::authorize::scope_resolution::resolve_effective_scopes( + &*state.cache, + requested, + authority, + ) + .await; + if !effective.outcome.failures.is_empty() { + let names: Vec = effective + .outcome + .failures + .iter() + .map(|f| f.nsid.clone()) + .collect(); + return Err(OAuthError::InvalidScope(format!( + "Permission set(s) expired and unresolvable: {}", + names.join(", ") + ))); + } + Ok(effective.resolved) +} + pub async fn handle_refresh_token_grant( state: AppState, _headers: HeaderMap, @@ -282,11 +333,12 @@ pub async fn handle_refresh_token_grant( "Refresh token reuse within grace period, returning existing tokens" ); let dpop_jkt = token_data.parameters.dpop_jkt.as_ref(); + let resolved = recompute_resolved_scope(&state, &token_data).await?; let access_token = create_access_token_with_delegation( &token_data.token_id, &token_data.did, dpop_jkt, - token_data.scope.as_deref(), + Some(resolved.as_str()), token_data.controller_did.as_ref(), )?; let mut response_headers = HeaderMap::new(); @@ -307,7 +359,7 @@ pub async fn handle_refresh_token_grant( }, expires_in: ACCESS_TOKEN_EXPIRY_SECONDS, refresh_token: token_data.current_refresh_token, - scope: token_data.scope, + scope: Some(resolved), sub: Some(token_data.did), }), )); @@ -396,11 +448,12 @@ pub async fn handle_refresh_token_grant( new_expires_at = %new_expires_at, "Refresh token rotated successfully" ); + let resolved = recompute_resolved_scope(&state, &token_data).await?; let access_token = create_access_token_with_delegation( &token_data.token_id, &token_data.did, dpop_jkt.as_ref(), - token_data.scope.as_deref(), + Some(resolved.as_str()), token_data.controller_did.as_ref(), )?; let mut response_headers = HeaderMap::new(); @@ -421,7 +474,7 @@ pub async fn handle_refresh_token_grant( }, expires_in: ACCESS_TOKEN_EXPIRY_SECONDS, refresh_token: Some(new_refresh_token), - scope: token_data.scope, + scope: Some(resolved), sub: Some(token_data.did), }), )) diff --git a/crates/tranquil-oauth-server/src/endpoints/token/introspect.rs b/crates/tranquil-oauth-server/src/endpoints/token/introspect.rs index 35204b5..d1669dd 100644 --- a/crates/tranquil-oauth-server/src/endpoints/token/introspect.rs +++ b/crates/tranquil-oauth-server/src/endpoints/token/introspect.rs @@ -106,6 +106,10 @@ pub async fn introspect_token( Ok(info) => info, Err(_) => return Ok(Json(inactive_response)), }; + let jwt_info = match tranquil_pds::oauth::verify::extract_oauth_token_info(&request.token) { + Ok(info) => info, + Err(_) => return Ok(Json(inactive_response)), + }; let token_id = TokenId::from(token_info.sid.clone()); let token_data = match state.repos.oauth.get_token_by_id(&token_id).await { Ok(Some(data)) => data, @@ -118,7 +122,7 @@ pub async fn introspect_token( let issuer = format!("https://{}", pds_hostname); Ok(Json(IntrospectResponse { active: true, - scope: token_data.scope, + scope: jwt_info.scope, client_id: Some(token_data.client_id), username: None, token_type: if token_data.parameters.dpop_jkt.is_some() { @@ -129,7 +133,7 @@ pub async fn introspect_token( exp: Some(token_info.exp), iat: Some(token_info.iat), nbf: Some(token_info.iat), - sub: Some(token_data.did.to_string()), + sub: Some(jwt_info.did.to_string()), aud: Some(issuer.clone()), iss: Some(issuer), jti: Some(token_info.jti), diff --git a/crates/tranquil-pds/src/oauth/verify.rs b/crates/tranquil-pds/src/oauth/verify.rs index 9dc1c1d..da1b630 100644 --- a/crates/tranquil-pds/src/oauth/verify.rs +++ b/crates/tranquil-pds/src/oauth/verify.rs @@ -96,7 +96,7 @@ pub async fn verify_oauth_access_token( did: token_data.did, token_id, client_id: token_data.client_id, - scope: token_data.scope, + scope: token_info.scope, }) } diff --git a/crates/tranquil-pds/tests/oauth_permission_sets.rs b/crates/tranquil-pds/tests/oauth_permission_sets.rs new file mode 100644 index 0000000..da779e1 --- /dev/null +++ b/crates/tranquil-pds/tests/oauth_permission_sets.rs @@ -0,0 +1,695 @@ +mod common; +mod helpers; + +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use chrono::Utc; +use common::{base_url, client, create_account_and_login}; +use helpers::verify_new_account; +use reqwest::StatusCode; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use tranquil_types::TokenId; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const PERMISSION_SET_NSID: &str = "io.atcr.authFullApp"; +const PERMISSION_SET_GRANULAR_SCOPE: &str = + "repo:io.atcr.manifest?action=create rpc:io.atcr.getManifest?aud=*"; + +fn generate_pkce() -> (String, String) { + let verifier_bytes: [u8; 32] = rand::random(); + let code_verifier = URL_SAFE_NO_PAD.encode(verifier_bytes); + let mut hasher = Sha256::new(); + hasher.update(code_verifier.as_bytes()); + let hash = hasher.finalize(); + let code_challenge = URL_SAFE_NO_PAD.encode(hash); + (code_verifier, code_challenge) +} + +async fn setup_mock_client_metadata(redirect_uri: &str) -> MockServer { + let mock_server = MockServer::start().await; + let client_id = mock_server.uri(); + let metadata = json!({ + "client_id": client_id, + "client_name": "Test Permission Set Client", + "redirect_uris": [redirect_uri], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "token_endpoint_auth_method": "none", + "dpop_bound_access_tokens": false + }); + Mock::given(method("GET")) + .and(path("/")) + .respond_with(ResponseTemplate::new(200).set_body_json(metadata)) + .mount(&mock_server) + .await; + mock_server +} + +async fn seed_permission_set(nsid: &str, granular_scope: &str) { + let state = common::get_test_app_state().await; + let key = tranquil_pds::cache_keys::permission_set_key(nsid, None); + let val = json!({ + "scope": granular_scope, + "title": "Basic", + "detail": null + }) + .to_string(); + state + .cache + .set(&key, &val, std::time::Duration::from_secs(3600)) + .await + .unwrap(); +} + +fn decode_jwt_payload(jwt: &str) -> Value { + let parts: Vec<&str> = jwt.split('.').collect(); + assert_eq!(parts.len(), 3, "Token should be a valid JWT"); + let payload_json = URL_SAFE_NO_PAD.decode(parts[1]).unwrap(); + serde_json::from_slice(&payload_json).unwrap() +} + +fn token_id_from_jwt(jwt: &str) -> TokenId { + let payload = decode_jwt_payload(jwt); + let sid = payload["sid"] + .as_str() + .expect("Token payload should contain sid claim"); + TokenId::new(sid) +} + +struct DelegatedSession { + access_token: String, + #[allow(dead_code)] + refresh_token: String, + delegated_did: String, + #[allow(dead_code)] + controller_did: String, + #[allow(dead_code)] + client_id: String, +} + +async fn create_delegated_session_with_scope( + handle_prefix: &str, + redirect_uri: &str, + scope: &str, +) -> (DelegatedSession, Value, MockServer) { + let url = base_url().await; + let http_client = client(); + + let (controller_jwt, controller_did) = create_account_and_login(&http_client).await; + + let suffix = &uuid::Uuid::new_v4().simple().to_string()[..4]; + let delegated_handle = format!("{}{}", handle_prefix, suffix); + let delegated_res = http_client + .post(format!("{}/xrpc/_delegation.createDelegatedAccount", url)) + .bearer_auth(&controller_jwt) + .json(&json!({ + "handle": delegated_handle, + "controllerScopes": tranquil_pds::delegation::OWNER_FULL_SCOPES + })) + .send() + .await + .expect("createDelegatedAccount request failed"); + if delegated_res.status() != StatusCode::OK { + let error_body = delegated_res.text().await.unwrap(); + panic!("Failed to create delegated account: {}", error_body); + } + let delegated_account: Value = delegated_res.json().await.unwrap(); + let delegated_did = delegated_account["did"].as_str().unwrap().to_string(); + + let mock_client = setup_mock_client_metadata(redirect_uri).await; + let client_id = mock_client.uri(); + let (code_verifier, code_challenge) = generate_pkce(); + + let par_res = http_client + .post(format!("{}/oauth/par", url)) + .form(&[ + ("response_type", "code"), + ("client_id", &client_id), + ("redirect_uri", redirect_uri), + ("code_challenge", &code_challenge), + ("code_challenge_method", "S256"), + ("scope", scope), + ("login_hint", delegated_did.as_str()), + ]) + .send() + .await + .expect("PAR failed"); + assert!( + par_res.status() == StatusCode::OK || par_res.status() == StatusCode::CREATED, + "PAR should succeed, got {}", + par_res.status() + ); + let par_body: Value = par_res.json().await.unwrap(); + let request_uri = par_body["request_uri"].as_str().unwrap().to_string(); + + let auth_res = http_client + .post(format!("{}/oauth/delegation/auth", url)) + .header("Content-Type", "application/json") + .json(&json!({ + "request_uri": request_uri, + "delegated_did": delegated_did, + "controller_did": controller_did, + "password": "Testpass123!", + "remember_device": false + })) + .send() + .await + .expect("Delegation auth request failed"); + if auth_res.status() != StatusCode::OK { + let error_body = auth_res.text().await.unwrap(); + panic!("Delegation auth failed: {}", error_body); + } + let auth_body: Value = auth_res.json().await.unwrap(); + assert!( + auth_body["success"].as_bool().unwrap_or(false), + "Delegation auth should succeed: {:?}", + auth_body + ); + + let consent_get_res = http_client + .get(format!("{}/oauth/authorize/consent", url)) + .query(&[("request_uri", request_uri.as_str())]) + .send() + .await + .expect("Consent GET failed"); + assert_eq!( + consent_get_res.status(), + StatusCode::OK, + "Consent GET should succeed" + ); + let consent_get_body: Value = consent_get_res.json().await.unwrap(); + + let approved_scopes: Vec<&str> = scope.split_whitespace().collect(); + let consent_post_res = http_client + .post(format!("{}/oauth/authorize/consent", url)) + .header("Content-Type", "application/json") + .json(&json!({ + "request_uri": request_uri, + "approved_scopes": approved_scopes, + "remember": false + })) + .send() + .await + .expect("Consent POST failed"); + if consent_post_res.status() != StatusCode::OK { + let error_body = consent_post_res.text().await.unwrap(); + panic!("Consent POST failed: {}", error_body); + } + let consent_post_body: Value = consent_post_res.json().await.unwrap(); + let location = consent_post_body["redirect_uri"] + .as_str() + .expect("Expected redirect_uri from consent") + .to_string(); + + let code = location + .split("code=") + .nth(1) + .unwrap() + .split('&') + .next() + .unwrap(); + + let token_res = http_client + .post(format!("{}/oauth/token", url)) + .form(&[ + ("grant_type", "authorization_code"), + ("code", code), + ("redirect_uri", redirect_uri), + ("code_verifier", &code_verifier), + ("client_id", &client_id), + ]) + .send() + .await + .expect("Token request failed"); + assert_eq!( + token_res.status(), + StatusCode::OK, + "Token exchange should succeed" + ); + let token_body: Value = token_res.json().await.unwrap(); + + let session = DelegatedSession { + access_token: token_body["access_token"].as_str().unwrap().to_string(), + refresh_token: token_body["refresh_token"].as_str().unwrap().to_string(), + delegated_did, + controller_did, + client_id, + }; + (session, consent_get_body, mock_client) +} + +#[tokio::test] +async fn test_delegated_include_scope_shows_granular_on_consent() { + seed_permission_set(PERMISSION_SET_NSID, PERMISSION_SET_GRANULAR_SCOPE).await; + + let scope = format!("atproto include:{}", PERMISSION_SET_NSID); + let (_session, consent_body, _mock) = create_delegated_session_with_scope( + "psc", + "https://example.com/permset-consent-callback", + &scope, + ) + .await; + + let scopes = consent_body["scopes"] + .as_array() + .expect("consent response should have a scopes array"); + assert!(!scopes.is_empty(), "consent scopes should not be empty"); + + let has_granular = scopes + .iter() + .any(|s| s["scope"].as_str() == Some("repo:io.atcr.manifest?action=create")); + assert!( + has_granular, + "consent scopes[] should list the expanded granular scope \ + 'repo:io.atcr.manifest?action=create', not just 'atproto'. Got: {:?}", + scopes + ); + + let only_atproto = scopes + .iter() + .all(|s| s["scope"].as_str() == Some("atproto")); + assert!( + !only_atproto, + "consent scopes[] should not collapse to just 'atproto'" + ); + + let has_raw_include = scopes.iter().any(|s| { + s["scope"] + .as_str() + .map(|sc| sc.starts_with("include:")) + .unwrap_or(false) + }); + assert!( + !has_raw_include, + "consent scopes[] should list the expanded set, not the raw include: token" + ); +} + +#[tokio::test] +async fn test_grant_row_keeps_include_jwt_carries_expanded() { + seed_permission_set(PERMISSION_SET_NSID, PERMISSION_SET_GRANULAR_SCOPE).await; + + let scope = format!("atproto include:{}", PERMISSION_SET_NSID); + let (session, _consent_body, _mock) = create_delegated_session_with_scope( + "psg", + "https://example.com/permset-grant-callback", + &scope, + ) + .await; + + let payload = decode_jwt_payload(&session.access_token); + let jwt_scope = payload["scope"] + .as_str() + .expect("access token JWT should have a scope claim"); + assert!( + jwt_scope.contains("repo:io.atcr.manifest?action=create"), + "JWT scope claim should carry the expanded granular scope, got: {}", + jwt_scope + ); + assert!( + !jwt_scope.contains("include:"), + "JWT scope claim should not carry the raw include: token, got: {}", + jwt_scope + ); + + let token_id = token_id_from_jwt(&session.access_token); + let token_data = common::get_test_repos() + .await + .oauth + .get_token_by_id(&token_id) + .await + .expect("get_token_by_id query failed") + .expect("token row should exist"); + let row_scope = token_data + .scope + .expect("stored token row should have a scope"); + assert!( + row_scope.contains(&format!("include:{}", PERMISSION_SET_NSID)), + "Stored oauth_token.scope row should still preserve the include: token, got: {}", + row_scope + ); +} + +#[tokio::test] +async fn test_introspect_reads_expanded_jwt_scope() { + seed_permission_set(PERMISSION_SET_NSID, PERMISSION_SET_GRANULAR_SCOPE).await; + + let scope = format!("atproto include:{}", PERMISSION_SET_NSID); + let (session, _consent_body, _mock) = create_delegated_session_with_scope( + "psi", + "https://example.com/permset-introspect-callback", + &scope, + ) + .await; + + let url = base_url().await; + let http_client = client(); + let introspect_res = http_client + .post(format!("{}/oauth/introspect", url)) + .form(&[("token", session.access_token.as_str())]) + .send() + .await + .expect("introspect request failed"); + assert_eq!(introspect_res.status(), StatusCode::OK); + let introspect_body: Value = introspect_res.json().await.unwrap(); + + assert_eq!( + introspect_body["active"].as_bool(), + Some(true), + "token should be active" + ); + let introspect_scope = introspect_body["scope"] + .as_str() + .expect("introspect response should have a scope string"); + assert!( + introspect_scope.contains("repo:io.atcr.manifest?action=create"), + "introspect scope should contain the expanded granular scope, got: {}", + introspect_scope + ); + assert!( + !introspect_scope.contains("include:"), + "introspect scope should not contain the raw include: token, got: {}", + introspect_scope + ); +} + +#[tokio::test] +async fn test_enforcement_uses_expanded_jwt_scope() { + seed_permission_set(PERMISSION_SET_NSID, PERMISSION_SET_GRANULAR_SCOPE).await; + + let scope = format!("atproto include:{}", PERMISSION_SET_NSID); + let (session, _consent_body, _mock) = create_delegated_session_with_scope( + "pse", + "https://example.com/permset-enforce-callback", + &scope, + ) + .await; + + let url = base_url().await; + let http_client = client(); + let collection = "io.atcr.manifest"; + let create_res = http_client + .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) + .bearer_auth(&session.access_token) + .json(&json!({ + "repo": session.delegated_did, + "collection": collection, + "validate": false, + "record": { + "$type": collection, + "note": "permission set enforcement test", + "createdAt": Utc::now().to_rfc3339() + } + })) + .send() + .await + .expect("createRecord request failed"); + + assert_ne!( + create_res.status(), + StatusCode::FORBIDDEN, + "createRecord for a collection covered by the permission set's expanded scope \ + should not be forbidden -- enforcement must read the expanded JWT scope, not \ + the include:-only stored row. Got body: {:?}", + create_res.text().await + ); +} + +#[tokio::test] +async fn test_consent_post_errors_when_set_unresolvable() { + const UNRESOLVABLE_NSID: &str = "io.atcr.authUnresolvableSet"; + seed_permission_set(UNRESOLVABLE_NSID, PERMISSION_SET_GRANULAR_SCOPE).await; + + let url = base_url().await; + let http_client = client(); + + let (controller_jwt, controller_did) = create_account_and_login(&http_client).await; + + let suffix = &uuid::Uuid::new_v4().simple().to_string()[..4]; + let delegated_handle = format!("psu{}", suffix); + let delegated_res = http_client + .post(format!("{}/xrpc/_delegation.createDelegatedAccount", url)) + .bearer_auth(&controller_jwt) + .json(&json!({ + "handle": delegated_handle, + "controllerScopes": tranquil_pds::delegation::OWNER_FULL_SCOPES + })) + .send() + .await + .expect("createDelegatedAccount request failed"); + if delegated_res.status() != StatusCode::OK { + let error_body = delegated_res.text().await.unwrap(); + panic!("Failed to create delegated account: {}", error_body); + } + let delegated_account: Value = delegated_res.json().await.unwrap(); + let delegated_did = delegated_account["did"].as_str().unwrap().to_string(); + + let redirect_uri = "https://example.com/permset-unresolvable-callback"; + let mock_client = setup_mock_client_metadata(redirect_uri).await; + let client_id = mock_client.uri(); + let (_code_verifier, code_challenge) = generate_pkce(); + + let scope = format!("atproto include:{}", UNRESOLVABLE_NSID); + let par_res = http_client + .post(format!("{}/oauth/par", url)) + .form(&[ + ("response_type", "code"), + ("client_id", &client_id), + ("redirect_uri", redirect_uri), + ("code_challenge", &code_challenge), + ("code_challenge_method", "S256"), + ("scope", scope.as_str()), + ("login_hint", delegated_did.as_str()), + ]) + .send() + .await + .expect("PAR failed"); + assert!( + par_res.status() == StatusCode::OK || par_res.status() == StatusCode::CREATED, + "PAR should succeed, got {}", + par_res.status() + ); + let par_body: Value = par_res.json().await.unwrap(); + let request_uri = par_body["request_uri"].as_str().unwrap().to_string(); + + let auth_res = http_client + .post(format!("{}/oauth/delegation/auth", url)) + .header("Content-Type", "application/json") + .json(&json!({ + "request_uri": request_uri, + "delegated_did": delegated_did, + "controller_did": controller_did, + "password": "Testpass123!", + "remember_device": false + })) + .send() + .await + .expect("Delegation auth request failed"); + if auth_res.status() != StatusCode::OK { + let error_body = auth_res.text().await.unwrap(); + panic!("Delegation auth failed: {}", error_body); + } + let auth_body: Value = auth_res.json().await.unwrap(); + assert!( + auth_body["success"].as_bool().unwrap_or(false), + "Delegation auth should succeed: {:?}", + auth_body + ); + + let consent_get_res = http_client + .get(format!("{}/oauth/authorize/consent", url)) + .query(&[("request_uri", request_uri.as_str())]) + .send() + .await + .expect("Consent GET failed"); + assert_eq!( + consent_get_res.status(), + StatusCode::OK, + "Consent GET should succeed" + ); + + let state = common::get_test_app_state().await; + let key = tranquil_pds::cache_keys::permission_set_key(UNRESOLVABLE_NSID, None); + state.cache.delete(&key).await.unwrap(); + + let approved_scopes: Vec<&str> = scope.split_whitespace().collect(); + let consent_post_res = http_client + .post(format!("{}/oauth/authorize/consent", url)) + .header("Content-Type", "application/json") + .json(&json!({ + "request_uri": request_uri, + "approved_scopes": approved_scopes, + "remember": true + })) + .send() + .await + .expect("Consent POST failed"); + assert_eq!( + consent_post_res.status(), + StatusCode::BAD_REQUEST, + "Consent POST must fail closed (400) when the include: set can no longer be \ + resolved, instead of silently persisting/granting truncated scopes" + ); + let error_body: Value = consent_post_res.json().await.unwrap(); + assert_eq!( + error_body["error"].as_str(), + Some("invalid_scope"), + "Expected invalid_scope error, got: {:?}", + error_body + ); +} + +#[tokio::test] +async fn test_legacy_granular_token_survives_refresh() { + let url = base_url().await; + let http_client = client(); + let redirect_uri = "https://example.com/permset-legacy-callback"; + let scope = "atproto repo:*?action=create"; + + let suffix = &uuid::Uuid::new_v4().simple().to_string()[..4]; + let handle = format!("psl{}", suffix); + let email = format!("psl{}@example.com", suffix); + let password = "LegacyPass123!"; + + let create_res = http_client + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) + .json(&json!({ + "handle": handle, + "email": email, + "password": password + })) + .send() + .await + .unwrap(); + assert_eq!(create_res.status(), StatusCode::OK); + let account: Value = create_res.json().await.unwrap(); + let user_did = account["did"].as_str().unwrap().to_string(); + let _ = verify_new_account(&http_client, &user_did).await; + + let mock_client = setup_mock_client_metadata(redirect_uri).await; + let client_id = mock_client.uri(); + let (code_verifier, code_challenge) = generate_pkce(); + + let par_res = http_client + .post(format!("{}/oauth/par", url)) + .form(&[ + ("response_type", "code"), + ("client_id", &client_id), + ("redirect_uri", redirect_uri), + ("code_challenge", &code_challenge), + ("code_challenge_method", "S256"), + ("scope", scope), + ]) + .send() + .await + .expect("PAR failed"); + assert!(par_res.status() == StatusCode::OK || par_res.status() == StatusCode::CREATED); + let par_body: Value = par_res.json().await.unwrap(); + let request_uri = par_body["request_uri"].as_str().unwrap(); + + let auth_res = http_client + .post(format!("{}/oauth/authorize", url)) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .json(&json!({ + "request_uri": request_uri, + "username": &handle, + "password": password, + "remember_device": false + })) + .send() + .await + .expect("Authorize failed"); + assert_eq!(auth_res.status(), StatusCode::OK); + let auth_body: Value = auth_res.json().await.unwrap(); + let mut location = auth_body["redirect_uri"] + .as_str() + .expect("Expected redirect_uri") + .to_string(); + if location.contains("/oauth/consent") { + let consent_res = http_client + .post(format!("{}/oauth/authorize/consent", url)) + .header("Content-Type", "application/json") + .json(&json!({ + "request_uri": request_uri, + "approved_scopes": scope.split_whitespace().collect::>(), + "remember": false + })) + .send() + .await + .expect("Consent request failed"); + assert_eq!(consent_res.status(), StatusCode::OK); + let consent_body: Value = consent_res.json().await.unwrap(); + location = consent_body["redirect_uri"] + .as_str() + .expect("Expected redirect_uri from consent") + .to_string(); + } + let code = location + .split("code=") + .nth(1) + .unwrap() + .split('&') + .next() + .unwrap(); + + let token_res = http_client + .post(format!("{}/oauth/token", url)) + .form(&[ + ("grant_type", "authorization_code"), + ("code", code), + ("redirect_uri", redirect_uri), + ("code_verifier", &code_verifier), + ("client_id", &client_id), + ]) + .send() + .await + .expect("Token request failed"); + assert_eq!(token_res.status(), StatusCode::OK); + let token_body: Value = token_res.json().await.unwrap(); + let access_token = token_body["access_token"].as_str().unwrap().to_string(); + let refresh_token = token_body["refresh_token"].as_str().unwrap().to_string(); + + let original_payload = decode_jwt_payload(&access_token); + let original_scope = original_payload["scope"].as_str().unwrap(); + assert!(original_scope.contains("repo:*?action=create")); + + let refresh_res = http_client + .post(format!("{}/oauth/token", url)) + .form(&[ + ("grant_type", "refresh_token"), + ("refresh_token", refresh_token.as_str()), + ("client_id", &client_id), + ]) + .send() + .await + .expect("Refresh request failed"); + assert_eq!( + refresh_res.status(), + StatusCode::OK, + "Refreshing a legacy granular-scope token should succeed" + ); + let refresh_body: Value = refresh_res.json().await.unwrap(); + let new_access_token = refresh_body["access_token"].as_str().unwrap(); + assert_ne!(new_access_token, access_token); + + let new_scope_from_response = refresh_body["scope"] + .as_str() + .expect("refresh response should include scope"); + assert!( + new_scope_from_response.contains("repo:*?action=create"), + "Refresh response scope should still contain the granular scope, got: {}", + new_scope_from_response + ); + + let new_payload = decode_jwt_payload(new_access_token); + let new_jwt_scope = new_payload["scope"] + .as_str() + .expect("new JWT should have a scope claim"); + assert!( + new_jwt_scope.contains("repo:*?action=create"), + "New JWT scope claim should still contain the granular scope after refresh, got: {}", + new_jwt_scope + ); +} -- 2.51.2 From 9c6730579e77c386ae1b79ed5859fa346fdc03d0 Mon Sep 17 00:00:00 2001 From: Trezy Date: Mon, 20 Jul 2026 20:56:36 -0500 Subject: [PATCH 05/39] feat: display bundled permission-sets on consent screen Signed-off-by: Trezy --- .../src/endpoints/authorize/consent.rs | 177 ++++-- crates/tranquil-pds/src/state.rs | 4 + .../tests/oauth_permission_sets.rs | 574 +++++++++++++++++- frontend/src/locales/en.json | 16 + frontend/src/routes/OAuthConsent.svelte | 136 ++++- frontend/src/styles/pages.css | 74 +++ 6 files changed, 918 insertions(+), 63 deletions(-) diff --git a/crates/tranquil-oauth-server/src/endpoints/authorize/consent.rs b/crates/tranquil-oauth-server/src/endpoints/authorize/consent.rs index 0e5bdbd..eb13b8b 100644 --- a/crates/tranquil-oauth-server/src/endpoints/authorize/consent.rs +++ b/crates/tranquil-oauth-server/src/endpoints/authorize/consent.rs @@ -10,6 +10,28 @@ pub struct ScopeInfo { pub granted: Option, } +#[derive(Debug, Serialize)] +pub struct PermissionSetInfo { + pub nsid: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub aud: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub detail: Option, + pub include_scope: String, + pub expanded: Vec, + pub granted: Option, +} + +#[derive(Debug, Serialize)] +pub struct FailedSetInfo { + pub nsid: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub aud: Option, + pub reason: String, +} + #[derive(Debug, Serialize)] pub struct ConsentResponse { pub request_uri: String, @@ -18,6 +40,8 @@ pub struct ConsentResponse { pub client_uri: Option, pub logo_uri: Option, pub scopes: Vec, + pub permission_sets: Vec, + pub failed_sets: Vec, pub show_consent: bool, pub did: Did, #[serde(skip_serializing_if = "Option::is_none")] @@ -126,19 +150,6 @@ pub async fn consent_get( authority, ) .await; - if !effective.outcome.failures.is_empty() { - let names: Vec = effective - .outcome - .failures - .iter() - .map(|f| f.nsid.clone()) - .collect(); - return json_error( - StatusCode::BAD_REQUEST, - "invalid_scope", - &format!("Could not resolve permission set(s): {}", names.join(", ")), - ); - } let requested_scopes: Vec<&str> = effective.resolved.split_whitespace().collect(); let preferences = state .repos @@ -150,30 +161,36 @@ pub async fn consent_get( .iter() .map(|p| (p.scope.as_str(), p.granted)) .collect(); - let requested_scope_strings: Vec = - requested_scopes.iter().map(|s| s.to_string()).collect(); + let presented_item_strings: Vec = effective + .outcome + .passthrough + .iter() + .cloned() + .chain(effective.outcome.sets.iter().map(|g| match &g.aud { + Some(a) => format!("include:{}?aud={}", g.nsid, a), + None => format!("include:{}", g.nsid), + })) + .collect(); let show_consent = should_show_consent( state.repos.oauth.as_ref(), &did, &request_data.parameters.client_id, - &requested_scope_strings, + &presented_item_strings, ) .await .unwrap_or(true); let has_granular_scopes = requested_scopes.iter().any(|s| is_granular_scope(s)); - let scopes: Vec = requested_scopes - .iter() - .map(|scope| { - let (category, required, description, display_name) = if let Some(def) = - tranquil_pds::oauth::scopes::SCOPE_DEFINITIONS.get(*scope) - { - let desc = if *scope == "atproto" && has_granular_scopes { + + let make_scope_info = |scope: &str| -> ScopeInfo { + let (category, required, description, display_name) = + if let Some(def) = tranquil_pds::oauth::scopes::SCOPE_DEFINITIONS.get(scope) { + let desc = if scope == "atproto" && has_granular_scopes { "AT Protocol baseline scope (permissions determined by selected options below)" .to_string() } else { def.description.to_string() }; - let name = if *scope == "atproto" && has_granular_scopes { + let name = if scope == "atproto" && has_granular_scopes { "AT Protocol Access".to_string() } else { def.display_name.to_string() @@ -199,18 +216,61 @@ pub async fn consent_get( scope.to_string(), ) }; - let granted = pref_map.get(*scope).copied(); - ScopeInfo { - scope: scope.to_string(), - category, - required, - description, - display_name, - granted, + let granted = pref_map.get(scope).copied(); + ScopeInfo { + scope: scope.to_string(), + category, + required, + description, + display_name, + granted, + } + }; + + let scopes: Vec = effective + .outcome + .passthrough + .iter() + .map(|s| make_scope_info(s)) + .collect(); + + let permission_sets: Vec = effective + .outcome + .sets + .iter() + .map(|g| { + let include_scope = match &g.aud { + Some(a) => format!("include:{}?aud={}", g.nsid, a), + None => format!("include:{}", g.nsid), + }; + PermissionSetInfo { + nsid: g.nsid.clone(), + aud: g.aud.clone(), + title: g.title.clone(), + detail: g.detail.clone(), + granted: pref_map.get(include_scope.as_str()).copied(), + include_scope, + expanded: g.expanded.iter().map(|s| make_scope_info(s)).collect(), } }) .collect(); + let failed_sets: Vec = effective + .outcome + .failures + .iter() + .map(|f| FailedSetInfo { + nsid: f.nsid.clone(), + aud: f.aud.clone(), + reason: match f.reason { + tranquil_scopes::ResolveFailure::NotFound => "not_found", + tranquil_scopes::ResolveFailure::NetworkError => "unreachable", + tranquil_scopes::ResolveFailure::Invalid => "invalid", + } + .to_string(), + }) + .collect(); + let account_handle = state .repos .user @@ -259,6 +319,8 @@ pub async fn consent_get( client_uri: client_metadata.as_ref().and_then(|m| m.client_uri.clone()), logo_uri: client_metadata.as_ref().and_then(|m| m.logo_uri.clone()), scopes, + permission_sets, + failed_sets, show_consent, did: did.clone(), handle: account_handle, @@ -356,21 +418,43 @@ pub async fn consent_post( authority, ) .await; - if !effective.outcome.failures.is_empty() { - let names: Vec = effective - .outcome - .failures - .iter() - .map(|f| f.nsid.clone()) - .collect(); + let include_token = |nsid: &str, aud: &Option| -> String { + match aud { + Some(a) => format!("include:{}?aud={}", nsid, a), + None => format!("include:{}", nsid), + } + }; + let approved_failed_sets: Vec = effective + .outcome + .failures + .iter() + .filter(|f| form.approved_scopes.contains(&include_token(&f.nsid, &f.aud))) + .map(|f| f.nsid.clone()) + .collect(); + if !approved_failed_sets.is_empty() { return json_error( StatusCode::BAD_REQUEST, "invalid_scope", - &format!("Could not resolve permission set(s): {}", names.join(", ")), + &format!( + "Could not resolve approved permission set(s): {}", + approved_failed_sets.join(", ") + ), ); } - let requested_scopes: Vec<&str> = effective.resolved.split_whitespace().collect(); - let atproto_was_requested = requested_scopes.contains(&"atproto"); + let presented_items: Vec = effective + .outcome + .passthrough + .iter() + .cloned() + .chain( + effective + .outcome + .sets + .iter() + .map(|g| include_token(&g.nsid, &g.aud)), + ) + .collect(); + let atproto_was_requested = presented_items.iter().any(|s| s == "atproto"); if atproto_was_requested && !form.approved_scopes.contains(&"atproto".to_string()) { return json_error( StatusCode::BAD_REQUEST, @@ -378,7 +462,8 @@ pub async fn consent_post( "The atproto scope was requested and must be approved", ); } - let final_approved: Vec = form.approved_scopes.clone(); + let mut final_approved: Vec = form.approved_scopes.clone(); + final_approved.retain(|s| presented_items.iter().any(|p| p == s) || s == "atproto"); if final_approved.is_empty() { return json_error( StatusCode::BAD_REQUEST, @@ -396,11 +481,11 @@ pub async fn consent_post( ); } if form.remember { - let preferences: Vec = requested_scopes + let preferences: Vec = presented_items .iter() .map(|s| ScopePreference { - scope: s.to_string(), - granted: form.approved_scopes.contains(&s.to_string()), + scope: s.clone(), + granted: form.approved_scopes.contains(s), }) .collect(); let _ = state diff --git a/crates/tranquil-pds/src/state.rs b/crates/tranquil-pds/src/state.rs index c2fbd36..66f5fd1 100644 --- a/crates/tranquil-pds/src/state.rs +++ b/crates/tranquil-pds/src/state.rs @@ -30,6 +30,10 @@ pub fn init_rate_limit_override() { } } +pub fn set_rate_limiting_disabled(disabled: bool) { + RATE_LIMITING_DISABLED.store(disabled, Ordering::Relaxed); +} + #[derive(Clone)] pub struct AppState { pub repos: Arc, diff --git a/crates/tranquil-pds/tests/oauth_permission_sets.rs b/crates/tranquil-pds/tests/oauth_permission_sets.rs index da779e1..cc2b7f4 100644 --- a/crates/tranquil-pds/tests/oauth_permission_sets.rs +++ b/crates/tranquil-pds/tests/oauth_permission_sets.rs @@ -16,6 +16,11 @@ const PERMISSION_SET_NSID: &str = "io.atcr.authFullApp"; const PERMISSION_SET_GRANULAR_SCOPE: &str = "repo:io.atcr.manifest?action=create rpc:io.atcr.getManifest?aud=*"; +fn disable_rate_limiting_once() { + static ONCE: std::sync::Once = std::sync::Once::new(); + ONCE.call_once(|| tranquil_pds::state::set_rate_limiting_disabled(true)); +} + fn generate_pkce() -> (String, String) { let verifier_bytes: [u8; 32] = rand::random(); let code_verifier = URL_SAFE_NO_PAD.encode(verifier_bytes); @@ -94,6 +99,7 @@ async fn create_delegated_session_with_scope( scope: &str, ) -> (DelegatedSession, Value, MockServer) { let url = base_url().await; + disable_rate_limiting_once(); let http_client = client(); let (controller_jwt, controller_did) = create_account_and_login(&http_client).await; @@ -251,29 +257,48 @@ async fn test_delegated_include_scope_shows_granular_on_consent() { ) .await; - let scopes = consent_body["scopes"] + let permission_sets = consent_body["permission_sets"] .as_array() - .expect("consent response should have a scopes array"); - assert!(!scopes.is_empty(), "consent scopes should not be empty"); + .expect("consent response should have a permission_sets array"); + assert!( + !permission_sets.is_empty(), + "consent permission_sets should not be empty. Got: {:?}", + consent_body + ); - let has_granular = scopes + let set_entry = permission_sets .iter() - .any(|s| s["scope"].as_str() == Some("repo:io.atcr.manifest?action=create")); - assert!( - has_granular, - "consent scopes[] should list the expanded granular scope \ - 'repo:io.atcr.manifest?action=create', not just 'atproto'. Got: {:?}", - scopes + .find(|s| s["nsid"].as_str() == Some(PERMISSION_SET_NSID)) + .unwrap_or_else(|| { + panic!( + "permission_sets should contain an entry for nsid '{}'. Got: {:?}", + PERMISSION_SET_NSID, permission_sets + ) + }); + + assert_eq!( + set_entry["include_scope"].as_str(), + Some(format!("include:{}", PERMISSION_SET_NSID).as_str()), + "permission_sets entry should carry the include: token the frontend submits" ); - let only_atproto = scopes + let expanded = set_entry["expanded"] + .as_array() + .expect("permission_sets entry should have an expanded array"); + let has_granular = expanded .iter() - .all(|s| s["scope"].as_str() == Some("atproto")); + .any(|s| s["scope"].as_str() == Some("repo:io.atcr.manifest?action=create")); assert!( - !only_atproto, - "consent scopes[] should not collapse to just 'atproto'" + has_granular, + "permission_sets entry's expanded[] should list the granular scope \ + 'repo:io.atcr.manifest?action=create'. Got: {:?}", + expanded ); + let scopes = consent_body["scopes"] + .as_array() + .expect("consent response should have a scopes array"); + let has_raw_include = scopes.iter().any(|s| { s["scope"] .as_str() @@ -282,7 +307,7 @@ async fn test_delegated_include_scope_shows_granular_on_consent() { }); assert!( !has_raw_include, - "consent scopes[] should list the expanded set, not the raw include: token" + "consent scopes[] should not carry the raw include: token" ); } @@ -422,6 +447,7 @@ async fn test_consent_post_errors_when_set_unresolvable() { seed_permission_set(UNRESOLVABLE_NSID, PERMISSION_SET_GRANULAR_SCOPE).await; let url = base_url().await; + disable_rate_limiting_once(); let http_client = client(); let (controller_jwt, controller_did) = create_account_and_login(&http_client).await; @@ -540,9 +566,367 @@ async fn test_consent_post_errors_when_set_unresolvable() { ); } +#[tokio::test] +async fn test_consent_post_succeeds_when_unapproved_set_fails() { + const GOOD_SET_NSID: &str = "io.atcr.goodSet"; + const BAD_SET_NSID: &str = "io.atcr.badSet"; + seed_permission_set(GOOD_SET_NSID, PERMISSION_SET_GRANULAR_SCOPE).await; + + let url = base_url().await; + disable_rate_limiting_once(); + let http_client = client(); + + let (controller_jwt, controller_did) = create_account_and_login(&http_client).await; + + let suffix = &uuid::Uuid::new_v4().simple().to_string()[..4]; + let delegated_handle = format!("psg{}", suffix); + let delegated_res = http_client + .post(format!("{}/xrpc/_delegation.createDelegatedAccount", url)) + .bearer_auth(&controller_jwt) + .json(&json!({ + "handle": delegated_handle, + "controllerScopes": tranquil_pds::delegation::OWNER_FULL_SCOPES + })) + .send() + .await + .expect("createDelegatedAccount request failed"); + if delegated_res.status() != StatusCode::OK { + let error_body = delegated_res.text().await.unwrap(); + panic!("Failed to create delegated account: {}", error_body); + } + let delegated_account: Value = delegated_res.json().await.unwrap(); + let delegated_did = delegated_account["did"].as_str().unwrap().to_string(); + + let redirect_uri = "https://example.com/permset-partial-fail-callback"; + let mock_client = setup_mock_client_metadata(redirect_uri).await; + let client_id = mock_client.uri(); + let (_code_verifier, code_challenge) = generate_pkce(); + + let scope = format!( + "atproto include:{} include:{}", + GOOD_SET_NSID, BAD_SET_NSID + ); + let par_res = http_client + .post(format!("{}/oauth/par", url)) + .form(&[ + ("response_type", "code"), + ("client_id", &client_id), + ("redirect_uri", redirect_uri), + ("code_challenge", &code_challenge), + ("code_challenge_method", "S256"), + ("scope", scope.as_str()), + ("login_hint", delegated_did.as_str()), + ]) + .send() + .await + .expect("PAR failed"); + assert!( + par_res.status() == StatusCode::OK || par_res.status() == StatusCode::CREATED, + "PAR should succeed, got {}", + par_res.status() + ); + let par_body: Value = par_res.json().await.unwrap(); + let request_uri = par_body["request_uri"].as_str().unwrap().to_string(); + + let auth_res = http_client + .post(format!("{}/oauth/delegation/auth", url)) + .header("Content-Type", "application/json") + .json(&json!({ + "request_uri": request_uri, + "delegated_did": delegated_did, + "controller_did": controller_did, + "password": "Testpass123!", + "remember_device": false + })) + .send() + .await + .expect("Delegation auth request failed"); + if auth_res.status() != StatusCode::OK { + let error_body = auth_res.text().await.unwrap(); + panic!("Delegation auth failed: {}", error_body); + } + let auth_body: Value = auth_res.json().await.unwrap(); + assert!( + auth_body["success"].as_bool().unwrap_or(false), + "Delegation auth should succeed: {:?}", + auth_body + ); + + let consent_get_res = http_client + .get(format!("{}/oauth/authorize/consent", url)) + .query(&[("request_uri", request_uri.as_str())]) + .send() + .await + .expect("Consent GET failed"); + assert_eq!( + consent_get_res.status(), + StatusCode::OK, + "Consent GET should succeed" + ); + let consent_get_body: Value = consent_get_res.json().await.unwrap(); + + let permission_sets = consent_get_body["permission_sets"] + .as_array() + .expect("consent response should have a permission_sets array"); + assert!( + permission_sets + .iter() + .any(|s| s["nsid"].as_str() == Some(GOOD_SET_NSID)), + "the resolvable set should be presented as a permission set. Got: {:?}", + consent_get_body + ); + let failed_sets = consent_get_body["failed_sets"] + .as_array() + .expect("consent response should have a failed_sets array"); + assert!( + failed_sets + .iter() + .any(|s| s["nsid"].as_str() == Some(BAD_SET_NSID)), + "the unresolvable set should be presented as a failed set. Got: {:?}", + consent_get_body + ); + + let approved_scopes = vec!["atproto".to_string(), format!("include:{}", GOOD_SET_NSID)]; + let consent_post_res = http_client + .post(format!("{}/oauth/authorize/consent", url)) + .header("Content-Type", "application/json") + .json(&json!({ + "request_uri": request_uri, + "approved_scopes": approved_scopes, + "remember": false + })) + .send() + .await + .expect("Consent POST failed"); + let status = consent_post_res.status(); + let consent_post_body: Value = consent_post_res.json().await.unwrap(); + assert_eq!( + status, + StatusCode::OK, + "Consent POST must succeed when the user approves only the resolvable set and \ + leaves the unresolvable set unapproved. Got: {:?}", + consent_post_body + ); + assert!( + consent_post_body["redirect_uri"].as_str().is_some(), + "Consent POST should return a redirect_uri. Got: {:?}", + consent_post_body + ); +} + +#[tokio::test] +async fn test_consent_remember_persists_set_preference() { + const REMEMBER_SET_NSID: &str = "io.atcr.rememberSet"; + seed_permission_set(REMEMBER_SET_NSID, PERMISSION_SET_GRANULAR_SCOPE).await; + + let url = base_url().await; + disable_rate_limiting_once(); + let http_client = client(); + + let (controller_jwt, controller_did) = create_account_and_login(&http_client).await; + + let suffix = &uuid::Uuid::new_v4().simple().to_string()[..4]; + let delegated_handle = format!("psr{}", suffix); + let delegated_res = http_client + .post(format!("{}/xrpc/_delegation.createDelegatedAccount", url)) + .bearer_auth(&controller_jwt) + .json(&json!({ + "handle": delegated_handle, + "controllerScopes": tranquil_pds::delegation::OWNER_FULL_SCOPES + })) + .send() + .await + .expect("createDelegatedAccount request failed"); + if delegated_res.status() != StatusCode::OK { + let error_body = delegated_res.text().await.unwrap(); + panic!("Failed to create delegated account: {}", error_body); + } + let delegated_account: Value = delegated_res.json().await.unwrap(); + let delegated_did = delegated_account["did"].as_str().unwrap().to_string(); + + let redirect_uri = "https://example.com/permset-remember-callback"; + let mock_client = setup_mock_client_metadata(redirect_uri).await; + let client_id = mock_client.uri(); + let (_code_verifier, code_challenge) = generate_pkce(); + + let scope = format!("atproto include:{}", REMEMBER_SET_NSID); + let par_res = http_client + .post(format!("{}/oauth/par", url)) + .form(&[ + ("response_type", "code"), + ("client_id", &client_id), + ("redirect_uri", redirect_uri), + ("code_challenge", &code_challenge), + ("code_challenge_method", "S256"), + ("scope", scope.as_str()), + ("login_hint", delegated_did.as_str()), + ]) + .send() + .await + .expect("PAR failed"); + assert!( + par_res.status() == StatusCode::OK || par_res.status() == StatusCode::CREATED, + "PAR should succeed, got {}", + par_res.status() + ); + let par_body: Value = par_res.json().await.unwrap(); + let request_uri = par_body["request_uri"].as_str().unwrap().to_string(); + + let auth_res = http_client + .post(format!("{}/oauth/delegation/auth", url)) + .header("Content-Type", "application/json") + .json(&json!({ + "request_uri": request_uri, + "delegated_did": delegated_did, + "controller_did": controller_did, + "password": "Testpass123!", + "remember_device": false + })) + .send() + .await + .expect("Delegation auth request failed"); + if auth_res.status() != StatusCode::OK { + let error_body = auth_res.text().await.unwrap(); + panic!("Delegation auth failed: {}", error_body); + } + let auth_body: Value = auth_res.json().await.unwrap(); + assert!( + auth_body["success"].as_bool().unwrap_or(false), + "Delegation auth should succeed: {:?}", + auth_body + ); + + let consent_get_res = http_client + .get(format!("{}/oauth/authorize/consent", url)) + .query(&[("request_uri", request_uri.as_str())]) + .send() + .await + .expect("Consent GET failed"); + assert_eq!( + consent_get_res.status(), + StatusCode::OK, + "Consent GET should succeed" + ); + + let approved_scopes = vec![ + "atproto".to_string(), + format!("include:{}", REMEMBER_SET_NSID), + ]; + let consent_post_res = http_client + .post(format!("{}/oauth/authorize/consent", url)) + .header("Content-Type", "application/json") + .json(&json!({ + "request_uri": request_uri, + "approved_scopes": approved_scopes, + "remember": true + })) + .send() + .await + .expect("Consent POST failed"); + if consent_post_res.status() != StatusCode::OK { + let error_body = consent_post_res.text().await.unwrap(); + panic!("Consent POST with remember:true failed: {}", error_body); + } + + let did: tranquil_types::Did = delegated_did.parse().expect("valid did"); + let client_id_typed = tranquil_types::ClientId::new(client_id.clone()); + let stored_prefs = common::get_test_repos() + .await + .oauth + .get_scope_preferences(&did, &client_id_typed) + .await + .expect("get_scope_preferences query failed"); + let include_token = format!("include:{}", REMEMBER_SET_NSID); + let set_pref = stored_prefs + .iter() + .find(|p| p.scope == include_token) + .unwrap_or_else(|| { + panic!( + "expected a stored scope preference for '{}', got: {:?}", + include_token, stored_prefs + ) + }); + assert!( + set_pref.granted, + "the remembered set preference should be granted: true, got: {:?}", + set_pref + ); + assert!( + !stored_prefs + .iter() + .any(|p| p.scope.starts_with("repo:") || p.scope.starts_with("rpc:")), + "remember must not store the expanded granular scopes as preferences, got: {:?}", + stored_prefs + ); + + let (code_verifier2, code_challenge2) = generate_pkce(); + let par_res2 = http_client + .post(format!("{}/oauth/par", url)) + .form(&[ + ("response_type", "code"), + ("client_id", &client_id), + ("redirect_uri", redirect_uri), + ("code_challenge", &code_challenge2), + ("code_challenge_method", "S256"), + ("scope", scope.as_str()), + ("login_hint", delegated_did.as_str()), + ]) + .send() + .await + .expect("second PAR failed"); + let _ = code_verifier2; + assert!(par_res2.status() == StatusCode::OK || par_res2.status() == StatusCode::CREATED); + let par_body2: Value = par_res2.json().await.unwrap(); + let request_uri2 = par_body2["request_uri"].as_str().unwrap().to_string(); + + let auth_res2 = http_client + .post(format!("{}/oauth/delegation/auth", url)) + .header("Content-Type", "application/json") + .json(&json!({ + "request_uri": request_uri2, + "delegated_did": delegated_did, + "controller_did": controller_did, + "password": "Testpass123!", + "remember_device": false + })) + .send() + .await + .expect("second delegation auth failed"); + assert_eq!(auth_res2.status(), StatusCode::OK); + + let consent_get_res2 = http_client + .get(format!("{}/oauth/authorize/consent", url)) + .query(&[("request_uri", request_uri2.as_str())]) + .send() + .await + .expect("second consent GET failed"); + assert_eq!(consent_get_res2.status(), StatusCode::OK); + let consent_get_body2: Value = consent_get_res2.json().await.unwrap(); + let permission_sets2 = consent_get_body2["permission_sets"] + .as_array() + .expect("consent response should have a permission_sets array"); + let set_entry2 = permission_sets2 + .iter() + .find(|s| s["nsid"].as_str() == Some(REMEMBER_SET_NSID)) + .unwrap_or_else(|| { + panic!( + "expected permission_sets to contain '{}', got: {:?}", + REMEMBER_SET_NSID, consent_get_body2 + ) + }); + assert_eq!( + set_entry2["granted"].as_bool(), + Some(true), + "the remembered set's include: token preference should round-trip as granted: true \ + on a subsequent consent_get. Got: {:?}", + set_entry2 + ); +} + #[tokio::test] async fn test_legacy_granular_token_survives_refresh() { let url = base_url().await; + disable_rate_limiting_once(); let http_client = client(); let redirect_uri = "https://example.com/permset-legacy-callback"; let scope = "atproto repo:*?action=create"; @@ -693,3 +1077,163 @@ async fn test_legacy_granular_token_survives_refresh() { new_jwt_scope ); } + +#[tokio::test] +async fn test_consent_post_drops_unpresented_scope() { + seed_permission_set(PERMISSION_SET_NSID, PERMISSION_SET_GRANULAR_SCOPE).await; + + let url = base_url().await; + disable_rate_limiting_once(); + let http_client = client(); + + let (controller_jwt, controller_did) = create_account_and_login(&http_client).await; + + let suffix = &uuid::Uuid::new_v4().simple().to_string()[..4]; + let delegated_handle = format!("psd{}", suffix); + let delegated_res = http_client + .post(format!("{}/xrpc/_delegation.createDelegatedAccount", url)) + .bearer_auth(&controller_jwt) + .json(&json!({ + "handle": delegated_handle, + "controllerScopes": tranquil_pds::delegation::OWNER_FULL_SCOPES + })) + .send() + .await + .expect("createDelegatedAccount request failed"); + if delegated_res.status() != StatusCode::OK { + let error_body = delegated_res.text().await.unwrap(); + panic!("Failed to create delegated account: {}", error_body); + } + let delegated_account: Value = delegated_res.json().await.unwrap(); + let delegated_did = delegated_account["did"].as_str().unwrap().to_string(); + + let redirect_uri = "https://example.com/permset-unpresented-callback"; + let mock_client = setup_mock_client_metadata(redirect_uri).await; + let client_id = mock_client.uri(); + let (code_verifier, code_challenge) = generate_pkce(); + + let scope = format!("atproto include:{}", PERMISSION_SET_NSID); + let par_res = http_client + .post(format!("{}/oauth/par", url)) + .form(&[ + ("response_type", "code"), + ("client_id", &client_id), + ("redirect_uri", redirect_uri), + ("code_challenge", &code_challenge), + ("code_challenge_method", "S256"), + ("scope", scope.as_str()), + ("login_hint", delegated_did.as_str()), + ]) + .send() + .await + .expect("PAR failed"); + assert!( + par_res.status() == StatusCode::OK || par_res.status() == StatusCode::CREATED, + "PAR should succeed, got {}", + par_res.status() + ); + let par_body: Value = par_res.json().await.unwrap(); + let request_uri = par_body["request_uri"].as_str().unwrap().to_string(); + + let auth_res = http_client + .post(format!("{}/oauth/delegation/auth", url)) + .header("Content-Type", "application/json") + .json(&json!({ + "request_uri": request_uri, + "delegated_did": delegated_did, + "controller_did": controller_did, + "password": "Testpass123!", + "remember_device": false + })) + .send() + .await + .expect("Delegation auth request failed"); + if auth_res.status() != StatusCode::OK { + let error_body = auth_res.text().await.unwrap(); + panic!("Delegation auth failed: {}", error_body); + } + let auth_body: Value = auth_res.json().await.unwrap(); + assert!( + auth_body["success"].as_bool().unwrap_or(false), + "Delegation auth should succeed: {:?}", + auth_body + ); + + let approved_scopes = vec![ + "atproto".to_string(), + format!("include:{}", PERMISSION_SET_NSID), + "repo:com.evil.collection?action=create".to_string(), + ]; + let consent_post_res = http_client + .post(format!("{}/oauth/authorize/consent", url)) + .header("Content-Type", "application/json") + .json(&json!({ + "request_uri": request_uri, + "approved_scopes": approved_scopes, + "remember": false + })) + .send() + .await + .expect("Consent POST failed"); + if consent_post_res.status() != StatusCode::OK { + let error_body = consent_post_res.text().await.unwrap(); + panic!("Consent POST failed: {}", error_body); + } + let consent_post_body: Value = consent_post_res.json().await.unwrap(); + let location = consent_post_body["redirect_uri"] + .as_str() + .expect("Expected redirect_uri from consent") + .to_string(); + + let code = location + .split("code=") + .nth(1) + .unwrap() + .split('&') + .next() + .unwrap(); + + let token_res = http_client + .post(format!("{}/oauth/token", url)) + .form(&[ + ("grant_type", "authorization_code"), + ("code", code), + ("redirect_uri", redirect_uri), + ("code_verifier", &code_verifier), + ("client_id", &client_id), + ]) + .send() + .await + .expect("Token request failed"); + assert_eq!( + token_res.status(), + StatusCode::OK, + "Token exchange should succeed" + ); + let token_body: Value = token_res.json().await.unwrap(); + let access_token = token_body["access_token"].as_str().unwrap().to_string(); + + let token_id = token_id_from_jwt(&access_token); + let token_data = common::get_test_repos() + .await + .oauth + .get_token_by_id(&token_id) + .await + .expect("get_token_by_id query failed") + .expect("token row should exist"); + let row_scope = token_data + .scope + .expect("stored token row should have a scope"); + assert!( + !row_scope.contains("com.evil.collection"), + "Stored oauth_token.scope row must not contain a scope that was never presented \ + to the resource owner, got: {}", + row_scope + ); + assert!( + row_scope.contains(&format!("include:{}", PERMISSION_SET_NSID)), + "Stored oauth_token.scope row should still preserve the legitimately approved \ + include: token, got: {}", + row_scope + ); +} diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 97bb2ad..609ff60 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -597,6 +597,22 @@ "permissionsRequested": "Permissions Requested", "required": "Required", "rememberChoiceLabel": "Remember my choice for this application", + "permissionSets": "Permission bundles", + "unavailableSets": "Unavailable permission bundles", + "showIncludedScopes": "Show included permissions ({count})", + "setFailureReason": { + "not_found": "This bundle could not be found and cannot be granted.", + "unreachable": "The bundle's publisher could not be reached; it cannot be granted right now.", + "invalid": "This bundle is invalid and cannot be granted." + }, + "permTable": { + "data": "Data", + "create": "Create", + "update": "Update", + "delete": "Delete", + "allData": "All collections", + "apiAccess": "API access" + }, "scopes": { "atproto": { "name": "AT Protocol Access", diff --git a/frontend/src/routes/OAuthConsent.svelte b/frontend/src/routes/OAuthConsent.svelte index 94ce589..d9fc834 100644 --- a/frontend/src/routes/OAuthConsent.svelte +++ b/frontend/src/routes/OAuthConsent.svelte @@ -32,6 +32,22 @@ scope.startsWith('identity:') } + interface PermissionSetInfo { + nsid: string + aud?: string + title?: string + detail?: string + include_scope: string + expanded: ScopeInfo[] + granted: boolean | null + } + + interface FailedSetInfo { + nsid: string + aud?: string + reason: string + } + interface ConsentData { request_uri: string client_id: string @@ -39,6 +55,8 @@ client_uri: string | null logo_uri: string | null scopes: ScopeInfo[] + permission_sets: PermissionSetInfo[] + failed_sets: FailedSetInfo[] show_consent: boolean did: string handle?: string @@ -130,6 +148,10 @@ ]) ) + for (const set of data.permission_sets ?? []) { + scopeSelections[set.include_scope] = set.granted ?? true + } + if (!data.show_consent) { await submitConsent() } @@ -152,7 +174,11 @@ .filter(([_, approved]) => approved) .map(([scope]) => scope) - if (approvedScopes.length === 0 && consentData.scopes.length === 0) { + if ( + approvedScopes.length === 0 && + consentData.scopes.length === 0 && + (consentData.permission_sets?.length ?? 0) === 0 + ) { approvedScopes = ['atproto'] } @@ -249,7 +275,10 @@ }) let scopeGroups = $derived(consentData ? groupScopesByCategory(consentData.scopes) : []) - let hasGranularScopes = $derived(consentData?.scopes.some(s => isGranularScope(s.scope)) ?? false) + let hasGranularScopes = $derived( + (consentData?.scopes.some(s => isGranularScope(s.scope)) ?? false) || + ((consentData?.permission_sets?.length ?? 0) > 0) + ) function getLocalizedScopeName(scope: ScopeInfo): string { const localeKey = SCOPE_LOCALE_MAP[scope.scope] @@ -276,6 +305,36 @@ const localized = $_(`oauth.consent.scopes.${localeKey}.description`) return localized !== `oauth.consent.scopes.${localeKey}.description` ? localized : scope.description } + + type RepoRow = { collection: string; create: boolean; update: boolean; delete: boolean } + function describeExpanded(expanded: ScopeInfo[]): { + repo: RepoRow[] + rpc: string[] + other: ScopeInfo[] + } { + const repo = new Map() + const rpc: string[] = [] + const other: ScopeInfo[] = [] + for (const s of expanded) { + const [base, query = ''] = s.scope.split('?') + const params = new URLSearchParams(query) + if (base.startsWith('repo:')) { + const collection = base.slice('repo:'.length) || '*' + const requested = params.getAll('action') + const actions = requested.length > 0 ? requested : ['create', 'update', 'delete'] + const row = repo.get(collection) ?? { collection, create: false, update: false, delete: false } + for (const a of actions) { + if (a === 'create' || a === 'update' || a === 'delete') row[a] = true + } + repo.set(collection, row) + } else if (base.startsWith('rpc:')) { + rpc.push(base.slice('rpc:'.length)) + } else { + other.push(s) + } + } + return { repo: [...repo.values()], rpc, other } + } {/each} {/if} + + {#if consentData.permission_sets?.length} +
+

{$_('oauth.consent.permissionSets')}

+ {#each consentData.permission_sets as set} + {@const desc = describeExpanded(set.expanded)} + + {/each} +
+ {/if} + + {#if consentData.failed_sets?.length} +
+

{$_('oauth.consent.unavailableSets')}

+ {#each consentData.failed_sets as f} +
+
+ {f.nsid}{#if f.aud} ({f.aud}){/if} + {$_(`oauth.consent.setFailureReason.${f.reason}`)} +
+
+ {/each} +
+ {/if}