diff --git a/backend/src/plugins/auth.ts b/backend/src/plugins/auth.ts index 6784252..b7a39cb 100644 --- a/backend/src/plugins/auth.ts +++ b/backend/src/plugins/auth.ts @@ -47,7 +47,7 @@ export async function getAnonymousUserId(): Promise { export interface AuthState { authEnabled: boolean; registrationEnabled: boolean; - localAuthEnabled: boolean; + formLoginEnabled: boolean; oidcEnabled: boolean; oidcProviderName: string; hasUsers: boolean; @@ -59,15 +59,18 @@ export async function getAuthState(): Promise { const [result] = await db.select({ count: count() }).from(users).where(sql`${users.id} != ${ANONYMOUS_USER_ID}`); const hasUsers = result.count > 0; + const needsSetup = env.AUTH_ENABLED && !hasUsers; + return { authEnabled: env.AUTH_ENABLED, // Registration: enabled via ENV OR no users exist (first-time setup) registrationEnabled: env.REGISTRATION_ENABLED || !hasUsers, - localAuthEnabled: env.AUTH_ENABLED, // Password auth available when auth is enabled + // Form login: enabled when auth + form login are both on, or forced on for first-user setup + formLoginEnabled: needsSetup || (env.AUTH_ENABLED && env.FORM_LOGIN_ENABLED), oidcEnabled: env.OIDC_ENABLED, oidcProviderName: env.OIDC_PROVIDER_NAME, hasUsers, - needsSetup: env.AUTH_ENABLED && !hasUsers, + needsSetup, }; } diff --git a/backend/src/plugins/env.ts b/backend/src/plugins/env.ts index 653f57e..aded744 100644 --- a/backend/src/plugins/env.ts +++ b/backend/src/plugins/env.ts @@ -28,7 +28,11 @@ const EnvSchema = z.object({ .string() .transform((v) => v === "true") .default("false"), - // Disable local auth when using SSO only + // Disable username/password form login (useful for OIDC-only setups) + FORM_LOGIN_ENABLED: z + .string() + .transform((v) => v === "true") + .default("true"), // JWT Secrets - only required when AUTH_ENABLED=true JWT_SECRET: z.string().min(10).optional(), @@ -128,4 +132,26 @@ if (parsed.OIDC_ENABLED) { } } +// Validate that at least one login method is available when auth is enabled +if (parsed.AUTH_ENABLED && !parsed.FORM_LOGIN_ENABLED && !parsed.OIDC_ENABLED) { + console.error("=".repeat(60)); + console.error("AUTHENTICATION CONFIGURATION ERROR"); + console.error("=".repeat(60)); + console.error("AUTH_ENABLED=true but no login method is available."); + console.error("FORM_LOGIN_ENABLED=false and OIDC_ENABLED=false means users cannot log in."); + console.error(""); + console.error("To fix this, either:"); + console.error(" 1. Set FORM_LOGIN_ENABLED=true to allow username/password login"); + console.error(" 2. Set OIDC_ENABLED=true to allow SSO login"); + console.error("=".repeat(60)); + process.exit(1); +} + +// Warn about ineffective registration when form login is disabled +if (parsed.REGISTRATION_ENABLED && !parsed.FORM_LOGIN_ENABLED) { + console.warn( + "[config] REGISTRATION_ENABLED=true has no effect when FORM_LOGIN_ENABLED=false (no registration form available)" + ); +} + export const env = parsed; diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index b0fb925..2148113 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -123,8 +123,8 @@ export async function authRoutes(app: FastifyInstance) { return reply.status(400).send({ error: "Registration is disabled", code: "REGISTRATION_DISABLED" }); } - if (!state.localAuthEnabled) { - return reply.status(400).send({ error: "Local authentication is disabled", code: "LOCAL_AUTH_DISABLED" }); + if (!state.formLoginEnabled) { + return reply.status(400).send({ error: "Form login is disabled", code: "FORM_LOGIN_DISABLED" }); } // Validate input @@ -185,8 +185,8 @@ export async function authRoutes(app: FastifyInstance) { return reply.status(400).send({ error: "Authentication is disabled", code: "AUTH_DISABLED" }); } - if (!state.localAuthEnabled) { - return reply.status(400).send({ error: "Local authentication is disabled", code: "LOCAL_AUTH_DISABLED" }); + if (!state.formLoginEnabled) { + return reply.status(400).send({ error: "Form login is disabled", code: "FORM_LOGIN_DISABLED" }); } const parsed = loginSchema.safeParse(request.body); diff --git a/backend/src/test/auth.test.ts b/backend/src/test/auth.test.ts index b791c5c..1111162 100644 --- a/backend/src/test/auth.test.ts +++ b/backend/src/test/auth.test.ts @@ -28,7 +28,7 @@ vi.mock("../db/client.js", () => ({ vi.mock("../plugins/env.js", () => ({ env: { AUTH_ENABLED: true, - LOCAL_AUTH_ENABLED: true, + FORM_LOGIN_ENABLED: true, REGISTRATION_ENABLED: true, OIDC_ENABLED: false, NODE_ENV: "test", @@ -144,7 +144,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => { const data = response.json(); expect(data.authEnabled).toBe(true); expect(data.registrationEnabled).toBe(true); - expect(data.localAuthEnabled).toBe(true); + expect(data.formLoginEnabled).toBe(true); }); }); diff --git a/frontend/src/components/Auth.tsx b/frontend/src/components/Auth.tsx index 6d5f4ac..2bc46a6 100644 --- a/frontend/src/components/Auth.tsx +++ b/frontend/src/components/Auth.tsx @@ -20,7 +20,7 @@ export interface User { export interface AuthState { authEnabled: boolean; registrationEnabled: boolean; - localAuthEnabled: boolean; + formLoginEnabled: boolean; oidcEnabled: boolean; oidcProviderName: string; hasUsers: boolean; @@ -425,7 +425,7 @@ export function LoginForm({ {t("auth.loginWithSSO", "Login with {{provider}}", { provider: authState.oidcProviderName || "SSO" })} - {authState?.localAuthEnabled && ( + {authState?.formLoginEnabled && (
{t("auth.or", "or")}
@@ -434,7 +434,7 @@ export function LoginForm({ )} {/* Local Login Form - only show if local auth is enabled */} - {authState?.localAuthEnabled && ( + {authState?.formLoginEnabled && (
{error &&
{error}
} @@ -474,7 +474,7 @@ export function LoginForm({
)} - {authState?.registrationEnabled && authState?.localAuthEnabled && onSwitchToRegister && ( + {authState?.registrationEnabled && authState?.formLoginEnabled && onSwitchToRegister && (
- {authState?.localAuthEnabled && ( + {authState?.formLoginEnabled && (
{t("auth.or", "or")}
@@ -549,7 +549,7 @@ export function RegisterForm({ onSuccess, onSwitchToLogin }: { onSuccess?: () => )} {/* Local Registration Form - only show if local auth is enabled */} - {authState?.localAuthEnabled && ( + {authState?.formLoginEnabled && (
{error &&
{error}
} diff --git a/frontend/src/test/components/AppHeader.test.tsx b/frontend/src/test/components/AppHeader.test.tsx index 7bc4b19..79dbbb9 100644 --- a/frontend/src/test/components/AppHeader.test.tsx +++ b/frontend/src/test/components/AppHeader.test.tsx @@ -36,7 +36,7 @@ describe("AppHeader", () => { json: () => Promise.resolve({ authEnabled: false, - localAuthEnabled: true, + formLoginEnabled: true, hasUsers: false, needsSetup: false, }), @@ -171,7 +171,7 @@ describe("AppHeader", () => { json: () => Promise.resolve({ authEnabled: false, - localAuthEnabled: true, + formLoginEnabled: true, hasUsers: false, needsSetup: false, }), @@ -205,7 +205,7 @@ describe("AppHeader", () => { json: () => Promise.resolve({ authEnabled: false, - localAuthEnabled: true, + formLoginEnabled: true, hasUsers: false, needsSetup: false, }), @@ -239,7 +239,7 @@ describe("AppHeader", () => { json: () => Promise.resolve({ authEnabled: false, - localAuthEnabled: true, + formLoginEnabled: true, hasUsers: false, needsSetup: false, }), @@ -322,7 +322,7 @@ describe("AppHeader", () => { Promise.resolve({ authEnabled: true, registrationEnabled: true, - localAuthEnabled: true, + formLoginEnabled: true, oidcEnabled: false, oidcProviderName: "", hasUsers: true, diff --git a/frontend/src/test/components/Auth.test.tsx b/frontend/src/test/components/Auth.test.tsx index a429b9f..45c3a3b 100644 --- a/frontend/src/test/components/Auth.test.tsx +++ b/frontend/src/test/components/Auth.test.tsx @@ -11,7 +11,7 @@ describe("AuthProvider", () => { vi.resetAllMocks(); (global.fetch as ReturnType).mockResolvedValue({ ok: true, - json: () => Promise.resolve({ authEnabled: true, localAuthEnabled: true }), + json: () => Promise.resolve({ authEnabled: true, formLoginEnabled: true }), }); }); @@ -79,7 +79,7 @@ describe("AuthProvider", () => { (global.fetch as ReturnType) .mockResolvedValueOnce({ ok: true, - json: () => Promise.resolve({ authEnabled: false, localAuthEnabled: true }), + json: () => Promise.resolve({ authEnabled: false, formLoginEnabled: true }), }) .mockResolvedValueOnce({ ok: false, status: 401 }) .mockResolvedValueOnce({ ok: true, status: 200 }) @@ -116,7 +116,7 @@ describe("AuthProvider", () => { (global.fetch as ReturnType) .mockResolvedValueOnce({ ok: true, - json: () => Promise.resolve({ authEnabled: true, localAuthEnabled: true }), + json: () => Promise.resolve({ authEnabled: true, formLoginEnabled: true }), }) .mockResolvedValueOnce({ ok: true, status: 200, json: () => Promise.resolve({ id: 1, username: "tester" }) }) .mockResolvedValueOnce({ ok: false, status: 401 }) @@ -141,7 +141,7 @@ describe("AuthProvider", () => { (global.fetch as ReturnType) .mockResolvedValueOnce({ ok: true, - json: () => Promise.resolve({ authEnabled: true, localAuthEnabled: true }), + json: () => Promise.resolve({ authEnabled: true, formLoginEnabled: true }), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 1, username: "timer-user" }) }) .mockResolvedValueOnce({ ok: true, status: 200 }); @@ -167,7 +167,7 @@ describe("AuthProvider", () => { describe("LoginForm", () => { const mockAuthState = { authEnabled: true, - localAuthEnabled: true, + formLoginEnabled: true, oidcEnabled: false, registrationEnabled: true, hasUsers: true, @@ -281,7 +281,7 @@ describe("LoginForm", () => { json: () => Promise.resolve({ authEnabled: false, - localAuthEnabled: true, + formLoginEnabled: true, oidcEnabled: false, registrationEnabled: true, hasUsers: true, @@ -317,7 +317,7 @@ describe("LoginForm", () => { describe("RegisterForm", () => { const mockAuthState = { authEnabled: true, - localAuthEnabled: true, + formLoginEnabled: true, oidcEnabled: false, registrationEnabled: true, hasUsers: false, @@ -404,7 +404,7 @@ describe("RegisterForm", () => { json: () => Promise.resolve({ authEnabled: true, - localAuthEnabled: true, + formLoginEnabled: true, oidcEnabled: false, registrationEnabled: true, hasUsers: false, @@ -439,7 +439,7 @@ describe("RegisterForm", () => { describe("AuthPage", () => { const mockAuthState = { authEnabled: true, - localAuthEnabled: true, + formLoginEnabled: true, oidcEnabled: false, registrationEnabled: true, hasUsers: true, @@ -504,7 +504,7 @@ describe("UserProfile", () => { (global.fetch as ReturnType) .mockResolvedValueOnce({ ok: true, - json: () => Promise.resolve({ authEnabled: true, localAuthEnabled: true }), + json: () => Promise.resolve({ authEnabled: true, formLoginEnabled: true }), }) .mockResolvedValueOnce({ ok: true, @@ -724,7 +724,7 @@ describe("AuthProvider methods", () => { it("refreshUser retries after token refresh on 401", async () => { vi.clearAllMocks(); (global.fetch as ReturnType) - .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ authEnabled: false, localAuthEnabled: true }) }) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ authEnabled: false, formLoginEnabled: true }) }) .mockResolvedValueOnce({ ok: false, status: 401 }) .mockResolvedValueOnce({ ok: true }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 1, username: "refreshed-user" }) });