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
313 changes: 313 additions & 0 deletions src/app/(dashboard)/dashboard/profile/page.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,19 @@ export default function ProfilePage() {
const [passLoading, setPassLoading] = useState(false);
const [dbLoading, setDbLoading] = useState(false);
const [dbStatus, setDbStatus] = useState({ type: "", message: "" });
const [oidcForm, setOidcForm] = useState({
authMode: "password",
oidcIssuerUrl: "",
oidcClientId: "",
oidcScopes: "openid profile email",
oidcLoginLabel: "Sign in with OIDC",
});
const [oidcClientSecret, setOidcClientSecret] = useState("");
const [oidcStatus, setOidcStatus] = useState({ type: "", message: "" });
const [oidcLoading, setOidcLoading] = useState(false);
const [oidcTestLoading, setOidcTestLoading] = useState(false);
const [oidcTestStatus, setOidcTestStatus] = useState({ type: "", message: "" });
const [oidcRedirectUri, setOidcRedirectUri] = useState("/api/auth/oidc/callback");
const importFileRef = useRef(null);
const [proxyForm, setProxyForm] = useState({
outboundProxyEnabled: false,
Expand All @@ -30,6 +43,14 @@ export default function ProfilePage() {
.then((res) => res.json())
.then((data) => {
setSettings(data);
setOidcForm({
authMode: data?.authMode || "password",
oidcIssuerUrl: data?.oidcIssuerUrl || "",
oidcClientId: data?.oidcClientId || "",
oidcScopes: data?.oidcScopes || "openid profile email",
oidcLoginLabel: data?.oidcLoginLabel || "Sign in with OIDC",
});
setOidcClientSecret("");
setProxyForm({
outboundProxyEnabled: data?.outboundProxyEnabled === true,
outboundProxyUrl: data?.outboundProxyUrl || "",
Expand All @@ -43,6 +64,12 @@ export default function ProfilePage() {
});
}, []);

useEffect(() => {
if (typeof window !== "undefined") {
setOidcRedirectUri(`${window.location.origin}/api/auth/oidc/callback`);
}
}, []);

const updateOutboundProxy = async (e) => {
e.preventDefault();
if (settings.outboundProxyEnabled !== true) return;
Expand Down Expand Up @@ -256,6 +283,143 @@ export default function ProfilePage() {
}
};

const updateOidcForm = (field, value) => {
setOidcForm((prev) => ({ ...prev, [field]: value }));
};

const saveOidcSettings = async (authMode = oidcForm.authMode || "password") => {
const issuerUrl = oidcForm.oidcIssuerUrl.trim();
const clientId = oidcForm.oidcClientId.trim();
const scopes = oidcForm.oidcScopes.trim();
const loginLabel = oidcForm.oidcLoginLabel.trim();
const secret = oidcClientSecret.trim();

if (authMode !== "password" && (!issuerUrl || !clientId || !secret) && !settings.oidcConfigured) {
setOidcStatus({ type: "error", message: "Issuer URL, client ID, and client secret are required to enable OIDC." });
return;
}

setOidcLoading(true);
setOidcStatus({ type: "", message: "" });
setOidcTestStatus({ type: "", message: "" });

try {
const payload = {
authMode,
oidcIssuerUrl: issuerUrl,
oidcClientId: clientId,
oidcScopes: scopes || "openid profile email",
oidcLoginLabel: loginLabel || "Sign in with OIDC",
};
if (secret) {
payload.oidcClientSecret = secret;
}

const res = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});

const data = await res.json();
if (res.ok) {
setSettings((prev) => ({ ...prev, ...data }));
setOidcForm({
authMode: data?.authMode || authMode,
oidcIssuerUrl: data?.oidcIssuerUrl || issuerUrl,
oidcClientId: data?.oidcClientId || clientId,
oidcScopes: data?.oidcScopes || scopes || "openid profile email",
oidcLoginLabel: data?.oidcLoginLabel || loginLabel || "Sign in with OIDC",
});
setOidcClientSecret("");
setOidcStatus({
type: "success",
message:
authMode === "oidc"
? "OIDC login enabled"
: authMode === "both"
? "Password and OIDC login enabled"
: "OIDC settings saved",
});
} else {
setOidcStatus({ type: "error", message: data.error || "Failed to save OIDC settings" });
}
} catch (err) {
setOidcStatus({ type: "error", message: "An error occurred" });
} finally {
setOidcLoading(false);
}
};

const testOidcConnection = async () => {
const issuerUrl = oidcForm.oidcIssuerUrl.trim();
const clientId = oidcForm.oidcClientId.trim();
const scopes = oidcForm.oidcScopes.trim();
const secret = oidcClientSecret.trim();

if (!issuerUrl || !clientId) {
setOidcTestStatus({ type: "error", message: "Issuer URL and client ID are required to test the connection." });
return;
}

setOidcTestLoading(true);
setOidcStatus({ type: "", message: "" });
setOidcTestStatus({ type: "", message: "" });

try {
const saveRes = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
authMode: oidcForm.authMode || settings.authMode || "password",
oidcIssuerUrl: issuerUrl,
oidcClientId: clientId,
oidcScopes: scopes || "openid profile email",
oidcLoginLabel: oidcForm.oidcLoginLabel.trim() || "Sign in with OIDC",
...(secret ? { oidcClientSecret: secret } : {}),
}),
});

const saved = await saveRes.json().catch(() => ({}));
if (!saveRes.ok) {
setOidcTestStatus({
type: "error",
message: saved.error || "Failed to save OIDC settings before testing",
});
return;
}

const res = await fetch("/api/auth/oidc/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
issuerUrl: saved.oidcIssuerUrl || issuerUrl,
clientId: saved.oidcClientId || clientId,
scopes: saved.oidcScopes || scopes || "openid profile email",
}),
});

const data = await res.json().catch(() => ({}));
if (res.ok && data?.ok) {
const statusMessage = data.clientSecretTested
? data.clientSecretValid === true
? `Connection OK. Discovery loaded from ${data.issuerUrl}. Client secret validated too.`
: `Connection OK. Discovery loaded from ${data.issuerUrl}. Client secret was not checked.`
: `Connection OK. Discovery loaded from ${data.issuerUrl}.`;
setOidcTestStatus({
type: "success",
message: statusMessage,
});
} else {
setOidcTestStatus({ type: "error", message: data.error || "OIDC connection test failed" });
}
} catch (err) {
setOidcTestStatus({ type: "error", message: "An error occurred" });
} finally {
setOidcTestLoading(false);
}
};

const updateObservabilityEnabled = async (enabled) => {
try {
const res = await fetch("/api/settings", {
Expand Down Expand Up @@ -509,6 +673,155 @@ export default function ProfilePage() {
</div>
</Card>

{/* OIDC */}
<Card>
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-lg bg-indigo-500/10 text-indigo-500 shrink-0">
<span className="material-symbols-outlined text-[20px]">lock_open</span>
</div>
<h3 className="text-base sm:text-lg font-semibold">OIDC Dashboard Login</h3>
</div>
<div className="flex flex-col gap-4">
<p className="text-xs sm:text-sm text-text-muted">
Use Authentik or any OIDC provider to sign in to the dashboard. You can enable password-only, OIDC-only, or both for the dashboard; model API access still uses API keys.
</p>

<div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">Auth Mode</label>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
{[
{
value: "password",
title: "Password only",
desc: "Keep the legacy password login.",
},
{
value: "oidc",
title: "OIDC only",
desc: "Require OIDC for dashboard access.",
},
{
value: "both",
title: "Both",
desc: "Allow either password or OIDC.",
},
].map((option) => {
const active = oidcForm.authMode === option.value;
return (
<button
key={option.value}
type="button"
onClick={() => updateOidcForm("authMode", option.value)}
className={cn(
"text-left rounded-lg border p-3 transition-colors",
active
? "border-primary bg-primary/5"
: "border-border bg-bg hover:bg-black/5 dark:hover:bg-white/5"
)}
disabled={loading || oidcLoading}
>
<p className="font-medium text-sm sm:text-base">{option.title}</p>
<p className="text-xs sm:text-sm text-text-muted mt-1">{option.desc}</p>
</button>
);
})}
</div>
</div>

<div className="grid grid-cols-1 gap-4">
<div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">Issuer URL</label>
<Input
placeholder="https://auth.example.com/application/o/9router/"
value={oidcForm.oidcIssuerUrl}
onChange={(e) => updateOidcForm("oidcIssuerUrl", e.target.value)}
disabled={loading || oidcLoading}
/>
</div>

<div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">Client ID</label>
<Input
placeholder="9router-dashboard"
value={oidcForm.oidcClientId}
onChange={(e) => updateOidcForm("oidcClientId", e.target.value)}
disabled={loading || oidcLoading}
/>
</div>

<div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">Client Secret</label>
<Input
type="password"
placeholder="Leave blank to keep existing secret"
value={oidcClientSecret}
onChange={(e) => setOidcClientSecret(e.target.value)}
disabled={loading || oidcLoading}
/>
<p className="text-xs sm:text-sm text-text-muted">This value is write-only after saving.</p>
</div>

<div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">Scopes</label>
<Input
placeholder="openid profile email"
value={oidcForm.oidcScopes}
onChange={(e) => updateOidcForm("oidcScopes", e.target.value)}
disabled={loading || oidcLoading}
/>
</div>

<div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">Login Button Label</label>
<Input
placeholder="Sign in with OIDC"
value={oidcForm.oidcLoginLabel}
onChange={(e) => updateOidcForm("oidcLoginLabel", e.target.value)}
disabled={loading || oidcLoading}
/>
</div>
</div>

<div className="rounded-lg border border-border bg-bg p-3 text-xs sm:text-sm text-text-muted">
<p className="font-medium text-text-main mb-1">Redirect URI</p>
<code className="block break-all font-mono">{oidcRedirectUri}</code>
</div>

<div className="flex flex-col sm:flex-row gap-2 pt-2 border-t border-border/50">
<Button type="button" variant="primary" loading={oidcLoading} onClick={() => saveOidcSettings()} className="w-full sm:w-auto">
Save auth mode
</Button>
<Button type="button" variant="outline" loading={oidcTestLoading} onClick={testOidcConnection} className="w-full sm:w-auto">
Test connection
</Button>
</div>

{oidcTestStatus.message && (
<p className={`text-xs sm:text-sm ${oidcTestStatus.type === "error" ? "text-red-500" : "text-green-500"}`}>
{oidcTestStatus.message}
</p>
)}

{oidcStatus.message && (
<p className={`text-xs sm:text-sm ${oidcStatus.type === "error" ? "text-red-500" : "text-green-500"}`}>
{oidcStatus.message}
</p>
)}

{settings.authMode === "oidc" && (
<p className="text-xs sm:text-sm text-amber-600 dark:text-amber-400">
OIDC login is currently active. Password login is disabled until you switch back.
</p>
)}

{settings.authMode === "both" && (
<p className="text-xs sm:text-sm text-amber-600 dark:text-amber-400">
Password and OIDC login are both active.
</p>
)}
</div>
</Card>

{/* Routing Preferences */}
<Card>
<div className="flex items-center gap-3 mb-4">
Expand Down
28 changes: 7 additions & 21 deletions src/app/api/auth/login/route.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
import { NextResponse } from "next/server";
import { getSettings } from "@/lib/localDb";
import bcrypt from "bcryptjs";
import { SignJWT } from "jose";
import { cookies } from "next/headers";

const SECRET = new TextEncoder().encode(
process.env.JWT_SECRET || "9router-default-secret-change-me"
);
import { setDashboardAuthCookie } from "@/lib/auth/dashboardSession";
import { isOidcConfigured } from "@/lib/auth/oidc";

function isTunnelRequest(request, settings) {
const host = (request.headers.get("host") || "").split(":")[0].toLowerCase();
Expand All @@ -28,6 +25,10 @@ export async function POST(request) {
// Default password is '123456' if not set
const storedHash = settings.password;

if (settings.authMode === "oidc" && isOidcConfigured(settings)) {
return NextResponse.json({ error: "Password login is disabled. Use OIDC sign in." }, { status: 403 });
}

let isValid = false;
if (storedHash) {
isValid = await bcrypt.compare(password, storedHash);
Expand All @@ -38,23 +39,8 @@ export async function POST(request) {
}

if (isValid) {
const forceSecureCookie = process.env.AUTH_COOKIE_SECURE === "true";
const forwardedProto = request.headers.get("x-forwarded-proto");
const isHttpsRequest = forwardedProto === "https";
const useSecureCookie = forceSecureCookie || isHttpsRequest;

const token = await new SignJWT({ authenticated: true })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("24h")
.sign(SECRET);

const cookieStore = await cookies();
cookieStore.set("auth_token", token, {
httpOnly: true,
secure: useSecureCookie,
sameSite: "lax",
path: "/",
});
await setDashboardAuthCookie(cookieStore, request);

return NextResponse.json({ success: true });
}
Expand Down
Loading