diff --git a/AGENTS.md b/AGENTS.md index fe8e960..b2b6aa0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`. 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.d/logs/access-.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.d/tokens/` — 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 ` to a CLI that calls back into zddc-server, or send `Authorization: Bearer ` 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/` — revoke. `` 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` alongside the six HTML-tool tags. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6e4e296..3072f7f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 ` validated against `/.zddc.d/tokens/` (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.d/logs/access-.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.d/tokens/` 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 ` 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/` | 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 diff --git a/CLAUDE.md b/CLAUDE.md index 64e4531..394a371 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `.form.yaml` file in the tree becomes an editable form at `/.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 ` validated against self-issued tokens stored under `/.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 `-vX.Y.Z` tags by checking out the tag and running `./build release X.Y.Z`. Per-version files (`_v.html`) are immutable; partial-version pins (`_v.html`, `_v.html`) and channel mirrors (`_{stable,beta,alpha}.html`) are symlinks; zddc-server has analogous `zddc-server_v_` per-version binaries plus channel/partial-version symlinks plus `zddc-server_.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 `/_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. diff --git a/zddc/README.md b/zddc/README.md index 9777344..b05996a 100644 --- a/zddc/README.md +++ b/zddc/README.md @@ -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 `** — a server-issued bearer token (see + "Bearer tokens" below). Used by CLI clients, scripts, and any + non-browser caller. Validated against `/.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:///.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 ` directly via curl/scripts). + +Storage lives at `/.zddc.d/tokens/`, +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/` 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":""}` (both optional). Returns the plaintext **once**. | +| `DELETE` | `/.api/tokens/` | revoke. `` 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 diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go index 5bb502f..7585c77 100644 --- a/zddc/cmd/zddc-server/main.go +++ b/zddc/cmd/zddc-server/main.go @@ -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.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 diff --git a/zddc/cmd/zddc-server/main_test.go b/zddc/cmd/zddc-server/main_test.go index f8df54a..ea4337c 100644 --- a/zddc/cmd/zddc-server/main_test.go +++ b/zddc/cmd/zddc-server/main_test.go @@ -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()) diff --git a/zddc/internal/auth/token.go b/zddc/internal/auth/token.go new file mode 100644 index 0000000..ecf39c9 --- /dev/null +++ b/zddc/internal/auth/token.go @@ -0,0 +1,349 @@ +// Package auth provides bearer-token issuance and validation for +// zddc-server. Tokens are persisted as YAML files under +// /.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 `, +// server hashes, looks up /. 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.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 +// /.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 +} diff --git a/zddc/internal/auth/token_test.go b/zddc/internal/auth/token_test.go new file mode 100644 index 0000000..835a283 --- /dev/null +++ b/zddc/internal/auth/token_test.go @@ -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) + } + } +} diff --git a/zddc/internal/config/config.go b/zddc/internal/config/config.go index 778e0ca..62f9541 100644 --- a/zddc/internal/config/config.go +++ b/zddc/internal/config/config.go @@ -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).") diff --git a/zddc/internal/handler/middleware.go b/zddc/internal/handler/middleware.go index 324aeaa..ee3a0ea 100644 --- a/zddc/internal/handler/middleware.go +++ b/zddc/internal/handler/middleware.go @@ -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 ` — 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 { diff --git a/zddc/internal/handler/middleware_test.go b/zddc/internal/handler/middleware_test.go index 4b856f0..4a11424 100644 --- a/zddc/internal/handler/middleware_test.go +++ b/zddc/internal/handler/middleware_test.go @@ -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") diff --git a/zddc/internal/handler/tokenhandler.go b/zddc/internal/handler/tokenhandler.go new file mode 100644 index 0000000..a0034e8 --- /dev/null +++ b/zddc/internal/handler/tokenhandler.go @@ -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/ → 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 = `

Token store is unavailable — the server failed to initialise .zddc.d/tokens/. Operations on this page will return 503.

` + } + 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 = 'Could not load tokens (' + r.status + ')'; + return; + } + const list = await r.json(); + if (!list.length) { + tbody.innerHTML = 'No tokens issued yet.'; + 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 = '' + escapeHTML(t.id) + '' + + '' + escapeHTML(t.description || "") + '' + + '' + escapeHTML(created) + '' + + '' + escapeHTML(expires) + '' + + ''; + 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 => + ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[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 = '

Create failed: ' + r.status + ' ' + escapeHTML(await r.text()) + '

'; + return; + } + const data = await r.json(); + out.innerHTML = '

New token (copy now — it is shown only once):

' + escapeHTML(data.token) + '
'; + document.getElementById("desc").value = ""; + document.getElementById("expires").value = ""; + refresh(); + }); + + refresh(); + ` + return ` +ZDDC tokens + + +

API tokens

+

Signed in as ` + html.EscapeString(email) + `

+` + storeNote + ` +
+Create a new token +
+ + + +
+
+
+
+Existing tokens + + + +
IDDescriptionCreatedExpires
+
+ +` +} diff --git a/zddc/internal/handler/tokenhandler_test.go b/zddc/internal/handler/tokenhandler_test.go new file mode 100644 index 0000000..2b6b4e5 --- /dev/null +++ b/zddc/internal/handler/tokenhandler_test.go @@ -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) + } +} diff --git a/zddc/internal/policy/policy.go b/zddc/internal/policy/policy.go index 492b27b..6e08b14 100644 --- a/zddc/internal/policy/policy.go +++ b/zddc/internal/policy/policy.go @@ -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.