A lexicon-driven AppView for ATProto.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759use sha2::{Digest, Sha256};
use crate::db::{DatabaseBackend, adapt_sql};use crate::error::AppError;
/// Resolved API client identity for DPoP operations.pub struct ResolvedClient { pub id: String, pub client_key: String, pub client_type: String, pub scopes: String, pub allowed_origins: Option<Vec<String>>,}
/// Authenticate a confidential client using client_key + client_secret.pub async fn authenticate_confidential( pool: &sqlx::AnyPool, backend: DatabaseBackend, client_key: &str, client_secret: &str,) -> Result<ResolvedClient, AppError> { let secret_hash = hex::encode(Sha256::digest(client_secret.as_bytes()));
let sql = adapt_sql( "SELECT id, client_key, client_type, scopes, allowed_origins, client_secret_hash FROM happyview_api_clients WHERE client_key = ? AND is_active = 1", backend, );
let row: Option<(String, String, String, String, Option<String>, String)> = crate::db::query_as(&sql) .bind(client_key) .fetch_optional(pool) .await .map_err(|e| AppError::Internal(format!("client lookup failed: {e}")))?;
let (id, key, client_type, scopes, origins_json, stored_hash) = row.ok_or_else(|| AppError::Auth("invalid client credentials".into()))?;
if !crate::constant_time::ct_eq_str(&stored_hash, &secret_hash) { return Err(AppError::Auth("invalid client credentials".into())); }
if client_type != "confidential" { return Err(AppError::Auth( "this endpoint requires confidential client authentication".into(), )); }
let allowed_origins = origins_json.map(|json| serde_json::from_str::<Vec<String>>(&json).unwrap_or_default());
Ok(ResolvedClient { id, client_key: key, client_type, scopes, allowed_origins, })}
/// Authenticate a public client using client_key + origin validation./// Returns the client but does NOT verify PKCE — that's done at session registration.pub async fn authenticate_public( pool: &sqlx::AnyPool, backend: DatabaseBackend, client_key: &str, origin: Option<&str>,) -> Result<ResolvedClient, AppError> { let sql = adapt_sql( "SELECT id, client_key, client_type, scopes, allowed_origins FROM happyview_api_clients WHERE client_key = ? AND is_active = 1", backend, );
let row: Option<(String, String, String, String, Option<String>)> = crate::db::query_as(&sql) .bind(client_key) .fetch_optional(pool) .await .map_err(|e| AppError::Internal(format!("client lookup failed: {e}")))?;
let (id, key, client_type, scopes, origins_json) = row.ok_or_else(|| AppError::Auth("unknown client".into()))?;
if client_type != "public" { return Err(AppError::Auth( "this client is not registered as a public client".into(), )); }
// Validate origin if the client has allowed_origins configured if let Some(ref origins_str) = origins_json { let allowed: Vec<String> = serde_json::from_str(origins_str).unwrap_or_default(); if !allowed.is_empty() { match origin { Some(o) if allowed.iter().any(|a| a == o) => {} Some(o) => { tracing::warn!(client_key, origin = o, "Origin mismatch for public client"); return Err(AppError::Auth("origin not allowed for this client".into())); } None => { tracing::warn!(client_key, "No Origin header for public client"); return Err(AppError::Auth( "Origin header required for public clients".into(), )); } } } }
let allowed_origins = origins_json.map(|json| serde_json::from_str::<Vec<String>>(&json).unwrap_or_default());
Ok(ResolvedClient { id, client_key: key, client_type, scopes, allowed_origins, })}
/// Resolve an API client by client_key only (no secret verification)./// Used when the caller has already been authenticated by other means (e.g. DPoP proof).pub async fn resolve_client_by_key( pool: &sqlx::AnyPool, backend: DatabaseBackend, client_key: &str,) -> Result<ResolvedClient, AppError> { let sql = adapt_sql( "SELECT id, client_key, client_type, scopes, allowed_origins FROM happyview_api_clients WHERE client_key = ? AND is_active = 1", backend, );
let row: Option<(String, String, String, String, Option<String>)> = crate::db::query_as(&sql) .bind(client_key) .fetch_optional(pool) .await .map_err(|e| AppError::Internal(format!("client lookup failed: {e}")))?;
let (id, key, client_type, scopes, origins_json) = row.ok_or_else(|| AppError::Auth("unknown client".into()))?;
let allowed_origins = origins_json.map(|json| serde_json::from_str::<Vec<String>>(&json).unwrap_or_default());
Ok(ResolvedClient { id, client_key: key, client_type, scopes, allowed_origins, })}
/// Validate that token scopes are allowed by the client's registered scopes.////// The grammar and the subset rules are `happyview-scopes`', which is pinned to/// the reference implementation. What is HappyView's is resolving `include:`/// scopes, because only HappyView has a lexicon registry to resolve them with.////// Rules:/// - `atproto` must be present in the token scopes and is always allowed/// - every other token scope must be fully covered by the client's registered/// scopes, *including* the actions it grants — a token asking for create,/// update and delete is not satisfied by a client registered for create alone/// - `include:X` client scopes expand to the permissions declared by permission/// set lexicon `X`, subject to that set's authority containmentpub async fn validate_scopes( token_scopes: &str, client_scopes: &str, lexicons: &crate::lexicon::LexiconRegistry,) -> Result<(), AppError> { let token_list = happyview_scopes::parse_scope_list(token_scopes);
if !token_list.iter().any(|s| s == "atproto") { return Err(AppError::BadRequest( "token must include the 'atproto' scope".into(), )); }
let mut effective: Vec<String> = Vec::new(); for scope in happyview_scopes::parse_scope_list(client_scopes) { if scope.starts_with("include:") { expand_permission_set(&scope, lexicons, &mut effective).await; } effective.push(scope); }
let client = happyview_scopes::ScopePermissions::from_scopes(effective);
for scope in &token_list { if !client.covers_scope(scope) { return Err(AppError::BadRequest(format!( "scope '{scope}' is not allowed for this client" ))); } }
Ok(())}
/// Expand an `include:<nsid>` scope into the permissions its lexicon declares.////// Resolution is the only part that is HappyView's: fetch the lexicon, hand the/// document to the shared crate, and take back the permissions it yields. The/// crate applies the rules that make this safe — a permission set may only/// grant NSIDs under its own authority, may not pin a concrete `aud`, and its/// `rpc` permissions need an audience to be expressible at all.////// A missing or malformed set contributes nothing rather than failing the/// whole validation, matching the reference, which skips what it cannot use.async fn expand_permission_set( scope: &str, lexicons: &crate::lexicon::LexiconRegistry, out: &mut Vec<String>,) { let Some(include) = happyview_scopes::IncludeScope::parse(scope) else { tracing::warn!(%scope, "malformed include: scope"); return; };
let Some(lexicon) = lexicons.get(&include.nsid).await else { tracing::warn!(nsid = %include.nsid, "permission set lexicon not found in registry"); return; };
let Some(permissions) = lexicon .raw .get("defs") .and_then(|d| d.get("main")) .and_then(|m| m.get("permissions")) .and_then(|p| p.as_array()) else { return; };
let set = happyview_scopes::LexPermissionSet { permissions: permissions.iter().filter_map(lex_permission).collect(), };
for permission in include.expand(&set) { match permission { happyview_scopes::IncludedPermission::Repo(p) => { for collection in &p.collection { for action in &p.action { out.push(format!("repo:{collection}?action={}", action.as_str())); } } } happyview_scopes::IncludedPermission::Rpc(p) => { for lxm in &p.lxm { // `aud` is not optional in the grammar. Emitting a bare // `rpc:<lxm>` here, as this used to, produced a scope string // the reference rejects outright — so an `include:` set's // rpc permissions never actually matched anything. out.push(format!("rpc:{lxm}?aud={}", urlencoding::encode(&p.aud))); } } } }}
/// Convert one lexicon `permission` entry into the crate's representation,/// preserving scalar/list arity — the reference treats a scalar supplied where/// a list belongs as invalidating the permission rather than coercing it.fn lex_permission(value: &serde_json::Value) -> Option<happyview_scopes::LexPermission> { use happyview_scopes::LexValue;
let obj = value.as_object()?; let resource = obj.get("resource")?.as_str()?.to_string();
let params = obj .iter() .filter(|(k, _)| k.as_str() != "resource" && k.as_str() != "type") .filter_map(|(k, v)| { let value = match v { serde_json::Value::Array(items) => LexValue::List( items .iter() .map(|i| i.as_str().map(str::to_string)) .collect::<Option<Vec<_>>>()?, ), serde_json::Value::Bool(b) => LexValue::Bool(*b), serde_json::Value::String(s) => LexValue::Scalar(s.clone()), // Anything else cannot appear in a valid permission; keep the // key so the crate's unknown-key check still rejects it. _ => LexValue::Scalar(String::new()), }; Some((k.clone(), value)) }) .collect();
Some(happyview_scopes::LexPermission { resource, params })}
/// Verify a PKCE challenge against a verifier.pub fn verify_pkce(challenge: &str, verifier: &str) -> bool { use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; let hash = Sha256::digest(verifier.as_bytes()); let computed = URL_SAFE_NO_PAD.encode(hash); crate::constant_time::ct_eq_str(&computed, challenge)}
#[cfg(test)]mod tests { use super::*;
fn empty_registry() -> crate::lexicon::LexiconRegistry { crate::lexicon::LexiconRegistry::new() }
#[tokio::test] async fn validate_scopes_requires_atproto() { let reg = empty_registry(); let result = validate_scopes("transition:generic", "atproto transition:generic", ®).await; assert!(result.is_err()); }
#[tokio::test] async fn validate_scopes_atproto_only_always_passes() { let reg = empty_registry(); let result = validate_scopes("atproto", "com.example.whatever", ®).await; assert!(result.is_ok()); }
#[tokio::test] async fn validate_scopes_subset_passes() { let reg = empty_registry(); let result = validate_scopes( "atproto com.example.basic", "atproto com.example.basic com.example.advanced", ®, ) .await; assert!(result.is_ok()); }
#[tokio::test] async fn validate_scopes_excess_scope_fails() { let reg = empty_registry(); let result = validate_scopes( "atproto com.example.basic com.example.advanced", "atproto com.example.basic", ®, ) .await; assert!(result.is_err()); }
#[tokio::test] async fn validate_scopes_transition_generic_requires_registration() { let reg = empty_registry(); let result = validate_scopes("atproto transition:generic", "atproto", ®).await; assert!(result.is_err());
let result = validate_scopes( "atproto transition:generic", "atproto transition:generic", ®, ) .await; assert!(result.is_ok()); }
#[tokio::test] async fn validate_scopes_expands_include_permission_set() { let reg = empty_registry(); let raw = serde_json::json!({ "lexicon": 1, "id": "com.example.authBasic", "defs": { "main": { "type": "permission-set", "permissions": [ { "type": "permission", "resource": "rpc", "aud": "*", "lxm": ["com.example.getProfile", "com.example.putProfile"] }, { "type": "permission", "resource": "repo", "collection": ["com.example.profile"] } ] } } }); let parsed = crate::lexicon::ParsedLexicon::parse( raw, 1, None, crate::lexicon::ProcedureAction::Upsert, None, ) .unwrap(); reg.upsert(parsed).await;
let result = validate_scopes( "atproto rpc:com.example.getProfile?aud=* repo:com.example.profile?action=create", "atproto include:com.example.authBasic", ®, ) .await; assert!(result.is_ok(), "{result:?}");
let result = validate_scopes( "atproto rpc:com.example.notAllowed?aud=*", "atproto include:com.example.authBasic", ®, ) .await; assert!(result.is_err()); }
/// A permission set may only grant NSIDs under its own authority group. /// /// Without this, publishing a permission-set lexicon would be enough to /// vouch for someone else's collections. This check did not exist before /// the shared crate; it is the reason adopting it is a behaviour change. #[tokio::test] async fn include_cannot_grant_another_authoritys_collection() { let reg = empty_registry(); let raw = serde_json::json!({ "lexicon": 1, "id": "com.example.authBasic", "defs": { "main": { "type": "permission-set", "permissions": [ { "type": "permission", "resource": "repo", "collection": ["com.example.profile"] }, { "type": "permission", "resource": "repo", "collection": ["app.bsky.feed.post"] } ] } } }); let parsed = crate::lexicon::ParsedLexicon::parse( raw, 1, None, crate::lexicon::ProcedureAction::Upsert, None, ) .unwrap(); reg.upsert(parsed).await;
// Its own authority: granted. assert!( validate_scopes( "atproto repo:com.example.profile?action=create", "atproto include:com.example.authBasic", ®, ) .await .is_ok() );
// Someone else's: dropped during expansion, so never granted. assert!( validate_scopes( "atproto repo:app.bsky.feed.post?action=create", "atproto include:com.example.authBasic", ®, ) .await .is_err() ); }
/// Containment is all-or-nothing *per permission entry*, not per NSID: one /// entry listing a foreign collection alongside its own grants neither. /// Splitting them into separate entries is what keeps the local one. #[tokio::test] async fn include_drops_a_whole_entry_that_reaches_outside_its_authority() { let reg = empty_registry(); let raw = serde_json::json!({ "lexicon": 1, "id": "com.example.authBasic", "defs": { "main": { "type": "permission-set", "permissions": [ { "type": "permission", "resource": "repo", "collection": ["com.example.profile", "app.bsky.feed.post"] } ] } } }); let parsed = crate::lexicon::ParsedLexicon::parse( raw, 1, None, crate::lexicon::ProcedureAction::Upsert, None, ) .unwrap(); reg.upsert(parsed).await;
for scope in [ "atproto repo:com.example.profile?action=create", "atproto repo:app.bsky.feed.post?action=create", ] { assert!( validate_scopes(scope, "atproto include:com.example.authBasic", ®) .await .is_err(), "{scope} should not be granted by a mixed-authority entry" ); } }
/// An `rpc` permission with no audience is not expressible, so a permission /// set declaring one expands to nothing. This used to emit a bare /// `rpc:<lxm>`, which the grammar rejects — meaning it matched nothing /// anyway, just less visibly. #[tokio::test] async fn include_rpc_without_an_audience_grants_nothing() { let reg = empty_registry(); let raw = serde_json::json!({ "lexicon": 1, "id": "com.example.authBasic", "defs": { "main": { "type": "permission-set", "permissions": [ { "type": "permission", "resource": "rpc", "lxm": ["com.example.getProfile"] } ] } } }); let parsed = crate::lexicon::ParsedLexicon::parse( raw, 1, None, crate::lexicon::ProcedureAction::Upsert, None, ) .unwrap(); reg.upsert(parsed).await;
assert!( validate_scopes( "atproto rpc:com.example.getProfile?aud=*", "atproto include:com.example.authBasic", ®, ) .await .is_err() ); }
/// Subsetting is per-action: a client registered for `create` alone does /// not satisfy a token asking for every action on the same collection. #[tokio::test] async fn validate_scopes_subsetting_is_per_action() { let reg = empty_registry();
assert!( validate_scopes( "atproto repo:com.example.post?action=create", "atproto repo:com.example.post?action=create&action=update", ®, ) .await .is_ok() );
// The bare form grants all three, which `?action=create` does not cover. assert!( validate_scopes( "atproto repo:com.example.post", "atproto repo:com.example.post?action=create", ®, ) .await .is_err() ); }
#[tokio::test] async fn validate_scopes_repo_collection_allowed_with_transition_generic() { let reg = empty_registry(); let result = validate_scopes( "atproto transition:generic repo?collection=com.example.profile&collection=com.example.post", "atproto transition:generic", ®, ) .await; assert!(result.is_ok()); }
#[tokio::test] async fn validate_scopes_repo_collection_allowed_with_expanded_permissions() { let reg = empty_registry(); let raw = serde_json::json!({ "lexicon": 1, "id": "com.example.authBasic", "defs": { "main": { "type": "permission-set", "permissions": [ { "type": "permission", "resource": "repo", "collection": ["com.example.profile", "com.example.post"] } ] } } }); let parsed = crate::lexicon::ParsedLexicon::parse( raw, 1, None, crate::lexicon::ProcedureAction::Upsert, None, ) .unwrap(); reg.upsert(parsed).await;
let result = validate_scopes( "atproto repo?collection=com.example.profile&collection=com.example.post", "atproto include:com.example.authBasic", ®, ) .await; assert!(result.is_ok()); }
#[tokio::test] async fn validate_scopes_repo_collection_rejected_without_permission() { let reg = empty_registry(); let result = validate_scopes( "atproto repo?collection=com.example.profile", "atproto", ®, ) .await; assert!(result.is_err()); }
#[tokio::test] async fn validate_scopes_repo_collection_rejected_partial_match() { let reg = empty_registry(); let raw = serde_json::json!({ "lexicon": 1, "id": "com.example.authBasic", "defs": { "main": { "type": "permission-set", "permissions": [ { "type": "permission", "resource": "repo", "collection": ["com.example.profile"] } ] } } }); let parsed = crate::lexicon::ParsedLexicon::parse( raw, 1, None, crate::lexicon::ProcedureAction::Upsert, None, ) .unwrap(); reg.upsert(parsed).await;
let result = validate_scopes( "atproto repo?collection=com.example.profile&collection=com.example.secret", "atproto include:com.example.authBasic", ®, ) .await; assert!(result.is_err()); }
#[tokio::test] async fn validate_scopes_bare_repo_collection_allowed_with_expanded_permissions() { let reg = empty_registry(); let raw = serde_json::json!({ "lexicon": 1, "id": "com.example.authBasic", "defs": { "main": { "type": "permission-set", "permissions": [ { "type": "permission", "resource": "repo", "collection": ["com.example.profile", "com.example.post"] } ] } } }); let parsed = crate::lexicon::ParsedLexicon::parse( raw, 1, None, crate::lexicon::ProcedureAction::Upsert, None, ) .unwrap(); reg.upsert(parsed).await;
let result = validate_scopes( "atproto repo:com.example.profile", "atproto include:com.example.authBasic", ®, ) .await; assert!(result.is_ok()); }
#[tokio::test] async fn validate_scopes_bare_repo_collection_rejected_without_permission() { let reg = empty_registry(); let result = validate_scopes("atproto repo:com.example.secret", "atproto", ®).await; assert!(result.is_err()); }
#[test] fn verify_pkce_valid() { use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD;
let verifier = "test-verifier-string-12345678901234567890"; let hash = sha2::Sha256::digest(verifier.as_bytes()); let challenge = URL_SAFE_NO_PAD.encode(hash);
assert!(verify_pkce(&challenge, verifier)); }
#[test] fn verify_pkce_invalid() { assert!(!verify_pkce("wrong-challenge", "some-verifier")); }}