Blob: docs/migrations/anvil.md
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.idand every FK that references it stay completely untouched. No cascade rewrites, no risk of partial-migration corruption. - A new
tessera_sub TEXT UNIQUEcolumn onusersis the new join key. The OIDC callback finds the local user bytessera_sub; on first sign-in for a known email, it binds (UPDATE users SET tessera_sub = ?). password_credentialsandsrc/worker/auth/passwords.tsgo 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:
"TESSERA_OIDC_ISSUER": "https://auth.limic.dev",
"TESSERA_OIDC_CLIENT_ID": "<minted in tessera /admin/clients>"wrangler secret put:
TESSERA_OIDC_CLIENT_SECRET # the secret tessera revealed once2. New OIDC callback route
Create src/worker/api/public/oidc.ts (sketch):
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:
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
-- 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):
-- 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.idand every FK column referencing it.- KV session mechanism —
createSession,readSession,requireAuthmiddleware. - Turnstile gating on non-auth public surfaces (webhook receiver, etc.).
appEncryptionKeysJsonand the encryption-at-rest pattern forrepoToken/webhookSecret.
Order of operations on rollout day
- Land tessera + verify the test client roundtrip in production.
- Register
anvilas an OAuth client in tessera's/admin/clients. Save the secret. - Deploy anvil with the new OIDC routes and the password fallback still in place. (Don't drop
password_credentialsyet.) - Have every active user sign in via tessera at least once —
tessera_subpopulates. - Once
users.tessera_sub IS NULLreturns zero, ship the follow-up PR that dropspassword_credentialsand 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.