Phase 2: Auth.js v5 replaces Supabase Auth; Entra OIDC; password reset

Full replacement of Supabase Auth with Auth.js v5, plus Microsoft Entra OIDC as a second provider and a complete password-reset flow. The session model is worth reading if you're evaluating the fork's auth approach; the individual security patterns (non-enumerating reset endpoint, hash-before-store) are portable regardless of stack.

securityintegration

Auth.js cutover (b4f7697f, +1375/-465 across 35 files). Auth.js v5 with a Credentials provider. Session JWTs are HS256 signed with AUTH_SECRET stored in an httpOnly cookie - never reaches the browser. For backend API calls, the Next.js server mints a short-lived (5-minute) HS256 JWT via GET /api/session-token, signed with the same shared AUTH_SECRET. The backend's requireAuth middleware verifies these; no Supabase Auth lookup anywhere.

New backend pieces: routes/auth.ts (register / verify-credentials / me), lib/users.ts (user creation, sign-in, email-domain allowlist from org_settings.allowed_email_domains, bootstrap-admin promotion), lib/passwords.ts (bcryptjs, cost 12, minimum 12 characters), lib/authToken.ts (jose). A requireAdmin middleware reads res.locals.userRole. Sign-in returns the same 401 for "no such user" and "wrong password" - deliberate non-enumeration.

On the frontend: src/auth.ts configures NextAuth with the Credentials provider and custom HS256 encode/decode. AuthContext becomes a thin shim around useSession(), preserving the existing useAuth() API so 30+ call sites keep working. src/middleware.ts redirects unauthenticated requests to /login. getSessionToken() replaces supabase.auth.getSession() at all seven call sites.

Entra OIDC (0ba2ba0d, +433/-7 across 9 files). Activated only when all three AUTH_MICROSOFT_ENTRA_ID_* env vars are present. The login page shows a "Sign in with Microsoft" button when configured, nothing when not. After a successful Entra exchange, Auth.js calls POST /auth/oidc-link server-to-server, authenticated with AUTH_SECRET via timingSafeEqual on X-Internal-Auth. upsertEntraUser in lib/users.ts resolves with three lookup paths: by entra_subject (direct match), by email (links a Credentials-only account), or creates a new row with password_hash = NULL. Disallowed domains, disabled accounts, and email/subject conflicts return 409. Bootstrap-admin promotion applies on every call.

Password reset (9e2123cd, migration 0012, +682/-0 across 11 files). Migration adds password_reset_token_hash and password_reset_expires_at to users with a partial index. Tokens are 32 random bytes, base64url-encoded; the raw token is never stored - SHA-256 hex only. Valid for 1 hour, single-use. The request endpoint always returns 200 regardless of whether the email is registered. Wrong tokens and expired tokens return the same 400 message. The frontend adds /forgot-password and /reset-password to the public allowlist. lib/email.ts wraps Resend; when RESEND_API_KEY is absent in development, the email body is written to the backend log so the operator can complete the flow manually; production refuses to start the send.

So what The entire auth model is a coupled package - take the Auth.js cookie + short-lived bearer pattern with the matching backend middleware, or take none of it. If your fork stays on Supabase Auth or another provider, there's nothing to lift here directly. If you're ever moving off Supabase Auth, this is a clean reference implementation. The non-enumeration return-200 password-reset pattern and hash-before-store discipline are worth copying regardless of stack. The `X-Internal-Auth` shared-secret pattern for server-to-server calls is fine but assumes a Next.js + Express split; adapt if your deployment shape differs.

View this fork on GitHub →

Spotted something wrong? Or know the PR text has fresher detail than the writeup above?

Commits in this thread

3 commits from cpatpa/PIP, oldest first. Source extracted verbatim from the harvested git log.

SHA Subject Author Date
b4f7697f Phase 2: Auth.js v5 cutover, replace Supabase Auth Claude 2026-05-15 ↗ GitHub
commit body
Frontend authentication switches from Supabase Auth to Auth.js v5
with a Credentials provider. The Supabase Auth code paths are fully
removed. The Supabase JS client is no longer present in the frontend
bundle. Microsoft Entra OIDC will be added as a second provider in
the next sub-phase.

Session model:
- Session JWT is HS256-signed with AUTH_SECRET, stored in an httpOnly
  cookie set by Auth.js. The browser cannot read it.
- For backend API calls the browser obtains a short-lived (5 minute)
  HS256 JWT from GET /api/session-token. The Next.js server mints it
  from the verified Auth.js session, signed with the same AUTH_SECRET.
- The backend requireAuth middleware verifies any bearer HS256 JWT
  with the shared AUTH_SECRET. No Supabase Auth lookup is performed.

Backend changes:
- New routes/auth.ts: POST /register, POST /verify-credentials, GET /me.
- New lib/users.ts: centralised user creation, sign-in verification,
  email-domain allowlist enforcement, bootstrap-admin promotion.
- New lib/passwords.ts: bcryptjs at cost 12, min length 12.
- New lib/authToken.ts: jose-based HS256 verify/sign.
- middleware/auth.ts rewritten to verify Auth.js JWT. New
  requireAdmin middleware checks res.locals.userRole.
- BOOTSTRAP_ADMIN_EMAIL env grants admin role on boot and on first
  matching registration.
- /ready endpoint pings Postgres before reporting ready.
- Sign-in responses are deliberately indistinguishable between
  "no such user" and "wrong password" to prevent user enumeration.

Frontend changes:
- New auth.ts wires NextAuth Credentials provider with custom HS256
  encode/decode.
- /api/auth/[...nextauth]/route.ts exposes Auth.js handlers.
- /api/session-token mints short-lived backend bearers.
- middleware.ts redirects unauthenticated requests to /login for any
  path outside the public allowlist.
- AuthContext.tsx is now a thin shim around useSession; the existing
  useAuth() API is preserved so 30+ call sites keep working unchanged.
- providers.tsx wraps the tree in SessionProvider.
- login and signup pages rewritten for the new flow. Signup calls
  POST /auth/register then signIn("credentials"). Password minimum
  bumped to 12.
- mikeApi.ts and seven other files migrated to a shared
  getSessionToken() helper. None of them call Supabase directly.
- Deleted dead frontend/src/lib/{auth,supabase}.ts.
- Uninstalled @supabase/supabase-js, @supabase/auth-helpers-nextjs,
  @supabase/auth-js from the frontend.

Docs:
- docs/developer/07-auth.md documents the new flow end to end with
  a sequence diagram.
- docs/security/03-encryption.md records every cryptographic primitive
  and rotation procedure.
- Instructions decisions log and CHANGELOG updated.

Note: backend routes that still use the Supabase JS client for
PostgREST data queries are unchanged in this commit. The Supabase JS
client is retained on the backend as a transitional dependency.
Migration onto plain pg is the next sub-phase.
0ba2ba0d Phase 2: Microsoft Entra ID (OIDC) provider Claude 2026-05-15 ↗ GitHub
commit body
Adds Entra OIDC as a second auth provider alongside Credentials. The
provider is enabled only when all three AUTH_MICROSOFT_ENTRA_ID_*
environment variables are set on the frontend; otherwise the
Credentials path remains and the login page omits the button.

Backend:
- New POST /auth/oidc-link route. Called server-to-server from the
  frontend Auth.js jwt callback after a successful Entra exchange.
  Authenticated by the shared AUTH_SECRET in the X-Internal-Auth
  header via timingSafeEqual.
- New upsertEntraUser helper. Looks up by entra_subject; falls back
  to email match (linking a Credentials user to their Entra identity
  by setting entra_subject); creates a new row otherwise. Rejects
  disallowed domains (org_settings.allowed_email_domains), disabled
  accounts, and email/subject conflicts (409). Applies the
  BOOTSTRAP_ADMIN_EMAIL promotion. Emits user.register.oidc on first
  sign-in and user.login.oidc on every sign-in.

Frontend:
- auth.ts: conditionally adds the MicrosoftEntraID provider when
  AUTH_MICROSOFT_ENTRA_ID_ID, _SECRET, and _TENANT_ID are all set.
  Exports isEntraConfigured.
- jwt callback detects account.provider === "microsoft-entra-id" and
  calls /auth/oidc-link; on success copies id, email, role,
  onboarded into the session token. On failure returns an empty
  token so the session does not persist.
- New GET /api/auth/providers-config route reports provider
  availability without exposing any secret.
- Login page fetches /api/auth/providers-config and renders a
  "Sign in with Microsoft" button when configured. Clicking it calls
  signIn("microsoft-entra-id", { callbackUrl }).

End-to-end verification against Postgres 16:
- Missing X-Internal-Auth returns 403.
- Wrong X-Internal-Auth returns 403 (timing-safe compare).
- First Entra sign-in promotes BOOTSTRAP_ADMIN_EMAIL to admin.
- Second sign-in same subject is idempotent.
- Disallowed domain returns 400 with clear message.
- Credentials user signing in via Entra links automatically.
- Same email with different Entra subject returns 409.
- Audit log records user.register.oidc and user.login.oidc.

Docs:
- docs/developer/07-auth.md: new Entra section with flow, redirect
  URI requirements, and the full env-var table.
- frontend/.env.local.example documents the three new variables.
- CHANGELOG.md and Instructions.md updated.
9e2123cd Phase 2: password reset flow Claude 2026-05-15 ↗ GitHub
commit body
Adds the Credentials password-reset flow. Entra-only accounts have no
local password and are silently ignored.

Backend:
- Migration 0012_password_reset.sql adds password_reset_token_hash and
  password_reset_expires_at columns to public.users with a partial
  index for token lookup.
- New backend/src/lib/email.ts wraps Resend. RESEND_API_KEY is
  required in production; in development the email body is written
  to the backend log so the operator can complete the flow manually.
- New POST /auth/request-password-reset and POST /auth/reset-password
  routes. The request endpoint always returns 200 regardless of
  whether the email is registered (prevents enumeration). Tokens are
  32 random bytes encoded base64url, hashed SHA-256 before storage
  (raw token never persisted), valid for one hour, single-use.
- Wrong token and expired token return the same 400 message so a
  brute-force attacker cannot discriminate. Weak password returns a
  separate 400 with the minimum-length message.
- Audit events: user.password-reset.request and
  user.password-reset.complete.
- Disabled accounts cannot reset.

Frontend:
- New /forgot-password page submits the email and shows a generic
  "if an account exists" success message.
- New /reset-password page reads ?token=, validates password length
  and confirmation, calls the backend, redirects to /login on
  success.
- "Forgot password?" link added under the Login submit button.
- middleware.ts adds /forgot-password and /reset-password to the
  public allowlist.

End-to-end verification against Postgres 16:
- Unknown email: 200, no email sent.
- Known email: 200, link captured from backend log.
- Wrong token: 400 generic.
- Weak password: 400 with min-length message.
- Valid token + strong password: 200, password updated, reset
  columns cleared.
- Same token re-used: 400 generic (one-shot).
- Old password rejected, new password signs in.
- audit_events recorded both events.

Docs:
- docs/developer/07-auth.md: new Password reset section.
- backend/.env.example: documents RESEND_API_KEY and RESEND_FROM.
- CHANGELOG and Instructions updated.

Capture this thread into my fork

Download a single Markdown prompt that tells Claude how to port every commit above into your working tree — adapting paths and structure to match your repo. Run it via claude -p < capture-thread-361.md from inside the repo you want the changes in.

⬇ Download capture-thread-361.md