Skip to content

Commit b557498

Browse files
committed
fix(auth): resolve OAuth cookie handling for Vercel deployment
- Update CORS config to allow requests with no origin (Vercel rewrites) - Add dynamic cookie options with proper SameSite/Secure flags - Improve JWT cookie extractor with debug logging - Simplify frontend auth callback cookie setting - Add CookieOptions type for lint compliance The OAuth flow now properly handles cross-domain authentication when backend and frontend are on different Vercel deployments.
1 parent 7819d0c commit b557498

7 files changed

Lines changed: 170 additions & 24490 deletions

File tree

backend/api/index.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,30 @@ async function bootstrap(): Promise<INestApplication> {
1515

1616
app.use(cookieParser());
1717

18+
// Configure CORS for both direct requests and Vercel proxy rewrites
19+
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5173';
20+
const allowedOrigins = [
21+
frontendUrl,
22+
'http://localhost:5173',
23+
'http://localhost:5174',
24+
'http://localhost:5175',
25+
];
26+
1827
app.enableCors({
19-
origin: process.env.FRONTEND_URL || 'http://localhost:5173',
28+
origin: (origin, callback) => {
29+
// Allow requests with no origin (Vercel rewrites, mobile apps, curl)
30+
if (!origin) {
31+
callback(null, true);
32+
return;
33+
}
34+
if (allowedOrigins.includes(origin)) {
35+
callback(null, true);
36+
} else {
37+
// In production, log unallowed origins for debugging
38+
console.warn(`CORS blocked origin: ${origin}`);
39+
callback(new Error('Not allowed by CORS'));
40+
}
41+
},
2042
credentials: true,
2143
});
2244

backend/src/auth/auth.controller.ts

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -36,17 +36,28 @@ import type {
3636
MessageResult,
3737
SafeUser,
3838
} from './auth.service';
39-
import type { Response, Request } from 'express';
39+
import type { Response, Request, CookieOptions } from 'express';
4040

4141
// Cookie configuration for secure token storage
42-
const COOKIE_OPTIONS = {
43-
httpOnly: true,
44-
secure: process.env.NODE_ENV === 'production',
45-
sameSite: 'lax' as const,
46-
maxAge: 24 * 60 * 60 * 1000,
47-
path: '/',
42+
const getCookieOptions = (isOAuthRedirect = false): CookieOptions => {
43+
const isProduction = process.env.NODE_ENV === 'production';
44+
45+
return {
46+
httpOnly: true,
47+
secure: isProduction,
48+
// For OAuth redirects across domains, we need 'none' with secure
49+
// For same-origin requests, 'lax' is preferred
50+
sameSite:
51+
isProduction && isOAuthRedirect ? ('none' as const) : ('lax' as const),
52+
maxAge: 24 * 60 * 60 * 1000, // 24 hours
53+
path: '/',
54+
// In production, don't set domain to allow cookie to work with Vercel proxy
55+
...(isProduction && { domain: undefined }),
56+
};
4857
};
4958

59+
const COOKIE_OPTIONS = getCookieOptions(false);
60+
5061
@Controller('auth')
5162
export class AuthController {
5263
constructor(
@@ -120,13 +131,19 @@ export class AuthController {
120131
);
121132
const frontendUrl = this.configService.get<string>('FRONTEND_URL');
122133

123-
// For cross-domain OAuth (backend and frontend on different domains),
124-
// we pass the token in URL. The frontend callback page will extract it
125-
// and store it securely, then clear from URL.
126-
// This is safe because:
127-
// 1. Token is short-lived
128-
// 2. Redirect happens immediately after Google callback
129-
// 3. Frontend immediately clears token from URL and stores in cookie
134+
// Set HTTP-only cookie as backup (useful if callback URL is proxied through frontend)
135+
const oauthCookieOptions = getCookieOptions(true);
136+
res.cookie('access_token', result.access_token, oauthCookieOptions);
137+
138+
// Also pass token in URL for frontend to set cookie on its domain
139+
// This is necessary because:
140+
// 1. Backend and frontend are on different Vercel deployments (different domains)
141+
// 2. The cookie we set here is on backend domain, not frontend domain
142+
// 3. Frontend sets cookie on its domain so it's sent with /api/* requests
143+
// The token in URL is secure because:
144+
// - It's passed via server redirect, not exposed to client scripts until callback loads
145+
// - Frontend immediately clears it from URL after extraction
146+
// - Token has limited lifetime
130147
res.redirect(`${frontendUrl}/auth/callback?token=${result.access_token}`);
131148
}
132149

@@ -148,9 +165,11 @@ export class AuthController {
148165
);
149166
const frontendUrl = this.configService.get<string>('FRONTEND_URL');
150167

151-
// For cross-domain OAuth (backend and frontend on different domains),
152-
// we pass the token in URL. The frontend callback page will extract it
153-
// and store it securely, then clear from URL.
168+
// Set HTTP-only cookie as backup (useful if callback URL is proxied through frontend)
169+
const oauthCookieOptions = getCookieOptions(true);
170+
res.cookie('access_token', result.access_token, oauthCookieOptions);
171+
172+
// Also pass token in URL for frontend to set cookie on its domain
154173
res.redirect(`${frontendUrl}/auth/callback?token=${result.access_token}`);
155174
}
156175

backend/src/auth/strategies/jwt.strategy.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Injectable } from '@nestjs/common';
1+
import { Injectable, Logger } from '@nestjs/common';
22
import { ConfigService } from '@nestjs/config';
33
import { PassportStrategy } from '@nestjs/passport';
44

@@ -13,16 +13,29 @@ interface JwtPayload {
1313

1414
// Custom extractor that tries cookie first, then falls back to Bearer token
1515
const cookieExtractor = (req: Request): string | null => {
16+
const logger = new Logger('JwtCookieExtractor');
17+
1618
// First try to get token from HTTP-only cookie
1719
const cookies = req.cookies as Record<string, string> | undefined;
1820
if (cookies?.access_token) {
21+
logger.debug('Token found in cookie');
1922
return cookies.access_token;
2023
}
24+
2125
// Fallback to Authorization header (for API clients, testing, etc.)
2226
const authHeader = req.headers.authorization;
2327
if (authHeader?.startsWith('Bearer ')) {
28+
logger.debug('Token found in Authorization header');
2429
return authHeader.substring(7);
2530
}
31+
32+
// Log cookie header for debugging (only in development)
33+
if (process.env.NODE_ENV !== 'production') {
34+
logger.debug(`Cookie header: ${req.headers.cookie ?? 'none'}`);
35+
logger.debug(`Cookies parsed: ${JSON.stringify(cookies ?? {})}`);
36+
}
37+
38+
logger.debug('No token found in cookie or Authorization header');
2639
return null;
2740
};
2841

frontend/src/pages/auth/auth-callback-page.tsx

Lines changed: 27 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,26 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
55

66
import { useAuth } from '@/contexts/use-auth';
77

8-
// Cookie utility for setting the auth token
9-
function setAuthCookie(token: string) {
10-
const isProduction = window.location.hostname !== 'localhost';
11-
const cookieOptions = [
12-
`access_token=${token}`,
13-
'path=/',
14-
`max-age=${24 * 60 * 60}`, // 24 hours
15-
isProduction ? 'secure' : '',
16-
'samesite=lax',
17-
]
18-
.filter(Boolean)
19-
.join('; ');
20-
document.cookie = cookieOptions;
8+
/**
9+
* Set auth cookie with proper SameSite and Secure flags.
10+
* This cookie will be sent with requests to /api/* which are proxied to backend.
11+
*/
12+
function setAuthCookie(token: string): void {
13+
const isSecure = window.location.protocol === 'https:';
14+
const maxAge = 24 * 60 * 60; // 24 hours in seconds
15+
16+
// Build cookie string with proper attributes
17+
// - path=/: Available for all paths
18+
// - max-age: 24 hours in seconds
19+
// - SameSite=Lax: Allows cookie on top-level navigations (OAuth redirects)
20+
// - Secure: Only sent over HTTPS (required in production)
21+
let cookieString = `access_token=${token}; path=/; max-age=${maxAge}; SameSite=Lax`;
22+
23+
if (isSecure) {
24+
cookieString += '; Secure';
25+
}
26+
27+
document.cookie = cookieString;
2128
}
2229

2330
export function AuthCallbackPage() {
@@ -29,31 +36,32 @@ export function AuthCallbackPage() {
2936
useEffect(() => {
3037
const handleCallback = async () => {
3138
try {
32-
// Check if token is in URL (cross-domain OAuth flow)
39+
// Check if token is in URL (OAuth callback from backend)
3340
const token = searchParams.get('token');
3441

3542
if (token) {
3643
// Set token as cookie on frontend domain
44+
// This cookie will be forwarded by Vercel rewrites to backend
3745
setAuthCookie(token);
3846

3947
// Clear token from URL for security (without triggering navigation)
4048
window.history.replaceState({}, '', '/auth/callback');
4149
}
4250

43-
// Refresh user state from server (which will use the cookie)
51+
// Small delay to ensure cookie is set before making API call
52+
await new Promise((resolve) => setTimeout(resolve, 100));
53+
54+
// Refresh user state from server (cookie is sent automatically via withCredentials)
4455
const user = await refreshUser();
4556

46-
// If user doesn't have a role, redirect to select-role
57+
// Redirect based on user role status
4758
if (!user?.role) {
48-
// Force full page reload to ensure clean state
4959
window.location.href = '/select-role';
5060
} else {
51-
// Force full page reload to ensure clean state
5261
window.location.href = '/dashboard';
5362
}
5463
} catch {
5564
setError('Authentication failed. Please try again.');
56-
// Redirect to login after a short delay
5765
setTimeout(() => {
5866
void navigate('/login');
5967
}, 2000);

0 commit comments

Comments
 (0)