Skip to content

Commit 8ea2298

Browse files
authored
feat(2fa): profile updates and 2fa (#128)
* refactor(next): extract dashboard shell and centralize auth in layout * fix(next): auth review - JWT verification, logout cache invalidation, return types - Replace decode-only JWT check with jose verifyJwt (crypto validation) - Add JWT_SECRET to Next env (server, prod-required) - Client-side logout with invalidateQueries for auth/session/user and jwt - Add explicit return types to Layout and DashboardShell - Add query-keys.ts for ESLint compliance, pass JWT_SECRET to e2e build * refactor(next): remove redundant auth checks in layout and login * fix(next): auth sign-out response check, jwt decode reuse, proxy refresh flow - handleSignOut: check response.ok/status before invalidating and redirecting; surface toast on failure - isTokenExpired: use decodeJwtToken instead of manual base64/atob - proxy: decode token without exp first (decodeJwtToken), allow expired tokens to reach refresh; verifyJwtToken only when not refreshing
1 parent a3b11d0 commit 8ea2298

File tree

17 files changed

+148
-78
lines changed

17 files changed

+148
-78
lines changed

.cursor/rules/frontend/auth.mdc

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,9 @@ See @apps/docu/content/docs/architecture/authentication.mdx for full architectur
2525
- Core calls Fastify `POST /auth/session/refresh` directly on 401 (no BFF proxy)
2626
- `onTokensRefreshed` → `updateAuthTokens` → `POST /api/auth/update-tokens` persists new tokens in cookie
2727
- Proxy (`proxy.ts`) refreshes on navigation; core refreshes on client-side 401 (e.g. `useUser`)
28+
29+
## Auth Protection (proxy.ts)
30+
31+
- Proxy (`proxy.ts`) is the single source of truth for route-level auth: unauthenticated users → `/auth/login`; authenticated on `/auth/login` → `/`
32+
- **Never** add `getAuthStatus()` + `redirect()` in layouts or pages for auth gating—proxy already enforces this
33+
- Use `getAuthStatus()` or `getUserInfo()` only when you need user/session data for rendering (e.g. shell, profile), not for redirect logic

.gitleaks.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ paths = [
2828
'''examples/''',
2929
'''docs/''',
3030
'''apps/fastify/scripts/generate-openapi\.ts$''',
31+
'''scripts/run-qa\.mjs$''',
3132
]
3233

3334
regexes = [

apps/docu/content/docs/architecture/authentication.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,8 @@ All endpoints below are served by `apps/fastify`. The API does not set cookies;
328328

329329
**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.
330330

331+
**Route protection**: Proxy handles all auth redirects. Do not duplicate `getAuthStatus()` + redirect in layouts or pages.
332+
331333
**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`.
332334

333335
**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.

apps/next/.env.production

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,6 @@
55
# NOTE: This uses a unified/staging URL across environments
66
# CI/CD validation will fail if this matches the placeholder
77
NEXT_PUBLIC_API_URL=https://basilic-fastify.vercel.app
8+
9+
# JWT_SECRET must be set via env (Vercel/deployment) - not committed. Use same value as Fastify.
10+
# For local prod build: export JWT_SECRET='your-32-char-secret' or add to .env.local

apps/next/app/(dashboard)/(news)/page.tsx

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
11
import { getErrorMessage } from '@repo/error/nextjs'
2-
import { getAuthStatus } from 'lib/auth/auth-utils'
3-
import { redirect } from 'next/navigation'
42
import { env } from '@/lib/env'
53
import { NewsList, type NewsListArticle } from './news-list'
64

@@ -26,9 +24,6 @@ async function fetchHeadlines() {
2624
}
2725

2826
export default async function Home() {
29-
const { authenticated } = await getAuthStatus()
30-
if (!authenticated) redirect('/auth/login')
31-
3227
const { articles, error, hasKey } = await fetchHeadlines()
3328

3429
const fallback = !hasKey ? (
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
'use client'
2+
3+
import { Button } from '@repo/ui/components/button'
4+
import { SidebarInset, SidebarProvider, SidebarTrigger } from '@repo/ui/components/sidebar'
5+
import { Toaster } from '@repo/ui/components/sonner'
6+
import { useQueryClient } from '@tanstack/react-query'
7+
import { AssistantSidebar } from 'components/assistant'
8+
import { ApiHealthBadge } from 'components/shared/api-health-badge'
9+
import { AuthBadge } from 'components/shared/auth-badge'
10+
import { LogOut } from 'lucide-react'
11+
import { toast } from 'sonner'
12+
import { authSessionJwtQueryKey, authSessionUserQueryKey } from '@/lib/query-keys'
13+
14+
import { PageTitle } from './page-title'
15+
import { DashboardSidebar } from './sidebar'
16+
17+
export function DashboardShell({
18+
children,
19+
}: Readonly<{ children: React.ReactNode }>): React.JSX.Element {
20+
const queryClient = useQueryClient()
21+
22+
async function handleSignOut() {
23+
const response = await fetch('/auth/logout', { redirect: 'manual' })
24+
const isSuccess = response.status >= 200 && response.status < 400
25+
if (!isSuccess) {
26+
toast.error('Sign out failed. Please try again.')
27+
return
28+
}
29+
queryClient.invalidateQueries({ queryKey: authSessionUserQueryKey })
30+
queryClient.invalidateQueries({ queryKey: authSessionJwtQueryKey })
31+
window.location.href = '/'
32+
}
33+
34+
return (
35+
<SidebarProvider>
36+
<DashboardSidebar />
37+
<div className="flex min-w-0 flex-1">
38+
<SidebarInset className="flex min-w-0 flex-1 flex-col">
39+
<header className="flex h-14 shrink-0 items-center justify-between gap-3 border-b px-4 md:gap-4 md:px-6">
40+
<div className="flex min-h-11 items-center md:hidden">
41+
<SidebarTrigger className="size-11 shrink-0" />
42+
</div>
43+
<div className="flex min-w-0 flex-1 items-center">
44+
<PageTitle />
45+
</div>
46+
<div className="flex min-h-11 items-center gap-3 md:gap-4">
47+
<ApiHealthBadge />
48+
<AuthBadge />
49+
<Button
50+
variant="ghost"
51+
size="icon"
52+
className="size-11 sm:size-9"
53+
aria-label="Sign out"
54+
type="button"
55+
onClick={handleSignOut}
56+
>
57+
<LogOut />
58+
</Button>
59+
</div>
60+
</header>
61+
<div className="flex min-h-0 flex-1" style={{ height: 'calc(100dvh - 3.5rem)' }}>
62+
<main className="min-w-0 w-0 flex-1 overflow-auto p-4 md:p-6">{children}</main>
63+
<AssistantSidebar />
64+
</div>
65+
</SidebarInset>
66+
</div>
67+
<Toaster richColors position="top-right" />
68+
</SidebarProvider>
69+
)
70+
}
Lines changed: 5 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,7 @@
1-
import { Button } from '@repo/ui/components/button'
1+
import { DashboardShell } from './_dashboard-shell'
22

3-
import { SidebarInset, SidebarProvider, SidebarTrigger } from '@repo/ui/components/sidebar'
4-
import { Toaster } from '@repo/ui/components/sonner'
5-
import { AssistantSidebar } from 'components/assistant'
6-
import { ApiHealthBadge } from 'components/shared/api-health-badge'
7-
import { AuthBadge } from 'components/shared/auth-badge'
8-
import { LogOut } from 'lucide-react'
9-
import Link from 'next/link'
10-
11-
import { PageTitle } from './page-title'
12-
import { DashboardSidebar } from './sidebar'
13-
14-
export default async function Layout({ children }: Readonly<{ children: React.ReactNode }>) {
15-
return (
16-
<SidebarProvider>
17-
<DashboardSidebar />
18-
<div className="flex min-w-0 flex-1">
19-
<SidebarInset className="flex min-w-0 flex-1 flex-col">
20-
<header className="flex h-14 shrink-0 items-center justify-between gap-3 border-b px-4 md:gap-4 md:px-6">
21-
<div className="flex min-h-11 items-center md:hidden">
22-
<SidebarTrigger className="size-11 shrink-0" />
23-
</div>
24-
<div className="flex min-w-0 flex-1 items-center">
25-
<PageTitle />
26-
</div>
27-
<div className="flex min-h-11 items-center gap-3 md:gap-4">
28-
<ApiHealthBadge />
29-
<AuthBadge />
30-
<Button
31-
variant="ghost"
32-
size="icon"
33-
className="size-11 sm:size-9"
34-
aria-label="Sign out"
35-
asChild
36-
>
37-
<Link href="/auth/logout" prefetch={false}>
38-
<LogOut />
39-
</Link>
40-
</Button>
41-
</div>
42-
</header>
43-
<div className="flex min-h-0 flex-1" style={{ height: 'calc(100dvh - 3.5rem)' }}>
44-
<main className="min-w-0 w-0 flex-1 overflow-auto p-4 md:p-6">{children}</main>
45-
<AssistantSidebar />
46-
</div>
47-
</SidebarInset>
48-
</div>
49-
<Toaster richColors position="top-right" />
50-
</SidebarProvider>
51-
)
3+
export default async function Layout({
4+
children,
5+
}: Readonly<{ children: React.ReactNode }>): Promise<React.JSX.Element> {
6+
return <DashboardShell>{children}</DashboardShell>
527
}

apps/next/app/(dashboard)/markets/page.tsx

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
11
import { getErrorMessage } from '@repo/error/nextjs'
2-
import { getAuthStatus } from 'lib/auth/auth-utils'
3-
import { redirect } from 'next/navigation'
42
import type { CoinMarket } from './markets-table'
53
import { MarketsTable } from './markets-table'
64

@@ -19,9 +17,6 @@ async function fetchMarkets() {
1917
}
2018

2119
export default async function MarketsPage() {
22-
const { authenticated } = await getAuthStatus()
23-
if (!authenticated) redirect('/auth/login')
24-
2520
const { coins, error } = await fetchMarkets()
2621

2722
return (

apps/next/app/auth/login/page.tsx

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
import { ApiHealthBadge } from 'components/shared/api-health-badge'
22
import { AuthBadge } from 'components/shared/auth-badge'
33
import { getAuthErrorMessage } from 'lib/auth/auth-error-messages'
4-
import { getAuthStatus } from 'lib/auth/auth-utils'
54
import { GalleryVerticalEnd } from 'lucide-react'
65
import Image from 'next/image'
7-
import { redirect } from 'next/navigation'
86
import { LoginActions } from './login-actions'
97

108
type LoginPageProps = {
@@ -16,10 +14,6 @@ type LoginPageProps = {
1614

1715
export default async function LoginPage({ searchParams }: LoginPageProps) {
1816
const params = await searchParams
19-
const { authenticated } = await getAuthStatus()
20-
21-
if (authenticated) redirect('/')
22-
2317
const errorParam = params.error || params.message
2418
const errorMessage = getAuthErrorMessage(errorParam)
2519

apps/next/lib/auth/auth-utils.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { z } from 'zod'
22
import { env } from '@/lib/env'
33
import { getServerAuthToken } from './auth-server'
4-
import { decodeJwtToken, isTokenExpired } from './jwt-utils'
4+
import { isTokenExpired, verifyJwtToken } from './jwt-utils'
55

66
const userResponseSchema = z
77
.object({
@@ -26,7 +26,7 @@ export async function getAuthStatus(): Promise<{
2626

2727
if (!token) return { authenticated: false, userId: null, sessionId: null }
2828

29-
const decoded = decodeJwtToken({ token })
29+
const decoded = await verifyJwtToken({ token, secret: env.JWT_SECRET })
3030
if (!decoded || decoded.typ !== 'access' || !decoded.sub || !decoded.sid)
3131
return { authenticated: false, userId: null, sessionId: null }
3232

0 commit comments

Comments
 (0)