# 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/.json /data/auth/oidc/.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 `/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 /api/v3/core/groups/?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.