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
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ This is the **Portkey AI Gateway** - a fast, reliable AI gateway that routes req
- `hooks` - Pre/post request hooks
- `memoryCache` - Response caching
- `logger` - Request/response logging
- `adminAuth` - Node-only local UI session auth for `/public/*` login endpoints and `/log/stream`
- `portkey` - Core Portkey-specific middleware for routing, guardrails, etc.

**Plugin System (`plugins/`)**
Expand Down Expand Up @@ -89,4 +90,6 @@ Tests are organized by component:

The gateway uses `conf.json` for runtime configuration. Sample config available in `conf_sample.json`.

For local UI hardening, set `admin_token` in `conf.json`. The `/public` UI uses this token to establish an in-memory admin session, and `/log/stream` requires that session.

Key environment variables and configuration handled through Hono's adapter system for multi-environment deployment.
1 change: 1 addition & 0 deletions conf.example.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"admin_token": "set-a-strong-local-admin-token",
"plugins_enabled": [
"default",
"portkey",
Expand Down
161 changes: 161 additions & 0 deletions src/middlewares/adminAuth/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { Context, Next } from 'hono';
import conf from '../../../conf.json';

const SESSION_COOKIE_NAME = 'portkey_admin_session';
const SESSION_MAX_AGE_SECONDS = 60 * 60 * 12; // 12 hours
const adminSessions = new Map<string, number>();

const getConfiguredAdminToken = (): string => {
const adminToken = (conf as Record<string, unknown>)?.admin_token;
if (
!adminToken ||
typeof adminToken !== 'string' ||
adminToken.trim() === ''
) {
throw new Error(
'Admin UI auth requires conf.json.admin_token. Set admin_token or start the gateway with --headless.'
);
}
return adminToken;
};

const parseCookies = (cookieHeader?: string): Record<string, string> => {
if (!cookieHeader) return {};

return cookieHeader.split(';').reduce(
(acc, cookiePart) => {
const [rawKey, ...valueParts] = cookiePart.trim().split('=');
if (!rawKey) return acc;
acc[rawKey] = decodeURIComponent(valueParts.join('='));
return acc;
},
{} as Record<string, string>
);
};

const getSessionId = (c: Context): string | undefined => {
const cookies = parseCookies(c.req.header('cookie'));
return cookies[SESSION_COOKIE_NAME];
};

const getBearerToken = (c: Context): string | undefined => {
const authHeader = c.req.header('authorization');
if (!authHeader) return undefined;

const [scheme, token] = authHeader.trim().split(/\s+/, 2);
if (!scheme || !token) return undefined;
if (scheme.toLowerCase() !== 'bearer') return undefined;

return token;
};

const isSessionActive = (sessionId?: string): boolean => {
if (!sessionId) return false;

const expiresAt = adminSessions.get(sessionId);
if (!expiresAt) return false;

if (expiresAt < Date.now()) {
adminSessions.delete(sessionId);
return false;
}

return true;
};

const createSession = (): string => {
const sessionId = crypto.randomUUID();
const expiresAt = Date.now() + SESSION_MAX_AGE_SECONDS * 1000;
adminSessions.set(sessionId, expiresAt);
return sessionId;
};

const setSessionCookie = (c: Context, sessionId: string) => {
c.header(
'Set-Cookie',
`${SESSION_COOKIE_NAME}=${encodeURIComponent(sessionId)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${SESSION_MAX_AGE_SECONDS}`
);
};

export const adminAuthMiddleware = async (c: Context, next: Next) => {
let configuredToken: string;
try {
configuredToken = getConfiguredAdminToken();
} catch (error) {
const message =
error instanceof Error
? error.message
: 'Admin UI auth is misconfigured.';
return c.json({ status: 'failure', message }, 500);
}

const hasValidSession = isSessionActive(getSessionId(c));
const hasValidBearerToken = getBearerToken(c) === configuredToken;

if (!hasValidSession && !hasValidBearerToken) {
return c.json(
{
status: 'failure',
message:
'Admin authentication required. Use a valid admin session cookie or Authorization: Bearer <admin_token>.',
},
401
);
}

await next();
};

export const adminAuthSessionStatusHandler = (c: Context) => {
try {
getConfiguredAdminToken();
} catch (error) {
const message =
error instanceof Error
? error.message
: 'Admin UI auth is misconfigured.';
return c.json({ status: 'failure', message }, 500);
}

return c.json({ authenticated: isSessionActive(getSessionId(c)) });
};

export const adminAuthLoginHandler = async (c: Context) => {
let configuredToken: string;
try {
configuredToken = getConfiguredAdminToken();
} catch (error) {
const message =
error instanceof Error
? error.message
: 'Admin UI auth is misconfigured.';
return c.json({ status: 'failure', message }, 500);
}

let body: { admin_token?: string } = {};
try {
body = await c.req.json();
} catch {
return c.json(
{
status: 'failure',
message: 'Invalid request body. Expected JSON with admin_token.',
},
400
);
}

if (!body.admin_token || body.admin_token !== configuredToken) {
return c.json(
{
status: 'failure',
message: 'Invalid admin token.',
},
401
);
}

const sessionId = createSession();
setSessionCookie(c, sessionId);
return c.json({ authenticated: true });
};
64 changes: 63 additions & 1 deletion src/middlewares/log/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,68 @@ const removeLogClient = (clientId: any) => {
logClients.delete(clientId);
};

const sanitizeHeaders = (headers: Record<string, unknown> = {}) =>
Object.fromEntries(Object.keys(headers).map((key) => [key, '[REDACTED]']));

const ALLOWED_PROVIDER_OPTION_KEYS = new Set([
'provider',
'overrideParams',
'retry',
'cache',
'requestURL',
'rubeusURL',
]);

const sanitizeProviderOptions = (
providerOptions: Record<string, unknown> = {}
) =>
Object.fromEntries(
Object.entries(providerOptions).map(([key, value]) => [
key,
ALLOWED_PROVIDER_OPTION_KEYS.has(key) ? value : '[REDACTED]',
])
);

const sanitizeRequestOption = (requestOption: any) => {
if (!requestOption || typeof requestOption !== 'object') return requestOption;

const sanitizedOption = { ...requestOption };

if (
sanitizedOption.providerOptions &&
typeof sanitizedOption.providerOptions === 'object'
) {
sanitizedOption.providerOptions = sanitizeProviderOptions(
sanitizedOption.providerOptions as Record<string, unknown>
);
}

if (
sanitizedOption.transformedRequest &&
typeof sanitizedOption.transformedRequest === 'object'
) {
sanitizedOption.transformedRequest = {
...sanitizedOption.transformedRequest,
};
if (sanitizedOption.transformedRequest.headers) {
sanitizedOption.transformedRequest.headers = sanitizeHeaders(
sanitizedOption.transformedRequest.headers as Record<string, unknown>
);
}
}

if (
sanitizedOption.responseHeaders &&
typeof sanitizedOption.responseHeaders === 'object'
) {
sanitizedOption.responseHeaders = sanitizeHeaders(
sanitizedOption.responseHeaders as Record<string, unknown>
);
}

return sanitizedOption;
};

const broadcastLog = async (log: any) => {
const message = {
data: log,
Expand Down Expand Up @@ -79,7 +141,7 @@ async function processLog(c: Context, start: number) {
endpoint: c.req.url.split(':8787')[1],
status: c.res.status,
duration: ms,
requestOptions: requestOptionsArray,
requestOptions: requestOptionsArray.map(sanitizeRequestOption),
})
);
}
Expand Down
Loading
Loading