- Wire COOKIE_DOMAIN env var into BetterAuth cookie config - Add URL validation for TRUSTED_ORIGINS (rejects non-HTTP, invalid URLs) - Include original parse error in validateRedirectUri error message - Distinguish infrastructure errors from auth errors in verifySession (Prisma/connection errors now propagate as 500 instead of masking as 401) Co-Authored-By: Claude Opus 4.6 <[email protected]>
195 lines
5.8 KiB
TypeScript
195 lines
5.8 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;
|
|
|
|
@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;
|
|
|
|
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<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
|
|
*/
|
|
async verifySession(
|
|
token: string
|
|
): Promise<{ user: Record<string, unknown>; session: Record<string, unknown> } | null> {
|
|
try {
|
|
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) {
|
|
// 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<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) {
|
|
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<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 };
|
|
}
|
|
}
|