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

194 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, say who it's for, 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;
                GrantsPanel / UserMenu — optional ui.css
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/provider/   optional OIDC provider over a gate, for a sign-in broker (D1 store in adapters/)
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.

Share-Link Admin and User Menu

Each has headless logic that owns state and API calls, and reference components built only on it, so an app can use them as they are, restyle them, or replace them.

Share links. createGrantsController({ client }) in /client holds the list, loading and failure state, per-link pending state, and the one-time token. It has mint, update, disable, enable, revoke, rotate, activity, refresh and dismissToken, and binds to any framework through subscribe/getSnapshot. A list response that left before a change finished is dropped, so it can't bring back old state. The raw token lives only in the controller's memory, until it's dismissed, replaced by a newer one, or its link is revoked: actions don't return it, and a reload loses it.

import { createAuthClient, shareUrl } from '@open-athena/auth/client'
import { GrantsPanel } from '@open-athena/auth/react'
import '@open-athena/auth/react/ui.css'

const client = createAuthClient()

<GrantsPanel
  client={client}
  scopes={[{ id: 'reports', label: 'Reports', hint: 'Everything under /reports' }]}
  buildLink={(grant, token) => shareUrl(token, `${location.origin}/${grant.scopes[0]}/`)}
/>

<GrantsPanel> composes <GrantMintForm>, <GrantCard> and <GrantLink>, all exported. A key works on any path the app serves, so buildLink may return one { label, url } per page a link opens (say, one per scope plus an index); the token is then shown as a row per page with its own Copy, plus "Copy all" (label: url lines). The recipient's name, email and face inputs carry NO_AUTOFILL (data-1p-ignore, data-lpignore, data-bwignore), since password managers ignore autoComplete="off". Pass anonChip matching your <WhoamiChip anon=…> so the anon checkbox says what the recipient will see. useGrants({ client }) is the hook. At least one scope must be chosen, and a lone scope starts checked. Rotate and revoke take a second click, because neither can be undone. editScopes adds a checkbox per scope on each link: a change reaches sessions already redeemed on their next request, with no new URL, and is logged with both sets. The API is PATCH /grants/:id with { scopes }. A row the link added to the allowlist keeps its scopes.

User menu. useUserMenu({ whoami, client, items }) in /react returns the identity, the exact items for this person, signOut() (sending the selection in retained mode), and ARIA and keyboard props. It doesn't position anything, which keeps /react free of @floating-ui/react. <UserMenu> adds positioning and outside-click dismissal, from its own subpath, and needs that optional peer:

import { UserMenu } from '@open-athena/auth/react/user-menu'

<UserMenu
  whoami={whoami}
  client={client}
  items={[
    { key: 'links', label: 'Share links', href: '/admin', when: 'admin' },
    { key: 'drafts', label: 'Your drafts', href: '/drafts', when: 'sso' },
  ]}
/>

It lists who you are, Profile (sso only; onOpenProfile for your own page, profile={false} for a gate with no profile store), your items, accounts (when whoami carries a selection, meaning the gate retains logins), and sign out. A share-link guest is always labelled as one, and an anonymous link still gets a face and a sign-out. identity overrides the name and face with what the app knows better, and an item's content renders as is, for an app's own submenu.

Outside React. @open-athena/auth/react/mount needs react-dom and @floating-ui/react, and fetches identity itself:

import { mountUserMenu, mountGrantsPanel } from '@open-athena/auth/react/mount'
import '@open-athena/auth/react/ui.css'

const menu = mountUserMenu(document.querySelector('#user')!, { basePath: '/api/auth' })
menu.update({ items: [] })
menu.unmount()

Theming. ui.css is optional. Set --oa-ui-surface, --oa-ui-ink and friends on .oa-ui, not an ancestor, because the menu's popover is portaled to <body>. Its defaults have no specificity, so your rule wins whichever stylesheet loads first. They follow the OS color scheme; a page with its own theme should set at least those two, plus color-scheme. Components also take classNames, replacing the defaults one by one.

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 root migrations 0003 and 0004, followed by migrations/retained-login/0001_admission.sql before deploying this adapter. The nested track is not applied by a root-directory migration run: copy it into your app's ordered sequence under a unique filename or use separate migration history. No live retained-mode migration has been performed for this release, including the demo. Run store.cleanup(nowS) now and then; Pages Functions have no cron, so the demo runs it on a small share of requests.

Retained email-code/link sign-in consumes proof and adds/selects the login in one transaction: a full roster or stale selection no longer burns valid proof. The D1 adapter implements commitEmail; custom retained adapters must implement that atomic operation as well. New browser and pending-link allocations also have atomic gate-wide fixed-window admission: defaults are 120 browser sets and 240 pending links per 60 seconds. Optional admissionWindowS, gateBootstrapsPerWindow and gatePendingsPerWindow overrides must be positive integers and identical across writers. Cookie churn cannot reset these counters. Global rate pressure returns 429, hard storage pressure 503, and browser capacity/context conflicts remain 409; custom handlers should honor RetainedLoginError.status. This is not a general request/email limiter or recoverable response replay; see specs/retained-hardening.md for boundaries.

  • 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/session-log/model framework-free timeline/formatting helpers, also usable by non-React viewers
@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.

Fetch cancellations remain visible as cancelled without inflating error counts. Explicit AbortError is classified immediately; other network failures wait up to five seconds so an intervening pagehide can classify them as navigation cancellations. That is a client heuristic, not proof that backend work stopped. Failures on a surviving page remain errors, unless nobody could have seen them fail (offlineFailure): the browser reported itself offline or the tab was hidden when the request ended, or the batch reached the server over a minute (OFFLINE_LAG_MS) after the request, as when a laptop is closed mid-request. Those count as requests, not errors, in the session list, the viewer and the activity bridge. Every request records a page-local number (id), the connectivity hint and visibility at its start and end (on0/on, vis0/vis), time since the tab was last shown (visMs), the latest long timer gap (gapMs, gapAgo), its end and wall time, and a well-formed Cloudflare Ray ID (ray) when there is one. A cancellation records which evidence said so (cancel: signal, AbortError or pagehide), never the abort reason. A life event records connectivity changes, freeze and resume, BFCache pageshow/pagehide, a prior tab discard, and timer gaps over 30 s from a 10-second pulse; a gap may be throttling, suspension or a busy main thread, not necessarily sleep. The viewer links a later successful repeat of an exact, unmasked same-page read to the failure it followed. Benign ResizeObserver scheduling reports are dropped by the client. Anonymous config reads never trigger retention writes, including opted-in public viewers.

The migrations are a track of their own (migrations/session-log/), so apps that don't use the session log carry no tables for it. Apply the track in order through your migration system (or copy into your app's sequence). 0002_actor.sql adds nullable grant_id/subject_name: apply it once after 0001_session_log.sql before deploying this version, including existing disky-shaped databases. Do not blindly re-run the ALTER statements. fromGate attaches trusted recipient identity, and the viewer's filter matches email, recipient name and grant ID; the private grant memo is never used. Old rows are not retrospectively attributed. The summary row tracks the tab's latest identity, not immutable per-event identity evidence.

Staff Activity Feed (Backend)

/activity is an optional feed with a separate migrations/activity/ track. It groups one actor's events on one origin into bursts (15-minute gap by default), deduplicates stable event IDs, and independently controls each kind's recording and notification detail (off, count, line). Slack and Discord sinks are separate imports; without sinks, it only records. activityRoutes mounts staff-authorized reads and settings, never public event ingestion. /react/activity supplies ActivityAdmin, ActivityList and ActivityKindSettings (TanStack Query provider required); styles are an optional /react/activity.css import.

import { activityFeed } from '@open-athena/auth/activity'
import { d1ActivityStore } from '@open-athena/auth/activity/d1'

const store = d1ActivityStore(env.DB)
const feed = activityFeed({ store })
await feed.record({
  id: 'access-row:123', // stable retry key, never a token
  actor: { id: 'grant:abc', name: 'Guest recipient', via: 'grant' },
  origin: 'https://app.example.org',
  ts: Math.floor(Date.now() / 1000),
  kind: 'view', path: '/report',
})

sessionLogActivity provides onStored/onPurge hooks with an explicit trusted actor resolver; accessLogActivity accepts persisted access rows and their stable IDs. activityActorFromAuth resolves a gate identity without exposing the grant memo. Use only one source for overlapping browser/page observations unless you deliberately want both. Hook failures need application-level retry/replay; recording a source row and bridging it are not one database transaction.

For posting, import slackActivitySink from /activity/slack or discordActivitySink from /activity/discord, supply a stable destination ID and staffOnly: true, and pass the sinks to activityFeed. That flag acknowledges a privacy requirement; it cannot verify channel privacy. Explicitly await feed.flush() (or use waitUntil), schedule retries yourself, and protect all store.list/get and feed.setKind calls with staff authorization. Delivery leases prevent simultaneous database acknowledgements, not exactly-once remote API calls: a crash after posting may duplicate a message. Slack posts one thread per burst, edited as it grows, optionally as the actor (actorSender, needs chat:write.customize) with app-supplied links (eventHref, sessionHref, burstHref); a tab's session resumes its own burst for 7 days. feed.purge(cutoffS) incrementally removes old inactive bursts; it does not delete already-posted remote messages. Mount activityRoutes({ feed, store, requireAdmin }) with an application callback returning { who } only after staff authorization (or a refusing Response); mutations require this site's Origin header. See Activity Adoption for migration and integration steps.

Signing In Through a Broker (OIDC Provider)

Experimental (specs/oidc-provider.md). @open-athena/auth/provider turns a gate into an OpenID Connect provider for a fixed set of your own apps: a valid, unambiguous broker login can admit you to another app without another sign-in click, subject to that app's policy. It's deliberately narrow: authorization code with PKCE S256 by default, confidential clients from a static or D1-backed registry, exact redirect URIs, no consent screen, no refresh tokens. OA's deployed login.oa.dev uses Google Workspace sign-in and the D1 client registry. It does not share app cookies or revoke all app sessions when its own cookie is cleared.

// the broker: discovery at /.well-known/openid-configuration, endpoints under /oidc
const provider = oidcProviderRoutes({
  gate,                                   // its sessions are who is signed in at the broker
  issuer: 'https://login.example.org',
  clients: [{ id: 'app-b', secretHash: await hashClientSecret(env.APP_B_SECRET), redirectUris: ['https://b.example.org/auth/cb'] }],
  keys: [JSON.parse(env.OIDC_SIGNING_KEY)],   // from generateSigningKey(); the first signs, all are published
  store: d1ProviderStore(env.DB),         // @open-athena/auth/provider/d1; migrations/provider/
  subjectSecret: env.OIDC_SUBJECT_SECRET, // keys `sub`: keep it stable
  loginUrl: '/signin',                    // gets `next`, and `prompt` / `login_hint` when an app sent them
  claims: ({ email }) => ({ groups: groupsOf(email) }),   // optional extras
})

// each app: the ordinary relying party, pointed at the broker
const rp = { gate, clientId: 'app-b', clientSecret: env.BROKER_SECRET, redirectUri: 'https://b.example.org/auth/cb', provider: brokerProvider('https://login.example.org') }
oidcStart(rp)     // `?prompt=none` tries silently; `?account=choose` asks for the chooser
oidcCallback(rp)  // a refusal comes back as `?sso=login_required` (or `access_denied`), never a loop

Only a verified person at the broker vouches for anyone: a share-link session there never signs anyone in to an app. For an organization-only broker, give its own Google sign-in hostedDomain: 'example.org' (and authParams: { hd: 'example.org' }): an address at the domain isn't proof of a managed account, hd is. The relying party sends PKCE S256 on every sign-in, Google's included, with the verifier derived from the nonce, so it stays storage-free. The demo runs a broker at /oidc and a second app, "App B", at /rp that signs in only through it.

Conformance Testing (Not Certification)

Tested against the OpenID Foundation's Basic OP conformance plan, suite v5.3.4, in November 2026: 20 passed, 6 warnings, 4 awaiting review, 5 skipped; no failures. These are historical results for the demo deployment at auth.oa.dev, not a separate conformance run against login.oa.dev, a security audit, or OpenID certification. Manual reviews and warning dispositions remain open. See results, limitations and follow-ups.

Cross-app logout is not enabled. Optional /backchannel and /backchannel/d1 exports provide a strict signed logout-token receiver, replay receipts and SID revocation tombstones (migrations/backchannel/). This is a foundation, not automatic logout: the broker does not yet issue correlated SIDs or fan out events, and existing app sessions do not consult these tombstones. Never advertise these exports alone as global sign-out. See specs/broker-revocation.md for the remaining provider/RP correlation, enforcement and durable-delivery work.

PR-preview clients. A client marked preview: true may register https://*.<host>/<path> callbacks (and post-logout URIs), where * is exactly one DNS label: one client for every branch host of a Cloudflare Pages project (pr-12.<project>.pages.dev). Because a preview runs unreviewed branch code, it never signs anyone in silently. The first sign-in per host, account and client is one click on a broker page naming the host, remembered for 7 days; until then prompt=none gets interaction_required. Give the preview its own client, so its tokens' aud differs from the app's, and its own secret, held only in the preview environment. Registration accepts a pattern only for hosts on the deployment's allowlist (redirectUriProblem(uri, domains, { previewSuffixes })). The relying party takes redirectUri: redirectFromOrigin('/auth/oa/callback') to follow whichever branch host the request came to.

Delegated broker registration. /provider also exports clientManagementRoutes and managementTokenAuth: an optional hash-only, expiring machine capability scoped to exact app IDs, callback URLs and operations. Matching create retries never rotate a credential; explicit rotation uses atomic compare-and-swap and audit. Metadata responses contain no secret hashes, and browser cookies grant no access. The reference broker leaves the API disabled until configured. See the agent-facing contract for wiring, secret delivery/recovery and the optional MCP boundary (not implemented).

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({
  subject: { name: 'Bob Smith' },
  name: 'Donor, Q3 outreach',
  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 } })

subject is who the link is for: it's what the recipient's chip, menu and access notice say. name is the memo, the minter's own label: admin routes return it, and whoami, /accounts and displayName never pass it to the link session. A link with no subject name or email is anonymous, and <UserMenu> and <WhoamiChip> say so (anonymousLabel). A server handler still holds it as auth.grant.name, so don't render that to a link session. test/holder-privacy.test.ts checks that no holder-reachable response carries the memo.

Two per-link flags, set at mint and editable by staff afterwards (PATCH /grants/:id, logged like other edits; the <GrantsPanel> form and cards have a checkbox for each):

  • anon: the holder must not learn who staff think they are. whoami, /exchange, /accounts and /profile omit the link's subject and email and say anon: true; staff routes, the session log and activity senders still see them. pseudonym(grantId) gives a stable "Kind Marmot"-style name; displayName returns null; <WhoamiChip anon="label" | "pseudonym" | "hidden"> picks what the chip shows; <AccountSwitcher> uses the pseudonym. holderIdentity(grant) is the filter, for an app's own holder-facing responses.
  • service: the link is held by a job, not a person. It can't be bound to an email (ServiceGrantEmailError, 400 from the routes, at mint or on either edit). Read it as auth.grant.service. A job-token minting path should pass service: true.

Both need migrations/0005_grant_flags.sql, applied once.

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 photo the person set or removed, or a name they typed. Until they do one of those, the sign-in photo is the fallback: a row with no photo and no choice about one (a seed that got none, or a save of only a name) is marked avatarSrc: 'seed-pending' and filled at a later sign-in, for the photo alone.

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

Freshness-bounded Google Groups authorization. For security-sensitive permissions, use /groups with its optional migrations/groups/ track. Unlike the legacy allowlist sync below, snapshots keep overlapping memberships independent and expire after 15 minutes by default, even if Google or the sync job is unavailable. Apply the policy at each authorization request, not only when issuing an OAuth token. Missing, future-dated and stale snapshots contribute no scopes. Snapshot writes are monotonic per group, not one transaction across all groups; authorize a union of the independently fresh groups, not an all-or-nothing directory revision.

import { groupPolicy } from '@open-athena/auth/groups'
import { d1GroupSnapshots } from '@open-athena/auth/groups/d1'
import { syncGroupSnapshots } from '@open-athena/auth/google-directory'

const groups = { 'owners@example.org': ['admin', 'view'] }
const snapshots = d1GroupSnapshots(env.DB)
const policy = groupPolicy(snapshots, { groups })
// Pass `policy` to createGate. Run the sync in a cron every 5 minutes:
await syncGroupSnapshots(snapshots, {
  key: env.GOOGLE_SA_KEY,
  groups: Object.keys(groups),
  api: 'directory',
})

Directory mode requires a service account with Workspace directory read privileges (prefer Groups Reader, not group ownership). No new credentials are included or provisioned by this library. The policy does not revoke independent share-link grants or bypass other policies an app deliberately composes with it. A central broker's membership claim alone cannot revoke app sessions that never consult current membership. See the setup and unsent Workspace-admin request for the GCP/Workspace permission split, and specs/group-snapshots.md for the rollout boundary.

Google groups, synced into the legacy allowlist. Google's OIDC id_token carries no Workspace group membership. For a simple periodically maintained address list, @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. This older path retains previous rows indefinitely after sync failures and has one row per email, so overlapping groups can overwrite each other's scopes; use snapshots above for group-based admin decisions:

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