Named share links, SSO, and an access log for gated dashboards.
Reusable auth layers for apps with the "public site, gate a slice" shape: a backend kernel (HMAC sessions + DB-backed grant tokens, an SSO IdP adapter) and source-agnostic React FE primitives (useWhoami / AuthGate / SignInPanel / WhoamiChip).
Mint a link, name it after the person you're sending it to, set how many times and how long it works, and see what they looked at. SSO for staff; request-access for everyone else.
Extraction target for the shipped implementations in watchy (Tier-2 reference), marin-gcs-usage (Tier 1), mortgage-viz (grants/nonce substrate), and applitrack (allowlist table).
Scope note: this is gating — sessions, SSO hand-off, share links, request-access, audit — not a general-purpose auth framework (no password store, no OAuth server, no RBAC engine).
Try it: auth.oa.dev
Get a throwaway sandbox, mint a named link, open it, watch its access log fill in — then revoke it and watch the session die on its next request. No account needed.
Backend kernel, request-access, the HTTP route surface, the React primitives, and the §4 analytics work (beacon, bot filtering, retention rollup) are implemented and covered by 231 tests, and deployed at auth.oa.dev. First adopter — watchy, the code this was extracted from — is live on it; see specs/adoption.md for who's next.
demo/— the deployed app: mint a link, watch its access log, revoke it and see the session diespecs/adoption.md— which repos should adopt this, in what order, and what each costsspecs/overview.md— two-tier model, layer split, packagingspecs/share-links-and-audit.md— share-link config, request-access, access log, analytics
core/ is runtime-agnostic — Web Crypto and a SQL-shaped store interface, nothing else. The Cloudflare coupling is exactly two adapters, kept as a file boundary rather than an abstraction layer (no plugin registry, no DI):
src/core/ sessions, tokens, grants, policy, requests, audit, routes — no CF, no Node
src/adapters/ d1.ts (grant + request stores, audit sink & queries), cf-access.ts (SSO IdP)
src/react/ useWhoami / AuthGate / SignInPanel / WhoamiChip / Avatar / disclosure — unstyled
src/testing/ in-memory stores, so adopters can test a gated route without a DB
migrations/ grants, access_log, access_requests, access_log_daily, dedupe index, request subject
demo/ a working Tier-2 app on Pages + Functions + D1
Peers of adapters/d1 are any SQLite (Turso, better-sqlite3) or Postgres; peers of adapters/cf-access are Google/GitHub OIDC, WorkOS, or no IdP at all. Every current consumer is on CF, so those stay the only two adapters until a non-CF consumer appears.
Not on npm yet — it ships as a dist branch, consumed by SHA (the npm-dist model other OA/personal libs use). npm comes once the API has stopped moving; the scope is registered and waiting.
pds gh auth # if the dep is already pds-managedor by hand, pinning a SHA rather than the branch so a consumer's build is reproducible:
SHA=$(gh api repos/Open-Athena/auth/commits/dist --jq .sha)
pnpm add "@open-athena/auth@github:Open-Athena/auth#$SHA"The dist branch only advances on a commit whose tests passed, and CI then installs the published branch and exercises it (scripts/verify-dist.mjs) — so any SHA you can pin is green as an artifact, not just as source. It carries built JS + .d.ts, the migrations, and the peer-dep declarations; versions read 0.1.0-dist.<sha>.
Peer deps are all optional and only needed for what you use: @cloudflare/workers-types (types only), and react + @tanstack/react-query for the /react subpath.
Apply the migrations, then build a gate:
import { createGate, domainPolicy, hasScope } from '@open-athena/auth'
import { d1AuditSink, d1GrantStore } from '@open-athena/auth/d1'
const gate = createGate({
store: d1GrantStore(env.DB),
audit: d1AuditSink(env.DB),
secret: env.SESSION_SECRET,
adminEmails: ['boss@openathena.ai'],
policy: domainPolicy(['openathena.ai'], ['internal']),
})
const auth = await gate.authenticate(request)
if (!auth || !hasScope(auth, 'internal')) return new Response('nope', { status: 401 })authenticate accepts a session cookie, Authorization: Bearer <token>, or ?key=<token> — the latter two let curl and scripts skip the cookie exchange.
Share links. Mint one, hand out the raw token exactly once (only its hash is stored), and let the browser trade it for a session:
const { grant, token } = await gate.mint({
name: 'Bob Smith (donor)',
scopes: ['reports'],
expiresAt: Math.floor(Date.now() / 1000) + 30 * 86400,
createdBy: auth.email,
})
// -> https://dash.example.org/?key=<token>
const res = await gate.redeem(token, request) // POST /auth/exchange
if (res.ok) return new Response(null, { headers: { 'set-cookie': res.cookie } })Every knob is optional; zero-config is an unlimited-use, never-expiring, unnamed link. maxRedeems counts sessions minted (≈ distinct browsers), not requests — which is what makes "one-use link" mean what a human predicts. Note that maxRedeems: 1 is hostile UX in practice (the recipient opens it on their phone, then their laptop, and is locked out); prefer unlimited-redeem, named, logged, and revocable.
SSO. Point one CF Access application at /auth/sso and leave the rest of the site public at the edge:
import { ssoHandler } from '@open-athena/auth/cf-access'
export const onRequest = ssoHandler({ gate, teamDomain: 'https://acme.cloudflareaccess.com', aud: env.ACCESS_AUD })Or skip Access entirely. @open-athena/auth/oidc signs people in against an OIDC provider directly, so the hosted chooser and its generic copy are replaced by a page you own:
import { GOOGLE, oidcCallback, oidcStart } from '@open-athena/auth/oidc'
const cfg = { gate, clientId: env.GOOGLE_CLIENT_ID, clientSecret: env.GOOGLE_CLIENT_SECRET,
redirectUri: 'https://app.example.org/auth/google/callback' }
export const start = oidcStart(cfg) // -> /auth/google
export const callback = oidcCallback(cfg) // -> /auth/google/callbackAuthorization-code flow, confidential clients only, nothing persisted between the two requests: state is HMAC'd with the gate secret and carries the next path plus a nonce, and the nonce is double-submitted via a short-lived cookie — without that, a signed state minted from the attacker's own sign-in is replayable against someone else's browser, and the victim ends up quietly signed in as the attacker. GOOGLE is a preset, not a special case; another issuer is four URLs.
A verified address that policy rejects redirects with ?denied=<email> rather than 403ing, which is what lets an app pre-fill request-access with an address the provider vouched for instead of one the visitor typed.
There's also a seat argument: every Access-authenticated user consumes a Cloudflare Zero Trust seat, while share links never touch Access at all. A growing allowlist hits that ceiling; this is the way off it.
ssoSessionHandler is the same thing for a deployment that can mint sessions but not verify them — the auth store lives in another worker, so there's no gate to hand it. It takes { secret, teamDomain, aud, cookieName } and mints for any Access-verified email; the gate that later verifies the cookie re-derives scopes from policy on every request, so authorization isn't being skipped, just deferred to where it can be answered.
Revocation is instant. Grant-backed sessions re-join their grant row on every request, so gate.revoke(id) kills every session that link ever minted — no waiting out a cookie TTL. That property is what makes the social story work: assume links get forwarded, and design so forwarding is visible and revocable rather than prevented.
Three verbs, not one, because "stop handing this out" and "throw everyone out" are different actions:
| new redemptions | sessions already minted | |
|---|---|---|
disable(id) / enable(id) |
✗ | untouched — and reversible |
revoke(id) |
✗ | dead on their next request, permanently |
expiresAt passing |
✗ | dead, unless expiryEndsSessions: false |
expiryEndsSessions defaults to true — the data-room reading, where "expires Friday" means access ends Friday. Set it false and expiresAt becomes purely a redemption window, with each session then living out its own sessionTtlS; that's the maxRedeems: 1 intuition generalised, where a link stops being redeemable the moment it's used without logging anybody out.
gate.update(id, patch) changes a link's terms after the fact — expiry, cap, TTL, memo — so extending a deadline doesn't mean minting and re-sending a second link. (sessionTtlS is baked into the cookie at redeem, so it only affects future redemptions.)
The access log is one store for auth-lifecycle events and (optionally) views, so "who viewed what" joins to grants natively. Lifecycle events always log; view events are deduped per (session, path, hour) by a partial unique index, and are off by default — turn them on alongside the "access is logged" disclosure copy, not silently. Client IPs are never stored, only HMAC(ip, secret).
Magic links / passwordless sign-up. anyEmailPolicy auto-approves any address, mints a grant bound to it, and hands it to notify — which becomes a real sign-in flow once notify can send mail:
import { emailNotify } from '@open-athena/auth'
import { resendEmail } from '@open-athena/auth/resend'
notify: emailNotify({
send: resendEmail({ apiKey: env.RESEND_API_KEY }),
from: 'Reports <auth@example.org>',
adminTo: 'boss@example.org', // access-requested goes here
linkFor: token => `https://reports.example.org/?key=${token}`,
})No password store and no account table: delivery is the verification. A link sent to the claimed address proves mailbox control; a link handed straight back to whoever typed the address proves nothing — which is why the demo, having no ESP, is explicit that showing you the link is the one dishonest step on the page.
The access-granted message is the only place a token is ever rendered, and it goes to the bound address alone — not to the admin who approved it, not into a log, and not into the subject line. Denials send nothing: a denial notice confirms to a prober that the address exists and that a human looked, and carries nothing actionable for a real requester.
SendEmail is one method, so Postmark or SES is a sibling file rather than a refactor. (MailChannels' free Workers integration ended in 2024, so an ESP is a real dependency now.)
Mounting it. authRoutes(gate, opts) is a whole /api/auth/* surface — whoami, exchange, logout, request-access, and admin grant/request/log routes — returning null for paths it doesn't own so your router can fall through. creatorOf/scopeToCreator confine an admin to their own grants, which is how the demo lets strangers share one deployment.
Request access collects an address, and optionally a person: <RequestAccessForm askName="split" /> posts first/last, stored as the same Subject a grant carries — so approving mints a link that knows who it's for, and the watermark says "Ada Lovelace" rather than ada@…. An avatar is never accepted from the form (a stranger-supplied URL rendered on the admin's queue is a tracking pixel aimed at the reviewer); <Avatar> derives initials instead, or renders subject.avatar when the app sets one itself.
On the frontend, @open-athena/auth/react ships the logic and leaves the presentation to you — every string and class is a prop, and no CSS is bundled:
<AuthGate
source={{ kind: 'app' }} // or { kind: 'edge' } for Tier 1 — the only line that changes
signIn={<SignInPanel signInUrl="/auth/sso" requestAccess />}
>
{whoami => <>
<AccessNotice whoami={whoami} /> {/* "Private link for Bob Smith · access is logged" */}
<Dashboard />
</>}
</AuthGate>pnpm install
pnpm test # vitest; core runs against an in-memory store, adapters against node:sqlite
pnpm typecheck
pnpm build
cd demo && pnpm dev # the whole thing running, on :4187pnpm build compiles src/core and src/adapters against @cloudflare/workers-types alone (no Node types), which is what keeps them honest about being runtime-agnostic. src/react is a separate compilation because DOM lib and workers-types declare conflicting globals.
