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
+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 &&