From 2466a9d3f461f980621b9c5ea88701c2bc103f09 Mon Sep 17 00:00:00 2001 From: lewis Date: Tue, 6 Jan 2026 20:31:42 +0200 Subject: [PATCH] experiment: deconstructing include oauth --- .gitignore | 2 - README.md | 2 - ref_pds_downloader.sh | 3 - src/oauth/endpoints/token/grants.rs | 13 ++- src/oauth/scopes/mod.rs | 2 + src/oauth/scopes/permission_set.rs | 172 ++++++++++++++++++++++++++++ 6 files changed, 186 insertions(+), 8 deletions(-) create mode 100644 src/oauth/scopes/permission_set.rs diff --git a/.gitignore b/.gitignore index 2163d71..7a79873 100644 --- a/.gitignore +++ b/.gitignore @@ -2,10 +2,8 @@ .env .direnv .result -reference-pds-hailey/ reference-pds-bsky/ reference-relay-indigo/ pds-moover/ -# Frontend build artifacts frontend/node_modules/ frontend/dist/ diff --git a/README.md b/README.md index 1a257c3..093e987 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,6 @@ Another excellent PDS is [Cocoon](https://tangled.org/hailey.at/cocoon), written ## What's different about Tranquil PDS -This software isn't an afterthought by a company with limited resources. - It is a superset of the reference PDS, including: passkeys and 2FA (WebAuthn/FIDO2, TOTP, backup codes, trusted devices), did:web support (PDS-hosted subdomains or bring-your-own), multi-channel communication (email, discord, telegram, signal) for verification and alerts, granular OAuth scopes with a consent UI showing human-readable descriptions, app passwords with granular permissions (read-only, post-only, or custom scopes), account delegation (letting others manage an account with configurable permission levels), automatic backups to s3-compatible object storage (configurable retention and frequency, one-click restore), and a built-in web UI for account management, OAuth consent, repo browsing, and admin. The PDS itself is a single small binary with no node/npm runtime. It does require postgres, valkey, and s3-compatible storage, which makes setup heavier than the reference PDS's sqlite. The tradeoff is that these are battle-tested pieces of infra that we already know how to scale, back up, and monitor. diff --git a/ref_pds_downloader.sh b/ref_pds_downloader.sh index e958424..c53ed3e 100755 --- a/ref_pds_downloader.sh +++ b/ref_pds_downloader.sh @@ -1,7 +1,4 @@ #!/bin/bash -echo "Downloading haileyok/cocoon" -git clone --depth 1 https://github.com/haileyok/cocoon reference-pds-hailey -rm -rf reference-pds-hailey/.git echo "Downloading bluesky-social/atproto pds package" mkdir reference-pds-bsky cd reference-pds-bsky diff --git a/src/oauth/endpoints/token/grants.rs b/src/oauth/endpoints/token/grants.rs index a58f0e7..993d6bf 100644 --- a/src/oauth/endpoints/token/grants.rs +++ b/src/oauth/endpoints/token/grants.rs @@ -7,6 +7,7 @@ use crate::oauth::{ client::{ClientMetadataCache, verify_client_auth}, db::{self, RefreshTokenLookup}, dpop::DPoPVerifier, + scopes::expand_include_scopes, }; use crate::state::AppState; use axum::Json; @@ -122,7 +123,7 @@ pub async fn handle_authorization_code_grant( let refresh_token = RefreshToken::generate(); let now = Utc::now(); - let (final_scope, controller_did) = if let Some(ref controller) = auth_request.controller_did { + let (raw_scope, controller_did) = if let Some(ref controller) = auth_request.controller_did { let grant = delegation::get_delegation(&state.db, &did, controller) .await .ok() @@ -139,6 +140,16 @@ pub async fn handle_authorization_code_grant( (auth_request.parameters.scope.clone(), None) }; + let final_scope = if let Some(ref scope) = raw_scope { + if scope.contains("include:") { + Some(expand_include_scopes(scope).await) + } else { + raw_scope + } + } else { + raw_scope + }; + let access_token = create_access_token_with_delegation( &token_id.0, &did, diff --git a/src/oauth/scopes/mod.rs b/src/oauth/scopes/mod.rs index 67f9e64..c6906e7 100644 --- a/src/oauth/scopes/mod.rs +++ b/src/oauth/scopes/mod.rs @@ -1,6 +1,7 @@ mod definitions; mod error; mod parser; +mod permission_set; mod permissions; pub use definitions::{SCOPE_DEFINITIONS, ScopeCategory, ScopeDefinition}; @@ -9,4 +10,5 @@ pub use parser::{ AccountAction, AccountAttr, AccountScope, BlobScope, IdentityAttr, IdentityScope, IncludeScope, ParsedScope, RepoAction, RepoScope, RpcScope, parse_scope, parse_scope_string, }; +pub use permission_set::expand_include_scopes; pub use permissions::ScopePermissions; diff --git a/src/oauth/scopes/permission_set.rs b/src/oauth/scopes/permission_set.rs new file mode 100644 index 0000000..aaaedbd --- /dev/null +++ b/src/oauth/scopes/permission_set.rs @@ -0,0 +1,172 @@ +use reqwest::Client; +use serde::Deserialize; +use std::collections::HashMap; +use std::sync::LazyLock; +use tokio::sync::RwLock; +use tracing::{debug, warn}; + +static LEXICON_CACHE: LazyLock>> = + LazyLock::new(|| RwLock::new(HashMap::new())); + +#[derive(Clone)] +struct CachedLexicon { + expanded_scope: String, + cached_at: std::time::Instant, +} + +const CACHE_TTL_SECS: u64 = 3600; + +#[derive(Debug, Deserialize)] +struct LexiconDoc { + defs: HashMap, +} + +#[derive(Debug, Deserialize)] +struct LexiconDef { + #[serde(rename = "type")] + def_type: String, + permissions: Option>, +} + +#[derive(Debug, Deserialize)] +struct PermissionEntry { + resource: String, + collection: Option>, +} + +pub async fn expand_include_scopes(scope_string: &str) -> String { + let futures: Vec<_> = scope_string + .split_whitespace() + .map(|scope| async move { + match scope.strip_prefix("include:") { + Some(nsid) => { + let nsid_base = nsid.split('?').next().unwrap_or(nsid); + expand_permission_set(nsid_base).await.unwrap_or_else(|e| { + warn!(nsid = nsid_base, error = %e, "Failed to expand permission set, keeping original"); + scope.to_string() + }) + } + None => scope.to_string(), + } + }) + .collect(); + + futures::future::join_all(futures).await.join(" ") +} + +async fn expand_permission_set(nsid: &str) -> Result { + { + let cache = LEXICON_CACHE.read().await; + if let Some(cached) = cache.get(nsid) { + if cached.cached_at.elapsed().as_secs() < CACHE_TTL_SECS { + debug!(nsid, "Using cached permission set expansion"); + return Ok(cached.expanded_scope.clone()); + } + } + } + + let parts: Vec<&str> = nsid.split('.').collect(); + if parts.len() < 3 { + return Err(format!("Invalid NSID format: {}", nsid)); + } + + let domain_parts: Vec<&str> = parts[..2].iter().rev().cloned().collect(); + let domain = domain_parts.join("."); + let path = parts[2..].join("/"); + + let url = format!("https://{}/lexicons/{}.json", domain, path); + debug!(nsid, url = %url, "Fetching permission set lexicon"); + + let client = Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .map_err(|e| format!("Failed to create HTTP client: {}", e))?; + + let response = client + .get(&url) + .header("Accept", "application/json") + .send() + .await + .map_err(|e| format!("Failed to fetch lexicon: {}", e))?; + + if !response.status().is_success() { + return Err(format!( + "Failed to fetch lexicon: HTTP {}", + response.status() + )); + } + + let lexicon: LexiconDoc = response + .json() + .await + .map_err(|e| format!("Failed to parse lexicon: {}", e))?; + + let main_def = lexicon + .defs + .get("main") + .ok_or("Missing 'main' definition in lexicon")?; + + if main_def.def_type != "permission-set" { + return Err(format!( + "Expected permission-set type, got: {}", + main_def.def_type + )); + } + + let permissions = main_def + .permissions + .as_ref() + .ok_or("Missing permissions in permission-set")?; + + let mut collections: Vec = permissions + .iter() + .filter(|perm| perm.resource == "repo") + .filter_map(|perm| perm.collection.as_ref()) + .flatten() + .cloned() + .collect(); + + if collections.is_empty() { + return Err("No repo collections found in permission-set".to_string()); + } + + collections.sort(); + + let collection_params: Vec = collections + .iter() + .map(|c| format!("collection={}", c)) + .collect(); + + let expanded = format!("repo?{}", collection_params.join("&")); + + { + let mut cache = LEXICON_CACHE.write().await; + cache.insert( + nsid.to_string(), + CachedLexicon { + expanded_scope: expanded.clone(), + cached_at: std::time::Instant::now(), + }, + ); + } + + debug!(nsid, expanded = %expanded, "Successfully expanded permission set"); + Ok(expanded) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_nsid_to_url() { + let nsid = "io.atcr.authFullApp"; + let parts: Vec<&str> = nsid.split('.').collect(); + let domain_parts: Vec<&str> = parts[..2].iter().rev().cloned().collect(); + let domain = domain_parts.join("."); + let path = parts[2..].join("/"); + + assert_eq!(domain, "atcr.io"); + assert_eq!(path, "authFullApp"); + } +}