114 lines
3.5 KiB
TypeScript
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;
|
|
}
|
|
}
|