mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-08-01 15:06:05 +00:00
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:
Generated
+2
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user