feat(server): self-issued bearer tokens + --no-auth flag

zddc-server now issues its own bearer tokens for non-browser callers
(CLI tools, scripts, downstream proxy/cache/mirror instances). No
external IDP, no JWKS rotation. Self-service flow: sign in via the
browser, visit /.tokens, click "Create token," paste the resulting
plaintext into a 0600 file, and pass --bearer-file <path> to whatever
calls back into the server.

Storage is <ZDDC_ROOT>/.zddc.d/tokens/<sha256-hex>, YAML per token
with email/created/expires/description. Filename is the *hash* of the
plaintext, never the plaintext itself — a leak of the tokens
directory exposes hashes, not credentials. Mode 0600 / 0700, atomic
writes via temp+rename. Already shielded from public serving by the
existing dot-prefix guards in dispatch and fs.ListDirectory.

ACLMiddleware now recognises Authorization: Bearer <token>. On valid
token, sets the request email from the token file and falls through
to the existing ACL chain. On any failure (unknown / expired / store
unavailable / Bearer with no validator), returns 401 — no silent
fallback to anonymous, so a misconfigured client fails loudly.

JSON API at /.api/tokens (GET list, POST create, DELETE /<id> revoke)
backs a small inline HTML self-service page at /.tokens. Users can
only see and revoke their own tokens; cross-user revoke returns 404
to avoid leaking ownership.

--no-auth (ZDDC_NO_AUTH=1) skips ACL enforcement entirely on this
instance. On master: anyone reads everything (dev / trusted-LAN /
public-read deployments). On a downstream proxy/cache/mirror: trust
upstream's filtering, don't re-evaluate ACLs locally. Implemented as
a swap to policy.AllowAllDecider; all existing handlers keep calling
AllowFromChain unchanged. Distinct from --insecure, which only
relaxes the no-root-.zddc startup check. WARN-level startup log when
--no-auth is active so accidental enablement is visible.

33 new tests covering token storage, validation/expiry/revocation,
the JSON API end-to-end, the HTML page, and the middleware-Bearer
integration including the case-insensitive prefix and expired-token
paths. Full suite + go vet clean.

Doc updates: zddc/README.md "Authentication" rewritten to cover both
auth paths and the token UI/API; AGENTS.md gains ZDDC_NO_AUTH and a
"Bearer tokens" subsection flagging the dot-prefix-shielding pre-
condition; ARCHITECTURE.md adds "Bearer token issuance" and
"--no-auth" subsections under "Server security model" with the
hash-as-filename rationale and dispatch-shielding regression-
sensitivity called out; CLAUDE.md adds a one-line summary of the new
auth topology so future agents pick it up by default.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-08 07:40:28 -05:00
parent 562b105550
commit 97ffaac13b
14 changed files with 1733 additions and 40 deletions

View file

@ -446,12 +446,30 @@ ZDDC_ROOT=/path/to/your/archive ZDDC_TLS_CERT=none ZDDC_ADDR=:8080 \
| `ZDDC_LOG_LEVEL` | `info` | Logging verbosity |
| `ZDDC_CORS_ORIGIN` | *(empty)* | Comma-separated CORS allowlist; empty (default) disables CORS — appropriate for embedded-tools deployments where tools and data are same-origin. Set explicitly only for self-hosted tools at a different host (e.g. `https://tools.acme.com`) or the CDN-bootstrap pattern (`https://zddc.varasys.io`). |
| `ZDDC_INSECURE` | *(empty)* | Must be `1` to allow startup with no `<ZDDC_ROOT>/.zddc`. Without it, the server refuses to start because no `.zddc` files anywhere → public-by-default. Set only for deliberately-public archives. |
| `ZDDC_NO_AUTH` | *(empty)* | `1` skips ACL enforcement entirely on this instance. On a master: anyone reads everything (dev / trusted-LAN read-only deployments). On a downstream proxy/cache/mirror: trust upstream's filtering, don't re-evaluate ACLs locally. **Distinct from `ZDDC_INSECURE`** (which gates a startup safety check). |
| `ZDDC_OPA_URL` | `internal` | Policy decider endpoint. `internal` (default) = in-process Go evaluator (same `.zddc` cascade we always had). `http(s)://...` or `unix:///...` = external OPA — every access decision becomes a `POST /v1/data/zddc/access/allow` to the configured endpoint. Federal customers with their own audited Rego use this; commercial deployments leave it `internal`. |
| `ZDDC_OPA_FAIL_OPEN` | *(empty)* | External OPA only. `1` = allow on transport error; default = fail closed (deny). |
| `ZDDC_OPA_CACHE_TTL` | `1s` | External OPA only. Per-decision cache TTL — amortizes round-trips on bursty patterns (e.g. `.archive` listings hit the same `(email, dir)` tuple many times). `0` disables. Format is Go `time.ParseDuration`. |
| `ZDDC_APPS_PUBKEY` | *(empty)* | Path to PEM Ed25519 pubkey for verifying signatures on URL-fetched `apps:` artifacts. Empty = URL apps refused. Download from `zddc.varasys.io/pubkey.pem` (canonical channels) or supply your own. No baked-in default — same posture as TLS certs. Alternative inline form: `apps_pubkey:` in root `.zddc` (root-only, env/flag wins). |
| `ZDDC_ACCESS_LOG` | `<ZDDC_ROOT>/.zddc.d/logs/access-<host>.log` | JSON-line audit log (lumberjack-rotated, 100 MB / 10 backups / 90 days, gzipped). Server auto-mkdirs the parent. Set explicitly to empty (`--access-log=`) to disable. Per-host filename + `host` field in every record so multi-replica deployments writing to the same `.zddc.d/` dir disambiguate cleanly. |
### Bearer tokens (CLI auth)
zddc-server self-issues bearer tokens for CLI / non-browser callers. No external IDP, no JWKS rotation. Source of truth: `<ZDDC_ROOT>/.zddc.d/tokens/<sha256-hex>` — a YAML file per token with `email`, `created`, `expires`, `description`. Filename is the **hash** of the token; the plaintext is never persisted.
User flow: sign in to the master in a browser, visit `/.tokens`, click "Create token," copy the value (shown once). Store in a 0600 file and pass `--bearer-file <path>` to a CLI that calls back into zddc-server, or send `Authorization: Bearer <token>` directly from scripts.
Endpoints:
- `GET /.tokens` — HTML self-service page (gated by browser auth).
- `GET/POST /.api/tokens` — list / create. Plaintext returned **only** on POST response.
- `DELETE /.api/tokens/<id>` — revoke. `<id>` is the 8-char short ID or full hash.
Validation flow inside the request path: `ACLMiddleware` checks for `Authorization: Bearer …` first; on success, sets the request email from the token file. On any failure (unknown / expired / store unavailable), returns 401 — there is no fallback to header-based auth, so a misconfigured client can't silently masquerade as anonymous. If no Bearer is present, the existing `cfg.EmailHeader` path runs unchanged.
The tokens directory inherits the existing `.zddc.d/` exclusion: dot-prefix segments 404 from direct GETs, and `fs.ListDirectory` filters them from listings. **Verify on any new deployment by attempting `GET /.zddc.d/tokens/anything` and confirming 404.**
Implementation: `zddc/internal/auth/` (storage), `zddc/internal/handler/tokenhandler.go` (HTTP layer), middleware extension in `zddc/internal/handler/middleware.go`.
### Release tagging
zddc-server has no separate release script. The top-level `./build alpha|beta|release [version]` is the canonical path: it cross-compiles the binaries inside the containerized Go toolchain, copies them into `dist/release-output/` with the lockstep symlink chain (one set of symlinks per platform), regenerates the per-version + per-channel stub pages, refreshes the index, and (on stable) tags `zddc-server-v<X.Y.Z>` alongside the six HTML-tool tags.

View file

@ -459,7 +459,7 @@ none of them is load-bearing alone.
| Layer | Job | Implementation |
|---|---|---|
| Authentication | Establish caller identity (email) | Delegated to upstream proxy via `X-Auth-Request-Email`; zddc-server does not authenticate |
| Authentication | Establish caller identity (email) | Two paths: `Authorization: Bearer <token>` validated against `<ZDDC_ROOT>/.zddc.d/tokens/<sha256-hex>` (CLI / scripted callers); or `X-Auth-Request-Email` injected by an upstream auth proxy (browser users). Token system is built-in and self-issuing — no external IDP required |
| Policy decider | Yield an allow/deny verdict for (identity, path, chain) | Pluggable via `ZDDC_OPA_URL`: in-process Go evaluator (default) or external OPA-compatible HTTP/socket endpoint. `zddc/internal/policy/` |
| ACL cascade | The default decider's rule set | Per-directory `.zddc` YAML with verb-set permissions (`r`/`w`/`c`/`d`/`a`) and roles, walked deepest-first first-match-wins under `--cascade-mode=delegated` or with absolute ancestor denies under `--cascade-mode=strict` (`zddc/internal/zddc/acl.go`, `cascade.go`). External OPA can replace this rule set with arbitrary Rego while keeping the same `.zddc` files as input data |
| Special folders | Codify the bilateral exchange-record archetype | `Incoming`/`Working`/`Staging` get auto-ownership on mkdir (creator gets `rwcda` via an auto-written `.zddc`); `Issued`/`Received` enforce a server-side WORM split (ancestor grants masked to `r`; only an explicit `.zddc` at-or-below the WORM folder can grant `c` for a write-once drop-box). Admins exempt. `zddc/internal/zddc/special.go` |
@ -468,6 +468,44 @@ none of them is load-bearing alone.
| Audit log | Reconstruct who did what after the fact | JSON-line tee per request to `<ZDDC_ROOT>/.zddc.d/logs/access-<host>.log`; writes also emit `file_write` op records |
| File API | Authenticated CRUD over the served tree | `zddc/internal/handler/fileapi.go` — PUT/DELETE/POST routed through the same ACL chain as GET, with per-method verbs (`r`/`w`/`c`/`d`/`a`). Mkdir under `Incoming`/`Working`/`Staging` writes a creator-owned `.zddc` automatically |
### Bearer token issuance
zddc-server issues its own bearer tokens for non-browser callers (CLI tools, scripts, downstream proxy/cache/mirror instances). The master is the identity provider; no external IDP, no JWKS rotation.
**Storage** — `<ZDDC_ROOT>/.zddc.d/tokens/<sha256-hex>` per token. Filename is the **hash** of the token, never the plaintext value. File contents are YAML (`email`, `created`, `expires`, `description`). Mode 0600, directory mode 0700, atomic writes via temp+rename.
**Why hash-as-filename**: a leak of the tokens directory (backup tools, FS-level audit logs, accidental `ls` in a screen recording) exposes hashes, not credentials. Same posture as `/etc/shadow` storing password hashes rather than passwords. The plaintext exists only in transit (HTTP `Authorization` header) and on the operator's disk (a 0600 file they manage).
**Self-service flow**:
1. User signs in via the browser (master's normal upstream auth).
2. Visits `/.tokens` — small HTML page (`zddc/internal/handler/tokenhandler.go`) listing existing tokens and offering a creation form.
3. JS fetches the JSON API (`/.api/tokens`), POSTs a new token, displays the plaintext **once**.
4. User copies into a 0600 file; passes `--bearer-file <path>` to a CLI.
**API**:
| Method | Path | Purpose |
|---|---|---|
| `GET` | `/.api/tokens` | list current user's tokens (no plaintext) |
| `POST` | `/.api/tokens` | create; plaintext returned exactly once |
| `DELETE` | `/.api/tokens/<id>` | revoke (8-char ID or full 64-char hash) |
**Validation in the request path**: `ACLMiddleware` in `zddc/internal/handler/middleware.go` checks `Authorization: Bearer …` first; on success, sets the request email from the token file and falls through. Any failure (missing / malformed / expired) → `401`. There is no silent fallback to anonymous on Bearer failure — a misconfigured client must fail loudly rather than escalate to "no auth at all." When no Bearer is present, the existing `cfg.EmailHeader` path runs unchanged.
**Directory shielding**: the tokens path is shielded by the existing `.`-prefix rules — `dispatch()` 404s any URL containing a dot-prefixed segment (other than the recognized virtual prefixes), and `fs.ListDirectory` filters dot entries from listings. The token system relies on this; a regression here is a credentials-leak vector. The token-handler test suite (`tokenhandler_test.go`) exercises the auth path; verifying the URL-level guard is the responsibility of `main_test.go` (`TestDispatchHidesDotPrefixedSegments`).
### `--no-auth` / "this instance is not the ACL boundary"
A symmetric flag, used in two distinct deployment shapes:
- **Master with `--no-auth`**: no ACL enforcement, no auth required. Anyone hitting the port reads everything in scope. Suitable for dev, internal trusted-LAN read-only tooling, or genuinely public archives.
- **Client with `--no-auth`** (downstream proxy/cache/mirror — see "Master + proxy / cache / mirror" below for context): the client trusts upstream's ACL filtering. Whatever the upstream returned is what the client serves; no per-request re-evaluation against `.zddc` files in the cache directory. Single-user-trust model on a laptop.
Implementation is a single swap: `policy.AllowAllDecider{}` replaces the configured decider when `cfg.NoAuth` is true. All existing handlers continue to call `policy.AllowFromChain` (or equivalent) unchanged; they just always get `allowed=true`. Logged at `WARN` on every restart so operators who set the flag inadvertently see it on stderr.
Distinct from `--insecure`, which only relaxes a startup-time safety check (refuse to start when no root `.zddc` exists). The two flags are independent.
### Commercial vs federal trust model
The current implementation is well-shaped for a commercial-tenant model with

View file

@ -22,7 +22,7 @@ If something in this CLAUDE.md conflicts with those, those win — and please up
This is a **monorepo of independent tools**, not one application:
- `archive/`, `transmittal/`, `classifier/`, `mdedit/`, `landing/`, `form/` — six self-contained HTML tools, each compiled to a single inlined HTML file in its own `dist/`. Most output `dist/tool.html`; **`landing/` outputs `dist/index.html`** (it's the project picker served at the root of `zddc-server`). The sixth tool, `form/`, is the schema-driven renderer for the form-data system (any `<name>.form.yaml` file in the tree becomes an editable form at `<path>/<name>.form.html`); see AGENTS.md "Form-data system" and ARCHITECTURE.md "Form Renderer".
- `zddc/` — Go HTTP server (separate sub-project; Go 1.24+). Serves `ZDDC_ROOT/index.html` at `GET /` as the landing page; `Accept: application/json` on `/` returns the ACL-filtered project list. Cross-compiled binaries are produced by `./build` and live in `dist/release-output/` (gitignored); `./deploy` rsyncs them to `/srv/zddc/releases/` on the deploy host (Caddy serves them at `https://zddc.varasys.io/releases/`). The `helm/` charts in this repo build from source at deploy time.
- `zddc/` — Go HTTP server (separate sub-project; Go 1.24+). Serves `ZDDC_ROOT/index.html` at `GET /` as the landing page; `Accept: application/json` on `/` returns the ACL-filtered project list. Two auth paths: (a) `Authorization: Bearer <token>` validated against self-issued tokens stored under `<ZDDC_ROOT>/.zddc.d/tokens/` (filename = SHA256 of token), used by CLI / non-browser callers; (b) `X-Auth-Request-Email` injected by an upstream auth proxy, used for browser sessions. Self-service token UI at `/.tokens` + JSON API at `/.api/tokens`. `--no-auth` skips ACL enforcement entirely (distinct from the older `--insecure` which only relaxes the no-root-`.zddc` startup check). Cross-compiled binaries are produced by `./build` and live in `dist/release-output/` (gitignored); `./deploy` rsyncs them to `/srv/zddc/releases/` on the deploy host (Caddy serves them at `https://zddc.varasys.io/releases/`). The `helm/` charts in this repo build from source at deploy time.
- `shared/``base.css` plus shared JS modules (`zddc.js`, `hash.js`, `zddc-filter.js`, `theme.js`, `help.js`) included by every tool's build, and `build-lib.sh` (POSIX sh helpers sourced by every tool's `build.sh` AND by the top-level `build` for lockstep release helpers).
- **Two-repo + deploy-host model.** Source code lives here (`codeberg.org/VARASYS/ZDDC`). Hand-edited website content lives in a separate repo (`codeberg.org/VARASYS/ZDDC-website`, typically cloned at `~/src/zddc-website/` — just `index.html`, `reference.html`, `css/`, `js/`, `img/`; no releases, no LFS). The live site at `zddc.varasys.io` is served from `/srv/zddc/` on the deploy host: Caddy bind-mounts that path, and it's populated by `./deploy` from this repo's `dist/release-output/` plus `~/src/zddc-website/`. **Releases are NOT in any git history** — they're reproducible from this repo's `<tool>-vX.Y.Z` tags by checking out the tag and running `./build release X.Y.Z`. Per-version files (`<tool>_v<X.Y.Z>.html`) are immutable; partial-version pins (`<tool>_v<X.Y>.html`, `<tool>_v<X>.html`) and channel mirrors (`<tool>_{stable,beta,alpha}.html`) are symlinks; zddc-server has analogous `zddc-server_v<X.Y.Z>_<platform>` per-version binaries plus channel/partial-version symlinks plus `zddc-server_<X>.html` stub pages that fan out the four-platform download in one cell. **Install model:** local use is a download from `/releases/`. Server use is `zddc-server`, which has the current-stable build of all six tools baked in via `//go:embed` (compile-time default). Tools auto-served at folder-name-driven paths: `archive` everywhere, `classifier` in `Incoming`/`Working`/`Staging` subtrees, `mdedit` in `Working` subtrees, `transmittal` in `Staging` subtrees, `landing` only at root. Override via `.zddc apps:` cascade entry (channel/version/URL/path) — fetched once, cached at `<ZDDC_ROOT>/_app/`. Drop a real `.html` file at any path to override.
- `helm/` — example Helm charts for zddc-server (`zddc-server-prod/`, `zddc-server-dev/`). Both compile from source via init container. Operators copy `values.yaml.example` and customize. No secrets in repo.

View file

@ -133,16 +133,74 @@ ZDDC_TLS_KEY=/etc/ssl/zddc/server.key \
## Authentication
zddc-server does **not** perform authentication itself. It reads the user's email address
from a request header (default: `X-Auth-Request-Email`) that must be set by an upstream reverse proxy
(nginx, Caddy, Traefik, Azure Application Gateway, etc.) after authenticating the user.
zddc-server reads the user's email from one of two sources, in order:
If the header is absent, the user is treated as anonymous (empty email). A request is
allowed only if (a) **no `.zddc` file exists anywhere in the chain from `ZDDC_ROOT` to
the requested directory** (a fresh tree with zero `.zddc` files defaults to public
access — see warning at the top of the next section), or (b) some level in the chain
explicitly allows the caller's email. See "Access control: the `.zddc` cascade" below
for the full evaluation order.
1. **`Authorization: Bearer <token>`** — a server-issued bearer token (see
"Bearer tokens" below). Used by CLI clients, scripts, and any
non-browser caller. Validated against `<ZDDC_ROOT>/.zddc.d/tokens/`;
on success the request runs as the email recorded in the token file.
On any failure (unknown token, expired token, malformed header) the
request is rejected with `401` — there is no silent fallback.
2. **`X-Auth-Request-Email`** (header name configurable) — set by an
upstream reverse proxy (nginx, Caddy, Traefik, oauth2-proxy, Azure
Application Gateway, etc.) after authenticating a browser user.
This is the path browser sessions use. **The header is trusted from
any client when no auth proxy is in front** — bind to loopback or
require TLS + proxy in any deployment that exposes the port.
If neither source yields an email, the user is treated as anonymous
(empty email). A request is then allowed only if (a) **no `.zddc` file
exists anywhere in the chain from `ZDDC_ROOT` to the requested
directory** (a fresh tree with zero `.zddc` files defaults to public
access — see warning at the top of the next section), or (b) some level
in the chain explicitly allows the empty email / anonymous callers.
Pass `--no-auth` (or `ZDDC_NO_AUTH=1`) to skip ACL enforcement entirely
on this instance. Anyone hitting the port reads everything in scope, no
matter what `.zddc` files say. Distinct from `--insecure`, which only
permits startup with no root `.zddc`. Use cases: dev, trusted-LAN read-
only deployments, and the "client trusts upstream's filtering" mode of
zddc-server when running as a downstream cache or mirror (see the
"master + proxy/cache/mirror" architecture in ARCHITECTURE.md).
### Bearer tokens
Bearer tokens are issued by zddc-server itself — no external IDP, no
JWKS rotation. Users self-serve via the browser:
1. Sign in to the master via the browser as usual (whatever the upstream
auth proxy expects).
2. Visit `https://<master>/.tokens`. The page lists existing tokens and
offers a "Create token" form (free-form description, optional
expiry).
3. The newly-created token is shown **once** — copy it into a file with
mode 0600 (e.g. `~/.config/zddc/token`). It is never re-derivable
from the server's storage.
4. Pass the token to a CLI/client: `--bearer-file ~/.config/zddc/token`
(or send `Authorization: Bearer <token>` directly via curl/scripts).
Storage lives at `<ZDDC_ROOT>/.zddc.d/tokens/<sha256-hex-of-token>`,
file mode 0600, directory mode 0700. The file is YAML with `email`,
`created`, `expires`, and `description` fields. The plaintext token is
never stored at rest — only its SHA256 hash. A token leak via backup
tools, FS-level audit logs, or an `ls` of the directory would expose
hashes, not credentials.
The directory is shielded from public serving by the standard
dot-prefix guard (any `.foo` segment in a request URL returns 404, and
listings filter dot-prefix entries) — but verify your deployment by
attempting `GET /.zddc.d/tokens/<anything>` and confirming 404.
JSON API for automation (same auth as the page):
| Method | Path | Description |
|---|---|---|
| `GET` | `/.api/tokens` | list current user's tokens (metadata only — never the plaintext) |
| `POST` | `/.api/tokens` | create a token. Body: `{"description":"…","expires":"<RFC3339>"}` (both optional). Returns the plaintext **once**. |
| `DELETE` | `/.api/tokens/<id>` | revoke. `<id>` is the 8-char ID shown in `GET` (or the full 64-char hash). |
A user can only see and revoke their own tokens. Revoking another
user's token returns 404 to avoid leaking ownership.
## Access control: the `.zddc` cascade

View file

@ -16,6 +16,7 @@ import (
"codeberg.org/VARASYS/ZDDC/zddc/internal/apps"
"codeberg.org/VARASYS/ZDDC/zddc/internal/archive"
"codeberg.org/VARASYS/ZDDC/zddc/internal/auth"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/handler"
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
@ -161,15 +162,35 @@ func main() {
slog.Error("invalid OPA URL", "url", cfg.OPAURL, "err", err)
os.Exit(1)
}
// --no-auth swaps the configured decider for one that allows
// everything. Logged at warn level so an operator who set this
// inadvertently sees it on every restart.
if cfg.NoAuth {
decider = policy.AllowAllDecider{}
slog.Warn("--no-auth enabled: ACL enforcement is disabled. Every request is permitted regardless of .zddc rules.")
}
slog.Info("policy decider ready",
"mode", policyModeLabel(cfg.OPAURL),
"url", cfg.OPAURL,
"cache_ttl", cfg.OPACacheTTL,
"cascade_mode", cfg.CascadeMode)
"cascade_mode", cfg.CascadeMode,
"no_auth", cfg.NoAuth)
// Token store: bearer-token issuance and validation.
// Persists under <ZDDC_ROOT>/.zddc.d/tokens/ — already excluded
// from public listings (fs.ListDirectory dot-prefix filter) and
// direct serving (dispatch's reserved-prefix guard). Failures here
// are non-fatal: token-based auth is opt-in per request, and
// header-based auth keeps working without it.
tokens, err := auth.NewStore(cfg.Root)
if err != nil {
slog.Warn("could not initialise token store; bearer-token auth disabled", "err", err)
tokens = nil
}
// Innermost handler: dispatch.
var inner http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
dispatch(cfg, idx, logRing, appsServer, w, r)
dispatch(cfg, idx, logRing, appsServer, tokens, w, r)
})
inner = handler.CORSMiddleware(cfg, inner)
// HSTS only when zddc-server itself is the TLS-terminating endpoint.
@ -179,7 +200,7 @@ func main() {
inner = handler.HSTSMiddleware(inner)
}
inner = handler.AccessLogMiddleware(auditLogger, inner)
inner = handler.ACLMiddleware(cfg, decider, inner)
inner = handler.ACLMiddleware(cfg, decider, tokens, inner)
mux.Handle("/", inner)
gzWrapper, err := newGzipWrapper()
@ -412,7 +433,7 @@ func embeddedVersionsForLog(embedded map[string]string) string {
}
// dispatch routes a request to the appropriate handler.
func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, appsSrv *apps.Server, w http.ResponseWriter, r *http.Request) {
func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, appsSrv *apps.Server, tokens *auth.Store, w http.ResponseWriter, r *http.Request) {
urlPath := r.URL.Path
email := handler.EmailFromContext(r)
@ -425,6 +446,20 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
return
}
// Token self-service: HTML page at /.tokens, JSON API at
// /.api/tokens. Both routes require an authenticated user (the
// existing email middleware injects the email from upstream auth).
// Both routes refuse to serve when no token store is available
// (e.g. NewStore failed at startup) — handled inside the handlers.
if urlPath == handler.TokensPathPrefix || urlPath == handler.TokensPathPrefix+"/" {
handler.ServeTokensPage(cfg, tokens, w, r)
return
}
if urlPath == handler.TokensAPIPathPrefix || strings.HasPrefix(urlPath, handler.TokensAPIPathPrefix+"/") {
handler.ServeTokensAPI(cfg, tokens, w, r)
return
}
// Auth check endpoints — machine-only forward_auth targets used by
// upstream proxies (e.g. the dev-shell pod's Caddy in front of
// code-server) to gate routes on root-admin status. Handled before

View file

@ -81,7 +81,7 @@ func TestDispatchHidesDotPrefixedSegments(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
rec := httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, rec, req)
dispatch(cfg, idx, ring, nil, nil, rec, req)
if rec.Code != tc.wantStatus {
t.Errorf("path=%q status=%d want=%d body=%q",
tc.path, rec.Code, tc.wantStatus, rec.Body.String())
@ -168,7 +168,7 @@ func TestDispatchAppsResolution(t *testing.T) {
// GET /archive.html → fetched from upstream (archive is available everywhere)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/archive.html", nil)
dispatch(cfg, idx, ring, appsSrv, rec, req)
dispatch(cfg, idx, ring, appsSrv, nil, rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("first /archive.html: status=%d body=%s", rec.Code, rec.Body.String())
}
@ -178,21 +178,21 @@ func TestDispatchAppsResolution(t *testing.T) {
// GET /archive.html again → cache hit (no new upstream fetch)
rec2 := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, rec2, httptest.NewRequest(http.MethodGet, "/archive.html", nil))
dispatch(cfg, idx, ring, appsSrv, nil, rec2, httptest.NewRequest(http.MethodGet, "/archive.html", nil))
if rec2.Code != http.StatusOK {
t.Errorf("second /archive.html: status=%d", rec2.Code)
}
// GET / → landing
rec3 := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, rec3, httptest.NewRequest(http.MethodGet, "/", nil))
dispatch(cfg, idx, ring, appsSrv, nil, rec3, httptest.NewRequest(http.MethodGet, "/", nil))
if rec3.Code != http.StatusOK {
t.Errorf("GET /: status=%d", rec3.Code)
}
// Direct URL access to /_app/ → 404
rec4 := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, rec4, httptest.NewRequest(http.MethodGet, "/_app/foo.html", nil))
dispatch(cfg, idx, ring, appsSrv, nil, rec4, httptest.NewRequest(http.MethodGet, "/_app/foo.html", nil))
if rec4.Code != http.StatusNotFound {
t.Errorf("/_app/ direct: status=%d, want 404", rec4.Code)
}
@ -201,12 +201,12 @@ func TestDispatchAppsResolution(t *testing.T) {
// (root has no Incoming/Working/Staging ancestor), but SHOULD work in
// /Project-A/Working/.
rec5 := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, rec5, httptest.NewRequest(http.MethodGet, "/classifier.html", nil))
dispatch(cfg, idx, ring, appsSrv, nil, rec5, httptest.NewRequest(http.MethodGet, "/classifier.html", nil))
if rec5.Code != http.StatusNotFound {
t.Errorf("/classifier.html at root: status=%d, want 404 (not in Incoming/Working/Staging)", rec5.Code)
}
rec6 := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, rec6, httptest.NewRequest(http.MethodGet, "/Project-A/Working/classifier.html", nil))
dispatch(cfg, idx, ring, appsSrv, nil, rec6, httptest.NewRequest(http.MethodGet, "/Project-A/Working/classifier.html", nil))
if rec6.Code != http.StatusOK {
t.Errorf("/Project-A/Working/classifier.html: status=%d, want 200", rec6.Code)
}
@ -246,7 +246,7 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) {
body := []byte("note body")
req := withEmail(httptest.NewRequest(http.MethodPut, "/Project-A/Working/note.md", strings.NewReader(string(body))), "alice@example.com")
rec := httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, rec, req)
dispatch(cfg, idx, ring, nil, nil, rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("PUT: want 201, got %d: %s", rec.Code, rec.Body.String())
}
@ -254,7 +254,7 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) {
// GET it back.
req = withEmail(httptest.NewRequest(http.MethodGet, "/Project-A/Working/note.md", nil), "alice@example.com")
rec = httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, rec, req)
dispatch(cfg, idx, ring, nil, nil, rec, req)
if rec.Code != http.StatusOK || rec.Body.String() != string(body) {
t.Fatalf("GET back: code=%d body=%q", rec.Code, rec.Body.String())
}
@ -264,7 +264,7 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) {
req.Header.Set("X-ZDDC-Op", "move")
req.Header.Set("X-ZDDC-Destination", "/Project-A/Working/renamed.md")
rec = httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, rec, req)
dispatch(cfg, idx, ring, nil, nil, rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("MOVE: want 200, got %d: %s", rec.Code, rec.Body.String())
}
@ -272,7 +272,7 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) {
// DELETE it.
req = withEmail(httptest.NewRequest(http.MethodDelete, "/Project-A/Working/renamed.md", nil), "alice@example.com")
rec = httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, rec, req)
dispatch(cfg, idx, ring, nil, nil, rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("DELETE: want 204, got %d: %s", rec.Code, rec.Body.String())
}
@ -280,7 +280,7 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) {
// Reserved segment guard still applies to writes.
req = withEmail(httptest.NewRequest(http.MethodPut, "/.devshell/foo.txt", strings.NewReader("x")), "alice@example.com")
rec = httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, rec, req)
dispatch(cfg, idx, ring, nil, nil, rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("PUT /.devshell/...: want 404, got %d", rec.Code)
}
@ -361,7 +361,7 @@ func TestDispatchArchiveRedirect(t *testing.T) {
}
req := httptest.NewRequest(http.MethodGet, rawURL, nil)
rec := httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, rec, req)
dispatch(cfg, idx, ring, nil, nil, rec, req)
if rec.Code != tc.wantStatus {
t.Fatalf("path=%q status=%d, want %d; body=%s", tc.path, rec.Code, tc.wantStatus, rec.Body.String())
}
@ -448,7 +448,7 @@ func TestDispatchSlashRouting(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
rec := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, rec, req)
dispatch(cfg, idx, ring, appsSrv, nil, rec, req)
if rec.Code != tc.wantStatus {
t.Fatalf("path=%q status=%d, want %d; body=%s",
tc.path, rec.Code, tc.wantStatus, rec.Body.String())
@ -498,7 +498,7 @@ func TestDispatchArchiveMethodGate(t *testing.T) {
req := httptest.NewRequest(method, path, strings.NewReader("body"))
req = req.WithContext(handler.WithEmail(req.Context(), "alice@example.com"))
rec := httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, rec, req)
dispatch(cfg, idx, ring, nil, nil, rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("%s %s: status %d, want 405; body=%s", method, path, rec.Code, rec.Body.String())
}
@ -674,7 +674,7 @@ func TestDispatchZddcEditorAtPath(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
req = req.WithContext(context.WithValue(req.Context(), handler.EmailKey, tc.email))
rec := httptest.NewRecorder()
dispatch(cfg, idx, ring, nil, rec, req)
dispatch(cfg, idx, ring, nil, nil, rec, req)
if rec.Code != tc.wantStatus {
t.Fatalf("path=%q status=%d, want %d; body=%s",
tc.path, rec.Code, tc.wantStatus, rec.Body.String())

349
zddc/internal/auth/token.go Normal file
View file

@ -0,0 +1,349 @@
// Package auth provides bearer-token issuance and validation for
// zddc-server. Tokens are persisted as YAML files under
// <ZDDC_ROOT>/.zddc.d/tokens/, keyed by the SHA256 hash of the token
// value (filename = hash, never the plaintext token). The directory is
// shielded from public serving by the standard dot-prefix guard in the
// dispatcher and the listing filter in fs.ListDirectory.
//
// Format of a token file:
//
// email: user@example.com
// created: 2026-05-08T10:23:00Z
// expires: 2026-08-08T10:23:00Z # zero/missing = no expiry
// description: Field laptop # free-form, optional
//
// Token validation: client sends `Authorization: Bearer <token>`,
// server hashes, looks up <dir>/<sha256-hex>. If the file exists, parses
// cleanly, and is not expired, the token is valid and the request runs
// as the file's email.
package auth
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"gopkg.in/yaml.v3"
)
// DirName is the leaf-directory name under <ZDDC_ROOT>/.zddc.d/ where
// token files live. Kept here so callers don't repeat the literal.
const DirName = "tokens"
// HashLen is the hex-encoded length of a SHA256 hash (the filename
// length for token files).
const HashLen = 64
// IDLen is the length of the short identifier used in the management UI
// and revocation API. First IDLen hex chars of the SHA256 hash.
const IDLen = 8
// ErrInvalidToken is returned when the token does not exist, fails to
// parse, or is expired. Distinct from other errors so callers can map
// it to a 401 without leaking which case occurred.
var ErrInvalidToken = errors.New("invalid token")
// ErrNotFound is returned by List/Revoke when a requested token does
// not exist. Revoke also returns this when the token exists but does
// not belong to the requesting email — same error to avoid leaking
// existence of other users' tokens.
var ErrNotFound = errors.New("token not found")
// Token holds the metadata persisted alongside a token. The token
// value itself (the bearer string the client sends) is NOT carried in
// this struct — it's only known at generation time, returned once to
// the user, and never re-derivable from the stored data.
type Token struct {
// Hash is the hex-encoded SHA256 of the plaintext token. Equal
// to the filename of the on-disk YAML file.
Hash string `yaml:"-"`
Email string `yaml:"email"`
Created time.Time `yaml:"created"`
Expires time.Time `yaml:"expires,omitempty"`
Description string `yaml:"description,omitempty"`
}
// ID returns the short identifier (first IDLen hex chars of Hash) used
// in the management UI and the revocation endpoint. Distinct from the
// secret token so it can be displayed and logged safely.
func (t Token) ID() string {
if len(t.Hash) < IDLen {
return t.Hash
}
return t.Hash[:IDLen]
}
// Expired reports whether the token has an expiry in the past. Tokens
// with a zero Expires never expire.
func (t Token) Expired(now time.Time) bool {
return !t.Expires.IsZero() && !now.Before(t.Expires)
}
// Store manages the on-disk token directory.
type Store struct {
dir string
mu sync.Mutex // serializes file creation; reads are lock-free
}
// NewStore opens (and creates if missing) the token directory at
// <root>/.zddc.d/tokens. Directory mode 0700 — single-user-trust at
// the FS layer.
func NewStore(root string) (*Store, error) {
dir := filepath.Join(root, ".zddc.d", DirName)
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, fmt.Errorf("create token dir: %w", err)
}
// Best-effort tighten if pre-existing dir was looser. Don't fail
// startup if chmod isn't permitted (NFS/SMB shares often refuse).
_ = os.Chmod(dir, 0o700)
return &Store{dir: dir}, nil
}
// Dir returns the absolute path of the token directory. Exposed for
// tests and operator diagnostics; not used in the request path.
func (s *Store) Dir() string { return s.dir }
// Generate creates a new token for the given email and writes its file.
// Returns the plaintext bearer token (to be displayed once to the user)
// and the persisted Token metadata.
//
// expires==zero means no expiry. description is free-form; the caller
// is responsible for any sanitization (e.g. trimming, length-capping).
func (s *Store) Generate(email, description string, expires time.Time) (string, Token, error) {
if email == "" {
return "", Token{}, errors.New("email required")
}
// 32 bytes = 256 bits of entropy, URL-safe base64 → 43-char string.
var raw [32]byte
if _, err := rand.Read(raw[:]); err != nil {
return "", Token{}, fmt.Errorf("generate token: %w", err)
}
plaintext := base64.RawURLEncoding.EncodeToString(raw[:])
sum := sha256.Sum256([]byte(plaintext))
hashHex := hex.EncodeToString(sum[:])
tok := Token{
Hash: hashHex,
Email: email,
Created: time.Now().UTC().Truncate(time.Second),
Expires: expires,
Description: description,
}
s.mu.Lock()
defer s.mu.Unlock()
if err := s.writeAtomic(hashHex, tok); err != nil {
return "", Token{}, err
}
return plaintext, tok, nil
}
// Validate looks up a plaintext bearer token. Returns the persisted
// Token on success, ErrInvalidToken if missing/malformed/expired.
//
// Constant-time comparison: not strictly required since the lookup is
// keyed by the hash (no string comparison happens), but the hex
// filename comparison via os.Stat is timing-stable enough for our
// purposes — there's no known oracle that distinguishes "file missing"
// from "file present but expired" without observing the response code.
func (s *Store) Validate(plaintext string) (*Token, error) {
if plaintext == "" {
return nil, ErrInvalidToken
}
sum := sha256.Sum256([]byte(plaintext))
hashHex := hex.EncodeToString(sum[:])
tok, err := s.readByHash(hashHex)
if err != nil {
return nil, ErrInvalidToken
}
if tok.Expired(time.Now()) {
return nil, ErrInvalidToken
}
return tok, nil
}
// List returns all tokens belonging to the given email, sorted by
// Created descending (newest first). Returns an empty slice (not nil)
// when the user has no tokens.
func (s *Store) List(email string) ([]Token, error) {
if email == "" {
return nil, errors.New("email required")
}
entries, err := os.ReadDir(s.dir)
if err != nil {
if os.IsNotExist(err) {
return []Token{}, nil
}
return nil, err
}
out := make([]Token, 0, len(entries))
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if len(name) != HashLen || !isHex(name) {
continue
}
tok, err := s.readByHash(name)
if err != nil {
continue
}
if !strings.EqualFold(tok.Email, email) {
continue
}
out = append(out, *tok)
}
sort.Slice(out, func(i, j int) bool {
return out[i].Created.After(out[j].Created)
})
return out, nil
}
// Revoke deletes the token identified by the given short ID (first
// IDLen hex chars of the hash) or the full hash. Only revokes tokens
// belonging to email; returns ErrNotFound otherwise (whether the token
// is missing or owned by a different user — to avoid leaking ownership).
func (s *Store) Revoke(email, idOrHash string) error {
if email == "" {
return errors.New("email required")
}
idOrHash = strings.ToLower(strings.TrimSpace(idOrHash))
s.mu.Lock()
defer s.mu.Unlock()
// Full-hash path: O(1) lookup.
if len(idOrHash) == HashLen && isHex(idOrHash) {
tok, err := s.readByHash(idOrHash)
if err != nil {
return ErrNotFound
}
if !strings.EqualFold(tok.Email, email) {
return ErrNotFound
}
return os.Remove(filepath.Join(s.dir, idOrHash))
}
// Short-ID path: must scan. Token IDs are 8 hex chars (~32 bits),
// collision-resistant in practice for any reasonable per-user
// token count. We pick the (single) match owned by email.
if len(idOrHash) < 4 || !isHex(idOrHash) {
return ErrNotFound
}
entries, err := os.ReadDir(s.dir)
if err != nil {
return ErrNotFound
}
var matches []string
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if len(name) != HashLen || !isHex(name) {
continue
}
if !strings.HasPrefix(name, idOrHash) {
continue
}
tok, err := s.readByHash(name)
if err != nil {
continue
}
if !strings.EqualFold(tok.Email, email) {
continue
}
matches = append(matches, name)
}
if len(matches) == 0 {
return ErrNotFound
}
if len(matches) > 1 {
return fmt.Errorf("ambiguous id %q matches %d tokens", idOrHash, len(matches))
}
return os.Remove(filepath.Join(s.dir, matches[0]))
}
// readByHash reads and parses the token file named by hashHex. The
// returned Token has its Hash field set.
func (s *Store) readByHash(hashHex string) (*Token, error) {
if len(hashHex) != HashLen || !isHex(hashHex) {
return nil, ErrInvalidToken
}
path := filepath.Join(s.dir, hashHex)
bytes, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var tok Token
if err := yaml.Unmarshal(bytes, &tok); err != nil {
return nil, err
}
tok.Hash = hashHex
if tok.Email == "" {
return nil, ErrInvalidToken
}
return &tok, nil
}
// writeAtomic serializes a token to YAML and writes it via tmp+rename.
// File mode 0600.
func (s *Store) writeAtomic(hashHex string, tok Token) error {
bytes, err := yaml.Marshal(tok)
if err != nil {
return fmt.Errorf("marshal token: %w", err)
}
final := filepath.Join(s.dir, hashHex)
tmp, err := os.CreateTemp(s.dir, ".tmp-*")
if err != nil {
return fmt.Errorf("create temp: %w", err)
}
tmpName := tmp.Name()
if _, err := tmp.Write(bytes); err != nil {
tmp.Close()
os.Remove(tmpName)
return fmt.Errorf("write temp: %w", err)
}
if err := tmp.Chmod(0o600); err != nil {
tmp.Close()
os.Remove(tmpName)
return fmt.Errorf("chmod temp: %w", err)
}
if err := tmp.Close(); err != nil {
os.Remove(tmpName)
return fmt.Errorf("close temp: %w", err)
}
if err := os.Rename(tmpName, final); err != nil {
os.Remove(tmpName)
return fmt.Errorf("rename temp: %w", err)
}
return nil
}
// isHex reports whether s is a non-empty string of [0-9a-f] characters.
func isHex(s string) bool {
if s == "" {
return false
}
for i := 0; i < len(s); i++ {
c := s[i]
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
return false
}
}
return true
}

View file

@ -0,0 +1,325 @@
package auth
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func newTestStore(t *testing.T) *Store {
t.Helper()
root := t.TempDir()
store, err := NewStore(root)
if err != nil {
t.Fatalf("NewStore: %v", err)
}
return store
}
func TestNewStoreCreatesDirectory(t *testing.T) {
root := t.TempDir()
store, err := NewStore(root)
if err != nil {
t.Fatalf("NewStore: %v", err)
}
want := filepath.Join(root, ".zddc.d", DirName)
if store.Dir() != want {
t.Errorf("Dir() = %q, want %q", store.Dir(), want)
}
info, err := os.Stat(store.Dir())
if err != nil {
t.Fatalf("stat: %v", err)
}
if !info.IsDir() {
t.Error("expected directory")
}
// On Linux, expect mode 0700; some FS (NFS, SMB) won't honor chmod
// so we accept any mode where no group/other bits are set in the
// happy-path tempdir.
mode := info.Mode().Perm()
if mode&0o077 != 0 {
t.Errorf("expected mode 0700, got %o", mode)
}
}
func TestGenerateAndValidate(t *testing.T) {
store := newTestStore(t)
plaintext, tok, err := store.Generate("alice@example.com", "test laptop", time.Time{})
if err != nil {
t.Fatalf("Generate: %v", err)
}
if plaintext == "" {
t.Fatal("plaintext token empty")
}
if len(plaintext) != 43 {
t.Errorf("plaintext length = %d, want 43", len(plaintext))
}
if tok.Email != "alice@example.com" {
t.Errorf("Email = %q", tok.Email)
}
if tok.Description != "test laptop" {
t.Errorf("Description = %q", tok.Description)
}
if !tok.Expires.IsZero() {
t.Errorf("Expires = %v, want zero", tok.Expires)
}
if len(tok.Hash) != HashLen {
t.Errorf("Hash length = %d, want %d", len(tok.Hash), HashLen)
}
if tok.ID() != tok.Hash[:IDLen] {
t.Errorf("ID() = %q, want %q", tok.ID(), tok.Hash[:IDLen])
}
// Validate the same plaintext.
got, err := store.Validate(plaintext)
if err != nil {
t.Fatalf("Validate: %v", err)
}
if got.Email != "alice@example.com" {
t.Errorf("validated Email = %q", got.Email)
}
if got.Hash != tok.Hash {
t.Errorf("validated Hash = %q, want %q", got.Hash, tok.Hash)
}
}
func TestValidateRejectsUnknownToken(t *testing.T) {
store := newTestStore(t)
if _, err := store.Validate("not-a-real-token"); !errors.Is(err, ErrInvalidToken) {
t.Errorf("expected ErrInvalidToken, got %v", err)
}
}
func TestValidateRejectsEmptyToken(t *testing.T) {
store := newTestStore(t)
if _, err := store.Validate(""); !errors.Is(err, ErrInvalidToken) {
t.Errorf("expected ErrInvalidToken, got %v", err)
}
}
func TestValidateRejectsExpiredToken(t *testing.T) {
store := newTestStore(t)
plaintext, _, err := store.Generate("bob@example.com", "expired", time.Now().Add(-time.Hour))
if err != nil {
t.Fatalf("Generate: %v", err)
}
if _, err := store.Validate(plaintext); !errors.Is(err, ErrInvalidToken) {
t.Errorf("expected ErrInvalidToken for expired, got %v", err)
}
}
func TestValidateAcceptsFutureExpiry(t *testing.T) {
store := newTestStore(t)
plaintext, _, err := store.Generate("bob@example.com", "valid", time.Now().Add(time.Hour))
if err != nil {
t.Fatalf("Generate: %v", err)
}
if _, err := store.Validate(plaintext); err != nil {
t.Errorf("Validate of future-expiry token: %v", err)
}
}
func TestGenerateProducesUniqueTokens(t *testing.T) {
store := newTestStore(t)
seen := make(map[string]bool)
for i := 0; i < 50; i++ {
plaintext, _, err := store.Generate("alice@example.com", "", time.Time{})
if err != nil {
t.Fatalf("Generate %d: %v", i, err)
}
if seen[plaintext] {
t.Fatalf("duplicate token at iteration %d", i)
}
seen[plaintext] = true
}
}
func TestList(t *testing.T) {
store := newTestStore(t)
if list, err := store.List("alice@example.com"); err != nil || len(list) != 0 {
t.Errorf("empty List: %v / %d", err, len(list))
}
_, _, _ = store.Generate("alice@example.com", "first", time.Time{})
time.Sleep(1100 * time.Millisecond)
_, _, _ = store.Generate("alice@example.com", "second", time.Time{})
_, _, _ = store.Generate("bob@example.com", "bob's", time.Time{})
alice, err := store.List("alice@example.com")
if err != nil {
t.Fatalf("List alice: %v", err)
}
if len(alice) != 2 {
t.Fatalf("List alice = %d, want 2", len(alice))
}
// Newest-first ordering.
if alice[0].Description != "second" {
t.Errorf("alice[0].Description = %q, want \"second\"", alice[0].Description)
}
if alice[1].Description != "first" {
t.Errorf("alice[1].Description = %q, want \"first\"", alice[1].Description)
}
bob, err := store.List("bob@example.com")
if err != nil {
t.Fatalf("List bob: %v", err)
}
if len(bob) != 1 {
t.Fatalf("List bob = %d, want 1", len(bob))
}
if bob[0].Description != "bob's" {
t.Errorf("bob[0].Description = %q", bob[0].Description)
}
}
func TestListIgnoresNonTokenFiles(t *testing.T) {
store := newTestStore(t)
_, _, err := store.Generate("alice@example.com", "real", time.Time{})
if err != nil {
t.Fatalf("Generate: %v", err)
}
// Drop a junk file alongside.
if err := os.WriteFile(filepath.Join(store.Dir(), "garbage"), []byte("nope"), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
if err := os.WriteFile(filepath.Join(store.Dir(), "ZZ"+strings.Repeat("0", HashLen-2)), []byte("not hex"), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
list, err := store.List("alice@example.com")
if err != nil {
t.Fatalf("List: %v", err)
}
if len(list) != 1 {
t.Errorf("List = %d, want 1 (ignoring junk)", len(list))
}
}
func TestRevokeByShortID(t *testing.T) {
store := newTestStore(t)
plaintext, tok, err := store.Generate("alice@example.com", "to revoke", time.Time{})
if err != nil {
t.Fatalf("Generate: %v", err)
}
if err := store.Revoke("alice@example.com", tok.ID()); err != nil {
t.Fatalf("Revoke: %v", err)
}
if _, err := store.Validate(plaintext); !errors.Is(err, ErrInvalidToken) {
t.Errorf("expected ErrInvalidToken after revoke, got %v", err)
}
}
func TestRevokeByFullHash(t *testing.T) {
store := newTestStore(t)
plaintext, tok, err := store.Generate("alice@example.com", "", time.Time{})
if err != nil {
t.Fatalf("Generate: %v", err)
}
if err := store.Revoke("alice@example.com", tok.Hash); err != nil {
t.Fatalf("Revoke by hash: %v", err)
}
if _, err := store.Validate(plaintext); !errors.Is(err, ErrInvalidToken) {
t.Errorf("expected ErrInvalidToken after revoke, got %v", err)
}
}
func TestRevokeOtherUsersTokenReturnsNotFound(t *testing.T) {
store := newTestStore(t)
plaintext, tok, err := store.Generate("alice@example.com", "alice's", time.Time{})
if err != nil {
t.Fatalf("Generate: %v", err)
}
// Bob attempts to revoke alice's token.
if err := store.Revoke("bob@example.com", tok.ID()); !errors.Is(err, ErrNotFound) {
t.Errorf("expected ErrNotFound, got %v", err)
}
// Token must still be valid.
if _, err := store.Validate(plaintext); err != nil {
t.Errorf("token wrongly revoked: %v", err)
}
}
func TestRevokeNonexistentReturnsNotFound(t *testing.T) {
store := newTestStore(t)
if err := store.Revoke("alice@example.com", "deadbeef"); !errors.Is(err, ErrNotFound) {
t.Errorf("expected ErrNotFound, got %v", err)
}
}
func TestRevokeRejectsInvalidIDs(t *testing.T) {
store := newTestStore(t)
for _, id := range []string{"", "xx", "ZZZ", "abc"} {
if err := store.Revoke("alice@example.com", id); !errors.Is(err, ErrNotFound) {
t.Errorf("Revoke(%q) expected ErrNotFound, got %v", id, err)
}
}
}
func TestGenerateRejectsEmptyEmail(t *testing.T) {
store := newTestStore(t)
if _, _, err := store.Generate("", "", time.Time{}); err == nil {
t.Error("expected error for empty email")
}
}
func TestTokenFileMode(t *testing.T) {
store := newTestStore(t)
_, tok, err := store.Generate("alice@example.com", "", time.Time{})
if err != nil {
t.Fatalf("Generate: %v", err)
}
info, err := os.Stat(filepath.Join(store.Dir(), tok.Hash))
if err != nil {
t.Fatalf("stat token file: %v", err)
}
if mode := info.Mode().Perm(); mode&0o077 != 0 {
t.Errorf("token file mode %o exposes group/other bits", mode)
}
}
func TestTokenFilenameIsHashNotPlaintext(t *testing.T) {
store := newTestStore(t)
plaintext, tok, err := store.Generate("alice@example.com", "", time.Time{})
if err != nil {
t.Fatalf("Generate: %v", err)
}
// Plaintext must not appear as a filename.
entries, err := os.ReadDir(store.Dir())
if err != nil {
t.Fatalf("ReadDir: %v", err)
}
for _, e := range entries {
if strings.Contains(e.Name(), plaintext) {
t.Errorf("plaintext token leaked in filename %q", e.Name())
}
}
// And the hash file must exist.
if _, err := os.Stat(filepath.Join(store.Dir(), tok.Hash)); err != nil {
t.Errorf("hash-named file missing: %v", err)
}
}
func TestExpiredHelper(t *testing.T) {
now := time.Date(2026, 5, 8, 12, 0, 0, 0, time.UTC)
cases := []struct {
expires time.Time
expired bool
}{
{time.Time{}, false}, // zero = no expiry
{now.Add(time.Hour), false}, // future
{now.Add(-time.Hour), true}, // past
{now, true}, // exactly now = expired
}
for _, c := range cases {
tok := Token{Expires: c.expires}
if got := tok.Expired(now); got != c.expired {
t.Errorf("Token{Expires:%v}.Expired(%v) = %v, want %v", c.expires, now, got, c.expired)
}
}
}

View file

@ -27,6 +27,7 @@ type Config struct {
CORSOrigins []string // --cors-origin / ZDDC_CORS_ORIGIN — comma-separated allowlist; default empty (CORS disabled); explicit value enables
AccessLog string // --access-log / ZDDC_ACCESS_LOG — file path for tee'd JSON access log; empty = stderr only
Insecure bool // --insecure / ZDDC_INSECURE=1 — opt out of safety checks (currently: allow start without a root .zddc, leaving the tree publicly accessible)
NoAuth bool // --no-auth / ZDDC_NO_AUTH=1 — skip ACL enforcement entirely. This instance is NOT the security boundary; on master = "open" (anyone reads everything), on a client = "trust upstream's filtering, don't re-evaluate ACLs locally."
OPAURL string // --opa-url / ZDDC_OPA_URL — policy decider endpoint: "internal" (default), "http(s)://..." (real OPA via HTTP), or "unix:///..." (OPA via Unix socket)
OPAFailOpen bool // --opa-fail-open / ZDDC_OPA_FAIL_OPEN=1 — when external OPA is unreachable, allow instead of deny (default: fail closed)
OPACacheTTL time.Duration // --opa-cache-ttl / ZDDC_OPA_CACHE_TTL — external mode only: per-decision cache TTL. Default 1s. Set 0s to disable.
@ -86,6 +87,8 @@ func Load(args []string) (Config, error) {
"Allow plain HTTP on non-loopback addresses (only safe behind an authenticating proxy).")
insecureFlag := fs.Bool("insecure", os.Getenv("ZDDC_INSECURE") == "1",
"Allow startup with no root .zddc file (the tree is then publicly accessible). Default: refuse to start.")
noAuthFlag := fs.Bool("no-auth", os.Getenv("ZDDC_NO_AUTH") == "1",
"Skip ACL enforcement entirely. On master: anyone reads everything (dev / trusted-LAN / public-read deployments). On client: trust upstream's filtering. Distinct from --insecure (which gates startup-without-.zddc). Default: enforce ACLs.")
opaURLFlag := fs.String("opa-url", getEnv("ZDDC_OPA_URL", "internal"),
"Policy decider endpoint: \"internal\" (built-in Go evaluator, default), \"http(s)://host:port\", or \"unix:///path/to/socket\".")
opaFailOpenFlag := fs.Bool("opa-fail-open", os.Getenv("ZDDC_OPA_FAIL_OPEN") == "1",
@ -153,6 +156,7 @@ func Load(args []string) (Config, error) {
CORSOrigins: resolveCORS(corsFlagSet, *corsOriginFlag),
AccessLog: *accessLogFlag,
Insecure: *insecureFlag,
NoAuth: *noAuthFlag,
OPAURL: *opaURLFlag,
OPAFailOpen: *opaFailOpenFlag,
OPACacheTTL: *opaCacheTTLFlag,
@ -274,6 +278,7 @@ func Usage(w io.Writer) {
fs.String("cors-origin", "", "Comma-separated CORS allowlist. Empty (default) = CORS disabled.")
fs.Bool("insecure-direct", false, "Allow plain HTTP on non-loopback addresses.")
fs.Bool("insecure", false, "Allow startup with no root .zddc file (publicly accessible). Default: refuse.")
fs.Bool("no-auth", false, "Skip ACL enforcement entirely. On master: anyone reads everything. On client: trust upstream's filtering. Distinct from --insecure.")
fs.String("opa-url", "internal", "Policy decider: \"internal\", \"http(s)://...\", or \"unix:///...\".")
fs.Bool("opa-fail-open", false, "External OPA: allow on transport error (default: deny / fail closed).")
fs.Duration("opa-cache-ttl", time.Second, "External OPA: per-decision cache TTL (default 1s; 0 disables).")

View file

@ -2,9 +2,12 @@ package handler
import (
"context"
"errors"
"net/http"
"strings"
"time"
"codeberg.org/VARASYS/ZDDC/zddc/internal/auth"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
"log/slog"
@ -23,13 +26,46 @@ const EmailKey contextKey = "email"
// "swap internal evaluator for external OPA" plumbing change.
const DeciderKey contextKey = "policy-decider"
// ACLMiddleware extracts the user email from the configured header and stores
// it (along with the policy decider) in the request context. It does NOT
// enforce ACL itself — each handler performs its own ACL check via
// ACLMiddleware extracts the user email and stores it (along with the
// policy decider) in the request context. It does NOT enforce ACL
// itself — each handler performs its own ACL check via
// policy.AllowFromChain.
func ACLMiddleware(cfg config.Config, decider policy.Decider, next http.Handler) http.Handler {
//
// Two email sources, in order:
//
// 1. `Authorization: Bearer <token>` — if present, the token is
// validated against the supplied auth.Store. On success, the
// request runs as the token-file's email. On failure (invalid /
// expired / no validator configured), the middleware short-circuits
// with 401 — silently falling back to header-based auth would let
// a misconfigured client masquerade as anonymous.
// 2. Otherwise, the email is read from cfg.EmailHeader, exactly as
// before. This is the upstream-auth-proxy path (oauth2-proxy,
// Caddy auth, etc.) that injects the header on validated requests.
//
// `tokens` may be nil — deployments without the token system simply
// reject any Bearer attempts with 401. This keeps Bearer-vs-no-Bearer
// trust paths decoupled from the operator's choice to issue tokens.
func ACLMiddleware(cfg config.Config, decider policy.Decider, tokens *auth.Store, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
email := r.Header.Get(cfg.EmailHeader)
var email string
if bearer := bearerToken(r); bearer != "" {
if tokens == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
tok, err := tokens.Validate(bearer)
if err != nil {
if !errors.Is(err, auth.ErrInvalidToken) {
slog.Warn("token validation error", "err", err)
}
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
email = tok.Email
} else {
email = r.Header.Get(cfg.EmailHeader)
}
// DEBUG-level header dump for diagnosing proxy / SSO header
// passthrough. Off by default (LogLevel info); enable with
// ZDDC_LOG_LEVEL=debug. Logs the configured header name, the
@ -50,6 +86,21 @@ func ACLMiddleware(cfg config.Config, decider policy.Decider, next http.Handler)
})
}
// bearerToken returns the token value from the Authorization header
// (case-insensitive on the "Bearer" scheme per RFC 6750), or the empty
// string when no Bearer credential is present.
func bearerToken(r *http.Request) string {
v := r.Header.Get("Authorization")
if v == "" {
return ""
}
const prefix = "bearer "
if len(v) <= len(prefix) || !strings.EqualFold(v[:len(prefix)], prefix) {
return ""
}
return strings.TrimSpace(v[len(prefix):])
}
// EmailFromContext extracts the user email from the request context.
func EmailFromContext(r *http.Request) string {
if v, ok := r.Context().Value(EmailKey).(string); ok {

View file

@ -32,7 +32,7 @@ func TestAccessLogReadsEmailFromACLContext(t *testing.T) {
// Correct order: ACL is outer, AccessLog is inner. AccessLog reads
// email from the context ACL populated.
chain := ACLMiddleware(cfg, nil, AccessLogMiddleware(nil, noop))
chain := ACLMiddleware(cfg, nil, nil, AccessLogMiddleware(nil, noop))
req := httptest.NewRequest(http.MethodGet, "/foo", nil)
req.Header.Set("X-Auth-Request-Email", "alice@example.com")
@ -60,7 +60,7 @@ func TestAccessLogAnonymousWhenNoEmail(t *testing.T) {
w.WriteHeader(http.StatusOK)
})
chain := ACLMiddleware(cfg, nil, AccessLogMiddleware(nil, noop))
chain := ACLMiddleware(cfg, nil, nil, AccessLogMiddleware(nil, noop))
req := httptest.NewRequest(http.MethodGet, "/foo", nil)
// Note: no X-Auth-Request-Email header set.
@ -90,7 +90,7 @@ func TestAccessLogOuterDoesNotSeeInnerContext(t *testing.T) {
})
// Inverted order — the ORIGINAL buggy chain.
chain := AccessLogMiddleware(nil, ACLMiddleware(cfg, nil, noop))
chain := AccessLogMiddleware(nil, ACLMiddleware(cfg, nil, nil, noop))
req := httptest.NewRequest(http.MethodGet, "/foo", nil)
req.Header.Set("X-Auth-Request-Email", "alice@example.com")
@ -119,7 +119,7 @@ func TestAccessLogMiddleware_AuditLoggerReceivesSameFields(t *testing.T) {
})
cfg := config.Config{EmailHeader: "X-Auth-Request-Email"}
chain := ACLMiddleware(cfg, nil, AccessLogMiddleware(auditLogger, noop))
chain := ACLMiddleware(cfg, nil, nil, AccessLogMiddleware(auditLogger, noop))
req := httptest.NewRequest(http.MethodGet, "/some/path", nil)
req.Header.Set("X-Auth-Request-Email", "bob@example.com")

View file

@ -0,0 +1,342 @@
package handler
import (
"encoding/json"
"errors"
"fmt"
"html"
"net/http"
"strings"
"time"
"codeberg.org/VARASYS/ZDDC/zddc/internal/auth"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
)
// TokensPathPrefix is the URL of the user-facing self-service token
// management page. Browser GET returns an HTML page; the page fetches
// the JSON API at TokensAPIPathPrefix.
const TokensPathPrefix = "/.tokens"
// TokensAPIPathPrefix is the URL prefix for the token JSON API.
//
// GET /.api/tokens → list current user's tokens (metadata only)
// POST /.api/tokens → create a new token; returns plaintext once
// DELETE /.api/tokens/<id> → revoke a token by short ID or full hash
const TokensAPIPathPrefix = "/.api/tokens"
// MaxTokenDescription caps how much free-form text a user can attach
// to a token. Keeps the on-disk YAML small and the HTML rendering
// simple.
const MaxTokenDescription = 200
// MaxTokensPerUser is a soft cap; refusing to create a new token at
// the limit is enough to prevent runaway accumulation. Operators who
// need more can revoke first.
const MaxTokensPerUser = 50
// tokenAPIView is the JSON shape returned to the management UI. Never
// includes the plaintext token (which is only returned on creation).
type tokenAPIView struct {
ID string `json:"id"`
Email string `json:"email"`
Created time.Time `json:"created"`
Expires time.Time `json:"expires,omitempty"`
Description string `json:"description,omitempty"`
}
// tokenCreateRequest is the body of POST /.api/tokens.
type tokenCreateRequest struct {
Description string `json:"description,omitempty"`
Expires time.Time `json:"expires,omitempty"`
}
// tokenCreateResponse is the body of a successful POST /.api/tokens.
// The plaintext Token is returned exactly once and is never derivable
// from the persisted file again.
type tokenCreateResponse struct {
Token string `json:"token"`
ID string `json:"id"`
Email string `json:"email"`
Created time.Time `json:"created"`
Expires time.Time `json:"expires,omitempty"`
Description string `json:"description,omitempty"`
}
// ServeTokensAPI dispatches requests to the JSON token API. Anonymous
// requests are 401; missing token store yields 503 (token issuance is
// disabled when storage isn't reachable).
func ServeTokensAPI(cfg config.Config, store *auth.Store, w http.ResponseWriter, r *http.Request) {
email := EmailFromContext(r)
if email == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if store == nil {
http.Error(w, "Token store unavailable", http.StatusServiceUnavailable)
return
}
rest := strings.TrimPrefix(r.URL.Path, TokensAPIPathPrefix)
switch {
case rest == "" || rest == "/":
switch r.Method {
case http.MethodGet:
handleTokensList(store, email, w)
case http.MethodPost:
handleTokensCreate(store, email, w, r)
default:
w.Header().Set("Allow", "GET, POST")
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
}
case strings.HasPrefix(rest, "/"):
id := strings.TrimPrefix(rest, "/")
if id == "" || strings.Contains(id, "/") {
http.NotFound(w, r)
return
}
switch r.Method {
case http.MethodDelete:
handleTokensRevoke(store, email, id, w)
default:
w.Header().Set("Allow", "DELETE")
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
}
default:
http.NotFound(w, r)
}
}
func handleTokensList(store *auth.Store, email string, w http.ResponseWriter) {
list, err := store.List(email)
if err != nil {
http.Error(w, "Internal Server Error: "+err.Error(), http.StatusInternalServerError)
return
}
views := make([]tokenAPIView, 0, len(list))
for _, t := range list {
views = append(views, tokenAPIView{
ID: t.ID(),
Email: t.Email,
Created: t.Created,
Expires: t.Expires,
Description: t.Description,
})
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(views)
}
func handleTokensCreate(store *auth.Store, email string, w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
var req tokenCreateRequest
if r.ContentLength > 0 || r.Header.Get("Content-Type") != "" {
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(&req); err != nil {
http.Error(w, "Bad Request: "+err.Error(), http.StatusBadRequest)
return
}
}
desc := strings.TrimSpace(req.Description)
if len(desc) > MaxTokenDescription {
http.Error(w, fmt.Sprintf("Bad Request: description longer than %d chars", MaxTokenDescription), http.StatusBadRequest)
return
}
if !req.Expires.IsZero() && req.Expires.Before(time.Now()) {
http.Error(w, "Bad Request: expires is in the past", http.StatusBadRequest)
return
}
// Soft cap to prevent runaway accumulation.
existing, err := store.List(email)
if err == nil && len(existing) >= MaxTokensPerUser {
http.Error(w, fmt.Sprintf("Conflict: token cap (%d) reached; revoke an existing token first", MaxTokensPerUser), http.StatusConflict)
return
}
plaintext, tok, err := store.Generate(email, desc, req.Expires)
if err != nil {
http.Error(w, "Internal Server Error: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(tokenCreateResponse{
Token: plaintext,
ID: tok.ID(),
Email: tok.Email,
Created: tok.Created,
Expires: tok.Expires,
Description: tok.Description,
})
}
func handleTokensRevoke(store *auth.Store, email, id string, w http.ResponseWriter) {
err := store.Revoke(email, id)
if err == nil {
w.WriteHeader(http.StatusNoContent)
return
}
if errors.Is(err, auth.ErrNotFound) {
http.NotFound(w, nil)
return
}
http.Error(w, "Internal Server Error: "+err.Error(), http.StatusInternalServerError)
}
// ServeTokensPage renders the self-service token management HTML page.
// The page fetches /.api/tokens via JavaScript; the server-side render
// only emits a static skeleton plus the authenticated user's email so
// the page can show "you are signed in as alice@..." without a flash.
func ServeTokensPage(cfg config.Config, store *auth.Store, w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", "GET, HEAD")
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
email := EmailFromContext(r)
if email == "" {
http.Error(w, "Unauthorized — log in via the master's auth proxy first.", http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
if r.Method == http.MethodHead {
return
}
storeAvailable := store != nil
body := renderTokensPage(email, storeAvailable)
_, _ = w.Write([]byte(body))
}
// renderTokensPage builds the HTML for the management page. Kept inline
// (no template files) to match the rest of the handler package; the
// page is small enough that string-concat is readable and there are no
// untrusted-data injection concerns beyond the `email` parameter,
// which is escaped via html.EscapeString.
func renderTokensPage(email string, storeAvailable bool) string {
storeNote := ""
if !storeAvailable {
storeNote = `<p class="warn">Token store is unavailable — the server failed to initialise <code>.zddc.d/tokens/</code>. Operations on this page will return 503.</p>`
}
const css = `
body { font: 14px/1.4 system-ui, sans-serif; max-width: 720px; margin: 2em auto; padding: 0 1em; color: #222; }
h1 { font-size: 1.4rem; margin-bottom: 0.2em; }
.who { color: #666; margin-bottom: 1.5em; }
fieldset { border: 1px solid #ccc; padding: 1em; margin-bottom: 1.5em; border-radius: 4px; }
legend { font-weight: 600; padding: 0 0.4em; }
label { display: block; margin: 0.5em 0; }
label span { display: inline-block; min-width: 9em; }
input[type=text], input[type=datetime-local] { padding: 0.3em; min-width: 18em; }
button { padding: 0.4em 0.9em; cursor: pointer; }
.danger { color: #b00; border-color: #b00; }
.warn { background: #fff3cd; border: 1px solid #ffc107; padding: 0.6em; border-radius: 4px; }
table { width: 100%; border-collapse: collapse; margin-top: 0.5em; }
th, td { text-align: left; padding: 0.4em; border-bottom: 1px solid #eee; }
.token-secret { font-family: ui-monospace, monospace; background: #f5f5f5; padding: 0.7em; word-break: break-all; border: 1px dashed #999; border-radius: 4px; }
.empty { color: #888; font-style: italic; }
`
const js = `
const api = "/.api/tokens";
async function refresh() {
const tbody = document.querySelector("#tokens tbody");
tbody.innerHTML = "";
const r = await fetch(api, { headers: {"Accept": "application/json"} });
if (!r.ok) {
tbody.innerHTML = '<tr><td colspan="4" class="empty">Could not load tokens (' + r.status + ')</td></tr>';
return;
}
const list = await r.json();
if (!list.length) {
tbody.innerHTML = '<tr><td colspan="4" class="empty">No tokens issued yet.</td></tr>';
return;
}
for (const t of list) {
const tr = document.createElement("tr");
const expires = t.expires && t.expires !== "0001-01-01T00:00:00Z" ? new Date(t.expires).toLocaleString() : "(never)";
const created = new Date(t.created).toLocaleString();
tr.innerHTML = '<td><code>' + escapeHTML(t.id) + '</code></td>'
+ '<td>' + escapeHTML(t.description || "") + '</td>'
+ '<td>' + escapeHTML(created) + '</td>'
+ '<td>' + escapeHTML(expires) + '</td>'
+ '<td><button class="danger" data-id="' + escapeHTML(t.id) + '">Revoke</button></td>';
tbody.appendChild(tr);
}
tbody.querySelectorAll("button.danger").forEach(b => {
b.addEventListener("click", () => revoke(b.dataset.id));
});
}
function escapeHTML(s) {
return String(s).replace(/[&<>"']/g, c =>
({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[c]);
}
async function revoke(id) {
if (!confirm("Revoke token " + id + "? Any clients using it will be cut off immediately.")) return;
const r = await fetch(api + "/" + encodeURIComponent(id), { method: "DELETE" });
if (!r.ok) {
alert("Revoke failed: " + r.status + " " + r.statusText);
return;
}
refresh();
}
document.getElementById("create").addEventListener("submit", async (ev) => {
ev.preventDefault();
const desc = document.getElementById("desc").value.trim();
const expRaw = document.getElementById("expires").value;
const body = {};
if (desc) body.description = desc;
if (expRaw) body.expires = new Date(expRaw).toISOString();
const r = await fetch(api, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(body),
});
const out = document.getElementById("created");
if (!r.ok) {
out.innerHTML = '<p class="warn">Create failed: ' + r.status + ' ' + escapeHTML(await r.text()) + '</p>';
return;
}
const data = await r.json();
out.innerHTML = '<p>New token (copy now it is shown only once):</p><div class="token-secret">' + escapeHTML(data.token) + '</div>';
document.getElementById("desc").value = "";
document.getElementById("expires").value = "";
refresh();
});
refresh();
`
return `<!doctype html>
<html><head><meta charset="utf-8"><title>ZDDC tokens</title>
<style>` + css + `</style>
</head><body>
<h1>API tokens</h1>
<p class="who">Signed in as <strong>` + html.EscapeString(email) + `</strong></p>
` + storeNote + `
<fieldset>
<legend>Create a new token</legend>
<form id="create">
<label><span>Description</span><input type="text" id="desc" maxlength="200" placeholder="e.g. Field laptop"></label>
<label><span>Expires (optional)</span><input type="datetime-local" id="expires"></label>
<button type="submit">Create token</button>
</form>
<div id="created"></div>
</fieldset>
<fieldset>
<legend>Existing tokens</legend>
<table id="tokens">
<thead><tr><th>ID</th><th>Description</th><th>Created</th><th>Expires</th><th></th></tr></thead>
<tbody></tbody>
</table>
</fieldset>
<script>` + js + `</script>
</body></html>`
}

View file

@ -0,0 +1,458 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"codeberg.org/VARASYS/ZDDC/zddc/internal/auth"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
)
// newTestTokenStore returns an isolated auth.Store under a TempDir.
func newTestTokenStore(t *testing.T) *auth.Store {
t.Helper()
root := t.TempDir()
store, err := auth.NewStore(root)
if err != nil {
t.Fatalf("auth.NewStore: %v", err)
}
return store
}
// authedReq builds an HTTP request with the authenticated email set in
// the request context (mimicking what ACLMiddleware would do upstream
// of the handler under test).
func authedReq(method, path, email string, body []byte) *http.Request {
var r *http.Request
if body != nil {
r = httptest.NewRequest(method, path, bytes.NewReader(body))
r.Header.Set("Content-Type", "application/json")
} else {
r = httptest.NewRequest(method, path, nil)
}
if email != "" {
r = r.WithContext(WithEmail(r.Context(), email))
}
return r
}
func TestServeTokensAPI_Anonymous401(t *testing.T) {
store := newTestTokenStore(t)
rec := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/.api/tokens", nil)
ServeTokensAPI(config.Config{}, store, rec, r)
if rec.Code != http.StatusUnauthorized {
t.Errorf("anonymous GET /.api/tokens = %d, want 401", rec.Code)
}
}
func TestServeTokensAPI_NoStore503(t *testing.T) {
rec := httptest.NewRecorder()
r := authedReq(http.MethodGet, "/.api/tokens", "alice@example.com", nil)
ServeTokensAPI(config.Config{}, nil, rec, r)
if rec.Code != http.StatusServiceUnavailable {
t.Errorf("nil store = %d, want 503", rec.Code)
}
}
func TestServeTokensAPI_ListEmpty(t *testing.T) {
store := newTestTokenStore(t)
rec := httptest.NewRecorder()
r := authedReq(http.MethodGet, "/.api/tokens", "alice@example.com", nil)
ServeTokensAPI(config.Config{}, store, rec, r)
if rec.Code != http.StatusOK {
t.Errorf("GET = %d, want 200", rec.Code)
}
if got := rec.Header().Get("Content-Type"); !strings.Contains(got, "application/json") {
t.Errorf("Content-Type = %q", got)
}
var list []tokenAPIView
if err := json.Unmarshal(rec.Body.Bytes(), &list); err != nil {
t.Fatalf("unmarshal: %v body=%q", err, rec.Body.String())
}
if len(list) != 0 {
t.Errorf("expected empty list, got %d entries", len(list))
}
}
func TestServeTokensAPI_CreateAndList(t *testing.T) {
store := newTestTokenStore(t)
body := []byte(`{"description":"laptop"}`)
rec := httptest.NewRecorder()
r := authedReq(http.MethodPost, "/.api/tokens", "alice@example.com", body)
ServeTokensAPI(config.Config{}, store, rec, r)
if rec.Code != http.StatusCreated {
t.Fatalf("POST = %d, want 201; body=%q", rec.Code, rec.Body.String())
}
var resp tokenCreateResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal POST resp: %v", err)
}
if resp.Token == "" {
t.Fatal("plaintext token missing from response")
}
if resp.ID == "" {
t.Fatal("ID missing")
}
if resp.Email != "alice@example.com" {
t.Errorf("Email = %q", resp.Email)
}
if resp.Description != "laptop" {
t.Errorf("Description = %q", resp.Description)
}
// The token must validate as a Bearer.
tok, err := store.Validate(resp.Token)
if err != nil {
t.Fatalf("Validate the new plaintext: %v", err)
}
if tok.ID() != resp.ID {
t.Errorf("validated ID %q != response ID %q", tok.ID(), resp.ID)
}
// Subsequent list contains the token (without plaintext).
rec2 := httptest.NewRecorder()
r2 := authedReq(http.MethodGet, "/.api/tokens", "alice@example.com", nil)
ServeTokensAPI(config.Config{}, store, rec2, r2)
if rec2.Code != http.StatusOK {
t.Fatalf("GET = %d", rec2.Code)
}
var list []tokenAPIView
_ = json.Unmarshal(rec2.Body.Bytes(), &list)
if len(list) != 1 {
t.Fatalf("list = %d entries, want 1", len(list))
}
if list[0].ID != resp.ID {
t.Errorf("listed ID mismatch")
}
// Plaintext token MUST NOT appear in any list response.
if strings.Contains(rec2.Body.String(), resp.Token) {
t.Error("plaintext token leaked in list response")
}
}
func TestServeTokensAPI_CreateRejectsPastExpiry(t *testing.T) {
store := newTestTokenStore(t)
past := time.Now().Add(-time.Hour).UTC().Format(time.RFC3339)
body := []byte(`{"expires":"` + past + `"}`)
rec := httptest.NewRecorder()
r := authedReq(http.MethodPost, "/.api/tokens", "alice@example.com", body)
ServeTokensAPI(config.Config{}, store, rec, r)
if rec.Code != http.StatusBadRequest {
t.Errorf("past-expiry create = %d, want 400", rec.Code)
}
}
func TestServeTokensAPI_CreateRejectsLongDescription(t *testing.T) {
store := newTestTokenStore(t)
huge := strings.Repeat("x", MaxTokenDescription+1)
body, _ := json.Marshal(tokenCreateRequest{Description: huge})
rec := httptest.NewRecorder()
r := authedReq(http.MethodPost, "/.api/tokens", "alice@example.com", body)
ServeTokensAPI(config.Config{}, store, rec, r)
if rec.Code != http.StatusBadRequest {
t.Errorf("long description = %d, want 400", rec.Code)
}
}
func TestServeTokensAPI_RevokeOwnToken(t *testing.T) {
store := newTestTokenStore(t)
plaintext, tok, err := store.Generate("alice@example.com", "", time.Time{})
if err != nil {
t.Fatalf("seed Generate: %v", err)
}
rec := httptest.NewRecorder()
r := authedReq(http.MethodDelete, "/.api/tokens/"+tok.ID(), "alice@example.com", nil)
ServeTokensAPI(config.Config{}, store, rec, r)
if rec.Code != http.StatusNoContent {
t.Errorf("DELETE = %d, want 204; body=%q", rec.Code, rec.Body.String())
}
// Token no longer validates.
if _, err := store.Validate(plaintext); err == nil {
t.Error("token still validates after revoke")
}
}
func TestServeTokensAPI_RevokeOtherUsers404(t *testing.T) {
store := newTestTokenStore(t)
_, tok, err := store.Generate("alice@example.com", "", time.Time{})
if err != nil {
t.Fatalf("seed: %v", err)
}
rec := httptest.NewRecorder()
r := authedReq(http.MethodDelete, "/.api/tokens/"+tok.ID(), "bob@example.com", nil)
ServeTokensAPI(config.Config{}, store, rec, r)
if rec.Code != http.StatusNotFound {
t.Errorf("DELETE other user's = %d, want 404", rec.Code)
}
}
func TestServeTokensAPI_RevokeMissing404(t *testing.T) {
store := newTestTokenStore(t)
rec := httptest.NewRecorder()
r := authedReq(http.MethodDelete, "/.api/tokens/deadbeef", "alice@example.com", nil)
ServeTokensAPI(config.Config{}, store, rec, r)
if rec.Code != http.StatusNotFound {
t.Errorf("DELETE nonexistent = %d, want 404", rec.Code)
}
}
func TestServeTokensAPI_RejectsBadMethods(t *testing.T) {
store := newTestTokenStore(t)
cases := []struct {
path string
method string
}{
{"/.api/tokens", http.MethodPut},
{"/.api/tokens", http.MethodDelete},
{"/.api/tokens/abc12345", http.MethodGet},
{"/.api/tokens/abc12345", http.MethodPost},
}
for _, c := range cases {
rec := httptest.NewRecorder()
r := authedReq(c.method, c.path, "alice@example.com", nil)
ServeTokensAPI(config.Config{}, store, rec, r)
if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("%s %s = %d, want 405", c.method, c.path, rec.Code)
}
}
}
func TestServeTokensPage_Authenticated(t *testing.T) {
store := newTestTokenStore(t)
rec := httptest.NewRecorder()
r := authedReq(http.MethodGet, "/.tokens", "alice@example.com", nil)
ServeTokensPage(config.Config{}, store, rec, r)
if rec.Code != http.StatusOK {
t.Fatalf("GET /.tokens = %d", rec.Code)
}
if got := rec.Header().Get("Content-Type"); !strings.Contains(got, "text/html") {
t.Errorf("Content-Type = %q", got)
}
body := rec.Body.String()
if !strings.Contains(body, "alice@example.com") {
t.Error("page does not show authenticated email")
}
if !strings.Contains(body, "/.api/tokens") {
t.Error("page does not reference the API endpoint")
}
}
func TestServeTokensPage_Anonymous401(t *testing.T) {
store := newTestTokenStore(t)
rec := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/.tokens", nil)
ServeTokensPage(config.Config{}, store, rec, r)
if rec.Code != http.StatusUnauthorized {
t.Errorf("anonymous = %d, want 401", rec.Code)
}
}
func TestServeTokensPage_NoStoreShowsWarning(t *testing.T) {
rec := httptest.NewRecorder()
r := authedReq(http.MethodGet, "/.tokens", "alice@example.com", nil)
ServeTokensPage(config.Config{}, nil, rec, r)
if rec.Code != http.StatusOK {
t.Fatalf("authenticated with nil store = %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "unavailable") {
t.Error("expected warning about token store unavailability")
}
}
func TestServeTokensPage_RejectsNonGET(t *testing.T) {
store := newTestTokenStore(t)
rec := httptest.NewRecorder()
r := authedReq(http.MethodPost, "/.tokens", "alice@example.com", nil)
ServeTokensPage(config.Config{}, store, rec, r)
if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("POST = %d, want 405", rec.Code)
}
}
func TestACLMiddleware_BearerSetsEmailFromToken(t *testing.T) {
store := newTestTokenStore(t)
plaintext, _, err := store.Generate("bearer-user@example.com", "", time.Time{})
if err != nil {
t.Fatalf("seed: %v", err)
}
cfg := config.Config{EmailHeader: "X-Auth-Request-Email"}
var seenEmail string
chain := ACLMiddleware(cfg, nil, store, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seenEmail = EmailFromContext(r)
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Authorization", "Bearer "+plaintext)
rec := httptest.NewRecorder()
chain.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
if seenEmail != "bearer-user@example.com" {
t.Errorf("seen email = %q, want bearer-user@example.com", seenEmail)
}
}
func TestACLMiddleware_InvalidBearerRejected(t *testing.T) {
store := newTestTokenStore(t)
cfg := config.Config{EmailHeader: "X-Auth-Request-Email"}
chain := ACLMiddleware(cfg, nil, store, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("inner handler should not run on invalid bearer")
}))
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Authorization", "Bearer not-a-real-token")
// And a valid header email — Bearer must still take precedence and fail.
req.Header.Set("X-Auth-Request-Email", "alice@example.com")
rec := httptest.NewRecorder()
chain.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("invalid bearer = %d, want 401", rec.Code)
}
}
func TestACLMiddleware_BearerWithoutStoreRejected(t *testing.T) {
cfg := config.Config{EmailHeader: "X-Auth-Request-Email"}
chain := ACLMiddleware(cfg, nil, nil, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("inner handler should not run when bearer present and store absent")
}))
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Authorization", "Bearer something")
rec := httptest.NewRecorder()
chain.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("bearer w/ nil store = %d, want 401", rec.Code)
}
}
func TestACLMiddleware_NoBearerFallsBackToHeader(t *testing.T) {
store := newTestTokenStore(t)
cfg := config.Config{EmailHeader: "X-Auth-Request-Email"}
var seen string
chain := ACLMiddleware(cfg, nil, store, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen = EmailFromContext(r)
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("X-Auth-Request-Email", "alice@example.com")
rec := httptest.NewRecorder()
chain.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
if seen != "alice@example.com" {
t.Errorf("email = %q, want alice@example.com (from header)", seen)
}
}
func TestACLMiddleware_BearerCaseInsensitive(t *testing.T) {
store := newTestTokenStore(t)
plaintext, _, err := store.Generate("alice@example.com", "", time.Time{})
if err != nil {
t.Fatalf("seed: %v", err)
}
cfg := config.Config{EmailHeader: "X-Auth-Request-Email"}
for _, prefix := range []string{"Bearer ", "bearer ", "BEARER ", "BeArEr "} {
var seen string
chain := ACLMiddleware(cfg, nil, store, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen = EmailFromContext(r)
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Authorization", prefix+plaintext)
rec := httptest.NewRecorder()
chain.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("prefix %q: status %d", prefix, rec.Code)
}
if seen != "alice@example.com" {
t.Errorf("prefix %q: email %q, want alice@example.com", prefix, seen)
}
}
}
func TestACLMiddleware_ExpiredBearerRejected(t *testing.T) {
store := newTestTokenStore(t)
plaintext, _, err := store.Generate("alice@example.com", "", time.Now().Add(-time.Hour))
if err != nil {
t.Fatalf("seed: %v", err)
}
cfg := config.Config{EmailHeader: "X-Auth-Request-Email"}
chain := ACLMiddleware(cfg, nil, store, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("inner handler should not run on expired bearer")
}))
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Authorization", "Bearer "+plaintext)
rec := httptest.NewRecorder()
chain.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("expired bearer = %d, want 401", rec.Code)
}
}
// TestEndToEndCreateAuthenticateRevoke walks the full happy path: an
// authenticated user creates a token via the API, uses it as a Bearer
// for a separate request, then revokes it and confirms the next Bearer
// fails.
func TestEndToEndCreateAuthenticateRevoke(t *testing.T) {
store := newTestTokenStore(t)
cfg := config.Config{EmailHeader: "X-Auth-Request-Email"}
// 1. Create token via API as alice (authenticated via context).
rec := httptest.NewRecorder()
r := authedReq(http.MethodPost, "/.api/tokens", "alice@example.com", []byte(`{"description":"e2e"}`))
ServeTokensAPI(cfg, store, rec, r)
if rec.Code != http.StatusCreated {
t.Fatalf("create: %d", rec.Code)
}
var resp tokenCreateResponse
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
// 2. Use the token as a Bearer through ACLMiddleware.
var seen string
bearerHandler := ACLMiddleware(cfg, nil, store, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen = EmailFromContext(r)
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Authorization", "Bearer "+resp.Token)
rec2 := httptest.NewRecorder()
bearerHandler.ServeHTTP(rec2, req)
if rec2.Code != http.StatusOK || seen != "alice@example.com" {
t.Fatalf("authenticate: code=%d email=%q", rec2.Code, seen)
}
// 3. Revoke via API as alice.
rec3 := httptest.NewRecorder()
r3 := authedReq(http.MethodDelete, "/.api/tokens/"+resp.ID, "alice@example.com", nil)
ServeTokensAPI(cfg, store, rec3, r3)
if rec3.Code != http.StatusNoContent {
t.Fatalf("revoke: %d", rec3.Code)
}
// 4. Bearer no longer authenticates.
req4 := httptest.NewRequest(http.MethodGet, "/", nil)
req4.Header.Set("Authorization", "Bearer "+resp.Token)
rec4 := httptest.NewRecorder()
bearerHandler.ServeHTTP(rec4, req4)
if rec4.Code != http.StatusUnauthorized {
t.Errorf("post-revoke Bearer = %d, want 401", rec4.Code)
}
}
// Sanity: WithEmail-from-context helper works as expected when used
// directly without the middleware (test seam).
func TestWithEmail(t *testing.T) {
ctx := WithEmail(context.Background(), "carol@example.com")
r := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx)
if got := EmailFromContext(r); got != "carol@example.com" {
t.Errorf("EmailFromContext = %q", got)
}
}

View file

@ -185,6 +185,20 @@ func New(cfg Config) (Decider, error) {
return &cachingDecider{inner: inner, ttl: ttl}, nil
}
// AllowAllDecider unconditionally permits every request. Used when the
// operator runs zddc-server with --no-auth — that flag declares the
// instance is NOT the ACL boundary (master in a trusted-LAN deployment,
// or client mode where the upstream master enforced ACLs and the local
// instance trusts those filtering decisions). Swap into the decider
// slot at startup; all existing handlers continue to call Allow* and
// see allowed=true.
type AllowAllDecider struct{}
// Allow always returns true with nil error.
func (AllowAllDecider) Allow(_ context.Context, _ AllowInput) (bool, error) {
return true, nil
}
// InternalDecider routes Allow through zddc.AllowedAction with the
// configured cascade mode and applies the Issued/Received WORM mask
// post-decision. No network, no Rego, no new dependencies.