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"}
+
-
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 */}