merge: update from decolua/9router upstream (SAML, providers, opencode session headers)

This commit is contained in:
MUH. IQRAM BAHRING committed 2026-08-15 03:54:03 +08:00
commit f555236545
103 files changed
+10682 -569

No files matched your search

@@ -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 [
+663 -149
View File
@@ -31,7 +31,7 @@ function getLocaleFromCookie() {
export default function ProfilePage() {
const { theme, setTheme, isDark } = useTheme();
const [locale, setLocale] = useState("en");
const [locale, setLocale] = useState(() => getLocaleFromCookie());
const [langOpen, setLangOpen] = useState(false);
const [shutdownOpen, setShutdownOpen] = useState(false);
const [isShuttingDown, setIsShuttingDown] = useState(false);
@@ -56,8 +56,31 @@ export default function ProfilePage() {
const [oidcLoading, setOidcLoading] = useState(false);
const [oidcTestLoading, setOidcTestLoading] = useState(false);
const [oidcTestStatus, setOidcTestStatus] = useState({ type: "", message: "" });
const [oidcRedirectUri, setOidcRedirectUri] = useState("/api/auth/oidc/callback");
const [oidcExpanded, setOidcExpanded] = useState(false);
const origin = typeof window !== "undefined" ? window.location.origin : "";
const oidcRedirectUri = origin ? `${origin}/api/auth/oidc/callback` : "/api/auth/oidc/callback";
const samlAcsUrl = origin ? `${origin}/api/auth/saml/acs` : "/api/auth/saml/acs";
const samlMetadataUrl = origin ? `${origin}/api/auth/saml/metadata` : "/api/auth/saml/metadata";
// SAML State
const [ssoTypeTab, setSsoTypeTab] = useState("saml");
const [samlForm, setSamlForm] = useState({
samlEntryPoint: "",
samlIssuer: "urn:9router:sp",
samlCert: "",
samlLoginLabel: "Sign in with SAML SSO",
samlAttributeEmail: "email",
samlAttributeName: "name",
});
const [samlStatus, setSamlStatus] = useState({ type: "", message: "" });
const [samlLoading, setSamlLoading] = useState(false);
const [samlTestLoading, setSamlTestLoading] = useState(false);
const [samlTestStatus, setSamlTestStatus] = useState({ type: "", message: "" });
const [showSamlGuide, setShowSamlGuide] = useState(false);
const idpMetadataFileRef = useRef(null);
const certFileRef = useRef(null);
const importFileRef = useRef(null);
const [memoryInfo, setMemoryInfo] = useState(null);
const [memoryLoading, setMemoryLoading] = useState(false);
@@ -70,10 +93,6 @@ export default function ProfilePage() {
const [proxyLoading, setProxyLoading] = useState(false);
const [proxyTestLoading, setProxyTestLoading] = useState(false);
useEffect(() => {
setLocale(getLocaleFromCookie());
}, [langOpen]);
useEffect(() => {
fetch("/api/settings")
.then((res) => res.json())
@@ -87,7 +106,23 @@ export default function ProfilePage() {
oidcLoginLabel: data?.oidcLoginLabel || "Sign in with OIDC",
});
setOidcClientSecret("");
if (data?.authMode === "oidc" || data?.authMode === "both") setOidcExpanded(true);
setSsoTypeTab(data?.ssoType || "saml");
setSamlForm({
samlEntryPoint: data?.samlEntryPoint || "",
samlIssuer: data?.samlIssuer || "urn:9router:sp",
samlCert: data?.samlCert || "",
samlLoginLabel: data?.samlLoginLabel || "Sign in with SAML SSO",
samlAttributeEmail: data?.samlAttributeEmail || "email",
samlAttributeName: data?.samlAttributeName || "name",
});
if (
data?.authMode === "sso" ||
data?.authMode === "saml" ||
data?.authMode === "oidc" ||
data?.authMode === "both"
) {
setOidcExpanded(true);
}
setProxyForm({
outboundProxyEnabled: data?.outboundProxyEnabled === true,
outboundProxyUrl: data?.outboundProxyUrl || "",
@@ -101,12 +136,6 @@ export default function ProfilePage() {
});
}, []);
useEffect(() => {
if (typeof window !== "undefined") {
setOidcRedirectUri(`${window.location.origin}/api/auth/oidc/callback`);
}
}, []);
const updateOutboundProxy = async (e) => {
e.preventDefault();
if (settings.outboundProxyEnabled !== true) return;
@@ -343,6 +372,7 @@ export default function ProfilePage() {
try {
const payload = {
authMode,
ssoType: "oidc",
oidcIssuerUrl: issuerUrl,
oidcClientId: clientId,
oidcScopes: scopes || "openid profile email",
@@ -457,6 +487,159 @@ export default function ProfilePage() {
}
};
const updateSamlForm = (field, value) => {
setSamlForm((prev) => ({ ...prev, [field]: value }));
};
const handleIdpMetadataUpload = (event) => {
const file = event.target.files?.[0];
if (idpMetadataFileRef.current) idpMetadataFileRef.current.value = "";
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const xmlText = e.target?.result || "";
const parser = new DOMParser();
const doc = parser.parseFromString(xmlText, "text/xml");
const parserError = doc.querySelector("parsererror");
if (parserError) {
setSamlStatus({ type: "error", message: "Unable to parse valid SAML IdP metadata from XML file" });
return;
}
const entityID = doc.documentElement.getAttribute("entityID") || "";
const ssoNodes = Array.from(doc.querySelectorAll("SingleSignOnService, *|SingleSignOnService"));
let ssoUrl = "";
for (const node of ssoNodes) {
const binding = node.getAttribute("Binding") || "";
const location = node.getAttribute("Location") || "";
if (location) {
ssoUrl = location;
if (binding.includes("HTTP-Redirect")) break;
}
}
const certNodes = Array.from(doc.querySelectorAll("X509Certificate, *|X509Certificate"));
let certStr = "";
if (certNodes.length > 0) {
certStr = certNodes[0].textContent.trim();
}
setSamlForm((prev) => ({
...prev,
samlEntryPoint: ssoUrl || prev.samlEntryPoint,
samlIssuer: prev.samlIssuer || "urn:9router:sp",
samlCert: certStr || prev.samlCert,
}));
setSamlStatus({
type: "success",
message: `IdP Metadata imported! (SSO URL: ${ssoUrl ? "found" : "not found"}, EntityID: ${entityID ? "found" : "not found"}, Cert: ${certStr ? "found" : "not found"})`,
});
} catch (err) {
setSamlStatus({ type: "error", message: "Error reading IdP Metadata XML file" });
}
};
reader.readAsText(file);
};
const handleCertFileUpload = (event) => {
const file = event.target.files?.[0];
if (certFileRef.current) certFileRef.current.value = "";
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
const text = e.target?.result || "";
setSamlForm((prev) => ({ ...prev, samlCert: text.trim() }));
setSamlStatus({ type: "success", message: "Certificate file loaded into configuration." });
};
reader.readAsText(file);
};
const saveSamlSettings = async (targetAuthMode = oidcForm.authMode || "password") => {
setSamlLoading(true);
setSamlStatus({ type: "", message: "" });
setSamlTestStatus({ type: "", message: "" });
try {
const payload = {
authMode: targetAuthMode,
ssoType: "saml",
samlEntryPoint: samlForm.samlEntryPoint.trim(),
samlIssuer: samlForm.samlIssuer.trim() || "urn:9router:sp",
samlCert: samlForm.samlCert.trim(),
samlLoginLabel: samlForm.samlLoginLabel.trim() || "Sign in with SAML SSO",
samlAttributeEmail: samlForm.samlAttributeEmail.trim() || "email",
samlAttributeName: samlForm.samlAttributeName.trim() || "name",
};
const res = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await res.json();
if (res.ok) {
setSettings((prev) => ({ ...prev, ...data }));
setSamlForm({
samlEntryPoint: data?.samlEntryPoint || payload.samlEntryPoint,
samlIssuer: data?.samlIssuer || payload.samlIssuer,
samlCert: data?.samlCert || payload.samlCert,
samlLoginLabel: data?.samlLoginLabel || payload.samlLoginLabel,
samlAttributeEmail: data?.samlAttributeEmail || payload.samlAttributeEmail,
samlAttributeName: data?.samlAttributeName || payload.samlAttributeName,
});
setSamlStatus({
type: "success",
message:
targetAuthMode === "sso" || targetAuthMode === "saml"
? "SAML SSO login enabled"
: targetAuthMode === "both"
? "Password and SAML SSO login enabled"
: "SAML 2.0 settings saved",
});
} else {
setSamlStatus({ type: "error", message: data.error || "Failed to save SAML settings" });
}
} catch {
setSamlStatus({ type: "error", message: "An error occurred while saving SAML settings" });
} finally {
setSamlLoading(false);
}
};
const testSamlConnection = async () => {
setSamlTestLoading(true);
setSamlStatus({ type: "", message: "" });
setSamlTestStatus({ type: "", message: "" });
try {
const res = await fetch("/api/auth/saml/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
samlEntryPoint: samlForm.samlEntryPoint.trim(),
samlIssuer: samlForm.samlIssuer.trim(),
samlCert: samlForm.samlCert.trim(),
}),
});
const data = await res.json();
if (res.ok && data.ok) {
setSamlTestStatus({ type: "success", message: data.message || "SAML configuration verified!" });
} else {
setSamlTestStatus({ type: "error", message: data.error || "SAML configuration test failed" });
}
} catch {
setSamlTestStatus({ type: "error", message: "An error occurred while testing SAML configuration" });
} finally {
setSamlTestLoading(false);
}
};
const updateObservabilityEnabled = async (enabled) => {
try {
const res = await fetch("/api/settings", {
@@ -793,7 +976,7 @@ export default function ProfilePage() {
</div>
</Card>
{/* OIDC */}
{/* Single Sign-On (SSO) */}
<Card>
<button
type="button"
@@ -804,9 +987,13 @@ export default function ProfilePage() {
<span className="material-symbols-outlined text-[20px]">lock_open</span>
</div>
<div className="flex-1 min-w-0">
<h3 className="text-base sm:text-lg font-semibold">OIDC Dashboard Login</h3>
<h3 className="text-base sm:text-lg font-semibold">Single Sign-On (SSO)</h3>
<p className="text-xs text-text-muted">
{settings.authMode === "oidc" ? "OIDC active" : settings.authMode === "both" ? "Password + OIDC active" : "Optional SSO via Authentik/Keycloak/Google"}
{settings.authMode === "sso" || settings.authMode === "oidc" || settings.authMode === "saml"
? `${settings.ssoType === "saml" ? "SAML 2.0" : "OIDC"} SSO active`
: settings.authMode === "both"
? `Password + ${settings.ssoType === "saml" ? "SAML 2.0" : "OIDC"} active`
: "Optional SSO via Okta, Entra ID, Keycloak, or OIDC"}
</p>
</div>
<span className="material-symbols-outlined text-text-muted shrink-0">
@@ -814,145 +1001,472 @@ export default function ProfilePage() {
</span>
</button>
{oidcExpanded && (
<div className="flex flex-col gap-4 mt-4">
<p className="text-xs sm:text-sm text-text-muted">
Use Authentik or any OIDC provider to sign in to the dashboard. You can enable password-only, OIDC-only, or both for the dashboard; model API access still uses API keys.
</p>
<div className="flex flex-col gap-4 mt-4">
<p className="text-xs sm:text-sm text-text-muted">
Configure enterprise Single Sign-On (SSO) for dashboard access using SAML 2.0 or OIDC.
</p>
<div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">Auth Mode</label>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
{[
{
value: "password",
title: "Password only",
desc: "Keep the legacy password login.",
},
{
value: "oidc",
title: "OIDC only",
desc: "Require OIDC for dashboard access.",
},
{
value: "both",
title: "Both",
desc: "Allow either password or OIDC.",
},
].map((option) => {
const active = oidcForm.authMode === option.value;
return (
{/* SSO Protocol Switcher Tabs */}
<div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">SSO Protocol</label>
<div className="flex p-1 rounded-lg bg-black/5 dark:bg-white/5 border border-border">
<button
type="button"
onClick={() => setSsoTypeTab("saml")}
className={cn(
"flex-1 py-1.5 px-3 rounded-md font-medium text-xs sm:text-sm transition-all text-center",
ssoTypeTab === "saml"
? "bg-white dark:bg-white/10 text-text-main shadow-sm"
: "text-text-muted hover:text-text-main"
)}
>
SAML 2.0
</button>
<button
type="button"
onClick={() => setSsoTypeTab("oidc")}
className={cn(
"flex-1 py-1.5 px-3 rounded-md font-medium text-xs sm:text-sm transition-all text-center",
ssoTypeTab === "oidc"
? "bg-white dark:bg-white/10 text-text-main shadow-sm"
: "text-text-muted hover:text-text-main"
)}
>
OIDC
</button>
</div>
</div>
{/* Auth Mode selection */}
<div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">Auth Mode</label>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
{[
{
value: "password",
title: "Password only",
desc: "Keep legacy password login.",
},
{
value: "sso",
title: `${ssoTypeTab === "saml" ? "SAML" : "OIDC"} only`,
desc: "Require SSO for dashboard access.",
},
{
value: "both",
title: "Both",
desc: "Allow password or SSO login.",
},
].map((option) => {
const currentMode = oidcForm.authMode;
const active =
option.value === "password"
? currentMode === "password"
: option.value === "sso"
? currentMode === "sso" || currentMode === "saml" || currentMode === "oidc"
: currentMode === "both";
return (
<button
key={option.value}
type="button"
onClick={() => updateOidcForm("authMode", option.value)}
className={cn(
"text-left rounded-lg border p-3 transition-colors",
active
? "border-primary bg-primary/5"
: "border-border bg-bg hover:bg-black/5 dark:hover:bg-white/5"
)}
disabled={loading || oidcLoading || samlLoading}
>
<p className="font-medium text-sm sm:text-base">{option.title}</p>
<p className="text-xs sm:text-sm text-text-muted mt-1">{option.desc}</p>
</button>
);
})}
</div>
</div>
{ssoTypeTab === "saml" ? (
/* SAML Configuration Panel */
<div className="flex flex-col gap-4 pt-2 border-t border-border/50">
{/* IdP Setup Guidelines Banner & Collapsible Drawer */}
<div className="rounded-lg border border-border bg-bg/80 overflow-hidden">
<button
key={option.value}
type="button"
onClick={() => updateOidcForm("authMode", option.value)}
className={cn(
"text-left rounded-lg border p-3 transition-colors",
active
? "border-primary bg-primary/5"
: "border-border bg-bg hover:bg-black/5 dark:hover:bg-white/5"
)}
disabled={loading || oidcLoading}
onClick={() => setShowSamlGuide((prev) => !prev)}
className="w-full p-3 flex items-center justify-between gap-2 text-left hover:bg-surface/50 transition-colors"
>
<p className="font-medium text-sm sm:text-base">{option.title}</p>
<p className="text-xs sm:text-sm text-text-muted mt-1">{option.desc}</p>
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-primary text-lg">menu_book</span>
<div>
<p className="font-semibold text-xs sm:text-sm text-text-main">
IdP Setup Guidelines & Provider Configuration Instructions
</p>
<p className="text-[11px] text-text-muted">
Click to view setup steps for AWS IAM Identity Center, Okta, Entra ID, Keycloak, & Authentik
</p>
</div>
</div>
<span
className="material-symbols-outlined text-text-muted transition-transform text-lg"
style={{ transform: showSamlGuide ? "rotate(180deg)" : "none" }}
>
expand_more
</span>
</button>
);
})}
</div>
{showSamlGuide && (
<div className="p-4 border-t border-border bg-surface/30 text-xs text-text-main flex flex-col gap-3">
<div className="p-2.5 rounded border border-primary/20 bg-primary/5 text-primary text-xs">
<p className="font-semibold mb-1">🔑 Required Service Provider (SP) Values for your IdP Setup:</p>
<ul className="list-disc pl-4 space-y-1 font-mono text-[11px]">
<li>
<b>Assertion Consumer Service (ACS) URL:</b>{" "}
<code className="bg-bg px-1 py-0.5 rounded break-all">{samlAcsUrl}</code>
</li>
<li>
<b>SP Entity ID / Audience URI:</b>{" "}
<code className="bg-bg px-1 py-0.5 rounded break-all">{samlForm.samlIssuer || "urn:9router:sp"}</code>
</li>
<li>
<b>NameID Format:</b>{" "}
<code className="bg-bg px-1 py-0.5 rounded">EmailAddress</code> or <code className="bg-bg px-1 py-0.5 rounded">Unspecified</code>
</li>
</ul>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 pt-1">
<div className="p-3 rounded border border-border bg-bg/50 flex flex-col gap-1.5">
<p className="font-semibold text-text-main flex items-center gap-1.5">
<span>☁️</span> AWS IAM Identity Center
</p>
<ol className="list-decimal pl-4 text-text-muted space-y-1">
<li>Applications → <b>Add application</b> → Select <b>Add custom SAML 2.0 application</b>.</li>
<li>Set <b>Application ACS URL</b> to <code className="text-text-main font-mono">{samlAcsUrl}</code>.</li>
<li>Set <b>Application SAML audience</b> to <code className="text-text-main font-mono">{samlForm.samlIssuer || "urn:9router:sp"}</code>.</li>
<li>Under <i>Attribute mappings</i>, map <code className="text-text-main font-mono">Subject</code> or <code className="text-text-main font-mono">email</code> to <code className="text-text-main font-mono">${`{user:email}`}</code>.</li>
<li>Download <b>IAM Identity Center SAML metadata XML</b> file and use 1-Click Import below!</li>
</ol>
</div>
<div className="p-3 rounded border border-border bg-bg/50 flex flex-col gap-1.5">
<p className="font-semibold text-text-main flex items-center gap-1.5">
<span>🔷</span> Microsoft Entra ID (Azure AD)
</p>
<ol className="list-decimal pl-4 text-text-muted space-y-1">
<li>Enterprise Applications → <b>New application</b> → <b>Create your own application</b>.</li>
<li>Select <b>Single sign-on</b> → <b>SAML</b>.</li>
<li><b>Identifier (Entity ID):</b> <code className="text-text-main font-mono">{samlForm.samlIssuer || "urn:9router:sp"}</code></li>
<li><b>Reply URL (ACS):</b> <code className="text-text-main font-mono">{samlAcsUrl}</code></li>
<li>Download <b>Federation Metadata XML</b> and import or copy X.509 Certificate.</li>
</ol>
</div>
<div className="p-3 rounded border border-border bg-bg/50 flex flex-col gap-1.5">
<p className="font-semibold text-text-main flex items-center gap-1.5">
<span>🟢</span> Okta / Auth0
</p>
<ol className="list-decimal pl-4 text-text-muted space-y-1">
<li>Applications → <b>Create App Integration</b> → Select <b>SAML 2.0</b>.</li>
<li><b>Single Sign-On URL:</b> <code className="text-text-main font-mono">{samlAcsUrl}</code></li>
<li><b>Audience URI (SP Entity ID):</b> <code className="text-text-main font-mono">{samlForm.samlIssuer || "urn:9router:sp"}</code></li>
<li>Name ID format: <i>EmailAddress</i>.</li>
<li>Download Identity Provider metadata XML or copy the X.509 cert.</li>
</ol>
</div>
<div className="p-3 rounded border border-border bg-bg/50 flex flex-col gap-1.5">
<p className="font-semibold text-text-main flex items-center gap-1.5">
<span>🛡️</span> Keycloak / Authentik
</p>
<ol className="list-decimal pl-4 text-text-muted space-y-1">
<li>Clients → <b>Create client</b> → Select <b>SAML</b>.</li>
<li><b>Client ID:</b> <code className="text-text-main font-mono">{samlForm.samlIssuer || "urn:9router:sp"}</code></li>
<li><b>Master SAML Processing URL:</b> <code className="text-text-main font-mono">{samlAcsUrl}</code></li>
<li>Export SAML Descriptor XML or copy IDP Certificate PEM.</li>
</ol>
</div>
</div>
</div>
)}
</div>
{/* Quick Import Card */}
<div className="p-3 rounded-lg border border-dashed border-primary/40 bg-primary/5 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<p className="font-medium text-sm text-text-main">1-Click IdP Metadata XML Import</p>
<p className="text-xs text-text-muted">Auto-fill SSO URL, Issuer & Cert from XML metadata</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
icon="upload_file"
onClick={() => idpMetadataFileRef.current?.click()}
>
Upload Metadata XML
</Button>
<input
ref={idpMetadataFileRef}
type="file"
accept=".xml,application/xml,text/xml"
className="hidden"
onChange={handleIdpMetadataUpload}
/>
</div>
<div className="grid grid-cols-1 gap-4">
<div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">Single Sign-On Service URL (samlEntryPoint)</label>
<Input
placeholder="https://idp.example.com/app/saml/sso/..."
value={samlForm.samlEntryPoint}
onChange={(e) => updateSamlForm("samlEntryPoint", e.target.value)}
disabled={loading || samlLoading}
/>
</div>
<div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">SP Entity ID / Audience (samlIssuer)</label>
<Input
placeholder="urn:9router:sp"
value={samlForm.samlIssuer}
onChange={(e) => updateSamlForm("samlIssuer", e.target.value)}
disabled={loading || samlLoading}
/>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<label className="font-medium text-sm sm:text-base">IdP X.509 Certificate (samlCert)</label>
<Button
type="button"
variant="outline"
size="sm"
icon="file_upload"
onClick={() => certFileRef.current?.click()}
>
Upload Certificate
</Button>
<input
ref={certFileRef}
type="file"
accept=".crt,.pem,.cer,text/plain"
className="hidden"
onChange={handleCertFileUpload}
/>
</div>
<textarea
rows={4}
placeholder="-----BEGIN CERTIFICATE-----&#10;MIIC...&#10;-----END CERTIFICATE-----"
value={samlForm.samlCert}
onChange={(e) => updateSamlForm("samlCert", e.target.value)}
className="w-full p-2.5 rounded-lg border border-border bg-bg text-xs font-mono text-text-main focus:outline-none focus:border-primary"
disabled={loading || samlLoading}
/>
<p className="text-xs text-text-muted">Paste raw Base64 certificate or PEM block.</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">Login Button Label</label>
<Input
placeholder="Sign in with SAML SSO"
value={samlForm.samlLoginLabel}
onChange={(e) => updateSamlForm("samlLoginLabel", e.target.value)}
disabled={loading || samlLoading}
/>
</div>
<div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">Email Claim Attribute</label>
<Input
placeholder="email"
value={samlForm.samlAttributeEmail}
onChange={(e) => updateSamlForm("samlAttributeEmail", e.target.value)}
disabled={loading || samlLoading}
/>
</div>
<div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">Display Name Claim</label>
<Input
placeholder="name"
value={samlForm.samlAttributeName}
onChange={(e) => updateSamlForm("samlAttributeName", e.target.value)}
disabled={loading || samlLoading}
/>
</div>
</div>
</div>
<div className="flex flex-col gap-2 p-3 rounded-lg border border-border bg-bg text-xs sm:text-sm text-text-muted">
<div className="flex items-center justify-between gap-2">
<div>
<p className="font-medium text-text-main">ACS Callback URL</p>
<code className="block break-all font-mono text-xs">{samlAcsUrl}</code>
</div>
<Button
type="button"
variant="outline"
size="sm"
icon="content_copy"
onClick={() => {
navigator.clipboard.writeText(samlAcsUrl);
setSamlStatus({ type: "success", message: "ACS URL copied to clipboard!" });
}}
>
Copy
</Button>
</div>
<div className="flex items-center justify-between gap-2 pt-2 border-t border-border/50">
<div>
<p className="font-medium text-text-main">SP XML Metadata</p>
<code className="block break-all font-mono text-xs">{samlMetadataUrl}</code>
</div>
<a
href={samlMetadataUrl}
target="_blank"
rel="noopener noreferrer"
download="9router-sp-metadata.xml"
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
>
<span className="material-symbols-outlined text-[16px]">download</span>
Download XML
</a>
</div>
</div>
<div className="flex flex-col sm:flex-row gap-2 pt-2 border-t border-border/50">
<Button
type="button"
variant="primary"
loading={samlLoading}
onClick={() => saveSamlSettings(oidcForm.authMode)}
className="w-full sm:w-auto"
>
Save SAML settings
</Button>
<Button
type="button"
variant="outline"
loading={samlTestLoading}
onClick={testSamlConnection}
className="w-full sm:w-auto"
>
Test SAML settings
</Button>
</div>
{samlTestStatus.message && (
<p className={`text-xs sm:text-sm ${samlTestStatus.type === "error" ? "text-red-500" : "text-green-500"}`}>
{samlTestStatus.message}
</p>
)}
{samlStatus.message && (
<p className={`text-xs sm:text-sm ${samlStatus.type === "error" ? "text-red-500" : "text-green-500"}`}>
{samlStatus.message}
</p>
)}
</div>
) : (
/* OIDC Panel */
<div className="flex flex-col gap-4 pt-2 border-t border-border/50">
<div className="grid grid-cols-1 gap-4">
<div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">Issuer URL</label>
<Input
placeholder="https://auth.example.com/application/o/9router/"
value={oidcForm.oidcIssuerUrl}
onChange={(e) => updateOidcForm("oidcIssuerUrl", e.target.value)}
disabled={loading || oidcLoading}
/>
</div>
<div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">Client ID</label>
<Input
placeholder="9router-dashboard"
value={oidcForm.oidcClientId}
onChange={(e) => updateOidcForm("oidcClientId", e.target.value)}
disabled={loading || oidcLoading}
/>
</div>
<div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">Client Secret</label>
<Input
type="password"
placeholder="Leave blank to keep existing secret"
value={oidcClientSecret}
onChange={(e) => setOidcClientSecret(e.target.value)}
disabled={loading || oidcLoading}
/>
<p className="text-xs sm:text-sm text-text-muted">This value is write-only after saving.</p>
</div>
<div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">Scopes</label>
<Input
placeholder="openid profile email"
value={oidcForm.oidcScopes}
onChange={(e) => updateOidcForm("oidcScopes", e.target.value)}
disabled={loading || oidcLoading}
/>
</div>
<div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">Login Button Label</label>
<Input
placeholder="Sign in with OIDC"
value={oidcForm.oidcLoginLabel}
onChange={(e) => updateOidcForm("oidcLoginLabel", e.target.value)}
disabled={loading || oidcLoading}
/>
</div>
</div>
<div className="rounded-lg border border-border bg-bg p-3 text-xs sm:text-sm text-text-muted">
<p className="font-medium text-text-main mb-1">Redirect URI</p>
<code className="block break-all font-mono">{oidcRedirectUri}</code>
</div>
<div className="flex flex-col sm:flex-row gap-2 pt-2 border-t border-border/50">
<Button type="button" variant="primary" loading={oidcLoading} onClick={() => saveOidcSettings()} className="w-full sm:w-auto">
Save OIDC settings
</Button>
<Button type="button" variant="outline" loading={oidcTestLoading} onClick={testOidcConnection} className="w-full sm:w-auto">
Test connection
</Button>
</div>
{oidcTestStatus.message && (
<p className={`text-xs sm:text-sm ${oidcTestStatus.type === "error" ? "text-red-500" : "text-green-500"}`}>
{oidcTestStatus.message}
</p>
)}
{oidcStatus.message && (
<p className={`text-xs sm:text-sm ${oidcStatus.type === "error" ? "text-red-500" : "text-green-500"}`}>
{oidcStatus.message}
</p>
)}
</div>
)}
{settings.authMode === "oidc" || settings.authMode === "saml" || settings.authMode === "sso" ? (
<p className="text-xs sm:text-sm text-amber-600 dark:text-amber-400">
SSO login ({settings.ssoType === "saml" ? "SAML 2.0" : "OIDC"}) is currently active. Password login is disabled until you switch back.
</p>
) : null}
{settings.authMode === "both" && (
<p className="text-xs sm:text-sm text-amber-600 dark:text-amber-400">
Password and SSO login ({settings.ssoType === "saml" ? "SAML 2.0" : "OIDC"}) are both active.
</p>
)}
</div>
<div className="grid grid-cols-1 gap-4">
<div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">Issuer URL</label>
<Input
placeholder="https://auth.example.com/application/o/9router/"
value={oidcForm.oidcIssuerUrl}
onChange={(e) => updateOidcForm("oidcIssuerUrl", e.target.value)}
disabled={loading || oidcLoading}
/>
</div>
<div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">Client ID</label>
<Input
placeholder="9router-dashboard"
value={oidcForm.oidcClientId}
onChange={(e) => updateOidcForm("oidcClientId", e.target.value)}
disabled={loading || oidcLoading}
/>
</div>
<div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">Client Secret</label>
<Input
type="password"
placeholder="Leave blank to keep existing secret"
value={oidcClientSecret}
onChange={(e) => setOidcClientSecret(e.target.value)}
disabled={loading || oidcLoading}
/>
<p className="text-xs sm:text-sm text-text-muted">This value is write-only after saving.</p>
</div>
<div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">Scopes</label>
<Input
placeholder="openid profile email"
value={oidcForm.oidcScopes}
onChange={(e) => updateOidcForm("oidcScopes", e.target.value)}
disabled={loading || oidcLoading}
/>
</div>
<div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">Login Button Label</label>
<Input
placeholder="Sign in with OIDC"
value={oidcForm.oidcLoginLabel}
onChange={(e) => updateOidcForm("oidcLoginLabel", e.target.value)}
disabled={loading || oidcLoading}
/>
</div>
</div>
<div className="rounded-lg border border-border bg-bg p-3 text-xs sm:text-sm text-text-muted">
<p className="font-medium text-text-main mb-1">Redirect URI</p>
<code className="block break-all font-mono">{oidcRedirectUri}</code>
</div>
<div className="flex flex-col sm:flex-row gap-2 pt-2 border-t border-border/50">
<Button type="button" variant="primary" loading={oidcLoading} onClick={() => saveOidcSettings()} className="w-full sm:w-auto">
Save auth mode
</Button>
<Button type="button" variant="outline" loading={oidcTestLoading} onClick={testOidcConnection} className="w-full sm:w-auto">
Test connection
</Button>
</div>
{oidcTestStatus.message && (
<p className={`text-xs sm:text-sm ${oidcTestStatus.type === "error" ? "text-red-500" : "text-green-500"}`}>
{oidcTestStatus.message}
</p>
)}
{oidcStatus.message && (
<p className={`text-xs sm:text-sm ${oidcStatus.type === "error" ? "text-red-500" : "text-green-500"}`}>
{oidcStatus.message}
</p>
)}
{settings.authMode === "oidc" && (
<p className="text-xs sm:text-sm text-amber-600 dark:text-amber-400">
OIDC login is currently active. Password login is disabled until you switch back.
</p>
)}
{settings.authMode === "both" && (
<p className="text-xs sm:text-sm text-amber-600 dark:text-amber-400">
Password and OIDC login are both active.
</p>
)}
</div>
)}
</Card>
@@ -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 || [])
+33 -5
View File
@@ -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
View 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
View 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
View 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
View 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 });
}
}
+25 -2
View File
@@ -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"),
};
};
+20 -3
View File
@@ -135,9 +135,11 @@ export async function pingModelByKind(model, kind, baseUrl = `http://127.0.0.1:$
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" }],
}),
@@ -180,6 +182,21 @@ export async function pingModelByKind(model, kind, baseUrl = `http://127.0.0.1:$
}
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,
@@ -803,6 +803,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" };
}
+3 -2
View File
@@ -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
@@ -168,7 +169,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)
@@ -176,7 +177,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}`);
}
+17 -2
View File
@@ -47,8 +47,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(
+22 -5
View File
@@ -77,7 +77,7 @@ const LIVE_MODEL_RESOLVERS = {
return result?.models?.length ? { models: result.models } : null;
},
"grok-cli": async (conn) => {
const proxy = await resolveConnectionProxyConfig(conn.providerSpecificData || {}, conn.id);
const proxy = await resolveConnectionProxyConfig(conn.providerSpecificData || {});
const result = await resolveGrokCliModels({
...conn,
connectionId: conn.id,
@@ -485,10 +485,27 @@ export async function buildModelsList(kindFilter, options = {}) {
|| capabilitiesFromServiceKind(customKind || liveKind)
|| (kind === LLM_KIND ? getCapabilitiesForModel(providerId, modelId) : null);
if (caps) model.capabilities = caps;
// Advertise the context window for clients that read it from /v1/models
// (OpenAI-compatible convention; opencode's generic adapter ignores it
// and needs limit.context declared client-side).
if (caps?.contextWindow) model.context_window = caps.contextWindow;
// Token limits under the snake_case names the OpenAI/OpenRouter
// convention uses. `capabilities.contextWindow` is camelCase and nested,
// so clients matching context_length find nothing, fall back to guessing
// the window from the model name, and guess high — a 372k model read as
// 1.05M never reaches its compaction threshold and hard-fails upstream.
// Emitted at top level because not every client recurses into nested
// objects; the camelCase `capabilities` block stays for compatibility.
if (kind === LLM_KIND || allowAsLlm) {
let contextWindow = caps?.contextWindow;
let maxOutput = caps?.maxOutput;
// Live-catalog and service-kind capabilities are usually partial
// (often just { tools: true }), so fill the gaps from the static
// table rather than emitting null and leaving clients to guess.
if (!Number.isFinite(contextWindow) || !Number.isFinite(maxOutput)) {
const fallback = getCapabilitiesForModel(providerId, modelId);
if (!Number.isFinite(contextWindow)) contextWindow = fallback.contextWindow;
if (!Number.isFinite(maxOutput)) maxOutput = fallback.maxOutput;
}
if (Number.isFinite(contextWindow)) model.context_length = contextWindow;
if (Number.isFinite(maxOutput)) model.max_completion_tokens = maxOutput;
}
models.push(model);
}
+32 -8
View File
@@ -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>
)}
+27 -9
View File
@@ -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 {
+7 -3
View File
@@ -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
View 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
View 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;
}
+2
View File
@@ -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);
+8 -1
View File
@@ -27,11 +27,18 @@ const DEFAULT_SETTINGS = {
requireApiKey: true,
tunnelDashboardAccess: true,
authMode: "password",
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,
@@ -63,7 +70,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 -3
View File
@@ -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 };
+32 -9
View File
@@ -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);
}
/**
+3 -4
View File
@@ -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.
+7 -4
View File
@@ -196,7 +196,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 {
@@ -301,12 +301,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>
)}
+7 -2
View File
@@ -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" },