Skip to content

fix(auth): allowlist the better-auth route (deny-by-default) and own the OAuth error page - #82

Merged
chriskehayias merged 1 commit into
mainfrom
fix/auth-allowlist-route
Sep 12, 2026
Merged

chriskehayias merged 1 commit into
mainfrom
fix/auth-allowlist-route

Conversation

@chriskehayias

Copy link
Copy Markdown
Contributor

Summary

Security review finding F7: better-auth 1.7.4 mounts ~30 HTTP endpoints under /api/auth/* via the catch-all route, but this app's browser client only calls three of them. This PR closes everything else with a deny-by-default allowlist, and replaces better-auth's own OAuth-error page with one this app controls.

Allowlist

Method Path Caller
GET /get-session authClient.useSession() (src/contexts/*), authClient.getSession() (src/app/signin/page.tsx)
POST /sign-in/social src/app/signin/page.tsx
GET /callback/ministry-platform Ministry Platform's redirect after login

Everything else (/get-access-token, /refresh-token, /list-accounts, /link-social, /unlink-account, /account-info, /list-sessions, /revoke-*, /sign-up/email, /sign-in/email, /update-session, /ok, /sign-out, /error, ...) now 404s at the route, before ever reaching better-auth.

  • /sign-out excluded on purpose: sign-out runs server-side via auth.api.signOut in src/components/user-menu/actions.ts. No HTTP sign-out route is needed today.
  • /error excluded on purpose: OAuth callback failures now redirect to this app's own /auth-error page (onAPIError.errorURL in src/lib/auth.ts) instead of better-auth's built-in error page.
  • disabledAuthPaths in src/lib/auth.ts stays as defense in depth (still verified directly against auth.handler by src/auth.test.ts); the route allowlist is now the primary control.

Behavior change

A failed Ministry Platform OAuth callback now lands on /auth-error?error=<code> (a new page, src/app/auth-error/page.tsx) instead of better-auth's built-in /api/auth/error page. Known codes (unable_to_get_user_info, account_not_linked, email_not_found, invalid_code/state_not_found/nonce_binding_missing) map to plain-English messages; anything else falls back to a generic message. error_description is never rendered. The page always offers a "Try signing in again" link to /signin, with no auto-redirect. src/proxy.ts allowlists /auth-error as a public path so an unauthenticated visit doesn't bounce back to /signin and restart the loop.

Verified directly against library source: onAPIError.errorURL (@better-auth/core's init-options.d.mts) is used as-is by the callback's redirectOnError/appendQueryParams, which leaves a root-relative URL like /auth-error untouched (no baseURL prefixing) and always appends error (plus error_description when available).

Tests

  • src/app/api/auth/[...all]/route.test.tsrewritten to drive the real exported GET/POST with real NextRequest objects (previously it mocked @/lib/auth and better-auth/next-js and only asserted wiring). Note: this file already existed on main before this branch — it did not originate in the parallel dependency-audit work stream the task brief assumed it might. Covers the exact allowlist value, both allowed endpoints reaching better-auth, seven disallowed endpoints 404ing, and trailing-slash/../doubled-slash tricks not bypassing exact matching.
  • src/app/auth-error/page.test.tsx — known/unknown code mapping, sign-in link always present, error_description never rendered.
  • src/proxy.test.ts/auth-error passes through without a session cookie.
  • src/auth.test.tsonAPIError.errorURL === '/auth-error'.

Verification

  • npx vitest run — 807 tests, 51 files, all passing
  • npx vitest run --coverage — all thresholds pass (src/proxy.ts 100/100/100/100, src/app/** 95/90/95/95 aggregate)
  • npx tsc --noEmit -p tsconfig.json — clean
  • npm run lint — clean

No changes to package-lock.json or any file from the parallel dependency-audit work stream (.claude/references/deps-known-issues.md, .claude/references/testing.md, src/components/contact-logs/contact-logs.test.tsx, vitest.config.mts, untracked *.test.tsx files).

🤖 Generated with Claude Code

…the OAuth error page

Security review finding F7: better-auth 1.7.4 mounts ~30 HTTP endpoints under
/api/auth/* via the catch-all route (`export const { GET, POST } =
toNextJsHandler(auth)`), but this app's browser client uses exactly three:

| Method | Path                        | Caller                                                            |
|--------|-----------------------------|---------------------------------------------------------------------|
| GET    | /get-session                | authClient.useSession() (src/contexts/*), authClient.getSession() (src/app/signin/page.tsx) |
| POST   | /sign-in/social              | src/app/signin/page.tsx                                              |
| GET    | /callback/ministry-platform  | Ministry Platform's redirect after login                             |

Everything else (`/get-access-token`, `/refresh-token`, `/list-accounts`,
`/link-social`, `/unlink-account`, `/account-info`, `/list-sessions`,
`/revoke-*`, `/sign-up/email`, `/sign-in/email`, `/update-session`, `/ok`,
`/sign-out`, `/error`, etc.) was previously reachable and is now closed.

`src/app/api/auth/[...all]/route.ts` now exports `allowedAuthRoutes` (a
deny-by-default allowlist) and wraps `toNextJsHandler(auth)`: `GET`/`POST`
compute the request path relative to `/api/auth` (prefix stripped, trailing
slashes stripped, exact string match — no regex/prefix matching) and return a
plain 404 without ever touching better-auth for anything not on the list.

`/sign-out` is deliberately excluded: sign-out runs server-side via
`auth.api.signOut` in `src/components/user-menu/actions.ts`, so no HTTP
sign-out route is needed today; adding `authClient.signOut()` client-side
would require adding it here first, and the 404 makes that omission loud.
`/error` is deliberately excluded: OAuth callback failures now redirect to our
own `/auth-error` page instead (`onAPIError.errorURL` in `src/lib/auth.ts`),
verified against `node_modules/better-auth/dist/api/routes/callback.mjs` and
`@better-auth/core`'s `appendQueryParams` — a root-relative errorURL like
`/auth-error` is left untouched (no baseURL prefixing needed), and the
redirect always carries `?error=<code>` plus, when available,
`&error_description=<text>`.

`src/app/auth-error/page.tsx` (mirrors `src/app/session-error/page.tsx`,
outside the `(web)` route group) maps known failure codes
(`unable_to_get_user_info`, `account_not_linked`, `email_not_found`,
`invalid_code`/`state_not_found`/`nonce_binding_missing`, ...) to plain-English
messages, never renders `error_description`, and always offers a "Try signing
in again" link to `/signin` with no auto-redirect (so a failing OAuth loop
lands somewhere stable). `src/proxy.ts` allowlists `/auth-error` as a public
path — without it, an unauthenticated visit here would bounce straight back
to `/signin`, which auto-starts OAuth again, looping forever.

`disabledAuthPaths` in `src/lib/auth.ts` is unchanged in behavior; its doc
comment now explains that the route allowlist is the primary, deny-by-default
control and this list is defense in depth, still verified by
`src/auth.test.ts`, which drives `auth.handler` directly and intentionally
bypasses the route.

Tests added/updated:
- `src/app/api/auth/[...all]/route.test.ts` — rewritten to drive the real
  exported GET/POST with real NextRequest objects (previously it mocked both
  `@/lib/auth` and `better-auth/next-js` and only asserted `toNextJsHandler`
  wiring). This file already existed on `main` prior to this branch (not from
  the parallel dependency-audit work stream, despite the task brief's
  assumption) — its tests are replaced to match the new allowlist behavior,
  keeping their original intent (GET/POST both exported, built from the
  shared `auth` instance, no extra exports) as new assertions. Covers: the
  allowlist's exact value; GET /get-session and POST /sign-in/social are not
  404; GET /list-accounts, POST /get-access-token, POST /sign-out, GET
  /error, GET /ok, POST /update-user, and POST /callback/ministry-platform
  (wrong method) all 404; trailing-slash/`..`/doubled-slash tricks do not
  bypass exact matching. Only `@/lib/providers/ministry-platform` is mocked
  (MPHelper class mock), plus a hoisted stub of MP's OIDC discovery fetch so
  the real `@/lib/auth` module constructs its provider without any network
  call.
- `src/app/auth-error/page.test.tsx` — known code → mapped message, unknown
  code → generic message, sign-in link always present, `error_description`
  never rendered.
- `src/proxy.test.ts` — `/auth-error` passes through without a session
  cookie.
- `src/auth.test.ts` — `auth.options.onAPIError?.errorURL === '/auth-error'`.

Verification: `npx vitest run` (807 tests, 51 files, all passing) and
`npx vitest run --coverage` (all thresholds pass, including `src/proxy.ts`
100/100/100/100 and the `src/app/**` glob), `npx tsc --noEmit` (clean),
`npm run lint` (clean). No changes to `package-lock.json` or any file from the
parallel dependency-audit work stream.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@chriskehayias
chriskehayias merged commit 20bea07 into main Sep 12, 2026
2 checks passed
@chriskehayias
chriskehayias deleted the fix/auth-allowlist-route branch September 12, 2026 21:35
@codecov

codecov Bot commented Sep 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.77419% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/app/api/auth/[...all]/route.ts 95.00% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

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.

1 participant