experiment: deconstructing include oauth

This commit is contained in:
lewis
2026-01-06 20:31:42 +02:00
parent 9b9c273b59
commit 2466a9d3f4
6 changed files with 186 additions and 8 deletions
-2
View File
@@ -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/
-2
View File
@@ -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.
-3
View File
@@ -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
+12 -1
View File
@@ -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,
+2
View File
@@ -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;
+172
View File
@@ -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");
}
}