feat(#413): add OIDC provider health check with 30s cache
All checks were successful
ci/woodpecker/push/api Pipeline was successful
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:
@@ -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<string, string> = {
|
||||
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
|
||||
|
||||
@@ -97,7 +97,7 @@ export class AuthController {
|
||||
*/
|
||||
@Get("config")
|
||||
@Header("Cache-Control", "public, max-age=300")
|
||||
getConfig(): AuthConfigResponse {
|
||||
async getConfig(): Promise<AuthConfigResponse> {
|
||||
return this.authService.getAuthConfig();
|
||||
}
|
||||
|
||||
|
||||
@@ -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" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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<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
|
||||
@@ -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.
|
||||
* 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" }];
|
||||
|
||||
if (isOidcEnabled()) {
|
||||
if (isOidcEnabled() && (await this.isOidcProviderReachable())) {
|
||||
providers.push({ id: "authentik", name: "Authentik", type: "oauth" });
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user