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

Signed-off-by: Trezy <tre@trezy.com>
This commit is contained in:
Trezy
2026-07-24 20:11:26 +03:00
committed by Tangled
parent ecdda4c555
commit 348ac887fc
9 changed files with 923 additions and 65 deletions
Generated
+2
View File
@@ -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",
+4
View File
@@ -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 }
@@ -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<String> = effective
.outcome
.failures
.iter()
.map(|f| f.nsid.clone())
.collect();
return json_error(
StatusCode::BAD_REQUEST,
"invalid_scope",
&format!("Could not resolve permission set(s): {}", names.join(", ")),
);
}
let requested_scopes: Vec<&str> = effective.resolved.split_whitespace().collect();
let preferences = state
.repos
.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<String> = effective
.outcome
.failures
.iter()
.map(|f| f.nsid.clone())
.collect();
return json_error(
StatusCode::BAD_REQUEST,
"invalid_scope",
&format!("Could not resolve permission set(s): {}", names.join(", ")),
);
}
let requested_scopes: Vec<&str> = effective.resolved.split_whitespace().collect();
let atproto_was_requested = requested_scopes.contains(&"atproto");
if atproto_was_requested && !form.approved_scopes.contains(&"atproto".to_string()) {
return json_error(
@@ -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::*;
@@ -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<HashMap<String, String>>);
#[async_trait::async_trait]
impl Cache for MapCache {
async fn get(&self, k: &str) -> Option<String> { 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<Vec<u8>> { 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"));
}
}
@@ -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<String> = 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<String> = 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<String, OAuthError> {
let requested = token_data.scope.as_deref().unwrap_or("atproto");
let granted_scopes: Option<String> = 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<String> = 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),
}),
))
@@ -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),
+1 -1
View File
@@ -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,
})
}
@@ -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::<Vec<_>>(),
"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
);
}