Add OIDC dashboard auth (#1020)

This commit is contained in:
Walter Cheng
2026-05-11 22:43:42 -04:00
committed by GitHub
parent a48fa4eb21
commit c3d91b019b
14 changed files with 1016 additions and 67 deletions

View File

@@ -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 });
}

View File

@@ -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 });
}

View File

@@ -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)));
}
}

View File

@@ -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)));
}
}

View File

@@ -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 });
}
}

View File

@@ -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,
});
}
}

View File

@@ -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);