Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .cursor/rules/frontend/auth.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ paths = [
'''examples/''',
'''docs/''',
'''apps/fastify/scripts/generate-openapi\.ts$''',
'''scripts/run-qa\.mjs$''',
]

regexes = [
Expand Down
2 changes: 2 additions & 0 deletions apps/docu/content/docs/architecture/authentication.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions apps/next/.env.production
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 0 additions & 5 deletions apps/next/app/(dashboard)/(news)/page.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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 ? (
Expand Down
70 changes: 70 additions & 0 deletions apps/next/app/(dashboard)/_dashboard-shell.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<SidebarProvider>
<DashboardSidebar />
<div className="flex min-w-0 flex-1">
<SidebarInset className="flex min-w-0 flex-1 flex-col">
<header className="flex h-14 shrink-0 items-center justify-between gap-3 border-b px-4 md:gap-4 md:px-6">
<div className="flex min-h-11 items-center md:hidden">
<SidebarTrigger className="size-11 shrink-0" />
</div>
<div className="flex min-w-0 flex-1 items-center">
<PageTitle />
</div>
<div className="flex min-h-11 items-center gap-3 md:gap-4">
<ApiHealthBadge />
<AuthBadge />
<Button
variant="ghost"
size="icon"
className="size-11 sm:size-9"
aria-label="Sign out"
type="button"
onClick={handleSignOut}
>
<LogOut />
</Button>
</div>
</header>
<div className="flex min-h-0 flex-1" style={{ height: 'calc(100dvh - 3.5rem)' }}>
<main className="min-w-0 w-0 flex-1 overflow-auto p-4 md:p-6">{children}</main>
<AssistantSidebar />
</div>
</SidebarInset>
</div>
<Toaster richColors position="top-right" />
</SidebarProvider>
)
}
55 changes: 5 additions & 50 deletions apps/next/app/(dashboard)/layout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<SidebarProvider>
<DashboardSidebar />
<div className="flex min-w-0 flex-1">
<SidebarInset className="flex min-w-0 flex-1 flex-col">
<header className="flex h-14 shrink-0 items-center justify-between gap-3 border-b px-4 md:gap-4 md:px-6">
<div className="flex min-h-11 items-center md:hidden">
<SidebarTrigger className="size-11 shrink-0" />
</div>
<div className="flex min-w-0 flex-1 items-center">
<PageTitle />
</div>
<div className="flex min-h-11 items-center gap-3 md:gap-4">
<ApiHealthBadge />
<AuthBadge />
<Button
variant="ghost"
size="icon"
className="size-11 sm:size-9"
aria-label="Sign out"
asChild
>
<Link href="/auth/logout" prefetch={false}>
<LogOut />
</Link>
</Button>
</div>
</header>
<div className="flex min-h-0 flex-1" style={{ height: 'calc(100dvh - 3.5rem)' }}>
<main className="min-w-0 w-0 flex-1 overflow-auto p-4 md:p-6">{children}</main>
<AssistantSidebar />
</div>
</SidebarInset>
</div>
<Toaster richColors position="top-right" />
</SidebarProvider>
)
export default async function Layout({
children,
}: Readonly<{ children: React.ReactNode }>): Promise<React.JSX.Element> {
return <DashboardShell>{children}</DashboardShell>
}
5 changes: 0 additions & 5 deletions apps/next/app/(dashboard)/markets/page.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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 (
Expand Down
6 changes: 0 additions & 6 deletions apps/next/app/auth/login/page.tsx
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -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)

Expand Down
4 changes: 2 additions & 2 deletions apps/next/lib/auth/auth-utils.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -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 }

Expand Down
25 changes: 21 additions & 4 deletions apps/next/lib/auth/jwt-utils.ts
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -12,8 +13,24 @@ export function decodeJwtToken({ token }: { token: string }): JwtPayload | null
}
}

export async function verifyJwtToken({
token,
secret,
}: {
token: string
secret: string
}): Promise<JwtPayload | null> {
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()
}
11 changes: 11 additions & 0 deletions apps/next/lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions apps/next/lib/query-keys.ts
Original file line number Diff line number Diff line change
@@ -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
13 changes: 8 additions & 5 deletions apps/next/proxy.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -22,12 +22,15 @@ async function checkAuthStatus(request: NextRequest): Promise<AuthCheckResult> {
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 }

Expand Down
10 changes: 10 additions & 0 deletions apps/next/scripts/run-e2e-local.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 5 additions & 1 deletion scripts/run-qa.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'] },
{
Expand All @@ -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'] },
]
Expand Down
1 change: 1 addition & 0 deletions turbo.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading