From 91925d64bfa4b3e37a0d85773437b5e8a48e56f8 Mon Sep 17 00:00:00 2001 From: mptyl Date: Tue, 18 Aug 2026 03:33:00 +0200 Subject: [PATCH] docs(auth): address authentication guide review --- docs/architecture/authentication.md | 58 ++++++++- docs/install/authentication-oidc.md | 31 +++-- docs/install/authentik.md | 9 +- docs/install/local.md | 2 +- docs/install/psd-workspace-setup.md | 4 +- docs/install/reverse-proxy-caddy.md | 101 ++++++++-------- docs/install/reverse-proxy-nginx.md | 111 +++++++++-------- docs/install/server.md | 4 +- .../authentication-manual-acceptance.md | 20 ++-- mkdocs.yml | 1 - scripts/auth-docs-smoke.sh | 113 +++++++++++++++++- scripts/test-auth-docs-smoke.sh | 94 +++++++++++++++ 12 files changed, 406 insertions(+), 142 deletions(-) create mode 100755 scripts/test-auth-docs-smoke.sh diff --git a/docs/architecture/authentication.md b/docs/architecture/authentication.md index 0bbea56e..9e3b3e5a 100644 --- a/docs/architecture/authentication.md +++ b/docs/architecture/authentication.md @@ -14,8 +14,15 @@ read from the mounted secret bundle and are never placed in YAML, command argume diagnostics, or browser storage. Local users have Argon2id password hashes, stable IDs, enabled state, roles, and `authRevision`. -The stable roles are `user` and `admin`; `admin` inherits `user` and adds installation, session, -Pi, workspace, secret-management, and diagnostic permissions. +The production role expansion from `backend/src/auth/config.ts` is exact: + +| Role | Permissions | +|---|---| +| `user` | `session.use` | +| `admin` | `session.use`, `session.read_all`, `session.manage_all`, `settings.manage`, `workspace.manage`, `workspace.secrets.manage`, `pi.manage`, `auth.diagnostics.read` | + +`admin` therefore includes the ordinary `session.use` permission. No other role or permission +label is part of the production catalog. OIDC is provider-neutral at the browser protocol boundary. Authorization Code + PKCE, issuer, signature, audience, expiry, state, and nonce are validated before a principal is created. @@ -24,15 +31,54 @@ Authentik is the first certified group-catalog adapter, not a special browser lo ## Group authorization OIDC must return a direct, non-empty `groups` claim whose value is a JSON array of strings. -Missing, malformed, indirect, or overage-style claims fail closed with -`oidc_groups_claim_invalid`. Exact configured external group names map to Thoth roles and then to -permissions. A user with no mapped group is authenticated but receives no role and gets `403` from -protected routes. Unmapped upstream groups are ignored silently, without an error or warning. +Missing, malformed, indirect, or overage-style claims fail closed. The browser callback returns +HTTP 401 with the generic code `oidc_callback_failed`; it does not expose the internal reason. +Authentication diagnostics and interactive device-flow validation use +`oidc_groups_claim_invalid` for invalid group-claim/identity results. Exact configured external +group names map to Thoth roles and then to permissions. A user with no mapped group is +authenticated but receives no role and gets `403` from protected routes. Unmapped upstream groups +are ignored silently, without an error or warning. Every configured mapping is checked by the configured group-catalog adapter. Authentik checks the exact group name and reports `oidc_mapped_group_missing` when it cannot find it. The dedicated Authentik API service account has group-view-only privilege; it is separate from the OIDC client. +## Diagnostics and ordering + +The closed production diagnostic-code union is: + +```text +auth_ready +auth_config_incomplete +auth_config_invalid +auth_session_store_invalid +local_user_registry_invalid +local_admin_missing +oidc_secret_missing +oidc_discovery_unreachable +oidc_issuer_mismatch +oidc_jwks_unreachable +oidc_group_catalog_unreachable +oidc_group_catalog_unauthorized +oidc_mapped_group_missing +oidc_mapped_group_ambiguous +oidc_groups_claim_invalid +oidc_device_flow_unavailable +``` + +Workspace Validate performs static authentication validation without provider connectivity. +`tht auth check` performs live, non-interactive diagnosis: static safety plus OIDC discovery, +issuer/JWKS, group-catalog authentication, and exact configured-group existence. Adding +`--interactive` runs that same live diagnosis and then validates a device-flow identity when the +provider supports Device Authorization. Workspace Test is the aggregate live workspace and +authentication validation. + +The ordered `tht doctor` report is exactly: `descriptor`, `files`, `docker`, `compose`, +`configuration`, `authentication`, `services`, `core-http`, `frontend-http`, +`workspace-registry`, `workflow`, `pi`. Its `authentication` entry is the live non-interactive +diagnosis against the healthy running core; later checks may be skipped when an earlier +prerequisite fails. + ## Browser sessions The browser receives only an opaque `HttpOnly`, `SameSite=Lax` cookie named `thothii_session`. diff --git a/docs/install/authentication-oidc.md b/docs/install/authentication-oidc.md index ac61a228..5f364336 100644 --- a/docs/install/authentication-oidc.md +++ b/docs/install/authentication-oidc.md @@ -51,7 +51,10 @@ authorization: The ID token must contain a direct, non-empty `groups` array of strings. ThothII does not follow distributed claims, overage links, or indirect provider expansion. Missing or malformed claims fail -closed with `oidc_groups_claim_invalid`. +closed. A browser callback exposes only HTTP 401 `oidc_callback_failed`; it never reveals whether +the claim was missing, malformed, indirect, or overage-style. The redacted diagnostic and +interactive device-flow contract uses `oidc_groups_claim_invalid` for invalid group-claim or +device-flow identity results. Configured group names are exact and case-sensitive. The union of matched mappings determines the Thoth roles. A token with no mapped group is authenticated but has no role and cannot use protected @@ -61,7 +64,16 @@ provider may authenticate while still failing installation readiness. ## Checks and diagnostics -Run the static check first: +The surfaces have distinct semantics and this order is recommended: + +1. Workspace Validate performs static authentication validation without contacting the provider. +2. `tht auth check` performs live, non-interactive authentication diagnosis, including discovery, + issuer/JWKS, catalog credentials, and all configured mapped groups. +3. `tht auth check --interactive` repeats live diagnosis and additionally validates a device-flow + identity and its direct `groups` claim when Device Authorization is available. +4. Workspace Test performs aggregate live workspace and authentication validation. + +The live CLI forms are: ```sh tht auth check @@ -72,12 +84,11 @@ For a provider that advertises Device Authorization, `tht auth check --interacti verification URI and one-time user code on the terminal, waits for completion, and validates a real ID token including `groups`. It is an operator check, not a replacement for browser login. -Static checks cover configuration, secret references, group mappings, file safety, and session -storage. Live checks then cover discovery, issuer/JWKS, provider access, and configured groups. -`tht doctor` runs the authentication check after configuration and before service/workspace checks. -Workspace validation includes static authentication readiness; workspace Test adds live OIDC and -group-catalog checks. Any authentication failure makes the workspace non-activatable. +`tht doctor` emits this exact ordered report: `descriptor`, `files`, `docker`, `compose`, +`configuration`, `authentication`, `services`, `core-http`, `frontend-http`, +`workspace-registry`, `workflow`, `pi`. Its authentication entry is live and non-interactive. +Any authentication failure makes Workspace Validate or Workspace Test non-activatable according +to that surface's static or live scope. -The redacted diagnostic codes include `oidc_secret_missing`, `oidc_discovery_unreachable`, -`oidc_issuer_mismatch`, `oidc_jwks_unreachable`, `oidc_groups_claim_invalid`, -`oidc_mapped_group_missing`, and `oidc_mapped_group_ambiguous`. +The complete closed diagnostic-code union and exact role-to-permission expansion are in the +[authentication architecture](../architecture/authentication.md). diff --git a/docs/install/authentik.md b/docs/install/authentik.md index f75a9195..196e467d 100644 --- a/docs/install/authentik.md +++ b/docs/install/authentik.md @@ -12,7 +12,8 @@ generic OIDC; these steps configure the provider-specific group catalog only. in the protected bundle under `THT_AUTHENTIK_API_TOKEN`. 4. Create or confirm the exact groups `TOT Users` and `TOT Admin`. Map them explicitly in `auth.yaml` to `user` and `admin`, respectively. Keep other upstream groups out of the mapping. -5. Run the static check and then the live interactive check: +5. Run Workspace Validate for static authentication validation. Then run live non-interactive + diagnosis, followed by the optional device-flow identity check: ```sh tht auth check @@ -20,8 +21,10 @@ generic OIDC; these steps configure the provider-specific group catalog only. tht doctor --json ``` -6. Run workspace Validate and then workspace Test. Test must prove discovery/JWKS, catalog access, - and every configured group. The diagnostic result must contain no secret values. +6. Run Workspace Test for aggregate live workspace and authentication validation. It must prove + discovery/JWKS, catalog access, and every configured group. The diagnostic result must contain + no secret values. `tht doctor --json` reports `authentication` after `configuration` and before + `services` in its exact ordered checklist. Only configured exact group names are queried. Additional Authentik or directory groups are ignored silently, without a warning. A mapped group absent from Authentik fails closed with diff --git a/docs/install/local.md b/docs/install/local.md index 654a5fb8..6d9addb0 100644 --- a/docs/install/local.md +++ b/docs/install/local.md @@ -173,7 +173,7 @@ The canonical local Compose smoke uses the base file plus the local profile. Kee base+profile command available for install verification: ~~~sh -docker compose --env-file deploy/env/local.env + -f compose.yaml -f deploy/compose.local.yaml up --build -d +docker compose --env-file deploy/env/local.env -f compose.yaml -f deploy/compose.local.yaml up --build -d ~~~ After the stack is ready, configure and check authentication with the single host CLI tht; see diff --git a/docs/install/psd-workspace-setup.md b/docs/install/psd-workspace-setup.md index 1e86c332..9b5374ab 100644 --- a/docs/install/psd-workspace-setup.md +++ b/docs/install/psd-workspace-setup.md @@ -2,8 +2,8 @@ Authentication acceptance is documented in the [manual authentication matrix](../testing/authentication-manual-acceptance.md). Use generic OIDC with Authentik as the certified group catalog, map only the exact TOT Users and -TOT Admin groups, then run tht auth check, tht auth check --interactive, workspace Validate, -and workspace Test in that order. Browser callback E2E, native Windows execution, approved PSD +TOT Admin groups, then run Workspace Validate, `tht auth check`, `tht auth check --interactive`, +and Workspace Test in that order. Browser callback E2E, native Windows execution, approved PSD manual identities, external L2, and the two parked restore-lock preconditions remain pending the Task 15/release gates. diff --git a/docs/install/reverse-proxy-caddy.md b/docs/install/reverse-proxy-caddy.md index bb90b62c..ed675fa8 100644 --- a/docs/install/reverse-proxy-caddy.md +++ b/docs/install/reverse-proxy-caddy.md @@ -1,37 +1,50 @@ # Put ThothII behind Caddy -This example assumes Caddy runs on the Linux host, ThothII `frontend` listens only on -`127.0.0.1:8080`, public DNS points to the host, and a separate authentication gateway validates -the user's real login/session. Replace the domain and auth-gateway address. +Choose exactly one authentication mode. Direct ThothII-managed OIDC and deprecated upstream +authentication are mutually exclusive proxy contracts; never combine their directives. -For ThothII-managed generic OIDC, preserve the configured public origin and the exact callback -`/api/auth/oidc/callback`; the browser and API remain one same-origin surface. Run -`tht auth check` and workspace Test after proxy changes. +## Direct ThothII-managed OIDC -## Trust boundary +Use this mode when `auth.yaml` has `mode: oidc`. Caddy terminates TLS and proxies every request to +`frontend`; ThothII performs login, callback validation, session creation, and authorization. +Caddy must not apply `forward_auth` or another external authentication gateway. -Caddy is the only public listener. It provides automatic HTTPS, performs `forward_auth`, and -proxies only to `frontend`; frontend then sends same-origin `/api` requests to private `core`. -Never send the public reverse proxy to core port 8787. - -The authentication gateway must return 2xx only after validating a real credential or session. -The ordered `route` below deletes every public/private Thoth identity request header before auth. -Only on auth success does `copy_headers` rename normalized response claims into the private -`X-Thoth-Trusted-*` headers consumed by frontend. -Forwarding identity headers alone does not authenticate a user. - -Do not replace the authentication gateway with static `header_up` values, a network allowlist, or -browser-provided identity. The admin claim must come from reviewed identity-provider authorization. - -## Example configuration - -Save a reviewed site block in the Caddyfile. Caddy obtains and renews TLS certificates for the real -DNS name; use the organization's approved ACME issuer or certificate policy. +The public `/api/auth/oidc/login` and `/api/auth/oidc/callback` paths pass unchanged through the +same proxy as the rest of `/api`. The configured `publicUrl` must match the browser origin. ```caddyfile -thoth.example.com { +thoth.example.invalid { + reverse_proxy 127.0.0.1:8080 { + # No URI rewrite: OIDC login and callback paths reach frontend unchanged. + flush_interval -1 + header_up Host {host} + header_up X-Forwarded-Proto https + header_up X-Forwarded-Host {host} + } + + log { + output file /var/log/caddy/thoth-access.log + format json + } +} +``` + +After reload, run Workspace Validate for static validation, `tht auth check` for live, +non-interactive authentication diagnosis, and then Workspace Test for aggregate live validation. + +## Deprecated upstream migration mode + +Use this section only while the installation explicitly uses deprecated `upstream` mode. Do not +use it with `mode: oidc` or `mode: local`. Here an external authentication gateway owns login and +Caddy applies `forward_auth` before forwarding normalized private identity headers to `frontend`. + +Forwarding identity headers alone does not authenticate a user. The authentication gateway returns +2xx only after validating its own credential or session. Clear browser-supplied public and trusted +headers before the subrequest, and map identity only from the successful auth response. + +```caddyfile +thoth.example.invalid { route { - # Remove untrusted browser claims before the auth subrequest. request_header -X-Authenticated-User request_header -X-Thoth-Principal-Issuer request_header -X-Thoth-Principal-Subject @@ -53,27 +66,23 @@ thoth.example.com { } reverse_proxy 127.0.0.1:8080 { - # Disable response batching so SSE reaches the browser immediately. flush_interval -1 header_up Host {host} header_up X-Forwarded-Proto https } } - - log { - output file /var/log/caddy/thoth-access.log - format json - } } ``` -Configure log processing to remove cookies, authorization data, query strings, and identity -headers. Keep Caddy's private keys and state outside the ThothII source/operator directories. +## Trust boundary + +Caddy is the only public listener and proxies only to loopback `frontend`, never directly to +`core`. Configure access logs to omit cookies, authorization data, query strings, and identity +headers. Keep Caddy keys and state outside ThothII source and operator directories. ## Validate and reload -Keep the public firewall rule closed while validating. Confirm ThothII responds only on loopback, -format a review copy if desired, validate the active file, then reload through the service manager: +Keep the public firewall closed while validating: ```sh curl --fail http://127.0.0.1:8080/health @@ -81,21 +90,9 @@ caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile sudo systemctl reload caddy ``` -Do not remove `forward_auth` if validation fails. Correct the Caddy version, adapter syntax, -authentication upstream, DNS, or certificate policy instead. - ## Test authentication and SSE -Open the firewall only after all of these pass: - -1. An unauthenticated HTTPS request is redirected to login or returns 401/403. -2. Supplying forged `X-Thoth-Principal-*`, `X-Thoth-Is-Admin`, or `X-Thoth-Trusted-*` request - headers does not grant access. -3. A real authenticated non-admin can use the application but cannot open Pi Management. -4. A real authenticated admin can use Pi Management. -5. A browser session receives live model updates without batching; a protected - `curl --no-buffer` request using a real short-lived login cookie is also acceptable. Delete the - cookie jar immediately afterward. - -Re-run these checks after changing the identity provider, authentication gateway, Caddy, or -ThothII release. +For direct OIDC, verify the login path redirects to the configured provider, the callback reaches +ThothII unchanged, forged identity headers grant nothing, and SSE is unbuffered. For deprecated +upstream mode, additionally verify the external gateway rejects unauthenticated traffic and only +its 2xx response can create trusted identity headers. diff --git a/docs/install/reverse-proxy-nginx.md b/docs/install/reverse-proxy-nginx.md index 7cb5b70e..2277112a 100644 --- a/docs/install/reverse-proxy-nginx.md +++ b/docs/install/reverse-proxy-nginx.md @@ -1,44 +1,66 @@ # Put ThothII behind Nginx -This example assumes Nginx runs on the Linux host, ThothII `frontend` listens only on -`127.0.0.1:8080`, and a separate authentication gateway validates the user's real login/session. -Replace the documentation domain, certificate paths, and auth-gateway address. +Choose exactly one authentication mode. Direct ThothII-managed OIDC and deprecated upstream +authentication are mutually exclusive proxy contracts; never combine their locations or headers. -For ThothII-managed generic OIDC, preserve the configured public origin and the exact callback -`/api/auth/oidc/callback`; the browser and API remain one same-origin surface. Run -`tht auth check` and workspace Test after proxy changes. +## Direct ThothII-managed OIDC -## Trust boundary +Use this mode when `auth.yaml` has `mode: oidc`. Nginx terminates TLS and proxies every request +to loopback `frontend`. ThothII owns OIDC login, callback validation, browser sessions, and +authorization. No external `auth_request` or authentication gateway belongs in this server. -Nginx is the only public listener. It terminates TLS, performs an `auth_request`, and proxies only -to `frontend`; frontend then uses its private same-origin `/api` route to reach `core`. Never proxy -the public listener directly to core port 8787. - -The authentication gateway must return 2xx only after validating a real credential or session. It -returns normalized `X-Thoth-Principal-*` and `X-Thoth-Is-Admin` response headers. Nginx clears all -client-supplied public and private-hop identity headers and copies only those successful auth -response values into `X-Thoth-Trusted-*` on the private hop to frontend. -Forwarding identity headers alone does not authenticate a user. - -Do not substitute a static header, network allowlist, or client-provided header for the -authentication gateway. The admin value must come from reviewed identity-provider authorization, -not from a username supplied by the browser. - -## Example configuration - -Install an Nginx build that includes `ngx_http_auth_request_module`. Save a reviewed version of -this server block under the host's Nginx configuration directory: +The `location /` block below has a `proxy_pass` without a replacement URI, so public +`/api/auth/oidc/login` and `/api/auth/oidc/callback` are forwarded unchanged. The configured +`publicUrl` must match the browser origin. ```nginx server { listen 80; - server_name thoth.example.com; + server_name thoth.example.invalid; return 301 https://$host$request_uri; } server { listen 443 ssl; - server_name thoth.example.com; + server_name thoth.example.invalid; + + ssl_certificate /etc/nginx/tls/thoth/fullchain.pem; + ssl_certificate_key /etc/nginx/tls/thoth/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + + location / { + # No auth_request and no URI rewrite: ThothII receives OIDC paths unchanged. + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header Connection ""; + proxy_pass http://127.0.0.1:8080; + proxy_http_version 1.1; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 3600s; + add_header X-Accel-Buffering no always; + } +} +``` + +After reload, run Workspace Validate for static validation, `tht auth check` for live, +non-interactive authentication diagnosis, and then Workspace Test for aggregate live validation. + +## Deprecated upstream migration mode + +Use this section only while the installation explicitly uses deprecated `upstream` mode. Do not +use it with `mode: oidc` or `mode: local`. In this mode an external authentication gateway owns +login, and Nginx applies `auth_request` before forwarding normalized private identity headers. + +Forwarding identity headers alone does not authenticate a user. The authentication gateway returns +2xx only after validating its own credential or session. + +```nginx +server { + listen 443 ssl; + server_name thoth.example.invalid; ssl_certificate /etc/nginx/tls/thoth/fullchain.pem; ssl_certificate_key /etc/nginx/tls/thoth/privkey.pem; @@ -51,8 +73,6 @@ server { proxy_set_header Content-Length ""; proxy_set_header X-Original-URI $request_uri; proxy_set_header X-Original-Method $request_method; - - # The auth service derives identity from the real login/session, never these headers. proxy_set_header X-Authenticated-User ""; proxy_set_header X-Thoth-Principal-Issuer ""; proxy_set_header X-Thoth-Principal-Subject ""; @@ -75,7 +95,6 @@ server { auth_request_set $thoth_is_admin $upstream_http_x_thoth_is_admin; - # Discard browser claims. Carry only successful auth-subrequest values to frontend. proxy_set_header X-Authenticated-User ""; proxy_set_header X-Thoth-Principal-Issuer ""; proxy_set_header X-Thoth-Principal-Subject ""; @@ -85,7 +104,6 @@ server { proxy_set_header X-Thoth-Trusted-Principal-Subject $thoth_principal_subject; proxy_set_header X-Thoth-Trusted-Principal-Display-Name $thoth_principal_display_name; proxy_set_header X-Thoth-Trusted-Is-Admin $thoth_is_admin; - proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto https; proxy_set_header X-Forwarded-Host $host; @@ -93,8 +111,6 @@ server { proxy_set_header Connection ""; proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; - - # SSE must reach the browser without response buffering or cache delay. proxy_buffering off; proxy_cache off; proxy_read_timeout 3600s; @@ -103,13 +119,15 @@ server { } ``` -Keep the certificate private key outside the ThothII tree. Do not log cookies, authorization -headers, auth response bodies, or trusted identity headers. +## Trust boundary + +Nginx is the only public listener and proxies only to loopback `frontend`, never directly to +`core`. Keep private keys outside the ThothII tree. Do not log cookies, authorization headers, +OIDC callback query values, authentication bodies, or trusted identity headers. ## Validate and reload -First keep the public firewall rule closed. Confirm ThothII responds only on loopback, validate the -full Nginx configuration, then reload without stopping existing connections: +Keep the public firewall closed while validating: ```sh curl --fail http://127.0.0.1:8080/health @@ -117,20 +135,9 @@ sudo nginx -t sudo systemctl reload nginx ``` -If `nginx -t` reports that `auth_request` is unknown, install the distribution package/module that -provides it. Do not remove authentication to make the syntax check pass. - ## Test authentication and SSE -Open the firewall only after all of these pass: - -1. An unauthenticated HTTPS request is redirected to login or returns 401/403. -2. Supplying forged `X-Thoth-Principal-*`, `X-Thoth-Is-Admin`, or `X-Thoth-Trusted-*` request - headers does not grant access. -3. A real authenticated non-admin can use the application but cannot open Pi Management. -4. A real authenticated admin can use Pi Management. -5. A browser session receives live model updates for longer than the default proxy timeout without - batching; a protected `curl --no-buffer` test using a real short-lived login cookie is also - acceptable. Delete the cookie jar immediately afterward. - -Re-run these checks after changing the identity provider, auth gateway, Nginx, or ThothII release. +For direct OIDC, verify login redirects to the configured provider, callback traffic reaches +ThothII unchanged, forged identity headers grant nothing, and SSE is unbuffered. For deprecated +upstream mode, additionally verify the external gateway rejects unauthenticated traffic and only +its 2xx response can create trusted identity headers. diff --git a/docs/install/server.md b/docs/install/server.md index 1e7f2c41..c7766a1b 100644 --- a/docs/install/server.md +++ b/docs/install/server.md @@ -3,7 +3,9 @@ Server authentication uses generic OIDC with the reverse proxy preserving the configured public origin and callback path. Follow the [OIDC guide](authentication-oidc.md), [Authentik guide](authentik.md) when applicable, and the [authentication acceptance matrix](../testing/authentication-manual-acceptance.md). -The host authentication CLI is tht; use tht auth check before workspace Validate/Test. +The host authentication CLI is `tht`: Workspace Validate is static, `tht auth check` is live and +non-interactive, `--interactive` adds device-flow identity validation, and Workspace Test is the +aggregate live gate. This guide is for an installer with basic Linux administration and very basic Docker knowledge. It deploys the same Compose distribution used on a local PC: the mandatory application is exactly diff --git a/docs/testing/authentication-manual-acceptance.md b/docs/testing/authentication-manual-acceptance.md index aa4269d6..84931ac8 100644 --- a/docs/testing/authentication-manual-acceptance.md +++ b/docs/testing/authentication-manual-acceptance.md @@ -12,10 +12,13 @@ URLs, directory/LDAP details, tokens, passwords, hashes, cookies, or realistic s that lock immediately before extraction; replace convention-only `createWithDependenciesLockHeld` with an opaque installation-bound transaction capability or closure so lock-held primitives cannot be called without the capability. -2. Run `tht auth check`, then `tht auth check --interactive` where Device Authorization is - available, then workspace Validate and workspace Test. Run `tht doctor --json` and confirm its - `authentication` check precedes service and workspace checks. -3. Confirm the exact direct `groups` claim for both identities and the mappings `TOT Users → user` +2. Run Workspace Validate first; it is the static authentication gate. Run `tht auth check` for + live non-interactive diagnosis, then `tht auth check --interactive` where Device Authorization + is available, then Workspace Test for aggregate live validation. +3. Run `tht doctor --json` and confirm this exact report order: `descriptor`, `files`, `docker`, + `compose`, `configuration`, `authentication`, `services`, `core-http`, `frontend-http`, + `workspace-registry`, `workflow`, `pi`. +4. Confirm the exact direct `groups` claim for both identities and the mappings `TOT Users → user` and `TOT Admin → admin`. Confirm extra upstream groups are ignored without warning. ## Matrix @@ -24,18 +27,19 @@ URLs, directory/LDAP details, tokens, passwords, hashes, cookies, or realistic s |---|---| | Ordinary identity opens its own application/session routes | Allowed; admin-only routes return `403`. | | Admin identity opens admin routes | Allowed according to the `admin` permission set. | -| Token omits `groups` | Authentication fails closed with `oidc_groups_claim_invalid`. | -| Token has malformed, indirect, or overage groups | Authentication fails closed with `oidc_groups_claim_invalid`. | +| Browser callback token omits `groups` | Callback returns HTTP 401 `oidc_callback_failed`; the internal reason is not exposed. | +| Browser callback token has malformed, indirect, or overage groups | Callback returns HTTP 401 `oidc_callback_failed`; the internal reason is not exposed. | +| Interactive diagnostic receives missing or invalid groups | Diagnostic fails with `oidc_groups_claim_invalid`. | | Token has no mapped group | Principal has no role; protected routes return `403`; no warning is emitted. | | A configured group is absent from Authentik | Check fails with `oidc_mapped_group_missing`. | -| Catalog token is wrong or lacks group-view-only access | Check fails redacted as catalog unavailable/unauthorized. | +| Catalog token is wrong or lacks group-view-only access | Live check fails redacted with `oidc_group_catalog_unauthorized`. | | Mapped group is renamed | The next check fails closed until configuration and provider agree. | | Token adds an unrelated group | Login and authorization are unchanged; no warning is emitted. | | Backend restarts with Remember me | Remembered local session survives within its TTL. | | Password/role/enable revision changes | Affected local sessions are rejected and reauthentication is required. | | CSRF or cross-origin mutation is attempted | Request is rejected. | | Logout | Cookie expires and the server session is deleted. | -| Provider outage | Live check and OIDC login fail closed; no credential is exposed. | +| Provider outage | Live check reports `oidc_discovery_unreachable`; browser login fails closed without exposing credentials. | | Restore is completed | Sessions and OIDC state are absent; all users must reauthenticate. | ## Status at Task 14 diff --git a/mkdocs.yml b/mkdocs.yml index 2f24d3d0..df7957de 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -48,7 +48,6 @@ markdown_extensions: nav: - Home: index.md - Guida utente: guida-utente.md -- Autenticazione: architecture/authentication.md - Accettazione autenticazione: testing/authentication-manual-acceptance.md - Setup Policlinico San Donato: install/psd-workspace-setup.md - ThothII (Documentazione Tecnica): diff --git a/scripts/auth-docs-smoke.sh b/scripts/auth-docs-smoke.sh index 2e4e2330..92478679 100755 --- a/scripts/auth-docs-smoke.sh +++ b/scripts/auth-docs-smoke.sh @@ -1,7 +1,15 @@ #!/usr/bin/env bash set -euo pipefail -root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P) +script_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P) +root=$script_root +if [[ $# -gt 0 ]]; then + [[ $# -eq 2 && $1 == "--root" && -d $2 ]] || { + echo "usage: auth-docs-smoke.sh [--root DIRECTORY]" >&2 + exit 2 + } + root=$(cd "$2" && pwd -P) +fi docs=( "$root/docs/architecture/authentication.md" "$root/docs/install/authentication-local.md" @@ -18,6 +26,7 @@ docs=( "$root/docs/index.md" "$root/README.md" "$root/PROJECT_STATE.md" + "$root/mkdocs.yml" ) for path in "${docs[@]}"; do @@ -36,21 +45,113 @@ required=( "THT_AUTHENTIK_API_TOKEN" "Remember me" "oidc_mapped_group_missing" + "oidc_callback_failed" + "session.read_all" + "workspace.secrets.manage" + "auth.diagnostics.read" ) for term in "${required[@]}"; do rg -Fq "$term" "$corpus" || { echo "auth docs smoke: missing required term: $term" >&2; exit 1; } done -if rg -n -i 'thothii-admin|thothctl[[:space:]]+auth' "$corpus"; then +canonical_compose='docker compose --env-file deploy/env/local.env -f compose.yaml -f deploy/compose.local.yaml up --build -d' +rg -Fxq "$canonical_compose" "$root/docs/install/local.md" || { + echo "auth docs smoke: missing canonical local Compose command" >&2 + exit 1 +} +if rg -n -F 'docker compose --env-file deploy/env/local.env +' "$corpus"; then + echo "auth docs smoke: noncanonical local Compose command" >&2 + exit 1 +fi + +nav_count=$(awk 'index($0, "architecture/authentication.md") { count++ } END { print count + 0 }' "$root/mkdocs.yml") +[[ $nav_count == 1 ]] || { + echo "auth docs smoke: authentication navigation must appear exactly once" >&2 + exit 1 +} + +python3 - "$root" <<'PY' +import pathlib +import re +import sys + +root = pathlib.Path(sys.argv[1]) +architecture = (root / "docs/architecture/authentication.md").read_text() + +user_row = "| `user` | `session.use` |" +admin_row = ( + "| `admin` | `session.use`, `session.read_all`, `session.manage_all`, `settings.manage`, " + "`workspace.manage`, `workspace.secrets.manage`, `pi.manage`, `auth.diagnostics.read` |" +) +if user_row not in architecture or admin_row not in architecture: + raise SystemExit("auth docs smoke: role-to-permission map is not exact") + +diagnostic_heading = "## Diagnostics and ordering" +diagnostic_start = architecture.find(diagnostic_heading) +diagnostic_end = architecture.find("\n## ", diagnostic_start + len(diagnostic_heading)) +diagnostic_section = architecture[diagnostic_start:diagnostic_end if diagnostic_end >= 0 else None] +match = re.search(r"```text\n([\s\S]*?)```", diagnostic_section) +expected_codes = [ + "auth_ready", + "auth_config_incomplete", + "auth_config_invalid", + "auth_session_store_invalid", + "local_user_registry_invalid", + "local_admin_missing", + "oidc_secret_missing", + "oidc_discovery_unreachable", + "oidc_issuer_mismatch", + "oidc_jwks_unreachable", + "oidc_group_catalog_unreachable", + "oidc_group_catalog_unauthorized", + "oidc_mapped_group_missing", + "oidc_mapped_group_ambiguous", + "oidc_groups_claim_invalid", + "oidc_device_flow_unavailable", +] +actual_codes = [] if match is None else [line for line in match.group(1).splitlines() if line] +if actual_codes != expected_codes: + raise SystemExit("auth docs smoke: diagnostic code union is not exact") + +for relative, language, forbidden, required in [ + ("docs/install/reverse-proxy-caddy.md", "caddyfile", "forward_auth", "forward_auth"), + ("docs/install/reverse-proxy-nginx.md", "nginx", "auth_request", "auth_request"), +]: + source = (root / relative).read_text() + direct_start = source.find("## Direct ThothII-managed OIDC") + deprecated_start = source.find("## Deprecated upstream migration mode") + if direct_start < 0 or deprecated_start <= direct_start: + raise SystemExit(f"auth docs smoke: {relative} does not split direct and deprecated modes") + direct = source[direct_start:deprecated_start] + deprecated_end = source.find("\n## ", deprecated_start + 4) + deprecated = source[deprecated_start:deprecated_end if deprecated_end >= 0 else None] + blocks = re.findall(rf"```{language}\n([\s\S]*?)```", direct) + direct_code = "\n".join(blocks) + if "/api/auth/oidc/login" not in direct or "/api/auth/oidc/callback" not in direct: + raise SystemExit(f"auth docs smoke: {relative} omits unchanged public OIDC paths") + if re.search(rf"(?m)^\s*{forbidden}\b", direct_code): + raise SystemExit(f"auth docs smoke: {relative} applies external auth in direct OIDC mode") + deprecated_code = "\n".join( + re.findall(rf"```{language}\n([\s\S]*?)```", deprecated) + ) + if not re.search(rf"(?m)^\s*{required}\b", deprecated_code): + raise SystemExit(f"auth docs smoke: {relative} omits scoped deprecated upstream auth") +PY + +if rg -n -i --pcre2 '\bthothii-admin\b|\bthothctl\b[^\r\n]{0,256}\bauth\b' "$corpus"; then echo "auth docs smoke: forbidden authentication CLI wording" >&2 exit 1 fi -if rg -n -i 'password[[:space:]]*[:=][[:space:]]*(?!<|YOUR|REPLACE|CHANGE|FILE|PROMPT)' --pcre2 "$corpus"; then - echo "auth docs smoke: plaintext password example" >&2 +if rg -n -i --pcre2 -- '--password(?!-file)\b(?:[[:space:]]+|=)\S+' "$corpus"; then + echo "auth docs smoke: plaintext password option" >&2 exit 1 fi -if rg -n -i 'unmapped[^.]{0,100}(generate|produce|emit|cause)[^.]{0,100}(warning|warn)|unmapped[^.]{0,100}warning(s)?[[:space:]]+(are|is)[[:space:]]+emitted' "$corpus"; then - echo "auth docs smoke: misleading warning claim for unmapped groups" >&2 +if rg -n -i --pcre2 '(?:^|[,{[:space:]])password[[:space:]]*:[[:space:]]*\S+|"password"[[:space:]]*:[[:space:]]*(?:"[^"]+"|[^,}[:space:]]+)' "$corpus"; then + echo "auth docs smoke: plaintext password field" >&2 + exit 1 +fi +if rg -n -i --pcre2 '(?:unmapped|additional|extra)[^.\r\n]{0,160}groups?[^.\r\n]{0,160}(?:generate|produce|emit|cause|trigger|raise|create|result)[^.\r\n]{0,160}(?:warnings?|alerts?|advisory|advisories|notices?|notifications?|noise)|(?:warnings?|alerts?|advisory|advisories|notices?|notifications?|noise)[^.\r\n]{0,160}(?:generate|produce|emit|cause|trigger|raise|create|result)[^.\r\n]{0,160}(?:unmapped|additional|extra)[^.\r\n]{0,160}groups?' "$corpus"; then + echo "auth docs smoke: misleading noise claim for unmapped groups" >&2 exit 1 fi diff --git a/scripts/test-auth-docs-smoke.sh b/scripts/test-auth-docs-smoke.sh new file mode 100755 index 00000000..0c74c57c --- /dev/null +++ b/scripts/test-auth-docs-smoke.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +set -euo pipefail + +root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P) +tmp=$(mktemp -d "${TMPDIR:-/tmp}/thoth-auth-docs.XXXXXX") +trap 'rm -rf "$tmp"' EXIT + +files=( + docs/architecture/authentication.md + docs/install/authentication-local.md + docs/install/authentication-oidc.md + docs/install/authentik.md + docs/testing/authentication-manual-acceptance.md + docs/architecture/overview.md + docs/install/local.md + docs/install/server.md + docs/install/psd-workspace-setup.md + docs/install/reverse-proxy-caddy.md + docs/install/reverse-proxy-nginx.md + docs/guida-utente.md + docs/index.md + README.md + PROJECT_STATE.md + mkdocs.yml +) + +make_fixture() { + local name=${1:?fixture name required} + local fixture="$tmp/$name" + mkdir -p "$fixture" + for relative in "${files[@]}"; do + mkdir -p "$fixture/$(dirname "$relative")" + cp "$root/$relative" "$fixture/$relative" + done + printf '%s\n' "$fixture" +} + +expect_rejected() { + local name=${1:?fixture name required} + local fixture_text=${2:?fixture text required} + local expected=${3:?expected error required} + local fixture output + fixture=$(make_fixture "$name") + printf '%s\n' "$fixture_text" >>"$fixture/README.md" + output="$tmp/$name.output" + if "$root/scripts/auth-docs-smoke.sh" --root "$fixture" >"$output" 2>&1; then + echo "auth docs fixture unexpectedly passed: $name" >&2 + exit 1 + fi + rg -Fq "$expected" "$output" || { + echo "auth docs fixture failed for the wrong reason: $name" >&2 + sed -n '1,20p' "$output" >&2 + exit 1 + } + echo "auth docs negative fixture rejected: $name" +} + +positive=$(make_fixture positive) +printf '%s\n' \ + 'Use --password-file ; never pass a password value.' \ + 'Additional unmapped groups are silently ignored without warnings, alerts, or advisories.' \ + >>"$positive/README.md" +"$root/scripts/auth-docs-smoke.sh" --root "$positive" >/dev/null +echo "auth docs positive fixture passed" + +expect_rejected thothctl-intervening \ + 'Run thothctl --installation --json auth check.' \ + 'forbidden authentication CLI wording' +expect_rejected alternate-admin \ + 'Run thothii-admin users list.' \ + 'forbidden authentication CLI wording' +expect_rejected password-option \ + 'Run tht auth user add demo --password example-value.' \ + 'plaintext password option' +expect_rejected yaml-password \ + 'password: example-value' \ + 'plaintext password field' +expect_rejected json-password \ + '{"password": "example-value"}' \ + 'plaintext password field' +expect_rejected unmapped-warning \ + 'Unmapped OIDC groups generate warnings.' \ + 'misleading noise claim for unmapped groups' +expect_rejected unmapped-alert \ + 'Unmapped provider groups trigger operator alerts.' \ + 'misleading noise claim for unmapped groups' +expect_rejected unmapped-advisory \ + 'An advisory is emitted for every unmapped group.' \ + 'misleading noise claim for unmapped groups' +expect_rejected compose-plus \ + 'docker compose --env-file deploy/env/local.env + -f compose.yaml -f deploy/compose.local.yaml up --build -d' \ + 'noncanonical local Compose command' + +echo "auth docs smoke fixture suite passed"