Skip to content
Merged
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
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -234,10 +234,10 @@ jobs:
JWT_REFRESH_EXPIRATION: 7d
GOOGLE_CLIENT_ID: ci-test-google-client-id
GOOGLE_CLIENT_SECRET: ci-test-google-client-secret
GOOGLE_CALLBACK_URL: http://localhost:3001/api/auth/google/callback
GOOGLE_CALLBACK_URL: http://localhost:3001/api/v1/auth/google/callback
GITHUB_CLIENT_ID: ci-test-github-client-id
GITHUB_CLIENT_SECRET: ci-test-github-client-secret
GITHUB_CALLBACK_URL: http://localhost:3001/api/auth/github/callback
GITHUB_CALLBACK_URL: http://localhost:3001/api/v1/auth/github/callback
MAIL_HOST: localhost
MAIL_PORT: '1025'
MAIL_USER: test@smalda.com
Expand Down
12 changes: 7 additions & 5 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,24 @@ JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
JWT_EXPIRATION=15m
JWT_REFRESH_SECRET=your-super-secret-refresh-key-change-this-in-production
JWT_REFRESH_EXPIRATION=7d
# Required when frontend and API use different hostnames; use a shared parent domain.
AUTH_COOKIE_DOMAIN=

# Application Configuration
APP_PORT=6004
APP_URL=http://localhost:6004
FRONTEND_URL=http://localhost:3001
APP_PORT=3001
APP_URL=http://localhost:3001
FRONTEND_URL=http://localhost:3000
LOG_LEVEL=info

# Google OAuth Configuration
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
GOOGLE_CALLBACK_URL=http://localhost:6004/api/auth/google/callback
GOOGLE_CALLBACK_URL=http://localhost:3001/api/v1/auth/google/callback

# GitHub OAuth Configuration
GITHUB_CLIENT_ID=your-github-client-id
GITHUB_CLIENT_SECRET=your-github-client-secret
GITHUB_CALLBACK_URL=http://localhost:6004/api/auth/github/callback
GITHUB_CALLBACK_URL=http://localhost:3001/api/v1/auth/github/callback

# Email Configuration (Nodemailer)
MAIL_HOST=smtp.gmail.com
Expand Down
66 changes: 66 additions & 0 deletions backend/src/auth/auth-cookie.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { ConfigService } from '@nestjs/config';
import {
ACCESS_COOKIE_NAME,
REFRESH_COOKIE_NAME,
clearAuthCookies,
getCookieValue,
setAuthCookies,
} from './auth-cookie';

describe('auth cookies', () => {
it('sets secure HttpOnly cookies for production origins', () => {
const config = {
get: jest.fn((key: string) => {
if (key === 'NODE_ENV') return 'production';
if (key === 'AUTH_COOKIE_DOMAIN') return 'example.com';
if (key === 'JWT_EXPIRATION') return '15m';
if (key === 'JWT_REFRESH_EXPIRATION') return '7d';
return undefined;
}),
} as unknown as ConfigService;
const response = { cookie: jest.fn(), clearCookie: jest.fn() };

setAuthCookies(
response as never,
{ access_token: 'access', refresh_token: 'refresh' },
config,
);

expect(response.cookie).toHaveBeenCalledWith(
ACCESS_COOKIE_NAME,
'access',
expect.objectContaining({
httpOnly: true,
secure: true,
sameSite: 'none',
domain: 'example.com',
path: '/',
maxAge: 900000,
}),
);
expect(response.cookie).toHaveBeenCalledWith(
REFRESH_COOKIE_NAME,
'refresh',
expect.objectContaining({ httpOnly: true, maxAge: 604800000 }),
);
});

it('parses encoded cookie values and clears both cookies', () => {
const config = {
get: jest.fn((key: string) =>
key === 'NODE_ENV' ? 'development' : undefined,
),
} as unknown as ConfigService;
const response = { cookie: jest.fn(), clearCookie: jest.fn() };

expect(
getCookieValue(
'other=value; access_token=access%2Bvalue; refresh_token=refresh',
ACCESS_COOKIE_NAME,
),
).toBe('access+value');

clearAuthCookies(response as never, config);
expect(response.clearCookie).toHaveBeenCalledTimes(2);
});
});
107 changes: 107 additions & 0 deletions backend/src/auth/auth-cookie.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { ConfigService } from '@nestjs/config';
import { Response } from 'express';

export const ACCESS_COOKIE_NAME = 'access_token';
export const REFRESH_COOKIE_NAME = 'refresh_token';

export interface AuthTokens {
access_token: string;
refresh_token: string;
}

interface AuthCookieOptions {
httpOnly: true;
secure: boolean;
sameSite: 'lax' | 'none';
path: string;
domain?: string;
}

function getCookieOptions(configService: ConfigService): AuthCookieOptions {
const nodeEnv = configService.get<string>('NODE_ENV') || 'development';
const secure = nodeEnv === 'production' || nodeEnv === 'staging';
const domain = configService.get<string>('AUTH_COOKIE_DOMAIN')?.trim();

return {
httpOnly: true,
secure,
sameSite: secure ? 'none' : 'lax',
path: '/',
...(domain ? { domain } : {}),
};
}

function durationMilliseconds(
value: string | undefined,
fallbackSeconds: number,
): number {
const normalized = value?.trim();
if (!normalized) return fallbackSeconds * 1000;

if (/^\d+$/.test(normalized)) return Number(normalized) * 1000;

const match = /^(\d+)([smhd])$/.exec(normalized);
if (!match) return fallbackSeconds * 1000;

const amount = Number(match[1]);
const multipliers = { s: 1, m: 60, h: 3600, d: 86400 } as const;
return amount * multipliers[match[2] as keyof typeof multipliers] * 1000;
}

export function setAuthCookies(
response: Response | undefined,
tokens: AuthTokens,
configService: ConfigService,
): void {
if (!response) return;

const options = getCookieOptions(configService);
response.cookie(ACCESS_COOKIE_NAME, tokens.access_token, {
...options,
maxAge: durationMilliseconds(configService.get<string>('JWT_EXPIRATION'), 900),
});
response.cookie(REFRESH_COOKIE_NAME, tokens.refresh_token, {
...options,
maxAge: durationMilliseconds(
configService.get<string>('JWT_REFRESH_EXPIRATION'),
604800,
),
});
}

export function clearAuthCookies(
response: Response | undefined,
configService: ConfigService,
): void {
if (!response) return;

const options = getCookieOptions(configService);
response.clearCookie(ACCESS_COOKIE_NAME, options);
response.clearCookie(REFRESH_COOKIE_NAME, options);
}

export function getCookieValue(
cookieHeader: string | undefined,
name: string,
): string | undefined {
if (!cookieHeader) return undefined;

for (const part of cookieHeader.split(';')) {
const separator = part.indexOf('=');
if (separator < 0) continue;

const key = part.slice(0, separator).trim();
if (key !== name) continue;

const value = part.slice(separator + 1).trim();
if (!value) return undefined;

try {
return decodeURIComponent(value);
} catch {
return value;
}
}

return undefined;
}
Loading
Loading