From 9c6730579e77c386ae1b79ed5859fa346fdc03d0 Mon Sep 17 00:00:00 2001 From: Trezy Date: Mon, 20 Jul 2026 20:56:36 -0500 Subject: [PATCH] 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,28 +257,47 @@ 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() + .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 expanded = set_entry["expanded"] + .as_array() + .expect("permission_sets entry should have an expanded array"); + let has_granular = expanded .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 + "permission_sets entry's expanded[] should list the granular scope \ + 'repo:io.atcr.manifest?action=create'. Got: {:?}", + expanded ); - 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 scopes = consent_body["scopes"] + .as_array() + .expect("consent response should have a scopes array"); let has_raw_include = scopes.iter().any(|s| { s["scope"] @@ -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}