Deployment: non-auth vs optional auth (Phase 37–38)¶
The API supports two coherent deployment modes (single codebase, configuration-driven). Phase 38 adds real identity for auth mode: JWT (HS256) and API keys (hashed in SQLite), while keeping non-auth and legacy modes.
Non-auth mode (default)¶
Auth:Enabledisfalse(omit the section or set explicitly).GET /api/analyses/{id}andGET /api/comparisons/{id}behave like capability URLs: anyone who knows the opaque id can load the snapshot (same as Phase 36).- No user identity is required for reads or writes.
- Suitable for local development and small trusted networks.
Auth-enabled mode¶
Set Auth:Enabled to true and choose Auth:Mode (case-insensitive):
| Mode | Identity source | Stored owner id |
|---|---|---|
ProxyHeaders |
Trusted proxy headers (X-PQAT-User, optional groups) |
Header user id |
BearerSubject |
Legacy (Phase 37): Authorization: Bearer <opaque> — entire bearer string is the user id (no JWT parsing). |
Raw bearer string |
JwtBearer |
Authorization: Bearer <JWT> — HS256, validated in-process |
sub claim (or Auth:Jwt:SubjectClaim) |
ApiKey |
X-Api-Key (or Auth:ApiKey:HeaderName) |
Stable UserId from SQLite mapping (key stored as SHA-256 hash only) |
JWT mode (JwtBearer)¶
Configure Auth:Jwt:
Issuer,Audience— required when mode isJwtBearer(startup fails if missing).SigningKeyBase64— symmetric key (recommended; base64, decodes to 32+ bytes for HS256), orSigningKey(UTF-8 secret; dev only).SubjectClaim— defaultsub.GroupsClaimNames— defaultgroups,roles; values may be comma-separated or a JSON array string in a single claim.
Invalid or expired JWTs → 401 { "error": "invalid_token", ... } when a Bearer token is present. Missing Bearer → no identity (401 on writes if identity required).
API key mode (ApiKey)¶
Auth:ApiKey:HeaderName— defaultX-Api-Key.Auth:ApiKey:Seeds— optional bootstrap rows (plaintextKeyonly in trusted config; stored hashed). Each seed:UserId, optionalGroups,DisplayName,Enabled,Description.
Unknown key or disabled key → 401 (invalid_api_key / api_key_disabled).
Common settings¶
Auth:RequireIdentityForWrites(defaulttrue):POST /api/analyzeandPOST /api/comparerequire a resolved identity (401 if missing).Auth:DefaultAccessScope: defaultprivate,link,group, orpublicfor new artifacts.- Groups (proxy / legacy bearer): comma-separated
X-PQAT-Groups(header names:Auth:ProxyUserGroupsHeader, delimiterAuth:GroupsDelimiter). User id header defaults toX-PQAT-User(Auth:ProxyUserIdHeader).
Access scopes (persisted on each artifact)¶
| Scope | Meaning |
|---|---|
private |
Owner only (+ manage sharing via PUT …/sharing). |
group |
Owner plus members of sharedGroupIds (ids must appear in the viewer’s group list). |
public |
Any authenticated user (Auth:Enabled + identity on the request). |
link |
Opaque URL access when allowLinkAccess is true (capability-style within auth mode). |
PUT /api/analyses/{id}/sharing and PUT /api/comparisons/{id}/sharing: 401 without identity, 403 if not owner, 400 if auth is disabled.
Legacy rows and migration (honest)¶
- Pre–Phase 37 SQLite rows may lack ACL columns; the store backfills safe defaults so old share links often keep working.
BearerSubject(legacy) artifacts storeownerUserId= entire raw bearer string. That is not the same namespace asJwtBearer(sub) orApiKey(mapped user id). Switching auth modes can strand old rows: owners no longer match. Mitigations: widen scope to link/public, re-create artifacts, or one-off SQL updates (out of scope for the app).
Rate limiting (optional)¶
When RateLimiting:Enabled is true, a fixed-window limiter applies to POST /api/analyze and POST /api/compare (policy pqatWrite). Configure RateLimiting:PermitLimit and RateLimiting:WindowSeconds. 429 with rate_limit_exceeded when exceeded.
Remote backup / export¶
Not implemented in Phase 38. Operate on the SQLite file (volume snapshot, sqlite3 .backup, or file copy when the API is stopped). Documented as a natural next step for production.
Frontend¶
-
Fonts (Phase 56): The SPA bundles @fontsource packages; strict CSP or air-gapped deploys do not need
fonts.googleapis.com. If you strip font files from the build, CSS still defines--sans/--heading/--monostacks so the UI stays readable with system faces. -
GET /api/configexposesauthEnabled,authMode,authIdentityKind(none|proxy|legacy_bearer|jwt|api_key),authHelp(non-secret summary),rateLimitingEnabled, and storage path. VITE_AUTH_BEARER_TOKEN— addsAuthorization: Bearer …(JWT or legacy bearer).VITE_AUTH_API_KEY— addsX-Api-Key(if set, preferred over bearer for browser calls).- Phase 55: When auth is on, the Sharing & access panel (Analyze/Compare summary) surfaces
authHelpand browser env hints inside a compact info callout; controls use sharedpqat-*inputs/buttons for consistency with the rest of the workstation. - Phase 56: Persisted and request errors on Analyze and Compare share
ArtifactErrorBanner(artifactErrorPresentation) so access denied, not found, 422 corrupt, 409 schema, and parse failures use the same severity classes and human titles as the rest of the workstation state language. - Phase 57: Error bodies use tone-specific kickers (Policy / Notice / Error) instead of a single “Error:” prefix; Sharing & access uses
pqat-metaPanel+pqat-authHelpCardaligned with workspace customizer chrome.
Reverse proxies, nginx, and auth headers (Phase 53)¶
The browser talks to the same origin as the SPA; the UI’s fetch('/api/…') must reach the API with credentials intact.
Authorization— required forJwtBearerand legacy bearer modes.X-Api-Key(or whateverAuth:ApiKey:HeaderNameis) — required for ApiKey mode when the client sends a key.
Docker Compose web image: nginx.conf proxies /api/ to the API. nginx forwards normal client headers to proxy_pass by default; the repo documents this in nginx.conf comments. If you insert another reverse proxy (TLS terminator, CDN, corporate gateway), configure it to forward Authorization, X-Api-Key, and (for ProxyHeaders mode) X-PQAT-User / X-PQAT-Groups — do not strip them.
Trusted proxy (ProxyHeaders) mode expects the edge to authenticate the user and set identity headers before traffic hits the API; the API does not validate a password at the HTTP layer for that mode.
Environment variables¶
ASP.NET Core binds nested config: Auth__Enabled, Auth__Mode, Auth__Jwt__Issuer, Auth__Jwt__Audience, Auth__Jwt__SigningKeyBase64, Auth__ApiKey__HeaderName, RateLimiting__Enabled, etc.
Automated browser E2E (Phase 52–54)¶
The stack runs one auth mode at a time. Playwright projects and .env.testing.* files are paired; see docs/contributing.md and scripts/e2e-playwright-docker.sh --help.
| Mode | Browser-covered? | Env / mechanism |
|---|---|---|
| None (smoke) | Yes | .env.testing, project e2e-smoke |
| ApiKey | Yes | .env.testing.auth, installApiKeyRoute → X-Api-Key on /api/* |
| JwtBearer | Yes | .env.testing.jwt, installBearerRoute → Authorization: Bearer (HS256 via jwtMint.ts) |
| ProxyHeaders | Yes (Phase 54) | .env.testing.proxy, installProxyHeadersRoute → X-PQAT-User / optional X-PQAT-Groups on /api/* (simulates an authenticated edge) |
| BearerSubject (legacy) | No | Not browser-E2E’d; verify via integration or manual calls. Opaque bearer-as-user-id is easy to confuse with JWT in automation. |
JWT test secret: UTF-8 pqat-jwt-e2e-sign-secret-key-32b → base64 cHFhdC1qd3QtZTJlLXNpZ24tc2VjcmV0LWtleS0zMmI=; subjects PQAT_JWT_SUB_A / PQAT_JWT_SUB_B.
ProxyHeaders E2E identities: PQAT_PROXY_USER_ID_A / PQAT_PROXY_USER_ID_B (defaults e2e-proxy-user-a / e2e-proxy-user-b) — test-only strings, not secrets.
- Never ship
.env.testing.*files’ keys to production; they are test-only. POST /api/e2e/seed/*remains gated byE2E:Enabled(PQAT_E2E_ENABLED), independent of auth mode.
Trust summary¶
| Mode | Trust boundary |
|---|---|
| Non-auth | Anyone with the id can read; protect network exposure. |
| Auth + proxy headers | The proxy must authenticate users and set headers; the API trusts them. |
| Legacy BearerSubject | Whoever can present the same full bearer string is that user (opaque; not a JWT). |
| JwtBearer | Only valid JWTs for the configured issuer/audience/key; owner = subject claim. |
| ApiKey | Whoever has a valid enabled key maps to a stable UserId; keys are not stored in plaintext in SQLite. |
SQLite remains one file per API instance; multi-instance deployments need a shared database path for consistent ACLs and API key rows.