262 lines
8.1 KiB
TypeScript
262 lines
8.1 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>;
|
|
}
|
|
|
|
interface SessionHeaderCandidate {
|
|
headers: Record<string, string>;
|
|
}
|
|
|
|
@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> {
|
|
let sawNonError = false;
|
|
|
|
for (const candidate of this.buildSessionHeaderCandidates(token)) {
|
|
try {
|
|
// TODO(#411): BetterAuth getSession returns opaque types — replace when upstream exports typed interfaces
|
|
const session = await this.auth.api.getSession(candidate);
|
|
|
|
if (!session) {
|
|
continue;
|
|
}
|
|
|
|
return {
|
|
user: session.user as Record<string, unknown>,
|
|
session: session.session as Record<string, unknown>,
|
|
};
|
|
} catch (error: unknown) {
|
|
if (error instanceof Error) {
|
|
if (this.isExpectedAuthError(error.message)) {
|
|
continue;
|
|
}
|
|
|
|
// 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 once for observability, treat as auth failure
|
|
if (!sawNonError) {
|
|
const errorDetail = typeof error === "string" ? error : JSON.stringify(error);
|
|
this.logger.warn("Session verification received non-Error thrown value", errorDetail);
|
|
sawNonError = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private buildSessionHeaderCandidates(token: string): SessionHeaderCandidate[] {
|
|
return [
|
|
{
|
|
headers: {
|
|
cookie: `__Secure-better-auth.session_token=${token}`,
|
|
},
|
|
},
|
|
{
|
|
headers: {
|
|
cookie: `better-auth.session_token=${token}`,
|
|
},
|
|
},
|
|
{
|
|
headers: {
|
|
cookie: `__Host-better-auth.session_token=${token}`,
|
|
},
|
|
},
|
|
{
|
|
headers: {
|
|
authorization: `Bearer ${token}`,
|
|
},
|
|
},
|
|
];
|
|
}
|
|
|
|
private isExpectedAuthError(message: string): boolean {
|
|
const normalized = message.toLowerCase();
|
|
return (
|
|
normalized.includes("invalid token") ||
|
|
normalized.includes("token expired") ||
|
|
normalized.includes("session expired") ||
|
|
normalized.includes("session not found") ||
|
|
normalized.includes("invalid session") ||
|
|
normalized === "unauthorized" ||
|
|
normalized === "expired"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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 };
|
|
}
|
|
}
|