fix: unrecognized scopes can no longer fail PAR

Signed-off-by: Trezy <tre@trezy.com>
This commit is contained in:
Trezy
2026-09-16 16:20:17 +00:00
committed by Tangled
parent cdd5fa70c9
commit 12a8712eae
12 changed files with 408 additions and 86 deletions
@@ -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);
}
}
+142
View File
@@ -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
);
}
+2 -2
View File
@@ -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"]);
+6
View File
@@ -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",
+33 -1
View File
@@ -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",
};