Blob: MIGRATION-OIDC.md
OIDC Migration Guide
Operator runbook for upgrading an existing anvil deployment from legacy invite/password sign-in to tessera OIDC browser sign-in.
Status: proposed migration guide for the OIDC cutover represented by:
400d497-feat(auth): migrate sign-in to tessera OIDC65340ff-chore(auth): remove legacy invite and password auth
This guide is for existing deployments. Fresh deployments can start from latest main after configuring tessera OIDC and applying D1 migrations.
The implementation and environment variable names say TESSERA_* because anvil uses tessera as its OIDC provider. Another OIDC provider can work if it satisfies the provider contract below, but access policy should be enforced at that provider because anvil auto-provisions users from accepted verified OIDC identities.
What Changes
The post-migration auth model is:
- Browser sign-in starts at
/api/public/oidc/startand returns through/api/public/oidc/callback. - OIDC uses authorization code + PKCE, nonce, state, and a short-lived signed
__Host-anvil_oidc_txtransaction cookie. - anvil stores the browser session id in a Secure HttpOnly
__Host-anvil_sessioncookie. - Private unsafe-method routes require same-origin request headers because sessions are now cookie-bound.
- D1 stores OIDC bindings in
tessera_identities. - Legacy password credentials and invite tokens are removed after the closure phase.
Legacy routes removed by this migration:
POST /api/public/auth/loginPOST /api/public/auth/invite/acceptPOST /api/private/invites
Existing bearer-token browser sessions do not carry forward. Users must sign in again through tessera after the phase 1 deploy.
Upgrade Path By Starting Version
| Starting point | Required path |
|---|---|
Before 400d497 |
Deploy 400d497 first, configure OIDC, apply D1 migration 0001, validate sign-in and identity binding, then deploy 65340ff or latest. |
At 400d497 |
Validate OIDC for every active operator, then deploy 65340ff or latest to drop legacy tables. |
At 65340ff or newer |
You are on the closed OIDC model. Confirm OIDC secrets, D1 migrations, and user bindings. |
| Fresh deployment | Deploy latest directly. Configure tessera, apply migrations, then sign in to create the first user. |
Do not skip the phase 1 validation gate on an existing deployment unless you have a tested D1 backup and every active user can authenticate through tessera with the expected verified email address. Latest main no longer contains the legacy invite/password rollback surface.
Prerequisites
Back up D1 before touching production.
npx wrangler d1 export anvil-db --remote --output ./anvil-db-before-oidc.sqlRegister an anvil OIDC client in tessera.
Production callback URL:
https://<anvil-hostname>/api/public/oidc/callbackLocal callback URL, if needed:
http://127.0.0.1:<port>/api/public/oidc/callbackConfirm the provider contract.
The provider must expose standard OIDC discovery, support authorization code + PKCE S256, accept confidential-client token exchange with client secret post, return signed ID tokens, and emit a stable opaque
sub,email, andemail_verified=true.preferred_usernameandnameare optional. They only affect display-name and slug generation for newly auto-provisioned users.Restrict provider access to the people who should be able to create an anvil account.
anvil does not maintain a separate invite gate after this migration. Any accepted OIDC identity with a verified email can sign in and create an anvil user unless the account is later disabled in D1.
Check existing anvil users and their email addresses.
npx wrangler d1 execute anvil-db --remote --command 'select id, slug, email, disabled_at from users order by created_at;'First OIDC sign-in binds an existing unbound user by verified email. If tessera returns a different email, anvil may update the existing bound user's email only when the new email is not owned by another user. Collisions fail closed.
Configure Cloudflare
Set the issuer as non-secret configuration:
TESSERA_OIDC_ISSUER=https://<tessera-hostname>Set the OIDC client credentials as Worker secrets:
npx wrangler secret put TESSERA_OIDC_CLIENT_ID
npx wrangler secret put TESSERA_OIDC_CLIENT_SECRETKeep the existing production encryption secrets:
APP_ENCRYPTION_KEY_CURRENT_VERSIONAPP_ENCRYPTION_KEYS_JSON
The OIDC migration does not rotate repository-token or webhook-secret encryption keys.
Turnstile configuration, invite TTL configuration, password PBKDF2 configuration, and legacy login/invite rate-limit bindings are not used after phase 1.
Phase 1: Reversible OIDC Cutover (400d497)
Phase 1 adds OIDC sign-in and the tessera_identities table while keeping the old password_credentials and invites D1 tables intact. The old HTTP routes are removed from active code, but the D1 data remains available if you roll back by redeploying the previous password/invite build.
Procedure
git checkout 400d497
npm ci --ignore-scripts
npx wrangler d1 export anvil-db --remote --output ./anvil-db-before-oidc-phase1.sql
npm run db:migrate:d1
npm run build
npx wrangler deployOpen /app/login, click "Sign in with tessera", and complete the provider flow.
Phase 1 Validation
-
/app/loginrenders the tessera sign-in button. - OIDC start redirects to the tessera authorization endpoint.
- OIDC callback returns to
/app/projects?oidc=1or the sanitizedreturn_topath. - The browser has a Secure HttpOnly
__Host-anvil_sessioncookie. -
/api/private/mesucceeds after sign-in. - A
tessera_identitiesrow exists for each active operator who signed in. - Existing projects are still visible to the same users.
- Creating, updating, and deleting projects still works from the UI.
- Triggering a run still works for the dispatch mode you use in production.
- Worker logs show
oidc_callback_successand no persistentidentity_conflict,tessera_email_conflict, oruser_disabledfailures.
Useful D1 checks:
npx wrangler d1 execute anvil-db --remote --command 'select sub, user_id, created_at, last_seen_at from tessera_identities order by created_at;'
npx wrangler d1 execute anvil-db --remote --command 'select id, slug, email, disabled_at from users order by created_at;'Phase 1 Recovery
If OIDC discovery fails, confirm TESSERA_OIDC_ISSUER has no query, fragment, or credentials and that non-loopback issuers use https.
If callback fails with oidc_unverified_email, the provider did not return email_verified=true with an email address.
If callback fails with identity_conflict or tessera_email_conflict, inspect the users and tessera_identities rows before retrying. Do not manually reassign a sub without confirming the provider identity.
Phase 1 rollback is a normal redeploy rollback to the previous password/invite build. The added tessera_identities table is ignored by old code. If you removed old Turnstile secrets or bindings from the deployment environment, restore them before rolling back to the pre-OIDC build.
Phase 2: Legacy Auth Closure (65340ff)
Phase 2 removes the remaining legacy auth surface:
- Drops D1 tables
invitesandpassword_credentialswith migration0002_tearful_toro.sql. - Deletes the bootstrap invite seeder and
db:seed-initial-userscript. - Removes dormant password/invite contract exports.
- Removes obsolete password, invite, Turnstile, and WAF references from operator docs.
Only proceed after phase 1 validation passes for every active operator.
Procedure
git checkout 65340ff
npm ci --ignore-scripts
npx wrangler d1 export anvil-db --remote --output ./anvil-db-before-oidc-closure.sql
npm run db:migrate:d1
npm run build
npx wrangler deployAfter validating 65340ff, you can return to the target branch and deploy normally:
git checkout main
npm ci --ignore-scripts
npm run db:migrate:d1
npm run build
npx wrangler deployPhase 2 Validation
-
invitesandpassword_credentialsare absent from D1. -
/api/public/auth/loginreturns 404. -
/api/public/auth/invite/acceptreturns 404. -
/api/private/invitesreturns 404 for an authenticated user. - OIDC sign-in still works for an existing bound user.
- OIDC sign-in creates a new user only when intended.
- Sign-out clears the
__Host-anvil_sessioncookie. - Private unsafe-method requests without same-origin headers fail with
403 cross_origin_blocked. - Worker logs show no unexpected OIDC callback failures.
Example table check:
npx wrangler d1 execute anvil-db --remote --command "select name from sqlite_master where type = 'table' and name in ('invites', 'password_credentials', 'tessera_identities') order by name;"Expected result after phase 2: only tessera_identities.
Identity Binding Rules
anvil accepts only OIDC claims with:
- non-empty
sub - non-empty
email email_verified=true
Callback binding behavior:
| Case | Result |
|---|---|
Existing sub, same email, active user |
Session created; last_seen_at updated. |
Existing sub, changed email, email is free |
User email updated; session created. |
Existing sub, changed email, email belongs to another user |
Fails with tessera_email_conflict. |
Existing sub, disabled user |
Fails with user_disabled. |
New sub, email matches an unbound active user |
Inserts tessera_identities; session created. |
New sub, email matches a user already bound to another sub |
Fails with identity_conflict. |
New sub, email matches a disabled user |
Fails with user_disabled. |
New sub, no matching user |
Creates a user and identity binding. |
For new users, slug candidates are tried in this order:
preferred_username- email local part
nameusr-<user-id-suffix>fallback
The slug is lowercased, normalized to the OwnerSlug character set, and retried with a -<user-id-suffix> suffix on slug collision.
Rollback Posture
Before phase 2, rollback to the pre-OIDC password/invite build is available because the legacy D1 tables still exist.
After phase 2, rollback to legacy password/invite auth requires restoring D1 from a backup that still contains invites and password_credentials. Treat post-closure auth failures as forward-fix unless you have a deliberate database restore plan.
Users auto-provisioned by OIDC have no legacy password row. If you roll back to the pre-OIDC auth model, those users cannot sign in until you create a legacy invite/password path for them or restore a database state that contains one.
Useful Validation Commands
Run the fast worker suite:
npm testRun OIDC-focused worker tests:
npx vitest run tests/worker/auth/oidc.test.ts tests/worker/routes/oidc.test.tsRun browser auth coverage when validating the local app:
npm run test:e2e -- tests/e2e/specs/01-oidc-sign-in.spec.ts tests/e2e/specs/02-oidc-returning-user.spec.tsRun the expensive live execution checks sequentially when the migration also needs run-path confidence:
npm run test:integration:queue
npm run test:integration:workflows