From 60abcc3dacd9affdbdfa9ed4a1ce734044a3c78c Mon Sep 17 00:00:00 2001 From: scanash00 Date: Thu, 13 Aug 2026 15:53:06 -0800 Subject: [PATCH] more scope fixes --- .../tranquil-api/src/server/service_auth.rs | 74 ++++++++++++++++++- crates/tranquil-auth/src/token.rs | 4 +- crates/tranquil-pds/tests/server.rs | 35 +++++++++ 3 files changed, 109 insertions(+), 4 deletions(-) diff --git a/crates/tranquil-api/src/server/service_auth.rs b/crates/tranquil-api/src/server/service_auth.rs index da08672..0b6c4c1 100644 --- a/crates/tranquil-api/src/server/service_auth.rs +++ b/crates/tranquil-api/src/server/service_auth.rs @@ -45,7 +45,7 @@ static PROTECTED_METHODS: LazyLock> = LazyLock::new(|| { #[derive(Deserialize)] pub struct GetServiceAuthParams { - pub aud: Did, + pub aud: String, pub lxm: Option, pub exp: Option, } @@ -55,11 +55,81 @@ pub struct GetServiceAuthOutput { pub token: String, } +fn is_valid_did_fragment(fragment: &str) -> bool { + let bytes = fragment.as_bytes(); + let mut index = 0; + while index < bytes.len() { + let byte = bytes[index]; + if byte.is_ascii_alphanumeric() + || matches!( + byte, + b'-' | b'.' + | b'_' + | b'~' + | b'!' + | b'$' + | b'&' + | b'\'' + | b'(' + | b')' + | b'*' + | b'+' + | b',' + | b';' + | b'=' + | b':' + | b'@' + | b'/' + | b'?' + ) + { + index += 1; + } else if byte == b'%' + && bytes + .get(index + 1..index + 3) + .is_some_and(|encoded| encoded.iter().all(u8::is_ascii_hexdigit)) + { + index += 3; + } else { + return false; + } + } + !fragment.is_empty() +} + +fn validate_service_audience(aud: &str) -> Result<(), ApiError> { + let did = match aud.split_once('#') { + Some((did, fragment)) => { + if fragment.contains('#') || !is_valid_did_fragment(fragment) { + return Err(ApiError::InvalidRequest( + "aud must be a valid atproto DID or did#serviceId reference".into(), + )); + } + did + } + None => aud, + }; + let did = Did::new(did).map_err(|_| { + ApiError::InvalidRequest( + "aud must be a valid atproto DID or did#serviceId reference".into(), + ) + })?; + if !did.is_plc() && !did.is_web() { + return Err(ApiError::InvalidRequest( + "aud must be a valid atproto DID or did#serviceId reference".into(), + )); + } + Ok(()) +} + pub async fn get_service_auth( State(state): State, auth: Auth, Query(params): Query, ) -> Response { + if let Err(error) = validate_service_audience(¶ms.aud) { + return error.into_response(); + } info!( did = %&auth.did, is_oauth = auth.is_oauth(), @@ -117,7 +187,7 @@ pub async fn get_service_auth( if let Err(e) = tranquil_pds::auth::scope_check::check_rpc_scope( &auth.auth_source, auth.scope.as_deref(), - params.aud.as_str(), + ¶ms.aud, method, ) { return e.into_response(); diff --git a/crates/tranquil-auth/src/token.rs b/crates/tranquil-auth/src/token.rs index 9fee5fb..61d03c4 100644 --- a/crates/tranquil-auth/src/token.rs +++ b/crates/tranquil-auth/src/token.rs @@ -127,7 +127,7 @@ pub fn create_refresh_token_with_jti( pub fn create_service_token( did: &Did, - aud: &Did, + aud: &str, lxm: Option<&Nsid>, key_bytes: &[u8], ) -> Result { @@ -141,7 +141,7 @@ pub fn create_service_token( let claims = Claims { iss: did.clone(), sub: did.clone(), - aud: aud.to_string(), + aud: aud.to_owned(), exp: expiration, iat: Utc::now().timestamp(), scope: None, diff --git a/crates/tranquil-pds/tests/server.rs b/crates/tranquil-pds/tests/server.rs index 6cd861a..e435c01 100644 --- a/crates/tranquil-pds/tests/server.rs +++ b/crates/tranquil-pds/tests/server.rs @@ -115,6 +115,41 @@ async fn test_service_auth() { assert_eq!(claims["iss"], did); assert_eq!(claims["sub"], did); assert_eq!(claims["aud"], "did:web:example.com"); + let service_ref_res = client + .get(format!("{}/xrpc/com.atproto.server.getServiceAuth", base)) + .bearer_auth(&access_jwt) + .query(&[ + ("aud", "did:web:api.colibri.social#colibri_appview"), + ("lxm", "com.atproto.server.describeServer"), + ]) + .send() + .await + .unwrap(); + assert_eq!(service_ref_res.status(), StatusCode::OK); + let service_ref_body: Value = service_ref_res.json().await.unwrap(); + let service_ref_token = service_ref_body["token"].as_str().unwrap(); + let service_ref_parts: Vec<&str> = service_ref_token.split('.').collect(); + let service_ref_payload = URL_SAFE_NO_PAD.decode(service_ref_parts[1]).unwrap(); + let service_ref_claims: Value = serde_json::from_slice(&service_ref_payload).unwrap(); + assert_eq!( + service_ref_claims["aud"], + "did:web:api.colibri.social#colibri_appview" + ); + for invalid_aud in [ + "not-a-did", + "did:foo:bar", + "did:web:example.com#", + "did:web:example.com#bad%ZZ", + ] { + let invalid_res = client + .get(format!("{}/xrpc/com.atproto.server.getServiceAuth", base)) + .bearer_auth(&access_jwt) + .query(&[("aud", invalid_aud)]) + .send() + .await + .unwrap(); + assert_eq!(invalid_res.status(), StatusCode::BAD_REQUEST); + } let lxm_res = client .get(format!("{}/xrpc/com.atproto.server.getServiceAuth", base)) .bearer_auth(&access_jwt) -- 2.51.2