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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user