Skip to content
File

Blob: OPERATOR.md

Markdown361 lines

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

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:

"d1_databases": [
  {
    "binding": "DB",
    "database_name": "tessera-prod",
    "database_id": "<from `wrangler d1 create tessera-prod`>",
    "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:

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:

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:

    "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:

    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=<client_id>
    • TESSERA_OIDC_CLIENT_SECRET=<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:

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=<your-admin-session>" \
  -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://<your-team>.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://<your-team>.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

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:

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:

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:

    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.