this repo has no description

experiment: deconstructing include oauth

lewis 08bec9b4 96840378

Changed files
+186 -8
src
oauth
endpoints
token
scopes
-2
.gitignore
··· 2 .env 3 .direnv 4 .result 5 - reference-pds-hailey/ 6 reference-pds-bsky/ 7 reference-relay-indigo/ 8 pds-moover/ 9 - # Frontend build artifacts 10 frontend/node_modules/ 11 frontend/dist/
··· 2 .env 3 .direnv 4 .result 5 reference-pds-bsky/ 6 reference-relay-indigo/ 7 pds-moover/ 8 frontend/node_modules/ 9 frontend/dist/
-2
README.md
··· 12 13 ## What's different about Tranquil PDS 14 15 - This software isn't an afterthought by a company with limited resources. 16 - 17 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. 18 19 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.
··· 12 13 ## What's different about Tranquil PDS 14 15 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. 16 17 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.
-3
ref_pds_downloader.sh
··· 1 #!/bin/bash 2 - echo "Downloading haileyok/cocoon" 3 - git clone --depth 1 https://github.com/haileyok/cocoon reference-pds-hailey 4 - rm -rf reference-pds-hailey/.git 5 echo "Downloading bluesky-social/atproto pds package" 6 mkdir reference-pds-bsky 7 cd reference-pds-bsky
··· 1 #!/bin/bash 2 echo "Downloading bluesky-social/atproto pds package" 3 mkdir reference-pds-bsky 4 cd reference-pds-bsky
+12 -1
src/oauth/endpoints/token/grants.rs
··· 7 client::{ClientMetadataCache, verify_client_auth}, 8 db::{self, RefreshTokenLookup}, 9 dpop::DPoPVerifier, 10 }; 11 use crate::state::AppState; 12 use axum::Json; ··· 122 let refresh_token = RefreshToken::generate(); 123 let now = Utc::now(); 124 125 - let (final_scope, controller_did) = if let Some(ref controller) = auth_request.controller_did { 126 let grant = delegation::get_delegation(&state.db, &did, controller) 127 .await 128 .ok() ··· 137 (Some(intersected), Some(controller.clone())) 138 } else { 139 (auth_request.parameters.scope.clone(), None) 140 }; 141 142 let access_token = create_access_token_with_delegation(
··· 7 client::{ClientMetadataCache, verify_client_auth}, 8 db::{self, RefreshTokenLookup}, 9 dpop::DPoPVerifier, 10 + scopes::expand_include_scopes, 11 }; 12 use crate::state::AppState; 13 use axum::Json; ··· 123 let refresh_token = RefreshToken::generate(); 124 let now = Utc::now(); 125 126 + let (raw_scope, controller_did) = if let Some(ref controller) = auth_request.controller_did { 127 let grant = delegation::get_delegation(&state.db, &did, controller) 128 .await 129 .ok() ··· 138 (Some(intersected), Some(controller.clone())) 139 } else { 140 (auth_request.parameters.scope.clone(), None) 141 + }; 142 + 143 + let final_scope = if let Some(ref scope) = raw_scope { 144 + if scope.contains("include:") { 145 + Some(expand_include_scopes(scope).await) 146 + } else { 147 + raw_scope 148 + } 149 + } else { 150 + raw_scope 151 }; 152 153 let access_token = create_access_token_with_delegation(
+2
src/oauth/scopes/mod.rs
··· 1 mod definitions; 2 mod error; 3 mod parser; 4 mod permissions; 5 6 pub use definitions::{SCOPE_DEFINITIONS, ScopeCategory, ScopeDefinition}; ··· 9 AccountAction, AccountAttr, AccountScope, BlobScope, IdentityAttr, IdentityScope, IncludeScope, 10 ParsedScope, RepoAction, RepoScope, RpcScope, parse_scope, parse_scope_string, 11 }; 12 pub use permissions::ScopePermissions;
··· 1 mod definitions; 2 mod error; 3 mod parser; 4 + mod permission_set; 5 mod permissions; 6 7 pub use definitions::{SCOPE_DEFINITIONS, ScopeCategory, ScopeDefinition}; ··· 10 AccountAction, AccountAttr, AccountScope, BlobScope, IdentityAttr, IdentityScope, IncludeScope, 11 ParsedScope, RepoAction, RepoScope, RpcScope, parse_scope, parse_scope_string, 12 }; 13 + pub use permission_set::expand_include_scopes; 14 pub use permissions::ScopePermissions;
+172
src/oauth/scopes/permission_set.rs
···
··· 1 + use reqwest::Client; 2 + use serde::Deserialize; 3 + use std::collections::HashMap; 4 + use std::sync::LazyLock; 5 + use tokio::sync::RwLock; 6 + use tracing::{debug, warn}; 7 + 8 + static LEXICON_CACHE: LazyLock<RwLock<HashMap<String, CachedLexicon>>> = 9 + LazyLock::new(|| RwLock::new(HashMap::new())); 10 + 11 + #[derive(Clone)] 12 + struct CachedLexicon { 13 + expanded_scope: String, 14 + cached_at: std::time::Instant, 15 + } 16 + 17 + const CACHE_TTL_SECS: u64 = 3600; 18 + 19 + #[derive(Debug, Deserialize)] 20 + struct LexiconDoc { 21 + defs: HashMap<String, LexiconDef>, 22 + } 23 + 24 + #[derive(Debug, Deserialize)] 25 + struct LexiconDef { 26 + #[serde(rename = "type")] 27 + def_type: String, 28 + permissions: Option<Vec<PermissionEntry>>, 29 + } 30 + 31 + #[derive(Debug, Deserialize)] 32 + struct PermissionEntry { 33 + resource: String, 34 + collection: Option<Vec<String>>, 35 + } 36 + 37 + pub async fn expand_include_scopes(scope_string: &str) -> String { 38 + let futures: Vec<_> = scope_string 39 + .split_whitespace() 40 + .map(|scope| async move { 41 + match scope.strip_prefix("include:") { 42 + Some(nsid) => { 43 + let nsid_base = nsid.split('?').next().unwrap_or(nsid); 44 + expand_permission_set(nsid_base).await.unwrap_or_else(|e| { 45 + warn!(nsid = nsid_base, error = %e, "Failed to expand permission set, keeping original"); 46 + scope.to_string() 47 + }) 48 + } 49 + None => scope.to_string(), 50 + } 51 + }) 52 + .collect(); 53 + 54 + futures::future::join_all(futures).await.join(" ") 55 + } 56 + 57 + async fn expand_permission_set(nsid: &str) -> Result<String, String> { 58 + { 59 + let cache = LEXICON_CACHE.read().await; 60 + if let Some(cached) = cache.get(nsid) { 61 + if cached.cached_at.elapsed().as_secs() < CACHE_TTL_SECS { 62 + debug!(nsid, "Using cached permission set expansion"); 63 + return Ok(cached.expanded_scope.clone()); 64 + } 65 + } 66 + } 67 + 68 + let parts: Vec<&str> = nsid.split('.').collect(); 69 + if parts.len() < 3 { 70 + return Err(format!("Invalid NSID format: {}", nsid)); 71 + } 72 + 73 + let domain_parts: Vec<&str> = parts[..2].iter().rev().cloned().collect(); 74 + let domain = domain_parts.join("."); 75 + let path = parts[2..].join("/"); 76 + 77 + let url = format!("https://{}/lexicons/{}.json", domain, path); 78 + debug!(nsid, url = %url, "Fetching permission set lexicon"); 79 + 80 + let client = Client::builder() 81 + .timeout(std::time::Duration::from_secs(10)) 82 + .build() 83 + .map_err(|e| format!("Failed to create HTTP client: {}", e))?; 84 + 85 + let response = client 86 + .get(&url) 87 + .header("Accept", "application/json") 88 + .send() 89 + .await 90 + .map_err(|e| format!("Failed to fetch lexicon: {}", e))?; 91 + 92 + if !response.status().is_success() { 93 + return Err(format!( 94 + "Failed to fetch lexicon: HTTP {}", 95 + response.status() 96 + )); 97 + } 98 + 99 + let lexicon: LexiconDoc = response 100 + .json() 101 + .await 102 + .map_err(|e| format!("Failed to parse lexicon: {}", e))?; 103 + 104 + let main_def = lexicon 105 + .defs 106 + .get("main") 107 + .ok_or("Missing 'main' definition in lexicon")?; 108 + 109 + if main_def.def_type != "permission-set" { 110 + return Err(format!( 111 + "Expected permission-set type, got: {}", 112 + main_def.def_type 113 + )); 114 + } 115 + 116 + let permissions = main_def 117 + .permissions 118 + .as_ref() 119 + .ok_or("Missing permissions in permission-set")?; 120 + 121 + let mut collections: Vec<String> = permissions 122 + .iter() 123 + .filter(|perm| perm.resource == "repo") 124 + .filter_map(|perm| perm.collection.as_ref()) 125 + .flatten() 126 + .cloned() 127 + .collect(); 128 + 129 + if collections.is_empty() { 130 + return Err("No repo collections found in permission-set".to_string()); 131 + } 132 + 133 + collections.sort(); 134 + 135 + let collection_params: Vec<String> = collections 136 + .iter() 137 + .map(|c| format!("collection={}", c)) 138 + .collect(); 139 + 140 + let expanded = format!("repo?{}", collection_params.join("&")); 141 + 142 + { 143 + let mut cache = LEXICON_CACHE.write().await; 144 + cache.insert( 145 + nsid.to_string(), 146 + CachedLexicon { 147 + expanded_scope: expanded.clone(), 148 + cached_at: std::time::Instant::now(), 149 + }, 150 + ); 151 + } 152 + 153 + debug!(nsid, expanded = %expanded, "Successfully expanded permission set"); 154 + Ok(expanded) 155 + } 156 + 157 + #[cfg(test)] 158 + mod tests { 159 + use super::*; 160 + 161 + #[test] 162 + fn test_nsid_to_url() { 163 + let nsid = "io.atcr.authFullApp"; 164 + let parts: Vec<&str> = nsid.split('.').collect(); 165 + let domain_parts: Vec<&str> = parts[..2].iter().rev().cloned().collect(); 166 + let domain = domain_parts.join("."); 167 + let path = parts[2..].join("/"); 168 + 169 + assert_eq!(domain, "atcr.io"); 170 + assert_eq!(path, "authFullApp"); 171 + } 172 + }