Files
ThothII/docs/superpowers/specs/2026-08-16-thothii-authentication-design.md
T

20 KiB
Raw Blame History

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.

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
  • 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:

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.
  • 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:

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/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:

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-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.