Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 3 additions & 10 deletions src/app/api/playground/presets/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,13 @@
import { z } from "zod";
import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
import { extractApiKey, isValidApiKey } from "@/sse/services/auth";
import {
getPlaygroundPreset,
updatePlaygroundPreset,
deletePlaygroundPreset,
} from "@/lib/db/playgroundPresets";
import { checkPlaygroundPresetAuth } from "@/lib/api/playgroundPresetAuth";
import { PlaygroundPresetUpdateSchema } from "@/shared/schemas/playground";
import { isRequireApiKeyEnabled } from "@/shared/utils/featureFlags";

const CORS_HEADERS = {
"Access-Control-Allow-Methods": "GET, PUT, DELETE, OPTIONS",
Expand All @@ -34,14 +33,8 @@ function errorResp(status: number, message: string): Response {
}

async function checkAuth(request: Request): Promise<Response | null> {
const apiKeyRaw = extractApiKey(request);
if (isRequireApiKeyEnabled() && !apiKeyRaw) {
return errorResp(HTTP_STATUS.UNAUTHORIZED, "Authentication required");
}
if (apiKeyRaw && !(await isValidApiKey(apiKeyRaw))) {
return errorResp(HTTP_STATUS.UNAUTHORIZED, "Invalid API key");
}
return null;
const error = await checkPlaygroundPresetAuth(request);
return error ? errorResp(HTTP_STATUS.UNAUTHORIZED, error) : null;
}

function validateId(id: unknown): { valid: true; id: string } | { valid: false; error: Response } {
Expand Down
26 changes: 3 additions & 23 deletions src/app/api/playground/presets/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,9 @@

import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
import { extractApiKey, isValidApiKey } from "@/sse/services/auth";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { listPlaygroundPresets, createPlaygroundPreset } from "@/lib/db/playgroundPresets";
import { checkPlaygroundPresetAuth } from "@/lib/api/playgroundPresetAuth";
import { PlaygroundPresetCreateSchema } from "@/shared/schemas/playground";
import { isRequireApiKeyEnabled } from "@/shared/utils/featureFlags";
import { paginationSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";

Expand All @@ -30,26 +28,8 @@ function errorResp(status: number, message: string): Response {
}

async function checkAuth(request: Request): Promise<Response | null> {
const apiKeyRaw = extractApiKey(request);
// External-client path: if an API key is presented it MUST be valid.
if (apiKeyRaw) {
if (!(await isValidApiKey(apiKeyRaw))) {
return errorResp(HTTP_STATUS.UNAUTHORIZED, "Invalid API key");
}
return null;
}
// Dashboard path: the Playground page itself calls this route with a
// cookie/session (no API key). Under REQUIRE_API_KEY=true that previously
// 401'd the authenticated dashboard (QA P1: preset auth mismatch). Accept a
// valid management/dashboard session as an alternative to an API key.
const managementError = await requireManagementAuth(request);
if (!managementError) return null;
// Neither an API key nor a valid dashboard session: enforce only when
// API-key enforcement is on (preserves the legacy anonymous-allowed default).
if (isRequireApiKeyEnabled()) {
return errorResp(HTTP_STATUS.UNAUTHORIZED, "Authentication required");
}
return null;
const error = await checkPlaygroundPresetAuth(request);
return error ? errorResp(HTTP_STATUS.UNAUTHORIZED, error) : null;
}

export async function OPTIONS(): Promise<Response> {
Expand Down
16 changes: 16 additions & 0 deletions src/lib/api/playgroundPresetAuth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { extractApiKey, isValidApiKey } from "@/sse/services/auth";
import { isRequireApiKeyEnabled } from "@/shared/utils/featureFlags";

export async function checkPlaygroundPresetAuth(
request: Request
): Promise<"Invalid API key" | "Authentication required" | null> {
const apiKey = extractApiKey(request);
if (apiKey) {
return (await isValidApiKey(apiKey)) ? null : "Invalid API key";
}

const requireApiKey = isRequireApiKeyEnabled();
if (!(await requireManagementAuth(request, { alwaysRequireAuth: requireApiKey }))) return null;
return requireApiKey ? "Authentication required" : null;
}
135 changes: 135 additions & 0 deletions tests/integration/presets-item-dashboard-auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
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-preset-item-auth-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.REQUIRE_API_KEY = "true";
process.env.INITIAL_PASSWORD = "test-dashboard-password";
process.env.JWT_SECRET = "test-presets-item-auth-secret";
process.env.API_KEY_SECRET = "test-presets-item-api-key-secret-32-bytes";

const core = await import("../../src/lib/db/core.ts");
const { createApiKey } = await import("../../src/lib/db/apiKeys.ts");
const { updateSettings } = await import("../../src/lib/db/settings.ts");
const { createPlaygroundPreset } = await import("../../src/lib/db/playgroundPresets.ts");
const { GET, PUT, DELETE } = await import("../../src/app/api/playground/presets/[id]/route.ts");
const { GET: listPresets } = await import("../../src/app/api/playground/presets/route.ts");

async function dashboardCookie(): Promise<string> {
const token = await new SignJWT({ authenticated: true })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("30m")
.sign(new TextEncoder().encode(process.env.JWT_SECRET as string));
return `auth_token=${token}`;
}

function request(id: string, method: string, cookie?: string, apiKey?: string): Request {
return new Request(`http://localhost/api/playground/presets/${id}`, {
method,
headers: {
...(cookie ? { cookie } : {}),
...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}),
...(method === "PUT" ? { "Content-Type": "application/json" } : {}),
},
...(method === "PUT" ? { body: JSON.stringify({ name: "Edited" }) } : {}),
});
}

function params(id: string) {
return { params: Promise.resolve({ id }) };
}

const input = {
name: "Original",
endpoint: "chat.completions",
model: "gpt-4o",
system: null,
params: {},
};

test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
});

test("dashboard session can GET, PUT, and DELETE a preset without an API key", async () => {
const preset = createPlaygroundPreset(input);
const cookie = await dashboardCookie();

const read = await GET(request(preset.id, "GET", cookie), params(preset.id));
assert.equal(read.status, 200);
assert.equal(((await read.json()) as { id: string }).id, preset.id);

const update = await PUT(request(preset.id, "PUT", cookie), params(preset.id));
assert.equal(update.status, 200);
assert.equal(((await update.json()) as { name: string }).name, "Edited");

const remove = await DELETE(request(preset.id, "DELETE", cookie), params(preset.id));
assert.equal(remove.status, 204);
});

test("item routes accept a valid API key without a dashboard session", async () => {
const preset = createPlaygroundPreset(input);
const key = await createApiKey("preset-item-auth", "machine-preset-item");
assert.ok(key?.key);

const read = await GET(request(preset.id, "GET", undefined, key.key), params(preset.id));
assert.equal(read.status, 200);
const update = await PUT(request(preset.id, "PUT", undefined, key.key), params(preset.id));
assert.equal(update.status, 200);
const remove = await DELETE(request(preset.id, "DELETE", undefined, key.key), params(preset.id));
assert.equal(remove.status, 204);
});

test("item routes reject missing credentials and invalid API keys even with a dashboard session", async () => {
const preset = createPlaygroundPreset(input);
const cookie = await dashboardCookie();
for (const [method, handler] of [
["GET", GET],
["PUT", PUT],
["DELETE", DELETE],
] as const) {
assert.equal((await handler(request(preset.id, method), params(preset.id))).status, 401);
assert.equal(
(await handler(request(preset.id, method, cookie, "invalid-key"), params(preset.id))).status,
401
);
}
});

test("API-key enforcement remains active when dashboard login is disabled", async () => {
await updateSettings({ requireLogin: false });
try {
const preset = createPlaygroundPreset(input);
const cookie = await dashboardCookie();
const key = await createApiKey("preset-item-auth-open-login", "machine-preset-item");
assert.ok(key?.key);

for (const [method, handler] of [
["GET", GET],
["PUT", PUT],
["DELETE", DELETE],
] as const) {
assert.equal((await handler(request(preset.id, method), params(preset.id))).status, 401);
assert.equal(
(await handler(request(preset.id, method, cookie, "invalid-key"), params(preset.id)))
.status,
401
);
}
const listUrl = "http://localhost/api/playground/presets";
assert.equal((await listPresets(new Request(listUrl))).status, 401);
assert.equal((await listPresets(new Request(listUrl, { headers: { cookie } }))).status, 200);
assert.equal((await GET(request(preset.id, "GET", cookie), params(preset.id))).status, 200);
assert.equal(
(await GET(request(preset.id, "GET", undefined, key.key), params(preset.id))).status,
200
);
} finally {
await updateSettings({ requireLogin: true });
}
});
Loading