refactor(auth): move auth guards to the Node layer; drop next-auth dependency

The edge middleware decoded the session via next-auth getToken, which only
resolves legacy cookies — not the thin hub civ-token (no embedded user; edge
can't hit Redis/DB). Move the session-based guards to the Node layer and remove
next-auth entirely.

- /moderator + /testing page guards and the preview-deploy gate now run in
  _app getInitialProps with the resolved session (preview Flipt check extracted
  to server/auth/preview-access.ts). Catches direct/SSR loads; non-mods can't
  client-nav there and the tRPC procedures are the real data gate.
- Strip getToken from middleware/index.ts; drop user/useSession from the
  Middleware type; route-guards keeps only the sessionless /api/testing gate.
- Delete preview-auth.middleware + next-auth.d.ts; repoint middleware.trpc's
  ExtendedUser -> first-party SessionUser (a superset). Drop next-auth +
  @next-auth/prisma-adapter — next-auth is fully removed.
- Preview e2e minters mint the legacy JWE via jose (mirrors @civitai/auth's
  legacy-cookie decoder) instead of next-auth/jwt.

Needs a QA pass before release (auth-critical, not runtime-tested).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Briant Diehl
2026-06-17 15:03:32 -06:00
parent 5f2e21a150
commit c142c37782
17 changed files with 144 additions and 293 deletions
+1 -3
View File
@@ -85,8 +85,8 @@
"@aws-sdk/lib-storage": "^3.490.0",
"@aws-sdk/s3-request-presigner": "^3.490.0",
"@axiomhq/axiom-node": "^0.12.0",
"@civitai/auth": "workspace:*",
"@civitai/app-sdk": "^0.6.0",
"@civitai/auth": "workspace:*",
"@civitai/blocks-react": "^0.4.0",
"@civitai/client": "0.2.0-beta.71",
"@civitai/cybertipline-tools": "^0.1.0",
@@ -116,7 +116,6 @@
"@marsidev/react-turnstile": "^1.0.1",
"@meilisearch/instant-meilisearch": "0.13.5",
"@microsoft/signalr": "^7.0.10",
"@next-auth/prisma-adapter": "^1.0.7",
"@next/bundle-analyzer": "^16.2.7",
"@next/third-parties": "^15.0.3",
"@node-oauth/oauth2-server": "^5.3.0",
@@ -221,7 +220,6 @@
"motion": "^11.11.17",
"msgpackr": "^1.10.2",
"next": "^16.2.7",
"next-auth": "^4.24.5",
"nodemailer": "^6.8.0",
"obscenity": "^0.4.5",
"openai": "^4.73.0",
+5 -63
View File
@@ -118,9 +118,6 @@ importers:
'@microsoft/signalr':
specifier: ^7.0.10
version: 7.0.14(bufferutil@4.0.9)(utf-8-validate@5.0.10)
'@next-auth/prisma-adapter':
specifier: ^1.0.7
version: 1.0.7(@prisma/client@6.13.0(prisma@6.13.0(typescript@5.9.2))(typescript@5.9.2))(next-auth@4.24.11(next@16.2.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(nodemailer@6.10.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))
'@next/bundle-analyzer':
specifier: ^16.2.7
version: 16.2.7(bufferutil@4.0.9)(utf-8-validate@5.0.10)
@@ -433,9 +430,6 @@ importers:
next:
specifier: ^16.2.7
version: 16.2.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0)
next-auth:
specifier: ^4.24.5
version: 4.24.11(next@16.2.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(nodemailer@6.10.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
nodemailer:
specifier: ^6.8.0
version: 6.10.1
@@ -715,13 +709,13 @@ importers:
version: 5.62.0(eslint@8.57.1)(typescript@5.9.2)
'@vitest/browser':
specifier: ^4.0.18
version: 4.0.18(bufferutil@4.0.9)(msw@2.12.10(@types/node@20.19.9)(typescript@5.9.2))(utf-8-validate@5.0.10)(vite@6.4.1(@types/node@20.19.9)(jiti@2.5.1)(sass@1.90.0)(sugarss@5.0.1(postcss@8.5.6))(terser@5.48.0)(tsx@4.20.3)(yaml@2.8.1))(vitest@4.0.18)
version: 4.0.18(bufferutil@4.0.9)(msw@2.12.10(@types/node@20.19.9)(typescript@5.9.2))(utf-8-validate@5.0.10)(vite@7.3.1(@types/node@20.19.9)(jiti@2.5.1)(sass@1.90.0)(sugarss@5.0.1(postcss@8.5.6))(terser@5.48.0)(tsx@4.20.3)(yaml@2.8.1))(vitest@4.0.18)
'@vitest/browser-playwright':
specifier: 4.0.18
version: 4.0.18(bufferutil@4.0.9)(msw@2.12.10(@types/node@20.19.9)(typescript@5.9.2))(playwright@1.57.0)(utf-8-validate@5.0.10)(vite@6.4.1(@types/node@20.19.9)(jiti@2.5.1)(sass@1.90.0)(sugarss@5.0.1(postcss@8.5.6))(terser@5.48.0)(tsx@4.20.3)(yaml@2.8.1))(vitest@4.0.18)
version: 4.0.18(bufferutil@4.0.9)(msw@2.12.10(@types/node@20.19.9)(typescript@5.9.2))(playwright@1.57.0)(utf-8-validate@5.0.10)(vite@7.3.1(@types/node@20.19.9)(jiti@2.5.1)(sass@1.90.0)(sugarss@5.0.1(postcss@8.5.6))(terser@5.48.0)(tsx@4.20.3)(yaml@2.8.1))(vitest@4.0.18)
'@vitest/coverage-v8':
specifier: ^4.0.18
version: 4.0.18(@vitest/browser@4.0.18(bufferutil@4.0.9)(msw@2.12.10(@types/node@20.19.9)(typescript@5.9.2))(utf-8-validate@5.0.10)(vite@6.4.1(@types/node@20.19.9)(jiti@2.5.1)(sass@1.90.0)(sugarss@5.0.1(postcss@8.5.6))(terser@5.48.0)(tsx@4.20.3)(yaml@2.8.1))(vitest@4.0.18))(vitest@4.0.18)
version: 4.0.18(@vitest/browser@4.0.18(bufferutil@4.0.9)(msw@2.12.10(@types/node@20.19.9)(typescript@5.9.2))(utf-8-validate@5.0.10)(vite@7.3.1(@types/node@20.19.9)(jiti@2.5.1)(sass@1.90.0)(sugarss@5.0.1(postcss@8.5.6))(terser@5.48.0)(tsx@4.20.3)(yaml@2.8.1))(vitest@4.0.18))(vitest@4.0.18)
autoprefixer:
specifier: ^10.4.19
version: 10.4.21(postcss@8.5.6)
@@ -2632,12 +2626,6 @@ packages:
'@napi-rs/wasm-runtime@0.2.12':
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
'@next-auth/prisma-adapter@1.0.7':
resolution: {integrity: sha512-Cdko4KfcmKjsyHFrWwZ//lfLUbcLqlyFqjd/nYE2m3aZ7tjMNUjpks47iw7NTCnXf+5UWz5Ypyt1dSs1EP5QJw==}
peerDependencies:
'@prisma/client': '>=2.26.0 || >=3'
next-auth: ^4
'@next/bundle-analyzer@16.2.7':
resolution: {integrity: sha512-Lh52e3gpnJQ8ZwBNsp54g/Czg1oqbx7bdZ9mEqHfI0VbtNMHCpneYNzKj6C+oW1JIeyjpmRSRGnhGOmi0SdNow==}
@@ -13205,11 +13193,6 @@ snapshots:
'@tybys/wasm-util': 0.10.1
optional: true
'@next-auth/prisma-adapter@1.0.7(@prisma/client@6.13.0(prisma@6.13.0(typescript@5.9.2))(typescript@5.9.2))(next-auth@4.24.11(next@16.2.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(nodemailer@6.10.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))':
dependencies:
'@prisma/client': 6.13.0(prisma@6.13.0(typescript@5.9.2))(typescript@5.9.2)
next-auth: 4.24.11(next@16.2.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(nodemailer@6.10.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@next/bundle-analyzer@16.2.7(bufferutil@4.0.9)(utf-8-validate@5.0.10)':
dependencies:
webpack-bundle-analyzer: 4.10.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)
@@ -15793,19 +15776,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@vitest/browser-playwright@4.0.18(bufferutil@4.0.9)(msw@2.12.10(@types/node@20.19.9)(typescript@5.9.2))(playwright@1.57.0)(utf-8-validate@5.0.10)(vite@6.4.1(@types/node@20.19.9)(jiti@2.5.1)(sass@1.90.0)(sugarss@5.0.1(postcss@8.5.6))(terser@5.48.0)(tsx@4.20.3)(yaml@2.8.1))(vitest@4.0.18)':
dependencies:
'@vitest/browser': 4.0.18(bufferutil@4.0.9)(msw@2.12.10(@types/node@20.19.9)(typescript@5.9.2))(utf-8-validate@5.0.10)(vite@6.4.1(@types/node@20.19.9)(jiti@2.5.1)(sass@1.90.0)(sugarss@5.0.1(postcss@8.5.6))(terser@5.48.0)(tsx@4.20.3)(yaml@2.8.1))(vitest@4.0.18)
'@vitest/mocker': 4.0.18(msw@2.12.10(@types/node@20.19.9)(typescript@5.9.2))(vite@6.4.1(@types/node@20.19.9)(jiti@2.5.1)(sass@1.90.0)(sugarss@5.0.1(postcss@8.5.6))(terser@5.48.0)(tsx@4.20.3)(yaml@2.8.1))
playwright: 1.57.0
tinyrainbow: 3.1.0
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@20.19.9)(@vitest/browser-playwright@4.0.18)(happy-dom@20.9.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(jiti@2.5.1)(jsdom@27.4.0(@noble/hashes@1.8.0)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(msw@2.12.10(@types/node@20.19.9)(typescript@5.9.2))(sass@1.90.0)(sugarss@5.0.1(postcss@8.5.6))(terser@5.48.0)(tsx@4.20.3)(yaml@2.8.1)
transitivePeerDependencies:
- bufferutil
- msw
- utf-8-validate
- vite
'@vitest/browser-playwright@4.0.18(bufferutil@4.0.9)(msw@2.12.10(@types/node@20.19.9)(typescript@5.9.2))(playwright@1.57.0)(utf-8-validate@5.0.10)(vite@7.3.1(@types/node@20.19.9)(jiti@2.5.1)(sass@1.90.0)(sugarss@5.0.1(postcss@8.5.6))(terser@5.48.0)(tsx@4.20.3)(yaml@2.8.1))(vitest@4.0.18)':
dependencies:
'@vitest/browser': 4.0.18(bufferutil@4.0.9)(msw@2.12.10(@types/node@20.19.9)(typescript@5.9.2))(utf-8-validate@5.0.10)(vite@7.3.1(@types/node@20.19.9)(jiti@2.5.1)(sass@1.90.0)(sugarss@5.0.1(postcss@8.5.6))(terser@5.48.0)(tsx@4.20.3)(yaml@2.8.1))(vitest@4.0.18)
@@ -15818,7 +15788,6 @@ snapshots:
- msw
- utf-8-validate
- vite
optional: true
'@vitest/browser-playwright@4.0.18(bufferutil@4.0.9)(msw@2.12.10(@types/node@20.19.9)(typescript@5.9.3))(playwright@1.57.0)(utf-8-validate@5.0.10)(vite@7.3.1(@types/node@20.19.9)(jiti@2.5.1)(sass@1.90.0)(sugarss@5.0.1(postcss@8.5.6))(terser@5.48.0)(tsx@4.20.3)(yaml@2.8.1))(vitest@4.0.18)':
dependencies:
@@ -15834,23 +15803,6 @@ snapshots:
- vite
optional: true
'@vitest/browser@4.0.18(bufferutil@4.0.9)(msw@2.12.10(@types/node@20.19.9)(typescript@5.9.2))(utf-8-validate@5.0.10)(vite@6.4.1(@types/node@20.19.9)(jiti@2.5.1)(sass@1.90.0)(sugarss@5.0.1(postcss@8.5.6))(terser@5.48.0)(tsx@4.20.3)(yaml@2.8.1))(vitest@4.0.18)':
dependencies:
'@vitest/mocker': 4.0.18(msw@2.12.10(@types/node@20.19.9)(typescript@5.9.2))(vite@6.4.1(@types/node@20.19.9)(jiti@2.5.1)(sass@1.90.0)(sugarss@5.0.1(postcss@8.5.6))(terser@5.48.0)(tsx@4.20.3)(yaml@2.8.1))
'@vitest/utils': 4.0.18
magic-string: 0.30.21
pixelmatch: 7.1.0
pngjs: 7.0.0
sirv: 3.0.2
tinyrainbow: 3.1.0
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@20.19.9)(@vitest/browser-playwright@4.0.18)(happy-dom@20.9.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(jiti@2.5.1)(jsdom@27.4.0(@noble/hashes@1.8.0)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(msw@2.12.10(@types/node@20.19.9)(typescript@5.9.2))(sass@1.90.0)(sugarss@5.0.1(postcss@8.5.6))(terser@5.48.0)(tsx@4.20.3)(yaml@2.8.1)
ws: 8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)
transitivePeerDependencies:
- bufferutil
- msw
- utf-8-validate
- vite
'@vitest/browser@4.0.18(bufferutil@4.0.9)(msw@2.12.10(@types/node@20.19.9)(typescript@5.9.2))(utf-8-validate@5.0.10)(vite@7.3.1(@types/node@20.19.9)(jiti@2.5.1)(sass@1.90.0)(sugarss@5.0.1(postcss@8.5.6))(terser@5.48.0)(tsx@4.20.3)(yaml@2.8.1))(vitest@4.0.18)':
dependencies:
'@vitest/mocker': 4.0.18(msw@2.12.10(@types/node@20.19.9)(typescript@5.9.2))(vite@7.3.1(@types/node@20.19.9)(jiti@2.5.1)(sass@1.90.0)(sugarss@5.0.1(postcss@8.5.6))(terser@5.48.0)(tsx@4.20.3)(yaml@2.8.1))
@@ -15867,7 +15819,6 @@ snapshots:
- msw
- utf-8-validate
- vite
optional: true
'@vitest/browser@4.0.18(bufferutil@4.0.9)(msw@2.12.10(@types/node@20.19.9)(typescript@5.9.3))(utf-8-validate@5.0.10)(vite@7.3.1(@types/node@20.19.9)(jiti@2.5.1)(sass@1.90.0)(sugarss@5.0.1(postcss@8.5.6))(terser@5.48.0)(tsx@4.20.3)(yaml@2.8.1))(vitest@4.0.18)':
dependencies:
@@ -15887,7 +15838,7 @@ snapshots:
- vite
optional: true
'@vitest/coverage-v8@4.0.18(@vitest/browser@4.0.18(bufferutil@4.0.9)(msw@2.12.10(@types/node@20.19.9)(typescript@5.9.2))(utf-8-validate@5.0.10)(vite@6.4.1(@types/node@20.19.9)(jiti@2.5.1)(sass@1.90.0)(sugarss@5.0.1(postcss@8.5.6))(terser@5.48.0)(tsx@4.20.3)(yaml@2.8.1))(vitest@4.0.18))(vitest@4.0.18)':
'@vitest/coverage-v8@4.0.18(@vitest/browser@4.0.18(bufferutil@4.0.9)(msw@2.12.10(@types/node@20.19.9)(typescript@5.9.2))(utf-8-validate@5.0.10)(vite@7.3.1(@types/node@20.19.9)(jiti@2.5.1)(sass@1.90.0)(sugarss@5.0.1(postcss@8.5.6))(terser@5.48.0)(tsx@4.20.3)(yaml@2.8.1))(vitest@4.0.18))(vitest@4.0.18)':
dependencies:
'@bcoe/v8-coverage': 1.0.2
'@vitest/utils': 4.0.18
@@ -15901,7 +15852,7 @@ snapshots:
tinyrainbow: 3.0.3
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@20.19.9)(@vitest/browser-playwright@4.0.18)(happy-dom@20.9.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(jiti@2.5.1)(jsdom@27.4.0(@noble/hashes@1.8.0)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(msw@2.12.10(@types/node@20.19.9)(typescript@5.9.2))(sass@1.90.0)(sugarss@5.0.1(postcss@8.5.6))(terser@5.48.0)(tsx@4.20.3)(yaml@2.8.1)
optionalDependencies:
'@vitest/browser': 4.0.18(bufferutil@4.0.9)(msw@2.12.10(@types/node@20.19.9)(typescript@5.9.2))(utf-8-validate@5.0.10)(vite@6.4.1(@types/node@20.19.9)(jiti@2.5.1)(sass@1.90.0)(sugarss@5.0.1(postcss@8.5.6))(terser@5.48.0)(tsx@4.20.3)(yaml@2.8.1))(vitest@4.0.18)
'@vitest/browser': 4.0.18(bufferutil@4.0.9)(msw@2.12.10(@types/node@20.19.9)(typescript@5.9.2))(utf-8-validate@5.0.10)(vite@7.3.1(@types/node@20.19.9)(jiti@2.5.1)(sass@1.90.0)(sugarss@5.0.1(postcss@8.5.6))(terser@5.48.0)(tsx@4.20.3)(yaml@2.8.1))(vitest@4.0.18)
'@vitest/expect@4.0.18':
dependencies:
@@ -15912,15 +15863,6 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.1.0
'@vitest/mocker@4.0.18(msw@2.12.10(@types/node@20.19.9)(typescript@5.9.2))(vite@6.4.1(@types/node@20.19.9)(jiti@2.5.1)(sass@1.90.0)(sugarss@5.0.1(postcss@8.5.6))(terser@5.48.0)(tsx@4.20.3)(yaml@2.8.1))':
dependencies:
'@vitest/spy': 4.0.18
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
msw: 2.12.10(@types/node@20.19.9)(typescript@5.9.2)
vite: 6.4.1(@types/node@20.19.9)(jiti@2.5.1)(sass@1.90.0)(sugarss@5.0.1(postcss@8.5.6))(terser@5.48.0)(tsx@4.20.3)(yaml@2.8.1)
'@vitest/mocker@4.0.18(msw@2.12.10(@types/node@20.19.9)(typescript@5.9.2))(vite@7.3.1(@types/node@20.19.9)(jiti@2.5.1)(sass@1.90.0)(sugarss@5.0.1(postcss@8.5.6))(terser@5.48.0)(tsx@4.20.3)(yaml@2.8.1))':
dependencies:
'@vitest/spy': 4.0.18
+28 -1
View File
@@ -39,7 +39,7 @@ import { ToursProvider } from '~/components/Tours/ToursProvider';
import { TrackPageView } from '~/components/TrackView/TrackPageView';
import { UpdateRequiredWatcher } from '~/components/UpdateRequiredWatcher/UpdateRequiredWatcher';
import { env } from '~/env/client';
import { isDev, isProd } from '~/env/other';
import { isDev, isPreview, isProd } from '~/env/other';
import { civitaiTokenCookieName } from '~/libs/auth';
import { ActivityReportingProvider } from '~/providers/ActivityReportingProvider';
import { AppProvider } from '~/providers/AppProvider';
@@ -498,6 +498,33 @@ MyApp.getInitialProps = async (appContext: AppContext) => {
hasAuthCookie = false;
}
// Auth route guards, moved off the edge middleware (the thin hub civ-token can't resolve the full user in the
// edge runtime — see preview-access.ts / route-guards). Runs here in Node with the resolved session, catching
// direct/SSR loads. A non-mod can't client-nav to these (the nav isn't shown to them) and the tRPC procedures
// are the real data gate, so the client-nav gap is harmless.
const guardPath = (request.url ?? '').split('?')[0];
const isModerator = !!session?.user?.isModerator;
let guardTo: string | undefined;
if (
(guardPath.startsWith('/moderator') && !isModerator) ||
(guardPath.startsWith('/testing') && isProd && !isModerator)
) {
// Login can't grant the missing permission (it would loop back here), so authed-but-unauthorized → home.
guardTo = session?.user ? '/' : `/login?returnUrl=${encodeURIComponent(guardPath)}`;
} else if (isPreview && !guardPath.startsWith('/login') && guardPath !== '/preview-restricted') {
// Preview deploys gate every page behind login + the moderator/testers allowlist.
if (!session?.user) {
guardTo = `/login?returnUrl=${encodeURIComponent(guardPath)}`;
} else if (!isModerator) {
const { checkPreviewAccess } = await import('~/server/auth/preview-access');
if (!(await checkPreviewAccess(session.user))) guardTo = '/preview-restricted';
}
}
if (guardTo && appContext.ctx.res) {
appContext.ctx.res.writeHead(302, { Location: guardTo });
appContext.ctx.res.end();
}
return {
pageProps: {
...pageProps,
+3 -4
View File
@@ -1,10 +1,9 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { getServerAuthSession } from '~/server/auth/get-server-auth-session';
// next-auth/react's SessionProvider + useSession (and the ~317 useCurrentUser sites) poll this endpoint. It
// shadows the old [...nextauth] catch-all for exactly /api/auth/session and returns the hub-resolved session
// (civ-token, or a legacy civitai-token via the jose decoder) in next-auth's `{ user, expires }` shape, so the
// client half is unchanged until the first-party provider replaces next-auth/react.
// The first-party SessionProvider + useSession (and the ~317 useCurrentUser sites) poll this endpoint. It returns
// the hub-resolved session (civ-token, or a legacy civitai-token via the jose decoder) in the `{ user, expires }`
// shape the client expects. (Replaced the old next-auth [...nextauth] /api/auth/session route.)
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getServerAuthSession({ req, res });
if (!session?.user) {
+5 -6
View File
@@ -10,12 +10,11 @@ import {
type ReactNode,
} from 'react';
// First-party replacement for next-auth/react's `SessionProvider` + `useSession` (cutover phase 4). The session
// is the hub's thin civ-token; the rich user is resolved server-side and exposed by GET /api/auth/session in
// next-auth's `{ user, expires }` shape — so this keeps the exact `useSession()` contract the ~13 call sites
// already use (`{ data, status, update }`), no per-site rewrites. NB: `Session` is still the next-auth type;
// phase 5 swaps it for a first-party type app-wide. signIn/signOut/getProviders remain on next-auth/react until
// phase 5 (they're coupled to the next-auth server endpoints).
// First-party replacement for the old next-auth/react `SessionProvider` + `useSession`. The session is the hub's
// thin civ-token; the rich user is resolved server-side and exposed by GET /api/auth/session in the `{ user,
// expires }` shape — keeping the exact `useSession()` contract (`{ data, status, update }`) the call sites use.
// next-auth is fully removed: login is hub-driven (popup / full-page redirect), sign-out hits /api/auth/logout,
// and `Session` is the first-party type from ~/types/session.
type SessionStatus = 'authenticated' | 'unauthenticated' | 'loading';
+54
View File
@@ -0,0 +1,54 @@
import type { SessionUser } from '~/types/session';
// Preview-site access check (preview deploys only) — extracted from the former `preview-auth` edge middleware,
// now that auth runs in the Node layer (_app getInitialProps). A user gets in if they're a moderator OR enabled
// in the Flipt `preview-site-access` segment. Plain fetch + a per-pod TTL cache; no Node-only deps.
const FLIPT_URL = process.env.FLIPT_URL;
const FLIPT_CACHE_TTL_MS = 60_000;
const FLIPT_TIMEOUT_MS = 2_000;
const fliptCache = new Map<string, { enabled: boolean | null; expiresAt: number }>();
type FliptBooleanResponse = { enabled?: boolean };
async function evaluateFliptFlag(
flagKey: string,
entityId: string,
context: Record<string, string>
): Promise<boolean | null> {
if (!FLIPT_URL) return null;
const cacheKey = `${flagKey}:${entityId}`;
const now = Date.now();
const cached = fliptCache.get(cacheKey);
if (cached && cached.expiresAt > now) return cached.enabled;
try {
const res = await fetch(`${FLIPT_URL}/evaluate/v1/boolean`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ namespaceKey: 'default', flagKey, entityId, context }),
signal: AbortSignal.timeout(FLIPT_TIMEOUT_MS),
});
if (!res.ok) return null;
const data = (await res.json()) as FliptBooleanResponse;
const enabled = data.enabled ?? null;
fliptCache.set(cacheKey, { enabled, expiresAt: now + FLIPT_CACHE_TTL_MS });
return enabled;
} catch {
// Includes timeout (AbortError) and network errors — fall through to deny.
return null;
}
}
/** True if the user may access a preview deploy: moderators always; otherwise the Flipt testers segment. */
export async function checkPreviewAccess(user: SessionUser): Promise<boolean> {
if (user.isModerator) return true;
const hasAccess = await evaluateFliptFlag('preview-site-access', String(user.id), {
userId: String(user.id),
isModerator: String(!!user.isModerator),
tier: user.tier ?? 'free',
isLoggedIn: 'true',
});
return !!hasAccess;
}
+2 -2
View File
@@ -8,7 +8,7 @@ import type { UserPreferencesInput } from '~/server/schema/base.schema';
import { getAllHiddenForUser } from '~/server/services/user-preferences.service';
import { middleware } from '~/server/trpc';
import { getRequestDomainColor } from '~/server/utils/server-domain';
import type { ExtendedUser } from '~/types/next-auth';
import type { SessionUser } from '~/types/session';
import { withSpan } from '~/server/utils/otel-helpers';
import { hashifyObject, slugit } from '~/utils/string-helpers';
import { booleanString } from '~/utils/zod-helpers';
@@ -106,7 +106,7 @@ export function cacheIt<TInput extends object>({
export type RateLimit = {
limit: number;
period: number; // seconds
userReq?: (user: ExtendedUser) => boolean;
userReq?: (user: SessionUser) => boolean;
errorMessage?: string;
};
export type RateLimitOptions = {
+5 -21
View File
@@ -1,24 +1,20 @@
import type { SessionUser } from '~/types/session';
import { getToken } from 'next-auth/jwt';
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { civitaiTokenCookieName } from '~/libs/auth';
import { apiCacheMiddleware } from '~/server/middleware/api-cache.middleware';
import { apiRegionBlockMiddleware } from '~/server/middleware/api-region-block.middleware';
import { botDetectionMiddleware } from '~/server/middleware/bot-detection.middleware';
import type { Middleware } from '~/server/middleware/middleware-utils';
import { regionBlockMiddleware } from '~/server/middleware/region-block.middleware';
import { regionRestrictionMiddleware } from '~/server/middleware/region-restriction.middleware';
import { previewAuthMiddleware } from '~/server/middleware/preview-auth.middleware';
import { routeGuardsMiddleware } from '~/server/middleware/route-guards.middleware';
// NOTE: order matters! Preview auth first, then region blocking, then restriction redirect.
// botDetectionMiddleware is last because it returns `NextResponse.next({ request })`
// to inject a request header for downstream handlers — the runner below merges any
// response-header-only passthroughs (like apiCacheMiddleware's Cache-Control) into
// NOTE: order matters! Region blocking, then restriction redirect. botDetectionMiddleware is last because it
// returns `NextResponse.next({ request })` to inject a request header for downstream handlers — the runner
// below merges any response-header-only passthroughs (like apiCacheMiddleware's Cache-Control) into
// botDetection's terminal-with-request-mods response so both effects apply.
// (Session-based guards — /moderator, /testing, preview-auth — moved to _app getInitialProps: the edge runtime
// can't resolve the thin hub civ-token to a full user.)
const middlewares: Middleware[] = [
previewAuthMiddleware,
regionBlockMiddleware,
regionRestrictionMiddleware,
apiRegionBlockMiddleware,
@@ -53,8 +49,6 @@ export async function runMiddlewares(
request: NextRequest,
middlewareList: Middleware[] = middlewares
) {
let user: SessionUser | null = null;
let hasToken = true;
const redirect = (to: string) => NextResponse.redirect(new URL(to, request.url));
// Response headers from passthrough middlewares (e.g. apiCacheMiddleware's
@@ -63,19 +57,9 @@ export async function runMiddlewares(
for (const middleware of middlewareList) {
if (middleware.shouldRun && !middleware.shouldRun(request)) continue;
if (middleware.useSession && !user && hasToken) {
const token = await getToken({
req: request,
secret: process.env.NEXTAUTH_SECRET,
cookieName: civitaiTokenCookieName,
});
if (!token) hasToken = false;
user = token?.user as SessionUser;
}
const response = await middleware.handler({
request,
user,
redirect,
});
if (!response) continue;
@@ -1,13 +1,10 @@
import type { SessionUser } from '~/types/session';
import type { NextRequest, NextResponse } from 'next/server';
export type Middleware = {
matcher: string[];
useSession?: boolean;
shouldRun?: (request: NextRequest) => boolean;
handler: (ctx: {
request: NextRequest;
user: SessionUser | null;
redirect: (to: string) => NextResponse;
}) => Promise<NextResponse | void>;
};
@@ -1,89 +0,0 @@
import { createMiddleware } from '~/server/middleware/middleware-utils';
import { isPreview } from '~/env/other';
// Read once at module init — env vars don't change at runtime.
const FLIPT_URL = process.env.FLIPT_URL;
// Edge-runtime-safe in-memory TTL cache. Lives for the isolate's lifetime;
// different regions/instances have independent caches, which is fine — even
// partial hit-rate eliminates most of the per-request Flipt round-trips on
// preview deploys. Can't use ~/server/utils/ttl-cache because that pulls in
// prom-client (Node-only).
const FLIPT_CACHE_TTL_MS = 60_000;
const FLIPT_TIMEOUT_MS = 2_000;
const fliptCache = new Map<string, { enabled: boolean | null; expiresAt: number }>();
type FliptBooleanResponse = { enabled?: boolean };
// Evaluate a Flipt boolean flag via HTTP API (Edge-compatible, no WASM SDK).
async function evaluateFliptFlag(
flagKey: string,
entityId: string,
context: Record<string, string>
): Promise<boolean | null> {
if (!FLIPT_URL) return null;
const cacheKey = `${flagKey}:${entityId}`;
const now = Date.now();
const cached = fliptCache.get(cacheKey);
if (cached && cached.expiresAt > now) return cached.enabled;
try {
const res = await fetch(`${FLIPT_URL}/evaluate/v1/boolean`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
namespaceKey: 'default',
flagKey,
entityId,
context,
}),
signal: AbortSignal.timeout(FLIPT_TIMEOUT_MS),
});
if (!res.ok) return null;
const data = (await res.json()) as FliptBooleanResponse;
const enabled = data.enabled ?? null;
fliptCache.set(cacheKey, { enabled, expiresAt: now + FLIPT_CACHE_TTL_MS });
return enabled;
} catch {
// Includes timeout (AbortError) and network errors — fall through to deny.
return null;
}
}
export const previewAuthMiddleware = createMiddleware({
matcher: ['/:path*'],
useSession: true,
shouldRun: (request) => {
if (!isPreview) return false;
const { pathname } = request.nextUrl;
// Don't block auth/login routes or static assets
if (pathname.startsWith('/api/auth')) return false;
if (pathname.startsWith('/login')) return false;
if (pathname === '/preview-restricted') return false;
if (pathname.startsWith('/_next')) return false;
if (pathname.startsWith('/favicon')) return false;
return true;
},
handler: async ({ user, redirect, request }) => {
if (!user) {
return redirect(`/login?returnUrl=${request.nextUrl.pathname}`);
}
// Moderators always have access
if (user.isModerator) return;
// Check Flipt testers segment via HTTP API (Edge-compatible)
const hasAccess = await evaluateFliptFlag('preview-site-access', String(user.id), {
userId: String(user.id),
isModerator: String(!!user.isModerator),
tier: user.tier ?? 'free',
isLoggedIn: 'true',
});
if (hasAccess) return;
return redirect('/preview-restricted');
},
});
@@ -1,18 +1,12 @@
import type { SessionUser } from '~/types/session';
import type { NextRequest } from 'next/server';
import { createMiddleware } from '~/server/middleware/middleware-utils';
import { pathToRegexp } from 'path-to-regexp';
import { isProd } from '~/env/other';
// The session-based PAGE guards (/moderator, /testing) moved to _app getInitialProps — the edge runtime can't
// resolve the thin hub civ-token to a full user. What's left here is the sessionless /api/testing gate (those
// debug endpoints are non-prod only), so no getToken is needed.
const routeGuards: RouteGuard[] = [];
addRouteGuard({
matcher: ['/moderator/:path*'],
canAccess: ({ user }) => user?.isModerator,
});
addRouteGuard({
matcher: ['/testing/:path*'],
canAccess: ({ user }) => !isProd || user?.isModerator,
});
addRouteGuard({
matcher: ['/api/testing/:path*'],
canAccess: () => !isProd,
@@ -22,7 +16,7 @@ addRouteGuard({
type RouteGuard = {
matcher: string[];
isMatch: (pathname: string) => boolean;
canAccess: (ctx: { request: NextRequest; user: SessionUser | null }) => boolean | undefined;
canAccess: (ctx: { request: NextRequest }) => boolean | undefined;
redirect?: string;
};
function addRouteGuard(routeGuard: Omit<RouteGuard, 'isMatch'>) {
@@ -36,21 +30,13 @@ function addRouteGuard(routeGuard: Omit<RouteGuard, 'isMatch'>) {
}
export const routeGuardsMiddleware = createMiddleware({
matcher: routeGuards.flatMap((routeGuard) => routeGuard.matcher),
useSession: true,
handler: async ({ user, request, redirect }) => {
handler: async ({ request, redirect }) => {
const { pathname } = request.nextUrl;
for (const routeGuard of routeGuards) {
if (!routeGuard.isMatch(pathname)) continue;
if (routeGuard.canAccess({ user, request })) continue;
// Can't access. Sending an already-authenticated user to /login can't grant the
// missing permission, and login redirects a logged-in user straight back to
// returnUrl → infinite /moderator/... ⇄ /login loop. So only unauthenticated
// users go to login; logged-in-but-unauthorized users go home (or the guard's
// explicit redirect, if set).
if (routeGuard.redirect) return redirect(routeGuard.redirect);
return redirect(user ? '/' : `/login?returnUrl=${pathname}`);
if (routeGuard.canAccess({ request })) continue;
return redirect(routeGuard.redirect ?? '/');
}
},
});
+2 -2
View File
@@ -9,8 +9,8 @@ import { getSanitizedStringSchema } from '~/server/schema/utils.schema';
export const commentRateLimits: RateLimit[] = [
{ limit: 10, period: CacheTTL.hour },
{ limit: 4 * 10, period: CacheTTL.day },
{ limit: 60, period: CacheTTL.hour, userReq: (user) => user.meta?.scores?.total >= 1000 },
{ limit: 8 * 60, period: CacheTTL.day, userReq: (user) => user.meta?.scores?.total >= 1000 },
{ limit: 60, period: CacheTTL.hour, userReq: (user) => (user.meta?.scores?.total ?? 0) >= 1000},
{ limit: 8 * 60, period: CacheTTL.day, userReq: (user) => (user.meta?.scores?.total ?? 0) >= 1000},
];
export type GetAllCommentsSchema = z.infer<typeof getAllCommentsSchema>;
+4 -4
View File
@@ -13,10 +13,10 @@ export const reactionRateLimits: RateLimit[] = [
// 24 hour limit - prevent systematic abuse while allowing heavy usage
{ limit: 5000, period: CacheTTL.day },
// Higher limits for users with good reputation scores (≥1000 total score)
{ limit: 100, period: CacheTTL.xs, userReq: (user) => user.meta?.scores?.total >= 1000 },
{ limit: 500, period: CacheTTL.md, userReq: (user) => user.meta?.scores?.total >= 1000 },
{ limit: 1500, period: CacheTTL.hour, userReq: (user) => user.meta?.scores?.total >= 1000 },
{ limit: 8000, period: CacheTTL.day, userReq: (user) => user.meta?.scores?.total >= 1000 },
{ limit: 100, period: CacheTTL.xs, userReq: (user) => (user.meta?.scores?.total ?? 0) >= 1000},
{ limit: 500, period: CacheTTL.md, userReq: (user) => (user.meta?.scores?.total ?? 0) >= 1000},
{ limit: 1500, period: CacheTTL.hour, userReq: (user) => (user.meta?.scores?.total ?? 0) >= 1000},
{ limit: 8000, period: CacheTTL.day, userReq: (user) => (user.meta?.scores?.total ?? 0) >= 1000},
];
export const reactableEntities: readonly [string, ...string[]] = [
-67
View File
@@ -1,67 +0,0 @@
import type { DefaultSession, DefaultUser } from 'next-auth';
import type { UserTier, UserSubscriptionsByBuzzType } from '~/server/schema/user.schema';
import type { User as PrismaUser } from '~/shared/utils/prisma/enums';
import type { getUserBanDetails } from '~/utils/user-helpers';
interface ExtendedUser {
id: number;
showNsfw: boolean;
blurNsfw: boolean; // client only
browsingLevel: number;
onboarding: number;
username?: string;
image?: string;
email?: string;
emailVerified?: Date;
createdAt?: Date;
isModerator?: boolean;
customerId?: string; // could be fetched
paddleCustomerId?: string; // could be fetched
subscriptionId?: string; // could be fetched - deprecated, kept for backward compatibility
tier?: UserTier; // Highest tier across all subscriptions
muted?: boolean;
mutedAt?: Date;
bannedAt?: Date;
autoplayGifs?: boolean; // client only - could be cookie setting
permissions?: string[];
filePreferences?: UserFilePreferences;
leaderboardShowcase?: string; // client only
referral?: { id: number }; // client only
memberInBadState?: boolean;
meta?: UserMeta;
allowAds?: boolean;
banDetails?: ReturnType<typeof getUserBanDetails>;
redBrowsingLevel?: number;
deletedAt?: Date;
// Multi-subscription support per buzzType
subscriptions?: UserSubscriptionsByBuzzType;
// TODO.briant - clean up user session data
/*
remove `emailVerified`, update user account page to make call to get current user data
*/
}
declare module 'next-auth' {
interface User extends ExtendedUser, Omit<DefaultUser, 'id'> {
id: PrismaUser['id'];
}
interface SessionUser extends ExtendedUser, DefaultSession['user'] {}
/**
* Returned by `useSession`, `getSession` and received as a prop on the `SessionProvider` React Context
*/
interface Session {
user?: ExtendedUser & DefaultSession['user'];
error?: string;
needsCookieRefresh?: boolean;
/** Moderator impersonation (F): the moderator's id when this session is impersonating someone. */
impersonatedBy?: number;
}
}
interface TokenUser {
id: number;
username: string;
isModerator?: boolean;
}
+3 -3
View File
@@ -5,9 +5,9 @@ import type {
} from '~/server/schema/user.schema';
import type { getUserBanDetails } from '~/utils/user-helpers';
// First-party session types (NextAuth cutover phase 5) — replaces the `declare module 'next-auth'` augmentation
// in src/types/next-auth.d.ts. The app imports `Session`/`SessionUser` from here instead of from 'next-auth', so
// the type no longer depends on the next-auth package. `UserFilePreferences` is an ambient global (global.d.ts).
// First-party session types — the app imports `Session`/`SessionUser` from here (not from 'next-auth'), so the
// types don't depend on the next-auth package (now fully removed). `UserFilePreferences` is an ambient global
// (global.d.ts).
/** The rich user attached to a session (was next-auth's augmented `SessionUser`/`ExtendedUser`). */
export interface SessionUser {
+12 -2
View File
@@ -25,7 +25,8 @@
*/
const fs = require('fs');
const path = require('path');
const { encode } = require('next-auth/jwt');
const { EncryptJWT } = require('jose');
const { hkdfSync } = require('node:crypto');
const { v4: uuid } = require('uuid');
const SECRET = process.env.NEXTAUTH_SECRET;
@@ -33,6 +34,11 @@ const BASE_URL = process.env.BASE_URL;
const COOKIE_NAME = '__Secure-civitai-token'; // libs/auth.ts — https => __Secure- prefix
const MAX_AGE_S = 30 * 24 * 60 * 60;
// Mint the legacy next-auth v4 session JWE WITHOUT next-auth (removed); the app still accepts it via
// @civitai/auth's decodeLegacySessionCookie. dir/A256GCM with an HKDF-derived key — mirrors that decoder.
const ENC_INFO = 'NextAuth.js Generated Encryption Key';
const derivedKey = (secret) => new Uint8Array(hkdfSync('sha256', secret, '', ENC_INFO, 32));
// Representative authed routes (kept small so 5 runs x N routes stays inside the
// build pool's time budget). All reachable by ci-smoke-gold. `/` = SSR home,
// `/models` = heavy list/feed, `/generate` = client-heavy generator, and
@@ -63,7 +69,11 @@ async function main() {
if (!BASE_URL) throw new Error('BASE_URL is required (e.g. https://pr-123.civitaic.com)');
const token = { user: GOLD, sub: String(GOLD.id), id: uuid(), signedAt: Date.now() };
const value = await encode({ token, secret: SECRET, maxAge: MAX_AGE_S });
const value = await new EncryptJWT(token)
.setProtectedHeader({ alg: 'dir', enc: 'A256GCM' })
.setIssuedAt()
.setExpirationTime(Math.floor(Date.now() / 1000) + MAX_AGE_S)
.encrypt(derivedKey(SECRET));
const base = JSON.parse(
fs.readFileSync(path.join(process.cwd(), 'lighthouserc.json'), 'utf8')
+13 -2
View File
@@ -1,6 +1,7 @@
import { test as setup } from '@playwright/test';
import fs from 'fs';
import { encode } from 'next-auth/jwt';
import { EncryptJWT } from 'jose';
import { hkdfSync } from 'node:crypto';
import { v4 as uuid } from 'uuid';
import { PREVIEW_USERS, type PreviewRole, storageStatePath } from './preview-fixtures';
@@ -39,6 +40,12 @@ const PREVIEW_URL = process.env.PREVIEW_URL;
const COOKIE_NAME = '__Secure-civitai-token'; // libs/auth.ts — https preview => __Secure- prefix
const MAX_AGE_S = 30 * 24 * 60 * 60;
// Mint the LEGACY next-auth v4 session cookie (a `dir`/`A256GCM` JWE, HKDF-derived key) WITHOUT next-auth, which
// is now removed. The app still ACCEPTS it via @civitai/auth's decodeLegacySessionCookie during the cutover, so
// this mirrors that decoder's key derivation exactly.
const ENC_INFO = 'NextAuth.js Generated Encryption Key';
const derivedKey = (secret: string) => new Uint8Array(hkdfSync('sha256', secret, '', ENC_INFO, 32));
async function mintStorageState(role: PreviewRole): Promise<string> {
const u = PREVIEW_USERS[role];
@@ -59,7 +66,11 @@ async function mintStorageState(role: PreviewRole): Promise<string> {
};
const token = { user, sub: String(u.id), id: uuid(), signedAt: Date.now() };
const value = await encode({ token, secret: SECRET as string, maxAge: MAX_AGE_S });
const value = await new EncryptJWT(token)
.setProtectedHeader({ alg: 'dir', enc: 'A256GCM' })
.setIssuedAt()
.setExpirationTime(Math.floor(Date.now() / 1000) + MAX_AGE_S)
.encrypt(derivedKey(SECRET as string));
const { hostname } = new URL(PREVIEW_URL as string);
const storageState = {