The login page froze after clicking "Continue with Authentik" because signIn.oauth2() resolves (not rejects) on server 500, leaving the button stuck in "Connecting..." state permanently. - Handle the .then() case to detect error/missing redirect URL and reset loading state with a user-facing error message - Log BetterAuth silent 500 responses that bypass NestJS error handling - Enable BetterAuth error-level logger for diagnostic visibility Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
217 lines
6.7 KiB
TypeScript
217 lines
6.7 KiB
TypeScript
import {
|
|
Controller,
|
|
All,
|
|
Req,
|
|
Res,
|
|
Get,
|
|
Header,
|
|
UseGuards,
|
|
Request,
|
|
Logger,
|
|
HttpException,
|
|
HttpStatus,
|
|
UnauthorizedException,
|
|
} from "@nestjs/common";
|
|
import { Throttle } from "@nestjs/throttler";
|
|
import type { Request as ExpressRequest, Response as ExpressResponse } from "express";
|
|
import type { AuthUser, AuthSession, AuthConfigResponse } from "@mosaic/shared";
|
|
import { AuthService } from "./auth.service";
|
|
import { AuthGuard } from "./guards/auth.guard";
|
|
import { CurrentUser } from "./decorators/current-user.decorator";
|
|
import { SkipCsrf } from "../common/decorators/skip-csrf.decorator";
|
|
import type { AuthenticatedRequest } from "./types/better-auth-request.interface";
|
|
|
|
@Controller("auth")
|
|
export class AuthController {
|
|
private readonly logger = new Logger(AuthController.name);
|
|
|
|
constructor(private readonly authService: AuthService) {}
|
|
|
|
/**
|
|
* Get current session
|
|
* Returns user and session data for authenticated user
|
|
*/
|
|
@Get("session")
|
|
@UseGuards(AuthGuard)
|
|
getSession(@Request() req: AuthenticatedRequest): AuthSession {
|
|
// Defense-in-depth: AuthGuard should guarantee these, but if someone adds
|
|
// a route with AuthenticatedRequest and forgets @UseGuards(AuthGuard),
|
|
// TypeScript types won't help at runtime.
|
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
if (!req.user || !req.session) {
|
|
throw new UnauthorizedException("Missing authentication context");
|
|
}
|
|
|
|
return {
|
|
user: req.user,
|
|
session: {
|
|
id: req.session.id,
|
|
token: req.session.token,
|
|
expiresAt: req.session.expiresAt,
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get current user profile
|
|
* Returns basic user information
|
|
*/
|
|
@Get("profile")
|
|
@UseGuards(AuthGuard)
|
|
getProfile(@CurrentUser() user: AuthUser): AuthUser {
|
|
// Return only defined properties to maintain type safety
|
|
const profile: AuthUser = {
|
|
id: user.id,
|
|
email: user.email,
|
|
name: user.name,
|
|
};
|
|
|
|
if (user.image !== undefined) {
|
|
profile.image = user.image;
|
|
}
|
|
if (user.emailVerified !== undefined) {
|
|
profile.emailVerified = user.emailVerified;
|
|
}
|
|
if (user.workspaceId !== undefined) {
|
|
profile.workspaceId = user.workspaceId;
|
|
}
|
|
if (user.currentWorkspaceId !== undefined) {
|
|
profile.currentWorkspaceId = user.currentWorkspaceId;
|
|
}
|
|
if (user.workspaceRole !== undefined) {
|
|
profile.workspaceRole = user.workspaceRole;
|
|
}
|
|
|
|
return profile;
|
|
}
|
|
|
|
/**
|
|
* Get available authentication providers.
|
|
* Public endpoint (no auth guard) so the frontend can discover login options
|
|
* before the user is authenticated.
|
|
*/
|
|
@Get("config")
|
|
@Header("Cache-Control", "public, max-age=300")
|
|
async getConfig(): Promise<AuthConfigResponse> {
|
|
return this.authService.getAuthConfig();
|
|
}
|
|
|
|
/**
|
|
* Handle all other auth routes (sign-in, sign-up, sign-out, etc.)
|
|
* Delegates to BetterAuth
|
|
*
|
|
* Rate limit: "strict" tier (10 req/min) - More restrictive than normal routes
|
|
* to prevent brute-force attacks on auth endpoints
|
|
*
|
|
* Security note: This catch-all route bypasses standard guards that other routes have.
|
|
* Rate limiting and logging are applied to mitigate abuse (SEC-API-10).
|
|
*/
|
|
@All("*")
|
|
// BetterAuth handles CSRF internally (Fetch Metadata + SameSite=Lax cookies).
|
|
// @SkipCsrf avoids double-protection conflicts.
|
|
// See: https://www.better-auth.com/docs/reference/security
|
|
@SkipCsrf()
|
|
@Throttle({ strict: { limit: 10, ttl: 60000 } })
|
|
async handleAuth(@Req() req: ExpressRequest, @Res() res: ExpressResponse): Promise<void> {
|
|
// Extract client IP for logging
|
|
const clientIp = this.getClientIp(req);
|
|
|
|
// Log auth catch-all hits for monitoring and debugging
|
|
this.logger.debug(`Auth catch-all: ${req.method} ${req.url} from ${clientIp}`);
|
|
|
|
const handler = this.authService.getNodeHandler();
|
|
|
|
try {
|
|
await handler(req, res);
|
|
|
|
// BetterAuth writes responses directly — catch silent 500s that bypass NestJS error handling
|
|
if (res.statusCode >= 500) {
|
|
this.logger.error(
|
|
`BetterAuth returned ${String(res.statusCode)} for ${req.method} ${req.url} from ${clientIp}` +
|
|
` — check container stdout for '# SERVER_ERROR' details`
|
|
);
|
|
}
|
|
} catch (error: unknown) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
const stack = error instanceof Error ? error.stack : undefined;
|
|
|
|
this.logger.error(
|
|
`BetterAuth handler error: ${req.method} ${req.url} from ${clientIp} - ${message}`,
|
|
stack
|
|
);
|
|
|
|
if (!res.headersSent) {
|
|
const mappedError = this.mapToHttpException(error);
|
|
if (mappedError) {
|
|
throw mappedError;
|
|
}
|
|
|
|
throw new HttpException(
|
|
"Unable to complete authentication. Please try again in a moment.",
|
|
HttpStatus.INTERNAL_SERVER_ERROR
|
|
);
|
|
}
|
|
|
|
this.logger.error(
|
|
`Headers already sent for failed auth request ${req.method} ${req.url} — client may have received partial response`
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract client IP from request, handling proxies
|
|
*/
|
|
private getClientIp(req: ExpressRequest): string {
|
|
// Check X-Forwarded-For header (for reverse proxy setups)
|
|
const forwardedFor = req.headers["x-forwarded-for"];
|
|
if (forwardedFor) {
|
|
const ips = Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor;
|
|
return ips?.split(",")[0]?.trim() ?? "unknown";
|
|
}
|
|
|
|
// Fall back to direct IP
|
|
return req.ip ?? req.socket.remoteAddress ?? "unknown";
|
|
}
|
|
|
|
/**
|
|
* Preserve known HTTP errors from BetterAuth/better-call instead of converting
|
|
* every failure into a generic 500.
|
|
*/
|
|
private mapToHttpException(error: unknown): HttpException | null {
|
|
if (error instanceof HttpException) {
|
|
return error;
|
|
}
|
|
|
|
if (!error || typeof error !== "object") {
|
|
return null;
|
|
}
|
|
|
|
const statusCode = "statusCode" in error ? error.statusCode : undefined;
|
|
if (!this.isHttpStatus(statusCode)) {
|
|
return null;
|
|
}
|
|
|
|
const responseBody = "body" in error && error.body !== undefined ? error.body : undefined;
|
|
if (
|
|
responseBody !== undefined &&
|
|
responseBody !== null &&
|
|
(typeof responseBody === "string" || typeof responseBody === "object")
|
|
) {
|
|
return new HttpException(responseBody, statusCode);
|
|
}
|
|
|
|
const message =
|
|
"message" in error && typeof error.message === "string" && error.message.length > 0
|
|
? error.message
|
|
: "Authentication request failed";
|
|
return new HttpException(message, statusCode);
|
|
}
|
|
|
|
private isHttpStatus(value: unknown): value is number {
|
|
if (typeof value !== "number" || !Number.isInteger(value)) {
|
|
return false;
|
|
}
|
|
return value >= 400 && value <= 599;
|
|
}
|
|
}
|