# Migration: anvil → tessera **Apply this in a separate PR against `~/code/anvil` after tessera is live and validated.** Do not modify anvil from the tessera repo. --- ## What changes Anvil today owns its own `users`, `password_credentials`, and KV-backed bearer-token sessions. After this migration: - Sign-in becomes a redirect to tessera's `/api/auth/oauth2/authorize`. - Anvil's `users.id` and every FK that references it stay **completely untouched**. No cascade rewrites, no risk of partial-migration corruption. - A new `tessera_sub TEXT UNIQUE` column on `users` is the new join key. The OIDC callback finds the local user by `tessera_sub`; on first sign-in for a known email, it binds (`UPDATE users SET tessera_sub = ?`). - `password_credentials` and `src/worker/auth/passwords.ts` go away — anvil no longer hashes passwords. - The KV session mechanism stays. Anvil mints its own bearer-token session at the OIDC callback; the OIDC ID token is consumed once at callback, never on every request. --- ## Code changes ### 1. New env / secrets `wrangler.jsonc` — add to `vars`: ```jsonc "TESSERA_OIDC_ISSUER": "https://auth.limic.dev", "TESSERA_OIDC_CLIENT_ID": "" ``` `wrangler secret put`: ``` TESSERA_OIDC_CLIENT_SECRET # the secret tessera revealed once ``` ### 2. New OIDC callback route Create `src/worker/api/public/oidc.ts` (sketch): ```ts import { jwtVerify, createRemoteJWKSet } from "jose"; const jwks = createRemoteJWKSet(new URL(`${env.TESSERA_OIDC_ISSUER}/api/auth/jwks`)); export const handleOidcStart = async (c) => { const verifier = randomToken(32); const challenge = await sha256base64url(verifier); await c.env.SESSIONS.put(`oidc:state:${state}`, JSON.stringify({ verifier }), { expirationTtl: 600 }); const u = new URL(`${env.TESSERA_OIDC_ISSUER}/api/auth/oauth2/authorize`); u.searchParams.set("response_type", "code"); u.searchParams.set("client_id", env.TESSERA_OIDC_CLIENT_ID); u.searchParams.set("redirect_uri", `${c.var.baseURL}/auth/callback`); u.searchParams.set("scope", "openid email profile"); u.searchParams.set("state", state); u.searchParams.set("code_challenge", challenge); u.searchParams.set("code_challenge_method", "S256"); return c.redirect(u.toString()); }; export const handleOidcCallback = async (c) => { const code = c.req.query("code"); const state = c.req.query("state"); const verifier = JSON.parse(await c.env.SESSIONS.get(`oidc:state:${state}`)).verifier; const tokenRes = await fetch(`${env.TESSERA_OIDC_ISSUER}/api/auth/oauth2/token`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "authorization_code", code, code_verifier: verifier, client_id: env.TESSERA_OIDC_CLIENT_ID, client_secret: env.TESSERA_OIDC_CLIENT_SECRET, redirect_uri: `${c.var.baseURL}/auth/callback`, }), }); const { id_token } = await tokenRes.json(); const { payload } = await jwtVerify(id_token, jwks, { issuer: env.TESSERA_OIDC_ISSUER }); // Find or bind by email (first-time sign-in). let user = await db.select().from(users).where(eq(users.tessera_sub, payload.sub)).get(); if (!user) { user = await db .select() .from(users) .where(eq(users.email, payload.email as string)) .get(); if (user) { await db.update(users).set({ tessera_sub: payload.sub }).where(eq(users.id, user.id)); } else { // Anvil policy: reject (registration in anvil happens via anvil's invite flow). throw new HttpError(403, "not_provisioned", "Ask the anvil operator for an invite."); } } // Mint anvil's own KV session and redirect to /. const session = await createSession(c.env, user.id); return c.redirect("/", { headers: { "set-cookie": cookieFor(session) } }); }; ``` Mount in `src/worker/router.ts`: ```ts app.get("/auth/start", handleOidcStart); app.get("/auth/callback", handleOidcCallback); ``` ### 3. Replace `handleLogin` `src/worker/api/public/auth.ts:62-106` — delete the password-form handler. Its replacement is `handleOidcStart` above. The old `/sign-in` page (anvil's React UI) becomes a redirect button to `/auth/start`. ### 4. Drop password column + table ```sql -- New migration in anvil: ALTER TABLE users ADD COLUMN tessera_sub TEXT; CREATE UNIQUE INDEX idx_users_tessera_sub ON users(tessera_sub); ``` Once every active user has signed in via tessera at least once (audit `SELECT count(*) FROM users WHERE tessera_sub IS NULL`): ```sql -- Follow-up migration: DROP TABLE password_credentials; ``` Remove `src/worker/auth/passwords.ts`. Remove `PASSWORD_PBKDF2_*` from `wrangler.jsonc`'s `vars`. ### 5. What stays unchanged - `users.id` and every FK column referencing it. - KV session mechanism — `createSession`, `readSession`, `requireAuth` middleware. - Turnstile gating on non-auth public surfaces (webhook receiver, etc.). - `appEncryptionKeysJson` and the encryption-at-rest pattern for `repoToken` / `webhookSecret`. --- ## Order of operations on rollout day 1. Land tessera + verify the test client roundtrip in production. 2. Register `anvil` as an OAuth client in tessera's `/admin/clients`. Save the secret. 3. Deploy anvil with the new OIDC routes **and** the password fallback still in place. (Don't drop `password_credentials` yet.) 4. Have every active user sign in via tessera at least once — `tessera_sub` populates. 5. Once `users.tessera_sub IS NULL` returns zero, ship the follow-up PR that drops `password_credentials` and the password fallback. --- ## Forced password reset Tessera does not carry over PBKDF2 hashes from anvil. Users sign in via OIDC after the cutover; their tessera password is whatever they set when accepting their tessera invite. The anvil-side `password_credentials` table is read-only during the transition and dropped at the end.