mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-22 06:42:19 +03:00
fix(auth): restore keyless local-first posture on GET /v1/models (#13354)
#9320 tightened /v1/models auth to opt-out only when requireAuthForModels is explicitly false, but isAuthRequired() can return true purely from its setupComplete bootstrap fallback with zero credentials configured anywhere. Pre-existing keyless installs that completed onboarding without ever setting a password/OIDC/INITIAL_PASSWORD now 401 on loopback /v1/models requests. getModelCatalogAuthRejection() now also skips the gate when there is no password, no OIDC, no INITIAL_PASSWORD, and no API key ever created (fail-closed on a DB error), matching #9320's stated intent without reopening its leak for any install that has a real credential surface.
This commit is contained in:
@@ -0,0 +1 @@
|
||||
- fix(auth): restore keyless local-first posture on `GET /v1/models` for pre-existing installs with no configured credentials (#13354)
|
||||
@@ -1,4 +1,9 @@
|
||||
import { isAuthRequired, isDashboardSessionAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import {
|
||||
hasConfiguredOidc,
|
||||
hasConfiguredPassword,
|
||||
isAuthRequired,
|
||||
isDashboardSessionAuthenticated,
|
||||
} from "@/shared/utils/apiAuth";
|
||||
import { extractApiKey } from "@/sse/services/auth";
|
||||
|
||||
// Request-scoped catalog helpers: API-key auth gating for `/v1/models` and Codex
|
||||
@@ -9,6 +14,29 @@ async function validateCatalogApiKey(apiKey: string): Promise<boolean> {
|
||||
return validateApiKey(apiKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* #13354: `isAuthRequired()` can return true purely from its bootstrap
|
||||
* `setupComplete === true || !loopback` fallback, with ZERO credentials
|
||||
* configured anywhere. That is a broader signal than "management auth is
|
||||
* configured" — the intent #9320 actually wants to gate on. A pre-existing
|
||||
* keyless install that completed onboarding (without ever configuring a
|
||||
* password, OIDC, or INITIAL_PASSWORD, and without ever creating an API key)
|
||||
* has no credential surface at all, so `/v1/models` must stay open for it —
|
||||
* restoring the documented keyless local-first posture without reopening
|
||||
* the #9320 leak for any install that DOES have a credential surface.
|
||||
*/
|
||||
async function hasNoCredentialSurface(settings: Record<string, any>): Promise<boolean> {
|
||||
if (hasConfiguredPassword(settings) || hasConfiguredOidc(settings)) return false;
|
||||
if (process.env.INITIAL_PASSWORD) return false;
|
||||
try {
|
||||
const { getApiKeysCount } = await import("@/lib/db/apiKeys");
|
||||
return getApiKeysCount() === 0;
|
||||
} catch {
|
||||
// Fail closed: on a DB hiccup, assume keys exist and keep requiring auth.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getModelCatalogAuthRejection(
|
||||
request: Request,
|
||||
settings: Record<string, any>,
|
||||
@@ -17,6 +45,7 @@ export async function getModelCatalogAuthRejection(
|
||||
const authRequired = await isAuthRequired(request);
|
||||
if (!authRequired) return null;
|
||||
if (settings.requireAuthForModels === false) return null;
|
||||
if (await hasNoCredentialSurface(settings)) return null;
|
||||
|
||||
const apiKey = extractApiKey(request);
|
||||
if (apiKey) {
|
||||
|
||||
@@ -44,10 +44,10 @@ export interface AuthRequiredOptions {
|
||||
loopback?: boolean;
|
||||
}
|
||||
|
||||
function hasConfiguredPassword(settings: Record<string, unknown>): boolean {
|
||||
export function hasConfiguredPassword(settings: Record<string, unknown>): boolean {
|
||||
return typeof settings.password === "string" && settings.password.length > 0;
|
||||
}
|
||||
function hasConfiguredOidc(settings: Record<string, unknown>): boolean {
|
||||
export function hasConfiguredOidc(settings: Record<string, unknown>): boolean {
|
||||
return (
|
||||
settings.oidcEnabled === true &&
|
||||
typeof settings.oidcIssuer === "string" &&
|
||||
|
||||
100
tests/unit/v1-models-keyless-loopback-13354.test.ts
Normal file
100
tests/unit/v1-models-keyless-loopback-13354.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
// #13354 — #9320 regressed the keyless local-first posture: /v1/models 401s
|
||||
// on pre-existing keyless installs (even loopback).
|
||||
//
|
||||
// Repro: a pre-existing install that completed onboarding WITHOUT ever
|
||||
// configuring a password, OIDC, or INITIAL_PASSWORD (a deliberately keyless
|
||||
// local-first setup). `settings.setupComplete` is true. A loopback request
|
||||
// to GET /v1/models must NOT be rejected with 401 — this is the documented
|
||||
// keyless local-first posture. #9320 changed getModelCatalogAuthRejection's
|
||||
// opt-out from "requireAuthForModels !== true" to "requireAuthForModels ===
|
||||
// false", but isAuthRequired() can return true purely because
|
||||
// `setupComplete === true`, even with zero credential surface configured —
|
||||
// so the opt-out no longer fires for these installs.
|
||||
|
||||
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";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-13354-keyless-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "test-secret-13354";
|
||||
delete process.env.INITIAL_PASSWORD;
|
||||
delete process.env.OMNIROUTE_PEER_STAMP_TOKEN;
|
||||
// Give the catalog builder generous headroom under a contended shared runner —
|
||||
// the default 8s bound (CATALOG_BUILD_TIMEOUT_MS_DEFAULT) is tuned for a real
|
||||
// deployment, not a loaded CI/dev box; this test only cares about the auth
|
||||
// gate's status code, not build latency.
|
||||
process.env.CATALOG_BUILD_TIMEOUT_MS = process.env.CATALOG_BUILD_TIMEOUT_MS || "30000";
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
|
||||
const settingsModule = await import("../../src/lib/db/settings.ts");
|
||||
const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts");
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
apiKeysDb.resetApiKeyState();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
try {
|
||||
v1ModelsCatalog.__resetCatalogBuilderRunsForTest();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetStorage();
|
||||
});
|
||||
test.after(async () => {
|
||||
core.resetDbInstance();
|
||||
apiKeysDb.resetApiKeyState();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
});
|
||||
|
||||
test("#13354: keyless install (setupComplete, no password/OIDC/INITIAL_PASSWORD) — loopback GET /v1/models must NOT 401", async () => {
|
||||
await settingsModule.updateSettings({ setupComplete: true });
|
||||
|
||||
const settingsAfter = await settingsModule.getSettings();
|
||||
assert.equal(settingsAfter.setupComplete, true, "precondition: setupComplete must be true");
|
||||
assert.ok(
|
||||
!settingsAfter.password,
|
||||
"precondition: no password must be configured (keyless install)"
|
||||
);
|
||||
|
||||
const res = await v1ModelsCatalog.getUnifiedModelsResponse(
|
||||
new Request("http://127.0.0.1:20128/v1/models")
|
||||
);
|
||||
|
||||
if (res.status === 401) {
|
||||
const body = await res.json();
|
||||
assert.fail(
|
||||
`BUG #13354 reproduced: loopback GET /v1/models on a keyless completed-onboarding ` +
|
||||
`install returned 401 instead of the model catalog. body=${JSON.stringify(body)}`
|
||||
);
|
||||
}
|
||||
|
||||
assert.equal(res.status, 200, `expected 200 for keyless loopback request, got ${res.status}`);
|
||||
});
|
||||
|
||||
test("#13354: keyless install with a password later configured — anonymous loopback GET /v1/models must still 401 (no #9320 regression)", async () => {
|
||||
await settingsModule.updateSettings({ setupComplete: true, password: "hashed-password-value" });
|
||||
|
||||
const res = await v1ModelsCatalog.getUnifiedModelsResponse(
|
||||
new Request("http://127.0.0.1:20128/v1/models")
|
||||
);
|
||||
|
||||
assert.equal(res.status, 401, `expected 401 once a password is configured, got ${res.status}`);
|
||||
});
|
||||
|
||||
test("#13354: keyless install with one API key created — anonymous loopback GET /v1/models must still 401 without that key", async () => {
|
||||
await settingsModule.updateSettings({ setupComplete: true });
|
||||
const { createApiKey } = await import("../../src/lib/db/apiKeys.ts");
|
||||
await createApiKey("test-key", "test-machine-13354");
|
||||
|
||||
const res = await v1ModelsCatalog.getUnifiedModelsResponse(
|
||||
new Request("http://127.0.0.1:20128/v1/models")
|
||||
);
|
||||
|
||||
assert.equal(res.status, 401, `expected 401 once an API key exists, got ${res.status}`);
|
||||
});
|
||||
Reference in New Issue
Block a user