add auth validation for public routes

This commit is contained in:
Narendranath Gogineni
2026-05-19 16:17:31 +05:30
parent d2ea41f4e1
commit 4e43b19d4e
6 changed files with 359 additions and 6 deletions
+3
View File
@@ -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/`)**
@@ -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
View File
@@ -1,4 +1,5 @@
{
"admin_token": "set-a-strong-local-admin-token",
"plugins_enabled": [
"default",
"portkey",
+2 -1
View File
@@ -17,5 +17,6 @@
"apiKey": "..."
}
},
"cache": false
"cache": false,
"admin_token": "test-token"
}
+161
View File
@@ -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?.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 });
};
+184 -4
View File
@@ -851,6 +851,54 @@
border-bottom-color: #3b82f6;
font-weight: bold;
}
body.admin-auth-pending header,
body.admin-auth-pending .main-content {
display: none;
}
.admin-auth-modal {
position: fixed;
inset: 0;
z-index: 3000;
display: none;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.6);
padding: 1rem;
}
body.admin-auth-pending .admin-auth-modal {
display: flex;
}
.admin-auth-card {
width: 100%;
max-width: 420px;
background: white;
border-radius: 0.75rem;
padding: 1.25rem;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.25);
}
.admin-auth-title {
margin: 0 0 0.4rem;
font-size: 1.2rem;
font-weight: 700;
}
.admin-auth-subtitle {
margin: 0 0 0.9rem;
font-size: 0.875rem;
color: #6b7280;
}
.admin-auth-error {
margin-top: 0.6rem;
color: #dc2626;
font-size: 0.8rem;
min-height: 1.1rem;
}
</style>
<!-- Google Tag Manager -->
@@ -867,11 +915,29 @@
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/confetti.browser.min.js"></script>
</head>
<body>
<body class="admin-auth-pending">
<!-- Google Tag Manager (noscript) -->
<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-KX3PMNVR"
height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
<!-- End Google Tag Manager (noscript) -->
<div class="admin-auth-modal" id="adminAuthModal">
<div class="admin-auth-card">
<h2 class="admin-auth-title">Admin token required</h2>
<p class="admin-auth-subtitle">Enter your <code>admin_token</code> stored in <code>conf.json</code> to access the local gateway UI. see <a href="https://github.com/Portkey-AI/gateway/discussions/1656">github discussion</a> for more details.</p>
<form id="adminAuthForm">
<input
id="adminAuthTokenInput"
class="input"
type="password"
placeholder="Enter admin token"
autocomplete="off"
required
/>
<button type="submit" class="btn">Unlock UI</button>
</form>
<div class="admin-auth-error" id="adminAuthError"></div>
</div>
</div>
<header>
<div class="container">
<div class="logo">
@@ -1624,6 +1690,88 @@
const saveApiDetailsBtn = document.getElementById('saveApiDetailsBtn');
const languageSelect = document.getElementById('languageSelect');
const copyConfigBtn = document.getElementById('copyConfigBtn');
const adminAuthModal = document.getElementById('adminAuthModal');
const adminAuthForm = document.getElementById('adminAuthForm');
const adminAuthTokenInput = document.getElementById('adminAuthTokenInput');
const adminAuthError = document.getElementById('adminAuthError');
let isAdminAuthenticated = false;
let hasStartedLogSource = false;
function setAdminAuthError(message = '') {
adminAuthError.textContent = message;
}
function lockUi() {
document.body.classList.add('admin-auth-pending');
}
function unlockUi() {
document.body.classList.remove('admin-auth-pending');
}
async function checkAdminSession() {
try {
const response = await fetch('/public/auth/session', {
credentials: 'same-origin',
});
if (!response.ok) {
const payload = await response.json();
setAdminAuthError(payload.message || 'Unable to validate admin session.');
lockUi();
return false;
}
const payload = await response.json();
return Boolean(payload.authenticated);
} catch (error) {
setAdminAuthError('Unable to connect to gateway auth endpoint.');
lockUi();
return false;
}
}
async function unlockUiWithToken(adminToken) {
try {
const response = await fetch('/public/auth', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'same-origin',
body: JSON.stringify({ admin_token: adminToken }),
});
const payload = await response.json();
if (!response.ok) {
setAdminAuthError(payload.message || 'Invalid admin token.');
return false;
}
setAdminAuthError('');
adminAuthTokenInput.value = '';
return true;
} catch (error) {
setAdminAuthError('Authentication request failed. Please try again.');
return false;
}
}
async function ensureAdminAuth() {
const isAuthenticated = await checkAdminSession();
if (isAuthenticated) {
isAdminAuthenticated = true;
unlockUi();
if (!hasStartedLogSource) {
setupLogSource();
hasStartedLogSource = true;
}
} else {
isAdminAuthenticated = false;
lockUi();
}
}
const camelToSnakeCase = str => str.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
const camelToKebabCase = str => str.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`);
@@ -1800,6 +1948,28 @@
// Event Listeners
testRequestBtn.addEventListener('click', dummyTestRequest);
adminAuthForm.addEventListener('submit', async (event) => {
event.preventDefault();
setAdminAuthError('');
const token = adminAuthTokenInput.value.trim();
if (!token) {
setAdminAuthError('Admin token is required.');
return;
}
const unlocked = await unlockUiWithToken(token);
if (!unlocked) {
return;
}
isAdminAuthenticated = true;
unlockUi();
if (!hasStartedLogSource) {
setupLogSource();
hasStartedLogSource = true;
}
});
document.querySelectorAll('.tabs').forEach(tabsContainer => {
tabsContainer.querySelectorAll('.tab').forEach(tab => {
@@ -1922,6 +2092,7 @@
});
managePage()
ensureAdminAuth();
// Logs functionality
const logsTableBody = document.getElementById('logsTableBody');
@@ -1934,6 +2105,10 @@
let logSource;
function setupLogSource() {
if (!isAdminAuthenticated) {
return;
}
logSource = new EventSource('/log/stream');
logSource.addEventListener('connected', (event) => {
@@ -1965,18 +2140,23 @@
}
}
function reconnectLogSource() {
async function reconnectLogSource() {
if (logSource) {
logSource.close();
}
const stillAuthenticated = await checkAdminSession();
if (!stillAuthenticated) {
isAdminAuthenticated = false;
lockUi();
return;
}
console.log('Attempting to reconnect to log stream...');
setTimeout(() => {
setupLogSource();
}, 5000); // Wait 5 seconds before attempting to reconnect
}
setupLogSource();
function addLogEntry(time, method, endpoint, status, duration, requestOptions) {
const tr = document.createElement('tr');
tr.classList.add('new-row');
+8 -1
View File
@@ -8,6 +8,11 @@ import { Context } from 'hono';
import { createNodeWebSocket } from '@hono/node-ws';
import { realTimeHandlerNode } from './handlers/realtimeHandlerNode';
import { requestValidator } from './middlewares/requestValidator';
import {
adminAuthLoginHandler,
adminAuthMiddleware,
adminAuthSessionStatusHandler,
} from './middlewares/adminAuth';
// Extract the port number from the command line arguments
const defaultPort = 8787;
@@ -41,6 +46,8 @@ if (
};
// Set up routes
app.post('/public/auth', adminAuthLoginHandler);
app.get('/public/auth/session', adminAuthSessionStatusHandler);
app.get('/public/logs', serveIndex);
app.get('/public/', serveIndex);
@@ -69,7 +76,7 @@ if (
return Promise.race([fn(), timeoutPromise]);
}
app.get('/log/stream', (c: Context) => {
app.get('/log/stream', adminAuthMiddleware, (c: Context) => {
const clientId = Date.now().toString();
// Set headers to prevent caching