1457 lines
57 KiB
Markdown
1457 lines
57 KiB
Markdown
# 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.
|