diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index 40f74e61f9..9abe7bbf1d 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -348,11 +348,6 @@ "count": 1 } }, - "src/app/api/auth/login/route.ts": { - "no-restricted-imports": { - "count": 1 - } - }, "src/app/api/auth/oidc/callback/route.ts": { "no-restricted-imports": { "count": 1 @@ -813,14 +808,10 @@ "count": 1 } }, - "src/app/api/settings/require-login/route.ts": { - "no-restricted-imports": { - "count": 1 - } - }, + "src/app/api/settings/route.ts": { "no-restricted-imports": { - "count": 2 + "count": 1 } }, "src/app/api/settings/system-prompt/route.ts": { @@ -1616,11 +1607,7 @@ "count": 11 } }, - "tests/unit/auth-login-route.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, + "tests/unit/auth-ollama-cloud-per-model-403-3027.test.ts": { "@typescript-eslint/no-explicit-any": { "count": 11 @@ -2499,11 +2486,7 @@ "count": 12 } }, - "tests/unit/login-bootstrap-route.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 10 - } - }, + "tests/unit/management-password.test.ts": { "@typescript-eslint/no-explicit-any": { "count": 4 diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index a0c1504560..8855c1a8c8 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance/index"; import { classifyIpScope } from "@/lib/ipUtils"; -import { getCachedSettings } from "@/lib/localDb"; +import { getCachedSettings } from "@/lib/db/settings"; import { SignJWT } from "jose"; import { cookies } from "next/headers"; import { @@ -9,6 +9,7 @@ import { getStoredManagementPassword, verifyManagementPassword, } from "@/lib/auth/managementPassword"; +import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; import { loginSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { checkLoginGuard, clearLoginAttempts, recordLoginFailure } from "@/server/auth/loginGuard"; @@ -74,8 +75,32 @@ export async function POST(request) { return NextResponse.json({ error: "Invalid password payload" }, { status: 400 }); } const settings = await getCachedSettings(); - const bruteForceEnabled = settings.bruteForceProtection !== false; const clientIp = auditContext.ipAddress || null; + const oidcDisabledPassword = + settings.oidcEnabled === true && + (settings.oidcDisablePasswordLogin === true || + isFeatureFlagEnabled("OMNIROUTE_OIDC_DISABLE_PASSWORD_LOGIN") || + process.env.OMNIROUTE_OIDC_DISABLE_PASSWORD_LOGIN === "true" || + process.env.OIDC_DISABLE_PASSWORD_LOGIN === "true"); + + if (oidcDisabledPassword) { + logAuditEvent({ + action: "auth.login.password_disabled_by_oidc", + actor: "anonymous", + target: "dashboard-auth", + resourceType: "auth_session", + status: "failed", + ipAddress: clientIp || undefined, + requestId: auditContext.requestId, + metadata: { reason: "password_login_disabled_when_oidc_active" }, + }); + return NextResponse.json( + { error: "Password login is disabled when OIDC is active. Please sign in with OIDC." }, + { status: 403 } + ); + } + + const bruteForceEnabled = settings.bruteForceProtection !== false; const guardCheck = checkLoginGuard(clientIp, { enabled: bruteForceEnabled }); if (!guardCheck.allowed) { diff --git a/src/app/api/settings/require-login/route.ts b/src/app/api/settings/require-login/route.ts index 8b1f9e8e63..570221409d 100644 --- a/src/app/api/settings/require-login/route.ts +++ b/src/app/api/settings/require-login/route.ts @@ -1,7 +1,8 @@ import { NextResponse } from "next/server"; import { cookies } from "next/headers"; import { jwtVerify } from "jose"; -import { getSettings, updateSettings } from "@/lib/localDb"; +import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; +import { getSettings, updateSettings } from "@/lib/db/settings"; import { hasManagementPasswordConfigured, hashManagementPassword, @@ -52,12 +53,19 @@ export async function GET() { const hasPassword = hasManagementPasswordConfigured(settings); const setupComplete = !!settings.setupComplete; const oidcEnabled = !!settings.oidcEnabled; + const oidcDisablePasswordLogin = + oidcEnabled && + (settings.oidcDisablePasswordLogin === true || + isFeatureFlagEnabled("OMNIROUTE_OIDC_DISABLE_PASSWORD_LOGIN") || + process.env.OMNIROUTE_OIDC_DISABLE_PASSWORD_LOGIN === "true" || + process.env.OIDC_DISABLE_PASSWORD_LOGIN === "true"); return NextResponse.json({ authenticated, requireLogin, hasPassword, setupComplete, oidcEnabled, + oidcDisablePasswordLogin, ...nodeInfo, }); } catch (error) { @@ -69,6 +77,7 @@ export async function GET() { hasPassword: true, setupComplete: true, oidcEnabled: false, + oidcDisablePasswordLogin: false, ...nodeInfo, }, { status: 200 } diff --git a/src/app/api/settings/route.ts b/src/app/api/settings/route.ts index 3c662c0f78..abb0c32e71 100644 --- a/src/app/api/settings/route.ts +++ b/src/app/api/settings/route.ts @@ -1,7 +1,11 @@ import { NextResponse } from "next/server"; import { z } from "zod"; -import { getSettings, getSettingsRevision, updateSettings } from "@/lib/localDb"; -import { SettingsRevisionConflictError } from "@/lib/db/settings"; +import { + getSettings, + getSettingsRevision, + updateSettings, + SettingsRevisionConflictError, +} from "@/lib/db/settings"; import { getRuntimePorts } from "@/lib/runtime/ports"; import { updateSettingsSchema } from "@/shared/validation/settingsSchemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; @@ -118,6 +122,7 @@ const SECURITY_IMPACTING_KEYS = [ "requireLogin", "newPassword", "oidcEnabled", + "oidcDisablePasswordLogin", "oidcClientSecret", ] as const; diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index ed195a0ec3..f2c70306f3 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -11,9 +11,10 @@ export default function LoginPage() { const [password, setPassword] = useState(""); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); - const [hasPassword, setHasPassword] = useState(null); - const [setupComplete, setSetupComplete] = useState(null); + const [hasPassword, setHasPassword] = useState(null); + const [setupComplete, setSetupComplete] = useState(null); const [oidcEnabled, setOidcEnabled] = useState(null); + const [oidcDisablePasswordLogin, setOidcDisablePasswordLogin] = useState(null); const [mounted, setMounted] = useState(false); const [nodeVersion, setNodeVersion] = useState(null); const [nodeCompatible, setNodeCompatible] = useState(true); @@ -44,16 +45,19 @@ export default function LoginPage() { setHasPassword(!!data.hasPassword); setSetupComplete(!!data.setupComplete); setOidcEnabled(!!data.oidcEnabled); + setOidcDisablePasswordLogin(!!data.oidcDisablePasswordLogin); } else { setHasPassword(true); setSetupComplete(true); setOidcEnabled(false); + setOidcDisablePasswordLogin(false); } } catch (err) { clearTimeout(timeoutId); setHasPassword(true); setSetupComplete(true); setOidcEnabled(false); + setOidcDisablePasswordLogin(false); } } checkAuth(); @@ -122,7 +126,12 @@ export default function LoginPage() { ) : null; - if (hasPassword === null || setupComplete === null || oidcEnabled === null) { + if ( + hasPassword === null || + setupComplete === null || + oidcEnabled === null || + (oidcEnabled && oidcDisablePasswordLogin === null) + ) { return (
{nodeWarningBanner} @@ -235,60 +244,84 @@ export default function LoginPage() {

{t("signIn")}

-

{t("enterPassword")}

+

+ {oidcEnabled && oidcDisablePasswordLogin + ? t("continueWithOidc") + : t("enterPassword")} +

-
-
- - setPassword(e.target.value)} - required - autoFocus - className="h-11" - /> - {error && ( -

- error - {error} -

- )} -

{t("defaultPasswordHint")}

-
- - -
- {oidcEnabled && ( -
+ {oidcEnabled && oidcDisablePasswordLogin ? ( +
+ ) : ( + <> +
+
+ + setPassword(e.target.value)} + required + autoFocus + className="h-11" + /> + {error && ( +

+ error + {error} +

+ )} +

{t("defaultPasswordHint")}

+
+ + +
+ + {oidcEnabled && ( +
+ +
+ )} + )} - + {!oidcEnabled && ( + + )}
diff --git a/src/lib/db/settings.ts b/src/lib/db/settings.ts index edccc2da85..ce2b889066 100644 --- a/src/lib/db/settings.ts +++ b/src/lib/db/settings.ts @@ -162,6 +162,7 @@ export async function getSettings() { antigravitySignatureCacheMode: "enabled", requireLogin: true, oidcEnabled: false, + oidcDisablePasswordLogin: false, oidcIssuer: "", oidcClientId: "", oidcClientSecret: "", diff --git a/src/shared/constants/featureFlagDefinitions.ts b/src/shared/constants/featureFlagDefinitions.ts index 37861d2994..afd69f8bf3 100644 --- a/src/shared/constants/featureFlagDefinitions.ts +++ b/src/shared/constants/featureFlagDefinitions.ts @@ -109,9 +109,9 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [ key: "AUTH_LOG_INCLUDE_ACCOUNT_ID", label: "Log Account IDs", description: - "Include the account ID prefix in AUTH log lines (e.g. \"Using account: abc12345...\"). " + - "Disabled by default so the account identifier is redacted in shared/multi-tenant process logs. " + - "Independent of Debug Mode — flipping Debug Mode on does not reveal this.", + 'Include account prefix in AUTH log lines (e.g. "Using account: abc12345..."). ' + + "Disabled by default so account identifiers are redacted from shared/multi-tenant process logs. " + + "Independent from Debug Mode; flipping Debug Mode does not reveal this.", descriptionI18nKey: "featureFlagAuthLogIncludeAccountIdDescription", category: "security", defaultValue: "false", @@ -119,6 +119,18 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [ requiresRestart: false, warningLevel: "info", }, + { + key: "OMNIROUTE_OIDC_DISABLE_PASSWORD_LOGIN", + label: "Disable Password Login With OIDC", + description: + "When OIDC is enabled, disable password login so users can only authenticate via OIDC Single Sign-On. When disabled (default), both password login and OIDC are available.", + descriptionI18nKey: "featureFlagOidcDisablePasswordLoginDescription", + category: "security", + defaultValue: "false", + type: "boolean", + requiresRestart: false, + warningLevel: "info", + }, // ──────────────── Network (7) ──────────────── { key: "ENABLE_TLS_FINGERPRINT", diff --git a/src/shared/validation/settingsSchemas.ts b/src/shared/validation/settingsSchemas.ts index 7bfc8c031f..008cba1bb0 100644 --- a/src/shared/validation/settingsSchemas.ts +++ b/src/shared/validation/settingsSchemas.ts @@ -108,6 +108,7 @@ export const updateSettingsSchema = z.object({ language: z.string().max(10).optional(), requireLogin: z.boolean().optional(), oidcEnabled: z.boolean().optional(), + oidcDisablePasswordLogin: z.boolean().optional(), oidcIssuer: z.string().max(500).optional(), oidcClientId: z.string().max(200).optional(), oidcClientSecret: z.string().max(500).optional(), diff --git a/tests/unit/auth-login-route.test.ts b/tests/unit/auth-login-route.test.ts index 61aa3c9332..b44f54a3fc 100644 --- a/tests/unit/auth-login-route.test.ts +++ b/tests/unit/auth-login-route.test.ts @@ -102,7 +102,7 @@ test("auth login route lazily migrates INITIAL_PASSWORD to a persisted hash befo assert.equal( await managementPassword.verifyManagementPassword( "bootstrap-secret", - (settings as any).password + (settings as Record).password as string ), true ); @@ -133,3 +133,24 @@ test("auth login route sets a bounded maxAge on the auth_token cookie (Seg3)", a assert.equal(options.httpOnly, true); assert.equal(options.path, "/"); }); + +test("auth login route returns 403 when OIDC password login is disabled", async () => { + process.env.INITIAL_PASSWORD = "bootstrap-secret"; + await settingsDb.updateSettings({ + requireLogin: true, + oidcEnabled: true, + oidcDisablePasswordLogin: true, + }); + + const response = await loginRoute.POST( + new Request("http://localhost/api/auth/login", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ password: "bootstrap-secret" }), + }) + ); + + assert.equal(response.status, 403); + const body = (await response.json()) as { error?: string }; + assert.match(body.error || "", /Password login is disabled when OIDC is active/); +}); diff --git a/tests/unit/login-bootstrap-route.test.ts b/tests/unit/login-bootstrap-route.test.ts index aff64ba9f2..c51bd9870a 100644 --- a/tests/unit/login-bootstrap-route.test.ts +++ b/tests/unit/login-bootstrap-route.test.ts @@ -12,6 +12,14 @@ const core = await import("../../src/lib/db/core.ts"); const settingsDb = await import("../../src/lib/db/settings.ts"); const route = await import("../../src/app/api/settings/require-login/route.ts"); +type BootstrapResponse = { + nodeVersion: string; + nodeCompatible: boolean; + oidcEnabled: boolean; + oidcDisablePasswordLogin: boolean; + error?: { message: string; details?: { field: string; message: string }[] }; +}; + async function resetStorage() { core.resetDbInstance(); fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); @@ -35,23 +43,24 @@ test.after(() => { const originalHash = bcrypt.hash; -test("public login bootstrap route exposes the metadata the login page consumes", async () => { +test("public login bootstrap route exposes metadata login page consumes", async () => { await settingsDb.updateSettings({ requireLogin: true, setupComplete: true, }); const response = await route.GET(); - const body = (await response.json()) as any; + const body = (await response.json()) as BootstrapResponse; assert.equal(response.status, 200); assert.deepEqual(body, { - // #9491 added `authenticated` so /login can redirect an active session. + // #9491 added `authenticated` to help /login redirect if active session. authenticated: false, requireLogin: true, hasPassword: false, setupComplete: true, oidcEnabled: false, + oidcDisablePasswordLogin: false, nodeVersion: body.nodeVersion, nodeCompatible: body.nodeCompatible, }); @@ -66,22 +75,23 @@ test("public login bootstrap route reports env-provided bootstrap password metad }); const response = await route.GET(); - const body = (await response.json()) as any; + const body = (await response.json()) as BootstrapResponse; assert.equal(response.status, 200); assert.deepEqual(body, { - // #9491 added `authenticated` so /login can redirect an active session. + // #9491 added `authenticated` to help /login redirect if active session. authenticated: false, requireLogin: true, hasPassword: true, setupComplete: true, oidcEnabled: false, + oidcDisablePasswordLogin: false, nodeVersion: body.nodeVersion, nodeCompatible: body.nodeCompatible, }); }); -test("public login bootstrap route reports stored password metadata and disabled auth state", async () => { +test("public login bootstrap route reports stored password metadata in disabled auth state", async () => { await settingsDb.updateSettings({ requireLogin: false, password: "hashed-password", @@ -89,21 +99,38 @@ test("public login bootstrap route reports stored password metadata and disabled }); const response = await route.GET(); - const body = (await response.json()) as any; + const body = (await response.json()) as BootstrapResponse; assert.equal(response.status, 200); assert.deepEqual(body, { - // #9491 added `authenticated` so /login can redirect an active session. + // #9491 added `authenticated` to help /login redirect if active session. authenticated: false, requireLogin: false, hasPassword: true, setupComplete: true, oidcEnabled: false, + oidcDisablePasswordLogin: false, nodeVersion: body.nodeVersion, nodeCompatible: body.nodeCompatible, }); }); +test("public login bootstrap route reports oidcDisablePasswordLogin when oidc is enabled and flag is set", async () => { + await settingsDb.updateSettings({ + requireLogin: true, + setupComplete: true, + oidcEnabled: true, + oidcDisablePasswordLogin: true, + }); + + const response = await route.GET(); + const body = (await response.json()) as BootstrapResponse; + + assert.equal(response.status, 200); + assert.equal(body.oidcEnabled, true); + assert.equal(body.oidcDisablePasswordLogin, true); +}); + test("public login bootstrap route POST rejects invalid JSON bodies", async () => { const request = new Request("http://localhost/api/settings/require-login", { method: "POST", @@ -112,7 +139,7 @@ test("public login bootstrap route POST rejects invalid JSON bodies", async () = }); const response = await route.POST(request); - const body = (await response.json()) as any; + const body = (await response.json()) as BootstrapResponse; assert.equal(response.status, 400); assert.equal(body.error.message, "Invalid request"); @@ -127,7 +154,7 @@ test("public login bootstrap route POST rejects empty updates", async () => { }); const response = await route.POST(request); - const body = (await response.json()) as any; + const body = (await response.json()) as BootstrapResponse; assert.equal(response.status, 400); assert.equal(body.error.message, "Invalid request"); @@ -142,7 +169,7 @@ test("public login bootstrap route POST updates requireLogin without forcing pas }); const response = await route.POST(request); - const body = (await response.json()) as any; + const body = (await response.json()) as BootstrapResponse; const settings = await settingsDb.getSettings(); assert.equal(response.status, 200); @@ -160,7 +187,7 @@ test("public login bootstrap route POST hashes and stores passwords", async () = }); const response = await route.POST(request); - const body = (await response.json()) as any; + const body = (await response.json()) as BootstrapResponse; const settings = await settingsDb.getSettings(); assert.equal(response.status, 200); @@ -185,7 +212,7 @@ test("login bootstrap route POST rejects unauthenticated writes after setup is c }); const response = await route.POST(request); - const body = (await response.json()) as any; + const body = (await response.json()) as BootstrapResponse; const settings = await settingsDb.getSettings(); assert.equal(response.status, 401); @@ -207,7 +234,7 @@ test("login bootstrap route POST allows first password creation after setup comp }); const response = await route.POST(request); - const body = (await response.json()) as any; + const body = (await response.json()) as BootstrapResponse; const settings = await settingsDb.getSettings(); assert.equal(response.status, 200); @@ -229,7 +256,7 @@ test("public login bootstrap route POST returns 500 when hashing fails", async ( }); const response = await route.POST(request); - const body = (await response.json()) as any; + const body = (await response.json()) as BootstrapResponse; assert.equal(response.status, 500); assert.deepEqual(body, { error: "hash failed" });