Skip to content
Open
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
57 changes: 11 additions & 46 deletions nexus/backend/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
import { Response, Request as ExpressRequest } from 'express';
import { AuthenticatedRequest } from '../common/interfaces/request.interface';
import { AuthResponse } from './interfaces/auth-response.interface';
import { setAuthCookies, clearAuthCookies } from './helpers/cookie.helper';
import { AuthService } from './auth.service';
import {
LoginDto,
Expand Down Expand Up @@ -65,7 +66,7 @@ export class AuthController {
);
const authResult = result as AuthResponse;
if (authResult && authResult.accessToken && authResult.refreshToken) {
this.setAuthCookies(res, authResult.accessToken, authResult.refreshToken);
setAuthCookies(res, authResult.accessToken, authResult.refreshToken);
}
return authResult;
}
Expand Down Expand Up @@ -96,7 +97,7 @@ export class AuthController {
);
const authResult = result as AuthResponse;
if (authResult && authResult.accessToken && authResult.refreshToken) {
this.setAuthCookies(res, authResult.accessToken, authResult.refreshToken);
setAuthCookies(res, authResult.accessToken, authResult.refreshToken);
}
return authResult;
}
Expand All @@ -111,7 +112,7 @@ export class AuthController {
const result = await this.authService.googleLogin(googleLoginDto, 'WEB');
const authResult = result as AuthResponse;
if (authResult && authResult.accessToken && authResult.refreshToken) {
this.setAuthCookies(res, authResult.accessToken, authResult.refreshToken);
setAuthCookies(res, authResult.accessToken, authResult.refreshToken);
}
return authResult;
}
Expand Down Expand Up @@ -150,7 +151,7 @@ export class AuthController {
authResult.refreshToken &&
channel === 'WEB'
) {
this.setAuthCookies(res, authResult.accessToken, authResult.refreshToken);
setAuthCookies(res, authResult.accessToken, authResult.refreshToken);
}
return authResult;
}
Expand Down Expand Up @@ -239,7 +240,7 @@ export class AuthController {
registerDto,
)) as AuthResponse;
if (result && result.accessToken && result.refreshToken) {
this.setAuthCookies(res, result.accessToken, result.refreshToken);
setAuthCookies(res, result.accessToken, result.refreshToken);
}
return result;
}
Expand Down Expand Up @@ -322,7 +323,7 @@ export class AuthController {
result.refreshToken &&
channel === 'WEB'
) {
this.setAuthCookies(res, result.accessToken, result.refreshToken);
setAuthCookies(res, result.accessToken, result.refreshToken);
}
return result;
}
Expand All @@ -342,7 +343,7 @@ export class AuthController {
// The channel is embedded in the MFA challenge token and propagated through
// generateAuthResponse back to the result — use it to decide cookie behaviour.
if (result && result.accessToken && result.channel === 'WEB') {
this.setAuthCookies(res, result.accessToken, result.refreshToken);
setAuthCookies(res, result.accessToken, result.refreshToken);
}
return result;
}
Expand All @@ -365,8 +366,7 @@ export class AuthController {
// Instead of maintaining complex blacklist states, we forcibly increment the user's tokenVersion.
// This instantly invalidates ALL active tokens (both AT and RT) for this user across all devices.
await this.authService.logoutAll(req.user.sub);
res.clearCookie('nexus_token');
res.clearCookie('nexus_refresh');
clearAuthCookies(res);
return { success: true, message: 'Session terminated. Token revoked.' };
}

Expand All @@ -380,8 +380,7 @@ export class AuthController {
@Res({ passthrough: true }) res: Response,
) {
await this.authService.logoutAll(req.user.sub);
res.clearCookie('nexus_token');
res.clearCookie('nexus_refresh');
clearAuthCookies(res);
return {
success: true,
message: 'All active sessions have been invalidated.',
Expand All @@ -408,42 +407,8 @@ export class AuthController {

// Auto-update web cookies if it was a web session
if (authResult && authResult.accessToken && authResult.refreshToken) {
this.setAuthCookies(res, authResult.accessToken, authResult.refreshToken);
setAuthCookies(res, authResult.accessToken, authResult.refreshToken);
}
return authResult;
}

private setAuthCookies(res: Response, token: string, refreshToken?: string) {
res.cookie('nexus_token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 24 * 60 * 60 * 1000, // 1 day
path: '/',
});

if (refreshToken) {
res.cookie('nexus_refresh', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
path: '/',
});
}

// FIX-AUTH-08: sameSite changed from 'strict' to 'lax'.
// 'strict' blocked the CSRF cookie on top-level navigations from external origins
// (e.g., clicking a link from an email). 'lax' allows it on top-level GET navigations
// while still blocking cross-site POST/PUT/PATCH/DELETE — which is the correct threat model.
// Non-httpOnly so frontend can read it to send X-CSRF-Token header.
const csrfToken = require('crypto').randomBytes(32).toString('hex');
res.cookie('nexus-csrf', csrfToken, {
httpOnly: false,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 24 * 60 * 60 * 1000,
path: '/',
});
}
}
45 changes: 45 additions & 0 deletions nexus/backend/src/auth/helpers/cookie.helper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { Response } from 'express';
import * as crypto from 'crypto';

export function setAuthCookies(
res: Response,
token: string,
refreshToken?: string,
) {
res.cookie('nexus_token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 24 * 60 * 60 * 1000, // 1 day
path: '/',
});

if (refreshToken) {
res.cookie('nexus_refresh', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
path: '/',
});
}

// FIX-AUTH-08: sameSite changed from 'strict' to 'lax'.
// 'strict' blocked the CSRF cookie on top-level navigations from external origins
// (e.g., clicking a link from an email). 'lax' allows it on top-level GET navigations
// while still blocking cross-site POST/PUT/PATCH/DELETE — which is the correct threat model.
// Non-httpOnly so frontend can read it to send X-CSRF-Token header.
const csrfToken = crypto.randomBytes(32).toString('hex');
res.cookie('nexus-csrf', csrfToken, {
httpOnly: false,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 24 * 60 * 60 * 1000,
path: '/',
});
}

export function clearAuthCookies(res: Response) {
res.clearCookie('nexus_token');
res.clearCookie('nexus_refresh');
}
Loading