mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-05-06 02:07:00 +00:00
Some checks are pending
CI / Lint (push) Waiting to run
CI / Security Audit (push) Waiting to run
CI / Build (push) Waiting to run
CI / Build-1 (push) Waiting to run
CI / Unit Tests (push) Blocked by required conditions
CI / Unit Tests-1 (push) Blocked by required conditions
CI / Coverage (push) Blocked by required conditions
CI / E2E Tests (push) Blocked by required conditions
CI / Integration Tests (push) Blocked by required conditions
CI / Security Tests (push) Blocked by required conditions
Publish to Docker Hub / Build and Push Docker (multi-arch) (push) Waiting to run
* chore(release): v3.2.8 — Docker auto-update UI and cache analytics fixes * fix(sse): remove race condition in cache metrics tracking (#758) - Remove in-memory metrics tracking (currentMetrics, trackCacheMetrics, updateCacheMetrics) - Cache metrics now computed on-the-fly from usage_history table (single source of truth) - Fixes CRITICAL issue from code review: concurrent requests overwriting metrics - Fixes WARNING: duplicate metric tracking logic in streaming/non-streaming paths Ref: PR #752 (merged before this fix was included) * fix: handle allRateLimited credentials & forward extra body keys in embeddings/images routes (#757) * fix: handle allRateLimited credentials in embeddings and images routes When getProviderCredentials() returns an allRateLimited object (truthy, but without apiKey/accessToken), the embeddings and images routes incorrectly passed it to handlers as valid credentials. The handlers then sent upstream requests without Authorization headers, causing 401 errors from providers (e.g. NVIDIA NIM). This only manifested under concurrent requests: a chat/completions call could trigger rate limiting on a provider account, and a simultaneous embeddings request would receive the allRateLimited sentinel — but treat it as valid credentials. The chat pipeline already handled this case correctly. This commit adds the same allRateLimited guard to all affected routes: - POST /v1/embeddings - POST /v1/providers/{provider}/embeddings - POST /v1/images/generations - POST /v1/providers/{provider}/images/generations Also adds a defense-in-depth guard in the embeddings handler itself: if no auth token is available for a non-local provider, return 401 immediately instead of sending an unauthenticated request upstream. Made-with: Cursor * fix(embeddings): forward extra body keys to upstream providers The embeddings handler only forwarded model, input, dimensions, and encoding_format to upstream providers, silently dropping any additional fields. This broke asymmetric embedding APIs (e.g. NVIDIA NIM nv-embedqa-e5-v5) that require input_type, and other providers expecting user or truncate parameters. Add a KNOWN_FIELDS exclusion set and forward all unrecognized body keys to the upstream request, matching the passthrough pattern used by the chat pipeline's DefaultExecutor.transformRequest(). Made-with: Cursor * fix(auth): redirect and unconditional 401 on disabled requireLogin + fix test cases * fix(build): remove legacy proxy.ts causing Next.js build collision * fix(build): revert middleware.ts rename to proxy.ts because of Next.js Edge constraints --------- Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com> Co-authored-by: tombii <tombii@users.noreply.github.com> Co-authored-by: Gorchakov-Pressure <117600961+Gorchakov-Pressure@users.noreply.github.com>
166 lines
4.9 KiB
JavaScript
166 lines
4.9 KiB
JavaScript
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 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) {
|
|
return {
|
|
cookies: {
|
|
get(name) {
|
|
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/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}` }),
|
|
};
|
|
|
|
const result = await apiAuth.verifyAuth(request);
|
|
|
|
assert.equal(result, null);
|
|
});
|
|
|
|
test("verifyAuth rejects requests without valid credentials", async () => {
|
|
const result = await apiAuth.verifyAuth({
|
|
cookies: {
|
|
get() {
|
|
return undefined;
|
|
},
|
|
},
|
|
headers: new Headers({ authorization: "Bearer sk-invalid" }),
|
|
});
|
|
|
|
assert.equal(result, "Authentication required");
|
|
});
|
|
|
|
test("isAuthenticated accepts bearer API keys", async () => {
|
|
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, true);
|
|
});
|
|
|
|
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 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);
|
|
});
|