docs(auth): add authentication design and implementation plan

This commit is contained in:
2026-08-16 17:07:32 +02:00
parent 351361f72f
commit 9196639cb3
2 changed files with 1951 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,495 @@
# ThothII Authentication Design
## Objective
Add first-party authentication and authorization to ThothII without bundling an identity
manager. The same product build must support:
1. a standalone PC or Mac with local users stored in protected operator files; and
2. a server deployment using a standards-based OIDC provider, with Authentik as the first
certified provider for the PSD integration.
The design replaces implicit trust in a local browser with an explicit authenticated session,
keeps the host operator interface exclusively under `tht`, and extends the existing workspace
validation and installation diagnostics with authentication checks.
## Confirmed decisions
- The only host CLI is `tht`. No `thothii-admin`, `thothctl`, or separate authentication binary
is introduced.
- Production authentication modes are `local` and `oidc`.
- `none` and `mock` remain test/development-only. Existing `upstream` support remains as a
deprecated migration adapter until direct OIDC deployment has passed PSD acceptance.
- Local passwords use Argon2id. Passwords are never stored or logged in plaintext.
- A local user can select **Remember me**. The resulting session survives browser and ThothII
restarts until its idle or absolute expiry.
- OIDC login is provider-neutral. Authentik is the provider certified by automated and PSD
acceptance tests in the first release.
- OIDC must return a `groups` claim in the ID token as a JSON array of strings. Missing,
malformed, indirect, or overage-style group claims fail authentication.
- OIDC groups are mapped to ThothII roles in installation configuration. Unmapped groups are
ignored silently, without errors or warnings.
- Every configured group must be proven to exist in the identity manager. OIDC itself cannot
enumerate groups, so this proof uses a provider-specific group-catalog adapter. The first
adapter is `authentik`.
- Authentication configuration is installation-global. Workspace validation still reports its
status and workspace connection tests include its live connectivity checks.
- Browser tokens are never stored in `localStorage`, `sessionStorage`, or JavaScript-readable
cookies. The browser receives only an opaque `HttpOnly` session cookie.
## Non-goals for the first release
- Bundling Authentik, Keycloak, LDAP, or another identity manager with ThothII.
- Implementing LDAP authentication directly in ThothII.
- Persisting OIDC access, ID, or refresh tokens after login.
- Supporting arbitrary provider management APIs through a user-programmable HTTP adapter.
- Building a web UI for local-user administration. Local users are managed through `tht auth`.
- Adding fine-grained workspace-specific ACLs. Authorization remains installation-wide.
- Guaranteeing group-catalog validation for an arbitrary OIDC provider without a supported
catalog adapter.
## Runtime architecture
The Fastify backend owns authentication, browser sessions, and authorization. The React frontend
only renders login state and sends same-origin requests. The Python harness receives a trusted,
already-authorized principal from the backend exactly as it does today.
```text
browser
-> local login -----------------------> Fastify auth service
-> OIDC Authorization Code + PKCE ----> Fastify auth service ----> OIDC provider
|
+--> durable opaque sessions
+--> group -> role -> permission mapping
+--> authorized ThothII routes
tht auth / tht doctor
-> host configuration and local-user files
-> container-local AuthDiagnoser
-> OIDC discovery/JWKS
-> Authentik group API
```
## Installation files and storage
Every installation descriptor gains one non-secret authentication location:
```yaml
authentication:
configDirectory: /absolute/operator-controlled/thothii-auth
```
The directory is mounted read-only into `core` at `/run/thothii-auth`. It contains:
```text
auth.yaml non-secret mode, URL, lifetime, OIDC, and group-role configuration
users.yaml local user IDs, Argon2id hashes, roles, enabled state, and auth revision
```
The host directory must be private to the operator. On POSIX its mode is `0700`, and both files
are regular, single-link, non-symlink files with mode `0600`. Windows uses an equivalent
owner-only ACL. `tht` performs bounded reads, known-field YAML decoding, and atomic replacement.
Durable browser session state lives under `/data/auth`, backed by a new Compose volume named
`auth-state` for local installations and by the existing server `/data` bind for server
installations:
```text
/data/auth/sessions/<sha256-of-cookie-token>.json
/data/auth/oidc/<sha256-of-state>.json
```
Raw cookie tokens, derived CSRF tokens, and raw OIDC tokens are never written to disk. Session
files contain only the principal, authorization snapshot, timestamps, and revision numbers.
## Authentication configuration contract
`auth.yaml` is strict, versioned YAML. The local form is:
```yaml
version: 1
mode: local
publicUrl: http://127.0.0.1:8080
session:
regularTtlSeconds: 43200
regularIdleSeconds: 7200
rememberTtlSeconds: 2592000
rememberIdleSeconds: 604800
oidcTtlSeconds: 28800
local:
usersFile: users.yaml
```
The OIDC/Authentik form is:
```yaml
version: 1
mode: oidc
publicUrl: https://thothii.example.org
session:
regularTtlSeconds: 43200
regularIdleSeconds: 7200
rememberTtlSeconds: 2592000
rememberIdleSeconds: 604800
oidcTtlSeconds: 28800
oidc:
issuer: https://authentik.example.org/application/o/thothii/
clientId: thothii
clientSecretRef: THT_OIDC_CLIENT_SECRET
scopes:
- openid
- profile
- email
groupsClaim: groups
groupCatalog:
driver: authentik
baseUrl: https://authentik.example.org
apiTokenRef: THT_AUTHENTIK_API_TOKEN
authorization:
groupRoles:
TOT Users:
- user
TOT Admin:
- admin
```
`publicUrl` has no path, query, fragment, or embedded credential. OIDC mode requires HTTPS except
for an explicit loopback test configuration. The callback is always
`<publicUrl>/api/auth/oidc/callback`; arbitrary redirect URIs and arbitrary post-login redirects
are not accepted.
The referenced secret names are read from the existing mounted ThothII secret bundle. Secret
values never appear in `auth.yaml`, command arguments, JSON diagnostics, logs, or frontend data.
## Local-user registry
`users.yaml` has this exact shape:
```yaml
version: 1
users:
- id: 6ba7b810-9dad-4ed1-80b4-00c04fd430c8
username: admin
displayName: Local administrator
passwordHash: $argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4
roles:
- admin
enabled: true
authRevision: 1
```
- `id` is an immutable UUIDv4 generated by `tht` and is the local OIDC-like `subject`.
- `username` is 3–64 ASCII characters, starts with an alphanumeric character, and then uses only
alphanumerics plus `.`, `_`, `@`, or `-`. Lookup is ASCII case-insensitive while display spelling
is retained. `displayName` remains unrestricted Unicode text after control-character rejection.
- Passwords are accepted from a TTY or an explicit `--password-file`, never a command argument.
- Password length is 12 to 1024 UTF-8 bytes. The upper bound prevents accidental memory abuse;
no composition rule is imposed.
- New hashes use Argon2id v19 with 64 MiB memory, three passes, parallelism one, a 16-byte random
salt, and a 32-byte output. Parameters remain embedded in the PHC string for future rehashing.
- Every password, role, enabled-state, or logout-all change increments `authRevision`.
- At least one enabled administrator must remain. Commands that would disable or demote the last
administrator fail before writing.
The Go CLI hashes with `golang.org/x/crypto/argon2`; Node verifies with the Node 24 native Argon2
API. Shared fixed test vectors prove cross-language compatibility.
## Roles and permissions
External groups and local records map to two stable roles:
```text
user
session.use
admin (inherits user)
session.read_all
session.manage_all
settings.manage
workspace.manage
workspace.secrets.manage
pi.manage
auth.diagnostics.read
```
Session ownership remains enforced through `issuer + subject`. `session.use` never permits access
to another user's session. `isAdmin` remains temporarily available as the derived value
`roles.includes("admin")` for compatibility, but route authorization is based on permissions.
Route policy is:
| Surface | Required authority |
|---|---|
| `/health`, local login, OIDC start/callback | Public protocol endpoint |
| `/me`, effective settings/models, workspace read, own sessions and SSE | Authenticated `user` |
| `scope=all`, operations on another user's session | `session.read_all` / `session.manage_all` |
| Settings writes | `settings.manage` |
| Workspace registry pull/bootstrap/validation/test | `workspace.manage` |
| Workspace secret writes/deletes | `workspace.secrets.manage` |
| Pi management routes | `pi.manage` |
| Authentication diagnostics | `auth.diagnostics.read` or host operator through `tht` |
An OIDC user whose token contains no mapped group is authenticated but receives no role. Protected
application routes return `403` with `code: "auth_not_authorized"`.
## Durable browser sessions
Successful authentication creates a cryptographically random 256-bit cookie token. The server
stores only its SHA-256 digest as the session filename. The record contains:
```ts
interface AuthSessionRecord {
version: 1;
issuer: string;
subject: string;
displayName?: string;
method: "local" | "oidc" | "upstream";
roles: readonly ("user" | "admin")[];
permissions: readonly Permission[];
userAuthRevision?: number;
authConfigRevision: string;
remembered: boolean;
createdAt: string;
lastSeenAt: string;
idleExpiresAt: string;
absoluteExpiresAt: string;
}
```
Session behavior is:
- Ordinary local login: session cookie with no `Max-Age`, 2-hour idle expiry, 12-hour absolute
expiry. Closing the browser removes the browser cookie.
- Local **Remember me**: persistent cookie, 7-day idle expiry, 30-day absolute expiry. It survives
browser and ThothII restarts.
- OIDC: maximum eight-hour ThothII session, never longer than the validated ID-token expiry. The
identity provider may independently remember its SSO login.
- `lastSeenAt` is written at most once every five minutes to bound filesystem writes.
- Expired records are pruned at startup and every fifteen minutes.
- Local sessions are invalid as soon as `authRevision` differs, the user is absent/disabled, or
the configured role set changes.
- All sessions are invalid on the first request after `authConfigRevision` differs following an
authentication configuration reload.
- Logout deletes the server record and expires the cookie.
- Backup restore intentionally invalidates remembered sessions; session records are not restored
as active credentials.
The cookie is named `thothii_session`, is `HttpOnly`, `SameSite=Lax`, `Path=/`, has no `Domain`,
and uses `Secure` whenever `publicUrl` is HTTPS. Local loopback HTTP deliberately omits `Secure` so
the browser can use the cookie.
## CSRF and browser boundary
Cookie authentication makes CSRF protection mandatory for every state-changing application
route. The backend derives a separate 256-bit CSRF token from the raw session cookie with
domain-separated HKDF-SHA-256; the derivation is one-way and nothing additional is persisted.
`/me` returns the derived token to the same-origin frontend, which keeps it in memory and sends it
as `X-ThothII-CSRF` for `POST`, `PUT`, `PATCH`, and `DELETE` requests.
The backend requires all of the following for a cookie-authenticated state change:
1. a valid session;
2. a constant-time match of the CSRF token;
3. a matching `Origin` when the browser supplies one;
4. `Sec-Fetch-Site: same-origin` when Fetch Metadata is present.
The local login POST requires a same-origin `Origin`; OIDC login uses OIDC `state`, `nonce`, and
PKCE. Frontend and API are supported as one browser origin. Development uses a Vite `/api` proxy
rather than credentialed cross-origin requests.
## Local authentication flow
The backend exposes:
```text
GET /auth/config public mode and login capabilities, no secrets
POST /auth/local/login username, password, remember
POST /auth/logout authenticated + CSRF
GET /me principal, roles, permissions, CSRF token
```
Login errors use one generic `invalid_credentials` response for unknown, disabled, and wrong-
password users. A dummy Argon2 verification runs for unknown users. Rate limits apply per source
address and normalized username, and concurrent Argon2 operations are bounded.
## Generic OIDC flow
OIDC uses `openid-client` 6.8.5, Authorization Code Flow, PKCE S256, `state`, and `nonce`. The
backend performs provider discovery, validates issuer, signature, audience, expiry, nonce, and
authorization response, then reads the ID-token claims.
`groupsClaim` is mandatory and resolves to a direct array of non-empty strings. The first release
does not follow distributed claims, provider overage links, or Graph-style group expansion. Such a
token fails with `oidc_groups_claim_invalid` rather than silently granting ordinary access.
Mapped roles are the union of all exactly matched group names. Additional token groups are ignored
without logging or warnings. No OIDC token is sent to the frontend or persisted after principal
and session creation.
## Authentik group-catalog adapter
The certified Authentik adapter calls its documented API with a dedicated service-account bearer
token. For each configured mapping key it requests:
```text
GET <baseUrl>/api/v3/core/groups/?name=<encoded-name>&include_users=false&page_size=2
```
The adapter requires exactly one exact-name result. Zero results produce
`oidc_mapped_group_missing`; more than one produces `oidc_mapped_group_ambiguous`. It never
enumerates or compares unrelated groups, so extra Authentik groups produce neither warning nor
error.
Outbound requests use HTTPS, fixed operator-controlled origins, five-second timeouts, no redirect
following, bounded JSON bodies, and redacted errors. The API token has only group-view permission
and is distinct from the OIDC client secret.
Authentik is connected to LDAP in PSD, but ThothII validates the groups visible in Authentik. A
group present only in LDAP and not represented in Authentik is correctly treated as missing.
## Authentication diagnostics
One backend `AuthDiagnoser` returns a redacted machine contract:
```ts
interface AuthDiagnostics {
ready: boolean;
mode: "local" | "oidc" | "upstream" | "none" | "mock";
checks: readonly AuthDiagnostic[];
}
interface AuthDiagnostic {
level: "error" | "info";
code: AuthDiagnosticCode;
message: string;
field?: string;
}
```
`AuthDiagnosticCode` is the closed union:
```text
auth_ready
auth_config_incomplete
auth_config_invalid
auth_session_store_invalid
local_user_registry_invalid
local_admin_missing
oidc_secret_missing
oidc_discovery_unreachable
oidc_issuer_mismatch
oidc_jwks_unreachable
oidc_group_catalog_unreachable
oidc_group_catalog_unauthorized
oidc_mapped_group_missing
oidc_mapped_group_ambiguous
oidc_groups_claim_invalid
oidc_device_flow_unavailable
```
Static checks cover configuration, file safety, secret references, local administrators, role
names, group mappings, URL policy, and session storage. Live OIDC checks cover discovery, issuer,
JWKS, Authentik API authentication, and every configured group.
The same implementation is consumed by:
- application startup for fatal static configuration errors;
- `POST /workspaces/validate` for static completeness;
- `POST /workspaces/:id/test` for live connectivity and group existence;
- `tht auth check [--json]`;
- aggregate `tht doctor [--json]`.
Workspace test results retain existing DWH/Qdrant/embedding diagnostics and add an
`authentication` section. Overall `activatable` is false when authentication is not ready.
An Authentik-backed `tht auth check --interactive` uses OIDC Device Authorization when the
provider advertises it. It prints the verification URI and user code, waits for completion, and
validates a real ID token including `groups`. Absence of a device endpoint is reported explicitly;
ordinary browser login remains usable for generic OIDC providers.
## Host CLI contract
The host-facing command surface added to `tht` is:
```text
tht auth configure --mode local [--public-url URL] [--admin-user USER \
--admin-display-name NAME --password-file FILE]
tht auth configure --mode oidc --public-url URL --issuer URL --client-id ID \
--authentik-base-url URL --user-group GROUP --admin-group GROUP
tht auth status [--json]
tht auth check [--interactive] [--json]
tht auth user list [--json]
tht auth user add USERNAME --role user|admin [--display-name NAME] [--password-file FILE]
tht auth user set-password USERNAME [--password-file FILE]
tht auth user enable USERNAME
tht auth user disable USERNAME
tht auth user grant USERNAME --role user|admin
tht auth user revoke USERNAME --role user|admin
tht auth user logout-all USERNAME --yes
```
Interactive password prompts disable terminal echo and require confirmation. JSON stdout remains
pristine; progress and prompts go to stderr. User commands refuse OIDC mode. Configuration writes
never overwrite an existing valid configuration without explicit confirmation.
`tht setup` creates the protected authentication directory and invokes local or OIDC
configuration before starting the stack. `tht doctor` adds an `authentication` check and redacts
the OIDC client secret, Authentik API token, passwords, hashes, cookie values, and CSRF values.
## Node and dependency baseline
The implementation aligns the runtime and CI on Node `24.16.0`. Docker uses the multi-platform
official image digest:
```text
node:24.16.0-bookworm@sha256:40ad9f3064e67d6860b4bc3fe1880b2953934fd6320ada990e45fe0efa6badd7
```
Required added dependencies are:
- backend `openid-client` 6.8.5;
- backend `@fastify/cookie` 11.1.2;
- backend `@fastify/rate-limit` 11.2.0;
- Go `golang.org/x/crypto` 0.55.0.
- Go `golang.org/x/term` 0.45.0.
No Node Argon2 native addon or Authentik SDK is added. Node upgrade acceptance requires backend
and frontend typechecks, builds, unit tests, Playwright, Docker smoke, Pi runtime smoke, and the L2
live-session smoke. A regression blocks the upgrade and the authentication release; it is not
waived merely to gain native Argon2.
## Deployment and migration
- Local profiles move from implicit `AUTH_MODE=none` to configured `mode: local` in `auth.yaml` and require an
initial administrator before startup is considered valid.
- Server profiles move from trusted-proxy `AUTH_MODE=upstream` to `mode: oidc` in `auth.yaml` after Authentik
setup. `upstream` remains available during the migration window but is marked deprecated.
- `auth.yaml` is the sole production source of truth for `local` and `oidc`. `AUTH_MODE` remains
accepted only for `none`, `mock`, and deprecated `upstream` when no auth configuration exists.
- Existing session/artifact storage is not migrated or re-owned by authentication work.
- Reverse proxies must preserve the configured public origin and callback path. ThothII trusts
forwarded scheme/host only under the existing explicit server proxy boundary.
- PSD acceptance uses Authentik connected to corporate LDAP, two real groups mapped to `user` and
`admin`, one ordinary test user, and one administrative test user.
## Documentation and acceptance
The release must document:
- standalone local setup, initial admin, Remember me, timeout behavior, password recovery, and
session invalidation;
- the mandatory direct `groups` claim contract and failure behavior;
- group-to-role and role-to-permission mapping, including exact case sensitivity;
- Authentik provider, scope/property mapping, service account, API token, callback, group mapping,
token rotation, and PSD LDAP relationship;
- why unmapped identity-manager groups are ignored without warnings;
- generic OIDC support versus provider-specific group-catalog certification;
- all `tht auth` commands and JSON contracts;
- authentication checks inside workspace validation and `tht doctor`;
- reverse-proxy and same-origin cookie requirements.
Release acceptance requires cross-language Argon2 vectors, local remembered-session restart tests,
local invalidation tests, authorization matrix tests, CSRF tests, OIDC protocol tests with a local
fake OP, Authentik API contract tests, a real Authentik integration test, workspace diagnostic
tests, frontend login tests, Compose smoke tests, and the existing full regression suites.