cpatpa rips out Supabase Auth for Auth.js plus Microsoft Entra

A full sign-in rebuild that lets enterprise users log in through their corporate Microsoft account - and quietly hardens the password flow while it's at it.

securityintegration

cpatpa has swapped the fork's entire authentication system. Out goes Supabase Auth (the login service bundled with the underlying database platform); in comes Auth.js, an open-source sign-in library, paired with optional support for Microsoft Entra - Microsoft's corporate identity service that lets a firm's users sign in with their existing work account. The Entra option only lights up when an admin configures it, so smaller fork-takers aren't forced into Microsoft's orbit.

Along the way the team rebuilt password reset from scratch with the kind of details security reviewers actually look for: reset links expire in an hour, can only be used once, and the system gives identical responses whether or not the email exists - so an attacker can't fish for valid accounts. An email domain allowlist and a bootstrap admin role round it out.

So what Relevant for any legal-tech buyer whose firm mandates Microsoft single sign-on, and a useful reference for teams thinking about how a serious login flow should behave.

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