From f1d963988e1736b83c6e563a70a0e6c1c87b13d7 Mon Sep 17 00:00:00 2001 From: Louis Escher Date: Mon, 21 Sep 2026 10:05:21 +0200 Subject: [PATCH] fix: actually pass the scope's aud with the service fragment --- crates/tranquil-pds/src/api/proxy.rs | 3 +- crates/tranquil-pds/tests/oauth_scopes.rs | 87 +++++++++++++++++++++++ crates/tranquil-storage/src/lib.rs | 6 +- 3 files changed, 93 insertions(+), 3 deletions(-) diff --git a/crates/tranquil-pds/src/api/proxy.rs b/crates/tranquil-pds/src/api/proxy.rs index 66344ed..82bd59a 100644 --- a/crates/tranquil-pds/src/api/proxy.rs +++ b/crates/tranquil-pds/src/api/proxy.rs @@ -293,10 +293,11 @@ async fn proxy_handler( return ApiError::InvalidRequest(format!("Invalid XRPC method: {}", method)) .into_response(); }; + let scope_aud = format!("{}#{}", resolved.did, service_id); if let Err(e) = crate::auth::scope_check::check_rpc_scope( &auth_user.auth_source, auth_user.scope.as_deref(), - &resolved.did, + &scope_aud, &method_nsid, ) { return e.into_response(); diff --git a/crates/tranquil-pds/tests/oauth_scopes.rs b/crates/tranquil-pds/tests/oauth_scopes.rs index 7ede0bd..179ed3a 100644 --- a/crates/tranquil-pds/tests/oauth_scopes.rs +++ b/crates/tranquil-pds/tests/oauth_scopes.rs @@ -1114,3 +1114,90 @@ async fn test_remembered_scope_later_unregistered_never_reaches_a_token() { refreshed["scope"] ); } + +const PROXY_LXM: &str = "io.atcr.getManifest"; +const PROXY_SERVICE_ID: &str = "test_service"; + +async fn setup_mock_proxy_service() -> (MockServer, String) { + let server = MockServer::start().await; + let uri = server.uri(); + let host = uri + .strip_prefix("http://") + .expect("mock uri should be http"); + let did = format!("did:web:{host}"); + + Mock::given(method("GET")) + .and(path("/.well-known/did.json")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": did, + "service": [{ + "id": format!("#{PROXY_SERVICE_ID}"), + "type": "TestService", + "serviceEndpoint": uri, + }] + }))) + .mount(&server) + .await; + + Mock::given(method("GET")) + .and(path(format!("/xrpc/{PROXY_LXM}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "manifest": [] }))) + .mount(&server) + .await; + + (server, did) +} + +async fn proxied_call(access_token: &str, service_did: &str) -> reqwest::Response { + client() + .get(format!("{}/xrpc/{}", base_url().await, PROXY_LXM)) + .bearer_auth(access_token) + .header("atproto-proxy", format!("{service_did}#{PROXY_SERVICE_ID}")) + .send() + .await + .expect("Proxied request failed") +} + +#[tokio::test] +async fn test_rpc_scope_with_service_fragment_allows_proxied_call() { + let (_service, service_did) = setup_mock_proxy_service().await; + let scope = format!("atproto rpc:{PROXY_LXM}?aud={service_did}#{PROXY_SERVICE_ID}"); + let (session, _mock) = create_user_and_oauth_session_with_scope( + "rpcfrag", + "https://example.com/rpc-fragment-callback", + &scope, + ) + .await; + + let res = proxied_call(&session.access_token, &service_did).await; + let status = res.status(); + assert_eq!( + status, + StatusCode::OK, + "a scope audienced to did#serviceId must cover a call proxied to that service, got {} {:?}", + status, + res.text().await + ); +} + +#[tokio::test] +async fn test_rpc_scope_with_bare_did_is_refused_for_proxied_call() { + let (_service, service_did) = setup_mock_proxy_service().await; + let scope = format!("atproto rpc:{PROXY_LXM}?aud={service_did}"); + let (session, _mock) = create_user_and_oauth_session_with_scope( + "rpcbare", + "https://example.com/rpc-bare-callback", + &scope, + ) + .await; + + let res = proxied_call(&session.access_token, &service_did).await; + let status = res.status(); + assert_eq!( + status, + StatusCode::FORBIDDEN, + "a bare DID audience must not cover a call proxied to a service on that DID, got {} {:?}", + status, + res.text().await + ); +} diff --git a/crates/tranquil-storage/src/lib.rs b/crates/tranquil-storage/src/lib.rs index fea57b8..16fc326 100644 --- a/crates/tranquil-storage/src/lib.rs +++ b/crates/tranquil-storage/src/lib.rs @@ -126,7 +126,9 @@ mod s3 { .clone() .expect("storage.s3_bucket (S3_BUCKET) must be set"); let client = create_s3_client().await; - let path = cfg.storage.s3_path + let path = cfg + .storage + .s3_path .trim_start_matches("/") .trim_end_matches("/") .to_string(); @@ -139,7 +141,7 @@ mod s3 { fn resolve_path(&self, key: &str) -> String { if self.path.is_empty() { - return key.to_string() + return key.to_string(); } format!("{}/{}", self.path, key)