fix(#411): QA-002 — invert verifySession error classification + health check escalation

verifySession now allowlists known auth errors (return null) and re-throws
everything else as infrastructure errors. OIDC health check escalates to
error level after 3 consecutive failures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jason Woltje
2026-02-16 13:15:41 -06:00
parent 097f5f4ab6
commit 4f31690281
2 changed files with 205 additions and 32 deletions

View File

@@ -12,6 +12,15 @@ 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);
@@ -22,11 +31,16 @@ export class AuthService {
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
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
this.auth = createAuth(this.prisma as unknown as PrismaClient);
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call
this.nodeHandler = toNodeHandler(this.auth);
}
@@ -87,12 +101,13 @@ export class AuthService {
/**
* Verify session token
* Returns session data if valid, null if invalid or expired
* 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<{ user: Record<string, unknown>; session: Record<string, unknown> } | null> {
async verifySession(token: string): Promise<VerifiedSession | null> {
try {
// TODO(#411): BetterAuth getSession returns opaque types — replace when upstream exports typed interfaces
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
const session = await this.auth.api.getSession({
headers: {
authorization: `Bearer ${token}`,
@@ -104,31 +119,32 @@ export class AuthService {
}
return {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
user: session.user as Record<string, unknown>,
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
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;
}
} 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("expired") ||
msg.includes("session not found") ||
msg.includes("unauthorized") ||
msg.includes("invalid session");
// Expected auth errors (invalid/expired token) return null
this.logger.warn(
"Session verification failed",
error instanceof Error ? error.message : "Unknown error",
);
if (!isExpectedAuthError) {
// Infrastructure or unexpected — propagate as 500
this.logger.error(
"Session verification failed due to unexpected error",
error.stack ?? error.message
);
throw error;
}
}
// Non-Error thrown values or expected auth errors
return null;
}
}
@@ -159,8 +175,18 @@ export class AuthService {
this.lastHealthCheck = Date.now();
this.lastHealthResult = response.ok;
if (!response.ok) {
this.logger.warn(
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}`
);
}
@@ -169,9 +195,12 @@ export class AuthService {
} catch (error: unknown) {
this.lastHealthCheck = Date.now();
this.lastHealthResult = false;
this.consecutiveHealthFailures++;
const message = error instanceof Error ? error.message : String(error);
this.logger.warn(`OIDC provider unreachable at ${discoveryUrl}: ${message}`);
const logLevel =
this.consecutiveHealthFailures >= HEALTH_ESCALATION_THRESHOLD ? "error" : "warn";
this.logger[logLevel](`OIDC provider unreachable at ${discoveryUrl}: ${message}`);
return false;
}