mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-09-04 01:06:55 +00:00
experiment: deconstructing include oauth
This commit is contained in:
@@ -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/
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<RwLock<HashMap<String, CachedLexicon>>> =
|
||||
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<String, LexiconDef>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LexiconDef {
|
||||
#[serde(rename = "type")]
|
||||
def_type: String,
|
||||
permissions: Option<Vec<PermissionEntry>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PermissionEntry {
|
||||
resource: String,
|
||||
collection: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
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<String, String> {
|
||||
{
|
||||
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<String> = 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<String> = 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user