Files
relata/docs/AUTHENTICATION.md
T

206 lines
9.2 KiB
Markdown

# Authentication and access
Relata has three ways in, for three different callers, and they do not overlap.
| Caller | Mechanism | Gets |
|---|---|---|
| A person, in a browser | OAuth 2.0 Authorization Code + PKCE against Blackwall | A server-side session and a cookie |
| A person, when the identity provider is broken | Local username and password, **disabled by default** | The same session and cookie |
| A program, against `/api/v1` | A bearer token the person created in the interface | A scoped, non-interactive principal |
Everything a signed-out visitor can reach is a static description of the product. No stored
item, entity, relationship, count or workspace name is readable without authenticating.
---
## 1. The public site
`/`, `/how-it-works` and `/api` are served to anyone. They are hand-written pages describing
what Relata does and what it deliberately does not do; they read nothing from the database.
`/` is the only dual-purpose route: signed in, it is the dashboard; signed out, it is the
landing page. Every other application route requires a session and redirects to `/login`.
---
## 2. Signing in through Blackwall
### The endpoint to register
```
https://<your-relata-host>/auth/oauth/callback
```
The path is fixed. Register the same path under whatever hosts you serve, including
`http://127.0.0.1:8080/auth/oauth/callback` for local development.
### The flow
1. `GET /auth/oauth/start` mints a 32-byte `state`, a PKCE verifier and (for OIDC) a `nonce`,
stores them in a short-lived, `HttpOnly`, `SameSite=Lax` cookie scoped to `/auth/oauth`,
and redirects to the provider's authorization URL with `code_challenge_method=S256`.
2. The person authenticates with Blackwall.
3. `GET /auth/oauth/callback` compares `state` against the cookie **in constant time**, clears
the cookie so the value is single-use, exchanges the code and PKCE verifier for tokens over
a timeout-bounded HTTP client, and establishes the identity.
4. Identity comes from the ID token when the provider is OIDC — signature verified against
cached JWKS, with `iss`, `aud`, `exp`, `iat` and `nonce` all checked — and otherwise from
the userinfo endpoint.
5. The user record is found or created, a **new** session ID is issued, and the browser lands
on the dashboard.
### Identity is the subject, never the email
Users are keyed on `(oauth_provider, oauth_subject)` with a unique index. The `sub` claim is
the only immutable identifier a provider offers; email addresses get reassigned, and keying on
one means whoever receives a recycled address inherits the previous holder's documents.
The email is stored for display and for the optional domain allowlist. It is never the key.
### What a new person gets
A **private workspace of their own**, created on first sign-in, in which they are `admin`.
Nobody sees anyone else's documents, entities or relationships — the workspace boundary was
enforced in storage from the first commit, and this reuses it rather than adding a new one.
The consequence is worth stating plainly: **relationship discovery never spans users.** Two
people who upload documents about the same contract get no link between them, because as far
as Relata is concerned they are separate corpora. That is the cost of the isolation.
Provisioning is idempotent. Two concurrent first sign-ins race on the unique subject index;
the loser re-reads the winner's record rather than creating a second workspace.
### Local login
Disabled unless `RELATA_LOCAL_LOGIN_ENABLED=true`. When disabled, the password form is not
rendered and `POST /login` returns 404 — the handler is not registered at all, rather than
registered and refusing, so there is no code path to reach.
Enable it to recover from a broken provider configuration. `relata createadmin` still works
regardless, so the account can be prepared in advance.
A user provisioned through OAuth has no password hash and can never sign in locally, whatever
is submitted.
---
## 3. API bearer tokens
### Creating one
Settings → API tokens. Choose a name, the scopes, and an expiry. **The token is shown once**
and never again; only a SHA-256 hash is stored, so a database read cannot be replayed as a
credential and neither can a backup.
```
rlt_v1_8Kx2mQ… (43 characters of base64url from 32 random bytes)
```
The first 12 characters are stored in clear as a display prefix, so the interface can list
`rlt_v1_8Kx2…` and you can tell which token is which without holding the secret.
### Using one
```bash
curl -H "Authorization: Bearer $RELATA_TOKEN" \
https://relata.example/api/v1/search?q=delivery+schedule
```
### Scopes
| Scope | Permits |
|---|---|
| `ingest` | Create items, upload files, reprocess, delete |
| `query` | Search, answer generation, and reading stored items, entities and relationships |
| `review` | Confirming, rejecting and retyping relationships; confirming entity aliases |
A token carries the scopes it was given and **never more than the role of the user who created
it**. A viewer's token cannot ingest even if `ingest` was requested, because the check is the
intersection of the two. Demoting a person immediately narrows every token they hold.
Scopes are checked per route. A request outside its scopes gets `403` with
`insufficient_scope` and the scope it needed, not a generic denial.
### What a token cannot do
- **Reach the web interface.** Bearer tokens authenticate `/api/v1` only. A token in a browser
request to `/items` is ignored.
- **Manage tokens.** Token creation, listing and revocation require a session. A leaked token
cannot mint more tokens or extend its own life.
- **Outlive its expiry or revocation.** Both are checked on every request against the stored
record; there is no cached grant.
- **Change the account.** Nothing under `/auth` accepts a bearer token.
### CSRF
Bearer requests do not carry CSRF tokens and are not asked for them: CSRF exists to stop a
third-party page using *ambient* credentials, and an `Authorization` header is not ambient — a
browser never attaches one on its own.
The exemption is bound to how the request authenticated, not to a header being present. A
request holding a session cookie still needs its CSRF token even if it also sends
`Authorization`, so an attacker cannot disable the check by adding a header.
### Rate limiting and audit
Each token has its own rate limit bucket. Creation, use-after-expiry, revocation and every
authentication failure are audit events. The token value never appears in a log, an audit
record, an error message or a metric label.
`last_used_at` is recorded at most once a minute per token, so a busy token does not turn every
read into a write.
---
## 4. Configuration
See `.env.example` for the full list with comments. The minimum for Blackwall:
```bash
RELATA_OAUTH_ENABLED=true
RELATA_OAUTH_PROVIDER_NAME=Blackwall
RELATA_OAUTH_CLIENT_ID=...
RELATA_OAUTH_CLIENT_SECRET=...
# Either discovery:
RELATA_OAUTH_ISSUER=https://www.blackwall.cx
# ...or the endpoints directly:
RELATA_OAUTH_AUTHORIZE_URL=https://www.blackwall.cx/oauth/authorize
RELATA_OAUTH_TOKEN_URL=https://www.blackwall.cx/oauth/token
RELATA_OAUTH_USERINFO_URL=https://www.blackwall.cx/oauth/userinfo
RELATA_OAUTH_SCOPES=openid profile email
RELATA_OAUTH_ALLOWED_EMAIL_DOMAINS= # optional; empty means any
```
Startup validates this as a set: OAuth enabled with neither an issuer nor a full endpoint
triple is refused, as is a non-HTTPS endpoint outside development, as is disabling both OAuth
and local login — which would leave no way in at all.
---
## 5. Known limitations
- **No refresh tokens.** Relata's session is its own; the provider's access token is used once
to establish identity and then discarded rather than stored. A revoked Blackwall account
keeps its Relata session until that session expires (idle 24h, absolute 7 days by default).
Revoke the Relata session too, from Settings, if that matters.
- **No group or role mapping yet.** Every OAuth user is admin of their own workspace. There is
nothing to map a provider group onto, because there is no shared workspace to have roles in.
If shared workspaces are added later, claim-based role mapping belongs with them.
- **No account linking.** A local account and an OAuth account with the same email are two
separate users with two separate workspaces. Merging them is deliberate manual work, not
something to infer from a matching address.
- **Token scopes are coarse.** Three scopes, checked per route. There is no per-item or
per-collection restriction.
- **The provider is trusted for identity.** If Blackwall asserts a subject, Relata believes it.
That is what delegating authentication means.
- **A newly created token is rendered only in the creation response.** It is never placed in a
redirect URL, browser history, referrer header or access-log query string. Refreshing or
navigating away cannot recover it because only its hash is stored.
- **Key rotation that reuses a key ID costs one failed request.** Relata caches the provider's
signing keys for an hour. If a key is replaced while keeping its `kid`, the first token
signed by the new key fails signature verification, triggers a single throttled refetch, and
succeeds on the retry within the same request. A provider that rotates properly — new key,
new `kid` — never hits this path.