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.
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.
Spotted something wrong? Or know the PR text has fresher detail than the writeup above?