# tessera — operator runbook Production deploy at `https://auth.limic.dev`. This document covers everything an operator needs from a fresh repo to a live, working IdP. --- ## 1. Prerequisites - Cloudflare account with Workers paid plan. D1 stores tessera's auth data; the native Workers Rate Limiting binding handles abuse throttling. - A registered domain with Cloudflare DNS — `auth.limic.dev` is the production hostname. The hostname is load-bearing for the OIDC `iss` claim, cookie domain, and downstream Cloudflare Access integration. - `wrangler` CLI logged in (`wrangler whoami`). --- ## 2. Wrangler secrets and runtime variables Set every secret below before deploying. Use `wrangler secret put ` and paste the value when prompted. | Name | What it is | How to generate / where to get it | | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | | `BETTER_AUTH_SECRET` | Better Auth's master secret. Drives session HMAC, plus symmetric encryption of JWKS private bytes (`jwt` plugin) and OAuth provider access/refresh/id tokens (`account.encryptOAuthTokens`). 64 hex chars. | `openssl rand -hex 32` | | `GITHUB_OAUTH_CLIENT_ID` / `GITHUB_OAUTH_CLIENT_SECRET` | tessera as a GitHub OAuth client. | See § 3. | | `GOOGLE_OAUTH_CLIENT_ID` / `GOOGLE_OAUTH_CLIENT_SECRET` | tessera as a Google OAuth client. | See § 4. | | `TURNSTILE_SITE_KEY` / `TURNSTILE_SECRET_KEY` | Cloudflare Turnstile site + secret keys. | See § 5. | Set these non-secret variables too. Use `wrangler.jsonc` `vars` if you manage deploy config in the repo, or the Cloudflare dashboard's Workers → Settings → Variables if you keep deployment-specific values out of git. For local development, set them in `.dev.vars`. `npm run deploy` includes `wrangler deploy --keep-vars` to preserve dashboard-managed variables. Include that flag when deploying directly with Wrangler too. | Name | What it is | | ------------------------ | ----------------------------------------------------------------------------------------------------- | | `OPERATOR_NAME` | Operator name rendered on `/privacy` and `/terms`; `/api/config` returns 503 if it is empty. | | `OPERATOR_CONTACT_EMAIL` | Operator contact email rendered on `/privacy` and `/terms`; `/api/config` returns 503 if it is empty. | `BOOTSTRAP_ADMIN_EMAIL` is **not** secret — set it as a wrangler `vars` entry (or in `.dev.vars`) for the bootstrap window only, then remove it once the operator account exists. ```sh wrangler secret put BETTER_AUTH_SECRET wrangler secret put GITHUB_OAUTH_CLIENT_ID wrangler secret put GITHUB_OAUTH_CLIENT_SECRET wrangler secret put GOOGLE_OAUTH_CLIENT_ID wrangler secret put GOOGLE_OAUTH_CLIENT_SECRET wrangler secret put TURNSTILE_SITE_KEY wrangler secret put TURNSTILE_SECRET_KEY ``` --- ## 3. GitHub OAuth app 1. GitHub → Settings → Developer settings → OAuth Apps → **New OAuth App**. 2. Application name: `tessera (auth.limic.dev)`. 3. Homepage URL: `https://auth.limic.dev`. 4. **Authorization callback URL**: `https://auth.limic.dev/api/auth/callback/github`. (Better Auth's plugin mounts the callback at this exact path.) 5. Click **Register application**, then **Generate a new client secret**. 6. Copy the client ID into `GITHUB_OAUTH_CLIENT_ID` and the freshly minted secret into `GITHUB_OAUTH_CLIENT_SECRET` via `wrangler secret put`. GitHub does not require a scopes whitelist — Better Auth requests the minimum (`read:user`, `user:email`). --- ## 4. Google OAuth app 1. Google Cloud Console → Google Auth Platform → **Get started** if the project is not registered for Google Auth yet. 2. App name: `tessera (auth.limic.dev)`. Set a monitored support email and contact email. 3. Audience: **External**. tessera only requests the Google sign-in scopes `openid email profile`; no sensitive or restricted Google API scopes are required. 4. Google Auth Platform → Data Access: keep scopes limited to `openid`, `email`, and `profile` if Google asks you to configure them. 5. Google Auth Platform → Clients → **Create client**. 6. Application type: **Web application**. Name: `tessera (auth.limic.dev)`. 7. **Authorized redirect URIs**: add `https://auth.limic.dev/api/auth/callback/google`. Leave **Authorized JavaScript origins** empty; tessera uses the server-side OAuth callback, not Google client-side JS. 8. Click **Create**. Copy the client ID and newly shown secret into `GOOGLE_OAUTH_CLIENT_ID` and `GOOGLE_OAUTH_CLIENT_SECRET`. --- ## 5. Turnstile widget 1. Cloudflare dashboard → Turnstile → **Add site**. 2. Hostname: `auth.limic.dev`. 3. Mode: **Managed**. 4. Copy the site key into `TURNSTILE_SITE_KEY` and the secret into `TURNSTILE_SECRET_KEY`. tessera always calls Cloudflare siteverify — there is no host-based short-circuit that skips the call. The always-pass test keys shipped in `.dev.vars.example` (`1x00000000000000000000AA` / `1x0000000000000000000000000000000AA`) still work for local dev because tessera recognizes Cloudflare's test-mode siteverify response shape (`action: "test"` or `metadata.result_with_testing_key: true`) and skips the action/hostname comparisons that would otherwise fail against it. Use those for dev; register a real Turnstile site for production. Fail-closed return codes: - Missing `TURNSTILE_SECRET_KEY` → 503 (deploy-config error). - Missing token in the request body → 400. - Failed siteverify → 403. - Action mismatch / hostname mismatch → 403. --- ## 6. D1 + rate-limit bindings Edit `wrangler.jsonc`: ```jsonc "d1_databases": [ { "binding": "DB", "database_name": "tessera-prod", "database_id": "", "migrations_dir": "drizzle/d1" } ], "ratelimits": [ // Tight tier. Used for sign-in, invite acceptance, OAuth issuance, // admin routes, and most /api/auth/* paths. { "name": "RL_AUTH", "namespace_id": "110001", "simple": { "limit": 10, "period": 60 } }, // Loose tier. Used for high-volume reads such as session reads, // /oauth2/userinfo, and /oauth2/introspect. { "name": "RL_API", "namespace_id": "110002", "simple": { "limit": 300, "period": 60 } } ] ``` `namespace_id` is an account-global positive integer string. Reuse one only if you intentionally want multiple Workers to share counters for the same keys. Both `RL_AUTH` and `RL_API` must exist before deployment: tessera-owned sign-in, social sign-in, and invite acceptance call `RL_AUTH` directly; `/api/auth/*` is tiered by `src/worker/middleware/rate-limit.ts`; public JWKS and auth utility endpoints are explicitly exempt. Apply migrations to the remote DB: ```sh npm run db:migrate # equivalent to: wrangler d1 migrations apply tessera-prod --remote ``` --- ### Better Auth 1.6 → 1.7 cutover `0004_better_auth_1_7.sql` adds the 1.7 OAuth tables and columns, renames `oauth_clients.type` to `application_type`, and removes `public`. The production check on September 9, 2026 found seven clients with null types, HTTPS redirects, and no duplicate account identities or foreign-key violations. No client-type or account-identity backfill is needed. Before upgrading the provider, correct the four registrations whose applications use `ClientSecretPost`: anvil, bland, flamemail admin, and git-on-cloudflare. The reviewed SQL targets their exact client IDs and changes only `token_endpoint_auth_method`, preserving secrets and existing tokens. It is compatible with the 1.6 provider and returns four updated rows on the first run, zero on a repeat: ```sh npx wrangler d1 execute tessera-prod --remote --file scripts/migrations/better-auth-1-7-client-auth.sql ``` Deploy tres's Cloudflare change to explicit `ClientSecretBasic` before upgrading tessera. Keep the shared tres registration, dab, and Cloudflare Access on Basic. tres's Go implementation starts with Basic; relying on its fallback to Post fails under 1.7 because the first attempt can consume the authorization code. Better Auth's client-update API does not support editing the authentication-method field, so use the narrow SQL correction above. The schema is incompatible with the 1.6 Worker. The completed rollout deployed tres first, validated a D1 backup, uploaded the tested 1.7.3 Worker version, applied the client corrections and schema migration, then activated the uploaded version at 100% traffic. `npm run deploy` builds before applying migrations and preserves dashboard-managed variables. Rolling back to 1.6 requires restoring the corresponding database schema as well as the old Worker. Token requests must use the client's registered authentication method. New tessera registrations default to `client_secret_basic`; explicitly configure the RP's discovery client to use Basic. Email/profile ID-token claims remain preserved by tessera, subject to their scopes. Sign-out now revokes access tokens bound to that session. Local HTTP callbacks use native redirect policy and must use exactly `localhost`, `127.0.0.1`, or `[::1]`; `.localhost` subdomains are no longer accepted as OAuth redirects. After deployment, verify fresh logins in the four Post clients, both tres backends, dab, and Cloudflare Access. ## 7. Bootstrap the first admin tessera is invite-only. Until at least one invite has been minted _and_ accepted, no one can sign in. 1. Set `BOOTSTRAP_ADMIN_EMAIL` to the operator's email for the bootstrap window. Use the Cloudflare dashboard variable UI or, if this deployment keeps runtime vars in `wrangler.jsonc`, add it alongside the existing vars: ```jsonc "vars": { "LOG_LEVEL": "info", "BETTER_AUTH_URL": "https://auth.limic.dev", "OIDC_ISSUER": "https://auth.limic.dev", "OPERATOR_NAME": "Example Operator", "OPERATOR_CONTACT_EMAIL": "ops@limic.dev", "BOOTSTRAP_ADMIN_EMAIL": "ops@limic.dev" } ``` The auth `databaseHooks.user.create.before` hook auto-promotes to `role: "admin"` only when the first user signs up with a matching email. 2. Mint the first invite. The admin UI is unreachable until step 3, so seed the row directly via the helper script: ```sh npm run db:seed-initial-user -- --email ops@limic.dev # or, for a local dev DB: npm run db:seed-initial-user:local -- --email ops@limic.dev ``` The script refuses to run if any user rows already exist, generates a 32-byte token (matching the production `encodeBase64Url` / `sha256` helpers), inserts the invite with `created_by = 'bootstrap'`, and prints the invite URL. Pass `--expires-in-days N`, `--issuer URL`, `--force`, `--dry-run`, `--print-sql`, or `--json` for non-default behavior; `--help` lists every flag. 3. Open the printed URL, accept the invite. Your account is now `role=admin` thanks to the bootstrap hook. 4. **Remove `BOOTSTRAP_ADMIN_EMAIL`** from the deployment variables and redeploy. Subsequent signups won't auto-promote. After this, mint future invites through `/admin/invites`. The seed script refuses to run once any user exists, so it's only ever the very first invite. --- ## 8. Register downstream projects as OAuth clients OAuth client registration and launcher tile registration are independent admin surfaces. Register a client for any app that performs an OIDC flow against tessera; register a launcher tile for any app that should appear on the signed-in landing page (`/`), including apps fronted by Cloudflare Access whose OAuth client is Cloudflare Access itself. ### 8a. OAuth client (`/admin/clients`) 1. Sign in to tessera as admin. 2. Navigate to **Admin → Clients**. 3. Fill in: - Client name: `anvil` (or `bland`, etc.). - Redirect URIs (one per line): `https://anvil.limic.dev/auth/callback`, plus any preview-deploy URLs. - Homepage URL (optional): the public homepage of the app. Stored as the OIDC `client_uri`; rendered on the consent screen and the connected-apps page. - For first-party limic apps, enable **Skip consent screen**. Leave it off for third-party clients. 4. Click **Register client**. The page reveals the client ID and `client_secret` exactly once — copy both. 5. In the downstream project, set wrangler secrets: - `TESSERA_OIDC_CLIENT_ID=` - `TESSERA_OIDC_CLIENT_SECRET=` - `TESSERA_OIDC_ISSUER=https://auth.limic.dev` To rotate a secret, click **Rotate** in the same UI; the new secret is shown once, the old one is invalidated immediately. To rename a client or toggle **Skip consent screen** on an existing client, click **Edit** on that row and save. Existing tokens are not revoked by an edit; rotation remains the kill-switch for a leaked secret. The same operation is exposed programmatically: ```sh curl -X POST https://auth.limic.dev/api/admin/clients \ -H "content-type: application/json" \ -H "origin: https://auth.limic.dev" \ -H "cookie: better-auth.session_token=" \ -d '{ "name": "anvil", "redirectUris": ["https://anvil.limic.dev/auth/callback"], "skipConsent": true, "uri": "https://anvil.limic.dev" }' ``` The response includes `client_id` and `client_secret` (the secret is included on this response only — it is hashed at rest after this point). ### 8b. Launcher tile (`/admin/launcher-apps`) 1. Sign in to tessera as admin. 2. Navigate to **Admin → Launcher**. 3. Fill in: - Name: e.g. `anvil`. - URL: the public landing URL of the app (e.g. `https://anvil.limic.dev`). - Optional icon and tint from the curated registry. 4. Click **Add tile**. A tile renders on the launcher for every signed-in user. Use the **Enabled** toggle on a row to temporarily hide a tile without deleting its config. Clicking a tile is a hard navigation to the URL; the destination app's own auth flow takes over from there. --- ## 9. Register tessera with Cloudflare Access (Generic OIDC) This configures Cloudflare Access to use tessera as its Generic OIDC identity provider. Access then issues its own `Cf-Access-Jwt-Assertion` JWT to protected apps. First, mint a dedicated OAuth client in tessera: 1. Find your Zero Trust team name in Cloudflare. The Access callback URI is `https://.cloudflareaccess.com/cdn-cgi/access/callback`. 2. Sign in to tessera as admin and open **Admin → Clients**. 3. Register a client: - Client name: `cloudflare-access`. - Redirect URIs: `https://.cloudflareaccess.com/cdn-cgi/access/callback`. 4. Copy the generated `client_id` and `client_secret`. In the Cloudflare Zero Trust dashboard: 1. **Integrations → Identity providers → Add new identity provider → OpenID Connect**. 2. Fill in: - Name: `tessera`. - App ID: the dedicated `client_id` minted above. - Client secret: the corresponding `client_secret`. - **Auth URL**: `https://auth.limic.dev/api/auth/oauth2/authorize`. - **Token URL**: `https://auth.limic.dev/api/auth/oauth2/token`. - **Certificate URL**: `https://auth.limic.dev/api/auth/jwks`. - Scopes: `openid email profile`. - PKCE: enabled. tessera advertises `S256`, and Access performs PKCE on every login attempt when this option is on. - OIDC Claims: add `tessera_sub` whenever a protected downstream app needs tessera's stable UUID through Access. tessera emits this claim on every ID token and `/userinfo` response; Access exposes the forwarded claim to protected origins under `Cf-Access-Jwt-Assertion.payload.custom.tessera_sub`. Leave blank only if no downstream app keys on the tessera UUID via Access. 3. Save. Click **Test** — Access redirects through tessera, you sign in, Access reports success. 4. Claim behavior: `Cf-Access-Jwt-Assertion.payload.sub` is Cloudflare Access's user ID, not tessera's upstream OIDC `sub`. Do not treat it as a tessera UUID. Downstream apps that need tessera's stable `sub` through Access read it from `payload.custom.tessera_sub` — tessera emits this claim on every ID token and `/userinfo` response (mirroring `sub`); pass it through by listing `tessera_sub` in the **OIDC Claims** field above. The `cloudflare-access` OAuth client can be marked **Skip consent screen** in `/admin/clients`. Access is operated as first-party limic infrastructure, so the per-user grant prompt is unnecessary; this is a runtime decision for the operator, not a code default. For an app fronted by Cloudflare Access (e.g. ccccocc) to appear on the launcher, register a launcher tile under `/admin/launcher-apps` pointing at the app's public URL. The OAuth identity is Cloudflare Access; the launcher tile is just a navigation affordance. --- ## 10. Local development ```sh cp .dev.vars.example .dev.vars # Generate the local secret: openssl rand -hex 32 # → BETTER_AUTH_SECRET # Fill OPERATOR_NAME and OPERATOR_CONTACT_EMAIL. /api/config fails closed # when either is empty. # Leave BETTER_AUTH_URL and OIDC_ISSUER empty so the worker derives the base URL # from the request origin. Use the Worker URL printed by npm run dev; pass # ISSUER=... to the test scripts if it is not http://localhost:5174. # The always-pass Turnstile test keys in .dev.vars.example are fine for dev — # tessera recognizes the test-mode siteverify response and skips action/hostname # comparisons for it. For non-localhost dev, register a real Turnstile site. npx wrangler d1 migrations apply tessera-prod --local npm run dev ``` To verify the OIDC flow end-to-end against the running dev server: ```sh TEST_EMAIL=ops@limic.dev TEST_PASSWORD=... npm run test:client ``` The script self-bootstraps: signs in with the operator credentials, registers a fresh OAuth client (with `skip_consent` so the run is non-interactive), walks discovery → authorization endpoint (PKCE) → token endpoint → ID token verify against JWKS → userinfo endpoint, prints the verified claims, then deletes the ephemeral client. Override `ISSUER` (default `http://localhost:5174`) or `TEST_REDIRECT_URI` if you need to. --- ## 11. Secret rotation tessera leans entirely on `BETTER_AUTH_SECRET` for Better Auth's master crypto. It signs session cookies and encrypts D1-backed JWKS private keys plus GitHub/Google OAuth provider tokens. tessera intentionally does not keep a keyring of old master secrets, so rotating this value is destructive and requires a maintenance window plus D1 cleanup. Do not change `BETTER_AUTH_SECRET` by itself: existing encrypted JWKS rows will no longer decrypt and OIDC token issuance can fail. Before touching the secret, export the production D1 database: ```sh wrangler d1 export tessera-prod --remote --output tessera-prod-before-secret-rotation.sql ``` Then perform the rotation: 1. Enter a maintenance window and expect all users to sign in again. 2. `wrangler secret put BETTER_AUTH_SECRET` (paste a new `openssl rand -hex 32` value). 3. Delete secret-bound auth state from D1: ```sh wrangler d1 execute tessera-prod --remote --command " DELETE FROM oauth_access_tokens; DELETE FROM oauth_refresh_tokens; DELETE FROM sessions; DELETE FROM jwkss; DELETE FROM verifications; UPDATE accounts SET access_token = NULL, refresh_token = NULL, id_token = NULL, access_token_expires_at = NULL, refresh_token_expires_at = NULL WHERE access_token IS NOT NULL OR refresh_token IS NOT NULL OR id_token IS NOT NULL; " ``` 4. Fetch `https://auth.limic.dev/api/auth/jwks` to force Better Auth to create a fresh signing key under the new secret. 5. Verify password sign-in, GitHub/Google sign-in, and the OIDC flow with `npm run test:client` against production credentials. Effects: sessions are revoked, OAuth access/refresh tokens are revoked, and already-issued ID tokens may fail validation once their old signing key is removed from JWKS. Users, invites, OAuth clients, client secrets, and consent rows are not wiped. GitHub/Google provider tokens are cleared from `accounts`; users reacquire them on the next social login or account link. The JWT plugin also manages signing-key rotation independently of the master secret. That normal JWKS rotation is not a replacement for the destructive `BETTER_AUTH_SECRET` procedure above. --- ## 12. Follow-ups deferred from v1 - MFA, SAML, multi-tenancy / orgs. - Email delivery for invites (Resend integration). Operators mint the URL and deliver it manually. - SCIM, audit log table, password reset flow. - Real GitHub/Google OAuth app automation. The unit, integration, and local e2e tests cover the OIDC shape and the password / Turnstile / rate-limit / invite flows; the GitHub-link-and-relogin flow is documented in the test plan but not automated.