Our Personal Data Server from scratch!
0

Configure Feed

Select the types of activity you want to include in your feed.

feat: display bundled permission-sets on consent screen

Signed-off-by: Trezy <tre@trezy.com>

author
Trezy
committer
Tangled
date (Jul 24, 2026, 8:11 PM +0300) commit 9c673057 parent 348ac887
+918 -63
+131 -46
crates/tranquil-oauth-server/src/endpoints/authorize/consent.rs
··· 11 11 } 12 12 13 13 #[derive(Debug, Serialize)] 14 + pub struct PermissionSetInfo { 15 + pub nsid: String, 16 + #[serde(skip_serializing_if = "Option::is_none")] 17 + pub aud: Option<String>, 18 + #[serde(skip_serializing_if = "Option::is_none")] 19 + pub title: Option<String>, 20 + #[serde(skip_serializing_if = "Option::is_none")] 21 + pub detail: Option<String>, 22 + pub include_scope: String, 23 + pub expanded: Vec<ScopeInfo>, 24 + pub granted: Option<bool>, 25 + } 26 + 27 + #[derive(Debug, Serialize)] 28 + pub struct FailedSetInfo { 29 + pub nsid: String, 30 + #[serde(skip_serializing_if = "Option::is_none")] 31 + pub aud: Option<String>, 32 + pub reason: String, 33 + } 34 + 35 + #[derive(Debug, Serialize)] 14 36 pub struct ConsentResponse { 15 37 pub request_uri: String, 16 38 pub client_id: ClientId, ··· 18 40 pub client_uri: Option<String>, 19 41 pub logo_uri: Option<String>, 20 42 pub scopes: Vec<ScopeInfo>, 43 + pub permission_sets: Vec<PermissionSetInfo>, 44 + pub failed_sets: Vec<FailedSetInfo>, 21 45 pub show_consent: bool, 22 46 pub did: Did, 23 47 #[serde(skip_serializing_if = "Option::is_none")] ··· 126 150 authority, 127 151 ) 128 152 .await; 129 - if !effective.outcome.failures.is_empty() { 130 - let names: Vec<String> = effective 131 - .outcome 132 - .failures 133 - .iter() 134 - .map(|f| f.nsid.clone()) 135 - .collect(); 136 - return json_error( 137 - StatusCode::BAD_REQUEST, 138 - "invalid_scope", 139 - &format!("Could not resolve permission set(s): {}", names.join(", ")), 140 - ); 141 - } 142 153 let requested_scopes: Vec<&str> = effective.resolved.split_whitespace().collect(); 143 154 let preferences = state 144 155 .repos ··· 150 161 .iter() 151 162 .map(|p| (p.scope.as_str(), p.granted)) 152 163 .collect(); 153 - let requested_scope_strings: Vec<String> = 154 - requested_scopes.iter().map(|s| s.to_string()).collect(); 164 + let presented_item_strings: Vec<String> = effective 165 + .outcome 166 + .passthrough 167 + .iter() 168 + .cloned() 169 + .chain(effective.outcome.sets.iter().map(|g| match &g.aud { 170 + Some(a) => format!("include:{}?aud={}", g.nsid, a), 171 + None => format!("include:{}", g.nsid), 172 + })) 173 + .collect(); 155 174 let show_consent = should_show_consent( 156 175 state.repos.oauth.as_ref(), 157 176 &did, 158 177 &request_data.parameters.client_id, 159 - &requested_scope_strings, 178 + &presented_item_strings, 160 179 ) 161 180 .await 162 181 .unwrap_or(true); 163 182 let has_granular_scopes = requested_scopes.iter().any(|s| is_granular_scope(s)); 164 - let scopes: Vec<ScopeInfo> = requested_scopes 165 - .iter() 166 - .map(|scope| { 167 - let (category, required, description, display_name) = if let Some(def) = 168 - tranquil_pds::oauth::scopes::SCOPE_DEFINITIONS.get(*scope) 169 - { 170 - let desc = if *scope == "atproto" && has_granular_scopes { 183 + 184 + let make_scope_info = |scope: &str| -> ScopeInfo { 185 + let (category, required, description, display_name) = 186 + if let Some(def) = tranquil_pds::oauth::scopes::SCOPE_DEFINITIONS.get(scope) { 187 + let desc = if scope == "atproto" && has_granular_scopes { 171 188 "AT Protocol baseline scope (permissions determined by selected options below)" 172 189 .to_string() 173 190 } else { 174 191 def.description.to_string() 175 192 }; 176 - let name = if *scope == "atproto" && has_granular_scopes { 193 + let name = if scope == "atproto" && has_granular_scopes { 177 194 "AT Protocol Access".to_string() 178 195 } else { 179 196 def.display_name.to_string() ··· 199 216 scope.to_string(), 200 217 ) 201 218 }; 202 - let granted = pref_map.get(*scope).copied(); 203 - ScopeInfo { 204 - scope: scope.to_string(), 205 - category, 206 - required, 207 - description, 208 - display_name, 209 - granted, 219 + let granted = pref_map.get(scope).copied(); 220 + ScopeInfo { 221 + scope: scope.to_string(), 222 + category, 223 + required, 224 + description, 225 + display_name, 226 + granted, 227 + } 228 + }; 229 + 230 + let scopes: Vec<ScopeInfo> = effective 231 + .outcome 232 + .passthrough 233 + .iter() 234 + .map(|s| make_scope_info(s)) 235 + .collect(); 236 + 237 + let permission_sets: Vec<PermissionSetInfo> = effective 238 + .outcome 239 + .sets 240 + .iter() 241 + .map(|g| { 242 + let include_scope = match &g.aud { 243 + Some(a) => format!("include:{}?aud={}", g.nsid, a), 244 + None => format!("include:{}", g.nsid), 245 + }; 246 + PermissionSetInfo { 247 + nsid: g.nsid.clone(), 248 + aud: g.aud.clone(), 249 + title: g.title.clone(), 250 + detail: g.detail.clone(), 251 + granted: pref_map.get(include_scope.as_str()).copied(), 252 + include_scope, 253 + expanded: g.expanded.iter().map(|s| make_scope_info(s)).collect(), 254 + } 255 + }) 256 + .collect(); 257 + 258 + let failed_sets: Vec<FailedSetInfo> = effective 259 + .outcome 260 + .failures 261 + .iter() 262 + .map(|f| FailedSetInfo { 263 + nsid: f.nsid.clone(), 264 + aud: f.aud.clone(), 265 + reason: match f.reason { 266 + tranquil_scopes::ResolveFailure::NotFound => "not_found", 267 + tranquil_scopes::ResolveFailure::NetworkError => "unreachable", 268 + tranquil_scopes::ResolveFailure::Invalid => "invalid", 210 269 } 270 + .to_string(), 211 271 }) 212 272 .collect(); 213 273 ··· 259 319 client_uri: client_metadata.as_ref().and_then(|m| m.client_uri.clone()), 260 320 logo_uri: client_metadata.as_ref().and_then(|m| m.logo_uri.clone()), 261 321 scopes, 322 + permission_sets, 323 + failed_sets, 262 324 show_consent, 263 325 did: did.clone(), 264 326 handle: account_handle, ··· 356 418 authority, 357 419 ) 358 420 .await; 359 - if !effective.outcome.failures.is_empty() { 360 - let names: Vec<String> = effective 361 - .outcome 362 - .failures 363 - .iter() 364 - .map(|f| f.nsid.clone()) 365 - .collect(); 421 + let include_token = |nsid: &str, aud: &Option<String>| -> String { 422 + match aud { 423 + Some(a) => format!("include:{}?aud={}", nsid, a), 424 + None => format!("include:{}", nsid), 425 + } 426 + }; 427 + let approved_failed_sets: Vec<String> = effective 428 + .outcome 429 + .failures 430 + .iter() 431 + .filter(|f| form.approved_scopes.contains(&include_token(&f.nsid, &f.aud))) 432 + .map(|f| f.nsid.clone()) 433 + .collect(); 434 + if !approved_failed_sets.is_empty() { 366 435 return json_error( 367 436 StatusCode::BAD_REQUEST, 368 437 "invalid_scope", 369 - &format!("Could not resolve permission set(s): {}", names.join(", ")), 438 + &format!( 439 + "Could not resolve approved permission set(s): {}", 440 + approved_failed_sets.join(", ") 441 + ), 370 442 ); 371 443 } 372 - let requested_scopes: Vec<&str> = effective.resolved.split_whitespace().collect(); 373 - let atproto_was_requested = requested_scopes.contains(&"atproto"); 444 + let presented_items: Vec<String> = effective 445 + .outcome 446 + .passthrough 447 + .iter() 448 + .cloned() 449 + .chain( 450 + effective 451 + .outcome 452 + .sets 453 + .iter() 454 + .map(|g| include_token(&g.nsid, &g.aud)), 455 + ) 456 + .collect(); 457 + let atproto_was_requested = presented_items.iter().any(|s| s == "atproto"); 374 458 if atproto_was_requested && !form.approved_scopes.contains(&"atproto".to_string()) { 375 459 return json_error( 376 460 StatusCode::BAD_REQUEST, ··· 378 462 "The atproto scope was requested and must be approved", 379 463 ); 380 464 } 381 - let final_approved: Vec<String> = form.approved_scopes.clone(); 465 + let mut final_approved: Vec<String> = form.approved_scopes.clone(); 466 + final_approved.retain(|s| presented_items.iter().any(|p| p == s) || s == "atproto"); 382 467 if final_approved.is_empty() { 383 468 return json_error( 384 469 StatusCode::BAD_REQUEST, ··· 396 481 ); 397 482 } 398 483 if form.remember { 399 - let preferences: Vec<ScopePreference> = requested_scopes 484 + let preferences: Vec<ScopePreference> = presented_items 400 485 .iter() 401 486 .map(|s| ScopePreference { 402 - scope: s.to_string(), 403 - granted: form.approved_scopes.contains(&s.to_string()), 487 + scope: s.clone(), 488 + granted: form.approved_scopes.contains(s), 404 489 }) 405 490 .collect(); 406 491 let _ = state
+4
crates/tranquil-pds/src/state.rs
··· 30 30 } 31 31 } 32 32 33 + pub fn set_rate_limiting_disabled(disabled: bool) { 34 + RATE_LIMITING_DISABLED.store(disabled, Ordering::Relaxed); 35 + } 36 + 33 37 #[derive(Clone)] 34 38 pub struct AppState { 35 39 pub repos: Arc<PostgresRepositories>,
+559 -15
crates/tranquil-pds/tests/oauth_permission_sets.rs
··· 16 16 const PERMISSION_SET_GRANULAR_SCOPE: &str = 17 17 "repo:io.atcr.manifest?action=create rpc:io.atcr.getManifest?aud=*"; 18 18 19 + fn disable_rate_limiting_once() { 20 + static ONCE: std::sync::Once = std::sync::Once::new(); 21 + ONCE.call_once(|| tranquil_pds::state::set_rate_limiting_disabled(true)); 22 + } 23 + 19 24 fn generate_pkce() -> (String, String) { 20 25 let verifier_bytes: [u8; 32] = rand::random(); 21 26 let code_verifier = URL_SAFE_NO_PAD.encode(verifier_bytes); ··· 94 99 scope: &str, 95 100 ) -> (DelegatedSession, Value, MockServer) { 96 101 let url = base_url().await; 102 + disable_rate_limiting_once(); 97 103 let http_client = client(); 98 104 99 105 let (controller_jwt, controller_did) = create_account_and_login(&http_client).await; ··· 251 257 ) 252 258 .await; 253 259 254 - let scopes = consent_body["scopes"] 260 + let permission_sets = consent_body["permission_sets"] 255 261 .as_array() 256 - .expect("consent response should have a scopes array"); 257 - assert!(!scopes.is_empty(), "consent scopes should not be empty"); 262 + .expect("consent response should have a permission_sets array"); 263 + assert!( 264 + !permission_sets.is_empty(), 265 + "consent permission_sets should not be empty. Got: {:?}", 266 + consent_body 267 + ); 258 268 259 - let has_granular = scopes 269 + let set_entry = permission_sets 260 270 .iter() 261 - .any(|s| s["scope"].as_str() == Some("repo:io.atcr.manifest?action=create")); 262 - assert!( 263 - has_granular, 264 - "consent scopes[] should list the expanded granular scope \ 265 - 'repo:io.atcr.manifest?action=create', not just 'atproto'. Got: {:?}", 266 - scopes 271 + .find(|s| s["nsid"].as_str() == Some(PERMISSION_SET_NSID)) 272 + .unwrap_or_else(|| { 273 + panic!( 274 + "permission_sets should contain an entry for nsid '{}'. Got: {:?}", 275 + PERMISSION_SET_NSID, permission_sets 276 + ) 277 + }); 278 + 279 + assert_eq!( 280 + set_entry["include_scope"].as_str(), 281 + Some(format!("include:{}", PERMISSION_SET_NSID).as_str()), 282 + "permission_sets entry should carry the include: token the frontend submits" 267 283 ); 268 284 269 - let only_atproto = scopes 285 + let expanded = set_entry["expanded"] 286 + .as_array() 287 + .expect("permission_sets entry should have an expanded array"); 288 + let has_granular = expanded 270 289 .iter() 271 - .all(|s| s["scope"].as_str() == Some("atproto")); 290 + .any(|s| s["scope"].as_str() == Some("repo:io.atcr.manifest?action=create")); 272 291 assert!( 273 - !only_atproto, 274 - "consent scopes[] should not collapse to just 'atproto'" 292 + has_granular, 293 + "permission_sets entry's expanded[] should list the granular scope \ 294 + 'repo:io.atcr.manifest?action=create'. Got: {:?}", 295 + expanded 275 296 ); 297 + 298 + let scopes = consent_body["scopes"] 299 + .as_array() 300 + .expect("consent response should have a scopes array"); 276 301 277 302 let has_raw_include = scopes.iter().any(|s| { 278 303 s["scope"] ··· 282 307 }); 283 308 assert!( 284 309 !has_raw_include, 285 - "consent scopes[] should list the expanded set, not the raw include: token" 310 + "consent scopes[] should not carry the raw include: token" 286 311 ); 287 312 } 288 313 ··· 422 447 seed_permission_set(UNRESOLVABLE_NSID, PERMISSION_SET_GRANULAR_SCOPE).await; 423 448 424 449 let url = base_url().await; 450 + disable_rate_limiting_once(); 425 451 let http_client = client(); 426 452 427 453 let (controller_jwt, controller_did) = create_account_and_login(&http_client).await; ··· 541 567 } 542 568 543 569 #[tokio::test] 570 + async fn test_consent_post_succeeds_when_unapproved_set_fails() { 571 + const GOOD_SET_NSID: &str = "io.atcr.goodSet"; 572 + const BAD_SET_NSID: &str = "io.atcr.badSet"; 573 + seed_permission_set(GOOD_SET_NSID, PERMISSION_SET_GRANULAR_SCOPE).await; 574 + 575 + let url = base_url().await; 576 + disable_rate_limiting_once(); 577 + let http_client = client(); 578 + 579 + let (controller_jwt, controller_did) = create_account_and_login(&http_client).await; 580 + 581 + let suffix = &uuid::Uuid::new_v4().simple().to_string()[..4]; 582 + let delegated_handle = format!("psg{}", suffix); 583 + let delegated_res = http_client 584 + .post(format!("{}/xrpc/_delegation.createDelegatedAccount", url)) 585 + .bearer_auth(&controller_jwt) 586 + .json(&json!({ 587 + "handle": delegated_handle, 588 + "controllerScopes": tranquil_pds::delegation::OWNER_FULL_SCOPES 589 + })) 590 + .send() 591 + .await 592 + .expect("createDelegatedAccount request failed"); 593 + if delegated_res.status() != StatusCode::OK { 594 + let error_body = delegated_res.text().await.unwrap(); 595 + panic!("Failed to create delegated account: {}", error_body); 596 + } 597 + let delegated_account: Value = delegated_res.json().await.unwrap(); 598 + let delegated_did = delegated_account["did"].as_str().unwrap().to_string(); 599 + 600 + let redirect_uri = "https://example.com/permset-partial-fail-callback"; 601 + let mock_client = setup_mock_client_metadata(redirect_uri).await; 602 + let client_id = mock_client.uri(); 603 + let (_code_verifier, code_challenge) = generate_pkce(); 604 + 605 + let scope = format!( 606 + "atproto include:{} include:{}", 607 + GOOD_SET_NSID, BAD_SET_NSID 608 + ); 609 + let par_res = http_client 610 + .post(format!("{}/oauth/par", url)) 611 + .form(&[ 612 + ("response_type", "code"), 613 + ("client_id", &client_id), 614 + ("redirect_uri", redirect_uri), 615 + ("code_challenge", &code_challenge), 616 + ("code_challenge_method", "S256"), 617 + ("scope", scope.as_str()), 618 + ("login_hint", delegated_did.as_str()), 619 + ]) 620 + .send() 621 + .await 622 + .expect("PAR failed"); 623 + assert!( 624 + par_res.status() == StatusCode::OK || par_res.status() == StatusCode::CREATED, 625 + "PAR should succeed, got {}", 626 + par_res.status() 627 + ); 628 + let par_body: Value = par_res.json().await.unwrap(); 629 + let request_uri = par_body["request_uri"].as_str().unwrap().to_string(); 630 + 631 + let auth_res = http_client 632 + .post(format!("{}/oauth/delegation/auth", url)) 633 + .header("Content-Type", "application/json") 634 + .json(&json!({ 635 + "request_uri": request_uri, 636 + "delegated_did": delegated_did, 637 + "controller_did": controller_did, 638 + "password": "Testpass123!", 639 + "remember_device": false 640 + })) 641 + .send() 642 + .await 643 + .expect("Delegation auth request failed"); 644 + if auth_res.status() != StatusCode::OK { 645 + let error_body = auth_res.text().await.unwrap(); 646 + panic!("Delegation auth failed: {}", error_body); 647 + } 648 + let auth_body: Value = auth_res.json().await.unwrap(); 649 + assert!( 650 + auth_body["success"].as_bool().unwrap_or(false), 651 + "Delegation auth should succeed: {:?}", 652 + auth_body 653 + ); 654 + 655 + let consent_get_res = http_client 656 + .get(format!("{}/oauth/authorize/consent", url)) 657 + .query(&[("request_uri", request_uri.as_str())]) 658 + .send() 659 + .await 660 + .expect("Consent GET failed"); 661 + assert_eq!( 662 + consent_get_res.status(), 663 + StatusCode::OK, 664 + "Consent GET should succeed" 665 + ); 666 + let consent_get_body: Value = consent_get_res.json().await.unwrap(); 667 + 668 + let permission_sets = consent_get_body["permission_sets"] 669 + .as_array() 670 + .expect("consent response should have a permission_sets array"); 671 + assert!( 672 + permission_sets 673 + .iter() 674 + .any(|s| s["nsid"].as_str() == Some(GOOD_SET_NSID)), 675 + "the resolvable set should be presented as a permission set. Got: {:?}", 676 + consent_get_body 677 + ); 678 + let failed_sets = consent_get_body["failed_sets"] 679 + .as_array() 680 + .expect("consent response should have a failed_sets array"); 681 + assert!( 682 + failed_sets 683 + .iter() 684 + .any(|s| s["nsid"].as_str() == Some(BAD_SET_NSID)), 685 + "the unresolvable set should be presented as a failed set. Got: {:?}", 686 + consent_get_body 687 + ); 688 + 689 + let approved_scopes = vec!["atproto".to_string(), format!("include:{}", GOOD_SET_NSID)]; 690 + let consent_post_res = http_client 691 + .post(format!("{}/oauth/authorize/consent", url)) 692 + .header("Content-Type", "application/json") 693 + .json(&json!({ 694 + "request_uri": request_uri, 695 + "approved_scopes": approved_scopes, 696 + "remember": false 697 + })) 698 + .send() 699 + .await 700 + .expect("Consent POST failed"); 701 + let status = consent_post_res.status(); 702 + let consent_post_body: Value = consent_post_res.json().await.unwrap(); 703 + assert_eq!( 704 + status, 705 + StatusCode::OK, 706 + "Consent POST must succeed when the user approves only the resolvable set and \ 707 + leaves the unresolvable set unapproved. Got: {:?}", 708 + consent_post_body 709 + ); 710 + assert!( 711 + consent_post_body["redirect_uri"].as_str().is_some(), 712 + "Consent POST should return a redirect_uri. Got: {:?}", 713 + consent_post_body 714 + ); 715 + } 716 + 717 + #[tokio::test] 718 + async fn test_consent_remember_persists_set_preference() { 719 + const REMEMBER_SET_NSID: &str = "io.atcr.rememberSet"; 720 + seed_permission_set(REMEMBER_SET_NSID, PERMISSION_SET_GRANULAR_SCOPE).await; 721 + 722 + let url = base_url().await; 723 + disable_rate_limiting_once(); 724 + let http_client = client(); 725 + 726 + let (controller_jwt, controller_did) = create_account_and_login(&http_client).await; 727 + 728 + let suffix = &uuid::Uuid::new_v4().simple().to_string()[..4]; 729 + let delegated_handle = format!("psr{}", suffix); 730 + let delegated_res = http_client 731 + .post(format!("{}/xrpc/_delegation.createDelegatedAccount", url)) 732 + .bearer_auth(&controller_jwt) 733 + .json(&json!({ 734 + "handle": delegated_handle, 735 + "controllerScopes": tranquil_pds::delegation::OWNER_FULL_SCOPES 736 + })) 737 + .send() 738 + .await 739 + .expect("createDelegatedAccount request failed"); 740 + if delegated_res.status() != StatusCode::OK { 741 + let error_body = delegated_res.text().await.unwrap(); 742 + panic!("Failed to create delegated account: {}", error_body); 743 + } 744 + let delegated_account: Value = delegated_res.json().await.unwrap(); 745 + let delegated_did = delegated_account["did"].as_str().unwrap().to_string(); 746 + 747 + let redirect_uri = "https://example.com/permset-remember-callback"; 748 + let mock_client = setup_mock_client_metadata(redirect_uri).await; 749 + let client_id = mock_client.uri(); 750 + let (_code_verifier, code_challenge) = generate_pkce(); 751 + 752 + let scope = format!("atproto include:{}", REMEMBER_SET_NSID); 753 + let par_res = http_client 754 + .post(format!("{}/oauth/par", url)) 755 + .form(&[ 756 + ("response_type", "code"), 757 + ("client_id", &client_id), 758 + ("redirect_uri", redirect_uri), 759 + ("code_challenge", &code_challenge), 760 + ("code_challenge_method", "S256"), 761 + ("scope", scope.as_str()), 762 + ("login_hint", delegated_did.as_str()), 763 + ]) 764 + .send() 765 + .await 766 + .expect("PAR failed"); 767 + assert!( 768 + par_res.status() == StatusCode::OK || par_res.status() == StatusCode::CREATED, 769 + "PAR should succeed, got {}", 770 + par_res.status() 771 + ); 772 + let par_body: Value = par_res.json().await.unwrap(); 773 + let request_uri = par_body["request_uri"].as_str().unwrap().to_string(); 774 + 775 + let auth_res = http_client 776 + .post(format!("{}/oauth/delegation/auth", url)) 777 + .header("Content-Type", "application/json") 778 + .json(&json!({ 779 + "request_uri": request_uri, 780 + "delegated_did": delegated_did, 781 + "controller_did": controller_did, 782 + "password": "Testpass123!", 783 + "remember_device": false 784 + })) 785 + .send() 786 + .await 787 + .expect("Delegation auth request failed"); 788 + if auth_res.status() != StatusCode::OK { 789 + let error_body = auth_res.text().await.unwrap(); 790 + panic!("Delegation auth failed: {}", error_body); 791 + } 792 + let auth_body: Value = auth_res.json().await.unwrap(); 793 + assert!( 794 + auth_body["success"].as_bool().unwrap_or(false), 795 + "Delegation auth should succeed: {:?}", 796 + auth_body 797 + ); 798 + 799 + let consent_get_res = http_client 800 + .get(format!("{}/oauth/authorize/consent", url)) 801 + .query(&[("request_uri", request_uri.as_str())]) 802 + .send() 803 + .await 804 + .expect("Consent GET failed"); 805 + assert_eq!( 806 + consent_get_res.status(), 807 + StatusCode::OK, 808 + "Consent GET should succeed" 809 + ); 810 + 811 + let approved_scopes = vec![ 812 + "atproto".to_string(), 813 + format!("include:{}", REMEMBER_SET_NSID), 814 + ]; 815 + let consent_post_res = http_client 816 + .post(format!("{}/oauth/authorize/consent", url)) 817 + .header("Content-Type", "application/json") 818 + .json(&json!({ 819 + "request_uri": request_uri, 820 + "approved_scopes": approved_scopes, 821 + "remember": true 822 + })) 823 + .send() 824 + .await 825 + .expect("Consent POST failed"); 826 + if consent_post_res.status() != StatusCode::OK { 827 + let error_body = consent_post_res.text().await.unwrap(); 828 + panic!("Consent POST with remember:true failed: {}", error_body); 829 + } 830 + 831 + let did: tranquil_types::Did = delegated_did.parse().expect("valid did"); 832 + let client_id_typed = tranquil_types::ClientId::new(client_id.clone()); 833 + let stored_prefs = common::get_test_repos() 834 + .await 835 + .oauth 836 + .get_scope_preferences(&did, &client_id_typed) 837 + .await 838 + .expect("get_scope_preferences query failed"); 839 + let include_token = format!("include:{}", REMEMBER_SET_NSID); 840 + let set_pref = stored_prefs 841 + .iter() 842 + .find(|p| p.scope == include_token) 843 + .unwrap_or_else(|| { 844 + panic!( 845 + "expected a stored scope preference for '{}', got: {:?}", 846 + include_token, stored_prefs 847 + ) 848 + }); 849 + assert!( 850 + set_pref.granted, 851 + "the remembered set preference should be granted: true, got: {:?}", 852 + set_pref 853 + ); 854 + assert!( 855 + !stored_prefs 856 + .iter() 857 + .any(|p| p.scope.starts_with("repo:") || p.scope.starts_with("rpc:")), 858 + "remember must not store the expanded granular scopes as preferences, got: {:?}", 859 + stored_prefs 860 + ); 861 + 862 + let (code_verifier2, code_challenge2) = generate_pkce(); 863 + let par_res2 = http_client 864 + .post(format!("{}/oauth/par", url)) 865 + .form(&[ 866 + ("response_type", "code"), 867 + ("client_id", &client_id), 868 + ("redirect_uri", redirect_uri), 869 + ("code_challenge", &code_challenge2), 870 + ("code_challenge_method", "S256"), 871 + ("scope", scope.as_str()), 872 + ("login_hint", delegated_did.as_str()), 873 + ]) 874 + .send() 875 + .await 876 + .expect("second PAR failed"); 877 + let _ = code_verifier2; 878 + assert!(par_res2.status() == StatusCode::OK || par_res2.status() == StatusCode::CREATED); 879 + let par_body2: Value = par_res2.json().await.unwrap(); 880 + let request_uri2 = par_body2["request_uri"].as_str().unwrap().to_string(); 881 + 882 + let auth_res2 = http_client 883 + .post(format!("{}/oauth/delegation/auth", url)) 884 + .header("Content-Type", "application/json") 885 + .json(&json!({ 886 + "request_uri": request_uri2, 887 + "delegated_did": delegated_did, 888 + "controller_did": controller_did, 889 + "password": "Testpass123!", 890 + "remember_device": false 891 + })) 892 + .send() 893 + .await 894 + .expect("second delegation auth failed"); 895 + assert_eq!(auth_res2.status(), StatusCode::OK); 896 + 897 + let consent_get_res2 = http_client 898 + .get(format!("{}/oauth/authorize/consent", url)) 899 + .query(&[("request_uri", request_uri2.as_str())]) 900 + .send() 901 + .await 902 + .expect("second consent GET failed"); 903 + assert_eq!(consent_get_res2.status(), StatusCode::OK); 904 + let consent_get_body2: Value = consent_get_res2.json().await.unwrap(); 905 + let permission_sets2 = consent_get_body2["permission_sets"] 906 + .as_array() 907 + .expect("consent response should have a permission_sets array"); 908 + let set_entry2 = permission_sets2 909 + .iter() 910 + .find(|s| s["nsid"].as_str() == Some(REMEMBER_SET_NSID)) 911 + .unwrap_or_else(|| { 912 + panic!( 913 + "expected permission_sets to contain '{}', got: {:?}", 914 + REMEMBER_SET_NSID, consent_get_body2 915 + ) 916 + }); 917 + assert_eq!( 918 + set_entry2["granted"].as_bool(), 919 + Some(true), 920 + "the remembered set's include: token preference should round-trip as granted: true \ 921 + on a subsequent consent_get. Got: {:?}", 922 + set_entry2 923 + ); 924 + } 925 + 926 + #[tokio::test] 544 927 async fn test_legacy_granular_token_survives_refresh() { 545 928 let url = base_url().await; 929 + disable_rate_limiting_once(); 546 930 let http_client = client(); 547 931 let redirect_uri = "https://example.com/permset-legacy-callback"; 548 932 let scope = "atproto repo:*?action=create"; ··· 693 1077 new_jwt_scope 694 1078 ); 695 1079 } 1080 + 1081 + #[tokio::test] 1082 + async fn test_consent_post_drops_unpresented_scope() { 1083 + seed_permission_set(PERMISSION_SET_NSID, PERMISSION_SET_GRANULAR_SCOPE).await; 1084 + 1085 + let url = base_url().await; 1086 + disable_rate_limiting_once(); 1087 + let http_client = client(); 1088 + 1089 + let (controller_jwt, controller_did) = create_account_and_login(&http_client).await; 1090 + 1091 + let suffix = &uuid::Uuid::new_v4().simple().to_string()[..4]; 1092 + let delegated_handle = format!("psd{}", suffix); 1093 + let delegated_res = http_client 1094 + .post(format!("{}/xrpc/_delegation.createDelegatedAccount", url)) 1095 + .bearer_auth(&controller_jwt) 1096 + .json(&json!({ 1097 + "handle": delegated_handle, 1098 + "controllerScopes": tranquil_pds::delegation::OWNER_FULL_SCOPES 1099 + })) 1100 + .send() 1101 + .await 1102 + .expect("createDelegatedAccount request failed"); 1103 + if delegated_res.status() != StatusCode::OK { 1104 + let error_body = delegated_res.text().await.unwrap(); 1105 + panic!("Failed to create delegated account: {}", error_body); 1106 + } 1107 + let delegated_account: Value = delegated_res.json().await.unwrap(); 1108 + let delegated_did = delegated_account["did"].as_str().unwrap().to_string(); 1109 + 1110 + let redirect_uri = "https://example.com/permset-unpresented-callback"; 1111 + let mock_client = setup_mock_client_metadata(redirect_uri).await; 1112 + let client_id = mock_client.uri(); 1113 + let (code_verifier, code_challenge) = generate_pkce(); 1114 + 1115 + let scope = format!("atproto include:{}", PERMISSION_SET_NSID); 1116 + let par_res = http_client 1117 + .post(format!("{}/oauth/par", url)) 1118 + .form(&[ 1119 + ("response_type", "code"), 1120 + ("client_id", &client_id), 1121 + ("redirect_uri", redirect_uri), 1122 + ("code_challenge", &code_challenge), 1123 + ("code_challenge_method", "S256"), 1124 + ("scope", scope.as_str()), 1125 + ("login_hint", delegated_did.as_str()), 1126 + ]) 1127 + .send() 1128 + .await 1129 + .expect("PAR failed"); 1130 + assert!( 1131 + par_res.status() == StatusCode::OK || par_res.status() == StatusCode::CREATED, 1132 + "PAR should succeed, got {}", 1133 + par_res.status() 1134 + ); 1135 + let par_body: Value = par_res.json().await.unwrap(); 1136 + let request_uri = par_body["request_uri"].as_str().unwrap().to_string(); 1137 + 1138 + let auth_res = http_client 1139 + .post(format!("{}/oauth/delegation/auth", url)) 1140 + .header("Content-Type", "application/json") 1141 + .json(&json!({ 1142 + "request_uri": request_uri, 1143 + "delegated_did": delegated_did, 1144 + "controller_did": controller_did, 1145 + "password": "Testpass123!", 1146 + "remember_device": false 1147 + })) 1148 + .send() 1149 + .await 1150 + .expect("Delegation auth request failed"); 1151 + if auth_res.status() != StatusCode::OK { 1152 + let error_body = auth_res.text().await.unwrap(); 1153 + panic!("Delegation auth failed: {}", error_body); 1154 + } 1155 + let auth_body: Value = auth_res.json().await.unwrap(); 1156 + assert!( 1157 + auth_body["success"].as_bool().unwrap_or(false), 1158 + "Delegation auth should succeed: {:?}", 1159 + auth_body 1160 + ); 1161 + 1162 + let approved_scopes = vec![ 1163 + "atproto".to_string(), 1164 + format!("include:{}", PERMISSION_SET_NSID), 1165 + "repo:com.evil.collection?action=create".to_string(), 1166 + ]; 1167 + let consent_post_res = http_client 1168 + .post(format!("{}/oauth/authorize/consent", url)) 1169 + .header("Content-Type", "application/json") 1170 + .json(&json!({ 1171 + "request_uri": request_uri, 1172 + "approved_scopes": approved_scopes, 1173 + "remember": false 1174 + })) 1175 + .send() 1176 + .await 1177 + .expect("Consent POST failed"); 1178 + if consent_post_res.status() != StatusCode::OK { 1179 + let error_body = consent_post_res.text().await.unwrap(); 1180 + panic!("Consent POST failed: {}", error_body); 1181 + } 1182 + let consent_post_body: Value = consent_post_res.json().await.unwrap(); 1183 + let location = consent_post_body["redirect_uri"] 1184 + .as_str() 1185 + .expect("Expected redirect_uri from consent") 1186 + .to_string(); 1187 + 1188 + let code = location 1189 + .split("code=") 1190 + .nth(1) 1191 + .unwrap() 1192 + .split('&') 1193 + .next() 1194 + .unwrap(); 1195 + 1196 + let token_res = http_client 1197 + .post(format!("{}/oauth/token", url)) 1198 + .form(&[ 1199 + ("grant_type", "authorization_code"), 1200 + ("code", code), 1201 + ("redirect_uri", redirect_uri), 1202 + ("code_verifier", &code_verifier), 1203 + ("client_id", &client_id), 1204 + ]) 1205 + .send() 1206 + .await 1207 + .expect("Token request failed"); 1208 + assert_eq!( 1209 + token_res.status(), 1210 + StatusCode::OK, 1211 + "Token exchange should succeed" 1212 + ); 1213 + let token_body: Value = token_res.json().await.unwrap(); 1214 + let access_token = token_body["access_token"].as_str().unwrap().to_string(); 1215 + 1216 + let token_id = token_id_from_jwt(&access_token); 1217 + let token_data = common::get_test_repos() 1218 + .await 1219 + .oauth 1220 + .get_token_by_id(&token_id) 1221 + .await 1222 + .expect("get_token_by_id query failed") 1223 + .expect("token row should exist"); 1224 + let row_scope = token_data 1225 + .scope 1226 + .expect("stored token row should have a scope"); 1227 + assert!( 1228 + !row_scope.contains("com.evil.collection"), 1229 + "Stored oauth_token.scope row must not contain a scope that was never presented \ 1230 + to the resource owner, got: {}", 1231 + row_scope 1232 + ); 1233 + assert!( 1234 + row_scope.contains(&format!("include:{}", PERMISSION_SET_NSID)), 1235 + "Stored oauth_token.scope row should still preserve the legitimately approved \ 1236 + include: token, got: {}", 1237 + row_scope 1238 + ); 1239 + }
+16
frontend/src/locales/en.json
··· 597 597 "permissionsRequested": "Permissions Requested", 598 598 "required": "Required", 599 599 "rememberChoiceLabel": "Remember my choice for this application", 600 + "permissionSets": "Permission bundles", 601 + "unavailableSets": "Unavailable permission bundles", 602 + "showIncludedScopes": "Show included permissions ({count})", 603 + "setFailureReason": { 604 + "not_found": "This bundle could not be found and cannot be granted.", 605 + "unreachable": "The bundle's publisher could not be reached; it cannot be granted right now.", 606 + "invalid": "This bundle is invalid and cannot be granted." 607 + }, 608 + "permTable": { 609 + "data": "Data", 610 + "create": "Create", 611 + "update": "Update", 612 + "delete": "Delete", 613 + "allData": "All collections", 614 + "apiAccess": "API access" 615 + }, 600 616 "scopes": { 601 617 "atproto": { 602 618 "name": "AT Protocol Access",
+134 -2
frontend/src/routes/OAuthConsent.svelte
··· 32 32 scope.startsWith('identity:') 33 33 } 34 34 35 + interface PermissionSetInfo { 36 + nsid: string 37 + aud?: string 38 + title?: string 39 + detail?: string 40 + include_scope: string 41 + expanded: ScopeInfo[] 42 + granted: boolean | null 43 + } 44 + 45 + interface FailedSetInfo { 46 + nsid: string 47 + aud?: string 48 + reason: string 49 + } 50 + 35 51 interface ConsentData { 36 52 request_uri: string 37 53 client_id: string ··· 39 55 client_uri: string | null 40 56 logo_uri: string | null 41 57 scopes: ScopeInfo[] 58 + permission_sets: PermissionSetInfo[] 59 + failed_sets: FailedSetInfo[] 42 60 show_consent: boolean 43 61 did: string 44 62 handle?: string ··· 130 148 ]) 131 149 ) 132 150 151 + for (const set of data.permission_sets ?? []) { 152 + scopeSelections[set.include_scope] = set.granted ?? true 153 + } 154 + 133 155 if (!data.show_consent) { 134 156 await submitConsent() 135 157 } ··· 152 174 .filter(([_, approved]) => approved) 153 175 .map(([scope]) => scope) 154 176 155 - if (approvedScopes.length === 0 && consentData.scopes.length === 0) { 177 + if ( 178 + approvedScopes.length === 0 && 179 + consentData.scopes.length === 0 && 180 + (consentData.permission_sets?.length ?? 0) === 0 181 + ) { 156 182 approvedScopes = ['atproto'] 157 183 } 158 184 ··· 249 275 }) 250 276 251 277 let scopeGroups = $derived(consentData ? groupScopesByCategory(consentData.scopes) : []) 252 - let hasGranularScopes = $derived(consentData?.scopes.some(s => isGranularScope(s.scope)) ?? false) 278 + let hasGranularScopes = $derived( 279 + (consentData?.scopes.some(s => isGranularScope(s.scope)) ?? false) || 280 + ((consentData?.permission_sets?.length ?? 0) > 0) 281 + ) 253 282 254 283 function getLocalizedScopeName(scope: ScopeInfo): string { 255 284 const localeKey = SCOPE_LOCALE_MAP[scope.scope] ··· 276 305 const localized = $_(`oauth.consent.scopes.${localeKey}.description`) 277 306 return localized !== `oauth.consent.scopes.${localeKey}.description` ? localized : scope.description 278 307 } 308 + 309 + type RepoRow = { collection: string; create: boolean; update: boolean; delete: boolean } 310 + function describeExpanded(expanded: ScopeInfo[]): { 311 + repo: RepoRow[] 312 + rpc: string[] 313 + other: ScopeInfo[] 314 + } { 315 + const repo = new Map<string, RepoRow>() 316 + const rpc: string[] = [] 317 + const other: ScopeInfo[] = [] 318 + for (const s of expanded) { 319 + const [base, query = ''] = s.scope.split('?') 320 + const params = new URLSearchParams(query) 321 + if (base.startsWith('repo:')) { 322 + const collection = base.slice('repo:'.length) || '*' 323 + const requested = params.getAll('action') 324 + const actions = requested.length > 0 ? requested : ['create', 'update', 'delete'] 325 + const row = repo.get(collection) ?? { collection, create: false, update: false, delete: false } 326 + for (const a of actions) { 327 + if (a === 'create' || a === 'update' || a === 'delete') row[a] = true 328 + } 329 + repo.set(collection, row) 330 + } else if (base.startsWith('rpc:')) { 331 + rpc.push(base.slice('rpc:'.length)) 332 + } else { 333 + other.push(s) 334 + } 335 + } 336 + return { repo: [...repo.values()], rpc, other } 337 + } 279 338 </script> 280 339 281 340 <div class="consent-container"> ··· 384 443 {/each} 385 444 </div> 386 445 {/each} 446 + {/if} 447 + 448 + {#if consentData.permission_sets?.length} 449 + <div class="scope-group"> 450 + <h3 class="category-title">{$_('oauth.consent.permissionSets')}</h3> 451 + {#each consentData.permission_sets as set} 452 + {@const desc = describeExpanded(set.expanded)} 453 + <label class="scope-item"> 454 + <input 455 + type="checkbox" 456 + checked={scopeSelections[set.include_scope]} 457 + disabled={submitting} 458 + onchange={() => handleScopeToggle(set.include_scope)} 459 + /> 460 + <div class="scope-info"> 461 + <span class="scope-name">{set.title ?? set.nsid}</span> 462 + {#if set.detail}<span class="scope-description">{set.detail}</span>{/if} 463 + <details class="scope-set-details"> 464 + <summary>{$_('oauth.consent.showIncludedScopes', { values: { count: set.expanded.length } })}</summary> 465 + {#if desc.repo.length} 466 + <table class="perm-table"> 467 + <thead> 468 + <tr> 469 + <th scope="col">{$_('oauth.consent.permTable.data')}</th> 470 + <th scope="col" class="perm-col">{$_('oauth.consent.permTable.create')}</th> 471 + <th scope="col" class="perm-col">{$_('oauth.consent.permTable.update')}</th> 472 + <th scope="col" class="perm-col">{$_('oauth.consent.permTable.delete')}</th> 473 + </tr> 474 + </thead> 475 + <tbody> 476 + {#each desc.repo as r} 477 + <tr> 478 + <td class="perm-nsid">{r.collection === '*' ? $_('oauth.consent.permTable.allData') : r.collection}</td> 479 + <td class="perm-col" class:on={r.create} aria-label={r.create ? $_('oauth.consent.permTable.create') : undefined}>{r.create ? '✓' : ''}</td> 480 + <td class="perm-col" class:on={r.update} aria-label={r.update ? $_('oauth.consent.permTable.update') : undefined}>{r.update ? '✓' : ''}</td> 481 + <td class="perm-col" class:on={r.delete} aria-label={r.delete ? $_('oauth.consent.permTable.delete') : undefined}>{r.delete ? '✓' : ''}</td> 482 + </tr> 483 + {/each} 484 + </tbody> 485 + </table> 486 + {/if} 487 + {#if desc.rpc.length} 488 + <div class="perm-rpc"> 489 + <span class="perm-subhead">{$_('oauth.consent.permTable.apiAccess')}</span> 490 + <ul class="scope-set-list"> 491 + {#each desc.rpc as lxm}<li><span class="scope-raw">{lxm}</span></li>{/each} 492 + </ul> 493 + </div> 494 + {/if} 495 + {#if desc.other.length} 496 + <ul class="scope-set-list"> 497 + {#each desc.other as s}<li>{getLocalizedScopeName(s)} <span class="scope-raw">{s.scope}</span></li>{/each} 498 + </ul> 499 + {/if} 500 + </details> 501 + </div> 502 + </label> 503 + {/each} 504 + </div> 505 + {/if} 506 + 507 + {#if consentData.failed_sets?.length} 508 + <div class="scope-group failed-sets"> 509 + <h3 class="category-title">{$_('oauth.consent.unavailableSets')}</h3> 510 + {#each consentData.failed_sets as f} 511 + <div class="scope-item failed"> 512 + <div class="scope-info"> 513 + <span class="scope-name">{f.nsid}{#if f.aud} ({f.aud}){/if}</span> 514 + <span class="scope-description">{$_(`oauth.consent.setFailureReason.${f.reason}`)}</span> 515 + </div> 516 + </div> 517 + {/each} 518 + </div> 387 519 {/if} 388 520 </div> 389 521
+74
frontend/src/styles/pages.css
··· 984 984 word-break: break-all; 985 985 } 986 986 987 + .scope-set-details { 988 + margin-top: 0.375rem; 989 + } 990 + 991 + .scope-set-details summary { 992 + cursor: pointer; 993 + font-size: 0.85em; 994 + opacity: 0.8; 995 + } 996 + 997 + .scope-set-list { 998 + margin: 0.375rem 0 0; 999 + padding-left: 1rem; 1000 + font-size: 0.85em; 1001 + } 1002 + 1003 + .scope-set-list .scope-raw { 1004 + opacity: 0.6; 1005 + font-family: monospace; 1006 + } 1007 + 1008 + .perm-table { 1009 + width: 100%; 1010 + border-collapse: collapse; 1011 + margin-top: 0.5rem; 1012 + font-size: 0.82em; 1013 + } 1014 + .perm-table th, 1015 + .perm-table td { 1016 + padding: 0.28rem 0.5rem; 1017 + text-align: left; 1018 + border-bottom: 1px solid var(--border-light); 1019 + } 1020 + .perm-table thead th { 1021 + font-weight: 600; 1022 + font-size: 0.85em; 1023 + color: var(--text-muted); 1024 + border-bottom: 1px solid var(--border-color); 1025 + } 1026 + .perm-table tbody tr:last-child td { 1027 + border-bottom: none; 1028 + } 1029 + .perm-table .perm-col { 1030 + width: 4.25rem; 1031 + text-align: center; 1032 + } 1033 + .perm-table .perm-nsid { 1034 + font-family: monospace; 1035 + color: var(--text-primary); 1036 + word-break: break-all; 1037 + } 1038 + .perm-table td.perm-col { 1039 + color: var(--text-muted); 1040 + } 1041 + .perm-table td.perm-col.on { 1042 + color: var(--text-primary); 1043 + font-weight: 700; 1044 + } 1045 + .perm-rpc { 1046 + margin-top: 0.5rem; 1047 + } 1048 + .perm-subhead { 1049 + display: block; 1050 + font-size: 0.72em; 1051 + letter-spacing: 0.04em; 1052 + text-transform: uppercase; 1053 + color: var(--text-muted); 1054 + margin-bottom: 0.1rem; 1055 + } 1056 + 1057 + .scope-group.failed-sets .scope-item.failed { 1058 + opacity: 0.7; 1059 + } 1060 + 987 1061 .required-badge { 988 1062 display: inline-block; 989 1063 font-size: 0.625rem;