diff --git a/Cargo.lock b/Cargo.lock index 85df977..922a0fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7859,6 +7859,7 @@ dependencies = [ name = "tranquil-oauth-server" version = "0.6.5" dependencies = [ + "async-trait", "axum", "base64 0.22.1", "bcrypt", @@ -7882,6 +7883,7 @@ dependencies = [ "tranquil-crypto", "tranquil-db-traits", "tranquil-pds", + "tranquil-scopes", "tranquil-types", "urlencoding", "uuid", diff --git a/crates/tranquil-oauth-server/Cargo.toml b/crates/tranquil-oauth-server/Cargo.toml index f57cbe1..eab1e37 100644 --- a/crates/tranquil-oauth-server/Cargo.toml +++ b/crates/tranquil-oauth-server/Cargo.toml @@ -11,6 +11,7 @@ tranquil-types = { workspace = true } tranquil-config = { workspace = true } tranquil-crypto = { workspace = true } tranquil-db-traits = { workspace = true } +tranquil-scopes = { workspace = true } axum = { workspace = true } base64 = { workspace = true } @@ -33,3 +34,6 @@ tracing = { workspace = true } urlencoding = { workspace = true } uuid = { workspace = true } webauthn-rs = { workspace = true } + +[dev-dependencies] +async-trait = { workspace = true } diff --git a/crates/tranquil-oauth-server/src/endpoints/authorize/consent.rs b/crates/tranquil-oauth-server/src/endpoints/authorize/consent.rs index 7b4176c..0e5bdbd 100644 --- a/crates/tranquil-oauth-server/src/endpoints/authorize/consent.rs +++ b/crates/tranquil-oauth-server/src/endpoints/authorize/consent.rs @@ -116,26 +116,30 @@ pub async fn consent_get( None }; - let effective_scope_str = if let Some(ref grant) = delegation_grant { - tranquil_pds::delegation::intersect_scopes( - requested_scope_str, - grant.granted_scopes.as_str(), - ) - } else { - requested_scope_str.to_string() + let authority = match delegation_grant.as_ref() { + Some(grant) => scope_resolution::Authority::Delegated(grant.granted_scopes.as_str()), + None => scope_resolution::Authority::FullSelf, }; - - let expanded_scope_str = match expand_include_scopes(&effective_scope_str).await { - Ok(s) => s, - Err(e) => { - return json_error( - StatusCode::BAD_REQUEST, - "invalid_scope", - &format!("Failed to expand permission set: {e}"), - ); - } - }; - let requested_scopes: Vec<&str> = expanded_scope_str.split_whitespace().collect(); + let effective = scope_resolution::resolve_effective_scopes( + &*state.cache, + requested_scope_str, + authority, + ) + .await; + if !effective.outcome.failures.is_empty() { + let names: Vec = effective + .outcome + .failures + .iter() + .map(|f| f.nsid.clone()) + .collect(); + return json_error( + StatusCode::BAD_REQUEST, + "invalid_scope", + &format!("Could not resolve permission set(s): {}", names.join(", ")), + ); + } + let requested_scopes: Vec<&str> = effective.resolved.split_whitespace().collect(); let preferences = state .repos .oauth @@ -342,15 +346,30 @@ pub async fn consent_post( None => None, }; - let effective_scope_str = if let Some(ref grant) = delegation_grant { - tranquil_pds::delegation::intersect_scopes( - original_scope_str, - grant.granted_scopes.as_str(), - ) - } else { - original_scope_str.to_string() + let authority = match delegation_grant.as_ref() { + Some(grant) => scope_resolution::Authority::Delegated(grant.granted_scopes.as_str()), + None => scope_resolution::Authority::FullSelf, }; - let requested_scopes: Vec<&str> = effective_scope_str.split_whitespace().collect(); + let effective = scope_resolution::resolve_effective_scopes( + &*state.cache, + original_scope_str, + authority, + ) + .await; + if !effective.outcome.failures.is_empty() { + let names: Vec = effective + .outcome + .failures + .iter() + .map(|f| f.nsid.clone()) + .collect(); + return json_error( + StatusCode::BAD_REQUEST, + "invalid_scope", + &format!("Could not resolve permission set(s): {}", names.join(", ")), + ); + } + let requested_scopes: Vec<&str> = effective.resolved.split_whitespace().collect(); let atproto_was_requested = requested_scopes.contains(&"atproto"); if atproto_was_requested && !form.approved_scopes.contains(&"atproto".to_string()) { return json_error( diff --git a/crates/tranquil-oauth-server/src/endpoints/authorize/mod.rs b/crates/tranquil-oauth-server/src/endpoints/authorize/mod.rs index 6eb1c3c..2b37337 100644 --- a/crates/tranquil-oauth-server/src/endpoints/authorize/mod.rs +++ b/crates/tranquil-oauth-server/src/endpoints/authorize/mod.rs @@ -15,7 +15,7 @@ use tranquil_pds::auth::{BareLoginIdentifier, NormalizedLoginIdentifier}; use tranquil_pds::comms::comms_repo::enqueue_2fa_code; use tranquil_pds::oauth::{ AuthFlow, ClientMetadataCache, DeviceData, DeviceId, OAuthError, Prompt, SessionId, - db::should_show_consent, scopes::expand_include_scopes, + db::should_show_consent, }; use tranquil_pds::rate_limit::{ OAuthAuthorizeLimit, OAuthRateLimited, OAuthRegisterCompleteLimit, TotpVerifyLimit, @@ -300,6 +300,7 @@ mod consent; mod login; mod passkey; mod registration; +pub mod scope_resolution; mod two_factor; pub use consent::*; diff --git a/crates/tranquil-oauth-server/src/endpoints/authorize/scope_resolution.rs b/crates/tranquil-oauth-server/src/endpoints/authorize/scope_resolution.rs new file mode 100644 index 0000000..e09089e --- /dev/null +++ b/crates/tranquil-oauth-server/src/endpoints/authorize/scope_resolution.rs @@ -0,0 +1,80 @@ +use tranquil_pds::cache::Cache; +use tranquil_pds::delegation::intersect_scopes; +use tranquil_pds::oauth::permission_set_resolver::expand_scopes; +use tranquil_scopes::ExpansionOutcome; + +pub enum Authority<'a> { + FullSelf, + Delegated(&'a str), +} + +pub struct EffectiveScopes { + pub resolved: String, + pub outcome: ExpansionOutcome, +} + +pub async fn resolve_effective_scopes( + cache: &dyn Cache, + requested: &str, + authority: Authority<'_>, +) -> EffectiveScopes { + let outcome = expand_scopes(cache, requested).await; + let expanded = outcome.to_scope_string(); + let resolved = match authority { + Authority::FullSelf => expanded, + Authority::Delegated(granted) => intersect_scopes(&expanded, granted), + }; + EffectiveScopes { resolved, outcome } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + use std::sync::Mutex; + use std::time::Duration; + use tranquil_pds::cache::{Cache, CacheError}; + + #[derive(Default)] + struct MapCache(Mutex>); + #[async_trait::async_trait] + impl Cache for MapCache { + async fn get(&self, k: &str) -> Option { self.0.lock().unwrap().get(k).cloned() } + async fn set(&self, k: &str, v: &str, _t: Duration) -> Result<(), CacheError> { + self.0.lock().unwrap().insert(k.into(), v.into()); Ok(()) + } + async fn delete(&self, k: &str) -> Result<(), CacheError> { self.0.lock().unwrap().remove(k); Ok(()) } + async fn get_bytes(&self, _k: &str) -> Option> { None } + async fn set_bytes(&self, _k: &str, _v: &[u8], _t: Duration) -> Result<(), CacheError> { Ok(()) } + } + + fn cache_with(nsid: &str, scopes: &str) -> MapCache { + let c = MapCache::default(); + let key = tranquil_pds::cache_keys::permission_set_key(nsid, None); + let json = serde_json::json!({ "scope": scopes, "title": null, "detail": null }).to_string(); + c.0.lock().unwrap().insert(key, json); + c + } + + #[tokio::test] + async fn full_self_keeps_all_expanded() { + let c = cache_with("io.atcr.authFullApp", "repo:io.atcr.manifest?action=create identity:*"); + let eff = resolve_effective_scopes(&c, "atproto include:io.atcr.authFullApp", Authority::FullSelf).await; + assert!(eff.resolved.contains("atproto")); + assert!(eff.resolved.contains("repo:io.atcr.manifest?action=create")); + assert!(eff.resolved.contains("identity:*")); + assert!(eff.outcome.failures.is_empty()); + } + + #[tokio::test] + async fn delegated_intersects_expanded_against_grant() { + let c = cache_with("io.atcr.authFullApp", "repo:io.atcr.manifest?action=create identity:*"); + let eff = resolve_effective_scopes( + &c, "atproto include:io.atcr.authFullApp", + Authority::Delegated("atproto repo:* blob:*/* account:*?action=manage"), + ).await; + assert!(eff.resolved.contains("atproto")); + assert!(eff.resolved.contains("repo:io.atcr.manifest?action=create")); + assert!(!eff.resolved.contains("identity")); + } +} diff --git a/crates/tranquil-oauth-server/src/endpoints/token/grants.rs b/crates/tranquil-oauth-server/src/endpoints/token/grants.rs index 275a754..8359359 100644 --- a/crates/tranquil-oauth-server/src/endpoints/token/grants.rs +++ b/crates/tranquil-oauth-server/src/endpoints/token/grants.rs @@ -7,12 +7,10 @@ use axum::http::{HeaderMap, Method}; use chrono::{Duration, Utc}; use tranquil_db_traits::RefreshTokenLookup; use tranquil_pds::config::AuthConfig; -use tranquil_pds::delegation::intersect_scopes; use tranquil_pds::oauth::{ AuthFlow, ClientAuth, ClientMetadataCache, DPoPVerifier, OAuthError, RefreshToken, TokenData, TokenId, db::{enforce_token_limit_for_user, lookup_refresh_token}, - scopes::expand_include_scopes, verify_client_auth, }; use tranquil_pds::state::AppState; @@ -132,46 +130,54 @@ pub async fn handle_authorization_code_grant( let refresh_token = RefreshToken::generate(); let now = Utc::now(); - let (raw_scope, controller_did) = if let Some(ref controller) = authorized.controller_did { + let controller_did = authorized.controller_did.clone(); + let requested_scope = authorized.parameters.scope.clone(); + + let granted_scopes: Option = if let Some(ref controller) = controller_did { let grant = state .repos .delegation .get_delegation(&did, controller) .await .ok() - .flatten(); - let granted_scopes = match grant { - Some(g) => g.granted_scopes, - None => { - return Err(OAuthError::InvalidGrant( - "Delegation grant not found or revoked".to_string(), - )); - } - }; - let requested = authorized.parameters.scope.as_deref().unwrap_or("atproto"); - let intersected = intersect_scopes(requested, granted_scopes.as_str()); - (Some(intersected), Some(controller.clone())) + .flatten() + .ok_or_else(|| { + OAuthError::InvalidGrant("Delegation grant not found or revoked".to_string()) + })?; + Some(grant.granted_scopes.as_str().to_string()) } else { - (authorized.parameters.scope.clone(), None) + None }; - - let final_scope = if let Some(ref scope) = raw_scope { - if scope.contains("include:") { - Some(expand_include_scopes(scope).await.map_err(|e| { - OAuthError::InvalidScope(format!("Failed to expand permission set: {e}")) - })?) - } else { - raw_scope - } - } else { - raw_scope + let authority = match granted_scopes.as_deref() { + Some(g) => crate::endpoints::authorize::scope_resolution::Authority::Delegated(g), + None => crate::endpoints::authorize::scope_resolution::Authority::FullSelf, }; + let requested_for_resolve = requested_scope.as_deref().unwrap_or("atproto"); + let effective = crate::endpoints::authorize::scope_resolution::resolve_effective_scopes( + &*state.cache, + requested_for_resolve, + authority, + ) + .await; + if !effective.outcome.failures.is_empty() { + let names: Vec = effective + .outcome + .failures + .iter() + .map(|f| f.nsid.clone()) + .collect(); + return Err(OAuthError::InvalidScope(format!( + "Could not resolve permission set(s): {}", + names.join(", ") + ))); + } + let resolved_scope = effective.resolved; let access_token = create_access_token_with_delegation( &token_id, &did, dpop_jkt.as_ref(), - final_scope.as_deref(), + Some(resolved_scope.as_str()), controller_did.as_ref(), )?; let stored_client_auth = authorized.client_auth.unwrap_or(ClientAuth::None); @@ -195,7 +201,7 @@ pub async fn handle_authorization_code_grant( details: None, code: None, current_refresh_token: Some(refresh_token.clone()), - scope: final_scope.clone(), + scope: requested_scope.clone(), controller_did: controller_did.clone(), }; state @@ -237,12 +243,57 @@ pub async fn handle_authorization_code_grant( }, expires_in: ACCESS_TOKEN_EXPIRY_SECONDS, refresh_token: Some(refresh_token), - scope: final_scope, + scope: Some(resolved_scope.clone()), sub: Some(did), }), )) } +async fn recompute_resolved_scope( + state: &AppState, + token_data: &TokenData, +) -> Result { + let requested = token_data.scope.as_deref().unwrap_or("atproto"); + let granted_scopes: Option = if let Some(ref controller) = token_data.controller_did { + let grant = state + .repos + .delegation + .get_delegation(&token_data.did, controller) + .await + .ok() + .flatten() + .ok_or_else(|| { + OAuthError::InvalidGrant("Delegation grant not found or revoked".to_string()) + })?; + Some(grant.granted_scopes.as_str().to_string()) + } else { + None + }; + let authority = match granted_scopes.as_deref() { + Some(g) => crate::endpoints::authorize::scope_resolution::Authority::Delegated(g), + None => crate::endpoints::authorize::scope_resolution::Authority::FullSelf, + }; + let effective = crate::endpoints::authorize::scope_resolution::resolve_effective_scopes( + &*state.cache, + requested, + authority, + ) + .await; + if !effective.outcome.failures.is_empty() { + let names: Vec = effective + .outcome + .failures + .iter() + .map(|f| f.nsid.clone()) + .collect(); + return Err(OAuthError::InvalidScope(format!( + "Permission set(s) expired and unresolvable: {}", + names.join(", ") + ))); + } + Ok(effective.resolved) +} + pub async fn handle_refresh_token_grant( state: AppState, _headers: HeaderMap, @@ -282,11 +333,12 @@ pub async fn handle_refresh_token_grant( "Refresh token reuse within grace period, returning existing tokens" ); let dpop_jkt = token_data.parameters.dpop_jkt.as_ref(); + let resolved = recompute_resolved_scope(&state, &token_data).await?; let access_token = create_access_token_with_delegation( &token_data.token_id, &token_data.did, dpop_jkt, - token_data.scope.as_deref(), + Some(resolved.as_str()), token_data.controller_did.as_ref(), )?; let mut response_headers = HeaderMap::new(); @@ -307,7 +359,7 @@ pub async fn handle_refresh_token_grant( }, expires_in: ACCESS_TOKEN_EXPIRY_SECONDS, refresh_token: token_data.current_refresh_token, - scope: token_data.scope, + scope: Some(resolved), sub: Some(token_data.did), }), )); @@ -396,11 +448,12 @@ pub async fn handle_refresh_token_grant( new_expires_at = %new_expires_at, "Refresh token rotated successfully" ); + let resolved = recompute_resolved_scope(&state, &token_data).await?; let access_token = create_access_token_with_delegation( &token_data.token_id, &token_data.did, dpop_jkt.as_ref(), - token_data.scope.as_deref(), + Some(resolved.as_str()), token_data.controller_did.as_ref(), )?; let mut response_headers = HeaderMap::new(); @@ -421,7 +474,7 @@ pub async fn handle_refresh_token_grant( }, expires_in: ACCESS_TOKEN_EXPIRY_SECONDS, refresh_token: Some(new_refresh_token), - scope: token_data.scope, + scope: Some(resolved), sub: Some(token_data.did), }), )) diff --git a/crates/tranquil-oauth-server/src/endpoints/token/introspect.rs b/crates/tranquil-oauth-server/src/endpoints/token/introspect.rs index 35204b5..d1669dd 100644 --- a/crates/tranquil-oauth-server/src/endpoints/token/introspect.rs +++ b/crates/tranquil-oauth-server/src/endpoints/token/introspect.rs @@ -106,6 +106,10 @@ pub async fn introspect_token( Ok(info) => info, Err(_) => return Ok(Json(inactive_response)), }; + let jwt_info = match tranquil_pds::oauth::verify::extract_oauth_token_info(&request.token) { + Ok(info) => info, + Err(_) => return Ok(Json(inactive_response)), + }; let token_id = TokenId::from(token_info.sid.clone()); let token_data = match state.repos.oauth.get_token_by_id(&token_id).await { Ok(Some(data)) => data, @@ -118,7 +122,7 @@ pub async fn introspect_token( let issuer = format!("https://{}", pds_hostname); Ok(Json(IntrospectResponse { active: true, - scope: token_data.scope, + scope: jwt_info.scope, client_id: Some(token_data.client_id), username: None, token_type: if token_data.parameters.dpop_jkt.is_some() { @@ -129,7 +133,7 @@ pub async fn introspect_token( exp: Some(token_info.exp), iat: Some(token_info.iat), nbf: Some(token_info.iat), - sub: Some(token_data.did.to_string()), + sub: Some(jwt_info.did.to_string()), aud: Some(issuer.clone()), iss: Some(issuer), jti: Some(token_info.jti), diff --git a/crates/tranquil-pds/src/oauth/verify.rs b/crates/tranquil-pds/src/oauth/verify.rs index 9dc1c1d..da1b630 100644 --- a/crates/tranquil-pds/src/oauth/verify.rs +++ b/crates/tranquil-pds/src/oauth/verify.rs @@ -96,7 +96,7 @@ pub async fn verify_oauth_access_token( did: token_data.did, token_id, client_id: token_data.client_id, - scope: token_data.scope, + scope: token_info.scope, }) } diff --git a/crates/tranquil-pds/tests/oauth_permission_sets.rs b/crates/tranquil-pds/tests/oauth_permission_sets.rs new file mode 100644 index 0000000..da779e1 --- /dev/null +++ b/crates/tranquil-pds/tests/oauth_permission_sets.rs @@ -0,0 +1,695 @@ +mod common; +mod helpers; + +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use chrono::Utc; +use common::{base_url, client, create_account_and_login}; +use helpers::verify_new_account; +use reqwest::StatusCode; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use tranquil_types::TokenId; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +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 generate_pkce() -> (String, String) { + let verifier_bytes: [u8; 32] = rand::random(); + let code_verifier = URL_SAFE_NO_PAD.encode(verifier_bytes); + let mut hasher = Sha256::new(); + hasher.update(code_verifier.as_bytes()); + let hash = hasher.finalize(); + let code_challenge = URL_SAFE_NO_PAD.encode(hash); + (code_verifier, code_challenge) +} + +async fn setup_mock_client_metadata(redirect_uri: &str) -> MockServer { + let mock_server = MockServer::start().await; + let client_id = mock_server.uri(); + let metadata = json!({ + "client_id": client_id, + "client_name": "Test Permission Set Client", + "redirect_uris": [redirect_uri], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "token_endpoint_auth_method": "none", + "dpop_bound_access_tokens": false + }); + Mock::given(method("GET")) + .and(path("/")) + .respond_with(ResponseTemplate::new(200).set_body_json(metadata)) + .mount(&mock_server) + .await; + mock_server +} + +async fn seed_permission_set(nsid: &str, granular_scope: &str) { + let state = common::get_test_app_state().await; + let key = tranquil_pds::cache_keys::permission_set_key(nsid, None); + let val = json!({ + "scope": granular_scope, + "title": "Basic", + "detail": null + }) + .to_string(); + state + .cache + .set(&key, &val, std::time::Duration::from_secs(3600)) + .await + .unwrap(); +} + +fn decode_jwt_payload(jwt: &str) -> Value { + let parts: Vec<&str> = jwt.split('.').collect(); + assert_eq!(parts.len(), 3, "Token should be a valid JWT"); + let payload_json = URL_SAFE_NO_PAD.decode(parts[1]).unwrap(); + serde_json::from_slice(&payload_json).unwrap() +} + +fn token_id_from_jwt(jwt: &str) -> TokenId { + let payload = decode_jwt_payload(jwt); + let sid = payload["sid"] + .as_str() + .expect("Token payload should contain sid claim"); + TokenId::new(sid) +} + +struct DelegatedSession { + access_token: String, + #[allow(dead_code)] + refresh_token: String, + delegated_did: String, + #[allow(dead_code)] + controller_did: String, + #[allow(dead_code)] + client_id: String, +} + +async fn create_delegated_session_with_scope( + handle_prefix: &str, + redirect_uri: &str, + scope: &str, +) -> (DelegatedSession, Value, MockServer) { + let url = base_url().await; + 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!("{}{}", handle_prefix, 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 mock_client = setup_mock_client_metadata(redirect_uri).await; + let client_id = mock_client.uri(); + let (code_verifier, code_challenge) = generate_pkce(); + + let par_res = http_client + .post(format!("{}/oauth/par", url)) + .form(&[ + ("response_type", "code"), + ("client_id", &client_id), + ("redirect_uri", redirect_uri), + ("code_challenge", &code_challenge), + ("code_challenge_method", "S256"), + ("scope", scope), + ("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 approved_scopes: Vec<&str> = scope.split_whitespace().collect(); + 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 session = DelegatedSession { + access_token: token_body["access_token"].as_str().unwrap().to_string(), + refresh_token: token_body["refresh_token"].as_str().unwrap().to_string(), + delegated_did, + controller_did, + client_id, + }; + (session, consent_get_body, mock_client) +} + +#[tokio::test] +async fn test_delegated_include_scope_shows_granular_on_consent() { + seed_permission_set(PERMISSION_SET_NSID, PERMISSION_SET_GRANULAR_SCOPE).await; + + let scope = format!("atproto include:{}", PERMISSION_SET_NSID); + let (_session, consent_body, _mock) = create_delegated_session_with_scope( + "psc", + "https://example.com/permset-consent-callback", + &scope, + ) + .await; + + let scopes = consent_body["scopes"] + .as_array() + .expect("consent response should have a scopes array"); + assert!(!scopes.is_empty(), "consent scopes should not be empty"); + + let has_granular = scopes + .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 + ); + + 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 has_raw_include = scopes.iter().any(|s| { + s["scope"] + .as_str() + .map(|sc| sc.starts_with("include:")) + .unwrap_or(false) + }); + assert!( + !has_raw_include, + "consent scopes[] should list the expanded set, not the raw include: token" + ); +} + +#[tokio::test] +async fn test_grant_row_keeps_include_jwt_carries_expanded() { + seed_permission_set(PERMISSION_SET_NSID, PERMISSION_SET_GRANULAR_SCOPE).await; + + let scope = format!("atproto include:{}", PERMISSION_SET_NSID); + let (session, _consent_body, _mock) = create_delegated_session_with_scope( + "psg", + "https://example.com/permset-grant-callback", + &scope, + ) + .await; + + let payload = decode_jwt_payload(&session.access_token); + let jwt_scope = payload["scope"] + .as_str() + .expect("access token JWT should have a scope claim"); + assert!( + jwt_scope.contains("repo:io.atcr.manifest?action=create"), + "JWT scope claim should carry the expanded granular scope, got: {}", + jwt_scope + ); + assert!( + !jwt_scope.contains("include:"), + "JWT scope claim should not carry the raw include: token, got: {}", + jwt_scope + ); + + let token_id = token_id_from_jwt(&session.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(&format!("include:{}", PERMISSION_SET_NSID)), + "Stored oauth_token.scope row should still preserve the include: token, got: {}", + row_scope + ); +} + +#[tokio::test] +async fn test_introspect_reads_expanded_jwt_scope() { + seed_permission_set(PERMISSION_SET_NSID, PERMISSION_SET_GRANULAR_SCOPE).await; + + let scope = format!("atproto include:{}", PERMISSION_SET_NSID); + let (session, _consent_body, _mock) = create_delegated_session_with_scope( + "psi", + "https://example.com/permset-introspect-callback", + &scope, + ) + .await; + + let url = base_url().await; + let http_client = client(); + let introspect_res = http_client + .post(format!("{}/oauth/introspect", url)) + .form(&[("token", session.access_token.as_str())]) + .send() + .await + .expect("introspect request failed"); + assert_eq!(introspect_res.status(), StatusCode::OK); + let introspect_body: Value = introspect_res.json().await.unwrap(); + + assert_eq!( + introspect_body["active"].as_bool(), + Some(true), + "token should be active" + ); + let introspect_scope = introspect_body["scope"] + .as_str() + .expect("introspect response should have a scope string"); + assert!( + introspect_scope.contains("repo:io.atcr.manifest?action=create"), + "introspect scope should contain the expanded granular scope, got: {}", + introspect_scope + ); + assert!( + !introspect_scope.contains("include:"), + "introspect scope should not contain the raw include: token, got: {}", + introspect_scope + ); +} + +#[tokio::test] +async fn test_enforcement_uses_expanded_jwt_scope() { + seed_permission_set(PERMISSION_SET_NSID, PERMISSION_SET_GRANULAR_SCOPE).await; + + let scope = format!("atproto include:{}", PERMISSION_SET_NSID); + let (session, _consent_body, _mock) = create_delegated_session_with_scope( + "pse", + "https://example.com/permset-enforce-callback", + &scope, + ) + .await; + + let url = base_url().await; + let http_client = client(); + let collection = "io.atcr.manifest"; + let create_res = http_client + .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) + .bearer_auth(&session.access_token) + .json(&json!({ + "repo": session.delegated_did, + "collection": collection, + "validate": false, + "record": { + "$type": collection, + "note": "permission set enforcement test", + "createdAt": Utc::now().to_rfc3339() + } + })) + .send() + .await + .expect("createRecord request failed"); + + assert_ne!( + create_res.status(), + StatusCode::FORBIDDEN, + "createRecord for a collection covered by the permission set's expanded scope \ + should not be forbidden -- enforcement must read the expanded JWT scope, not \ + the include:-only stored row. Got body: {:?}", + create_res.text().await + ); +} + +#[tokio::test] +async fn test_consent_post_errors_when_set_unresolvable() { + const UNRESOLVABLE_NSID: &str = "io.atcr.authUnresolvableSet"; + seed_permission_set(UNRESOLVABLE_NSID, PERMISSION_SET_GRANULAR_SCOPE).await; + + let url = base_url().await; + 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!("psu{}", 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-unresolvable-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:{}", UNRESOLVABLE_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 state = common::get_test_app_state().await; + let key = tranquil_pds::cache_keys::permission_set_key(UNRESOLVABLE_NSID, None); + state.cache.delete(&key).await.unwrap(); + + let approved_scopes: Vec<&str> = scope.split_whitespace().collect(); + 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"); + assert_eq!( + consent_post_res.status(), + StatusCode::BAD_REQUEST, + "Consent POST must fail closed (400) when the include: set can no longer be \ + resolved, instead of silently persisting/granting truncated scopes" + ); + let error_body: Value = consent_post_res.json().await.unwrap(); + assert_eq!( + error_body["error"].as_str(), + Some("invalid_scope"), + "Expected invalid_scope error, got: {:?}", + error_body + ); +} + +#[tokio::test] +async fn test_legacy_granular_token_survives_refresh() { + let url = base_url().await; + let http_client = client(); + let redirect_uri = "https://example.com/permset-legacy-callback"; + let scope = "atproto repo:*?action=create"; + + let suffix = &uuid::Uuid::new_v4().simple().to_string()[..4]; + let handle = format!("psl{}", suffix); + let email = format!("psl{}@example.com", suffix); + let password = "LegacyPass123!"; + + let create_res = http_client + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) + .json(&json!({ + "handle": handle, + "email": email, + "password": password + })) + .send() + .await + .unwrap(); + assert_eq!(create_res.status(), StatusCode::OK); + let account: Value = create_res.json().await.unwrap(); + let user_did = account["did"].as_str().unwrap().to_string(); + let _ = verify_new_account(&http_client, &user_did).await; + + let mock_client = setup_mock_client_metadata(redirect_uri).await; + let client_id = mock_client.uri(); + let (code_verifier, code_challenge) = generate_pkce(); + + let par_res = http_client + .post(format!("{}/oauth/par", url)) + .form(&[ + ("response_type", "code"), + ("client_id", &client_id), + ("redirect_uri", redirect_uri), + ("code_challenge", &code_challenge), + ("code_challenge_method", "S256"), + ("scope", scope), + ]) + .send() + .await + .expect("PAR failed"); + assert!(par_res.status() == StatusCode::OK || par_res.status() == StatusCode::CREATED); + let par_body: Value = par_res.json().await.unwrap(); + let request_uri = par_body["request_uri"].as_str().unwrap(); + + let auth_res = http_client + .post(format!("{}/oauth/authorize", url)) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .json(&json!({ + "request_uri": request_uri, + "username": &handle, + "password": password, + "remember_device": false + })) + .send() + .await + .expect("Authorize failed"); + assert_eq!(auth_res.status(), StatusCode::OK); + let auth_body: Value = auth_res.json().await.unwrap(); + let mut location = auth_body["redirect_uri"] + .as_str() + .expect("Expected redirect_uri") + .to_string(); + if location.contains("/oauth/consent") { + let consent_res = http_client + .post(format!("{}/oauth/authorize/consent", url)) + .header("Content-Type", "application/json") + .json(&json!({ + "request_uri": request_uri, + "approved_scopes": scope.split_whitespace().collect::>(), + "remember": false + })) + .send() + .await + .expect("Consent request failed"); + assert_eq!(consent_res.status(), StatusCode::OK); + let consent_body: Value = consent_res.json().await.unwrap(); + location = consent_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); + let token_body: Value = token_res.json().await.unwrap(); + let access_token = token_body["access_token"].as_str().unwrap().to_string(); + let refresh_token = token_body["refresh_token"].as_str().unwrap().to_string(); + + let original_payload = decode_jwt_payload(&access_token); + let original_scope = original_payload["scope"].as_str().unwrap(); + assert!(original_scope.contains("repo:*?action=create")); + + let refresh_res = http_client + .post(format!("{}/oauth/token", url)) + .form(&[ + ("grant_type", "refresh_token"), + ("refresh_token", refresh_token.as_str()), + ("client_id", &client_id), + ]) + .send() + .await + .expect("Refresh request failed"); + assert_eq!( + refresh_res.status(), + StatusCode::OK, + "Refreshing a legacy granular-scope token should succeed" + ); + let refresh_body: Value = refresh_res.json().await.unwrap(); + let new_access_token = refresh_body["access_token"].as_str().unwrap(); + assert_ne!(new_access_token, access_token); + + let new_scope_from_response = refresh_body["scope"] + .as_str() + .expect("refresh response should include scope"); + assert!( + new_scope_from_response.contains("repo:*?action=create"), + "Refresh response scope should still contain the granular scope, got: {}", + new_scope_from_response + ); + + let new_payload = decode_jwt_payload(new_access_token); + let new_jwt_scope = new_payload["scope"] + .as_str() + .expect("new JWT should have a scope claim"); + assert!( + new_jwt_scope.contains("repo:*?action=create"), + "New JWT scope claim should still contain the granular scope after refresh, got: {}", + new_jwt_scope + ); +}