Skip to content
Open
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
6 changes: 6 additions & 0 deletions .changeset/anonymous-auth-mode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@hyperdx/api": minor
"@hyperdx/app": minor
---

feat: Add anonymous authentication mode for login-free usage with real MongoDB persistence
9 changes: 9 additions & 0 deletions docker/hyperdx/entry.local.anonymous.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
#!/bin/bash

# Set anonymous auth mode (auth enabled but auto-login with real MongoDB user)
export IS_LOCAL_APP_MODE="REQUIRED_AUTH"
export HDX_AUTH_ANONYMOUS_ENABLED="true"
export NEXT_PUBLIC_HDX_AUTH_ANONYMOUS_ENABLED="true"

# Source the common entry script
source "/etc/local/entry.base.sh"
146 changes: 146 additions & 0 deletions packages/api/src/__tests__/anonymousAuth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import express from 'express';
import request from 'supertest';

import * as config from '@/config';
import * as teamController from '@/controllers/team';
import { isUserAuthenticated } from '@/middleware/auth';
import rootRouter from '@/routers/api/root';

// Minimal Express app for testing middleware
function createTestApp() {
const app = express();
app.use(express.json());
app.get('/test', isUserAuthenticated, (req, res) => {
res.json({ userId: req.user?._id, email: req.user?.email });
});
return app;
}

describe('Anonymous Auth Middleware', () => {
const originalIsAnonymous = config.IS_ANONYMOUS_AUTH_ENABLED;
const originalIsLocal = config.IS_LOCAL_APP_MODE;

afterEach(() => {
Object.defineProperty(config, 'IS_ANONYMOUS_AUTH_ENABLED', {
value: originalIsAnonymous,
writable: true,
});
Object.defineProperty(config, 'IS_LOCAL_APP_MODE', {
value: originalIsLocal,
writable: true,
});
jest.restoreAllMocks();
});

it('should inject anonymous user when provisioned', async () => {
Object.defineProperty(config, 'IS_ANONYMOUS_AUTH_ENABLED', {
value: true,
writable: true,
});
Object.defineProperty(config, 'IS_LOCAL_APP_MODE', {
value: false,
writable: true,
});

const mockUser = {
_id: 'anon-user-id',
email: 'anonymous@hyperdx.io',
team: 'anon-team-id',
};
jest
.spyOn(teamController, 'getAnonymousUser')
.mockReturnValue(mockUser as any);

const app = createTestApp();
const res = await request(app).get('/test');

expect(res.status).toBe(200);
expect(res.body.userId).toBe('anon-user-id');
expect(res.body.email).toBe('anonymous@hyperdx.io');
});

it('should return 503 when anonymous user not yet provisioned', async () => {
Object.defineProperty(config, 'IS_ANONYMOUS_AUTH_ENABLED', {
value: true,
writable: true,
});
Object.defineProperty(config, 'IS_LOCAL_APP_MODE', {
value: false,
writable: true,
});

jest.spyOn(teamController, 'getAnonymousUser').mockReturnValue(null);

const app = createTestApp();
const res = await request(app).get('/test');

expect(res.status).toBe(503);
});

it('should return 401 when anonymous auth is disabled and not authenticated', async () => {
Object.defineProperty(config, 'IS_ANONYMOUS_AUTH_ENABLED', {
value: false,
writable: true,
});
Object.defineProperty(config, 'IS_LOCAL_APP_MODE', {
value: false,
writable: true,
});

const app = createTestApp();
const res = await request(app).get('/test');

expect(res.status).toBe(401);
});
});

describe('Anonymous Auth Route Blocking', () => {
const originalIsAnonymous = config.IS_ANONYMOUS_AUTH_ENABLED;

afterEach(() => {
Object.defineProperty(config, 'IS_ANONYMOUS_AUTH_ENABLED', {
value: originalIsAnonymous,
writable: true,
});
});

it('should block login route in anonymous mode', async () => {
Object.defineProperty(config, 'IS_ANONYMOUS_AUTH_ENABLED', {
value: true,
writable: true,
});

const app = express();
app.use(express.json());
app.use(rootRouter);

const res = await request(app)
.post('/login/password')
.send({ email: 'test@test.com', password: 'test' });

expect(res.status).toBe(403);
expect(res.body.error).toBe('authDisabled');
});

it('should block register route in anonymous mode', async () => {
Object.defineProperty(config, 'IS_ANONYMOUS_AUTH_ENABLED', {
value: true,
writable: true,
});

const app = express();
app.use(express.json());
app.use(rootRouter);

const res = await request(app)
.post('/register/password')
.send({
email: 'test@test.com',
password: 'TestPass!2#4X',
confirmPassword: 'TestPass!2#4X',
});

expect(res.status).toBe(403);
expect(res.body.error).toBe('authDisabled');
});
});
4 changes: 4 additions & 0 deletions packages/api/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ export const RUN_SCHEDULED_TASKS_EXTERNALLY =
export const IS_LOCAL_APP_MODE =
env.IS_LOCAL_APP_MODE === 'DANGEROUSLY_is_local_app_mode💀';

// Anonymous authentication mode - skips login but uses real MongoDB user/team
export const IS_ANONYMOUS_AUTH_ENABLED =
env.HDX_AUTH_ANONYMOUS_ENABLED === 'true';

// Only used to bootstrap empty instances
export const DEFAULT_CONNECTIONS = env.DEFAULT_CONNECTIONS;
export const DEFAULT_SOURCES = env.DEFAULT_SOURCES;
Expand Down
48 changes: 48 additions & 0 deletions packages/api/src/controllers/team.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import type { ObjectId } from '@/models';
import Dashboard from '@/models/dashboard';
import { SavedSearch } from '@/models/savedSearch';
import Team from '@/models/team';
import User from '@/models/user';
import type { UserDocument } from '@/models/user';

const LOCAL_APP_TEAM_ID = '_local_team_';
export const LOCAL_APP_TEAM = {
Expand Down Expand Up @@ -109,3 +111,49 @@ export async function getTags(teamId: ObjectId) {
]),
];
}

// Anonymous authentication mode
let _anonymousUser: UserDocument | null = null;

export const ANONYMOUS_USER_EMAIL = 'anonymous@hyperdx.io';

export async function provisionAnonymousUser() {
// Find existing anonymous user
const existingUser = await User.findOne({ email: ANONYMOUS_USER_EMAIL });
if (existingUser?.team) {
_anonymousUser = existingUser;
return existingUser;
}

// Create team for anonymous user
const team = new Team({
name: 'Anonymous Team',
collectorAuthenticationEnforced: false,
});
await team.save();

// Handle orphaned user (exists but no team) vs new user
if (existingUser) {
existingUser.team = team._id;
await existingUser.save();
_anonymousUser = existingUser;
return existingUser;
}

// Create user without password (passport-local-mongoose allows this)
// Note: This user persists in MongoDB even if anonymous mode is later disabled.
// To clean up, manually remove the user: db.users.deleteOne({ email: "anonymous@hyperdx.io" })
const user = new User({
email: ANONYMOUS_USER_EMAIL,
name: 'Anonymous User',
team: team._id,
});
await user.save();

_anonymousUser = user;
return user;
}

export function getAnonymousUser() {
return _anonymousUser;
}
16 changes: 16 additions & 0 deletions packages/api/src/middleware/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { NextFunction, Request, Response } from 'express';
import { serializeError } from 'serialize-error';

import * as config from '@/config';
import { getAnonymousUser } from '@/controllers/team';
import { findUserByAccessKey } from '@/controllers/user';
import type { UserDocument } from '@/models/user';
import logger from '@/utils/logger';
Expand Down Expand Up @@ -106,6 +107,21 @@ export function isUserAuthenticated(
return next();
}

if (config.IS_ANONYMOUS_AUTH_ENABLED) {
const anonymousUser = getAnonymousUser();
if (anonymousUser) {
req.user = anonymousUser.toObject();
setTraceAttributes({
userId: anonymousUser._id.toString(),
userEmail: anonymousUser.email,
});
return next();
}
// User not provisioned yet (server still starting)
logger.warn('Anonymous user not yet provisioned');
return res.sendStatus(503);
}

if (req.isAuthenticated()) {
// set user id as trace attribute
setTraceAttributes({
Expand Down
12 changes: 12 additions & 0 deletions packages/api/src/routers/api/root.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@ router.get('/installation', async (req, res, next) => {

router.post(
'/login/password',
(req, res, next) => {
if (config.IS_ANONYMOUS_AUTH_ENABLED) {
return res.status(403).json({ error: 'authDisabled' });
}
next();
},
passport.authenticate('local', {
failWithError: true,
failureMessage: true,
Expand All @@ -76,6 +82,12 @@ router.post(

router.post(
'/register/password',
(req, res, next) => {
if (config.IS_ANONYMOUS_AUTH_ENABLED) {
return res.status(403).json({ error: 'authDisabled' });
}
next();
},
validateRequest({ body: registrationSchema }),
async (req, res, next) => {
try {
Expand Down
24 changes: 23 additions & 1 deletion packages/api/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { serializeError } from 'serialize-error';

import app from '@/api-app';
import * as config from '@/config';
import { LOCAL_APP_TEAM } from '@/controllers/team';
import { LOCAL_APP_TEAM, provisionAnonymousUser } from '@/controllers/team';
import { connectDB, mongooseConnection } from '@/models';
import opampApp from '@/opamp/app';
import { setupTeamDefaults } from '@/setupDefaults';
Expand Down Expand Up @@ -105,5 +105,27 @@ export default class Server {
// Don't throw - allow server to start even if defaults setup fails
}
}

// Initialize anonymous user for anonymous auth mode
if (config.IS_ANONYMOUS_AUTH_ENABLED) {
try {
logger.info(
'Anonymous auth mode detected, provisioning anonymous user...',
);
const user = await provisionAnonymousUser();
const teamId = user.team?.toString();
if (!teamId) {
throw new Error('Anonymous user has no team assigned');
}
await setupTeamDefaults(teamId);
logger.info('Anonymous user provisioned successfully');
} catch (error) {
logger.error(
{ err: serializeError(error) },
'Failed to provision anonymous user, shutting down',
);
process.exit(1);
}
}
}
}
2 changes: 1 addition & 1 deletion packages/app/src/AuthPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export default function AuthPage({ action }: { action: 'register' | 'login' }) {
const isLoggedIn = Boolean(!teamIsLoading && team);

useEffect(() => {
if (isLoggedIn) {
if (isLoggedIn || config.IS_ANONYMOUS_MODE) {
router.push('/search');
}
}, [isLoggedIn, router]);
Expand Down
4 changes: 2 additions & 2 deletions packages/app/src/LandingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useRouter } from 'next/router';

import api from '@/api';
import AuthLoadingBlocker from '@/AuthLoadingBlocker';
import { IS_LOCAL_MODE } from '@/config';
import { IS_ANONYMOUS_MODE, IS_LOCAL_MODE } from '@/config';

export default function LandingPage() {
const { data: installation, isLoading: installationIsLoading } =
Expand All @@ -14,7 +14,7 @@ export default function LandingPage() {
const isLoggedIn = Boolean(!teamIsLoading && team);

useEffect(() => {
if (isLoggedIn || IS_LOCAL_MODE) {
if (isLoggedIn || IS_LOCAL_MODE || IS_ANONYMOUS_MODE) {
router.push('/search');
}
}, [isLoggedIn, router]);
Expand Down
Loading
Loading