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; @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; 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 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 */ async verifySession( token: string ): Promise<{ user: Record; session: Record } | null> { try { 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) { // Infrastructure errors (database down, connection failures) should propagate // so the global exception filter returns 500/503, not 401 if ( error instanceof Error && (error.constructor.name.startsWith("Prisma") || error.message.includes("connect") || error.message.includes("ECONNREFUSED") || error.message.includes("timeout")) ) { this.logger.error( "Session verification failed due to infrastructure error", error.stack, ); throw error; } // Expected auth errors (invalid/expired token) return null this.logger.warn( "Session verification failed", error instanceof Error ? error.message : "Unknown error", ); 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 HTTP 200, 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) { this.logger.warn( `OIDC provider returned non-OK status: ${String(response.status)} from ${discoveryUrl}` ); } return this.lastHealthResult; } catch (error: unknown) { this.lastHealthCheck = Date.now(); this.lastHealthResult = false; const message = error instanceof Error ? error.message : String(error); this.logger.warn(`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 }; } }