feat: display bundled permission-sets on consent screen

Signed-off-by: Trezy <tre@trezy.com>
This commit is contained in:
Trezy
2026-07-24 20:11:27 +03:00
committed by Tangled
parent 348ac887fc
commit 9c6730579e
6 changed files with 918 additions and 63 deletions
@@ -10,6 +10,28 @@ pub struct ScopeInfo {
pub granted: Option<bool>,
}
#[derive(Debug, Serialize)]
pub struct PermissionSetInfo {
pub nsid: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub aud: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
pub include_scope: String,
pub expanded: Vec<ScopeInfo>,
pub granted: Option<bool>,
}
#[derive(Debug, Serialize)]
pub struct FailedSetInfo {
pub nsid: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub aud: Option<String>,
pub reason: String,
}
#[derive(Debug, Serialize)]
pub struct ConsentResponse {
pub request_uri: String,
@@ -18,6 +40,8 @@ pub struct ConsentResponse {
pub client_uri: Option<String>,
pub logo_uri: Option<String>,
pub scopes: Vec<ScopeInfo>,
pub permission_sets: Vec<PermissionSetInfo>,
pub failed_sets: Vec<FailedSetInfo>,
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<String> = 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<String> =
requested_scopes.iter().map(|s| s.to_string()).collect();
let presented_item_strings: Vec<String> = 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<ScopeInfo> = 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<ScopeInfo> = effective
.outcome
.passthrough
.iter()
.map(|s| make_scope_info(s))
.collect();
let permission_sets: Vec<PermissionSetInfo> = 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<FailedSetInfo> = 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<String> = effective
.outcome
.failures
.iter()
.map(|f| f.nsid.clone())
.collect();
let include_token = |nsid: &str, aud: &Option<String>| -> String {
match aud {
Some(a) => format!("include:{}?aud={}", nsid, a),
None => format!("include:{}", nsid),
}
};
let approved_failed_sets: Vec<String> = 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<String> = 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<String> = form.approved_scopes.clone();
let mut final_approved: Vec<String> = 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<ScopePreference> = requested_scopes
let preferences: Vec<ScopePreference> = 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
+4
View File
@@ -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<PostgresRepositories>,
@@ -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
);
}
+16
View File
@@ -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",
+134 -2
View File
@@ -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<string, RepoRow>()
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 }
}
</script>
<div class="consent-container">
@@ -385,6 +444,79 @@
</div>
{/each}
{/if}
{#if consentData.permission_sets?.length}
<div class="scope-group">
<h3 class="category-title">{$_('oauth.consent.permissionSets')}</h3>
{#each consentData.permission_sets as set}
{@const desc = describeExpanded(set.expanded)}
<label class="scope-item">
<input
type="checkbox"
checked={scopeSelections[set.include_scope]}
disabled={submitting}
onchange={() => handleScopeToggle(set.include_scope)}
/>
<div class="scope-info">
<span class="scope-name">{set.title ?? set.nsid}</span>
{#if set.detail}<span class="scope-description">{set.detail}</span>{/if}
<details class="scope-set-details">
<summary>{$_('oauth.consent.showIncludedScopes', { values: { count: set.expanded.length } })}</summary>
{#if desc.repo.length}
<table class="perm-table">
<thead>
<tr>
<th scope="col">{$_('oauth.consent.permTable.data')}</th>
<th scope="col" class="perm-col">{$_('oauth.consent.permTable.create')}</th>
<th scope="col" class="perm-col">{$_('oauth.consent.permTable.update')}</th>
<th scope="col" class="perm-col">{$_('oauth.consent.permTable.delete')}</th>
</tr>
</thead>
<tbody>
{#each desc.repo as r}
<tr>
<td class="perm-nsid">{r.collection === '*' ? $_('oauth.consent.permTable.allData') : r.collection}</td>
<td class="perm-col" class:on={r.create} aria-label={r.create ? $_('oauth.consent.permTable.create') : undefined}>{r.create ? '✓' : ''}</td>
<td class="perm-col" class:on={r.update} aria-label={r.update ? $_('oauth.consent.permTable.update') : undefined}>{r.update ? '✓' : ''}</td>
<td class="perm-col" class:on={r.delete} aria-label={r.delete ? $_('oauth.consent.permTable.delete') : undefined}>{r.delete ? '✓' : ''}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
{#if desc.rpc.length}
<div class="perm-rpc">
<span class="perm-subhead">{$_('oauth.consent.permTable.apiAccess')}</span>
<ul class="scope-set-list">
{#each desc.rpc as lxm}<li><span class="scope-raw">{lxm}</span></li>{/each}
</ul>
</div>
{/if}
{#if desc.other.length}
<ul class="scope-set-list">
{#each desc.other as s}<li>{getLocalizedScopeName(s)} <span class="scope-raw">{s.scope}</span></li>{/each}
</ul>
{/if}
</details>
</div>
</label>
{/each}
</div>
{/if}
{#if consentData.failed_sets?.length}
<div class="scope-group failed-sets">
<h3 class="category-title">{$_('oauth.consent.unavailableSets')}</h3>
{#each consentData.failed_sets as f}
<div class="scope-item failed">
<div class="scope-info">
<span class="scope-name">{f.nsid}{#if f.aud} ({f.aud}){/if}</span>
<span class="scope-description">{$_(`oauth.consent.setFailureReason.${f.reason}`)}</span>
</div>
</div>
{/each}
</div>
{/if}
</div>
<label class="remember-choice">
+74
View File
@@ -984,6 +984,80 @@ button.forget-btn:hover {
word-break: break-all;
}
.scope-set-details {
margin-top: 0.375rem;
}
.scope-set-details summary {
cursor: pointer;
font-size: 0.85em;
opacity: 0.8;
}
.scope-set-list {
margin: 0.375rem 0 0;
padding-left: 1rem;
font-size: 0.85em;
}
.scope-set-list .scope-raw {
opacity: 0.6;
font-family: monospace;
}
.perm-table {
width: 100%;
border-collapse: collapse;
margin-top: 0.5rem;
font-size: 0.82em;
}
.perm-table th,
.perm-table td {
padding: 0.28rem 0.5rem;
text-align: left;
border-bottom: 1px solid var(--border-light);
}
.perm-table thead th {
font-weight: 600;
font-size: 0.85em;
color: var(--text-muted);
border-bottom: 1px solid var(--border-color);
}
.perm-table tbody tr:last-child td {
border-bottom: none;
}
.perm-table .perm-col {
width: 4.25rem;
text-align: center;
}
.perm-table .perm-nsid {
font-family: monospace;
color: var(--text-primary);
word-break: break-all;
}
.perm-table td.perm-col {
color: var(--text-muted);
}
.perm-table td.perm-col.on {
color: var(--text-primary);
font-weight: 700;
}
.perm-rpc {
margin-top: 0.5rem;
}
.perm-subhead {
display: block;
font-size: 0.72em;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 0.1rem;
}
.scope-group.failed-sets .scope-item.failed {
opacity: 0.7;
}
.required-badge {
display: inline-block;
font-size: 0.625rem;