fix(#337): Validate OIDC configuration at startup, fail fast if missing
- Add OIDC_ENABLED environment variable to control OIDC authentication - Validate required OIDC env vars (OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET) are present when OIDC is enabled - Validate OIDC_ISSUER ends with trailing slash for correct discovery URL - Throw descriptive error at startup if configuration is invalid - Skip OIDC plugin registration when OIDC is disabled - Add comprehensive tests for validation logic (17 test cases) Refs #337 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -49,7 +49,12 @@ KNOWLEDGE_CACHE_TTL=300
|
|||||||
# ======================
|
# ======================
|
||||||
# Authentication (Authentik OIDC)
|
# Authentication (Authentik OIDC)
|
||||||
# ======================
|
# ======================
|
||||||
# Authentik Server URLs
|
# Set to 'true' to enable OIDC authentication with Authentik
|
||||||
|
# When enabled, OIDC_ISSUER, OIDC_CLIENT_ID, and OIDC_CLIENT_SECRET are required
|
||||||
|
OIDC_ENABLED=false
|
||||||
|
|
||||||
|
# Authentik Server URLs (required when OIDC_ENABLED=true)
|
||||||
|
# OIDC_ISSUER must end with a trailing slash (/)
|
||||||
OIDC_ISSUER=https://auth.example.com/application/o/mosaic-stack/
|
OIDC_ISSUER=https://auth.example.com/application/o/mosaic-stack/
|
||||||
OIDC_CLIENT_ID=your-client-id-here
|
OIDC_CLIENT_ID=your-client-id-here
|
||||||
OIDC_CLIENT_SECRET=your-client-secret-here
|
OIDC_CLIENT_SECRET=your-client-secret-here
|
||||||
|
|||||||
138
apps/api/src/auth/auth.config.spec.ts
Normal file
138
apps/api/src/auth/auth.config.spec.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
import { isOidcEnabled, validateOidcConfig } from "./auth.config";
|
||||||
|
|
||||||
|
describe("auth.config", () => {
|
||||||
|
// Store original env vars to restore after each test
|
||||||
|
const originalEnv = { ...process.env };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Clear relevant env vars before each test
|
||||||
|
delete process.env.OIDC_ENABLED;
|
||||||
|
delete process.env.OIDC_ISSUER;
|
||||||
|
delete process.env.OIDC_CLIENT_ID;
|
||||||
|
delete process.env.OIDC_CLIENT_SECRET;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Restore original env vars
|
||||||
|
process.env = { ...originalEnv };
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isOidcEnabled", () => {
|
||||||
|
it("should return false when OIDC_ENABLED is not set", () => {
|
||||||
|
expect(isOidcEnabled()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false when OIDC_ENABLED is 'false'", () => {
|
||||||
|
process.env.OIDC_ENABLED = "false";
|
||||||
|
expect(isOidcEnabled()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false when OIDC_ENABLED is '0'", () => {
|
||||||
|
process.env.OIDC_ENABLED = "0";
|
||||||
|
expect(isOidcEnabled()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false when OIDC_ENABLED is empty string", () => {
|
||||||
|
process.env.OIDC_ENABLED = "";
|
||||||
|
expect(isOidcEnabled()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return true when OIDC_ENABLED is 'true'", () => {
|
||||||
|
process.env.OIDC_ENABLED = "true";
|
||||||
|
expect(isOidcEnabled()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return true when OIDC_ENABLED is '1'", () => {
|
||||||
|
process.env.OIDC_ENABLED = "1";
|
||||||
|
expect(isOidcEnabled()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validateOidcConfig", () => {
|
||||||
|
describe("when OIDC is disabled", () => {
|
||||||
|
it("should not throw when OIDC_ENABLED is not set", () => {
|
||||||
|
expect(() => validateOidcConfig()).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not throw when OIDC_ENABLED is false even if vars are missing", () => {
|
||||||
|
process.env.OIDC_ENABLED = "false";
|
||||||
|
// Intentionally not setting any OIDC vars
|
||||||
|
expect(() => validateOidcConfig()).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when OIDC is enabled", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.OIDC_ENABLED = "true";
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw when OIDC_ISSUER is missing", () => {
|
||||||
|
process.env.OIDC_CLIENT_ID = "test-client-id";
|
||||||
|
process.env.OIDC_CLIENT_SECRET = "test-client-secret";
|
||||||
|
|
||||||
|
expect(() => validateOidcConfig()).toThrow("OIDC_ISSUER");
|
||||||
|
expect(() => validateOidcConfig()).toThrow("OIDC authentication is enabled");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw when OIDC_CLIENT_ID is missing", () => {
|
||||||
|
process.env.OIDC_ISSUER = "https://auth.example.com/";
|
||||||
|
process.env.OIDC_CLIENT_SECRET = "test-client-secret";
|
||||||
|
|
||||||
|
expect(() => validateOidcConfig()).toThrow("OIDC_CLIENT_ID");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw when OIDC_CLIENT_SECRET is missing", () => {
|
||||||
|
process.env.OIDC_ISSUER = "https://auth.example.com/";
|
||||||
|
process.env.OIDC_CLIENT_ID = "test-client-id";
|
||||||
|
|
||||||
|
expect(() => validateOidcConfig()).toThrow("OIDC_CLIENT_SECRET");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw when all required vars are missing", () => {
|
||||||
|
expect(() => validateOidcConfig()).toThrow(
|
||||||
|
"OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw when vars are empty strings", () => {
|
||||||
|
process.env.OIDC_ISSUER = "";
|
||||||
|
process.env.OIDC_CLIENT_ID = "";
|
||||||
|
process.env.OIDC_CLIENT_SECRET = "";
|
||||||
|
|
||||||
|
expect(() => validateOidcConfig()).toThrow(
|
||||||
|
"OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw when vars are whitespace only", () => {
|
||||||
|
process.env.OIDC_ISSUER = " ";
|
||||||
|
process.env.OIDC_CLIENT_ID = "test-client-id";
|
||||||
|
process.env.OIDC_CLIENT_SECRET = "test-client-secret";
|
||||||
|
|
||||||
|
expect(() => validateOidcConfig()).toThrow("OIDC_ISSUER");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw when OIDC_ISSUER does not end with trailing slash", () => {
|
||||||
|
process.env.OIDC_ISSUER = "https://auth.example.com/application/o/mosaic";
|
||||||
|
process.env.OIDC_CLIENT_ID = "test-client-id";
|
||||||
|
process.env.OIDC_CLIENT_SECRET = "test-client-secret";
|
||||||
|
|
||||||
|
expect(() => validateOidcConfig()).toThrow("OIDC_ISSUER must end with a trailing slash");
|
||||||
|
expect(() => validateOidcConfig()).toThrow("https://auth.example.com/application/o/mosaic");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not throw with valid complete configuration", () => {
|
||||||
|
process.env.OIDC_ISSUER = "https://auth.example.com/application/o/mosaic-stack/";
|
||||||
|
process.env.OIDC_CLIENT_ID = "test-client-id";
|
||||||
|
process.env.OIDC_CLIENT_SECRET = "test-client-secret";
|
||||||
|
|
||||||
|
expect(() => validateOidcConfig()).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should suggest disabling OIDC in error message", () => {
|
||||||
|
expect(() => validateOidcConfig()).toThrow("OIDC_ENABLED=false");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,7 +3,85 @@ import { prismaAdapter } from "better-auth/adapters/prisma";
|
|||||||
import { genericOAuth } from "better-auth/plugins";
|
import { genericOAuth } from "better-auth/plugins";
|
||||||
import type { PrismaClient } from "@prisma/client";
|
import type { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Required OIDC environment variables when OIDC is enabled
|
||||||
|
*/
|
||||||
|
const REQUIRED_OIDC_ENV_VARS = ["OIDC_ISSUER", "OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET"] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if OIDC authentication is enabled via environment variable
|
||||||
|
*/
|
||||||
|
export function isOidcEnabled(): boolean {
|
||||||
|
const enabled = process.env.OIDC_ENABLED;
|
||||||
|
return enabled === "true" || enabled === "1";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates OIDC configuration at startup.
|
||||||
|
* Throws an error if OIDC is enabled but required environment variables are missing.
|
||||||
|
*
|
||||||
|
* @throws Error if OIDC is enabled but required vars are missing or empty
|
||||||
|
*/
|
||||||
|
export function validateOidcConfig(): void {
|
||||||
|
if (!isOidcEnabled()) {
|
||||||
|
// OIDC is disabled, no validation needed
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const missingVars: string[] = [];
|
||||||
|
|
||||||
|
for (const envVar of REQUIRED_OIDC_ENV_VARS) {
|
||||||
|
const value = process.env[envVar];
|
||||||
|
if (!value || value.trim() === "") {
|
||||||
|
missingVars.push(envVar);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missingVars.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`OIDC authentication is enabled (OIDC_ENABLED=true) but required environment variables are missing or empty: ${missingVars.join(", ")}. ` +
|
||||||
|
`Either set these variables or disable OIDC by setting OIDC_ENABLED=false.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional validation: OIDC_ISSUER should end with a trailing slash for proper discovery URL
|
||||||
|
const issuer = process.env.OIDC_ISSUER;
|
||||||
|
if (issuer && !issuer.endsWith("/")) {
|
||||||
|
throw new Error(
|
||||||
|
`OIDC_ISSUER must end with a trailing slash (/). Current value: "${issuer}". ` +
|
||||||
|
`The discovery URL is constructed by appending ".well-known/openid-configuration" to the issuer.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get OIDC plugins configuration.
|
||||||
|
* Returns empty array if OIDC is disabled, otherwise returns configured OAuth plugin.
|
||||||
|
*/
|
||||||
|
function getOidcPlugins(): ReturnType<typeof genericOAuth>[] {
|
||||||
|
if (!isOidcEnabled()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
genericOAuth({
|
||||||
|
config: [
|
||||||
|
{
|
||||||
|
providerId: "authentik",
|
||||||
|
clientId: process.env.OIDC_CLIENT_ID ?? "",
|
||||||
|
clientSecret: process.env.OIDC_CLIENT_SECRET ?? "",
|
||||||
|
discoveryUrl: `${process.env.OIDC_ISSUER ?? ""}.well-known/openid-configuration`,
|
||||||
|
scopes: ["openid", "profile", "email"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
export function createAuth(prisma: PrismaClient) {
|
export function createAuth(prisma: PrismaClient) {
|
||||||
|
// Validate OIDC configuration at startup - fail fast if misconfigured
|
||||||
|
validateOidcConfig();
|
||||||
|
|
||||||
return betterAuth({
|
return betterAuth({
|
||||||
database: prismaAdapter(prisma, {
|
database: prismaAdapter(prisma, {
|
||||||
provider: "postgresql",
|
provider: "postgresql",
|
||||||
@@ -11,19 +89,7 @@ export function createAuth(prisma: PrismaClient) {
|
|||||||
emailAndPassword: {
|
emailAndPassword: {
|
||||||
enabled: true, // Enable for now, can be disabled later
|
enabled: true, // Enable for now, can be disabled later
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [...getOidcPlugins()],
|
||||||
genericOAuth({
|
|
||||||
config: [
|
|
||||||
{
|
|
||||||
providerId: "authentik",
|
|
||||||
clientId: process.env.OIDC_CLIENT_ID ?? "",
|
|
||||||
clientSecret: process.env.OIDC_CLIENT_SECRET ?? "",
|
|
||||||
discoveryUrl: `${process.env.OIDC_ISSUER ?? ""}.well-known/openid-configuration`,
|
|
||||||
scopes: ["openid", "profile", "email"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
session: {
|
session: {
|
||||||
expiresIn: 60 * 60 * 24, // 24 hours
|
expiresIn: 60 * 60 * 24, // 24 hours
|
||||||
updateAge: 60 * 60 * 24, // 24 hours
|
updateAge: 60 * 60 * 24, // 24 hours
|
||||||
|
|||||||
Reference in New Issue
Block a user