diff --git a/docs/superpowers/plans/2026-08-16-thothii-authentication.md b/docs/superpowers/plans/2026-08-16-thothii-authentication.md new file mode 100644 index 00000000..2196b381 --- /dev/null +++ b/docs/superpowers/plans/2026-08-16-thothii-authentication.md @@ -0,0 +1,1456 @@ +# ThothII Authentication Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add secure local authentication with remembered sessions and generic OIDC authentication with certified Authentik group validation, all administered through `tht` and included in workspace diagnostics. + +**Architecture:** Fastify owns authentication, opaque file-backed browser sessions, CSRF protection, OIDC, and permission enforcement. The Go `tht` CLI owns protected local-user/configuration writes and delegates live provider checks to a container-local backend diagnostic command. React becomes a same-origin authenticated shell and never handles passwords beyond login submission or stores bearer/session tokens. + +**Tech Stack:** Node 24.16.0, Fastify 5, TypeScript, React 18, `openid-client` 6.8.5, `@fastify/cookie` 11.1.2, `@fastify/rate-limit` 11.2.0, Go 1.26, `golang.org/x/crypto/argon2` 0.55.0, `golang.org/x/term` 0.45.0, Vitest, Playwright, Docker Compose. + +**Spec:** `docs/superpowers/specs/2026-08-16-thothii-authentication-design.md` + +## Global Constraints + +- The only host CLI is `tht`; do not add another executable or revive `thothctl`. +- Production modes are `local` and `oidc`; `none` and `mock` are development/test only, while `upstream` remains a deprecated migration adapter. +- Authentik is the first-release certified OIDC provider; the browser OIDC protocol layer must not contain Authentik-specific login logic. +- The ID token must contain direct claim `groups: string[]`; absent, malformed, indirect, or overage claims fail closed. +- Only configured groups are checked and mapped. Unmapped provider groups are ignored silently and never produce a warning. +- Every configured group must be proven to exist through the configured group-catalog adapter; first release provides `authentik`. +- Local passwords use Argon2id v19 with `m=65536,t=3,p=1`, 16-byte random salt, and 32-byte output. +- A remembered local session has a seven-day idle timeout and thirty-day absolute timeout and survives browser/backend restarts. +- No raw session cookie, password, CSRF token, OIDC token, client secret, Authentik API token, or password hash may enter logs or diagnostic output. +- Browser authentication uses an opaque `HttpOnly`, `SameSite=Lax`, path-scoped cookie; `Secure` is conditional on an HTTPS public URL so loopback HTTP remains functional. +- Every cookie-authenticated state-changing route requires a CSRF token and same-origin browser checks. +- Authentication configuration is installation-global, but static checks appear in workspace validation and live checks appear in workspace connection tests. +- Workspace document content remains in its workspace language; application chrome and new authentication UI strings are English. +- JSON CLI stdout is pristine. Prompts, progress, and human guidance go to stderr. +- Node is exactly `24.16.0`; Docker uses `sha256:40ad9f3064e67d6860b4bc3fe1880b2953934fd6320ada990e45fe0efa6badd7`. +- Existing session artifacts, workflow persistence, workspace ownership, and Pi RPC behavior must not change. +- Preserve unrelated user changes and the existing untracked `.playwright-cli/` and `.thothctl/` paths. + +## Target file structure + +New backend files are split by responsibility: + +```text +backend/src/auth/ + types.ts roles, permissions, principal and diagnostic contracts + config.ts strict auth.yaml parser and canonical revision + authorization.ts permission expansion and route guards + local-registry.ts bounded/safe users.yaml reader and revision lookup + password.ts PHC parsing and Node Argon2id verification + session-store.ts opaque durable session and OIDC-state files + csrf.ts CSRF token and browser-origin enforcement + oidc-client.ts provider-neutral OIDC protocol adapter + group-catalog.ts catalog interface + authentik-group-catalog.ts Authentik read-only group existence adapter + diagnostics.ts shared static/live authentication diagnostics + routes.ts local/OIDC login, callback, logout, config and /me + diagnostic-command.ts machine/device-flow checker invoked by tht +``` + +New frontend files are: + +```text +frontend/src/auth/ + AuthGate.tsx + LoginPage.tsx + authState.ts +frontend/src/api/auth.ts +``` + +New Go files are: + +```text +tools/tht/internal/authconfig/ + types.go + password.go + store.go + users.go + commands.go +``` + +Focused tests use matching `*.test.ts`, `*.test.tsx`, and `*_test.go` files. Avoid adding auth +logic to `backend/src/app.ts`, `frontend/src/shell/AppShell.tsx`, or +`tools/tht/cmd/tht/main.go` beyond dependency wiring and command dispatch. + +--- + +### Task 1: Align the Runtime on Node 24 and Install Authentication Dependencies + +**Files:** +- Modify: `docker/core.Dockerfile` +- Modify: `docker/frontend.Dockerfile` +- Modify: `docker/smoke/core-smoke.sh` +- Modify: `backend/package.json` +- Modify: `backend/package-lock.json` +- Modify: `frontend/package-lock.json` only if `npm install` normalizes lock metadata under Node 24 +- Test: `backend/test/health.test.ts` + +**Interfaces:** +- Produces: Node `24.16.0` in backend build, frontend build, Pi build, and final core runtime. +- Produces: backend imports for `openid-client`, `@fastify/cookie`, and `@fastify/rate-limit`. +- Preserves: the Pi package engine floor `>=22.19.0` and all existing image/runtime contracts. + +- [ ] **Step 1: Pin the failing runtime expectation** + +Add an assertion to `backend/test/health.test.ts` that the build/runtime contract exposes major +version 24, and update `docker/smoke/core-smoke.sh` to reject Node 22/23. The accepted shell case is: + +```sh +case "$node_version" in + v24.16.*) ;; + *) echo "Node 24.16 required, found $node_version" >&2; exit 1 ;; +esac +``` + +- [ ] **Step 2: Run the focused checks and observe the old runtime failure** + +Run: + +```bash +cd backend && npx vitest run test/health.test.ts +docker build --target backend-build -f docker/core.Dockerfile . +``` + +Expected: the source/runtime assertion or smoke inspection still reports Node 22. + +- [ ] **Step 3: Update all official Node stages and package metadata** + +Replace every Node stage in `docker/core.Dockerfile` and `docker/frontend.Dockerfile` with: + +```dockerfile +FROM node:24.16.0-bookworm@sha256:40ad9f3064e67d6860b4bc3fe1880b2953934fd6320ada990e45fe0efa6badd7 +``` + +Update comments and smoke messages from Node 22 to Node 24.16. Then install exact backend versions: + +```bash +cd backend +npm install --save-exact openid-client@6.8.5 @fastify/cookie@11.1.2 @fastify/rate-limit@11.2.0 +npm install --save-dev --save-exact @types/node@24.13.3 +``` + +- [ ] **Step 4: Run the complete Node compatibility gate** + +Run: + +```bash +cd backend && npx tsc --noEmit -p . && npx vitest run && npm run build +cd ../frontend && npx tsc -b && npx vitest run && npm run build +cd .. && docker build -f docker/core.Dockerfile -t thothii-core:auth-node24 . +docker run --rm --entrypoint /bin/sh thothii-core:auth-node24 -c 'node --version && /app/docker/smoke/core-smoke.sh' +``` + +Expected: Node reports `v24.16.0`; backend/frontend gates and the core smoke pass. Any Pi or native +dependency regression blocks this task and is fixed before proceeding. + +- [ ] **Step 5: Commit the runtime baseline** + +```bash +git add docker/core.Dockerfile docker/frontend.Dockerfile docker/smoke/core-smoke.sh \ + backend/package.json backend/package-lock.json backend/test/health.test.ts frontend/package-lock.json +git commit -m "build: align authentication runtime on Node 24" +``` + +### Task 2: Define and Validate Authentication Configuration + +**Files:** +- Create: `backend/src/auth/types.ts` +- Create: `backend/src/auth/config.ts` +- Create: `backend/test/auth-config.test.ts` +- Modify: `backend/src/config.ts` +- Modify: `backend/test/config.test.ts` + +**Interfaces:** +- Produces: + +```ts +export type AuthMode = "local" | "oidc" | "upstream" | "none" | "mock"; +export type Role = "user" | "admin"; +export type Permission = + | "session.use" | "session.read_all" | "session.manage_all" + | "settings.manage" | "workspace.manage" | "workspace.secrets.manage" + | "pi.manage" | "auth.diagnostics.read"; + +export interface LoadedAuthConfig { + value: AuthenticationConfig; + revision: string; + sourcePath: string; +} + +export function loadAuthenticationConfig(path: string): LoadedAuthConfig; +export interface AuthenticationConfigProvider { current(): LoadedAuthConfig } +export function createAuthenticationConfigProvider(path: string): AuthenticationConfigProvider; +export function rolesToPermissions(roles: readonly Role[]): readonly Permission[]; +``` + +- Consumes: existing `yaml` and `zod` backend dependencies. + +- [ ] **Step 1: Write strict parser and permission tests first** + +Cover at least these fixtures in `backend/test/auth-config.test.ts`: + +```ts +test.each([ + ["unknown root key", "unexpected"], + ["relative users file", "../users.yaml"], + ["OIDC without groups claim", undefined], + ["OIDC without admin mapping", {}], + ["HTTP non-loopback public URL", "http://thoth.example"], +])("rejects %s", (_label, mutation) => { + expect(() => loadAuthenticationConfig(writeFixture(mutation))).toThrow(); +}); + +test("canonical group map order produces one stable revision", () => { + expect(loadAuthenticationConfig(first).revision).toBe(loadAuthenticationConfig(reordered).revision); +}); +``` + +Also assert `admin` expands to every admin permission plus `session.use`, duplicate roles collapse, +and unknown roles fail parsing. + +- [ ] **Step 2: Run tests and verify missing-module failures** + +Run: `cd backend && npx vitest run test/auth-config.test.ts test/config.test.ts` + +Expected: failure because the new parser/types and AppConfig fields do not exist. + +- [ ] **Step 3: Implement strict configuration types and canonical revision** + +Implement `loadAuthenticationConfig()` with bounded 1 MiB reads, `yaml.parseDocument`, explicit +duplicate-key rejection, `z.strictObject`, and a canonical SHA-256 revision over sorted JSON. +`createAuthenticationConfigProvider()` caches by inode/size/mtime and reloads after the CLI's atomic +replacement so mapping revisions invalidate sessions without a process restart. Add these fields +to `AppConfig`: + +```ts +authMode: AuthMode; +authConfigFile: string; +authStateRoot: string; +authentication?: AuthenticationConfigProvider; +``` + +`THT_AUTH_CONFIG_FILE` defaults to `/run/thothii-auth/auth.yaml` and +`THT_AUTH_STATE_ROOT` defaults to `/data/auth`. A present config file is the sole source for +`local` or `oidc`; reject a simultaneous `AUTH_MODE` to prevent split-brain configuration. When the +file is absent, accept `AUTH_MODE=none|mock|upstream` only for development or migration. Public +exposure accepts config-derived `oidc` or deprecated `upstream`, never `local`, `none`, or `mock`. + +- [ ] **Step 4: Add complete local and OIDC fixture coverage** + +Assert the exact default lifetimes from the spec, loopback HTTP exception, the fixed references +`THT_OIDC_CLIENT_SECRET` and `THT_AUTHENTIK_API_TOKEN`, exact group-name matching, one admin +mapping, and no secret values in thrown messages. + +- [ ] **Step 5: Run focused and compile gates** + +Run: + +```bash +cd backend +npx vitest run test/auth-config.test.ts test/config.test.ts +npx tsc --noEmit -p . +``` + +Expected: all tests pass and TypeScript is clean. + +- [ ] **Step 6: Commit the configuration contract** + +```bash +git add backend/src/auth/types.ts backend/src/auth/config.ts backend/src/config.ts \ + backend/test/auth-config.test.ts backend/test/config.test.ts +git commit -m "feat(auth): define strict installation authentication config" +``` + +### Task 3: Centralize Roles, Permissions, and Route Authorization + +**Files:** +- Create: `backend/src/auth/authorization.ts` +- Create: `backend/test/authorization.test.ts` +- Modify: `backend/src/auth/principal.ts` +- Modify: `backend/src/auth/auth.ts` +- Modify: `backend/src/app.ts` +- Modify: `backend/src/routes/pi-management.ts` +- Modify: `backend/src/routes/settings.ts` +- Modify: `backend/src/routes/sessions.ts` +- Modify: `backend/src/routes/workspaces.ts` +- Modify: `backend/src/tht/tht-runner.ts` +- Modify: relevant `backend/test/routes-*.test.ts`, `backend/test/auth.test.ts`, and `backend/test/tht-runner.test.ts` + +**Interfaces:** +- Produces: + +```ts +export interface PrincipalContext { + issuer: string; + subject: string; + displayName?: string; + roles: readonly Role[]; + permissions: readonly Permission[]; + isAdmin: boolean; +} + +export function hasPermission(principal: PrincipalContext, permission: Permission): boolean; +export function requirePermission( + request: FastifyRequest, + reply: FastifyReply, + permission: Permission, +): PrincipalContext | FastifyReply; +``` + +- Preserves: `issuer + subject` ownership and `THT_PRINCIPAL_IS_ADMIN` for the harness transition. + +- [ ] **Step 1: Write a route authorization matrix that fails under scattered `isAdmin` checks** + +In `backend/test/authorization.test.ts`, create principals for no role, `user`, and `admin`; assert +every permission in the spec. Add focused route cases: + +```ts +expect(await requestAs(user, "PUT", "/settings", body)).toHaveStatus(403); +expect(await requestAs(admin, "PUT", "/settings", body)).toHaveStatus(200); +expect(await requestAs(user, "POST", "/pi-management/test")).toHaveStatus(403); +expect(await requestAs(admin, "POST", "/pi-management/test")).toHaveStatus(200); +``` + +Add session cases proving a user can mutate an owned session, cannot request `scope=all`, and an +admin can do both. + +- [ ] **Step 2: Run focused route tests and record the expected failures** + +Run: + +```bash +cd backend +npx vitest run test/authorization.test.ts test/routes-settings.test.ts \ + test/routes-pi-management.test.ts test/routes-sessions.test.ts test/routes-workspaces.test.ts +``` + +Expected: failures expose missing permissions and existing mode-specific authorization branches. + +- [ ] **Step 3: Implement one authorization guard and migrate every route** + +Implement `requirePermission()` to return one stable response: + +```json +{"code":"auth_forbidden","error":"This operation is not permitted"} +``` + +Remove `managementAllowed()` and route-local admin booleans. Apply the route table in the spec. +Keep object ownership inside the session locator/authorizer, with admin bypass based on +`session.read_all` or `session.manage_all` rather than `isAdmin`. + +For compatibility adapters, derive roles as follows: + +```ts +none -> admin only when publicExposure is false +mock -> user, or admin when the explicit mock-admin test header is true +upstream -> user plus admin when X-Thoth-Is-Admin is true +``` + +- [ ] **Step 4: Preserve the harness principal environment contract** + +Continue exporting issuer, subject, display name, and derived admin status. Add a new trusted +comma-separated `THT_PRINCIPAL_PERMISSIONS` value containing only catalog values, and clear it in +`clearPrincipalEnvironment()`. + +- [ ] **Step 5: Run all backend authorization and type gates** + +Run: + +```bash +cd backend +npx vitest run test/auth.test.ts test/authorization.test.ts test/routes-pi-management.test.ts \ + test/routes-settings.test.ts test/routes-sessions.test.ts test/routes-workspaces.test.ts \ + test/tht-runner.test.ts +npx tsc --noEmit -p . +``` + +- [ ] **Step 6: Commit the permission boundary** + +```bash +git add backend/src/auth backend/src/app.ts backend/src/routes backend/src/tht/tht-runner.ts backend/test +git commit -m "feat(auth): centralize ThothII permission enforcement" +``` + +### Task 4: Build the Safe Go Authentication Store and Argon2id Registry + +**Files:** +- Create: `tools/tht/internal/authconfig/types.go` +- Create: `tools/tht/internal/authconfig/password.go` +- Create: `tools/tht/internal/authconfig/store.go` +- Create: `tools/tht/internal/authconfig/users.go` +- Create: `tools/tht/internal/authconfig/password_test.go` +- Create: `tools/tht/internal/authconfig/store_test.go` +- Create: `tools/tht/internal/authconfig/users_test.go` +- Modify: `tools/tht/internal/safeio/files.go` +- Create: `tools/tht/internal/safeio/replace_unix.go` +- Create: `tools/tht/internal/safeio/replace_windows.go` +- Modify: `tools/tht/internal/safeio/files_unix_test.go` +- Modify: `tools/tht/internal/safeio/files_windows_test.go` +- Modify: `tools/tht/go.mod` +- Modify: `tools/tht/go.sum` + +**Interfaces:** +- Produces: + +```go +type Role string +const (RoleUser Role = "user"; RoleAdmin Role = "admin") + +type User struct { + ID string `yaml:"id" json:"id"` + Username string `yaml:"username" json:"username"` + DisplayName string `yaml:"displayName,omitempty" json:"displayName,omitempty"` + PasswordHash string `yaml:"passwordHash" json:"-"` + Roles []Role `yaml:"roles" json:"roles"` + Enabled bool `yaml:"enabled" json:"enabled"` + AuthRevision uint64 `yaml:"authRevision" json:"authRevision"` +} + +func HashPassword(password []byte, random io.Reader) (string, error) +func VerifyPassword(password []byte, encoded string) bool +func Load(directory string) (Config, Registry, error) +func MutateUsers(directory string, mutate func(*Registry) error) error +func ReplaceCanonicalRegular(path string, contents []byte, mode os.FileMode) error +``` + +- Consumes: `golang.org/x/crypto/argon2` 0.55.0 and existing `gofrs/flock`. + +- [ ] **Step 1: Write fixed Argon2 and unsafe-filesystem tests** + +Use one fixed salt and password to assert an exact PHC string. Cover wrong password, malformed PHC, +oversized parameters, short/long passwords, symlinked directory/file, hard link, duplicate YAML +key, unknown field, concurrent mutation, and last-admin refusal. + +The shared vector file is `backend/test/fixtures/argon2id-vectors.json` with: + +```json +[{"password":"correct horse battery staple","saltHex":"000102030405060708090a0b0c0d0e0f","memoryKiB":65536,"passes":3,"parallelism":1,"keyLength":32,"phc":"$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4"}] +``` + +The committed literal is the cross-language oracle; both Go and Node independently derive it from +the password and salt and compare it byte-for-byte. + +- [ ] **Step 2: Run Go tests and verify missing implementation failures** + +Run: `cd tools/tht && go test ./internal/authconfig ./internal/safeio` + +- [ ] **Step 3: Implement PHC parsing and bounded Argon2id hashing** + +Accept only the PHC grammar demonstrated by the committed vector, with decimal `m`, `t`, and `p` +fields and unpadded base64 salt/digest fields. Reject memory above 256 MiB, passes above ten, +parallelism above four, salt outside 16–64 bytes, and digest outside 16–64 bytes. +Use `subtle.ConstantTimeCompare` for verification. + +- [ ] **Step 4: Implement safe atomic replacement and locked mutations** + +Write a same-directory exclusive temporary file, set `0600`, fsync file and directory, then replace +the regular single-link target. Unix uses `rename`; Windows uses +`MoveFileEx(MOVEFILE_REPLACE_EXISTING|MOVEFILE_WRITE_THROUGH)`. Never follow a symlinked path +component. Hold `/.auth.lock` for the complete read-check-write transaction. + +- [ ] **Step 5: Implement registry invariants** + +Require the spec's ASCII username grammar and use ASCII lowercase for lookup while preserving +display spelling. Reject control characters in Unicode display names. Generate UUIDv4 IDs with +`crypto/rand`. Increment `authRevision` on every security mutation and reject any result with no +enabled `admin`. + +- [ ] **Step 6: Run Go race and platform compilation gates** + +Run: + +```bash +cd tools/tht +go test -race ./internal/authconfig ./internal/safeio +GOOS=windows GOARCH=amd64 go test -c ./internal/authconfig +GOOS=windows GOARCH=amd64 go test -c ./internal/safeio +``` + +Delete only the two generated test binaries after recording successful compilation. + +- [ ] **Step 7: Commit the safe local registry** + +```bash +git add tools/tht/internal/authconfig tools/tht/internal/safeio tools/tht/go.mod tools/tht/go.sum \ + backend/test/fixtures/argon2id-vectors.json +git commit -m "feat(auth): add safe Argon2id local user registry" +``` + +### Task 5: Add `tht auth configure`, User Management, and Status + +**Files:** +- Create: `tools/tht/internal/authconfig/commands.go` +- Create: `tools/tht/internal/authconfig/commands_test.go` +- Modify: `tools/tht/internal/config/installation.go` +- Modify: `tools/tht/internal/config/installation_test.go` +- Modify: `tools/tht/internal/setup/files.go` +- Modify: `tools/tht/internal/setup/files_test.go` +- Modify: `tools/tht/cmd/tht/main.go` +- Modify: `tools/tht/cmd/tht/main_test.go` +- Modify: `tools/tht/go.mod` +- Modify: `tools/tht/go.sum` + +**Interfaces:** +- Produces all `tht auth` commands in the spec except live `check`, which Task 12 wires to the + backend diagnostic command. +- Adds to `config.Installation`: + +```go +Authentication struct { ConfigDirectory string } +func (i Installation) AuthenticationDirectory() string +func Run(ctx context.Context, installation config.Installation, args []string, stdin io.Reader, stdout, stderr io.Writer) int +``` + +- Consumes: `golang.org/x/term` 0.45.0 for echo-free TTY password reads. + +- [ ] **Step 1: Write CLI grammar and pristine-JSON tests** + +Assert root help contains exactly one `auth` subtree and still rejects the retired CLI name. Test +local configure, OIDC configure, list JSON redaction, add/set-password/enable/disable/grant/revoke, +last-admin refusal, logout-all revision increment, non-TTY password refusal, and OIDC-mode refusal +for `auth user`. + +The local non-interactive configure grammar is: + +```text +tht auth configure --mode local --public-url URL --admin-user USER \ + [--admin-display-name NAME] --password-file FILE +``` + +TTY mode may omit the admin flags and prompts for them. Password-file reads are bounded to 1025 +bytes and remove one trailing CRLF/LF only. + +- [ ] **Step 2: Run CLI tests and observe unknown-command failures** + +Run: `cd tools/tht && go test ./cmd/tht ./internal/config ./internal/setup ./internal/authconfig` + +- [ ] **Step 3: Extend the strict installation descriptor** + +Add: + +```yaml +authentication: + configDirectory: /absolute/operator-controlled/thothii-auth +``` + +Require an absolute canonical path and verify `THT_AUTH_CONFIG_ROOT` in the env file equals the +descriptor. `setup` and `auth configure` may create a missing final directory with private +permissions; `start`, `doctor`, `status`, user commands, and Compose operations require it to exist, +be private, and contain no symlinked component. Known-fields decoding must reject misspellings. + +- [ ] **Step 4: Implement command dispatch without growing `main.go` business logic** + +`main.go` parses the first-level `auth` verb and delegates to the exact `authconfig.Run` signature +above. All mutation logic stays in `internal/authconfig`. `status --json` returns mode, public URL, +user counts by role, and config revision; it never returns password hashes, secret refs' values, or +session data. + +- [ ] **Step 5: Implement protected bootstrap behavior** + +`tht auth configure --mode local` creates `auth.yaml` and `users.yaml` atomically with an initial +enabled admin. OIDC configuration writes the two exact group mappings supplied by +`--user-group` and `--admin-group`; if both names are equal, the command refuses the ambiguous +configuration. + +- [ ] **Step 6: Run CLI, race, and root-identity gates** + +Run: + +```bash +cd tools/tht +go test -race ./internal/authconfig ./internal/config ./internal/setup ./cmd/tht +go build ./cmd/tht +./tht --help +``` + +Expected: only `tht` is named; JSON outputs parse with `jq`; no password/hash appears in captured +stdout/stderr. + +- [ ] **Step 7: Commit the host operator surface** + +```bash +git add tools/tht +git commit -m "feat(auth): add local and OIDC management to tht" +``` + +### Task 6: Read Local Users and Verify Go-Generated Argon2 Hashes in Node + +**Files:** +- Create: `backend/src/auth/password.ts` +- Create: `backend/src/auth/local-registry.ts` +- Create: `backend/test/auth-password.test.ts` +- Create: `backend/test/local-registry.test.ts` +- Consume: `backend/test/fixtures/argon2id-vectors.json` + +**Interfaces:** +- Produces: + +```ts +export interface LocalUserRecord { + id: string; + username: string; + normalizedUsername: string; + displayName?: string; + passwordHash: string; + roles: readonly Role[]; + enabled: boolean; + authRevision: number; +} + +export interface LocalUserRegistry { + findByUsername(username: string): Promise; + findBySubject(id: string): Promise; + verify(user: LocalUserRecord | undefined, password: string): Promise; +} + +export function createLocalUserRegistry(usersPath: string): LocalUserRegistry; +``` + +- Consumes: Node 24 native `crypto.argon2` and the exact Go PHC format from Task 4. + +- [ ] **Step 1: Write cross-language vector and safe-file tests** + +Assert Node accepts every committed Go vector, rejects a one-byte password change, and refuses +oversized PHC parameters before allocating Argon2 memory. Registry tests cover known fields, +duplicate names/IDs, symlinks, hard links, mode wider than `0600`, file larger than 1 MiB, and a +same-size atomic replacement with changed mtime. + +- [ ] **Step 2: Run focused tests and observe missing exports** + +Run: `cd backend && npx vitest run test/auth-password.test.ts test/local-registry.test.ts` + +- [ ] **Step 3: Implement Node PHC verification and dummy verification** + +Parse the same bounded PHC parameters as Go, derive exactly 32 bytes, and compare with +`timingSafeEqual`. Construct one process-local dummy hash at startup so unknown and disabled users +perform an indistinguishable Argon2 verification path. + +- [ ] **Step 4: Implement safe registry reload** + +Read only the configured `users.yaml`, reject unsafe metadata before/after read, parse strictly, +and cache by inode/size/mtime. Reload after an atomic host replacement. Error messages expose only +`local_user_registry_invalid`, not usernames, hashes, paths, or YAML content. + +- [ ] **Step 5: Run focused tests, typecheck, and Go/Node round trip** + +Run: + +```bash +cd backend +npx vitest run test/auth-password.test.ts test/local-registry.test.ts +npx tsc --noEmit -p . +cd ../tools/tht && go test ./internal/authconfig +``` + +- [ ] **Step 6: Commit the backend local identity reader** + +```bash +git add backend/src/auth/password.ts backend/src/auth/local-registry.ts \ + backend/test/auth-password.test.ts backend/test/local-registry.test.ts +git commit -m "feat(auth): verify local ThothII users in the backend" +``` + +### Task 7: Implement Durable Opaque Sessions and Revision Invalidation + +**Files:** +- Create: `backend/src/auth/session-store.ts` +- Create: `backend/test/auth-session-store.test.ts` +- Modify: `backend/src/auth/types.ts` + +**Interfaces:** +- Produces: + +```ts +export interface SessionCreateInput { + principal: PrincipalContext; + method: "local" | "oidc" | "upstream"; + remembered: boolean; + userAuthRevision?: number; + authConfigRevision: string; + idleTtlMs: number; + absoluteTtlMs: number; +} + +export interface CreatedAuthSession { + token: string; + csrfToken: string; + record: AuthSessionRecord; +} + +export interface AuthSessionStore { + create(input: SessionCreateInput, now?: Date): Promise; + resolve(token: string, now?: Date): Promise; + touch(token: string, now?: Date): Promise; + revoke(token: string): Promise; + prune(now?: Date): Promise; +} + +export function createFileAuthSessionStore(root: string): AuthSessionStore; +export function deriveCsrfToken(sessionToken: string): string; +``` + +- Produces: matching bounded OIDC-state create/consume methods with ten-minute expiry and + single-use semantics. + +- [ ] **Step 1: Write lifecycle, restart, and attack-path tests** + +Use two store instances against the same temporary directory to prove a remembered session +survives a backend restart. Cover 256-bit token entropy/format, digest-only filenames, no raw token +in file content, idle and absolute expiry, five-minute touch throttling, logout deletion, prune, +single-use OIDC state, symlink/hard-link refusal, malformed/oversized records, and concurrent +resolve/revoke. + +- [ ] **Step 2: Run the focused test and observe missing store failures** + +Run: `cd backend && npx vitest run test/auth-session-store.test.ts` + +- [ ] **Step 3: Implement one-file-per-session storage** + +Generate tokens with `randomBytes(32).toString("base64url")`; derive filenames with SHA-256 and +derive the frontend CSRF token with HKDF-SHA-256 using context `thothii-csrf-v1`. Persist neither +raw value. Create root/subdirectories as `0700` and files as `0600`. Use exclusive create for new +records and same-directory write/fsync/rename for touches. Validate filename and record schema +before use. + +- [ ] **Step 4: Implement session validity hooks** + +Add a resolver callback that compares `authConfigRevision` on every request and, for local +sessions, looks up `subject`, `enabled`, and `authRevision`. Revoke on any mismatch before returning +a principal. + +- [ ] **Step 5: Run focused tests and a restart simulation** + +Run: + +```bash +cd backend +npx vitest run test/auth-session-store.test.ts --repeat 3 +npx tsc --noEmit -p . +``` + +Expected: repeated runs pass without timing flakes; the second store instance resolves the first +instance's token. + +- [ ] **Step 6: Commit durable sessions** + +```bash +git add backend/src/auth/types.ts backend/src/auth/session-store.ts \ + backend/test/auth-session-store.test.ts +git commit -m "feat(auth): persist opaque remembered sessions" +``` + +### Task 8: Add Local Login, Cookies, Logout, Rate Limits, and CSRF + +**Files:** +- Create: `backend/src/auth/csrf.ts` +- Create: `backend/src/auth/routes.ts` +- Create: `backend/test/auth-csrf.test.ts` +- Create: `backend/test/auth-routes-local.test.ts` +- Modify: `backend/src/auth/auth.ts` +- Modify: `backend/src/app.ts` +- Modify: `backend/src/server.ts` +- Modify: `backend/test/auth.test.ts` +- Modify: every state-changing route test that now needs a CSRF header + +**Interfaces:** +- Produces public routes `GET /auth/config`, `POST /auth/local/login`, OIDC route placeholders, + authenticated `POST /auth/logout`, and authenticated `GET /me`. +- Produces: + +```ts +export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDependencies): void; +export function authenticateSession(deps: AuthDependencies): preHandlerHookHandler; +export function requireCsrf(request: FastifyRequest, reply: FastifyReply): true | FastifyReply; +``` + +- Consumes: Task 6 local registry and Task 7 session store. + +- [ ] **Step 1: Write end-to-end Fastify injection tests before route code** + +Cover successful ordinary and remembered login, generic failure for unknown/disabled/wrong +password, missing/wrong Origin, cookie attributes under HTTP and HTTPS public URLs, `/me` response, +logout, restart persistence, rate-limit response, concurrent Argon2 cap, and CSRF rejection for +every non-GET API family. + +The remembered cookie must contain `Max-Age=2592000`; the ordinary cookie must not contain +`Max-Age`. Both contain `HttpOnly`, `SameSite=Lax`, and `Path=/`. + +- [ ] **Step 2: Run tests and observe missing route/plugin failures** + +Run: `cd backend && npx vitest run test/auth-csrf.test.ts test/auth-routes-local.test.ts` + +- [ ] **Step 3: Register cookie and bounded login protection** + +Register `@fastify/cookie` and `@fastify/rate-limit` before auth routes. Set conservative default +limits of ten failed login attempts per normalized username and twenty per source address per ten +minutes. Bound Argon2 verification to two concurrent jobs; excess requests return 429 without +queuing unbounded work. + +- [ ] **Step 4: Implement session authentication and CSRF once at the app boundary** + +Replace the current global `authPreHandler` with a hook that explicitly allows `/health` and +protocol endpoints, then resolves the opaque cookie for protected routes. For state-changing +methods compare `X-ThothII-CSRF` in constant time with `deriveCsrfToken(cookieToken)`, require +`Origin` to equal the configured `publicUrl` origin, and require `Sec-Fetch-Site: same-origin` when +that header exists. Keep test helpers that generate a session+CSRF pair so individual route tests +do not bypass production hooks. + +- [ ] **Step 5: Return a frontend-safe `/me` representation** + +Return only: + +```ts +{ + issuer, subject, displayName, roles, permissions, isAdmin, + csrfToken, + session: { method, remembered, idleExpiresAt, absoluteExpiresAt } +} +``` + +Never return cookie tokens, hashes, auth revisions, config revisions, or file paths. + +- [ ] **Step 6: Run backend auth, route, and build gates** + +Run: + +```bash +cd backend +npx vitest run test/auth*.test.ts test/routes-*.test.ts test/sse-route.test.ts +npx tsc --noEmit -p . +npm run build +``` + +- [ ] **Step 7: Commit the secure local web session** + +```bash +git add backend/src/auth backend/src/app.ts backend/src/server.ts backend/test +git commit -m "feat(auth): add local login and CSRF-protected sessions" +``` + +### Task 9: Gate the React Application and Expose Remember Me + +**Files:** +- Create: `frontend/src/api/auth.ts` +- Create: `frontend/src/auth/authState.ts` +- Create: `frontend/src/auth/AuthGate.tsx` +- Create: `frontend/src/auth/LoginPage.tsx` +- Create: `frontend/src/auth/AuthGate.test.tsx` +- Create: `frontend/src/auth/LoginPage.test.tsx` +- Modify: `frontend/src/api/client.ts` +- Modify: `frontend/src/api/sessions.ts` +- Modify: `frontend/src/api/types.ts` +- Modify: `frontend/src/App.tsx` +- Modify: `frontend/src/shell/AppShell.tsx` +- Modify: `frontend/src/stream/useSessionStream.ts` +- Modify: `frontend/src/api/client.test.ts` +- Modify: relevant `frontend/src/shell/*.test.tsx` + +**Interfaces:** +- Produces: + +```ts +export interface AuthenticatedUser { + issuer: string; + subject: string; + displayName?: string; + roles: readonly ("user" | "admin")[]; + permissions: readonly string[]; + isAdmin: boolean; + csrfToken: string; + session: { method: "local" | "oidc" | "upstream"; remembered: boolean; idleExpiresAt: string; absoluteExpiresAt: string }; +} + +export function getAuthConfig(): Promise; +export function loginLocal(username: string, password: string, remember: boolean): Promise; +export function logout(): Promise; +``` + +- Consumes: same-origin `/api` and the backend CSRF contract. + +- [ ] **Step 1: Write shell-state and network-boundary tests** + +Test loading, local login form, invalid credentials, remembered checkbox, OIDC button, authenticated +shell, logout, expired-session 401, 403 presentation, and admin-only visibility. Assert neither +password nor any token is written to `localStorage`/`sessionStorage`. + +- [ ] **Step 2: Run focused frontend tests and observe missing UI failures** + +Run: + +```bash +cd frontend +npx vitest run src/auth/AuthGate.test.tsx src/auth/LoginPage.test.tsx src/api/client.test.ts +``` + +- [ ] **Step 3: Add an in-memory auth state and automatic CSRF header** + +`apiFetch` keeps default same-origin credentials and, for `POST|PUT|PATCH|DELETE`, reads the current +in-memory CSRF token and adds `X-ThothII-CSRF`. It never sends credentials to a cross-origin base +URL; extend the runtime URL policy to reject such a production configuration. + +- [ ] **Step 4: Build the authentication gate and login page** + +`AuthGate` calls `/me`; 200 renders `AppShell`, 401 renders `LoginPage`, and transient 503 renders a +retryable provider-unavailable state. The password input is uncontrolled beyond submission and is +cleared after every attempt. **Remember me** is unchecked by default and is shown only in local +mode. + +- [ ] **Step 5: Apply permission-aware chrome without relying on it for security** + +Hide Pi management and workspace mutation/test controls unless the relevant permission exists. +Hide `scope=all` unless `session.read_all` exists. Keep backend 403 handling because frontend +visibility is not authorization. + +- [ ] **Step 6: Preserve cookie-authenticated SSE** + +Keep EventSource same-origin under `/api`. On an authentication-generation change, close the old +EventSource and reset live session state before reconnecting. Do not add query-string tokens. + +- [ ] **Step 7: Run frontend unit, type, and build gates** + +```bash +cd frontend +npx vitest run +npx tsc -b +npm run build +``` + +- [ ] **Step 8: Commit the authenticated frontend** + +```bash +git add frontend/src +git commit -m "feat(auth): add remembered local login to the frontend" +``` + +### Task 10: Implement Provider-Neutral OIDC Authorization Code Flow + +**Files:** +- Create: `backend/src/auth/oidc-client.ts` +- Create: `backend/test/oidc-client.test.ts` +- Create: `backend/test/auth-routes-oidc.test.ts` +- Modify: `backend/src/auth/routes.ts` +- Modify: `backend/src/auth/session-store.ts` +- Modify: `backend/src/app.ts` + +**Interfaces:** +- Produces: + +```ts +export interface OidcIdentity { + issuer: string; + subject: string; + displayName?: string; + groups: readonly string[]; + tokenExpiresAt: Date; +} + +export interface OidcProtocol { + authorizationUrl(input: { state: string; nonce: string; codeVerifier: string }): Promise; + callback(input: { currentUrl: URL; state: string; nonce: string; codeVerifier: string }): Promise; + diagnose(signal: AbortSignal): Promise; + verifyDeviceFlow?(signal: AbortSignal, present: (uri: string, code: string) => void): Promise; +} +``` + +- Consumes: `openid-client` 6.8.5 and Task 7 single-use OIDC state storage. + +- [ ] **Step 1: Write protocol-validation and callback tests** + +Cover discovery issuer mismatch, missing HTTPS, state mismatch, replay, nonce mismatch, wrong +audience, expired token, invalid signature, missing `sub`, absent groups, non-array groups, empty +group item, distributed/overage groups, extra groups, unmapped groups, and a successful callback. + +Tests inject a deterministic `OidcProtocol`; one concrete-adapter test supplies a local in-process +discovery/JWKS/token fixture through the library's custom fetch hook and never contacts the network. + +- [ ] **Step 2: Run focused tests and observe missing OIDC adapter failures** + +Run: `cd backend && npx vitest run test/oidc-client.test.ts test/auth-routes-oidc.test.ts` + +- [ ] **Step 3: Implement discovery and Authorization Code + PKCE** + +Use PKCE S256, random state, random nonce, exact configured callback, and exact issuer validation. +Store verifier/nonce/return target under the digest of state for ten minutes and consume it once. +Allow only the fixed return target `/`; do not accept arbitrary `returnTo` URLs. + +- [ ] **Step 4: Enforce the mandatory groups contract and map roles** + +Read `groupsClaim` from the verified ID-token claims. Require a direct array of unique non-empty +strings. Map exact strings through `authorization.groupRoles`, union roles, and ignore all other +strings without logging. A valid identity with no mapped role reaches the authenticated-but- +forbidden state. + +- [ ] **Step 5: Create an OIDC session without persisting tokens** + +Set absolute expiry to `min(now + oidcTtlSeconds, ID-token exp)`. Persist only the derived principal +and authorization/config revision. Clear the state record after both successful and terminal +failed callbacks. + +- [ ] **Step 6: Run OIDC, auth-route, type, and build gates** + +```bash +cd backend +npx vitest run test/oidc-client.test.ts test/auth-routes-oidc.test.ts test/auth-session-store.test.ts +npx tsc --noEmit -p . +npm run build +``` + +- [ ] **Step 7: Commit generic OIDC login** + +```bash +git add backend/src/auth backend/test/oidc-client.test.ts backend/test/auth-routes-oidc.test.ts +git commit -m "feat(auth): add generic OIDC login with mandatory groups" +``` + +### Task 11: Add the Authentik Group Catalog and Shared Auth Diagnostics + +**Files:** +- Create: `backend/src/auth/group-catalog.ts` +- Create: `backend/src/auth/authentik-group-catalog.ts` +- Create: `backend/src/auth/diagnostics.ts` +- Create: `backend/test/authentik-group-catalog.test.ts` +- Create: `backend/test/auth-diagnostics.test.ts` +- Modify: `backend/src/config/secret-bundle.ts` +- Modify: `backend/test/secret-bundle.test.ts` + +**Interfaces:** +- Produces: + +```ts +export interface GroupCatalog { + verifyConfiguredGroups(names: readonly string[], signal: AbortSignal): Promise; +} + +export interface AuthDiagnoser { + inspect(options: { live: boolean; interactive?: boolean; signal?: AbortSignal }): Promise; +} + +export function createAuthDiagnoser(deps: AuthDiagnoserDependencies): AuthDiagnoser; +``` + +- Consumes fixed secret references `THT_OIDC_CLIENT_SECRET` and `THT_AUTHENTIK_API_TOKEN` from the + existing literal secret bundle parser. + +- [ ] **Step 1: Write exact Authentik request and comparison tests** + +Assert each mapped group produces one request with `include_users=false&page_size=2`, bearer auth, +five-second abort, `redirect: "error"`, and encoded exact name. Test 0/1/2 results, pagination/body +overflow, 401/403, invalid JSON, redirect, timeout, and secret redaction. + +Add the defining negative assertion: + +```ts +expect(report.checks).not.toContainEqual(expect.objectContaining({ level: "warning" })); +expect(JSON.stringify(report)).not.toContain("Unmapped Corporate Group"); +``` + +- [ ] **Step 2: Run focused tests and observe missing adapter failures** + +Run: `cd backend && npx vitest run test/authentik-group-catalog.test.ts test/auth-diagnostics.test.ts` + +- [ ] **Step 3: Implement the least-privilege Authentik adapter** + +Use native `fetch`, a fixed operator-configured base origin, no redirects, bounded 1 MiB response, +and an AbortSignal. Return only stable codes and configured group names; discard upstream response +bodies and never enumerate unrelated groups. + +- [ ] **Step 4: Implement static and live diagnosis** + +Static mode validates configuration, file safety, secret presence, local enabled admin, role names, +group map, URL policy, and session root. Live OIDC mode additionally validates discovery/JWKS and +all Authentik mapped groups. Codes are exactly the closed `AuthDiagnosticCode` union in the spec; +`auth_ready` is the sole success code. + +- [ ] **Step 5: Verify redaction under every upstream failure** + +Seed tests with unique client-secret, API-token, cookie, password-hash, and file-path sentinels. +Assert none appears in thrown errors, Fastify logs, human diagnostics, or JSON diagnostics. + +- [ ] **Step 6: Run focused and full auth gates** + +```bash +cd backend +npx vitest run test/auth*.test.ts test/oidc-client.test.ts test/secret-bundle.test.ts +npx tsc --noEmit -p . +``` + +- [ ] **Step 7: Commit Authentik certification logic** + +```bash +git add backend/src/auth backend/src/config/secret-bundle.ts backend/test +git commit -m "feat(auth): validate mapped groups through Authentik" +``` + +### Task 12: Integrate Auth Diagnostics with Workspaces, `tht doctor`, and `tht auth check` + +**Files:** +- Create: `backend/src/auth/diagnostic-command.ts` +- Create: `backend/test/auth-diagnostic-command.test.ts` +- Modify: `backend/src/routes/workspaces.ts` +- Modify: `backend/test/routes-workspaces.test.ts` +- Modify: `backend/src/workspaces/diagnostics.ts` +- Modify: `frontend/src/api/workspaces.ts` +- Modify: `frontend/src/api/workspaces.test.ts` +- Modify: `frontend/src/shell/WorkspaceManager.tsx` +- Modify: `frontend/src/shell/WorkspaceManager.test.tsx` +- Modify: `tools/tht/internal/authconfig/commands.go` +- Modify: `tools/tht/internal/authconfig/commands_test.go` +- Modify: `tools/tht/internal/doctor/report.go` +- Modify: `tools/tht/internal/doctor/report_test.go` +- Modify: `tools/tht/cmd/tht/main_test.go` + +**Interfaces:** +- Extends workspace results with: + +```ts +interface WorkspaceDiagnostics { + activatable: boolean; + diagnostics: Diagnostic[]; + authentication: AuthDiagnostics; +} +``` + +- Produces `tht auth check [--json|--interactive]` and a doctor check named `authentication`. + +- [ ] **Step 1: Write workspace aggregation and CLI delegation tests** + +Assert `/workspaces/validate` uses `inspect({live:false})`, `/workspaces/:id/test` uses +`inspect({live:true})`, and `activatable` is false on auth failure even when workspace connectors +pass. Assert configured missing groups appear as errors and unrelated provider groups never appear. + +In Go, assert exact Compose invocation and redaction for both stopped/running stacks. JSON stdout +must decode as the backend `AuthDiagnostics` contract with no banner. + +- [ ] **Step 2: Run focused backend/frontend/Go tests and observe failures** + +Run: + +```bash +cd backend && npx vitest run test/routes-workspaces.test.ts test/auth-diagnostic-command.test.ts +cd ../frontend && npx vitest run src/api/workspaces.test.ts src/shell/WorkspaceManager.test.tsx +cd ../tools/tht && go test ./internal/authconfig ./internal/doctor ./cmd/tht +``` + +- [ ] **Step 3: Add one backend diagnostic command** + +`node dist/auth/diagnostic-command.js --json` prints only the redacted report. `--interactive` +requires OIDC mode and the discovery document's device authorization endpoint, prints the +verification URI and user code to stderr, validates the resulting ID token and groups, and prints +one final success/failure report. No token is persisted. + +- [ ] **Step 4: Delegate host checks through Compose** + +`tht auth check` invokes a one-shot core command with the installation's normal mounts and secrets; +`tht doctor` uses `exec -T` when core is healthy. Add `authentication` after `configuration` and +before remote workspace/Pi checks. Sanitize both process output streams with all installation +secret values. + +- [ ] **Step 5: Aggregate workspace diagnostics without duplicating auth logic** + +Inject `AuthDiagnoser` into workspace routes. Static draft validation includes the static report; +installation test includes the live report. Preserve all existing connector diagnostic ordering +and messages, and compute overall `activatable` from both components. + +- [ ] **Step 6: Render authentication results in Workspace Manager** + +Show one Authentication section with Passed/Failed and configured-group errors. Do not render or +calculate a list of unmapped groups. Restrict Validate/Test actions to `workspace.manage`. + +- [ ] **Step 7: Run all diagnostic gates** + +```bash +cd backend && npx vitest run test/routes-workspaces.test.ts test/auth-diagnostic-command.test.ts +cd ../frontend && npx vitest run src/api/workspaces.test.ts src/shell/WorkspaceManager.test.tsx +cd ../tools/tht && go test -race ./internal/authconfig ./internal/doctor ./cmd/tht +``` + +- [ ] **Step 8: Commit unified diagnostics** + +```bash +git add backend/src backend/test frontend/src tools/tht +git commit -m "feat(auth): include authentication in workspace and tht diagnostics" +``` + +### Task 13: Wire Authentication into Compose, Setup, Backup, and Restore + +**Files:** +- Modify: `compose.yaml` +- Modify: `deploy/compose.local.yaml` +- Modify: `deploy/compose.server.yaml` +- Modify: `deploy/compose.session-server.yaml.example` +- Modify: `deploy/env/local.env.example` +- Modify: `deploy/env/server.env.example` +- Modify: `deploy/psd/operator.env.example` +- Modify: `deploy/psd/thothii-installation.yaml.example` +- Modify: `docs/install/examples/thothii-installation.local.yaml` +- Modify: `docs/install/examples/thothii-installation.server.yaml` +- Modify: `deploy/secrets/thothii.secrets.example` +- Modify: `deploy/secrets/README.md` +- Modify: `tools/tht/internal/setup/files.go` +- Modify: `tools/tht/internal/setup/files_test.go` +- Modify: `tools/tht/internal/backup/create.go` +- Modify: `tools/tht/internal/backup/create_test.go` +- Modify: `tools/tht/internal/backup/restore.go` +- Modify: `tools/tht/internal/backup/restore_test.go` +- Modify: `tools/tht/internal/doctor/report.go` +- Modify: `tools/tht/internal/doctor/report_test.go` +- Modify: `scripts/unified-deployment-smoke.sh` + +**Interfaces:** +- Produces mount `${THT_AUTH_CONFIG_ROOT}:/run/thothii-auth:ro` and volume + `auth-state:/data/auth`. +- Produces core env `THT_AUTH_CONFIG_FILE=/run/thothii-auth/auth.yaml` and + `THT_AUTH_STATE_ROOT=/data/auth`. +- Preserves server whole-`/data` bind semantics. + +- [ ] **Step 1: Write Compose render and setup tests before YAML changes** + +Assert both profiles render the auth config directory read-only, core alone can access auth state, +the local profile declares `auth-state`, server uses `${THT_DATA_ROOT}/auth` through its `/data` +bind, and workspace-maintenance receives neither user files nor auth state. + +Update doctor volume expectations from seven to eight local persistent volumes. + +- [ ] **Step 2: Run render/setup/doctor tests and observe missing bindings** + +Run: + +```bash +cd tools/tht && go test ./internal/setup ./internal/doctor ./internal/backup +``` + +Expected: tests fail until auth paths and the volume are declared. Compose rendering remains inside +the existing fixture-safe setup/doctor tests so it never depends on an operator's uncommitted env +or secret files. + +- [ ] **Step 3: Add mounts, volume, environment, and examples** + +Remove `AUTH_MODE` from the normal local and server profiles so `auth.yaml` is authoritative, and +keep an explicitly commented deprecated upstream migration example that is valid only when no +auth config exists. Add the two fixed secret-bundle keys: + +```text +THT_OIDC_CLIENT_SECRET= +THT_AUTHENTIK_API_TOKEN= +``` + +Never place example real-looking values in committed files. + +- [ ] **Step 4: Make setup configure authentication before startup** + +The ordered setup workflow becomes: create/validate installation files, configure local/OIDC auth, +validate auth statically, render Compose, build/start, then run aggregate doctor. Non-interactive +setup requires complete auth flags and password-file input for local mode. + +- [ ] **Step 5: Define backup and restore custody** + +Without `--include-secrets`, backup records the auth configuration path but excludes `users.yaml` +and secret values. With `--include-secrets --yes`, include `auth.yaml` and `users.yaml` under the +encrypted/custody-warning secret section. Never include active session or OIDC-state files. +Restore recreates `/data/auth` with private ownership and no active sessions, so every browser must +authenticate again. + +- [ ] **Step 6: Extend deployment smoke assertions** + +Add local bootstrap/login/remember/restart/logout checks and server static OIDC diagnostics with a +fake provider fixture. Assert workspace-maintenance cannot read `/run/thothii-auth` or `/data/auth`. +Assert final cleanup removes only test-scoped containers/volumes. + +- [ ] **Step 7: Run setup, backup, Compose, and smoke gates** + +```bash +cd tools/tht && go test -race ./internal/setup ./internal/config ./internal/backup ./internal/doctor ./cmd/tht +cd ../.. +bash scripts/unified-deployment-smoke.sh +``` + +- [ ] **Step 8: Commit deployment integration** + +```bash +git add compose.yaml deploy tools/tht scripts/unified-deployment-smoke.sh docs/install/examples +git commit -m "feat(auth): integrate authentication with installation lifecycle" +``` + +### Task 14: Document Local Auth, Generic OIDC, Authentik, Groups, and PSD Acceptance + +**Files:** +- Create: `docs/architecture/authentication.md` +- Create: `docs/install/authentication-local.md` +- Create: `docs/install/authentication-oidc.md` +- Create: `docs/install/authentik.md` +- Create: `docs/testing/authentication-manual-acceptance.md` +- Modify: `docs/architecture/overview.md` +- Modify: `docs/install/local.md` +- Modify: `docs/install/server.md` +- Modify: `docs/install/psd-workspace-setup.md` +- Modify: `docs/install/reverse-proxy-caddy.md` +- Modify: `docs/install/reverse-proxy-nginx.md` +- Modify: `docs/guida-utente.md` +- Modify: `docs/index.md` +- Modify: `README.md` +- Modify: `PROJECT_STATE.md` only after automated and manual status is known +- Modify: `mkdocs.yml` if navigation is explicit there + +**Interfaces:** +- Documents the exact YAML, CLI, group claim, group-role mapping, session lifetime, invalidation, + diagnostic codes, Authentik service-account privileges, and PSD acceptance flow from the spec. + +- [ ] **Step 1: Add a documentation contract test** + +Extend the existing docs smoke or add `scripts/auth-docs-smoke.sh` to require: + +```text +tht auth +groups +TOT Admin +THT_OIDC_CLIENT_SECRET +THT_AUTHENTIK_API_TOKEN +Remember me +oidc_mapped_group_missing +``` + +Also fail on `thothii-admin`, host-facing `thothctl auth`, plaintext-password examples, or wording +that claims unmapped OIDC groups generate warnings. + +- [ ] **Step 2: Run the docs smoke and observe missing-document failures** + +Run: `bash scripts/auth-docs-smoke.sh` + +- [ ] **Step 3: Write local and generic OIDC guides** + +Document bootstrap, initial admin, password recovery, remembered/ordinary expiry, logout-all, +restart behavior, all commands, JSON use, same-origin browser requirement, callback URL, mandatory +direct `groups` array, fail-closed unmapped-user behavior, and provider-adapter boundary. + +- [ ] **Step 4: Write the Authentik and PSD guide** + +Include exact operator steps: create OAuth2/OIDC application/provider, register callback, include +`openid profile email`, verify the `groups` claim, create dedicated service account/API token with +group-view permission only, create/confirm `TOT Users` and `TOT Admin`, map them in `auth.yaml`, run +`tht auth check`, run `tht auth check --interactive`, then run workspace Test. State explicitly +that additional Authentik/LDAP groups are ignored silently. + +- [ ] **Step 5: Write the manual acceptance matrix** + +Require one ordinary and one admin PSD test identity. Record expected results for ordinary/admin +route access, missing claim, missing mapped group, wrong API token, group rename, extra unmapped +group, session restart, password/role invalidation, CSRF rejection, logout, and provider outage. +Never record real names, tokens, passwords, LDAP details, or internal URLs in committed evidence. + +- [ ] **Step 6: Run docs build and smoke** + +```bash +bash scripts/auth-docs-smoke.sh +python -m mkdocs build --strict +``` + +- [ ] **Step 7: Commit operator and user documentation** + +```bash +git add docs README.md mkdocs.yml scripts/auth-docs-smoke.sh +git commit -m "docs(auth): document local OIDC and Authentik operation" +``` + +### Task 15: Execute Full Automated and Manual Release Gates + +**Files:** +- Create: `backend/test/fixtures/oidc-provider.mjs` +- Create: `scripts/authentication-smoke.sh` +- Create: `frontend/e2e/auth.spec.ts` +- Modify: `.github/workflows/deployment.yml` +- Modify: `docs/testing/authentication-manual-acceptance.md` +- Modify: `PROJECT_STATE.md` after evidence is retained + +**Interfaces:** +- Produces one retained test report containing commit SHA, image IDs, Node/Pi versions, individual + gate status, and no secret values. +- Consumes every interface and acceptance condition from Tasks 1–14. + +- [ ] **Step 1: Add a deterministic fake OIDC provider and browser E2E** + +The fixture exposes discovery, JWKS, authorization, token, device authorization, and Authentik-like +group-list endpoints on loopback only. It issues signed short-lived ID tokens for ordinary, admin, +missing-groups, malformed-groups, and unmapped-group identities. + +Playwright covers local ordinary/remembered login, backend restart, logout, admin chrome, OIDC +redirect/callback, 403 for an unmapped user, and expired session recovery. + +- [ ] **Step 2: Run every language-level gate** + +```bash +cd tools/tht && go test -race ./... && go build ./cmd/tht +cd ../../backend && npx tsc --noEmit -p . && npx vitest run && npm run build +cd ../frontend && npx tsc -b && npx vitest run && npm run build && npm run e2e +cd ../harness && .venv/bin/ruff check . && .venv/bin/pytest -q +``` + +- [ ] **Step 3: Run Docker, installation, and security smoke gates** + +```bash +cd .. +bash scripts/authentication-smoke.sh +bash scripts/unified-deployment-smoke.sh +bash scripts/auth-docs-smoke.sh +``` + +Verify the built core reports Node `v24.16.0`, Pi starts, auth state survives only the intended +restart, and no sentinel secret appears in logs or artifacts. + +- [ ] **Step 4: Run the opt-in L2 live-session smoke** + +Use the repository's configured PSD/L2 secret layout without copying it into the worktree: + +```bash +cd harness +.venv/bin/pytest -q -m l2 +``` + +Then create one browser session through the real stack and complete a live session workflow as an +ordinary authenticated user. Confirm principal ownership remains stable after resume. + +- [ ] **Step 5: Execute Authentik PSD manual acceptance** + +Follow `docs/testing/authentication-manual-acceptance.md`. Run `tht auth check --interactive`, +workspace Validate/Test, ordinary/admin authorization checks, extra-group no-warning check, and +mapped-group rename failure. Store only sanitized pass/fail evidence under a task-scoped +`.artifacts/manual-acceptance/authentication/` directory. + +- [ ] **Step 6: Update project state with actual evidence** + +Record exact pass counts, retained artifact digest, source commit, Node version, Authentik version, +and whether manual PSD acceptance is PASS or PENDING. Do not mark the feature complete while any +required gate is pending. + +- [ ] **Step 7: Commit final gates and state** + +```bash +git add backend/test/fixtures/oidc-provider.mjs frontend/e2e/auth.spec.ts \ + scripts/authentication-smoke.sh .github/workflows/deployment.yml \ + docs/testing/authentication-manual-acceptance.md PROJECT_STATE.md +git commit -m "test(auth): gate local and Authentik authentication release" +``` + +## Final acceptance checklist + +- [ ] `tht --help` exposes one CLI and the complete `auth` subtree. +- [ ] A fresh local setup creates one admin without plaintext credentials. +- [ ] Ordinary local login and **Remember me** behave with the exact configured lifetimes. +- [ ] A remembered session survives browser and core restart. +- [ ] Password change, role change, disable, logout-all, logout, config change, and restore revoke + the expected sessions. +- [ ] Every state-changing cookie-authenticated route rejects missing/invalid CSRF. +- [ ] Ordinary and admin permission matrices pass in backend and browser tests. +- [ ] OIDC Authorization Code + PKCE validates issuer, signature, audience, expiry, state, nonce, + and mandatory direct `groups`. +- [ ] Authentik group validation fails for configured missing/ambiguous groups. +- [ ] Unmapped Authentik/token groups produce no error, warning, or log entry. +- [ ] Workspace static validation and live Test include authentication and combine `activatable`. +- [ ] `tht auth check`, interactive device check, and aggregate `tht doctor` are redacted and + machine-readable. +- [ ] Frontend stores no token/session secret in Web Storage and SSE uses only the session cookie. +- [ ] Node 24.16, Pi, backend, frontend, harness, Compose, backup/restore, L2, and PSD gates pass. +- [ ] Documentation explains the mandatory `groups` claim and exact group-to-role mapping. diff --git a/docs/superpowers/specs/2026-08-16-thothii-authentication-design.md b/docs/superpowers/specs/2026-08-16-thothii-authentication-design.md new file mode 100644 index 00000000..35787b19 --- /dev/null +++ b/docs/superpowers/specs/2026-08-16-thothii-authentication-design.md @@ -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/.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.