Blob: reference/tessera-integration-plan.md
tessera integration plan for anvil
Status: proposed implementation plan. Saving this artifact is the only implementation step for the current session; code changes follow in a later task. Scope: Phase 1 (hard cutover, non-destructive for legacy data) and Phase 2 (destructive cleanup after soak).
1. Correctness invariant
Every accepted tessera sub resolves to at most one non-disabled anvil user, every non-disabled anvil user has at most one bound sub, and verified-email collisions never silently reassign identity. D1 PRIMARY KEY on tessera_identities.sub and UNIQUE on tessera_identities.user_id are the race guard; D1 batch() is the atomic write boundary.
State / transition matrix (callback, after openid-client validation succeeds)
| Case | sub bound? | claims.email vs user.email | user state | Writes | Outcome |
|---|---|---|---|---|---|
| A | yes | equal | not disabled | tessera_identities.last_seen_at = now |
signed_in |
| B | yes | different, free | not disabled | users.email; tessera_identities.last_seen_at = now |
email_updated |
| C | yes | different, collides another user | not disabled | none | fail tessera_email_conflict |
| D | yes | any | disabledAt set |
none | fail user_disabled |
| E | no | matches unbound user | not disabled | INSERT tessera_identities (created_at = now, last_seen_at = now); UNIQUE re-check |
bound_legacy or identity_conflict |
| F | no | matches user already bound to another sub | not disabled | none | fail identity_conflict |
| G | no | matches a disabled user | disabled | none | fail user_disabled |
| H | no | no user match | n/a | db.batch([INSERT users, INSERT tessera_identities]) with created_at = now, last_seen_at = now on identity |
created |
| I | no | no user match, race on sub | n/a | UNIQUE on sub fires; recheck classifies | raced_to_existing or identity_conflict |
Pre-callback validation failures (no sub, no email, email_verified=false, expired/tampered tx cookie, state mismatch, openid-client throw) redirect with oidc_unverified_email, oidc_session_expired, or oidc_provider_error codes. No DB writes on any failure path.
Use Drizzle .returning() on insert/update to avoid a second SELECT in the happy path; re-query only after UNIQUE-constraint races to classify the outcome.
Slug generation for Case H
When auto-provisioning a new user, build users.slug from the first non-empty claim source in this order: preferred_username, the email local part (the substring before @), then name. Lowercase the candidate and replace every character outside /[A-Za-z0-9_-]/ with -, collapse repeated -, and strip leading/trailing -, so the result passes the OwnerSlug codec (/^[A-Za-z0-9_-]+$/u). If the cleaned candidate is empty, fall back to usr-<last-6-of-generated-usr-id>. On idx_users_slug UNIQUE collision, retry with the suffix -<last-6-of-generated-usr-id> for up to 3 attempts; if collision still persists, fail closed with identity_conflict. The chosen slug is externally visible via /api/public/hooks/:provider/:ownerSlug/:projectSlug and must remain stable for the user's lifetime.
2. Phase split
Phase 1 (this PR): hard auth cutover, non-destructive for legacy data
- Add
tessera_identitiestable. - Add
GET /api/public/oidc/startandGET /api/public/oidc/callback. Both handlers callenforcePublicOidcRateLimit(c)(keyed oncf-connecting-ip) before doing any work. - Move auth transport from
Authorization: Bearerto__Host-anvil_sessionHttpOnly cookie. - Add same-origin Origin guard for unsafe-method private routes and cookie-bound logout.
- Remove from active worker exports/routes: password login, accept-invite acceptance, and invite creation. Specifically: drop
POST /api/public/auth/login,POST /api/public/auth/invite/accept,POST /api/private/invitesfrom the routers, and delete their handlers along withsrc/worker/auth/passwords.ts,src/worker/auth/headers.ts,src/worker/db/d1/repositories/password-credentials.ts,src/worker/db/d1/repositories/invites.ts, andsrc/worker/api/private/invites.ts. - Remove Turnstile UI/widget, server verification, CSP allowances, env vars, dependency, and tests.
- Remove the SPA mock-auth surface entirely (mock-api, mock state, AuthMode toggle).
- Rename rate-limit binding to
PUBLIC_OIDC_RATE_LIMITER; drop the two old bindings. - Active contract changes:
PublicAppConfigResponselosesturnstileSiteKey;GetMeResponselosesinviteTtlSeconds. - Dormant rollback surface retained in Phase 1:
password_credentialsandinvitesschema files undersrc/worker/db/d1/schema/, the D1 tables themselves,scripts/seed-bootstrap-invite.ts, and the password/invite request/response/codec exports insrc/contracts/auth.ts. These are unreachable from active code; reverting the PR restores the routes and reuses the live data. - Dependencies: remove
@marsidev/react-turnstile; addopenid-clientto dependencies; add@mongodb-js/oidc-mock-providerto devDependencies.
Phase 2 (later, after soak): destructive cleanup
- Drop
password_credentialsandinvitestables (new D1 migration after deleting their schema files). - Delete
scripts/seed-bootstrap-invite.tsand thedb:seed-initial-usernpm script. - Delete dormant password/invite request/response/codec exports from
src/contracts/auth.ts(LoginRequest,LoginResponse,AcceptInviteRequest,CreateInviteRequest,CreateInviteResponse,InviteTtlSeconds,MIN_PASSWORD_LENGTH,MIN_INVITE_TTL_HOURS,MAX_INVITE_TTL_HOURS). - Remove
PASSWORD_PBKDF2_DIGEST,PASSWORD_PBKDF2_ITERATIONS,INVITE_TTL_SECONDS,INVITE_TOKEN_BYTESfromwrangler.jsoncvars. - Decide whether
PUBLIC_OIDC_RATE_LIMITERstays or is replaced by a tighter scheme.
3. Schema changes (Phase 1)
New Drizzle table at src/worker/db/d1/schema/tessera-identities.ts:
import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
import { users } from "@/worker/db/d1/schema/users";
export const tesseraIdentities = sqliteTable(
"tessera_identities",
{
sub: text("sub").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
createdAt: integer("created_at").notNull(),
lastSeenAt: integer("last_seen_at"),
},
(table) => [uniqueIndex("idx_tessera_identities_user_id").on(table.userId)],
);Export added to src/worker/db/d1/schema/index.ts. Integer ms timestamps match anvil's existing users.createdAt / users.disabledAt convention; deviates from bland's ISO text deliberately.
Existing password_credentials and invites schema files are NOT touched in Phase 1 so drizzle does not emit a DROP TABLE migration.
4. File and module touchpoints
Worker, additions
src/worker/db/d1/schema/tessera-identities.ts(new).src/worker/db/d1/repositories/tessera-identities.ts(new) --findIdentityBySub,findUserIdBySub,bumpLastSeenAt,insertIdentity.src/worker/auth/oidc.ts(new) -- OIDC config validation, loopback HTTP allowance, discovery cache, tx-cookie HKDF sign/verify (key derived fromTESSERA_OIDC_CLIENT_SECRETvia HKDF-SHA-256 with a fixed info stringanvil-oidc-transaction-v1; no new secret introduced),validateClaims,sanitizeReturnTo,appendOidcMarker,oidcErrorContext, and a file-scoped__testnamespace for unit-test injection (mirror bland's shape).src/worker/api/public/oidc.ts(new) --handleOidcStart,handleOidcCallback, internalbindIdentitythat implements the state/transition matrix, and agenerateUserSlug(claims, attempt)helper that returns anOwnerSlug-conforming candidate built frompreferred_username/ email-local / name with a-<usr-suffix-6>collision fallback (max 3 retries) per Section 1's slug rule. Both handlers callenforcePublicOidcRateLimit(c)as the first step.src/worker/auth/cookies.ts(new) --setSessionCookie,clearSessionCookie,readSessionCookie. HonosetCookie/deleteCookie/getCookie. Cookie name__Host-anvil_session, options{ path: "/", httpOnly: true, secure: true, sameSite: "Lax", maxAge: AUTH_SESSION_TTL_SECONDS }. Browsers accept Secure cookies onhttp://localhost, so no localhost branch is needed.src/worker/security/same-origin.ts(new) --requireSameOriginmiddleware. Skips safe methods; for unsafe methods requiresOriginequal to request origin, elseSec-Fetch-Sitein{same-origin, none}. Rejects with 403cross_origin_blockedotherwise.src/worker/security/rate-limit.ts-- replace login/invite helpers withenforcePublicOidcRateLimit(c)keyed oncf-connecting-ip.
Worker, modifications
src/worker/auth/middleware.ts-- read__Host-anvil_sessioninstead of bearer; oninvalid_session, clear the cookie.maybeRefreshSessioncallers re-set the cookie when the KV record refreshes (cookie max-age tracks the KV TTL).src/worker/auth/index.ts-- drop theheadersandpasswordsre-exports.src/worker/api/public/router.ts-- removeauth/login,auth/invite/accept; addoidc/start,oidc/callback; keepauth/logoutwith cookie-read, same-origin guard, and 204 on missing session.src/worker/api/private/router.ts-- removePOST /invitesroute. ApplyrequireSameOriginmiddleware on unsafe-method routes:POST /runs/:runId/cancel,POST /runs/:runId/log-ticket,POST /projects,PATCH /projects/:projectId,PUT/POST/DELETEwebhook subroutes,POST /projects/:projectId/runs. Skip guard onGET /runs/:runId/logsWebSocket upgrade.src/worker/router.ts-- drop the Turnstile origin from CSPscript-src,connect-src,frame-src. Keep ws/wssconnect-src.src/worker/api/public/auth.ts-- deletehandleLoginandhandleInviteAccept. ReshapehandleLogoutto read cookie, delete KV session, clear cookie, 204.src/worker/api/private/invites.ts-- delete file (route removed).src/worker/api/public/app-config.ts-- dropturnstileSiteKeyfrom response.src/worker/api/private/me.ts-- dropinviteTtlSecondsfrom response.src/worker/services/turnstile.ts-- delete; remove fromsrc/worker/services/index.ts.src/worker/config.ts-- dropinviteTtlSeconds, drop turnstile config.wrangler.jsonc--vars: addTESSERA_OIDC_ISSUER(placeholder for prod).ratelimits: drop the two old bindings, addPUBLIC_OIDC_RATE_LIMITER(limit 20 / period 60, new namespace id). Secrets required:TESSERA_OIDC_CLIENT_IDandTESSERA_OIDC_CLIENT_SECRET(both held as secrets to keep registered RP client IDs out of committed config). ExistingTURNSTILE_*secrets are unset at deploy..dev.vars.example-- removeTURNSTILE_SITE_KEYandTURNSTILE_SECRET_KEY; add local placeholders forTESSERA_OIDC_ISSUER,TESSERA_OIDC_CLIENT_ID, andTESSERA_OIDC_CLIENT_SECRET.wrangler types --env-file .dev.vars.example(vianpm run types:generate) regeneratesworker-configuration.d.tsfrom this file, so it must list every secret/var the Env type requires.worker-configuration.d.ts-- regenerate vianpm run types:generateafterwrangler.jsoncand.dev.vars.exampleedits.
Client, modifications
src/client/pages/login-page.tsx-- replace form with a "Sign in with tessera" button that doeswindow.location.assign("/api/public/oidc/start?return_to=" + encodeURIComponent(returnTo)). Read?error=...and render the matching message. Drop password fields, mock-mode toggle, Turnstile widget.src/client/pages/accept-invite-page.tsx-- delete. Remove the/app/invite/acceptroute fromsrc/client/app.tsx.src/client/components/turnstile-challenge.tsx,src/client/components/turnstile-widget.tsx-- delete. Remove fromsrc/client/components/index.ts.src/client/components/invite-dialog.tsx-- delete.src/client/components/app-shell.tsx-- remove theInviteDialogimport, theinviteOpenstate, and the rendered dialog.src/client/components/header.tsx,src/client/components/user-menu.tsx-- dropAuthModeprops/imports, the mode toggle, and any "Mint invite" affordance.src/client/auth/auth-provider.tsx-- dropsignIn(LoginRequest),loginDirect, mock/live toggle, sessionId storage. On mount, call/api/private/me; 403 means unauthenticated. Sign-out hitsPOST /api/public/auth/logout(cookie attached automatically).SESSION_EXPIRED_EVENTstill fires from live-api-request.src/client/auth/auth-context.ts-- shrink touser,isAuthenticated,isInitializing,startupError,signOut.src/client/lib/storage.ts-- delete sessionId helpers and the AuthMode toggle. Keep D1 bookmark helpers.src/client/lib/live-api-request.ts-- dropAuthorizationheader injection; setcredentials: "same-origin"on the fetch options.src/client/lib/mock-api.ts,src/client/lib/mock/*-- delete entirely.src/client/lib/api.tscollapses to the live client.src/client/lib/api-contract.ts-- removelogin,acceptInvite, andcreateInvitefrom the interface, plus the now-unusedLoginResponse/CreateInviteResponseimports.src/client/lib/live-api.ts-- delete the concretelogin,acceptInvite, andcreateInviteimplementations and their contract imports.src/client/lib/query-keys.ts-- drop themode: AuthModediscriminator from every query key and update call sites to invoke the keys without the mode argument.src/client/pages/landing-page.tsx-- replace accept-invite CTAs and invite-only copy with tessera sign-in messaging.src/client/pages/profile-page.tsx-- remove theAuthModeModeToggle and the bearer-session description; reword to "Session is HttpOnly cookie via tessera OIDC."src/client/pages/run-detail-page.tsx,src/client/pages/project-settings-page.tsx-- dropAuthModetype usage from component props and imports.
Shared contracts
Phase 1 leaves dormant password/invite request/response exports (LoginRequest, LoginResponse, AcceptInviteRequest, CreateInviteRequest, CreateInviteResponse, InviteTtlSeconds, MIN_PASSWORD_LENGTH, MIN_INVITE_TTL_HOURS, MAX_INVITE_TTL_HOURS) in src/contracts/auth.ts unchanged for rollback. Active contracts change: PublicAppConfigResponse drops turnstileSiteKey; GetMeResponse drops inviteTtlSeconds. Phase 2 removes the dormant exports.
5. Dependency changes
Install with --ignore-scripts per anvil convention:
npm install --ignore-scripts openid-client
npm install --ignore-scripts --save-dev @mongodb-js/oidc-mock-provider
npm uninstall --ignore-scripts @marsidev/react-turnstilePin to openid-client ^6.x and @mongodb-js/oidc-mock-provider ^0.13.x, matching bland. Lockfile is committed.
6. Migration generation
D1 schema only changes in Phase 1, so use the narrow generator:
npm run db:generate:d1Confirm the produced file under drizzle/d1/ adds tessera_identities with PK on sub, FK user_id -> users(id) ON DELETE CASCADE, and unique index idx_tessera_identities_user_id. Commit the generated file unedited.
Apply locally:
npm run db:migrate:localProduction apply happens through npm run deploy per existing workflow.
7. Test plan
Worker unit tests (new, in tests/worker/)
auth/oidc-config.test.ts:validateIssuerUrlaccepts https, rejects unknown protocols, accepts http only on loopback; missing env throws.auth/oidc-tx-cookie.test.ts: encode/decode round-trip, expired payload rejected, tampered payload rejected, wrong-secret payload rejected. Asserts HKDF key is derived fromTESSERA_OIDC_CLIENT_SECRET.auth/oidc-claims.test.ts: missingsub/emailrejected,email_verified=falserejected, name/preferred_username/email-local fallback for display name, slug normalization passesOwnerSlugcodec.security/same-origin.test.ts: POST withoutOriginand withoutSec-Fetch-Sitefails; same-originOriginpasses; cross-originOriginfails; GET always passes;Sec-Fetch-Site: nonepasses.
Worker route tests (new, tests/worker/routes/oidc.test.ts)
Use a file-scoped __test.setAuthorizationCodeGrantImpl to inject claims. Prove each matrix cell:
- A: existing sub, same email ->
signed_in, sets__Host-anvil_session, redirects to returnTo. - B: existing sub, new free email ->
email_updated. - C: existing sub, colliding email ->
tessera_email_conflict, no email change. - D: existing sub, disabled user ->
user_disabled. - E (success): new sub, legacy user by email ->
bound_legacy, identity inserted, no users row created. - E (race): two parallel callbacks for same sub onto same legacy user -> one wins, the other classified
bound_legacyvia recheck. - F: new sub, target email belongs to user already bound to another sub ->
identity_conflict. - G: new sub, target email belongs to disabled user ->
user_disabled. - H: new sub, no matching user ->
created;usersrow id matches^usr_[0-9A-Za-z]{22}$,slugpassesOwnerSlug,tessera_identities.created_atandlast_seen_atboth set. - H (slug collision fallback): seed an existing user holding the slug the OIDC claims would resolve to; the callback completes with the
-<usr-suffix-6>fallback slug, the row writes successfully, and the test asserts the fallback shape (not just that the final slug passes the codec). - H (slug empty source): claims with no
preferred_username, noname, and an unrouteable email local part fall back tousr-<usr-suffix-6>and still produce a validOwnerSlug. - I: race on sub during auto-provision ->
raced_to_existingoridentity_conflict. - Negative: missing tx cookie, tampered tx cookie, state mismatch, openid-client throw all redirect to
/app/login?error=<code>with no DB writes. - Start: redirects to discovered authorization URL with
state,nonce,code_challenge,code_challenge_method=S256; sets__Host-anvil_oidc_tx;return_tois sanitized bysanitizeReturnTo. - Rate limit: start and callback both call
enforcePublicOidcRateLimit(c)and respond with the standard 429 +Retry-After: 60when over the IP budget.
Schema tests (extend tests/worker/structure.test.ts)
- duplicate
subinsert rejected by PK. - duplicate
user_idinsert rejected by unique index. - delete users row cascades to
tessera_identities.
Same-origin guard in existing route tests
- Add
Originheader to private-route helpers; verify unsafe-method routes reject missing-Origin requests with 403cross_origin_blocked.
Existing worker test reshape
tests/helpers/runtime.ts--authHeaders(sessionId)switches fromAuthorization: Bearer ...tocookie: __Host-anvil_session=....loginViaRoutedeleted; newmintCookieAuth(env, userId)mints a session directly viacreateSessionand returns cookie + same-origin headers.tests/worker/routes/public-auth-and-invites.test.ts-- drop password/invite-acceptance assertions; replace with smoke check thatauth/login,auth/invite/accept, andprivate/invitesare 404 after removal.tests/worker/services/turnstile.test.ts-- delete.tests/worker/routes/auth-and-validation.test.ts-- drop bearer-specific assertions; add cookie + missing-Origin equivalents on a representative private route.
Browser e2e
tests/e2e/global-setup.ts-- startOIDCMockProvideron a dynamically allocated loopback port (do not passport; useprovider.issuerfrom the returned object, matching bland). InjectTESSERA_OIDC_ISSUER=<provider.issuer>,TESSERA_OIDC_CLIENT_ID=anvil-e2e,TESSERA_OIDC_CLIENT_SECRET=anvil-e2e-tessera-secretinto the dev-server env beforestartDevServer. Drop the bootstrap invite seed.tests/e2e/fixtures/anvil-test.ts-- drop the live mode addInitScript and Turnstile stub. Add anoidcMockfixture exposingsetNextIdentity({ sub, email, name? }).- Replace
01-bootstrap-and-login.spec.tswith01-oidc-sign-in.spec.ts: navigates to/, clicks Sign in, mock provider returns baseline claims, lands on/app/projects. Asserts__Host-anvil_sessioncookie isHttpOnlyandSecure. - Replace
02-return-login.spec.tswith02-oidc-returning-user.spec.ts: pre-seededtessera_identitiesrow, complete callback, no new user row created. - Keep
03-project-crud.spec.ts,04-profile-and-signout.spec.ts,05-auth-guards.spec.tswith auth helpers updated to use the OIDC flow once before exercising the page.
Integration tests
tests/integration/queue-runner/harness.ts-- replace the bootstrap-invite seed and invite-accept HTTP flow with anoidcSignInOnce(ctx)helper that drives the mock-provider authorization endpoint with PKCE/state/nonce and walks the resulting__Host-anvil_sessioncookie back. Reuse the cookie for the rest of the run-execution assertions.tests/integration/queue-runner-happy-path.test.tsandworkflows-runner-happy-path.test.ts-- switch tooidcSignInOnce; do not duplicate OIDC matrix coverage here.
Typecheck
npx tsc -p tests/tsconfig.json --noEmit and npm run typecheck both clean.
8. Rollout and rollback
Rollout sequence
- Land tessera, register anvil RP at tessera
/admin/clients, capture client id + secret. - Set
TESSERA_OIDC_ISSUERvar andTESSERA_OIDC_CLIENT_ID/TESSERA_OIDC_CLIENT_SECRETsecrets in Cloudflare. Deploy this PR.password_credentialsandinvitestables remain populated; their HTTP routes are removed. - Operators sign in via OIDC. First-time logins that match an existing email insert
tessera_identitiesrows (Case E). - Monitor
oidc_callback_success/oidc_callback_failedlogs. Treat persistentidentity_conflictrate as a Phase-1 blocker. - After a soak window (operator decides; typical 1-2 weeks), Phase 2 PR drops the tables, dormant contracts, and bootstrap script.
Rollback (Phase 1 only)
Phase 1 is reversible by reverting the PR. The password_credentials and invites tables still exist with original data, and tessera_identities rows do not interfere with reverted code paths (they sit unused on the dropped schema). The bootstrap-invite seeder is still on disk. Caveat: any user that auto-provisioned (Case H) has no password row, so after rollback they must be granted access via a fresh invite + password.
Production secret + var configuration
| Name | Type | Phase 1 | Phase 2 |
|---|---|---|---|
TESSERA_OIDC_ISSUER |
var | required | required |
TESSERA_OIDC_CLIENT_ID |
secret | required | required |
TESSERA_OIDC_CLIENT_SECRET |
secret | required | required |
TURNSTILE_SITE_KEY |
secret | delete | n/a |
TURNSTILE_SECRET_KEY |
secret | delete | n/a |
PASSWORD_PBKDF2_DIGEST |
var | keep (dormant) | delete |
PASSWORD_PBKDF2_ITERATIONS |
var | keep (dormant) | delete |
INVITE_TTL_SECONDS |
var | keep (dormant) | delete |
INVITE_TOKEN_BYTES |
var | keep (dormant) | delete |
9. Non-goals and explicit acceptances
- Anvil no longer gates registration; tessera is the gate. Auto-provision on first OIDC sign-in is intentional.
- No MFA, password recovery, or SAML in anvil. Tessera owns those.
- Mock auth mode in the SPA is removed entirely in Phase 1; local dev exercises the OIDC mock provider in e2e and real tessera elsewhere.
- D1 bookmark header remains a custom header (
x-anvil-d1-bookmark); it is not auth and does not move to a cookie. TESSERA_OIDC_CLIENT_IDis held as a secret rather than a var so registered RP client IDs do not appear in committedwrangler.jsonc.
10. Open follow-ups (not Phase 1)
- Retention policy for
tessera_identities.last_seen_at(e.g., inactive-account reporting). - Whether to expose a "view linked identity" surface in the profile page.
- Whether to rotate
__Host-anvil_sessionon privilege change once anvil grows role/permission concepts.