add virtual api key validation in verifySession

This commit is contained in:
miloschwartz
2026-08-12 10:38:08 -04:00
parent ac3402a8b3
commit 49020fa6ea
29 changed files with 852 additions and 197 deletions
+2 -12
View File
@@ -1,17 +1,7 @@
import type { Request } from "express";
import { AI_CAPABILITIES, type AiCapability } from "@app/lib/aiCapabilities";
export const AI_CAPABILITIES = [
"openai_chat",
"openai_responses",
"anthropic_messages",
"gemini_generate_content",
"bedrock_model_invoke",
"google_generate_content",
"google_raw_predict",
"bedrock_converse"
] as const;
export type AiCapability = (typeof AI_CAPABILITIES)[number];
export { AI_CAPABILITIES, type AiCapability };
export type AiCapabilityRoute = {
method: "POST";
+38
View File
@@ -0,0 +1,38 @@
import { createHash } from "crypto";
import config from "@server/lib/config";
export const AI_GATEWAY_TRUST_HEADER = "X-Pangolin-Ai-Gateway-Auth";
/**
* Derive a Traefik-injected trust token from the server secret.
* Traefik overwrites this header on inference routes so the AI gateway can
* trust Badger-injected Remote-* identity without re-validating credentials.
*/
export function deriveAiGatewayTrustToken(secret: string): string {
return createHash("sha256")
.update(`ai-gateway-trust:${secret}`)
.digest("hex");
}
export function getAiGatewayTrustToken(): string {
const secret = config.getRawConfig().server.secret;
if (!secret) {
throw new Error("Server secret is required for AI gateway trust token");
}
return deriveAiGatewayTrustToken(secret);
}
export function isAiGatewayTrustHeaderValid(
headers: Record<string, string | string[] | undefined> | undefined,
expectedToken?: string
): boolean {
if (!headers) {
return false;
}
const expected = expectedToken ?? getAiGatewayTrustToken();
const raw =
headers[AI_GATEWAY_TRUST_HEADER] ??
headers[AI_GATEWAY_TRUST_HEADER.toLowerCase()];
const value = Array.isArray(raw) ? raw[0] : raw;
return typeof value === "string" && value === expected;
}
+28 -104
View File
@@ -3,82 +3,29 @@ import {
parseCapabilities,
type AiCapability
} from "@server/lib/aiCapabilities";
import { stripVirtualApiKeyAuthHeaders } from "@app/lib/virtualApiKeyFormat";
import {
AI_PROVIDER_AUTH_TYPES,
AI_PROVIDER_DEFAULTS,
authTypeRequiresApiKey,
defaultsForProviderType,
providerRequiresUpstreamUrl,
type AiBudgetUnit,
type AiProviderAuthType,
type AiProviderRoutingMode,
type AiProviderType
} from "@app/lib/aiProviderDefaults";
export type AiProviderType =
| "openai"
| "anthropic"
| "googleGemini"
| "vertexAi"
| "bedrock"
| "microsoftFoundry"
| "openRouter"
| "vercelAiGateway"
| "custom";
export const AI_PROVIDER_AUTH_TYPES = [
"bearer",
"x-api-key",
"x-goog-api-key",
"hec",
"cf-aig-authorization",
"none",
"passthrough"
] as const;
export type AiProviderAuthType = (typeof AI_PROVIDER_AUTH_TYPES)[number];
export type AiBudgetUnit = "usd" | "tokens";
export type AiProviderRoutingMode = "url" | "target";
type AiProviderDefaults = {
upstreamUrl: string | null;
authType: AiProviderAuthType;
capabilities: readonly AiCapability[];
};
export const AI_PROVIDER_DEFAULTS: Record<
Exclude<AiProviderType, "custom">,
AiProviderDefaults
> = {
openai: {
upstreamUrl: "https://api.openai.com/v1",
authType: "bearer",
capabilities: ["openai_chat", "openai_responses"]
},
anthropic: {
upstreamUrl: "https://api.anthropic.com",
authType: "x-api-key",
capabilities: ["anthropic_messages"]
},
googleGemini: {
upstreamUrl: "https://generativelanguage.googleapis.com",
authType: "x-goog-api-key",
capabilities: ["gemini_generate_content"]
},
vertexAi: {
upstreamUrl: null,
authType: "bearer",
capabilities: ["google_generate_content", "google_raw_predict"]
},
bedrock: {
upstreamUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
authType: "bearer",
capabilities: ["bedrock_converse"]
},
microsoftFoundry: {
upstreamUrl: null,
authType: "bearer",
capabilities: ["openai_chat", "openai_responses", "anthropic_messages"]
},
openRouter: {
upstreamUrl: "https://openrouter.ai/api/v1",
authType: "bearer",
capabilities: ["openai_chat"]
},
vercelAiGateway: {
upstreamUrl: "https://ai-gateway.vercel.sh/v1",
authType: "bearer",
capabilities: ["openai_chat", "openai_responses"]
}
export {
AI_PROVIDER_AUTH_TYPES,
AI_PROVIDER_DEFAULTS,
authTypeRequiresApiKey,
defaultsForProviderType,
providerRequiresUpstreamUrl,
type AiBudgetUnit,
type AiProviderAuthType,
type AiProviderRoutingMode,
type AiProviderType
};
const CONFLICTING_AUTH_HEADERS = [
@@ -88,23 +35,6 @@ const CONFLICTING_AUTH_HEADERS = [
"cf-aig-authorization"
] as const;
export function authTypeRequiresApiKey(authType: AiProviderAuthType): boolean {
return authType !== "none" && authType !== "passthrough";
}
export function providerRequiresUpstreamUrl(
type: AiProviderType,
routingMode: AiProviderRoutingMode = "url"
): boolean {
if (routingMode === "target") {
return false;
}
if (type === "custom") {
return true;
}
return AI_PROVIDER_DEFAULTS[type].upstreamUrl === null;
}
export function resolveAiProviderCreateFields(input: {
type: AiProviderType;
upstreamUrl?: string | null;
@@ -191,15 +121,18 @@ export function applyAiProviderCustomHeaders(
/**
* Apply provider auth to upstream headers.
* - Injected modes: strip client auth headers, then set the provider key.
* - none: strip client auth headers, send no auth.
* - passthrough: leave client auth headers as-is.
* - Always strips Pangolin virtual API key credentials from client auth headers.
* - Injected modes: strip conflicting client auth headers, then set the provider key.
* - none: strip conflicting client auth headers, send no auth.
* - passthrough: leave remaining client auth headers as-is (after VAK strip).
*/
export function applyAiProviderAuthHeaders(
headers: Record<string, string>,
authType: AiProviderAuthType,
apiKey: string | null
): void {
stripVirtualApiKeyAuthHeaders(headers);
if (authType === "passthrough") {
return;
}
@@ -251,12 +184,3 @@ export function resolveCapabilitiesForCreate(input: {
}
return [...AI_PROVIDER_DEFAULTS[input.type].capabilities];
}
export function defaultsForProviderType(
type: AiProviderType
): readonly AiCapability[] {
if (type === "custom") {
return [];
}
return AI_PROVIDER_DEFAULTS[type].capabilities;
}
+8 -2
View File
@@ -24,6 +24,10 @@ import { resources, sites, Target, targets } from "@server/db";
import createPathRewriteMiddleware from "./middleware";
import { sanitize, encodePath, validatePathRewriteConfig } from "./utils";
import regionalCache from "@server/lib/cache";
import {
AI_GATEWAY_TRUST_HEADER,
getAiGatewayTrustToken
} from "@server/lib/aiGatewayTrust";
const redirectHttpsMiddlewareName = "redirect-to-https";
const badgerMiddlewareName = "badger";
@@ -808,7 +812,8 @@ export async function getTraefikConfig(
headers: {
customRequestHeaders: {
...(aiGatewayHost ? { Host: aiGatewayHost } : {}),
"p-host": fullDomain
"p-host": fullDomain,
[AI_GATEWAY_TRUST_HEADER]: getAiGatewayTrustToken()
}
}
};
@@ -911,7 +916,8 @@ export async function getTraefikConfig(
headers: {
customRequestHeaders: {
...(aiGatewayHost ? { Host: aiGatewayHost } : {}),
"p-host": fullDomain
"p-host": fullDomain,
[AI_GATEWAY_TRUST_HEADER]: getAiGatewayTrustToken()
}
}
};
+8
View File
@@ -14,6 +14,14 @@ import config from "@server/lib/config";
import { decrypt, encrypt } from "@server/lib/crypto";
import { and, eq, inArray } from "drizzle-orm";
export {
VIRTUAL_API_KEY_PREFIX,
formatVirtualApiKeyCredential,
formatVirtualApiKeyPreview,
looksLikeVirtualApiKeyCredential,
stripVirtualApiKeyAuthHeaders
} from "@app/lib/virtualApiKeyFormat";
export type MintedVirtualApiKeySecret = {
virtualApiKeyId: string;
secret: string;