diff --git a/.cursor/rules/frontend/auth.mdc b/.cursor/rules/frontend/auth.mdc index 9b21c58f..214cb01e 100644 --- a/.cursor/rules/frontend/auth.mdc +++ b/.cursor/rules/frontend/auth.mdc @@ -25,3 +25,9 @@ See @apps/docu/content/docs/architecture/authentication.mdx for full architectur - Core calls Fastify `POST /auth/session/refresh` directly on 401 (no BFF proxy) - `onTokensRefreshed` → `updateAuthTokens` → `POST /api/auth/update-tokens` persists new tokens in cookie - Proxy (`proxy.ts`) refreshes on navigation; core refreshes on client-side 401 (e.g. `useUser`) + +## Auth Protection (proxy.ts) + +- Proxy (`proxy.ts`) is the single source of truth for route-level auth: unauthenticated users → `/auth/login`; authenticated on `/auth/login` → `/` +- **Never** add `getAuthStatus()` + `redirect()` in layouts or pages for auth gating—proxy already enforces this +- Use `getAuthStatus()` or `getUserInfo()` only when you need user/session data for rendering (e.g. shell, profile), not for redirect logic diff --git a/.gitleaks.toml b/.gitleaks.toml index 1f6b3b2d..262cf651 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -28,6 +28,7 @@ paths = [ '''examples/''', '''docs/''', '''apps/fastify/scripts/generate-openapi\.ts$''', + '''scripts/run-qa\.mjs$''', ] regexes = [ diff --git a/apps/docu/content/docs/architecture/authentication.mdx b/apps/docu/content/docs/architecture/authentication.mdx index 42f74e8a..b1980118 100644 --- a/apps/docu/content/docs/architecture/authentication.mdx +++ b/apps/docu/content/docs/architecture/authentication.mdx @@ -328,6 +328,8 @@ All endpoints below are served by `apps/fastify`. The API does not set cookies; **Proxy vs coreClient refresh**: The proxy (`proxy.ts`) runs on navigation before React mounts—it refreshes expired tokens so the user isn’t redirected to login. The core client runs on client-side 401 (e.g. `useUser`)—it calls Fastify `POST /auth/session/refresh` directly, then `onTokensRefreshed` POSTs to `/api/auth/update-tokens` to persist new tokens in the cookie. Both are needed. +**Route protection**: Proxy handles all auth redirects. Do not duplicate `getAuthStatus()` + redirect in layouts or pages. + **Cookies**: Single cookie `api.session` (configurable via `AUTH_COOKIE_NAME` / `NEXT_PUBLIC_AUTH_COOKIE_NAME`) stores JSON `{ token, refreshToken }`. Readable on the client (`httpOnly: false`) so `getAuthToken` can read from `document.cookie`. Cookie `maxAge` is derived from refresh JWT `exp`. **Core auth modes**: `createClient` supports three modes—(1) **apiKey**: static Bearer, no refresh; (2) **JWT**: `getAuthToken`, `getRefreshToken`, `onTokensRefreshed` required, refresh on 401; (3) **no-auth**: `baseUrl` only. diff --git a/apps/next/.env.production b/apps/next/.env.production index 2a22d67e..5182aa0b 100644 --- a/apps/next/.env.production +++ b/apps/next/.env.production @@ -5,3 +5,6 @@ # NOTE: This uses a unified/staging URL across environments # CI/CD validation will fail if this matches the placeholder NEXT_PUBLIC_API_URL=https://basilic-fastify.vercel.app + +# JWT_SECRET must be set via env (Vercel/deployment) - not committed. Use same value as Fastify. +# For local prod build: export JWT_SECRET='your-32-char-secret' or add to .env.local diff --git a/apps/next/app/(dashboard)/(news)/page.tsx b/apps/next/app/(dashboard)/(news)/page.tsx index 8ef971c9..7720262d 100644 --- a/apps/next/app/(dashboard)/(news)/page.tsx +++ b/apps/next/app/(dashboard)/(news)/page.tsx @@ -1,6 +1,4 @@ import { getErrorMessage } from '@repo/error/nextjs' -import { getAuthStatus } from 'lib/auth/auth-utils' -import { redirect } from 'next/navigation' import { env } from '@/lib/env' import { NewsList, type NewsListArticle } from './news-list' @@ -26,9 +24,6 @@ async function fetchHeadlines() { } export default async function Home() { - const { authenticated } = await getAuthStatus() - if (!authenticated) redirect('/auth/login') - const { articles, error, hasKey } = await fetchHeadlines() const fallback = !hasKey ? ( diff --git a/apps/next/app/(dashboard)/_dashboard-shell.tsx b/apps/next/app/(dashboard)/_dashboard-shell.tsx new file mode 100644 index 00000000..4de23012 --- /dev/null +++ b/apps/next/app/(dashboard)/_dashboard-shell.tsx @@ -0,0 +1,70 @@ +'use client' + +import { Button } from '@repo/ui/components/button' +import { SidebarInset, SidebarProvider, SidebarTrigger } from '@repo/ui/components/sidebar' +import { Toaster } from '@repo/ui/components/sonner' +import { useQueryClient } from '@tanstack/react-query' +import { AssistantSidebar } from 'components/assistant' +import { ApiHealthBadge } from 'components/shared/api-health-badge' +import { AuthBadge } from 'components/shared/auth-badge' +import { LogOut } from 'lucide-react' +import { toast } from 'sonner' +import { authSessionJwtQueryKey, authSessionUserQueryKey } from '@/lib/query-keys' + +import { PageTitle } from './page-title' +import { DashboardSidebar } from './sidebar' + +export function DashboardShell({ + children, +}: Readonly<{ children: React.ReactNode }>): React.JSX.Element { + const queryClient = useQueryClient() + + async function handleSignOut() { + const response = await fetch('/auth/logout', { redirect: 'manual' }) + const isSuccess = response.status >= 200 && response.status < 400 + if (!isSuccess) { + toast.error('Sign out failed. Please try again.') + return + } + queryClient.invalidateQueries({ queryKey: authSessionUserQueryKey }) + queryClient.invalidateQueries({ queryKey: authSessionJwtQueryKey }) + window.location.href = '/' + } + + return ( + + +
+ +
+
+ +
+
+ +
+
+ + + +
+
+
+
{children}
+ +
+
+
+ +
+ ) +} diff --git a/apps/next/app/(dashboard)/layout.tsx b/apps/next/app/(dashboard)/layout.tsx index a975aa2d..b6aae1d9 100644 --- a/apps/next/app/(dashboard)/layout.tsx +++ b/apps/next/app/(dashboard)/layout.tsx @@ -1,52 +1,7 @@ -import { Button } from '@repo/ui/components/button' +import { DashboardShell } from './_dashboard-shell' -import { SidebarInset, SidebarProvider, SidebarTrigger } from '@repo/ui/components/sidebar' -import { Toaster } from '@repo/ui/components/sonner' -import { AssistantSidebar } from 'components/assistant' -import { ApiHealthBadge } from 'components/shared/api-health-badge' -import { AuthBadge } from 'components/shared/auth-badge' -import { LogOut } from 'lucide-react' -import Link from 'next/link' - -import { PageTitle } from './page-title' -import { DashboardSidebar } from './sidebar' - -export default async function Layout({ children }: Readonly<{ children: React.ReactNode }>) { - return ( - - -
- -
-
- -
-
- -
-
- - - -
-
-
-
{children}
- -
-
-
- -
- ) +export default async function Layout({ + children, +}: Readonly<{ children: React.ReactNode }>): Promise { + return {children} } diff --git a/apps/next/app/(dashboard)/markets/page.tsx b/apps/next/app/(dashboard)/markets/page.tsx index 797304d7..952f684f 100644 --- a/apps/next/app/(dashboard)/markets/page.tsx +++ b/apps/next/app/(dashboard)/markets/page.tsx @@ -1,6 +1,4 @@ import { getErrorMessage } from '@repo/error/nextjs' -import { getAuthStatus } from 'lib/auth/auth-utils' -import { redirect } from 'next/navigation' import type { CoinMarket } from './markets-table' import { MarketsTable } from './markets-table' @@ -19,9 +17,6 @@ async function fetchMarkets() { } export default async function MarketsPage() { - const { authenticated } = await getAuthStatus() - if (!authenticated) redirect('/auth/login') - const { coins, error } = await fetchMarkets() return ( diff --git a/apps/next/app/auth/login/page.tsx b/apps/next/app/auth/login/page.tsx index b4b631c7..f4f7cb07 100644 --- a/apps/next/app/auth/login/page.tsx +++ b/apps/next/app/auth/login/page.tsx @@ -1,10 +1,8 @@ import { ApiHealthBadge } from 'components/shared/api-health-badge' import { AuthBadge } from 'components/shared/auth-badge' import { getAuthErrorMessage } from 'lib/auth/auth-error-messages' -import { getAuthStatus } from 'lib/auth/auth-utils' import { GalleryVerticalEnd } from 'lucide-react' import Image from 'next/image' -import { redirect } from 'next/navigation' import { LoginActions } from './login-actions' type LoginPageProps = { @@ -16,10 +14,6 @@ type LoginPageProps = { export default async function LoginPage({ searchParams }: LoginPageProps) { const params = await searchParams - const { authenticated } = await getAuthStatus() - - if (authenticated) redirect('/') - const errorParam = params.error || params.message const errorMessage = getAuthErrorMessage(errorParam) diff --git a/apps/next/lib/auth/auth-utils.ts b/apps/next/lib/auth/auth-utils.ts index 7d78fa24..f05cccd3 100644 --- a/apps/next/lib/auth/auth-utils.ts +++ b/apps/next/lib/auth/auth-utils.ts @@ -1,7 +1,7 @@ import { z } from 'zod' import { env } from '@/lib/env' import { getServerAuthToken } from './auth-server' -import { decodeJwtToken, isTokenExpired } from './jwt-utils' +import { isTokenExpired, verifyJwtToken } from './jwt-utils' const userResponseSchema = z .object({ @@ -26,7 +26,7 @@ export async function getAuthStatus(): Promise<{ if (!token) return { authenticated: false, userId: null, sessionId: null } - const decoded = decodeJwtToken({ token }) + const decoded = await verifyJwtToken({ token, secret: env.JWT_SECRET }) if (!decoded || decoded.typ !== 'access' || !decoded.sub || !decoded.sid) return { authenticated: false, userId: null, sessionId: null } diff --git a/apps/next/lib/auth/jwt-utils.ts b/apps/next/lib/auth/jwt-utils.ts index d2a235e0..c6d2e14a 100644 --- a/apps/next/lib/auth/jwt-utils.ts +++ b/apps/next/lib/auth/jwt-utils.ts @@ -1,7 +1,8 @@ -import { decodeJwt } from 'jose' +import { decodeJwt, jwtVerify } from 'jose' import type { JwtPayload } from './auth-schemas' import { jwtPayloadSchema } from './auth-schemas' +/** Decode without verification. Use only for non-auth cases (e.g. reading exp for cookie maxAge). */ export function decodeJwtToken({ token }: { token: string }): JwtPayload | null { try { const decoded = decodeJwt(token) @@ -12,8 +13,24 @@ export function decodeJwtToken({ token }: { token: string }): JwtPayload | null } } +export async function verifyJwtToken({ + token, + secret, +}: { + token: string + secret: string +}): Promise { + try { + const { payload } = await jwtVerify(token, new TextEncoder().encode(secret)) + const parsed = jwtPayloadSchema.safeParse(payload) + return parsed.success ? parsed.data : null + } catch { + return null + } +} + export function isTokenExpired({ token }: { token: string }): boolean { - const decoded = decodeJwtToken({ token }) - if (!decoded?.exp) return true - return decoded.exp * 1000 <= Date.now() + const payload = decodeJwtToken({ token }) + if (!payload?.exp) return true + return payload.exp * 1000 <= Date.now() } diff --git a/apps/next/lib/env.ts b/apps/next/lib/env.ts index c31367d0..e3392fe3 100644 --- a/apps/next/lib/env.ts +++ b/apps/next/lib/env.ts @@ -6,6 +6,16 @@ export const env = createEnv({ NODE_ENV: z.enum(['development', 'test', 'production']).default('development'), ALLOW_TEST: z.enum(['true', 'false']).optional(), AUTH_COOKIE_NAME: z.string().default('api.session'), + JWT_SECRET: + process.env.NODE_ENV === 'production' + ? z + .string() + .min(32) + .refine( + val => val !== 'default-jwt-secret-min-32-chars-for-dev', + 'JWT_SECRET must not be the dev default in production', + ) + : z.string().min(32).default('default-jwt-secret-min-32-chars-for-dev'), NEWSAPI_KEY: z.string().optional(), ERROR_REPORTING_DSN: z.string().min(1).optional(), ERROR_REPORTING_ENVIRONMENT: z.string().min(1).optional(), @@ -26,6 +36,7 @@ export const env = createEnv({ NEXT_PUBLIC_NODE_ENV: process.env.NODE_ENV, ALLOW_TEST: process.env.ALLOW_TEST, AUTH_COOKIE_NAME: process.env.AUTH_COOKIE_NAME, + JWT_SECRET: process.env.JWT_SECRET, NEWSAPI_KEY: process.env.NEWSAPI_KEY, NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL, NEXT_PUBLIC_GOOGLE_CLIENT_ID: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID, diff --git a/apps/next/lib/query-keys.ts b/apps/next/lib/query-keys.ts new file mode 100644 index 00000000..19c29e0a --- /dev/null +++ b/apps/next/lib/query-keys.ts @@ -0,0 +1,3 @@ +/** Query keys matching @repo/react auth hooks (useUser, useSession). Used for invalidation only. */ +export const authSessionUserQueryKey = ['auth', 'session', 'user'] as const +export const authSessionJwtQueryKey = ['auth', 'session', 'jwt'] as const diff --git a/apps/next/proxy.ts b/apps/next/proxy.ts index 14ea92b8..811b504f 100644 --- a/apps/next/proxy.ts +++ b/apps/next/proxy.ts @@ -1,7 +1,7 @@ import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import { refreshTokensWithRefreshToken, setAuthCookiesOnResponse } from '@/lib/auth/auth-server' -import { isTokenExpired } from '@/lib/auth/jwt-utils' +import { decodeJwtToken, isTokenExpired, verifyJwtToken } from '@/lib/auth/jwt-utils' import { parseAuthCookie } from '@/lib/auth/parse-auth-cookie' import { env } from '@/lib/env' @@ -22,12 +22,15 @@ async function checkAuthStatus(request: NextRequest): Promise { if (!token) return { status: 'unauthenticated', shouldClearCookies: false } try { - const { decodeJwtToken } = await import('@/lib/auth/jwt-utils') - const jwtDecoded = decodeJwtToken({ token }) - if (jwtDecoded?.typ !== 'access' || !jwtDecoded?.sub || !jwtDecoded?.sid) + const payload = decodeJwtToken({ token }) + if (payload?.typ !== 'access' || !payload?.sub || !payload?.sid) return { status: 'unauthenticated', shouldClearCookies: true } - if (!isTokenExpired({ token })) return { status: 'authenticated', shouldClearCookies: false } + if (!isTokenExpired({ token })) { + const verified = await verifyJwtToken({ token, secret: env.JWT_SECRET }) + if (verified) return { status: 'authenticated', shouldClearCookies: false } + return { status: 'unauthenticated', shouldClearCookies: true } + } if (!refreshToken) return { status: 'unauthenticated', shouldClearCookies: true } diff --git a/apps/next/scripts/run-e2e-local.mjs b/apps/next/scripts/run-e2e-local.mjs index 58d297c0..b729d288 100644 --- a/apps/next/scripts/run-e2e-local.mjs +++ b/apps/next/scripts/run-e2e-local.mjs @@ -62,9 +62,19 @@ async function main() { // eslint-disable-next-line turbo/no-undeclared-env-vars -- set by root test:e2e or user if (!process.env.SKIP_BUILD) { + const loadedForBuild = loadEnvTest() + const buildEnv = { + ...process.env, + ...loadedForBuild, + JWT_SECRET: + loadedForBuild.JWT_SECRET ?? + process.env.JWT_SECRET ?? + 'e2e-jwt-secret-min-32-chars-for-tests', + } const build = spawn('pnpm', ['-F', '@repo/next', 'run', 'build:e2e'], { cwd: repoRoot, stdio: 'inherit', + env: buildEnv, }) const buildCode = await new Promise(r => build.on('exit', c => r(c ?? 1))) if (buildCode !== 0) process.exit(buildCode) diff --git a/scripts/run-qa.mjs b/scripts/run-qa.mjs index d4ef8d86..4d9e2ceb 100644 --- a/scripts/run-qa.mjs +++ b/scripts/run-qa.mjs @@ -11,6 +11,10 @@ import { fileURLToPath } from 'node:url' const scriptDir = dirname(fileURLToPath(import.meta.url)) const repoRoot = dirname(scriptDir) +const qaBuildEnv = process.env.JWT_SECRET + ? undefined + : { JWT_SECRET: 'qa-build-placeholder-min-32-chars-to-pass-validation' } + const phases = [ { name: 'install', cmd: 'pnpm', args: ['i', '--no-frozen-lockfile'] }, { @@ -19,7 +23,7 @@ const phases = [ args: ['exec', 'turbo', 'run', 'checktypes', '--concurrency=100%'], }, { name: 'lint:fix', cmd: 'pnpm', args: ['lint:fix'] }, - { name: 'build', cmd: 'pnpm', args: ['build'] }, + { name: 'build', cmd: 'pnpm', args: ['build'], env: qaBuildEnv }, { name: 'test', cmd: 'pnpm', args: ['exec', 'turbo', 'run', 'test', '--concurrency=100%'] }, { name: 'test:e2e', cmd: 'pnpm', args: ['test:e2e'] }, ] diff --git a/turbo.json b/turbo.json index 87c2c974..7cf06b6e 100644 --- a/turbo.json +++ b/turbo.json @@ -26,6 +26,7 @@ "outputs": [".next/**", "!.next/cache/**", "dist/**", ".react-email/**"], "env": [ "NODE_ENV", + "JWT_SECRET", "AI_PROVIDER", "OLLAMA_BASE_URL", "OPEN_ROUTER_API_KEY",