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:
diegosouzapw
2026-09-21 21:17:58 -03:00
parent 06f1df9d77
commit d5137d0772
4 changed files with 133 additions and 3 deletions

View File

@@ -0,0 +1 @@
- fix(auth): restore keyless local-first posture on `GET /v1/models` for pre-existing installs with no configured credentials (#13354)

View File

@@ -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) {

View File

@@ -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" &&

View 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}`);
});