Merge remote-tracking branch 'origin/master' into gitea/new_feature
# Conflicts: # open-sse/handlers/chatCore.js # open-sse/services/combo.js # src/app/(dashboard)/dashboard/profile/page.js # src/app/api/v1/models/route.js # src/lib/db/repos/settingsRepo.js
This commit is contained in:
@@ -170,7 +170,7 @@ export default function HermesToolCard({
|
||||
? selectedApiKey
|
||||
: (!cloudEnabled ? "sk_9router" : "<API_KEY_FROM_DASHBOARD>");
|
||||
|
||||
const yamlContent = `model:\n default: "${selectedModel || "provider/model-id"}"\n provider: "custom"\n base_url: "${getEffectiveBaseUrl()}"\n`;
|
||||
const yamlContent = `model:\n default: "${selectedModel || "provider/model-id"}"\n provider: "custom"\n base_url: "${getEffectiveBaseUrl()}"\n api_key: \${OPENAI_API_KEY}\n`;
|
||||
const envContent = `OPENAI_API_KEY=${keyToUse}\n`;
|
||||
|
||||
return [
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -529,8 +529,7 @@ export default function TokenSaverClient() {
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={headroomEnabled && headroomRunning}
|
||||
disabled={!headroomRunning}
|
||||
checked={headroomEnabled}
|
||||
onChange={() => handleHeadroomEnabled(!headroomEnabled)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -216,7 +216,7 @@ export default function ProviderLimits() {
|
||||
);
|
||||
|
||||
// Fetch quota for a specific connection
|
||||
const fetchQuota = useCallback(async (connectionId, provider) => {
|
||||
const fetchQuota = useCallback(async (connectionId, provider, { force = false } = {}) => {
|
||||
setLoading((prev) => ({ ...prev, [connectionId]: true }));
|
||||
setErrors((prev) => ({ ...prev, [connectionId]: null }));
|
||||
|
||||
@@ -224,7 +224,8 @@ export default function ProviderLimits() {
|
||||
console.log(
|
||||
`[ProviderLimits] Fetching quota for ${provider} (${connectionId})`,
|
||||
);
|
||||
const response = await fetch(`/api/usage/${connectionId}`);
|
||||
const url = `/api/usage/${connectionId}${force ? "?force=1" : ""}`;
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
@@ -295,7 +296,7 @@ export default function ProviderLimits() {
|
||||
// Refresh quota for a specific provider
|
||||
const refreshProvider = useCallback(
|
||||
async (connectionId, provider) => {
|
||||
await fetchQuota(connectionId, provider);
|
||||
await fetchQuota(connectionId, provider, { force: true });
|
||||
setLastUpdated(new Date());
|
||||
},
|
||||
[fetchQuota],
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getModelsByProviderId } from "open-sse/config/providerModels.js";
|
||||
export const QUOTA_CACHE_KEY = "quotaCacheData";
|
||||
export const REFRESH_INTERVAL_MS = 60000;
|
||||
// Claude usage/quota endpoint rate-limits; poll it less often than other providers
|
||||
export const CLAUDE_REFRESH_INTERVAL_MS = 180000;
|
||||
export const CLAUDE_REFRESH_INTERVAL_MS = 600000;
|
||||
export const DEPLETED_QUOTA_THRESHOLD = 5;
|
||||
export const AUTO_REFRESH_STORAGE_KEY = "quotaAutoRefresh";
|
||||
export const CONNECTIONS_PAGE_SIZE = 20;
|
||||
@@ -36,6 +36,17 @@ export function getConnectionQuotaRemaining(connection, quotaData) {
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
// Stable group-by-provider: first-seen provider order, original order within group.
|
||||
function groupByProviderStable(connections) {
|
||||
const seen = new Map();
|
||||
for (const conn of connections) {
|
||||
const key = conn.provider || "";
|
||||
if (!seen.has(key)) seen.set(key, []);
|
||||
seen.get(key).push(conn);
|
||||
}
|
||||
return Array.from(seen.values()).flat();
|
||||
}
|
||||
|
||||
export function sortVisibleConnections(
|
||||
connections,
|
||||
quotaData,
|
||||
@@ -58,7 +69,7 @@ export function sortVisibleConnections(
|
||||
});
|
||||
}
|
||||
|
||||
if (!expiringFirst) return connections;
|
||||
if (!expiringFirst) return groupByProviderStable(connections);
|
||||
|
||||
const getEarliestResetTime = (connection) => {
|
||||
const resetTimes = (quotaData[connection.id]?.quotas || [])
|
||||
|
||||
@@ -4,6 +4,7 @@ import bcrypt from "bcryptjs";
|
||||
import { cookies } from "next/headers";
|
||||
import { setDashboardAuthCookie } from "@/lib/auth/dashboardSession";
|
||||
import { isOidcConfigured } from "@/lib/auth/oidc";
|
||||
import { isSamlConfigured } from "@/lib/auth/saml.js";
|
||||
import { checkLock, recordFail, recordSuccess, getClientIp } from "@/lib/auth/loginLimiter";
|
||||
import { isLocalRequest } from "@/dashboardGuard";
|
||||
|
||||
@@ -39,8 +40,14 @@ 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 });
|
||||
if (settings.authMode === "sso" || settings.authMode === "saml" || settings.authMode === "oidc") {
|
||||
const ssoType = settings.ssoType || (settings.authMode === "saml" ? "saml" : "oidc");
|
||||
if (ssoType === "saml" && isSamlConfigured(settings)) {
|
||||
return NextResponse.json({ error: "Password login is disabled. Use SAML SSO sign in." }, { status: 403 });
|
||||
}
|
||||
if (ssoType === "oidc" && isOidcConfigured(settings)) {
|
||||
return NextResponse.json({ error: "Password login is disabled. Use OIDC sign in." }, { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
let isValid = false;
|
||||
@@ -54,15 +61,36 @@ export async function POST(request) {
|
||||
|
||||
if (isValid) {
|
||||
recordSuccess(ip);
|
||||
const cookieStore = await cookies();
|
||||
await setDashboardAuthCookie(cookieStore, request);
|
||||
|
||||
// Default password still in use on a remote client → force a password
|
||||
// change before the dashboard is exposed remotely (keeps local UX intact).
|
||||
const mustChangePassword =
|
||||
!storedHash && !process.env.INITIAL_PASSWORD && !isLocalRequest(request);
|
||||
|
||||
return NextResponse.json({ success: true, mustChangePassword }, { headers: NO_STORE_HEADERS });
|
||||
if (mustChangePassword) {
|
||||
// Do NOT issue a session token: a fresh install's default password is
|
||||
// public knowledge ("123456"), so handing out a valid JWT would let any
|
||||
// remote attacker authenticate and (e.g.) PATCH /api/settings to disable
|
||||
// authentication entirely (CVE-2026-56679 class). Require the password
|
||||
// to be changed first.
|
||||
//
|
||||
// NOTE: this intentionally leaves no remote self-service password-change
|
||||
// path — the change-password flow (PATCH /api/settings) requires a JWT,
|
||||
// which we deliberately withhold. A remote fresh-install user must either
|
||||
// change the password from the local machine or set INITIAL_PASSWORD
|
||||
// before first launch. This is a deliberate security trade-off, not an
|
||||
// oversight: issuing any credential before the default password is
|
||||
// rotated re-opens the exact attack chain this branch closes.
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Default password must be changed before remote access. Change it from the local machine (or set INITIAL_PASSWORD).", mustChangePassword },
|
||||
{ status: 403, headers: NO_STORE_HEADERS }
|
||||
);
|
||||
}
|
||||
|
||||
const cookieStore = await cookies();
|
||||
await setDashboardAuthCookie(cookieStore, request);
|
||||
|
||||
return NextResponse.json({ success: true, mustChangePassword: false }, { headers: NO_STORE_HEADERS });
|
||||
}
|
||||
|
||||
const { remainingBeforeLock } = recordFail(ip);
|
||||
|
||||
69
src/app/api/auth/saml/acs/route.js
Normal file
69
src/app/api/auth/saml/acs/route.js
Normal file
@@ -0,0 +1,69 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
import { getSettings } from "@/lib/localDb";
|
||||
import {
|
||||
getSamlBaseUrl,
|
||||
isSamlConfigured,
|
||||
pickSamlDisplayName,
|
||||
pickSamlEmail,
|
||||
validateSamlResponse,
|
||||
} from "@/lib/auth/saml.js";
|
||||
import { setDashboardAuthCookie } from "@/lib/auth/dashboardSession";
|
||||
import { checkLock, recordFail, recordSuccess, getClientIp } from "@/lib/auth/loginLimiter";
|
||||
|
||||
export async function POST(request) {
|
||||
const settings = await getSettings();
|
||||
const origin = getSamlBaseUrl(request, settings);
|
||||
const ip = getClientIp(request);
|
||||
|
||||
const lock = checkLock(ip);
|
||||
if (lock.locked) {
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
`/login?error=${encodeURIComponent(`Too many failed attempts. Try again in ${lock.retryAfter}s.`)}`,
|
||||
origin
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const cookieStore = await cookies();
|
||||
const storedRequestId = cookieStore.get("saml_state")?.value || "";
|
||||
|
||||
// Always clear saml_state cookie after attempt
|
||||
cookieStore.delete("saml_state");
|
||||
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const SAMLResponse = formData.get("SAMLResponse");
|
||||
|
||||
if (!SAMLResponse) {
|
||||
recordFail(ip);
|
||||
return NextResponse.redirect(new URL("/login?error=saml_missing_response", origin));
|
||||
}
|
||||
|
||||
if (!isSamlConfigured(settings)) {
|
||||
recordFail(ip);
|
||||
return NextResponse.redirect(new URL("/login?error=saml_not_configured", origin));
|
||||
}
|
||||
|
||||
const profile = await validateSamlResponse(request, { SAMLResponse }, storedRequestId, settings);
|
||||
|
||||
const samlEmail = pickSamlEmail(profile, settings) || null;
|
||||
const samlName = pickSamlDisplayName(profile, settings) || "SAML user";
|
||||
|
||||
recordSuccess(ip);
|
||||
|
||||
await setDashboardAuthCookie(cookieStore, request, {
|
||||
saml: true,
|
||||
samlEmail,
|
||||
samlName,
|
||||
});
|
||||
|
||||
return NextResponse.redirect(new URL("/dashboard", origin));
|
||||
} catch (error) {
|
||||
recordFail(ip);
|
||||
return NextResponse.redirect(
|
||||
new URL(`/login?error=${encodeURIComponent(error.message || "saml_acs_failed")}`, origin)
|
||||
);
|
||||
}
|
||||
}
|
||||
25
src/app/api/auth/saml/metadata/route.js
Normal file
25
src/app/api/auth/saml/metadata/route.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { getSettings } from "@/lib/localDb";
|
||||
import { generateSamlMetadata } from "@/lib/auth/saml";
|
||||
|
||||
export async function GET(request) {
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
const origin = new URL(request.url).origin;
|
||||
const metadataXml = generateSamlMetadata(origin, settings);
|
||||
|
||||
return new Response(metadataXml, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/xml",
|
||||
"Cache-Control": "no-cache",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return new Response(`<?xml version="1.0"?><Error>${error.message || "Failed to generate metadata"}</Error>`, {
|
||||
status: 500,
|
||||
headers: {
|
||||
"Content-Type": "application/xml",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
32
src/app/api/auth/saml/start/route.js
Normal file
32
src/app/api/auth/saml/start/route.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
import { getSettings } from "@/lib/localDb";
|
||||
import { buildSamlAuthorizeUrl, getSamlBaseUrl, isSamlConfigured } from "@/lib/auth/saml.js";
|
||||
import { shouldUseSecureCookie } from "@/lib/auth/dashboardSession";
|
||||
|
||||
export async function GET(request) {
|
||||
const settings = await getSettings();
|
||||
const origin = getSamlBaseUrl(request, settings);
|
||||
try {
|
||||
if (!isSamlConfigured(settings)) {
|
||||
return NextResponse.redirect(new URL("/login?error=saml_not_configured", origin));
|
||||
}
|
||||
|
||||
const { authorizeUrl, requestId } = await buildSamlAuthorizeUrl(request, settings);
|
||||
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set("saml_state", requestId, {
|
||||
httpOnly: true,
|
||||
secure: shouldUseSecureCookie(request),
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 10 * 60,
|
||||
});
|
||||
|
||||
return NextResponse.redirect(authorizeUrl);
|
||||
} catch (error) {
|
||||
return NextResponse.redirect(
|
||||
new URL(`/login?error=${encodeURIComponent(error.message || "saml_start_failed")}`, origin)
|
||||
);
|
||||
}
|
||||
}
|
||||
72
src/app/api/auth/saml/test/route.js
Normal file
72
src/app/api/auth/saml/test/route.js
Normal file
@@ -0,0 +1,72 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
import { getSettings } from "@/lib/localDb";
|
||||
import { formatX509Certificate } from "@/lib/auth/saml.js";
|
||||
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 samlEntryPoint = String(body.samlEntryPoint || settings.samlEntryPoint || "").trim();
|
||||
const samlIssuer = String(body.samlIssuer || settings.samlIssuer || "urn:9router:sp").trim();
|
||||
const samlCert = String(
|
||||
Object.prototype.hasOwnProperty.call(body, "samlCert")
|
||||
? body.samlCert
|
||||
: settings.samlCert || ""
|
||||
).trim();
|
||||
|
||||
if (!samlEntryPoint) {
|
||||
return NextResponse.json({ error: "Single Sign-On Service URL (samlEntryPoint) is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
new URL(samlEntryPoint);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Single Sign-On Service URL must be a valid URL" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!samlIssuer) {
|
||||
return NextResponse.json({ error: "SP Entity ID / Issuer (samlIssuer) is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!samlCert) {
|
||||
return NextResponse.json({ error: "IdP X.509 Certificate (samlCert) is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const formattedCert = formatX509Certificate(samlCert);
|
||||
if (!formattedCert) {
|
||||
return NextResponse.json({ error: "Invalid IdP X.509 Certificate format" }, { status: 400 });
|
||||
}
|
||||
|
||||
const origin = new URL(request.url).origin;
|
||||
const acsUrl = `${origin}/api/auth/saml/acs`;
|
||||
const metadataUrl = `${origin}/api/auth/saml/metadata`;
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
samlEntryPoint,
|
||||
samlIssuer,
|
||||
certValid: true,
|
||||
acsUrl,
|
||||
metadataUrl,
|
||||
message: "SAML 2.0 configuration verified successfully.",
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error.message || "SAML test failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
import { getSettings } from "@/lib/localDb";
|
||||
import { isOidcConfigured } from "@/lib/auth/oidc";
|
||||
import { isSamlConfigured } from "@/lib/auth/saml.js";
|
||||
import { getDashboardAuthSession } from "@/lib/auth/dashboardSession";
|
||||
|
||||
export async function GET() {
|
||||
@@ -11,16 +12,29 @@ export async function GET() {
|
||||
const session = await getDashboardAuthSession(cookieStore.get("auth_token")?.value);
|
||||
const requireLogin = settings.requireLogin !== false;
|
||||
const authMode = settings.authMode || "password";
|
||||
const ssoType = settings.ssoType || "oidc";
|
||||
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";
|
||||
const samlName = String(session?.samlName || "").trim();
|
||||
const samlEmail = String(session?.samlEmail || "").trim();
|
||||
|
||||
const displayName =
|
||||
samlName ||
|
||||
samlEmail ||
|
||||
oidcName ||
|
||||
oidcEmail ||
|
||||
(session?.saml ? "SAML user" : session?.oidc ? "OIDC user" : "Password user");
|
||||
|
||||
const loginMethod = session?.saml ? "SAML" : session?.oidc ? "OIDC" : "Password";
|
||||
|
||||
return NextResponse.json({
|
||||
requireLogin,
|
||||
authMode,
|
||||
ssoType,
|
||||
oidcConfigured: isOidcConfigured(settings),
|
||||
oidcLoginLabel: (settings.oidcLoginLabel || "Sign in with OIDC").trim() || "Sign in with OIDC",
|
||||
samlConfigured: isSamlConfigured(settings),
|
||||
samlLoginLabel: (settings.samlLoginLabel || "Sign in with SAML SSO").trim() || "Sign in with SAML SSO",
|
||||
hasPassword: !!settings.password,
|
||||
displayName,
|
||||
loginMethod,
|
||||
@@ -28,13 +42,19 @@ export async function GET() {
|
||||
oidcName: oidcName || null,
|
||||
oidcEmail: oidcEmail || null,
|
||||
oidcLogin: !!session?.oidc,
|
||||
samlName: samlName || null,
|
||||
samlEmail: samlEmail || null,
|
||||
samlLogin: !!session?.saml,
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({
|
||||
requireLogin: true,
|
||||
authMode: "password",
|
||||
ssoType: "oidc",
|
||||
oidcConfigured: false,
|
||||
oidcLoginLabel: "Sign in with OIDC",
|
||||
samlConfigured: false,
|
||||
samlLoginLabel: "Sign in with SAML SSO",
|
||||
hasPassword: false,
|
||||
displayName: "Password user",
|
||||
loginMethod: "Password",
|
||||
@@ -42,6 +62,9 @@ export async function GET() {
|
||||
oidcName: null,
|
||||
oidcEmail: null,
|
||||
oidcLogin: false,
|
||||
samlName: null,
|
||||
samlEmail: null,
|
||||
samlLogin: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ const getHermesEnvPath = () => path.join(getHermesDir(), ".env");
|
||||
const MODEL_BLOCK_RE = /^model:[ \t]*\r?\n((?:[ \t]+.*\r?\n?|[ \t]*\r?\n)*)/m;
|
||||
|
||||
const buildModelBlock = (model, baseUrl) =>
|
||||
`model:\n default: "${model}"\n provider: "custom"\n base_url: "${baseUrl}"\n`;
|
||||
`model:\n default: "${model}"\n provider: "custom"\n base_url: "${baseUrl}"\n api_key: \${OPENAI_API_KEY}\n`;
|
||||
|
||||
// Parse current model block back to fields (best-effort, simple key:value)
|
||||
const parseModelBlock = (yaml) => {
|
||||
@@ -35,6 +35,7 @@ const parseModelBlock = (yaml) => {
|
||||
default: get("default"),
|
||||
provider: get("provider"),
|
||||
base_url: get("base_url"),
|
||||
api_key: get("api_key"),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -142,9 +142,11 @@ export async function pingModelByKind(
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
// Claude-on-Copilot returns empty choices at max_tokens:1 (budget is spent
|
||||
// before a content token emits), so a 1-token probe yields a false negative.
|
||||
max_tokens: 16,
|
||||
// 1024 tokens: reasoning models (ClinePass/kimi-k3, deepseek-v4-pro, etc.) spend
|
||||
// their budget on chain-of-thought before emitting an answer. A tiny probe like
|
||||
// max_tokens:16 starves the answer and yields a false "no choices" failure.
|
||||
// See issue #3010.
|
||||
max_tokens: 1024,
|
||||
stream: false,
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
}),
|
||||
@@ -187,6 +189,21 @@ export async function pingModelByKind(
|
||||
}
|
||||
|
||||
const hasChoices = Array.isArray(parsed?.choices) && parsed.choices.length > 0;
|
||||
|
||||
// Soft-pass (issue #3010): a reasoning model may burn its whole budget on
|
||||
// chain-of-thought and return finish_reason:"length" with empty content but
|
||||
// non-empty reasoning/thinking. That's a successful connection, not a failure.
|
||||
const firstChoice = parsed?.choices?.[0] || {};
|
||||
const hasReasoning =
|
||||
firstChoice.message?.reasoning ||
|
||||
firstChoice.message?.reasoning_content ||
|
||||
firstChoice.message?.thinking ||
|
||||
firstChoice.message?.thinking_content;
|
||||
const contentEmpty = !String(firstChoice.message?.content || "").trim();
|
||||
if (hasChoices && firstChoice.finish_reason === "length" && contentEmpty && hasReasoning) {
|
||||
return { ok: true, latencyMs, error: null, status: res.status, note: "reasoning-only response (length-limited)" };
|
||||
}
|
||||
|
||||
if (!hasChoices) {
|
||||
return {
|
||||
ok: false,
|
||||
|
||||
@@ -788,6 +788,27 @@ async function testApiKeyConnection(connection, effectiveProxy = null) {
|
||||
);
|
||||
return { valid: exRes.ok, error: exRes.ok ? null : "Invalid Personal Access Token" };
|
||||
}
|
||||
case "llm7": {
|
||||
const baseUrl = connection.providerSpecificData?.baseUrl || "https://api.llm7.io/v1";
|
||||
const res = await fetchWithConnectionProxy(`${baseUrl.replace(/\/$/, "")}/models`, {
|
||||
headers: { Authorization: `Bearer ${connection.apiKey}` },
|
||||
}, effectiveProxy);
|
||||
return { valid: res.ok, error: res.ok ? null : "Invalid API key or base URL" };
|
||||
}
|
||||
case "kimchi": {
|
||||
// Dual-auth: same validation endpoint as the OAuth flow — the token (API key
|
||||
// or OAuth access token) is sent as Authorization: Bearer.
|
||||
const url = KIMCHI_CONFIG.validationUrl || "https://api.cast.ai/v1/llm/openai/supported-providers";
|
||||
const res = await fetchWithConnectionProxy(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${connection.apiKey}`,
|
||||
"User-Agent": "kimchi/0.1.40",
|
||||
},
|
||||
}, effectiveProxy);
|
||||
return { valid: res.ok, error: res.ok ? null : "Invalid API key", refreshed: false };
|
||||
}
|
||||
default:
|
||||
return { valid: false, error: "Provider test not supported" };
|
||||
}
|
||||
|
||||
@@ -123,6 +123,7 @@ export async function GET(request, { params }) {
|
||||
let connection;
|
||||
try {
|
||||
const { connectionId } = await params;
|
||||
const force = new URL(request.url).searchParams.get("force") === "1";
|
||||
|
||||
|
||||
// Get connection from database
|
||||
@@ -169,7 +170,7 @@ export async function GET(request, { params }) {
|
||||
}
|
||||
|
||||
// Fetch usage from provider API
|
||||
let usage = await getUsageForProvider(connection, proxyOptions);
|
||||
let usage = await getUsageForProvider(connection, proxyOptions, { force });
|
||||
|
||||
// If provider returned an auth-expired message instead of throwing,
|
||||
// force-refresh token and retry once (OAuth only)
|
||||
@@ -177,7 +178,7 @@ export async function GET(request, { params }) {
|
||||
try {
|
||||
const retryResult = await refreshAndUpdateCredentials(connection, true, proxyOptions);
|
||||
connection = retryResult.connection;
|
||||
usage = await getUsageForProvider(connection, proxyOptions);
|
||||
usage = await getUsageForProvider(connection, proxyOptions, { force });
|
||||
} catch (retryError) {
|
||||
console.warn(`[Usage] ${connection.provider}: force refresh failed: ${retryError.message}`);
|
||||
}
|
||||
|
||||
@@ -49,8 +49,23 @@ export async function GET(request) {
|
||||
if (endDate) filter.endDate = endDate;
|
||||
|
||||
const result = await getRequestDetails(filter);
|
||||
|
||||
return NextResponse.json(result);
|
||||
|
||||
// Redact conversation payloads: the stored details include full request
|
||||
// bodies (user prompts, tool calls) and provider responses. Returning them
|
||||
// wholesale lets any dashboard-authenticated user (or, if requireLogin is
|
||||
// disabled, anyone) read every user's conversation history. Keep the
|
||||
// metadata (model, tokens, latency, status) but drop message content.
|
||||
const redactedDetails = (result.details || []).map((d) => {
|
||||
const redacted = { ...d };
|
||||
for (const key of ["request", "providerRequest", "providerResponse", "response"]) {
|
||||
if (redacted[key] !== undefined) {
|
||||
redacted[key] = { redacted: true };
|
||||
}
|
||||
}
|
||||
return redacted;
|
||||
});
|
||||
|
||||
return NextResponse.json({ ...result, details: redactedDetails });
|
||||
} catch (error) {
|
||||
console.error("[API] Failed to get request details:", error);
|
||||
return NextResponse.json(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,8 +11,11 @@ export default function LoginPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasPassword, setHasPassword] = useState(null);
|
||||
const [authMode, setAuthMode] = useState("password");
|
||||
const [ssoType, setSsoType] = useState("oidc");
|
||||
const [oidcConfigured, setOidcConfigured] = useState(false);
|
||||
const [oidcLoginLabel, setOidcLoginLabel] = useState("Sign in with OIDC");
|
||||
const [samlConfigured, setSamlConfigured] = useState(false);
|
||||
const [samlLoginLabel, setSamlLoginLabel] = useState("Sign in with SAML SSO");
|
||||
const [mustChange, setMustChange] = useState(false);
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
|
||||
@@ -43,8 +46,11 @@ export default function LoginPage() {
|
||||
}
|
||||
setHasPassword(!!data.hasPassword);
|
||||
setAuthMode(data.authMode || "password");
|
||||
setSsoType(data.ssoType || "oidc");
|
||||
setOidcConfigured(data.oidcConfigured === true);
|
||||
setOidcLoginLabel(data.oidcLoginLabel || "Sign in with OIDC");
|
||||
setSamlConfigured(data.samlConfigured === true);
|
||||
setSamlLoginLabel(data.samlLoginLabel || "Sign in with SAML SSO");
|
||||
} else {
|
||||
// Safe fallback on non-OK response to avoid infinite loading state.
|
||||
setHasPassword(true);
|
||||
@@ -118,8 +124,18 @@ export default function LoginPage() {
|
||||
window.location.href = "/api/auth/oidc/start";
|
||||
};
|
||||
|
||||
const oidcAvailable = oidcConfigured && ["oidc", "both"].includes(authMode);
|
||||
const passwordAvailable = authMode !== "oidc" || !oidcConfigured;
|
||||
const handleSamlLogin = () => {
|
||||
window.location.href = "/api/auth/saml/start";
|
||||
};
|
||||
|
||||
const isSsoEnabled = ["sso", "oidc", "saml", "both"].includes(authMode);
|
||||
const activeSsoType = ssoType || (authMode === "saml" ? "saml" : "oidc");
|
||||
|
||||
const samlAvailable = isSsoEnabled && activeSsoType === "saml" && samlConfigured;
|
||||
const oidcAvailable = isSsoEnabled && activeSsoType === "oidc" && oidcConfigured;
|
||||
const ssoAvailable = samlAvailable || oidcAvailable;
|
||||
|
||||
const passwordAvailable = authMode === "password" || authMode === "both" || !ssoAvailable;
|
||||
|
||||
// Show loading state while checking password
|
||||
if (hasPassword === null) {
|
||||
@@ -141,7 +157,9 @@ export default function LoginPage() {
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-primary mb-2">9Router</h1>
|
||||
<p className="text-text-muted">
|
||||
{authMode === "oidc" && oidcConfigured
|
||||
{samlAvailable
|
||||
? "Sign in with SAML 2.0 Single Sign-On"
|
||||
: oidcAvailable
|
||||
? "Sign in with your OIDC provider to access the dashboard"
|
||||
: "Enter your password to access the dashboard"}
|
||||
</p>
|
||||
@@ -171,25 +189,31 @@ export default function LoginPage() {
|
||||
</form>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{samlAvailable && (
|
||||
<Button type="button" variant="primary" className="w-full" onClick={handleSamlLogin}>
|
||||
{samlLoginLabel}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{oidcAvailable && (
|
||||
<Button type="button" variant="primary" className="w-full" onClick={handleOidcLogin}>
|
||||
{oidcLoginLabel}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{oidcAvailable && passwordAvailable && <div className="h-px bg-border/60" />}
|
||||
{ssoAvailable && passwordAvailable && <div className="h-px bg-border/60" />}
|
||||
|
||||
{passwordAvailable ? (
|
||||
<form onSubmit={handleLogin} className="flex flex-col gap-4">
|
||||
{((authMode === "oidc" && !oidcConfigured) || (authMode === "both" && !oidcConfigured)) && (
|
||||
{isSsoEnabled && !ssoAvailable && (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400 text-center">
|
||||
OIDC login is enabled, but the issuer/client fields are not configured yet. Password login is still available for recovery.
|
||||
{activeSsoType === "saml" ? "SAML SSO" : "OIDC"} login is enabled, but configuration is incomplete. Password login is still available for recovery.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{authMode === "both" && oidcConfigured && (
|
||||
{authMode === "both" && ssoAvailable && (
|
||||
<p className="text-xs text-text-muted text-center">
|
||||
Password and OIDC login are both enabled.
|
||||
Password and {activeSsoType === "saml" ? "SAML SSO" : "OIDC"} login are both enabled.
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
||||
import { getSettings, validateApiKey } from "@/lib/localDb";
|
||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
import { verifyDashboardAuthToken } from "@/lib/auth/dashboardSession";
|
||||
import { hasTrustedPeerHeaders } from "@/lib/auth/trustedPeer";
|
||||
|
||||
const CLI_TOKEN_HEADER = "x-9r-cli-token";
|
||||
const CLI_TOKEN_SALT = "9r-cli-auth";
|
||||
@@ -27,6 +28,7 @@ const PUBLIC_API_PATHS = [
|
||||
"/api/auth/logout",
|
||||
"/api/auth/status",
|
||||
"/api/auth/oidc",
|
||||
"/api/auth/saml",
|
||||
"/api/version",
|
||||
"/api/settings/require-login",
|
||||
];
|
||||
@@ -86,24 +88,40 @@ const LOCAL_ONLY_PATHS = [
|
||||
|
||||
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
|
||||
|
||||
// Accepts a Host header, a URL hostname or a raw socket address. Splitting on the first
|
||||
// colon only works for IPv4 and would reduce every IPv6 form to "", so a dual-stack
|
||||
// listener handing back ::ffff:127.0.0.1 would not read as loopback.
|
||||
function isLoopbackHostname(h) {
|
||||
if (!h) return false;
|
||||
const name = h.split(":")[0].replace(/^\[|\]$/g, "").toLowerCase();
|
||||
let name = String(h).trim().toLowerCase();
|
||||
if (name.startsWith("[")) {
|
||||
const end = name.indexOf("]");
|
||||
if (end === -1) return false;
|
||||
name = name.slice(1, end);
|
||||
} else if (name.indexOf(":") !== -1 && name.indexOf(":") === name.lastIndexOf(":")) {
|
||||
name = name.slice(0, name.indexOf(":"));
|
||||
}
|
||||
if (name.startsWith("::ffff:")) name = name.slice(7);
|
||||
return LOOPBACK_HOSTS.has(name);
|
||||
}
|
||||
|
||||
function isLoopbackPeer(request) {
|
||||
if (hasTrustedPeerHeaders(request)) {
|
||||
return isLoopbackHostname(request.headers.get("x-9r-real-ip"));
|
||||
}
|
||||
// Bare `next dev` forks its server, so the wrapper never loads and no peer address
|
||||
// reaches us. Host is spoofable, so this stays confined to development.
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
return isLoopbackHostname(request.headers.get("host"));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isLocalRequest(request) {
|
||||
// Stamped by custom-server.js when forwarding headers exist: request came through
|
||||
// a reverse proxy, so the loopback socket is the proxy hop, not the end-user.
|
||||
if (request.headers.get("x-9r-via-proxy")) return false;
|
||||
// Trusted peer IP from TCP socket (custom-server.js); unspoofable. Primary anchor for "local".
|
||||
const realIp = request.headers.get("x-9r-real-ip");
|
||||
if (realIp) {
|
||||
if (!isLoopbackHostname(realIp)) return false;
|
||||
} else if (!isLoopbackHostname(request.headers.get("host"))) {
|
||||
// Fallback for bare server.js (dev) without custom-server: legacy Host-based check.
|
||||
return false;
|
||||
}
|
||||
if (!isLoopbackPeer(request)) return false;
|
||||
const origin = request.headers.get("origin");
|
||||
if (origin) {
|
||||
try {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// In-memory progressive lockout for dashboard login. Resets on process restart.
|
||||
import { hasTrustedPeerHeaders } from "./trustedPeer.js";
|
||||
|
||||
const MAX_FAILS_BEFORE_LOCK = 5;
|
||||
const LOCK_STEPS_MS = [30_000, 120_000, 600_000, 1_800_000]; // 30s, 2m, 10m, 30m
|
||||
@@ -46,9 +47,12 @@ export function recordSuccess(ip) {
|
||||
}
|
||||
|
||||
export function getClientIp(request) {
|
||||
// Trusted: set from TCP socket by custom-server.js (client cannot spoof).
|
||||
const realIp = request.headers.get("x-9r-real-ip");
|
||||
if (realIp) return realIp;
|
||||
// Trusted only when custom-server.js proves it stamped the header from the TCP socket;
|
||||
// otherwise a client could rotate the value to escape its own lockout bucket.
|
||||
if (hasTrustedPeerHeaders(request)) {
|
||||
const realIp = request.headers.get("x-9r-real-ip");
|
||||
if (realIp) return realIp;
|
||||
}
|
||||
// Behind a trusted reverse proxy that overwrites XFF with the real client IP.
|
||||
if (process.env.TRUST_PROXY === "true") {
|
||||
const xff = request.headers.get("x-forwarded-for");
|
||||
|
||||
268
src/lib/auth/saml.js
Normal file
268
src/lib/auth/saml.js
Normal file
@@ -0,0 +1,268 @@
|
||||
import { SAML } from "@node-saml/node-saml";
|
||||
import { getSettings } from "../db/repos/settingsRepo.js";
|
||||
|
||||
/**
|
||||
* Formats a raw Base64 string or unformatted X.509 certificate into standard PEM format.
|
||||
* @param {string} certStr
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatX509Certificate(certStr) {
|
||||
if (!certStr || typeof certStr !== "string") return "";
|
||||
const clean = certStr
|
||||
.replace(/-----BEGIN CERTIFICATE-----/gi, "")
|
||||
.replace(/-----END CERTIFICATE-----/gi, "")
|
||||
.replace(/[^A-Za-z0-9+/=]/g, "");
|
||||
|
||||
if (!clean) return "";
|
||||
|
||||
const lines = clean.match(/.{1,64}/g) || [];
|
||||
return `-----BEGIN CERTIFICATE-----\n${lines.join("\n")}\n-----END CERTIFICATE-----`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether SAML configuration has essential parameters (entryPoint & cert).
|
||||
* @param {object} settings
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isSamlConfigured(settings) {
|
||||
return Boolean(settings?.samlEntryPoint && settings?.samlCert);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches settings and returns runtime status + settings.
|
||||
* @returns {Promise<{ configured: boolean, settings: object }>}
|
||||
*/
|
||||
export async function getSamlRuntimeConfig() {
|
||||
const settings = await getSettings();
|
||||
return {
|
||||
configured: isSamlConfigured(settings),
|
||||
settings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a configured `@node-saml/node-saml` SAML instance with security defaults.
|
||||
* @param {object} settings
|
||||
* @param {string} origin
|
||||
* @returns {SAML}
|
||||
*/
|
||||
const DUMMY_FALLBACK_CERT =
|
||||
"-----BEGIN CERTIFICATE-----\nMIIC...DUMMY...\n-----END CERTIFICATE-----";
|
||||
|
||||
function trimTrailingSlashes(str) {
|
||||
return (str || "").replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the public Base URL / Origin for SAML requests.
|
||||
* Respects settings.baseUrl, process.env.BASE_URL, x-forwarded-proto, and x-forwarded-host.
|
||||
* @param {Request} request
|
||||
* @param {object} settings
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getSamlBaseUrl(request, settings) {
|
||||
const configuredBaseUrl =
|
||||
(settings?.baseUrl || "").trim() ||
|
||||
process.env.BASE_URL ||
|
||||
process.env.NEXT_PUBLIC_BASE_URL ||
|
||||
"";
|
||||
|
||||
if (configuredBaseUrl) {
|
||||
return trimTrailingSlashes(configuredBaseUrl);
|
||||
}
|
||||
|
||||
if (request) {
|
||||
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(/\/+$/, "");
|
||||
}
|
||||
if (request.url) {
|
||||
return trimTrailingSlashes(new URL(request.url).origin);
|
||||
}
|
||||
}
|
||||
|
||||
return "http://localhost:20128";
|
||||
}
|
||||
|
||||
export function createSamlInstance(settings, origin) {
|
||||
const cert = formatX509Certificate(settings?.samlCert || "") || DUMMY_FALLBACK_CERT;
|
||||
const callbackUrl = `${origin}/api/auth/saml/acs`;
|
||||
return new SAML({
|
||||
entryPoint: settings?.samlEntryPoint || "https://example.com/sso",
|
||||
issuer: settings?.samlIssuer || "urn:9router:sp",
|
||||
idpCert: cert,
|
||||
cert: cert,
|
||||
callbackUrl: callbackUrl,
|
||||
acceptedClockSkewMs: 60000,
|
||||
wantAssertionsSigned: true,
|
||||
validateInResponseTo: "never",
|
||||
requestIdExpirationMs: 28800000, // 8 hours
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds SAML AuthnRequest redirect URL and returns { authorizeUrl, requestId }.
|
||||
* @param {Request} request
|
||||
* @param {object} settings
|
||||
* @returns {Promise<{ authorizeUrl: string, requestId: string }>}
|
||||
*/
|
||||
export async function buildSamlAuthorizeUrl(request, settings) {
|
||||
const origin = getSamlBaseUrl(request, settings);
|
||||
const samlInstance = createSamlInstance(settings, origin);
|
||||
|
||||
const xml = await samlInstance.generateAuthorizeRequestAsync(false, false);
|
||||
const match = xml.match(/ID="([^"]+)"/);
|
||||
const requestId = match ? match[1] : "";
|
||||
|
||||
const authorizeUrl = await samlInstance._requestToUrlAsync(xml, null, "authorize", {});
|
||||
|
||||
return { authorizeUrl, requestId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates SAML POST response from IdP ACS callback and returns user profile.
|
||||
* @param {Request} request
|
||||
* @param {object} body - Parsed form body or object containing SAMLResponse
|
||||
* @param {string} expectedRequestId - Request ID stored in saml_state cookie
|
||||
* @param {object} settings
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function validateSamlResponse(request, body, expectedRequestId, settings) {
|
||||
if (!settings?.samlCert) {
|
||||
throw new Error("IdP X.509 Certificate (samlCert) is missing or not configured");
|
||||
}
|
||||
|
||||
const origin = getSamlBaseUrl(request, settings);
|
||||
const samlInstance = createSamlInstance(settings, origin);
|
||||
|
||||
const container = typeof body === "object" && body !== null ? body : { SAMLResponse: body };
|
||||
const rawSamlResponse = container.SAMLResponse;
|
||||
|
||||
if (!rawSamlResponse) {
|
||||
throw new Error("Missing SAMLResponse parameter in assertion POST body");
|
||||
}
|
||||
|
||||
// Parse response XML to inspect InResponseTo for replay protection
|
||||
if (expectedRequestId) {
|
||||
const xml = Buffer.from(rawSamlResponse, "base64").toString("utf8");
|
||||
const match = xml.match(/InResponseTo=["']([^"']+)["']/i);
|
||||
const inResponseTo = match ? match[1] : null;
|
||||
|
||||
if (!inResponseTo || inResponseTo !== expectedRequestId) {
|
||||
throw new Error(`InResponseTo mismatch: expected ${expectedRequestId}, received ${inResponseTo || "none"}`);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await samlInstance.validatePostResponseAsync({ SAMLResponse: rawSamlResponse });
|
||||
const profile = result?.profile || result;
|
||||
|
||||
return profile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates standard SP XML Metadata.
|
||||
* @param {string} origin
|
||||
* @param {object} settings
|
||||
* @returns {string}
|
||||
*/
|
||||
export function generateSamlMetadata(origin, settings) {
|
||||
const samlInstance = createSamlInstance(settings, origin);
|
||||
return samlInstance.generateServiceProviderMetadata();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts email claim from SAML profile assertion.
|
||||
* @param {object} profile
|
||||
* @param {object} settings
|
||||
* @returns {string}
|
||||
*/
|
||||
export function pickSamlEmail(profile = {}, settings = {}) {
|
||||
if (!profile) return "";
|
||||
|
||||
// 1. Configured custom attribute
|
||||
const customAttr = settings.samlAttributeEmail;
|
||||
if (customAttr && profile[customAttr]) {
|
||||
const val = profile[customAttr];
|
||||
return Array.isArray(val) ? val[0] : String(val);
|
||||
}
|
||||
|
||||
// 2. Common email claims
|
||||
const emailKeys = [
|
||||
"email",
|
||||
"emailAddress",
|
||||
"mail",
|
||||
"nameID",
|
||||
"nameId",
|
||||
"upn",
|
||||
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
|
||||
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier",
|
||||
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn",
|
||||
];
|
||||
|
||||
for (const key of emailKeys) {
|
||||
if (profile[key]) {
|
||||
const val = profile[key];
|
||||
return Array.isArray(val) ? val[0] : String(val);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallback: check attributes object if present
|
||||
if (profile.attributes) {
|
||||
for (const key of emailKeys) {
|
||||
if (profile.attributes[key]) {
|
||||
const val = profile.attributes[key];
|
||||
return Array.isArray(val) ? val[0] : String(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts display name claim from SAML profile assertion.
|
||||
* @param {object} profile
|
||||
* @param {object} settings
|
||||
* @returns {string}
|
||||
*/
|
||||
export function pickSamlDisplayName(profile = {}, settings = {}) {
|
||||
if (!profile) return "";
|
||||
|
||||
// 1. Configured custom attribute
|
||||
const customAttr = settings.samlAttributeName;
|
||||
if (customAttr && profile[customAttr]) {
|
||||
const val = profile[customAttr];
|
||||
return Array.isArray(val) ? val[0] : String(val);
|
||||
}
|
||||
|
||||
// 2. Common name claims
|
||||
const nameKeys = [
|
||||
"displayName",
|
||||
"name",
|
||||
"cn",
|
||||
"commonName",
|
||||
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name",
|
||||
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname",
|
||||
];
|
||||
|
||||
for (const key of nameKeys) {
|
||||
if (profile[key]) {
|
||||
const val = profile[key];
|
||||
return Array.isArray(val) ? val[0] : String(val);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Combined givenName + surname
|
||||
if (profile.givenName || profile.sn || profile.surname) {
|
||||
const given = profile.givenName || "";
|
||||
const surname = profile.sn || profile.surname || "";
|
||||
const combined = `${given} ${surname}`.trim();
|
||||
if (combined) return combined;
|
||||
}
|
||||
|
||||
// 4. Fallback to email
|
||||
return pickSamlEmail(profile, settings);
|
||||
}
|
||||
7
src/lib/auth/trustedPeer.js
Normal file
7
src/lib/auth/trustedPeer.js
Normal file
@@ -0,0 +1,7 @@
|
||||
// x-9r-real-ip is only trustworthy when custom-server.js stamped it from the TCP socket.
|
||||
// It proves that by echoing the per-process secret it generated at boot, which a client
|
||||
// cannot guess. Without the proof the header is just attacker-supplied input.
|
||||
export function hasTrustedPeerHeaders(request) {
|
||||
const token = process.env.NINEROUTER_PEER_TOKEN;
|
||||
return Boolean(token) && request.headers.get("x-9r-peer-token") === token;
|
||||
}
|
||||
@@ -68,6 +68,8 @@ function sanitizeHeaders(headers) {
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
export const __test__ = { sanitizeHeaders };
|
||||
|
||||
function generateDetailId(model) {
|
||||
const timestamp = new Date().toISOString();
|
||||
const random = Math.random().toString(36).substring(2, 8);
|
||||
|
||||
@@ -28,11 +28,18 @@ const DEFAULT_SETTINGS = {
|
||||
tunnelDashboardAccess: true,
|
||||
authMode: "password",
|
||||
logLevel: "info",
|
||||
ssoType: "oidc",
|
||||
oidcIssuerUrl: "",
|
||||
oidcClientId: "",
|
||||
oidcClientSecret: "",
|
||||
oidcScopes: "openid profile email",
|
||||
oidcLoginLabel: "Sign in with OIDC",
|
||||
samlEntryPoint: "",
|
||||
samlIssuer: "urn:9router:sp",
|
||||
samlCert: "",
|
||||
samlLoginLabel: "Sign in with SAML SSO",
|
||||
samlAttributeEmail: "email",
|
||||
samlAttributeName: "name",
|
||||
enableObservability: false,
|
||||
observabilityMaxRecords: 1000,
|
||||
observabilityBatchSize: 20,
|
||||
@@ -64,7 +71,7 @@ async function readRaw() {
|
||||
}
|
||||
|
||||
// Merge raw settings with defaults; backward-compat for missing keys
|
||||
function mergeWithDefaults(raw) {
|
||||
export function mergeWithDefaults(raw) {
|
||||
const merged = { ...DEFAULT_SETTINGS, ...(raw || {}) };
|
||||
for (const [key, defVal] of Object.entries(DEFAULT_SETTINGS)) {
|
||||
if (merged[key] === undefined) {
|
||||
|
||||
@@ -26,10 +26,28 @@ const TARGET_HOSTS = [
|
||||
const URL_PATTERNS = {
|
||||
antigravity: [":generateContent", ":streamGenerateContent"],
|
||||
copilot: ["/chat/completions", "/v1/messages", "/responses"],
|
||||
// Legacy path form. Kiro IDE 1.0.228+ posts to `/` with x-amz-target instead —
|
||||
// see isChatRequest() for the header-based match.
|
||||
kiro: ["/generateAssistantResponse"],
|
||||
cursor: ["/BidiAppend", "/RunSSE", "/RunPoll", "/Run"],
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether this request is a chat turn we should intercept (vs passthrough).
|
||||
* Kiro Runtime moved GenerateAssistantResponse from path `/generateAssistantResponse`
|
||||
* to `POST /` + `x-amz-target: KiroRuntimeService.GenerateAssistantResponse`
|
||||
* (verified via live mitmproxy capture of Kiro IDE 1.0.228).
|
||||
*/
|
||||
function isChatRequest(tool, req) {
|
||||
const patterns = URL_PATTERNS[tool] || [];
|
||||
if (patterns.some((p) => (req.url || "").includes(p))) return true;
|
||||
if (tool === "kiro") {
|
||||
const target = String(req.headers?.["x-amz-target"] || "");
|
||||
return target.includes("GenerateAssistantResponse");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Synonym map: rawModel from request → canonical alias key in mitmAlias DB
|
||||
const MODEL_SYNONYMS = {
|
||||
antigravity: {
|
||||
@@ -37,6 +55,9 @@ const MODEL_SYNONYMS = {
|
||||
"gemini-3.5-flash-high": "gemini-3-flash-agent",
|
||||
"gemini-3.5-flash-medium": "gemini-3.5-flash-low",
|
||||
"gemini-3.5-flash-extra-low": "gemini-3.5-flash-extra-low",
|
||||
"gemini-3.7-flash-high": "gemini-3.7-flash-high",
|
||||
"gemini-3.7-flash-medium": "gemini-3.7-flash-medium",
|
||||
"gemini-3.7-flash-low": "gemini-3.7-flash-low",
|
||||
"gemini-3.1-pro-high": "gemini-pro-agent",
|
||||
"gemini-3-pro-high": "gemini-pro-agent",
|
||||
"gemini-3-pro-low": "gemini-3.1-pro-low",
|
||||
@@ -113,13 +134,15 @@ function extractModel(url, body) {
|
||||
return parsed.conversationState.currentMessage?.userInputMessage?.modelId || null;
|
||||
}
|
||||
const model = urlModel || parsed.model || null;
|
||||
if (String(model).replace(/^models\//, "") === "gemini-3.6-flash-tiered") {
|
||||
const cleanModelName = String(model).replace(/^models\//, "");
|
||||
if (cleanModelName === "gemini-3.6-flash-tiered" || cleanModelName === "gemini-3.7-flash-tiered") {
|
||||
const ver = cleanModelName.includes("3.7") ? "3.7" : "3.6";
|
||||
const rawLevel = parsed.request?.generationConfig?.thinkingConfig?.thinkingLevel
|
||||
|| parsed.generationConfig?.thinkingConfig?.thinkingLevel;
|
||||
const level = ["high", "medium", "low"].includes(String(rawLevel).toLowerCase())
|
||||
? String(rawLevel).toLowerCase()
|
||||
: "medium";
|
||||
return `gemini-3.6-flash-${level}`;
|
||||
return `gemini-${ver}-flash-${level}`;
|
||||
}
|
||||
return model;
|
||||
} catch {
|
||||
@@ -127,4 +150,4 @@ function extractModel(url, body) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { IS_DEV, LSOF_BIN, TARGET_HOSTS, URL_PATTERNS, MODEL_SYNONYMS, MODEL_PATTERNS, MODEL_NO_MAP, LOG_BLACKLIST_URL_PARTS, getToolForHost, extractModel };
|
||||
module.exports = { IS_DEV, LSOF_BIN, TARGET_HOSTS, URL_PATTERNS, MODEL_SYNONYMS, MODEL_PATTERNS, MODEL_NO_MAP, LOG_BLACKLIST_URL_PARTS, getToolForHost, isChatRequest, extractModel };
|
||||
|
||||
@@ -43,7 +43,8 @@ function initKiroState(modelId) {
|
||||
finishSent: false, // Whether termination has been emitted
|
||||
usage: null, // Accumulated usage from usage-only chunks
|
||||
inThink: false, // Whether inside a <thinking> block
|
||||
thinkBuf: "" // Buffer for partial thinking content
|
||||
thinkBuf: "", // Buffer for partial thinking content
|
||||
initialSent: false, // Whether initial-response frame was emitted
|
||||
};
|
||||
}
|
||||
|
||||
@@ -130,9 +131,9 @@ function encodeHeader(name, value) {
|
||||
* The SmithyMessageDecoderStream layer requires three system headers on every frame:
|
||||
* :message-type = "event" (or "exception" / "error")
|
||||
* :event-type = e.g. "assistantResponseEvent"
|
||||
* :content-type = "application/json"
|
||||
* :content-type = "application/json" (initial-response uses x-amz-json-1.0)
|
||||
*/
|
||||
function buildEventStreamFrame(eventType, payload) {
|
||||
function buildEventStreamFrame(eventType, payload, contentType = "application/json") {
|
||||
const payloadBuf = Buffer.from(
|
||||
typeof payload === "string" ? payload : JSON.stringify(payload),
|
||||
"utf8"
|
||||
@@ -142,7 +143,7 @@ function buildEventStreamFrame(eventType, payload) {
|
||||
const headersBuf = Buffer.concat([
|
||||
encodeHeader(":message-type", "event"),
|
||||
encodeHeader(":event-type", eventType),
|
||||
encodeHeader(":content-type", "application/json"),
|
||||
encodeHeader(":content-type", contentType),
|
||||
]);
|
||||
const headersLen = headersBuf.length;
|
||||
|
||||
@@ -159,6 +160,24 @@ function buildEventStreamFrame(eventType, payload) {
|
||||
return frame;
|
||||
}
|
||||
|
||||
/** Real Kiro Runtime always starts the stream with this frame (capture of IDE 1.0.228). */
|
||||
function buildInitialResponseFrame(conversationId = "") {
|
||||
return buildEventStreamFrame(
|
||||
"initial-response",
|
||||
{ conversationId: conversationId || "" },
|
||||
"application/x-amz-json-1.0"
|
||||
);
|
||||
}
|
||||
|
||||
/** Prepend initial-response once per stream so Smithy decoder is happy. */
|
||||
function withInitialFrame(state, frames) {
|
||||
const list = frames == null ? [] : Array.isArray(frames) ? frames : [frames];
|
||||
if (state.initialSent) return list.length === 0 ? null : list.length === 1 ? list[0] : list;
|
||||
state.initialSent = true;
|
||||
const out = [buildInitialResponseFrame(""), ...list];
|
||||
return out.length === 1 ? out[0] : out;
|
||||
}
|
||||
|
||||
// ─── CodeWhisperer → OpenAI conversion ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -321,12 +340,12 @@ function convertOpenAIToKiro(chunk, state) {
|
||||
state.inThink = false;
|
||||
const thinking = state.thinkBuf;
|
||||
state.thinkBuf = "";
|
||||
return buildEventStreamFrame("reasoningContentEvent", {
|
||||
return withInitialFrame(state, buildEventStreamFrame("reasoningContentEvent", {
|
||||
content: thinking,
|
||||
modelId: state.modelId || "kiro-unknown"
|
||||
});
|
||||
}));
|
||||
}
|
||||
return buildEventStreamFrame("messageStopEvent", {});
|
||||
return withInitialFrame(state, buildEventStreamFrame("messageStopEvent", {}));
|
||||
}
|
||||
|
||||
const frames = [];
|
||||
@@ -408,8 +427,12 @@ function convertOpenAIToKiro(chunk, state) {
|
||||
}
|
||||
}
|
||||
|
||||
if (frames.length === 0) return null;
|
||||
return frames.length === 1 ? frames[0] : frames;
|
||||
if (frames.length === 0) {
|
||||
// اولین چانک ممکنه فقط role/empty باشه — initial رو همونجا بفرست
|
||||
if (!state.initialSent) return withInitialFrame(state, null);
|
||||
return null;
|
||||
}
|
||||
return withInitialFrame(state, frames.length === 1 ? frames[0] : frames);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,7 +7,7 @@ const dns = require("dns");
|
||||
const { promisify } = require("util");
|
||||
const { execSync } = require("child_process");
|
||||
const { log, err, dumpRequest, createResponseDumper, clearDumpDir } = require("./logger");
|
||||
const { IS_DEV, LSOF_BIN, TARGET_HOSTS, URL_PATTERNS, MODEL_SYNONYMS, MODEL_PATTERNS, MODEL_NO_MAP, getToolForHost, extractModel } = require("./config");
|
||||
const { IS_DEV, LSOF_BIN, TARGET_HOSTS, URL_PATTERNS, MODEL_SYNONYMS, MODEL_PATTERNS, MODEL_NO_MAP, getToolForHost, isChatRequest, extractModel } = require("./config");
|
||||
const { DATA_DIR, MITM_DIR } = require("./paths");
|
||||
const { generateCert, getCertForDomain } = require("./cert/generate");
|
||||
const { getMitmAlias } = require("./dbReader");
|
||||
@@ -311,9 +311,8 @@ const server = https.createServer(sslOptions, async (req, res) => {
|
||||
const tool = getToolForHost(req.headers.host);
|
||||
if (!tool) return passthrough(req, res, bodyBuffer);
|
||||
|
||||
const patterns = URL_PATTERNS[tool] || [];
|
||||
const isChat = patterns.some(p => req.url.includes(p));
|
||||
if (!isChat) return passthrough(req, res, bodyBuffer);
|
||||
// Kiro IDE posts chat to `/` with x-amz-target (not path /generateAssistantResponse)
|
||||
if (!isChatRequest(tool, req)) return passthrough(req, res, bodyBuffer);
|
||||
|
||||
// Cursor uses binary proto — model extraction not possible at this layer.
|
||||
// Delegate directly to handler which decodes proto internally.
|
||||
|
||||
@@ -198,7 +198,7 @@ export default function Header({ onMenuClick, showMenuButton = true }) {
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
if (!cancelled) {
|
||||
setDisplayName(data?.displayName || data?.oidcName || data?.oidcEmail || "");
|
||||
setDisplayName(data?.displayName || data?.samlName || data?.samlEmail || data?.oidcName || data?.oidcEmail || "");
|
||||
setLoginMethod(data?.loginMethod || "");
|
||||
}
|
||||
} catch {
|
||||
@@ -303,12 +303,15 @@ export default function Header({ onMenuClick, showMenuButton = true }) {
|
||||
|
||||
{/* Right actions */}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{displayName && loginMethod === "OIDC" && (
|
||||
<div className="hidden sm:flex items-center max-w-[220px] px-3 py-1.5 rounded-full border border-border bg-surface/70 text-xs text-text-muted truncate">
|
||||
{displayName && (loginMethod === "OIDC" || loginMethod === "SAML") && (
|
||||
<div
|
||||
className="hidden sm:flex items-center max-w-[220px] px-3 py-1.5 rounded-full border border-border bg-surface/70 text-xs text-text-muted truncate"
|
||||
title={displayName}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px] mr-1.5 text-primary">person</span>
|
||||
<span className="truncate">{displayName}</span>
|
||||
<span className="ml-2 shrink-0 rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-primary">
|
||||
OIDC
|
||||
{loginMethod}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -8,7 +8,7 @@ export const MITM_TOOLS = {
|
||||
description: "Google Antigravity IDE with MITM",
|
||||
configType: "mitm",
|
||||
mitmDomain: "daily-cloudcode-pa.googleapis.com",
|
||||
modelAliases: ["gemini-3.6-flash-high", "gemini-3.6-flash-medium", "gemini-3.6-flash-low", "gemini-3.5-flash-low", "gemini-3-flash-agent", "gemini-3.5-flash-extra-low", "gemini-3.1-pro-low", "gemini-pro-agent", "claude-sonnet-4-6", "claude-opus-4-6-thinking", "gpt-oss-120b-medium", "gemini-3-flash"],
|
||||
modelAliases: ["gemini-3.7-flash-high", "gemini-3.7-flash-medium", "gemini-3.7-flash-low", "gemini-3.6-flash-high", "gemini-3.6-flash-medium", "gemini-3.6-flash-low", "gemini-3.5-flash-low", "gemini-3-flash-agent", "gemini-3.5-flash-extra-low", "gemini-3.1-pro-low", "gemini-pro-agent", "claude-sonnet-4-6", "claude-opus-4-6-thinking", "gpt-oss-120b-medium", "gemini-3-flash"],
|
||||
defaultModels: [
|
||||
{ id: "gemini-3.6-flash-high", name: "Gemini 3.6 Flash (High)", alias: "gemini-3.6-flash-high" },
|
||||
{ id: "gemini-3.6-flash-medium", name: "Gemini 3.6 Flash (Medium)", alias: "gemini-3.6-flash-medium" },
|
||||
@@ -57,8 +57,13 @@ export const MITM_TOOLS = {
|
||||
color: "#FF6B00",
|
||||
description: "Kiro IDE with MITM",
|
||||
configType: "mitm",
|
||||
mitmDomain: "q.us-east-1.amazonaws.com",
|
||||
mitmDomain: "runtime.us-east-1.kiro.dev",
|
||||
defaultModels: [
|
||||
// Kiro's agent/"vibe" mode sends modelId "auto" for the main turn and "simple-task"
|
||||
// for background sub-tasks (verified via MITM request dump of generateAssistantResponse).
|
||||
// Both need a mappable slot — otherwise getMappedModel returns null and the chat call
|
||||
// is passed through to AWS instead of being routed to the chosen provider.
|
||||
{ id: "auto", name: "Auto (Kiro Agent)", alias: "auto" },
|
||||
{ id: "claude-sonnet-5", name: "Claude Sonnet 5", alias: "claude-sonnet-5" },
|
||||
{ id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5", alias: "claude-sonnet-4.5" },
|
||||
{ id: "claude-sonnet-4", name: "Claude Sonnet 4", alias: "claude-sonnet-4" },
|
||||
|
||||
Reference in New Issue
Block a user