Blob: MIGRATION-OIDC.md
OIDC Migration Guide
Operator runbook for upgrading an existing bland deployment from password login, first-user seeding, and Turnstile-gated invite acceptance to tessera OIDC sign-in and local bland sessions.
This guide is primarily for self-hosted forks or clones. The upstream deployment has already completed this migration.
The implementation and variable names say tessera because upstream uses tessera as its OIDC provider. Another OIDC provider can work if it supports standard discovery, authorization code + PKCE, confidential clients, signed ID tokens, a stable opaque sub, and verified email claims.
Prerequisites
Back up production state. Record a D1 Time Travel recovery timestamp or export production D1 before applying the closure migration that drops
users.password_hash.OIDC client is registered. Register bland as an OIDC relying party with your provider.
- Production redirect URI:
https://<your-bland-host>/api/v1/oidc/callback - Add one redirect URI for each app origin in
ALLOWED_ORIGINS, such ashttps://docs.limic.dev/api/v1/oidc/callbackif you serve bland there. - Local dev redirect URI:
http://127.0.0.1:<port>/api/v1/oidc/callbackor the Vite URL printed bynpm run dev - Scopes:
openid email profile - Flow: authorization code with PKCE S256
- Production redirect URI:
Provider claims are suitable.
submust be stable and opaque. bland stores it intessera_identities.sub.emailmust be present and verified. bland uses verified email only for first-time legacy binding and profile projection.nameis optional. New users fall back to a name derived from the email local part.
Cloudflare bindings exist. The OIDC-era application uses:
- D1 binding
DB - Durable Object bindings
DocSyncandWorkspaceIndexer - R2 bindings
R2andSITES - Queue binding
SEARCH_QUEUE - Workers AI binding
AI - Rate-limit bindings
RL_AUTH,RL_API, andRL_AI
- D1 binding
1. Upgrade Path By Starting Version
| Upgrading from | Required steps |
|---|---|
Before d55c6c6 |
Deploy d55c6c6 first. Configure OIDC, apply migration 0004, validate sign-in and legacy email binding while users.password_hash still exists, then continue to the closure. |
d55c6c6 to before b499aa4 |
Finish OIDC validation, confirm at least one owner/admin can sign in through tessera, then deploy b499aa4 or newer to drop the legacy password column. |
b499aa4 or newer |
You are on the post-password schema. Make sure OIDC vars/secrets are configured and tessera_identities exists. |
| Fresh deployment | Deploy latest directly. Configure OIDC, sign in through tessera, and let bland create the first user, workspace, and owner membership from the verified tessera identity. |
Do not apply 0005_famous_juggernaut.sql until OIDC sign-in is validated. That migration drops users.password_hash. After it runs, rollback to password-era code is not a normal operational path.
2. Configure OIDC Runtime Values
Set production secrets:
npx wrangler secret put JWT_SECRET
npx wrangler secret put TESSERA_OIDC_CLIENT_ID
npx wrangler secret put TESSERA_OIDC_CLIENT_SECRETSet the issuer in wrangler.jsonc vars, or as a secret if you prefer:
TESSERA_OIDC_ISSUER=https://auth.limic.devFor local development, copy .dev.vars.example to .dev.vars and point TESSERA_OIDC_ISSUER, TESSERA_OIDC_CLIENT_ID, and TESSERA_OIDC_CLIENT_SECRET at a local or development provider. Loopback http://localhost and http://127.0.0.1 issuers are allowed only for local development.
After the OIDC cutover, these legacy values are no longer read and can be removed from the deployed environment after validation:
TURNSTILE_SITE_KEYTURNSTILE_SECRET
3. Phase 1 Deploy: OIDC Compatibility (d55c6c6)
Phase 1 adds tessera OIDC sign-in, tessera_identities, authenticated invite acceptance, post-OIDC session bootstrap, and the legacy email binding path. It removes password login, Turnstile, and the initial-user seed script, but keeps users.password_hash in D1 so rollback to password-era code still has the column it expects.
Procedure
Check out and deploy the compatibility commit:
git checkout d55c6c6 npm ci --ignore-scripts npm run db:migrate:remote npm run build npx wrangler deployOpen
/loginand start tessera sign-in.For an existing password-era user, sign in with a tessera account whose verified email matches
users.email. The callback binds that stable OIDCsubto the existing user by inserting a row intessera_identities.For a new user, sign in with a verified tessera account that does not match an existing email. bland creates the user, default workspace, and owner membership.
Validate the application before applying the closure migration.
Phase 1 Validation
-
/loginshows a tessera sign-in action, not a password form -
/api/v1/oidc/startredirects to the configured provider -
/api/v1/oidc/callbackreturns to the app with anoidc=1marker, then the SPA removes the marker after refresh - A returning legacy user gets a
tessera_identitiesrow for their existingusers.id - Existing workspace memberships, shares, pages, uploads, and DocSync content remain attached to the same
users.id - A first-time tessera user creates a new user, workspace, and owner membership
- Invite preview remains public, while invite acceptance requires tessera auth
- Email-pinned invites compare against the verified tessera email
- Existing document editing and DocSync WebSocket connections still work
- Worker logs show
oidc_callback_successand no repeateddiscovery_failedoroidc_token_exchange_failed
Useful D1 checks:
npx wrangler d1 execute bland-prod --remote --command \
"SELECT users.email, tessera_identities.sub FROM users LEFT JOIN tessera_identities ON users.id = tessera_identities.user_id ORDER BY users.email"
npx wrangler d1 execute bland-prod --remote --command \
"PRAGMA table_info(users)"During phase 1, PRAGMA table_info(users) should still show password_hash.
Phase 1 Recovery
If OIDC discovery fails:
- Confirm
TESSERA_OIDC_ISSUERpoints at the issuer root that serves/.well-known/openid-configuration. - Confirm non-loopback issuers use HTTPS.
- Confirm
authorization_endpoint,token_endpoint, andjwks_uriare on the same host as the issuer.
If callback fails:
- Confirm the exact callback origin is registered with the provider.
- Confirm
TESSERA_OIDC_CLIENT_IDandTESSERA_OIDC_CLIENT_SECRETmatch the provider registration. - Confirm the provider returns a signed ID token with
sub,email, andemail_verified: true.
If a legacy user does not bind:
- Confirm the tessera verified email exactly matches
users.emailafter lowercasing. - If the email belongs to a different already-bound user, the callback fails closed with
tessera_email_conflictoridentity_conflict. - Do not manually reuse a
subacross multiple users; one tesserasubmaps to exactly one blandusers.id.
4. Phase 2 Closure: Drop Password Column (b499aa4 Or Newer)
Phase 2 removes the legacy password column and sentinel code. After this point, bland is OIDC-only for human sign-in.
Procedure
Confirm the phase 1 validation checklist has passed.
Deploy the closure commit or latest
main:git checkout b499aa4 npm ci --ignore-scripts npm run db:migrate:remote npm run build npx wrangler deployTo deploy current
mainafter validating the closure boundary:git checkout main npm ci --ignore-scripts npm run deployVerify migration
0005_famous_juggernaut.sqlhas run:npx wrangler d1 execute bland-prod --remote --command "PRAGMA table_info(users)"password_hashshould no longer be present.
Post-Closure Validation
- Existing tessera-bound users can sign in
- Existing legacy users without a
tessera_identitiesrow can still bind by verified email on first OIDC sign-in - New tessera users can still be created
- Password login routes are absent
- Turnstile client and Worker middleware are absent
-
scripts/seed-initial-user.tsand seed npm scripts are absent -
users.password_hashis absent from D1 -
tessera_identities.subis primary key andtessera_identities.user_idis unique - Invite acceptance works only after tessera auth
- Auth refresh still propagates the D1 bookmark after OIDC callback
5. Operational Notes
OIDC Provider Compatibility
The code uses openid-client v6. The provider contract is:
- Issuer exposes OIDC discovery at
/.well-known/openid-configuration - Authorization endpoint supports authorization code + PKCE S256
- Token endpoint returns a signed ID token
- ID token validates for the configured client id audience
- ID token includes stable
sub - ID token includes verified
email
bland never uses tessera ID tokens as application sessions. The callback validates the ID token once, binds the identity, then mints local bland JWT sessions.
Session And Invite Behavior After Migration
- Refresh tokens live in the
bland_refreshHttpOnly cookie. - Access tokens live only in client memory and are sent as bearer headers for HTTP API calls and DocSync connection params.
- The OIDC transaction cookie is
__Host-bland_oidc_tx, HttpOnly, Secure, SameSite=Lax, and short-lived. - The callback redirects to the sanitized
return_topath withoidc=1; the SPA performs a blocking refresh and then removes the marker. - Invite preview stays public. Invite acceptance is strictly auth-required.
- Shared
/s/:tokensurfaces remain token-scoped and do not become member surfaces just because the viewer also has a session.
Rollback Posture
Before migration 0005, rollback to password-era code is possible if the old Turnstile and password-login runtime configuration is still available. After 0005, rollback to password-era code requires a deliberate D1 restore or manual schema/data restoration. Treat post-closure failures as forward-fix unless you have an external restore plan.
6. Useful Commands
# Apply D1 migrations to production
npm run db:migrate:remote
# Apply D1 migrations locally
npm run db:migrate:local
# Generate Worker binding types from local env placeholders
npm run types:generate
# Typecheck and lint
npm run typecheck
npm run lintTargeted validation:
npx vitest run --project worker-unit tests/worker/lib/oidc.test.ts
npx vitest run --project worker-runtime \
tests/worker/db/tessera-identities.workers.test.ts \
tests/worker/routes/oidc.workers.test.ts \
tests/worker/routes/invite-accept.workers.test.ts
npx vitest run --project client tests/client/lib/session-bootstrap.test.ts
npx vitest run --project client-dom \
tests/client/lib/session-bootstrap.dom.test.ts \
tests/client/lib/api.dom.test.ts
npx playwright test -c tests/e2e/playwright.config.ts \
tests/e2e/specs/28-oidc-first-login.spec.ts \
tests/e2e/specs/29-oidc-returning.spec.tsAppendix: What The OIDC Cutover Removed
- Password login route and client password form
- Cloudflare Turnstile invite/login integration
- First-user seed script and seed npm scripts
TURNSTILE_SITE_KEYandTURNSTILE_SECRETruntime dependencyusers.password_hashin the closure migration- Password-hash dependencies used only by the seed/login path
The post-closure auth model is: tessera owns human identity and verified email; bland owns local JWT sessions, workspace memberships, roles, invites, shares, uploads, Sites authorization, and product permissions.