From c6d77c35d878fba4dfd1cb35d942d648410a4c5b Mon Sep 17 00:00:00 2001 From: Trezy Date: Tue, 21 Apr 2026 14:38:02 -0500 Subject: [PATCH] fix(xrpc): fix XRPC writes to use the new auth system --- src/admin/api_clients.rs | 32 +- src/admin/types.rs | 5 +- src/auth/client_registry.rs | 116 ++-- src/lua/execute.rs | 90 ++- src/lua/record.rs | 91 ++- src/oauth/client_auth.rs | 178 +++++- src/oauth/pds_write.rs | 551 ++++++++++++++++-- src/oauth/routes.rs | 2 +- src/repo/mod.rs | 2 +- src/repo/pds.rs | 47 ++ src/repo/upload_blob.rs | 1 + src/xrpc/procedure.rs | 1 + tests/dpop_auth.rs | 2 +- .../dashboard/settings/api-clients/page.tsx | 159 ++++- web/src/lib/api.ts | 3 + web/src/types/api-clients.ts | 5 +- 16 files changed, 1051 insertions(+), 234 deletions(-) diff --git a/src/admin/api_clients.rs b/src/admin/api_clients.rs index 720039b..f3b2212 100644 --- a/src/admin/api_clients.rs +++ b/src/admin/api_clients.rs @@ -30,11 +30,16 @@ pub(super) async fn create_api_client( rand::rng().fill(&mut random_bytes); let client_key = format!("hvc_{}", hex::encode(random_bytes)); - // Generate the client secret: "hvs_" + 64 random hex chars. - let mut secret_bytes = [0u8; 32]; - rand::rng().fill(&mut secret_bytes); - let client_secret = format!("hvs_{}", hex::encode(secret_bytes)); - let client_secret_hash = hex::encode(Sha256::digest(client_secret.as_bytes())); + // Generate the client secret for confidential clients only. + let (client_secret, client_secret_hash) = if body.client_type == "confidential" { + let mut secret_bytes = [0u8; 32]; + rand::rng().fill(&mut secret_bytes); + let secret = format!("hvs_{}", hex::encode(secret_bytes)); + let hash = hex::encode(Sha256::digest(secret.as_bytes())); + (Some(secret), hash) + } else { + (None, String::new()) + }; let id = Uuid::new_v4().to_string(); let now = now_rfc3339(); @@ -92,7 +97,7 @@ pub(super) async fn create_api_client( state.rate_limiter.register_client_identity( client_key.clone(), crate::rate_limit::ClientIdentity { - secret_hash: client_secret_hash, + secret_hash: client_secret_hash.clone(), client_uri: body.client_uri.clone(), }, ); @@ -138,6 +143,7 @@ pub(super) async fn create_api_client( client_secret, name: body.name, client_id_url: body.client_id_url, + client_type: body.client_type, }), )) } @@ -317,7 +323,7 @@ pub(super) async fn update_api_client( // Read current values let select_sql = adapt_sql( - "SELECT client_key, client_secret_hash, name, client_id_url, client_uri, redirect_uris, scopes, rate_limit_capacity, rate_limit_refill_rate, is_active FROM api_clients WHERE id = ?", + "SELECT client_key, client_secret_hash, name, client_id_url, client_uri, redirect_uris, scopes, allowed_origins, rate_limit_capacity, rate_limit_refill_rate, is_active FROM api_clients WHERE id = ?", state.db_backend, ); @@ -329,6 +335,7 @@ pub(super) async fn update_api_client( String, String, String, + Option, Option, Option, i32, @@ -347,6 +354,7 @@ pub(super) async fn update_api_client( cur_client_uri, cur_redirect_uris, cur_scopes, + cur_allowed_origins, cur_capacity, cur_refill, cur_active, @@ -362,6 +370,13 @@ pub(super) async fn update_api_client( .map(|uris| serde_json::to_string(&uris).unwrap_or_else(|_| "[]".to_string())) .unwrap_or(cur_redirect_uris); let scopes = body.scopes.unwrap_or(cur_scopes); + let allowed_origins_json: Option = match body.allowed_origins { + Some(Some(origins)) => { + Some(serde_json::to_string(&origins).unwrap_or_else(|_| "[]".to_string())) + } + Some(None) => None, + None => cur_allowed_origins, + }; let capacity = body.rate_limit_capacity.unwrap_or(cur_capacity); let refill_rate = body.rate_limit_refill_rate.unwrap_or(cur_refill); let is_active = body @@ -371,7 +386,7 @@ pub(super) async fn update_api_client( let now = now_rfc3339(); let update_sql = adapt_sql( - "UPDATE api_clients SET name = ?, client_uri = ?, redirect_uris = ?, scopes = ?, rate_limit_capacity = ?, rate_limit_refill_rate = ?, is_active = ?, updated_at = ? WHERE id = ?", + "UPDATE api_clients SET name = ?, client_uri = ?, redirect_uris = ?, scopes = ?, allowed_origins = ?, rate_limit_capacity = ?, rate_limit_refill_rate = ?, is_active = ?, updated_at = ? WHERE id = ?", state.db_backend, ); @@ -380,6 +395,7 @@ pub(super) async fn update_api_client( .bind(&client_uri) .bind(&redirect_uris_json) .bind(&scopes) + .bind(&allowed_origins_json) .bind(capacity) .bind(refill_rate) .bind(is_active) diff --git a/src/admin/types.rs b/src/admin/types.rs index 101f667..4e3af07 100644 --- a/src/admin/types.rs +++ b/src/admin/types.rs @@ -375,6 +375,7 @@ pub(super) struct UpdateApiClientBody { pub(super) client_uri: Option, pub(super) redirect_uris: Option>, pub(super) scopes: Option, + pub(super) allowed_origins: Option>>, pub(super) rate_limit_capacity: Option>, pub(super) rate_limit_refill_rate: Option>, pub(super) is_active: Option, @@ -403,7 +404,9 @@ pub(super) struct ApiClientSummary { pub(super) struct CreateApiClientResponse { pub(super) id: String, pub(super) client_key: String, - pub(super) client_secret: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) client_secret: Option, pub(super) name: String, pub(super) client_id_url: String, + pub(super) client_type: String, } diff --git a/src/auth/client_registry.rs b/src/auth/client_registry.rs index c46a9d0..78cf1d9 100644 --- a/src/auth/client_registry.rs +++ b/src/auth/client_registry.rs @@ -5,8 +5,8 @@ use std::sync::Arc; use atrium_identity::did::{CommonDidResolver, CommonDidResolverConfig}; use atrium_identity::handle::{AtprotoHandleResolver, AtprotoHandleResolverConfig}; use atrium_oauth::{ - AtprotoClientMetadata, AuthMethod, DefaultHttpClient, GrantType, OAuthClientConfig, - OAuthResolverConfig, + AtprotoClientMetadata, AtprotoLocalhostClientMetadata, AuthMethod, DefaultHttpClient, + GrantType, OAuthClientConfig, OAuthResolverConfig, }; use crate::HappyViewOAuthClient; @@ -14,6 +14,10 @@ use crate::auth::oauth_store::{DbSessionStore, DbStateStore}; use crate::db::{DatabaseBackend, adapt_sql}; use crate::dns::NativeDnsResolver; +fn is_loopback_url(url: &str) -> bool { + url.contains("127.0.0.1") || url.contains("[::1]") || url.contains("localhost") +} + /// Parameters needed to build an OAuth client for an API client registration. pub struct ApiClientOAuthParams { pub plc_url: String, @@ -57,6 +61,16 @@ impl OAuthClientRegistry { self.clients.get(client_id_url).map(|r| r.value().clone()) } + /// Get the resolved OAuth `client_id` for a registered client. + /// + /// For loopback clients this returns `http://localhost?scope=...` (the format + /// auth servers expect), not the original `client_id_url` key. + pub fn get_resolved_client_id(&self, client_id_url: &str) -> Option { + self.clients + .get(client_id_url) + .map(|r| r.value().client_metadata.client_id.clone()) + } + /// Look up a client by `client_id_url`, falling back to the primary client. pub fn get_or_default(&self, client_id_url: Option<&str>) -> Arc { if let Some(url) = client_id_url { @@ -141,17 +155,6 @@ impl OAuthClientRegistry { scopes }; - let metadata = AtprotoClientMetadata { - client_id: client_id_url.to_string(), - client_uri: Some(client_uri.to_string()), - redirect_uris, - token_endpoint_auth_method: AuthMethod::None, - grant_types: vec![GrantType::AuthorizationCode, GrantType::RefreshToken], - scopes, - jwks_uri: None, - token_endpoint_auth_signing_alg: None, - }; - let http = Arc::new(DefaultHttpClient::default()); let resolver = OAuthResolverConfig { did_resolver: CommonDidResolver::new(CommonDidResolverConfig { @@ -166,13 +169,37 @@ impl OAuthClientRegistry { protected_resource_metadata: Default::default(), }; - match atrium_oauth::OAuthClient::new(OAuthClientConfig { - client_metadata: metadata, - keys: None, - state_store: state_store.clone(), - session_store: DbSessionStore::new(session_store_pool.clone(), *db_backend), - resolver, - }) { + let client = if is_loopback_url(client_id_url) { + atrium_oauth::OAuthClient::new(OAuthClientConfig { + client_metadata: AtprotoLocalhostClientMetadata { + redirect_uris: None, + scopes: Some(scopes), + }, + keys: None, + state_store: state_store.clone(), + session_store: DbSessionStore::new(session_store_pool.clone(), *db_backend), + resolver, + }) + } else { + atrium_oauth::OAuthClient::new(OAuthClientConfig { + client_metadata: AtprotoClientMetadata { + client_id: client_id_url.to_string(), + client_uri: Some(client_uri.to_string()), + redirect_uris, + token_endpoint_auth_method: AuthMethod::None, + grant_types: vec![GrantType::AuthorizationCode, GrantType::RefreshToken], + scopes, + jwks_uri: None, + token_endpoint_auth_signing_alg: None, + }, + keys: None, + state_store: state_store.clone(), + session_store: DbSessionStore::new(session_store_pool.clone(), *db_backend), + resolver, + }) + }; + + match client { Ok(client) => { self.register(client_id_url.to_string(), Arc::new(client)); Ok(()) @@ -217,17 +244,6 @@ impl OAuthClientRegistry { scopes }; - let metadata = AtprotoClientMetadata { - client_id: client_id_url.clone(), - client_uri: Some(client_uri), - redirect_uris, - token_endpoint_auth_method: AuthMethod::None, - grant_types: vec![GrantType::AuthorizationCode, GrantType::RefreshToken], - scopes, - jwks_uri: None, - token_endpoint_auth_signing_alg: None, - }; - // Each OAuthClient needs its own resolver instances (they're not Clone) let http = Arc::new(DefaultHttpClient::default()); let resolver = OAuthResolverConfig { @@ -243,13 +259,37 @@ impl OAuthClientRegistry { protected_resource_metadata: Default::default(), }; - match atrium_oauth::OAuthClient::new(OAuthClientConfig { - client_metadata: metadata, - keys: None, - state_store: state_store.clone(), - session_store: DbSessionStore::new(session_store_pool.clone(), db_backend), - resolver, - }) { + let client = if is_loopback_url(&client_id_url) { + atrium_oauth::OAuthClient::new(OAuthClientConfig { + client_metadata: AtprotoLocalhostClientMetadata { + redirect_uris: None, + scopes: Some(scopes), + }, + keys: None, + state_store: state_store.clone(), + session_store: DbSessionStore::new(session_store_pool.clone(), db_backend), + resolver, + }) + } else { + atrium_oauth::OAuthClient::new(OAuthClientConfig { + client_metadata: AtprotoClientMetadata { + client_id: client_id_url.clone(), + client_uri: Some(client_uri), + redirect_uris, + token_endpoint_auth_method: AuthMethod::None, + grant_types: vec![GrantType::AuthorizationCode, GrantType::RefreshToken], + scopes, + jwks_uri: None, + token_endpoint_auth_signing_alg: None, + }, + keys: None, + state_store: state_store.clone(), + session_store: DbSessionStore::new(session_store_pool.clone(), db_backend), + resolver, + }) + }; + + match client { Ok(client) => { tracing::info!(client_id = %client_id_url, "Registered API client OAuth identity"); self.register(client_id_url, Arc::new(client)); diff --git a/src/lua/execute.rs b/src/lua/execute.rs index 973e17d..1fdf106 100644 --- a/src/lua/execute.rs +++ b/src/lua/execute.rs @@ -57,30 +57,68 @@ pub async fn execute_procedure_script( let script_source = script.to_string(); let input_json = input.clone(); - let session = match repo::get_oauth_session(state, claims.did()).await { - Ok(s) => s, - Err(e) => { - let error_message = format!("{e}"); - log_event( - &state.db, - EventLog { - event_type: "script.error".to_string(), - severity: Severity::Error, - actor_did: Some(claims.did().to_string()), - subject: Some(method.to_string()), - detail: serde_json::json!({ - "error": error_message, - "script_source": script_source, - "input": input_json, - "caller_did": claims.did(), - "method": method, - "duration_ms": start.elapsed().as_millis() as u64, - }), - }, - backend, - ) - .await; - return Err(e); + let pds_auth = if let Some(client_key) = claims.client_key() { + let encryption_key = state + .config + .token_encryption_key + .as_ref() + .ok_or_else(|| AppError::Internal("TOKEN_ENCRYPTION_KEY not configured".into()))?; + let api_client_id = match repo::get_dpop_client_id(state, client_key).await { + Ok(id) => id, + Err(e) => { + let error_message = format!("{e}"); + log_event( + &state.db, + EventLog { + event_type: "script.error".to_string(), + severity: Severity::Error, + actor_did: Some(claims.did().to_string()), + subject: Some(method.to_string()), + detail: serde_json::json!({ + "error": error_message, + "script_source": script_source, + "input": input_json, + "caller_did": claims.did(), + "method": method, + "duration_ms": start.elapsed().as_millis() as u64, + }), + }, + backend, + ) + .await; + return Err(e); + } + }; + repo::PdsAuth::Dpop { + api_client_id, + encryption_key: *encryption_key, + } + } else { + match repo::get_oauth_session(state, claims.did()).await { + Ok(s) => repo::PdsAuth::OAuth(Arc::new(s)), + Err(e) => { + let error_message = format!("{e}"); + log_event( + &state.db, + EventLog { + event_type: "script.error".to_string(), + severity: Severity::Error, + actor_did: Some(claims.did().to_string()), + subject: Some(method.to_string()), + detail: serde_json::json!({ + "error": error_message, + "script_source": script_source, + "input": input_json, + "caller_did": claims.did(), + "method": method, + "duration_ms": start.elapsed().as_millis() as u64, + }), + }, + backend, + ) + .await; + return Err(e); + } } }; @@ -113,7 +151,7 @@ pub async fn execute_procedure_script( let state_arc = Arc::new(state.clone()); let claims_arc = Arc::new(claims.clone()); - let session_arc = Arc::new(session); + let pds_auth_arc = Arc::new(pds_auth); if let Err(e) = db_api::register_db_api(&lua, state_arc.clone()) { let error_message = format!("failed to register db API: {e}"); @@ -213,7 +251,7 @@ pub async fn execute_procedure_script( return Err(AppError::Internal(error_message)); } - if let Err(e) = record::register_record_api(&lua, state_arc, claims_arc, session_arc) { + if let Err(e) = record::register_record_api(&lua, state_arc, claims_arc, pds_auth_arc) { let error_message = format!("failed to register Record API: {e}"); log_event( &state.db, diff --git a/src/lua/record.rs b/src/lua/record.rs index 76911d2..601be7b 100644 --- a/src/lua/record.rs +++ b/src/lua/record.rs @@ -4,11 +4,10 @@ use serde_json::{Value, json}; use std::sync::Arc; use crate::AppState; -use crate::HappyViewOAuthSession; use crate::auth::Claims; use crate::db::{adapt_sql, now_rfc3339}; use crate::record_refs::sync_refs; -use crate::repo; +use crate::repo::PdsAuth; use super::tid::generate_tid; @@ -28,7 +27,7 @@ pub fn register_record_api( lua: &Lua, state: Arc, claims: Arc, - session: Arc, + pds_auth: Arc, ) -> LuaResult<()> { // -- methods table (shared by all Record instances) -- let methods = lua.create_table()?; @@ -37,11 +36,11 @@ pub fn register_record_api( { let state = state.clone(); let claims = claims.clone(); - let session = session.clone(); + let pds_auth = pds_auth.clone(); let save_fn = lua.create_async_function(move |lua, this: mlua::Table| { let state = state.clone(); let claims = claims.clone(); - let session = session.clone(); + let pds_auth = pds_auth.clone(); async move { let backend = state.db_backend; let collection: String = this.raw_get("_collection")?; @@ -74,14 +73,10 @@ pub fn register_record_api( "record": data, }); - let resp = repo::pds_post_json_raw( - &state, - &session, - "com.atproto.repo.putRecord", - &pds_body, - ) - .await - .map_err(|e| mlua::Error::runtime(format!("PDS putRecord failed: {e}")))?; + let resp = pds_auth + .post_json(&state, repo, "com.atproto.repo.putRecord", &pds_body) + .await + .map_err(|e| mlua::Error::runtime(format!("PDS putRecord failed: {e}")))?; if !resp.status().is_success() { let status = resp.status(); @@ -141,14 +136,10 @@ pub fn register_record_api( pds_body["rkey"] = json!(rkey); } - let resp = repo::pds_post_json_raw( - &state, - &session, - "com.atproto.repo.createRecord", - &pds_body, - ) - .await - .map_err(|e| mlua::Error::runtime(format!("PDS createRecord failed: {e}")))?; + let resp = pds_auth + .post_json(&state, repo, "com.atproto.repo.createRecord", &pds_body) + .await + .map_err(|e| mlua::Error::runtime(format!("PDS createRecord failed: {e}")))?; if !resp.status().is_success() { let status = resp.status(); @@ -215,11 +206,11 @@ pub fn register_record_api( { let state = state.clone(); let claims = claims.clone(); - let session = session.clone(); + let pds_auth = pds_auth.clone(); let delete_fn = lua.create_async_function(move |_lua, this: mlua::Table| { let state = state.clone(); let claims = claims.clone(); - let session = session.clone(); + let pds_auth = pds_auth.clone(); async move { let backend = state.db_backend; let uri: String = this.raw_get::>("_uri")?.ok_or_else(|| { @@ -241,14 +232,10 @@ pub fn register_record_api( "rkey": rkey, }); - let resp = repo::pds_post_json_raw( - &state, - &session, - "com.atproto.repo.deleteRecord", - &pds_body, - ) - .await - .map_err(|e| mlua::Error::runtime(format!("PDS deleteRecord failed: {e}")))?; + let resp = pds_auth + .post_json(&state, repo, "com.atproto.repo.deleteRecord", &pds_body) + .await + .map_err(|e| mlua::Error::runtime(format!("PDS deleteRecord failed: {e}")))?; if !resp.status().is_success() { let status = resp.status(); @@ -451,12 +438,12 @@ pub fn register_record_api( { let state = state.clone(); let claims = claims.clone(); - let session = session.clone(); + let pds_auth = pds_auth.clone(); let save_all_fn = lua.create_async_function(move |lua, records_table: mlua::Table| { let state = state.clone(); let claims = claims.clone(); - let session = session.clone(); + let pds_auth = pds_auth.clone(); async move { let backend = state.db_backend; // Extract save data from each record (sync) @@ -484,7 +471,7 @@ pub fn register_record_api( let futs = save_items.iter().map(|(_, collection, existing_uri, rkey, repo_override, data)| { let state = state.clone(); let claims = claims.clone(); - let session = session.clone(); + let pds_auth = pds_auth.clone(); let collection = collection.clone(); let existing_uri = existing_uri.clone(); let rkey = rkey.clone(); @@ -506,16 +493,12 @@ pub fn register_record_api( "record": data, }); - let resp = repo::pds_post_json_raw( - &state, - &session, - "com.atproto.repo.putRecord", - &pds_body, - ) - .await - .map_err(|e| { - mlua::Error::runtime(format!("PDS putRecord failed: {e}")) - })?; + let resp = pds_auth + .post_json(&state, repo, "com.atproto.repo.putRecord", &pds_body) + .await + .map_err(|e| { + mlua::Error::runtime(format!("PDS putRecord failed: {e}")) + })?; if !resp.status().is_success() { let status = resp.status(); @@ -575,18 +558,14 @@ pub fn register_record_api( pds_body["rkey"] = json!(rkey); } - let resp = repo::pds_post_json_raw( - &state, - &session, - "com.atproto.repo.createRecord", - &pds_body, - ) - .await - .map_err(|e| { - mlua::Error::runtime(format!( - "PDS createRecord failed: {e}" - )) - })?; + let resp = pds_auth + .post_json(&state, repo, "com.atproto.repo.createRecord", &pds_body) + .await + .map_err(|e| { + mlua::Error::runtime(format!( + "PDS createRecord failed: {e}" + )) + })?; if !resp.status().is_success() { let status = resp.status(); diff --git a/src/oauth/client_auth.rs b/src/oauth/client_auth.rs index a4c14b2..9eefb9c 100644 --- a/src/oauth/client_auth.rs +++ b/src/oauth/client_auth.rs @@ -156,9 +156,22 @@ pub async fn resolve_client_by_key( /// Rules: /// - `atproto` must be present in token scopes (always implicitly allowed) /// - Every non-`atproto` scope in the token must appear in the client's registered scopes -pub fn validate_scopes(token_scopes: &str, client_scopes: &str) -> Result<(), AppError> { +/// - `include:X` client scopes are expanded by looking up the permission set +/// lexicon `X` and extracting its `rpc:` and `repo:` permissions +pub async fn validate_scopes( + token_scopes: &str, + client_scopes: &str, + lexicons: &crate::lexicon::LexiconRegistry, +) -> Result<(), AppError> { let token_set: std::collections::HashSet<&str> = token_scopes.split_whitespace().collect(); - let client_set: std::collections::HashSet<&str> = client_scopes.split_whitespace().collect(); + let mut client_set: std::collections::HashSet = std::collections::HashSet::new(); + + for scope in client_scopes.split_whitespace() { + if let Some(perm_set_id) = scope.strip_prefix("include:") { + expand_permission_set(perm_set_id, lexicons, &mut client_set).await; + } + client_set.insert(scope.to_string()); + } if !token_set.contains("atproto") { return Err(AppError::BadRequest( @@ -168,9 +181,9 @@ pub fn validate_scopes(token_scopes: &str, client_scopes: &str) -> Result<(), Ap for scope in &token_set { if *scope == "atproto" { - continue; // always allowed + continue; } - if !client_set.contains(scope) { + if !client_set.contains(*scope) { return Err(AppError::BadRequest(format!( "scope '{}' is not allowed for this client", scope @@ -181,6 +194,59 @@ pub fn validate_scopes(token_scopes: &str, client_scopes: &str) -> Result<(), Ap Ok(()) } +/// Expand a permission set lexicon into individual `rpc:` and `repo:` scopes. +async fn expand_permission_set( + nsid: &str, + lexicons: &crate::lexicon::LexiconRegistry, + out: &mut std::collections::HashSet, +) { + let lexicon = match lexicons.get(nsid).await { + Some(l) => l, + None => { + tracing::warn!(nsid = %nsid, "permission set lexicon not found in registry"); + return; + } + }; + + let permissions = match lexicon + .raw + .get("defs") + .and_then(|d| d.get("main")) + .and_then(|m| m.get("permissions")) + .and_then(|p| p.as_array()) + { + Some(p) => p, + None => return, + }; + + for perm in permissions { + let resource = perm.get("resource").and_then(|r| r.as_str()).unwrap_or(""); + match resource { + "rpc" => { + if let Some(lxms) = perm.get("lxm").and_then(|l| l.as_array()) { + for lxm in lxms { + if let Some(s) = lxm.as_str() { + out.insert(format!("rpc:{s}")); + } + } + } + } + "repo" => { + if let Some(collections) = perm.get("collection").and_then(|c| c.as_array()) { + for col in collections { + if let Some(s) = col.as_str() { + out.insert(format!("repo:{s}?action=create")); + out.insert(format!("repo:{s}?action=update")); + out.insert(format!("repo:{s}?action=delete")); + } + } + } + } + _ => {} + } + } +} + /// Verify a PKCE challenge against a verifier. pub fn verify_pkce(challenge: &str, verifier: &str) -> bool { use base64::Engine; @@ -194,45 +260,117 @@ pub fn verify_pkce(challenge: &str, verifier: &str) -> bool { mod tests { use super::*; - #[test] - fn validate_scopes_requires_atproto() { - let result = validate_scopes("transition:generic", "atproto transition:generic"); + 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()); } - #[test] - fn validate_scopes_atproto_only_always_passes() { - let result = validate_scopes("atproto", "com.example.whatever"); + #[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()); } - #[test] - fn validate_scopes_subset_passes() { + #[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()); } - #[test] - fn validate_scopes_excess_scope_fails() { + #[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()); } - #[test] - fn validate_scopes_transition_generic_requires_registration() { - let result = validate_scopes("atproto transition:generic", "atproto"); + #[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"); + 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", + "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, + None, + None, + ) + .unwrap(); + reg.upsert(parsed).await; + + let result = validate_scopes( + "atproto rpc:com.example.getProfile repo:com.example.profile?action=create", + "atproto include:com.example.authBasic", + ®, + ) + .await; + assert!(result.is_ok()); + + let result = validate_scopes( + "atproto rpc:com.example.notAllowed", + "atproto include:com.example.authBasic", + ®, + ) + .await; + assert!(result.is_err()); + } + #[test] fn verify_pkce_valid() { use base64::Engine; diff --git a/src/oauth/pds_write.rs b/src/oauth/pds_write.rs index 0597f69..85307b0 100644 --- a/src/oauth/pds_write.rs +++ b/src/oauth/pds_write.rs @@ -3,13 +3,24 @@ use base64::engine::general_purpose::URL_SAFE_NO_PAD; use p256::ecdsa::{SigningKey, signature::Signer}; use sha2::{Digest, Sha256}; +use std::sync::Arc; + +use crate::auth::OAuthClientRegistry; use crate::db::DatabaseBackend; use crate::error::AppError; use crate::plugin::encryption::decrypt; -/// Make an authenticated POST to a PDS XRPC endpoint using a DPoP session. -#[allow(clippy::too_many_arguments)] -pub async fn dpop_pds_post( +use super::sessions::DpopSession; + +/// Resolved DPoP credentials needed to make authenticated PDS requests. +struct DpopCredentials { + session: DpopSession, + pds_url: String, + private_jwk: serde_json::Value, +} + +/// Resolve DPoP credentials: session, PDS URL, and decrypted private key. +async fn resolve_credentials( http: &reqwest::Client, pool: &sqlx::AnyPool, backend: DatabaseBackend, @@ -17,9 +28,7 @@ pub async fn dpop_pds_post( plc_url: &str, api_client_id: &str, user_did: &str, - xrpc_method: &str, - body: &serde_json::Value, -) -> Result { +) -> Result { let session = super::sessions::get_dpop_session(pool, backend, encryption_key, api_client_id, user_did) .await?; @@ -29,9 +38,6 @@ pub async fn dpop_pds_post( None => resolve_pds_from_did(http, plc_url, user_did).await?, }; - let target_url = format!("{}/xrpc/{}", pds_url.trim_end_matches('/'), xrpc_method); - - // Decrypt the DPoP private key let key_sql = crate::db::adapt_sql( "SELECT private_key_enc FROM dpop_keys WHERE id = ?", backend, @@ -50,21 +56,192 @@ pub async fn dpop_pds_post( let private_jwk: serde_json::Value = serde_json::from_slice(&key_bytes) .map_err(|e| AppError::Internal(format!("failed to parse DPoP key: {e}")))?; - let proof = generate_dpop_proof(&private_jwk, "POST", &target_url, &session.access_token)?; + Ok(DpopCredentials { + session, + pds_url, + private_jwk, + }) +} - let resp = http - .post(&target_url) - .header("Authorization", format!("DPoP {}", session.access_token)) - .header("DPoP", proof) - .header("Content-Type", "application/json") - .json(body) +/// Make an authenticated POST, handling DPoP nonce negotiation and token refresh. +#[allow(clippy::too_many_arguments)] +async fn dpop_post_with_retry( + http: &reqwest::Client, + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + encryption_key: &[u8; 32], + oauth_registry: &Arc, + creds: &mut DpopCredentials, + target_url: &str, + request_builder: impl Fn(&reqwest::Client, &str, &str) -> reqwest::RequestBuilder, +) -> Result { + let proof = generate_dpop_proof( + &creds.private_jwk, + "POST", + target_url, + &creds.session.access_token, + None, + )?; + + let resp = request_builder(http, &creds.session.access_token, &proof) .send() .await .map_err(|e| AppError::Internal(format!("PDS request failed: {e}")))?; + // Handle DPoP nonce requirement + if let Some(nonce) = extract_dpop_nonce(&resp) { + let proof = generate_dpop_proof( + &creds.private_jwk, + "POST", + target_url, + &creds.session.access_token, + Some(&nonce), + )?; + + let resp = request_builder(http, &creds.session.access_token, &proof) + .send() + .await + .map_err(|e| AppError::Internal(format!("PDS request failed: {e}")))?; + + // If we still get invalid_token after nonce, try refresh + if is_expired_token(&resp) { + return retry_after_refresh( + http, + pool, + backend, + encryption_key, + oauth_registry, + creds, + target_url, + Some(&nonce), + &request_builder, + ) + .await; + } + + return Ok(resp); + } + + // Handle expired token + if is_expired_token(&resp) { + return retry_after_refresh( + http, + pool, + backend, + encryption_key, + oauth_registry, + creds, + target_url, + None, + &request_builder, + ) + .await; + } + + Ok(resp) +} + +/// Refresh the access token and retry the PDS request. +#[allow(clippy::too_many_arguments)] +async fn retry_after_refresh( + http: &reqwest::Client, + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + encryption_key: &[u8; 32], + oauth_registry: &Arc, + creds: &mut DpopCredentials, + target_url: &str, + nonce: Option<&str>, + request_builder: &impl Fn(&reqwest::Client, &str, &str) -> reqwest::RequestBuilder, +) -> Result { + refresh_access_token(http, pool, backend, encryption_key, oauth_registry, creds).await?; + + let proof = generate_dpop_proof( + &creds.private_jwk, + "POST", + target_url, + &creds.session.access_token, + nonce, + )?; + + let resp = request_builder(http, &creds.session.access_token, &proof) + .send() + .await + .map_err(|e| AppError::Internal(format!("PDS request failed after token refresh: {e}")))?; + + // One more nonce negotiation attempt after refresh + if let Some(new_nonce) = extract_dpop_nonce(&resp) { + let proof = generate_dpop_proof( + &creds.private_jwk, + "POST", + target_url, + &creds.session.access_token, + Some(&new_nonce), + )?; + + let resp = request_builder(http, &creds.session.access_token, &proof) + .send() + .await + .map_err(|e| AppError::Internal(format!("PDS request failed: {e}")))?; + + return Ok(resp); + } + Ok(resp) } +/// Make an authenticated POST to a PDS XRPC endpoint using a DPoP session. +#[allow(clippy::too_many_arguments)] +pub async fn dpop_pds_post( + http: &reqwest::Client, + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + encryption_key: &[u8; 32], + oauth_registry: &Arc, + plc_url: &str, + api_client_id: &str, + user_did: &str, + xrpc_method: &str, + body: &serde_json::Value, +) -> Result { + let mut creds = resolve_credentials( + http, + pool, + backend, + encryption_key, + plc_url, + api_client_id, + user_did, + ) + .await?; + + let target_url = format!( + "{}/xrpc/{}", + creds.pds_url.trim_end_matches('/'), + xrpc_method + ); + + let body = body.clone(); + let target = target_url.clone(); + dpop_post_with_retry( + http, + pool, + backend, + encryption_key, + oauth_registry, + &mut creds, + &target_url, + |http, access_token, proof| { + http.post(&target) + .header("Authorization", format!("DPoP {access_token}")) + .header("DPoP", proof) + .header("Content-Type", "application/json") + .json(&body) + }, + ) + .await +} + /// Make an authenticated blob upload to a PDS using a DPoP session. #[allow(clippy::too_many_arguments)] pub async fn dpop_pds_post_blob( @@ -72,58 +249,261 @@ pub async fn dpop_pds_post_blob( pool: &sqlx::AnyPool, backend: DatabaseBackend, encryption_key: &[u8; 32], + oauth_registry: &Arc, plc_url: &str, api_client_id: &str, user_did: &str, content_type: &str, blob: bytes::Bytes, ) -> Result { - let session = - super::sessions::get_dpop_session(pool, backend, encryption_key, api_client_id, user_did) - .await?; - - let pds_url = match session.pds_url { - Some(ref url) => url.clone(), - None => resolve_pds_from_did(http, plc_url, user_did).await?, - }; + let mut creds = resolve_credentials( + http, + pool, + backend, + encryption_key, + plc_url, + api_client_id, + user_did, + ) + .await?; let target_url = format!( "{}/xrpc/com.atproto.repo.uploadBlob", - pds_url.trim_end_matches('/') + creds.pds_url.trim_end_matches('/') ); - // Decrypt the DPoP private key - let key_sql = crate::db::adapt_sql( - "SELECT private_key_enc FROM dpop_keys WHERE id = ?", + let content_type = content_type.to_string(); + let target = target_url.clone(); + dpop_post_with_retry( + http, + pool, backend, - ); - let row: Option<(Vec,)> = sqlx::query_as(&key_sql) - .bind(&session.dpop_key_id) - .fetch_optional(pool) - .await - .map_err(|e| AppError::Internal(format!("failed to look up DPoP key: {e}")))?; - - let (encrypted_key,) = row.ok_or_else(|| AppError::Internal("DPoP key not found".into()))?; + encryption_key, + oauth_registry, + &mut creds, + &target_url, + |http, access_token, proof| { + http.post(&target) + .header("Authorization", format!("DPoP {access_token}")) + .header("DPoP", proof) + .header("Content-Type", &content_type) + .body(blob.clone()) + }, + ) + .await +} - let key_bytes = decrypt(encryption_key, &encrypted_key) - .map_err(|e| AppError::Internal(format!("failed to decrypt DPoP key: {e}")))?; +/// Check if a response is a 401 with an expired/invalid token error. +fn is_expired_token(resp: &reqwest::Response) -> bool { + resp.status() == reqwest::StatusCode::UNAUTHORIZED +} - let private_jwk: serde_json::Value = serde_json::from_slice(&key_bytes) - .map_err(|e| AppError::Internal(format!("failed to parse DPoP key: {e}")))?; +/// Check if a response indicates that a DPoP nonce is required, and extract it. +fn extract_dpop_nonce(resp: &reqwest::Response) -> Option { + if resp.status() == reqwest::StatusCode::UNAUTHORIZED + || resp.status() == reqwest::StatusCode::BAD_REQUEST + { + resp.headers() + .get("dpop-nonce") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()) + } else { + None + } +} - let proof = generate_dpop_proof(&private_jwk, "POST", &target_url, &session.access_token)?; +/// Refresh an expired access token using the session's refresh_token. +/// +/// Discovers the token endpoint from the issuer's OAuth metadata, sends a +/// `grant_type=refresh_token` request with a DPoP proof, and updates the +/// stored session with the new tokens. +async fn refresh_access_token( + http: &reqwest::Client, + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + encryption_key: &[u8; 32], + oauth_registry: &Arc, + creds: &mut DpopCredentials, +) -> Result<(), AppError> { + let refresh_token = creds + .session + .refresh_token + .as_deref() + .ok_or_else(|| AppError::Auth("token expired and no refresh_token available".into()))?; + + let issuer = creds + .session + .issuer + .as_deref() + .ok_or_else(|| AppError::Auth("token expired and no issuer URL stored".into()))?; + + let token_endpoint = discover_token_endpoint(http, issuer).await?; + + // Get the resolved client_id from the OAuth registry. For loopback clients + // this returns `http://localhost?scope=...` which auth servers handle inline, + // rather than the `client_id_url` from the DB which they'd try to fetch. + let client_id_url = lookup_client_id_url(pool, backend, &creds.session.api_client_id).await?; + let client_id = oauth_registry + .get_resolved_client_id(&client_id_url) + .unwrap_or(client_id_url); + + let proof = generate_dpop_proof_no_ath(&creds.private_jwk, "POST", &token_endpoint, None)?; let resp = http - .post(&target_url) - .header("Authorization", format!("DPoP {}", session.access_token)) - .header("DPoP", proof) - .header("Content-Type", content_type) - .body(blob) + .post(&token_endpoint) + .header("DPoP", &proof) + .header("Content-Type", "application/x-www-form-urlencoded") + .form(&[ + ("grant_type", "refresh_token"), + ("refresh_token", refresh_token), + ("client_id", &client_id), + ]) .send() .await - .map_err(|e| AppError::Internal(format!("PDS uploadBlob request failed: {e}")))?; + .map_err(|e| AppError::Internal(format!("token refresh request failed: {e}")))?; + + // Handle nonce requirement on the token endpoint + if let Some(nonce) = extract_dpop_nonce(&resp) { + let proof = + generate_dpop_proof_no_ath(&creds.private_jwk, "POST", &token_endpoint, Some(&nonce))?; + + let resp = http + .post(&token_endpoint) + .header("DPoP", &proof) + .header("Content-Type", "application/x-www-form-urlencoded") + .form(&[ + ("grant_type", "refresh_token"), + ("refresh_token", refresh_token), + ("client_id", &client_id), + ]) + .send() + .await + .map_err(|e| AppError::Internal(format!("token refresh request failed: {e}")))?; + + return handle_refresh_response(http, pool, backend, encryption_key, creds, resp).await; + } - Ok(resp) + handle_refresh_response(http, pool, backend, encryption_key, creds, resp).await +} + +/// Parse the token refresh response and update the stored session. +async fn handle_refresh_response( + _http: &reqwest::Client, + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + encryption_key: &[u8; 32], + creds: &mut DpopCredentials, + resp: reqwest::Response, +) -> Result<(), AppError> { + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(AppError::Auth(format!( + "token refresh failed ({status}): {body}" + ))); + } + + let token_resp: serde_json::Value = resp + .json() + .await + .map_err(|e| AppError::Internal(format!("invalid token refresh response: {e}")))?; + + let new_access_token = token_resp["access_token"] + .as_str() + .ok_or_else(|| AppError::Internal("refresh response missing access_token".into()))?; + + let new_refresh_token = token_resp["refresh_token"].as_str(); + + let expires_in = token_resp["expires_in"].as_u64(); + let new_expires_at = expires_in + .map(|secs| (chrono::Utc::now() + chrono::Duration::seconds(secs as i64)).to_rfc3339()); + + // Update the stored session + super::sessions::store_dpop_session( + pool, + backend, + encryption_key, + &creds.session.id, + &creds.session.api_client_id, + &creds.session.dpop_key_id, + &creds.session.user_did, + new_access_token, + new_refresh_token.or(creds.session.refresh_token.as_deref()), + new_expires_at + .as_deref() + .or(creds.session.token_expires_at.as_deref()), + &creds.session.scopes, + creds.session.pds_url.as_deref(), + creds.session.issuer.as_deref(), + ) + .await?; + + // Update the in-memory credentials + creds.session.access_token = new_access_token.to_string(); + if let Some(rt) = new_refresh_token { + creds.session.refresh_token = Some(rt.to_string()); + } + if let Some(ref exp) = new_expires_at { + creds.session.token_expires_at = Some(exp.clone()); + } + + tracing::info!( + user_did = %creds.session.user_did, + api_client_id = %creds.session.api_client_id, + "refreshed DPoP access token" + ); + + Ok(()) +} + +/// Discover the token endpoint from an OAuth authorization server's metadata. +async fn discover_token_endpoint(http: &reqwest::Client, issuer: &str) -> Result { + let metadata_url = format!( + "{}/.well-known/oauth-authorization-server", + issuer.trim_end_matches('/') + ); + + let resp = + http.get(&metadata_url).send().await.map_err(|e| { + AppError::Internal(format!("failed to fetch auth server metadata: {e}")) + })?; + + if !resp.status().is_success() { + return Err(AppError::Internal(format!( + "auth server metadata returned {}", + resp.status() + ))); + } + + let metadata: serde_json::Value = resp + .json() + .await + .map_err(|e| AppError::Internal(format!("invalid auth server metadata: {e}")))?; + + metadata["token_endpoint"] + .as_str() + .map(|s| s.to_string()) + .ok_or_else(|| AppError::Internal("auth server metadata missing token_endpoint".into())) +} + +/// Look up the client_id_url for an API client by its internal ID. +async fn lookup_client_id_url( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + api_client_id: &str, +) -> Result { + let sql = crate::db::adapt_sql( + "SELECT client_id_url FROM api_clients WHERE id = ?", + backend, + ); + let row: Option<(String,)> = sqlx::query_as(&sql) + .bind(api_client_id) + .fetch_optional(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to look up API client: {e}")))?; + + row.map(|(url,)| url) + .ok_or_else(|| AppError::Internal("API client not found".into())) } /// Generate a DPoP proof JWT for a PDS request. @@ -132,6 +512,28 @@ pub fn generate_dpop_proof( method: &str, url: &str, access_token: &str, + nonce: Option<&str>, +) -> Result { + let ath = URL_SAFE_NO_PAD.encode(Sha256::digest(access_token.as_bytes())); + generate_dpop_proof_inner(private_jwk, method, url, Some(&ath), nonce) +} + +/// Generate a DPoP proof JWT without an `ath` claim (for token endpoint requests). +fn generate_dpop_proof_no_ath( + private_jwk: &serde_json::Value, + method: &str, + url: &str, + nonce: Option<&str>, +) -> Result { + generate_dpop_proof_inner(private_jwk, method, url, None, nonce) +} + +fn generate_dpop_proof_inner( + private_jwk: &serde_json::Value, + method: &str, + url: &str, + ath: Option<&str>, + nonce: Option<&str>, ) -> Result { let d_b64 = private_jwk["d"] .as_str() @@ -162,21 +564,24 @@ pub fn generate_dpop_proof( .unwrap() .as_secs(); - let ath = URL_SAFE_NO_PAD.encode(Sha256::digest(access_token.as_bytes())); - let header = serde_json::json!({ "alg": "ES256", "typ": "dpop+jwt", "jwk": public_jwk, }); - let payload = serde_json::json!({ + let mut payload = serde_json::json!({ "htm": method, "htu": url, "iat": now, - "ath": ath, "jti": format!("{:x}", rand::random::()), }); + if let Some(ath) = ath { + payload["ath"] = serde_json::json!(ath); + } + if let Some(nonce) = nonce { + payload["nonce"] = serde_json::json!(nonce); + } let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap()); let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).unwrap()); @@ -245,6 +650,7 @@ mod tests { "POST", "https://pds.example.com/xrpc/com.atproto.repo.createRecord", "test-access-token", + None, ) .unwrap(); @@ -269,13 +675,52 @@ mod tests { assert!(payload["jti"].is_string()); } + #[test] + fn generate_dpop_proof_includes_nonce() { + let keypair = super::super::keys::generate_dpop_keypair().unwrap(); + + let proof = generate_dpop_proof( + &keypair.private_jwk, + "POST", + "https://pds.example.com/xrpc/test", + "token", + Some("server-nonce-123"), + ) + .unwrap(); + + let parts: Vec<&str> = proof.split('.').collect(); + let payload_bytes = URL_SAFE_NO_PAD.decode(parts[1]).unwrap(); + let payload: serde_json::Value = serde_json::from_slice(&payload_bytes).unwrap(); + assert_eq!(payload["nonce"], "server-nonce-123"); + } + + #[test] + fn generate_dpop_proof_no_ath_omits_ath() { + let keypair = super::super::keys::generate_dpop_keypair().unwrap(); + + let proof = generate_dpop_proof_no_ath( + &keypair.private_jwk, + "POST", + "https://auth.example.com/oauth/token", + None, + ) + .unwrap(); + + let parts: Vec<&str> = proof.split('.').collect(); + let payload_bytes = URL_SAFE_NO_PAD.decode(parts[1]).unwrap(); + let payload: serde_json::Value = serde_json::from_slice(&payload_bytes).unwrap(); + assert!(payload.get("ath").is_none()); + assert!(payload["htm"].is_string()); + assert!(payload["htu"].is_string()); + } + #[test] fn generated_proof_validates_against_own_key() { let keypair = super::super::keys::generate_dpop_keypair().unwrap(); let url = "https://pds.example.com/xrpc/test.method"; let token = "my-access-token"; - let proof = generate_dpop_proof(&keypair.private_jwk, "POST", url, token).unwrap(); + let proof = generate_dpop_proof(&keypair.private_jwk, "POST", url, token, None).unwrap(); let result = super::super::dpop_proof::validate_dpop_proof( &proof, diff --git a/src/oauth/routes.rs b/src/oauth/routes.rs index 46785c0..4836c4b 100644 --- a/src/oauth/routes.rs +++ b/src/oauth/routes.rs @@ -224,7 +224,7 @@ async fn register_session( } // Validate scopes - client_auth::validate_scopes(&body.scopes, &client.scopes)?; + client_auth::validate_scopes(&body.scopes, &client.scopes, &state.lexicons).await?; // Clean up any existing session's DPoP key before upserting // (the ON CONFLICT upsert would orphan the old key otherwise) diff --git a/src/repo/mod.rs b/src/repo/mod.rs index d70cbd9..d802063 100644 --- a/src/repo/mod.rs +++ b/src/repo/mod.rs @@ -2,6 +2,6 @@ mod pds; pub(crate) mod session; mod upload_blob; -pub(crate) use pds::{forward_pds_response, pds_post_json_raw}; +pub(crate) use pds::{PdsAuth, forward_pds_response, pds_post_json_raw}; pub(crate) use session::{get_dpop_client_id, get_oauth_session}; pub use upload_blob::upload_blob; diff --git a/src/repo/pds.rs b/src/repo/pds.rs index e6a7b73..db7c7bd 100644 --- a/src/repo/pds.rs +++ b/src/repo/pds.rs @@ -1,3 +1,5 @@ +use std::sync::Arc; + use atrium_xrpc::{InputDataOrBytes, OutputDataOrBytes, XrpcClient, XrpcRequest, http::Method}; use axum::body::Bytes; use axum::http::StatusCode; @@ -8,6 +10,51 @@ use crate::AppState; use crate::HappyViewOAuthSession; use crate::error::AppError; +/// Abstraction over the two PDS authentication paths. +/// +/// - `OAuth`: uses atrium's OAuthSession (dashboard cookie auth) +/// - `Dpop`: uses the manual DPoP session from `dpop_sessions` table (third-party apps) +#[derive(Clone)] +pub(crate) enum PdsAuth { + OAuth(Arc), + Dpop { + api_client_id: String, + encryption_key: [u8; 32], + }, +} + +impl PdsAuth { + pub async fn post_json( + &self, + state: &AppState, + user_did: &str, + xrpc_method: &str, + body: &Value, + ) -> Result { + match self { + PdsAuth::OAuth(session) => pds_post_json_raw(state, session, xrpc_method, body).await, + PdsAuth::Dpop { + api_client_id, + encryption_key, + } => { + crate::oauth::pds_write::dpop_pds_post( + &state.http, + &state.db, + state.db_backend, + encryption_key, + &state.oauth, + &state.config.plc_url, + api_client_id, + user_did, + xrpc_method, + body, + ) + .await + } + } + } +} + /// Forward a PDS response back to the client, preserving status and body. pub(crate) async fn forward_pds_response(resp: reqwest::Response) -> Result { let status = resp.status(); diff --git a/src/repo/upload_blob.rs b/src/repo/upload_blob.rs index 8bb7c44..f0a23e2 100644 --- a/src/repo/upload_blob.rs +++ b/src/repo/upload_blob.rs @@ -61,6 +61,7 @@ pub async fn upload_blob( &state.db, state.db_backend, encryption_key, + &state.oauth, &state.config.plc_url, &api_client_id, claims.did(), diff --git a/src/xrpc/procedure.rs b/src/xrpc/procedure.rs index 4dbec53..c24dc4b 100644 --- a/src/xrpc/procedure.rs +++ b/src/xrpc/procedure.rs @@ -401,6 +401,7 @@ async fn handle_dpop_procedure( &state.db, state.db_backend, encryption_key, + &state.oauth, &state.config.plc_url, api_client_id, claims.did(), diff --git a/tests/dpop_auth.rs b/tests/dpop_auth.rs index 0533960..a9d0615 100644 --- a/tests/dpop_auth.rs +++ b/tests/dpop_auth.rs @@ -378,7 +378,7 @@ async fn test_xrpc_dpop_auth_accepted() { // 3. Generate a DPoP proof for an XRPC GET request let request_url = "http://127.0.0.1:0/xrpc/com.example.test.getStuff"; - let proof = generate_dpop_proof(dpop_key, "GET", request_url, access_token) + let proof = generate_dpop_proof(dpop_key, "GET", request_url, access_token, None) .expect("failed to generate DPoP proof"); // 4. Make an XRPC request with DPoP auth diff --git a/web/src/app/dashboard/settings/api-clients/page.tsx b/web/src/app/dashboard/settings/api-clients/page.tsx index 78062a0..cf32b4c 100644 --- a/web/src/app/dashboard/settings/api-clients/page.tsx +++ b/web/src/app/dashboard/settings/api-clients/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useCallback, useEffect, useState } from "react"; -import { Copy, Check, Trash2, X } from "lucide-react"; +import { Copy, Check, Trash2, X, ExternalLink } from "lucide-react"; import { useConfig } from "@/lib/config-context"; import { useCurrentUser } from "@/hooks/use-current-user"; @@ -17,6 +17,7 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Switch } from "@/components/ui/switch"; import { ResponsiveDialog, @@ -151,6 +152,7 @@ export default function ApiClientsPage() { Name + Type Client Key Client ID URL Scopes @@ -163,7 +165,7 @@ export default function ApiClientsPage() { {clients.length === 0 && ( No API clients yet. @@ -176,8 +178,13 @@ export default function ApiClientsPage() { className={!client.is_active ? "opacity-50" : undefined} > {client.name} + + + {client.client_type === "public" ? "Public" : "Confidential"} + + - {client.client_key.slice(0, 12)}... + {client.client_key} {client.client_id_url} @@ -221,10 +228,12 @@ function CreateApiClientDialog({ onSuccess }: { onSuccess: () => void }) { const config = useConfig(); const happyviewCallbackUri = `${config.public_url.replace(/\/$/, "")}/auth/callback`; + const [clientType, setClientType] = useState<"confidential" | "public">("confidential"); const [name, setName] = useState(""); const [clientIdUrl, setClientIdUrl] = useState(""); const [clientUri, setClientUri] = useState(""); const [redirectUris, setRedirectUris] = useState([""]); + const [allowedOrigins, setAllowedOrigins] = useState([""]); const [scopes, setScopes] = useState([""]); const [rateLimitEnabled, setRateLimitEnabled] = useState(true); const [rateLimitCapacity, setRateLimitCapacity] = useState( @@ -241,10 +250,12 @@ function CreateApiClientDialog({ onSuccess }: { onSuccess: () => void }) { function handleOpenChange(nextOpen: boolean) { setOpen(nextOpen); if (!nextOpen) { + setClientType("confidential"); setName(""); setClientIdUrl(""); setClientUri(""); setRedirectUris([""]); + setAllowedOrigins([""]); setScopes([""]); setRateLimitEnabled(true); setRateLimitCapacity(String(config.default_rate_limit_capacity)); @@ -279,12 +290,15 @@ function CreateApiClientDialog({ onSuccess }: { onSuccess: () => void }) { return; } try { + const filteredOrigins = allowedOrigins.map((o) => o.trim()).filter(Boolean); const result = await createApiClient({ name: name.trim(), client_id_url: clientIdUrl.trim(), client_uri: clientUri.trim(), redirect_uris: allUris, scopes: allScopes, + client_type: clientType, + allowed_origins: clientType === "public" && filteredOrigins.length > 0 ? filteredOrigins : undefined, rate_limit_capacity: rateLimitEnabled ? Number(rateLimitCapacity) : null, rate_limit_refill_rate: rateLimitEnabled ? Number(rateLimitRefillRate) : null, }); @@ -306,7 +320,9 @@ function CreateApiClientDialog({ onSuccess }: { onSuccess: () => void }) { {created - ? "Save the credentials below. The secret will not be shown again." + ? created.client_type === "public" + ? "Your public client has been created. Use PKCE for authentication." + : "Save the credentials below. The secret will not be shown again." : "Register a new application that authenticates through this AppView."} @@ -339,36 +355,80 @@ function CreateApiClientDialog({ onSuccess }: { onSuccess: () => void }) { or client_key query parameter.

-
- -
- - +
+

+ Keep this secret. Send as the X-Client-Secret header + for server-to-server requests. Browser requests are validated by Origin instead. +

+
+ ) : ( +
+

+ This is a public client. Authenticate using PKCE instead of a client secret. +

+ - {copiedField === "secret" ? ( - - ) : ( - - )} - + PKCE authentication docs + +
-

- Keep this secret. Send as the X-Client-Secret header - for server-to-server requests. Browser requests are validated by Origin instead. -

- + )} ) : (
{error &&

{error}

} +
+ Client Type + setClientType(v as "confidential" | "public")} + className="flex flex-col gap-3" + > +
+ +
+ +

+ Server-side applications that can securely store a client secret. +

+
+
+
+ +
+ +

+ Browser or native apps that authenticate using PKCE (no secret). +

+
+
+
+
Application
@@ -418,6 +478,21 @@ function CreateApiClientDialog({ onSuccess }: { onSuccess: () => void }) { readonlyValues={[happyviewCallbackUri]} />
+ {clientType === "public" && ( +
+ Allowed Origins +

+ Origins permitted to use this client. Requests from unlisted origins will be + rejected. Leave empty to allow any origin. +

+ +
+ )}
Scopes

@@ -521,10 +596,18 @@ function EditApiClientDialog({ return parts.length > 0 ? [...parts, ""] : [""]; } + function parseAllowedOrigins(origins: string[] | null): string[] { + if (!origins || origins.length === 0) return [""]; + return [...origins, ""]; + } + const [name, setName] = useState(client.name); const [redirectUris, setRedirectUris] = useState( parseRedirectUris(client.redirect_uris) ); + const [allowedOrigins, setAllowedOrigins] = useState( + parseAllowedOrigins(client.allowed_origins) + ); const [scopes, setScopes] = useState(parseScopes(client.scopes)); const [isActive, setIsActive] = useState(client.is_active); const [rateLimitEnabled, setRateLimitEnabled] = useState( @@ -545,6 +628,7 @@ function EditApiClientDialog({ if (nextOpen) { setName(client.name); setRedirectUris(parseRedirectUris(client.redirect_uris)); + setAllowedOrigins(parseAllowedOrigins(client.allowed_origins)); setScopes(parseScopes(client.scopes)); setIsActive(client.is_active); setRateLimitEnabled( @@ -573,10 +657,14 @@ function EditApiClientDialog({ const extraScopes = scopes.map((s) => s.trim()).filter(Boolean); const allScopes = ["atproto", ...extraScopes].join(" "); + const filteredOrigins = allowedOrigins.map((o) => o.trim()).filter(Boolean); await updateApiClient(client.id, { name: name.trim() || undefined, redirect_uris: allUris, scopes: allScopes, + allowed_origins: client.client_type === "public" + ? (filteredOrigins.length > 0 ? filteredOrigins : null) + : undefined, is_active: isActive, rate_limit_capacity: rateLimitEnabled ? Number(rateLimitCapacity) : null, rate_limit_refill_rate: rateLimitEnabled ? Number(rateLimitRefillRate) : null, @@ -639,6 +727,21 @@ function EditApiClientDialog({ readonlyValues={[happyviewCallbackUri]} />

+ {client.client_type === "public" && ( +
+ Allowed Origins +

+ Origins permitted to use this client. Requests from unlisted origins will be + rejected. Leave empty to allow any origin. +

+ +
+ )}
Scopes

diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index dfef8a5..57a052a 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -373,6 +373,8 @@ export function createApiClient( client_uri: string redirect_uris: string[] scopes?: string + client_type?: string + allowed_origins?: string[] rate_limit_capacity: number | null rate_limit_refill_rate: number | null } @@ -390,6 +392,7 @@ export function updateApiClient( client_uri?: string redirect_uris?: string[] scopes?: string + allowed_origins?: string[] | null rate_limit_capacity?: number | null rate_limit_refill_rate?: number | null is_active?: boolean diff --git a/web/src/types/api-clients.ts b/web/src/types/api-clients.ts index 7057c4b..4feaae7 100644 --- a/web/src/types/api-clients.ts +++ b/web/src/types/api-clients.ts @@ -6,6 +6,8 @@ export interface ApiClientSummary { client_uri: string redirect_uris: string[] scopes: string + client_type: string + allowed_origins: string[] | null rate_limit_capacity: number | null rate_limit_refill_rate: number | null is_active: boolean @@ -17,7 +19,8 @@ export interface ApiClientSummary { export interface CreateApiClientResponse { id: string client_key: string - client_secret: string + client_secret?: string name: string client_id_url: string + client_type: string } \ No newline at end of file -- 2.51.2