import { Injectable, Logger } from "@nestjs/common"; import type { PrismaClient } from "@prisma/client"; import type { IncomingMessage, ServerResponse } from "http"; import { toNodeHandler } from "better-auth/node"; import type { AuthConfigResponse, AuthProviderConfig } from "@mosaic/shared"; import { PrismaService } from "../prisma/prisma.service"; import { createAuth, isOidcEnabled, type Auth } from "./auth.config"; /** Duration in milliseconds to cache the OIDC health check result */ const OIDC_HEALTH_CACHE_TTL_MS = 30_000; /** Timeout in milliseconds for the OIDC discovery URL fetch */ const OIDC_HEALTH_TIMEOUT_MS = 2_000; /** Number of consecutive health-check failures before escalating to error level */ const HEALTH_ESCALATION_THRESHOLD = 3; /** Verified session shape returned by BetterAuth's getSession */ interface VerifiedSession { user: Record; session: Record; } @Injectable() export class AuthService { private readonly logger = new Logger(AuthService.name); private readonly auth: Auth; private readonly nodeHandler: (req: IncomingMessage, res: ServerResponse) => Promise; /** Timestamp of the last OIDC health check */ private lastHealthCheck = 0; /** Cached result of the last OIDC health check */ private lastHealthResult = false; /** Consecutive OIDC health check failure count for log-level escalation */ private consecutiveHealthFailures = 0; constructor(private readonly prisma: PrismaService) { // PrismaService extends PrismaClient and is compatible with BetterAuth's adapter // Cast is safe as PrismaService provides all required PrismaClient methods // TODO(#411): BetterAuth returns opaque types — replace when upstream exports typed interfaces this.auth = createAuth(this.prisma as unknown as PrismaClient); this.nodeHandler = toNodeHandler(this.auth); } /** * Get BetterAuth instance */ getAuth(): Auth { return this.auth; } /** * Get Node.js-compatible request handler for BetterAuth. * Wraps BetterAuth's Web API handler to work with Express/Node.js req/res. */ getNodeHandler(): (req: IncomingMessage, res: ServerResponse) => Promise { return this.nodeHandler; } /** * Get user by ID */ async getUserById(userId: string): Promise<{ id: string; email: string; name: string; authProviderId: string | null; } | null> { return this.prisma.user.findUnique({ where: { id: userId }, select: { id: true, email: true, name: true, authProviderId: true, }, }); } /** * Get user by email */ async getUserByEmail(email: string): Promise<{ id: string; email: string; name: string; authProviderId: string | null; } | null> { return this.prisma.user.findUnique({ where: { email }, select: { id: true, email: true, name: true, authProviderId: true, }, }); } /** * Verify session token * Returns session data if valid, null if invalid or expired. * Only known-safe auth errors return null; everything else propagates as 500. */ async verifySession(token: string): Promise { try { // TODO(#411): BetterAuth getSession returns opaque types — replace when upstream exports typed interfaces const session = await this.auth.api.getSession({ headers: { authorization: `Bearer ${token}`, }, }); if (!session) { return null; } return { user: session.user as Record, session: session.session as Record, }; } catch (error: unknown) { // Only known-safe auth errors return null if (error instanceof Error) { const msg = error.message.toLowerCase(); const isExpectedAuthError = msg.includes("invalid token") || msg.includes("token expired") || msg.includes("session expired") || msg.includes("session not found") || msg.includes("invalid session") || msg === "unauthorized" || msg === "expired"; if (!isExpectedAuthError) { // Infrastructure or unexpected — propagate as 500 const safeMessage = (error.stack ?? error.message).replace( /Bearer\s+\S+/gi, "Bearer [REDACTED]" ); this.logger.error("Session verification failed due to unexpected error", safeMessage); throw error; } } // Non-Error thrown values — log for observability, treat as auth failure if (!(error instanceof Error)) { const errorDetail = typeof error === "string" ? error : JSON.stringify(error); this.logger.warn("Session verification received non-Error thrown value", errorDetail); } return null; } } /** * Check if the OIDC provider (Authentik) is reachable by fetching the discovery URL. * Results are cached for 30 seconds to prevent repeated network calls. * * @returns true if the provider responds with an HTTP 2xx status, false otherwise */ async isOidcProviderReachable(): Promise { const now = Date.now(); // Return cached result if still valid if (now - this.lastHealthCheck < OIDC_HEALTH_CACHE_TTL_MS) { this.logger.debug("OIDC health check: returning cached result"); return this.lastHealthResult; } const discoveryUrl = `${process.env.OIDC_ISSUER ?? ""}.well-known/openid-configuration`; this.logger.debug(`OIDC health check: fetching ${discoveryUrl}`); try { const response = await fetch(discoveryUrl, { signal: AbortSignal.timeout(OIDC_HEALTH_TIMEOUT_MS), }); this.lastHealthCheck = Date.now(); this.lastHealthResult = response.ok; if (response.ok) { if (this.consecutiveHealthFailures > 0) { this.logger.log( `OIDC provider recovered after ${String(this.consecutiveHealthFailures)} consecutive failure(s)` ); } this.consecutiveHealthFailures = 0; } else { this.consecutiveHealthFailures++; const logLevel = this.consecutiveHealthFailures >= HEALTH_ESCALATION_THRESHOLD ? "error" : "warn"; this.logger[logLevel]( `OIDC provider returned non-OK status: ${String(response.status)} from ${discoveryUrl}` ); } return this.lastHealthResult; } catch (error: unknown) { this.lastHealthCheck = Date.now(); this.lastHealthResult = false; this.consecutiveHealthFailures++; const message = error instanceof Error ? error.message : String(error); const logLevel = this.consecutiveHealthFailures >= HEALTH_ESCALATION_THRESHOLD ? "error" : "warn"; this.logger[logLevel](`OIDC provider unreachable at ${discoveryUrl}: ${message}`); return false; } } /** * Get authentication configuration for the frontend. * Returns available auth providers so the UI can render login options dynamically. * When OIDC is enabled, performs a health check to verify the provider is reachable. */ async getAuthConfig(): Promise { const providers: AuthProviderConfig[] = [{ id: "email", name: "Email", type: "credentials" }]; if (isOidcEnabled() && (await this.isOidcProviderReachable())) { providers.push({ id: "authentik", name: "Authentik", type: "oauth" }); } return { providers }; } }