diff --git a/packages/docs/docs/getting-started/authentication.md b/packages/docs/docs/getting-started/authentication.md index 195b7b4..edc2416 100644 --- a/packages/docs/docs/getting-started/authentication.md +++ b/packages/docs/docs/getting-started/authentication.md @@ -318,4 +318,4 @@ This deletes the stored session and the associated DPoP key. - [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 +- [Third-Party API Clients](../reference/oauth/api-clients.md) — let third-party apps manage their own API clients programmatically diff --git a/packages/docs/docs/reference/admin/api-clients.md b/packages/docs/docs/reference/admin/api-clients.md index 50091bf..afb1d47 100644 --- a/packages/docs/docs/reference/admin/api-clients.md +++ b/packages/docs/docs/reference/admin/api-clients.md @@ -6,8 +6,8 @@ A single API client represents your application, not individual users. Create on 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. +:::tip Third-Party API Clients +Third-party apps can also create, list, and delete their own API clients programmatically via the [XRPC API](../oauth/api-clients.md), without needing admin access. ::: ```sh diff --git a/packages/docs/docs/reference/oauth/api-clients.md b/packages/docs/docs/reference/oauth/api-clients.md index 62ebcc2..e77416b 100644 --- a/packages/docs/docs/reference/oauth/api-clients.md +++ b/packages/docs/docs/reference/oauth/api-clients.md @@ -1,82 +1,181 @@ -# OAuth API: Self-Service API Clients +# Third-Party 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. +Third-party applications can manage their own API clients via the `dev.happyview.*` XRPC endpoints. A third-party client is always tied to exactly one parent — the admin-created top-level API client whose DPoP session made the request. Only one level of nesting is allowed; third-party clients cannot create further children. Each third-party 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. +All endpoints use [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 how API clients work. -## Create a child client +:::note +Only top-level API clients can call these endpoints. Third-party (child) clients receive `401 Unauthorized` or `403 Forbidden`. +::: -``` -POST /oauth/api-clients -``` +## Authentication -Requires three headers: +All requests require three headers: | Header | Value | | --------------- | ------------------------------------------------------------ | | `Authorization` | `DPoP ` | -| `DPoP` | A DPoP proof JWT (method: `POST`, htu: the full request URL) | +| `DPoP` | A DPoP proof JWT (method matches the HTTP method, `htu` is scheme + host + path, no query string) | | `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. +The access token must belong to a valid DPoP session for the parent client. + +## List clients + +``` +GET /xrpc/dev.happyview.listApiClients +``` + +Returns all API clients owned by the authenticated user. + +**Response**: `200 OK` + +```json +{ + "clients": [ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "clientKey": "hvc_a1b2c3d4e5f6...", + "name": "My App", + "clientIdUrl": "https://myapp.example.com/client-metadata.json", + "clientUri": "https://myapp.example.com", + "redirectUris": ["https://myapp.example.com/callback"], + "clientType": "confidential", + "scopes": "atproto", + "allowedOrigins": [], + "isActive": true, + "createdAt": "2026-04-28T12:00:00Z" + } + ] +} +``` + +## Get a client + +``` +GET /xrpc/dev.happyview.getApiClient?id= +``` + +| Parameter | Type | Required | Description | +| --------- | ------ | -------- | ----------------- | +| `id` | string | yes | The client's UUID | + +**Response**: `200 OK` + +```json +{ + "client": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "clientKey": "hvc_a1b2c3d4e5f6...", + "name": "My App", + "clientIdUrl": "https://myapp.example.com/client-metadata.json", + "clientUri": "https://myapp.example.com", + "redirectUris": ["https://myapp.example.com/callback"], + "clientType": "confidential", + "scopes": "atproto", + "allowedOrigins": [], + "isActive": true, + "createdAt": "2026-04-28T12:00:00Z" + } +} +``` + +Returns `404` if the client doesn't exist or isn't owned by the authenticated user. + +## Create a client + +``` +POST /xrpc/dev.happyview.createApiClient +``` ```sh -curl -X POST https://happyview.example.com/oauth/api-clients \ +curl -X POST https://happyview.example.com/xrpc/dev.happyview.createApiClient \ -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" + "name": "My Third-Party App", + "clientIdUrl": "https://myapp.example.com/client-metadata.json", + "clientUri": "https://myapp.example.com", + "redirectUris": ["https://myapp.example.com/callback"], + "clientType": "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 | +| Field | Type | Required | Description | +| ----------------- | -------- | -------- | -------------------------------------------------------------- | +| `name` | string | yes | Display name for the client | +| `clientIdUrl` | string | yes | Unique OAuth client ID URL | +| `clientUri` | string | yes | The client's homepage URL | +| `redirectUris` | string[] | yes | OAuth redirect URIs | +| `scopes` | string | no | Space-separated OAuth scopes (default `"atproto"`) | +| `clientType` | string | no | `"confidential"` or `"public"` (default `"confidential"`) | +| `allowedOrigins` | string[] | no | CORS allowed origins (relevant for public clients) | **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" + "client": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "clientKey": "hvc_a1b2c3d4e5f6...", + "name": "My Third-Party App", + "clientIdUrl": "https://myapp.example.com/client-metadata.json", + "clientUri": "https://myapp.example.com", + "redirectUris": ["https://myapp.example.com/callback"], + "clientType": "confidential", + "scopes": "atproto", + "allowedOrigins": [], + "isActive": true, + "createdAt": "2026-04-28T12:00:00Z" + }, + "clientSecret": "hvs_f6e5d4c3b2a1..." } ``` -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. +The `clientSecret` is only present for confidential clients and is only returned in this response. It is stored as a SHA-256 hash and cannot be retrieved again. + +## Delete a client + +``` +POST /xrpc/dev.happyview.deleteApiClient +``` + +```sh +curl -X POST https://happyview.example.com/xrpc/dev.happyview.deleteApiClient \ + -H "X-Client-Key: hvc_parent_key" \ + -H "Authorization: DPoP eyJhbG..." \ + -H "DPoP: eyJhbG..." \ + -H "Content-Type: application/json" \ + -d '{ "id": "550e8400-e29b-41d4-a716-446655440000" }' +``` + +| Field | Type | Required | Description | +| ----- | ------ | -------- | ----------------- | +| `id` | string | yes | The client's UUID | + +**Response**: `200 OK` with `{}` + +Returns `404` if the client doesn't exist or isn't owned by the authenticated user. Deleting a client cascades to all its children. ## 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` | +| 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 | `requires DPoP authentication` | `Authorization` header is missing or doesn't use the DPoP scheme | +| 401 | `requires an API client key` | `X-Client-Key` header is absent | +| 401 | `token_expired` | The access token has expired | +| 401 | `Invalid client` | `X-Client-Key` doesn't match a known client | +| 401 | `child clients cannot manage API clients` | The calling client is itself a third-party (child) client | +| 403 | `Child clients cannot create API clients` | The calling client is itself a third-party (child) client | +| 404 | `API client not found` | No client with that ID owned by the authenticated user | +| 409 | `client_id_url already registered` | Another client already uses that `clientIdUrl` | ## 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. +Each third-party 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/sidebars.ts b/packages/docs/sidebars.ts index 624ebde..c64e5d1 100644 --- a/packages/docs/sidebars.ts +++ b/packages/docs/sidebars.ts @@ -386,7 +386,7 @@ const sidebars: SidebarsConfig = { { type: "doc", id: "reference/oauth/api-clients", - label: "Self-Service API Clients", + label: "Third-Party API Clients", }, ], },