Tags
No tags yet
tessera
Identity for the limic suite. A small, opinionated OIDC provider that issues stable opaque sub claims to anvil, bland, git-on-cloudflare, and flamemail, plus a Cloudflare Access Generic OIDC integration for Access-protected apps. Runs entirely on Cloudflare Workers.
A tessera is a small ceramic tile bearing an identification mark — Roman authentication tokens. The aesthetic borrows from that: warm zinc surfaces, burnished gold accent, Spectral display serif.
What it does
- Email + password sign-in with Argon2id hashing (via
@noble/hashes/argon2.js, pure-JS, workerd-compatible). - Social sign-in via GitHub and Google. Account linking preserves a single
subper human across providers. - OIDC 1.0 / OAuth 2.1 provider at
/.well-known/openid-configuration, with/api/auth/oauth2/{authorize,token,userinfo,introspect,revoke}and JWKS at/api/auth/jwks. RS256 signing keys; private bytes encrypted at rest by the JWT plugin. - Cloudflare Access integration: tessera can register as a Generic OIDC provider for a Zero Trust team. Access re-signs its own JWT, so
Cf-Access-Jwt-Assertion.payload.subis Access's user id, not tessera's OIDCsub. tessera additionally emits atessera_subclaim (mirroringsub) on both ID tokens and/userinfo; configure Access to forward it and downstream apps read tessera's stable UUID viapayload.custom.tessera_sub. - Invite-only registration. No open signup — operators mint single-use, optionally email-bound, 7-day-expiring invite links.
- Admin UI for minting invites, registering OAuth clients (rotate / revoke secrets), and managing user roles + bans.
- Cloudflare Turnstile gates
/sign-inand/invite/<token>. Fail-closed on missing config (503), missing token (400), failed siteverify (403). - Workers Rate Limiting bindings:
RL_AUTHis the tight 10/minute tier for sign-in IP, sign-in email, invite acceptance, and default/api/auth/*traffic.RL_APIis the 300/minute tier for hot read paths such as session reads,/oauth2/userinfo, and/oauth2/introspect. Rate-limit responses includeRetry-After: 60.
Stack
- Worker: Hono on Cloudflare Workers, Better Auth
1.7.3core,@better-auth/oauth-providerfor OAuth 2.1,better-auth/plugins(admin, jwt). Better Auth packages are pinned together because upgrades can require coordinated migrations. - Storage: D1 (Drizzle ORM) for users / accounts / sessions / OAuth clients / signing keys / invites. Better Auth state stays in D1; abuse throttling uses native Workers Rate Limiting bindings.
- Frontend: React 19, Vite 8, Tailwind v4 (
@themeconfig, no PostCSS), TypeScript 7 strict. ESLint uses Microsoft's TypeScript 6 API compatibility package. Self-hosted fonts via@fontsource(Hanken Grotesk, JetBrains Mono, Spectral) so the auth surface makes zero third-party requests. CSP locksfont-srcto'self'. - Tests: Vitest 4 through
@cloudflare/vitest-pluginfor Worker coverage, colocated client Vitest specs, and Playwright e2e coverage with an isolated Vite/Wrangler dev server.
Quick start
npm install
cp .dev.vars.example .dev.vars # fill BETTER_AUTH_SECRET, OPERATOR_NAME, OPERATOR_CONTACT_EMAIL
npm run db:migrate:local
npm run devThe dev server picks an open port (Vite default 5173; often 5174 when another app already has 5173). Use the Worker URL printed by npm run dev. Without a BETTER_AUTH_URL set in .dev.vars, the worker derives the base URL from the request origin so OIDC discovery, JWKS, cookies, and the iss claim all match the local URL automatically. GitHub and Google OAuth credentials are optional locally; the UI only shows providers that have both a client ID and secret configured.
Health endpoint: GET /healthz → { "ok": true, "service": "tessera" }.
Common commands
| Script | What it does |
|---|---|
npm run dev |
Vite + Wrangler dev server. |
npm run build |
Production Vite bundle into dist/. |
npm run typecheck |
Generate Worker binding types, then tsc --noEmit against src//tests/. |
npm test |
Vitest suite for Worker and client specs. |
npm run test:e2e |
Playwright e2e tests with an isolated local dev server. |
npm run db:migrate:local |
Apply Drizzle migrations to local D1. |
npm run deploy |
Build, migrate, deploy. |
npm run format |
Apply Prettier formatting. |
package.json has the full list, including the OIDC RP simulator (test:client), consent harness (test:consent), schema generators (db:generate, auth:generate), and bootstrap-invite seeders.
Project map
src/worker/— Hono entry (index.ts), Better Auth setup (auth/), OIDC + invite + admin API routes (api/), D1 schema and Drizzle client (db/), middleware (auth gates, CSP, origin guard, rate limits), and Turnstile / crypto / URL services.src/client/— React 19 app: pages for sign-in, invites, OAuth consent, account, admin, and launcher; shared UI primitives incomponents/ui/; Better Auth React client and HTTP helpers inlib/.scripts/— bootstrap-invite seeder (seed-bootstrap-invite.ts), OIDC RP simulator (test-client/), manual consent harness (test-consent/), launcher icon builder.tests/— Vitest inworker/(Cloudflare workers pool) andclient/, Playwright ine2e/.drizzle/d1/— generated D1 migrations (npm run db:generate).docs/— historical phase-1 / phase-2 design context plus per-downstreamMIGRATION.mdfiles.
For module-level guidance and the route map, see AGENTS.md § Project Map and the route mounts in src/worker/index.ts.
Operator quick reference
See OPERATOR.md for the full setup. The short version:
- Set wrangler secrets:
BETTER_AUTH_SECRET,GITHUB_OAUTH_CLIENT_*,GOOGLE_OAUTH_CLIENT_*,TURNSTILE_*. - Set required non-secret vars:
OPERATOR_NAME,OPERATOR_CONTACT_EMAIL, plus productionBETTER_AUTH_URL/OIDC_ISSUER. - Create the D1 database and confirm the
RL_AUTHandRL_APIWorkers Rate Limiting bindings inwrangler.jsonc. - Set
BOOTSTRAP_ADMIN_EMAILto your operator email for the bootstrap window. npm run deploy— builds, applies migrations, and deploys. Existing 1.6 installations must follow the 1.7 cutover note inOPERATOR.mdfirst.- Mint and accept the bootstrap invite via
npm run db:seed-initial-user, then unsetBOOTSTRAP_ADMIN_EMAIL. - Register downstream RPs via the
/admin/clientsUI. - Register tessera with Cloudflare Access as a Generic OIDC provider.
Migrations into downstream projects
Each downstream project (anvil, bland, git-on-cloudflare, flamemail) gets its own MIGRATION.md under docs/migrations/. Apply them in separate PRs against those repos after tessera is live and validated. Access-protected apps such as ccccocc integrate through Cloudflare Access; if they need tessera's stable UUID, configure Access to pass an explicit tessera custom claim once tessera emits one.
Recommended limic.dev app integration
Use openid-client v6 as the standard relying-party implementation for limic.dev apps. It supports Cloudflare Workers and should own OIDC discovery, authorization URL construction, authorization-code exchange, PKCE helpers, and ID token validation. Downstream apps should not hand-code tessera endpoint paths or maintain their own JWKS verification logic.
The normal app contract is:
- Runtime dependency:
openid-client. - Runtime env:
TESSERA_OIDC_ISSUER,TESSERA_OIDC_CLIENT_ID, and secretTESSERA_OIDC_CLIENT_SECRET. - Register each app in tessera
/admin/clients; production issuer ishttps://auth.limic.dev. - Pass
openidClient.ClientSecretBasic(clientSecret)explicitly toopenidClient.discovery()for tessera's default client registration. Existing clients registered forclient_secret_postmust instead useClientSecretPost; the requested method must match the registration. - Start sign-in with authorization code + PKCE S256,
state, andnonce; persist the transaction in an HttpOnly sealed cookie or short-lived server-side state. - On callback, let
openid-client.authorizationCodeGrant()validate state, nonce, issuer, audience, token endpoint response, and ID token signature. - Consume the ID token once, then mint the app's own session. Do not use tessera ID tokens as long-lived app sessions.
- Authorize locally from the verified stable
sub: operator-only apps can compare against an allowlist; multi-user apps should bindsubto their local user row. - Fail closed when OIDC config or discovery is unavailable. Allow plaintext issuers only for loopback local development.
Use @mongodb-js/oidc-mock-provider as the standard local/e2e provider for downstream apps. Configure it with a fixed local port, a payload whose sub matches local allowlists or fixtures, and short token expiry. Prefer a real mock provider in browser/e2e tests; in Cloudflare worker-pool unit tests, mock outbound OIDC fetch calls at the boundary because workerd can be brittle when tests depend on a Node HTTP server on loopback.
Keep jose out of downstream app runtime dependencies unless the app has a separate need to sign or verify JWTs directly. openid-client brings jose transitively for OIDC validation. Test-only direct jose usage is usually unnecessary when @mongodb-js/oidc-mock-provider can issue signed tokens.
Out of scope for v1
MFA, SAML, multi-tenancy, email delivery for invites (mint + copy manually), SCIM, audit logs, password recovery flows, and real GitHub/Google OAuth app automation. All deferred.