# 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 `sub` per 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.sub` is Access's user id, not tessera's OIDC `sub`. tessera additionally emits a `tessera_sub` claim (mirroring `sub`) on both ID tokens and `/userinfo`; configure Access to forward it and downstream apps read tessera's stable UUID via `payload.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-in` and `/invite/`. Fail-closed on missing config (503), missing token (400), failed siteverify (403). - **Workers Rate Limiting bindings**: `RL_AUTH` is the tight 10/minute tier for sign-in IP, sign-in email, invite acceptance, and default `/api/auth/*` traffic. `RL_API` is the 300/minute tier for hot read paths such as session reads, `/oauth2/userinfo`, and `/oauth2/introspect`. Rate-limit responses include `Retry-After: 60`. --- ## Stack - **Worker**: Hono on Cloudflare Workers, Better Auth `1.7.3` core, `@better-auth/oauth-provider` for 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 (`@theme` config, 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 locks `font-src` to `'self'`. - **Tests**: Vitest 4 through `@cloudflare/vitest-plugin` for Worker coverage, colocated client Vitest specs, and Playwright e2e coverage with an isolated Vite/Wrangler dev server. --- ## Quick start ```sh npm install cp .dev.vars.example .dev.vars # fill BETTER_AUTH_SECRET, OPERATOR_NAME, OPERATOR_CONTACT_EMAIL npm run db:migrate:local npm run dev ``` The 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 in `components/ui/`; Better Auth React client and HTTP helpers in `lib/`. - `scripts/` — bootstrap-invite seeder (`seed-bootstrap-invite.ts`), OIDC RP simulator (`test-client/`), manual consent harness (`test-consent/`), launcher icon builder. - `tests/` — Vitest in `worker/` (Cloudflare workers pool) and `client/`, Playwright in `e2e/`. - `drizzle/d1/` — generated D1 migrations (`npm run db:generate`). - `docs/` — historical phase-1 / phase-2 design context plus per-downstream `MIGRATION.md` files. 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`](./OPERATOR.md) for the full setup. The short version: 1. Set wrangler secrets: `BETTER_AUTH_SECRET`, `GITHUB_OAUTH_CLIENT_*`, `GOOGLE_OAUTH_CLIENT_*`, `TURNSTILE_*`. 2. Set required non-secret vars: `OPERATOR_NAME`, `OPERATOR_CONTACT_EMAIL`, plus production `BETTER_AUTH_URL` / `OIDC_ISSUER`. 3. Create the D1 database and confirm the `RL_AUTH` and `RL_API` Workers Rate Limiting bindings in `wrangler.jsonc`. 4. Set `BOOTSTRAP_ADMIN_EMAIL` to your operator email for the bootstrap window. 5. `npm run deploy` — builds, applies migrations, and deploys. Existing 1.6 installations must follow the 1.7 cutover note in `OPERATOR.md` first. 6. Mint and accept the bootstrap invite via `npm run db:seed-initial-user`, then unset `BOOTSTRAP_ADMIN_EMAIL`. 7. Register downstream RPs via the `/admin/clients` UI. 8. 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 secret `TESSERA_OIDC_CLIENT_SECRET`. - Register each app in tessera `/admin/clients`; production issuer is `https://auth.limic.dev`. - Pass `openidClient.ClientSecretBasic(clientSecret)` explicitly to `openidClient.discovery()` for tessera's default client registration. Existing clients registered for `client_secret_post` must instead use `ClientSecretPost`; the requested method must match the registration. - Start sign-in with authorization code + PKCE S256, `state`, and `nonce`; 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 bind `sub` to 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.