Access gate: dashboard auth is a strategy — Cloudflare Access, a bearer token, or loopback-only none
Everything the dashboard serves — the /runs* pages and streams, /residents*, /costs*, the /mcp/connect/* credential page and the whole command-registry HTTP surface /api/* — sits behind ONE identity gate, asked once per request before any view runs. Which credential the gate checks is a strategy picked by config.yaml's dashboard.auth (plan D5), behind one small interface — verify(req) → identity | refusal (DashboardVerifier, src/channels/dashboardAuth.ts) — so src/index.ts, the tests and any future strategy see the verifier, never the whole Access module (interface segregation). Three strategies:
dashboard.auth | What proves the caller | Identity → actor | For |
|---|---|---|---|
access | The Cloudflare Access JWT the edge rule injects in Cf-Access-Jwt-Assertion, re-verified here and failing closed (below) | access:<sub> (browser session) or access:svc:<common_name> (service token) | A deployment behind a Cloudflare Access application — production |
token | Authorization: Bearer <token>, compared in constant time against the secret in the env var dashboard.token.env names (default DASHBOARD_TOKEN) | The one configured dashboard.token.actor (access:<name>) | An Access-free deployment fronted by anything that can add a header — a reverse proxy, curl, a script |
none | Nothing — so the one rule is WHERE the request comes from: a loopback socket on a localhost deployment (PUBLIC_BASE_URL unset or naming localhost). Every other request is refused; none never serves a remote caller | access:loopback | A developer running the bot locally |
Default when the key is absent: access when ACCESS_TEAM_DOMAIN and ACCESS_AUD are both set, else none. A deployed installation that never configured the dashboard is unchanged: Access when Access is there; without it — a public PUBLIC_BASE_URL — every request was 403 before and is 403 now. The one behavioural change is local development: a loopback caller of a localhost deployment is admitted without setting a variable, where it used to need ACCESS_DEV_BYPASS and was otherwise refused. The one rule is resolveDashboardAuthMode (src/core/dashboardAuthConfig.ts), shared with the capabilities value (src/core/capabilities.ts) so capabilities.dashboardAuth names the same strategy that gates requests. A strategy whose inputs are missing is a startup error naming the piece — access without both ACCESS_*, token without its env var or a usable actor, an explicit none on a deployment whose PUBLIC_BASE_URL is public (it would refuse every request — better said once at boot than one 403 at a time).
We do not trust the edge alone. Under access the bot runs on a custom domain; a Cloudflare Access edge rule on the dashboard paths authenticates the user and injects a signed RS256 JWT. This module re-verifies that identity in our own code and fails closed, so the dashboard refuses to serve without a valid Access JWT — even if the edge rule is ever misconfigured, removed, or a client reaches the origin directly and spoofs the header.
This is the identity gate only. It runs FIRST; the live-view handler still applies its existing per-run capability-token check afterward (live-view.md) — defense in depth — and, for the /runs index and the tokenless finished-run routes, decides with the verified identity resolved into the same Actor the /api/* adapter uses (accessActor in src/channels/commandHttp.ts, handed in as ctx.actor): the strategy says who may reach the page, the policy table says which runs it shows (authorization.md items 5–7). /api/* is recognized by the ONE predicate isCommandPath from src/channels/commandHttp.ts (never a second prefix list; percent-encoded, doubled-slash, and trailing-slash spellings all count). Under access the Cloudflare Access application must cover /api/* before the bot deploy that serves it. Non-gated paths (/ingress, /mcp, the health probe) are unaffected.
Two identity shapes. A browser session's JWT has a non-empty sub (+ email) → { sub, email? }. A Cloudflare Access service token (the machine credential for /api/*, presented as CF-Access-Client-Id/-Secret and exchanged by the edge for a JWT) carries an EMPTY sub and a non-empty common_name → { sub: "", commonName }; isServiceToken(identity) tells them apart. accessActor — the one resolver for every surface the gate fronts, /api/* and the /runs pages — maps them to the actors access:<sub> and access:svc:<common_name> respectively (command-registry.md item 14, authorization.md item 9). The token and none strategies produce the browser shape ({ sub }), so their actors are access:<name> and access:loopback, granted like any other browser session (authorization.md: every group's read, plus the id's grants entry). A service token is a command-surface credential only: serviceTokenAllowed admits it on /api/* and refuses it (403) on every page the gate fronts.
- Code:
src/channels/dashboardAuth.ts(DashboardVerifier,accessVerifier,tokenVerifier,loopbackVerifier,isLoopbackAddress,isLocalhostBase,buildDashboardVerifier);src/core/dashboardAuthConfig.ts(thedashboardblock,validateDashboardConfig,resolveDashboardAuthMode,tokenSubjectOf);src/channels/accessAuth.ts(parseAccessConfig,verifyAccessJwt,JwksCache,httpJwksFetcher,isServiceToken);src/config/validate.ts(validates the block at load);src/index.ts(composes the verifier once, runs the async gate before the view dispatch, states the strategy in the startup log);deploy/cloudflare/worker.ts(forwardsDASHBOARD_TOKENinto the container). - Tests:
src/channels/dashboardAuth.test.ts,src/core/dashboardAuthConfig.test.ts,src/channels/accessAuth.test.ts,src/config.test.ts(thedashboardblock). - Docs: AGENTS.md invariants 2 (≥2 implementations — three verifiers behind one interface, the JWKS fetch seam), live-view.md, routing-and-config.md (the
dashboardconfig block), http-ingress.md (sibling fail-closed / constant-time auth patterns), reference: config.yaml, reference: dashboard routes.
Environment
| Var | Meaning |
|---|---|
ACCESS_TEAM_DOMAIN | Bare Access team host, e.g. acme.cloudflareaccess.com. Defensively normalized (scheme + trailing slash stripped, trimmed). Sets iss (https://<team-domain>) and the JWKS URL (https://<team-domain>/cdn-cgi/access/certs). |
ACCESS_AUD | The Access application's AUD tag; the token's aud (string or array) must include it. |
DASHBOARD_TOKEN (or the var dashboard.token.env names) | The token strategy's bearer. Compared in constant time; a match resolves to dashboard.token.actor; never logged. On Cloudflare the bot Worker forwards DASHBOARD_TOKEN into the container — another name must join its forward list. |
PUBLIC_BASE_URL | Under none, decides whether the deployment is localhost: unset, or a localhost / 127.0.0.1 / [::1] host on any port. A public host — or a malformed value, which is "not localhost", never a boot crash — refuses every request, and an explicit none there is a startup error. |
Both ACCESS_TEAM_DOMAIN and ACCESS_AUD must be set and non-blank for Access to be considered configured; otherwise the config is null — which selects none when dashboard.auth is absent and is a startup error when it is access. ACCESS_DEV_BYPASS is no longer read: none is what it used to grant, and none is the default without Access; a set variable draws one warning at startup. The bypass identity was access:dev-bypass; the none strategy's is access:loopback, so a grants entry keyed by the old id is dead and belongs under the new one (the same warning says so).
Behavior
- One gate, one strategy.
buildDashboardVerifiercomposes exactly oneDashboardVerifierat startup fromconfig.dashboard,parseAccessConfig(env), the environment andPUBLIC_BASE_URL;src/index.tscallsverify(req)before every dashboard route and writes a refusal's status and body as-is. Nothing downstream asks again who the caller is: the/api/*handler and the history reads that once carried their own loopback rule carry none — the strategy decided before they ran. access: RS256 verification against the live JWKS.verifyAccessJwtsplits the compact JWS, base64url-decodes the header + payload, looks up the signing key by headerkid, and verifies the signature over the exactheader.payloadbytes withcrypto.verify("RSA-SHA256", …)usingcrypto.createPublicKey({ format: "jwk", key }). A bad/absent signature →null→403 forbidden. A missing header is the same 403.- Algorithm-confusion defense (critical). The header
algMUST beRS256;none,HS256, or anything else is rejected before any signature work, and verification is always RSA-SHA256 — never an algorithm named by the attacker-controlled header. This is red-verified: a token bearing a genuine RS256 signature but a lyingalgheader is accepted only if the guard is removed. - Spoofed-header defense. Because we re-verify the JWT at the origin, a client that reaches the origin directly and sets
Cf-Access-Jwt-Assertionto a forged, expired, wrong-aud/iss, or differently-signed token is rejected (403). The header is trusted only after cryptographic + claim validation. - Claim validation.
issmust equalhttps://<team-domain>;aud(string or array) must includeACCESS_AUD;expmust be present and in the future;nbf/iat, if present, must not be in the future beyond a 60s skew. The token must then name a subject: a non-emptysub(browser session →{ sub, email? }), or an empty/absentsubwith a non-empty stringcommon_name(service token →{ sub: "", commonName }). An emptysubwithout acommon_name, or with an empty or non-string one, is refused. A browser token that also carriescommon_namestays a browser identity. - JWKS seam + caching. The JWKS fetch is injectable (
JwksFetcher): the realhttpJwksFetcheruses globalfetch; tests inject a fake (the ≥2-implementations invariant). Keys are cached bykidwith a TTL (default 3600s) in a sharedJwksCache; an unknownkidtriggers exactly one refetch per verify (handles key rotation), and a genuinely unknown kid or a failed fetch fails closed (null). - Never throws on bad input. Any malformed token — wrong segment count, non-base64url, non-JSON header/payload, missing
kid, empty — yieldsnull, not an exception. Insrc/index.tsthe async gate's.catchis a403, never a500that would serve the page. token: a constant-time bearer.Authorization: Bearer <token>(scheme case-insensitive, surrounding whitespace tolerated) is hashed and compared withtimingSafeEqualagainst the env var's value, so neither the length nor a matching prefix leaks; a match is the browser-shaped identity{ sub: <name> }fordashboard.token.actor: access:<name>; no header, another scheme, a wrong, partial or empty token is403 forbidden. The socket address is irrelevant. A bare browser cannot send the header — this strategy is for a proxy, curl or a script in front of an Access-free deployment.none: loopback on a localhost deployment, or nothing. The identity is{ sub: "loopback" }(actoraccess:loopback) for a request on a loopback socket (127.0.0.1,::1,::ffff:127.0.0.1) of a localhost deployment; every other request — a remote socket, or any socket whenPUBLIC_BASE_URLis public or malformed — is403with a body that says why. Live token pages are no exception: undernonethe whole dashboard is local.- Selection and fail-fast. An explicit
dashboard.authwins; absent →accessifparseAccessConfig(env)is non-null, elsenone.accesswithout bothACCESS_*,tokenwithout its env var or without adashboard.token.actorof the formaccess:<name>(a service-token id or another namespace is refused), and an explicitnonewith a publicPUBLIC_BASE_URLare startup errors naming the missing piece; the implicitnonenever throws. Thedashboardblock itself is validated at load — a mapping,authone of the three words (a typo is refused, never read asnone),token.enva non-blank string,token.actoranaccess:<name>, andtoken.actorrequired whenauthistoken. - Startup log states the strategy. The HTTP server logs
dashboard auth: access (Cloudflare Access SSO, <team-domain>),dashboard auth: token (bearer from $<ENV> → access:<name>), ordashboard auth: none (no credential — loopback callers on a localhost deployment only)— never a secret.
Validation criteria
| Criterion | Evidence |
|---|---|
Valid RS256 token → { sub, email }; email omitted → { sub, email: undefined } | [unit] src/channels/accessAuth.test.ts::verifyAccessJwt::accepts a valid RS256 token and returns { sub, email }, ::returns { sub } with email undefined when the email claim is absent |
Expired or missing exp → null | [unit] ::verifyAccessJwt::rejects an expired token (exp in the past), ::rejects a token whose exp claim is missing |
aud mismatch (string + array) → null; array containing the AUD → ok | [unit] ::verifyAccessJwt::rejects an aud mismatch (string and array forms), ::accepts aud as an array that includes the configured AUD |
iss mismatch → null; future nbf beyond skew → null | [unit] ::verifyAccessJwt::rejects an iss mismatch, ::rejects a token whose nbf is in the future beyond skew |
| Signature integrity: tampered signature, altered payload, attacker key → null (red-verified: skipping signature verification flips all three) | [unit] ::verifyAccessJwt::rejects a token with a tampered signature, ::rejects a token whose payload was altered after signing, ::rejects a token signed by a different (attacker) key |
Algorithm confusion: alg:none (empty sig), HS256 HMAC-forged with the public key, and — red-verified — alg:none/HS256 carrying a genuine RS256 signature → null (removing the alg guard flips the last two + the gate's spoof test) | [unit] src/channels/accessAuth.test.ts::verifyAccessJwt::rejects alg:none with an empty signature, ::rejects alg:none even when a valid RS256 signature is attached (alg guard, red-verifiable), ::rejects an HS256 token forged with the public key as the HMAC secret, ::rejects alg:HS256 even when a valid RS256 signature is attached (alg guard, red-verifiable); the access strategy turns that null into 403 forbidden (src/channels/dashboardAuth.test.ts::accessVerifier — the Cloudflare Access strategy::refuses a missing header, a malformed token and an expired one with 403 (fail closed)) |
| Malformed/empty tokens and non-JSON header never throw → null | [unit] ::verifyAccessJwt::returns null (never throws) for malformed or empty tokens, ::returns null when the header base64 decodes to non-JSON |
| kid resolution: missing kid → null; unknown kid → null after exactly one refetch | [unit] ::verifyAccessJwt::rejects a token whose header has no kid, ::returns null for an unknown kid after exactly one refetch attempt |
| JWKS caching: fetcher called once across repeated verifies of the same kid; unknown kid → one refetch then verifies (rotation); certs URL is the team domain's cdn-cgi path; TTL expiry re-fetches | [unit] ::verifyAccessJwt::caches JWKS by kid: the fetcher is called once across repeated verifies of the same kid, ::an unknown kid triggers exactly one refetch, then verifies (key rotation), ::requests the JWKS from the team domain's cdn-cgi certs URL; ::JwksCache::re-fetches once a cached key has passed its TTL |
| Fail-closed on JWKS fetch failure → null | [unit] ::verifyAccessJwt::fails closed (null) when the JWKS fetch throws |
parseAccessConfig: both vars → config; scheme/slash/whitespace normalized; either missing/blank → null | [unit] ::parseAccessConfig::* |
access strategy (items 1–2): a valid Cf-Access-Jwt-Assertion → its identity, from any socket; a missing header, a malformed token or an expired one → 403 forbidden; a service token → the service identity; the description names the team domain, never a token | [unit] src/channels/dashboardAuth.test.ts::accessVerifier — the Cloudflare Access strategy::* |
token strategy (item 8): Authorization: Bearer <token> (scheme case-insensitive, whitespace tolerated) → the configured subject, from any socket; no header, another scheme, a wrong token, a prefix or superstring of it, or an empty bearer → 403 forbidden; the description names the env var and the actor, never the token | [unit] src/channels/dashboardAuth.test.ts::tokenVerifier — a bearer resolving to one configured actor::* |
none strategy (item 9): a loopback socket (v4, v6, mapped) on a localhost deployment → access:loopback; a non-loopback socket → 403 with the reason, whatever headers it carries; a public or malformed PUBLIC_BASE_URL refuses everyone, loopback included; isLoopbackAddress / isLocalhostBase are the two predicates | [unit] src/channels/dashboardAuth.test.ts::loopbackVerifier…::* |
Selection (item 10): an explicit mode wins; absent → access iff Access is configured, else none; tokenSubjectOf accepts exactly access:<name> | [unit] src/core/dashboardAuthConfig.test.ts::resolveDashboardAuthMode — the default-selection rule::*, ::tokenSubjectOf — the actor id a bearer resolves to::* |
Composition fails fast by name (item 10): no key + no ACCESS_* → none, no key + ACCESS_* → access; access without ACCESS_*, token without its env var (default DASHBOARD_TOKEN, or the named one) or without an access:<name> actor, and an explicit none on a public PUBLIC_BASE_URL throw naming the piece; the implicit none never throws; token reads the named env var and resolves to the configured actor | [unit] src/channels/dashboardAuth.test.ts::buildDashboardVerifier — composing the configured strategy, failing fast by name::* |
The dashboard block is validated at load (item 10): a non-mapping, an unknown key, a misspelled mode, a bad token block and auth: token without an actor are refused naming the key; each valid shape accepted and exposed as written | [unit] src/core/dashboardAuthConfig.test.ts::validateDashboardConfig…::*, src/config.test.ts::dashboard::* |
Service tokens: empty/absent sub + non-empty common_name → { sub:"", commonName } and isServiceToken true; empty sub without / with an empty or non-string common_name → null; browser token with common_name stays { sub, email }; expired service token → null; the access strategy admits a service-token header as the service identity | [unit] src/channels/accessAuth.test.ts::verifyAccessJwt — Cloudflare Access service tokens::*, src/channels/dashboardAuth.test.ts::accessVerifier — the Cloudflare Access strategy::admits a service token… |
/api/* sits behind the same gate via isCommandPath (every spelling); the handler itself carries no reachability rule (item 1) — the strategy decided before it ran | [unit] src/channels/commandHttp.test.ts::isCommandPath (the ONE gate predicate …)::*, src/channels/dashboardAuth.test.ts::loopbackVerifier…::* |
Live end-to-end under access: with ACCESS_* set and a Cloudflare Access rule on the dashboard paths, an SSO'd browser opens the live-view link; a direct origin request with a forged/absent Cf-Access-Jwt-Assertion gets 403; with ACCESS_* unset and no dashboard.auth, the strategy is none and every remote request gets 403 with the loopback reason; the startup log names the strategy (item 11) | [agent] Requires the bot deployed on the custom domain with the Access edge rule in place and ACCESS_TEAM_DOMAIN/ACCESS_AUD set; the none half is a deploy without them. |
src/index.tsroute wiring (the gate dispatch, the startup-log strategy line) has no unit harness in this repo — consistent with howliveView/ingress/mcpwiring is left to the handler unit tests. The gate's decision logic lives entirely inbuildDashboardVerifier+ the three verifiers +resolveDashboardAuthMode+parseAccessConfig, which are covered above.