diff --git a/apps/api/src/security/sessionCookies.test.ts b/apps/api/src/security/sessionCookies.test.ts new file mode 100644 index 0000000..3c0c351 --- /dev/null +++ b/apps/api/src/security/sessionCookies.test.ts @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { clearSessionCookie, setSessionCookie } from './sessionCookies' + +const originalEnv = { ...process.env } + +function mockResponse() { + return { + cookie: vi.fn(), + clearCookie: vi.fn(), + } +} + +afterEach(() => { + process.env = { ...originalEnv } +}) + +describe('session cookies', () => { + it('sets employee session cookies for the configured parent domain', () => { + process.env.NODE_ENV = 'production' + process.env.SESSION_COOKIE_DOMAIN = '.rentaldrivego.ma' + const res = mockResponse() + + setSessionCookie(res as never, 'employee', 'token-123', 1000) + + expect(res.cookie).toHaveBeenCalledWith('employee_session', 'token-123', { + httpOnly: true, + secure: true, + sameSite: 'lax', + path: '/', + domain: '.rentaldrivego.ma', + maxAge: 1000, + }) + }) + + it('clears cookies with the same domain attributes used when setting them', () => { + process.env.NODE_ENV = 'production' + process.env.SESSION_COOKIE_DOMAIN = '.rentaldrivego.ma' + const res = mockResponse() + + clearSessionCookie(res as never, 'employee') + + expect(res.clearCookie).toHaveBeenCalledWith('employee_session', { + httpOnly: true, + secure: true, + sameSite: 'lax', + path: '/', + domain: '.rentaldrivego.ma', + }) + }) + + it('omits the domain option when no shared cookie domain is configured', () => { + delete process.env.SESSION_COOKIE_DOMAIN + const res = mockResponse() + + setSessionCookie(res as never, 'employee', 'token-123') + + expect(res.cookie).toHaveBeenCalledWith('employee_session', 'token-123', { + httpOnly: true, + secure: false, + sameSite: 'lax', + path: '/', + }) + }) +}) diff --git a/apps/api/src/security/sessionCookies.ts b/apps/api/src/security/sessionCookies.ts index 71f4038..0cbb77f 100644 --- a/apps/api/src/security/sessionCookies.ts +++ b/apps/api/src/security/sessionCookies.ts @@ -1,4 +1,4 @@ -import type { Response } from 'express' +import type { CookieOptions, Response } from 'express' import type { ActorType } from './tokens' const COOKIE_NAMES: Record = { @@ -11,21 +11,23 @@ export function getSessionCookieName(actorType: ActorType) { return COOKIE_NAMES[actorType] } -export function setSessionCookie(res: Response, actorType: ActorType, token: string, maxAgeMs?: number) { - res.cookie(getSessionCookieName(actorType), token, { +function getSessionCookieOptions(actorType: ActorType, maxAgeMs?: number): CookieOptions { + const domain = process.env.SESSION_COOKIE_DOMAIN?.trim() + + return { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: actorType === 'admin' ? 'strict' : 'lax', path: '/', - maxAge: maxAgeMs, - }) + ...(domain ? { domain } : {}), + ...(maxAgeMs !== undefined ? { maxAge: maxAgeMs } : {}), + } +} + +export function setSessionCookie(res: Response, actorType: ActorType, token: string, maxAgeMs?: number) { + res.cookie(getSessionCookieName(actorType), token, getSessionCookieOptions(actorType, maxAgeMs)) } export function clearSessionCookie(res: Response, actorType: ActorType) { - res.clearCookie(getSessionCookieName(actorType), { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: actorType === 'admin' ? 'strict' : 'lax', - path: '/', - }) + res.clearCookie(getSessionCookieName(actorType), getSessionCookieOptions(actorType)) } diff --git a/apps/dashboard/src/lib/api.test.ts b/apps/dashboard/src/lib/api.test.ts index 6a10d38..cc972f2 100644 --- a/apps/dashboard/src/lib/api.test.ts +++ b/apps/dashboard/src/lib/api.test.ts @@ -39,7 +39,7 @@ describe('dashboard apiFetch', () => { })) }) - it('keeps browser requests on the dashboard proxy even when a public API origin is configured', async () => { + it('normalizes a public API origin to the versioned API base', async () => { installBrowser() process.env.NEXT_PUBLIC_API_URL = 'https://api.rentaldrivego.ma' const fetchMock = vi.fn(async () => ({ @@ -51,7 +51,7 @@ describe('dashboard apiFetch', () => { const { apiFetch } = await import('./api') await apiFetch('/auth/account/start', { method: 'POST', body: JSON.stringify({}) }) - expect(fetchMock).toHaveBeenCalledWith('/dashboard/api/v1/auth/account/start', expect.any(Object)) + expect(fetchMock).toHaveBeenCalledWith('https://api.rentaldrivego.ma/api/v1/auth/account/start', expect.any(Object)) }) it('does not force JSON content type for FormData payloads', async () => { diff --git a/apps/dashboard/src/lib/api.ts b/apps/dashboard/src/lib/api.ts index 3e533f7..e0aeda8 100644 --- a/apps/dashboard/src/lib/api.ts +++ b/apps/dashboard/src/lib/api.ts @@ -6,7 +6,7 @@ function normalizeApiBase(value: string): string { export const API_BASE = normalizeApiBase( typeof window === 'undefined' ? (process.env.API_INTERNAL_URL || 'http://localhost:4000/api/v1') - : '/dashboard/api/v1', + : (process.env.NEXT_PUBLIC_API_URL || '/dashboard/api/v1'), ) export const EMPLOYEE_PROFILE_KEY = 'employee_profile'