Skip to content
File

Blob: docs/migrations/anvil.md

Markdown151 lines

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:

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

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