docs: apply simplified technical english across docs and docstrings.

This commit is contained in:
imSp4rky
2026-08-19 16:31:06 -06:00
parent 4177cb2267
commit 66971230c3
135 changed files with 2651 additions and 2570 deletions
+43 -43
View File
@@ -1,16 +1,16 @@
# REST API Authentication
The unshackle REST API protects every endpoint (with one exception) behind a single API key. Clients present that key in a request header, and the server checks it against the keys defined in your configuration. This page explains how that check works, how to configure keys, how clients send them, and the extra controls that build on top of the key: per-key service allowlists, per-key CDM device access, and session IP binding.
The unshackle REST API protects every endpoint (with one exception) behind a single API key. Clients present that API key in a request header, and the server examines it against the API keys in your configuration. This page explains how that check works, how to configure API keys, and how clients send them. It also explains the extra controls on top of the API key: per-key service allowlists, per-key CDM device access, and remote session IP binding.
The API is started with the [`unshackle serve`](../../guide/cli-reference.md) command. Authentication is on by default: if you start the server without an API key configured, it refuses to launch.
You start the API with the [`unshackle serve`](../../guide/cli-reference.md) command. Authentication is on by default: if you start the server without an API key configured, it refuses to launch.
!!! note "Which server am I running?"
`unshackle serve` can run in two shapes, and each uses a slightly different piece of code to check your key:
`unshackle serve` can operate in two shapes, and each uses a slightly different piece of code to examine your API key:
- The **integrated server** (the default) exposes the REST API *and* the pywidevine / pyplayready CDM HTTP endpoints.
- The **API-only server** (`--api-only`, also implied by `--remote-only`) exposes only the REST API.
The header, the config, and the behaviour you see as a client are the same in both. The differences are internal and are called out in the developer notes below.
The header, the config, and the behaviour you see as a client are the same in both. The differences are internal, and the developer notes below give them.
## The short version
@@ -36,7 +36,7 @@ serve:
api_secret: "a-long-random-secret-string"
```
This value becomes the one and only key that the server accepts. Requests that present it in the `X-Secret-Key` header are allowed; everything else is rejected.
This value becomes the one and only API key that the server accepts. The server allows requests that present it in the `X-Secret-Key` header. The server rejects everything else.
!!! warning "The server will not start without a key"
If you start `unshackle serve` with no `api_secret` configured, and you have *not* passed `--no-key`, the command fails immediately with:
@@ -45,11 +45,11 @@ This value becomes the one and only key that the server accepts. Requests that p
API secret key is not configured. Please add 'api_secret' to the 'serve' section in your config.
```
Choose a long, unpredictable value. The key is the only thing standing between the internet and your CDM and download endpoints.
Choose a long, unpredictable value. The API key is the only thing standing between the internet and your CDM and download endpoints.
### Multiple keys with `users`
For finer control you can define named users, each with their own key. A user entry can also grant CDM device access and restrict which services that key may touch:
For finer control you can define named users, each with their own API key. A user entry can also grant CDM device access and restrict which services that API key may touch:
```yaml title="unshackle.yaml"
serve:
@@ -65,16 +65,16 @@ serve:
devices: ["my_widevine_device"]
```
The key of each entry under `users` (for example `a-secret-key-for-alice`) *is* the secret that client sends in `X-Secret-Key`. The `username` is a human-readable label used in logs and in job/history metadata.
The config key of each entry under `users` (for example `a-secret-key-for-alice`) *is* the secret that client sends in `X-Secret-Key`. The `username` is a human-readable label used in logs and in job/history metadata.
!!! info "How `api_secret` and `users` combine (integrated server)"
On the integrated server, the configured `api_secret` is automatically added as an extra accepted key alongside everyone in `users`. It is granted access to all loaded Widevine and PlayReady devices and is labelled `api_user`. So `api_secret` behaves as an admin-level key in addition to any named users you define.
On the integrated server, the server automatically adds the configured `api_secret` as an extra accepted API key alongside everyone in `users`. It gives that API key access to all loaded Widevine and PlayReady devices, and labels it `api_user`. So `api_secret` behaves as an admin-level API key in addition to any named users you define.
On the API-only server, only `api_secret` is accepted: the single-key model. (See the developer notes for the exact difference.)
On the API-only server, the server accepts only `api_secret`: the single-key model. (See the developer notes for the exact difference.)
### CDM device access per key
### CDM device access per API key
Under the integrated server, a key can only run DRM licensing through a CDM device that its user entry lists:
Under the integrated server, an API key can do DRM licensing only through a CDM device that its user entry lists:
| Config key | Grants access to |
|---|---|
@@ -82,21 +82,21 @@ Under the integrated server, a key can only run DRM licensing through a CDM devi
| `playready_devices` | PlayReady `.prd` devices, by name |
!!! warning "PlayReady access is opt-in per user"
If a user entry omits `playready_devices`, that key is given **no** PlayReady access, and the server logs a warning at startup. You must list PlayReady devices explicitly for any key that needs them. Widevine and PlayReady device files themselves are auto-loaded from your WVDs and PRDs directories.
If a user entry omits `playready_devices`, the server gives that API key **no** PlayReady access, and logs a warning at startup. You must name the PlayReady devices explicitly for any API key that needs them. Widevine and PlayReady device files themselves are auto-loaded from your WVDs and PRDs directories.
!!! note "Device lists *are* the server-side-decryption switch"
There is no separate tier, capability flag, or permission toggle for whether the server will hand back keys (`KID:KEY`) for a session download. The presence of devices on the calling key decides it. With **empty** `devices` and `playready_devices`, the server can only proxy CDM challenges, so the client must run its own CDM. Once those lists are **populated**, the client may request `mode:server_cdm` and receive keys back. Don't go looking for a tier setting; configuring devices is what enables server-side decryption.
There is no separate tier, capability flag, or permission toggle for whether the server will hand back content keys (`KID:KEY`) for a remote session download. The presence of devices on the calling API key decides it. With **empty** `devices` and `playready_devices`, the server can only proxy CDM challenges, so the client must operate its own CDM. Once those lists are **populated**, the client may request `mode:server_cdm` and receive content keys back. Do not look for a tier setting. The devices you configure are what enables server-side decryption.
### Disabling authentication
The `--no-key` flag turns authentication off entirely. Every request is allowed, `X-Secret-Key` is ignored, and no key needs to be configured.
The `--no-key` flag turns authentication off entirely. The server allows every request, ignores `X-Secret-Key`, and needs no API key in your configuration.
```bash
unshackle serve --no-key
```
!!! danger "Only use `--no-key` on a trusted, isolated network"
With `--no-key` there is nothing stopping any client that can reach the port from listing your services, starting downloads, or using your CDM devices. Never expose a `--no-key` server to an untrusted network.
With `--no-key`, any client that can reach the port can show your services, start downloads, and use your CDM devices. Never put a `--no-key` server on an untrusted network.
## How clients present credentials
@@ -106,18 +106,18 @@ Clients authenticate by sending the secret in a single HTTP request header on ev
X-Secret-Key: <your-secret>
```
The value must exactly match one of the configured keys (`api_secret`, or one of the keys under `users`). There is no login step, no session cookie, and no token exchange for API access. The same static key is sent on each request.
The value must match one of the configured API keys exactly (`api_secret`, or one of the keys under `users`). There is no login step, no HTTP session cookie, and no token exchange for API access. The client sends the same static API key on each request.
!!! info "One route also accepts the key as a query parameter"
The job event stream, `GET /api/download/jobs/{job_id}/events`, also accepts the key in a `secret_key` query parameter. This is for browsers: the `EventSource` API cannot set request headers. The exception applies to that one route only, and it works in both server modes.
The job event stream, `GET /api/download/jobs/{job_id}/events`, also accepts the API key in a `secret_key` query parameter. This is for browsers: the `EventSource` API cannot set request headers. The exception applies to that one route only, and it works in both server modes.
```javascript
new EventSource(`http://127.0.0.1:8786/api/download/jobs/${jobId}/events?secret_key=${key}`);
```
The header always wins. If the request has an `X-Secret-Key` header, the server checks that header and ignores the query parameter, so a wrong header is rejected even when the query parameter holds a valid key.
The header always wins. If the request has an `X-Secret-Key` header, the server examines that header and ignores the query parameter. The server rejects a wrong header even when the query parameter holds a valid API key.
A key in a URL is easier to leak than a key in a header, because proxies and browser history can record it. Use the header wherever your client can set one.
An API key in a URL is easier to leak than an API key in a header, because proxies and browser history can record it. Use the header wherever your client can set one.
=== "curl"
@@ -150,11 +150,11 @@ The value must exactly match one of the configured keys (`api_secret`, or one of
```
!!! tip "The reference client sets it for you"
unshackle's own [remote download](../../guide/downloading.md) client (`RemoteService`) sends `X-Secret-Key` automatically, reading the key from your `remote_services` config. It also sends a `User-Agent` of `unshackle/<version>`. You only deal with the header directly when writing your own client.
unshackle's own [remote download](../../guide/downloading.md) client (`RemoteService`) sends `X-Secret-Key` automatically, reading the API key from your `remote_services` config. It also sends a `User-Agent` of `unshackle/<version>`. You only deal with the header directly when writing your own client.
### The health check is always open
One endpoint is exempt from authentication: `GET /api/health`. It never requires a key, so you can use it for liveness and version probes without embedding a secret in your monitoring:
One endpoint is exempt from authentication: `GET /api/health`. It never requires an API key, so you can use it for liveness and version probes without embedding a secret in your monitoring:
```bash title="No key needed"
curl http://127.0.0.1:8786/api/health
@@ -172,11 +172,11 @@ curl http://127.0.0.1:8786/api/health
}
```
Every other endpoint (including the Swagger UI at `/api/docs/` on the API-only server) requires a valid key.
Every other endpoint (including the Swagger UI at `/api/docs/` on the API-only server) requires a valid API key.
## Authentication failures
When a request is rejected for authentication reasons, the response has an HTTP `401 Unauthorized` status and a small JSON body.
When the server rejects a request for authentication reasons, the response has an HTTP `401 Unauthorized` status and a small JSON body.
=== "Missing header"
@@ -191,7 +191,7 @@ When a request is rejected for authentication reasons, the response has an HTTP
=== "Wrong key"
Sending a key that is not in your config:
Sending an API key that is not in your config:
```json
{
@@ -203,17 +203,17 @@ When a request is rejected for authentication reasons, the response has an HTTP
!!! warning "Auth errors use a different JSON shape than other errors (developer note)"
The rest of the API reports errors with a structured body containing `error_code`, `message`, `timestamp`, and other fields (see [Errors](errors.md)). The authentication middleware is different: it returns `{"status": 401, "message": "..."}`, where `status` is an **integer**, and there is no `error_code` or `timestamp`.
A robust client should treat any `401` as an authentication problem and be prepared to parse *both* shapes rather than assuming the standard error body is always present.
In your client, treat any `401` as an authentication problem and parse *both* shapes, because the standard error body is not always present.
## Building on the key: access controls
## Access controls on top of the API key
Once a request is authenticated, several additional controls decide what that specific key is allowed to do.
After the server authenticates a request, several additional controls decide what that specific API key can do.
### Per-key service allowlists
You can restrict which services a key may use. Two layers combine:
You can restrict which services an API key may use. Two layers combine:
- **Global allowlist**: `serve.services`, applied to every key.
- **Global allowlist**: `serve.services`, applied to every API key.
- **Per-key allowlist**: `services` inside that key's `users` entry.
The effective allowlist for a request is:
@@ -235,11 +235,11 @@ serve:
services: ["EXAMPLE1"] # this key ends up allowed only EXAMPLE1
```
Endpoints that name a service (listing services, searching, listing titles/tracks, downloading, profiles, history, and remote sessions) all filter against this allowlist. A request for a service outside the key's allowlist is rejected as an invalid service. Service tags are matched after normalization, so casing and aliases resolve consistently.
Endpoints that name a service (listing services, searching, listing titles/tracks, downloading, profiles, history, and remote sessions) all filter against this allowlist. The server rejects a request for a service outside the API key's allowlist as an invalid service. The server matches service tags after normalisation, so casing and aliases give the same result.
### CDM overrides and per-job credentials
Two download-time capabilities are gated behind server config and, unless enabled, are refused with `403 Forbidden` even for an authenticated key:
The server config gates two download-time capabilities. Unless you enable them, the server refuses them with `403 Forbidden`, even for an authenticated API key:
| Config key | Default | Controls |
|---|---|---|
@@ -254,26 +254,26 @@ serve:
```
!!! tip "When to enable these gates"
The two gates are fully independent and can be combined. The intended posture is that a locked-down or public-facing deployment leaves **both** unset (they default off and reject with `403`) so client requests can never steer the server's CDM or feed it their own logins. Enabling them only makes sense for a trusted single-client deployment, where the caller and the operator are effectively the same party.
The two gates are fully independent, and you can combine them. For a locked-down or public-facing deployment, leave **both** unset. They then default off and reject with `403`, so client requests can never steer the server's CDM or feed it their own logins. Enable them only for a trusted single-client deployment, where the caller and the operator are effectively the same party.
!!! note "Client-supplied credentials get isolated token caches"
When `allow_job_credentials` is enabled and a download job supplies its own `credential` / `credentials`, each distinct credential is given its **own** isolated token cache on the server. Client-supplied logins are never shared or merged with the server's configured-credential caches, so they cannot cross-contaminate or read the server's own token state, and they don't clobber each other.
When you enable `allow_job_credentials` and a download job supplies its own `credential` / `credentials`, the server gives each distinct credential its **own** isolated token cache. The server never shares or merges client-supplied logins with its configured-credential caches. They cannot cross-contaminate or read the server's own token state, and they do not clobber each other.
See [Downloads](endpoints.md) for how these fields are used on the download endpoint.
See [Downloads](endpoints.md) for how the download endpoint uses these fields.
### Session IP binding (remote sessions)
The remote-download session endpoints (`/api/session/*`) add an extra check on top of the key. When a session is created, the server records the client's IP address. Any later request for that session must come from the **same** IP, or it is rejected with `403 Forbidden`. This means a session is bound both to the key that created it and to the address it was created from.
The remote-download session endpoints (`/api/session/*`) add an extra check on top of the API key. When the server creates a remote session, it records the client's IP address. Any later request for that remote session must come from the **same** IP, or the server rejects it with `403 Forbidden`. Thus the server binds a remote session both to the API key that created it and to the address it came from.
## Transport security (HTTPS)
The API server speaks plain HTTP. Because your key is sent on every request, you should not expose it over an unencrypted connection on an untrusted network. To serve over HTTPS, run a reverse proxy in front of unshackle. The `serve` command can launch Caddy for you:
The API server speaks plain HTTP. Because the client sends your API key on every request, do not use it over an unencrypted connection on an untrusted network. To serve over HTTPS, operate a reverse proxy in front of unshackle. The `serve` command can launch Caddy for you:
```bash
unshackle serve --caddy
```
With `--caddy`, unshackle also starts `caddy run` using the `Caddyfile` located next to your unshackle config, letting you terminate TLS and reverse-proxy to the API. Configuring Caddy itself (certificates, domains, upstream) is done in that `Caddyfile`.
With `--caddy`, unshackle also starts `caddy run` using the `Caddyfile` located next to your unshackle config, letting Caddy end the TLS connection and reverse-proxy to the API. Configuring Caddy itself (certificates, domains, upstream) is done in that `Caddyfile`.
## Quick reference
@@ -291,7 +291,7 @@ With `--caddy`, unshackle also starts `caddy run` using the `Caddyfile` located
---
## Developer notes: how the check is implemented
## Developer notes: how the server does the check
!!! note "This section is for people writing or debugging the server, not for API consumers."
@@ -299,13 +299,13 @@ With `--caddy`, unshackle also starts `caddy run` using the `Caddyfile` located
The header and config are identical between modes, but the code that enforces them differs:
- **API-only server** (`--api-only`, or `--remote-only` which forces API-only). An `api_key_authentication` middleware runs on every request. It lets `GET /api/health` through unconditionally, then reads `X-Secret-Key`. An empty header returns the *"Secret Key is Empty."* body; a header that is not a configured user returns *"Secret Key is Invalid."*. In this mode the accepted-keys map holds your `api_secret` (labelled `api_user`, with no devices) plus every key under `serve.users`, so a per-user key authenticates here exactly as it does on the integrated server, and that user's `services` allowlist applies as usual. With `--no-key`, this middleware is simply not installed and the users map is empty.
- **API-only server** (`--api-only`, or `--remote-only` which forces API-only). An `api_key_authentication` middleware runs on every request. It lets `GET /api/health` through unconditionally, then reads `X-Secret-Key`. An empty header returns the *"Secret Key is Empty."* body. A header that is not a configured user returns *"Secret Key is Invalid."*. In this mode the accepted-keys map holds your `api_secret` (labelled `api_user`, with no devices) plus every key under `serve.users`. A per-user key therefore authenticates here exactly as it does on the integrated server, and that user's `services` allowlist applies as usual. With `--no-key`, this middleware is not installed and the users map is empty.
- **Integrated server** (default). A `serve_authentication` middleware wraps pywidevine's authentication (and pyplayready's for `/playready*` paths), and the REST routes live in the same application, guarded by the same check. Here the accepted-keys map is your full `serve` config: `api_secret` (auto-added with access to all loaded devices) plus every entry under `users`, each with its declared `devices` / `playready_devices`.
Because the API-only middleware guards *all* non-health paths, the Swagger UI at `/api/docs/` is also behind `X-Secret-Key` there. In `--remote-only` mode the Swagger UI is not mounted at all.
Both middlewares read the caller's key through the shared `request_secret_key()` helper in `unshackle/core/api/handlers.py`, which returns the `X-Secret-Key` header and falls back to the `secret_key` query parameter only when the matched route is the job event stream. The integrated server needs one extra branch for this: pywidevine's `authentication` is header-only, so `serve_authentication` compares the query-parameter key itself when the header is absent, and delegates to pywidevine in every other case. That is why a present-but-wrong header is rejected in both modes.
Both middlewares read the caller's API key through the shared `request_secret_key()` helper in `unshackle/core/api/handlers.py`. That helper returns the `X-Secret-Key` header, and falls back to the `secret_key` query parameter only when the matched route is the job event stream. The integrated server needs one extra branch for this. pywidevine's `authentication` is header-only. So `serve_authentication` compares the API key from the query parameter itself when the header is absent, and delegates to pywidevine in every other case. That is why both modes reject a present-but-wrong header.
### CORS and the `Authorization` header
@@ -318,8 +318,8 @@ Access-Control-Allow-Headers: Content-Type, X-Secret-Key, Authorization
Access-Control-Max-Age: 3600
```
The REST API authenticates with `X-Secret-Key`; `Authorization` is advertised because the co-hosted pywidevine / pyplayready CDM endpoints use it. `PUT` appears in the allowed methods list but no REST route uses it; only `GET`, `POST`, and `DELETE` are wired up.
The REST API authenticates with `X-Secret-Key`. The middleware advertises `Authorization` because the co-hosted pywidevine / pyplayready CDM endpoints use it. `PUT` appears in the allowed-methods list, but no REST route uses it. Only `GET`, `POST`, and `DELETE` have routes.
### Where device access is read
The map of accepted keys lives at `request.app["config"]["users"]`. For DRM operations, a key's permitted Widevine devices come from its `devices` list and PlayReady devices from `playready_devices`; the first configured device is used when a request does not (or may not) override it. A server-wide `config.cdm[<service>]` mapping, if present, takes precedence over the key's device list when resolving which device to use.
The map of accepted API keys lives at `request.app["config"]["users"]`. For DRM operations, an API key's permitted Widevine devices come from its `devices` list, and PlayReady devices from `playready_devices`. The server uses the first configured device when a request does not (or may not) override it. A server-wide `config.cdm[<service>]` mapping, if present, takes precedence over the API key's device list when the server chooses which device to use.