refactor(#411): QA-011 — unify request-with-user types into AuthenticatedRequest

Replace 4 redundant request interfaces (RequestWithSession, AuthRequest,
BetterAuthRequest, RequestWithUser) with AuthenticatedRequest and
MaybeAuthenticatedRequest in apps/api/src/auth/types/.

- AuthenticatedRequest: extends Express Request with non-optional user/session
  (used in controllers behind AuthGuard)
- MaybeAuthenticatedRequest: extends Express Request with optional user/session
  (used in AuthGuard and CurrentUser decorator before auth is confirmed)
- Removed dead-code null checks in getSession (AuthGuard guarantees presence)
- Fixed cookies type safety in AuthGuard (cast from any to Record)
- Updated test expectations to match new type contract

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jason Woltje
2026-02-16 14:00:14 -06:00
parent df495c67b5
commit 0a2eaaa5e4
5 changed files with 44 additions and 91 deletions

View File

@@ -287,41 +287,9 @@ describe("AuthController", () => {
expect(result).toEqual(expected); expect(result).toEqual(expected);
}); });
it("should throw HttpException(401) if user not found in request", () => { // Note: Tests for missing user/session were removed because
const mockRequest = { // AuthenticatedRequest guarantees both are present (enforced by AuthGuard).
session: { // NestJS returns 401 before getSession is reached if the guard rejects.
id: "session-123",
token: "session-token",
expiresAt: new Date(),
},
};
expect(() => controller.getSession(mockRequest)).toThrow(HttpException);
try {
controller.getSession(mockRequest);
} catch (err) {
expect((err as HttpException).getStatus()).toBe(HttpStatus.UNAUTHORIZED);
expect((err as HttpException).getResponse()).toBe("User session not found");
}
});
it("should throw HttpException(401) if session not found in request", () => {
const mockRequest = {
user: {
id: "user-123",
email: "test@example.com",
name: "Test User",
},
};
expect(() => controller.getSession(mockRequest)).toThrow(HttpException);
try {
controller.getSession(mockRequest);
} catch (err) {
expect((err as HttpException).getStatus()).toBe(HttpStatus.UNAUTHORIZED);
expect((err as HttpException).getResponse()).toBe("User session not found");
}
});
}); });
describe("getProfile", () => { describe("getProfile", () => {

View File

@@ -18,16 +18,7 @@ import { AuthService } from "./auth.service";
import { AuthGuard } from "./guards/auth.guard"; import { AuthGuard } from "./guards/auth.guard";
import { CurrentUser } from "./decorators/current-user.decorator"; import { CurrentUser } from "./decorators/current-user.decorator";
import { SkipCsrf } from "../common/decorators/skip-csrf.decorator"; import { SkipCsrf } from "../common/decorators/skip-csrf.decorator";
import type { AuthenticatedRequest } from "./types/better-auth-request.interface";
interface RequestWithSession {
user?: AuthUser;
session?: {
id: string;
token: string;
expiresAt: Date;
[key: string]: unknown;
};
}
@Controller("auth") @Controller("auth")
export class AuthController { export class AuthController {
@@ -41,12 +32,9 @@ export class AuthController {
*/ */
@Get("session") @Get("session")
@UseGuards(AuthGuard) @UseGuards(AuthGuard)
getSession(@Request() req: RequestWithSession): AuthSession { getSession(@Request() req: AuthenticatedRequest): AuthSession {
if (!req.user || !req.session) { // AuthGuard guarantees user and session are present — NestJS returns 401
// This should never happen after AuthGuard, but TypeScript needs the check // before this method is reached if the guard rejects.
throw new HttpException("User session not found", HttpStatus.UNAUTHORIZED);
}
return { return {
user: req.user, user: req.user,
session: { session: {
@@ -140,12 +128,12 @@ export class AuthController {
if (!res.headersSent) { if (!res.headersSent) {
throw new HttpException( throw new HttpException(
"Unable to complete authentication. Please try again in a moment.", "Unable to complete authentication. Please try again in a moment.",
HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR
); );
} }
this.logger.error( this.logger.error(
`Headers already sent for failed auth request ${req.method} ${req.url} — client may have received partial response`, `Headers already sent for failed auth request ${req.method} ${req.url} — client may have received partial response`
); );
} }
} }

View File

@@ -1,14 +1,13 @@
import type { ExecutionContext } from "@nestjs/common"; import type { ExecutionContext } from "@nestjs/common";
import { createParamDecorator, UnauthorizedException } from "@nestjs/common"; import { createParamDecorator, UnauthorizedException } from "@nestjs/common";
import type { AuthUser } from "@mosaic/shared"; import type { AuthUser } from "@mosaic/shared";
import type { MaybeAuthenticatedRequest } from "../types/better-auth-request.interface";
interface RequestWithUser {
user?: AuthUser;
}
export const CurrentUser = createParamDecorator( export const CurrentUser = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): AuthUser => { (_data: unknown, ctx: ExecutionContext): AuthUser => {
const request = ctx.switchToHttp().getRequest<RequestWithUser>(); // Use MaybeAuthenticatedRequest because the decorator doesn't know
// whether AuthGuard ran — the null check provides defense-in-depth.
const request = ctx.switchToHttp().getRequest<MaybeAuthenticatedRequest>();
if (!request.user) { if (!request.user) {
throw new UnauthorizedException("No authenticated user found on request"); throw new UnauthorizedException("No authenticated user found on request");
} }

View File

@@ -1,23 +1,14 @@
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from "@nestjs/common"; import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from "@nestjs/common";
import { AuthService } from "../auth.service"; import { AuthService } from "../auth.service";
import type { AuthUser } from "@mosaic/shared"; import type { AuthUser } from "@mosaic/shared";
import type { MaybeAuthenticatedRequest } from "../types/better-auth-request.interface";
/**
* Request type with authentication context
*/
interface AuthRequest {
user?: AuthUser;
session?: Record<string, unknown>;
headers: Record<string, string | string[] | undefined>;
cookies?: Record<string, string>;
}
@Injectable() @Injectable()
export class AuthGuard implements CanActivate { export class AuthGuard implements CanActivate {
constructor(private readonly authService: AuthService) {} constructor(private readonly authService: AuthService) {}
async canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<AuthRequest>(); const request = context.switchToHttp().getRequest<MaybeAuthenticatedRequest>();
// Try to get token from either cookie (preferred) or Authorization header // Try to get token from either cookie (preferred) or Authorization header
const token = this.extractToken(request); const token = this.extractToken(request);
@@ -56,7 +47,7 @@ export class AuthGuard implements CanActivate {
/** /**
* Extract token from cookie (preferred) or Authorization header * Extract token from cookie (preferred) or Authorization header
*/ */
private extractToken(request: AuthRequest): string | undefined { private extractToken(request: MaybeAuthenticatedRequest): string | undefined {
// Try cookie first (BetterAuth default) // Try cookie first (BetterAuth default)
const cookieToken = this.extractTokenFromCookie(request); const cookieToken = this.extractTokenFromCookie(request);
if (cookieToken) { if (cookieToken) {
@@ -70,19 +61,21 @@ export class AuthGuard implements CanActivate {
/** /**
* Extract token from cookie (BetterAuth stores session token in better-auth.session_token cookie) * Extract token from cookie (BetterAuth stores session token in better-auth.session_token cookie)
*/ */
private extractTokenFromCookie(request: AuthRequest): string | undefined { private extractTokenFromCookie(request: MaybeAuthenticatedRequest): string | undefined {
if (!request.cookies) { // Express types `cookies` as `any`; cast to a known shape for type safety.
const cookies = request.cookies as Record<string, string> | undefined;
if (!cookies) {
return undefined; return undefined;
} }
// BetterAuth uses 'better-auth.session_token' as the cookie name by default // BetterAuth uses 'better-auth.session_token' as the cookie name by default
return request.cookies["better-auth.session_token"]; return cookies["better-auth.session_token"];
} }
/** /**
* Extract token from Authorization header (Bearer token) * Extract token from Authorization header (Bearer token)
*/ */
private extractTokenFromHeader(request: AuthRequest): string | undefined { private extractTokenFromHeader(request: MaybeAuthenticatedRequest): string | undefined {
const authHeader = request.headers.authorization; const authHeader = request.headers.authorization;
if (typeof authHeader !== "string") { if (typeof authHeader !== "string") {
return undefined; return undefined;

View File

@@ -1,11 +1,14 @@
/** /**
* BetterAuth Request Type * Unified request types for authentication context.
* *
* BetterAuth expects a Request object compatible with the Fetch API standard. * Replaces the previously scattered interfaces:
* This extends the web standard Request interface with additional properties * - RequestWithSession (auth.controller.ts)
* that may be present in the Express request object at runtime. * - AuthRequest (auth.guard.ts)
* - BetterAuthRequest (this file, removed)
* - RequestWithUser (current-user.decorator.ts)
*/ */
import type { Request } from "express";
import type { AuthUser } from "@mosaic/shared"; import type { AuthUser } from "@mosaic/shared";
// Re-export AuthUser for use in other modules // Re-export AuthUser for use in other modules
@@ -22,19 +25,21 @@ export interface RequestSession {
} }
/** /**
* Web standard Request interface extended with Express-specific properties * Request that may or may not have auth data (before guard runs).
* This matches the Fetch API Request specification that BetterAuth expects. * Used by AuthGuard and other middleware that processes requests
* before authentication is confirmed.
*/ */
export interface BetterAuthRequest extends Request { export interface MaybeAuthenticatedRequest extends Request {
// Express route parameters
params?: Record<string, string>;
// Express query string parameters
query?: Record<string, string | string[]>;
// Session data attached by AuthGuard after successful authentication
session?: RequestSession;
// Authenticated user attached by AuthGuard
user?: AuthUser; user?: AuthUser;
session?: Record<string, unknown>;
}
/**
* Request with authenticated user attached by AuthGuard.
* After AuthGuard runs, user and session are guaranteed present.
* Use this type in controllers/decorators that sit behind AuthGuard.
*/
export interface AuthenticatedRequest extends Request {
user: AuthUser;
session: RequestSession;
} }