From c3d91b019bfdcbb19f2f1217ed4f1e66a874362b Mon Sep 17 00:00:00 2001 From: Walter Cheng Date: Mon, 11 May 2026 22:43:42 -0400 Subject: [PATCH] Add OIDC dashboard auth (#1020) --- src/app/(dashboard)/dashboard/profile/page.js | 313 ++++++++++++++++++ src/app/api/auth/login/route.js | 28 +- src/app/api/auth/logout/route.js | 6 +- src/app/api/auth/oidc/callback/route.js | 87 +++++ src/app/api/auth/oidc/start/route.js | 52 +++ src/app/api/auth/oidc/test/route.js | 84 +++++ src/app/api/auth/status/route.js | 45 +++ src/app/api/settings/route.js | 12 +- src/app/login/page.js | 102 ++++-- src/dashboardGuard.js | 19 +- src/lib/auth/dashboardSession.js | 54 +++ src/lib/auth/oidc.js | 234 +++++++++++++ src/lib/db/repos/settingsRepo.js | 6 + src/shared/components/Header.js | 41 ++- 14 files changed, 1016 insertions(+), 67 deletions(-) create mode 100644 src/app/api/auth/oidc/callback/route.js create mode 100644 src/app/api/auth/oidc/start/route.js create mode 100644 src/app/api/auth/oidc/test/route.js create mode 100644 src/app/api/auth/status/route.js create mode 100644 src/lib/auth/dashboardSession.js create mode 100644 src/lib/auth/oidc.js diff --git a/src/app/(dashboard)/dashboard/profile/page.js b/src/app/(dashboard)/dashboard/profile/page.js index 450242d..fde0591 100644 --- a/src/app/(dashboard)/dashboard/profile/page.js +++ b/src/app/(dashboard)/dashboard/profile/page.js @@ -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, @@ -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 || "", @@ -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; @@ -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", { @@ -509,6 +673,155 @@ export default function ProfilePage() { + {/* OIDC */} + +
+
+ lock_open +
+

OIDC Dashboard Login

+
+
+

+ 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. +

+ +
+ +
+ {[ + { + 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 ( + + ); + })} +
+
+ +
+
+ + updateOidcForm("oidcIssuerUrl", e.target.value)} + disabled={loading || oidcLoading} + /> +
+ +
+ + updateOidcForm("oidcClientId", e.target.value)} + disabled={loading || oidcLoading} + /> +
+ +
+ + setOidcClientSecret(e.target.value)} + disabled={loading || oidcLoading} + /> +

This value is write-only after saving.

+
+ +
+ + updateOidcForm("oidcScopes", e.target.value)} + disabled={loading || oidcLoading} + /> +
+ +
+ + updateOidcForm("oidcLoginLabel", e.target.value)} + disabled={loading || oidcLoading} + /> +
+
+ +
+

Redirect URI

+ {oidcRedirectUri} +
+ +
+ + +
+ + {oidcTestStatus.message && ( +

+ {oidcTestStatus.message} +

+ )} + + {oidcStatus.message && ( +

+ {oidcStatus.message} +

+ )} + + {settings.authMode === "oidc" && ( +

+ OIDC login is currently active. Password login is disabled until you switch back. +

+ )} + + {settings.authMode === "both" && ( +

+ Password and OIDC login are both active. +

+ )} +
+
+ {/* Routing Preferences */}
diff --git a/src/app/api/auth/login/route.js b/src/app/api/auth/login/route.js index c8bee2d..27e0f62 100644 --- a/src/app/api/auth/login/route.js +++ b/src/app/api/auth/login/route.js @@ -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(); @@ -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); @@ -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 }); } diff --git a/src/app/api/auth/logout/route.js b/src/app/api/auth/logout/route.js index b09c38d..d6a5814 100644 --- a/src/app/api/auth/logout/route.js +++ b/src/app/api/auth/logout/route.js @@ -1,8 +1,12 @@ import { NextResponse } from "next/server"; import { cookies } from "next/headers"; +import { clearDashboardAuthCookie } from "@/lib/auth/dashboardSession"; export async function POST() { const cookieStore = await cookies(); - cookieStore.delete("auth_token"); + clearDashboardAuthCookie(cookieStore); + cookieStore.delete("oidc_state"); + cookieStore.delete("oidc_nonce"); + cookieStore.delete("oidc_code_verifier"); return NextResponse.json({ success: true }); } diff --git a/src/app/api/auth/oidc/callback/route.js b/src/app/api/auth/oidc/callback/route.js new file mode 100644 index 0000000..2e6a253 --- /dev/null +++ b/src/app/api/auth/oidc/callback/route.js @@ -0,0 +1,87 @@ +import { NextResponse } from "next/server"; +import { cookies } from "next/headers"; +import { + exchangeOidcCode, + fetchOidcDiscovery, + getOidcRuntimeConfig, + getPublicOrigin, + pickOidcDisplayName, + pickOidcEmail, + verifyOidcIdToken, +} from "@/lib/auth/oidc"; +import { setDashboardAuthCookie } from "@/lib/auth/dashboardSession"; + +function clearOidcCookies(cookieStore) { + cookieStore.delete("oidc_state"); + cookieStore.delete("oidc_nonce"); + cookieStore.delete("oidc_code_verifier"); +} + +export async function GET(request) { + const url = new URL(request.url); + const error = url.searchParams.get("error"); + if (error) { + return NextResponse.redirect(new URL(`/login?error=${encodeURIComponent(error)}`, getPublicOrigin(request))); + } + + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + if (!code || !state) { + return NextResponse.redirect(new URL("/login?error=oidc_missing_code", getPublicOrigin(request))); + } + + const cookieStore = await cookies(); + const storedState = cookieStore.get("oidc_state")?.value; + const storedNonce = cookieStore.get("oidc_nonce")?.value; + const codeVerifier = cookieStore.get("oidc_code_verifier")?.value; + + if (!storedState || !storedNonce || !codeVerifier || storedState !== state) { + clearOidcCookies(cookieStore); + return NextResponse.redirect(new URL("/login?error=oidc_invalid_state", getPublicOrigin(request))); + } + + try { + const config = await getOidcRuntimeConfig(); + if (!config) { + clearOidcCookies(cookieStore); + return NextResponse.redirect(new URL("/login?error=oidc_not_configured", getPublicOrigin(request))); + } + + const discovery = await fetchOidcDiscovery(config.issuerUrl); + const discoveredIssuer = discovery.issuer || config.issuerUrl; + const redirectUri = `${getPublicOrigin(request)}/api/auth/oidc/callback`; + const tokenData = await exchangeOidcCode({ + tokenEndpoint: discovery.token_endpoint, + clientId: config.clientId, + clientSecret: config.clientSecret, + code, + redirectUri, + codeVerifier, + }); + + if (!tokenData.id_token) { + throw new Error("OIDC provider did not return an id_token"); + } + + const payload = await verifyOidcIdToken({ + idToken: tokenData.id_token, + issuer: discoveredIssuer, + audience: config.clientId, + jwksUri: discovery.jwks_uri, + nonce: storedNonce, + }); + + clearOidcCookies(cookieStore); + await setDashboardAuthCookie(cookieStore, request, { + oidc: true, + oidcSub: payload.sub || null, + oidcEmail: pickOidcEmail(payload) || null, + oidcName: pickOidcDisplayName(payload), + }); + + return NextResponse.redirect(new URL("/dashboard", getPublicOrigin(request))); + } catch (error) { + clearOidcCookies(cookieStore); + return NextResponse.redirect(new URL(`/login?error=${encodeURIComponent(error.message || "oidc_callback_failed")}`, getPublicOrigin(request))); + } +} diff --git a/src/app/api/auth/oidc/start/route.js b/src/app/api/auth/oidc/start/route.js new file mode 100644 index 0000000..d4ae5e5 --- /dev/null +++ b/src/app/api/auth/oidc/start/route.js @@ -0,0 +1,52 @@ +import { NextResponse } from "next/server"; +import { cookies } from "next/headers"; +import { + buildOidcAuthorizationUrl, + createOidcNonce, + createOidcState, + createPkcePair, + fetchOidcDiscovery, + getOidcRuntimeConfig, + getPublicOrigin, +} from "@/lib/auth/oidc"; +import { shouldUseSecureCookie } from "@/lib/auth/dashboardSession"; + +export async function GET(request) { + try { + const config = await getOidcRuntimeConfig(); + if (!config) { + return NextResponse.redirect(new URL("/login?error=oidc_not_configured", getPublicOrigin(request))); + } + + const discovery = await fetchOidcDiscovery(config.issuerUrl); + const state = createOidcState(); + const nonce = createOidcNonce(); + const { verifier, challenge } = createPkcePair(); + const redirectUri = `${getPublicOrigin(request)}/api/auth/oidc/callback`; + const authUrl = buildOidcAuthorizationUrl({ + authorizationEndpoint: discovery.authorization_endpoint, + clientId: config.clientId, + redirectUri, + scopes: config.scopes, + state, + nonce, + codeChallenge: challenge, + }); + + const cookieStore = await cookies(); + const baseOptions = { + httpOnly: true, + secure: shouldUseSecureCookie(request), + sameSite: "lax", + path: "/", + maxAge: 10 * 60, + }; + cookieStore.set("oidc_state", state, baseOptions); + cookieStore.set("oidc_nonce", nonce, baseOptions); + cookieStore.set("oidc_code_verifier", verifier, baseOptions); + + return NextResponse.redirect(authUrl); + } catch (error) { + return NextResponse.redirect(new URL(`/login?error=${encodeURIComponent(error.message || "oidc_start_failed")}`, getPublicOrigin(request))); + } +} diff --git a/src/app/api/auth/oidc/test/route.js b/src/app/api/auth/oidc/test/route.js new file mode 100644 index 0000000..85f0aaf --- /dev/null +++ b/src/app/api/auth/oidc/test/route.js @@ -0,0 +1,84 @@ +import { NextResponse } from "next/server"; +import { cookies } from "next/headers"; +import { getSettings } from "@/lib/localDb"; +import { fetchOidcDiscovery, getPublicOrigin, probeOidcClientSecret } from "@/lib/auth/oidc"; +import { verifyDashboardAuthToken } from "@/lib/auth/dashboardSession"; + +async function canAccessTestRoute() { + const settings = await getSettings(); + if (settings.requireLogin === false) return true; + + const cookieStore = await cookies(); + const token = cookieStore.get("auth_token")?.value; + return await verifyDashboardAuthToken(token); +} + +export async function POST(request) { + try { + if (!(await canAccessTestRoute())) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json().catch(() => ({})); + const settings = await getSettings(); + + const issuerUrl = String(body.issuerUrl || settings.oidcIssuerUrl || "").trim(); + const clientId = String(body.clientId || settings.oidcClientId || "").trim(); + const scopes = String(body.scopes || settings.oidcScopes || "openid profile email").trim() || "openid profile email"; + const clientSecret = String( + Object.prototype.hasOwnProperty.call(body, "clientSecret") + ? body.clientSecret + : settings.oidcClientSecret || "" + ).trim(); + + if (!issuerUrl) { + return NextResponse.json({ error: "Issuer URL is required" }, { status: 400 }); + } + if (!clientId) { + return NextResponse.json({ error: "Client ID is required" }, { status: 400 }); + } + + const discovery = await fetchOidcDiscovery(issuerUrl); + const redirectUri = `${getPublicOrigin(request)}/api/auth/oidc/callback`; + const secretProbe = await probeOidcClientSecret({ + tokenEndpoint: discovery.token_endpoint, + clientId, + clientSecret, + redirectUri, + }); + + if (secretProbe.tested && secretProbe.valid === false) { + return NextResponse.json({ + ok: false, + discoveryOk: true, + clientSecretTested: true, + clientSecretValid: false, + issuerUrl, + clientId, + scopes, + redirectUri, + authorizationEndpoint: discovery.authorization_endpoint || "", + tokenEndpoint: discovery.token_endpoint || "", + jwksUri: discovery.jwks_uri || "", + error: `Discovery loaded, but the client secret is not valid: ${secretProbe.message}`, + }); + } + + return NextResponse.json({ + ok: true, + discoveryOk: true, + clientSecretTested: secretProbe.tested, + clientSecretValid: secretProbe.valid, + issuerUrl, + clientId, + scopes, + redirectUri, + authorizationEndpoint: discovery.authorization_endpoint || "", + tokenEndpoint: discovery.token_endpoint || "", + jwksUri: discovery.jwks_uri || "", + message: secretProbe.message, + }); + } catch (error) { + return NextResponse.json({ error: error.message || "OIDC test failed" }, { status: 500 }); + } +} diff --git a/src/app/api/auth/status/route.js b/src/app/api/auth/status/route.js new file mode 100644 index 0000000..32cc503 --- /dev/null +++ b/src/app/api/auth/status/route.js @@ -0,0 +1,45 @@ +import { NextResponse } from "next/server"; +import { cookies } from "next/headers"; +import { getSettings } from "@/lib/localDb"; +import { isOidcConfigured } from "@/lib/auth/oidc"; +import { getDashboardAuthSession } from "@/lib/auth/dashboardSession"; + +export async function GET() { + try { + const settings = await getSettings(); + const cookieStore = await cookies(); + const session = await getDashboardAuthSession(cookieStore.get("auth_token")?.value); + const requireLogin = settings.requireLogin !== false; + const authMode = settings.authMode || "password"; + const oidcName = String(session?.oidcName || "").trim(); + const oidcEmail = String(session?.oidcEmail || "").trim(); + const displayName = oidcName || oidcEmail || (session?.oidc ? "OIDC user" : "Password user"); + const loginMethod = session?.oidc ? "OIDC" : "Password"; + + return NextResponse.json({ + requireLogin, + authMode, + oidcConfigured: isOidcConfigured(settings), + oidcLoginLabel: (settings.oidcLoginLabel || "Sign in with OIDC").trim() || "Sign in with OIDC", + hasPassword: !!settings.password, + displayName, + loginMethod, + oidcName: oidcName || null, + oidcEmail: oidcEmail || null, + oidcLogin: !!session?.oidc, + }); + } catch { + return NextResponse.json({ + requireLogin: true, + authMode: "password", + oidcConfigured: false, + oidcLoginLabel: "Sign in with OIDC", + hasPassword: false, + displayName: "Password user", + loginMethod: "Password", + oidcName: null, + oidcEmail: null, + oidcLogin: false, + }); + } +} diff --git a/src/app/api/settings/route.js b/src/app/api/settings/route.js index b545e79..bc69e0d 100644 --- a/src/app/api/settings/route.js +++ b/src/app/api/settings/route.js @@ -14,7 +14,8 @@ const SETTINGS_RESPONSE_HEADERS = { export async function GET() { try { const settings = await getSettings(); - const { password, ...safeSettings } = settings; + const { password, oidcClientSecret, ...safeSettings } = settings; + safeSettings.oidcConfigured = !!(safeSettings.oidcIssuerUrl && safeSettings.oidcClientId && oidcClientSecret); const enableRequestLogs = process.env.ENABLE_REQUEST_LOGS === "true"; const enableTranslator = process.env.ENABLE_TRANSLATOR === "true"; @@ -63,6 +64,12 @@ export async function PATCH(request) { delete body.currentPassword; } + if (Object.prototype.hasOwnProperty.call(body, "oidcClientSecret")) { + if (!body.oidcClientSecret || !String(body.oidcClientSecret).trim()) { + delete body.oidcClientSecret; + } + } + const settings = await updateSettings(body); // Apply outbound proxy settings immediately (no restart required) @@ -83,7 +90,8 @@ export async function PATCH(request) { resetComboRotation(); } - const { password, ...safeSettings } = settings; + const { password, oidcClientSecret, ...safeSettings } = settings; + safeSettings.oidcConfigured = !!(safeSettings.oidcIssuerUrl && safeSettings.oidcClientId && oidcClientSecret); return NextResponse.json(safeSettings, { headers: SETTINGS_RESPONSE_HEADERS }); } catch (error) { console.log("Error updating settings:", error); diff --git a/src/app/login/page.js b/src/app/login/page.js index 679dd4c..18de9f0 100644 --- a/src/app/login/page.js +++ b/src/app/login/page.js @@ -9,6 +9,9 @@ export default function LoginPage() { const [error, setError] = useState(""); const [loading, setLoading] = useState(false); const [hasPassword, setHasPassword] = useState(null); + const [authMode, setAuthMode] = useState("password"); + const [oidcConfigured, setOidcConfigured] = useState(false); + const [oidcLoginLabel, setOidcLoginLabel] = useState("Sign in with OIDC"); const router = useRouter(); useEffect(() => { @@ -18,7 +21,7 @@ export default function LoginPage() { const baseUrl = typeof window !== "undefined" ? window.location.origin : ""; try { - const res = await fetch(`${baseUrl}/api/settings`, { + const res = await fetch(`${baseUrl}/api/auth/status`, { signal: controller.signal, }); clearTimeout(timeoutId); @@ -31,6 +34,9 @@ export default function LoginPage() { return; } setHasPassword(!!data.hasPassword); + setAuthMode(data.authMode || "password"); + setOidcConfigured(data.oidcConfigured === true); + setOidcLoginLabel(data.oidcLoginLabel || "Sign in with OIDC"); } else { // Safe fallback on non-OK response to avoid infinite loading state. setHasPassword(true); @@ -69,6 +75,13 @@ export default function LoginPage() { } }; + const handleOidcLogin = () => { + window.location.href = "/api/auth/oidc/start"; + }; + + const oidcAvailable = oidcConfigured && ["oidc", "both"].includes(authMode); + const passwordAvailable = authMode !== "oidc" || !oidcConfigured; + // Show loading state while checking password if (hasPassword === null) { return ( @@ -88,37 +101,72 @@ export default function LoginPage() {

9Router

-

Enter your password to access the dashboard

+

+ {authMode === "oidc" && oidcConfigured + ? "Sign in with your OIDC provider to access the dashboard" + : "Enter your password to access the dashboard"} +

-
-
- - setPassword(e.target.value)} - required - autoFocus - /> - {error &&

{error}

} -
+
+ {oidcAvailable && ( + + )} - + {oidcAvailable && passwordAvailable &&
} -

- Default password is 123456 -

- + {passwordAvailable ? ( +
+ {((authMode === "oidc" && !oidcConfigured) || (authMode === "both" && !oidcConfigured)) && ( +

+ OIDC login is enabled, but the issuer/client fields are not configured yet. Password login is still available for recovery. +

+ )} + + {authMode === "both" && oidcConfigured && ( +

+ Password and OIDC login are both enabled. +

+ )} + +
+ + setPassword(e.target.value)} + required + autoFocus={!oidcAvailable} + /> + {error &&

{error}

} +
+ + + +

+ Default password is 123456 +

+ {hasPassword === false && ( +

+ No custom password is set yet. The default password above will work until you change it. +

+ )} +
+ ) : ( + error &&

{error}

+ )} +
diff --git a/src/dashboardGuard.js b/src/dashboardGuard.js index 12c1d30..49d8831 100644 --- a/src/dashboardGuard.js +++ b/src/dashboardGuard.js @@ -1,11 +1,7 @@ import { NextResponse } from "next/server"; -import { jwtVerify } from "jose"; import { getSettings } from "@/lib/localDb"; import { getConsistentMachineId } from "@/shared/utils/machineId"; - -const SECRET = new TextEncoder().encode( - process.env.JWT_SECRET || "9router-default-secret-change-me" -); +import { verifyDashboardAuthToken } from "@/lib/auth/dashboardSession"; const CLI_TOKEN_HEADER = "x-9r-cli-token"; const CLI_TOKEN_SALT = "9r-cli-auth"; @@ -38,13 +34,7 @@ const PROTECTED_API_PATHS = [ async function hasValidToken(request) { const token = request.cookies.get("auth_token")?.value; - if (!token) return false; - try { - await jwtVerify(token, SECRET); - return true; - } catch { - return false; - } + return await verifyDashboardAuthToken(token); } // Read settings directly from DB to avoid self-fetch deadlock in proxy @@ -112,10 +102,9 @@ export async function proxy(request) { // Verify JWT token const token = request.cookies.get("auth_token")?.value; if (token) { - try { - await jwtVerify(token, SECRET); + if (await verifyDashboardAuthToken(token)) { return NextResponse.next(); - } catch { + } else { return NextResponse.redirect(new URL("/login", request.url)); } } diff --git a/src/lib/auth/dashboardSession.js b/src/lib/auth/dashboardSession.js new file mode 100644 index 0000000..4a0ee36 --- /dev/null +++ b/src/lib/auth/dashboardSession.js @@ -0,0 +1,54 @@ +import { SignJWT, jwtVerify } from "jose"; + +const SECRET = new TextEncoder().encode( + process.env.JWT_SECRET || "9router-default-secret-change-me" +); + +export function shouldUseSecureCookie(request) { + const forceSecureCookie = process.env.AUTH_COOKIE_SECURE === "true"; + const forwardedProto = request?.headers?.get?.("x-forwarded-proto"); + const isHttpsRequest = forwardedProto === "https"; + return forceSecureCookie || isHttpsRequest; +} + +export async function createDashboardAuthToken(claims = {}) { + return new SignJWT({ authenticated: true, ...claims }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setExpirationTime("24h") + .sign(SECRET); +} + +export async function verifyDashboardAuthToken(token) { + if (!token) return false; + try { + await jwtVerify(token, SECRET); + return true; + } catch { + return false; + } +} + +export async function getDashboardAuthSession(token) { + if (!token) return null; + try { + const { payload } = await jwtVerify(token, SECRET); + return payload; + } catch { + return null; + } +} + +export async function setDashboardAuthCookie(cookieStore, request, claims = {}) { + const token = await createDashboardAuthToken(claims); + cookieStore.set("auth_token", token, { + httpOnly: true, + secure: shouldUseSecureCookie(request), + sameSite: "lax", + path: "/", + }); +} + +export function clearDashboardAuthCookie(cookieStore) { + cookieStore.delete("auth_token"); +} diff --git a/src/lib/auth/oidc.js b/src/lib/auth/oidc.js new file mode 100644 index 0000000..dcabcb5 --- /dev/null +++ b/src/lib/auth/oidc.js @@ -0,0 +1,234 @@ +import crypto from "node:crypto"; +import { createRemoteJWKSet, jwtVerify } from "jose"; +import { getSettings } from "@/lib/localDb"; + +export const OIDC_COOKIE_NAMES = { + state: "oidc_state", + nonce: "oidc_nonce", + verifier: "oidc_code_verifier", +}; + +const DEFAULT_SCOPES = "openid profile email"; +const DEFAULT_LOGIN_LABEL = "Sign in with OIDC"; + +function trimTrailingSlashes(value) { + return (value || "").trim().replace(/\/+$/, ""); +} + +function normalizeScopes(value) { + return (value || DEFAULT_SCOPES).trim() || DEFAULT_SCOPES; +} + +export function getPublicOrigin(request) { + const configuredBaseUrl = + process.env.BASE_URL || + process.env.NEXT_PUBLIC_BASE_URL || + ""; + + if (configuredBaseUrl) { + return trimTrailingSlashes(configuredBaseUrl); + } + + const forwardedProto = request?.headers?.get?.("x-forwarded-proto") || ""; + const forwardedHost = request?.headers?.get?.("x-forwarded-host") || ""; + const host = forwardedHost || request?.headers?.get?.("host") || ""; + if (host) { + const protocol = (forwardedProto || new URL(request.url).protocol || "http:").replace(/:$/, ""); + return `${protocol}://${host}`.replace(/\/+$/, ""); + } + + return trimTrailingSlashes(new URL(request.url).origin); +} + +export function isOidcConfigured(settings) { + return !!( + trimTrailingSlashes(settings?.oidcIssuerUrl) && + (settings?.oidcClientId || "").trim() && + (settings?.oidcClientSecret || "").trim() + ); +} + +export async function getOidcRuntimeConfig() { + const settings = await getSettings(); + if (!["oidc", "both"].includes(settings.authMode) || !isOidcConfigured(settings)) return null; + + const issuerUrl = trimTrailingSlashes(settings.oidcIssuerUrl); + return { + issuerUrl, + clientId: settings.oidcClientId.trim(), + clientSecret: settings.oidcClientSecret.trim(), + scopes: normalizeScopes(settings.oidcScopes), + loginLabel: (settings.oidcLoginLabel || DEFAULT_LOGIN_LABEL).trim() || DEFAULT_LOGIN_LABEL, + }; +} + +export async function fetchOidcDiscovery(issuerUrl) { + const discoveryUrl = `${trimTrailingSlashes(issuerUrl)}/.well-known/openid-configuration`; + const res = await fetch(discoveryUrl, { cache: "no-store" }); + if (!res.ok) { + throw new Error(`Failed to load OIDC discovery document from ${discoveryUrl}`); + } + return await res.json(); +} + +export function createPkcePair() { + const verifier = crypto.randomBytes(32).toString("base64url"); + const challenge = crypto.createHash("sha256").update(verifier).digest("base64url"); + return { verifier, challenge }; +} + +export function createOidcState() { + return crypto.randomBytes(16).toString("base64url"); +} + +export function createOidcNonce() { + return crypto.randomBytes(16).toString("base64url"); +} + +export function buildOidcAuthorizationUrl({ + authorizationEndpoint, + clientId, + redirectUri, + scopes = DEFAULT_SCOPES, + state, + nonce, + codeChallenge, +}) { + const url = new URL(authorizationEndpoint); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", clientId); + url.searchParams.set("redirect_uri", redirectUri); + url.searchParams.set("scope", normalizeScopes(scopes)); + url.searchParams.set("state", state); + url.searchParams.set("nonce", nonce); + url.searchParams.set("code_challenge", codeChallenge); + url.searchParams.set("code_challenge_method", "S256"); + return url.toString(); +} + +export async function exchangeOidcCode({ + tokenEndpoint, + clientId, + clientSecret, + code, + redirectUri, + codeVerifier, +}) { + const body = new URLSearchParams({ + grant_type: "authorization_code", + client_id: clientId, + code, + redirect_uri: redirectUri, + code_verifier: codeVerifier, + }); + + if (clientSecret) { + body.set("client_secret", clientSecret); + } + + const res = await fetch(tokenEndpoint, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + }); + + const data = await res.json().catch(() => ({})); + if (!res.ok) { + const message = data?.error_description || data?.error || `OIDC token exchange failed (${res.status})`; + throw new Error(message); + } + + return data; +} + +export async function probeOidcClientSecret({ + tokenEndpoint, + clientId, + clientSecret, + redirectUri, +}) { + if (!clientSecret) { + return { + tested: false, + valid: null, + message: "No client secret was provided, so secret validation was skipped.", + }; + } + + const body = new URLSearchParams({ + grant_type: "authorization_code", + client_id: clientId, + client_secret: clientSecret, + code: "__oidc_test_invalid_code__", + redirect_uri: redirectUri, + code_verifier: "__oidc_test_invalid_verifier__", + }); + + const res = await fetch(tokenEndpoint, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + }); + + const data = await res.json().catch(() => ({})); + const error = (data?.error || "").toLowerCase(); + const errorDescription = data?.error_description || data?.error || ""; + + if (res.ok) { + return { + tested: true, + valid: true, + message: "Client secret was accepted by the token endpoint.", + raw: data, + }; + } + + if (error === "invalid_client" || error === "unauthorized_client" || /client.*(invalid|failed|mismatch)/i.test(errorDescription)) { + return { + tested: true, + valid: false, + message: errorDescription || "Client secret is not valid.", + raw: data, + }; + } + + if (error === "invalid_grant" || error === "invalid_code" || /grant|code/i.test(errorDescription)) { + return { + tested: true, + valid: true, + message: "Client secret was accepted; the token exchange failed only because the test authorization code is invalid.", + raw: data, + }; + } + + return { + tested: true, + valid: null, + message: errorDescription || `Token endpoint responded with ${res.status}`, + raw: data, + }; +} + +export async function verifyOidcIdToken({ + idToken, + issuer, + audience, + jwksUri, + nonce, +}) { + const jwks = createRemoteJWKSet(new URL(jwksUri)); + const { payload } = await jwtVerify(idToken, jwks, { + issuer, + audience, + nonce, + }); + return payload; +} + +export function pickOidcDisplayName(payload = {}) { + return payload.preferred_username || payload.email || payload.name || payload.given_name || payload.sub || "OIDC user"; +} + +export function pickOidcEmail(payload = {}) { + return payload.email || ""; +} diff --git a/src/lib/db/repos/settingsRepo.js b/src/lib/db/repos/settingsRepo.js index 20c3307..7201a76 100644 --- a/src/lib/db/repos/settingsRepo.js +++ b/src/lib/db/repos/settingsRepo.js @@ -17,6 +17,12 @@ const DEFAULT_SETTINGS = { comboStrategies: {}, requireLogin: true, tunnelDashboardAccess: true, + authMode: "password", + oidcIssuerUrl: "", + oidcClientId: "", + oidcClientSecret: "", + oidcScopes: "openid profile email", + oidcLoginLabel: "Sign in with OIDC", enableObservability: true, observabilityMaxRecords: 1000, observabilityBatchSize: 20, diff --git a/src/shared/components/Header.js b/src/shared/components/Header.js index 2d451c8..4d00361 100644 --- a/src/shared/components/Header.js +++ b/src/shared/components/Header.js @@ -1,7 +1,7 @@ "use client"; +import { useEffect, useMemo, useState } from "react"; import { usePathname, useRouter } from "next/navigation"; -import { useMemo } from "react"; import Link from "next/link"; import PropTypes from "prop-types"; import ProviderIcon from "@/shared/components/ProviderIcon"; @@ -172,11 +172,39 @@ const getPageInfo = (pathname) => { export default function Header({ onMenuClick, showMenuButton = true }) { const pathname = usePathname(); const router = useRouter(); + const [displayName, setDisplayName] = useState(""); + const [loginMethod, setLoginMethod] = useState(""); // Memoize page info to prevent unnecessary recalculations const pageInfo = useMemo(() => getPageInfo(pathname), [pathname]); const { title, description, icon, breadcrumbs } = pageInfo; + useEffect(() => { + let cancelled = false; + + async function loadAuthStatus() { + try { + const res = await fetch("/api/auth/status", { cache: "no-store" }); + if (!res.ok) return; + const data = await res.json(); + if (!cancelled) { + setDisplayName(data?.displayName || data?.oidcName || data?.oidcEmail || ""); + setLoginMethod(data?.loginMethod || ""); + } + } catch { + if (!cancelled) { + setDisplayName(""); + setLoginMethod(""); + } + } + } + + loadAuthStatus(); + return () => { + cancelled = true; + }; + }, []); + const handleLogout = async () => { try { const res = await fetch("/api/auth/logout", { method: "POST" }); @@ -266,6 +294,17 @@ export default function Header({ onMenuClick, showMenuButton = true }) { {/* Right actions */}
+ {displayName && ( +
+ person + {displayName} + {loginMethod && ( + + {loginMethod} + + )} +
+ )}