Skip to content
Open-AthenaPublic

About

Named share links, SSO, and an access log for gated dashboards. Live demo: https://auth.oa.dev

Resources

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

126 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

@open-athena/auth

Named share links, Google sign-in, email codes, 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, Google/OIDC sign-in, emailed codes), a framework-free client, unstyled React primitives, and Vue useWhoami / AuthGate wrappers.

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. Google or an emailed code for people the policy admits; request-access for everyone else.

Scope note: this is gating — sessions, sign-in, 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.

@open-athena/auth

Status

Pre-1.0 and moving: the backend kernel, Google and email-code sign-in, request-access, the HTTP route surface, the React primitives, and the analytics work (beacon, bot filtering, retention rollup) are implemented, tested, and deployed at auth.oa.dev. See specs/adoption.md for who uses it.

Layout

core/ is runtime-agnostic — Web Crypto and a SQL-shaped store interface, nothing else. Everything platform-specific is an adapter, kept as a file boundary rather than an abstraction layer (no plugin registry, no DI):

src/core/       sessions, tokens, grants, policy, requests, email codes, audit, routes — no CF, no Node
src/adapters/   d1 (stores, audit sink & queries), oidc (Google + any issuer), resend (mail),
                r2 (avatar bytes), google-directory (group → allowlist sync)
src/client/     typed fetch client, URL/exchange/identity/image helpers — no framework imports
src/react/      useWhoami / AuthGate / SignInPanel / WhoamiChip / Avatar / AvatarField / disclosure — unstyled
src/vue/        useWhoami / AuthGate — Vue-only composable and slotted gate, unstyled
src/session-log/  optional, time-boxed user-action logging (routes; D1 store in adapters/,
                logger in client/session-log/, admin UI in react/session-log/)
src/testing/    in-memory stores, so adopters can test a gated route without a DB
migrations/     the schema (one baseline file pre-1.0; see below)
scripts/        provisioning (Google client, Resend domain, group-sync SA) and d1-rebaseline
demo/           a working app on Pages + Functions + D1

Peers of adapters/d1 are any SQLite (Turso, better-sqlite3) or Postgres; every current consumer is on Cloudflare, so D1 is the only store adapter until a non-CF consumer appears.

Framework-Free Client

Vue/Slidev, vanilla JavaScript and React can all call the same HTTP surface without importing React:

import { AuthClientError, createAuthClient, shareUrl } from '@open-athena/auth/client'

const auth = createAuthClient() // defaults to /api/auth; fetch is injectable
const { grant, token } = await auth.grants.mint({ scopes: ['reports'], name: 'Board packet' })
const link = shareUrl(token, 'https://app.example/reports/3?tab=costs#notes')
await auth.grants.update(grant.id, { note: 'Shared with the board' })
await auth.grants.revoke(grant.id)

grants also supports list({ active? }), disable, enable, rotate(id, { endSessions? }) and activity; the client has whoami(), logout() and log({ grant?, limit? }). Successful values retain the route's JSON envelope. Non-2xx responses throw AuthClientError with status and body (JSON when available, otherwise text/null); network and malformed-success-JSON errors stay distinct. Unlike useWhoami, client.whoami() throws on 401 rather than returning null. Allowlist wrappers and migrating the existing React hooks/admin UI onto this client are deferred.

displayName, hasScope, hasKeyParam, exchangeKeyParam and downscaleImage are available from /client, along with the shared wire types. Existing /react helper imports still work. Importing /client is SSR-safe; browser-only helpers need browser APIs when called, and shareUrl needs an explicit absolute target outside the browser. It preserves the target's other query parameters and hash, replacing an existing key. The emitted module graph is checked during pnpm build for external dependencies, including React/Vue.

The backend root import and platform adapters remain separate from /client. React hooks/components live under /react; Vue wrappers live under /vue. Vanilla apps need neither framework.

Session Loss

authenticate() still returns Auth | null. For a gated route that needs a consistent 401, use the detailed result once:

const result = await gate.authenticateResult(request)
if (!result.auth) return gate.unauthenticated(request, result)
// Enforce this route's scopes against result.auth before serving private data.

The response is { error: 'unauthenticated', reason }, where reason is revoked, expired, rotated, delisted, invalid, or null for an anonymous request. A failed session cookie is expired and the response requests Clear-Site-Data: "cache"; other cookies and browser storage are not cleared. Failed explicit Bearer or ?key= credentials never clear the browser cookie/cache, even beside a live cookie. gate.unauthenticated(request) can resolve the failure itself when a caller has not already authenticated. /whoami and protected authRoutes endpoints use the same helper.

In the app's fetch wrapper or query-cache 401 handler, report the failure to the matching whoami endpoint:

import { isSessionLossReason, reportUnauthenticated } from '@open-athena/auth/client'

if (response.status === 401) {
  const body = await response.clone().json().catch(() => null)
  reportUnauthenticated(isSessionLossReason(body?.reason) ? body.reason : null, {
    endpoint: '/api/view/whoami', // omit for the default /api/auth/whoami
  })
}

This revalidates identity; it is not an unconditional logout command. If a rejected explicit credential accompanied a healthy browser cookie, whoami can confirm that cookie and keep the app signed in. React and Vue observers receive the reason, including when the first 401 already cleared the cookie and a later whoami has no reason. A successful identity probe or explicit sign-out clears it. Existing imports remain valid; reportUnauthenticated is also exported from /react and /vue.

React AuthGate passes the reason as the second argument to signIn(refresh, reason) and supplies it automatically to a nested SignInPanel. The panel accepts reason and reasonMessage overrides. Vue exposes reason as a readonly ref and in gate slot contexts; custom/vanilla walls can use sessionLossMessage(reason). useWhoami and AuthGate (React) and the Vue options accept revalidateS for optional periodic probes, off by default. Focus/visibility checks remain enabled. No push connection is needed.

Clear-Site-Data is a browser cache signal, not application-memory or service-worker cache cleanup, and support requires a secure context. Keep successful sensitive responses Cache-Control: no-store where appropriate. Consumers must also cancel/discard their own private query/store data on identity loss; target those caches rather than clearing the identity query itself while it is revalidating. Revocation cannot erase content already read or copied. See Clear-Site-Data.

Vue

Import useWhoami, AuthGate, and shared identity helpers from @open-athena/auth/vue. Vue is an optional peer dependency; React and TanStack Query are not required for this subpath.

<script setup lang="ts">
import { AuthGate, displayName } from '@open-athena/auth/vue'

const options = {
  basePath: '/api/view',
  exchange: { endpoint: '/api/view/exchange' },
}
</script>

<template>
  <AuthGate :options="options">
    <template #loading>Checking access...</template>
    <template #error="{ error }"><p role="alert">{{ error.message }}</p></template>
    <template #unauthenticated><h2>Private workspace</h2></template>
    <template #default="{ whoami, signOut }">
      <p>{{ displayName(whoami) ?? 'Anonymous link' }}</p>
      <button @click="signOut">Sign out</button>
    </template>
  </AuthGate>
</template>

useWhoami(options) runs during component setup and returns readonly whoami, loading, and error refs plus refetch() and signOut(). A 401 means signed out; other failures populate error. Matching clients share state within a Vue app, not across apps or SSR requests. Options are setup-time configuration: remount to change them. The composable opts into key exchange through exchange; AuthGate enables it by default, before probing identity. The exchange endpoint defaults to <basePath>/exchange; a supplied custom client needs an explicit exchange endpoint if its base differs. Slots receive whoami, loading, error, refetch, and signOut. signOut() records errors in state and rejects, so event handlers should handle that rejection. The server must still enforce authorization.

Several Accounts in One Browser

Off by default. With retained on a gate, a browser keeps several logins (email sign-ins and share links) and switches between them without signing in again. Each request acts as exactly one of them, the active one; scopes are never combined.

import { d1RetainedStore } from '@open-athena/auth/d1'
const gate = createGate({
  ...,
  retained: { store: d1RetainedStore(env.DB, { sessions: 8, pending: 4, gateBrowserSets: 50_000, gatePending: 10_000,
    browserTtlS: 30 * 86400, pendingTtlS: 600, retryTtlS: 86400, defaultSessionTtlS: 7 * 86400 }) },
})

It needs migrations 0003 and 0004. Run store.cleanup(nowS) now and then; Pages Functions have no cron, so the demo runs it on a small share of requests.

  • Signing in (Google, an emailed code, One Tap) adds the address and selects it. The browser's other logins stay.
  • A share link opened while signed in is held, unspent, as an account to switch to (incoming: 'keep', the default), so a member following a guest link stays the member. 'switch' changes to it at once, or a function decides per link. With no login, a link signs in as before.
  • Routes (with authRoutes): GET /accounts, POST /accounts/select|remove|incoming and POST /logout-all. POST /logout signs out of the active account only.
  • Stale selections: each change carries X-OA-Selection-Context, the selection the page last saw, which whoami and exchange return. A stale one gets 409, so a tab still showing account A can't act as B. gate.retained.checkMutation(req) applies the same check to your own mutations.
  • Older clients: a sign-out with no header (a client from before retained mode) signs out whatever is active.
  • Client: createAccountsClient (/client), or React's useAccounts and <AccountSwitcher>. Tabs hear about each other's switches over a BroadcastChannel.
  • Existing sessions: a single-session cookie from before is imported on the browser's next sign-in, keeping its original expiry, so turning this on signs nobody out.

Session Log

Optional, time-boxed logging of what signed-in users do, for replaying a reported bug: page loads, URL changes, clicks on controls, requests (route, status, timing, the error body's code), errors and console errors, viewport and visibility, plus app events you declare. It's an event log, not a screen recorder: no DOM snapshots, keystrokes, headers or request bodies (a request body is kept as counts and kinds only), and input contents only from fields marked data-log-input. Secrets in URLs (key, code, token, signed S3/GCS params, and any you add) are masked before they leave the page.

It's off by default. An admin turns it on for a few days (default 7, at most 31, or explicitly with no end date); a dated run turns itself off, on the server and in open tabs. Batches are idempotent per (sid, pid, seq), capped at 20,000 events per page load and per session, and purged after 30 days in bounded chunks.

Subpath What
@open-athena/auth/session-log sessionLogRoutes({ store, identify, requireAdmin, appEvents? }) and fromGate(gate, { adminScope }); the shared shapes
@open-athena/auth/session-log/d1 d1SessionLogStore(db); schema in migrations/session-log/
@open-athena/auth/session-log/client bootSessionLog({ build }) and slog(kind, fields); the logger itself is a lazily imported chunk
@open-athena/auth/react/session-log SessionLogSwitch, SessionList, SessionView (needs a QueryClientProvider)
@open-athena/auth/react/session-log.css optional styles
// functions/api/session-log/[[path]].ts: one routes instance per isolate (it caches the switch for 60 s)
const handle = sessionLogRoutes({
  store: d1SessionLogStore(env.DB),
  ...fromGate(gate, { adminScope: 'admin' }),
  appEvents: { 'movie.queued': ['query', 'format'] },   // a declared app event and its scalar fields
  onSwitch: ({ prior, row }) => { /* your audit trail */ },
})
// in the app's entry: one request per page load while off
void bootSessionLog({ build: import.meta.env.VITE_BUILD_SHA })
// anywhere: a declared app event (dropped while off, or if undeclared)
slog('movie.queued', { query, format })

The identity on every batch is the request's (identify), never the client's. App events must be declared on the server: the client drops undeclared ones and undeclared fields, and the server refuses them, so an app event can't carry an arbitrary object. filterEvents, onStored (run through waitUntil) and onPurge hook in an app's own muting, activity feed and derived tables.

The migration is a track of its own (migrations/session-log/0001_session_log.sql, CREATE … IF NOT EXISTS), so apps that don't use the session log carry no tables for it. Apply it with wrangler d1 execute <db> --file …, or copy it into your own migrations. Its tables are disky's, so disky adopts with no migration.

Minimal Demo Routes

The demo's root and Vanilla routes use the framework-free client; Vue and React show their respective wrappers. Framework links navigate to separate documents so vanilla loads neither framework, and Vue and React do not load each other.

Each example opens a named or anonymous share link, exchanges its key for a session, removes the key from the URL, and shows the returned identity. Sign out returns to the private-workspace wall. The examples use raw identity JSON from GET /api/view/whoami (401 when signed out), POST /api/view/exchange with { token }, and POST /api/view/logout; /api/demo-link?named=1|0&next=<route> mints a link and redirects back to the selected example.

Run pnpm test:browser for isolated Chromium regressions (mocked auth boundaries). With pnpm --filter @open-athena/auth-demo dev running at http://localhost:4187, pnpm test:browser:live exercises actual local Pages Functions, D1, and shared session cookies across frameworks. It does not send email or authenticate with Google.

Installing

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-managed

or 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), react + @tanstack/react-query for /react, and vue for /vue.

Migrations

migrations/0001_init.sql is the baseline schema, and 0002_access_log_location.sql adds the log's location columns. A fresh database applies it (wrangler d1 migrations apply with migrations_dir pointed at the package's migrations/, as the demo does, or its content copied into your own sequence alongside your app's tables).

Pre-1.0, migrations are squashed: a schema change lands as a new numbered file, and once every known database has applied it, the directory is collapsed back into one baseline rather than kept as history. To move an existing database across a squash, bring it to the previous head (apply the pre-squash files from the SHA you're on), then mark the new files applied without running them:

scripts/d1-rebaseline.mjs --db <name> --migrations-dir migrations --remote [--wrangler <cmd>]   # dry run
scripts/d1-rebaseline.mjs --db <name> --migrations-dir migrations --remote --run

It builds the schema the directory describes in an in-memory SQLite, compares it structurally with the live database (columns, constraints, indexes — column order and comments ignored), and only then rewrites wrangler's d1_migrations rows; a mismatch prints the difference and writes nothing. It's schema-agnostic, so an app that squashes its own migrations (auth tables next to its own) uses it the same way.

Quickstart

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.

Sign in with Google (or any OIDC issuer). @open-athena/auth/oidc signs people in against the provider directly, on a sign-in page you own. Pair it with One Tap (GoogleOneTap) in the page and email codes (below) for addresses Google can't vouch for:

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/callback

Provisioning the Google client is the one genuinely manual step, and not for lack of trying: Google exposes no API to create a "Web application" OAuth client or read its secret — it's Cloud-Console-only (specs/done/oauth-client-iac.md has the full spike). So instead of a nonexistent terraform apply, there's scripts/provision-oauth-client.mjs, which scripts the envelope around that click — prints a deep link to the create form with the exact field values, then reads the Console's "Download JSON" (checking the client lists every origin and redirect URI you asked for) and stores the id/secret in your Pages secrets and, optionally, a local .dev.vars, never echoing the secret. Default is a dry run:

scripts/provision-oauth-client.mjs \
  --project <gcp-project> \
  --app-origin https://your-app.pages.dev --app-origin http://localhost:4187 \
  --redirect-uri https://your-app.pages.dev/auth/google/callback \
  --from-json client_secret_….json \
  --pages-project your-app [--wrangler <account-pinning wrapper>] [--dev-vars .dev.vars]   # add --run to store

A first Google sign-in seeds the person's profile (their name, and a copy of their picture; see Faces below) when the gate has a profiles store, so the chip shows a face without any setup; pass seedProfile: false to oidcCallback / googleOneTapVerify to opt out. It never overwrites a profile the person set themselves.

Use one client per deployment (the callback aud names the app, so a token minted for one is inert at another). For One Tap (GoogleOneTap), the app's origin just needs to be in the client's Authorized JavaScript origins.

One Tap is button-first: Google's rendered button, with the redirect as its fallback. The corner prompt is opt-in (oneTap={{ clientId, prompt: true }}), because it's an overlay the visitor didn't ask for; turn it on where nearly everyone signs in with Google. prompt: { autoSelect: true } signs a returning visitor in with no click at all. Silent sign-in needs two things: the account has already granted this client through One Tap or the button (a redirect sign-in doesn't count), and the browser supports FedCM or allows third-party cookies. Both flows request FedCM, so Chrome shows its own account UI in the page instead of a popup window. An External consent screen can't be published without a privacy-policy link on its Branding page.

One click on the in-page button. GoogleOneTap enables Google's button_auto_select by default: eligible returning users can click the personalized button without choosing the same account again. This is independent of prompt: { autoSelect: true }, which controls signing in without a click. Set oneTap={{ clientId, buttonAutoSelect: false }} to retain the button's chooser. Google/browser consent and eligibility rules still apply; this is not a guarantee that every sign-in skips confirmation.

Several Google accounts in one browser. Successful sign-ins remember up to five normalized addresses in most-recently-used order, in HttpOnly oa_google_hint / oa_google_hint_accounts cookies kept through sign-out. googleOneTapNonce({ gate })({ request }) returns loginHint and loginHints; SignInPanel renders one hinted redirect per saved account, plus "Use another Google account". oidcStart accepts ?account=<remembered email> and sends that address as login_hint; ?account=choose bypasses the hints and requests Google's chooser. Google may still require consent or reauthentication. These are display hints, not retained authenticated sessions or proof of email ownership; every click still authenticates with Google and checks live app policy. Denial removes only the refused address. Existing single-address cookies remain readable. Pass accountHintCookie: false to every handler to disable both cookies, or a name to rename them. continueAs(email) customizes each row's content; continueAs={false} keeps Google's own button. The demo uses the callback for its Google-mark/email/Last-used/arrow layout. On a Workspace-only site, oneTap={{ clientId, hd: 'openathena.ai' }} can restrict Google's offered accounts; server policy must still enforce access.

Authorization-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.

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). In their place each row keeps where Cloudflare places the client (request.cf city, region, and network operator, e.g. "Comcast"), alongside the country, user agent and referer; GET <basePath>/log returns all of them, so an admin can tell whether a redeem came from the person the link was sent to.

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.)

Email codes (emailCodeAuth) are the same delivery-is-verification idea as a sign-in rather than a sign-up: a magic link plus a 6-digit code in one mail, backed by a pending_auth row (d1PendingAuthStore), converging on the same gate.signIn as Google or Access. It's four mountable handlers — examples/pages-functions/auth/email/[[path]].ts is the reference Pages-Function mount, and <EmailCodeForm> (or <SignInPanel emailAuth>) is the front half.

Provisioning Resend. Unlike the Google client, every step here has an API, so scripts/provision-resend.mjs does the whole thing: creates the sending domain in Resend, writes the SPF/DKIM records it returns onto your Cloudflare zone (idempotently — re-runs skip what's already there), kicks off verification, and stores RESEND_API_KEY + MAIL_FROM as Pages (or Worker) secrets, piping the key on stdin so it never touches argv. Dry run by default:

RESEND_API_KEY=re_… CLOUDFLARE_API_TOKEN=… scripts/provision-resend.mjs \
  --domain example.org --zone-id <zone-id> --from noreply@example.org \
  --pages-project your-app --dmarc      # add --run to apply; --wait 600 to poll verification

What stays manual: a Resend account and a full-access API key (domains are account-scoped; a send-only key can't create one), a Cloudflare token with Zone:DNS:Edit, accepting an ESP as a dependency, and the DNS-propagation wait — usually minutes on a Cloudflare zone, after which --only verify --wait 600 finishes the job. The records are ordinary DNS, so teams that already run their zone as code can express the same thing in Terraform instead (resend_domain emits the identical records list; cloudflare_dns_record consumes it) — the script just spares everyone else a provider, a state backend, and a key in state.

Allowlist as a table, not a redeploy. A policy can be a literal (domainPolicy, adminPolicy) or a DB table, because EmailPolicy is (email) => scopes | null | Promise<…>. allowlistPolicy(store) reads an allowed_emails table so "who's allowed" is edited, not shipped:

import { allowlistPolicy, firstMatch, adminPolicy } from '@open-athena/auth'
import { d1Allowlist } from '@open-athena/auth/d1'

const allowlist = d1Allowlist(env.DB)
createGate({ ..., policy: firstMatch(adminPolicy(ADMINS), allowlistPolicy(allowlist)) })
authRoutes(gate, { allowlist })   // adds admin GET/POST/DELETE <base>/allowed; <AllowlistPanel/> in react/

Each row carries its own scopes, or pass allowlistPolicy(store, { scopes }) to grant a fixed set to every member. Rows arrive by hand (the panel) or out of band: a directory sync owns a source and calls store.replaceSource('sync:board@…', members) — one transaction, so a sign-in mid-sync never sees the group empty and a sync never clobbers a hand-added guest. Mounting the editor doesn't change who gets in; that's the separate policy wiring, on purpose.

A link can put its recipient on the list too. POST <basePath>/grants with email and allowlist: true mints the link and makes sure that address holds the link's scopes, so the person you sent it to can later sign in with Google or an emailed code instead of being turned away. An existing row that already covers the scopes is left alone; one that doesn't is widened and becomes manual. The response says which (allowed: { email, status: 'added' | 'widened' | 'already' }). Revoking the link doesn't remove the row: the link and the address are separate grants of access, and you revoke each where it lives.

If you only learn the address after sending the link, bind it later: PATCH <basePath>/grants/:id with email (and optionally allowlist: true, with the same allowed result). Nothing is re-sent. A link session re-reads its grant on every request, so the holder's existing session is email-bound from its next request: whoami carries the address, and with allowGrantSelfEdit they can edit their own profile. email: null (or blank) unbinds it and leaves any allowlist row alone. A revoked link refuses with 409. The access log's update row names what changed, and a rebind also names the address it replaced (email; was old@x.org).

Google groups, synced. Google's OIDC id_token carries no Workspace group membership (by design — enterprise IdPs emit a groups claim; Google doesn't, over OIDC), and nothing pushes a group change to a custom app (Google's Shared Signals role is receiver, session-revocation only). So "gate this to board@" is a periodic pull into the table above, and @open-athena/auth/google-directory makes it one call — a service-account token minted with WebCrypto (no dependency, no nodejs_compat), one paginated list, one replaceSource:

import { syncGroupsToAllowlist } from '@open-athena/auth/google-directory'

export default {
  // wrangler.toml: [triggers] crons = ["*/15 * * * *"]
  async scheduled(_ev, env) {
    await syncGroupsToAllowlist(d1Allowlist(env.DB), {
      key: env.GOOGLE_SA_KEY,                          // the SA key JSON, as a secret
      groups: [{ group: 'board@example.org', scopes: ['board'] }],
    })
  },
}

Because allowlistPolicy is re-evaluated on every request, a removed member is denied within one sync interval — at 15 minutes, well inside the ~1 h token lifetime most orgs accept as their revocation window. listGroupMembers/googleAccessToken are exported separately for anything else that needs a group or an SA token.

On Pages (no cron): mount the sync as a route instead — authRoutes(gate, { allowlist, sync: { run: () => syncGroupsToAllowlist(…), token: env.SYNC_TOKEN } }) adds POST <base>/allowed/sync, callable by an admin session (<AllowlistPanel sync /> shows a "Sync now" button) or by Authorization: Bearer <SYNC_TOKEN>, so a scheduled GitHub Action is the whole cron:

on: { schedule: [{ cron: '*/15 * * * *' }], workflow_dispatch: {} }
jobs:
  sync: { runs-on: ubuntu-latest, steps: [{ run: "curl -fsS -X POST -H 'Authorization: Bearer ${{ secrets.SYNC_TOKEN }}' https://your-app.pages.dev/api/auth/allowed/sync" }] }

A failing sync answers 502 with the error and leaves the table as it was.

Provisioning is gcloud, and needs no domain-wide delegation: the service account acts as itself once it can read the group — either as an owner of just that group (lightest), or holding the Groups Reader admin role (Admin console → Assign service accounts, or Terraform's googleworkspace_role_assignment). Whoever runs this once must be a Workspace admin or the group's owner; after that, membership is edited in Workspace and nothing here changes.

P=<gcp-project>; SA=group-sync@$P.iam.gserviceaccount.com; G=board@example.org
gcloud services enable cloudidentity.googleapis.com --project $P
gcloud iam service-accounts create group-sync --project $P
gcloud identity groups memberships add --group-email=$G --member-email=$SA --project $P
gcloud identity groups memberships modify-membership-roles --group-email=$G --member-email=$SA --add-roles=OWNER --project $P
# prove the SA reads the group, then land the key as the app's secret — it exists once, on stdin, never on disk:
gcloud iam service-accounts keys create /dev/stdout --iam-account $SA \
  | scripts/verify-group-sync.mjs --group $G --store-pages <pages-project>    # or --store-worker <name>

Three things the first real run taught (specs/done/google-directory-sync.md): gcloud identity … resolves groups under its quota project, so pass --project for one with the Cloud Identity API enabled; a multi-role --roles=MEMBER,OWNER trips a gcloud validation bug, hence add-then-modify; and a just-minted key can fail with invalid_grant for ~30 s before it propagates — scripts/verify-group-sync.mjs separates "token ok" from "group readable" and stores nothing until both pass.

The default read is the Cloud Identity API, which is what honours a group-owner SA (verified against a real Workspace: the Admin SDK Directory API refuses one with "Not Authorized"). api: 'directory' switches to the Admin SDK — it flattens nested groups — for an SA holding the Groups Reader admin role, or with subject: 'admin@…' for orgs wired for domain-wide delegation. The "live, signed at each sign-in" tier — a SAML adapter consuming Google's group-attribute assertion — stays specced but unbuilt (specs/saml-groups.md): it only refreshes at login, so it is slower to revoke than this sync unless it writes into the same table anyway.

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.

Faces are always copies, never links. A face on a share link (POST <basePath>/grants with avatar, or later PATCH <basePath>/grants/:id with avatar and/or subjectName, which the holder sees on their next request), a profile (PUT <basePath>/profile), or a Google sign-in's picture is fetched once, server-side, sniffed as a real PNG/JPEG/WebP/GIF, and stored as a data: URI (≤64 KB) or, with an assets store bound (r2AssetStore), as an asset:// ref that authRoutes serves from <basePath>/avatar/:id to signed-in sessions only. A stored third-party URL would tell its host who opened your private page on every view, and some hosts (LinkedIn's media.licdn.com) sign their image URLs with an expiry, so a stored link quietly breaks weeks later. The avatar field takes anything parseAvatarRef does:

  • a GitHub profile or handle (github.com/ada, @ada);
  • a Bluesky profile or handle (bsky.app/profile/ada.bsky.social, ada.bsky.social);
  • a Mastodon profile or address (https://hachyderm.io/@ada, @ada@hachyderm.io);
  • a direct https: image address;
  • any other web page or bare domain (loom.com, once it's no Bluesky handle), by its site icon: the page's apple-touch-icon, else its largest PNG/JPEG/WebP rel=icon, else /apple-touch-icon.png or the PNG inside /favicon.ico. Good for a company or project as the face on a link. An SVG is never stored (it can carry script), but <AvatarField> still takes an SVG icon, SVG image URL or SVG upload: the preview route returns the SVG (allowSvg), and the browser draws it to a canvas (where it runs nothing) and sends back the pixels. Without the browser, an SVG-only site is refused with a pointer to the picker;
  • a data: URI (an upload).

With no avatar and an email, the mint tries the recipient's Gravatar; avatar: null opts out. LinkedIn, X, Facebook and similar have no public way to fetch someone's photo, so their profile URLs are refused with a pointer to "copy image address" or an upload, rather than scraped. <AvatarField> is the UI for all of this:

  • It takes a pasted profile, an image address or an upload, or falls back to the recipient's Gravatar.
  • It previews through POST <basePath>/avatar, which returns up to 2 MB.
  • It center-crops and downscales every face to 256 px WebP in the browser before sending, which also strips EXIF. Resizing therefore needs no server-side codec or image service; Mastodon's 400 KB originals become about 8 KB.

The mint form uses <AvatarField>, and so does <ProfilePanel>.

Request access collects an address, and optionally a person: <RequestAccessForm askName /> posts a single name (one field, not first/last: name structure varies too much across cultures to split), stored as the same Subject a grant carries — so approving mints a link that knows who it's for, and the page 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 signIn={<SignInPanel googleUrl="/auth/google" oneTap={{ clientId }} emailAuth requestAccess />}>
  {whoami => <>
    <AccessNotice whoami={whoami} />   {/* "Private link for Bob Smith · access is logged" */}
    <Dashboard />
  </>}
</AuthGate>

Development

pnpm install
pnpm test        # vitest; core runs against an in-memory store, adapters against node:sqlite
pnpm typecheck
pnpm build
pnpm test:browser      # real Chromium layout/links, mocked Google/auth boundaries
cd demo && pnpm dev    # the whole thing running, on :4187

pnpm 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.

About

Named share links, SSO, and an access log for gated dashboards. Live demo: https://auth.oa.dev

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages