From 3b2356f5a027f403058195ebec37ce21350db930 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Mon, 16 Feb 2026 11:20:05 -0600 Subject: [PATCH] feat(#413): add OIDC provider health check with 30s cache - isOidcProviderReachable() fetches discovery URL with 2s timeout - getAuthConfig() omits authentik when provider unreachable - 30-second cache prevents repeated network calls Refs #413 Co-Authored-By: Claude Opus 4.6 --- apps/api/src/auth/auth.controller.spec.ts | 18 +-- apps/api/src/auth/auth.controller.ts | 2 +- apps/api/src/auth/auth.service.spec.ts | 147 ++++++++++++++++++++-- apps/api/src/auth/auth.service.ts | 60 ++++++++- 4 files changed, 205 insertions(+), 22 deletions(-) diff --git a/apps/api/src/auth/auth.controller.spec.ts b/apps/api/src/auth/auth.controller.spec.ts index 764b5ca..3a1590d 100644 --- a/apps/api/src/auth/auth.controller.spec.ts +++ b/apps/api/src/auth/auth.controller.spec.ts @@ -126,28 +126,28 @@ describe("AuthController", () => { }); describe("getConfig", () => { - it("should return auth config from service", () => { + it("should return auth config from service", async () => { const mockConfig = { providers: [ { id: "email", name: "Email", type: "credentials" as const }, { id: "authentik", name: "Authentik", type: "oauth" as const }, ], }; - mockAuthService.getAuthConfig.mockReturnValue(mockConfig); + mockAuthService.getAuthConfig.mockResolvedValue(mockConfig); - const result = controller.getConfig(); + const result = await controller.getConfig(); expect(result).toEqual(mockConfig); expect(mockAuthService.getAuthConfig).toHaveBeenCalled(); }); - it("should return correct response shape with only email provider", () => { + it("should return correct response shape with only email provider", async () => { const mockConfig = { providers: [{ id: "email", name: "Email", type: "credentials" as const }], }; - mockAuthService.getAuthConfig.mockReturnValue(mockConfig); + mockAuthService.getAuthConfig.mockResolvedValue(mockConfig); - const result = controller.getConfig(); + const result = await controller.getConfig(); expect(result).toEqual(mockConfig); expect(result.providers).toHaveLength(1); @@ -158,7 +158,7 @@ describe("AuthController", () => { }); }); - it("should never leak secrets in auth config response", () => { + it("should never leak secrets in auth config response", async () => { // Set ALL sensitive environment variables with known values const sensitiveEnv: Record = { OIDC_CLIENT_SECRET: "test-client-secret", @@ -186,9 +186,9 @@ describe("AuthController", () => { { id: "authentik", name: "Authentik", type: "oauth" as const }, ], }; - mockAuthService.getAuthConfig.mockReturnValue(mockConfig); + mockAuthService.getAuthConfig.mockResolvedValue(mockConfig); - const result = controller.getConfig(); + const result = await controller.getConfig(); const serialized = JSON.stringify(result); // Assert no secret values leak into the serialized response diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index 5dd76bf..b0157c2 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -97,7 +97,7 @@ export class AuthController { */ @Get("config") @Header("Cache-Control", "public, max-age=300") - getConfig(): AuthConfigResponse { + async getConfig(): Promise { return this.authService.getAuthConfig(); } diff --git a/apps/api/src/auth/auth.service.spec.ts b/apps/api/src/auth/auth.service.spec.ts index a5b4e65..3e464c4 100644 --- a/apps/api/src/auth/auth.service.spec.ts +++ b/apps/api/src/auth/auth.service.spec.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, vi } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { Test, TestingModule } from "@nestjs/testing"; import { AuthService } from "./auth.service"; import { PrismaService } from "../prisma/prisma.service"; @@ -30,6 +30,12 @@ describe("AuthService", () => { vi.clearAllMocks(); }); + afterEach(() => { + vi.restoreAllMocks(); + delete process.env.OIDC_ENABLED; + delete process.env.OIDC_ISSUER; + }); + describe("getAuth", () => { it("should return BetterAuth instance", () => { const auth = service.getAuth(); @@ -90,21 +96,128 @@ describe("AuthService", () => { }); }); + describe("isOidcProviderReachable", () => { + const discoveryUrl = "https://auth.example.com/.well-known/openid-configuration"; + + beforeEach(() => { + process.env.OIDC_ISSUER = "https://auth.example.com/"; + // Reset the cache by accessing private fields via bracket notation + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (service as any).lastHealthCheck = 0; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (service as any).lastHealthResult = false; + }); + + it("should return true when discovery URL returns 200", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + }); + vi.stubGlobal("fetch", mockFetch); + + const result = await service.isOidcProviderReachable(); + + expect(result).toBe(true); + expect(mockFetch).toHaveBeenCalledWith(discoveryUrl, { + signal: expect.any(AbortSignal) as AbortSignal, + }); + }); + + it("should return false on network error", async () => { + const mockFetch = vi.fn().mockRejectedValue(new Error("ECONNREFUSED")); + vi.stubGlobal("fetch", mockFetch); + + const result = await service.isOidcProviderReachable(); + + expect(result).toBe(false); + }); + + it("should return false on timeout", async () => { + const mockFetch = vi.fn().mockRejectedValue(new DOMException("The operation was aborted")); + vi.stubGlobal("fetch", mockFetch); + + const result = await service.isOidcProviderReachable(); + + expect(result).toBe(false); + }); + + it("should return false when discovery URL returns non-200", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 503, + }); + vi.stubGlobal("fetch", mockFetch); + + const result = await service.isOidcProviderReachable(); + + expect(result).toBe(false); + }); + + it("should cache result for 30 seconds", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + }); + vi.stubGlobal("fetch", mockFetch); + + // First call - fetches + const result1 = await service.isOidcProviderReachable(); + expect(result1).toBe(true); + expect(mockFetch).toHaveBeenCalledTimes(1); + + // Second call within 30s - uses cache + const result2 = await service.isOidcProviderReachable(); + expect(result2).toBe(true); + expect(mockFetch).toHaveBeenCalledTimes(1); // Still 1, no new fetch + + // Simulate cache expiry by moving lastHealthCheck back + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (service as any).lastHealthCheck = Date.now() - 31_000; + + // Third call after cache expiry - fetches again + const result3 = await service.isOidcProviderReachable(); + expect(result3).toBe(true); + expect(mockFetch).toHaveBeenCalledTimes(2); // Now 2 + }); + + it("should cache false results too", async () => { + const mockFetch = vi + .fn() + .mockRejectedValueOnce(new Error("ECONNREFUSED")) + .mockResolvedValueOnce({ ok: true, status: 200 }); + vi.stubGlobal("fetch", mockFetch); + + // First call - fails + const result1 = await service.isOidcProviderReachable(); + expect(result1).toBe(false); + expect(mockFetch).toHaveBeenCalledTimes(1); + + // Second call within 30s - returns cached false + const result2 = await service.isOidcProviderReachable(); + expect(result2).toBe(false); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + }); + describe("getAuthConfig", () => { - it("should return only email provider when OIDC is disabled", () => { + it("should return only email provider when OIDC is disabled", async () => { delete process.env.OIDC_ENABLED; - const result = service.getAuthConfig(); + const result = await service.getAuthConfig(); expect(result).toEqual({ providers: [{ id: "email", name: "Email", type: "credentials" }], }); }); - it("should return both email and authentik providers when OIDC is enabled", () => { + it("should return both email and authentik providers when OIDC is enabled and reachable", async () => { process.env.OIDC_ENABLED = "true"; + process.env.OIDC_ISSUER = "https://auth.example.com/"; - const result = service.getAuthConfig(); + const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal("fetch", mockFetch); + + const result = await service.getAuthConfig(); expect(result).toEqual({ providers: [ @@ -112,20 +225,34 @@ describe("AuthService", () => { { id: "authentik", name: "Authentik", type: "oauth" }, ], }); - - delete process.env.OIDC_ENABLED; }); - it("should return only email provider when OIDC_ENABLED is false", () => { + it("should return only email provider when OIDC_ENABLED is false", async () => { process.env.OIDC_ENABLED = "false"; - const result = service.getAuthConfig(); + const result = await service.getAuthConfig(); expect(result).toEqual({ providers: [{ id: "email", name: "Email", type: "credentials" }], }); + }); - delete process.env.OIDC_ENABLED; + it("should omit authentik when OIDC is enabled but provider is unreachable", async () => { + process.env.OIDC_ENABLED = "true"; + process.env.OIDC_ISSUER = "https://auth.example.com/"; + + // Reset cache + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (service as any).lastHealthCheck = 0; + + const mockFetch = vi.fn().mockRejectedValue(new Error("ECONNREFUSED")); + vi.stubGlobal("fetch", mockFetch); + + const result = await service.getAuthConfig(); + + expect(result).toEqual({ + providers: [{ id: "email", name: "Email", type: "credentials" }], + }); }); }); diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index d5012ca..13559df 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -6,12 +6,23 @@ 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; + /** 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 @@ -105,14 +116,59 @@ export class AuthService { } } + /** + * 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 { + 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. */ - getAuthConfig(): AuthConfigResponse { + async getAuthConfig(): Promise { const providers: AuthProviderConfig[] = [{ id: "email", name: "Email", type: "credentials" }]; - if (isOidcEnabled()) { + if (isOidcEnabled() && (await this.isOidcProviderReachable())) { providers.push({ id: "authentik", name: "Authentik", type: "oauth" }); }