diff --git a/crates/tranquil-oauth-server/src/endpoints/authorize/consent.rs b/crates/tranquil-oauth-server/src/endpoints/authorize/consent.rs index fcef639..0eb66d4 100644 --- a/crates/tranquil-oauth-server/src/endpoints/authorize/consent.rs +++ b/crates/tranquil-oauth-server/src/endpoints/authorize/consent.rs @@ -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, pub transition_supersedes: bool, pub failed_sets: Vec, + pub rejected_scopes: Vec, 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 = 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 = 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 { 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()) { diff --git a/crates/tranquil-oauth-server/src/endpoints/authorize/mod.rs b/crates/tranquil-oauth-server/src/endpoints/authorize/mod.rs index d9283cd..e915240 100644 --- a/crates/tranquil-oauth-server/src/endpoints/authorize/mod.rs +++ b/crates/tranquil-oauth-server/src/endpoints/authorize/mod.rs @@ -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 { diff --git a/crates/tranquil-oauth-server/src/endpoints/authorize/scope_resolution.rs b/crates/tranquil-oauth-server/src/endpoints/authorize/scope_resolution.rs index d2246cf..7984946 100644 --- a/crates/tranquil-oauth-server/src/endpoints/authorize/scope_resolution.rs +++ b/crates/tranquil-oauth-server/src/endpoints/authorize/scope_resolution.rs @@ -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:*")); + } } diff --git a/crates/tranquil-oauth-server/src/endpoints/par.rs b/crates/tranquil-oauth-server/src/endpoints/par.rs index 4ebb162..f218d14 100644 --- a/crates/tranquil-oauth-server/src/endpoints/par.rs +++ b/crates/tranquil-oauth-server/src/endpoints/par.rs @@ -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 Ok(ClientAuth::None) } -fn validate_scope( - requested_scope: &Option, - client_metadata: &tranquil_pds::oauth::ClientMetadata, -) -> Result, OAuthError> { +fn normalize_scope(requested_scope: &Option) -> Result, 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 { match value { "code" => Ok(ResponseType::Code), diff --git a/crates/tranquil-oauth-server/src/endpoints/token/grants.rs b/crates/tranquil-oauth-server/src/endpoints/token/grants.rs index 981d5a9..9360c92 100644 --- a/crates/tranquil-oauth-server/src/endpoints/token/grants.rs +++ b/crates/tranquil-oauth-server/src/endpoints/token/grants.rs @@ -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() { diff --git a/crates/tranquil-pds/src/oauth/permission_set_resolver.rs b/crates/tranquil-pds/src/oauth/permission_set_resolver.rs index defe864..276241a 100644 --- a/crates/tranquil-pds/src/oauth/permission_set_resolver.rs +++ b/crates/tranquil-pds/src/oauth/permission_set_resolver.rs @@ -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); + } } diff --git a/crates/tranquil-pds/tests/oauth_scopes.rs b/crates/tranquil-pds/tests/oauth_scopes.rs index c671142..06fbf66 100644 --- a/crates/tranquil-pds/tests/oauth_scopes.rs +++ b/crates/tranquil-pds/tests/oauth_scopes.rs @@ -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 + ); +} diff --git a/crates/tranquil-scopes/src/lib.rs b/crates/tranquil-scopes/src/lib.rs index 807fb19..138737b 100644 --- a/crates/tranquil-scopes/src/lib.rs +++ b/crates/tranquil-scopes/src/lib.rs @@ -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}; diff --git a/crates/tranquil-scopes/src/permission_set.rs b/crates/tranquil-scopes/src/permission_set.rs index a5c07a5..5c530b4 100644 --- a/crates/tranquil-scopes/src/permission_set.rs +++ b/crates/tranquil-scopes/src/permission_set.rs @@ -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, } +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, pub sets: Vec, pub failures: Vec, + pub rejected: Vec, } 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"]); diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 1fbe45c..15d98d2 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -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", diff --git a/frontend/src/routes/OAuthConsent.svelte b/frontend/src/routes/OAuthConsent.svelte index d7feec1..685ce6a 100644 --- a/frontend/src/routes/OAuthConsent.svelte +++ b/frontend/src/routes/OAuthConsent.svelte @@ -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> = 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 @@ {/each} {/if} + + {#if rejectedScopes.length} +

{$_('oauth.consent.unavailableRejected')}

+ {#each rejectedScopes as r} +
+
+ {r.scope} + {$_(scopeRejectionLocaleKey(r.reason))} +
+
+ {/each} + {/if} {/if} diff --git a/frontend/src/tests/OAuthConsentSupersede.test.ts b/frontend/src/tests/OAuthConsentSupersede.test.ts index 3c13adb..a42f31b 100644 --- a/frontend/src/tests/OAuthConsentSupersede.test.ts +++ b/frontend/src/tests/OAuthConsentSupersede.test.ts @@ -65,6 +65,7 @@ const consentPayload = { ], permission_sets: [], failed_sets: [], + rejected_scopes: [], show_consent: true, did: "did:plc:example", };