diff --git a/adapters/http/RequestContext.ts b/adapters/http/RequestContext.ts new file mode 100644 index 000000000..6f336bd53 --- /dev/null +++ b/adapters/http/RequestContext.ts @@ -0,0 +1,26 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; + +import type { NextFunction, Request, Response } from 'express'; + +export type HttpRequestContext = { + req: Request; + res: Response; +}; + +const requestContextStorage = new AsyncLocalStorage(); + +export function tryGetHttpRequestContext(): HttpRequestContext | null { + return requestContextStorage.getStore() ?? null; +} + +export function getHttpRequestContext(): HttpRequestContext { + const ctx = tryGetHttpRequestContext(); + if (!ctx) { + throw new Error('HttpRequestContext is not available (missing requestContextMiddleware)'); + } + return ctx; +} + +export function requestContextMiddleware(req: Request, res: Response, next: NextFunction): void { + requestContextStorage.run({ req, res }, next); +} \ No newline at end of file diff --git a/adapters/identity/session/CookieIdentitySessionAdapter.ts b/adapters/identity/session/CookieIdentitySessionAdapter.ts index d00fd8516..7ee3bf7a7 100644 --- a/adapters/identity/session/CookieIdentitySessionAdapter.ts +++ b/adapters/identity/session/CookieIdentitySessionAdapter.ts @@ -1,46 +1,145 @@ -/** - * Adapter: CookieIdentitySessionAdapter - * - * Manages user session using cookies. This is a placeholder implementation. - */ +import { randomUUID } from 'node:crypto'; import type { AuthenticatedUser } from '@core/identity/application/ports/IdentityProviderPort'; import type { AuthSession, IdentitySessionPort } from '@core/identity/application/ports/IdentitySessionPort'; import type { Logger } from '@core/shared/application'; +import { tryGetHttpRequestContext } from '@adapters/http/RequestContext'; + +const COOKIE_NAME = 'gp_session'; +const SESSION_TTL_MS = 3600 * 1000; + +type StoredSession = AuthSession; + +function readCookieHeader(headerValue: string | undefined): Record { + if (!headerValue) return {}; + const pairs = headerValue.split(';'); + const record: Record = {}; + for (const pair of pairs) { + const trimmed = pair.trim(); + if (!trimmed) continue; + const eq = trimmed.indexOf('='); + if (eq === -1) continue; + const name = trimmed.slice(0, eq).trim(); + const value = trimmed.slice(eq + 1).trim(); + if (!name) continue; + record[name] = decodeURIComponent(value); + } + return record; +} + +function buildSetCookieHeader(options: { + name: string; + value: string; + maxAgeSeconds: number; + httpOnly: boolean; + sameSite: 'Lax' | 'Strict' | 'None'; + secure: boolean; + path?: string; +}): string { + const parts: string[] = []; + parts.push(`${options.name}=${encodeURIComponent(options.value)}`); + parts.push(`Max-Age=${options.maxAgeSeconds}`); + parts.push(`Path=${options.path ?? '/'}`); + if (options.httpOnly) parts.push('HttpOnly'); + parts.push(`SameSite=${options.sameSite}`); + if (options.secure) parts.push('Secure'); + return parts.join('; '); +} + +function appendSetCookieHeader(existing: string | string[] | undefined, next: string): string[] { + if (!existing) return [next]; + if (Array.isArray(existing)) return [...existing, next]; + return [existing, next]; +} export class CookieIdentitySessionAdapter implements IdentitySessionPort { - private currentSession: AuthSession | null = null; + private readonly sessionsByToken = new Map(); constructor(private readonly logger: Logger) { - this.logger.info('CookieIdentitySessionAdapter initialized.'); - // In a real application, you would load the session from a cookie here - // For demo, we'll start with no session. + this.logger.info('[CookieIdentitySessionAdapter] initialized (in-memory cookie sessions).'); } async getCurrentSession(): Promise { - this.logger.debug('[CookieIdentitySessionAdapter] Getting current session.'); - return Promise.resolve(this.currentSession); + const ctx = tryGetHttpRequestContext(); + if (!ctx) { + // Called outside HTTP request (e.g. some unit tests). Behave as no session. + return null; + } + + const cookies = readCookieHeader(ctx.req.headers.cookie); + const token = cookies[COOKIE_NAME]; + if (!token) return null; + + const session = this.sessionsByToken.get(token) ?? null; + if (!session) return null; + + const now = Date.now(); + if (session.expiresAt <= now) { + this.sessionsByToken.delete(token); + return null; + } + + return session; } async createSession(user: AuthenticatedUser): Promise { - this.logger.debug(`[CookieIdentitySessionAdapter] Creating session for user: ${user.id}`); - const newSession: AuthSession = { - user: user, - issuedAt: Date.now(), - expiresAt: Date.now() + 3600 * 1000, // 1 hour expiration - token: `mock-token-${user.id}-${Date.now()}`, + const issuedAt = Date.now(); + const expiresAt = issuedAt + SESSION_TTL_MS; + + const token = `gp_${randomUUID()}`; + + const session: AuthSession = { + user, + issuedAt, + expiresAt, + token, }; - this.currentSession = newSession; - // In a real app, you'd set a secure, HTTP-only cookie here. - this.logger.info(`[CookieIdentitySessionAdapter] Session created for user ${user.id}.`); - return Promise.resolve(newSession); + + this.sessionsByToken.set(token, session); + + const ctx = tryGetHttpRequestContext(); + if (ctx) { + const setCookie = buildSetCookieHeader({ + name: COOKIE_NAME, + value: token, + maxAgeSeconds: Math.floor(SESSION_TTL_MS / 1000), + httpOnly: true, + sameSite: 'Lax', + secure: false, + }); + + const existing = ctx.res.getHeader('Set-Cookie'); + ctx.res.setHeader('Set-Cookie', appendSetCookieHeader(existing as any, setCookie)); + } + + return session; } async clearSession(): Promise { - this.logger.debug('[CookieIdentitySessionAdapter] Clearing session.'); - this.currentSession = null; - // In a real app, you'd clear the session cookie here. - this.logger.info('[CookieIdentitySessionAdapter] Session cleared.'); - return Promise.resolve(); + const ctx = tryGetHttpRequestContext(); + + if (ctx) { + const cookies = readCookieHeader(ctx.req.headers.cookie); + const token = cookies[COOKIE_NAME]; + if (token) { + this.sessionsByToken.delete(token); + } + + const setCookie = buildSetCookieHeader({ + name: COOKIE_NAME, + value: '', + maxAgeSeconds: 0, + httpOnly: true, + sameSite: 'Lax', + secure: false, + }); + + const existing = ctx.res.getHeader('Set-Cookie'); + ctx.res.setHeader('Set-Cookie', appendSetCookieHeader(existing as any, setCookie)); + return; + } + + // No request context: nothing to clear from cookie; just clear all in-memory sessions. + this.sessionsByToken.clear(); } -} +} \ No newline at end of file diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 8a24bdee5..daa651fd9 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -1,5 +1,5 @@ -import { Module } from '@nestjs/common'; +import { MiddlewareConsumer, Module, type NestModule } from '@nestjs/common'; import { AnalyticsModule } from './domain/analytics/AnalyticsModule'; import { AuthModule } from './domain/auth/AuthModule'; import { BootstrapModule } from './domain/bootstrap/BootstrapModule'; @@ -19,6 +19,7 @@ import { SponsorModule } from './domain/sponsor/SponsorModule'; import { TeamModule } from './domain/team/TeamModule'; import { getApiPersistence, getEnableBootstrap } from './env'; +import { RequestContextMiddleware } from './shared/http/RequestContext'; const API_PERSISTENCE = getApiPersistence(); const USE_DATABASE = API_PERSISTENCE === 'postgres'; @@ -47,4 +48,8 @@ const ENABLE_BOOTSTRAP = getEnableBootstrap(); PolicyModule, ], }) -export class AppModule {} +export class AppModule implements NestModule { + configure(consumer: MiddlewareConsumer): void { + consumer.apply(RequestContextMiddleware).forRoutes('*'); + } +} diff --git a/apps/api/src/domain/auth/AuthController.test.ts b/apps/api/src/domain/auth/AuthController.test.ts index 847281417..5de425e47 100644 --- a/apps/api/src/domain/auth/AuthController.test.ts +++ b/apps/api/src/domain/auth/AuthController.test.ts @@ -80,7 +80,7 @@ describe('AuthController', () => { }); describe('getSession', () => { - it('should call service.getCurrentSession and return session DTO', async () => { + it('should call service.getCurrentSession and write JSON response', async () => { const session: AuthSessionDTO = { token: 'token123', user: { @@ -91,18 +91,23 @@ describe('AuthController', () => { }; (service.getCurrentSession as Mock).mockResolvedValue(session); - const result = await controller.getSession(); + const res = { json: vi.fn() } as any; + + await controller.getSession(res); expect(service.getCurrentSession).toHaveBeenCalled(); - expect(result).toEqual(session); + expect(res.json).toHaveBeenCalledWith(session); }); - it('should return null if no session', async () => { + it('should write JSON null when no session', async () => { (service.getCurrentSession as Mock).mockResolvedValue(null); - const result = await controller.getSession(); + const res = { json: vi.fn() } as any; - expect(result).toBeNull(); + await controller.getSession(res); + + expect(service.getCurrentSession).toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith(null); }); }); diff --git a/apps/api/src/domain/auth/AuthController.ts b/apps/api/src/domain/auth/AuthController.ts index c06511067..2c65ce3a9 100644 --- a/apps/api/src/domain/auth/AuthController.ts +++ b/apps/api/src/domain/auth/AuthController.ts @@ -1,8 +1,9 @@ -import { Controller, Get, Post, Body, Query, Inject } from '@nestjs/common'; +import { Controller, Get, Post, Body, Query, Inject, Res } from '@nestjs/common'; import { Public } from './Public'; import { AuthService } from './AuthService'; import { LoginParamsDTO, SignupParamsDTO, AuthSessionDTO } from './dtos/AuthDto'; import type { CommandResultDTO } from './presenters/CommandResultPresenter'; +import type { Response } from 'express'; @Public() @Controller('auth') @@ -20,8 +21,11 @@ export class AuthController { } @Get('session') - async getSession(): Promise { - return this.authService.getCurrentSession(); + async getSession(@Res() res: Response): Promise { + const session = await this.authService.getCurrentSession(); + // Nest treats `null`/`undefined` as "no body" for Express responses; we need JSON `null` + // so browser clients can safely call `response.json()`. + res.json(session); } @Post('logout') diff --git a/apps/api/src/domain/auth/AuthProviders.ts b/apps/api/src/domain/auth/AuthProviders.ts index 042545afc..7db82f74e 100644 --- a/apps/api/src/domain/auth/AuthProviders.ts +++ b/apps/api/src/domain/auth/AuthProviders.ts @@ -37,8 +37,13 @@ export const COMMAND_RESULT_OUTPUT_PORT_TOKEN = 'CommandResultOutputPort'; export const AuthProviders: Provider[] = [ { provide: AUTH_REPOSITORY_TOKEN, - useFactory: (passwordHashingService: IPasswordHashingService, logger: Logger) => { - // Seed initial users for InMemoryUserRepository + useFactory: (userRepository: InMemoryUserRepository, passwordHashingService: IPasswordHashingService, logger: Logger) => + new InMemoryAuthRepository(userRepository, passwordHashingService, logger), + inject: [USER_REPOSITORY_TOKEN, PASSWORD_HASHING_SERVICE_TOKEN, LOGGER_TOKEN], + }, + { + provide: USER_REPOSITORY_TOKEN, + useFactory: (logger: Logger) => { const initialUsers: StoredUser[] = [ { // Match seeded racing driver id so dashboard works in inmemory mode. @@ -50,14 +55,8 @@ export const AuthProviders: Provider[] = [ createdAt: new Date(), }, ]; - const inMemoryUserRepository = new InMemoryUserRepository(logger, initialUsers); - return new InMemoryAuthRepository(inMemoryUserRepository, passwordHashingService, logger); + return new InMemoryUserRepository(logger, initialUsers); }, - inject: [PASSWORD_HASHING_SERVICE_TOKEN, LOGGER_TOKEN], - }, - { - provide: USER_REPOSITORY_TOKEN, - useFactory: (logger: Logger) => new InMemoryUserRepository(logger), // Factory for InMemoryUserRepository inject: [LOGGER_TOKEN], }, { diff --git a/apps/api/src/domain/auth/AuthSession.http.test.ts b/apps/api/src/domain/auth/AuthSession.http.test.ts new file mode 100644 index 000000000..2371ef3e6 --- /dev/null +++ b/apps/api/src/domain/auth/AuthSession.http.test.ts @@ -0,0 +1,92 @@ +import 'reflect-metadata'; + +import { ValidationPipe } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import request from 'supertest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { requestContextMiddleware } from '@adapters/http/RequestContext'; +import { AuthModule } from './AuthModule'; + +describe('Auth session (HTTP, inmemory)', () => { + let app: any; + + beforeEach(async () => { + const module = await Test.createTestingModule({ + imports: [AuthModule], + }).compile(); + + app = module.createNestApplication(); + + app.use(requestContextMiddleware); + + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); + + await app.init(); + }); + + afterEach(async () => { + await app?.close(); + }); + + it('signup sets gp_session cookie and session persists across requests', async () => { + const agent = request.agent(app.getHttpServer()); + + const signupRes = await agent + .post('/auth/signup') + .send({ email: 'u1@gridpilot.local', password: 'pw1', displayName: 'User 1' }) + .expect(201); + + const setCookie = signupRes.headers['set-cookie'] as string[] | undefined; + expect(setCookie?.some((v) => v.startsWith('gp_session='))).toBe(true); + + const sessionRes = await agent.get('/auth/session').expect(200); + + expect(sessionRes.body).toMatchObject({ + token: expect.stringMatching(/^gp_/), + user: { + email: 'u1@gridpilot.local', + displayName: 'User 1', + userId: expect.any(String), + }, + }); + }); + + it('login sets gp_session cookie for seeded admin and logout clears it', async () => { + const agent = request.agent(app.getHttpServer()); + + const loginRes = await agent + .post('/auth/login') + .send({ email: 'admin@gridpilot.local', password: 'admin123' }) + .expect(201); + + const setCookie = loginRes.headers['set-cookie'] as string[] | undefined; + expect(setCookie?.some((v) => v.startsWith('gp_session='))).toBe(true); + + const sessionRes = await agent.get('/auth/session').expect(200); + expect(sessionRes.body).toMatchObject({ + token: expect.any(String), + user: { + userId: 'driver-1', + email: 'admin@gridpilot.local', + displayName: 'Admin', + }, + }); + + const logoutRes = await agent.post('/auth/logout').expect(201); + expect(logoutRes.body).toEqual({ success: true }); + + const logoutCookies = logoutRes.headers['set-cookie'] as string[] | undefined; + expect(logoutCookies?.some((v) => v.includes('gp_session=') && v.includes('Max-Age=0'))).toBe(true); + + await agent.get('/auth/session').expect(200).expect((res) => { + expect(res.body).toBeNull(); + }); + }); +}); \ No newline at end of file diff --git a/apps/api/src/shared/http/RequestContext.ts b/apps/api/src/shared/http/RequestContext.ts new file mode 100644 index 000000000..2222c249b --- /dev/null +++ b/apps/api/src/shared/http/RequestContext.ts @@ -0,0 +1,11 @@ +import type { Request, Response, NextFunction } from 'express'; +import { Injectable, type NestMiddleware } from '@nestjs/common'; + +import { requestContextMiddleware } from '@adapters/http/RequestContext'; + +@Injectable() +export class RequestContextMiddleware implements NestMiddleware { + use(req: Request, res: Response, next: NextFunction): void { + requestContextMiddleware(req, res, next as NextFunction); + } +} \ No newline at end of file diff --git a/vitest.api.config.ts b/vitest.api.config.ts index bfbd2959a..249cf5ef3 100644 --- a/vitest.api.config.ts +++ b/vitest.api.config.ts @@ -10,7 +10,7 @@ export default defineConfig({ include: ['apps/api/src/**/*.{test,spec}.ts'], exclude: ['node_modules/**', 'apps/api/dist/**', 'dist/**'], coverage: { - enabled: true, + enabled: false, provider: 'v8', reportsDirectory: 'coverage/api', reporter: ['text', 'html', 'lcov'],