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:
parent
562b105550
commit
97ffaac13b
14 changed files with 1733 additions and 40 deletions
18
AGENTS.md
18
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_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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
349
zddc/internal/auth/token.go
Normal 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
|
||||
}
|
||||
325
zddc/internal/auth/token_test.go
Normal file
325
zddc/internal/auth/token_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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).")
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
342
zddc/internal/handler/tokenhandler.go
Normal file
342
zddc/internal/handler/tokenhandler.go
Normal 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 =>
|
||||
({'&':'&','<':'<','>':'>','"':'"',"'":'''})[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>`
|
||||
}
|
||||
458
zddc/internal/handler/tokenhandler_test.go
Normal file
458
zddc/internal/handler/tokenhandler_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue