diff --git a/server/auth/verifyVirtualApiKey.ts b/server/auth/verifyVirtualApiKey.ts new file mode 100644 index 000000000..354a27e74 --- /dev/null +++ b/server/auth/verifyVirtualApiKey.ts @@ -0,0 +1,316 @@ +import { canUserAccessResource } from "@server/auth/canUserAccessResource"; +import { + db, + users, + virtualApiKeyResources, + virtualApiKeys, + type VirtualApiKey +} from "@server/db"; +import config from "@server/lib/config"; +import { + decryptVirtualApiKeyToken, + VIRTUAL_API_KEY_PREFIX, + looksLikeVirtualApiKeyCredential +} from "@server/lib/virtualApiKey"; +import { getUserOrgRoles } from "@server/lib/userOrgRoles"; +import { and, eq } from "drizzle-orm"; +import { isWithinExpirationDate } from "oslo"; + +export type VirtualApiKeyCredential = { + virtualApiKeyId: string; + secret: string; +}; + +export type VirtualApiKeyUserData = { + userId: string; + username: string; + email: string | null; + name: string | null; + role: string | null; +}; + +function getHeader( + headers: Record | undefined, + name: string +): string | undefined { + if (!headers) { + return undefined; + } + if (headers[name] !== undefined) { + return headers[name]; + } + const lower = name.toLowerCase(); + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === lower) { + return value; + } + } + return undefined; +} + +function parseVkCredential( + raw: string | undefined +): VirtualApiKeyCredential | null { + if (!raw || !looksLikeVirtualApiKeyCredential(raw)) { + return null; + } + const withoutPrefix = raw.trim().slice(VIRTUAL_API_KEY_PREFIX.length); + const dot = withoutPrefix.indexOf("."); + return { + virtualApiKeyId: withoutPrefix.slice(0, dot), + secret: withoutPrefix.slice(dot + 1) + }; +} + +/** + * Extract a virtual API key credential from provider-style auth headers. + * Checks Authorization Bearer / Splunk, x-api-key, x-goog-api-key, and + * cf-aig-authorization. First matching vk-{id}.{secret} wins. + */ +export function extractVirtualApiKeyCredential( + headers: Record | undefined +): VirtualApiKeyCredential | null { + if (!headers) { + return null; + } + + const authorization = getHeader(headers, "authorization"); + if (authorization) { + const bearerMatch = authorization.match(/^Bearer\s+(.+)$/i); + if (bearerMatch) { + const credential = parseVkCredential(bearerMatch[1]); + if (credential) { + return credential; + } + } + const splunkMatch = authorization.match(/^Splunk\s+(.+)$/i); + if (splunkMatch) { + const credential = parseVkCredential(splunkMatch[1]); + if (credential) { + return credential; + } + } + } + + const cfAig = getHeader(headers, "cf-aig-authorization"); + if (cfAig) { + const bearerMatch = cfAig.match(/^Bearer\s+(.+)$/i); + const credential = parseVkCredential( + bearerMatch ? bearerMatch[1] : cfAig + ); + if (credential) { + return credential; + } + } + + for (const name of ["x-api-key", "x-goog-api-key"] as const) { + const credential = parseVkCredential(getHeader(headers, name)); + if (credential) { + return credential; + } + } + + return null; +} + +async function buildUserData( + userId: string, + orgId: string +): Promise { + const [user] = await db + .select() + .from(users) + .where(eq(users.userId, userId)) + .limit(1); + + if (!user) { + return undefined; + } + + if ( + config.getRawConfig().flags?.require_email_verification && + !user.emailVerified + ) { + return undefined; + } + + const userOrgRoles = await getUserOrgRoles(user.userId, orgId); + if (userOrgRoles.length === 0) { + return undefined; + } + + return { + userId: user.userId, + username: user.username, + email: user.email, + name: user.name, + role: userOrgRoles.map((r) => r.roleName).join(", ") || null + }; +} + +async function userHasResourceAccess( + userId: string, + resourceId: number, + orgId: string +): Promise<{ allowed: boolean; userData?: VirtualApiKeyUserData }> { + const [user] = await db + .select() + .from(users) + .where(eq(users.userId, userId)) + .limit(1); + + if (!user) { + return { allowed: false }; + } + + if ( + config.getRawConfig().flags?.require_email_verification && + !user.emailVerified + ) { + return { allowed: false }; + } + + const userOrgRoles = await getUserOrgRoles(user.userId, orgId); + if (userOrgRoles.length === 0) { + return { allowed: false }; + } + + const allowed = await canUserAccessResource({ + userId, + resourceId, + roleIds: userOrgRoles.map((r) => r.roleId) + }); + + if (!allowed) { + return { allowed: false }; + } + + return { + allowed: true, + userData: { + userId: user.userId, + username: user.username, + email: user.email, + name: user.name, + role: userOrgRoles.map((r) => r.roleName).join(", ") || null + } + }; +} + +async function manualKeyHasResourceAccess( + key: VirtualApiKey, + resourceId: number +): Promise { + if (key.allResources) { + return true; + } + + const [row] = await db + .select({ resourceId: virtualApiKeyResources.resourceId }) + .from(virtualApiKeyResources) + .where( + and( + eq(virtualApiKeyResources.virtualApiKeyId, key.virtualApiKeyId), + eq(virtualApiKeyResources.resourceId, resourceId) + ) + ) + .limit(1); + + return Boolean(row); +} + +async function touchLastUsedAt(virtualApiKeyId: string): Promise { + try { + await db + .update(virtualApiKeys) + .set({ lastUsedAt: Date.now() }) + .where(eq(virtualApiKeys.virtualApiKeyId, virtualApiKeyId)); + } catch { + // Best-effort; do not fail auth on audit timestamp updates. + } +} + +export async function verifyVirtualApiKey({ + credential, + resourceId, + orgId +}: { + credential: VirtualApiKeyCredential; + resourceId: number; + orgId: string; +}): Promise<{ + valid: boolean; + error?: string; + key?: VirtualApiKey; + userData?: VirtualApiKeyUserData; +}> { + const [key] = await db + .select() + .from(virtualApiKeys) + .where(eq(virtualApiKeys.virtualApiKeyId, credential.virtualApiKeyId)) + .limit(1); + + if (!key) { + return { valid: false, error: "Virtual API key not found" }; + } + + if (key.orgId !== orgId) { + return { valid: false, error: "Virtual API key org mismatch" }; + } + + let plaintext: string; + try { + plaintext = decryptVirtualApiKeyToken(key.token); + } catch { + return { valid: false, error: "Virtual API key secret is invalid" }; + } + + if (plaintext !== credential.secret) { + return { valid: false, error: "Invalid virtual API key secret" }; + } + + if (key.expiresAt && !isWithinExpirationDate(new Date(key.expiresAt))) { + return { valid: false, error: "Virtual API key has expired" }; + } + + if (key.kind === "manual") { + const scoped = await manualKeyHasResourceAccess(key, resourceId); + if (!scoped) { + return { + valid: false, + error: "Virtual API key is not scoped to this resource" + }; + } + + let userData: VirtualApiKeyUserData | undefined; + if (key.userId) { + userData = await buildUserData(key.userId, orgId); + } + + await touchLastUsedAt(key.virtualApiKeyId); + return { valid: true, key, userData }; + } + + if (key.kind === "user") { + if (!key.userId) { + return { valid: false, error: "User virtual API key has no user" }; + } + + const access = await userHasResourceAccess( + key.userId, + resourceId, + orgId + ); + if (!access.allowed || !access.userData) { + return { + valid: false, + error: "User is not allowed to access this resource" + }; + } + + await touchLastUsedAt(key.virtualApiKeyId); + return { valid: true, key, userData: access.userData }; + } + + return { valid: false, error: "Unknown virtual API key kind" }; +} diff --git a/server/lib/aiCapabilities.ts b/server/lib/aiCapabilities.ts index 89fbd3d75..268acd36e 100644 --- a/server/lib/aiCapabilities.ts +++ b/server/lib/aiCapabilities.ts @@ -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"; diff --git a/server/lib/aiGatewayTrust.ts b/server/lib/aiGatewayTrust.ts new file mode 100644 index 000000000..2eb3c104d --- /dev/null +++ b/server/lib/aiGatewayTrust.ts @@ -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 | 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; +} diff --git a/server/lib/aiProviderDefaults.ts b/server/lib/aiProviderDefaults.ts index a8b3a0ca0..217709f41 100644 --- a/server/lib/aiProviderDefaults.ts +++ b/server/lib/aiProviderDefaults.ts @@ -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, - 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, 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; -} diff --git a/server/lib/traefik/getTraefikConfig.ts b/server/lib/traefik/getTraefikConfig.ts index a6535c766..559f1f7ad 100644 --- a/server/lib/traefik/getTraefikConfig.ts +++ b/server/lib/traefik/getTraefikConfig.ts @@ -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() } } }; diff --git a/server/lib/virtualApiKey.ts b/server/lib/virtualApiKey.ts index 66ebb4b5a..e4f7480db 100644 --- a/server/lib/virtualApiKey.ts +++ b/server/lib/virtualApiKey.ts @@ -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; diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index f51dad24e..dc7f0fef0 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -59,6 +59,10 @@ import { } from "#private/lib/certificates"; import { build } from "@server/build"; import regionalCache from "#private/lib/cache"; +import { + AI_GATEWAY_TRUST_HEADER, + getAiGatewayTrustToken +} from "@server/lib/aiGatewayTrust"; const redirectHttpsMiddlewareName = "redirect-to-https"; const redirectToRootMiddlewareName = "redirect-to-root"; @@ -1634,18 +1638,19 @@ export async function getTraefikConfig( config.getRawConfig().traefik.additional_middlewares || []; const routerMiddlewares = [badgerMiddlewareName]; - if (aiGatewayOverride) { - const irHeadersMiddlewareName = `${irKey}-headers-middleware`; - config_output.http.middlewares[irHeadersMiddlewareName] = { - headers: { - customRequestHeaders: { - ...(aiGatewayHost ? { Host: aiGatewayHost } : {}), - "p-host": fullDomain - } + const irHeadersMiddlewareName = `${irKey}-headers-middleware`; + config_output.http.middlewares[irHeadersMiddlewareName] = { + headers: { + customRequestHeaders: { + ...(aiGatewayOverride && aiGatewayHost + ? { Host: aiGatewayHost } + : {}), + ...(aiGatewayOverride ? { "p-host": fullDomain } : {}), + [AI_GATEWAY_TRUST_HEADER]: getAiGatewayTrustToken() } - }; - routerMiddlewares.push(irHeadersMiddlewareName); - } + } + }; + routerMiddlewares.push(irHeadersMiddlewareName); routerMiddlewares.push(...additionalMiddlewares); @@ -1733,18 +1738,19 @@ export async function getTraefikConfig( config.getRawConfig().traefik.additional_middlewares || []; const routerMiddlewares: string[] = []; - if (aiGatewayOverride) { - const srHeadersMiddlewareName = `${srKey}-headers-middleware`; - config_output.http.middlewares[srHeadersMiddlewareName] = { - headers: { - customRequestHeaders: { - ...(aiGatewayHost ? { Host: aiGatewayHost } : {}), - "p-host": fullDomain - } + const srHeadersMiddlewareName = `${srKey}-headers-middleware`; + config_output.http.middlewares[srHeadersMiddlewareName] = { + headers: { + customRequestHeaders: { + ...(aiGatewayOverride && aiGatewayHost + ? { Host: aiGatewayHost } + : {}), + ...(aiGatewayOverride ? { "p-host": fullDomain } : {}), + [AI_GATEWAY_TRUST_HEADER]: getAiGatewayTrustToken() } - }; - routerMiddlewares.push(srHeadersMiddlewareName); - } + } + }; + routerMiddlewares.push(srHeadersMiddlewareName); routerMiddlewares.push(...additionalMiddlewares); diff --git a/server/routers/aiGateway/pipeline.ts b/server/routers/aiGateway/pipeline.ts index 7648e2355..5aa69d7fc 100644 --- a/server/routers/aiGateway/pipeline.ts +++ b/server/routers/aiGateway/pipeline.ts @@ -39,6 +39,10 @@ import { import { getUserOrgRoles } from "@server/lib/userOrgRoles"; import { isIpInCidr } from "@server/lib/ip"; import { localCache } from "@server/lib/cache"; +import { + AI_GATEWAY_TRUST_HEADER, + isAiGatewayTrustHeaderValid +} from "@server/lib/aiGatewayTrust"; import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; import { @@ -217,9 +221,42 @@ async function buildRequestUser( async function resolveRequestUser( req: Request, - _resourceId: number | null, + resourceId: number | null, orgId: string | null ): Promise { + // Public inference: identity comes from Badger via Remote-* only when the + // Traefik trust header proves the request passed verify-session (VAK). + if (isAiGatewayTrustHeaderValid(req.headers as Record)) { + const userId = getRequestHeader(req, "remote-user-id"); + if (userId) { + const username = getRequestHeader(req, "remote-user") || userId; + const email = getRequestHeader(req, "remote-email"); + const name = getRequestHeader(req, "remote-name"); + const role = getRequestHeader(req, "remote-role"); + const orgRoles = orgId ? await getUserOrgRoles(userId, orgId) : []; + + return { + userId, + username, + email: email || null, + name: name || null, + role: + role || orgRoles.map((r) => r.roleName).join(", ") || null, + roleIds: orgRoles.map((r) => r.roleId) + }; + } + + // Trusted request with no associated user (manual key without userId). + if (resourceId != null) { + return null; + } + } + + // Public inference must come through Badger; do not authorize via app session. + if (resourceId != null) { + return null; + } + const sessionToken = req.cookies?.[SESSION_COOKIE_NAME]; if (sessionToken) { const { session, user } = await validateSessionToken(sessionToken); @@ -251,6 +288,14 @@ async function resolveRequestUser( return buildRequestUser(client.userId, orgId); } +function getRequestHeader(req: Request, name: string): string | undefined { + const raw = req.headers[name.toLowerCase()]; + if (Array.isArray(raw)) { + return raw[0]; + } + return raw; +} + async function resolveTarget(host: string): Promise { const [[resourceRow], [siteResourceRow]] = await Promise.all([ db @@ -696,6 +741,21 @@ export async function handleAiGatewayProxy( orgId } = target; + // Public inference must pass Badger verify-session first. Traefik + // injects the trust header only on that path; the gateway trusts it + // and does not re-verify the virtual API key. + if ( + resourceId != null && + !isAiGatewayTrustHeaderValid(req.headers as Record) + ) { + return res.status(HttpCode.UNAUTHORIZED).json({ + error: { + message: + "Request must be authenticated via the inference resource" + } + }); + } + const capableAttachments = attachments.filter((a) => providerHasCapability(a.provider.capabilities, capability) ); @@ -823,7 +883,8 @@ export async function handleAiGatewayProxy( "transfer-encoding", "upgrade", "content-length", - "accept-encoding" + "accept-encoding", + AI_GATEWAY_TRUST_HEADER.toLowerCase() ]); const headers: Record = {}; diff --git a/server/routers/aiGateway/targetRouting.ts b/server/routers/aiGateway/targetRouting.ts index 00ef39d12..9ffbf1d1b 100644 --- a/server/routers/aiGateway/targetRouting.ts +++ b/server/routers/aiGateway/targetRouting.ts @@ -33,6 +33,7 @@ import { type RequestUser } from "@server/routers/aiGateway/pipeline"; import { streamAiGatewayResponse } from "@server/routers/aiGateway/streamAiGatewayResponse"; +import { AI_GATEWAY_TRUST_HEADER } from "@server/lib/aiGatewayTrust"; // Short TTL: long enough to spare the DB on a burst of requests, short // enough that target/site changes (added, removed, exit node moved) show up @@ -62,7 +63,8 @@ const SKIP_HEADERS = new Set([ "transfer-encoding", "upgrade", "content-length", - "accept-encoding" + "accept-encoding", + AI_GATEWAY_TRUST_HEADER.toLowerCase() ]); type ResolvedProviderTarget = { diff --git a/server/routers/badger/logRequestAudit.ts b/server/routers/badger/logRequestAudit.ts index 884fb7ae4..3fb97dce5 100644 --- a/server/routers/badger/logRequestAudit.ts +++ b/server/routers/badger/logRequestAudit.ts @@ -19,6 +19,7 @@ Reasons: 106 - Valid email 107 - Valid SSO 108 - Connected Client +109 - Valid Virtual API Key 201 - Resource Not Found 202 - Resource Blocked @@ -90,7 +91,9 @@ async function flushAuditLogs() { auditLogBuffer.unshift(...logsToWrite); logger.info(`Re-queued ${logsToWrite.length} audit logs for retry`); } else { - logger.error(`Buffer full, dropped ${logsToWrite.length} audit logs`); + logger.error( + `Buffer full, dropped ${logsToWrite.length} audit logs` + ); } } finally { isFlushInProgress = false; diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index b0aed6e30..ee69ce163 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -5,6 +5,10 @@ import { } from "@server/auth/sessions/resource"; import { generateSessionToken } from "@server/auth/sessions/app"; import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken"; +import { + extractVirtualApiKeyCredential, + verifyVirtualApiKey +} from "@server/auth/verifyVirtualApiKey"; import { getResourceByDomain, getResourceRules, @@ -127,7 +131,8 @@ export async function verifyResourceSession( // Extract HTTP Basic Auth credentials if present const clientHeaderAuth = extractBasicAuth(headers); - const clientUserAgent = headers?.["user-agent"] || headers?.["User-Agent"]; + const clientUserAgent = + headers?.["user-agent"] || headers?.["User-Agent"]; const clientIsBrowser = isBrowserUserAgent(clientUserAgent); const clientIp = requestIp @@ -254,20 +259,28 @@ export async function verifyResourceSession( ); if (action == "ACCEPT") { - logger.debug("Resource allowed by rule"); + // Public inference still requires a virtual API key; do not + // bypass that with an allow rule. + if (mode === "inference") { + logger.debug( + "Rule ACCEPT ignored for inference; continuing to virtual API key check" + ); + } else { + logger.debug("Resource allowed by rule"); - logRequestAudit( - { - action: true, - reason: 100, // allowed by rule - resourceId: resource.resourceId, - orgId: resource.orgId, - location: ipCC - }, - parsedBody.data - ); + logRequestAudit( + { + action: true, + reason: 100, // allowed by rule + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC + }, + parsedBody.data + ); - return allowed(res, undefined, dontStripSession); + return allowed(res, undefined, dontStripSession); + } } else if (action == "DROP") { logger.debug("Resource denied by rule"); @@ -302,20 +315,23 @@ export async function verifyResourceSession( !emailWhitelistEnabled && !headerAuth ) { - logger.debug("Resource allowed because no auth"); + // Public inference always requires a virtual API key. + if (mode !== "inference") { + logger.debug("Resource allowed because no auth"); - logRequestAudit( - { - action: true, - reason: 101, // allowed no auth - resourceId: resource.resourceId, - orgId: resource.orgId, - location: ipCC - }, - parsedBody.data - ); + logRequestAudit( + { + action: true, + reason: 101, // allowed no auth + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC + }, + parsedBody.data + ); - return allowed(res, undefined, dontStripSession); + return allowed(res, undefined, dontStripSession); + } } // Only offer a browser redirect to clients that can actually follow one and log in @@ -327,6 +343,82 @@ export async function verifyResourceSession( )}?redirect=${encodeURIComponent(originalRequestURL)}` : undefined; + // Virtual API keys for public inference resources (provider-style auth headers). + // Session/SSO may authenticate users elsewhere (e.g. dashboard key pages), but + // only a valid virtual API key is allowed through to the AI gateway. + if (mode === "inference") { + const vakCredential = extractVirtualApiKeyCredential(headers); + if (vakCredential) { + const { + valid, + error, + key, + userData: vakUserData + } = await verifyVirtualApiKey({ + credential: vakCredential, + resourceId: resource.resourceId, + orgId: resource.orgId + }); + + if (error) { + logger.debug("Virtual API key invalid: " + error); + } + + if (!valid) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Virtual API key is invalid. Resource ID: ${resource.resourceId}. IP: ${clientIp}.` + ); + } + } + + if (valid && key) { + logRequestAudit( + { + action: true, + reason: 109, // valid virtual API key + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC, + ...(vakUserData + ? { + user: { + username: vakUserData.username, + userId: vakUserData.userId + } + } + : { + apiKey: { + name: key.name, + apiKeyId: key.virtualApiKeyId + } + }), + metadata: { + virtualApiKeyId: key.virtualApiKeyId, + virtualApiKeyKind: key.kind + } + }, + parsedBody.data + ); + + return allowed(res, vakUserData, dontStripSession); + } + } + + logRequestAudit( + { + action: false, + reason: 299, // no more auth methods / VAK required + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC + }, + parsedBody.data + ); + + return notAllowed(res, redirectPath); + } + // check for access token in headers if ( headers && diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/authentication/page.tsx b/src/app/[orgId]/settings/ai-providers/[providerId]/authentication/page.tsx index f9e44cb09..74f5552d0 100644 --- a/src/app/[orgId]/settings/ai-providers/[providerId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/[providerId]/authentication/page.tsx @@ -38,7 +38,7 @@ import { authTypeRequiresApiKey, type AiProviderAuthType, type AiProviderType -} from "@server/lib/aiProviderDefaults"; +} from "@app/lib/aiProviderDefaults"; import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types"; import type { AxiosResponse } from "axios"; import { useTranslations } from "next-intl"; diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/general/page.tsx b/src/app/[orgId]/settings/ai-providers/[providerId]/general/page.tsx index 6dabdbaad..9e41ff9c3 100644 --- a/src/app/[orgId]/settings/ai-providers/[providerId]/general/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/[providerId]/general/page.tsx @@ -30,7 +30,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { zodResolver } from "@hookform/resolvers/zod"; -import { AI_CAPABILITIES, type AiCapability } from "@server/lib/aiCapabilities"; +import { AI_CAPABILITIES, type AiCapability } from "@app/lib/aiCapabilities"; import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types"; import type { AxiosResponse } from "axios"; import { useTranslations } from "next-intl"; diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/network/page.tsx b/src/app/[orgId]/settings/ai-providers/[providerId]/network/page.tsx index 1aa25e9e5..e685bc560 100644 --- a/src/app/[orgId]/settings/ai-providers/[providerId]/network/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/[providerId]/network/page.tsx @@ -49,7 +49,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import type { AiProviderAuthType, AiProviderType -} from "@server/lib/aiProviderDefaults"; +} from "@app/lib/aiProviderDefaults"; import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types"; import { useQuery } from "@tanstack/react-query"; import type { AxiosResponse } from "axios"; diff --git a/src/app/[orgId]/settings/ai-providers/create/page.tsx b/src/app/[orgId]/settings/ai-providers/create/page.tsx index 999782bc3..f5e407cc1 100644 --- a/src/app/[orgId]/settings/ai-providers/create/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/create/page.tsx @@ -50,7 +50,7 @@ import { type AiProviderFormValues } from "@app/lib/aiProviderFormSchema"; import { zodResolver } from "@hookform/resolvers/zod"; -import { authTypeRequiresApiKey } from "@server/lib/aiProviderDefaults"; +import { authTypeRequiresApiKey } from "@app/lib/aiProviderDefaults"; import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types"; import type { AxiosResponse } from "axios"; import { useTranslations } from "next-intl"; diff --git a/src/components/AiProviderAuthTypeSelect.tsx b/src/components/AiProviderAuthTypeSelect.tsx index a040c6fa1..460ec6a47 100644 --- a/src/components/AiProviderAuthTypeSelect.tsx +++ b/src/components/AiProviderAuthTypeSelect.tsx @@ -18,7 +18,7 @@ import { cn } from "@app/lib/cn"; import { AI_PROVIDER_AUTH_TYPES, type AiProviderAuthType -} from "@server/lib/aiProviderDefaults"; +} from "@app/lib/aiProviderDefaults"; import { CheckIcon, ChevronsUpDown } from "lucide-react"; import { useTranslations } from "next-intl"; import { useMemo, useState } from "react"; diff --git a/src/components/AiProviderCapabilitiesSelect.tsx b/src/components/AiProviderCapabilitiesSelect.tsx index 086b6f03e..ecb636472 100644 --- a/src/components/AiProviderCapabilitiesSelect.tsx +++ b/src/components/AiProviderCapabilitiesSelect.tsx @@ -1,7 +1,7 @@ "use client"; import { MultiSelectTagInput } from "@app/components/multi-select/multi-select-tag-input"; -import { AI_CAPABILITIES, type AiCapability } from "@server/lib/aiCapabilities"; +import { AI_CAPABILITIES, type AiCapability } from "@app/lib/aiCapabilities"; import { useTranslations } from "next-intl"; import { useMemo, useState } from "react"; diff --git a/src/components/AiProviderTypeSelect.tsx b/src/components/AiProviderTypeSelect.tsx index ed0565f53..b817d4169 100644 --- a/src/components/AiProviderTypeSelect.tsx +++ b/src/components/AiProviderTypeSelect.tsx @@ -16,7 +16,7 @@ import { } from "@app/components/ui/popover"; import { cn } from "@app/lib/cn"; import { aiProviderTypeValues } from "@app/lib/aiProviderFormSchema"; -import type { AiProviderType } from "@server/lib/aiProviderDefaults"; +import type { AiProviderType } from "@app/lib/aiProviderDefaults"; import { CheckIcon, ChevronsUpDown } from "lucide-react"; import { useTranslations } from "next-intl"; import { useMemo, useState } from "react"; diff --git a/src/components/CreateVirtualApiKeyForm.tsx b/src/components/CreateVirtualApiKeyForm.tsx index 5a3e88ce8..e67488b5d 100644 --- a/src/components/CreateVirtualApiKeyForm.tsx +++ b/src/components/CreateVirtualApiKeyForm.tsx @@ -42,6 +42,7 @@ import { Checkbox } from "@app/components/ui/checkbox"; import { useTranslations } from "next-intl"; import { UserSelector, type SelectedUser } from "@app/components/user-selector"; import type { CreateOrEditVirtualApiKeyResponse } from "@server/routers/virtualApiKey/types"; +import { formatVirtualApiKeyCredential } from "@app/lib/virtualApiKeyFormat"; import { MultiResourcesSelector, formatMultiResourcesSelectorLabel @@ -146,7 +147,12 @@ export default function CreateVirtualApiKeyForm({ if (res?.data.data.virtualApiKey) { const key = res.data.data.virtualApiKey; if (key.secret) { - setCredential(`vk-${key.virtualApiKeyId}.${key.secret}`); + setCredential( + formatVirtualApiKeyCredential( + key.virtualApiKeyId, + key.secret + ) + ); } const resourceLookup = new Map( diff --git a/src/components/EditVirtualApiKeyForm.tsx b/src/components/EditVirtualApiKeyForm.tsx index 533de947c..0ef5e936b 100644 --- a/src/components/EditVirtualApiKeyForm.tsx +++ b/src/components/EditVirtualApiKeyForm.tsx @@ -40,6 +40,7 @@ import { Checkbox } from "@app/components/ui/checkbox"; import { useTranslations } from "next-intl"; import { UserSelector, type SelectedUser } from "@app/components/user-selector"; import type { CreateOrEditVirtualApiKeyResponse } from "@server/routers/virtualApiKey/types"; +import { formatVirtualApiKeyCredential } from "@app/lib/virtualApiKeyFormat"; import { MultiResourcesSelector, formatMultiResourcesSelectorLabel @@ -151,7 +152,10 @@ export default function EditVirtualApiKeyForm({ const secret = res.data.data.virtualApiKey.secret; if (secret) { setCredential( - `vk-${virtualApiKey.virtualApiKeyId}.${secret}` + formatVirtualApiKeyCredential( + virtualApiKey.virtualApiKeyId, + secret + ) ); } else { toast({ diff --git a/src/components/UserVirtualApiKeys.tsx b/src/components/UserVirtualApiKeys.tsx index 5513b59e4..d90241d0b 100644 --- a/src/components/UserVirtualApiKeys.tsx +++ b/src/components/UserVirtualApiKeys.tsx @@ -27,6 +27,10 @@ import type { ListMyVirtualApiKeysResponse, VirtualApiKeyWithResources } from "@server/routers/virtualApiKey/types"; +import { + formatVirtualApiKeyCredential, + formatVirtualApiKeyPreview +} from "@app/lib/virtualApiKeyFormat"; type UserVirtualApiKeysProps = { orgId: string; @@ -34,10 +38,6 @@ type UserVirtualApiKeysProps = { initialData: ListMyVirtualApiKeysResponse; }; -function keyPreview(virtualApiKeyId: string, lastChars: string): string { - return `vk-${virtualApiKeyId}••••${lastChars}`; -} - function useRevealSecret(orgId: string, virtualApiKeyId: string) { const t = useTranslations(); const api = createApiClient(useEnvContext()); @@ -56,7 +56,9 @@ function useRevealSecret(orgId: string, virtualApiKeyId: string) { .then((res) => { const secret = res.data.data.virtualApiKey.secret; if (secret) { - setCredential(`vk-${virtualApiKeyId}.${secret}`); + setCredential( + formatVirtualApiKeyCredential(virtualApiKeyId, secret) + ); } else { toast({ variant: "destructive", @@ -95,7 +97,7 @@ function OwnedKeySecret({ lastChars: string; }) { const t = useTranslations(); - const preview = keyPreview(virtualApiKeyId, lastChars); + const preview = formatVirtualApiKeyPreview(virtualApiKeyId, lastChars); const { credential, loading, revealSecret } = useRevealSecret( orgId, virtualApiKeyId @@ -137,7 +139,7 @@ function IdentityKeyCenterpiece({ resourceGuid?: string; }) { const t = useTranslations(); - const preview = keyPreview(virtualApiKeyId, lastChars); + const preview = formatVirtualApiKeyPreview(virtualApiKeyId, lastChars); const { credential, loading, revealSecret } = useRevealSecret( orgId, virtualApiKeyId diff --git a/src/components/ViewVirtualApiKeySecret.tsx b/src/components/ViewVirtualApiKeySecret.tsx index 800fb3484..3e155053b 100644 --- a/src/components/ViewVirtualApiKeySecret.tsx +++ b/src/components/ViewVirtualApiKeySecret.tsx @@ -19,6 +19,7 @@ import { createApiClient, formatAxiosError } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import type { GetVirtualApiKeyResponse } from "@server/routers/virtualApiKey/types"; +import { formatVirtualApiKeyCredential } from "@app/lib/virtualApiKeyFormat"; type ViewVirtualApiKeySecretProps = { open: boolean; @@ -56,7 +57,12 @@ export default function ViewVirtualApiKeySecret({ } const key = res.data.data.virtualApiKey; if (key.secret) { - setCredential(`vk-${key.virtualApiKeyId}.${key.secret}`); + setCredential( + formatVirtualApiKeyCredential( + key.virtualApiKeyId, + key.secret + ) + ); } else { toast({ variant: "destructive", diff --git a/src/components/VirtualApiKeysTable.tsx b/src/components/VirtualApiKeysTable.tsx index 074d82f74..1f5f8e4e8 100644 --- a/src/components/VirtualApiKeysTable.tsx +++ b/src/components/VirtualApiKeysTable.tsx @@ -46,6 +46,10 @@ import { import { cn } from "@app/lib/cn"; import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover"; import type { GetVirtualApiKeyResponse } from "@server/routers/virtualApiKey/types"; +import { + formatVirtualApiKeyCredential, + formatVirtualApiKeyPreview +} from "@app/lib/virtualApiKeyFormat"; import { AxiosResponse } from "axios"; export type VirtualApiKeyRow = CreatedVirtualApiKey; @@ -507,7 +511,7 @@ function VirtualApiKeySecretCell({ }) { const t = useTranslations(); const api = createApiClient(useEnvContext()); - const preview = `vk-${virtualApiKeyId}••••${lastChars}`; + const preview = formatVirtualApiKeyPreview(virtualApiKeyId, lastChars); const [credential, setCredential] = useState(null); useEffect(() => { @@ -522,7 +526,9 @@ function VirtualApiKeySecretCell({ } const secret = res.data.data.virtualApiKey.secret; if (secret) { - setCredential(`vk-${virtualApiKeyId}.${secret}`); + setCredential( + formatVirtualApiKeyCredential(virtualApiKeyId, secret) + ); } }) .catch((e) => { diff --git a/src/components/resource-launcher/LauncherInferenceApiKeysSection.tsx b/src/components/resource-launcher/LauncherInferenceApiKeysSection.tsx index 8a71a07db..2434ed575 100644 --- a/src/components/resource-launcher/LauncherInferenceApiKeysSection.tsx +++ b/src/components/resource-launcher/LauncherInferenceApiKeysSection.tsx @@ -20,6 +20,10 @@ import type { GetMyVirtualApiKeyResponse, VirtualApiKeyWithResources } from "@server/routers/virtualApiKey/types"; +import { + formatVirtualApiKeyCredential, + formatVirtualApiKeyPreview +} from "@app/lib/virtualApiKeyFormat"; import { useQuery } from "@tanstack/react-query"; import type { AxiosResponse } from "axios"; import { Loader2 } from "lucide-react"; @@ -31,10 +35,6 @@ type LauncherInferenceApiKeysSectionProps = { resourceGuid: string; }; -function keyPreview(virtualApiKeyId: string, lastChars: string): string { - return `vk-${virtualApiKeyId}••••${lastChars}`; -} - function useRevealSecret(orgId: string, virtualApiKeyId: string) { const t = useTranslations(); const api = createApiClient(useEnvContext()); @@ -53,7 +53,9 @@ function useRevealSecret(orgId: string, virtualApiKeyId: string) { .then((res) => { const secret = res.data.data.virtualApiKey.secret; if (secret) { - setCredential(`vk-${virtualApiKeyId}.${secret}`); + setCredential( + formatVirtualApiKeyCredential(virtualApiKeyId, secret) + ); } else { toast({ variant: "destructive", @@ -92,7 +94,7 @@ function PanelKeySecret({ lastChars: string; }) { const t = useTranslations(); - const preview = keyPreview(virtualApiKeyId, lastChars); + const preview = formatVirtualApiKeyPreview(virtualApiKeyId, lastChars); const { credential, loading, revealSecret } = useRevealSecret( orgId, virtualApiKeyId diff --git a/src/components/user-selector.tsx b/src/components/user-selector.tsx index 58008f465..74258a165 100644 --- a/src/components/user-selector.tsx +++ b/src/components/user-selector.tsx @@ -46,7 +46,7 @@ export function UserSelector({ const [debouncedValue] = useDebounce(userSearchQuery, 150); const { data: users = [] } = useQuery( - orgQueries.users({ orgId, perPage: 10, term: debouncedValue }) + orgQueries.users({ orgId, perPage: 10, query: debouncedValue }) ); const usersShown = useMemo(() => { diff --git a/src/lib/aiCapabilities.ts b/src/lib/aiCapabilities.ts new file mode 100644 index 000000000..faa1f7af6 --- /dev/null +++ b/src/lib/aiCapabilities.ts @@ -0,0 +1,12 @@ +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]; diff --git a/src/lib/aiProviderDefaults.ts b/src/lib/aiProviderDefaults.ts new file mode 100644 index 000000000..da4c43739 --- /dev/null +++ b/src/lib/aiProviderDefaults.ts @@ -0,0 +1,107 @@ +import { AI_CAPABILITIES, type AiCapability } from "@app/lib/aiCapabilities"; + +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, + 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 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 defaultsForProviderType( + type: AiProviderType +): readonly AiCapability[] { + if (type === "custom") { + return []; + } + return AI_PROVIDER_DEFAULTS[type].capabilities; +} + +export { AI_CAPABILITIES }; +export type { AiCapability }; diff --git a/src/lib/aiProviderFormSchema.ts b/src/lib/aiProviderFormSchema.ts index 2f75c92ec..e033c0185 100644 --- a/src/lib/aiProviderFormSchema.ts +++ b/src/lib/aiProviderFormSchema.ts @@ -1,14 +1,15 @@ import { z } from "zod"; import { + AI_CAPABILITIES, AI_PROVIDER_AUTH_TYPES, AI_PROVIDER_DEFAULTS, authTypeRequiresApiKey, defaultsForProviderType, providerRequiresUpstreamUrl, + type AiCapability, type AiProviderAuthType, type AiProviderType -} from "@server/lib/aiProviderDefaults"; -import { AI_CAPABILITIES, type AiCapability } from "@server/lib/aiCapabilities"; +} from "@app/lib/aiProviderDefaults"; type TranslateFn = (key: string) => string; diff --git a/src/lib/virtualApiKeyFormat.ts b/src/lib/virtualApiKeyFormat.ts new file mode 100644 index 000000000..f09686c15 --- /dev/null +++ b/src/lib/virtualApiKeyFormat.ts @@ -0,0 +1,63 @@ +export const VIRTUAL_API_KEY_PREFIX = "vk-"; + +const VIRTUAL_API_KEY_AUTH_HEADER_NAMES = [ + "authorization", + "x-api-key", + "x-goog-api-key", + "cf-aig-authorization" +] as const; + +export function formatVirtualApiKeyCredential( + virtualApiKeyId: string, + secret: string +): string { + return `${VIRTUAL_API_KEY_PREFIX}${virtualApiKeyId}.${secret}`; +} + +export function formatVirtualApiKeyPreview( + virtualApiKeyId: string, + lastChars: string +): string { + return `${VIRTUAL_API_KEY_PREFIX}${virtualApiKeyId}••••${lastChars}`; +} + +export function looksLikeVirtualApiKeyCredential(value: string): boolean { + const trimmed = value.trim(); + if (!trimmed.startsWith(VIRTUAL_API_KEY_PREFIX)) { + return false; + } + const withoutPrefix = trimmed.slice(VIRTUAL_API_KEY_PREFIX.length); + const dot = withoutPrefix.indexOf("."); + return dot > 0 && dot < withoutPrefix.length - 1; +} + +function headerValueCarriesVirtualApiKey(raw: string): boolean { + const trimmed = raw.trim(); + const bearerMatch = trimmed.match(/^(?:Bearer|Splunk)\s+(.+)$/i); + if (bearerMatch) { + return looksLikeVirtualApiKeyCredential(bearerMatch[1]); + } + return looksLikeVirtualApiKeyCredential(trimmed); +} + +/** + * Remove client headers that carry a Pangolin virtual API key so they are + * never forwarded to upstream providers (including passthrough auth). + */ +export function stripVirtualApiKeyAuthHeaders( + headers: Record +): void { + for (const key of Object.keys(headers)) { + const lower = key.toLowerCase(); + if ( + !(VIRTUAL_API_KEY_AUTH_HEADER_NAMES as readonly string[]).includes( + lower + ) + ) { + continue; + } + if (headerValueCarriesVirtualApiKey(headers[key])) { + delete headers[key]; + } + } +}