@codefusion-cc/cloudflare-access
v0.1.0
Published
Cloudflare Access as an app's sign-in: the application token of each request verified in the Worker (signature against the team's cached keys, audience, issuer, expiry), a fetch for the page that tells a session that ended from a lost connection, and a fa
Maintainers
Readme
@codefusion-cc/cloudflare-access
Cloudflare Access as an app's sign-in. Access stands in front of some paths of the app, signs people in and adds a signed token to every request it lets through. This package is the app's side of that:
- in the Worker, the token of each request verified against the team's published keys, so a request that reached the Worker around Access is refused;
- in the page, a
fetchon which a session that ended is a 401 and not a lost connection; - for tests, a fake Access team whose keys the test holds.
Access says only who someone is. What they may do stays the app's.
WebCrypto and fetch only. The key cache is remoteKeys of @codefusion-cc/google-sign-in, which this package
depends on for it.
npm install @codefusion-cc/cloudflare-accessIn the Worker
import { accessApplication, accessSignIn } from '@codefusion-cc/cloudflare-access'
// Neither value is a secret: plain `vars`. Null while either is unset.
const application = accessApplication(env.ACCESS_TEAM_DOMAIN, env.ACCESS_AUD)
const signIn = await accessSignIn(request, application)
if (!signIn.ok) return error(signIn.status, signIn.reason) // 401 signed-out, 503 not-configured or keys-unavailable
const { email, subject } = signIn.identityaccessSignIn is the whole decision: without an application nobody is let in (503 not-configured, whatever token
a request carries); no token or one that does not verify is 401 signed-out, with the problem for the log and
never for the client; keys that could not be read are 503 keys-unavailable with retryAfterSeconds. The answer
is kept for the request, so a guard in front of the routes and a route can both ask and the token is verified once
(another application, or other options, is another question and is answered anew).
verifyAccessRequest and verifyAccessToken underneath give the bare check.
- What is checked: the token in
Cf-Access-Jwt-Assertion(never the cookie) is a JWT of at most 16 000 characters; its algorithm is RS256, fixed and never read from the token, soalg: noneand HMAC-with-the-public-key fail;issis exactlyhttps://<team domain>;audholds the application's audience tag;typeisapp(the team-wide session token is not for origins);expis in the future andnbfandiat, when there, are not, each with 60 s of allowance for clocks (clockSkewSeconds);emailis an address (a service token names no person and is refused); and last, the signature holds under the published key the token'skidnames. The claims come first only so a token that could never pass costs no key lookup; nothing is accepted before the signature holds. - Never the header's presence or the hostname. On an address Access does not cover (workers.dev, a preview, a
path the application misses) anyone can send the header; only a token the team signed for this application
passes. A request with no token is
missing, wherever it arrived. - Results, not exceptions:
{ ok: true, identity }or{ ok: false, problem }.keys-unavailablemeans the team's keys could not be read: answer 503, it is not a forgery. Only a programming mistake rejects with a TypeError: anapplicationthat is none, or anow,clockSkewSecondsormaxAgeSecondsthat is not a finite number from 0 (Number(env.MAX_AGE)of an unset variable isNaN, which would switch the limit off). A failure of the runtime's own WebCrypto rejects with its error. maxAgeSecondsrefuses a token issued longer ago, whatever its own expiry says: a ceiling on the session length that holds when the application's is set longer by mistake. Keep it at or above the application's session duration, or people are refused while Access still lets them through.
The team's keys
https://<team domain>/cdn-cgi/access/certs, through one cache per isolate and team (accessKeys):
- used for 5 minutes, then read again; a key the team withdraws stops working within that time;
- a token naming a key id the cache has not seen reads the keys again at once (Access rotates them every six weeks and keeps the old one for seven days), at most once per 30 seconds, so made-up key ids cannot make the Worker hammer the endpoint;
- while the endpoint cannot be reached, the last good keys serve for up to 24 hours; a key id they do not hold,
and everything after those 24 hours, is
keys-unavailable.
In the page
Once the session ends, Access answers a request with a redirect to its sign-in page on the team's domain. fetch
follows it into a CORS failure and rejects exactly as it does for a lost connection. Cloudflare documents the way
out (Cloudflare One → Access settings → Session management): Access answers 401 instead for requests that carry
X-Requested-With: XMLHttpRequest.
import { ACCESS_LOGOUT_PATH, accessFetch } from '@codefusion-cc/cloudflare-access/browser'
const send = accessFetch()
const response = await send('/api/panel/orders', { method: 'POST', body })
if (response.status === 401) showSignInAgain() // keep what the person typedaccessFetch() is fetch for the page's own origin with that header, credentials: 'same-origin' and
redirect: 'manual'; a redirect Access still answers with comes back as an empty 401. So 401 always means "not
signed in" (Access's, the Worker's, or the redirect), and a rejection is still a lost connection or an abort.
An API behind it must never redirect. It refuses another origin, and an init that asks for other credentials or
redirect.
Signing in again is any navigation of a tab to a path of the application: a reload, or a new tab when a form must
keep its values (window.open('/panel'), then retry once the person comes back). A request cannot do it. A link
to ACCESS_LOGOUT_PATH signs out.
Session length and revocation
The Worker sees a token, not the session behind it. It cannot learn that someone was taken off a policy or that
their session was revoked: an issued token verifies until its exp.
- On a hostname the application covers, Access refuses revoked sessions itself, before the Worker (Cloudflare: within about a minute). Taking someone off the policy alone does not revoke: their token lasts until the application's session duration ends. So remove the person and revoke their session (Zero Trust → Team & Resources → Users → Revoke), and keep the application's session duration short enough for the day that is forgotten.
- A short session costs little: when the application's token ends and the team-wide session still holds, the next navigation gets a new one without a prompt.
- What the Worker can do: cap the age it accepts (
maxAgeSeconds), and keep its own list of who may do what.
The Access application
One self-hosted application per app, with a destination for every path the Worker serves behind the sign-in (the
pages and their API, e.g. /panel and /api/panel), so they share one audience tag. A policy that allows the
staff's addresses, a session duration, and one login method. The team domain and the application's audience tag
(its Additional settings) are the two values the Worker needs.
In tests
import { testAccessTeam } from '@codefusion-cc/cloudflare-access/testing'
const access = await testAccessTeam()
vi.stubGlobal('fetch', access.fetch) // the certs endpoint, as the Worker's fetch; what else
// the Worker fetches: testAccessTeam({ passThrough })
const env = { ACCESS_TEAM_DOMAIN: access.teamDomain, ACCESS_AUD: access.audience }
await worker.fetch(await access.request('https://shop.test/api/panel/me', '[email protected]'), env)
await access.token('[email protected]', { aud: ['another-application'] })
await access.token('[email protected]', {}, { signedWith: 'stranger' }) // or 'none', 'hmac-public-key'
access.answerKeys('error') // 'network', 'malformed', 'empty', 'hang'
await access.rotate()Each team gets a domain of its own by default, so two tests never share cached keys. It runs in Node, in workerd and in a browser.
