diff --git a/README.md b/README.md index eea5243..49adcd9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Cafeteria -A soft fork of Tranquil for use in margin.cafe, we might fix things, add things, or improve things for our use case. +A downstream fork of Tranquil for use in margin.cafe, we might fix things, add things, or improve things for our use case. ## Tranquil PDS diff --git a/crates/tranquil-config/src/lib.rs b/crates/tranquil-config/src/lib.rs index f540dcc..661e5e3 100644 --- a/crates/tranquil-config/src/lib.rs +++ b/crates/tranquil-config/src/lib.rs @@ -470,6 +470,15 @@ pub struct ServerConfig { #[config(env = "PDS_BANNED_WORDS", parse_env = split_comma_list)] pub banned_words: Option>, + /// Whether to reserve the built-in list of common and notable account names. + /// Protocol-specific names remain reserved regardless of this setting. + #[config(env = "PDS_USE_DEFAULT_RESERVED_HANDLES", default = true)] + pub use_default_reserved_handles: bool, + + /// Additional account names to reserve locally. + #[config(env = "PDS_RESERVED_HANDLES", parse_env = split_comma_list)] + pub reserved_handles: Option>, + /// URL to a privacy policy page. #[config(env = "PRIVACY_POLICY_URL")] pub privacy_policy_url: Option, @@ -843,6 +852,11 @@ pub struct PlcConfig { /// Seconds to cache DID documents in memory. #[config(env = "DID_CACHE_TTL_SECS", default = 300)] pub did_cache_ttl_secs: u64, + + /// Maximum number of entries retained by each in-memory DID cache. + /// Set to zero to disable DID caching. + #[config(env = "DID_CACHE_MAX_ENTRIES", default = 10000)] + pub did_cache_max_entries: usize, } #[derive(Debug, Config)] diff --git a/crates/tranquil-oauth-server/src/endpoints/authorize/consent.rs b/crates/tranquil-oauth-server/src/endpoints/authorize/consent.rs index d459421..e36b95d 100644 --- a/crates/tranquil-oauth-server/src/endpoints/authorize/consent.rs +++ b/crates/tranquil-oauth-server/src/endpoints/authorize/consent.rs @@ -1,4 +1,5 @@ use super::*; +use axum::body::Bytes; use tranquil_types::Nsid; #[derive(Debug, Serialize)] @@ -74,6 +75,33 @@ pub struct ConsentSubmit { pub remember: bool, } +fn parse_consent_form(body: &[u8]) -> Result { + let fields: Vec<(String, String)> = + serde_urlencoded::from_bytes(body).map_err(|e| e.to_string())?; + let mut request_uri = None; + let mut approved_scopes = Vec::new(); + let mut remember = false; + + for (name, value) in fields { + match name.as_str() { + "request_uri" => request_uri = Some(value), + "approved_scopes" => approved_scopes.push(value), + "remember" => match value.as_str() { + "true" | "1" | "on" => remember = true, + "false" | "0" | "off" => {} + _ => return Err("Invalid remember value".to_string()), + }, + _ => {} + } + } + + Ok(ConsentSubmit { + request_uri: request_uri.ok_or_else(|| "Missing request_uri".to_string())?, + approved_scopes, + remember, + }) +} + pub async fn consent_get( State(state): State, Query(query): Query, @@ -342,8 +370,38 @@ pub async fn consent_get( pub async fn consent_post( State(state): State, - Json(form): Json, + headers: HeaderMap, + body: Bytes, ) -> Response { + let content_type = headers + .get(axum::http::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default(); + let is_native_form = content_type + .split(';') + .next() + .is_some_and(|value| value.trim() == "application/x-www-form-urlencoded"); + let form = if is_native_form { + parse_consent_form(&body) + } else if content_type + .split(';') + .next() + .is_some_and(|value| value.trim() == "application/json") + { + serde_json::from_slice(&body).map_err(|e| e.to_string()) + } else { + return json_error( + StatusCode::UNSUPPORTED_MEDIA_TYPE, + "invalid_request", + "Content-Type must be application/json or application/x-www-form-urlencoded", + ); + }; + let form = match form { + Ok(form) => form, + Err(error) => { + return json_error(StatusCode::BAD_REQUEST, "invalid_request", &error); + } + }; tracing::info!( "consent_post: approved_scopes={:?}, remember={}", form.approved_scopes, @@ -539,9 +597,14 @@ pub async fn consent_post( tracing::info!( intermediate_url = %intermediate_url, client_redirect = %redirect_uri, - "consent_post returning JSON with intermediate URL (for 303 redirect)" + is_native_form, + "consent_post completed authorization" ); - Json(serde_json::json!({ "redirect_uri": intermediate_url })).into_response() + if is_native_form { + redirect_see_other(&intermediate_url) + } else { + Json(serde_json::json!({ "redirect_uri": intermediate_url })).into_response() + } } #[derive(Debug, Deserialize)] diff --git a/crates/tranquil-pds/src/auth/service.rs b/crates/tranquil-pds/src/auth/service.rs index d9de672..dd82065 100644 --- a/crates/tranquil-pds/src/auth/service.rs +++ b/crates/tranquil-pds/src/auth/service.rs @@ -2,9 +2,8 @@ use base64::Engine as _; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use chrono::Utc; use k256::ecdsa::{Signature, VerifyingKey, signature::Verifier}; -use reqwest::Client; use serde::{Deserialize, Serialize}; -use std::time::Duration; +use std::sync::Arc; use tracing::debug; use tranquil_types::{Did, Jti, Nsid}; @@ -36,6 +35,8 @@ pub enum ServiceTokenError { MissingPublicKey, #[error("Unsupported DID method")] UnsupportedDidMethod, + #[error("DID resolution failed: {0}")] + DidResolution(String), #[error("DID not found: {0}")] DidNotFound(String), #[error("HTTP request failed")] @@ -138,31 +139,19 @@ struct TokenHeader { } pub struct ServiceTokenVerifier { - client: Client, - plc_directory_url: String, + did_resolver: Arc, pds_did: Did, } impl ServiceTokenVerifier { pub fn new() -> Self { - let plc_directory_url = tranquil_config::get().plc.directory_url.clone(); - let pds_hostname = &tranquil_config::get().server.hostname; let pds_did: Did = format!("did:web:{}", pds_hostname) .parse() .expect("PDS hostname produces a valid DID"); - let client = Client::builder() - .timeout(Duration::from_secs(10)) - .connect_timeout(Duration::from_secs(5)) - .pool_max_idle_per_host(10) - .pool_idle_timeout(Duration::from_secs(90)) - .build() - .unwrap_or_else(|_| Client::new()); - Self { - client, - plc_directory_url, + did_resolver: crate::did::create_did_resolver(), pds_did, } } @@ -258,88 +247,12 @@ impl ServiceTokenVerifier { } async fn resolve_did_document(&self, did: &Did) -> Result { - if did.is_plc() { - self.resolve_did_plc(did).await - } else if did.is_web() { - self.resolve_did_web(did).await - } else { - Err(ServiceTokenError::UnsupportedDidMethod) - } - } - - async fn resolve_did_plc(&self, did: &Did) -> Result { - let url = format!( - "{}/{}", - self.plc_directory_url, - urlencoding::encode(did.as_str()) - ); - debug!("Resolving did:plc {} via {}", did, url); - - let resp = self - .client - .get(&url) - .send() - .await - .map_err(ServiceTokenError::HttpFailed)?; - - if resp.status() == reqwest::StatusCode::NOT_FOUND { - return Err(ServiceTokenError::DidNotFound(did.to_string())); - } - - if !resp.status().is_success() { - return Err(ServiceTokenError::HttpStatus(resp.status())); - } - - resp.json::() - .await - .map_err(ServiceTokenError::InvalidDidDocument) - } - - async fn resolve_did_web(&self, did: &Did) -> Result { - let host = did - .strip_prefix("did:web:") - .ok_or(ServiceTokenError::InvalidFormat)?; - - let mut host_parts = host.splitn(2, ':'); - let host_part = host_parts - .next() - .ok_or(ServiceTokenError::InvalidFormat)? - .replace("%3A", ":"); - let path_part = host_parts.next(); - - let scheme = if host_part.starts_with("localhost") - || host_part.starts_with("127.0.0.1") - || host_part.contains(':') - { - "http" - } else { - "https" - }; - - let url = match path_part { - None => format!("{}://{}/.well-known/did.json", scheme, host_part), - Some(path) => { - let resolved_path = path.replace(':', "/"); - format!("{}://{}/{}/did.json", scheme, host_part, resolved_path) - } - }; - - debug!("Resolving did:web {} via {}", did, url); - - let resp = self - .client - .get(&url) - .send() - .await - .map_err(ServiceTokenError::HttpFailed)?; - - if !resp.status().is_success() { - return Err(ServiceTokenError::HttpStatus(resp.status())); - } - - resp.json::() + let document = self + .did_resolver + .fetch_did_document(did) .await - .map_err(ServiceTokenError::InvalidDidDocument) + .map_err(|error| ServiceTokenError::DidResolution(error.to_string()))?; + serde_json::from_value((*document).clone()).map_err(ServiceTokenError::JsonDecode) } } diff --git a/crates/tranquil-pds/src/did.rs b/crates/tranquil-pds/src/did.rs index ac41376..9d8731b 100644 --- a/crates/tranquil-pds/src/did.rs +++ b/crates/tranquil-pds/src/did.rs @@ -2,7 +2,7 @@ use crate::types::Did; use reqwest::Client; use serde::{Deserialize, Serialize}; use std::collections::HashMap; -use std::sync::Arc; +use std::sync::{Arc, LazyLock}; use std::time::{Duration, Instant}; use tokio::sync::RwLock; use tracing::{debug, info, warn}; @@ -58,12 +58,34 @@ pub struct ResolvedService { type TimedCache = RwLock, (Instant, Arc)>>; +fn insert_bounded_cache_entry( + cache: &mut HashMap, (Instant, Arc)>, + key: Box, + value: Arc, + max_entries: usize, +) { + if max_entries == 0 { + return; + } + if cache.len() >= max_entries && !cache.contains_key(key.as_ref()) { + if let Some(oldest) = cache + .iter() + .min_by_key(|(_, (inserted_at, _))| *inserted_at) + .map(|(key, _)| key.clone()) + { + cache.remove(oldest.as_ref()); + } + } + cache.insert(key, (Instant::now(), value)); +} + pub struct DidResolver { did_doc_cache: TimedCache, parsed_did_doc_cache: TimedCache, service_cache: TimedCache, client: Client, cache_ttl: Duration, + cache_max_entries: usize, plc_directory_url: String, } @@ -71,6 +93,7 @@ impl DidResolver { pub fn new() -> Self { let cfg = tranquil_config::get(); let cache_ttl_secs = cfg.plc.did_cache_ttl_secs; + let cache_max_entries = cfg.plc.did_cache_max_entries; let plc_directory_url = cfg.plc.directory_url.clone(); @@ -89,6 +112,7 @@ impl DidResolver { service_cache: RwLock::new(HashMap::new()), client, cache_ttl: Duration::from_secs(cache_ttl_secs), + cache_max_entries, plc_directory_url, } } @@ -124,9 +148,11 @@ impl DidResolver { { let mut cache = self.service_cache.write().await; - cache.insert( + insert_bounded_cache_entry( + &mut cache, format!("{did}#{service_id}").into(), - (Instant::now(), resolved.clone()), + resolved.clone(), + self.cache_max_entries, ); } @@ -147,7 +173,12 @@ impl DidResolver { { let mut cache = self.parsed_did_doc_cache.write().await; - cache.insert(did.as_str().into(), (Instant::now(), resolved.clone())); + insert_bounded_cache_entry( + &mut cache, + did.as_str().into(), + resolved.clone(), + self.cache_max_entries, + ); } Ok(resolved) @@ -247,13 +278,17 @@ impl DidResolver { { let mut cache = self.did_doc_cache.write().await; - cache.insert(did.as_str().into(), (Instant::now(), resolved.clone())); + insert_bounded_cache_entry( + &mut cache, + did.as_str().into(), + resolved.clone(), + self.cache_max_entries, + ); } Ok(resolved) } - // TODO: make cached version async fn fetch_did_document_uncached( &self, did: &Did, @@ -339,7 +374,8 @@ impl Default for DidResolver { } pub fn create_did_resolver() -> Arc { - Arc::new(DidResolver::new()) + static RESOLVER: LazyLock> = LazyLock::new(|| Arc::new(DidResolver::new())); + RESOLVER.clone() } fn build_did_web_url(did: &Did) -> Result { @@ -387,3 +423,37 @@ fn build_did_web_url(did: &Did) -> Result { Ok(url) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bounded_cache_evicts_the_oldest_entry() { + let mut cache = HashMap::new(); + let old_value = Arc::new("old"); + let new_value = Arc::new("new"); + cache.insert( + Box::::from("did:plc:old"), + (Instant::now() - Duration::from_secs(10), old_value), + ); + + insert_bounded_cache_entry(&mut cache, Box::::from("did:plc:new"), new_value, 1); + + assert_eq!(cache.len(), 1); + assert!(!cache.contains_key("did:plc:old")); + assert!(cache.contains_key("did:plc:new")); + } + + #[test] + fn zero_capacity_does_not_retain_entries() { + let mut cache = HashMap::new(); + insert_bounded_cache_entry( + &mut cache, + Box::::from("did:plc:uncached"), + Arc::new("value"), + 0, + ); + assert!(cache.is_empty()); + } +} diff --git a/crates/tranquil-pds/src/handle/reserved.rs b/crates/tranquil-pds/src/handle/reserved.rs index 81f480f..813fec2 100644 --- a/crates/tranquil-pds/src/handle/reserved.rs +++ b/crates/tranquil-pds/src/handle/reserved.rs @@ -1,9 +1,5 @@ use std::collections::HashSet; use std::sync::LazyLock; - -// TODO: make all of this configurable. -// PDS implementation should not impose any reserved domains imo. -// some of these are even bad to have as defaults let alone non-configurables const ATP_SPECIFIC: &[&str] = &[ "at", "atp", "plc", "pds", "did", "repo", "tid", "nsid", "xrpc", "lex", "lexicon", "bsky", "bluesky", "handle", @@ -1041,7 +1037,31 @@ pub static RESERVED_SUBDOMAINS: LazyLock> = LazyLock::new( }); pub fn is_reserved_subdomain(subdomain: &str) -> bool { - RESERVED_SUBDOMAINS.contains(subdomain.to_lowercase().as_str()) + match tranquil_config::try_get() { + Some(config) => is_reserved_subdomain_with_config( + subdomain, + config.server.use_default_reserved_handles, + config + .server + .reserved_handles + .as_deref() + .unwrap_or_default(), + ), + None => is_reserved_subdomain_with_config(subdomain, true, &[]), + } +} + +fn is_reserved_subdomain_with_config( + subdomain: &str, + use_defaults: bool, + configured: &[String], +) -> bool { + let normalized = subdomain.to_ascii_lowercase(); + ATP_SPECIFIC.contains(&normalized.as_str()) + || (use_defaults && RESERVED_SUBDOMAINS.contains(normalized.as_str())) + || configured + .iter() + .any(|handle| handle.trim().eq_ignore_ascii_case(&normalized)) } #[cfg(test)] @@ -1078,4 +1098,36 @@ mod tests { assert!(!is_reserved_subdomain("bob123")); assert!(!is_reserved_subdomain("randomuser")); } + + #[test] + fn protocol_handles_cannot_be_unreserved() { + assert!(is_reserved_subdomain_with_config("xrpc", false, &[])); + assert!(is_reserved_subdomain_with_config("BSKY", false, &[])); + } + + #[test] + fn operator_can_disable_non_protocol_defaults() { + assert!(is_reserved_subdomain_with_config("taylorswift", true, &[])); + assert!(!is_reserved_subdomain_with_config( + "taylorswift", + false, + &[] + )); + assert!(!is_reserved_subdomain_with_config("admin", false, &[])); + } + + #[test] + fn operator_reserved_handles_are_case_insensitive() { + let configured = vec!["LocalName".to_string()]; + assert!(is_reserved_subdomain_with_config( + "localname", + false, + &configured + )); + assert!(is_reserved_subdomain_with_config( + "LOCALNAME", + false, + &configured + )); + } } diff --git a/crates/tranquil-pds/src/sync/verify.rs b/crates/tranquil-pds/src/sync/verify.rs index d661a37..8900af7 100644 --- a/crates/tranquil-pds/src/sync/verify.rs +++ b/crates/tranquil-pds/src/sync/verify.rs @@ -4,7 +4,6 @@ use jacquard_common::IntoStatic; use jacquard_common::types::crypto::PublicKey; use jacquard_common::types::did_doc::DidDocument; use jacquard_repo::commit::Commit; -use reqwest::Client; use std::collections::HashMap; use thiserror::Error; use tracing::{debug, warn}; @@ -33,9 +32,7 @@ pub enum VerifyError { InvalidCbor(String), } -pub struct CarVerifier { - http_client: Client, -} +pub struct CarVerifier; impl Default for CarVerifier { fn default() -> Self { @@ -45,15 +42,7 @@ impl Default for CarVerifier { impl CarVerifier { pub fn new() -> Self { - Self { - http_client: Client::builder() - .timeout(std::time::Duration::from_secs(10)) - .connect_timeout(std::time::Duration::from_secs(5)) - .pool_max_idle_per_host(10) - .pool_idle_timeout(std::time::Duration::from_secs(90)) - .build() - .unwrap_or_default(), - } + Self } pub async fn verify_car( @@ -125,65 +114,16 @@ impl CarVerifier { &self, did: &Did, ) -> Result, VerifyError> { - if did.is_plc() { - self.resolve_plc_did(did).await - } else if did.is_web() { - self.resolve_web_did(did).await - } else { - Err(VerifyError::DidResolutionFailed(format!( - "Unsupported DID method: {}", - did - ))) - } - } - - async fn resolve_plc_did(&self, did: &Did) -> Result, VerifyError> { - let plc_url = std::env::var("PLC_DIRECTORY_URL") - .unwrap_or_else(|_| tranquil_config::get().plc.directory_url.clone()); - let url = format!("{}/{}", plc_url, urlencoding::encode(did.as_str())); - let response = self - .http_client - .get(&url) - .send() - .await - .map_err(|e| VerifyError::DidResolutionFailed(e.to_string()))?; - if !response.status().is_success() { + if !did.is_plc() && !did.is_web() { return Err(VerifyError::DidResolutionFailed(format!( - "PLC directory returned {}", - response.status() + "Unsupported DID method: {did}" ))); } - let body = response - .text() + let value = crate::did::create_did_resolver() + .fetch_did_document(did) .await .map_err(|e| VerifyError::DidResolutionFailed(e.to_string()))?; - let doc: DidDocument<'_> = serde_json::from_str(&body) - .map_err(|e| VerifyError::DidResolutionFailed(e.to_string()))?; - Ok(doc.into_static()) - } - - async fn resolve_web_did(&self, did: &Did) -> Result, VerifyError> { - let domain = did.strip_prefix("did:web:").ok_or_else(|| { - VerifyError::DidResolutionFailed("Invalid did:web format".to_string()) - })?; - let domain_decoded = urlencoding::decode(domain) - .map_err(|e| VerifyError::DidResolutionFailed(e.to_string()))?; - let url = format!("https://{}/.well-known/did.json", domain_decoded); - let response = self - .http_client - .get(&url) - .send() - .await - .map_err(|e| VerifyError::DidResolutionFailed(e.to_string()))?; - if !response.status().is_success() { - return Err(VerifyError::DidResolutionFailed(format!( - "did:web resolution returned {}", - response.status() - ))); - } - let body = response - .text() - .await + let body = serde_json::to_string(&*value) .map_err(|e| VerifyError::DidResolutionFailed(e.to_string()))?; let doc: DidDocument<'_> = serde_json::from_str(&body) .map_err(|e| VerifyError::DidResolutionFailed(e.to_string()))?; diff --git a/crates/tranquil-pds/tests/oauth.rs b/crates/tranquil-pds/tests/oauth.rs index 284ac81..187af96 100644 --- a/crates/tranquil-pds/tests/oauth.rs +++ b/crates/tranquil-pds/tests/oauth.rs @@ -377,6 +377,88 @@ async fn test_full_oauth_flow() { ); } +#[tokio::test] +async fn test_oauth_consent_native_form_redirects() { + let url = base_url().await; + let http_client = client(); + let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8]; + let handle = format!("of{}", suffix); + let email = format!("of{}@example.com", suffix); + let password = "Oauthform123!"; + let create_res = http_client + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) + .json(&json!({ "handle": handle, "email": email, "password": password })) + .send() + .await + .unwrap(); + assert_eq!(create_res.status(), StatusCode::OK); + let account: Value = create_res.json().await.unwrap(); + verify_new_account(&http_client, account["did"].as_str().unwrap()).await; + + let redirect_uri = "https://example.com/native-form-callback"; + let mock_client = setup_mock_client_metadata(redirect_uri).await; + let client_id = mock_client.uri(); + let (_, code_challenge) = generate_pkce(); + let par_body: Value = 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 transition:generic"), + ]) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let request_uri = par_body["request_uri"].as_str().unwrap(); + let auth_res = http_client + .post(format!("{}/oauth/authorize", url)) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .json(&json!({ + "request_uri": request_uri, + "username": &handle, + "password": password, + "remember_device": false + })) + .send() + .await + .unwrap(); + assert_eq!(auth_res.status(), StatusCode::OK); + let auth_body: Value = auth_res.json().await.unwrap(); + assert!( + auth_body["redirect_uri"] + .as_str() + .unwrap() + .contains("/oauth/consent"), + "Expected the authorization flow to present consent" + ); + + let consent_res = no_redirect_client() + .post(format!("{}/oauth/authorize/consent", url)) + .form(&[ + ("request_uri", request_uri), + ("approved_scopes", "atproto"), + ("approved_scopes", "transition:generic"), + ("remember", "true"), + ]) + .send() + .await + .unwrap(); + assert_eq!(consent_res.status(), StatusCode::SEE_OTHER); + let location = consent_res + .headers() + .get(reqwest::header::LOCATION) + .and_then(|value| value.to_str().ok()) + .expect("Native consent response should include a Location header"); + assert!(location.contains("/oauth/authorize/redirect?")); +} + #[tokio::test] async fn test_oauth_error_cases() { let url = base_url().await; diff --git a/example.toml b/example.toml index aeceade..8f5771e 100644 --- a/example.toml +++ b/example.toml @@ -75,6 +75,19 @@ # Can also be specified via environment variable `PDS_BANNED_WORDS`. #banned_words = +# Reserve Tranquil's built-in list of common and notable account names. +# Protocol-specific names such as `xrpc` remain reserved when this is false. +# +# Can also be specified via environment variable `PDS_USE_DEFAULT_RESERVED_HANDLES`. +# +# Default value: true +#use_default_reserved_handles = true + +# List of additional account names to reserve on this PDS. +# +# Can also be specified via environment variable `PDS_RESERVED_HANDLES`. +#reserved_handles = + # URL to a privacy policy page. # # Can also be specified via environment variable `PRIVACY_POLICY_URL`. @@ -390,6 +403,14 @@ # Default value: 300 #did_cache_ttl_secs = 300 +# Maximum number of entries retained by each in-memory DID cache. +# Set to zero to disable DID caching. +# +# Can also be specified via environment variable `DID_CACHE_MAX_ENTRIES`. +# +# Default value: 10000 +#did_cache_max_entries = 10000 + [firehose] # Size of the in-memory broadcast buffer for firehose events. # diff --git a/frontend/src/routes/OAuthConsent.svelte b/frontend/src/routes/OAuthConsent.svelte index fd7bddb..b18b350 100644 --- a/frontend/src/routes/OAuthConsent.svelte +++ b/frontend/src/routes/OAuthConsent.svelte @@ -197,17 +197,7 @@ } submitting = true - let approvedScopes = Object.entries(scopeSelections) - .filter(([_, approved]) => approved) - .map(([scope]) => scope) - - if ( - approvedScopes.length === 0 && - consentData.scopes.length === 0 && - (consentData.permission_sets?.length ?? 0) === 0 - ) { - approvedScopes = ['atproto'] - } + const approvedScopes = getApprovedScopes() try { const response = await fetch('/oauth/authorize/consent', { @@ -243,6 +233,22 @@ } } + function getApprovedScopes(): string[] { + let approvedScopes = Object.entries(scopeSelections) + .filter(([_, approved]) => approved) + .map(([scope]) => scope) + + if ( + approvedScopes.length === 0 && + consentData?.scopes.length === 0 && + (consentData.permission_sets?.length ?? 0) === 0 + ) { + approvedScopes = ['atproto'] + } + + return approvedScopes + } + async function handleDeny() { if (!consentData) return @@ -402,6 +408,12 @@ {:else if consentData} +
submitting = true}> + + {#each getApprovedScopes() as scope} + + {/each} +