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

1457 lines
57 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `<configDirectory>/.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<LocalUserRecord | undefined>;
findBySubject(id: string): Promise<LocalUserRecord | undefined>;
verify(user: LocalUserRecord | undefined, password: string): Promise<boolean>;
}
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<CreatedAuthSession>;
resolve(token: string, now?: Date): Promise<AuthSessionRecord | undefined>;
touch(token: string, now?: Date): Promise<void>;
revoke(token: string): Promise<void>;
prune(now?: Date): Promise<number>;
}
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<AuthPublicConfig>;
export function loginLocal(username: string, password: string, remember: boolean): Promise<AuthenticatedUser>;
export function logout(): Promise<void>;
```
- 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<URL>;
callback(input: { currentUrl: URL; state: string; nonce: string; codeVerifier: string }): Promise<OidcIdentity>;
diagnose(signal: AbortSignal): Promise<void>;
verifyDeviceFlow?(signal: AbortSignal, present: (uri: string, code: string) => void): Promise<OidcIdentity>;
}
```
- 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<readonly AuthDiagnostic[]>;
}
export interface AuthDiagnoser {
inspect(options: { live: boolean; interactive?: boolean; signal?: AbortSignal }): Promise<AuthDiagnostics>;
}
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.