Skip to content

Cloudflare Access: CLI auth without Access-app configuration#12

Open
koomen wants to merge 12 commits into
mainfrom
cloudflare-access
Open

Cloudflare Access: CLI auth without Access-app configuration#12
koomen wants to merge 12 commits into
mainfrom
cloudflare-access

Conversation

@koomen

@koomen koomen commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements notes/cloudflare-access.md.

Server: Cloudflare Access auth mode

  • SCRATCHWORK_AUTH=cloudflare-access delegates auth to a Cloudflare Access application: the server verifies the edge-injected Cf-Access-Jwt-Assertion (RS256 signature against the team JWKS, issuer, AUD tag, expiry) and uses the asserted email as the identity. Shared RS256/JWKS machinery extracted to jwt-rs256.ts (also used by the Google ID-token path).
  • /auth/login converts the verified assertion into the CLI bearer token and relays the Access JWT to the CLI loopback as cf_token (loopback-validated redirects only, same exposure class as the bearer token).
  • API requests also accept the relayed JWT via a cf-access-token header, verified identically to the assertion header; the edge-injected header wins when both are present. The AUD check is enforced in every acceptance path.

CLI: pass the Access-protected edge

  • auth.json records gain an optional cfToken (file stays version 1; old files and old CLIs unaffected). apiRequest resolves it per origin and sends it as cf-access-token, which Cloudflare's edge accepts as an Access credential — so publish/share/etc. work with zero Access-application configuration.
  • SCRATCHWORK_CF_ACCESS_CLIENT_ID/SECRET attach Access service-token headers for CI/headless use (identity still comes from the bearer token).
  • Edge blocks (403 + cf-mitigated, or an Access login page where JSON was expected) fail with a clear "run scratchwork login again" prompt instead of dumping HTML; ordinary 403s and non-Access HTML error pages still flow through to callers unchanged.

Docs

  • README: the /api/* Access bypass policy is now an optional fallback for older CLIs; documents the relay, service tokens, and the session-duration recommendation. spec.md "Authenticating" updated.

Test coverage

  • Server (143 pass): login relays cf_token in CF mode and never in OAuth mode (including a full mocked Google login→callback round trip); cf-access-token acceptance, assertion-header precedence, invalid tokens rejected; JWKS/RS256 and config-validation suites.
  • CLI (82 pass): callback decoding, auth.json round-trip + old-file compatibility, and apiRequest against real loopback servers for header attachment, service tokens, both edge-block shapes, and both false-positive guards.

Test plan

  • cd cli && bun run check — typecheck + 82 tests pass
  • cd server && bun run check — typecheck + 143 tests pass across core/scripts/deploy suites
  • Manual: verify against a real Access-protected deploy (login → publish; expired-session edge block)

🤖 Generated with Claude Code

koomen and others added 9 commits July 6, 2026 14:50
SCRATCHWORK_AUTH=cloudflare-access delegates authentication to a
Cloudflare Access application: the server verifies the edge-injected
Cf-Access-Jwt-Assertion (RS256 vs team JWKS, issuer, AUD tag, expiry)
and uses the asserted email as the identity. /auth/login converts the
assertion into the CLI bearer token and relays the verified Access JWT
to the CLI loopback as cf_token; API requests also accept the relayed
JWT via a cf-access-token header, verified identically.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Store the cf_token relayed by a cloudflare-access server at login in
auth.json and send it back as a cf-access-token header on every API
request, so publish/share/etc. pass the edge with no Access bypass
policy. SCRATCHWORK_CF_ACCESS_CLIENT_ID/SECRET attach Access
service-token headers for CI. Requests the edge blocks anyway (403 +
cf-mitigated, or an Access login page where JSON was expected) fail
with a clear re-login prompt instead of dumping HTML.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The /api/* Access bypass policy is now an optional fallback for older
CLIs; document the token relay, service-token env vars, and the Access
session-duration recommendation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
In cloudflare-access mode, requireApiUser propagated a bearer-signature
failure (e.g. a token signed with a rotated session secret) instead of
trying the request's Access assertion, locking the CLI out until manual
re-login even though it carried a valid credential. Treat an unverifiable
bearer as absent, matching currentUser.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the wrangler CLI shell-outs in deployServer with the official
cloudflare SDK: R2 bucket and D1 database provisioning, the multipart
worker upload with R2/D1/plain-text bindings, zone routes and custom
domains, the workers.dev subdomain, and secrets all go through the REST
API. Deploys now authenticate with CLOUDFLARE_API_TOKEN (plus
CLOUDFLARE_ACCOUNT_ID when the token sees several accounts) instead of
wrangler credentials; the local runtime still runs through wrangler dev,
which remains the only way to run workerd locally.

Rename deploy/sndbx.sh to deploy/cloudflare-vanilla to match the other
deploy projects' flavor naming (compare deploy/cf-access), and document
the exact token permissions in its .env.example.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@koomen

koomen commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

Pushed 7596985, which adds two related changes to this branch:

  • Deploys now go through the official cloudflare SDK instead of shelling out to the wrangler CLI: R2/D1 provisioning, the multipart worker upload with bindings, routes and custom domains, the workers.dev subdomain, and secrets all use the REST API. Deploys authenticate with CLOUDFLARE_API_TOKEN (+ CLOUDFLARE_ACCOUNT_ID when the token sees several accounts); the local runtime still uses wrangler dev, the only way to run workerd locally.
  • deploy/sndbx.sh is renamed to deploy/cloudflare-vanilla to match the flavor naming of the other deploy projects (compare deploy/cf-access), with the run scripts renamed to deploy:cloudflare-vanilla / local:cloudflare-vanilla and the required token permissions documented in its .env.example.

🤖 Generated with Claude Code

Pete Koomen and others added 3 commits July 8, 2026 17:27
"Visibility" meant two different things: a full access-group ceiling in
server config (maxVisibility) and a binary public/private toggle
everywhere else. Make every setting say exactly what it does:

- Server: allowPublicProjects (bool), allowedShareDomains,
  publicByDefault (bool) replace maxVisibility, shareAllowedDomains,
  defaultVisibility. Retired SCRATCHWORK_* variables fail startup with
  a pointer to their replacement so policy is never silently dropped.
- Project: a boolean isPublic replaces the visibility string in the
  stored record (now version 5; v4 records migrate at load), the
  publish wire format, API responses, and .scratchwork.json.
- CLI: --public/--private flags replace --visibility.

allowedShareDomains is now also enforced at role-resolution time, so
tightening it locks down existing grants the way the maxVisibility
subset check used to. Setting share domains no longer implicitly
forbids public publishes; that is allowPublicProjects' job.
accessGroupIsSubset had no remaining callers and is removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rename deploy/cf-access to deploy/cloudflare-access and give it the same
shape as cloudflare-vanilla: shared server-config.ts/cloudflare-config.ts
consumed by both deploy.ts and local.ts, plus an .env.example.

The remote deploy serves access.sndbx.sh (app) and access-pages.sndbx.sh
(content) behind a Cloudflare Access application, with the Worker
scratchwork-access and its own R2 bucket and D1 database. Everything is
private (allowPublicProjects: false) since Access blocks anonymous
visitors at the edge; allowedUsers stays "public" so the Access policy
alone decides who gets in. The team domain and AUD tag live in .env
because they only exist once the Access application is created.

The local run keeps its previous behavior — simulated Access edge, no
.env or Cloudflare account needed — now driven by the shared config. The
root scripts become local:cloudflare-access / deploy:cloudflare-access,
and stale cf-access references in the READMEs are updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The generic sign-in page swallowed the reason auth failed, so a
Cloudflare Access misconfiguration (wrong AUD tag, missing assertion
header) rendered the same page as an ordinary signed-out visit and was
undiagnosable from the browser. Surface the caught message as the note
line under the friendly copy on 401 and 403 pages.

This exposes nothing new: the JSON error path already sends these exact
messages to API clients for the same statuses. 404 and 5xx pages are
unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant