Add OIDC dashboard auth (#1020)
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
87
src/app/api/auth/oidc/callback/route.js
Normal file
87
src/app/api/auth/oidc/callback/route.js
Normal 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)));
|
||||
}
|
||||
}
|
||||
52
src/app/api/auth/oidc/start/route.js
Normal file
52
src/app/api/auth/oidc/start/route.js
Normal 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)));
|
||||
}
|
||||
}
|
||||
84
src/app/api/auth/oidc/test/route.js
Normal file
84
src/app/api/auth/oidc/test/route.js
Normal 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 });
|
||||
}
|
||||
}
|
||||
45
src/app/api/auth/status/route.js
Normal file
45
src/app/api/auth/status/route.js
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user