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

57 KiB
Raw Blame History

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:

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:

frontend/src/auth/
  AuthGate.tsx
  LoginPage.tsx
  authState.ts
frontend/src/api/auth.ts

New Go files are:

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:

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:

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:

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:

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:

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

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:

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:

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

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:

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:

{"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:

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:

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

[{"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:

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

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:

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:

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

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

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

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

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

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
cd frontend
npx vitest run
npx tsc -b
npm run build
  • Step 8: Commit the authenticated frontend
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:
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
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
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:
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:

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

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

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:

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

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 scripts/auth-docs-smoke.sh
python -m mkdocs build --strict
  • Step 7: Commit operator and user documentation
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
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
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:

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