diff --git a/migrations/postgres/20260425000000_api_clients_add_parent_and_owner.sql b/migrations/postgres/20260425000000_api_clients_add_parent_and_owner.sql new file mode 100644 --- /dev/null +++ b/migrations/postgres/20260425000000_api_clients_add_parent_and_owner.sql @@ -0,0 +1,5 @@ +ALTER TABLE api_clients ADD COLUMN parent_client_id TEXT REFERENCES api_clients(id) ON DELETE CASCADE; +ALTER TABLE api_clients ADD COLUMN owner_did TEXT; + +CREATE INDEX idx_api_clients_parent_id ON api_clients(parent_client_id); +CREATE INDEX idx_api_clients_owner_did ON api_clients(owner_did); diff --git a/migrations/sqlite/20260425000000_api_clients_add_parent_and_owner.sql b/migrations/sqlite/20260425000000_api_clients_add_parent_and_owner.sql new file mode 100644 --- /dev/null +++ b/migrations/sqlite/20260425000000_api_clients_add_parent_and_owner.sql @@ -0,0 +1,5 @@ +ALTER TABLE api_clients ADD COLUMN parent_client_id TEXT REFERENCES api_clients(id) ON DELETE CASCADE; +ALTER TABLE api_clients ADD COLUMN owner_did TEXT; + +CREATE INDEX idx_api_clients_parent_id ON api_clients(parent_client_id); +CREATE INDEX idx_api_clients_owner_did ON api_clients(owner_did); diff --git a/packages/docs/docs/getting-started/authentication.md b/packages/docs/docs/getting-started/authentication.md --- a/packages/docs/docs/getting-started/authentication.md +++ b/packages/docs/docs/getting-started/authentication.md @@ -318,3 +318,4 @@ - [JavaScript SDK](../sdk/overview.md) — authenticate and make XRPC calls from JavaScript - [Permissions](../guides/admin/permissions.md) — full list of permissions and what each one grants - [API Keys](../guides/admin/api-keys.md) — create scoped admin API keys for automation - [Admin API — API Clients](../reference/admin/api-clients.md) — register API clients and configure rate limits +- [Self-Service API Clients](../reference/oauth/api-clients.md) — let third-party apps create child API clients programmatically diff --git a/packages/docs/docs/getting-started/deployment/docker.md b/packages/docs/docs/getting-started/deployment/docker.md --- a/packages/docs/docs/getting-started/deployment/docker.md +++ b/packages/docs/docs/getting-started/deployment/docker.md @@ -9,7 +9,7 @@ ## 1. Clone and configure ```sh -git clone https://github.com/gamesgamesgamesgamesgames/happyview.git +git clone git@tangled.org:gamesgamesgamesgames.games/happyview cd happyview cp .env.example .env ``` diff --git a/packages/docs/docs/getting-started/deployment/other.md b/packages/docs/docs/getting-started/deployment/other.md --- a/packages/docs/docs/getting-started/deployment/other.md +++ b/packages/docs/docs/getting-started/deployment/other.md @@ -10,7 +10,7 @@ ## 1. Clone and configure ```sh -git clone https://github.com/gamesgamesgamesgamesgames/happyview.git +git clone git@tangled.org:gamesgamesgamesgames.games/happyview cd happyview cp .env.example .env ``` diff --git a/packages/docs/docs/guides/features/developing-plugins.md b/packages/docs/docs/guides/features/developing-plugins.md --- a/packages/docs/docs/guides/features/developing-plugins.md +++ b/packages/docs/docs/guides/features/developing-plugins.md @@ -2,7 +2,7 @@ # Developing Plugins This guide covers how to build your own HappyView WASM plugins. For installing and configuring plugins, see the [Plugins guide](plugins.md). -See the [happyview-plugins](https://github.com/gamesgamesgamesgamesgames/happyview-plugins) repository for examples and the plugin SDK. +See the [happyview-plugins](https://tangled.org/gamesgamesgamesgames.games/happyview-plugins) repository for examples and the plugin SDK. ## Plugin Manifest @@ -99,7 +99,7 @@ | `host_kv_delete` | Delete from KV storage | ## Next steps -- [Official plugins repository](https://github.com/gamesgamesgamesgamesgames/happyview-plugins) — ready-to-use plugins and the plugin SDK +- [Official plugins repository](https://tangled.org/gamesgamesgamesgames.games/happyview-plugins) — ready-to-use plugins and the plugin SDK - [Plugins guide](plugins.md) — install and configure plugins - [API Keys](../admin/api-keys.md) — authenticate programmatic access to admin endpoints - [Permissions](../admin/permissions.md) — configure user access to plugin management diff --git a/packages/docs/docs/guides/features/plugins.md b/packages/docs/docs/guides/features/plugins.md --- a/packages/docs/docs/guides/features/plugins.md +++ b/packages/docs/docs/guides/features/plugins.md @@ -2,7 +2,7 @@ # Plugins HappyView uses WASM plugins to extend its functionality. Plugins can integrate with external platforms, sync data to users' atproto identities, and more. Auth plugins — the first supported plugin type — enable users to link accounts from platforms like Steam, Xbox, itch.io, and others, then sync data like game libraries. -Official plugins for Steam, Xbox, itch.io, and other platforms are available in the [happyview-plugins](https://github.com/gamesgamesgamesgamesgames/happyview-plugins) repository. +Official plugins for Steam, Xbox, itch.io, and other platforms are available in the [happyview-plugins](https://tangled.org/gamesgamesgamesgames.games/happyview-plugins) repository. ## Installing Plugins @@ -74,6 +74,6 @@ ## Next steps - [Developing Plugins](developing-plugins.md) — create your own plugins with the WASM plugin API -- [Official plugins repository](https://github.com/gamesgamesgamesgamesgames/happyview-plugins) — ready-to-use plugins for Steam, Xbox, itch.io, and more +- [Official plugins repository](https://tangled.org/gamesgamesgamesgames.games/happyview-plugins) — ready-to-use plugins for Steam, Xbox, itch.io, and more - [API Keys](../admin/api-keys.md) — authenticate programmatic access to admin endpoints - [Permissions](../admin/permissions.md) — configure user access to plugin management diff --git a/packages/docs/docs/guides/upgrading-to-v2.md b/packages/docs/docs/guides/upgrading-to-v2.md --- a/packages/docs/docs/guides/upgrading-to-v2.md +++ b/packages/docs/docs/guides/upgrading-to-v2.md @@ -1,6 +1,6 @@ # Migrating from v1 -v2 consolidates HappyView, [Tap](https://github.com/bluesky-social/indigo/tree/main/cmd/tap), and [AIP](https://github.com/graze-social/aip) into a single binary. Real-time indexing, backfill, and OAuth are now built in — there are no companion services to deploy. +v2 consolidates HappyView, [Tap](https://github.com/bluesky-social/indigo/tree/main/cmd/tap), and [AIP](https://tangled.org/gamesgamesgamesgames.games/aip) into a single binary. Real-time indexing, backfill, and OAuth are now built in — there are no companion services to deploy. This guide covers every breaking change and the steps to migrate. diff --git a/packages/docs/docs/reference/admin/api-clients.md b/packages/docs/docs/reference/admin/api-clients.md --- a/packages/docs/docs/reference/admin/api-clients.md +++ b/packages/docs/docs/reference/admin/api-clients.md @@ -6,6 +6,10 @@ A single API client represents your application, not individual users. Create one client for your app and use the same client key across all instances. Users authenticate separately via OAuth — the client key identifies _your app_, not _who is using it_. Each client has an `hvc_`-prefixed client key and an `hvs_`-prefixed client secret. The secret is only returned at creation and is sha256-hashed in the database. Server-to-server callers pass the secret as `X-Client-Secret`. Browser callers use the `Origin` header, which is matched against the client's `client_uri`. Mismatches currently log warnings rather than rejecting the request, but rate limiting applies either way. See [Authentication — XRPC](../../getting-started/authentication.md#xrpc-api-client-identification) for the client-side view, and the [API Keys guide](../../guides/admin/api-keys.md) for how admin API keys differ from API clients. +:::tip Self-service API clients +Third-party apps can also create **child API clients** programmatically via the [self-service endpoint](../oauth/api-clients.md), without needing admin access. +::: + ```sh # All examples assume $TOKEN is an API key (hv_...) AUTH="Authorization: Bearer $TOKEN" @@ -40,7 +44,9 @@ "rate_limit_refill_rate": 5.0, "is_active": true, "created_by": "did:plc:...", "created_at": "2026-04-13T12:00:00Z", - "updated_at": "2026-04-13T12:00:00Z" + "updated_at": "2026-04-13T12:00:00Z", + "parent_client_id": null, + "owner_did": null } ] ``` diff --git a/packages/docs/docs/reference/oauth/api-clients.md b/packages/docs/docs/reference/oauth/api-clients.md new file mode 100644 --- /dev/null +++ b/packages/docs/docs/reference/oauth/api-clients.md @@ -0,0 +1,82 @@ +# OAuth API: Self-Service API Clients + +Third-party applications can create child API clients on behalf of authenticated users via `POST /oauth/api-clients`. A child client is always tied to exactly one parent — the admin-created top-level API client that made the request. Only one level of nesting is allowed; child clients cannot create further children. Each child client gets its own rate limit bucket with instance default settings. + +The endpoint uses [DPoP authentication](../../getting-started/authentication.md#authenticating-users-for-procedures). See the [admin API client docs](../admin/api-clients.md) for managing clients through the admin API, and the [API Clients guide](../../guides/features/api-clients.md) for an overview of how API clients work in HappyView. + +## Create a child client + +``` +POST /oauth/api-clients +``` + +Requires three headers: + +| Header | Value | +| --------------- | ------------------------------------------------------------ | +| `Authorization` | `DPoP ` | +| `DPoP` | A DPoP proof JWT (method: `POST`, htu: the full request URL) | +| `X-Client-Key` | The parent client's `client_key` | + +The access token must belong to a valid DPoP session for the parent client. The parent client's owner (its `created_by` DID) must exist in the HappyView `users` table. + +```sh +curl -X POST https://happyview.example.com/oauth/api-clients \ + -H "X-Client-Key: hvc_parent_key" \ + -H "Authorization: DPoP eyJhbG..." \ + -H "DPoP: eyJhbG..." \ + -H "Content-Type: application/json" \ + -d '{ + "name": "My Child App", + "client_id_url": "https://child.example.com/client-metadata.json", + "client_uri": "https://child.example.com", + "redirect_uris": ["https://child.example.com/callback"], + "client_type": "confidential" + }' +``` + +| Field | Type | Required | Description | +| ----------------- | -------- | -------- | ------------------------------------------------ | +| `name` | string | yes | Display name for the child client | +| `client_id_url` | string | yes | Unique OAuth client ID URL | +| `client_uri` | string | yes | The client's homepage URL | +| `redirect_uris` | string[] | yes | OAuth redirect URIs | +| `scopes` | string | no | Space-separated OAuth scopes (default `"atproto"`) | +| `client_type` | string | no | `"confidential"` or `"public"` (default `"confidential"`) | +| `allowed_origins` | string[] | no | CORS allowed origins | + +**Response**: `201 Created` + +```json +{ + "id": "550e8400-e29b-41d4-a716-446655440000", + "client_key": "hvc_a1b2c3d4e5f6...", + "client_secret": "hvs_f6e5d4c3b2a1...", + "name": "My Child App", + "client_id_url": "https://child.example.com/client-metadata.json", + "client_type": "confidential" +} +``` + +The `client_secret` is only present for confidential clients and is only returned in this response — store it securely. It is stored as a SHA-256 hash and cannot be retrieved again. + +## Errors + +| Status | Error | Cause | +| ------ | ---------------------------------------- | ------------------------------------------------------------------ | +| 400 | `Invalid client_type` | `client_type` is not `"confidential"` or `"public"` | +| 400 | `invalid request body` | Missing required fields or malformed JSON | +| 401 | `Missing client identification` | `X-Client-Key` header is absent | +| 401 | `DPoP authorization scheme required` | `Authorization` header doesn't start with `DPoP ` | +| 401 | `DPoP proof header required` | `DPoP` header is absent | +| 401 | `token_expired` | The access token has expired | +| 401 | `Invalid client` | `X-Client-Key` doesn't match a known client | +| 403 | `Child clients cannot create API clients` | The calling client is itself a child | +| 403 | `Parent client owner not found` | The parent client's `created_by` DID is not in the `users` table | +| 409 | `client_id_url already registered` | Another client already uses that `client_id_url` | + +## Operational notes + +Each child client gets its own rate limit bucket using the instance's default capacity and refill rate (`DEFAULT_RATE_LIMIT_CAPACITY` / `DEFAULT_RATE_LIMIT_REFILL_RATE`). Deactivating or deleting a parent via the [admin API](../admin/api-clients.md) cascades to all its children. + +The admin API clients list (`GET /admin/api-clients`) returns `parent_client_id` and `owner_did` fields for each client and supports `?parent_id=` filtering. The dashboard's API Clients table shows these as "Parent Client" and "Owner" columns. diff --git a/packages/docs/docusaurus.config.ts b/packages/docs/docusaurus.config.ts --- a/packages/docs/docusaurus.config.ts +++ b/packages/docs/docusaurus.config.ts @@ -52,8 +52,8 @@ position: "left", label: "Docs", }, { - href: "https://github.com/gamesgamesgamesgamesgames/happyview", - label: "GitHub", + href: "https://tangled.org/gamesgamesgamesgames.games/happyview", + label: "Tangled", position: "right", }, ], diff --git a/packages/docs/sidebars.ts b/packages/docs/sidebars.ts --- a/packages/docs/sidebars.ts +++ b/packages/docs/sidebars.ts @@ -381,6 +381,17 @@ ], }, { type: "category", + label: "OAuth API", + items: [ + { + type: "doc", + id: "reference/oauth/api-clients", + label: "Self-Service API Clients", + }, + ], + }, + { + type: "category", label: "Lua API", items: [ { diff --git a/src/admin/api_clients.rs b/src/admin/api_clients.rs --- a/src/admin/api_clients.rs +++ b/src/admin/api_clients.rs @@ -1,9 +1,11 @@ use axum::Json; -use axum::extract::{Path, State}; +use axum::extract::{Path, Query, State}; use axum::http::StatusCode; use hex; use rand::Rng; +use serde::Deserialize; use sha2::{Digest, Sha256}; +use sqlx::Row; use uuid::Uuid; use crate::AppState; @@ -16,6 +18,11 @@ use super::permissions::Permission; use super::types::{ ApiClientSummary, CreateApiClientBody, CreateApiClientResponse, UpdateApiClientBody, }; + +#[derive(Deserialize)] +pub(super) struct ListApiClientsQuery { + pub(super) parent_id: Option, +} /// POST /admin/api-clients — create a new API client. pub(super) async fn create_api_client( @@ -152,80 +159,71 @@ /// GET /admin/api-clients — list all API clients. pub(super) async fn list_api_clients( State(state): State, auth: UserAuth, + Query(query): Query, ) -> Result>, AppError> { auth.require(Permission::ApiClientsView).await?; - let select_sql = adapt_sql( - "SELECT id, client_key, name, client_id_url, client_uri, redirect_uris, scopes, client_type, allowed_origins, rate_limit_capacity, rate_limit_refill_rate, is_active, created_by, created_at, updated_at FROM api_clients ORDER BY created_at DESC", - state.db_backend, - ); + let (select_sql, parent_filter) = if let Some(ref parent_id) = query.parent_id { + ( + adapt_sql( + "SELECT id, client_key, name, client_id_url, client_uri, redirect_uris, scopes, client_type, allowed_origins, rate_limit_capacity, rate_limit_refill_rate, is_active, created_by, created_at, updated_at, parent_client_id, owner_did FROM api_clients WHERE parent_client_id = ? ORDER BY created_at DESC", + state.db_backend, + ), + Some(parent_id.clone()), + ) + } else { + ( + adapt_sql( + "SELECT id, client_key, name, client_id_url, client_uri, redirect_uris, scopes, client_type, allowed_origins, rate_limit_capacity, rate_limit_refill_rate, is_active, created_by, created_at, updated_at, parent_client_id, owner_did FROM api_clients ORDER BY created_at DESC", + state.db_backend, + ), + None, + ) + }; + + let q = sqlx::query(&select_sql); + let q = if let Some(ref pid) = parent_filter { + q.bind(pid) + } else { + q + }; - #[allow(clippy::type_complexity)] - let rows: Vec<( - String, - String, - String, - String, - String, - String, - String, - String, - Option, - Option, - Option, - i32, - String, - String, - String, - )> = sqlx::query_as(&select_sql) + let rows = q .fetch_all(&state.db) .await .map_err(|e| AppError::Internal(format!("failed to list api clients: {e}")))?; let clients: Vec = rows .into_iter() - .map( - |( - id, - client_key, - name, - client_id_url, - client_uri, - redirect_uris_json, - scopes, - client_type, - allowed_origins_json, - rate_limit_capacity, - rate_limit_refill_rate, - is_active, - created_by, - created_at, - updated_at, - )| { - let redirect_uris: Vec = - serde_json::from_str(&redirect_uris_json).unwrap_or_default(); - let allowed_origins: Option> = allowed_origins_json - .as_deref() - .and_then(|j| serde_json::from_str(j).ok()); - ApiClientSummary { - id, - client_key, - name, - client_id_url, - client_uri, - redirect_uris, - scopes, - client_type, - allowed_origins, - rate_limit_capacity, - rate_limit_refill_rate, - is_active: is_active != 0, - created_by, - created_at, - updated_at, - } - }, - ) + .map(|row| { + let redirect_uris_json: String = row.get("redirect_uris"); + let allowed_origins_json: Option = row.get("allowed_origins"); + let is_active: i32 = row.get("is_active"); + let redirect_uris: Vec = + serde_json::from_str(&redirect_uris_json).unwrap_or_default(); + let allowed_origins: Option> = allowed_origins_json + .as_deref() + .and_then(|j| serde_json::from_str(j).ok()); + ApiClientSummary { + id: row.get("id"), + client_key: row.get("client_key"), + name: row.get("name"), + client_id_url: row.get("client_id_url"), + client_uri: row.get("client_uri"), + redirect_uris, + scopes: row.get("scopes"), + client_type: row.get("client_type"), + allowed_origins, + rate_limit_capacity: row.get("rate_limit_capacity"), + rate_limit_refill_rate: row.get("rate_limit_refill_rate"), + is_active: is_active != 0, + created_by: row.get("created_by"), + created_at: row.get("created_at"), + updated_at: row.get("updated_at"), + parent_client_id: row.get("parent_client_id"), + owner_did: row.get("owner_did"), + } + }) .collect(); Ok(Json(clients)) @@ -240,75 +238,46 @@ ) -> Result, AppError> { auth.require(Permission::ApiClientsView).await?; let select_sql = adapt_sql( - "SELECT id, client_key, name, client_id_url, client_uri, redirect_uris, scopes, client_type, allowed_origins, rate_limit_capacity, rate_limit_refill_rate, is_active, created_by, created_at, updated_at FROM api_clients WHERE id = ?", + "SELECT id, client_key, name, client_id_url, client_uri, redirect_uris, scopes, client_type, allowed_origins, rate_limit_capacity, rate_limit_refill_rate, is_active, created_by, created_at, updated_at, parent_client_id, owner_did FROM api_clients WHERE id = ?", state.db_backend, ); - type GetRow = ( - String, - String, - String, - String, - String, - String, - String, - String, - Option, - Option, - Option, - i32, - String, - String, - String, - ); - let row: Option = sqlx::query_as(&select_sql) + let row = sqlx::query(&select_sql) .bind(&id) .fetch_optional(&state.db) .await .map_err(|e| AppError::Internal(format!("failed to get api client: {e}")))?; - let Some(( - id, - client_key, - name, - client_id_url, - client_uri, - redirect_uris_json, - scopes, - client_type, - allowed_origins_json, - rate_limit_capacity, - rate_limit_refill_rate, - is_active, - created_by, - created_at, - updated_at, - )) = row - else { + let Some(row) = row else { return Err(AppError::NotFound(format!("api client '{id}' not found"))); }; + let redirect_uris_json: String = row.get("redirect_uris"); + let allowed_origins_json: Option = row.get("allowed_origins"); + let is_active: i32 = row.get("is_active"); let redirect_uris: Vec = serde_json::from_str(&redirect_uris_json).unwrap_or_default(); let allowed_origins: Option> = allowed_origins_json .as_deref() .and_then(|j| serde_json::from_str(j).ok()); Ok(Json(ApiClientSummary { - id, - client_key, - name, - client_id_url, - client_uri, + id: row.get("id"), + client_key: row.get("client_key"), + name: row.get("name"), + client_id_url: row.get("client_id_url"), + client_uri: row.get("client_uri"), redirect_uris, - scopes, - client_type, + scopes: row.get("scopes"), + client_type: row.get("client_type"), allowed_origins, - rate_limit_capacity, - rate_limit_refill_rate, + rate_limit_capacity: row.get("rate_limit_capacity"), + rate_limit_refill_rate: row.get("rate_limit_refill_rate"), is_active: is_active != 0, - created_by, - created_at, - updated_at, + created_by: row.get("created_by"), + created_at: row.get("created_at"), + updated_at: row.get("updated_at"), + parent_client_id: row.get("parent_client_id"), + owner_did: row.get("owner_did"), })) } @@ -456,6 +425,33 @@ } } else { state.rate_limiter.remove_client_identity(&client_key); state.rate_limiter.remove_client_config(&client_key); + + // Cascade deactivation to child clients. + let deactivate_children_sql = adapt_sql( + "UPDATE api_clients SET is_active = 0, updated_at = ? WHERE parent_client_id = ? AND is_active = 1", + state.db_backend, + ); + let _ = sqlx::query(&deactivate_children_sql) + .bind(&now) + .bind(&id) + .execute(&state.db) + .await; + + let children_sql = adapt_sql( + "SELECT client_id_url, client_key FROM api_clients WHERE parent_client_id = ?", + state.db_backend, + ); + if let Ok(children) = sqlx::query_as::<_, (String, String)>(&children_sql) + .bind(&id) + .fetch_all(&state.db) + .await + { + 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); + } + } } log_event( @@ -493,6 +489,17 @@ .fetch_optional(&state.db) .await .map_err(|e| AppError::Internal(format!("failed to look up api client: {e}")))?; + // 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(); + let delete_sql = adapt_sql("DELETE FROM api_clients WHERE id = ?", state.db_backend); let result = sqlx::query(&delete_sql) @@ -505,11 +512,18 @@ if result.rows_affected() == 0 { return Err(AppError::NotFound(format!("api client '{id}' not found"))); } - // Remove from OAuth registry, rate limiter, and client identities. + // 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); + } + + // 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); } log_event( diff --git a/src/admin/mod.rs b/src/admin/mod.rs --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -14,7 +14,7 @@ mod records; mod script_variables; pub mod settings; mod stats; -mod types; +pub(crate) mod types; mod users; use axum::Router; diff --git a/src/admin/types.rs b/src/admin/types.rs --- a/src/admin/types.rs +++ b/src/admin/types.rs @@ -398,15 +398,17 @@ pub(super) is_active: bool, pub(super) created_by: String, pub(super) created_at: String, pub(super) updated_at: String, + pub(super) parent_client_id: Option, + pub(super) owner_did: Option, } #[derive(Serialize)] -pub(super) struct CreateApiClientResponse { - pub(super) id: String, - pub(super) client_key: String, +pub(crate) struct CreateApiClientResponse { + pub(crate) id: String, + pub(crate) client_key: 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, + pub(crate) client_secret: Option, + pub(crate) name: String, + pub(crate) client_id_url: String, + pub(crate) client_type: String, } diff --git a/src/error.rs b/src/error.rs --- a/src/error.rs +++ b/src/error.rs @@ -51,6 +51,7 @@ /// Auth failure with a DPoP nonce that the client should retry with. AuthDpopNonce(String), BadGateway(String), BadRequest(String), + Conflict(String), Forbidden(String), InsufficientPermissions(String), Internal(String), @@ -76,6 +77,7 @@ AppError::Auth(msg) => write!(f, "auth error: {msg}"), AppError::AuthDpopNonce(nonce) => write!(f, "auth error: use_dpop_nonce ({nonce})"), AppError::BadGateway(msg) => write!(f, "bad gateway: {msg}"), AppError::BadRequest(msg) => write!(f, "bad request: {msg}"), + AppError::Conflict(msg) => write!(f, "conflict: {msg}"), AppError::Forbidden(msg) => write!(f, "forbidden: {msg}"), AppError::InsufficientPermissions(perm) => write!(f, "Missing permission: {perm}"), AppError::Internal(msg) => write!(f, "internal error: {msg}"), @@ -170,7 +172,7 @@ let (status, message) = match &other { AppError::Auth(msg) => (StatusCode::UNAUTHORIZED, msg.clone()), AppError::BadGateway(msg) => (StatusCode::BAD_GATEWAY, msg.clone()), AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()), - + AppError::Conflict(msg) => (StatusCode::CONFLICT, msg.clone()), AppError::Forbidden(msg) => (StatusCode::FORBIDDEN, msg.clone()), AppError::Internal(msg) => { tracing::error!("{msg}"); diff --git a/src/main.rs b/src/main.rs --- a/src/main.rs +++ b/src/main.rs @@ -272,28 +272,46 @@ tokio::spawn(rate_limiter.clone().spawn_cleanup()); // Load per-client rate limit configs and identities from api_clients table. { - type ClientRow = (String, String, String, Option, Option); + type ClientRow = ( + String, + String, + String, + Option, + Option, + Option, + ); let client_rows: Vec = sqlx::query_as( - "SELECT client_key, client_secret_hash, client_uri, rate_limit_capacity, rate_limit_refill_rate FROM api_clients WHERE is_active = 1", + "SELECT client_key, client_secret_hash, client_uri, rate_limit_capacity, rate_limit_refill_rate, parent_client_id FROM api_clients WHERE is_active = 1", ) .fetch_all(&db_pool) .await .unwrap_or_default(); - for (client_key, secret_hash, client_uri, capacity, refill_rate) in client_rows { + for (client_key, secret_hash, client_uri, capacity, refill_rate, _) in &client_rows { rate_limiter.register_client_identity( client_key.clone(), happyview::rate_limit::ClientIdentity { - secret_hash, - client_uri, + secret_hash: secret_hash.clone(), + client_uri: client_uri.clone(), }, ); if let (Some(cap), Some(refill)) = (capacity, refill_rate) { rate_limiter.register_client_config( - client_key, + client_key.clone(), + happyview::rate_limit::RateLimitConfig { + capacity: *cap as u32, + refill_rate: *refill, + default_query_cost: defaults.query_cost, + default_procedure_cost: defaults.procedure_cost, + default_proxy_cost: defaults.proxy_cost, + }, + ); + } else { + rate_limiter.register_client_config( + client_key.clone(), happyview::rate_limit::RateLimitConfig { - capacity: cap as u32, - refill_rate: refill, + capacity: config.default_rate_limit_capacity, + refill_rate: config.default_rate_limit_refill_rate, default_query_cost: defaults.query_cost, default_procedure_cost: defaults.procedure_cost, default_proxy_cost: defaults.proxy_cost, diff --git a/src/oauth/mod.rs b/src/oauth/mod.rs --- a/src/oauth/mod.rs +++ b/src/oauth/mod.rs @@ -3,4 +3,5 @@ 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 --- a/src/oauth/routes.rs +++ b/src/oauth/routes.rs @@ -18,6 +18,10 @@ Router::new() .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 new file mode 100644 --- /dev/null +++ b/src/oauth/self_service.rs @@ -0,0 +1,313 @@ +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/rate_limit.rs b/src/rate_limit.rs --- a/src/rate_limit.rs +++ b/src/rate_limit.rs @@ -170,6 +170,18 @@ pub fn register_client_config(&self, client_key: String, config: RateLimitConfig) { self.client_configs.insert(client_key, config); } + pub fn get_client_config(&self, client_key: &str) -> Option { + self.client_configs + .get(client_key) + .map(|cfg| RateLimitConfig { + capacity: cfg.capacity, + refill_rate: cfg.refill_rate, + default_query_cost: cfg.default_query_cost, + default_procedure_cost: cfg.default_procedure_cost, + default_proxy_cost: cfg.default_proxy_cost, + }) + } + pub fn remove_client_config(&self, client_key: &str) { self.client_configs.remove(client_key); } diff --git a/tests/common/db.rs b/tests/common/db.rs --- a/tests/common/db.rs +++ b/tests/common/db.rs @@ -20,7 +20,7 @@ let backend = test_backend(); match backend { DatabaseBackend::Postgres => { sqlx::query( - "TRUNCATE records, lexicons, backfill_jobs, users, user_permissions, api_keys, event_logs, script_variables, dead_letter_hooks, record_refs, labeler_subscriptions, labels, instance_settings, domains RESTART IDENTITY CASCADE", + "TRUNCATE records, lexicons, backfill_jobs, users, user_permissions, api_keys, event_logs, script_variables, dead_letter_hooks, record_refs, labeler_subscriptions, labels, instance_settings, domains, dpop_sessions, dpop_keys, api_clients RESTART IDENTITY CASCADE", ) .execute(pool) .await @@ -28,6 +28,9 @@ .expect("failed to truncate tables"); } DatabaseBackend::Sqlite => { let tables = [ + "dpop_sessions", + "dpop_keys", + "api_clients", "records", "lexicons", "backfill_jobs", diff --git a/tests/e2e_api_clients.rs b/tests/e2e_api_clients.rs --- a/tests/e2e_api_clients.rs +++ b/tests/e2e_api_clients.rs @@ -2,9 +2,12 @@ mod common; use axum::body::Body; use axum::http::{Request, StatusCode}; +use happyview::db::{adapt_sql, now_rfc3339}; +use happyview::oauth::pds_write::generate_dpop_proof; use http_body_util::BodyExt; use serde_json::{Value, json}; use serial_test::serial; +use sha2::Digest; use tower::ServiceExt; use common::app::TestApp; @@ -641,3 +644,940 @@ let json = json_body(resp).await; assert_eq!(json["rate_limit_capacity"], 50); assert_eq!(json["rate_limit_refill_rate"], 1.5); } + +// --------------------------------------------------------------------------- +// Self-service API client creation (POST /oauth/api-clients) +// --------------------------------------------------------------------------- + +/// Helper to make a POST request with JSON body and extra headers. +fn post_json_with_headers( + uri: &str, + body: &serde_json::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() +} + +/// Parse a response body as JSON, returning `null` on empty/invalid bodies. +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)) +} + +/// Runs the full DPoP provisioning flow and returns the values needed to call +/// the self-service endpoint: +/// `(client_key, dpop_key_json, access_token)` +/// +/// `user_did` is the DID that will be associated with the DPoP session. +async fn setup_dpop_session(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) +} + +/// Build a self-service POST /oauth/api-clients request with full DPoP auth. +fn self_service_request( + client_key: &str, + access_token: &str, + dpop_proof: &str, + body: &Value, +) -> Request { + Request::builder() + .method("POST") + .uri("/oauth/api-clients") + .header("host", "127.0.0.1:0") + .header("content-type", "application/json") + .header("x-client-key", client_key) + .header("authorization", format!("DPoP {}", access_token)) + .header("dpop", dpop_proof) + .body(Body::from(serde_json::to_vec(body).unwrap())) + .unwrap() +} + +// --------------------------------------------------------------------------- +// Happy path +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +#[ignore] +async fn test_self_service_create_confidential_child_client() { + let app = TestApp::new_with_encryption().await; + let (client_key, dpop_key, access_token) = setup_dpop_session(&app, "did:plc:testadmin").await; + + let request_url = "http://127.0.0.1:0/oauth/api-clients"; + 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 Child", + "client_id_url": "https://child-confidential.example.com/oauth-client-metadata.json", + "client_uri": "https://child-confidential.example.com", + "redirect_uris": ["https://child-confidential.example.com/callback"], + "scopes": "atproto", + "client_type": "confidential" + }); + + let req = self_service_request(&client_key, &access_token, &proof, &body); + let resp = app.router.clone().oneshot(req).await.unwrap(); + + assert_eq!(resp.status(), StatusCode::CREATED); + let json = response_json(resp).await; + + // Verify all expected fields + assert!(json["id"].as_str().is_some(), "response should have id"); + let key = json["client_key"].as_str().unwrap(); + assert!(key.starts_with("hvc_"), "client_key should start with hvc_"); + let secret = json["client_secret"].as_str().unwrap(); + assert!( + secret.starts_with("hvs_"), + "client_secret should start with hvs_" + ); + assert_eq!(json["name"], "My Confidential Child"); + assert_eq!( + json["client_id_url"], + "https://child-confidential.example.com/oauth-client-metadata.json" + ); + assert_eq!(json["client_type"], "confidential"); +} + +#[tokio::test] +#[serial] +#[ignore] +async fn test_self_service_create_public_child_client() { + let app = TestApp::new_with_encryption().await; + let (client_key, dpop_key, access_token) = setup_dpop_session(&app, "did:plc:testadmin").await; + + let request_url = "http://127.0.0.1:0/oauth/api-clients"; + 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 Child", + "client_id_url": "https://child-public.example.com/oauth-client-metadata.json", + "client_uri": "https://child-public.example.com", + "redirect_uris": ["https://child-public.example.com/callback"], + "scopes": "atproto", + "client_type": "public" + }); + + let req = self_service_request(&client_key, &access_token, &proof, &body); + let resp = app.router.clone().oneshot(req).await.unwrap(); + + assert_eq!(resp.status(), StatusCode::CREATED); + let json = response_json(resp).await; + + assert!(json["id"].as_str().is_some(), "response should have id"); + let key = json["client_key"].as_str().unwrap(); + assert!(key.starts_with("hvc_"), "client_key should start with hvc_"); + assert_eq!(json["name"], "My Public Child"); + assert_eq!( + json["client_id_url"], + "https://child-public.example.com/oauth-client-metadata.json" + ); + assert_eq!(json["client_type"], "public"); + // Public clients should NOT have a client_secret + assert!( + json["client_secret"].is_null(), + "public client should not have a client_secret" + ); +} + +// --------------------------------------------------------------------------- +// Error cases +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +#[ignore] +async fn test_self_service_child_cannot_create_children() { + let app = TestApp::new_with_encryption().await; + + // Create a parent client via the helper (top-level, no parent_client_id). + let (_parent_key, _parent_secret, parent_id) = + app.create_api_client("confidential", None).await; + + // Insert a child client directly in the DB with parent_client_id set. + let child_key = format!("hvc_{}", hex::encode([0xAAu8; 16])); + let child_secret = format!("hvs_{}", hex::encode([0xBBu8; 32])); + let child_secret_hash = hex::encode(sha2::Sha256::digest(child_secret.as_bytes())); + let child_id = uuid::Uuid::new_v4().to_string(); + let now = now_rfc3339(); + + let sql = adapt_sql( + "INSERT INTO api_clients (id, client_key, client_secret_hash, name, client_id_url, client_uri, redirect_uris, scopes, client_type, is_active, created_by, created_at, updated_at, parent_client_id, owner_did) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?)", + app.state.db_backend, + ); + sqlx::query(&sql) + .bind(&child_id) + .bind(&child_key) + .bind(&child_secret_hash) + .bind("child-client") + .bind("https://child-no-nest.example.com/oauth-client-metadata.json") + .bind("https://child-no-nest.example.com") + .bind("[]") + .bind("atproto") + .bind("confidential") + .bind("did:plc:testadmin") + .bind(&now) + .bind(&now) + .bind(&parent_id) + .bind("did:plc:testadmin") + .execute(&app.state.db) + .await + .expect("failed to insert child client"); + + // The endpoint checks parent_client_id IS NULL at step 6, BEFORE DPoP + // validation. So we just need the DPoP headers to exist — they do not + // need to be cryptographically valid. + let body = json!({ + "name": "Grandchild", + "client_id_url": "https://grandchild.example.com/oauth-client-metadata.json", + "client_uri": "https://grandchild.example.com", + "redirect_uris": ["https://grandchild.example.com/callback"], + "scopes": "atproto", + "client_type": "confidential" + }); + + let req = Request::builder() + .method("POST") + .uri("/oauth/api-clients") + .header("host", "127.0.0.1:0") + .header("content-type", "application/json") + .header("x-client-key", &child_key) + .header("authorization", "DPoP fake-token") + .header("dpop", "fake-proof") + .body(Body::from(serde_json::to_vec(&body).unwrap())) + .unwrap(); + + let resp = app.router.clone().oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +#[serial] +#[ignore] +async fn test_self_service_duplicate_client_id_url() { + let app = TestApp::new_with_encryption().await; + let (client_key, dpop_key, access_token) = setup_dpop_session(&app, "did:plc:testadmin").await; + + let shared_url = "https://dup-test.example.com/oauth-client-metadata.json"; + + // First creation should succeed. + let request_url = "http://127.0.0.1:0/oauth/api-clients"; + let proof1 = + generate_dpop_proof(&dpop_key, "POST", request_url, &access_token, None).expect("proof 1"); + + let body = json!({ + "name": "First Child", + "client_id_url": shared_url, + "client_uri": "https://dup-test.example.com", + "redirect_uris": ["https://dup-test.example.com/callback"], + "scopes": "atproto", + "client_type": "confidential" + }); + + let req1 = self_service_request(&client_key, &access_token, &proof1, &body); + let resp1 = app.router.clone().oneshot(req1).await.unwrap(); + assert_eq!(resp1.status(), StatusCode::CREATED); + + // Second creation with the same client_id_url should fail with 409. + let proof2 = + generate_dpop_proof(&dpop_key, "POST", request_url, &access_token, None).expect("proof 2"); + + let body2 = json!({ + "name": "Second Child", + "client_id_url": shared_url, + "client_uri": "https://dup-test2.example.com", + "redirect_uris": ["https://dup-test2.example.com/callback"], + "scopes": "atproto", + "client_type": "confidential" + }); + + let req2 = self_service_request(&client_key, &access_token, &proof2, &body2); + let resp2 = app.router.clone().oneshot(req2).await.unwrap(); + assert_eq!(resp2.status(), StatusCode::CONFLICT); +} + +#[tokio::test] +#[serial] +#[ignore] +async fn test_self_service_missing_client_key() { + let app = TestApp::new_with_encryption().await; + + let body = json!({ + "name": "No Key Client", + "client_id_url": "https://nokey.example.com/oauth-client-metadata.json", + "client_uri": "https://nokey.example.com", + "redirect_uris": ["https://nokey.example.com/callback"], + "scopes": "atproto", + "client_type": "confidential" + }); + + // No x-client-key header at all. + let req = Request::builder() + .method("POST") + .uri("/oauth/api-clients") + .header("host", "127.0.0.1:0") + .header("content-type", "application/json") + .header("authorization", "DPoP fake-token") + .header("dpop", "fake-proof") + .body(Body::from(serde_json::to_vec(&body).unwrap())) + .unwrap(); + + let resp = app.router.clone().oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +#[serial] +#[ignore] +async fn test_self_service_invalid_client_type() { + let app = TestApp::new_with_encryption().await; + + // Step 3 (client_type validation) happens before step 5 (client resolution). + // The request just needs the required headers to exist. + let body = json!({ + "name": "Invalid Type Client", + "client_id_url": "https://badtype.example.com/oauth-client-metadata.json", + "client_uri": "https://badtype.example.com", + "redirect_uris": ["https://badtype.example.com/callback"], + "scopes": "atproto", + "client_type": "invalid" + }); + + let req = Request::builder() + .method("POST") + .uri("/oauth/api-clients") + .header("host", "127.0.0.1:0") + .header("content-type", "application/json") + .header("x-client-key", "hvc_doesnotmatter") + .header("authorization", "DPoP fake-token") + .header("dpop", "fake-proof") + .body(Body::from(serde_json::to_vec(&body).unwrap())) + .unwrap(); + + let resp = app.router.clone().oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +#[serial] +#[ignore] +async fn test_self_service_parent_owner_not_in_users() { + let app = TestApp::new_with_encryption().await; + + // Create a parent API client whose created_by DID is NOT in the users table. + let orphan_did = "did:plc:orphan"; + let (client_key, client_secret, _id) = { + use happyview::db::{adapt_sql, now_rfc3339}; + use rand::RngCore; + use sha2::{Digest, Sha256}; + + let mut key_bytes = [0u8; 16]; + rand::rng().fill_bytes(&mut key_bytes); + let client_key = format!("hvc_{}", hex::encode(key_bytes)); + + let mut secret_bytes = [0u8; 32]; + rand::rng().fill_bytes(&mut secret_bytes); + let client_secret = format!("hvs_{}", hex::encode(secret_bytes)); + let secret_hash = hex::encode(Sha256::digest(client_secret.as_bytes())); + + let id = uuid::Uuid::new_v4().to_string(); + let now = now_rfc3339(); + + let sql = 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) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)", + app.state.db_backend, + ); + + sqlx::query(&sql) + .bind(&id) + .bind(&client_key) + .bind(&secret_hash) + .bind("orphan-client") + .bind(format!("https://orphan.example.com/oauth/{}", &id[..8])) + .bind("https://orphan.example.com") + .bind("[]") + .bind("atproto") + .bind("confidential") + .bind(None::) + .bind(orphan_did) + .bind(&now) + .bind(&now) + .execute(&app.state.db) + .await + .expect("failed to create orphan API client"); + + app.state.rate_limiter.register_client_identity( + client_key.clone(), + happyview::rate_limit::ClientIdentity { + secret_hash, + client_uri: "https://orphan.example.com".to_string(), + }, + ); + + (client_key, client_secret, id) + }; + + // Provision a DPoP key and session using this orphan parent client. + 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); + 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(); + + 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": "did:plc:sessionuser", + "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); + + let request_url = "http://127.0.0.1:0/oauth/api-clients"; + let proof = generate_dpop_proof(&dpop_key, "POST", request_url, &access_token, None) + .expect("failed to generate DPoP proof"); + + let body = json!({ + "name": "Orphan Owner Child", + "client_id_url": "https://orphan-child.example.com/oauth-client-metadata.json", + "client_uri": "https://orphan-child.example.com", + "redirect_uris": ["https://orphan-child.example.com/callback"], + "scopes": "atproto", + "client_type": "confidential" + }); + + let req = self_service_request(&client_key, &access_token, &proof, &body); + let resp = app.router.clone().oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +// --------------------------------------------------------------------------- +// Cascade: deactivate / delete parent cascades to children +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +#[ignore] +async fn test_deactivate_parent_cascades_to_children() { + let app = TestApp::new().await; + + // Create parent via admin API + let parent_body = json!({ + "name": "Cascade Parent", + "client_id_url": "https://cascade-deactivate-parent.example.com/oauth-client-metadata.json", + "client_uri": "https://cascade-deactivate-parent.example.com", + "redirect_uris": ["https://happyview.example.com/auth/callback"], + "scopes": "atproto" + }); + let create_resp = app + .router + .clone() + .oneshot(admin_post( + "/admin/api-clients", + app.admin_cookie(), + &parent_body, + )) + .await + .unwrap(); + assert_eq!(create_resp.status(), StatusCode::CREATED); + let created = json_body(create_resp).await; + let parent_id = created["id"].as_str().unwrap().to_string(); + + // Insert child directly in DB + let child_id = uuid::Uuid::new_v4().to_string(); + let child_key = format!("hvc_{}", hex::encode([0xCCu8; 16])); + let child_hash = hex::encode(sha2::Sha256::digest("hvs_fake_cc".as_bytes())); + let now = now_rfc3339(); + + let sql = adapt_sql( + "INSERT INTO api_clients (id, client_key, client_secret_hash, name, client_id_url, client_uri, redirect_uris, scopes, client_type, is_active, created_by, created_at, updated_at, parent_client_id, owner_did) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?)", + app.state.db_backend, + ); + sqlx::query(&sql) + .bind(&child_id) + .bind(&child_key) + .bind(&child_hash) + .bind("Cascade Child") + .bind("https://cascade-deactivate-child.example.com/metadata.json") + .bind("https://cascade-deactivate-child.example.com") + .bind("[]") + .bind("atproto") + .bind("confidential") + .bind("did:plc:testadmin") + .bind(&now) + .bind(&now) + .bind(&parent_id) + .bind("did:plc:testadmin") + .execute(&app.state.db) + .await + .expect("failed to insert child"); + + // Deactivate the parent + let deactivate_resp = app + .router + .clone() + .oneshot(admin_put( + &format!("/admin/api-clients/{parent_id}"), + app.admin_cookie(), + &json!({"is_active": false}), + )) + .await + .unwrap(); + assert_eq!(deactivate_resp.status(), StatusCode::NO_CONTENT); + + // Verify parent is deactivated + let parent_get = app + .router + .clone() + .oneshot(admin_get( + &format!("/admin/api-clients/{parent_id}"), + app.admin_cookie(), + )) + .await + .unwrap(); + assert_eq!(parent_get.status(), StatusCode::OK); + let parent_json = json_body(parent_get).await; + assert_eq!( + parent_json["is_active"], false, + "parent should be deactivated" + ); + + // Verify child is also deactivated + let child_get = app + .router + .clone() + .oneshot(admin_get( + &format!("/admin/api-clients/{child_id}"), + app.admin_cookie(), + )) + .await + .unwrap(); + assert_eq!(child_get.status(), StatusCode::OK); + let child_json = json_body(child_get).await; + assert_eq!( + child_json["is_active"], false, + "child should be deactivated by cascade" + ); +} + +#[tokio::test] +#[serial] +#[ignore] +async fn test_delete_parent_cascades_to_children() { + let app = TestApp::new().await; + + // Create parent via admin API + let parent_body = json!({ + "name": "Delete Parent", + "client_id_url": "https://cascade-delete-parent.example.com/oauth-client-metadata.json", + "client_uri": "https://cascade-delete-parent.example.com", + "redirect_uris": ["https://happyview.example.com/auth/callback"], + "scopes": "atproto" + }); + let create_resp = app + .router + .clone() + .oneshot(admin_post( + "/admin/api-clients", + app.admin_cookie(), + &parent_body, + )) + .await + .unwrap(); + assert_eq!(create_resp.status(), StatusCode::CREATED); + let created = json_body(create_resp).await; + let parent_id = created["id"].as_str().unwrap().to_string(); + + // Insert child directly in DB + let child_id = uuid::Uuid::new_v4().to_string(); + let child_key = format!("hvc_{}", hex::encode([0xDDu8; 16])); + let child_hash = hex::encode(sha2::Sha256::digest("hvs_fake_dd".as_bytes())); + let now = now_rfc3339(); + + let sql = adapt_sql( + "INSERT INTO api_clients (id, client_key, client_secret_hash, name, client_id_url, client_uri, redirect_uris, scopes, client_type, is_active, created_by, created_at, updated_at, parent_client_id, owner_did) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?)", + app.state.db_backend, + ); + sqlx::query(&sql) + .bind(&child_id) + .bind(&child_key) + .bind(&child_hash) + .bind("Delete Child") + .bind("https://cascade-delete-child.example.com/metadata.json") + .bind("https://cascade-delete-child.example.com") + .bind("[]") + .bind("atproto") + .bind("confidential") + .bind("did:plc:testadmin") + .bind(&now) + .bind(&now) + .bind(&parent_id) + .bind("did:plc:testadmin") + .execute(&app.state.db) + .await + .expect("failed to insert child"); + + // Delete the parent + let delete_resp = app + .router + .clone() + .oneshot(admin_delete( + &format!("/admin/api-clients/{parent_id}"), + app.admin_cookie(), + )) + .await + .unwrap(); + assert_eq!(delete_resp.status(), StatusCode::NO_CONTENT); + + // Verify parent is gone + let parent_get = app + .router + .clone() + .oneshot(admin_get( + &format!("/admin/api-clients/{parent_id}"), + app.admin_cookie(), + )) + .await + .unwrap(); + assert_eq!(parent_get.status(), StatusCode::NOT_FOUND); + + // Verify child is also gone (ON DELETE CASCADE) + let child_get = app + .router + .clone() + .oneshot(admin_get( + &format!("/admin/api-clients/{child_id}"), + app.admin_cookie(), + )) + .await + .unwrap(); + assert_eq!(child_get.status(), StatusCode::NOT_FOUND); +} + +// --------------------------------------------------------------------------- +// List filtering: parent_id query param and response fields +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +#[ignore] +async fn test_list_api_clients_filter_by_parent() { + let app = TestApp::new().await; + + // Create two parents via admin API + let parent1_body = json!({ + "name": "Filter Parent 1", + "client_id_url": "https://filter-parent-1.example.com/oauth-client-metadata.json", + "client_uri": "https://filter-parent-1.example.com", + "redirect_uris": ["https://happyview.example.com/auth/callback"], + "scopes": "atproto" + }); + let parent2_body = json!({ + "name": "Filter Parent 2", + "client_id_url": "https://filter-parent-2.example.com/oauth-client-metadata.json", + "client_uri": "https://filter-parent-2.example.com", + "redirect_uris": ["https://happyview.example.com/auth/callback"], + "scopes": "atproto" + }); + + let p1_resp = app + .router + .clone() + .oneshot(admin_post( + "/admin/api-clients", + app.admin_cookie(), + &parent1_body, + )) + .await + .unwrap(); + assert_eq!(p1_resp.status(), StatusCode::CREATED); + let p1 = json_body(p1_resp).await; + let parent1_id = p1["id"].as_str().unwrap().to_string(); + + let p2_resp = app + .router + .clone() + .oneshot(admin_post( + "/admin/api-clients", + app.admin_cookie(), + &parent2_body, + )) + .await + .unwrap(); + assert_eq!(p2_resp.status(), StatusCode::CREATED); + let p2 = json_body(p2_resp).await; + let parent2_id = p2["id"].as_str().unwrap().to_string(); + + // Insert a child under parent 1 + let child1_id = uuid::Uuid::new_v4().to_string(); + let child1_key = format!("hvc_{}", hex::encode([0xE1u8; 16])); + let child1_hash = hex::encode(sha2::Sha256::digest("hvs_fake_e1".as_bytes())); + let now = now_rfc3339(); + + let sql = adapt_sql( + "INSERT INTO api_clients (id, client_key, client_secret_hash, name, client_id_url, client_uri, redirect_uris, scopes, client_type, is_active, created_by, created_at, updated_at, parent_client_id, owner_did) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?)", + app.state.db_backend, + ); + sqlx::query(&sql) + .bind(&child1_id) + .bind(&child1_key) + .bind(&child1_hash) + .bind("Child of Parent 1") + .bind("https://filter-child-1.example.com/metadata.json") + .bind("https://filter-child-1.example.com") + .bind("[]") + .bind("atproto") + .bind("confidential") + .bind("did:plc:testadmin") + .bind(&now) + .bind(&now) + .bind(&parent1_id) + .bind("did:plc:testadmin") + .execute(&app.state.db) + .await + .expect("failed to insert child1"); + + // Insert a child under parent 2 + let child2_id = uuid::Uuid::new_v4().to_string(); + let child2_key = format!("hvc_{}", hex::encode([0xE2u8; 16])); + let child2_hash = hex::encode(sha2::Sha256::digest("hvs_fake_e2".as_bytes())); + + let sql2 = adapt_sql( + "INSERT INTO api_clients (id, client_key, client_secret_hash, name, client_id_url, client_uri, redirect_uris, scopes, client_type, is_active, created_by, created_at, updated_at, parent_client_id, owner_did) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?)", + app.state.db_backend, + ); + sqlx::query(&sql2) + .bind(&child2_id) + .bind(&child2_key) + .bind(&child2_hash) + .bind("Child of Parent 2") + .bind("https://filter-child-2.example.com/metadata.json") + .bind("https://filter-child-2.example.com") + .bind("[]") + .bind("atproto") + .bind("confidential") + .bind("did:plc:testadmin") + .bind(&now) + .bind(&now) + .bind(&parent2_id) + .bind("did:plc:testadmin") + .execute(&app.state.db) + .await + .expect("failed to insert child2"); + + // Filter by parent1_id — should return only child1 + let resp = app + .router + .clone() + .oneshot(admin_get( + &format!("/admin/api-clients?parent_id={parent1_id}"), + app.admin_cookie(), + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let arr = json_body(resp).await; + let items = arr.as_array().expect("response should be an array"); + assert_eq!( + items.len(), + 1, + "should return exactly one child for parent 1" + ); + assert_eq!(items[0]["id"], child1_id); + assert_eq!(items[0]["name"], "Child of Parent 1"); + + // Filter by parent2_id — should return only child2 + let resp2 = app + .router + .clone() + .oneshot(admin_get( + &format!("/admin/api-clients?parent_id={parent2_id}"), + app.admin_cookie(), + )) + .await + .unwrap(); + assert_eq!(resp2.status(), StatusCode::OK); + let arr2 = json_body(resp2).await; + let items2 = arr2.as_array().expect("response should be an array"); + assert_eq!( + items2.len(), + 1, + "should return exactly one child for parent 2" + ); + assert_eq!(items2[0]["id"], child2_id); + assert_eq!(items2[0]["name"], "Child of Parent 2"); +} + +#[tokio::test] +#[serial] +#[ignore] +async fn test_list_api_clients_includes_parent_and_owner_fields() { + let app = TestApp::new().await; + + // Create a top-level parent via admin API + let parent_body = json!({ + "name": "Fields Parent", + "client_id_url": "https://fields-parent.example.com/oauth-client-metadata.json", + "client_uri": "https://fields-parent.example.com", + "redirect_uris": ["https://happyview.example.com/auth/callback"], + "scopes": "atproto" + }); + let create_resp = app + .router + .clone() + .oneshot(admin_post( + "/admin/api-clients", + app.admin_cookie(), + &parent_body, + )) + .await + .unwrap(); + assert_eq!(create_resp.status(), StatusCode::CREATED); + let created = json_body(create_resp).await; + let parent_id = created["id"].as_str().unwrap().to_string(); + + // Insert a child with owner_did set + let child_id = uuid::Uuid::new_v4().to_string(); + let child_key = format!("hvc_{}", hex::encode([0xFFu8; 16])); + let child_hash = hex::encode(sha2::Sha256::digest("hvs_fake_ff".as_bytes())); + let now = now_rfc3339(); + let owner_did = "did:plc:childowner"; + + let sql = adapt_sql( + "INSERT INTO api_clients (id, client_key, client_secret_hash, name, client_id_url, client_uri, redirect_uris, scopes, client_type, is_active, created_by, created_at, updated_at, parent_client_id, owner_did) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?)", + app.state.db_backend, + ); + sqlx::query(&sql) + .bind(&child_id) + .bind(&child_key) + .bind(&child_hash) + .bind("Fields Child") + .bind("https://fields-child.example.com/metadata.json") + .bind("https://fields-child.example.com") + .bind("[]") + .bind("atproto") + .bind("confidential") + .bind("did:plc:testadmin") + .bind(&now) + .bind(&now) + .bind(&parent_id) + .bind(owner_did) + .execute(&app.state.db) + .await + .expect("failed to insert child"); + + // List all clients + let resp = app + .router + .clone() + .oneshot(admin_get("/admin/api-clients", app.admin_cookie())) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let arr = json_body(resp).await; + let items = arr.as_array().expect("response should be an array"); + assert_eq!(items.len(), 2, "should have parent + child"); + + // Find the parent in the list + let parent_item = items + .iter() + .find(|c| c["id"].as_str() == Some(&parent_id)) + .expect("parent should be in list"); + // Top-level client has null parent_client_id and null owner_did + assert!( + parent_item["parent_client_id"].is_null(), + "top-level client should have null parent_client_id" + ); + assert!( + parent_item["owner_did"].is_null(), + "admin-created client should have null owner_did" + ); + + // Find the child in the list + let child_item = items + .iter() + .find(|c| c["id"].as_str() == Some(&child_id)) + .expect("child should be in list"); + assert_eq!( + child_item["parent_client_id"].as_str(), + Some(parent_id.as_str()), + "child should reference its parent" + ); + assert_eq!( + child_item["owner_did"].as_str(), + Some(owner_did), + "child should have owner_did set" + ); +} diff --git a/web/src/app/dashboard/settings/api-clients/page.tsx b/web/src/app/dashboard/settings/api-clients/page.tsx --- a/web/src/app/dashboard/settings/api-clients/page.tsx +++ b/web/src/app/dashboard/settings/api-clients/page.tsx @@ -158,6 +158,8 @@ Client ID URL Scopes Status Created + Parent Client + Owner @@ -165,7 +167,7 @@ {clients.length === 0 && ( No API clients yet. @@ -203,6 +205,14 @@ {new Date(client.created_at).toLocaleString()} + + + {client.parent_client_id + ? clients.find((c) => c.id === client.parent_client_id)?.name ?? client.parent_client_id + : "—"} + + + {client.owner_did ?? "—"}
diff --git a/web/src/types/api-clients.ts b/web/src/types/api-clients.ts --- a/web/src/types/api-clients.ts +++ b/web/src/types/api-clients.ts @@ -14,6 +14,8 @@ is_active: boolean created_by: string created_at: string updated_at: string + parent_client_id: string | null + owner_did: string | null } export interface CreateApiClientResponse {