ref: extract authentication and remove db query in it, add prom metric collection

This commit is contained in:
Steve Korshakov 2025-08-19 18:43:08 -07:00
parent 1224039d8b
commit 84afe7c3ad
4 changed files with 205 additions and 47 deletions

View File

@ -16,6 +16,10 @@ spec:
metadata:
labels:
app: handy-server
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "9090"
prometheus.io/path: "/metrics"
spec:
containers:
- name: handy

View File

@ -11,6 +11,7 @@ import { onShutdown } from "@/utils/shutdown";
import { allocateSessionSeq, allocateUserSeq } from "@/services/seq";
import { randomKeyNaked } from "@/utils/randomKeyNaked";
import { AsyncLock } from "@/utils/lock";
import { auth } from "@/modules/auth";
import {
EventRouter,
ClientConnection,
@ -40,7 +41,7 @@ import { activityCache } from "@/modules/sessionCache";
declare module 'fastify' {
interface FastifyRequest {
user: Account;
userId: string;
}
interface FastifyInstance {
authenticate: any;
@ -52,14 +53,6 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }>
// Configure
log('Starting API...');
const tokenGenerator = await privacyKit.createPersistentTokenGenerator({
service: 'handy',
seed: process.env.HANDY_MASTER_SECRET!
});
const tokenVerifier = await privacyKit.createPersistentTokenVerifier({
service: 'handy',
publicKey: tokenGenerator.publicKey
});
// Start API
const app = fastify({
@ -89,25 +82,14 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }>
}
const token = authHeader.substring(7);
const verified = await tokenVerifier.verify(token);
const verified = await auth.verifyToken(token);
if (!verified) {
log({ module: 'auth-decorator' }, `Auth failed - invalid token`);
return reply.code(401).send({ error: 'Invalid token' });
}
// Get user from database
const user = await db.account.findUnique({
where: { id: verified.user as string }
});
if (!user) {
log({ module: 'auth-decorator' }, `Auth failed - user not found: ${verified.user}`);
return reply.code(401).send({ error: 'User not found' });
}
log({ module: 'auth-decorator' }, `Auth success - user: ${user.id}`);
request.user = user;
log({ module: 'auth-decorator' }, `Auth success - user: ${verified.userId}`);
request.userId = verified.userId;
} catch (error) {
return reply.code(401).send({ error: 'Authentication failed' });
}
@ -144,7 +126,7 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }>
return reply.send({
success: true,
token: await tokenGenerator.new({ user: user.id })
token: await auth.createToken(user.id)
});
});
@ -183,7 +165,7 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }>
});
if (answer.response && answer.responseAccountId) {
const token = await tokenGenerator.new({ user: answer.responseAccountId!, extras: { session: answer.id } });
const token = await auth.createToken(answer.responseAccountId!, { session: answer.id });
return reply.send({
state: 'authorized',
token: token,
@ -204,7 +186,7 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }>
})
}
}, async (request, reply) => {
log({ module: 'auth-response' }, `Auth response endpoint hit - user: ${request.user?.id || 'NO USER'}, publicKey: ${request.body.publicKey.substring(0, 20)}...`);
log({ module: 'auth-response' }, `Auth response endpoint hit - user: ${request.userId}, publicKey: ${request.body.publicKey.substring(0, 20)}...`);
const publicKey = privacyKit.decodeBase64(request.body.publicKey);
const isValid = tweetnacl.box.publicKeyLength === publicKey.length;
if (!isValid) {
@ -229,7 +211,7 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }>
if (!authRequest.response) {
await db.terminalAuthRequest.update({
where: { id: authRequest.id },
data: { response: request.body.response, responseAccountId: request.user.id }
data: { response: request.body.response, responseAccountId: request.userId }
});
}
return reply.send({ success: true });
@ -268,7 +250,7 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }>
});
if (answer.response && answer.responseAccountId) {
const token = await tokenGenerator.new({ user: answer.responseAccountId! });
const token = await auth.createToken(answer.responseAccountId!);
return reply.send({
state: 'authorized',
token: token,
@ -303,7 +285,7 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }>
if (!authRequest.response) {
await db.accountAuthRequest.update({
where: { id: authRequest.id },
data: { response: request.body.response, responseAccountId: request.user.id }
data: { response: request.body.response, responseAccountId: request.userId }
});
}
return reply.send({ success: true });
@ -372,7 +354,7 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }>
typed.get('/v1/sessions', {
preHandler: app.authenticate,
}, async (request, reply) => {
const userId = request.user.id;
const userId = request.userId;
const sessions = await db.session.findMany({
where: { accountId: userId },
@ -443,7 +425,7 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }>
},
preHandler: app.authenticate
}, async (request, reply) => {
const userId = request.user.id;
const userId = request.userId;
const { tag, metadata } = request.body;
const session = await db.session.findFirst({
@ -535,7 +517,7 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }>
},
preHandler: app.authenticate
}, async (request, reply) => {
const userId = request.user.id;
const userId = request.userId;
const { token } = request.body;
try {
@ -578,7 +560,7 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }>
},
preHandler: app.authenticate
}, async (request, reply) => {
const userId = request.user.id;
const userId = request.userId;
const { token } = request.params;
try {
@ -599,7 +581,7 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }>
typed.get('/v1/push-tokens', {
preHandler: app.authenticate
}, async (request, reply) => {
const userId = request.user.id;
const userId = request.userId;
try {
const tokens = await db.accountPushToken.findMany({
@ -640,9 +622,18 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }>
}
}, async (request, reply) => {
try {
const user = await db.account.findUnique({
where: { id: request.userId },
select: { settings: true, settingsVersion: true }
});
if (!user) {
return reply.code(500).send({ error: 'Failed to get account settings' });
}
return reply.send({
settings: request.user.settings,
settingsVersion: request.user.settingsVersion
settings: user.settings,
settingsVersion: user.settingsVersion
});
} catch (error) {
return reply.code(500).send({ error: 'Failed to get account settings' });
@ -674,17 +665,30 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }>
},
preHandler: app.authenticate
}, async (request, reply) => {
const userId = request.user.id;
const userId = request.userId;
const { settings, expectedVersion } = request.body;
try {
// Get current user data for version check
const currentUser = await db.account.findUnique({
where: { id: userId },
select: { settings: true, settingsVersion: true }
});
if (!currentUser) {
return reply.code(500).send({
success: false,
error: 'Failed to update account settings'
});
}
// Check current version
if (request.user.settingsVersion !== expectedVersion) {
if (currentUser.settingsVersion !== expectedVersion) {
return reply.code(200).send({
success: false,
error: 'version-mismatch',
currentVersion: request.user.settingsVersion,
currentSettings: request.user.settings
currentVersion: currentUser.settingsVersion,
currentSettings: currentUser.settings
});
}
@ -754,7 +758,7 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }>
},
preHandler: app.authenticate
}, async (request, reply) => {
const userId = request.user.id;
const userId = request.userId;
const { sessionId, startTime, endTime, groupBy } = request.body;
const actualGroupBy = groupBy || 'day';
@ -886,7 +890,7 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }>
},
preHandler: app.authenticate
}, async (request, reply) => {
const userId = request.user.id;
const userId = request.userId;
const { sessionId } = request.params;
// Verify session belongs to user
@ -940,7 +944,7 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }>
})
}
}, async (request, reply) => {
const userId = request.user.id;
const userId = request.userId;
const { id, metadata, daemonState } = request.body;
// Check if machine exists (like sessions do)
@ -1017,7 +1021,7 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }>
typed.get('/v1/machines', {
preHandler: app.authenticate,
}, async (request, reply) => {
const userId = request.user.id;
const userId = request.userId;
const machines = await db.machine.findMany({
where: { accountId: userId },
@ -1047,7 +1051,7 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }>
})
}
}, async (request, reply) => {
const userId = request.user.id;
const userId = request.userId;
const { id } = request.params;
const machine = await db.machine.findFirst({
@ -1195,7 +1199,7 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }>
return;
}
const verified = await tokenVerifier.verify(token);
const verified = await auth.verifyToken(token);
if (!verified) {
log({ module: 'websocket' }, `Invalid token provided`);
socket.emit('error', { message: 'Invalid authentication token' });
@ -1203,7 +1207,7 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }>
return;
}
const userId = verified.user as string;
const userId = verified.userId;
log({ module: 'websocket' }, `Token verified: ${userId}, clientType: ${clientType || 'user-scoped'}, sessionId: ${sessionId || 'none'}, machineId: ${machineId || 'none'}, socketId: ${socket.id}`);
// Store connection based on type

View File

@ -6,6 +6,7 @@ import { startTimeout } from "./app/timeout";
import { redis } from "./services/redis";
import { startMetricsServer } from "@/app/metrics";
import { activityCache } from "@/modules/sessionCache";
import { auth } from "./modules/auth";
async function main() {
@ -19,6 +20,9 @@ async function main() {
});
await redis.ping();
// Initialize auth module
await auth.init();
//
// Start
//

146
sources/modules/auth.ts Normal file
View File

@ -0,0 +1,146 @@
import * as privacyKit from "privacy-kit";
import { log } from "@/utils/log";
interface TokenCacheEntry {
userId: string;
extras?: any;
cachedAt: number;
}
interface AuthTokens {
generator: Awaited<ReturnType<typeof privacyKit.createPersistentTokenGenerator>>;
verifier: Awaited<ReturnType<typeof privacyKit.createPersistentTokenVerifier>>;
}
class AuthModule {
private tokenCache = new Map<string, TokenCacheEntry>();
private tokens: AuthTokens | null = null;
async init(): Promise<void> {
if (this.tokens) {
return; // Already initialized
}
log({ module: 'auth' }, 'Initializing auth module...');
const generator = await privacyKit.createPersistentTokenGenerator({
service: 'handy',
seed: process.env.HANDY_MASTER_SECRET!
});
const verifier = await privacyKit.createPersistentTokenVerifier({
service: 'handy',
publicKey: generator.publicKey
});
this.tokens = { generator, verifier };
log({ module: 'auth' }, 'Auth module initialized');
}
async createToken(userId: string, extras?: any): Promise<string> {
if (!this.tokens) {
throw new Error('Auth module not initialized');
}
const payload: any = { user: userId };
if (extras) {
payload.extras = extras;
}
const token = await this.tokens.generator.new(payload);
// Cache the token immediately
this.tokenCache.set(token, {
userId,
extras,
cachedAt: Date.now()
});
return token;
}
async verifyToken(token: string): Promise<{ userId: string; extras?: any } | null> {
// Check cache first
const cached = this.tokenCache.get(token);
if (cached) {
return {
userId: cached.userId,
extras: cached.extras
};
}
// Cache miss - verify token
if (!this.tokens) {
throw new Error('Auth module not initialized');
}
try {
const verified = await this.tokens.verifier.verify(token);
if (!verified) {
return null;
}
const userId = verified.user as string;
const extras = verified.extras;
// Cache the result permanently
this.tokenCache.set(token, {
userId,
extras,
cachedAt: Date.now()
});
return { userId, extras };
} catch (error) {
log({ module: 'auth', level: 'error' }, `Token verification failed: ${error}`);
return null;
}
}
invalidateUserTokens(userId: string): void {
// Remove all tokens for a specific user
// This is expensive but rarely needed
for (const [token, entry] of this.tokenCache.entries()) {
if (entry.userId === userId) {
this.tokenCache.delete(token);
}
}
log({ module: 'auth' }, `Invalidated tokens for user: ${userId}`);
}
invalidateToken(token: string): void {
this.tokenCache.delete(token);
}
getCacheStats(): { size: number; oldestEntry: number | null } {
if (this.tokenCache.size === 0) {
return { size: 0, oldestEntry: null };
}
let oldest = Date.now();
for (const entry of this.tokenCache.values()) {
if (entry.cachedAt < oldest) {
oldest = entry.cachedAt;
}
}
return {
size: this.tokenCache.size,
oldestEntry: oldest
};
}
// Cleanup old entries (optional - can be called periodically)
cleanup(): void {
// Note: Since tokens are cached "forever" as requested,
// we don't do automatic cleanup. This method exists if needed later.
const stats = this.getCacheStats();
log({ module: 'auth' }, `Token cache size: ${stats.size} entries`);
}
}
// Global instance
export const auth = new AuthModule();