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: 13 additions & 0 deletions src/lib/server/services/auth-methods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,19 @@ import { targetAuthMethods } from "../db/schema";
const VALID_TYPES = ["bearer", "basic", "custom_header", "query_param", "ssh_key", "jwt_es256", "oauth2_refresh_token"];

export function computeCredentialHint(credential: string, type?: string): string {
if (type === "custom_header") {
// Try JSON array format: [{"name":"X-Key","value":"val"}, ...]
try {
const parsed = JSON.parse(credential);
if (Array.isArray(parsed) && parsed.length > 0) {
const names = parsed
.filter((e: unknown) => e && typeof (e as Record<string, unknown>).name === "string")
.map((e: { name: string }) => e.name);
if (names.length === 1) return `Header: ${names[0]}`;
if (names.length > 1) return `${names.length} headers: ${names.join(", ")}`;
}
} catch { /* legacy format — fall through */ }
}
if (type === "ssh_key") {
if (credential.includes("RSA")) return "SSH Key (RSA)";
if (credential.includes("ED25519")) return "SSH Key (Ed25519)";
Expand Down
22 changes: 17 additions & 5 deletions src/lib/server/services/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,11 +104,23 @@ export async function proxyToTarget(
const encoded = Buffer.from(authMethod.credential).toString("base64");
headers.set("Authorization", `Basic ${encoded}`);
} else if (authMethod.type === "custom_header") {
const separatorIndex = authMethod.credential.indexOf(":");
if (separatorIndex > 0) {
const headerName = authMethod.credential.slice(0, separatorIndex).trim();
const headerValue = authMethod.credential.slice(separatorIndex + 1).trim();
headers.set(headerName, headerValue);
// Try JSON array format first: [{"name":"X-Key","value":"val"}, ...]
let parsed: unknown;
try { parsed = JSON.parse(authMethod.credential); } catch { /* legacy format */ }
if (Array.isArray(parsed)) {
for (const entry of parsed) {
if (entry && typeof entry.name === "string" && typeof entry.value === "string" && entry.name.trim()) {
headers.set(entry.name.trim(), entry.value);
}
}
} else {
// Legacy single-header format: "Header: Value"
const separatorIndex = authMethod.credential.indexOf(":");
if (separatorIndex > 0) {
const headerName = authMethod.credential.slice(0, separatorIndex).trim();
const headerValue = authMethod.credential.slice(separatorIndex + 1).trim();
headers.set(headerName, headerValue);
}
}
} else if (authMethod.type === "query_param") {
const separatorIndex = authMethod.credential.indexOf(":");
Expand Down
23 changes: 14 additions & 9 deletions src/routes/(app)/targets/[slug]/+page.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,10 +132,13 @@ export const actions = {
if (!username || !password) return fail(400, { error: "Username and password are required" });
credential = `${username}:${password}`;
} else if (type === "custom_header") {
const headerName = data.get("credential1")?.toString() ?? "";
const headerValue = data.get("credential2")?.toString() ?? "";
if (!headerName || !headerValue) return fail(400, { error: "Header name and value are required" });
credential = `${headerName}: ${headerValue}`;
const headerNames = data.getAll("headerName").map((v) => v.toString().trim());
const headerValues = data.getAll("headerValue").map((v) => v.toString());
const headers = headerNames
.map((name, i) => ({ name, value: headerValues[i] ?? "" }))
.filter((h) => h.name && h.value);
if (headers.length === 0) return fail(400, { error: "At least one header name and value is required" });
credential = JSON.stringify(headers);
} else if (type === "query_param") {
const paramName = data.get("credential1")?.toString() ?? "";
const paramValue = data.get("credential2")?.toString() ?? "";
Expand Down Expand Up @@ -234,11 +237,13 @@ export const actions = {
credential = `${username}:${password}`;
}
} else if (type === "custom_header") {
const headerName = data.get("credential1")?.toString() ?? "";
const headerValue = data.get("credential2")?.toString() ?? "";
if (headerName || headerValue) {
if (!headerName || !headerValue) return fail(400, { error: "Header name and value are required" });
credential = `${headerName}: ${headerValue}`;
const headerNames = data.getAll("headerName").map((v) => v.toString().trim());
const headerValues = data.getAll("headerValue").map((v) => v.toString());
const headers = headerNames
.map((name, i) => ({ name, value: headerValues[i] ?? "" }))
.filter((h) => h.name && h.value);
if (headers.length > 0) {
credential = JSON.stringify(headers);
}
} else if (type === "query_param") {
const paramName = data.get("credential1")?.toString() ?? "";
Expand Down
86 changes: 59 additions & 27 deletions src/routes/(app)/targets/[slug]/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import LoaderCircleIcon from "@lucide/svelte/icons/loader-circle";
import EyeIcon from "@lucide/svelte/icons/eye";
import EyeOffIcon from "@lucide/svelte/icons/eye-off";
import KeyIcon from "@lucide/svelte/icons/key";
import TrashIcon from "@lucide/svelte/icons/trash-2";
import type { PageData } from "./$types";


Expand Down Expand Up @@ -90,6 +91,9 @@ let authCredential = $state("");
let showCredential = $state(false);
let isDefaultChecked = $state(true);

// Multi-header state for custom_header
let customHeaders = $state<{ name: string; value: string }[]>([{ name: "", value: "" }]);

// Auth type display labels
const authTypeLabels: Record<string, string> = {
bearer: "Bearer",
Expand Down Expand Up @@ -178,6 +182,7 @@ function openAddAuthSheet() {
authCredential = "";
showCredential = false;
isDefaultChecked = true;
customHeaders = [{ name: "", value: "" }];
jwtPrivateKey = "";
jwtKeyId = "";
jwtIssuerId = "";
Expand All @@ -203,6 +208,7 @@ function openEditAuthSheet(method: AuthMethod) {
authCredential = "";
showCredential = false;
isDefaultChecked = method.isDefault;
customHeaders = [{ name: "", value: "" }];
jwtPrivateKey = "";
jwtKeyId = "";
jwtIssuerId = "";
Expand Down Expand Up @@ -452,33 +458,38 @@ async function copyToClipboard(text: string) {
</div>
{:else if authType === 'custom_header'}
<div class="grid gap-2">
<Label for="add-auth-header-name">Header Name</Label>
<Input id="add-auth-header-name" name="credential1" bind:value={authCredential} placeholder="X-API-Key" required />
</div>
<div class="grid gap-2">
<Label for="add-auth-header-value">Header Value</Label>
<div class="relative">
<Input
id="add-auth-header-value"
name="credential2"
type={showCredential ? 'text' : 'password'}
placeholder="your-key-here"
required
/>
<div class="flex items-center justify-between">
<Label>Headers</Label>
<Button
type="button"
variant="ghost"
size="icon"
class="absolute right-0 top-0 h-full px-3"
size="sm"
class="h-7 text-xs"
onclick={() => (showCredential = !showCredential)}
>
{#if showCredential}
<EyeOffIcon class="size-4" />
<EyeOffIcon class="mr-1 size-3" /> Hide values
{:else}
<EyeIcon class="size-4" />
<EyeIcon class="mr-1 size-3" /> Show values
{/if}
</Button>
</div>
{#each customHeaders as header, i}
<div class="flex gap-2 items-start">
<Input name="headerName" bind:value={header.name} placeholder="X-API-Key" required class="flex-1" />
<div class="relative flex-1">
<Input name="headerValue" type={showCredential ? 'text' : 'password'} bind:value={header.value} placeholder="value" required />
</div>
{#if customHeaders.length > 1}
<Button type="button" variant="ghost" size="icon" class="h-9 w-9 shrink-0" onclick={() => { customHeaders = customHeaders.filter((_, j) => j !== i); }}>
<TrashIcon class="size-4" />
</Button>
{/if}
</div>
{/each}
<Button type="button" variant="outline" size="sm" class="w-full" onclick={() => { customHeaders = [...customHeaders, { name: "", value: "" }]; }}>
<PlusIcon class="mr-2 size-4" /> Add Header
</Button>
</div>
{:else if authType === 'query_param'}
<div class="grid gap-2">
Expand Down Expand Up @@ -631,7 +642,7 @@ async function copyToClipboard(text: string) {
<Checkbox id="add-auth-default" name="isDefault" checked={isDefaultChecked} onCheckedChange={(v) => (isDefaultChecked = v === true)} />
<Label for="add-auth-default" class="text-sm font-normal">Set as default</Label>
</div>
<Button type="submit" disabled={sheetSubmitting || !authLabel.trim() || (authType === 'jwt_es256' ? (!jwtPrivateKey || !jwtKeyId || !jwtIssuerId) : authType === 'oauth2_refresh_token' ? (!oauth2ClientId || !oauth2ClientSecret || !oauth2RefreshToken) : !authCredential)}>
<Button type="submit" disabled={sheetSubmitting || !authLabel.trim() || (authType === 'jwt_es256' ? (!jwtPrivateKey || !jwtKeyId || !jwtIssuerId) : authType === 'oauth2_refresh_token' ? (!oauth2ClientId || !oauth2ClientSecret || !oauth2RefreshToken) : authType === 'custom_header' ? !customHeaders.some((header) => header.name.trim() && header.value) : !authCredential)}>
{#if sheetSubmitting}
<LoaderCircleIcon class="mr-2 size-4 animate-spin" />
{/if}
Expand Down Expand Up @@ -755,17 +766,38 @@ async function copyToClipboard(text: string) {
</div>
{:else if editAuthType === 'custom_header'}
<div class="grid gap-2">
<Label for="edit-auth-header-name">Header Name <span class="text-muted-foreground text-xs">(leave blank to keep existing)</span></Label>
<Input id="edit-auth-header-name" name="credential1" bind:value={authCredential} placeholder="X-API-Key" />
</div>
<div class="grid gap-2">
<Label for="edit-auth-header-value">Header Value</Label>
<div class="relative">
<Input id="edit-auth-header-value" name="credential2" type={showCredential ? 'text' : 'password'} placeholder="your-key-here" />
<Button type="button" variant="ghost" size="icon" class="absolute right-0 top-0 h-full px-3" onclick={() => (showCredential = !showCredential)}>
{#if showCredential}<EyeOffIcon class="size-4" />{:else}<EyeIcon class="size-4" />{/if}
<div class="flex items-center justify-between">
<Label>Headers <span class="text-muted-foreground text-xs">(leave blank to keep existing)</span></Label>
<Button
type="button"
variant="ghost"
size="sm"
class="h-7 text-xs"
onclick={() => (showCredential = !showCredential)}
>
{#if showCredential}
<EyeOffIcon class="mr-1 size-3" /> Hide values
{:else}
<EyeIcon class="mr-1 size-3" /> Show values
{/if}
</Button>
</div>
{#each customHeaders as header, i}
<div class="flex gap-2 items-start">
<Input name="headerName" bind:value={header.name} placeholder="X-API-Key" class="flex-1" />
<div class="relative flex-1">
<Input name="headerValue" type={showCredential ? 'text' : 'password'} bind:value={header.value} placeholder="value" />
</div>
{#if customHeaders.length > 1}
<Button type="button" variant="ghost" size="icon" class="h-9 w-9 shrink-0" onclick={() => { customHeaders = customHeaders.filter((_, j) => j !== i); }}>
<TrashIcon class="size-4" />
</Button>
{/if}
</div>
{/each}
<Button type="button" variant="outline" size="sm" class="w-full" onclick={() => { customHeaders = [...customHeaders, { name: "", value: "" }]; }}>
<PlusIcon class="mr-2 size-4" /> Add Header
</Button>
</div>
{:else if editAuthType === 'query_param'}
<div class="grid gap-2">
Expand Down
49 changes: 49 additions & 0 deletions tests/integration/gateway.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,4 +291,53 @@ describe("gateway proxy", () => {
expect(payload.iat).toBeGreaterThan(0);
expect(payload.exp).toBeGreaterThan(payload.iat);
});

it("proxies request with JSON multi-header custom_header credential", async () => {
const { token: tokenRow } = await createTestToken();
const target = await createTestTarget("MultiHeaderAPI", "https://api.multi.com");
const multiHeaders = JSON.stringify([
{ name: "X-API-Key", value: "key-123" },
{ name: "X-Org-Id", value: "org-456" },
]);
await createTestAuthMethod(target.id, { type: "custom_header", credential: multiHeaders });
await grantPermission(tokenRow.id, target.id);

const fullToken = await getFullToken(tokenRow.id);

const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ ok: true }));

const request = new Request(`http://localhost/gateway/${target.slug}/data`, {
method: "GET",
});

const response = await proxyRequest(fullToken, target.slug, "data", request);

expect(response.status).toBe(200);
expect(fetchSpy).toHaveBeenCalledOnce();
const [, init] = fetchSpy.mock.calls[0];
expect((init!.headers as Headers).get("X-API-Key")).toBe("key-123");
expect((init!.headers as Headers).get("X-Org-Id")).toBe("org-456");
});

it("proxies request with legacy single custom_header credential (backward compat)", async () => {
const { token: tokenRow } = await createTestToken();
const target = await createTestTarget("LegacyHeaderAPI", "https://api.legacy.com");
await createTestAuthMethod(target.id, { type: "custom_header", credential: "X-API-Key: legacy-key" });
await grantPermission(tokenRow.id, target.id);

const fullToken = await getFullToken(tokenRow.id);

const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ ok: true }));

const request = new Request(`http://localhost/gateway/${target.slug}/data`, {
method: "GET",
});

const response = await proxyRequest(fullToken, target.slug, "data", request);

expect(response.status).toBe(200);
expect(fetchSpy).toHaveBeenCalledOnce();
const [, init] = fetchSpy.mock.calls[0];
expect((init!.headers as Headers).get("X-API-Key")).toBe("legacy-key");
});
});
18 changes: 18 additions & 0 deletions tests/unit/auth-methods.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,22 @@ describe("computeCredentialHint", () => {
const hint = computeCredentialHint("key:abc-semrush-key-1234");
expect(hint).toBe("key••••••••1234");
});

it("shows header name for single JSON custom_header", () => {
const credential = JSON.stringify([{ name: "X-API-Key", value: "secret" }]);
expect(computeCredentialHint(credential, "custom_header")).toBe("Header: X-API-Key");
});

it("shows count and names for multiple JSON custom_headers", () => {
const credential = JSON.stringify([
{ name: "X-API-Key", value: "key1" },
{ name: "X-Secret", value: "key2" },
]);
expect(computeCredentialHint(credential, "custom_header")).toBe("2 headers: X-API-Key, X-Secret");
});

it("falls through to default hint for legacy custom_header format", () => {
const hint = computeCredentialHint("X-API-Key: my-secret-value", "custom_header");
expect(hint).toBe("X-A••••••••alue");
});
});
Loading