Skip to content

chore(release): promote develop to main - #147

Merged
WhiteMuush merged 114 commits into
mainfrom
develop
Aug 15, 2026
Merged

chore(release): promote develop to main#147
WhiteMuush merged 114 commits into
mainfrom
develop

Conversation

@WhiteMuush

Copy link
Copy Markdown
Owner

Deliberate release promotion, not feature work. main still runs the old NextAuth stack; this brings it to the state develop has been integrating since 2026-07-25.

107 commits, 212 files.

What lands

  • Auth stack: full NextAuth to Better Auth migration, API-level 2FA enforcement, email OTP and WebAuthn passkeys, auth-method policy per company.
  • RBAC: permission catalog, roles with presets, no-escalation rule, crown-jewel step-up, last-admin guard, append-only audit trail.
  • Enterprise SSO (OIDC): encrypted client config at rest, DNS domain verification, strict pre-provisioning, a tenant guard on every login, email-first routing with an anti-lockout escape hatch.
  • Invitations and forced rotation: single-use links, no shared temporary passwords, rotation enforced in the API guard.
  • Authorization hardening: page-level guards with a coverage test, the escalation path through account creation closed, SSO resolution no longer an account-existence oracle, the account section behind a step-up.
  • Supply chain: the outstanding high-severity advisories (nanoid, js-yaml) cleared through overrides, Dependabot config reworked, audit gate green.

Verification

CI is green on the head of develop: quality, unit, build, integration, e2e, plus CodeQL and the Security workflow with npm audit --audit-level=high.

A security review ran against the SSO branch before it merged and produced one finding, a privilege escalation through account creation, which is fixed in this promotion (4b5090b).

Known and open, carried into production

  • style-src 'unsafe-inline' in the CSP.
  • TOTP and email OTP are not AiTM-resistant; only passkeys are.
  • The seed still creates the administrator with a default password and no forced rotation.
  • The OIDC round trip is proven against a local stub identity provider, never against a real one in a browser.
  • Dependabot alerts track main, so they should close on their own once this lands.

Phase 1 scopes the next-auth v5 to Better Auth migration with per-company
2FA policy and TOTP. Records the seam-preserving approach, Prisma schema and
data migration, session strategy, and phase boundaries.
requireAuth and requireAdmin now reject un-enrolled users when their
company sets require2fa, matching the dashboard layout redirect so the
policy is not bypassable through direct API calls. The company lookup
only runs for users without 2FA, keeping the enrolled path a single
round-trip. Enrollment endpoints under /api/auth stay ungated so a
forced user can still enroll.

Also reject an empty allowedAuthMethods list in the auth-policy PATCH
(an empty array previously passed the truthy check and persisted an
invalid policy the UI already forbids).
Full NextAuth to Better Auth migration (10-task plan) plus post-review
fixes: API-level 2FA policy enforcement and empty-method-list rejection.
Resolves both Dependabot high alerts for brace-expansion (ReDoS via
exponential expansion of consecutive non-expanding braces). Transitive
via minimatch under eslint tooling; patch-level bumps to 1.1.16 and
5.0.8, no API impact.
Better Auth 1.6.25's two-factor plugin writes verified,
failedVerificationCount and lockedUntil on the TwoFactor row. The model
only had secret/backupCodes/userId, so every enrollment failed with a
PrismaClientValidationError (Unknown argument verified) and 2FA was
completely broken. Unit tests mock Prisma and the e2e admin never
enrolls, so nothing caught it.

Add the three columns (matching the plugin's declared defaults) plus a
migration, and a seed:2fa dev helper that enrolls a dedicated
mfa@datashield.local through the real Better Auth flow and prints the
TOTP secret and backup codes for local testing.
Adds mfa@datashield.local with a credential account so the two-factor
spec can enroll it without touching the password-only admin the smoke
test relies on.
Drives the real Better Auth enable/verify flow over HTTP and generates
matching TOTP codes (base32-decoding the URI secret to the raw string
Better Auth signs over).
Enrolls a user, then asserts password alone shows the TOTP form (not the
dashboard) and only a valid code completes sign-in. Guards the missing
TwoFactor column regression that the mocked unit suite could not catch.
Pin sharp, find-my-way and valibot to patched versions through npm
overrides instead of npm audit fix --force, which would downgrade Next
15 to 14 and Prisma 7.9 to 7.8 (breaking, and older majors). Clears the
sharp (DoS), find-my-way and valibot advisories with no framework
change.

The remaining audit 'high' entries are brace-expansion false positives:
the installed 1.1.16 and 5.0.8 are the patched releases of each branch,
but npm audit merges the two advisories into a single <=5.0.7 range and
misflags the fixed 1.x line. All are dev-only eslint tooling.
Replace the checkmark glyph with plain text so the pre-push ascii
compliance gate passes.
A Better Auth before-hook maps each enrollment endpoint to the method it
enables and rejects with 403 when that method is not in the caller's
Company.allowedAuthMethods, so the policy is real server-side instead of
decorative. Currently covers TOTP; the map grows as methods are added.
Mirrors the server enforcement in the UI so users are not offered a
method their company policy rejects.
Adds a serial test asserting a company that excludes TOTP gets a 403 on
enable, alongside the existing enrollment/challenge test. Restores the
policy afterwards so the parallel smoke run is unaffected.
Wire the twoFactor plugin's OTP sub-mode so a 2FA user can receive a
login code by email as an alternative to TOTP. Codes go through the
shared email sender, with a dev console fallback when email is disabled.

Extend the policy gate to /two-factor/send-otp so a company that does not
allow EMAIL_OTP is refused server-side. That endpoint runs mid-login, in
the 2FA-pending state where there is no full session yet, so resolve the
caller's company from the signed two_factor cookie the same way Better
Auth's verifyTwoFactor does. Without this the gate would silently skip
and EMAIL_OTP would never be enforced.

Also record the phase-B approach plus the later OAuth (D) and SSO (E)
phases in the plan.

Claude-Session: https://claude.ai/code/session_01XU4Pf6uxHPJhEBwkFvwtuh
After the password step, a 2FA user can pick "email me a code" instead of
the authenticator app, then send and verify it. The server policy hook
stays the real guard: a disallowed EMAIL_OTP is reported as such.

Claude-Session: https://claude.ai/code/session_01XU4Pf6uxHPJhEBwkFvwtuh
Two Playwright cases: a company that disallows EMAIL_OTP gets a 403 at
send-otp, and an allowed company completes the email-code sign-in. A
helper reads the stored code from the verification table since the dev
fallback only logs it.

The serial 2FA suite makes several sign-ins inside Better Auth's
3-per-10s window, so set E2E=1 on the CI e2e job to disable that rate
limit there (armed only under a loopback base URL, inert in prod).

Claude-Session: https://claude.ai/code/session_01XU4Pf6uxHPJhEBwkFvwtuh
Add @better-auth/passkey with a Passkey model and migration. The server
plugin derives rpID and origin from AUTH_URL so dev (localhost) and prod
are correct without extra config. Passkey enrollment is gated the same
way TOTP is: /passkey/generate-register-options is refused when a company
does not allow PASSKEY. Registration and authentication ceremonies (UI)
come next.

Claude-Session: https://claude.ai/code/session_01XU4Pf6uxHPJhEBwkFvwtuh
Spec for SSO login via company IdP (no self-signup), a granular
permission catalog with customizable per-company roles and presets,
in-app role management with anti-escalation invariants, a sealed
break-glass local account, and a threat model covering the RBAC, SSO,
session, and break-glass attack surfaces.

Claude-Session: https://claude.ai/code/session_01XU4Pf6uxHPJhEBwkFvwtuh
WhiteMuush and others added 15 commits August 15, 2026 13:01
Five suites created companies and users per run and never removed them.
On this machine that had grown to 450 companies and 730 users, which
makes the local database unusable for manual testing: the seeded admin is
buried under hundreds of rows nobody can tell apart from real data.

Deleting the company is the whole cleanup. Users, credential accounts,
sessions, roles and audit rows all reach it through onDelete: Cascade, so
tracking the company id is enough and no suite needs a bespoke teardown
list that would rot as it grows new fixtures.

Verified by counting rows around a full integration run: 451 companies
before, 451 after, where the same run previously added one per suite.
The 2FA, passkey, auth policy and SSO controls lived inside the onboarding
checklist, which redirects to the dashboard as soon as a workspace has its
first employee or API key. Past that point an administrator could not
reach the SSO configuration at all: no menu entry pointed there, and the
page bounced. The only way in was the enroll=2fa query param the layout
used for forced enrollment, which e2e had already resorted to.

The blocks move into a shared component rendered by both the new page and
the checklist, so onboarding keeps them without a second copy.

The route is /security rather than /settings because next.config.ts holds
a permanent redirect from /settings to /data-sources. A 308 is cached hard
by browsers, so anyone who had ever hit that path would keep being sent
away even after removing the rule.
A company with require2fa plus a pre-provisioned SSO account locked that
user out of the whole application. The layout redirected anyone without
two-factor to the enrollment screen, and that screen calls
twoFactor.enable, which demands the current password before it will issue
a TOTP secret. An SSO-only account has never had one, so the form could
not be satisfied and every other page bounced the user back to it.

Those accounts get their second factor from the identity provider, which
is the same reasoning the ssoExempt flag already encodes for the opposite
direction, so the redirect now skips users with no credential account.
The account lookup only runs on the path that can redirect.

The rule moved into a pure function because it is a three-state
combination and the state that matters is the one nobody reaches by
accident. The layout is the wrong place to discover that.
Creating an account left no way to hand someone their first access. The
obvious fix, generating a temporary password and passing it along, has
three problems that no amount of care removes: the secret has to travel
over a channel nobody controls, it keeps working until the user gets
around to changing it, and the administrator knows it, so every action
that account takes afterwards is deniable.

An invitation link avoids all three. The invitee chooses their own
password and nobody else ever learns it.

Security properties, each covered by a test that fails without it:

- 32 random bytes, stored only as a SHA-256 hash. Reading the table
  yields nothing that can be replayed. SHA-256 rather than bcrypt because
  the input already carries 256 bits of entropy: there is no dictionary
  to slow down, and the accept path stays cheap enough to rate-limit.
- Single use, claimed with a conditional update inside a transaction, so
  two requests arriving together cannot both succeed.
- 72-hour expiry, and issuing a new link retires the outstanding one, so
  a resend after a suspected leak actually closes the leak.
- Redeeming sets the password, marks the address verified (clicking the
  link is the proof), and deletes every session that user already had.
- Unknown, spent and expired tokens return one identical message, so the
  endpoint cannot be used to learn which tokens ever existed.
- Issuing requires users:manage plus a fresh step-up, refuses a target in
  another company, and refuses outright when the company mandates SSO,
  where a password path would be dead on arrival but still worth stealing.
- If the mail cannot be delivered the invitation is voided rather than
  left live with its only copy in a log. With no mail provider configured
  the link comes back to the caller, but never in production.

The middleware needed /invite in its public list: the invitee has no
session, so the link would otherwise bounce to a login form they cannot
pass.
An administrator can now require somebody to replace their password, for
a suspected compromise or a rotation. The requirement holds in the API
guard, not only in a page redirect: a rule that lives in the layout is
obeyed by browsers and ignored by everything else that can hold a session
cookie.

requireAuth and requirePermission both refuse with a distinct
PASSWORD_CHANGE_REQUIRED code, which the client uses to route to the form
rather than show a dead end. The flag is read from the session instead of
querying per request, which is sound because setting it also deletes that
user's sessions: a session saying false was necessarily issued after the
flag was last cleared. That deletion is also what makes the requirement
land immediately rather than whenever the session happened to expire.

The password endpoint deliberately sits outside requireAuth, since that
guard refuses the very user who needs it. It authenticates on its own
terms and re-checks the current password, so a stolen cookie alone cannot
lock the owner out, keeps the calling session alive and drops the others,
and is rate-limited because it sits outside Better Auth's own limiter.

In the dashboard layout the rotation gate runs before the two-factor
gate. Enrolling an authenticator asks for the current password, so the
other order would have users binding a second factor while still on the
password an administrator handed them.

Verified end to end against a running server: invite, accept, sign in,
forced rotation, old session refused, API refused with the code, page
redirected, password changed, access restored.
Two changes to how a forced password rotation is presented, and one to
who may start one.

The rotation and the mandatory two-factor enrollment now happen on
/secure, a screen outside the dashboard layout: no sidebar, no other page
to wander into while the requirement is outstanding. Both steps run in
order on that one screen, password first, and it re-evaluates after each,
so somebody who owes both sees them one at a time and lands on the
dashboard when nothing is left. Previously the layout pushed such users at
/security, a normal page of the application they were not otherwise
allowed to use.

Changing a password is no longer self-service: a user who wants a new one
asks an administrator, who requires a rotation, which is what brings them
to /secure. The endpoint enforces that itself rather than relying on the
form being absent from the interface, since the request can be made with
any HTTP client. The trade-off is real and deliberate: somebody who
suspects their password is compromised now waits for an administrator
instead of acting immediately.

Verified against a running server: with a rotation pending, /dashboard
and /security both redirect to /secure and the API refuses with its code;
after the change the dashboard opens and /secure sends the user back to
it; a change with no rotation pending is refused.
Only /access and /setup checked a permission before rendering. Every other
dashboard page resolved one, then used it to decide what to draw: an
address typed into the bar reached the page anyway, ran its queries and
returned the company's data to a role that was never granted it. The rail
hiding the entry was the only thing in the way, and an address bar goes
around that.

Pages now declare their permission in one registry, next to the one the
API side has always had, and a coverage test fails the build when a page
is missing from it or does not call the guard. An undeclared page resolves
to no permission at all, which the guard treats as a refusal, so the
failure mode of forgetting is a locked page rather than an open one.

The guard sits in the page, before its first query, not only in the
layout. A layout cannot stop a page from running: Next renders both, so a
page that fetches first and is hidden afterwards has already put the data
in the response. Measured before fixing, with the layout check alone: a
role without employees:read still received every employee address in the
HTML of /employees. The layout keeps its own check for the shell, and
builds the rail from the same registry, so the menu advertises exactly
what the server would allow.

The e2e fixture for this is a role holding two permissions, because every
preset holds all the ":read" ones and none of them could show the refusal.
It asserts both halves: the page refuses, and the address that used to
leak is absent from the HTML.
Security stops being a section of the product and becomes the account:
its own entry at the foot of the rail, next to signing out, rather than a
row among Employees and Alerts. What lives there is about the caller, not
about the company's data, and more will follow.

Reaching it now asks for a password or an authenticator code, whichever
the account has. This is where a session turns into lasting access: a new
authenticator, a passkey, an identity provider. A screen left unlocked
should not be enough to add one, and a session cookie is a poor proof of
who is holding the keyboard right now.

The step-up endpoint accepts either factor for the same reason. Password
only would have locked out SSO-only accounts, which have no password to
re-enter and would then be the one kind of user unable to open their own
account. The code is verified by the two-factor plugin against its own
encrypted secret rather than reimplemented here, and both branches are
rate-limited per user: five attempts a minute, since six digits are
guessable in a way a password is not.

The e2e specs clear the grant up front. It lives five minutes and is
shared by everything that user does, so one spec proving its identity
would carry the gate away for the next one.
Two guards asked for together, both about what an interface leaves
available rather than what it displays.

A ceiling on how fast one account can call the API, applied in the guard
every authenticated route already passes through. Route-by-route limits
were in place on the handful somebody thought about (sign-in, SSO
resolution, scanning, redeeming an invitation, changing a password,
proving identity again); the other thirty had none, and the next route
added would have been the one nobody remembered. Sized for a dashboard, so
a person never approaches it while a script hits it in seconds. Three
endpoints keep a tighter ceiling of their own because each call is
expensive elsewhere: testing and syncing a directory reach out to somebody
else's LDAP or Azure, and a PDF export walks the whole company. The cost
is one indexed upsert per API call, which is what buys the ceiling
existing at all rather than living where it gets forgotten.

Password fields now refuse copy, cut and drag. Masking hides characters
from someone looking at the screen and does nothing about selecting the
field and copying, which is how a password actually leaves a borrowed
machine. Paste stays allowed: password managers fill fields that way, and
blocking it pushes people towards passwords they can type from memory.
Applied to every secret-bearing field, including the API keys and the SSO
client secret, not only to the login form.

The limit is not a claim about attackers with the machine: anyone at the
keyboard can read the value from the devtools. It closes the casual path.
Verifying a password used to answer at two very different speeds. A wrong
password ran bcrypt and took roughly a quarter of a second; an account
with no password at all was refused in about a millisecond, because there
was nothing to compare and the function returned early. That gap is
readable over the network, and it answers a question nobody asked: which
accounts have a password worth attacking.

Both paths now do the same work. When there is no stored hash the supplied
password is compared against a fixed one instead, which fails and costs
what a real comparison costs, and the whole check is held to a floor so
the surrounding work cannot leak the difference either.

The floor is not what stops guessing. bcrypt at cost 12 already makes each
attempt expensive, and the per-account ceiling in the API guard is what
bounds how many attempts are possible at all. What this closes is the
enumeration channel on top of them.

Sign-in is deliberately left alone. Better Auth already hashes the
supplied password when the address is unknown, when it has no credential
account, and when that account has no password, so it is equalised
already. Adding a second burn made it worse, and the measurement is the
reason this commit says so: 590ms for an unknown address against 350ms for
a known one with a wrong password, an inverted gap where there had been
none. Removed, and re-measured at 347ms against 347ms.
Creating an account was the second way to hand out a role, and it checked
neither of the two rules the reassignment route applies. It accepted any
role of the company marked assignable, Administrator included.

The consequence was full tenant takeover from a semi-trusted position. A
holder of users:manage (the Security Manager preset, which deliberately
lacks roles:manage) could read the Administrator role id, create an
account carrying it at an address they own, mint a step-up grant with
their own password since that endpoint asks only for a session, send
themselves the invitation, choose a password, and sign in with every
permission in the tenant. The reassignment route would have refused the
same grant with a 403.

Both routes now ask the same function: no-escalation first, refusing any
role holding a permission the actor lacks, then a step-up when the role
carries a crown jewel. One decision in one place, because the two copies
already drifted once and the drift is the whole bug.

The role pickers filter on a grantable flag the server computes for the
caller instead of on isAssignable, so the interface stops offering a role
the write path would refuse. That is presentation: the rule is enforced on
the write paths, which is where it was missing.

Found by the security review on this branch. The tests fail without the
guard: removing the call makes both new cases go red, and the step-up case
proves a gate rather than a blanket refusal by passing once the grant
exists.
The pre-sign-in lookup answered from the User row, so "sso: true" meant
"this address has an account here". Anyone could type addresses at a
company running a verified provider and read back which people exist. Rate
limiting slowed that walk down; it did not stop it, and enumeration is
what precedes a targeted attack on the accounts it finds.

The answer now comes from the address's domain alone. A domain reveals
nothing personal, and reading it is safe because a provider only counts
once domainVerified is set, which requires proving control of that domain
through DNS: a company still cannot capture another company's users by
claiming a domain it does not own, which is what the User lookup was there
to prevent in the first place.

Two verified providers on one domain now answer like none at all. Sending
somebody to the wrong company's identity provider is worse than asking
them for a password, and improvising that routing is not this endpoint's
call to make.

One consequence worth stating: routing keys off the address the person
types, so a user whose address is not at a verified domain lands on the
password path. A company whose people use several domains registers a
provider per domain, which the schema already allows.

Tests assert the property directly: an address nobody has ever created
gets the same reply as a real one, and a real account at a domain with no
provider is refused. Both go red against the previous implementation.
Brings the branch up to date before the pull request: develop had moved to
next 16.3.0 and added a js-yaml override, and this branch was two commits
behind.

Two conflicts, both mechanical.

package.json: the overrides block. develop added js-yaml, this branch
added nanoid, and both floors are needed, so both are kept. The lockfile
was regenerated from the merged manifest rather than hand-resolved, and
the audit comes back clean.

The login page: develop pinned the single form to method="post" while this
branch had already split the page into an email step and a password step.
Kept this branch's structure, which carries that same fix on all four of
its forms, so the protection develop added is not lost in the merge.

Verified on the merged tree, against next 16.3.0: 233 unit, 86
integration, 14 e2e, tsc, eslint, production build, npm audit clean.
The integration job carried only DATABASE_URL. That was enough until this
branch, which writes SsoProvider rows: the Prisma extension that seals
oidcConfig refuses to run without an encryption key, so every suite
touching a provider failed at the first write with "DIRECTORY_ENCRYPTION_KEY
missing or too short".

Same placeholders the build and e2e jobs already use, and for the same
reason they are safe there: the value only ever encrypts throwaway rows in
a database this job creates and discards, and CI must never depend on a
real secret to run its tests.
feat(sso): enterprise SSO over OIDC with invitations
Comment thread src/app/api/users/route.ts Fixed
@WhiteMuush WhiteMuush changed the title release: promote develop to main (Better Auth, RBAC, SSO) chore(release): promote develop to main Aug 15, 2026
The page guards added this week closed the pages and left the endpoints
behind them open. Eight read routes sat on requireAuth, meaning any valid
session of the company, while the POST in the same file demanded a
permission. A member with no role at all, the "no access pending" state
the reassignment route sets deliberately, saw NoAccess everywhere and
could still fetch the data:

  GET /api/reports/export?section=employees

returned every employee with name, email, department, exposures, risk
score and MFA status. Same shape for the exposure register and its
evidence, the directory connections, the API credentials, the report
schedules, the webhooks, and the per-employee breach detail.

The reason it survived is in the coverage test: it only ever looked at
mutating handlers, so a GET could skip the check and nothing noticed. It
now also fails the build when a route that declares a permission falls
back to a bare session check, with the two endpoints that authenticate on
their own terms named explicitly rather than left to drift.

Found by the security review run against the develop-to-main promotion,
which is exactly what that review is for.
Signing in with a passkey is a complete primary authentication: the plugin
creates the session and sets the cookie itself. It went through no policy
check at all, because the before-hook only applied ssoMandatory to
/sign-in/email and allowedAuthMethods to the three enrollment paths.

So a company that turned on mandatory SSO to cut somebody's access, which
is how offboarding and conditional access are meant to reach this app,
still let that person in through the passkey button. Removing PASSKEY from
the allowed methods only hid the button; the endpoint kept working.

The assertion names the credential rather than the user, so the owner is
resolved from the credential id before deciding, and both rules apply:
mandatory SSO refuses a non-exempt user, and a method the company no
longer allows refuses everyone.
WhiteMuush and others added 2 commits August 15, 2026 19:04
fix(rbac): close the read APIs the page guards left open
The compliance job blocks AI attribution markers in added lines, and this
plan states the rule by quoting the exact trailer it forbids. On a feature
branch the line is old and never re-added, so nothing noticed; the
develop-to-main promotion diffs against main, every line of develop counts
as added, and the doc trips the check it is describing.

Says the same thing without carrying the literal marker.
WhiteMuush and others added 2 commits August 15, 2026 19:17
CodeQL flags the unbounded form (js/polynomial-redos): each part uses + on
the same negated class, so a long adversarial local part makes the engine
backtrack polynomially before rejecting. It only surfaced now because the
promotion diffs against main, where this route does not exist yet.

The bounds are the addressing limits, so nothing legitimate is refused:
64 for the local part, 255 for the domain, 63 for the last label.
@WhiteMuush
WhiteMuush merged commit 03b8d51 into main Aug 15, 2026
23 checks passed
@WhiteMuush
WhiteMuush deleted the develop branch August 15, 2026 17:38
@WhiteMuush
WhiteMuush restored the develop branch August 15, 2026 17:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants