Skip to content
Closed
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
54 changes: 54 additions & 0 deletions nginx-native.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
worker_processes 1;
error_log /tmp/nginx-error.log;
pid /tmp/nginx.pid;

events {
worker_connections 1024;
}

http {
access_log /tmp/nginx-access.log;
client_body_temp_path /tmp/client_body;
proxy_temp_path /tmp/proxy;
fastcgi_temp_path /tmp/fastcgi;
uwsgi_temp_path /tmp/uwsgi;
scgi_temp_path /tmp/scgi;

upstream api_backend {
server 127.0.0.1:8000;
}

upstream app_backend {
server 127.0.0.1:8080;
}

server {
listen 9090;
server_name localhost;

# Note: /api/* routes go to the frontend (Next.js), which then proxies to backend
# Only direct backend routes like /me, /team, etc. go to port 8000

# Proxy to HyperDX Frontend (Next.js on port 8080)
location / {
# Add the authenticated user header (simulating OAuth2 Proxy)
proxy_set_header X-Forwarded-User "demo@hyperdx.io";

# Standard proxy headers
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr; # Set to client IP for whitelisting
proxy_set_header X-Forwarded-Proto $scheme;

# Proxy to frontend
proxy_pass http://app_backend;
proxy_redirect off;

# WebSocket support for Next.js hot reload
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
}

7 changes: 7 additions & 0 deletions packages/api/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,10 @@ export const CLICKHOUSE_PASSWORD = env.CLICKHOUSE_PASSWORD as string;

// AI Assistant
export const ANTHROPIC_API_KEY = env.ANTHROPIC_API_KEY as string;

// Auth Proxy Configuration
export const AUTH_PROXY_ENABLED = env.AUTH_PROXY_ENABLED === 'true';
export const AUTH_PROXY_HEADER_NAME =
env.AUTH_PROXY_HEADER_NAME || 'X-Forwarded-User';
export const AUTH_PROXY_AUTO_SIGN_UP = env.AUTH_PROXY_AUTO_SIGN_UP !== 'false';
export const AUTH_PROXY_WHITELIST = env.AUTH_PROXY_WHITELIST || '';
7 changes: 7 additions & 0 deletions packages/api/src/middleware/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import * as config from '@/config';
import { findUserByAccessKey } from '@/controllers/user';
import type { UserDocument } from '@/models/user';
import logger from '@/utils/logger';
import { authProxyMiddleware } from './authProxy';

declare global {
namespace Express {
Expand Down Expand Up @@ -106,6 +107,12 @@ export function isUserAuthenticated(
return next();
}

// If auth proxy is enabled, try that first
if (config.AUTH_PROXY_ENABLED) {
return authProxyMiddleware(req, res, next);
}

// Fall back to regular session-based auth
if (req.isAuthenticated()) {
// set user id as trace attribute
setTraceAttributes({
Expand Down
150 changes: 150 additions & 0 deletions packages/api/src/middleware/authProxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { splitAndTrimCSV } from '@hyperdx/common-utils/dist/utils';
import { setTraceAttributes } from '@hyperdx/node-opentelemetry';
import type { NextFunction, Request, Response } from 'express';
import { serializeError } from 'serialize-error';

import * as config from '@/config';
import { createTeam, getTeam } from '@/controllers/team';
import { findUserByEmail } from '@/controllers/user';
import User from '@/models/user';
import logger from '@/utils/logger';

/**
* Validates if the request comes from an allowed proxy IP
*/
function isAllowedProxyIP(req: Request): boolean {
if (!config.AUTH_PROXY_WHITELIST) {
return false; // No whitelist = reject (security by default)
}

const allowedIPs = splitAndTrimCSV(config.AUTH_PROXY_WHITELIST);

// Extract client IP from x-forwarded-for header or fallback to req.ip
const forwardedFor = req.headers['x-forwarded-for'];
const clientIP = forwardedFor
? Array.isArray(forwardedFor)
? forwardedFor[0]
: forwardedFor.split(',')[0].trim()
: req.ip || req.socket.remoteAddress || '';

return allowedIPs.some(
allowedIP => clientIP === allowedIP || clientIP.startsWith(allowedIP),
);
}

/**
* Extracts user identifier from the configured header
*/
function getUserIdentifierFromHeader(req: Request): string | null {
const headerValue = req.headers[config.AUTH_PROXY_HEADER_NAME.toLowerCase()];

if (!headerValue) {
return null;
}

return Array.isArray(headerValue) ? headerValue[0] : headerValue;
}

/**
* Auth proxy middleware - authenticates users based on headers from trusted proxy
*/
export async function authProxyMiddleware(
req: Request,
res: Response,
next: NextFunction,
) {
if (!config.AUTH_PROXY_ENABLED) {
return next();
}

// SECURITY: Check if request is from allowed proxy
if (!isAllowedProxyIP(req)) {
logger.warn(
{ ip: req.ip, path: req.path },
'Auth proxy request from unauthorized IP',
);
return res.status(407).json({ error: 'Proxy authentication required' });
}

// Extract user identifier from header
const userEmail = getUserIdentifierFromHeader(req);

if (!userEmail) {
logger.debug(
{ header: config.AUTH_PROXY_HEADER_NAME },
'No user identifier in auth proxy header',
);
return res.status(401).json({ error: 'Authentication header missing' });
}

try {
// Find or create user
let user = await findUserByEmail(userEmail);

if (!user && config.AUTH_PROXY_AUTO_SIGN_UP) {
logger.info({ email: userEmail }, 'Auto-creating user via auth proxy');

// Get or create the single team (app only supports one team)
let team = await getTeam();

if (!team) {
try {
team = await createTeam({
name: `${userEmail}'s Team`,
collectorAuthenticationEnforced: true,
});
} catch {
// If team creation fails (e.g., race condition), try to get it again
team = await getTeam();
if (!team) {
logger.error('Failed to get or create team for auto-provisioning');
return res.status(500).json({ error: 'Team configuration error' });
}
}
}

// Create user without password
user = await (User as any).create({
email: userEmail,
name: userEmail,
team: team._id,
});
}

if (!user) {
logger.warn(
{ email: userEmail },
'User not found and auto-signup disabled',
);
return res.status(401).json({ error: 'User not found' });
}

// Attach user to request
req.user = user;

// Set trace attributes
setTraceAttributes({
userId: user._id.toString(),
userEmail: user.email,
});

// Establish session (important for subsequent requests)
req.login(user, err => {
if (err) {
logger.error({ err, email: userEmail }, 'Failed to establish session');
return next(err);
}
logger.debug(
{ email: userEmail, userId: user._id },
'User authenticated via auth proxy',
);
next();
});
} catch (error) {
logger.error(
{ error: serializeError(error), email: userEmail },
'Auth proxy authentication error',
);
return res.status(500).json({ error: 'Authentication failed' });
}
}
2 changes: 1 addition & 1 deletion packages/api/src/utils/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import pinoHttp from 'pino-http';

import * as config from '@/config';

const MAX_LEVEL = config.HYPERDX_LOG_LEVEL ?? 'debug';
const MAX_LEVEL = config.HYPERDX_LOG_LEVEL || 'debug';

const hyperdxTransport = config.HYPERDX_API_KEY
? getPinoTransport(MAX_LEVEL, {
Expand Down