feat(#413): add OIDC provider health check with 30s cache
All checks were successful
ci/woodpecker/push/api Pipeline was successful

- 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 <noreply@anthropic.com>
This commit is contained in:
Jason Woltje
2026-02-16 11:20:05 -06:00
parent d2605196ac
commit 3b2356f5a0
4 changed files with 205 additions and 22 deletions

View File

@@ -126,28 +126,28 @@ describe("AuthController", () => {
}); });
describe("getConfig", () => { describe("getConfig", () => {
it("should return auth config from service", () => { it("should return auth config from service", async () => {
const mockConfig = { const mockConfig = {
providers: [ providers: [
{ id: "email", name: "Email", type: "credentials" as const }, { id: "email", name: "Email", type: "credentials" as const },
{ id: "authentik", name: "Authentik", type: "oauth" 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(result).toEqual(mockConfig);
expect(mockAuthService.getAuthConfig).toHaveBeenCalled(); 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 = { const mockConfig = {
providers: [{ id: "email", name: "Email", type: "credentials" as const }], 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).toEqual(mockConfig);
expect(result.providers).toHaveLength(1); 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 // Set ALL sensitive environment variables with known values
const sensitiveEnv: Record<string, string> = { const sensitiveEnv: Record<string, string> = {
OIDC_CLIENT_SECRET: "test-client-secret", OIDC_CLIENT_SECRET: "test-client-secret",
@@ -186,9 +186,9 @@ describe("AuthController", () => {
{ id: "authentik", name: "Authentik", type: "oauth" 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();
const serialized = JSON.stringify(result); const serialized = JSON.stringify(result);
// Assert no secret values leak into the serialized response // Assert no secret values leak into the serialized response

View File

@@ -97,7 +97,7 @@ export class AuthController {
*/ */
@Get("config") @Get("config")
@Header("Cache-Control", "public, max-age=300") @Header("Cache-Control", "public, max-age=300")
getConfig(): AuthConfigResponse { async getConfig(): Promise<AuthConfigResponse> {
return this.authService.getAuthConfig(); return this.authService.getAuthConfig();
} }

View File

@@ -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 { Test, TestingModule } from "@nestjs/testing";
import { AuthService } from "./auth.service"; import { AuthService } from "./auth.service";
import { PrismaService } from "../prisma/prisma.service"; import { PrismaService } from "../prisma/prisma.service";
@@ -30,6 +30,12 @@ describe("AuthService", () => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
afterEach(() => {
vi.restoreAllMocks();
delete process.env.OIDC_ENABLED;
delete process.env.OIDC_ISSUER;
});
describe("getAuth", () => { describe("getAuth", () => {
it("should return BetterAuth instance", () => { it("should return BetterAuth instance", () => {
const auth = service.getAuth(); 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", () => { 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; delete process.env.OIDC_ENABLED;
const result = service.getAuthConfig(); const result = await service.getAuthConfig();
expect(result).toEqual({ expect(result).toEqual({
providers: [{ id: "email", name: "Email", type: "credentials" }], 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_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({ expect(result).toEqual({
providers: [ providers: [
@@ -112,20 +225,34 @@ describe("AuthService", () => {
{ id: "authentik", name: "Authentik", type: "oauth" }, { 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"; process.env.OIDC_ENABLED = "false";
const result = service.getAuthConfig(); const result = await service.getAuthConfig();
expect(result).toEqual({ expect(result).toEqual({
providers: [{ id: "email", name: "Email", type: "credentials" }], 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" }],
});
}); });
}); });

View File

@@ -6,12 +6,23 @@ import type { AuthConfigResponse, AuthProviderConfig } from "@mosaic/shared";
import { PrismaService } from "../prisma/prisma.service"; import { PrismaService } from "../prisma/prisma.service";
import { createAuth, isOidcEnabled, type Auth } from "./auth.config"; 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() @Injectable()
export class AuthService { export class AuthService {
private readonly logger = new Logger(AuthService.name); private readonly logger = new Logger(AuthService.name);
private readonly auth: Auth; private readonly auth: Auth;
private readonly nodeHandler: (req: IncomingMessage, res: ServerResponse) => Promise<void>; 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) { constructor(private readonly prisma: PrismaService) {
// PrismaService extends PrismaClient and is compatible with BetterAuth's adapter // PrismaService extends PrismaClient and is compatible with BetterAuth's adapter
// Cast is safe as PrismaService provides all required PrismaClient methods // 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<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. * Get authentication configuration for the frontend.
* Returns available auth providers so the UI can render login options dynamically. * 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<AuthConfigResponse> {
const providers: AuthProviderConfig[] = [{ id: "email", name: "Email", type: "credentials" }]; const providers: AuthProviderConfig[] = [{ id: "email", name: "Email", type: "credentials" }];
if (isOidcEnabled()) { if (isOidcEnabled() && (await this.isOidcProviderReachable())) {
providers.push({ id: "authentik", name: "Authentik", type: "oauth" }); providers.push({ id: "authentik", name: "Authentik", type: "oauth" });
} }