Files
stack/apps/api/src/auth/auth.service.ts
Jason Woltje 9d3a673e6c
Some checks failed
ci/woodpecker/push/web Pipeline failed
ci/woodpecker/push/api Pipeline was successful
fix(#411): resolve CI lint errors — prettier, unused directives, no-base-to-string
- auth.config.ts: collapse multiline template literal to single line
- auth.controller.ts: add eslint-disable for intentional no-unnecessary-condition
- auth.service.ts: remove 5 unused eslint-disable directives (Node 24 resolves
  BetterAuth types), fix prettier formatting, fix no-base-to-string
- login/page.tsx: remove unnecessary String() wrapper
- auth-context.test.tsx: fix prettier line length

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 17:00:01 -06:00

226 lines
7.4 KiB
TypeScript

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<string, unknown>;
session: Record<string, unknown>;
}
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
private readonly auth: Auth;
private readonly nodeHandler: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
/** 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<void> {
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<VerifiedSession | null> {
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<string, unknown>,
session: session.session as Record<string, unknown>,
};
} 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<boolean> {
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<AuthConfigResponse> {
const providers: AuthProviderConfig[] = [{ id: "email", name: "Email", type: "credentials" }];
if (isOidcEnabled() && (await this.isOidcProviderReachable())) {
providers.push({ id: "authentik", name: "Authentik", type: "oauth" });
}
return { providers };
}
}