mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-08-30 04:37:03 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eba8167da8 | ||
|
|
2e92310518 | ||
|
|
0e82a38add |
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use tranquil_scopes::{ParsedScope, parse_scope};
|
||||
use tranquil_types::Nsid;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -10,6 +11,7 @@ pub struct ScopeInfo {
|
||||
pub display_name: String,
|
||||
pub granted: Option<bool>,
|
||||
pub restricted: bool,
|
||||
pub superseded: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub effective_scope: Option<String>,
|
||||
}
|
||||
@@ -27,6 +29,7 @@ pub struct PermissionSetInfo {
|
||||
pub expanded: Vec<ScopeInfo>,
|
||||
pub granted: Option<bool>,
|
||||
pub restricted: bool,
|
||||
pub superseded: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -49,6 +52,7 @@ pub struct ConsentResponse {
|
||||
pub logo_uri: Option<String>,
|
||||
pub scopes: Vec<ScopeInfo>,
|
||||
pub permission_sets: Vec<PermissionSetInfo>,
|
||||
pub transition_supersedes: bool,
|
||||
pub failed_sets: Vec<FailedSetInfo>,
|
||||
pub show_consent: bool,
|
||||
pub did: Did,
|
||||
@@ -185,6 +189,9 @@ pub async fn consent_get(
|
||||
.await
|
||||
.unwrap_or(true);
|
||||
let has_granular_scopes = requested_scopes.iter().any(|s| is_granular_scope(s));
|
||||
let has_transition_generic = requested_scopes
|
||||
.iter()
|
||||
.any(|s| matches!(parse_scope(s), ParsedScope::TransitionGeneric));
|
||||
|
||||
let grant_scope_str: Option<&str> =
|
||||
delegation_grant.as_ref().map(|g| g.granted_scopes.as_str());
|
||||
@@ -237,6 +244,8 @@ pub async fn consent_get(
|
||||
)
|
||||
};
|
||||
let granted = pref_map.get(scope).copied();
|
||||
let superseded = has_transition_generic
|
||||
&& tranquil_scopes::superseded_by_transition_generic(&parse_scope(scope));
|
||||
ScopeInfo {
|
||||
scope: scope.to_string(),
|
||||
category,
|
||||
@@ -245,6 +254,7 @@ pub async fn consent_get(
|
||||
display_name,
|
||||
granted,
|
||||
restricted,
|
||||
superseded,
|
||||
effective_scope,
|
||||
}
|
||||
};
|
||||
@@ -267,6 +277,7 @@ pub async fn consent_get(
|
||||
};
|
||||
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);
|
||||
PermissionSetInfo {
|
||||
nsid: g.nsid.clone(),
|
||||
aud: g.aud.clone(),
|
||||
@@ -276,6 +287,7 @@ pub async fn consent_get(
|
||||
include_scope,
|
||||
expanded,
|
||||
restricted,
|
||||
superseded,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -332,6 +344,9 @@ pub async fn consent_get(
|
||||
(None, None, None, None)
|
||||
};
|
||||
|
||||
let transition_supersedes =
|
||||
scopes.iter().any(|s| s.superseded) || permission_sets.iter().any(|s| s.superseded);
|
||||
|
||||
Json(ConsentResponse {
|
||||
request_uri: query.request_uri.clone(),
|
||||
client_id: request_data.parameters.client_id.clone(),
|
||||
@@ -340,6 +355,7 @@ pub async fn consent_get(
|
||||
logo_uri: client_metadata.as_ref().and_then(|m| m.logo_uri.clone()),
|
||||
scopes,
|
||||
permission_sets,
|
||||
transition_supersedes,
|
||||
failed_sets,
|
||||
show_consent,
|
||||
did: did.clone(),
|
||||
|
||||
@@ -187,32 +187,6 @@ fn validate_scope(
|
||||
)));
|
||||
}
|
||||
|
||||
let has_transition = requested_scopes.iter().any(|s| {
|
||||
matches!(
|
||||
parse_scope(s),
|
||||
ParsedScope::TransitionGeneric
|
||||
| ParsedScope::TransitionChat
|
||||
| ParsedScope::TransitionEmail
|
||||
)
|
||||
});
|
||||
let has_granular = requested_scopes.iter().any(|s| {
|
||||
matches!(
|
||||
parse_scope(s),
|
||||
ParsedScope::Repo(_)
|
||||
| ParsedScope::Blob(_)
|
||||
| ParsedScope::Rpc(_)
|
||||
| ParsedScope::Account(_)
|
||||
| ParsedScope::Identity(_)
|
||||
| ParsedScope::Include(_)
|
||||
)
|
||||
});
|
||||
|
||||
if has_transition && has_granular {
|
||||
return Err(OAuthError::InvalidScope(
|
||||
"Cannot mix transition scopes with granular scopes. Use either transition:* scopes OR granular scopes (repo:*, blob:*, rpc:*, account:*, include:*), not both.".to_string()
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(client_scope) = &client_metadata.scope {
|
||||
let client_scopes: Vec<&str> = client_scope.split_whitespace().collect();
|
||||
if let Some(unregistered) = requested_scopes
|
||||
|
||||
@@ -48,7 +48,7 @@ pub static SCOPE_DEFINITIONS: LazyLock<HashMap<&'static str, ScopeDefinition>> =
|
||||
category: ScopeCategory::Transition,
|
||||
required: false,
|
||||
description: "Generic transition scope for compatibility",
|
||||
display_name: "Transition Access",
|
||||
display_name: "Generic Access",
|
||||
},
|
||||
ScopeDefinition {
|
||||
scope: "transition:chat.bsky",
|
||||
|
||||
@@ -19,4 +19,4 @@ pub use permission_set::{
|
||||
ExpansionOutcome, FailedSet, FetchedSet, ResolveFailure, ResolvedSetGroup, ScopeExpansionError,
|
||||
fetch_and_expand, parse_include_scope,
|
||||
};
|
||||
pub use permissions::ScopePermissions;
|
||||
pub use permissions::{ScopePermissions, superseded_by_transition_generic};
|
||||
|
||||
@@ -43,7 +43,26 @@ impl ScopePermissions {
|
||||
has_transition_email,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether holding `transition:generic` makes `scope` redundant.
|
||||
pub fn superseded_by_transition_generic(scope: &ParsedScope) -> bool {
|
||||
match scope {
|
||||
ParsedScope::Repo(_) | ParsedScope::Blob(_) => true,
|
||||
ParsedScope::Rpc(rpc) => !rpc
|
||||
.lxm
|
||||
.as_deref()
|
||||
.is_some_and(|lxm| lxm == "*" || lxm.starts_with("chat.bsky.")),
|
||||
ParsedScope::Account(_)
|
||||
| ParsedScope::Identity(_)
|
||||
| ParsedScope::TransitionEmail
|
||||
| ParsedScope::TransitionChat => false,
|
||||
ParsedScope::Include(_) => false,
|
||||
ParsedScope::TransitionGeneric | ParsedScope::Atproto | ParsedScope::Unknown(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
impl ScopePermissions {
|
||||
pub fn has_scope(&self, scope: &str) -> bool {
|
||||
self.scopes.contains(scope)
|
||||
}
|
||||
@@ -158,22 +177,17 @@ impl ScopePermissions {
|
||||
}
|
||||
|
||||
pub fn assert_rpc(&self, aud: &str, lxm: &Nsid) -> Result<(), ScopeError> {
|
||||
if lxm.starts_with("chat.bsky.") {
|
||||
if self.has_transition_chat {
|
||||
return Ok(());
|
||||
}
|
||||
if self.has_transition_generic && !self.has_transition_chat {
|
||||
return Err(ScopeError::InsufficientScope {
|
||||
required: "transition:chat.bsky".to_string(),
|
||||
message: format!(
|
||||
"Chat access requires transition:chat.bsky scope to call {}",
|
||||
lxm
|
||||
),
|
||||
});
|
||||
}
|
||||
let is_chat = lxm.starts_with("chat.bsky.");
|
||||
|
||||
if is_chat && self.has_transition_chat {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if self.has_transition_generic {
|
||||
// `transition:generic` covers every lexicon except chat. Note it does not *block* chat:
|
||||
// holding it must never remove access a granular `rpc:chat.bsky.*` scope would grant on
|
||||
// its own, so chat requests fall through to the granular check below rather than
|
||||
// failing here.
|
||||
if self.has_transition_generic && !is_chat {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -198,13 +212,24 @@ impl ScopePermissions {
|
||||
});
|
||||
|
||||
if has_permission {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ScopeError::InsufficientScope {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Point a caller holding only `transition:generic` at the scope it actually needs,
|
||||
// rather than at a granular rpc scope it probably did not mean to request.
|
||||
Err(match is_chat && self.has_transition_generic {
|
||||
true => ScopeError::InsufficientScope {
|
||||
required: "transition:chat.bsky".to_string(),
|
||||
message: format!(
|
||||
"Chat access requires transition:chat.bsky scope to call {}",
|
||||
lxm
|
||||
),
|
||||
},
|
||||
false => ScopeError::InsufficientScope {
|
||||
required: format!("rpc:{}?aud={}", lxm, aud),
|
||||
message: format!("Insufficient scope to call {} on {}", lxm, aud),
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
pub fn assert_account(
|
||||
@@ -212,10 +237,6 @@ impl ScopePermissions {
|
||||
attr: AccountAttr,
|
||||
action: AccountAction,
|
||||
) -> Result<(), ScopeError> {
|
||||
if self.has_transition_generic {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if attr == AccountAttr::Email && action == AccountAction::Read && self.has_transition_email
|
||||
{
|
||||
return Ok(());
|
||||
@@ -245,8 +266,7 @@ impl ScopePermissions {
|
||||
}
|
||||
|
||||
pub fn allows_email_read(&self) -> bool {
|
||||
self.has_transition_generic
|
||||
|| self.has_transition_email
|
||||
self.has_transition_email
|
||||
|| self
|
||||
.find_account_scopes()
|
||||
.any(|a| a.attr == AccountAttr::Email || a.attr == AccountAttr::Wildcard)
|
||||
@@ -269,10 +289,6 @@ impl ScopePermissions {
|
||||
}
|
||||
|
||||
pub fn assert_identity(&self, attr: IdentityAttr) -> Result<(), ScopeError> {
|
||||
if self.has_transition_generic {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let has_permission = self.find_identity_scopes().any(|identity_scope| {
|
||||
identity_scope.attr == IdentityAttr::Wildcard || identity_scope.attr == attr
|
||||
});
|
||||
@@ -336,6 +352,7 @@ impl Default for ScopePermissions {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::parser::parse_scope;
|
||||
|
||||
fn c(s: &str) -> Nsid {
|
||||
s.parse().unwrap()
|
||||
@@ -512,10 +529,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transition_generic_grants_identity() {
|
||||
fn test_transition_generic_does_not_grant_identity() {
|
||||
let perms = ScopePermissions::from_scope_string(Some("transition:generic"));
|
||||
assert!(perms.allows_identity(IdentityAttr::Handle));
|
||||
assert!(perms.allows_identity(IdentityAttr::Wildcard));
|
||||
assert!(!perms.allows_identity(IdentityAttr::Handle));
|
||||
assert!(!perms.allows_identity(IdentityAttr::Wildcard));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -597,4 +614,160 @@ mod tests {
|
||||
&c("app.bsky.feed.getAuthorFeed")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transition_generic_supersedes_granular_scopes() {
|
||||
for scope in [
|
||||
"repo:app.bsky.feed.post?action=create",
|
||||
"blob:image/png",
|
||||
"rpc:app.bsky.actor.getProfile?aud=*",
|
||||
] {
|
||||
assert!(
|
||||
superseded_by_transition_generic(&parse_scope(scope)),
|
||||
"{scope} should be superseded by transition:generic"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transition_generic_does_not_supersede_chat() {
|
||||
// assert_rpc rejects chat.bsky.* when transition:generic is held without
|
||||
// transition:chat.bsky, so neither the transition scope nor an rpc scope that
|
||||
// could reach a chat lexicon is covered by it.
|
||||
for scope in [
|
||||
"transition:chat.bsky",
|
||||
"rpc:chat.bsky.convo.sendMessage?aud=*",
|
||||
"rpc:*?aud=did:web:api.bsky.app",
|
||||
"account:email?action=manage",
|
||||
"account:email?action=read",
|
||||
"account:status?action=read",
|
||||
"identity:handle",
|
||||
"identity:*",
|
||||
"transition:email",
|
||||
] {
|
||||
assert!(
|
||||
!superseded_by_transition_generic(&parse_scope(scope)),
|
||||
"{scope} must not be treated as superseded"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transition_generic_does_not_supersede_itself_or_baseline() {
|
||||
assert!(!superseded_by_transition_generic(&parse_scope(
|
||||
"transition:generic"
|
||||
)));
|
||||
assert!(!superseded_by_transition_generic(&parse_scope("atproto")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn superseded_matches_enforcement_for_chat_and_feed() {
|
||||
// Cross-check against ScopePermissions so the two cannot drift apart.
|
||||
let perms = ScopePermissions::from_scope_string(Some("atproto transition:generic"));
|
||||
let feed = Nsid::new("app.bsky.feed.getTimeline").unwrap();
|
||||
let chat = Nsid::new("chat.bsky.convo.sendMessage").unwrap();
|
||||
assert!(perms.allows_rpc("did:web:api.bsky.app", &feed));
|
||||
assert!(!perms.allows_rpc("did:web:api.bsky.app", &chat));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn granular_chat_rpc_works_without_transition_generic() {
|
||||
// Baseline for the test below: on its own, a granular chat rpc scope grants chat.
|
||||
let perms = ScopePermissions::from_scope_string(Some(
|
||||
"atproto rpc:chat.bsky.convo.sendMessage?aud=*",
|
||||
));
|
||||
assert!(perms.allows_rpc("did:web:api.bsky.chat", &c("chat.bsky.convo.sendMessage")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transition_generic_does_not_revoke_granular_chat_rpc() {
|
||||
// Adding a broader scope must never remove access. transition:generic does not cover
|
||||
// chat lexicons, but it must not stop a granular chat rpc scope from doing so either.
|
||||
let perms = ScopePermissions::from_scope_string(Some(
|
||||
"atproto transition:generic rpc:chat.bsky.convo.sendMessage?aud=*",
|
||||
));
|
||||
assert!(perms.allows_rpc("did:web:api.bsky.chat", &c("chat.bsky.convo.sendMessage")));
|
||||
// ...and still grants everything else it covers.
|
||||
assert!(perms.allows_rpc("did:web:api.bsky.app", &c("app.bsky.feed.getTimeline")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transition_generic_does_not_widen_granular_chat_rpc() {
|
||||
// The granular scope grants exactly one chat lexicon; transition:generic must not be
|
||||
// read as covering the rest of chat.
|
||||
let perms = ScopePermissions::from_scope_string(Some(
|
||||
"atproto transition:generic rpc:chat.bsky.convo.sendMessage?aud=*",
|
||||
));
|
||||
assert!(!perms.allows_rpc("did:web:api.bsky.chat", &c("chat.bsky.convo.deleteMessage")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_denial_still_names_the_scope_the_caller_needs() {
|
||||
// transition:generic alone: the useful advice is "ask for transition:chat.bsky",
|
||||
// not "ask for rpc:chat.bsky.convo.listConvos".
|
||||
let generic = ScopePermissions::from_scope_string(Some("atproto transition:generic"));
|
||||
let err = generic
|
||||
.assert_rpc("did:web:api.bsky.chat", &c("chat.bsky.convo.listConvos"))
|
||||
.expect_err("chat must be denied without transition:chat.bsky");
|
||||
match err {
|
||||
ScopeError::InsufficientScope { required, .. } => {
|
||||
assert_eq!(required, "transition:chat.bsky");
|
||||
}
|
||||
other => panic!("unexpected error: {other:?}"),
|
||||
}
|
||||
|
||||
// Without transition:generic the granular scope is the right thing to name.
|
||||
let bare = ScopePermissions::from_scope_string(Some("atproto"));
|
||||
let err = bare
|
||||
.assert_rpc("did:web:api.bsky.chat", &c("chat.bsky.convo.listConvos"))
|
||||
.expect_err("chat must be denied with no rpc scope at all");
|
||||
match err {
|
||||
ScopeError::InsufficientScope { required, .. } => {
|
||||
assert!(required.starts_with("rpc:chat.bsky.convo.listConvos"));
|
||||
}
|
||||
other => panic!("unexpected error: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transition_generic_does_not_grant_account_management() {
|
||||
// "no account management actions: change handle, change email, delete or deactivate
|
||||
// account, migrate account" -- atproto OAuth spec.
|
||||
let perms = ScopePermissions::from_scope_string(Some("atproto transition:generic"));
|
||||
assert!(!perms.allows_account(AccountAttr::Email, AccountAction::Manage));
|
||||
assert!(!perms.allows_account(AccountAttr::Repo, AccountAction::Manage));
|
||||
assert!(!perms.allows_account(AccountAttr::Status, AccountAction::Manage));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transition_generic_does_not_grant_email_read() {
|
||||
// Reading the account email is what transition:email is for.
|
||||
let perms = ScopePermissions::from_scope_string(Some("atproto transition:generic"));
|
||||
assert!(!perms.allows_email_read());
|
||||
assert!(!perms.allows_account(AccountAttr::Email, AccountAction::Read));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn granular_scopes_still_grant_alongside_transition_generic() {
|
||||
// Removing the short-circuit must not stop an explicitly granted scope from working.
|
||||
let perms = ScopePermissions::from_scope_string(Some(
|
||||
"atproto transition:generic account:email?action=manage identity:handle",
|
||||
));
|
||||
assert!(perms.allows_account(AccountAttr::Email, AccountAction::Manage));
|
||||
assert!(perms.allows_identity(IdentityAttr::Handle));
|
||||
|
||||
let with_email = ScopePermissions::from_scope_string(Some(
|
||||
"atproto transition:generic transition:email",
|
||||
));
|
||||
assert!(with_email.allows_email_read());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transition_generic_still_grants_what_the_spec_says_it_does() {
|
||||
let perms = ScopePermissions::from_scope_string(Some("atproto transition:generic"));
|
||||
assert!(perms.allows_repo(RepoAction::Create, &c("app.bsky.feed.post")));
|
||||
assert!(perms.allows_repo(RepoAction::Delete, &c("app.bsky.feed.post")));
|
||||
assert!(perms.allows_blob("image/png"));
|
||||
assert!(perms.allows_rpc("did:web:api.bsky.app", &c("app.bsky.feed.getTimeline")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -634,7 +634,13 @@
|
||||
"title": "Unexpected State",
|
||||
"description": "The consent page is in an unexpected state. Please check the browser console for errors.",
|
||||
"reload": "Reload Page"
|
||||
}
|
||||
},
|
||||
"supersedeWarningTitle": "This app asked for broad access",
|
||||
"supersedeWarningBody": "This application has requested full read and write access to your account.",
|
||||
"supersededNote": "Already covered by transition:generic",
|
||||
"deselectedWarningTitle": "Some permissions are disabled",
|
||||
"deselectedWarningBody": "You have disabled some of the permissions requested by this app. This may cause some parts of the app to be broken or unavailable.",
|
||||
"supersedeWarningBodyMixed": "This application has requested full read and write access to your account, alongside more specific permissions. The specific permissions are meaningless if you grant the application complete and total control by leaving transition:generic selected."
|
||||
},
|
||||
"accounts": {
|
||||
"title": "Choose account",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
display_name: string
|
||||
granted: boolean | null
|
||||
restricted?: boolean
|
||||
superseded?: boolean
|
||||
effective_scope?: string
|
||||
}
|
||||
|
||||
@@ -43,6 +44,7 @@
|
||||
expanded: ScopeInfo[]
|
||||
granted: boolean | null
|
||||
restricted?: boolean
|
||||
superseded?: boolean
|
||||
}
|
||||
|
||||
type SetFailureReason =
|
||||
@@ -81,6 +83,7 @@
|
||||
logo_uri: string | null
|
||||
scopes: ScopeInfo[]
|
||||
permission_sets: PermissionSetInfo[]
|
||||
transition_supersedes?: boolean
|
||||
failed_sets: FailedSetInfo[]
|
||||
show_consent: boolean
|
||||
did: string
|
||||
@@ -264,10 +267,32 @@
|
||||
}
|
||||
}
|
||||
|
||||
const TRANSITION_GENERIC = 'transition:generic'
|
||||
|
||||
let transitionGenericSelected = $derived(scopeSelections[TRANSITION_GENERIC] === true)
|
||||
|
||||
let anyDeselected = $derived(
|
||||
Object.values(scopeSelections).some((selected) => selected === false)
|
||||
)
|
||||
|
||||
function isSupersededNow(item: { superseded?: boolean }): boolean {
|
||||
return Boolean(consentData?.transition_supersedes && item.superseded && transitionGenericSelected)
|
||||
}
|
||||
|
||||
function handleScopeToggle(scope: string) {
|
||||
const scopeInfo = consentData?.scopes.find(s => s.scope === scope)
|
||||
if (scopeInfo?.required) return
|
||||
scopeSelections[scope] = !scopeSelections[scope]
|
||||
if (scopeInfo && isSupersededNow(scopeInfo)) return
|
||||
const next = !scopeSelections[scope]
|
||||
scopeSelections[scope] = next
|
||||
if (scope === TRANSITION_GENERIC && next) {
|
||||
for (const s of consentData?.scopes ?? []) {
|
||||
if (s.superseded && !s.restricted) scopeSelections[s.scope] = true
|
||||
}
|
||||
for (const set of consentData?.permission_sets ?? []) {
|
||||
if (set.superseded && !set.restricted) scopeSelections[set.include_scope] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const CATEGORY_ORDER = [
|
||||
@@ -460,6 +485,30 @@
|
||||
<span class="consent-account-did">{consentData.did}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if transitionGenericSelected}
|
||||
<div class="permissions-notice" role="status">
|
||||
<div class="notice-header">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
||||
<span>{$_('oauth.consent.supersedeWarningTitle')}</span>
|
||||
</div>
|
||||
<p class="notice-text">
|
||||
{consentData.transition_supersedes
|
||||
? $_('oauth.consent.supersedeWarningBodyMixed')
|
||||
: $_('oauth.consent.supersedeWarningBody')}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if anyDeselected}
|
||||
<div class="permissions-notice" role="status">
|
||||
<div class="notice-header">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
||||
<span>{$_('oauth.consent.deselectedWarningTitle')}</span>
|
||||
</div>
|
||||
<p class="notice-text">{$_('oauth.consent.deselectedWarningBody')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="permissions-panel">
|
||||
@@ -482,8 +531,8 @@
|
||||
<label class="scope-item" class:required={scope.required}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={scopeSelections[scope.scope]}
|
||||
disabled={scope.required || submitting}
|
||||
checked={isSupersededNow(scope) ? true : scopeSelections[scope.scope]}
|
||||
disabled={scope.required || submitting || isSupersededNow(scope)}
|
||||
onchange={() => handleScopeToggle(scope.scope)}
|
||||
/>
|
||||
<div class="scope-info">
|
||||
@@ -492,6 +541,9 @@
|
||||
{#if scope.required}
|
||||
<span class="required-badge">{$_('oauth.consent.required')}</span>
|
||||
{/if}
|
||||
{#if isSupersededNow(scope)}
|
||||
<span class="superseded-note">{$_('oauth.consent.supersededNote')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</label>
|
||||
{/each}
|
||||
@@ -509,8 +561,8 @@
|
||||
<label class="scope-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={scopeSelections[set.include_scope]}
|
||||
disabled={submitting}
|
||||
checked={isSupersededNow(set) ? true : scopeSelections[set.include_scope]}
|
||||
disabled={submitting || isSupersededNow(set)}
|
||||
onchange={() => handleScopeToggle(set.include_scope)}
|
||||
/>
|
||||
<div class="scope-info">
|
||||
|
||||
@@ -1085,7 +1085,8 @@ button.forget-btn:hover {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.restricted-note {
|
||||
.restricted-note,
|
||||
.superseded-note {
|
||||
display: block;
|
||||
font-size: 0.75em;
|
||||
color: var(--text-muted);
|
||||
@@ -1589,3 +1590,7 @@ button.forget-btn:hover {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.scope-item:has(input:disabled:checked) .scope-name {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/svelte";
|
||||
import OAuthConsent from "../routes/OAuthConsent.svelte";
|
||||
import {
|
||||
clearMocks,
|
||||
jsonResponse,
|
||||
mockEndpoint,
|
||||
setupFetchMock,
|
||||
setupIndexedDBMock,
|
||||
} from "./mocks.ts";
|
||||
|
||||
const consentPayload = {
|
||||
request_uri: "urn:mock:request",
|
||||
client_id: "https://example.com",
|
||||
client_name: "Mixed Scope App",
|
||||
client_uri: null,
|
||||
logo_uri: null,
|
||||
transition_supersedes: true,
|
||||
scopes: [
|
||||
{
|
||||
scope: "atproto",
|
||||
category: "Core Access",
|
||||
required: true,
|
||||
description: "Baseline",
|
||||
display_name: "AT Protocol Access",
|
||||
granted: null,
|
||||
superseded: false,
|
||||
},
|
||||
{
|
||||
scope: "transition:generic",
|
||||
category: "Other",
|
||||
required: false,
|
||||
description: "Broad access",
|
||||
display_name: "Generic Access",
|
||||
granted: null,
|
||||
superseded: false,
|
||||
},
|
||||
{
|
||||
scope: "repo:app.bsky.feed.post?action=create",
|
||||
category: "Other",
|
||||
required: false,
|
||||
description: "Create posts",
|
||||
display_name: "repo:app.bsky.feed.post",
|
||||
granted: null,
|
||||
superseded: true,
|
||||
},
|
||||
{
|
||||
scope: "account:email?action=manage",
|
||||
category: "Other",
|
||||
required: false,
|
||||
description: "Manage email",
|
||||
display_name: "account:email",
|
||||
granted: null,
|
||||
superseded: false,
|
||||
},
|
||||
{
|
||||
scope: "transition:chat.bsky",
|
||||
category: "Other",
|
||||
required: false,
|
||||
description: "Chat access",
|
||||
display_name: "Chat Access",
|
||||
granted: null,
|
||||
superseded: false,
|
||||
},
|
||||
],
|
||||
permission_sets: [],
|
||||
failed_sets: [],
|
||||
show_consent: true,
|
||||
did: "did:plc:example",
|
||||
};
|
||||
|
||||
function boxFor(name: string): HTMLInputElement {
|
||||
const label = screen.getByText(name).closest("label");
|
||||
if (!label) throw new Error(`no label containing "${name}"`);
|
||||
const input = label.querySelector("input[type=checkbox]");
|
||||
if (!input) throw new Error(`no checkbox in label for "${name}"`);
|
||||
return input as HTMLInputElement;
|
||||
}
|
||||
|
||||
describe("OAuthConsent transition:generic supersede behaviour", () => {
|
||||
beforeEach(() => {
|
||||
clearMocks();
|
||||
setupFetchMock();
|
||||
setupIndexedDBMock();
|
||||
Object.defineProperty(window.location, "search", {
|
||||
value: "?request_uri=urn:mock:request",
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
mockEndpoint("/oauth/authorize/consent", () =>
|
||||
jsonResponse(consentPayload),
|
||||
);
|
||||
});
|
||||
|
||||
it("warns that the itemised scopes are redundant", async () => {
|
||||
render(OAuthConsent);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/asked for broad access/i)).toBeTruthy(),
|
||||
);
|
||||
expect(
|
||||
screen.getByText(/specific permissions are meaningless/i),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("warns about transition:generic even when nothing is superseded", async () => {
|
||||
mockEndpoint("/oauth/authorize/consent", () =>
|
||||
jsonResponse({
|
||||
...consentPayload,
|
||||
transition_supersedes: false,
|
||||
scopes: consentPayload.scopes
|
||||
.filter((s) => !s.superseded)
|
||||
.map((s) => ({ ...s, superseded: false })),
|
||||
}),
|
||||
);
|
||||
render(OAuthConsent);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/asked for broad access/i)).toBeTruthy(),
|
||||
);
|
||||
expect(screen.getByText(/full read and write access/i)).toBeTruthy();
|
||||
expect(
|
||||
screen.queryByText(/specific permissions are meaningless/i),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("drops the warning once transition:generic is declined", async () => {
|
||||
render(OAuthConsent);
|
||||
await waitFor(() => expect(boxFor("Generic Access")).toBeTruthy());
|
||||
|
||||
await fireEvent.click(boxFor("Generic Access"));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText(/asked for broad access/i)).toBeNull(),
|
||||
);
|
||||
});
|
||||
|
||||
it("locks superseded scopes checked while transition:generic is selected", async () => {
|
||||
render(OAuthConsent);
|
||||
await waitFor(() => expect(boxFor("Generic Access")).toBeTruthy());
|
||||
|
||||
const superseded = boxFor("repo:app.bsky.feed.post");
|
||||
expect(superseded.checked).toBe(true);
|
||||
expect(superseded.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves scopes generic does not cover editable", async () => {
|
||||
render(OAuthConsent);
|
||||
await waitFor(() => expect(boxFor("Chat Access")).toBeTruthy());
|
||||
|
||||
expect(boxFor("Chat Access").disabled).toBe(false);
|
||||
expect(boxFor("account:email").disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("warns once a requested permission is switched off", async () => {
|
||||
render(OAuthConsent);
|
||||
await waitFor(() => expect(boxFor("Chat Access")).toBeTruthy());
|
||||
|
||||
expect(screen.queryByText(/disabled some of the permissions/i)).toBeNull();
|
||||
await fireEvent.click(boxFor("Chat Access"));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText(/disabled some of the permissions/i),
|
||||
).toBeTruthy(),
|
||||
);
|
||||
});
|
||||
|
||||
it("hands control back once transition:generic is unchecked", async () => {
|
||||
render(OAuthConsent);
|
||||
await waitFor(() => expect(boxFor("Generic Access")).toBeTruthy());
|
||||
|
||||
await fireEvent.click(boxFor("Generic Access"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(boxFor("repo:app.bsky.feed.post").disabled).toBe(false),
|
||||
);
|
||||
});
|
||||
|
||||
it("re-locks them when transition:generic is selected again", async () => {
|
||||
render(OAuthConsent);
|
||||
await waitFor(() => expect(boxFor("Generic Access")).toBeTruthy());
|
||||
|
||||
const generic = boxFor("Generic Access");
|
||||
await fireEvent.click(generic);
|
||||
await waitFor(() =>
|
||||
expect(boxFor("repo:app.bsky.feed.post").disabled).toBe(false),
|
||||
);
|
||||
|
||||
await fireEvent.click(boxFor("repo:app.bsky.feed.post"));
|
||||
await fireEvent.click(boxFor("Generic Access"));
|
||||
|
||||
await waitFor(() => {
|
||||
const superseded = boxFor("repo:app.bsky.feed.post");
|
||||
expect(superseded.disabled).toBe(true);
|
||||
expect(superseded.checked).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user