mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-09-20 17:24:15 +00:00
fix: unrecognized scopes can no longer fail PAR
Signed-off-by: Trezy <tre@trezy.com>
This commit is contained in:
@@ -43,6 +43,13 @@ pub struct FailedSetInfo {
|
||||
pub reason: tranquil_scopes::ResolveFailure,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct RejectedScopeInfo {
|
||||
// The scope exactly as the client requested it, which may be invalid or malformed.
|
||||
pub scope: String,
|
||||
pub reason: tranquil_scopes::ScopeRejection,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ConsentResponse {
|
||||
pub request_uri: String,
|
||||
@@ -54,6 +61,7 @@ pub struct ConsentResponse {
|
||||
pub permission_sets: Vec<PermissionSetInfo>,
|
||||
pub transition_supersedes: bool,
|
||||
pub failed_sets: Vec<FailedSetInfo>,
|
||||
pub rejected_scopes: Vec<RejectedScopeInfo>,
|
||||
pub show_consent: bool,
|
||||
pub did: Did,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -156,9 +164,13 @@ pub async fn consent_get(
|
||||
Some(grant) => scope_resolution::Authority::Delegated(&grant.granted_scopes),
|
||||
None => scope_resolution::Authority::FullSelf,
|
||||
};
|
||||
let effective =
|
||||
scope_resolution::resolve_effective_scopes(&*state.cache, requested_scope_str, authority)
|
||||
.await;
|
||||
let effective = scope_resolution::resolve_effective_scopes(
|
||||
&*state.cache,
|
||||
requested_scope_str,
|
||||
authority,
|
||||
client_metadata.as_ref().and_then(|m| m.scope.as_deref()),
|
||||
)
|
||||
.await;
|
||||
let requested_scopes: Vec<&str> = effective.permitted.split_whitespace().collect();
|
||||
let preferences = state
|
||||
.repos
|
||||
@@ -175,10 +187,7 @@ pub async fn consent_get(
|
||||
.passthrough
|
||||
.iter()
|
||||
.cloned()
|
||||
.chain(effective.outcome.sets.iter().map(|g| match &g.aud {
|
||||
Some(a) => format!("include:{}?aud={}", g.nsid, a),
|
||||
None => format!("include:{}", g.nsid),
|
||||
}))
|
||||
.chain(effective.outcome.sets.iter().map(|g| g.include_token()))
|
||||
.collect();
|
||||
let show_consent = should_show_consent(
|
||||
state.repos.oauth.as_ref(),
|
||||
@@ -271,10 +280,7 @@ pub async fn consent_get(
|
||||
.sets
|
||||
.iter()
|
||||
.map(|g| {
|
||||
let include_scope = match &g.aud {
|
||||
Some(a) => format!("include:{}?aud={}", g.nsid, a),
|
||||
None => format!("include:{}", g.nsid),
|
||||
};
|
||||
let include_scope = g.include_token();
|
||||
let expanded: Vec<ScopeInfo> = g.expanded.iter().map(|s| make_scope_info(s)).collect();
|
||||
let restricted = !expanded.is_empty() && expanded.iter().all(|s| s.restricted);
|
||||
let superseded = !expanded.is_empty() && expanded.iter().all(|s| s.superseded);
|
||||
@@ -303,6 +309,16 @@ pub async fn consent_get(
|
||||
})
|
||||
.collect();
|
||||
|
||||
let rejected_scopes: Vec<RejectedScopeInfo> = effective
|
||||
.outcome
|
||||
.rejected
|
||||
.iter()
|
||||
.map(|r| RejectedScopeInfo {
|
||||
scope: r.scope.clone(),
|
||||
reason: r.reason,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let account_handle = state
|
||||
.repos
|
||||
.user
|
||||
@@ -357,6 +373,7 @@ pub async fn consent_get(
|
||||
permission_sets,
|
||||
transition_supersedes,
|
||||
failed_sets,
|
||||
rejected_scopes,
|
||||
show_consent,
|
||||
did: did.clone(),
|
||||
handle: account_handle,
|
||||
@@ -448,9 +465,19 @@ pub async fn consent_post(
|
||||
Some(grant) => scope_resolution::Authority::Delegated(&grant.granted_scopes),
|
||||
None => scope_resolution::Authority::FullSelf,
|
||||
};
|
||||
let effective =
|
||||
scope_resolution::resolve_effective_scopes(&*state.cache, original_scope_str, authority)
|
||||
.await;
|
||||
let client_scope = state
|
||||
.client_metadata_cache
|
||||
.get(&request_data.parameters.client_id)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|m| m.scope);
|
||||
let effective = scope_resolution::resolve_effective_scopes(
|
||||
&*state.cache,
|
||||
original_scope_str,
|
||||
authority,
|
||||
client_scope.as_deref(),
|
||||
)
|
||||
.await;
|
||||
let include_token = |nsid: &str, aud: &Option<String>| -> String {
|
||||
match aud {
|
||||
Some(a) => format!("include:{}?aud={}", nsid, a),
|
||||
@@ -482,13 +509,7 @@ pub async fn consent_post(
|
||||
.passthrough
|
||||
.iter()
|
||||
.cloned()
|
||||
.chain(
|
||||
effective
|
||||
.outcome
|
||||
.sets
|
||||
.iter()
|
||||
.map(|g| include_token(&g.nsid, &g.aud)),
|
||||
)
|
||||
.chain(effective.outcome.sets.iter().map(|g| g.include_token()))
|
||||
.collect();
|
||||
let atproto_was_requested = presented_items.iter().any(|s| s == "atproto");
|
||||
if atproto_was_requested && !form.approved_scopes.contains(&"atproto".to_string()) {
|
||||
|
||||
@@ -78,12 +78,10 @@ fn is_granular_scope(s: &str) -> bool {
|
||||
}
|
||||
|
||||
fn is_valid_scope(s: &str) -> bool {
|
||||
s == "atproto"
|
||||
|| s == "transition:generic"
|
||||
|| s == "transition:chat.bsky"
|
||||
|| s == "transition:email"
|
||||
|| is_granular_scope(s)
|
||||
|| s.starts_with("include:")
|
||||
!matches!(
|
||||
tranquil_pds::oauth::scopes::parse_scope(s),
|
||||
tranquil_pds::oauth::scopes::ParsedScope::Unknown(_)
|
||||
)
|
||||
}
|
||||
|
||||
fn extract_device_cookie(headers: &HeaderMap) -> Option<tranquil_types::DeviceId> {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use tranquil_db_traits::DbScope;
|
||||
use tranquil_pds::cache::Cache;
|
||||
use tranquil_pds::delegation::intersect_scopes;
|
||||
use tranquil_pds::delegation::{GrantCoverage, grant_coverage, intersect_scopes};
|
||||
use tranquil_pds::oauth::permission_set_resolver::expand_scopes;
|
||||
use tranquil_scopes::ExpansionOutcome;
|
||||
use tranquil_scopes::{ExpansionOutcome, RejectedScope, ScopeRejection};
|
||||
|
||||
pub enum Authority<'a> {
|
||||
FullSelf,
|
||||
@@ -20,8 +20,12 @@ pub async fn resolve_effective_scopes(
|
||||
cache: &dyn Cache,
|
||||
requested: &str,
|
||||
authority: Authority<'_>,
|
||||
client_scope: Option<&str>,
|
||||
) -> EffectiveScopes {
|
||||
let outcome = expand_scopes(cache, requested).await;
|
||||
let mut outcome = expand_scopes(cache, requested).await;
|
||||
if let Some(registered) = client_scope.map(str::trim).filter(|s| !s.is_empty()) {
|
||||
reject_unregistered(&mut outcome, registered);
|
||||
}
|
||||
let expanded = outcome.to_scope_string();
|
||||
let permitted = match authority {
|
||||
Authority::FullSelf => expanded,
|
||||
@@ -30,6 +34,32 @@ pub async fn resolve_effective_scopes(
|
||||
EffectiveScopes { permitted, outcome }
|
||||
}
|
||||
|
||||
fn reject_unregistered(outcome: &mut ExpansionOutcome, registered: &str) {
|
||||
let mut rejected = Vec::new();
|
||||
let mut keep = |scope: String| match grant_coverage(registered, &scope) {
|
||||
GrantCoverage::Full => Some(scope),
|
||||
GrantCoverage::Narrowed(narrowed) => Some(narrowed),
|
||||
GrantCoverage::Withheld => {
|
||||
rejected.push(RejectedScope {
|
||||
scope,
|
||||
reason: ScopeRejection::NotRegistered,
|
||||
});
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
outcome.passthrough = std::mem::take(&mut outcome.passthrough)
|
||||
.into_iter()
|
||||
.filter_map(&mut keep)
|
||||
.collect();
|
||||
outcome.sets = std::mem::take(&mut outcome.sets)
|
||||
.into_iter()
|
||||
.filter(|group| keep(group.include_token()).is_some())
|
||||
.collect();
|
||||
|
||||
outcome.rejected.extend(rejected);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -65,6 +95,7 @@ mod tests {
|
||||
&c,
|
||||
"atproto include:io.atcr.authFullApp",
|
||||
Authority::FullSelf,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(eff.permitted.contains("atproto"));
|
||||
@@ -88,6 +119,7 @@ mod tests {
|
||||
&c,
|
||||
"atproto include:io.atcr.authFullApp",
|
||||
Authority::Delegated(&granted),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(eff.permitted.contains("atproto"));
|
||||
@@ -97,4 +129,80 @@ mod tests {
|
||||
);
|
||||
assert!(!eff.permitted.contains("identity"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unrecognized_scopes_never_reach_permitted() {
|
||||
let c = MemoryCache::new();
|
||||
let eff = resolve_effective_scopes(&c, "atproto chat", Authority::FullSelf, None).await;
|
||||
assert!(eff.permitted.split_whitespace().any(|s| s == "atproto"));
|
||||
assert!(
|
||||
!eff.permitted.split_whitespace().any(|s| s == "chat"),
|
||||
"permitted was {:?}",
|
||||
eff.permitted
|
||||
);
|
||||
assert_eq!(eff.outcome.rejected.len(), 1);
|
||||
assert_eq!(eff.outcome.rejected[0].reason, ScopeRejection::Unrecognized);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scopes_absent_from_client_metadata_are_rejected() {
|
||||
let c = MemoryCache::new();
|
||||
let eff = resolve_effective_scopes(
|
||||
&c,
|
||||
"atproto identity:*",
|
||||
Authority::FullSelf,
|
||||
Some("atproto"),
|
||||
)
|
||||
.await;
|
||||
assert!(!eff.permitted.split_whitespace().any(|s| s == "identity:*"));
|
||||
assert_eq!(eff.outcome.rejected.len(), 1);
|
||||
assert_eq!(eff.outcome.rejected[0].scope, "identity:*");
|
||||
assert_eq!(
|
||||
eff.outcome.rejected[0].reason,
|
||||
ScopeRejection::NotRegistered
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wildcard_client_registration_covers_narrower_request() {
|
||||
let c = MemoryCache::new();
|
||||
let eff = resolve_effective_scopes(
|
||||
&c,
|
||||
"atproto repo:app.bsky.feed.post?action=create",
|
||||
Authority::FullSelf,
|
||||
Some("atproto repo:*"),
|
||||
)
|
||||
.await;
|
||||
assert!(eff.outcome.rejected.is_empty());
|
||||
assert!(
|
||||
eff.permitted
|
||||
.contains("repo:app.bsky.feed.post?action=create")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn absent_client_metadata_scope_constrains_nothing() {
|
||||
let c = MemoryCache::new();
|
||||
let eff =
|
||||
resolve_effective_scopes(&c, "atproto identity:*", Authority::FullSelf, None).await;
|
||||
assert!(eff.outcome.rejected.is_empty());
|
||||
assert!(eff.permitted.contains("identity:*"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_expanded_scopes_bypass_the_client_registration_check() {
|
||||
let c = cache_with("io.atcr.authFullApp", "identity:*").await;
|
||||
let eff = resolve_effective_scopes(
|
||||
&c,
|
||||
"atproto include:io.atcr.authFullApp",
|
||||
Authority::FullSelf,
|
||||
Some("atproto include:io.atcr.authFullApp"),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
eff.outcome.rejected.is_empty(),
|
||||
"a permission set legitimately expands to scopes the client never registered"
|
||||
);
|
||||
assert!(eff.permitted.contains("identity:*"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ use serde::{Deserialize, Serialize};
|
||||
use tranquil_pds::oauth::{
|
||||
AuthorizationRequestParameters, ClientAuth, CodeChallengeMethod, OAuthError, Prompt,
|
||||
RequestData, RequestId, ResponseMode, ResponseType,
|
||||
scopes::{ParsedScope, parse_scope},
|
||||
};
|
||||
use tranquil_pds::rate_limit::{OAuthParLimit, OAuthRateLimited};
|
||||
use tranquil_pds::state::AppState;
|
||||
@@ -84,7 +83,7 @@ pub async fn pushed_authorization_request(
|
||||
let client_metadata = client_cache.get(&request.client_id).await?;
|
||||
client_cache.validate_redirect_uri(&client_metadata, &request.redirect_uri)?;
|
||||
let client_auth = determine_client_auth(&request)?;
|
||||
let validated_scope = validate_scope(&request.scope, &client_metadata)?;
|
||||
let validated_scope = normalize_scope(&request.scope)?;
|
||||
let request_id = RequestId::generate();
|
||||
let expires_at = Utc::now() + Duration::seconds(PAR_EXPIRY_SECONDS);
|
||||
let response_mode = parse_response_mode(request.response_mode.as_deref())?;
|
||||
@@ -165,10 +164,7 @@ fn determine_client_auth(request: &ParRequest) -> Result<ClientAuth, OAuthError>
|
||||
Ok(ClientAuth::None)
|
||||
}
|
||||
|
||||
fn validate_scope(
|
||||
requested_scope: &Option<String>,
|
||||
client_metadata: &tranquil_pds::oauth::ClientMetadata,
|
||||
) -> Result<Option<String>, OAuthError> {
|
||||
fn normalize_scope(requested_scope: &Option<String>) -> Result<Option<String>, OAuthError> {
|
||||
let scope_str = match requested_scope {
|
||||
Some(s) if !s.is_empty() => s,
|
||||
_ => return Ok(Some("atproto".to_string())),
|
||||
@@ -177,54 +173,9 @@ fn validate_scope(
|
||||
if requested_scopes.is_empty() {
|
||||
return Ok(Some("atproto".to_string()));
|
||||
}
|
||||
if let Some(unknown) = requested_scopes
|
||||
.iter()
|
||||
.find(|s| matches!(parse_scope(s), ParsedScope::Unknown(_)))
|
||||
{
|
||||
return Err(OAuthError::InvalidScope(format!(
|
||||
"Unsupported scope: {}",
|
||||
unknown
|
||||
)));
|
||||
}
|
||||
|
||||
if let Some(client_scope) = &client_metadata.scope {
|
||||
let client_scopes: Vec<&str> = client_scope.split_whitespace().collect();
|
||||
if let Some(unregistered) = requested_scopes
|
||||
.iter()
|
||||
.find(|scope| !client_scopes.iter().any(|cs| scope_matches(cs, scope)))
|
||||
{
|
||||
return Err(OAuthError::InvalidScope(format!(
|
||||
"Scope '{}' not registered for this client",
|
||||
unregistered
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(Some(requested_scopes.join(" ")))
|
||||
}
|
||||
|
||||
fn scope_matches(client_scope: &str, requested_scope: &str) -> bool {
|
||||
if client_scope == requested_scope {
|
||||
return true;
|
||||
}
|
||||
|
||||
fn get_resource_type(scope: &str) -> &str {
|
||||
let base = scope.split('?').next().unwrap_or(scope);
|
||||
base.split(':').next().unwrap_or(base)
|
||||
}
|
||||
|
||||
let client_type = get_resource_type(client_scope);
|
||||
let requested_type = get_resource_type(requested_scope);
|
||||
|
||||
if client_type == requested_type {
|
||||
let client_base = client_scope.split('?').next().unwrap_or(client_scope);
|
||||
if client_base.contains('*') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn parse_response_type(value: &str) -> Result<ResponseType, OAuthError> {
|
||||
match value {
|
||||
"code" => Ok(ResponseType::Code),
|
||||
|
||||
@@ -157,6 +157,7 @@ pub async fn handle_authorization_code_grant(
|
||||
&*state.cache,
|
||||
requested_for_resolve,
|
||||
authority,
|
||||
client_metadata.scope.as_deref(),
|
||||
)
|
||||
.await;
|
||||
if !effective.outcome.failures.is_empty() {
|
||||
@@ -274,10 +275,17 @@ async fn recompute_resolved_scope(
|
||||
Some(g) => crate::endpoints::authorize::scope_resolution::Authority::Delegated(g),
|
||||
None => crate::endpoints::authorize::scope_resolution::Authority::FullSelf,
|
||||
};
|
||||
let client_scope = state
|
||||
.client_metadata_cache
|
||||
.get(&token_data.client_id)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|m| m.scope);
|
||||
let effective = crate::endpoints::authorize::scope_resolution::resolve_effective_scopes(
|
||||
&*state.cache,
|
||||
requested,
|
||||
authority,
|
||||
client_scope.as_deref(),
|
||||
)
|
||||
.await;
|
||||
if !effective.outcome.failures.is_empty() {
|
||||
|
||||
@@ -3,8 +3,8 @@ use crate::cache_keys::permission_set_key;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
use tranquil_scopes::{
|
||||
ExpansionOutcome, FailedSet, ResolveFailure, ResolvedSetGroup, ScopeExpansionError,
|
||||
fetch_and_expand, parse_include_scope,
|
||||
ExpansionOutcome, FailedSet, ParsedScope, RejectedScope, ResolveFailure, ResolvedSetGroup,
|
||||
ScopeExpansionError, ScopeRejection, fetch_and_expand, parse_include_scope, parse_scope,
|
||||
};
|
||||
use tranquil_types::Nsid;
|
||||
|
||||
@@ -32,6 +32,12 @@ pub async fn expand_scopes(cache: &dyn Cache, scope_string: &str) -> ExpansionOu
|
||||
let mut outcome = ExpansionOutcome::default();
|
||||
for tok in scope_string.split_whitespace() {
|
||||
match tok.strip_prefix("include:") {
|
||||
None if matches!(parse_scope(tok), ParsedScope::Unknown(_)) => {
|
||||
outcome.rejected.push(RejectedScope {
|
||||
scope: tok.to_string(),
|
||||
reason: ScopeRejection::Unrecognized,
|
||||
})
|
||||
}
|
||||
None => outcome.passthrough.push(tok.to_string()),
|
||||
Some(rest) => {
|
||||
let (nsid, aud) = parse_include_scope(rest);
|
||||
@@ -236,4 +242,28 @@ mod tests {
|
||||
assert_eq!(out.failures.len(), 1);
|
||||
assert_eq!(out.failures[0].given_nsid, "nonexistent.fake.permissionSet");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unrecognized_scopes_are_rejected_not_passed_through() {
|
||||
let cache = MemoryCache::new();
|
||||
let out = expand_scopes(&cache, "atproto chat").await;
|
||||
assert_eq!(out.passthrough, vec!["atproto".to_string()]);
|
||||
assert!(
|
||||
!out.flat_scopes().iter().any(|s| s == "chat"),
|
||||
"an unrecognized scope must never reach the effective scope set"
|
||||
);
|
||||
assert_eq!(out.rejected.len(), 1);
|
||||
assert_eq!(out.rejected[0].scope, "chat");
|
||||
assert_eq!(out.rejected[0].reason, ScopeRejection::Unrecognized);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn structurally_invalid_granular_scopes_are_rejected() {
|
||||
let cache = MemoryCache::new();
|
||||
let out = expand_scopes(&cache, "atproto rpc:*?aud=*").await;
|
||||
assert_eq!(out.passthrough, vec!["atproto".to_string()]);
|
||||
assert_eq!(out.rejected.len(), 1);
|
||||
assert_eq!(out.rejected[0].scope, "rpc:*?aud=*");
|
||||
assert_eq!(out.rejected[0].reason, ScopeRejection::Unrecognized);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -693,3 +693,145 @@ async fn test_dereference_scope_requires_auth() {
|
||||
"Should require authentication"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_unrecognized_scope_reaches_consent_and_is_never_granted() {
|
||||
let url = base_url().await;
|
||||
let http_client = client();
|
||||
let redirect_uri = "https://example.com/callback";
|
||||
let suffix = &uuid::Uuid::new_v4().simple().to_string()[..4];
|
||||
let handle = format!("badscope{}", suffix);
|
||||
let email = format!("badscope{}@example.com", suffix);
|
||||
let password = "BadscopePass123!";
|
||||
|
||||
let create_res = http_client
|
||||
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
|
||||
.json(&json!({ "handle": handle, "email": email, "password": password }))
|
||||
.send()
|
||||
.await
|
||||
.expect("Account creation failed");
|
||||
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", "atproto chat"),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.expect("PAR failed");
|
||||
assert!(
|
||||
par_res.status() == StatusCode::OK || par_res.status() == StatusCode::CREATED,
|
||||
"PAR must not reject an unrecognized scope, 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/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 location = auth_body["redirect_uri"].as_str().unwrap().to_string();
|
||||
assert!(
|
||||
location.contains("/oauth/consent"),
|
||||
"should land on the consent screen, got {}",
|
||||
location
|
||||
);
|
||||
|
||||
let consent_get: Value = http_client
|
||||
.get(format!(
|
||||
"{}/oauth/authorize/consent?request_uri={}",
|
||||
url, request_uri
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.expect("Consent GET failed")
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let rejected = consent_get["rejected_scopes"].as_array().unwrap();
|
||||
assert_eq!(rejected.len(), 1, "got {:?}", rejected);
|
||||
assert_eq!(rejected[0]["scope"].as_str(), Some("chat"));
|
||||
assert_eq!(rejected[0]["reason"].as_str(), Some("unrecognized"));
|
||||
assert!(
|
||||
!consent_get["scopes"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|s| s["scope"] == "chat"),
|
||||
"an unrecognized scope must never be offered as grantable"
|
||||
);
|
||||
|
||||
let consent_res = http_client
|
||||
.post(format!("{}/oauth/authorize/consent", url))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&json!({
|
||||
"request_uri": request_uri,
|
||||
"approved_scopes": ["atproto", "chat"],
|
||||
"remember": false
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("Consent POST failed");
|
||||
assert_eq!(consent_res.status(), StatusCode::OK);
|
||||
let consent_body: Value = consent_res.json().await.unwrap();
|
||||
let location = consent_body["redirect_uri"].as_str().unwrap().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 granted = token_body["scope"].as_str().unwrap();
|
||||
assert!(
|
||||
granted.split_whitespace().any(|s| s == "atproto"),
|
||||
"granted scope was {:?}",
|
||||
granted
|
||||
);
|
||||
assert!(
|
||||
!granted.split_whitespace().any(|s| s == "chat"),
|
||||
"an unrecognized scope leaked into the issued token: {:?}",
|
||||
granted
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ pub use parser::{
|
||||
ParsedScope, RepoAction, RepoScope, RpcScope, parse_scope, parse_scope_string,
|
||||
};
|
||||
pub use permission_set::{
|
||||
ExpansionOutcome, FailedSet, FetchedSet, ResolveFailure, ResolvedSetGroup, ScopeExpansionError,
|
||||
fetch_and_expand, parse_include_scope,
|
||||
ExpansionOutcome, FailedSet, FetchedSet, RejectedScope, ResolveFailure, ResolvedSetGroup,
|
||||
ScopeExpansionError, ScopeRejection, fetch_and_expand, parse_include_scope,
|
||||
};
|
||||
pub use permissions::{ScopePermissions, superseded_by_transition_generic};
|
||||
|
||||
@@ -44,6 +44,19 @@ pub enum ResolveFailure {
|
||||
EmptyPermissions,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ScopeRejection {
|
||||
Unrecognized,
|
||||
NotRegistered,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RejectedScope {
|
||||
pub scope: String,
|
||||
pub reason: ScopeRejection,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FailedSet {
|
||||
// NSID and aud are left as strings to avoid issues from malformed requests.
|
||||
@@ -61,11 +74,21 @@ pub struct ResolvedSetGroup {
|
||||
pub expanded: Vec<String>,
|
||||
}
|
||||
|
||||
impl ResolvedSetGroup {
|
||||
pub fn include_token(&self) -> String {
|
||||
match &self.aud {
|
||||
Some(aud) => format!("include:{}?aud={}", self.nsid, aud),
|
||||
None => format!("include:{}", self.nsid),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ExpansionOutcome {
|
||||
pub passthrough: Vec<String>,
|
||||
pub sets: Vec<ResolvedSetGroup>,
|
||||
pub failures: Vec<FailedSet>,
|
||||
pub rejected: Vec<RejectedScope>,
|
||||
}
|
||||
|
||||
impl ExpansionOutcome {
|
||||
@@ -811,6 +834,7 @@ mod tests {
|
||||
given_aud: None,
|
||||
reason: ResolveFailure::NotFound,
|
||||
}],
|
||||
rejected: vec![],
|
||||
};
|
||||
let flat = out.flat_scopes();
|
||||
assert_eq!(
|
||||
@@ -839,6 +863,7 @@ mod tests {
|
||||
expanded: vec!["repo:x".into(), "rpc:io.atcr.getManifest".into()],
|
||||
}],
|
||||
failures: vec![],
|
||||
rejected: vec![],
|
||||
};
|
||||
let flat = out.flat_scopes();
|
||||
assert_eq!(flat, vec!["repo:x", "rpc:io.atcr.getManifest"]);
|
||||
|
||||
@@ -607,6 +607,7 @@
|
||||
"unavailablePermissions": "Unavailable permissions",
|
||||
"unavailableLimited": "Limited by delegation",
|
||||
"unavailableFailed": "Failed to load",
|
||||
"unavailableRejected": "Not granted",
|
||||
"setPartiallyLimited": "Some permissions in this bundle are limited by your delegation; see \"Unavailable Permissions\" to find out which permissions weren't allowed.",
|
||||
"setFailureReason": {
|
||||
"not_found": "This bundle could not be found and cannot be granted.",
|
||||
@@ -617,6 +618,11 @@
|
||||
"empty_permissions": "This bundle does not currently grant any permissions.",
|
||||
"unknown": "This bundle cannot be granted."
|
||||
},
|
||||
"scopeRejectionReason": {
|
||||
"unrecognized": "This server does not recognize this permission, so it cannot be granted.",
|
||||
"not_registered": "The application did not register this permission, so it cannot be granted.",
|
||||
"unknown": "This permission cannot be granted."
|
||||
},
|
||||
"permTable": {
|
||||
"data": "Data",
|
||||
"create": "Create",
|
||||
|
||||
@@ -69,12 +69,27 @@
|
||||
return known[reason] ?? 'oauth.consent.setFailureReason.unknown'
|
||||
}
|
||||
|
||||
const SCOPE_REJECTION_LOCALE_KEYS = {
|
||||
unrecognized: 'oauth.consent.scopeRejectionReason.unrecognized',
|
||||
not_registered: 'oauth.consent.scopeRejectionReason.not_registered',
|
||||
}
|
||||
|
||||
function scopeRejectionLocaleKey(reason: string): string {
|
||||
const known: Partial<Record<string, string>> = SCOPE_REJECTION_LOCALE_KEYS
|
||||
return known[reason] ?? 'oauth.consent.scopeRejectionReason.unknown'
|
||||
}
|
||||
|
||||
interface FailedSetInfo {
|
||||
nsid: string
|
||||
aud?: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
interface RejectedScopeInfo {
|
||||
scope: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
interface ConsentData {
|
||||
request_uri: string
|
||||
client_id: string
|
||||
@@ -85,6 +100,7 @@
|
||||
permission_sets: PermissionSetInfo[]
|
||||
transition_supersedes?: boolean
|
||||
failed_sets: FailedSetInfo[]
|
||||
rejected_scopes: RejectedScopeInfo[]
|
||||
show_consent: boolean
|
||||
did: string
|
||||
handle?: string
|
||||
@@ -336,8 +352,12 @@
|
||||
consentData ? (consentData.permission_sets ?? []).filter(s => s.expanded.some(e => e.restricted)) : []
|
||||
)
|
||||
let failedSets = $derived(consentData?.failed_sets ?? [])
|
||||
let rejectedScopes = $derived(consentData?.rejected_scopes ?? [])
|
||||
let hasUnavailable = $derived(
|
||||
restrictedScopes.length > 0 || limitedBundles.length > 0 || failedSets.length > 0
|
||||
restrictedScopes.length > 0 ||
|
||||
limitedBundles.length > 0 ||
|
||||
failedSets.length > 0 ||
|
||||
rejectedScopes.length > 0
|
||||
)
|
||||
|
||||
let hasGranularScopes = $derived(
|
||||
@@ -652,6 +672,18 @@
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if rejectedScopes.length}
|
||||
<p class="unavailable-subhead">{$_('oauth.consent.unavailableRejected')}</p>
|
||||
{#each rejectedScopes as r}
|
||||
<div class="scope-item failed">
|
||||
<div class="scope-info">
|
||||
<span class="scope-name scope-raw">{r.scope}</span>
|
||||
<span class="scope-description">{$_(scopeRejectionLocaleKey(r.reason))}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -65,6 +65,7 @@ const consentPayload = {
|
||||
],
|
||||
permission_sets: [],
|
||||
failed_sets: [],
|
||||
rejected_scopes: [],
|
||||
show_consent: true,
|
||||
did: "did:plc:example",
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user