Files
stack/apps/api/src/auth/guards/auth.guard.ts
Jason Woltje af299abdaf
All checks were successful
ci/woodpecker/push/infra Pipeline was successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
debug(auth): log session cookie source
2026-02-18 21:36:01 -06:00

114 lines
3.5 KiB
TypeScript

import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
Logger,
} from "@nestjs/common";
import { AuthService } from "../auth.service";
import type { AuthUser } from "@mosaic/shared";
import type { MaybeAuthenticatedRequest } from "../types/better-auth-request.interface";
@Injectable()
export class AuthGuard implements CanActivate {
private readonly logger = new Logger(AuthGuard.name);
constructor(private readonly authService: AuthService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<MaybeAuthenticatedRequest>();
// Try to get token from either cookie (preferred) or Authorization header
const token = this.extractToken(request);
if (!token) {
throw new UnauthorizedException("No authentication token provided");
}
try {
const sessionData = await this.authService.verifySession(token);
if (!sessionData) {
throw new UnauthorizedException("Invalid or expired session");
}
// Attach user and session to request
const user = sessionData.user;
// Validate user has required fields
if (typeof user !== "object" || !("id" in user) || !("email" in user) || !("name" in user)) {
throw new UnauthorizedException("Invalid user data in session");
}
request.user = user as unknown as AuthUser;
request.session = sessionData.session;
return true;
} catch (error) {
if (error instanceof UnauthorizedException) {
throw error;
}
// Infrastructure errors (DB down, connection refused, timeouts) must propagate
// as 500/503 via GlobalExceptionFilter — never mask as 401
throw error;
}
}
/**
* Extract token from cookie (preferred) or Authorization header
*/
private extractToken(request: MaybeAuthenticatedRequest): string | undefined {
// Try cookie first (BetterAuth default)
const cookieToken = this.extractTokenFromCookie(request);
if (cookieToken) {
return cookieToken;
}
// Fallback to Authorization header for API clients
return this.extractTokenFromHeader(request);
}
/**
* Extract token from cookie.
* BetterAuth may prefix the cookie name with "__Secure-" when running on HTTPS.
*/
private extractTokenFromCookie(request: MaybeAuthenticatedRequest): string | undefined {
// 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;
}
// BetterAuth default cookie name is "better-auth.session_token"
// When Secure cookies are enabled, BetterAuth prefixes with "__Secure-".
const candidates = [
"__Secure-better-auth.session_token",
"better-auth.session_token",
"__Host-better-auth.session_token",
] as const;
for (const name of candidates) {
const token = cookies[name];
if (token) {
this.logger.debug(`Session cookie found: ${name}`);
return token;
}
}
return undefined;
}
/**
* Extract token from Authorization header (Bearer token)
*/
private extractTokenFromHeader(request: MaybeAuthenticatedRequest): string | undefined {
const authHeader = request.headers.authorization;
if (typeof authHeader !== "string") {
return undefined;
}
const parts = authHeader.split(" ");
const [type, token] = parts;
return type === "Bearer" ? token : undefined;
}
}