Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 4 additions & 21 deletions config/quality/eslint-suppressions.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
29 changes: 27 additions & 2 deletions src/app/api/auth/login/route.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
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 {
ensurePersistentManagementPasswordHash,
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";
Expand Down Expand Up @@ -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) {
Expand Down
11 changes: 10 additions & 1 deletion src/app/api/settings/require-login/route.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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) {
Expand All @@ -69,6 +77,7 @@ export async function GET() {
hasPassword: true,
setupComplete: true,
oidcEnabled: false,
oidcDisablePasswordLogin: false,
...nodeInfo,
},
{ status: 200 }
Expand Down
9 changes: 7 additions & 2 deletions src/app/api/settings/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -118,6 +122,7 @@ const SECURITY_IMPACTING_KEYS = [
"requireLogin",
"newPassword",
"oidcEnabled",
"oidcDisablePasswordLogin",
"oidcClientSecret",
] as const;

Expand Down
125 changes: 79 additions & 46 deletions src/app/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean | null>(null);
const [setupComplete, setSetupComplete] = useState<boolean | null>(null);
const [oidcEnabled, setOidcEnabled] = useState<boolean | null>(null);
const [oidcDisablePasswordLogin, setOidcDisablePasswordLogin] = useState<boolean | null>(null);
const [mounted, setMounted] = useState(false);
const [nodeVersion, setNodeVersion] = useState(null);
const [nodeCompatible, setNodeCompatible] = useState(true);
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -122,7 +126,12 @@ export default function LoginPage() {
</div>
</div>
) : null;
if (hasPassword === null || setupComplete === null || oidcEnabled === null) {
if (
hasPassword === null ||
setupComplete === null ||
oidcEnabled === null ||
(oidcEnabled && oidcDisablePasswordLogin === null)
) {
return (
<div className="min-h-screen flex flex-col items-center justify-center p-6">
{nodeWarningBanner}
Expand Down Expand Up @@ -235,60 +244,84 @@ export default function LoginPage() {
</span>
</div>
<h1 className="text-2xl font-bold text-text-main tracking-tight">{t("signIn")}</h1>
<p className="text-text-muted mt-1.5">{t("enterPassword")}</p>
<p className="text-text-muted mt-1.5">
{oidcEnabled && oidcDisablePasswordLogin
? t("continueWithOidc")
: t("enterPassword")}
</p>
</div>

<form onSubmit={handleLogin} className="space-y-5">
<div className="space-y-2">
<label className="text-sm font-medium text-text-main">{t("password")}</label>
<Input
type="password"
placeholder={t("enterPassword")}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoFocus
className="h-11"
/>
{error && (
<p className="text-sm text-red-500 flex items-center gap-1.5 pt-1">
<span className="material-symbols-outlined text-base">error</span>
{error}
</p>
)}
<p className="text-xs text-text-muted/60 pt-0.5">{t("defaultPasswordHint")}</p>
</div>

<Button
type="submit"
variant="primary"
className="w-full h-11 text-sm font-medium"
loading={loading}
>
{t("continue")}
</Button>
</form>
{oidcEnabled && (
<div className="mt-4">
{oidcEnabled && oidcDisablePasswordLogin ? (
<div className="space-y-4">
<Button
type="button"
variant="secondary"
className="w-full h-11 text-sm font-medium"
variant="primary"
className="w-full h-11 text-sm font-medium flex items-center justify-center gap-2"
onClick={() => (window.location.href = "/api/auth/oidc/login")}
>
<span className="material-symbols-outlined text-lg">login</span>
{t("continueWithOidc")}
</Button>
</div>
) : (
<>
<form onSubmit={handleLogin} className="space-y-5 w-full">
<div className="space-y-2">
<label className="text-sm font-medium text-text-main">{t("password")}</label>
<Input
type="password"
placeholder={t("enterPassword")}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoFocus
className="h-11"
/>
{error && (
<p className="text-sm text-red-500 flex items-center gap-1.5 pt-1">
<span className="material-symbols-outlined text-base">error</span>
{error}
</p>
)}
<p className="text-xs text-text-muted/60 pt-0.5">{t("defaultPasswordHint")}</p>
</div>

<Button
type="submit"
variant="primary"
className="w-full h-11 text-sm font-medium"
loading={loading}
>
{t("continue")}
</Button>
</form>

{oidcEnabled && (
<div className="mt-4">
<Button
type="button"
variant="secondary"
className="w-full h-11 text-sm font-medium flex items-center justify-center gap-2"
onClick={() => (window.location.href = "/api/auth/oidc/login")}
>
<span className="material-symbols-outlined text-lg">login</span>
{t("continueWithOidc")}
</Button>
</div>
)}
</>
)}

<div className="mt-6 pt-6 border-t border-border">
<a
href="/forgot-password"
className="text-sm text-text-muted hover:text-primary transition-colors"
>
{t("forgotPassword")}
</a>
</div>
{!oidcEnabled && (
<div className="mt-6 pt-6 border-t border-border">
<a
href="/forgot-password"
className="text-sm text-text-muted hover:text-primary transition-colors"
>
{t("forgotPassword")}
</a>
</div>
)}
</div>
</div>

Expand Down
1 change: 1 addition & 0 deletions src/lib/db/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ export async function getSettings() {
antigravitySignatureCacheMode: "enabled",
requireLogin: true,
oidcEnabled: false,
oidcDisablePasswordLogin: false,
oidcIssuer: "",
oidcClientId: "",
oidcClientSecret: "",
Expand Down
18 changes: 15 additions & 3 deletions src/shared/constants/featureFlagDefinitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,16 +109,28 @@ 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 <provider> 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 <provider> 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",
type: "boolean",
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",
Expand Down
1 change: 1 addition & 0 deletions src/shared/validation/settingsSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Loading
Loading