Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
64 changes: 64 additions & 0 deletions apps/next/app/(dashboard)/_dashboard-shell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
'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 { 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() {
await fetch('/auth/logout', { redirect: 'manual' })
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
32 changes: 28 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,31 @@ 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()
try {
const parts = token.split('.')
const payloadPart = parts[1]
if (parts.length !== 3 || !payloadPart) return true
const payload = JSON.parse(atob(payloadPart)) as { exp?: number }
if (!payload?.exp) return true
return payload.exp * 1000 <= Date.now()
} catch {
return true
}
}
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
4 changes: 2 additions & 2 deletions apps/next/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ 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 })
const { verifyJwtToken } = await import('@/lib/auth/jwt-utils')
const jwtDecoded = await verifyJwtToken({ token, secret: env.JWT_SECRET })
if (jwtDecoded?.typ !== 'access' || !jwtDecoded?.sub || !jwtDecoded?.sid)
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