20 KiB
ThothII Authentication Design
Objective
Add first-party authentication and authorization to ThothII without bundling an identity manager. The same product build must support:
- a standalone PC or Mac with local users stored in protected operator files; and
- 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. Nothothii-admin,thothctl, or separate authentication binary is introduced. - Production authentication modes are
localandoidc. noneandmockremain test/development-only. Existingupstreamsupport 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
groupsclaim 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 opaqueHttpOnlysession 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.
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:
authentication:
configDirectory: /absolute/operator-controlled/thothii-auth
The directory is mounted read-only into core at /run/thothii-auth. It contains:
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:
/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:
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:
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:
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
idis an immutable UUIDv4 generated bythtand is the local OIDC-likesubject.usernameis 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.displayNameremains 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:
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:
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.
lastSeenAtis 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
authRevisiondiffers, the user is absent/disabled, or the configured role set changes. - All sessions are invalid on the first request after
authConfigRevisiondiffers 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:
- a valid session;
- a constant-time match of the CSRF token;
- a matching
Originwhen the browser supplies one; Sec-Fetch-Site: same-originwhen 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:
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:
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:
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:
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/validatefor static completeness;POST /workspaces/:id/testfor 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:
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:
node:24.16.0-bookworm@sha256:40ad9f3064e67d6860b4bc3fe1880b2953934fd6320ada990e45fe0efa6badd7
Required added dependencies are:
- backend
openid-client6.8.5; - backend
@fastify/cookie11.1.2; - backend
@fastify/rate-limit11.2.0; - Go
golang.org/x/crypto0.55.0. - Go
golang.org/x/term0.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=noneto configuredmode: localinauth.yamland require an initial administrator before startup is considered valid. - Server profiles move from trusted-proxy
AUTH_MODE=upstreamtomode: oidcinauth.yamlafter Authentik setup.upstreamremains available during the migration window but is marked deprecated. auth.yamlis the sole production source of truth forlocalandoidc.AUTH_MODEremains accepted only fornone,mock, and deprecatedupstreamwhen 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
userandadmin, 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
groupsclaim 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 authcommands 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.