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
+316
View File
@@ -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<string, string> | 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<string, string> | 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<VirtualApiKeyUserData | undefined> {
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<boolean> {
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<void> {
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" };
}
+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;
+28 -22
View File
@@ -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);
+63 -2
View File
@@ -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<RequestUser | null> {
// 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<string, string>)) {
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<ResolvedTarget | null> {
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<string, string>)
) {
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<string, string> = {};
+3 -1
View File
@@ -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 = {
+4 -1
View File
@@ -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;
+117 -25
View File
@@ -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 &&