Skip to content

Commit 90e0648

Browse files
authored
Merge pull request #399 from Dydex/feat/csrf-token-generation
Feat/csrf token generation
2 parents 655b900 + d0f6d62 commit 90e0648

13 files changed

Lines changed: 2073 additions & 448 deletions

File tree

app/api/auth/csrf/route.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { NextResponse } from 'next/server';
2+
import { NextRequest } from 'next/server';
3+
import { generateCsrfToken, CSRF_COOKIE_NAME } from '@/lib/utils/csrf';
4+
5+
/**
6+
* GET /api/auth/csrf
7+
*
8+
* Bootstrap endpoint for the CSRF double-submit cookie pattern.
9+
*
10+
* Call this once before the React app hydrates (e.g. from the root layout
11+
* server component) to guarantee the `csrf_token` cookie is present.
12+
* Subsequent calls are cheap: if a valid token is already in the request
13+
* cookies the same value is echoed back so the existing cookie is refreshed
14+
* rather than rotated unnecessarily.
15+
*
16+
* The token IS rotated on every new session (login sets a fresh one via
17+
* POST /api/auth/session) and on every token refresh (POST /api/auth/refresh).
18+
*
19+
* Cookie attributes:
20+
* - SameSite=Strict — never sent on cross-site navigations
21+
* - Secure — HTTPS-only in production
22+
* - non-HttpOnly — JS must read the value for the double-submit header
23+
* - Max-Age=86400 — 24 h; rotated on login/refresh
24+
*/
25+
export async function GET(req: NextRequest) {
26+
const isProduction = process.env.NODE_ENV === 'production';
27+
28+
// Re-use an existing valid token so we don't invalidate in-flight requests.
29+
const existing = req.cookies.get(CSRF_COOKIE_NAME)?.value;
30+
const token = existing && existing.length === 64 ? existing : generateCsrfToken();
31+
32+
const cookieParts = [
33+
`${CSRF_COOKIE_NAME}=${token}`,
34+
'Path=/',
35+
'SameSite=Strict',
36+
'Max-Age=86400',
37+
...(isProduction ? ['Secure'] : []),
38+
];
39+
40+
const res = NextResponse.json(
41+
{ ok: true },
42+
{
43+
headers: {
44+
// Prevent the browser / CDN from caching the CSRF response.
45+
'Cache-Control': 'no-store, no-cache, must-revalidate',
46+
Pragma: 'no-cache',
47+
'Set-Cookie': cookieParts.join('; '),
48+
},
49+
}
50+
);
51+
52+
return res;
53+
}

app/api/auth/refresh/route.ts

Lines changed: 30 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,30 @@
1-
import { NextResponse } from 'next/server';
2-
import { generateCsrfToken } from '@/lib/utils/csrf';
3-
4-
export async function POST() {
5-
try {
6-
// In production, the backend would validate the existing auth_token cookie,
7-
// issue a new token, and return it. For now, we simulate a refresh by
8-
// re-setting the existing cookie with a fresh expiry.
9-
//
10-
// The real implementation should:
11-
// 1. Read and validate the existing auth_token
12-
// 2. Generate a new token with extended expiry
13-
// 3. Return the new token / set a new cookie
14-
15-
const res = NextResponse.json({ ok: true });
16-
17-
// Refresh CSRF token as well
18-
const csrfToken = generateCsrfToken();
19-
res.headers.set(
20-
'Set-Cookie',
21-
`csrf_token=${csrfToken}; Path=/; SameSite=Strict; Max-Age=86400`
22-
);
23-
24-
return res;
25-
} catch (error) {
26-
console.error('Failed to refresh session:', error);
27-
return NextResponse.json(
28-
{ ok: false, error: 'Failed to refresh session' },
29-
{ status: 401 }
30-
);
31-
}
32-
}
1+
import { NextResponse } from 'next/server';
2+
import { generateCsrfToken, buildCsrfCookieHeader } from '@/lib/utils/csrf';
3+
4+
export async function POST() {
5+
try {
6+
// In production, the backend would validate the existing auth_token cookie,
7+
// issue a new token, and return it. For now, we simulate a refresh by
8+
// re-setting the existing cookie with a fresh expiry.
9+
//
10+
// The real implementation should:
11+
// 1. Read and validate the existing auth_token
12+
// 2. Call the backend refresh endpoint
13+
// 3. Set the new auth_token cookie with the returned value
14+
15+
// Rotate the CSRF token on every access-token refresh so a stolen CSRF
16+
// token from a previous session cannot be replayed after re-auth.
17+
const csrfToken = generateCsrfToken();
18+
19+
const res = NextResponse.json({ ok: true });
20+
res.headers.set('Set-Cookie', buildCsrfCookieHeader(csrfToken));
21+
22+
return res;
23+
} catch (error) {
24+
console.error('Failed to refresh session:', error);
25+
return NextResponse.json(
26+
{ ok: false, error: 'Failed to refresh session' },
27+
{ status: 401 }
28+
);
29+
}
30+
}

app/api/auth/session/route.ts

Lines changed: 79 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,61 +1,79 @@
1-
import { NextResponse, NextRequest } from 'next/server';
2-
import { generateCsrfToken } from '@/lib/utils/csrf';
3-
4-
export async function GET(req: NextRequest) {
5-
const token = req.cookies.get('auth_token')?.value;
6-
const role = req.cookies.get('user_role')?.value;
7-
8-
if (!token) {
9-
return NextResponse.json({ error: 'Session expired' }, { status: 401 });
10-
}
11-
12-
// In production, validate the token and fetch real user data.
13-
// For mock/preview mode, return a session based on the cookie values.
14-
return NextResponse.json({
15-
user: {
16-
id: role === 'admin' ? 'admin-1' : 'GCCHHKNI7GRA5QWC7RCTT3OHO7SKAUMKQA6IBWEQEO2SXI3GF376UHDD',
17-
email: role === 'admin' ? 'admin@bettapay.com' : 'merchant@bettapay.com',
18-
name: role === 'admin' ? 'System Admin' : 'Merchant User',
19-
role: role || 'merchant',
20-
},
21-
token,
22-
});
23-
}
24-
25-
export async function POST(req: Request) {
26-
try {
27-
const body = await req.json();
28-
const token = body.token;
29-
const role = body.role || '';
30-
31-
const res = NextResponse.json({ ok: true });
32-
33-
// Determine environment for Secure flag
34-
const isProduction = process.env.NODE_ENV === 'production';
35-
const secureFlag = isProduction ? '; Secure' : '';
36-
37-
// Set HttpOnly cookie for auth token
38-
res.headers.set('Set-Cookie', `auth_token=${token}; HttpOnly; Path=/; SameSite=Lax${secureFlag}`);
39-
// Also set a non-HttpOnly role cookie so middleware/server-side can read role where needed
40-
res.headers.append('Set-Cookie', `user_role=${role}; Path=/; SameSite=Lax${secureFlag}`);
41-
// Set CSRF token cookie (non-HttpOnly so the client JS can read it for double-submit)
42-
const csrfToken = generateCsrfToken();
43-
res.headers.append('Set-Cookie', `csrf_token=${csrfToken}; Path=/; SameSite=Strict; Max-Age=86400${secureFlag}`);
44-
45-
return res;
46-
} catch (error) {
47-
console.error('Failed to set session:', error);
48-
return NextResponse.json({ ok: false, error: 'Failed to set session' }, { status: 500 });
49-
}
50-
}
51-
52-
export async function DELETE() {
53-
const res = NextResponse.json({ ok: true });
54-
const isProduction = process.env.NODE_ENV === 'production';
55-
const secureFlag = isProduction ? '; Secure' : '';
56-
// Clear cookies
57-
res.headers.set('Set-Cookie', `auth_token=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax${secureFlag}`);
58-
res.headers.append('Set-Cookie', `user_role=; Path=/; Max-Age=0; SameSite=Lax${secureFlag}`);
59-
res.headers.append('Set-Cookie', `csrf_token=; Path=/; Max-Age=0; SameSite=Strict${secureFlag}`);
60-
return res;
61-
}
1+
import { NextResponse, NextRequest } from 'next/server';
2+
import { generateCsrfToken, buildCsrfCookieHeader } from '@/lib/utils/csrf';
3+
4+
export async function GET(req: NextRequest) {
5+
const token = req.cookies.get('auth_token')?.value;
6+
const role = req.cookies.get('user_role')?.value;
7+
8+
if (!token) {
9+
return NextResponse.json({ error: 'Session expired' }, { status: 401 });
10+
}
11+
12+
// In production, validate the token and fetch real user data.
13+
// For mock/preview mode, return a session based on the cookie values.
14+
return NextResponse.json({
15+
user: {
16+
id: role === 'admin' ? 'admin-1' : 'GCCHHKNI7GRA5QWC7RCTT3OHO7SKAUMKQA6IBWEQEO2SXI3GF376UHDD',
17+
email: role === 'admin' ? 'admin@bettapay.com' : 'merchant@bettapay.com',
18+
name: role === 'admin' ? 'System Admin' : 'Merchant User',
19+
role: role || 'merchant',
20+
},
21+
token,
22+
});
23+
}
24+
25+
export async function POST(req: Request) {
26+
try {
27+
const body = await req.json();
28+
const token = body.token;
29+
const role = body.role || '';
30+
31+
const isProduction = process.env.NODE_ENV === 'production';
32+
const secureFlag = isProduction ? '; Secure' : '';
33+
34+
// Rotate the CSRF token on every login — this is the primary token rotation
35+
// point. A fresh token is tied to the new authenticated session.
36+
const csrfToken = generateCsrfToken();
37+
38+
const res = NextResponse.json({ ok: true });
39+
40+
// auth_token: HttpOnly so JS cannot read it (XSS protection)
41+
res.headers.set(
42+
'Set-Cookie',
43+
`auth_token=${token}; HttpOnly; Path=/; SameSite=Lax; Max-Age=86400${secureFlag}`
44+
);
45+
// user_role: non-HttpOnly so middleware / server-side can read it
46+
res.headers.append(
47+
'Set-Cookie',
48+
`user_role=${role}; Path=/; SameSite=Lax; Max-Age=86400${secureFlag}`
49+
);
50+
// csrf_token: non-HttpOnly (JS must read it), SameSite=Strict
51+
res.headers.append('Set-Cookie', buildCsrfCookieHeader(csrfToken));
52+
53+
return res;
54+
} catch (error) {
55+
console.error('Failed to set session:', error);
56+
return NextResponse.json({ ok: false, error: 'Failed to set session' }, { status: 500 });
57+
}
58+
}
59+
60+
export async function DELETE() {
61+
const isProduction = process.env.NODE_ENV === 'production';
62+
const secureFlag = isProduction ? '; Secure' : '';
63+
64+
const res = NextResponse.json({ ok: true });
65+
// Expire all three cookies atomically on logout
66+
res.headers.set(
67+
'Set-Cookie',
68+
`auth_token=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax${secureFlag}`
69+
);
70+
res.headers.append(
71+
'Set-Cookie',
72+
`user_role=; Path=/; Max-Age=0; SameSite=Lax${secureFlag}`
73+
);
74+
res.headers.append(
75+
'Set-Cookie',
76+
`csrf_token=; Path=/; Max-Age=0; SameSite=Strict${secureFlag}`
77+
);
78+
return res;
79+
}

app/layout.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import "./globals.css";
55
import { cn } from "@/lib/utils";
66
import { GoogleOAuthProvider } from '@react-oauth/google';
77
import { I18nProvider } from '@/components/i18n/I18nProvider';
8+
import { ensureCsrfCookie } from '@/lib/utils/csrf';
89

910

1011
export const metadata: Metadata = {
@@ -15,13 +16,17 @@ export const metadata: Metadata = {
1516
};
1617

1718

18-
export default function RootLayout({
19+
export default async function RootLayout({
1920
children,
2021
}: Readonly<{
2122
children: React.ReactNode;
22-
2323
}>) {
24-
const googleClientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID;
24+
// Seed the CSRF cookie before the page HTML is streamed to the client.
25+
// ensureCsrfCookie() is a no-op when a valid token is already present,
26+
// so this adds no overhead on subsequent requests.
27+
await ensureCsrfCookie();
28+
29+
const googleClientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID;
2530
// Pass the clientId straight to GoogleOAuthProvider only when configured;
2631
// otherwise pass an empty placeholder so the provider target render does
2732
// not blow up if a GoogleLogin button somehow ends up rendered. The login

0 commit comments

Comments
 (0)