From 311530a9a97ddba284793cde2915389ca0f37dfa Mon Sep 17 00:00:00 2001 From: Trezy Date: Tue, 15 Sep 2026 14:37:02 -0500 Subject: [PATCH] test: give the mock client a `scope` so `NotRegistered` actually gets exercised Signed-off-by: Trezy --- crates/tranquil-pds/tests/oauth_scopes.rs | 171 +++++++++++++++++++++- 1 file changed, 170 insertions(+), 1 deletion(-) diff --git a/crates/tranquil-pds/tests/oauth_scopes.rs b/crates/tranquil-pds/tests/oauth_scopes.rs index 06fbf66..e9d2d9a 100644 --- a/crates/tranquil-pds/tests/oauth_scopes.rs +++ b/crates/tranquil-pds/tests/oauth_scopes.rs @@ -22,9 +22,16 @@ fn generate_pkce() -> (String, String) { } async fn setup_mock_client_metadata(redirect_uri: &str) -> MockServer { + setup_mock_client_metadata_with_scope(redirect_uri, None).await +} + +async fn setup_mock_client_metadata_with_scope( + redirect_uri: &str, + scope: Option<&str>, +) -> MockServer { let mock_server = MockServer::start().await; let client_id = mock_server.uri(); - let metadata = json!({ + let mut metadata = json!({ "client_id": client_id, "client_name": "Test OAuth Scope Client", "redirect_uris": [redirect_uri], @@ -33,6 +40,9 @@ async fn setup_mock_client_metadata(redirect_uri: &str) -> MockServer { "token_endpoint_auth_method": "none", "dpop_bound_access_tokens": false }); + if let Some(scope) = scope { + metadata["scope"] = json!(scope); + } Mock::given(method("GET")) .and(path("/")) .respond_with(ResponseTemplate::new(200).set_body_json(metadata)) @@ -835,3 +845,162 @@ async fn test_unrecognized_scope_reaches_consent_and_is_never_granted() { granted ); } + +struct PendingAuthorization { + client_id: String, + request_uri: String, + code_verifier: String, + // Where authorize sent us: the consent screen, or straight to the client with a code. + location: String, + _mock: MockServer, +} + +const REDIRECT_URI: &str = "https://example.com/callback"; + +async fn par_and_login( + handle_prefix: &str, + requested_scope: &str, + client_scope: Option<&str>, + before_login: impl AsyncFnOnce(&str, &str), +) -> PendingAuthorization { + let url = base_url().await; + let http_client = client(); + let suffix = &uuid::Uuid::new_v4().simple().to_string()[..4]; + let handle = format!("{}{}", handle_prefix, suffix); + let password = format!("{}Pass123!", handle_prefix); + let create_res = http_client + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) + .json(&json!({ + "handle": handle, + "email": format!("{}{}@example.com", handle_prefix, suffix), + "password": password + })) + .send() + .await + .expect("Account creation failed"); + assert_eq!(create_res.status(), StatusCode::OK); + let account: Value = create_res.json().await.unwrap(); + let did = account["did"].as_str().unwrap().to_string(); + let _ = verify_new_account(&http_client, &did).await; + + let mock = setup_mock_client_metadata_with_scope(REDIRECT_URI, client_scope).await; + let client_id = mock.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", requested_scope), + ]) + .send() + .await + .expect("PAR failed"); + assert_eq!(par_res.status(), StatusCode::CREATED, "PAR should succeed"); + let par_body: Value = par_res.json().await.unwrap(); + let request_uri = par_body["request_uri"].as_str().unwrap().to_string(); + + before_login(&did, &client_id).await; + + 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 location = auth_body["redirect_uri"].as_str().unwrap().to_string(); + + PendingAuthorization { + client_id, + request_uri, + code_verifier, + location, + _mock: mock, + } +} + +async fn exchange_code(pending: &PendingAuthorization, location: &str) -> Value { + let code = location + .split("code=") + .nth(1) + .expect("redirect should carry a code") + .split('&') + .next() + .unwrap(); + let token_res = client() + .post(format!("{}/oauth/token", base_url().await)) + .form(&[ + ("grant_type", "authorization_code"), + ("code", code), + ("redirect_uri", REDIRECT_URI), + ("code_verifier", &pending.code_verifier), + ("client_id", &pending.client_id), + ]) + .send() + .await + .expect("Token request failed"); + assert_eq!(token_res.status(), StatusCode::OK); + token_res.json().await.unwrap() +} + +fn has_scope(scope_str: &str, scope: &str) -> bool { + scope_str.split_whitespace().any(|s| s == scope) +} + +#[tokio::test] +async fn test_scope_missing_from_client_metadata_is_not_registered_on_consent() { + let pending = par_and_login( + "unreg", + "atproto identity:*", + Some("atproto"), + async |_, _| {}, + ) + .await; + assert!(pending.location.contains("/oauth/consent")); + + let url = base_url().await; + let consent_get: Value = client() + .get(format!( + "{}/oauth/authorize/consent?request_uri={}", + url, pending.request_uri + )) + .send() + .await + .expect("Consent GET failed") + .json() + .await + .unwrap(); + let rejected = consent_get["rejected_scopes"].as_array().unwrap(); + assert_eq!(rejected.len(), 1, "got {:?}", rejected); + assert_eq!(rejected[0]["scope"].as_str(), Some("identity:*")); + assert_eq!(rejected[0]["reason"].as_str(), Some("not_registered")); + + let consent_res = client() + .post(format!("{}/oauth/authorize/consent", url)) + .header("Content-Type", "application/json") + .json(&json!({ + "request_uri": pending.request_uri, + "approved_scopes": ["atproto", "identity:*"], + "remember": false + })) + .send() + .await + .expect("Consent POST failed"); + assert_eq!(consent_res.status(), StatusCode::OK); + let consent_body: Value = consent_res.json().await.unwrap(); + let token = exchange_code(&pending, consent_body["redirect_uri"].as_str().unwrap()).await; + let granted = token["scope"].as_str().unwrap(); + assert!(!has_scope(granted, "identity:*"), "granted {:?}", granted); +}