Our Personal Data Server from scratch!
0

Configure Feed

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

fix: use JWT scopes as source-of-truth for access and refresh tokens

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

author
Trezy
committer
Tangled
date (Jul 24, 2026, 8:11 PM +0300) commit 348ac887 parent ecdda4c5
+923 -65
+2
Cargo.lock
··· 7859 7859 name = "tranquil-oauth-server" 7860 7860 version = "0.6.5" 7861 7861 dependencies = [ 7862 + "async-trait", 7862 7863 "axum", 7863 7864 "base64 0.22.1", 7864 7865 "bcrypt", ··· 7882 7883 "tranquil-crypto", 7883 7884 "tranquil-db-traits", 7884 7885 "tranquil-pds", 7886 + "tranquil-scopes", 7885 7887 "tranquil-types", 7886 7888 "urlencoding", 7887 7889 "uuid",
+4
crates/tranquil-oauth-server/Cargo.toml
··· 11 11 tranquil-config = { workspace = true } 12 12 tranquil-crypto = { workspace = true } 13 13 tranquil-db-traits = { workspace = true } 14 + tranquil-scopes = { workspace = true } 14 15 15 16 axum = { workspace = true } 16 17 base64 = { workspace = true } ··· 33 34 urlencoding = { workspace = true } 34 35 uuid = { workspace = true } 35 36 webauthn-rs = { workspace = true } 37 + 38 + [dev-dependencies] 39 + async-trait = { workspace = true }
+46 -27
crates/tranquil-oauth-server/src/endpoints/authorize/consent.rs
··· 116 116 None 117 117 }; 118 118 119 - let effective_scope_str = if let Some(ref grant) = delegation_grant { 120 - tranquil_pds::delegation::intersect_scopes( 121 - requested_scope_str, 122 - grant.granted_scopes.as_str(), 123 - ) 124 - } else { 125 - requested_scope_str.to_string() 126 - }; 127 - 128 - let expanded_scope_str = match expand_include_scopes(&effective_scope_str).await { 129 - Ok(s) => s, 130 - Err(e) => { 131 - return json_error( 132 - StatusCode::BAD_REQUEST, 133 - "invalid_scope", 134 - &format!("Failed to expand permission set: {e}"), 135 - ); 136 - } 119 + let authority = match delegation_grant.as_ref() { 120 + Some(grant) => scope_resolution::Authority::Delegated(grant.granted_scopes.as_str()), 121 + None => scope_resolution::Authority::FullSelf, 137 122 }; 138 - let requested_scopes: Vec<&str> = expanded_scope_str.split_whitespace().collect(); 123 + let effective = scope_resolution::resolve_effective_scopes( 124 + &*state.cache, 125 + requested_scope_str, 126 + authority, 127 + ) 128 + .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 + let requested_scopes: Vec<&str> = effective.resolved.split_whitespace().collect(); 139 143 let preferences = state 140 144 .repos 141 145 .oauth ··· 342 346 None => None, 343 347 }; 344 348 345 - let effective_scope_str = if let Some(ref grant) = delegation_grant { 346 - tranquil_pds::delegation::intersect_scopes( 347 - original_scope_str, 348 - grant.granted_scopes.as_str(), 349 - ) 350 - } else { 351 - original_scope_str.to_string() 349 + let authority = match delegation_grant.as_ref() { 350 + Some(grant) => scope_resolution::Authority::Delegated(grant.granted_scopes.as_str()), 351 + None => scope_resolution::Authority::FullSelf, 352 352 }; 353 - let requested_scopes: Vec<&str> = effective_scope_str.split_whitespace().collect(); 353 + let effective = scope_resolution::resolve_effective_scopes( 354 + &*state.cache, 355 + original_scope_str, 356 + authority, 357 + ) 358 + .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(); 366 + return json_error( 367 + StatusCode::BAD_REQUEST, 368 + "invalid_scope", 369 + &format!("Could not resolve permission set(s): {}", names.join(", ")), 370 + ); 371 + } 372 + let requested_scopes: Vec<&str> = effective.resolved.split_whitespace().collect(); 354 373 let atproto_was_requested = requested_scopes.contains(&"atproto"); 355 374 if atproto_was_requested && !form.approved_scopes.contains(&"atproto".to_string()) { 356 375 return json_error(
+2 -1
crates/tranquil-oauth-server/src/endpoints/authorize/mod.rs
··· 15 15 use tranquil_pds::comms::comms_repo::enqueue_2fa_code; 16 16 use tranquil_pds::oauth::{ 17 17 AuthFlow, ClientMetadataCache, DeviceData, DeviceId, OAuthError, Prompt, SessionId, 18 - db::should_show_consent, scopes::expand_include_scopes, 18 + db::should_show_consent, 19 19 }; 20 20 use tranquil_pds::rate_limit::{ 21 21 OAuthAuthorizeLimit, OAuthRateLimited, OAuthRegisterCompleteLimit, TotpVerifyLimit, ··· 300 300 mod login; 301 301 mod passkey; 302 302 mod registration; 303 + pub mod scope_resolution; 303 304 mod two_factor; 304 305 305 306 pub use consent::*;
+80
crates/tranquil-oauth-server/src/endpoints/authorize/scope_resolution.rs
··· 1 + use tranquil_pds::cache::Cache; 2 + use tranquil_pds::delegation::intersect_scopes; 3 + use tranquil_pds::oauth::permission_set_resolver::expand_scopes; 4 + use tranquil_scopes::ExpansionOutcome; 5 + 6 + pub enum Authority<'a> { 7 + FullSelf, 8 + Delegated(&'a str), 9 + } 10 + 11 + pub struct EffectiveScopes { 12 + pub resolved: String, 13 + pub outcome: ExpansionOutcome, 14 + } 15 + 16 + pub async fn resolve_effective_scopes( 17 + cache: &dyn Cache, 18 + requested: &str, 19 + authority: Authority<'_>, 20 + ) -> EffectiveScopes { 21 + let outcome = expand_scopes(cache, requested).await; 22 + let expanded = outcome.to_scope_string(); 23 + let resolved = match authority { 24 + Authority::FullSelf => expanded, 25 + Authority::Delegated(granted) => intersect_scopes(&expanded, granted), 26 + }; 27 + EffectiveScopes { resolved, outcome } 28 + } 29 + 30 + #[cfg(test)] 31 + mod tests { 32 + use super::*; 33 + use std::collections::HashMap; 34 + use std::sync::Mutex; 35 + use std::time::Duration; 36 + use tranquil_pds::cache::{Cache, CacheError}; 37 + 38 + #[derive(Default)] 39 + struct MapCache(Mutex<HashMap<String, String>>); 40 + #[async_trait::async_trait] 41 + impl Cache for MapCache { 42 + async fn get(&self, k: &str) -> Option<String> { self.0.lock().unwrap().get(k).cloned() } 43 + async fn set(&self, k: &str, v: &str, _t: Duration) -> Result<(), CacheError> { 44 + self.0.lock().unwrap().insert(k.into(), v.into()); Ok(()) 45 + } 46 + async fn delete(&self, k: &str) -> Result<(), CacheError> { self.0.lock().unwrap().remove(k); Ok(()) } 47 + async fn get_bytes(&self, _k: &str) -> Option<Vec<u8>> { None } 48 + async fn set_bytes(&self, _k: &str, _v: &[u8], _t: Duration) -> Result<(), CacheError> { Ok(()) } 49 + } 50 + 51 + fn cache_with(nsid: &str, scopes: &str) -> MapCache { 52 + let c = MapCache::default(); 53 + let key = tranquil_pds::cache_keys::permission_set_key(nsid, None); 54 + let json = serde_json::json!({ "scope": scopes, "title": null, "detail": null }).to_string(); 55 + c.0.lock().unwrap().insert(key, json); 56 + c 57 + } 58 + 59 + #[tokio::test] 60 + async fn full_self_keeps_all_expanded() { 61 + let c = cache_with("io.atcr.authFullApp", "repo:io.atcr.manifest?action=create identity:*"); 62 + let eff = resolve_effective_scopes(&c, "atproto include:io.atcr.authFullApp", Authority::FullSelf).await; 63 + assert!(eff.resolved.contains("atproto")); 64 + assert!(eff.resolved.contains("repo:io.atcr.manifest?action=create")); 65 + assert!(eff.resolved.contains("identity:*")); 66 + assert!(eff.outcome.failures.is_empty()); 67 + } 68 + 69 + #[tokio::test] 70 + async fn delegated_intersects_expanded_against_grant() { 71 + let c = cache_with("io.atcr.authFullApp", "repo:io.atcr.manifest?action=create identity:*"); 72 + let eff = resolve_effective_scopes( 73 + &c, "atproto include:io.atcr.authFullApp", 74 + Authority::Delegated("atproto repo:* blob:*/* account:*?action=manage"), 75 + ).await; 76 + assert!(eff.resolved.contains("atproto")); 77 + assert!(eff.resolved.contains("repo:io.atcr.manifest?action=create")); 78 + assert!(!eff.resolved.contains("identity")); 79 + } 80 + }
+87 -34
crates/tranquil-oauth-server/src/endpoints/token/grants.rs
··· 7 7 use chrono::{Duration, Utc}; 8 8 use tranquil_db_traits::RefreshTokenLookup; 9 9 use tranquil_pds::config::AuthConfig; 10 - use tranquil_pds::delegation::intersect_scopes; 11 10 use tranquil_pds::oauth::{ 12 11 AuthFlow, ClientAuth, ClientMetadataCache, DPoPVerifier, OAuthError, RefreshToken, TokenData, 13 12 TokenId, 14 13 db::{enforce_token_limit_for_user, lookup_refresh_token}, 15 - scopes::expand_include_scopes, 16 14 verify_client_auth, 17 15 }; 18 16 use tranquil_pds::state::AppState; ··· 132 130 let refresh_token = RefreshToken::generate(); 133 131 let now = Utc::now(); 134 132 135 - let (raw_scope, controller_did) = if let Some(ref controller) = authorized.controller_did { 133 + let controller_did = authorized.controller_did.clone(); 134 + let requested_scope = authorized.parameters.scope.clone(); 135 + 136 + let granted_scopes: Option<String> = if let Some(ref controller) = controller_did { 136 137 let grant = state 137 138 .repos 138 139 .delegation 139 140 .get_delegation(&did, controller) 140 141 .await 141 142 .ok() 142 - .flatten(); 143 - let granted_scopes = match grant { 144 - Some(g) => g.granted_scopes, 145 - None => { 146 - return Err(OAuthError::InvalidGrant( 147 - "Delegation grant not found or revoked".to_string(), 148 - )); 149 - } 150 - }; 151 - let requested = authorized.parameters.scope.as_deref().unwrap_or("atproto"); 152 - let intersected = intersect_scopes(requested, granted_scopes.as_str()); 153 - (Some(intersected), Some(controller.clone())) 143 + .flatten() 144 + .ok_or_else(|| { 145 + OAuthError::InvalidGrant("Delegation grant not found or revoked".to_string()) 146 + })?; 147 + Some(grant.granted_scopes.as_str().to_string()) 154 148 } else { 155 - (authorized.parameters.scope.clone(), None) 149 + None 156 150 }; 157 - 158 - let final_scope = if let Some(ref scope) = raw_scope { 159 - if scope.contains("include:") { 160 - Some(expand_include_scopes(scope).await.map_err(|e| { 161 - OAuthError::InvalidScope(format!("Failed to expand permission set: {e}")) 162 - })?) 163 - } else { 164 - raw_scope 165 - } 166 - } else { 167 - raw_scope 151 + let authority = match granted_scopes.as_deref() { 152 + Some(g) => crate::endpoints::authorize::scope_resolution::Authority::Delegated(g), 153 + None => crate::endpoints::authorize::scope_resolution::Authority::FullSelf, 168 154 }; 155 + let requested_for_resolve = requested_scope.as_deref().unwrap_or("atproto"); 156 + let effective = crate::endpoints::authorize::scope_resolution::resolve_effective_scopes( 157 + &*state.cache, 158 + requested_for_resolve, 159 + authority, 160 + ) 161 + .await; 162 + if !effective.outcome.failures.is_empty() { 163 + let names: Vec<String> = effective 164 + .outcome 165 + .failures 166 + .iter() 167 + .map(|f| f.nsid.clone()) 168 + .collect(); 169 + return Err(OAuthError::InvalidScope(format!( 170 + "Could not resolve permission set(s): {}", 171 + names.join(", ") 172 + ))); 173 + } 174 + let resolved_scope = effective.resolved; 169 175 170 176 let access_token = create_access_token_with_delegation( 171 177 &token_id, 172 178 &did, 173 179 dpop_jkt.as_ref(), 174 - final_scope.as_deref(), 180 + Some(resolved_scope.as_str()), 175 181 controller_did.as_ref(), 176 182 )?; 177 183 let stored_client_auth = authorized.client_auth.unwrap_or(ClientAuth::None); ··· 195 201 details: None, 196 202 code: None, 197 203 current_refresh_token: Some(refresh_token.clone()), 198 - scope: final_scope.clone(), 204 + scope: requested_scope.clone(), 199 205 controller_did: controller_did.clone(), 200 206 }; 201 207 state ··· 237 243 }, 238 244 expires_in: ACCESS_TOKEN_EXPIRY_SECONDS, 239 245 refresh_token: Some(refresh_token), 240 - scope: final_scope, 246 + scope: Some(resolved_scope.clone()), 241 247 sub: Some(did), 242 248 }), 243 249 )) 244 250 } 245 251 252 + async fn recompute_resolved_scope( 253 + state: &AppState, 254 + token_data: &TokenData, 255 + ) -> Result<String, OAuthError> { 256 + let requested = token_data.scope.as_deref().unwrap_or("atproto"); 257 + let granted_scopes: Option<String> = if let Some(ref controller) = token_data.controller_did { 258 + let grant = state 259 + .repos 260 + .delegation 261 + .get_delegation(&token_data.did, controller) 262 + .await 263 + .ok() 264 + .flatten() 265 + .ok_or_else(|| { 266 + OAuthError::InvalidGrant("Delegation grant not found or revoked".to_string()) 267 + })?; 268 + Some(grant.granted_scopes.as_str().to_string()) 269 + } else { 270 + None 271 + }; 272 + let authority = match granted_scopes.as_deref() { 273 + Some(g) => crate::endpoints::authorize::scope_resolution::Authority::Delegated(g), 274 + None => crate::endpoints::authorize::scope_resolution::Authority::FullSelf, 275 + }; 276 + let effective = crate::endpoints::authorize::scope_resolution::resolve_effective_scopes( 277 + &*state.cache, 278 + requested, 279 + authority, 280 + ) 281 + .await; 282 + if !effective.outcome.failures.is_empty() { 283 + let names: Vec<String> = effective 284 + .outcome 285 + .failures 286 + .iter() 287 + .map(|f| f.nsid.clone()) 288 + .collect(); 289 + return Err(OAuthError::InvalidScope(format!( 290 + "Permission set(s) expired and unresolvable: {}", 291 + names.join(", ") 292 + ))); 293 + } 294 + Ok(effective.resolved) 295 + } 296 + 246 297 pub async fn handle_refresh_token_grant( 247 298 state: AppState, 248 299 _headers: HeaderMap, ··· 282 333 "Refresh token reuse within grace period, returning existing tokens" 283 334 ); 284 335 let dpop_jkt = token_data.parameters.dpop_jkt.as_ref(); 336 + let resolved = recompute_resolved_scope(&state, &token_data).await?; 285 337 let access_token = create_access_token_with_delegation( 286 338 &token_data.token_id, 287 339 &token_data.did, 288 340 dpop_jkt, 289 - token_data.scope.as_deref(), 341 + Some(resolved.as_str()), 290 342 token_data.controller_did.as_ref(), 291 343 )?; 292 344 let mut response_headers = HeaderMap::new(); ··· 307 359 }, 308 360 expires_in: ACCESS_TOKEN_EXPIRY_SECONDS, 309 361 refresh_token: token_data.current_refresh_token, 310 - scope: token_data.scope, 362 + scope: Some(resolved), 311 363 sub: Some(token_data.did), 312 364 }), 313 365 )); ··· 396 448 new_expires_at = %new_expires_at, 397 449 "Refresh token rotated successfully" 398 450 ); 451 + let resolved = recompute_resolved_scope(&state, &token_data).await?; 399 452 let access_token = create_access_token_with_delegation( 400 453 &token_data.token_id, 401 454 &token_data.did, 402 455 dpop_jkt.as_ref(), 403 - token_data.scope.as_deref(), 456 + Some(resolved.as_str()), 404 457 token_data.controller_did.as_ref(), 405 458 )?; 406 459 let mut response_headers = HeaderMap::new(); ··· 421 474 }, 422 475 expires_in: ACCESS_TOKEN_EXPIRY_SECONDS, 423 476 refresh_token: Some(new_refresh_token), 424 - scope: token_data.scope, 477 + scope: Some(resolved), 425 478 sub: Some(token_data.did), 426 479 }), 427 480 ))
+6 -2
crates/tranquil-oauth-server/src/endpoints/token/introspect.rs
··· 106 106 Ok(info) => info, 107 107 Err(_) => return Ok(Json(inactive_response)), 108 108 }; 109 + let jwt_info = match tranquil_pds::oauth::verify::extract_oauth_token_info(&request.token) { 110 + Ok(info) => info, 111 + Err(_) => return Ok(Json(inactive_response)), 112 + }; 109 113 let token_id = TokenId::from(token_info.sid.clone()); 110 114 let token_data = match state.repos.oauth.get_token_by_id(&token_id).await { 111 115 Ok(Some(data)) => data, ··· 118 122 let issuer = format!("https://{}", pds_hostname); 119 123 Ok(Json(IntrospectResponse { 120 124 active: true, 121 - scope: token_data.scope, 125 + scope: jwt_info.scope, 122 126 client_id: Some(token_data.client_id), 123 127 username: None, 124 128 token_type: if token_data.parameters.dpop_jkt.is_some() { ··· 129 133 exp: Some(token_info.exp), 130 134 iat: Some(token_info.iat), 131 135 nbf: Some(token_info.iat), 132 - sub: Some(token_data.did.to_string()), 136 + sub: Some(jwt_info.did.to_string()), 133 137 aud: Some(issuer.clone()), 134 138 iss: Some(issuer), 135 139 jti: Some(token_info.jti),
+1 -1
crates/tranquil-pds/src/oauth/verify.rs
··· 96 96 did: token_data.did, 97 97 token_id, 98 98 client_id: token_data.client_id, 99 - scope: token_data.scope, 99 + scope: token_info.scope, 100 100 }) 101 101 } 102 102
+695
crates/tranquil-pds/tests/oauth_permission_sets.rs
··· 1 + mod common; 2 + mod helpers; 3 + 4 + use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; 5 + use chrono::Utc; 6 + use common::{base_url, client, create_account_and_login}; 7 + use helpers::verify_new_account; 8 + use reqwest::StatusCode; 9 + use serde_json::{Value, json}; 10 + use sha2::{Digest, Sha256}; 11 + use tranquil_types::TokenId; 12 + use wiremock::matchers::{method, path}; 13 + use wiremock::{Mock, MockServer, ResponseTemplate}; 14 + 15 + const PERMISSION_SET_NSID: &str = "io.atcr.authFullApp"; 16 + const PERMISSION_SET_GRANULAR_SCOPE: &str = 17 + "repo:io.atcr.manifest?action=create rpc:io.atcr.getManifest?aud=*"; 18 + 19 + fn generate_pkce() -> (String, String) { 20 + let verifier_bytes: [u8; 32] = rand::random(); 21 + let code_verifier = URL_SAFE_NO_PAD.encode(verifier_bytes); 22 + let mut hasher = Sha256::new(); 23 + hasher.update(code_verifier.as_bytes()); 24 + let hash = hasher.finalize(); 25 + let code_challenge = URL_SAFE_NO_PAD.encode(hash); 26 + (code_verifier, code_challenge) 27 + } 28 + 29 + async fn setup_mock_client_metadata(redirect_uri: &str) -> MockServer { 30 + let mock_server = MockServer::start().await; 31 + let client_id = mock_server.uri(); 32 + let metadata = json!({ 33 + "client_id": client_id, 34 + "client_name": "Test Permission Set Client", 35 + "redirect_uris": [redirect_uri], 36 + "grant_types": ["authorization_code", "refresh_token"], 37 + "response_types": ["code"], 38 + "token_endpoint_auth_method": "none", 39 + "dpop_bound_access_tokens": false 40 + }); 41 + Mock::given(method("GET")) 42 + .and(path("/")) 43 + .respond_with(ResponseTemplate::new(200).set_body_json(metadata)) 44 + .mount(&mock_server) 45 + .await; 46 + mock_server 47 + } 48 + 49 + async fn seed_permission_set(nsid: &str, granular_scope: &str) { 50 + let state = common::get_test_app_state().await; 51 + let key = tranquil_pds::cache_keys::permission_set_key(nsid, None); 52 + let val = json!({ 53 + "scope": granular_scope, 54 + "title": "Basic", 55 + "detail": null 56 + }) 57 + .to_string(); 58 + state 59 + .cache 60 + .set(&key, &val, std::time::Duration::from_secs(3600)) 61 + .await 62 + .unwrap(); 63 + } 64 + 65 + fn decode_jwt_payload(jwt: &str) -> Value { 66 + let parts: Vec<&str> = jwt.split('.').collect(); 67 + assert_eq!(parts.len(), 3, "Token should be a valid JWT"); 68 + let payload_json = URL_SAFE_NO_PAD.decode(parts[1]).unwrap(); 69 + serde_json::from_slice(&payload_json).unwrap() 70 + } 71 + 72 + fn token_id_from_jwt(jwt: &str) -> TokenId { 73 + let payload = decode_jwt_payload(jwt); 74 + let sid = payload["sid"] 75 + .as_str() 76 + .expect("Token payload should contain sid claim"); 77 + TokenId::new(sid) 78 + } 79 + 80 + struct DelegatedSession { 81 + access_token: String, 82 + #[allow(dead_code)] 83 + refresh_token: String, 84 + delegated_did: String, 85 + #[allow(dead_code)] 86 + controller_did: String, 87 + #[allow(dead_code)] 88 + client_id: String, 89 + } 90 + 91 + async fn create_delegated_session_with_scope( 92 + handle_prefix: &str, 93 + redirect_uri: &str, 94 + scope: &str, 95 + ) -> (DelegatedSession, Value, MockServer) { 96 + let url = base_url().await; 97 + let http_client = client(); 98 + 99 + let (controller_jwt, controller_did) = create_account_and_login(&http_client).await; 100 + 101 + let suffix = &uuid::Uuid::new_v4().simple().to_string()[..4]; 102 + let delegated_handle = format!("{}{}", handle_prefix, suffix); 103 + let delegated_res = http_client 104 + .post(format!("{}/xrpc/_delegation.createDelegatedAccount", url)) 105 + .bearer_auth(&controller_jwt) 106 + .json(&json!({ 107 + "handle": delegated_handle, 108 + "controllerScopes": tranquil_pds::delegation::OWNER_FULL_SCOPES 109 + })) 110 + .send() 111 + .await 112 + .expect("createDelegatedAccount request failed"); 113 + if delegated_res.status() != StatusCode::OK { 114 + let error_body = delegated_res.text().await.unwrap(); 115 + panic!("Failed to create delegated account: {}", error_body); 116 + } 117 + let delegated_account: Value = delegated_res.json().await.unwrap(); 118 + let delegated_did = delegated_account["did"].as_str().unwrap().to_string(); 119 + 120 + let mock_client = setup_mock_client_metadata(redirect_uri).await; 121 + let client_id = mock_client.uri(); 122 + let (code_verifier, code_challenge) = generate_pkce(); 123 + 124 + let par_res = http_client 125 + .post(format!("{}/oauth/par", url)) 126 + .form(&[ 127 + ("response_type", "code"), 128 + ("client_id", &client_id), 129 + ("redirect_uri", redirect_uri), 130 + ("code_challenge", &code_challenge), 131 + ("code_challenge_method", "S256"), 132 + ("scope", scope), 133 + ("login_hint", delegated_did.as_str()), 134 + ]) 135 + .send() 136 + .await 137 + .expect("PAR failed"); 138 + assert!( 139 + par_res.status() == StatusCode::OK || par_res.status() == StatusCode::CREATED, 140 + "PAR should succeed, got {}", 141 + par_res.status() 142 + ); 143 + let par_body: Value = par_res.json().await.unwrap(); 144 + let request_uri = par_body["request_uri"].as_str().unwrap().to_string(); 145 + 146 + let auth_res = http_client 147 + .post(format!("{}/oauth/delegation/auth", url)) 148 + .header("Content-Type", "application/json") 149 + .json(&json!({ 150 + "request_uri": request_uri, 151 + "delegated_did": delegated_did, 152 + "controller_did": controller_did, 153 + "password": "Testpass123!", 154 + "remember_device": false 155 + })) 156 + .send() 157 + .await 158 + .expect("Delegation auth request failed"); 159 + if auth_res.status() != StatusCode::OK { 160 + let error_body = auth_res.text().await.unwrap(); 161 + panic!("Delegation auth failed: {}", error_body); 162 + } 163 + let auth_body: Value = auth_res.json().await.unwrap(); 164 + assert!( 165 + auth_body["success"].as_bool().unwrap_or(false), 166 + "Delegation auth should succeed: {:?}", 167 + auth_body 168 + ); 169 + 170 + let consent_get_res = http_client 171 + .get(format!("{}/oauth/authorize/consent", url)) 172 + .query(&[("request_uri", request_uri.as_str())]) 173 + .send() 174 + .await 175 + .expect("Consent GET failed"); 176 + assert_eq!( 177 + consent_get_res.status(), 178 + StatusCode::OK, 179 + "Consent GET should succeed" 180 + ); 181 + let consent_get_body: Value = consent_get_res.json().await.unwrap(); 182 + 183 + let approved_scopes: Vec<&str> = scope.split_whitespace().collect(); 184 + let consent_post_res = http_client 185 + .post(format!("{}/oauth/authorize/consent", url)) 186 + .header("Content-Type", "application/json") 187 + .json(&json!({ 188 + "request_uri": request_uri, 189 + "approved_scopes": approved_scopes, 190 + "remember": false 191 + })) 192 + .send() 193 + .await 194 + .expect("Consent POST failed"); 195 + if consent_post_res.status() != StatusCode::OK { 196 + let error_body = consent_post_res.text().await.unwrap(); 197 + panic!("Consent POST failed: {}", error_body); 198 + } 199 + let consent_post_body: Value = consent_post_res.json().await.unwrap(); 200 + let location = consent_post_body["redirect_uri"] 201 + .as_str() 202 + .expect("Expected redirect_uri from consent") 203 + .to_string(); 204 + 205 + let code = location 206 + .split("code=") 207 + .nth(1) 208 + .unwrap() 209 + .split('&') 210 + .next() 211 + .unwrap(); 212 + 213 + let token_res = http_client 214 + .post(format!("{}/oauth/token", url)) 215 + .form(&[ 216 + ("grant_type", "authorization_code"), 217 + ("code", code), 218 + ("redirect_uri", redirect_uri), 219 + ("code_verifier", &code_verifier), 220 + ("client_id", &client_id), 221 + ]) 222 + .send() 223 + .await 224 + .expect("Token request failed"); 225 + assert_eq!( 226 + token_res.status(), 227 + StatusCode::OK, 228 + "Token exchange should succeed" 229 + ); 230 + let token_body: Value = token_res.json().await.unwrap(); 231 + 232 + let session = DelegatedSession { 233 + access_token: token_body["access_token"].as_str().unwrap().to_string(), 234 + refresh_token: token_body["refresh_token"].as_str().unwrap().to_string(), 235 + delegated_did, 236 + controller_did, 237 + client_id, 238 + }; 239 + (session, consent_get_body, mock_client) 240 + } 241 + 242 + #[tokio::test] 243 + async fn test_delegated_include_scope_shows_granular_on_consent() { 244 + seed_permission_set(PERMISSION_SET_NSID, PERMISSION_SET_GRANULAR_SCOPE).await; 245 + 246 + let scope = format!("atproto include:{}", PERMISSION_SET_NSID); 247 + let (_session, consent_body, _mock) = create_delegated_session_with_scope( 248 + "psc", 249 + "https://example.com/permset-consent-callback", 250 + &scope, 251 + ) 252 + .await; 253 + 254 + let scopes = consent_body["scopes"] 255 + .as_array() 256 + .expect("consent response should have a scopes array"); 257 + assert!(!scopes.is_empty(), "consent scopes should not be empty"); 258 + 259 + let has_granular = scopes 260 + .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 267 + ); 268 + 269 + let only_atproto = scopes 270 + .iter() 271 + .all(|s| s["scope"].as_str() == Some("atproto")); 272 + assert!( 273 + !only_atproto, 274 + "consent scopes[] should not collapse to just 'atproto'" 275 + ); 276 + 277 + let has_raw_include = scopes.iter().any(|s| { 278 + s["scope"] 279 + .as_str() 280 + .map(|sc| sc.starts_with("include:")) 281 + .unwrap_or(false) 282 + }); 283 + assert!( 284 + !has_raw_include, 285 + "consent scopes[] should list the expanded set, not the raw include: token" 286 + ); 287 + } 288 + 289 + #[tokio::test] 290 + async fn test_grant_row_keeps_include_jwt_carries_expanded() { 291 + seed_permission_set(PERMISSION_SET_NSID, PERMISSION_SET_GRANULAR_SCOPE).await; 292 + 293 + let scope = format!("atproto include:{}", PERMISSION_SET_NSID); 294 + let (session, _consent_body, _mock) = create_delegated_session_with_scope( 295 + "psg", 296 + "https://example.com/permset-grant-callback", 297 + &scope, 298 + ) 299 + .await; 300 + 301 + let payload = decode_jwt_payload(&session.access_token); 302 + let jwt_scope = payload["scope"] 303 + .as_str() 304 + .expect("access token JWT should have a scope claim"); 305 + assert!( 306 + jwt_scope.contains("repo:io.atcr.manifest?action=create"), 307 + "JWT scope claim should carry the expanded granular scope, got: {}", 308 + jwt_scope 309 + ); 310 + assert!( 311 + !jwt_scope.contains("include:"), 312 + "JWT scope claim should not carry the raw include: token, got: {}", 313 + jwt_scope 314 + ); 315 + 316 + let token_id = token_id_from_jwt(&session.access_token); 317 + let token_data = common::get_test_repos() 318 + .await 319 + .oauth 320 + .get_token_by_id(&token_id) 321 + .await 322 + .expect("get_token_by_id query failed") 323 + .expect("token row should exist"); 324 + let row_scope = token_data 325 + .scope 326 + .expect("stored token row should have a scope"); 327 + assert!( 328 + row_scope.contains(&format!("include:{}", PERMISSION_SET_NSID)), 329 + "Stored oauth_token.scope row should still preserve the include: token, got: {}", 330 + row_scope 331 + ); 332 + } 333 + 334 + #[tokio::test] 335 + async fn test_introspect_reads_expanded_jwt_scope() { 336 + seed_permission_set(PERMISSION_SET_NSID, PERMISSION_SET_GRANULAR_SCOPE).await; 337 + 338 + let scope = format!("atproto include:{}", PERMISSION_SET_NSID); 339 + let (session, _consent_body, _mock) = create_delegated_session_with_scope( 340 + "psi", 341 + "https://example.com/permset-introspect-callback", 342 + &scope, 343 + ) 344 + .await; 345 + 346 + let url = base_url().await; 347 + let http_client = client(); 348 + let introspect_res = http_client 349 + .post(format!("{}/oauth/introspect", url)) 350 + .form(&[("token", session.access_token.as_str())]) 351 + .send() 352 + .await 353 + .expect("introspect request failed"); 354 + assert_eq!(introspect_res.status(), StatusCode::OK); 355 + let introspect_body: Value = introspect_res.json().await.unwrap(); 356 + 357 + assert_eq!( 358 + introspect_body["active"].as_bool(), 359 + Some(true), 360 + "token should be active" 361 + ); 362 + let introspect_scope = introspect_body["scope"] 363 + .as_str() 364 + .expect("introspect response should have a scope string"); 365 + assert!( 366 + introspect_scope.contains("repo:io.atcr.manifest?action=create"), 367 + "introspect scope should contain the expanded granular scope, got: {}", 368 + introspect_scope 369 + ); 370 + assert!( 371 + !introspect_scope.contains("include:"), 372 + "introspect scope should not contain the raw include: token, got: {}", 373 + introspect_scope 374 + ); 375 + } 376 + 377 + #[tokio::test] 378 + async fn test_enforcement_uses_expanded_jwt_scope() { 379 + seed_permission_set(PERMISSION_SET_NSID, PERMISSION_SET_GRANULAR_SCOPE).await; 380 + 381 + let scope = format!("atproto include:{}", PERMISSION_SET_NSID); 382 + let (session, _consent_body, _mock) = create_delegated_session_with_scope( 383 + "pse", 384 + "https://example.com/permset-enforce-callback", 385 + &scope, 386 + ) 387 + .await; 388 + 389 + let url = base_url().await; 390 + let http_client = client(); 391 + let collection = "io.atcr.manifest"; 392 + let create_res = http_client 393 + .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) 394 + .bearer_auth(&session.access_token) 395 + .json(&json!({ 396 + "repo": session.delegated_did, 397 + "collection": collection, 398 + "validate": false, 399 + "record": { 400 + "$type": collection, 401 + "note": "permission set enforcement test", 402 + "createdAt": Utc::now().to_rfc3339() 403 + } 404 + })) 405 + .send() 406 + .await 407 + .expect("createRecord request failed"); 408 + 409 + assert_ne!( 410 + create_res.status(), 411 + StatusCode::FORBIDDEN, 412 + "createRecord for a collection covered by the permission set's expanded scope \ 413 + should not be forbidden -- enforcement must read the expanded JWT scope, not \ 414 + the include:-only stored row. Got body: {:?}", 415 + create_res.text().await 416 + ); 417 + } 418 + 419 + #[tokio::test] 420 + async fn test_consent_post_errors_when_set_unresolvable() { 421 + const UNRESOLVABLE_NSID: &str = "io.atcr.authUnresolvableSet"; 422 + seed_permission_set(UNRESOLVABLE_NSID, PERMISSION_SET_GRANULAR_SCOPE).await; 423 + 424 + let url = base_url().await; 425 + let http_client = client(); 426 + 427 + let (controller_jwt, controller_did) = create_account_and_login(&http_client).await; 428 + 429 + let suffix = &uuid::Uuid::new_v4().simple().to_string()[..4]; 430 + let delegated_handle = format!("psu{}", suffix); 431 + let delegated_res = http_client 432 + .post(format!("{}/xrpc/_delegation.createDelegatedAccount", url)) 433 + .bearer_auth(&controller_jwt) 434 + .json(&json!({ 435 + "handle": delegated_handle, 436 + "controllerScopes": tranquil_pds::delegation::OWNER_FULL_SCOPES 437 + })) 438 + .send() 439 + .await 440 + .expect("createDelegatedAccount request failed"); 441 + if delegated_res.status() != StatusCode::OK { 442 + let error_body = delegated_res.text().await.unwrap(); 443 + panic!("Failed to create delegated account: {}", error_body); 444 + } 445 + let delegated_account: Value = delegated_res.json().await.unwrap(); 446 + let delegated_did = delegated_account["did"].as_str().unwrap().to_string(); 447 + 448 + let redirect_uri = "https://example.com/permset-unresolvable-callback"; 449 + let mock_client = setup_mock_client_metadata(redirect_uri).await; 450 + let client_id = mock_client.uri(); 451 + let (_code_verifier, code_challenge) = generate_pkce(); 452 + 453 + let scope = format!("atproto include:{}", UNRESOLVABLE_NSID); 454 + let par_res = http_client 455 + .post(format!("{}/oauth/par", url)) 456 + .form(&[ 457 + ("response_type", "code"), 458 + ("client_id", &client_id), 459 + ("redirect_uri", redirect_uri), 460 + ("code_challenge", &code_challenge), 461 + ("code_challenge_method", "S256"), 462 + ("scope", scope.as_str()), 463 + ("login_hint", delegated_did.as_str()), 464 + ]) 465 + .send() 466 + .await 467 + .expect("PAR failed"); 468 + assert!( 469 + par_res.status() == StatusCode::OK || par_res.status() == StatusCode::CREATED, 470 + "PAR should succeed, got {}", 471 + par_res.status() 472 + ); 473 + let par_body: Value = par_res.json().await.unwrap(); 474 + let request_uri = par_body["request_uri"].as_str().unwrap().to_string(); 475 + 476 + let auth_res = http_client 477 + .post(format!("{}/oauth/delegation/auth", url)) 478 + .header("Content-Type", "application/json") 479 + .json(&json!({ 480 + "request_uri": request_uri, 481 + "delegated_did": delegated_did, 482 + "controller_did": controller_did, 483 + "password": "Testpass123!", 484 + "remember_device": false 485 + })) 486 + .send() 487 + .await 488 + .expect("Delegation auth request failed"); 489 + if auth_res.status() != StatusCode::OK { 490 + let error_body = auth_res.text().await.unwrap(); 491 + panic!("Delegation auth failed: {}", error_body); 492 + } 493 + let auth_body: Value = auth_res.json().await.unwrap(); 494 + assert!( 495 + auth_body["success"].as_bool().unwrap_or(false), 496 + "Delegation auth should succeed: {:?}", 497 + auth_body 498 + ); 499 + 500 + let consent_get_res = http_client 501 + .get(format!("{}/oauth/authorize/consent", url)) 502 + .query(&[("request_uri", request_uri.as_str())]) 503 + .send() 504 + .await 505 + .expect("Consent GET failed"); 506 + assert_eq!( 507 + consent_get_res.status(), 508 + StatusCode::OK, 509 + "Consent GET should succeed" 510 + ); 511 + 512 + let state = common::get_test_app_state().await; 513 + let key = tranquil_pds::cache_keys::permission_set_key(UNRESOLVABLE_NSID, None); 514 + state.cache.delete(&key).await.unwrap(); 515 + 516 + let approved_scopes: Vec<&str> = scope.split_whitespace().collect(); 517 + let consent_post_res = http_client 518 + .post(format!("{}/oauth/authorize/consent", url)) 519 + .header("Content-Type", "application/json") 520 + .json(&json!({ 521 + "request_uri": request_uri, 522 + "approved_scopes": approved_scopes, 523 + "remember": true 524 + })) 525 + .send() 526 + .await 527 + .expect("Consent POST failed"); 528 + assert_eq!( 529 + consent_post_res.status(), 530 + StatusCode::BAD_REQUEST, 531 + "Consent POST must fail closed (400) when the include: set can no longer be \ 532 + resolved, instead of silently persisting/granting truncated scopes" 533 + ); 534 + let error_body: Value = consent_post_res.json().await.unwrap(); 535 + assert_eq!( 536 + error_body["error"].as_str(), 537 + Some("invalid_scope"), 538 + "Expected invalid_scope error, got: {:?}", 539 + error_body 540 + ); 541 + } 542 + 543 + #[tokio::test] 544 + async fn test_legacy_granular_token_survives_refresh() { 545 + let url = base_url().await; 546 + let http_client = client(); 547 + let redirect_uri = "https://example.com/permset-legacy-callback"; 548 + let scope = "atproto repo:*?action=create"; 549 + 550 + let suffix = &uuid::Uuid::new_v4().simple().to_string()[..4]; 551 + let handle = format!("psl{}", suffix); 552 + let email = format!("psl{}@example.com", suffix); 553 + let password = "LegacyPass123!"; 554 + 555 + let create_res = http_client 556 + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 557 + .json(&json!({ 558 + "handle": handle, 559 + "email": email, 560 + "password": password 561 + })) 562 + .send() 563 + .await 564 + .unwrap(); 565 + assert_eq!(create_res.status(), StatusCode::OK); 566 + let account: Value = create_res.json().await.unwrap(); 567 + let user_did = account["did"].as_str().unwrap().to_string(); 568 + let _ = verify_new_account(&http_client, &user_did).await; 569 + 570 + let mock_client = setup_mock_client_metadata(redirect_uri).await; 571 + let client_id = mock_client.uri(); 572 + let (code_verifier, code_challenge) = generate_pkce(); 573 + 574 + let par_res = http_client 575 + .post(format!("{}/oauth/par", url)) 576 + .form(&[ 577 + ("response_type", "code"), 578 + ("client_id", &client_id), 579 + ("redirect_uri", redirect_uri), 580 + ("code_challenge", &code_challenge), 581 + ("code_challenge_method", "S256"), 582 + ("scope", scope), 583 + ]) 584 + .send() 585 + .await 586 + .expect("PAR failed"); 587 + assert!(par_res.status() == StatusCode::OK || par_res.status() == StatusCode::CREATED); 588 + let par_body: Value = par_res.json().await.unwrap(); 589 + let request_uri = par_body["request_uri"].as_str().unwrap(); 590 + 591 + let auth_res = http_client 592 + .post(format!("{}/oauth/authorize", url)) 593 + .header("Content-Type", "application/json") 594 + .header("Accept", "application/json") 595 + .json(&json!({ 596 + "request_uri": request_uri, 597 + "username": &handle, 598 + "password": password, 599 + "remember_device": false 600 + })) 601 + .send() 602 + .await 603 + .expect("Authorize failed"); 604 + assert_eq!(auth_res.status(), StatusCode::OK); 605 + let auth_body: Value = auth_res.json().await.unwrap(); 606 + let mut location = auth_body["redirect_uri"] 607 + .as_str() 608 + .expect("Expected redirect_uri") 609 + .to_string(); 610 + if location.contains("/oauth/consent") { 611 + let consent_res = http_client 612 + .post(format!("{}/oauth/authorize/consent", url)) 613 + .header("Content-Type", "application/json") 614 + .json(&json!({ 615 + "request_uri": request_uri, 616 + "approved_scopes": scope.split_whitespace().collect::<Vec<_>>(), 617 + "remember": false 618 + })) 619 + .send() 620 + .await 621 + .expect("Consent request failed"); 622 + assert_eq!(consent_res.status(), StatusCode::OK); 623 + let consent_body: Value = consent_res.json().await.unwrap(); 624 + location = consent_body["redirect_uri"] 625 + .as_str() 626 + .expect("Expected redirect_uri from consent") 627 + .to_string(); 628 + } 629 + let code = location 630 + .split("code=") 631 + .nth(1) 632 + .unwrap() 633 + .split('&') 634 + .next() 635 + .unwrap(); 636 + 637 + let token_res = http_client 638 + .post(format!("{}/oauth/token", url)) 639 + .form(&[ 640 + ("grant_type", "authorization_code"), 641 + ("code", code), 642 + ("redirect_uri", redirect_uri), 643 + ("code_verifier", &code_verifier), 644 + ("client_id", &client_id), 645 + ]) 646 + .send() 647 + .await 648 + .expect("Token request failed"); 649 + assert_eq!(token_res.status(), StatusCode::OK); 650 + let token_body: Value = token_res.json().await.unwrap(); 651 + let access_token = token_body["access_token"].as_str().unwrap().to_string(); 652 + let refresh_token = token_body["refresh_token"].as_str().unwrap().to_string(); 653 + 654 + let original_payload = decode_jwt_payload(&access_token); 655 + let original_scope = original_payload["scope"].as_str().unwrap(); 656 + assert!(original_scope.contains("repo:*?action=create")); 657 + 658 + let refresh_res = http_client 659 + .post(format!("{}/oauth/token", url)) 660 + .form(&[ 661 + ("grant_type", "refresh_token"), 662 + ("refresh_token", refresh_token.as_str()), 663 + ("client_id", &client_id), 664 + ]) 665 + .send() 666 + .await 667 + .expect("Refresh request failed"); 668 + assert_eq!( 669 + refresh_res.status(), 670 + StatusCode::OK, 671 + "Refreshing a legacy granular-scope token should succeed" 672 + ); 673 + let refresh_body: Value = refresh_res.json().await.unwrap(); 674 + let new_access_token = refresh_body["access_token"].as_str().unwrap(); 675 + assert_ne!(new_access_token, access_token); 676 + 677 + let new_scope_from_response = refresh_body["scope"] 678 + .as_str() 679 + .expect("refresh response should include scope"); 680 + assert!( 681 + new_scope_from_response.contains("repo:*?action=create"), 682 + "Refresh response scope should still contain the granular scope, got: {}", 683 + new_scope_from_response 684 + ); 685 + 686 + let new_payload = decode_jwt_payload(new_access_token); 687 + let new_jwt_scope = new_payload["scope"] 688 + .as_str() 689 + .expect("new JWT should have a scope claim"); 690 + assert!( 691 + new_jwt_scope.contains("repo:*?action=create"), 692 + "New JWT scope claim should still contain the granular scope after refresh, got: {}", 693 + new_jwt_scope 694 + ); 695 + }