Files
OmniRoute/tests/unit/api-auth.test.ts
Diego Rodrigues de Sa e Souza a25d5f1ef6 Release v3.8.13 (#3327)
* chore(release): open v3.8.13 development cycle

Bump 3.8.12 → 3.8.13 across package.json, lockfile, electron/, open-sse/, and
docs/reference/openapi.yaml; add the [3.8.13] cycle placeholder to the root
CHANGELOG and the 41 i18n mirrors. Integration branch for the v3.8.13 cycle —
fixes/features land here via per-issue PRs and it merges to main at release time.

* fix(ci): skip auto-deploy when VPS host is unreachable from the runner (#3299)

Integrated into release/v3.8.13

* fix(dev): auto-rebuild better-sqlite3 on Node ABI mismatch at dev startup (#3301)

Integrated into release/v3.8.13

* feat(api): accept path-scoped API keys on client API routes (#3300)

Integrated into release/v3.8.13

* fix(sse): harden against empty responses causing Copilot Chat failures (#3297)

Integrated into release/v3.8.13

* fix(api): remove Completions.me rickroll provider (discussion #3293) (#3302)

Integrated into release/v3.8.13

* fix(opencode-provider): extract contextLength from live model catalog (#3298)

Integrated into release/v3.8.13

* feat(web-cookie): self-service login infrastructure + auto-refresh daemon (#3292)

Integrated into release/v3.8.13

* docs(changelog): record the v3.8.13 PRs merged this round (#3292/#3300/#3297/#3298/#3301/#3302/#3299)

* fix(auth): harden URL token extraction — drop query-string fallback, gate to client routes (security follow-up to #3300) (#3309)

Security follow-up to #3300 — integrated into release/v3.8.13

* docs: rename resolve-issues → review-issues skill references

* fix(dashboard): keep no-auth providers visible under 'Show configured only' (#3290) (#3312)

no-auth providers (opencode, duckduckgo-web, theoldllm, veoaifree-web) never
create a DB connection row so stats.total stays 0, which the configured-only
filter treated as 'unconfigured' and hid them — even though they are always
usable and appear unconditionally in /v1/models. filterConfiguredProviderEntries
now treats displayAuthType === 'no-auth' as configured.

Co-authored-by: uniQta <uniQta@users.noreply.github.com>

* fix(cli): resolve update paths relative to script + recursive backup (#3295) (#3313)

omniroute update always failed on a global install:
- getCurrentVersion() read package.json from process.cwd(), which on a global
  npm/brew install is the user's working dir, not the package root → null →
  'Could not determine current version'.
- createBackup() resolved bin/ from cwd too, and passed the 'cli' directory to
  copyFileSync → EISDIR, swallowed by the catch → 'Failed to create backup'.

Both now resolve package.json/bin relative to the script via import.meta.url,
and the backup uses cpSync({recursive:true}) so the cli/ directory is copied.

Co-authored-by: uniQta <uniQta@users.noreply.github.com>

* fix(theoldllm): read upstream body once to avoid [502] body-already-read (#3296) (#3314)

On the cached-token path the executor never enters the refresh branch, so the
same upstream Response was read with .text() twice (token-rejection check +
final body). A Response body is single-use, so the second read threw
'Body is unusable: Body has already been read', caught and surfaced as [502].

Read the body once into finalBody and only re-read after a token-rejection
refetch.

Co-authored-by: onizukashonan14-png <onizukashonan14-png@users.noreply.github.com>

* fix(sse): strip leaked internal tool envelopes from streaming output (#3311)

Integrated into release/v3.8.13

* fix(sse): expose Claude + Gemini budget tiers in the antigravity catalog (#3184) (#3303)

Integrated into release/v3.8.13 (#3184)

* fix(catalog): compute combo context_length from known targets only (#3304)

Integrated into release/v3.8.13 — live contextLength + known-targets combo context (#3298 follow-up)

* chore(i18n): add message keys for proxy UI + vscode/ollama endpoint (#3307)

Integrated into release/v3.8.13 — i18n message keys for proxy UI + vscode/ollama

* feat(dashboard): i18n the proxy settings UI (#3310)

Integrated into release/v3.8.13 — i18n the proxy settings UI

* feat(api): model catalog enrichment + MCP model-catalog tools (#3306)

Integrated into release/v3.8.13 — model catalog enrichment + MCP model-catalog tools, reconciled with #3309 URL-token hardening

* test(catalog): align Antigravity preview-alias test with #3303 budget tiers

#3303 added the Gemini `-high`/`-low` budget tiers to ANTIGRAVITY_PUBLIC_MODELS
(user-callable on the Antigravity OAuth backend, verified via #3184), but did
not update the catalog-route test that asserted `antigravity/gemini-3.1-pro-high`
must NOT be exposed. The assertion now reflects the intended behavior — the
client-visible budget alias IS surfaced — while keeping the legacy
`gemini-claude-*` alias keys unexposed. Caught running the full catalog suite
on the merged release HEAD (the #3303 round only ran the antigravity-aliases
and usage-hardening files).

* docs(changelog): record the 6 PRs merged this review round into v3.8.13

#3306/#3307/#3310 (New Features — VS Code split: catalog+MCP, i18n keys, proxy
UI i18n), #3311/#3303/#3304 (Bug Fixes — SSE envelope sanitizer, antigravity
budget tiers, combo known-targets context_length).

* chore(release): finalize v3.8.13 changelog and cleanup

Finalize the v3.8.13 changelog with release date, maintenance notes,
and contributor credits. Update MCP docs to reference the correct tool
inventory diagram, exclude nested .claude worktrees from ESLint scans,
and tighten a response sanitizer type guard.

* fix(dashboard): refresh connections after provider auth import (#3320)

Integrated into release/v3.8.13 — refresh connections after provider auth import

* fix(codex): strip client-only params on native /responses passthrough (#3317) (#3325)

A /v1/responses request against the built-in codex/ provider does an
openai-responses -> openai-responses passthrough (CodexExecutor.transformRequest
returns the body early for _nativeCodexPassthrough). It forwarded client-only
fields verbatim and the Codex upstream rejected them with 400 Unsupported
parameter: prompt_cache_retention / safety_identifier / user — breaking Factory
Droid (which injects all three). The chat-completions path already strips these
(base.ts #1884, openai-responses translator #2770) but the passthrough skips
translation. Strip the three fields in the shared block before the passthrough
return; user is removed unconditionally since Codex /responses always rejects it.

Co-authored-by: tycronk20 <tycronk20@users.noreply.github.com>

* fix(dashboard): normalize agent-bridge /state response to stop page crash (#3318) (#3326)

The Agent Bridge page seeded a well-shaped initialData default then replaced it
wholesale with the raw /api/tools/agent-bridge/state response. The route returns
{ server, agents } but the UI reads { serverState, agentStates, bypassPatterns,
mappings }, so serverState became undefined and AgentBridgeServerCard crashed on
serverState.running — surfaced as the full-page 'Internal Server Error' boundary
(client render error, not a real 5xx).

Add a shared normalizeAgentBridgeState() that maps the route shape into the page
contract (server.running/certExists -> serverState) and always returns safe
defaults (never undefined serverState). Wired into both the SSR loader (page.tsx)
and the polling hook. The legacy 'agents' entry shape differs from AgentStateEntry
so it is not coerced; full route<->page contract reconciliation (port, upstreamCa,
bypassPatterns, mappings, agentStates) is a follow-up.

Co-authored-by: tycronk20 <tycronk20@users.noreply.github.com>

* docs: VS Code/Ollama endpoints + env & i18n tooling (#3319)

Integrated into release/v3.8.13 — VS Code/Ollama docs + env & i18n tooling

* feat(provider): test-all endpoint, rate-limit overrides, visibility f… (#3267)

Integrated into release/v3.8.13 — provider test-all endpoint, rate-limit overrides, model visibility

* feat: auto-combo optimization, playground model dropdown, only-configured toggle (#3322)

Integrated into release/v3.8.13 — auto-combo candidate expansion + playground dropdown + only-configured toggle

* feat(api): VS Code Copilot Ollama-compatible BYOK endpoint (#3316)

Integrated into release/v3.8.13 — VS Code Copilot Ollama-compatible BYOK endpoint (reconciled with #3306/#3309 auth hardening)

* chore(release): document #3320 in the v3.8.13 changelog + contributor credits

---------

Co-authored-by: Felipe Almeman <4226997+zhiru@users.noreply.github.com>
Co-authored-by: Wilson <pedbookmed@gmail.com>
Co-authored-by: Hernan Javier Ardila Sanchez <hjasgr@gmail.com>
Co-authored-by: Paijo <14921983+oyi77@users.noreply.github.com>
Co-authored-by: uniQta <uniQta@users.noreply.github.com>
Co-authored-by: onizukashonan14-png <onizukashonan14-png@users.noreply.github.com>
Co-authored-by: tycronk20 <tycronk20@users.noreply.github.com>
Co-authored-by: Vinayrnani <vinayrnani@gmail.com>
2026-06-06 19:13:11 -03:00

483 lines
16 KiB
TypeScript

import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { SignJWT } from "jose";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-api-auth-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = "test-api-key-secret";
const core = await import("../../src/lib/db/core.ts");
const localDb = await import("../../src/lib/localDb.ts");
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
const apiAuth = await import("../../src/shared/utils/apiAuth.ts");
const { requireManagementAuth } = await import("../../src/lib/api/requireManagementAuth.ts");
const { getLegacyCliTokenSync, getMachineTokenSync } =
await import("../../src/lib/machineToken.ts");
const { CLI_TOKEN_HEADER } = await import("../../src/server/authz/headers.ts");
const ORIGINAL_JWT_SECRET = process.env.JWT_SECRET;
const ORIGINAL_INITIAL_PASSWORD = process.env.INITIAL_PASSWORD;
async function resetStorage() {
core.resetDbInstance();
apiKeysDb.resetApiKeyState();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
delete process.env.JWT_SECRET;
delete process.env.INITIAL_PASSWORD;
}
function makeCookieRequest(token: string) {
return {
cookies: {
get(name: string) {
return name === "auth_token" && token ? { value: token } : undefined;
},
},
headers: new Headers(),
};
}
test.beforeEach(async () => {
await resetStorage();
});
test.after(() => {
core.resetDbInstance();
apiKeysDb.resetApiKeyState();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
if (ORIGINAL_JWT_SECRET === undefined) {
delete process.env.JWT_SECRET;
} else {
process.env.JWT_SECRET = ORIGINAL_JWT_SECRET;
}
if (ORIGINAL_INITIAL_PASSWORD === undefined) {
delete process.env.INITIAL_PASSWORD;
} else {
process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL_PASSWORD;
}
});
test("isPublicRoute recognizes allowed API prefixes", () => {
assert.equal(apiAuth.isPublicRoute("/api/auth/login"), true);
assert.equal(apiAuth.isPublicRoute("/api/v1/chat/completions"), true);
assert.equal(apiAuth.isPublicRoute("/api/sync/bundle"), true);
assert.equal(apiAuth.isPublicRoute("/api/monitoring/health", "GET"), true);
assert.equal(apiAuth.isPublicRoute("/api/monitoring/health", "DELETE"), false);
assert.equal(apiAuth.isPublicRoute("/api/settings"), false);
});
test("verifyAuth accepts a valid JWT session cookie", async () => {
process.env.JWT_SECRET = "jwt-secret-for-tests";
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
const token = await new SignJWT({ authenticated: true })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("1h")
.sign(secret);
const result = await apiAuth.verifyAuth(makeCookieRequest(token));
assert.equal(result, null);
});
test("verifyAuth falls back to bearer API key validation after a bad JWT", async () => {
process.env.JWT_SECRET = "jwt-secret-for-tests";
const key = await apiKeysDb.createApiKey("integration", "machine1234567890");
const request = {
cookies: {
get() {
return { value: "definitely-not-a-valid-jwt" };
},
},
headers: new Headers({ authorization: `Bearer ${key.key}` }),
url: "https://example.com/api/v1/models",
};
const result = await apiAuth.verifyAuth(request);
assert.equal(result, null);
});
test("verifyAuth no longer accepts API keys supplied via query string (#3300 follow-up)", async () => {
// Query-string token fallbacks were removed (credential-in-URL leaks into logs).
const key = await apiKeysDb.createApiKey("query-auth", "machine1234567890");
const result = await apiAuth.verifyAuth({
cookies: {
get() {
return undefined;
},
},
headers: new Headers(),
url: `https://example.com/api/v1/models?token=${encodeURIComponent(key.key)}`,
});
// No usable credential → authentication fails (was incorrectly accepted before).
assert.notEqual(result, null);
});
test("isAuthenticated accepts API keys embedded in vscode path aliases", async () => {
const key = await apiKeysDb.createApiKey("path-auth", "machine1234567890");
const request = new Request(
`https://example.com/api/v1/vscode/${encodeURIComponent(key.key)}/models`
);
const result = await apiAuth.isAuthenticated(request);
assert.equal(result, true);
});
test("verifyAuth never honours a URL-borne token on MANAGEMENT routes (#3300 follow-up)", async () => {
// The historical escalation: a credential in the query string on a management
// route (/api/* but not /api/v1/*). It must not be extracted at all, so the
// failure is "Authentication required" (no credential) — NOT "Invalid
// management token" (which the pre-fix code returned, proving the URL token
// had been picked up and tried against management validation).
const key = await apiKeysDb.createApiKey("mgmt-url", "machine1234567890");
const result = await apiAuth.verifyAuth({
cookies: {
get() {
return undefined;
},
},
headers: new Headers(),
url: `https://example.com/api/providers?token=${encodeURIComponent(key.key)}`,
});
assert.equal(result, "Authentication required");
});
test("verifyAuth rejects bearer API keys on management routes", async () => {
const key = await apiKeysDb.createApiKey("integration", "machine1234567890");
const result = await apiAuth.verifyAuth({
cookies: {
get() {
return undefined;
},
},
headers: new Headers({ authorization: `Bearer ${key.key}` }),
url: "https://example.com/api/providers",
});
assert.equal(result, "Invalid management token");
});
test("verifyAuth rejects requests without valid credentials", async () => {
const result = await apiAuth.verifyAuth({
cookies: {
get() {
return undefined;
},
},
headers: new Headers({ authorization: "Bearer sk-invalid" }),
url: "https://example.com/api/v1/models",
});
assert.equal(result, "Authentication required");
});
test("isAuthenticated accepts bearer API keys on client-facing routes", async () => {
const key = await apiKeysDb.createApiKey("integration", "machine1234567890");
const request = new Request("https://example.com/api/v1/models", {
headers: { authorization: `Bearer ${key.key}` },
});
const result = await apiAuth.isAuthenticated(request);
assert.equal(result, true);
});
test("isAuthenticated rejects bearer API keys on management routes", async () => {
process.env.INITIAL_PASSWORD = "bootstrap-password";
await localDb.updateSettings({ requireLogin: true, password: "" });
const key = await apiKeysDb.createApiKey("integration", "machine1234567890");
const request = new Request("https://example.com/api/providers", {
headers: { authorization: `Bearer ${key.key}` },
});
const result = await apiAuth.isAuthenticated(request);
assert.equal(result, false);
});
test("verifyAuth accepts bearer API keys with manage scope on management routes", async () => {
const key = await apiKeysDb.createApiKey("mcp-management", "machine1234567890", ["manage"]);
const result = await apiAuth.verifyAuth({
cookies: {
get() {
return undefined;
},
},
headers: new Headers({ authorization: `Bearer ${key.key}` }),
url: "https://example.com/api/providers",
});
assert.equal(result, null);
});
test("verifyAuth accepts bearer API keys with admin scope on management routes", async () => {
const key = await apiKeysDb.createApiKey("mcp-admin", "machine1234567890", ["admin"]);
const result = await apiAuth.verifyAuth({
cookies: {
get() {
return undefined;
},
},
headers: new Headers({ authorization: `Bearer ${key.key}` }),
url: "https://example.com/api/settings",
});
assert.equal(result, null);
});
test("verifyAuth still rejects unscoped bearer API keys on management routes", async () => {
const key = await apiKeysDb.createApiKey("integration-no-scope", "machine1234567890");
const result = await apiAuth.verifyAuth({
cookies: {
get() {
return undefined;
},
},
headers: new Headers({ authorization: `Bearer ${key.key}` }),
url: "https://example.com/api/providers",
});
assert.equal(result, "Invalid management token");
});
test("isAuthenticated accepts bearer API keys with manage scope on management routes", async () => {
process.env.INITIAL_PASSWORD = "bootstrap-password";
await localDb.updateSettings({ requireLogin: true, password: "" });
const key = await apiKeysDb.createApiKey("mcp-management", "machine1234567890", ["manage"]);
const request = new Request("https://example.com/api/providers", {
headers: { authorization: `Bearer ${key.key}` },
});
const result = await apiAuth.isAuthenticated(request);
assert.equal(result, true);
});
test("monitoring health reset route requires dashboard authentication", async () => {
process.env.INITIAL_PASSWORD = "bootstrap-password";
await localDb.updateSettings({ requireLogin: true, password: "" });
const healthRoute = await import("../../src/app/api/monitoring/health/route.ts");
const response = await healthRoute.DELETE(
new Request("https://example.com/api/monitoring/health", { method: "DELETE" })
);
assert.equal(response.status, 401);
});
test("isAuthenticated returns false when auth is required without valid credentials", async () => {
// Force requireLogin to be active
process.env.INITIAL_PASSWORD = "bootstrap-password";
await localDb.updateSettings({ requireLogin: true, password: "" });
const request = new Request("https://example.com/api/providers");
const result = await apiAuth.isAuthenticated(request);
assert.equal(result, false);
});
test("isAuthRequired is disabled when requireLogin is false", async () => {
await localDb.updateSettings({ requireLogin: false });
const result = await apiAuth.isAuthRequired();
assert.equal(result, false);
});
test("isAuthRequired is disabled while no password exists", async () => {
await localDb.updateSettings({ requireLogin: true, password: "" });
const result = await apiAuth.isAuthRequired();
assert.equal(result, false);
});
test("isAuthRequired keeps fresh bootstrap open only on loopback", async () => {
await localDb.updateSettings({ requireLogin: true, password: "" });
assert.equal(await apiAuth.isAuthRequired(new Request("http://localhost/api/providers")), false);
assert.equal(await apiAuth.isAuthRequired(new Request("http://127.0.0.1/api/providers")), false);
assert.equal(
await apiAuth.isAuthRequired(new Request("https://example.com/api/providers")),
true
);
});
test("isAuthenticated rejects remote management bootstrap without a configured password", async () => {
await localDb.updateSettings({ requireLogin: true, password: "" });
const request = new Request("https://example.com/api/providers");
assert.equal(await apiAuth.isAuthenticated(request), false);
});
test("isAuthRequired stays enabled when a password exists", async () => {
await localDb.updateSettings({ requireLogin: true, password: "hashed-password" });
const result = await apiAuth.isAuthRequired();
assert.equal(result, true);
});
test("isAuthRequired stays enabled when INITIAL_PASSWORD is present", async () => {
process.env.INITIAL_PASSWORD = "bootstrap-password";
await localDb.updateSettings({ requireLogin: true, password: "" });
const result = await apiAuth.isAuthRequired();
assert.equal(result, true);
delete process.env.INITIAL_PASSWORD;
});
test("getApiKeyMetadata recognizes OMNIROUTE_API_KEY environment variable", async () => {
const envKey = "sk-test-env-key-" + Date.now();
process.env.OMNIROUTE_API_KEY = envKey;
const metadata = await apiKeysDb.getApiKeyMetadata(envKey);
assert.ok(metadata);
assert.equal(metadata.id, "env-key");
assert.equal(metadata.name, "Environment Key");
delete process.env.OMNIROUTE_API_KEY;
});
test("getApiKeyMetadata recognizes ROUTER_API_KEY environment variable", async () => {
const envKey = "sk-test-router-key-" + Date.now();
process.env.ROUTER_API_KEY = envKey;
const metadata = await apiKeysDb.getApiKeyMetadata(envKey);
assert.ok(metadata);
assert.equal(metadata.id, "env-key");
delete process.env.ROUTER_API_KEY;
});
// ──── requireManagementAuth ────
async function setupAuth() {
process.env.INITIAL_PASSWORD = "bootstrap-password";
await localDb.updateSettings({ requireLogin: true, password: "" });
}
function managementRequest(bearerKey?: string) {
return new Request("https://example.com/api/combos", {
headers: bearerKey ? { authorization: `Bearer ${bearerKey}` } : {},
});
}
function managementCliRequest(token: string) {
const request = new Request("http://localhost:20128/api/combos", {
headers: { [CLI_TOKEN_HEADER]: token },
});
return Object.assign(request, { ip: "127.0.0.1" }) as Request;
}
test("requireManagementAuth returns 401 with no credentials", async () => {
await setupAuth();
const res = await requireManagementAuth(managementRequest());
assert.ok(res);
assert.equal(res.status, 401);
});
test("requireManagementAuth returns 403 for an invalid management token", async () => {
await setupAuth();
const res = await requireManagementAuth(managementRequest("sk-not-a-real-key"));
assert.ok(res);
assert.equal(res.status, 403);
const body = await res.json();
assert.equal(body.error?.message, "Invalid management token");
});
test("requireManagementAuth returns 403 for valid key without manage scope", async () => {
await setupAuth();
const key = await apiKeysDb.createApiKey("inference-only", "machine-test");
const res = await requireManagementAuth(managementRequest(key.key));
assert.ok(res);
assert.equal(res.status, 403);
const body = await res.json();
assert.ok(body.error?.message?.includes("manage"));
});
test("requireManagementAuth returns null for valid key with manage scope", async () => {
await setupAuth();
const key = await apiKeysDb.createApiKey("admin-key", "machine-test", ["manage"]);
const res = await requireManagementAuth(managementRequest(key.key));
assert.equal(res, null);
});
test("requireManagementAuth accepts the 64-character local machine token", async () => {
await setupAuth();
const token = getMachineTokenSync();
assert.equal(token.length, 64);
const res = await requireManagementAuth(managementCliRequest(token));
assert.equal(res, null);
});
test("requireManagementAuth accepts the legacy 32-character local CLI token", async () => {
await setupAuth();
const token = getLegacyCliTokenSync();
assert.equal(token.length, 32);
const res = await requireManagementAuth(managementCliRequest(token));
assert.equal(res, null);
});
test("requireManagementAuth returns null for OMNIROUTE_API_KEY env passthrough", async () => {
await setupAuth();
const envKey = "sk-env-root-" + Date.now();
process.env.OMNIROUTE_API_KEY = envKey;
try {
const res = await requireManagementAuth(managementRequest(envKey));
assert.equal(res, null);
} finally {
delete process.env.OMNIROUTE_API_KEY;
}
});
test("requireManagementAuth returns 403 for revoked key with manage scope", async () => {
await setupAuth();
const key = await apiKeysDb.createApiKey("revoked-admin", "machine-test", ["manage"]);
await apiKeysDb.revokeApiKey(key.id);
const res = await requireManagementAuth(managementRequest(key.key));
assert.ok(res);
assert.equal(res.status, 403);
const body = await res.json();
assert.equal(body.error?.message, "Invalid management token");
});
test("requireManagementAuth returns null for valid JWT cookie", async () => {
await setupAuth();
process.env.JWT_SECRET = "jwt-secret-for-tests";
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
const token = await new SignJWT({ authenticated: true })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("1h")
.sign(secret);
const request = {
cookies: { get: (name: string) => (name === "auth_token" ? { value: token } : undefined) },
headers: new Headers(),
url: "https://example.com/api/combos",
};
const res = await requireManagementAuth(request as unknown as Request);
assert.equal(res, null);
});