diff --git a/.env.example b/.env.example index 80490c9..82bb8d5 100644 --- a/.env.example +++ b/.env.example @@ -7,7 +7,7 @@ DATABASE_URL=sqlite://data/happyview.db?mode=rwc # POSTGRES_DB=happyview # HappyView -PUBLIC_URL=http://localhost:3000 +PUBLIC_URL=http://127.0.0.1:3000 SESSION_SECRET=change-me-in-production RELAY_URL=https://relay1.us-east.bsky.network PORT=3000 diff --git a/src/dev_happyview/create_client.rs b/src/dev_happyview/create_client.rs new file mode 100644 index 0000000..0863190 --- /dev/null +++ b/src/dev_happyview/create_client.rs @@ -0,0 +1,254 @@ +use axum::Json; +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use rand::Rng; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +use super::CreateApiClientInput; +use crate::AppState; +use crate::auth::XrpcClaims; +use crate::db::{adapt_sql, now_rfc3339}; +use crate::error::AppError; +use crate::event_log::{EventLog, Severity, log_event}; +use crate::rate_limit::CheckResult; + +pub async fn create_api_client( + State(state): State, + xrpc_claims: XrpcClaims, + Json(input): Json, +) -> Result { + // 1. Require DPoP auth + let claims = xrpc_claims + .0 + .ok_or_else(|| AppError::Auth("createApiClient requires DPoP authentication".into()))?; + + // 2. Rate-limit the request (procedure type) + let check = if let Some(client_key) = claims.client_key() { + let cost = state + .rate_limiter + .default_cost_for_type(client_key, "procedure"); + Some(state.rate_limiter.check(client_key, cost)) + } else { + None + }; + + if let Some(CheckResult::Limited { + retry_after, + limit, + reset, + }) = check + { + return Err(AppError::RateLimited { + retry_after, + limit, + reset, + }); + } + + // 3. Get client_key from claims, resolve parent client, verify top-level + let client_key_str = claims + .client_key() + .ok_or_else(|| AppError::Auth("createApiClient requires an API client key".into()))?; + + let parent_client = crate::oauth::client_auth::resolve_client_by_key( + &state.db, + state.db_backend, + client_key_str, + ) + .await + .map_err(|_| AppError::Auth("Invalid client".into()))?; + + let parent_check_sql = adapt_sql( + "SELECT parent_client_id, created_by FROM api_clients WHERE id = ?", + state.db_backend, + ); + let parent_row: Option<(Option, String)> = sqlx::query_as(&parent_check_sql) + .bind(&parent_client.id) + .fetch_optional(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to check parent status: {e}")))?; + + match parent_row { + Some((Some(_), _)) => { + return Err(AppError::Forbidden( + "Child clients cannot create API clients".into(), + )); + } + Some((None, _)) => { /* ok, top-level */ } + None => return Err(AppError::Auth("Invalid client".into())), + }; + + let user_did = claims.did().to_string(); + + // 4. Validate client_type + if input.client_type != "confidential" && input.client_type != "public" { + return Err(AppError::BadRequest( + "client_type must be 'confidential' or 'public'".into(), + )); + } + + // 5. Check for duplicate client_id_url + let dup_check_sql = adapt_sql( + "SELECT id FROM api_clients WHERE client_id_url = ?", + state.db_backend, + ); + let dup: Option<(String,)> = sqlx::query_as(&dup_check_sql) + .bind(&input.client_id_url) + .fetch_optional(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to check client_id_url: {e}")))?; + + if dup.is_some() { + return Err(AppError::Conflict( + "client_id_url already registered".into(), + )); + } + + // 6. Generate `hvc_` client key and optional `hvs_` client secret + let mut random_bytes = [0u8; 16]; + rand::rng().fill(&mut random_bytes); + let child_client_key = format!("hvc_{}", hex::encode(random_bytes)); + + let (client_secret, client_secret_hash) = if input.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()) + }; + + // 7. Insert into `api_clients` table + let id = Uuid::new_v4().to_string(); + let now = now_rfc3339(); + let redirect_uris_json = + serde_json::to_string(&input.redirect_uris).unwrap_or_else(|_| "[]".to_string()); + let allowed_origins_json = input + .allowed_origins + .as_ref() + .map(|origins| serde_json::to_string(origins).unwrap_or_else(|_| "[]".to_string())); + + let insert_sql = adapt_sql( + "INSERT INTO api_clients (id, client_key, client_secret_hash, name, client_id_url, client_uri, redirect_uris, scopes, rate_limit_capacity, rate_limit_refill_rate, client_type, allowed_origins, is_active, created_by, created_at, updated_at, parent_client_id, owner_did) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, 1, ?, ?, ?, ?, ?)", + state.db_backend, + ); + sqlx::query(&insert_sql) + .bind(&id) + .bind(&child_client_key) + .bind(&client_secret_hash) + .bind(&input.name) + .bind(&input.client_id_url) + .bind(&input.client_uri) + .bind(&redirect_uris_json) + .bind(&input.scopes) + .bind(&input.client_type) + .bind(&allowed_origins_json) + .bind(&user_did) // created_by + .bind(&now) // created_at + .bind(&now) // updated_at + .bind(&parent_client.id) // parent_client_id + .bind(&user_did) // owner_did + .execute(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to create api client: {e}")))?; + + // 8. Register with OAuth registry and rate limiter + let oauth_params = crate::auth::client_registry::ApiClientOAuthParams { + plc_url: state.config.plc_url.clone(), + state_store: state.oauth_state_store.clone(), + session_store_pool: state.db.clone(), + db_backend: state.db_backend, + }; + if let Err(e) = state.oauth.register_api_client( + &input.client_id_url, + &input.client_uri, + input.redirect_uris.clone(), + &input.scopes, + &oauth_params, + ) { + tracing::warn!( + client_id = %input.client_id_url, + error = %e, + "OAuth client registration failed (DB row created)" + ); + } + + state.rate_limiter.register_client_identity( + child_client_key.clone(), + crate::rate_limit::ClientIdentity { + secret_hash: client_secret_hash.clone(), + client_uri: input.client_uri.clone(), + }, + ); + + let defaults = state.rate_limiter.defaults(); + state.rate_limiter.register_client_config( + child_client_key.clone(), + crate::rate_limit::RateLimitConfig { + capacity: state.config.default_rate_limit_capacity, + refill_rate: state.config.default_rate_limit_refill_rate, + default_query_cost: defaults.query_cost, + default_procedure_cost: defaults.procedure_cost, + default_proxy_cost: defaults.proxy_cost, + }, + ); + + // 9. Log event + log_event( + &state.db, + EventLog { + event_type: "api_client.created".to_string(), + severity: Severity::Info, + actor_did: Some(user_did.clone()), + subject: Some(input.name.clone()), + detail: serde_json::json!({ + "client_key": child_client_key, + "client_id_url": input.client_id_url, + "parent_client_id": parent_client.id, + "self_service": true, + }), + }, + state.db_backend, + ) + .await; + + // 10. Return { client: ApiClientView, clientSecret?: string } + let view = super::ApiClientView { + id, + name: input.name, + client_key: child_client_key, + client_id_url: input.client_id_url, + client_uri: input.client_uri, + redirect_uris: input.redirect_uris, + client_type: input.client_type, + scopes: input.scopes, + allowed_origins: input.allowed_origins.unwrap_or_default(), + is_active: true, + created_at: now, + }; + + let mut body = serde_json::json!({ "client": view }); + if let Some(ref secret) = client_secret { + body["clientSecret"] = serde_json::json!(secret); + } + + let mut response = Json(body).into_response(); + *response.status_mut() = StatusCode::CREATED; + + if let Some(CheckResult::Allowed { + remaining, + limit, + reset, + }) = check + { + let h = response.headers_mut(); + h.insert("RateLimit-Limit", limit.into()); + h.insert("RateLimit-Remaining", remaining.into()); + h.insert("RateLimit-Reset", reset.into()); + } + + Ok(response) +} diff --git a/src/dev_happyview/delete_client.rs b/src/dev_happyview/delete_client.rs new file mode 100644 index 0000000..26fbce1 --- /dev/null +++ b/src/dev_happyview/delete_client.rs @@ -0,0 +1,132 @@ +use axum::Json; +use axum::extract::State; +use axum::response::{IntoResponse, Response}; + +use super::DeleteApiClientInput; +use crate::AppState; +use crate::auth::XrpcClaims; +use crate::db::adapt_sql; +use crate::error::AppError; +use crate::event_log::{EventLog, Severity, log_event}; +use crate::rate_limit::CheckResult; + +pub async fn delete_api_client( + State(state): State, + xrpc_claims: XrpcClaims, + Json(input): Json, +) -> Result { + // 1. Require DPoP auth + let claims = xrpc_claims + .0 + .ok_or_else(|| AppError::Auth("deleteApiClient requires DPoP authentication".into()))?; + + // 2. Rate-limit the request (procedure type) + let check = if let Some(client_key) = claims.client_key() { + let cost = state + .rate_limiter + .default_cost_for_type(client_key, "procedure"); + Some(state.rate_limiter.check(client_key, cost)) + } else { + None + }; + + if let Some(CheckResult::Limited { + retry_after, + limit, + reset, + }) = check + { + return Err(AppError::RateLimited { + retry_after, + limit, + reset, + }); + } + + let user_did = claims.did().to_string(); + let id = input.id; + + // 3. Look up client_id_url and client_key before deleting (scoped to owner) + let lookup_sql = adapt_sql( + "SELECT client_id_url, client_key FROM api_clients WHERE id = ? AND owner_did = ?", + state.db_backend, + ); + let client_info: Option<(String, String)> = sqlx::query_as(&lookup_sql) + .bind(&id) + .bind(&user_did) + .fetch_optional(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to look up api client: {e}")))?; + + // 4. Look up child clients before deleting (ON DELETE CASCADE will remove DB rows) + let children_sql = adapt_sql( + "SELECT client_id_url, client_key FROM api_clients WHERE parent_client_id = ?", + state.db_backend, + ); + let children: Vec<(String, String)> = sqlx::query_as(&children_sql) + .bind(&id) + .fetch_all(&state.db) + .await + .unwrap_or_default(); + + // 5. Delete from DB — scoped to owner_did so users cannot delete others' clients + let delete_sql = adapt_sql( + "DELETE FROM api_clients WHERE id = ? AND owner_did = ?", + state.db_backend, + ); + let result = sqlx::query(&delete_sql) + .bind(&id) + .bind(&user_did) + .execute(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to delete api client: {e}")))?; + + if result.rows_affected() == 0 { + return Err(AppError::NotFound(format!("api client '{id}' not found"))); + } + + // 6. Remove parent from OAuth registry, rate limiter, and client identities + if let Some((url, key)) = client_info { + state.oauth.remove(&url); + state.rate_limiter.remove_client_config(&key); + state.rate_limiter.remove_client_identity(&key); + } + + // 7. Remove child clients from in-memory registries (DB rows already cascaded) + for (child_url, child_key) in &children { + state.oauth.remove(child_url); + state.rate_limiter.remove_client_config(child_key); + state.rate_limiter.remove_client_identity(child_key); + } + + // 8. Log event + log_event( + &state.db, + EventLog { + event_type: "api_client.deleted".to_string(), + severity: Severity::Info, + actor_did: Some(user_did.clone()), + subject: Some(id), + detail: serde_json::json!({}), + }, + state.db_backend, + ) + .await; + + // 9. Return `{}` + let mut response = Json(serde_json::json!({})).into_response(); + + if let Some(CheckResult::Allowed { + remaining, + limit, + reset, + }) = check + { + let h = response.headers_mut(); + h.insert("RateLimit-Limit", limit.into()); + h.insert("RateLimit-Remaining", remaining.into()); + h.insert("RateLimit-Reset", reset.into()); + } + + Ok(response) +} diff --git a/src/dev_happyview/get_client.rs b/src/dev_happyview/get_client.rs new file mode 100644 index 0000000..11d7e66 --- /dev/null +++ b/src/dev_happyview/get_client.rs @@ -0,0 +1,84 @@ +use axum::Json; +use axum::extract::{Query, State}; +use axum::response::{IntoResponse, Response}; +use serde::Deserialize; + +use super::row_to_view; +use crate::AppState; +use crate::auth::XrpcClaims; +use crate::db::adapt_sql; +use crate::error::AppError; +use crate::rate_limit::CheckResult; + +#[derive(Debug, Deserialize)] +pub struct GetApiClientParams { + pub id: String, +} + +pub async fn get_api_client( + State(state): State, + xrpc_claims: XrpcClaims, + Query(params): Query, +) -> Result { + let claims = xrpc_claims + .0 + .ok_or_else(|| AppError::Auth("getApiClient requires DPoP authentication".into()))?; + + let check = if let Some(client_key) = claims.client_key() { + let cost = state + .rate_limiter + .default_cost_for_type(client_key, "query"); + Some(state.rate_limiter.check(client_key, cost)) + } else { + None + }; + + if let Some(CheckResult::Limited { + retry_after, + limit, + reset, + }) = check + { + return Err(AppError::RateLimited { + retry_after, + limit, + reset, + }); + } + + let did = claims.did(); + + let sql = adapt_sql( + "SELECT id, client_key, name, client_id_url, client_uri, redirect_uris, scopes, client_type, allowed_origins, is_active, created_at \ + FROM api_clients \ + WHERE id = $1 AND owner_did = $2", + state.db_backend, + ); + + let row = sqlx::query(&sql) + .bind(¶ms.id) + .bind(did) + .fetch_optional(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to get client: {e}")))? + .ok_or_else(|| AppError::NotFound("API client not found".into()))?; + + let client = + row_to_view(&row).map_err(|e| AppError::Internal(format!("failed to read client: {e}")))?; + + let mut response = Json(serde_json::json!({ "client": client })).into_response(); + + if let Some(CheckResult::Allowed { + remaining, + limit, + reset, + }) = check + { + let h = response.headers_mut(); + h.insert("RateLimit-Limit", limit.into()); + h.insert("RateLimit-Remaining", remaining.into()); + h.insert("RateLimit-Reset", reset.into()); + } + + Ok(response) +} diff --git a/src/dev_happyview/list_clients.rs b/src/dev_happyview/list_clients.rs new file mode 100644 index 0000000..3e29874 --- /dev/null +++ b/src/dev_happyview/list_clients.rs @@ -0,0 +1,98 @@ +use axum::Json; +use axum::extract::State; +use axum::response::{IntoResponse, Response}; + +use super::row_to_view; +use crate::AppState; +use crate::auth::XrpcClaims; +use crate::db::adapt_sql; +use crate::error::AppError; +use crate::rate_limit::CheckResult; + +pub async fn list_api_clients( + State(state): State, + xrpc_claims: XrpcClaims, +) -> Result { + let claims = xrpc_claims + .0 + .ok_or_else(|| AppError::Auth("listApiClients requires DPoP authentication".into()))?; + + let check = if let Some(client_key) = claims.client_key() { + let cost = state + .rate_limiter + .default_cost_for_type(client_key, "query"); + Some(state.rate_limiter.check(client_key, cost)) + } else { + None + }; + + if let Some(CheckResult::Limited { + retry_after, + limit, + reset, + }) = check + { + return Err(AppError::RateLimited { + retry_after, + limit, + reset, + }); + } + + let did = claims.did(); + + // Reject requests from child clients + if let Some(client_key) = claims.client_key() { + let sql = adapt_sql( + "SELECT parent_client_id FROM api_clients WHERE client_key = $1", + state.db_backend, + ); + let parent_check: Option> = sqlx::query_scalar(&sql) + .bind(client_key) + .fetch_optional(&state.db) + .await + .map_err(|e| AppError::Internal(format!("client lookup failed: {e}")))?; + + if let Some(Some(_)) = parent_check { + return Err(AppError::Auth( + "child clients cannot manage API clients".into(), + )); + } + } + + // Fetch all clients owned by the authenticated user + let sql = adapt_sql( + "SELECT id, client_key, name, client_id_url, client_uri, redirect_uris, scopes, client_type, allowed_origins, is_active, created_at \ + FROM api_clients \ + WHERE owner_did = $1 \ + ORDER BY created_at DESC", + state.db_backend, + ); + + let rows = sqlx::query(&sql) + .bind(did) + .fetch_all(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to list clients: {e}")))?; + + let clients: Vec<_> = rows + .iter() + .filter_map(|row| row_to_view(row).ok()) + .collect(); + + let mut response = Json(serde_json::json!({ "clients": clients })).into_response(); + + if let Some(CheckResult::Allowed { + remaining, + limit, + reset, + }) = check + { + let h = response.headers_mut(); + h.insert("RateLimit-Limit", limit.into()); + h.insert("RateLimit-Remaining", remaining.into()); + h.insert("RateLimit-Reset", reset.into()); + } + + Ok(response) +} diff --git a/src/dev_happyview/mod.rs b/src/dev_happyview/mod.rs new file mode 100644 index 0000000..c8dc47b --- /dev/null +++ b/src/dev_happyview/mod.rs @@ -0,0 +1,99 @@ +pub mod create_client; +pub mod delete_client; +pub mod get_client; +pub mod list_clients; + +pub use create_client::create_api_client; +pub use delete_client::delete_api_client; +pub use get_client::get_api_client; +pub use list_clients::list_api_clients; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ApiClientView { + pub id: String, + pub name: String, + pub client_key: String, + pub client_id_url: String, + pub client_uri: String, + pub redirect_uris: Vec, + pub client_type: String, + pub scopes: String, + pub allowed_origins: Vec, + pub is_active: bool, + pub created_at: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ApiClientViewWithSecret { + #[serde(flatten)] + pub client: ApiClientView, + #[serde(skip_serializing_if = "Option::is_none")] + pub client_secret: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateApiClientInput { + pub name: String, + pub client_id_url: String, + pub client_uri: String, + pub redirect_uris: Vec, + #[serde(default = "default_client_type")] + pub client_type: String, + #[serde(default = "default_scopes")] + pub scopes: String, + pub allowed_origins: Option>, +} + +fn default_client_type() -> String { + "confidential".to_string() +} + +fn default_scopes() -> String { + "atproto".to_string() +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteApiClientInput { + pub id: String, +} + +/// Build an ApiClientView from a database row. +/// +/// Note: `is_active` is stored as i32 in the database (SQLite/Postgres compat via AnyPool). +/// The existing codebase uses `row.get()` with the `sqlx::Row` trait. We follow the same +/// pattern here but use `try_get` to return a Result rather than panic on missing columns. +pub fn row_to_view(row: &sqlx::any::AnyRow) -> Result { + use sqlx::Row; + + let redirect_uris_raw: String = row.try_get("redirect_uris")?; + let redirect_uris: Vec = serde_json::from_str(&redirect_uris_raw).unwrap_or_default(); + + let allowed_origins_raw: Option = row.try_get("allowed_origins")?; + let allowed_origins: Vec = allowed_origins_raw + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(); + + // is_active is stored as i32 (0/1) for SQLite/Postgres compatibility, + // matching the pattern used throughout admin/api_clients.rs. + let is_active_raw: i32 = row.try_get("is_active")?; + + Ok(ApiClientView { + id: row.try_get("id")?, + name: row.try_get("name")?, + client_key: row.try_get("client_key")?, + client_id_url: row.try_get("client_id_url")?, + client_uri: row.try_get("client_uri")?, + redirect_uris, + client_type: row.try_get("client_type")?, + scopes: row.try_get("scopes")?, + allowed_origins, + is_active: is_active_raw != 0, + created_at: row.try_get("created_at")?, + }) +} diff --git a/src/lib.rs b/src/lib.rs index f34fada..e846b50 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,7 @@ pub mod admin; pub mod auth; pub mod config; pub mod db; +pub mod dev_happyview; pub mod dns; pub mod domain; pub mod domain_middleware; diff --git a/src/oauth/mod.rs b/src/oauth/mod.rs index 49c2209..9063f32 100644 --- a/src/oauth/mod.rs +++ b/src/oauth/mod.rs @@ -3,5 +3,4 @@ pub mod dpop_proof; pub mod keys; pub mod pds_write; pub mod routes; -pub(crate) mod self_service; pub mod sessions; diff --git a/src/oauth/routes.rs b/src/oauth/routes.rs index eb67e48..4836c4b 100644 --- a/src/oauth/routes.rs +++ b/src/oauth/routes.rs @@ -18,10 +18,6 @@ pub fn routes() -> Router { .route("/dpop-keys", post(provision_dpop_key)) .route("/sessions", post(register_session)) .route("/sessions/{did}", delete(delete_session)) - .route( - "/api-clients", - post(super::self_service::create_child_api_client), - ) } // --- Request / response types --- diff --git a/src/oauth/self_service.rs b/src/oauth/self_service.rs deleted file mode 100644 index d0d9d40..0000000 --- a/src/oauth/self_service.rs +++ /dev/null @@ -1,313 +0,0 @@ -use axum::Json; -use axum::extract::State; -use axum::http::StatusCode; -use hex; -use rand::Rng; -use serde::Deserialize; -use sha2::{Digest, Sha256}; -use uuid::Uuid; - -use crate::AppState; -use crate::admin::types::CreateApiClientResponse; -use crate::db::{adapt_sql, now_rfc3339}; -use crate::error::AppError; -use crate::event_log::{EventLog, Severity, log_event}; - -use super::client_auth; -use super::sessions; - -#[derive(Deserialize)] -struct CreateChildApiClientBody { - name: String, - client_id_url: String, - client_uri: String, - redirect_uris: Vec, - #[serde(default = "default_scopes")] - scopes: String, - #[serde(default = "default_client_type")] - client_type: String, - allowed_origins: Option>, -} - -fn default_scopes() -> String { - "atproto".to_string() -} - -fn default_client_type() -> String { - "confidential".to_string() -} - -/// POST /oauth/api-clients — create a child API client (self-service). -/// -/// Authenticated via DPoP (`Authorization: DPoP ` + `DPoP` proof + `X-Client-Key`). -/// Only top-level (admin-created) API clients can create children. -pub(super) async fn create_child_api_client( - State(state): State, - req: axum::extract::Request, -) -> Result<(StatusCode, Json), AppError> { - use axum::extract::FromRequest; - - let client_key_header = req - .headers() - .get("x-client-key") - .and_then(|v| v.to_str().ok()) - .ok_or_else(|| AppError::Auth("Missing client identification".into()))? - .to_string(); - - let auth_header = req - .headers() - .get("authorization") - .and_then(|v| v.to_str().ok()) - .ok_or_else(|| AppError::Auth("Authorization header required".into()))? - .to_string(); - - let access_token = auth_header - .strip_prefix("DPoP ") - .ok_or_else(|| AppError::Auth("DPoP authorization scheme required".into()))?; - - let dpop_proof = req - .headers() - .get("dpop") - .and_then(|v| v.to_str().ok()) - .ok_or_else(|| AppError::Auth("DPoP proof header required".into()))? - .to_string(); - - let scheme = if state.config.public_url.starts_with("https") { - "https" - } else { - "http" - }; - let host = req - .headers() - .get("host") - .and_then(|v| v.to_str().ok()) - .unwrap_or("localhost") - .to_string(); - let request_path = req - .extensions() - .get::() - .map(|u| u.0.path().to_string()) - .unwrap_or_else(|| req.uri().path().to_string()); - - let body: CreateChildApiClientBody = - Json::::from_request(req, &state) - .await - .map_err(|e| AppError::BadRequest(format!("invalid request body: {e}")))? - .0; - - if body.client_type != "confidential" && body.client_type != "public" { - return Err(AppError::BadRequest("Invalid client_type".into())); - } - - let encryption_key = state - .config - .token_encryption_key - .as_ref() - .ok_or_else(|| AppError::Internal("TOKEN_ENCRYPTION_KEY not configured".into()))?; - - // Resolve the parent API client. - let parent_client = - client_auth::resolve_client_by_key(&state.db, state.db_backend, &client_key_header) - .await - .map_err(|_| AppError::Auth("Invalid client".into()))?; - - // Verify the client is a top-level client (no parent) and fetch its creator. - let parent_check_sql = adapt_sql( - "SELECT parent_client_id, created_by FROM api_clients WHERE id = ?", - state.db_backend, - ); - let parent_row: Option<(Option, String)> = sqlx::query_as(&parent_check_sql) - .bind(&parent_client.id) - .fetch_optional(&state.db) - .await - .map_err(|e| AppError::Internal(format!("failed to check parent status: {e}")))?; - - let parent_created_by = match parent_row { - Some((Some(_), _)) => { - return Err(AppError::Forbidden( - "Child clients cannot create API clients".into(), - )); - } - Some((None, created_by)) => created_by, - None => return Err(AppError::Auth("Invalid client".into())), - }; - - // Validate the DPoP proof and resolve the authenticated user. - let session = sessions::get_dpop_session_by_token_hash( - &state.db, - state.db_backend, - encryption_key, - &parent_client.id, - access_token, - ) - .await?; - - if let Some(ref expires_at) = session.token_expires_at - && let Ok(exp) = chrono::DateTime::parse_from_rfc3339(expires_at) - && exp < chrono::Utc::now() - { - return Err(AppError::Auth("token_expired".into())); - } - - let thumbprint = - super::keys::get_dpop_key_thumbprint(&state.db, state.db_backend, &session.dpop_key_id) - .await?; - - let request_url = format!("{}://{}{}", scheme, host, request_path); - super::dpop_proof::validate_dpop_proof( - &dpop_proof, - "POST", - &request_url, - access_token, - &thumbprint, - )?; - - let user_did = &session.user_did; - - // Verify the parent client's owner exists in the users table. - let user_check_sql = adapt_sql("SELECT id FROM users WHERE did = ?", state.db_backend); - let user_exists: Option<(String,)> = sqlx::query_as(&user_check_sql) - .bind(&parent_created_by) - .fetch_optional(&state.db) - .await - .map_err(|e| AppError::Internal(format!("failed to check user: {e}")))?; - - if user_exists.is_none() { - return Err(AppError::Forbidden("Parent client owner not found".into())); - } - - // Check for duplicate client_id_url. - let dup_check_sql = adapt_sql( - "SELECT id FROM api_clients WHERE client_id_url = ?", - state.db_backend, - ); - let dup: Option<(String,)> = sqlx::query_as(&dup_check_sql) - .bind(&body.client_id_url) - .fetch_optional(&state.db) - .await - .map_err(|e| AppError::Internal(format!("failed to check client_id_url: {e}")))?; - - if dup.is_some() { - return Err(AppError::Conflict( - "client_id_url already registered".into(), - )); - } - - // Generate the client key and secret. - let mut random_bytes = [0u8; 16]; - rand::rng().fill(&mut random_bytes); - let child_client_key = format!("hvc_{}", hex::encode(random_bytes)); - - 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(); - let redirect_uris_json = - serde_json::to_string(&body.redirect_uris).unwrap_or_else(|_| "[]".to_string()); - let allowed_origins_json = body - .allowed_origins - .as_ref() - .map(|origins| serde_json::to_string(origins).unwrap_or_else(|_| "[]".to_string())); - - let insert_sql = adapt_sql( - "INSERT INTO api_clients (id, client_key, client_secret_hash, name, client_id_url, client_uri, redirect_uris, scopes, rate_limit_capacity, rate_limit_refill_rate, client_type, allowed_origins, is_active, created_by, created_at, updated_at, parent_client_id, owner_did) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, 1, ?, ?, ?, ?, ?)", - state.db_backend, - ); - - sqlx::query(&insert_sql) - .bind(&id) - .bind(&child_client_key) - .bind(&client_secret_hash) - .bind(&body.name) - .bind(&body.client_id_url) - .bind(&body.client_uri) - .bind(&redirect_uris_json) - .bind(&body.scopes) - .bind(&body.client_type) - .bind(&allowed_origins_json) - .bind(user_did) - .bind(&now) - .bind(&now) - .bind(&parent_client.id) - .bind(user_did) - .execute(&state.db) - .await - .map_err(|e| AppError::Internal(format!("failed to create child api client: {e}")))?; - - // Register the new client in the OAuth registry. - let oauth_params = crate::auth::client_registry::ApiClientOAuthParams { - plc_url: state.config.plc_url.clone(), - state_store: state.oauth_state_store.clone(), - session_store_pool: state.db.clone(), - db_backend: state.db_backend, - }; - if let Err(e) = state.oauth.register_api_client( - &body.client_id_url, - &body.client_uri, - body.redirect_uris.clone(), - &body.scopes, - &oauth_params, - ) { - tracing::warn!(client_id = %body.client_id_url, error = %e, "OAuth client registration failed (DB row created)"); - } - - // Register the client identity for request validation. - state.rate_limiter.register_client_identity( - child_client_key.clone(), - crate::rate_limit::ClientIdentity { - secret_hash: client_secret_hash.clone(), - client_uri: body.client_uri.clone(), - }, - ); - - // Register the child with its own rate limit bucket using instance defaults. - let defaults = state.rate_limiter.defaults(); - state.rate_limiter.register_client_config( - child_client_key.clone(), - crate::rate_limit::RateLimitConfig { - capacity: state.config.default_rate_limit_capacity, - refill_rate: state.config.default_rate_limit_refill_rate, - default_query_cost: defaults.query_cost, - default_procedure_cost: defaults.procedure_cost, - default_proxy_cost: defaults.proxy_cost, - }, - ); - - log_event( - &state.db, - EventLog { - event_type: "api_client.created".to_string(), - severity: Severity::Info, - actor_did: Some(user_did.clone()), - subject: Some(body.name.clone()), - detail: serde_json::json!({ - "client_key": child_client_key, - "client_id_url": body.client_id_url, - "parent_client_id": parent_client.id, - "self_service": true, - }), - }, - state.db_backend, - ) - .await; - - Ok(( - StatusCode::CREATED, - Json(CreateApiClientResponse { - id, - client_key: child_client_key, - client_secret, - name: body.name, - client_id_url: body.client_id_url, - client_type: body.client_type, - }), - )) -} diff --git a/src/server.rs b/src/server.rs index 52308d5..8e96995 100644 --- a/src/server.rs +++ b/src/server.rs @@ -72,6 +72,22 @@ pub fn router(state: AppState) -> Router { "/xrpc/com.atproto.repo.uploadBlob", post(repo::upload_blob).layer(DefaultBodyLimit::max(50 * 1024 * 1024)), ) + .route( + "/xrpc/dev.happyview.listApiClients", + get(crate::dev_happyview::list_api_clients), + ) + .route( + "/xrpc/dev.happyview.getApiClient", + get(crate::dev_happyview::get_api_client), + ) + .route( + "/xrpc/dev.happyview.createApiClient", + post(crate::dev_happyview::create_api_client), + ) + .route( + "/xrpc/dev.happyview.deleteApiClient", + post(crate::dev_happyview::delete_api_client), + ) // Catch-all for dynamically registered lexicons .route("/xrpc/{method}", get(xrpc::xrpc_get).post(xrpc::xrpc_post)) .route("/config", get(config_endpoint)) diff --git a/tests/dev_happyview.rs b/tests/dev_happyview.rs new file mode 100644 index 0000000..e196add --- /dev/null +++ b/tests/dev_happyview.rs @@ -0,0 +1,461 @@ +mod common; + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use happyview::oauth::pds_write::generate_dpop_proof; +use http_body_util::BodyExt; +use serde_json::{Value, json}; +use serial_test::serial; +use tower::ServiceExt; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +async fn response_json(resp: axum::response::Response) -> Value { + let body = resp.into_body().collect().await.unwrap().to_bytes(); + serde_json::from_slice(&body).unwrap_or(json!(null)) +} + +fn post_json_with_headers(uri: &str, body: &Value, headers: Vec<(&str, &str)>) -> Request { + let mut builder = Request::builder() + .method("POST") + .uri(uri) + .header("content-type", "application/json") + .header("host", "127.0.0.1:0"); + for (name, value) in headers { + builder = builder.header(name, value); + } + builder + .body(Body::from(serde_json::to_vec(body).unwrap())) + .unwrap() +} + +/// Set up a full DPoP session and return `(client_key, dpop_key, access_token)`. +async fn setup_dpop_session(app: &common::app::TestApp, user_did: &str) -> (String, Value, String) { + let (client_key, client_secret, _id) = app.create_api_client("confidential", None).await; + + // 1. Provision DPoP key + let key_req = post_json_with_headers( + "/oauth/dpop-keys", + &json!({}), + vec![ + ("x-client-key", &client_key), + ("x-client-secret", &client_secret), + ], + ); + let key_resp = app.router.clone().oneshot(key_req).await.unwrap(); + assert_eq!( + key_resp.status(), + StatusCode::CREATED, + "dpop key provisioning failed" + ); + let key_body = response_json(key_resp).await; + let provision_id = key_body["provision_id"].as_str().unwrap().to_string(); + let dpop_key = key_body["dpop_key"].clone(); + + // 2. Register session + let access_token = format!("test-access-{}", uuid::Uuid::new_v4()); + let session_req = post_json_with_headers( + "/oauth/sessions", + &json!({ + "provision_id": provision_id, + "did": user_did, + "access_token": &access_token, + "scopes": "atproto", + "pds_url": "https://pds.example.com", + }), + vec![ + ("x-client-key", &client_key), + ("x-client-secret", &client_secret), + ], + ); + let session_resp = app.router.clone().oneshot(session_req).await.unwrap(); + assert_eq!( + session_resp.status(), + StatusCode::CREATED, + "session registration failed" + ); + + (client_key, dpop_key, access_token) +} + +// --------------------------------------------------------------------------- +// listApiClients tests +// --------------------------------------------------------------------------- + +/// Unauthenticated request (no Authorization header) should be rejected. +#[tokio::test] +#[serial] +async fn list_api_clients_unauthenticated_returns_non_200() { + let app = common::app::TestApp::new_with_encryption().await; + + let req = Request::builder() + .method("GET") + .uri("/xrpc/dev.happyview.listApiClients") + .header("host", "127.0.0.1:0") + .header("x-client-key", "hvc_fake") + .body(Body::empty()) + .unwrap(); + + let resp = app.router.clone().oneshot(req).await.unwrap(); + // Handler requires DPoP auth — anonymous access should be rejected (non-200) + assert_ne!( + resp.status(), + StatusCode::OK, + "unauthenticated request should not return 200" + ); +} + +/// DPoP-authenticated request returns 200 with a `clients` array. +#[tokio::test] +#[serial] +async fn list_api_clients_authenticated_returns_200_with_clients_array() { + let app = common::app::TestApp::new_with_encryption().await; + let user_did = "did:plc:testowner"; + let (client_key, dpop_key, access_token) = setup_dpop_session(&app, user_did).await; + + let request_url = "http://127.0.0.1:0/xrpc/dev.happyview.listApiClients"; + let proof = generate_dpop_proof(&dpop_key, "GET", request_url, &access_token, None) + .expect("failed to generate DPoP proof"); + + let req = Request::builder() + .method("GET") + .uri("/xrpc/dev.happyview.listApiClients") + .header("host", "127.0.0.1:0") + .header("x-client-key", &client_key) + .header("authorization", format!("DPoP {}", access_token)) + .header("dpop", &proof) + .body(Body::empty()) + .unwrap(); + + let resp = app.router.clone().oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let body = response_json(resp).await; + assert!( + body["clients"].is_array(), + "response should contain a 'clients' array, got: {body}" + ); +} + +// --------------------------------------------------------------------------- +// getApiClient tests +// --------------------------------------------------------------------------- + +/// Authenticated request for a nonexistent client ID returns 404. +#[tokio::test] +#[serial] +async fn get_api_client_not_found() { + let app = common::app::TestApp::new_with_encryption().await; + let user_did = "did:plc:testowner404"; + let (client_key, dpop_key, access_token) = setup_dpop_session(&app, user_did).await; + + let request_url = "http://127.0.0.1:0/xrpc/dev.happyview.getApiClient?id=nonexistent-id"; + let proof = generate_dpop_proof(&dpop_key, "GET", request_url, &access_token, None) + .expect("failed to generate DPoP proof"); + + let req = Request::builder() + .method("GET") + .uri("/xrpc/dev.happyview.getApiClient?id=nonexistent-id") + .header("host", "127.0.0.1:0") + .header("x-client-key", &client_key) + .header("authorization", format!("DPoP {}", access_token)) + .header("dpop", &proof) + .body(Body::empty()) + .unwrap(); + + let resp = app.router.clone().oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +// --------------------------------------------------------------------------- +// createApiClient tests +// --------------------------------------------------------------------------- + +/// DPoP-authenticated request creates a confidential client and returns +/// clientKey (hvc_) and clientSecret (hvs_) in the response. +#[tokio::test] +#[serial] +async fn create_api_client_via_xrpc() { + let app = common::app::TestApp::new_with_encryption().await; + let user_did = "did:plc:testcreator"; + let (client_key, dpop_key, access_token) = setup_dpop_session(&app, user_did).await; + + let request_url = "http://127.0.0.1:0/xrpc/dev.happyview.createApiClient"; + let proof = generate_dpop_proof(&dpop_key, "POST", request_url, &access_token, None) + .expect("failed to generate DPoP proof"); + + let body = json!({ + "name": "My Confidential Client", + "clientIdUrl": "https://myapp.example.com/oauth/client", + "clientUri": "https://myapp.example.com", + "redirectUris": ["https://myapp.example.com/callback"], + "clientType": "confidential", + }); + + let req = post_json_with_headers( + "/xrpc/dev.happyview.createApiClient", + &body, + vec![ + ("x-client-key", &client_key), + ("authorization", &format!("DPoP {}", access_token)), + ("dpop", &proof), + ], + ); + + let resp = app.router.clone().oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::CREATED, "expected 201 CREATED"); + + let resp_body = response_json(resp).await; + let client_key_val = resp_body["clientKey"].as_str().unwrap_or(""); + assert!( + client_key_val.starts_with("hvc_"), + "clientKey should start with 'hvc_', got: {client_key_val}" + ); + + let client_secret_val = resp_body["clientSecret"].as_str().unwrap_or(""); + assert!( + client_secret_val.starts_with("hvs_"), + "clientSecret should start with 'hvs_', got: {client_secret_val}" + ); + + assert_eq!( + resp_body["clientType"].as_str().unwrap_or(""), + "confidential" + ); +} + +/// Creating a public client returns no clientSecret in the response. +#[tokio::test] +#[serial] +async fn create_api_client_public_no_secret() { + let app = common::app::TestApp::new_with_encryption().await; + let user_did = "did:plc:testcreatorpublic"; + let (client_key, dpop_key, access_token) = setup_dpop_session(&app, user_did).await; + + let request_url = "http://127.0.0.1:0/xrpc/dev.happyview.createApiClient"; + let proof = generate_dpop_proof(&dpop_key, "POST", request_url, &access_token, None) + .expect("failed to generate DPoP proof"); + + let body = json!({ + "name": "My Public Client", + "clientIdUrl": "https://pubapp.example.com/oauth/client", + "clientUri": "https://pubapp.example.com", + "redirectUris": ["https://pubapp.example.com/callback"], + "clientType": "public", + }); + + let req = post_json_with_headers( + "/xrpc/dev.happyview.createApiClient", + &body, + vec![ + ("x-client-key", &client_key), + ("authorization", &format!("DPoP {}", access_token)), + ("dpop", &proof), + ], + ); + + let resp = app.router.clone().oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::CREATED, "expected 201 CREATED"); + + let resp_body = response_json(resp).await; + let client_key_val = resp_body["clientKey"].as_str().unwrap_or(""); + assert!( + client_key_val.starts_with("hvc_"), + "clientKey should start with 'hvc_', got: {client_key_val}" + ); + + assert!( + resp_body["clientSecret"].is_null(), + "public client should have no clientSecret, got: {}", + resp_body["clientSecret"] + ); + + assert_eq!(resp_body["clientType"].as_str().unwrap_or(""), "public"); +} + +// --------------------------------------------------------------------------- +// deleteApiClient tests +// --------------------------------------------------------------------------- + +/// Create a client, delete it, then verify a GET returns 404. +#[tokio::test] +#[serial] +async fn delete_api_client_success() { + let app = common::app::TestApp::new_with_encryption().await; + let user_did = "did:plc:testownerdelete"; + let (client_key, dpop_key, access_token) = setup_dpop_session(&app, user_did).await; + + // Insert a client owned by user_did. + let client_id = uuid::Uuid::new_v4().to_string(); + let now = happyview::db::now_rfc3339(); + let sql = happyview::db::adapt_sql( + "INSERT INTO api_clients (id, client_key, client_secret_hash, name, client_id_url, client_uri, redirect_uris, scopes, client_type, allowed_origins, is_active, created_by, created_at, updated_at, owner_did) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?)", + app.state.db_backend, + ); + sqlx::query(&sql) + .bind(&client_id) + .bind("hvc_delete_test_key") + .bind("dummyhash") + .bind("to-be-deleted") + .bind("https://delete.example.com/oauth/abc") + .bind("https://delete.example.com") + .bind("[]") + .bind("atproto") + .bind("confidential") + .bind::>(None) + .bind(user_did) + .bind(&now) + .bind(&now) + .bind(user_did) + .execute(&app.state.db) + .await + .expect("failed to insert client for deletion test"); + + // Delete the client via XRPC. + let delete_url = "http://127.0.0.1:0/xrpc/dev.happyview.deleteApiClient"; + let delete_proof = generate_dpop_proof(&dpop_key, "POST", delete_url, &access_token, None) + .expect("failed to generate DPoP proof for delete"); + + let delete_req = post_json_with_headers( + "/xrpc/dev.happyview.deleteApiClient", + &json!({ "id": client_id }), + vec![ + ("x-client-key", &client_key), + ("authorization", &format!("DPoP {}", access_token)), + ("dpop", &delete_proof), + ], + ); + + let delete_resp = app.router.clone().oneshot(delete_req).await.unwrap(); + assert_eq!( + delete_resp.status(), + StatusCode::OK, + "expected 200 OK on delete" + ); + + // Verify GET now returns 404. + let get_uri = format!("/xrpc/dev.happyview.getApiClient?id={}", client_id); + let get_url = format!("http://127.0.0.1:0{}", get_uri); + let get_proof = generate_dpop_proof(&dpop_key, "GET", &get_url, &access_token, None) + .expect("failed to generate DPoP proof for get"); + + let get_req = Request::builder() + .method("GET") + .uri(&get_uri) + .header("host", "127.0.0.1:0") + .header("x-client-key", &client_key) + .header("authorization", format!("DPoP {}", access_token)) + .header("dpop", &get_proof) + .body(Body::empty()) + .unwrap(); + + let get_resp = app.router.clone().oneshot(get_req).await.unwrap(); + assert_eq!( + get_resp.status(), + StatusCode::NOT_FOUND, + "client should be gone after deletion" + ); +} + +/// Attempting to delete a nonexistent client returns 404. +#[tokio::test] +#[serial] +async fn delete_api_client_not_found() { + let app = common::app::TestApp::new_with_encryption().await; + let user_did = "did:plc:testownerdel404"; + let (client_key, dpop_key, access_token) = setup_dpop_session(&app, user_did).await; + + let delete_url = "http://127.0.0.1:0/xrpc/dev.happyview.deleteApiClient"; + let delete_proof = generate_dpop_proof(&dpop_key, "POST", delete_url, &access_token, None) + .expect("failed to generate DPoP proof"); + + let delete_req = post_json_with_headers( + "/xrpc/dev.happyview.deleteApiClient", + &json!({ "id": "nonexistent-id-12345" }), + vec![ + ("x-client-key", &client_key), + ("authorization", &format!("DPoP {}", access_token)), + ("dpop", &delete_proof), + ], + ); + + let resp = app.router.clone().oneshot(delete_req).await.unwrap(); + assert_eq!( + resp.status(), + StatusCode::NOT_FOUND, + "deleting nonexistent client should return 404" + ); +} + +/// Authenticated request returns 200 with the matching client. +#[tokio::test] +#[serial] +async fn get_api_client_returns_client() { + let app = common::app::TestApp::new_with_encryption().await; + let user_did = "did:plc:testownerget"; + let (client_key, dpop_key, access_token) = setup_dpop_session(&app, user_did).await; + + // Insert a child client owned by user_did directly. + let client_id = uuid::Uuid::new_v4().to_string(); + let now = happyview::db::now_rfc3339(); + let sql = happyview::db::adapt_sql( + "INSERT INTO api_clients (id, client_key, client_secret_hash, name, client_id_url, client_uri, redirect_uris, scopes, client_type, allowed_origins, is_active, created_by, created_at, updated_at, owner_did) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?)", + app.state.db_backend, + ); + sqlx::query(&sql) + .bind(&client_id) + .bind("hvc_owned_test_key") + .bind("dummyhash") + .bind("owned-client") + .bind("https://owned.example.com/oauth/abc") + .bind("https://owned.example.com") + .bind("[]") + .bind("atproto") + .bind("confidential") + .bind::>(None) + .bind(user_did) + .bind(&now) + .bind(&now) + .bind(user_did) + .execute(&app.state.db) + .await + .expect("failed to insert owned client"); + + let uri = format!("/xrpc/dev.happyview.getApiClient?id={}", client_id); + let request_url = format!("http://127.0.0.1:0{}", uri); + let proof = generate_dpop_proof(&dpop_key, "GET", &request_url, &access_token, None) + .expect("failed to generate DPoP proof"); + + let req = Request::builder() + .method("GET") + .uri(&uri) + .header("host", "127.0.0.1:0") + .header("x-client-key", &client_key) + .header("authorization", format!("DPoP {}", access_token)) + .header("dpop", &proof) + .body(Body::empty()) + .unwrap(); + + let resp = app.router.clone().oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let body = response_json(resp).await; + assert!( + body["client"].is_object(), + "response should contain a 'client' object, got: {body}" + ); + assert_eq!( + body["client"]["id"].as_str().unwrap(), + client_id, + "returned client id should match" + ); + assert_eq!( + body["client"]["name"].as_str().unwrap(), + "owned-client", + "returned client name should match" + ); +}