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*, /delivery*, /settings*, the /threads* chat and its send route, 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 current host-resolved Actor, shared with the /api/* adapter and 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. MCP reuses this verifier for named sessions before reading its request body (mcp-ingress.md item 11), with the same browser actor and grants. Static and personal MCP bearers remain separate credential paths. /ingress and the health probe are unaffected.
Two identity shapes. A browser session has a signed, non-empty Access sub; a Cloudflare Access service token has an empty sub and a non-empty common_name. isServiceToken(identity) distinguishes them. The raw accessActor helper maps those identifiers to access:<sub> and access:svc:<common_name>; it does not prove a personal organization source. Access service tokens remain limited to /api/* by serviceTokenAllowed.
Current dashboard actors. The production host resolves personal browser pages and their command requests through browserHomeWiring.context, fresh native Access proof, the organization directory and current grants. The verified subject owns the account; an email lookup cannot select another person's private data. Configured dashboard bearer reads use tokenPageActor, retaining the configured caller as a service actor with its current grants and own data scope, including when organization storage is absent. The host uses the same service actor for that bearer's /api/* requests. The token verifier's { sub } transport shape is not a human browser attestation. Loopback none admits only the local transport and supplies no Access or identity-link proof. Personal Home and account linking still need independent current human proof (record 0104).
Retained legacy resolver. resolveAccessActor and the cached Slack email lookup remain separately tested helpers from record 0042. In that helper, a matching active human can add self and asUser while leaving the original actor's grants unchanged; missing or failed lookup leaves it unlinked. Those helper tests do not prove production browser identity or private-source authorization.
- Code:
src/channels/browserHomeWiring.ts(configured token page actor remains separate from personal Home proof);src/channels/organizationNativeSources.ts(current source proof distinguishes provider loss from denial);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/browserHomeWiring.test.ts;src/channels/organizationNativeSources.test.ts;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 after a successful refresh fails closed (null). A failed provider read remainsnullfor default low-level verification; current dashboard verification uses the same verifier in strict availability mode and returns503 authentication unavailable. The native browser source maps that response tounavailable, preserving pending work instead of recording a denial. Malformed input, expired claims, and a bad signature remain refused; known invalid claims need no provider read. - 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. Authorized dashboard reads use that configured caller’s current grants and own data scope, even when organization storage is absent. The bearer supplies no human private Home proof, linked Slack owner or personal identity-link authority; those paths remain fenced. Asynchronous report reads recheck the same credential and current grants before returning data.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 |
| Current signed browser proof: expired signing-key cache plus cert-provider outage returns 503/unavailable for concurrent checks; recovery re-verifies the same unexpired JWT. Forged signature, successful-refresh unknown key, and expired JWT remain fenced. | [unit] src/channels/organizationNativeSources.test.ts::organization native sources::keeps a valid signed browser session unavailable through a cert outage and revalidates after recovery |
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. |
| Explicit identity proof configuration refuses incomplete or incompatible flows and names missing settings | [unit] src/core/dashboardAuthConfig.test.ts::validateDashboardConfig…::accepts explicit identity proof configuration and refuses incomplete or incompatible flows |
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.
| Criterion | Proof |
|---|---|
| Browser proof remains available before a Slack realm is registered; Slack proof is unavailable until the current realm is known, verifies after registration, and fences a changed installation. A realm changing during verification cannot release the old proof. | [unit] src/channels/organizationNativeSources.test.ts::organization native sources::keeps browser proof independent while Slack realm is unavailable and verifies Slack afresh after registration, src/channels/organizationNativeSources.test.ts::organization native sources::holds Slack proof when its live realm changes during native verification to %s |
| A superuser can read a known offline Access requester's work through that requester's current config ceiling. The projection asserts no native target authentication or Slack membership, and unknown Access targets cannot create a projection. | [unit] src/channels/commandHttp.test.ts::createCommandHttpHandler — caller resolution::views a known offline Access requester through their current config ceiling without asserting native authentication |
| A valid configured dashboard bearer opens its own Runs page without organization storage, sees no other requester’s rows, and cannot become a personal Home source. Invalid or changed credentials refuse later report reads; Access sessions still require their independent current personal proof. | [unit] src/channels/browserHomeWiring.test.ts::browser production wiring::serves the configured token caller's run page without inventing a private human Home source; ::keeps token private Home fenced and rechecks the original credential for later report reads |
| A verified service token requesting a browser page receives 403. Its once-per-process warning uses fixed text and never includes the requested path. | [unit] src/channels/commandHttp.test.ts::serviceTokenAllowed — a service token is a command-surface credential only::admits a service token on /api/* (every spelling isCommandPath claims) and nowhere else; [agent] Inspect the service-token refusal branch in src/index.ts: after verification, the denial remains before page actor resolution, and its warning is a fixed string with no request interpolation. Source inspection qualifies the log copy; live logging is not claimed. |