Repository navigation
[codex] Add scoped API key copy flow for Api Manager #740
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -111,6 +111,7 @@ export default function ApiManagerPageClient() { | |
| const [isSubmitting, setIsSubmitting] = useState(false); | ||
| const [usageStats, setUsageStats] = useState<Record<string, KeyUsageStats>>({}); | ||
| const [sessionCounts, setSessionCounts] = useState<Record<string, number>>({}); | ||
| const [allowKeyReveal, setAllowKeyReveal] = useState(false); | ||
|
|
||
| const { copied, copy } = useCopyToClipboard(); | ||
|
|
||
|
|
@@ -150,6 +151,7 @@ export default function ApiManagerPageClient() { | |
| if (res.ok) { | ||
| const data = await res.json(); | ||
| setKeys(data.keys || []); | ||
| setAllowKeyReveal(data.allowKeyReveal === true); | ||
| // Fetch usage stats after keys are loaded | ||
| fetchUsageStats(data.keys || []); | ||
| fetchSessionCounts(data.keys || []); | ||
|
|
@@ -288,6 +290,25 @@ export default function ApiManagerPageClient() { | |
| setShowPermissionsModal(true); | ||
| }; | ||
|
|
||
| const handleCopyExistingKey = async (keyId: string) => { | ||
| if (!keyId) return; | ||
|
|
||
| try { | ||
| const res = await fetch(`/api/keys/${encodeURIComponent(keyId)}/reveal`); | ||
| if (!res.ok) { | ||
| console.log("Error revealing key:", await res.text()); | ||
| return; | ||
| } | ||
|
|
||
| const data = await res.json(); | ||
| if (typeof data?.key === "string") { | ||
| await copy(data.key, `existing_key_${keyId}`); | ||
| } | ||
| } catch (error) { | ||
| console.log("Error copying existing key:", error); | ||
| } | ||
| }; | ||
|
Comment on lines
+293
to
+310
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The current implementation of |
||
|
|
||
| const handleUpdatePermissions = async ( | ||
| allowedModels: string[], | ||
| noLog: boolean, | ||
|
|
@@ -560,12 +581,25 @@ export default function ApiManagerPageClient() { | |
| </div> | ||
| <div className="col-span-3 flex items-center gap-1.5"> | ||
| <code className="text-sm text-text-muted font-mono truncate">{key.key}</code> | ||
| <span | ||
| className="p-1 text-text-muted/40 opacity-0 group-hover:opacity-100 transition-all shrink-0 cursor-help" | ||
| title={t("keyOnlyAvailableAtCreation")} | ||
| > | ||
| <span className="material-symbols-outlined text-[14px]">lock</span> | ||
| </span> | ||
| {allowKeyReveal ? ( | ||
| <button | ||
| onClick={() => handleCopyExistingKey(key.id)} | ||
| className="p-1 text-text-muted/60 hover:text-primary transition-colors shrink-0" | ||
| title={tc("copy")} | ||
| aria-label={tc("copy")} | ||
| > | ||
| <span className="material-symbols-outlined text-[14px]"> | ||
| {copied === `existing_key_${key.id}` ? "check" : "content_copy"} | ||
| </span> | ||
| </button> | ||
| ) : ( | ||
| <span | ||
| className="p-1 text-text-muted/40 opacity-0 group-hover:opacity-100 transition-all shrink-0 cursor-help" | ||
| title={t("keyOnlyAvailableAtCreation")} | ||
| > | ||
| <span className="material-symbols-outlined text-[14px]">lock</span> | ||
| </span> | ||
| )} | ||
| </div> | ||
| <div className="col-span-2 flex items-center"> | ||
| <div className="flex flex-col items-start gap-1"> | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,24 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { NextResponse } from "next/server"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { getApiKeyById } from "@/lib/localDb"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { isApiKeyRevealEnabled } from "@/lib/apiKeyExposure"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // GET /api/keys/[id]/reveal - Reveal full API key for explicit copy actions | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export async function GET(_request, { params }) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. CRITICAL: Missing authentication check — The route exposes full API keys without verifying the request is from an authenticated user. Other management routes (e.g., if (await isAuthRequired()) {
if (!(await isAuthenticated(request))) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
}Without this check, anyone who can access the dashboard (or if |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!isApiKeyRevealEnabled()) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return NextResponse.json({ error: "API key reveal is disabled" }, { status: 403 }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const { id } = await params; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const key = await getApiKeyById(id); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+5
to
+13
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!key || typeof key.key !== "string") { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return NextResponse.json({ error: "Key not found" }, { status: 404 }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return NextResponse.json({ key: key.key }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return NextResponse.json({ key: key.key }); | |
| return NextResponse.json( | |
| { key: key.key }, | |
| { | |
| headers: { | |
| "Cache-Control": "no-store", | |
| "Pragma": "no-cache", | |
| }, | |
| }, | |
| ); |
Copilot
AI
Mar 29, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This new management API route uses ad-hoc NextResponse.json({ error: "..." }) payloads. The repo has canonical helpers in src/shared/utils/apiResponse.ts intended for management routes (structured { error: { code, message, ... } }). Using those helpers here would keep client error handling consistent.
| // GET /api/keys/[id]/reveal - Reveal full API key for explicit copy actions | |
| export async function GET(_request, { params }) { | |
| try { | |
| if (!isApiKeyRevealEnabled()) { | |
| return NextResponse.json({ error: "API key reveal is disabled" }, { status: 403 }); | |
| } | |
| const { id } = await params; | |
| const key = await getApiKeyById(id); | |
| if (!key || typeof key.key !== "string") { | |
| return NextResponse.json({ error: "Key not found" }, { status: 404 }); | |
| } | |
| return NextResponse.json({ key: key.key }); | |
| } catch (error) { | |
| console.log("Error revealing key:", error); | |
| return NextResponse.json({ error: "Failed to reveal key" }, { status: 500 }); | |
| import { errorResponse, successResponse } from "@/shared/utils/apiResponse"; | |
| // GET /api/keys/[id]/reveal - Reveal full API key for explicit copy actions | |
| export async function GET(_request, { params }) { | |
| try { | |
| if (!isApiKeyRevealEnabled()) { | |
| return errorResponse( | |
| 403, | |
| "API_KEY_REVEAL_DISABLED", | |
| "API key reveal is disabled", | |
| ); | |
| } | |
| const { id } = await params; | |
| const key = await getApiKeyById(id); | |
| if (!key || typeof key.key !== "string") { | |
| return errorResponse(404, "API_KEY_NOT_FOUND", "Key not found"); | |
| } | |
| return successResponse({ key: key.key }); | |
| } catch (error) { | |
| console.log("Error revealing key:", error); | |
| return errorResponse(500, "API_KEY_REVEAL_FAILED", "Failed to reveal key"); |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,13 @@ | ||||||||||||||||||||||||||||||||||||||||||||
| const ENABLED_VALUES = new Set(["1", "true", "yes", "on"]); | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| export function isApiKeyRevealEnabled(): boolean { | ||||||||||||||||||||||||||||||||||||||||||||
| const raw = String(process.env.ALLOW_API_KEY_REVEAL || "") | ||||||||||||||||||||||||||||||||||||||||||||
| .trim() | ||||||||||||||||||||||||||||||||||||||||||||
| .toLowerCase(); | ||||||||||||||||||||||||||||||||||||||||||||
| return ENABLED_VALUES.has(raw); | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| export function maskStoredApiKey(key: unknown): string | null { | ||||||||||||||||||||||||||||||||||||||||||||
| if (typeof key !== "string") return null; | ||||||||||||||||||||||||||||||||||||||||||||
| return key.slice(0, 8) + "****" + key.slice(-4); | ||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||
| return key.slice(0, 8) + "****" + key.slice(-4); | |
| const str = key as string; | |
| const len = str.length; | |
| // For empty or very short keys, mask aggressively to avoid leaking the full value. | |
| if (len === 0) { | |
| return null; | |
| } | |
| if (len <= 4) { | |
| return "****"; | |
| } | |
| // For short keys, ensure prefix and suffix do not cover the entire string. | |
| if (len <= 12) { | |
| // Reveal only first and last character, mask everything in between. | |
| return str[0] + "****" + str.slice(-1); | |
| } | |
| // For longer keys, keep existing behavior: show first 8 and last 4 characters. | |
| return str.slice(0, 8) + "****" + str.slice(-4); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| 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-api-key-reveal-")); | ||
| 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 apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); | ||
| const listRoute = await import("../../src/app/api/keys/route.ts"); | ||
| const revealRoute = await import("../../src/app/api/keys/[id]/reveal/route.ts"); | ||
|
|
||
| const MACHINE_ID = "1234567890abcdef"; | ||
|
|
||
| async function resetStorage() { | ||
| delete process.env.ALLOW_API_KEY_REVEAL; | ||
| core.resetDbInstance(); | ||
| apiKeysDb.resetApiKeyState(); | ||
| fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); | ||
| fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); | ||
| } | ||
|
|
||
| function maskKey(key) { | ||
| return key.slice(0, 8) + "****" + key.slice(-4); | ||
| } | ||
|
|
||
| test.beforeEach(async () => { | ||
| await resetStorage(); | ||
| }); | ||
|
|
||
| test.after(async () => { | ||
| delete process.env.ALLOW_API_KEY_REVEAL; | ||
| core.resetDbInstance(); | ||
| apiKeysDb.resetApiKeyState(); | ||
| fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); | ||
| }); | ||
|
|
||
| test("GET /api/keys stays masked even when reveal is enabled", async () => { | ||
| process.env.ALLOW_API_KEY_REVEAL = "true"; | ||
| const created = await apiKeysDb.createApiKey("Primary Key", MACHINE_ID); | ||
|
|
||
| const response = await listRoute.GET(); | ||
| const body = await response.json(); | ||
|
|
||
| assert.equal(response.status, 200); | ||
| assert.equal(body.allowKeyReveal, true); | ||
| assert.equal(Array.isArray(body.keys), true); | ||
| assert.equal(body.keys[0].key, maskKey(created.key)); | ||
| }); | ||
|
|
||
| test("GET /api/keys/[id]/reveal rejects requests when reveal is disabled", async () => { | ||
| const created = await apiKeysDb.createApiKey("Primary Key", MACHINE_ID); | ||
| const request = new Request(`http://localhost/api/keys/${created.id}/reveal`); | ||
|
|
||
| const response = await revealRoute.GET(request, { | ||
| params: Promise.resolve({ id: created.id }), | ||
| }); | ||
|
Comment on lines
+58
to
+60
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The const response = await revealRoute.GET(request, {
params: { id: created.id },
}); |
||
| const body = await response.json(); | ||
|
|
||
| assert.equal(response.status, 403); | ||
| assert.equal(body.error, "API key reveal is disabled"); | ||
| }); | ||
|
|
||
| test("GET /api/keys/[id]/reveal returns the full key when reveal is enabled", async () => { | ||
| process.env.ALLOW_API_KEY_REVEAL = "true"; | ||
| const created = await apiKeysDb.createApiKey("Primary Key", MACHINE_ID); | ||
| const request = new Request(`http://localhost/api/keys/${created.id}/reveal`); | ||
|
|
||
| const response = await revealRoute.GET(request, { | ||
| params: Promise.resolve({ id: created.id }), | ||
| }); | ||
|
Comment on lines
+72
to
+74
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| const body = await response.json(); | ||
|
|
||
| assert.equal(response.status, 200); | ||
| assert.equal(body.key, created.key); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
WARNING: No user feedback on failure — When the reveal request fails (e.g., network error, server error), the user is not informed. The error is only logged to console.
Consider adding error state to inform users why the copy operation failed.