InkdownInkdown
Start writing

Merlin Backend

12 files·0 subfolders

Shared Workspace

Merlin Backend
01-Orchestration.md

11-SecurityAndAuth

Shared from "Merlin Backend" on Inkdown

Security & Authentication Architecture

Overview

The security layer provides authentication, authorization, and request validation. It uses Firebase Auth for user identity and implements multiple authentication patterns for different contexts.


Architecture

Plain text
Request
    ↓
┌─────────────────────────────────────────────────────────────────┐
│                    AUTHENTICATION LAYER                          │
│                                                                  │
│  Bearer Token                                                    │
│       ↓                                                          │
│  Firebase.verifyIdToken()                                         │
│       ↓                                                          │
│  User Instance (load from Firestore)                             │
│       ↓                                                          │
│  Check Plan / Limits / Flags                                     │
└─────────────────────────────────────────────────────────────────┘
    ↓
Request Context (available to all handlers)
    ↓
Handler Execution
02-DeepResearch.md
03-Search.md
04-Scraping.md
05-Streaming.md
06-MultiProviderLLM.md
07-MemoryAndContext.md
08-ErrorHandling.md
09-RateLimiting.md
10-TaskQueue.md
11-SecurityAndAuth.md
Orchestration-2nd-draft

Main Auth Middleware

File: src/server/middlewares/auth/auth.ts

TypeScript
export const authMiddleware = new Middleware({
	security: {
		type: "bearer", // Authorization: Bearer <token>
	},
	input: z.object({}), // No additional input required
	handler: async ({ request }) => {
		try {
			// Extract token
			const authHeader = request.header("Authorization");
			if (!authHeader) {
				throw new ClientError(401, ErrorType.MISSING_BEARER_TOKEN);
			}

			const token = authHeader.split(" ")[1];
			if (!token) {
				throw new ClientError(401, ErrorType.MISSING_BEARER_TOKEN);
			}

			// Verify with Firebase
			const decodedToken = await firebase.authInstance.verifyIdToken(token);

			// Create user instance
			const userInstance = new User(decodedToken);
			await userInstance.init(); // Load from Firestore

			// Check and cleanup temporary features
			await userInstance.checkAndRemoveTemporaryPro();
			await userInstance.checkAndRemoveTopUps();

			// Update user record (last seen, etc.)
			await userInstance.updateUser();

			// Store in request context
			requestContext.set({
				user: userInstance.user,
				userInstance,
			});

			return {};
		} catch (error) {
			// Log invalid tokens for debugging
			if (error.statusCode === 401) {
				try {
					const token = request.header("Authorization")?.split(" ")[1];
					const decodedInvalidToken = decodeJwt(token);
					logger.warn({ invalidToken: decodedInvalidToken }, "INVALID_TOKEN");
				} catch {
					// Do Nothing
				}
			}

			// Re-throw typed errors
			if (error instanceof ClientError || error instanceof ServerError) {
				throw error;
			}

			throw new ClientError(401, ErrorType.UNAUTHORIZED, Severity.WARNING, {
				error,
			});
		}
	},
});

User Model

File: src/server/models/user.ts

TypeScript
export class User {
	user: TUserDoc;
	decodedToken: DecodedIdToken;

	constructor(decodedToken: DecodedIdToken) {
		this.decodedToken = decodedToken;
		this.user = {} as TUserDoc;
	}

	async init(): Promise<void> {
		// Load user from Firestore
		const userDoc = await db
			.collection("users")
			.doc(this.decodedToken.uid)
			.get();

		if (userDoc.exists) {
			this.user = userDoc.data() as TUserDoc;
		} else {
			// Create new user record
			this.user = await this.createNewUser();
		}
	}

	async checkAndRemoveTemporaryPro(): Promise<void> {
		// Remove temporary Pro status if expired
		if (this.user.temporaryProExpiry) {
			const now = Timestamp.now();
			if (this.user.temporaryProExpiry.toMillis() < now.toMillis()) {
				this.user.userPlan = "FREE";
				this.user.temporaryProExpiry = null;
			}
		}
	}

	async checkAndRemoveTopUps(): Promise<void> {
		// Clear expired query top-ups
		if (this.user.topUpExpiry && this.user.topUpQueries) {
			const now = Timestamp.now();
			if (this.user.topUpExpiry.toMillis() < now.toMillis()) {
				this.user.topUpQueries = 0;
				this.user.topUpExpiry = null;
			}
		}
	}

	async updateUser(): Promise<void> {
		// Update last seen, session count, etc.
		await db.collection("users").doc(this.decodedToken.uid).update({
			lastSeenAt: Timestamp.now(),
			lastSessionId: this.decodedToken.sessionId,
		});
	}

	private async createNewUser(): Promise<TUserDoc> {
		const newUser: TUserDoc = {
			uid: this.decodedToken.uid,
			email: this.decodedToken.email ?? "",
			name: this.decodedToken.name ?? "",
			userPlan: "FREE",
			createdAt: Timestamp.now(),
			lastSeenAt: Timestamp.now(),
			dailyQueriesUsed: 0,
			monthlyQueriesUsed: 0,
			totalQueriesUsed: 0,
		};

		await db.collection("users").doc(this.decodedToken.uid).set(newUser);
		return newUser;
	}
}

Task Execution Auth

File: src/server/middlewares/auth/auth.ts:81

For background task execution (different auth pattern):

TypeScript
export const authMiddlewareForTaskExecutionContext = new Middleware({
	input: taskPayloadSchema,
	handler: async ({ input }) => {
		try {
			const { decodedToken, secret } = input;

			// Verify internal secret
			if (secret !== TASKS_EXECUTOR_API.SECRET) {
				throw new Error("TASK_API_SECRET_NOT_FOUND");
			}

			// Create lightweight user instance
			const userInstance = new User({
				uid: decodedToken.uid,
				name: decodedToken.name,
			});
			await userInstance.init();

			// Skip expensive checks for tasks
			// await userInstance.checkAndRemoveTemporaryPro();
			// await userInstance.updateUser();

			requestContext.set({
				user: userInstance.user,
				userInstance,
				executionContext: executionContextSchema.Enum.TASK,
			});

			return {};
		} catch (error) {
			if (error instanceof ClientError || error instanceof ServerError) {
				throw error;
			}
			throw new ClientError(401, ErrorType.UNAUTHORIZED, Severity.WARNING, {
				error,
			});
		}
	},
});

Why Different:

  • Tasks execute without user present
  • Secret-based auth (internal only)
  • Lightweight user loading (skip heavy checks)
  • Marked as executionContext: TASK

Request Context

File: src/server/repositories/context/requestContext.ts

TypeScript
import { AsyncLocalStorage } from "async_hooks";

interface TRequestContext {
	// User
	user: TUserDoc;
	userInstance: User;

	// Request
	req: Request;
	res: Response;

	// Chat
	chatStateManager: ChatStateManager;
	assistantMessageNode: TMessageDBV2;
	userMessageNode: TMessageDBV2;

	// Schema
	schema: TSchemaConfig;

	// Execution
	executionContext?: "CHAT" | "TASK" | "MCP";

	// Streaming
	eventManager: EventManager;
	currentContentIndex: number;

	// Deep Research
	deepResearchPlan?: TDeepResearchPlanStep[];
	researchMemory?: TResearchMemory;
	startTime?: Date;
	isDeepResearch?: boolean;
}

const requestContext = new AsyncLocalStorage<TRequestContext>();

export const requestContext = {
	get: () => {
		const store = requestContextStorage.getStore();
		if (!store) {
			throw new Error("Request context not initialized");
		}
		return store;
	},
	set: (value: Partial<TRequestContext>) => {
		const store = requestContextStorage.getStore();
		if (!store) {
			throw new Error("Request context not initialized");
		}
		Object.assign(store, value);
	},
	run: <T>(context: TRequestContext, callback: () => T): T => {
		return requestContextStorage.run(context, callback);
	},
};

Why AsyncLocalStorage:

  • Maintains context across async operations
  • No need to pass context through every function
  • Automatically cleaned up after request
  • Thread-safe (per-request isolation)

Internal API Secret

File: src/server/middlewares/auth/secret.ts

TypeScript
export const TASKS_EXECUTOR_API = {
	ENDPOINT:
		process.env.TASKS_EXECUTOR_ENDPOINT ||
		"https://api.merlin.com/v1/tasks/execute",
	SECRET: process.env.TASKS_EXECUTOR_SECRET || "dev-secret-not-for-production",
};

export const INTERNAL_API_SECRETS = {
	RUNE: process.env.RUNE_AUTH_TOKEN,
	SCRAPING_BEE: process.env.SCRAPING_BEE_API_KEY,
	FIRECRAWL: process.env.FIRECRAWL_API_KEY,
	// ... other service API keys
};

Security Headers

File: src/index.ts (Express app setup)

TypeScript
import helmet from "helmet";
import cors from "cors";

app.use(
	helmet({
		contentSecurityPolicy: {
			directives: {
				defaultSrc: ["'self'"],
				scriptSrc: ["'self'", "'unsafe-inline'"],
				styleSrc: ["'self'", "'unsafe-inline'"],
				imgSrc: ["'self'", "data:", "https:"],
			},
		},
		hsts: {
			maxAge: 31536000,
			includeSubDomains: true,
			preload: true,
		},
	}),
);

app.use(
	cors({
		origin: [
			"https://merlin.foyer.work",
			"https://app.merlin.com",
			...(isDevEnv ? ["http://localhost:3000"] : []),
		],
		methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
		allowedHeaders: ["Content-Type", "Authorization", "x-merlin-version"],
		credentials: true,
	}),
);

Token Validation

Firebase Token Structure:

TypeScript
interface DecodedIdToken {
	uid: string; // Firebase user ID
	email?: string; // User email
	email_verified?: boolean; // Email verification status
	name?: string; // Display name
	picture?: string; // Profile photo URL
	issuer: string; // "https://securetoken.google.com/{projectId}"
	audience: string; // Firebase project ID
	auth_time: number; // Unix timestamp (seconds)
	issued_at: number; // Unix timestamp (seconds)
	expiration_time: number; // Unix timestamp (seconds)
	firebase: {
		identities: {
			[provider: string]: string[];
		};
		sign_in_provider: string; // "password", "google.com", etc.
	};
}

Validation Checks:

  1. Token present
  2. Valid format (3 parts: header.payload.signature)
  3. Not expired (exp > now)
  4. Valid issuer (Firebase)
  5. Valid audience (our project)
  6. Signature valid (Firebase verifies)

Error Handling

File: src/server/models/error/error.ts

TypeScript
export class ClientError extends TypedError {
	constructor(
		statusCode: IntRange<400, 500>,
		type: ErrorType,
		severity: Severity = Severity.WARNING,
		cause?: ErrorOptions["cause"],
	) {
		super(statusCode, type, severity, cause);
	}
}

// Auth-specific errors
ErrorType.MISSING_BEARER_TOKEN; // 401 - No Authorization header
ErrorType.UNAUTHORIZED; // 401 - Invalid or expired token
ErrorType.FORBIDDEN; // 403 - Valid token, insufficient permissions
ErrorType.INVALID_TOKEN; // 401 - Malformed token

Middleware Chain

File: src/config/routing.ts

TypeScript
// Standard endpoint
endpoint({
	method: "post",
	path: "/v1/chat/completions",
	middlewares: [
		authMiddleware, // 1. Authenticate
		usageLimitsMiddleware, // 2. Check rate limits
		providerConfigMiddleware, // 3. Apply provider overrides
	],
	handler: async ({ input }) => {
		// Handler has authenticated user in context
		const { user } = requestContext.get();
		// ... handle request
	},
});

// Task execution endpoint (internal)
endpoint({
	method: "post",
	path: "/v1/tasks/execute",
	middlewares: [authMiddlewareForTaskExecutionContext], // Secret-based
	handler: async ({ input }) => {
		// Handler knows this is a task execution
		const { executionContext } = requestContext.get();
		// ... handle task
	},
});

Security Best Practices

1. No Secrets in Code
TypeScript
// ❌ BAD
const API_KEY = "sk-1234567890abcdef";

// ✅ GOOD
const API_KEY = process.env.API_KEY;
2. Input Validation
TypeScript
// Zod schema validation
const inputSchema = z.object({
	message: z.string().max(10000), // Limit input size
	model: z.enum(VALID_MODELS), // Only allow known models
});
3. Rate Limiting
TypeScript
// Applied to all endpoints
await rateLimitGuest(request, user); // Guest: 50/15min
await checkUsageLimits(user, usage); // Plan-based limits
4. Error Sanitization
TypeScript
catch (error) {
    // Remove sensitive info
    delete error.config?.httpAgent;
    delete error.config?.httpsAgent;
    logger.error(error);
}
5. Token Expiration
TypeScript
// Check token age
const tokenAge = Date.now() - decodedToken.auth_time * 1000;
if (tokenAge > 24 * 60 * 60 * 1000) {
	// >24 hours
	// Consider refreshing
}

Summary

The security architecture:

  1. Firebase Auth: Industry-standard JWT tokens
  2. Bearer Pattern: Authorization: Bearer <token>
  3. AsyncLocalStorage: Context maintained across async ops
  4. User Model: Lazy loading with cleanup checks
  5. Task Auth: Secret-based for background jobs
  6. Helmet + CORS: Security headers and origin validation
  7. Secret Management: Environment variables only
  8. Input Validation: Zod schemas for all inputs
  9. Error Sanitization: No sensitive data in errors
  10. Rate Limiting: Multi-layer protection

Key Principle: Defense in depth. Multiple layers of protection, clear error messages for users, detailed logs for developers.