From 7fc0d0a745d848441c3dae336dbb871f49fc6622 Mon Sep 17 00:00:00 2001 From: Omer Aplak Date: Mon, 22 Dec 2025 19:25:27 -0800 Subject: [PATCH 1/4] feat: add authNext and deprecate legacy auth --- .changeset/floppy-dogs-hear.md | 41 + examples/with-auth/src/index.ts | 13 +- packages/server-core/src/auth/index.ts | 1 + packages/server-core/src/auth/next.spec.ts | 47 + packages/server-core/src/auth/next.ts | 85 ++ packages/server-core/src/auth/utils.ts | 7 +- packages/server-core/src/websocket/setup.ts | 132 ++- packages/server-hono/src/app-factory.spec.ts | 41 + packages/server-hono/src/app-factory.ts | 12 +- packages/server-hono/src/auth/index.ts | 12 +- packages/server-hono/src/auth/middleware.ts | 181 +++- .../server-hono/src/hono-server-provider.ts | 5 +- packages/server-hono/src/index.ts | 2 +- packages/server-hono/src/types.ts | 14 +- website/docs/api/authentication.md | 816 +++++++++--------- 15 files changed, 893 insertions(+), 516 deletions(-) create mode 100644 .changeset/floppy-dogs-hear.md create mode 100644 packages/server-core/src/auth/next.spec.ts create mode 100644 packages/server-core/src/auth/next.ts diff --git a/.changeset/floppy-dogs-hear.md b/.changeset/floppy-dogs-hear.md new file mode 100644 index 000000000..c396b04a9 --- /dev/null +++ b/.changeset/floppy-dogs-hear.md @@ -0,0 +1,41 @@ +--- +"@voltagent/server-core": patch +"@voltagent/server-hono": patch +--- + +feat: add authNext and deprecate legacy auth + +Add a new `authNext` policy that splits routes into public, console, and user access. All routes are protected by default; use `publicRoutes` to opt out. + +AuthNext example: + +```ts +import { jwtAuth } from "@voltagent/server-core"; +import { honoServer } from "@voltagent/server-hono"; + +const server = honoServer({ + authNext: { + provider: jwtAuth({ secret: process.env.JWT_SECRET! }), + publicRoutes: ["GET /health"], + }, +}); +``` + +Behavior summary: + +- When `authNext` is set, all routes are private by default. +- Console endpoints (agents, workflows, tools, docs, observability, updates) require a Console Access Key. +- Execution endpoints require a user token (JWT). + +Console access uses `VOLTAGENT_CONSOLE_ACCESS_KEY`: + +```bash +VOLTAGENT_CONSOLE_ACCESS_KEY=your-console-key +``` + +```bash +curl http://localhost:3141/agents \ + -H "x-console-access-key: your-console-key" +``` + +Legacy `auth` remains supported but is deprecated. Use `authNext` for new integrations. diff --git a/examples/with-auth/src/index.ts b/examples/with-auth/src/index.ts index 8ade76f61..a1c1dea4d 100644 --- a/examples/with-auth/src/index.ts +++ b/examples/with-auth/src/index.ts @@ -1,7 +1,7 @@ import { openai } from "@ai-sdk/openai"; import { Agent, Memory, VoltAgent } from "@voltagent/core"; import { createPinoLogger } from "@voltagent/logger"; -import { honoServer, jwtAuth } from "@voltagent/server-hono"; +import { authNext, honoServer, jwtAuth } from "@voltagent/server-hono"; // Import Memory and TelemetryStore from core import { AiSdkEmbeddingAdapter, InMemoryVectorAdapter } from "@voltagent/core"; @@ -34,11 +34,12 @@ const agent = new Agent({ new VoltAgent({ agents: { agent }, server: honoServer({ - auth: jwtAuth({ - secret: "super-secret", - defaultPrivate: true, - publicRoutes: ["GET /api/health"], - }), + authNext: { + provider: jwtAuth({ + secret: "super-secret", + }), + publicRoutes: ["/api/health"], + }, configureApp: (app) => { app.get("/api/health", (c) => c.json({ status: "ok" })); app.get("/api/protected", (c) => c.json({ message: "This is protected" })); diff --git a/packages/server-core/src/auth/index.ts b/packages/server-core/src/auth/index.ts index b5c87670c..a08fe70c9 100644 --- a/packages/server-core/src/auth/index.ts +++ b/packages/server-core/src/auth/index.ts @@ -5,6 +5,7 @@ export * from "./types"; export * from "./defaults"; +export * from "./next"; export * from "./utils"; // Export auth providers diff --git a/packages/server-core/src/auth/next.spec.ts b/packages/server-core/src/auth/next.spec.ts new file mode 100644 index 000000000..c64125dc9 --- /dev/null +++ b/packages/server-core/src/auth/next.spec.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { resolveAuthNextAccess } from "./next"; +import type { AuthProvider } from "./types"; + +const mockProvider: AuthProvider = { + type: "jwt", + verifyToken: async () => ({ id: "user-1" }), + publicRoutes: ["GET /provider-public"], +}; + +describe("resolveAuthNextAccess", () => { + it("treats explicit publicRoutes as public", () => { + const config = { provider: mockProvider, publicRoutes: ["GET /health"] }; + expect(resolveAuthNextAccess("GET", "/health", config)).toBe("public"); + }); + + it("treats provider publicRoutes as public", () => { + const config = { provider: mockProvider }; + expect(resolveAuthNextAccess("GET", "/provider-public", config)).toBe("public"); + }); + + it("treats default console routes as console", () => { + const config = { provider: mockProvider }; + expect(resolveAuthNextAccess("GET", "/agents", config)).toBe("console"); + }); + + it("treats non-console routes as user", () => { + const config = { provider: mockProvider }; + expect(resolveAuthNextAccess("POST", "/agents/test-agent/text", config)).toBe("user"); + }); + + it("treats websocket test connection as console", () => { + const config = { provider: mockProvider }; + expect(resolveAuthNextAccess("WS", "/ws", config)).toBe("console"); + }); + + it("allows publicRoutes to override console routes", () => { + const config = { provider: mockProvider, publicRoutes: ["GET /agents"] }; + expect(resolveAuthNextAccess("GET", "/agents", config)).toBe("public"); + }); + + it("uses custom consoleRoutes when provided", () => { + const config = { provider: mockProvider, consoleRoutes: ["GET /custom-console"] }; + expect(resolveAuthNextAccess("GET", "/custom-console", config)).toBe("console"); + expect(resolveAuthNextAccess("GET", "/agents", config)).toBe("user"); + }); +}); diff --git a/packages/server-core/src/auth/next.ts b/packages/server-core/src/auth/next.ts new file mode 100644 index 000000000..fc1726a33 --- /dev/null +++ b/packages/server-core/src/auth/next.ts @@ -0,0 +1,85 @@ +import { DEFAULT_PUBLIC_ROUTES, pathMatches } from "./defaults"; +import type { AuthProvider } from "./types"; + +export type AuthNextAccess = "public" | "console" | "user"; + +export interface AuthNextRoutesConfig { + publicRoutes?: string[]; + consoleRoutes?: string[]; +} + +export interface AuthNextConfig extends AuthNextRoutesConfig { + provider: AuthProvider; +} + +export function isAuthNextConfig( + value: AuthProvider | AuthNextConfig, +): value is AuthNextConfig { + return typeof (value as AuthNextConfig).provider !== "undefined"; +} + +export function normalizeAuthNextConfig( + value: AuthProvider | AuthNextConfig, +): AuthNextConfig { + return isAuthNextConfig(value) ? value : { provider: value }; +} + +/** + * Console routes require console access when authNext is enabled. + */ +export const DEFAULT_CONSOLE_ROUTES = [ + ...DEFAULT_PUBLIC_ROUTES, + "GET /agents/:id/history", + "GET /workflows/executions", + "GET /workflows/:id/executions/:executionId/state", + "GET /api/logs", + "POST /setup-observability", + "/observability/*", + "GET /updates", + "POST /updates", + "POST /updates/:packageName", + "WS /ws", + "WS /ws/logs", + "WS /ws/observability/**", +]; + +function routeMatches(method: string, path: string, routePattern: string): boolean { + const parts = routePattern.split(" "); + if (parts.length === 2) { + const [routeMethod, routePath] = parts; + if (method.toUpperCase() !== routeMethod.toUpperCase()) { + return false; + } + return pathMatches(path, routePath); + } + + return pathMatches(path, routePattern); +} + +function matchesAnyRoute(method: string, path: string, routes?: string[]): boolean { + if (!routes || routes.length === 0) { + return false; + } + + return routes.some((route) => routeMatches(method, path, route)); +} + +export function resolveAuthNextAccess( + method: string, + path: string, + authNext: AuthNextConfig | AuthProvider, +): AuthNextAccess { + const config = normalizeAuthNextConfig(authNext); + const publicRoutes = [...(config.publicRoutes ?? []), ...(config.provider.publicRoutes ?? [])]; + + if (matchesAnyRoute(method, path, publicRoutes)) { + return "public"; + } + + const consoleRoutes = config.consoleRoutes ?? DEFAULT_CONSOLE_ROUTES; + if (matchesAnyRoute(method, path, consoleRoutes)) { + return "console"; + } + + return "user"; +} diff --git a/packages/server-core/src/auth/utils.ts b/packages/server-core/src/auth/utils.ts index e449dff79..81d6616e5 100644 --- a/packages/server-core/src/auth/utils.ts +++ b/packages/server-core/src/auth/utils.ts @@ -52,6 +52,9 @@ export function isDevRequest(req: Request): boolean { * // Production with console key * NODE_ENV=production + x-console-access-key=valid-key → true * + * // Production with console key in query param + * NODE_ENV=production + ?key=valid-key → true + * * // Production without key * NODE_ENV=production + no key → false * @@ -68,9 +71,11 @@ export function hasConsoleAccess(req: Request): boolean { // 2. Console Access Key check (for production) const consoleKey = req.headers.get("x-console-access-key"); + const url = new URL(req.url, "http://localhost"); + const queryKey = url.searchParams.get("key"); const configuredKey = process.env.VOLTAGENT_CONSOLE_ACCESS_KEY; - if (configuredKey && consoleKey === configuredKey) { + if (configuredKey && (consoleKey === configuredKey || queryKey === configuredKey)) { return true; } diff --git a/packages/server-core/src/websocket/setup.ts b/packages/server-core/src/websocket/setup.ts index 6eea2d48a..94f4d3f90 100644 --- a/packages/server-core/src/websocket/setup.ts +++ b/packages/server-core/src/websocket/setup.ts @@ -9,6 +9,8 @@ import type { ServerProviderDeps } from "@voltagent/core"; import type { Logger } from "@voltagent/internal"; import { WebSocketServer } from "ws"; import { requiresAuth } from "../auth/defaults"; +import type { AuthNextConfig } from "../auth/next"; +import { isAuthNextConfig, normalizeAuthNextConfig, resolveAuthNextAccess } from "../auth/next"; import type { AuthProvider } from "../auth/types"; import { handleWebSocketConnection } from "./handlers"; @@ -21,34 +23,30 @@ function isDevWebSocketRequest(req: IncomingMessage): boolean { return hasDevHeader && isDevEnv; } -/** - * Helper to check console access for WebSocket IncomingMessage - */ -function hasWebSocketConsoleAccess(req: IncomingMessage): boolean { - // Parse URL to get query parameters - const url = new URL(req.url || "", `http://${req.headers.host || "localhost"}`); - - // 1. Development bypass - check both header and query param +function isWebSocketDevBypass(req: IncomingMessage, url: URL): boolean { if (isDevWebSocketRequest(req)) { return true; } - // Also check query param for dev bypass (for browser WebSocket) const devParam = url.searchParams.get("dev"); - if (devParam === "true" && process.env.NODE_ENV !== "production") { + return devParam === "true" && process.env.NODE_ENV !== "production"; +} + +/** + * Helper to check console access for WebSocket IncomingMessage + */ +function hasWebSocketConsoleAccess(req: IncomingMessage, url: URL): boolean { + if (isWebSocketDevBypass(req, url)) { return true; } - // 2. Console Access Key check - check both header and query param const configuredKey = process.env.VOLTAGENT_CONSOLE_ACCESS_KEY; if (configuredKey) { - // Check header (for non-browser clients) const headerKey = req.headers["x-console-access-key"] as string; if (headerKey === configuredKey) { return true; } - // Check query param (for browser WebSocket) const queryKey = url.searchParams.get("key"); if (queryKey === configuredKey) { return true; @@ -62,13 +60,13 @@ function hasWebSocketConsoleAccess(req: IncomingMessage): boolean { * Create and configure a WebSocket server * @param deps Server provider dependencies * @param logger Logger instance - * @param auth Optional authentication provider + * @param auth Optional authentication provider or authNext config * @returns Configured WebSocket server */ export function createWebSocketServer( deps: ServerProviderDeps, logger: Logger, - _auth?: AuthProvider, + _auth?: AuthProvider | AuthNextConfig, ): WebSocketServer { const wss = new WebSocketServer({ noServer: true }); @@ -85,14 +83,14 @@ export function createWebSocketServer( * @param server HTTP server instance * @param wss WebSocket server instance * @param pathPrefix Path prefix for WebSocket connections (default: "/ws") - * @param auth Optional authentication provider + * @param auth Optional authentication provider or authNext config * @param logger Logger instance */ export function setupWebSocketUpgrade( server: any, wss: WebSocketServer, pathPrefix = "/ws", - auth?: AuthProvider, + auth?: AuthProvider | AuthNextConfig, logger?: Logger, ): void { server.addListener("upgrade", async (req: IncomingMessage, socket: Socket, head: Buffer) => { @@ -105,33 +103,37 @@ export function setupWebSocketUpgrade( // Check authentication if auth provider is configured if (auth) { try { - // Check if it's an observability WebSocket that needs Console access - if (path.includes("/observability")) { - // Check Console Access or dev bypass using WebSocket-specific helpers - const hasAccess = hasWebSocketConsoleAccess(req); - if (!hasAccess) { - logger?.debug("[WebSocket] Unauthorized observability connection attempt"); - socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n"); - socket.destroy(); - return; - } - // Set a pseudo user for console access - user = { id: "console", type: "console-access" }; - } else { - // For other WebSocket paths, check console access first (console always has access) - const hasConsoleAccess = hasWebSocketConsoleAccess(req); - if (hasConsoleAccess) { + if (isAuthNextConfig(auth)) { + const config = normalizeAuthNextConfig(auth); + const provider = config.provider; + const access = resolveAuthNextAccess("WS", path, config); + + if (access === "public") { + const token = url.searchParams.get("token"); + if (token) { + try { + user = await provider.verifyToken(token); + } catch { + // Ignore token errors on public routes + } + } + } else if (access === "console") { + const hasAccess = hasWebSocketConsoleAccess(req, url); + if (!hasAccess) { + logger?.debug("[WebSocket] Unauthorized console connection attempt"); + socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n"); + socket.destroy(); + return; + } user = { id: "console", type: "console-access" }; } else { - // Check if this WebSocket path requires authentication - const needsAuth = requiresAuth("WS", path, auth.publicRoutes, auth.defaultPrivate); - - if (needsAuth) { - // Route requires authentication - verify JWT token + if (isWebSocketDevBypass(req, url)) { + // Dev bypass for local testing + } else { const token = url.searchParams.get("token"); if (token) { try { - user = await auth.verifyToken(token); + user = await provider.verifyToken(token); } catch (error) { logger?.debug("[WebSocket] Token verification failed:", { error }); socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n"); @@ -139,20 +141,56 @@ export function setupWebSocketUpgrade( return; } } else { - // No token provided for protected route logger?.debug("[WebSocket] No token provided for protected WebSocket"); socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n"); socket.destroy(); return; } + } + } + } else { + // Legacy auth flow + if (path.includes("/observability")) { + const hasAccess = hasWebSocketConsoleAccess(req, url); + if (!hasAccess) { + logger?.debug("[WebSocket] Unauthorized observability connection attempt"); + socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n"); + socket.destroy(); + return; + } + user = { id: "console", type: "console-access" }; + } else { + const hasConsoleAccess = hasWebSocketConsoleAccess(req, url); + if (hasConsoleAccess) { + user = { id: "console", type: "console-access" }; } else { - // Public route - optionally verify token if provided - const token = url.searchParams.get("token"); - if (token) { - try { - user = await auth.verifyToken(token); - } catch { - // Ignore token errors on public routes + const needsAuth = requiresAuth("WS", path, auth.publicRoutes, auth.defaultPrivate); + + if (needsAuth) { + const token = url.searchParams.get("token"); + if (token) { + try { + user = await auth.verifyToken(token); + } catch (error) { + logger?.debug("[WebSocket] Token verification failed:", { error }); + socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n"); + socket.destroy(); + return; + } + } else { + logger?.debug("[WebSocket] No token provided for protected WebSocket"); + socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n"); + socket.destroy(); + return; + } + } else { + const token = url.searchParams.get("token"); + if (token) { + try { + user = await auth.verifyToken(token); + } catch { + // Ignore token errors on public routes + } } } } diff --git a/packages/server-hono/src/app-factory.spec.ts b/packages/server-hono/src/app-factory.spec.ts index 323c12133..60998cf22 100644 --- a/packages/server-hono/src/app-factory.spec.ts +++ b/packages/server-hono/src/app-factory.spec.ts @@ -156,6 +156,47 @@ describe("app-factory CORS configuration", () => { }); }); + it("should protect custom routes added via configureApp with authNext", async () => { + const mockAuthProvider = { + type: "custom", + verifyToken: async (token: string) => { + if (token === "valid-token") { + return { id: "user-123", email: "test@example.com" }; + } + return null; + }, + }; + + const { app } = await createApp(createDeps(), { + authNext: { provider: mockAuthProvider }, + configureApp: (app) => { + app.get("/custom-protected-next", (c) => { + const user = c.get("authenticatedUser"); + return c.json({ message: "protected", user }); + }); + }, + }); + + const unauthorizedRes = await app.request("/custom-protected-next"); + expect(unauthorizedRes.status).toBe(401); + const unauthorizedJson = await unauthorizedRes.json(); + expect(unauthorizedJson.success).toBe(false); + expect(unauthorizedJson.error).toContain("Authorization: Bearer"); + + const authorizedRes = await app.request("/custom-protected-next", { + headers: { + Authorization: "Bearer valid-token", + }, + }); + expect(authorizedRes.status).toBe(200); + const authorizedJson = await authorizedRes.json(); + expect(authorizedJson.message).toBe("protected"); + expect(authorizedJson.user).toEqual({ + id: "user-123", + email: "test@example.com", + }); + }); + it("should allow disabling default CORS and using route-specific CORS", async () => { const { app } = await createApp(createDeps(), { cors: false, // Disable default CORS diff --git a/packages/server-hono/src/app-factory.ts b/packages/server-hono/src/app-factory.ts index d21a5118f..2e4f777ac 100644 --- a/packages/server-hono/src/app-factory.ts +++ b/packages/server-hono/src/app-factory.ts @@ -7,7 +7,7 @@ import { shouldEnableSwaggerUI, } from "@voltagent/server-core"; import { cors } from "hono/cors"; -import { createAuthMiddleware } from "./auth/middleware"; +import { createAuthMiddleware, createAuthNextMiddleware } from "./auth/middleware"; import { registerA2ARoutes, registerAgentRoutes, @@ -67,7 +67,17 @@ export async function createApp( } }, auth: () => { + if (config.authNext && config.auth) { + logger.warn("Both authNext and auth are set. authNext will take precedence."); + } + + if (config.authNext) { + app.use("*", createAuthNextMiddleware(config.authNext)); + return; + } + if (config.auth) { + logger.warn("auth is deprecated. Use authNext to protect all routes by default."); app.use("*", createAuthMiddleware(config.auth)); } }, diff --git a/packages/server-hono/src/auth/index.ts b/packages/server-hono/src/auth/index.ts index 1c73741f6..54a29a905 100644 --- a/packages/server-hono/src/auth/index.ts +++ b/packages/server-hono/src/auth/index.ts @@ -2,8 +2,14 @@ * Hono-specific authentication implementations */ -// Re-export JWT auth from server-core -export { jwtAuth, createJWT, type JWTAuthOptions } from "@voltagent/server-core"; +// Re-export auth utilities from server-core +export { + createJWT, + DEFAULT_CONSOLE_ROUTES, + jwtAuth, + type AuthNextConfig, + type JWTAuthOptions, +} from "@voltagent/server-core"; // Export Hono-specific middleware -export { createAuthMiddleware } from "./middleware"; +export { createAuthMiddleware, createAuthNextMiddleware } from "./middleware"; diff --git a/packages/server-hono/src/auth/middleware.ts b/packages/server-hono/src/auth/middleware.ts index 311a23fc3..d9817d8c7 100644 --- a/packages/server-hono/src/auth/middleware.ts +++ b/packages/server-hono/src/auth/middleware.ts @@ -1,5 +1,11 @@ -import type { AuthProvider } from "@voltagent/server-core"; -import { hasConsoleAccess, isDevRequest, requiresAuth } from "@voltagent/server-core"; +import type { AuthNextConfig, AuthProvider } from "@voltagent/server-core"; +import { + hasConsoleAccess, + isDevRequest, + normalizeAuthNextConfig, + requiresAuth, + resolveAuthNextAccess, +} from "@voltagent/server-core"; import type { Context, Next } from "hono"; /** @@ -78,41 +84,7 @@ export function createAuthMiddleware(authProvider: AuthProvider) { ); } - // Store user in context for later use - c.set("authenticatedUser", user); - - // Inject user into request body for protected routes - // This modifies c.req.json() to include context - const originalJson = c.req.json.bind(c.req); - c.req.json = async () => { - const body = await originalJson(); - return { - ...body, - // Not removing context from body as it might be used somewhere else - context: { - ...body.context, - user, - }, - // Set userId if available - ...(user.id && { userId: user.id }), - ...(user.sub && !user.id && { userId: user.sub }), - // Adding the above in options, as this is where context is read from - // by processAgentOptions (packages/server-core/src/utils/options.ts:37) - // and processWorkflowOptions - // These is needed so the auth context/user arrives into OperationContext - options: { - ...body.options, // Preserve all existing options (conversationId, temperature, etc.) - context: { - ...body.options?.context, - ...body.context, - user, - }, - // Set userId if available - ...(user.id && { userId: user.id }), - ...(user.sub && !user.id && { userId: user.sub }), - }, - }; - }; + injectUserContext(c, user); return next(); } catch (error) { @@ -127,3 +99,138 @@ export function createAuthMiddleware(authProvider: AuthProvider) { } }; } + +/** + * Create authentication middleware for Hono using authNext policy + * This middleware handles both authentication and user context injection + * @param authNextConfig The authNext configuration + * @returns Hono middleware function + */ +export function createAuthNextMiddleware( + authNextConfig: AuthNextConfig | AuthProvider, +) { + const config = normalizeAuthNextConfig(authNextConfig); + const authProvider = config.provider; + + return async (c: Context, next: Next) => { + const path = c.req.path; + const method = c.req.method; + const access = resolveAuthNextAccess(method, path, config); + + if (access === "public") { + return next(); + } + + if (access === "console") { + if (hasConsoleAccess(c.req.raw)) { + return next(); + } + + return c.json( + { + success: false, + error: buildAuthNextMessage("console", "Console access required"), + }, + 401, + ); + } + + if (isDevRequest(c.req.raw)) { + return next(); + } + + try { + let token: string | undefined; + + if (authProvider.extractToken) { + token = authProvider.extractToken(c.req.raw); + } else { + const authHeader = c.req.header("Authorization"); + if (authHeader?.startsWith("Bearer ")) { + token = authHeader.substring(7); + } + } + + if (!token) { + return c.json( + { + success: false, + error: buildAuthNextMessage("user", "Authentication required"), + }, + 401, + ); + } + + const user = await authProvider.verifyToken(token, c.req.raw); + + if (!user) { + return c.json( + { + success: false, + error: buildAuthNextMessage("user", "Invalid authentication"), + }, + 401, + ); + } + + injectUserContext(c, user); + return next(); + } catch (error) { + const reason = error instanceof Error ? error.message : "Authentication failed"; + return c.json( + { + success: false, + error: buildAuthNextMessage("user", reason), + }, + 401, + ); + } + }; +} + +function injectUserContext(c: Context, user: any) { + c.set("authenticatedUser", user); + + const originalJson = c.req.json.bind(c.req); + c.req.json = async () => { + const body = await originalJson(); + return { + ...body, + context: { + ...body.context, + user, + }, + ...(user.id && { userId: user.id }), + ...(user.sub && !user.id && { userId: user.sub }), + options: { + ...body.options, + context: { + ...body.options?.context, + ...body.context, + user, + }, + ...(user.id && { userId: user.id }), + ...(user.sub && !user.id && { userId: user.sub }), + }, + }; + }; +} + +function buildAuthNextMessage(access: "console" | "user", reason: string): string { + const hint = buildAuthNextHint(access); + const normalized = reason.endsWith(".") ? reason.slice(0, -1) : reason; + return `${normalized}. ${hint}`; +} + +function buildAuthNextHint(access: "console" | "user"): string { + const devHint = + process.env.NODE_ENV !== "production" + ? " In development, you can set x-voltagent-dev: true." + : ""; + + if (access === "console") { + return `Set VOLTAGENT_CONSOLE_ACCESS_KEY and send x-console-access-key header or add ?key=YOUR_KEY query param.${devHint}`; + } + + return `Send Authorization: Bearer .${devHint}`; +} diff --git a/packages/server-hono/src/hono-server-provider.ts b/packages/server-hono/src/hono-server-provider.ts index 1a11ba7f8..da32f3afe 100644 --- a/packages/server-hono/src/hono-server-provider.ts +++ b/packages/server-hono/src/hono-server-provider.ts @@ -108,12 +108,13 @@ export class HonoServerProvider extends BaseServerProvider { // Setup WebSocket if enabled if (this.config.enableWebSocket !== false) { - this.websocketServer = createWebSocketServer(this.deps, this.logger, this.config.auth); + const authConfig = this.honoConfig.authNext ?? this.honoConfig.auth; + this.websocketServer = createWebSocketServer(this.deps, this.logger, authConfig); setupWebSocketUpgrade( this.server, this.websocketServer, this.config.websocketPath, - this.config.auth, + authConfig, this.logger, ); } diff --git a/packages/server-hono/src/index.ts b/packages/server-hono/src/index.ts index 07fa2e210..abd9055b5 100644 --- a/packages/server-hono/src/index.ts +++ b/packages/server-hono/src/index.ts @@ -18,7 +18,7 @@ export default honoServer; export type { HonoServerConfig } from "./types"; // Export auth utilities -export { jwtAuth } from "./auth"; +export { DEFAULT_CONSOLE_ROUTES, jwtAuth, type AuthNextConfig } from "./auth"; // Export custom endpoint utilities export { extractCustomEndpoints, getEnhancedOpenApiDoc } from "./utils/custom-endpoints"; diff --git a/packages/server-hono/src/types.ts b/packages/server-hono/src/types.ts index 4ad6f41e0..5c88a8204 100644 --- a/packages/server-hono/src/types.ts +++ b/packages/server-hono/src/types.ts @@ -1,4 +1,4 @@ -import type { AuthProvider } from "@voltagent/server-core"; +import type { AuthNextConfig, AuthProvider } from "@voltagent/server-core"; import type { Context } from "hono"; import type { OpenAPIHonoType } from "./zod-openapi-compat"; @@ -66,12 +66,12 @@ export interface HonoServerConfig { * routes and middleware using Hono's native API. * * NOTE: Custom routes added via configureApp are protected by the auth middleware - * if one is configured. Routes are registered AFTER authentication middleware. + * if one is configured (auth/authNext). Routes are registered AFTER authentication middleware. * * @example * ```typescript * configureApp: (app) => { - * // Add custom routes (will be auth-protected if config.auth is set) + * // Add custom routes (will be auth-protected if auth/authNext is set) * app.get('/health', (c) => c.json({ status: 'ok' })); * * // Add middleware @@ -125,9 +125,17 @@ export interface HonoServerConfig { }; }) => void | Promise; + /** + * Next-gen authentication policy. + * When provided, all routes are protected by default, with console routes + * requiring console access and publicRoutes explicitly allowed. + */ + authNext?: AuthNextConfig; + /** * Authentication provider for protecting agent/workflow execution endpoints * When provided, execution endpoints will require valid authentication + * @deprecated Use authNext instead. */ auth?: AuthProvider; } diff --git a/website/docs/api/authentication.md b/website/docs/api/authentication.md index 7569e2b80..d7839909f 100644 --- a/website/docs/api/authentication.md +++ b/website/docs/api/authentication.md @@ -5,13 +5,13 @@ sidebar_label: Authentication # Authentication -VoltAgent supports optional authentication to secure your AI agents and workflows. You can run without authentication, use simple JWT tokens, or implement complex auth flows - the choice is yours. +VoltAgent supports optional authentication. The recommended policy is **authNext**, which protects all routes by default and separates **console access** from **user access**. The legacy `auth` option is still supported but deprecated. -## Getting Started +## Quick Start ### Option 1: No Authentication (Default) -Perfect for development and internal tools: +Use for development and internal tools: ```typescript import { VoltAgent } from "@voltagent/core"; @@ -23,299 +23,194 @@ new VoltAgent({ }); ``` -All endpoints are publicly accessible. This is the simplest way to get started. +All endpoints are publicly accessible. -### Option 2: Basic JWT Authentication +### Option 2: authNext (Recommended) -Protect execution endpoints while keeping management endpoints public: +Protect everything by default. Explicitly allow public routes, and use a Console Access Key for management endpoints: ```typescript import { jwtAuth } from "@voltagent/server-core"; +import { honoServer } from "@voltagent/server-hono"; new VoltAgent({ agents: { myAgent }, server: honoServer({ - auth: jwtAuth({ - secret: process.env.JWT_SECRET!, // Your JWT secret key - }), + authNext: { + provider: jwtAuth({ + secret: process.env.JWT_SECRET!, + }), + publicRoutes: ["GET /health"], + }, }), }); ``` -With this setup: +Legacy `auth` is still supported for existing integrations. See the **Legacy auth** section at the end for details. -- ✅ Agent/workflow execution endpoints require JWT token -- ✅ Management endpoints (list agents, etc.) remain public -- ✅ Documentation endpoints remain public +## authNext: Policy Model (Recommended) -### Option 3: Protect Everything (Recommended for Production) +authNext is a policy layer that decides **how each route is accessed**: -Make all routes private by default, then explicitly make some public: +- **public**: No authentication required +- **console**: Requires Console Access Key (or dev bypass) +- **user**: Requires a valid user token (JWT) -```typescript -new VoltAgent({ - agents: { myAgent }, - server: honoServer({ - auth: jwtAuth({ - secret: process.env.JWT_SECRET!, - defaultPrivate: true, // Protect all routes - publicRoutes: [ - // Except these - "GET /health", - "GET /", - "POST /webhooks/*", - ], - }), - }), -}); -``` +Console routes cover management, docs, and observability endpoints used by the VoltAgent Console UI. -### Environment Variables +### Access Resolution Order -```bash -# Required for JWT authentication -JWT_SECRET=your-secret-key-here # Generate with: openssl rand -hex 32 +authNext resolves access in this order: -# Required for Console in production -VOLTAGENT_CONSOLE_ACCESS_KEY=your-console-key-here # Generate with: openssl rand -hex 32 -NODE_ENV=production # Set to enable Console authentication -``` +1. **public** routes (from `authNext.publicRoutes` + `provider.publicRoutes`) +2. **console** routes (from `authNext.consoleRoutes` or defaults) +3. **user** routes (everything else) -## Common Use Cases +If a route matches both public and console, **public wins**. -### Public API with Protected Admin Routes +When using authNext, define public routes on `authNext.publicRoutes`. `provider.publicRoutes` is merged in for provider defaults. -Most endpoints are public, only admin operations require auth: +### Default Console Routes -```typescript -auth: jwtAuth({ - secret: process.env.JWT_SECRET, - // defaultPrivate: false (default - only execution endpoints protected) - publicRoutes: [ - "GET /api/public/*", // Additional public routes - ], -}); -``` +By default, authNext treats these as **console** routes (Console Key required): -### Private API with Public Health Check +**Management** -Everything requires auth except health monitoring: +- `GET /agents` +- `GET /agents/:id` +- `GET /workflows` +- `GET /workflows/:id` +- `GET /tools` +- `GET /agents/:id/history` +- `GET /workflows/executions` +- `GET /workflows/:id/executions/:executionId/state` -```typescript -auth: jwtAuth({ - secret: process.env.JWT_SECRET, - defaultPrivate: true, // Everything protected - publicRoutes: [ - "GET /health", // Health check for load balancers - "GET /metrics", // Metrics for monitoring - ], -}); -``` +**Docs + Landing** -### Multi-Tenant SaaS Application +- `GET /` +- `GET /doc` +- `GET /ui` -Extract tenant information from JWT tokens: +**Discovery** -```typescript -auth: jwtAuth({ - secret: process.env.JWT_SECRET, - defaultPrivate: true, +- `GET /agents/:id/card` +- `GET /mcp/servers` +- `GET /mcp/servers/:serverId` +- `GET /mcp/servers/:serverId/tools` - // Transform JWT payload to your user model - mapUser: (payload) => ({ - id: payload.sub, - tenantId: payload.tenant_id, - email: payload.email, - role: payload.role, - }), -}); -``` +**Observability + Updates** -Then access tenant info in your agents: +- `/observability/*` +- `GET /api/logs` +- `GET /updates` +- `POST /updates` +- `POST /updates/:packageName` -```typescript -const agent = new Agent({ - name: "TenantAgent", - hooks: { - onStart: async ({ context }) => { - const user = context.context.get("user"); - const tenantId = user?.tenantId; - // Filter data by tenant - }, - }, -}); -``` +**WebSocket (Console Channels)** -## How Authentication Works +- `WS /ws` +- `WS /ws/logs` +- `WS /ws/observability/**` -### What Gets Protected? +This list is defined in `packages/server-core/src/auth/next.ts`. -When you enable authentication with default settings: +### Route Pattern Syntax -| Endpoint Type | No Auth | With Auth (Default) | With Auth (defaultPrivate: true) | -| ------------------------------------- | ------- | ------------------- | -------------------------------- | -| **Execution** (`POST /agents/*/text`) | Public | **Protected** | **Protected** | -| **Management** (`GET /agents`) | Public | Public | **Protected** | -| **Documentation** (`/doc`, `/ui`) | Public | Public | **Protected** | -| **Your Custom Routes** | Public | Public | **Protected** | +Route patterns support: -### User Context in Agents +- Method prefix: `GET /agents/:id` +- Path params: `/agents/:id` +- Single wildcard: `/observability/*` (matches `/observability/x` and deeper) +- Double-star: `/ws/observability/**` (matches `/ws/observability` and children) -When a request is authenticated, user information is automatically available: +### Making Routes Public -```typescript -const agent = new Agent({ - name: "MyAgent", - - // Dynamic instructions based on user - instructions: ({ context }) => { - const user = context?.get("user"); - if (user?.role === "admin") { - return "You have admin privileges..."; - } - return "You are a standard user..."; - }, +Add routes to `authNext.publicRoutes`: - // Access user in hooks - hooks: { - onStart: async ({ context }) => { - const user = context.context.get("user"); - console.log("Request from:", user?.email); - }, - }, -}); +```typescript +authNext: { + provider: jwtAuth({ secret: process.env.JWT_SECRET! }), + publicRoutes: [ + "GET /health", + "POST /webhooks/*", + ], +}, ``` -### Testing Your Authentication - -#### Generate a Test Token - -Create `generate-token.js`: +### Custom Console Routes -```javascript -import jwt from "jsonwebtoken"; -import dotenv from "dotenv"; - -dotenv.config(); - -const token = jwt.sign( - { - id: "test-user", - email: "test@example.com", - role: "admin", - }, - process.env.JWT_SECRET, - { expiresIn: "24h" } -); +`authNext.consoleRoutes` **replaces** the default console list: -console.log("Token:", token); -console.log("\nTest with curl:"); -console.log(`curl -H "Authorization: Bearer ${token}" http://localhost:3141/agents/my-agent/text`); +```typescript +authNext: { + provider: jwtAuth({ secret: process.env.JWT_SECRET! }), + consoleRoutes: [ + "/observability/*", + "GET /updates", + "POST /updates", + ], +}, ``` -#### Test Protected Endpoints +If you want the defaults plus custom routes, include the defaults explicitly. -```bash -# Without token (will fail with 401) -curl -X POST http://localhost:3141/agents/my-agent/text \ - -H "Content-Type: application/json" \ - -d '{"input": "Hello"}' +```typescript +import { DEFAULT_CONSOLE_ROUTES } from "@voltagent/server-core"; -# With token (will succeed) -curl -X POST http://localhost:3141/agents/my-agent/text \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer YOUR_TOKEN" \ - -d '{"input": "Hello"}' +authNext: { + provider: jwtAuth({ secret: process.env.JWT_SECRET! }), + consoleRoutes: [...DEFAULT_CONSOLE_ROUTES, "GET /admin/metrics"], +}, ``` -## Advanced Configuration - -### Custom User Mapping - -Transform JWT payload into your application's user model: - -```typescript -auth: jwtAuth({ - secret: process.env.JWT_SECRET, - - mapUser: (payload) => ({ - // Map JWT claims to your user object - id: payload.sub || payload.user_id, - email: payload.email, - name: payload.name || payload.given_name, +### Console Access Key - // Add custom fields - tenantId: payload.tenant_id || payload.org_id, - permissions: payload.permissions || [], - tier: payload.subscription_tier || "free", +In production, set a Console Access Key: - // Add computed properties - isAdmin: payload.role === "admin" || payload.admin === true, - canAccessPremiumFeatures: ["premium", "enterprise"].includes(payload.tier), - }), -}); +```bash +NODE_ENV=production +VOLTAGENT_CONSOLE_ACCESS_KEY=your-console-key ``` -### JWT Verification Options +Provide the key via: -Configure how JWT tokens are verified: +- Header: `x-console-access-key: ` +- Query: `?key=` (required for WebSocket) -```typescript -auth: jwtAuth({ - secret: process.env.JWT_SECRET, +### Development Bypass - verifyOptions: { - // Accepted signing algorithms - algorithms: ["HS256", "RS256"], +In non-production, the dev bypass is allowed: - // Token audience (aud claim) - audience: "https://api.example.com", +```bash +# HTTP requests +x-voltagent-dev: true - // Token issuer (iss claim) - issuer: "https://auth.example.com", - }, -}); +# WebSocket connections +?dev=true ``` -### Using RS256 (Public Key) - -For tokens signed with RS256: +This bypass works for both **console** and **user** routes. -```typescript -import fs from "fs"; +## Auth Providers -auth: jwtAuth({ - secret: fs.readFileSync("public-key.pem"), - verifyOptions: { - algorithms: ["RS256"], - }, -}); -``` +VoltAgent includes a JWT provider and supports custom providers via the `AuthProvider` interface. +Providers validate tokens and return a user object; role and tenant checks live in your handlers or middleware. -### Complete Configuration Example +### JWT (`jwtAuth`) -All options together: +`jwtAuth` can be used with **authNext** or legacy `auth`: ```typescript -auth: jwtAuth({ - // JWT secret or public key - secret: process.env.JWT_SECRET!, - - // Protect all routes by default - defaultPrivate: true, - - // Routes that don't require auth - publicRoutes: ["GET /health", "GET /", "POST /webhooks/*", "GET /api/public/*"], +import { jwtAuth } from "@voltagent/server-core"; - // Transform JWT to user object +const provider = jwtAuth({ + secret: process.env.JWT_SECRET!, mapUser: (payload) => ({ id: payload.sub, email: payload.email, tenantId: payload.tenant_id, - permissions: payload.permissions || [], + role: payload.role, }), - - // JWT verification settings verifyOptions: { algorithms: ["HS256"], audience: "voltagent-api", @@ -324,265 +219,313 @@ auth: jwtAuth({ }); ``` -## Console & Observability Authentication +**Note**: `defaultPrivate` only affects legacy `auth`. For authNext, use `authNext.publicRoutes`. -VoltAgent Console uses a separate authentication system for observability endpoints. +### Custom Providers (AuthProvider) -### Understanding Dual Authentication +If you use a different identity system, implement `AuthProvider` and plug it into `authNext`: -VoltAgent uses two independent auth systems: +```typescript +import { VoltAgent } from "@voltagent/core"; +import type { AuthProvider } from "@voltagent/server-core"; +import { honoServer } from "@voltagent/server-hono"; + +const provider: AuthProvider = { + type: "my-provider", + async verifyToken(token, _request) { + // Validate token and return your user object + return { id: "user-id", role: "user" }; + }, +}; + +new VoltAgent({ + agents: { myAgent }, + server: honoServer({ + authNext: { provider }, + }), +}); +``` -1. **End-User Auth (JWT)**: For your application's users accessing agents/workflows -2. **Console Auth**: For developers accessing the observability dashboard +### Provider Recipes -### Developer Mode +VoltAgent does not ship official packages for these providers yet, but you can wire them up +by implementing `AuthProvider`. Use the examples below as starting points. -In development (`NODE_ENV !== "production"`), the Console works automatically: +All examples assume: -```bash -# Console connects without authentication -npm run dev # NODE_ENV is not "production" +```typescript +import type { AuthProvider } from "@voltagent/server-core"; ``` -The Console sends `x-voltagent-dev: true` header which is accepted in development mode. +#### Auth0 (JWKS) -### Production Mode +Env: -In production, set a Console Access Key: +- `AUTH0_DOMAIN` +- `AUTH0_AUDIENCE` -```bash -# Server environment variables -NODE_ENV=production -VOLTAGENT_CONSOLE_ACCESS_KEY=your-secure-key-here +```typescript +import { createRemoteJWKSet, jwtVerify } from "jose"; + +const domain = process.env.AUTH0_DOMAIN!; +const audience = process.env.AUTH0_AUDIENCE!; +const jwks = createRemoteJWKSet(new URL(`https://${domain}/.well-known/jwks.json`)); + +const provider: AuthProvider = { + type: "auth0", + async verifyToken(token) { + const { payload } = await jwtVerify(token, jwks, { + issuer: `https://${domain}/`, + audience, + }); + return payload; + }, +}; ``` -When accessing the Console: - -1. Console detects 401 error -2. Prompts for access key -3. Stores key locally -4. Automatically retries requests +#### Clerk (JWKS) -### WebSocket Authentication +Env: -Browsers cannot send headers during WebSocket handshake, so use query parameters: +- `CLERK_JWKS_URI` (from Clerk dashboard) +- `CLERK_SECRET_KEY` +- `CLERK_PUBLISHABLE_KEY` -```javascript -// User authentication (JWT) -const token = "your-jwt-token"; -const ws = new WebSocket(`ws://localhost:3141/ws?token=${token}`); +```typescript +import { createRemoteJWKSet, jwtVerify } from "jose"; -// Console authentication (development) -const ws = new WebSocket("ws://localhost:3141/ws/observability?dev=true"); +const jwks = createRemoteJWKSet(new URL(process.env.CLERK_JWKS_URI!)); -// Console authentication (production) -const key = localStorage.getItem("voltagent_console_access_key"); -const ws = new WebSocket(`ws://localhost:3141/ws/observability?key=${key}`); +const provider: AuthProvider = { + type: "clerk", + async verifyToken(token) { + const { payload } = await jwtVerify(token, jwks); + return payload; + }, +}; ``` -## API Reference +If you need organization membership or user lookups, use `@clerk/backend` with the secret and publishable keys. -### Protected Routes +#### WorkOS (JWKS) -When authentication is configured, these endpoints require valid tokens: +Env: -#### Agent Execution +- `WORKOS_API_KEY` +- `WORKOS_CLIENT_ID` -- `POST /agents/:id/text` - Generate text -- `POST /agents/:id/stream` - Stream text -- `POST /agents/:id/chat` - Chat stream -- `POST /agents/:id/object` - Generate object -- `POST /agents/:id/stream-object` - Stream object +```typescript +import { WorkOS } from "@workos-inc/node"; +import { createRemoteJWKSet, jwtVerify } from "jose"; -#### Workflow Execution +const workos = new WorkOS(process.env.WORKOS_API_KEY!, { + clientId: process.env.WORKOS_CLIENT_ID!, +}); +const jwksUrl = workos.userManagement.getJwksUrl(process.env.WORKOS_CLIENT_ID!); +const jwks = createRemoteJWKSet(new URL(jwksUrl)); + +const provider: AuthProvider = { + type: "workos", + async verifyToken(token) { + const { payload } = await jwtVerify(token, jwks); + return payload; + }, +}; +``` -- `POST /workflows/:id/run` - Run workflow -- `POST /workflows/:id/stream` - Stream workflow -- `POST /workflows/:id/executions/:executionId/suspend` - Suspend -- `POST /workflows/:id/executions/:executionId/resume` - Resume -- `POST /workflows/:id/executions/:executionId/cancel` - Cancel +#### Firebase -#### Observability (Console Auth) +Env: -- `GET /observability/*` - All observability endpoints -- `WS /ws/observability` - Real-time observability +- `FIREBASE_SERVICE_ACCOUNT` (path to service account JSON) +- `FIRESTORE_DATABASE_ID` or `FIREBASE_DATABASE_ID` (optional) -### Public Routes (Default) +```typescript +import { initializeApp, cert } from "firebase-admin/app"; +import { getAuth } from "firebase-admin/auth"; -These remain public unless `defaultPrivate: true`: +const app = initializeApp({ + credential: cert(process.env.FIREBASE_SERVICE_ACCOUNT!), +}); -#### Management +const provider: AuthProvider = { + type: "firebase", + async verifyToken(token) { + return getAuth(app).verifyIdToken(token); + }, +}; +``` -- `GET /agents` - List agents -- `GET /agents/:id` - Get agent details -- `GET /workflows` - List workflows -- `GET /workflows/executions` - List workflow executions -- `GET /workflows/:id` - Get workflow details +#### Supabase -#### Documentation +Env: -- `GET /` - Landing page -- `GET /doc` - OpenAPI spec -- `GET /ui` - Swagger UI +- `SUPABASE_URL` +- `SUPABASE_ANON_KEY` -#### Discovery +```typescript +import { createClient } from "@supabase/supabase-js"; -- `GET /agents/:id/card` - Agent card (A2A) -- `GET /mcp/servers` - MCP servers -- `GET /mcp/servers/:id` - MCP server details +const client = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!); -### Error Responses +const provider: AuthProvider = { + type: "supabase", + async verifyToken(token) { + const { data, error } = await client.auth.getUser(token); + if (error) { + throw error; + } + return data.user; + }, +}; +``` -Authentication failures return consistent JSON errors: +#### Better Auth -```json -// No token provided -{ - "success": false, - "error": "Authentication required" -} +Env: -// Invalid token -{ - "success": false, - "error": "Invalid token: jwt malformed" -} +- `DATABASE_URL` (or whatever your Better Auth setup requires) -// Expired token -{ - "success": false, - "error": "Token expired" -} -``` +```typescript +import { betterAuth } from "better-auth"; -## Troubleshooting +const auth = betterAuth({ + // Your Better Auth configuration +}); -### Console Shows 401 Errors +const provider: AuthProvider = { + type: "better-auth", + async verifyToken(token, request) { + const headers = new Headers(); + const authHeader = request?.headers?.get("authorization"); + headers.set("Authorization", authHeader ?? `Bearer ${token}`); -**Problem**: VoltAgent Console displays authentication errors. + const cookie = request?.headers?.get("cookie"); + if (cookie) { + headers.set("Cookie", cookie); + } -**Solution for Development**: + const result = await auth.api.getSession({ headers }); + if (!result?.user) { + return null; + } -```bash -# Ensure NODE_ENV is not "production" -unset NODE_ENV -# or -NODE_ENV=development npm run dev + return result.user; + }, +}; ``` -**Solution for Production**: +These providers authenticate **user routes** only. Console routes still use the Console Access Key. -```bash -# Set Console Access Key on server -export VOLTAGENT_CONSOLE_ACCESS_KEY=your-key-here -export NODE_ENV=production +## WebSocket Authentication -# Console will prompt for the key - enter the same value -``` +VoltAgent WebSocket endpoints are used by the Console (logs, observability). Browsers cannot send headers in the handshake, so use query params: -### WebSocket Connection Fails +```javascript +// Console auth (observability, logs) +const wsConsole = new WebSocket(`ws://localhost:3141/ws/observability?key=${consoleKey}`); -**Problem**: WebSocket connections are rejected with 401. +// Dev bypass (non-production only) +const wsDev = new WebSocket("ws://localhost:3141/ws/observability?dev=true"); +``` -**Common Causes**: +If you expose **custom WebSocket endpoints**, include them in your authNext route patterns and use `?token=` for user access or `?key=` for console access. -1. **Missing token in query params** - Browsers can't send headers in WebSocket handshake -2. **Expired JWT token** - Generate a new token -3. **Wrong authentication method** - Use JWT for user endpoints, Console Key for observability +## Testing Your Authentication -**Solution**: +### Generate a Test Token + +Create `generate-token.js`: ```javascript -// Correct: Token in query params -const ws = new WebSocket(`ws://localhost:3141/ws?token=${token}`); +import jwt from "jsonwebtoken"; +import dotenv from "dotenv"; -// Wrong: Trying to send headers (doesn't work in browsers) -const ws = new WebSocket("ws://localhost:3141/ws", { - headers: { Authorization: `Bearer ${token}` }, // ❌ Won't work -}); +dotenv.config(); + +const token = jwt.sign( + { + id: "test-user", + email: "test@example.com", + role: "admin", + }, + process.env.JWT_SECRET, + { expiresIn: "24h" } +); + +console.log("Token:", token); ``` -### Mixed Authentication Issues +### Test Protected Endpoints (authNext) + +```bash +# Execution requires JWT +curl -X POST http://localhost:3141/agents/my-agent/text \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -d '{"input": "Hello"}' -**Problem**: Some endpoints work, others return 401. +# Management requires Console Key +curl http://localhost:3141/agents \ + -H "x-console-access-key: YOUR_CONSOLE_KEY" +``` -**Remember the dual authentication**: +## Troubleshooting -- **User endpoints** (`/agents/*/text`, `/workflows/*/run`) → JWT token -- **Observability** (`/observability/*`) → Console Access Key or dev bypass -- **WebSockets** → Query parameters for both types +### Console Shows 401 Errors -### Console Access Key Not Working +**Production**: -**Problem**: Entered key but still getting 401. +```bash +export NODE_ENV=production +export VOLTAGENT_CONSOLE_ACCESS_KEY=your-key +``` -**Checklist**: +Then provide the same key via header or `?key=`. -1. Verify server has the key: +**Development**: - ```bash - echo $VOLTAGENT_CONSOLE_ACCESS_KEY - ``` +```bash +# Ensure NODE_ENV is not "production" +unset NODE_ENV +``` -2. Check NODE_ENV: +### WebSocket Connection Fails - ```bash - echo $NODE_ENV # Should be "production" if using key - ``` +Common causes: -3. Clear browser storage: - - Open DevTools → Application → Local Storage - - Delete `voltagent_console_access_key` - - Refresh and re-enter key +1. Missing `?key=` (console) or `?token=` (custom user WS) +2. `NODE_ENV=production` with dev bypass headers +3. Using console key on user routes or JWT on console routes -4. Verify key format (no extra spaces): +### Mixed Authentication Issues - ```javascript - // Correct - VOLTAGENT_CONSOLE_ACCESS_KEY = abc123; +Remember the authNext split: - // Wrong (has quotes) - VOLTAGENT_CONSOLE_ACCESS_KEY = "abc123"; - ``` +- **User routes** (execution) -> JWT +- **Console routes** (management, docs, observability, updates) -> Console Key +- **Public routes** -> no auth ## Security Best Practices ### 1. Use Environment Variables ```typescript -// ❌ Bad: Hardcoded secret -const auth = jwtAuth({ - secret: "my-secret-key", -}); - -// ✅ Good: Environment variable -const auth = jwtAuth({ +// Good: Environment variable +const provider = jwtAuth({ secret: process.env.JWT_SECRET!, }); - -// ✅ Better: With validation -if (!process.env.JWT_SECRET) { - throw new Error("JWT_SECRET environment variable is required"); -} -const auth = jwtAuth({ - secret: process.env.JWT_SECRET, -}); ``` ### 2. Generate Strong Secrets ```bash -# Generate secure random keys openssl rand -hex 32 - -# Or using Node.js -node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" ``` ### 3. Use HTTPS in Production ```typescript -// Enforce HTTPS in production if (process.env.NODE_ENV === "production") { app.use(async (c, next) => { if (c.req.header("x-forwarded-proto") !== "https") { @@ -593,41 +536,84 @@ if (process.env.NODE_ENV === "production") { } ``` -### 4. Implement Token Refresh +## Next Steps + +- Learn about [Custom Endpoints](./custom-endpoints.md) +- See [Streaming](./streaming.md) +- Read about [Agent Endpoints](./endpoints/agents.md) +- Set up [Observability](../observability/developer-console.md) + +## Legacy `auth` (Deprecated) + +Legacy auth uses two default lists: + +- **DEFAULT_PUBLIC_ROUTES**: management, docs, discovery +- **PROTECTED_ROUTES**: execution, tool execution, observability, updates + +When `auth` is enabled: + +- **Execution endpoints** require JWT +- **Management and docs** remain public +- `defaultPrivate: true` only protects **custom/unknown routes**, but does **not** override DEFAULT_PUBLIC_ROUTES + +If you need `/agents`, `/workflows`, `/doc`, or `/ui` protected, use **authNext**. + +### Legacy Behavior Summary + +| Endpoint Type | Legacy Auth (Default) | Legacy Auth (defaultPrivate: true) | +| --------------------------------- | --------------------- | ---------------------------------- | +| Execution (`POST /agents/*/text`) | Protected | Protected | +| Management (`GET /agents`) | Public | Public | +| Docs (`/doc`, `/ui`) | Public | Public | +| Custom routes | Public | Protected | + +Observability and updates still require Console Access Key in production. + +### Legacy Usage + +Minimal setup with JWT: ```typescript -// Short-lived access tokens with refresh tokens -const accessToken = createJWT(payload, secret, { expiresIn: "15m" }); -const refreshToken = createJWT(payload, refreshSecret, { expiresIn: "7d" }); +import { VoltAgent } from "@voltagent/core"; +import { honoServer } from "@voltagent/server-hono"; +import { jwtAuth } from "@voltagent/server-core"; -// Add refresh endpoint to publicRoutes -publicRoutes: ["POST /auth/refresh"]; +new VoltAgent({ + agents: { myAgent }, + server: honoServer({ + auth: jwtAuth({ + secret: process.env.JWT_SECRET!, + }), + }), +}); ``` -### 5. Rate Limiting +### Legacy Route Controls + +`auth` supports `publicRoutes` and `defaultPrivate` on the provider: ```typescript -import { rateLimiter } from "hono-rate-limiter"; - -server: honoServer({ - configureApp: (app) => { - app.use( - "/agents/*/text", - rateLimiter({ - windowMs: 15 * 60 * 1000, // 15 minutes - limit: 100, // Max 100 requests - standardHeaders: "draft-6", - keyGenerator: (c) => c.req.header("x-forwarded-for") || "anonymous", - }) - ); - }, - auth, -}); +auth: jwtAuth({ + secret: process.env.JWT_SECRET!, + + // Protect all unknown/custom routes by default + defaultPrivate: true, + + // Add additional public routes + publicRoutes: ["GET /health", "POST /webhooks/*"], +}), ``` -## Next Steps +Important behavior: + +- `publicRoutes` are **added** to the default public list +- `defaultPrivate: true` does **not** make default public routes private +- To protect management or docs endpoints, switch to **authNext** + +### Legacy Route Pattern Syntax + +Legacy route patterns use the same matcher as authNext: -- Learn about [Custom Endpoints](./custom-endpoints.md) with authentication -- Explore [Streaming](./streaming.md) with authenticated connections -- Read about [Agent Endpoints](./endpoints/agents.md) in detail -- Set up [Observability](../observability/developer-console.md) with Console authentication +- `GET /agents/:id` +- `/observability/*` +- `/ws/observability/**` From 593f667204ee2b215eefcfd67ad087cc4fb309ef Mon Sep 17 00:00:00 2001 From: Omer Aplak Date: Tue, 23 Dec 2025 07:37:48 -0800 Subject: [PATCH 2/4] docs: update docs --- website/docs/api/authentication.md | 44 ++++++++++++++++++- website/recipes/authentication.md | 69 ++++++++++++++++++++++-------- 2 files changed, 93 insertions(+), 20 deletions(-) diff --git a/website/docs/api/authentication.md b/website/docs/api/authentication.md index d7839909f..e63776864 100644 --- a/website/docs/api/authentication.md +++ b/website/docs/api/authentication.md @@ -5,7 +5,7 @@ sidebar_label: Authentication # Authentication -VoltAgent supports optional authentication. The recommended policy is **authNext**, which protects all routes by default and separates **console access** from **user access**. The legacy `auth` option is still supported but deprecated. +VoltAgent supports optional authentication. For new integrations, use **authNext**, which treats all routes as private by default and separates **console access** from **user access**. The legacy `auth` option is still supported but deprecated. ## Quick Start @@ -48,6 +48,48 @@ new VoltAgent({ Legacy `auth` is still supported for existing integrations. See the **Legacy auth** section at the end for details. +## Concepts + +- **User token**: A JWT from your identity provider, sent in `Authorization: Bearer `. +- **Console access key**: A static key for management, docs, observability, and updates endpoints. Set `VOLTAGENT_CONSOLE_ACCESS_KEY` and send `x-console-access-key` (or `?key=` for WebSocket). +- **Public routes**: Endpoints that bypass auth. You control them. +- **Dev bypass**: `x-voltagent-dev: true` is accepted only when `NODE_ENV` is not `"production"`. + +## How Authentication Works + +When auth is enabled, VoltAgent evaluates each request based on the configured mode. + +### authNext + +1. Match `publicRoutes` (authNext + provider). If matched, request is public. +2. Match `consoleRoutes` (authNext or defaults). If matched, require console key or dev bypass. +3. Everything else requires a user token (JWT) or dev bypass. + +### Legacy auth + +1. Routes in `DEFAULT_PUBLIC_ROUTES` are always public. +2. Routes in `PROTECTED_ROUTES` require a user token (JWT). +3. `defaultPrivate: true` applies to custom routes only. + +## Route Access Summary + +Default access by endpoint group: + +| Endpoint Group | Examples | authNext Access | Legacy auth Access | +| -------------- | ------------------------------------------------------------------------------- | --------------------------- | ------------------ | +| Execution | `POST /agents/:id/text`, `POST /workflows/:id/run`, `POST /tools/:name/execute` | User token (JWT) | User token (JWT) | +| Management | `GET /agents`, `GET /workflows`, `GET /tools` | Console key | Public | +| Docs + UI | `GET /`, `GET /doc`, `GET /ui` | Console key | Public | +| Discovery | `GET /mcp/servers`, `GET /agents/:id/card` | Console key | Public | +| Observability | `/observability/*`, `GET /api/logs`, `WS /ws/observability/**` | Console key | Console key or JWT | +| Updates | `GET /updates`, `POST /updates`, `POST /updates/:packageName` | Console key | Console key or JWT | +| Custom routes | `GET /health`, `POST /webhooks/*` | User token (JWT) by default | Public by default | + +Notes: + +- WebSocket console endpoints require `?key=` or `?dev=true` in non-production. +- In legacy auth, `defaultPrivate: true` changes custom routes only; it does not change default public routes. + ## authNext: Policy Model (Recommended) authNext is a policy layer that decides **how each route is accessed**: diff --git a/website/recipes/authentication.md b/website/recipes/authentication.md index 14183ec43..90e340adb 100644 --- a/website/recipes/authentication.md +++ b/website/recipes/authentication.md @@ -2,45 +2,72 @@ id: authentication title: Authentication slug: authentication -description: Add JWT authentication to your VoltAgent server. +description: Configure authNext with JWT and a console access key. --- # Authentication -Protect your VoltAgent endpoints with JWT authentication. +Use `authNext` to split access into **public**, **console**, and **user** routes. + +Defaults with authNext: + +- All routes are private unless listed in `publicRoutes`. +- Console routes require `VOLTAGENT_CONSOLE_ACCESS_KEY`. +- Execution routes require a user token (JWT). + +For details, see [Authentication API](../api/authentication.md). ## Quick Setup ```typescript import { openai } from "@ai-sdk/openai"; import { Agent, VoltAgent } from "@voltagent/core"; -import { honoServer, jwtAuth } from "@voltagent/server-hono"; +import { honoServer } from "@voltagent/server-hono"; +import { jwtAuth } from "@voltagent/server-core"; const agent = new Agent({ - name: "Protected Agent", - instructions: "A secure assistant", + name: "Agent", + instructions: "Assistant for auth example", model: openai("gpt-4o-mini"), }); new VoltAgent({ agents: { agent }, server: honoServer({ - auth: jwtAuth({ - secret: process.env.JWT_SECRET || "your-secret-key", - defaultPrivate: true, + authNext: { + provider: jwtAuth({ + secret: process.env.JWT_SECRET || "your-secret-key", + }), publicRoutes: ["GET /api/health"], - }), + }, }), }); ``` -## Making Authenticated Requests +## Console Access Key + +Set the key on the server: + +```bash +VOLTAGENT_CONSOLE_ACCESS_KEY=your-console-key +``` + +Send it for console routes (agents, workflows, tools, docs, observability, updates): + +```bash +curl http://localhost:3141/agents \ + -H "x-console-access-key: your-console-key" +``` + +## User Auth Requests Include the JWT token in the Authorization header: ```bash -curl -H "Authorization: Bearer " \ - http://localhost:3141/api/agents/agent/chat +curl -X POST http://localhost:3141/agents/agent/text \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{"input":"Hello"}' ``` ## Custom Public Routes @@ -48,18 +75,20 @@ curl -H "Authorization: Bearer " \ Define routes that don't require authentication: ```typescript -jwtAuth({ - secret: "your-secret", - defaultPrivate: true, +authNext: { + provider: jwtAuth({ secret: "your-secret" }), publicRoutes: ["GET /api/health", "GET /api/status"], -}); +}, ``` ## Adding Custom Endpoints ```typescript server: honoServer({ - auth: jwtAuth({ secret: "your-secret", defaultPrivate: true }), + authNext: { + provider: jwtAuth({ secret: "your-secret" }), + publicRoutes: ["GET /api/health"], + }, configureApp: (app) => { app.get("/api/health", (c) => c.json({ status: "ok" })); app.get("/api/protected", (c) => c.json({ message: "Authenticated!" })); @@ -67,6 +96,8 @@ server: honoServer({ }); ``` -## Full Example +Custom endpoints are treated as **user** routes by default. Add them to `publicRoutes` if they should be public. + +## Example -See the complete example: [with-auth on GitHub](https://github.com/VoltAgent/voltagent/tree/main/examples/with-auth) +See the example: [with-auth on GitHub](https://github.com/VoltAgent/voltagent/tree/main/examples/with-auth) From 516d3761c668f72f6e3fb540e75540b2f6eccf38 Mon Sep 17 00:00:00 2001 From: Omer Aplak Date: Tue, 23 Dec 2025 07:41:31 -0800 Subject: [PATCH 3/4] chore: docs update --- website/docs/api/custom-endpoints.md | 81 +++++-------------- .../docs/getting-started/migration-guide.md | 20 +++-- 2 files changed, 34 insertions(+), 67 deletions(-) diff --git a/website/docs/api/custom-endpoints.md b/website/docs/api/custom-endpoints.md index de31a8364..87c3d74e1 100644 --- a/website/docs/api/custom-endpoints.md +++ b/website/docs/api/custom-endpoints.md @@ -503,11 +503,11 @@ configureApp: (app) => { ## Authentication for Custom Endpoints -**Important**: Custom routes added via `configureApp` are registered AFTER the authentication middleware. This means when you configure an auth provider, your custom routes automatically inherit the same authentication behavior as VoltAgent's built-in routes. When `defaultPrivate: true` is set, all custom routes are protected by default unless explicitly listed in `publicRoutes`. +**Important**: Custom routes added via `configureApp` are registered AFTER the authentication middleware. This means your custom routes follow the same auth rules as built-in routes. ### How Authentication Works with Custom Routes -VoltAgent applies authentication middleware to all routes before `configureApp` is called. This ensures your custom endpoints have the same security posture as built-in endpoints. +VoltAgent applies authentication middleware before `configureApp`: ```typescript // Authentication flow: @@ -517,83 +517,46 @@ VoltAgent applies authentication middleware to all routes before `configureApp` // 4. configureApp called → your custom routes registered ``` -### Opt-In Mode (Default) +### authNext (Default Behavior) -By default (`defaultPrivate: false`), only execution endpoints require authentication. Custom routes are **public** unless they match a protected pattern: +With `authNext`, custom routes are **user** routes by default. To make a route public, add it to `authNext.publicRoutes`. To make a route console-only, add it to `authNext.consoleRoutes`. ```typescript -import { jwtAuth } from "@voltagent/server-core"; +import { DEFAULT_CONSOLE_ROUTES, jwtAuth } from "@voltagent/server-core"; new VoltAgent({ agents: { myAgent }, server: honoServer({ - auth: jwtAuth({ - secret: process.env.JWT_SECRET, - // defaultPrivate: false (default) - }), - configureApp: (app) => { - // Public endpoint (doesn't match protected patterns) - app.get("/api/public-data", (c) => { - return c.json({ data: "anyone can access this" }); - }); - - // Also public (you can access authenticatedUser if it exists) - app.get("/api/optional-auth", (c) => { - const user = c.get("authenticatedUser"); - if (user) { - return c.json({ message: `Hello, ${user.email}` }); - } - return c.json({ message: "Hello, anonymous" }); - }); + authNext: { + provider: jwtAuth({ secret: process.env.JWT_SECRET! }), + publicRoutes: ["GET /api/health"], + consoleRoutes: [...DEFAULT_CONSOLE_ROUTES, "GET /api/admin/metrics"], }, - }), -}); -``` - -### Opt-Out Mode (Recommended) - -Set `defaultPrivate: true` to protect **all routes by default**, including custom endpoints. Then selectively make routes public using `publicRoutes`: - -```typescript -import { jwtAuth } from "@voltagent/server-core"; - -new VoltAgent({ - agents: { myAgent }, - server: honoServer({ - auth: jwtAuth({ - secret: process.env.JWT_SECRET, - defaultPrivate: true, // Protect all routes by default - publicRoutes: ["GET /api/health", "GET /api/status", "POST /api/webhooks/*"], - }), configureApp: (app) => { - // Public endpoint (in publicRoutes) + // Public route app.get("/api/health", (c) => c.json({ status: "ok" })); - // Protected endpoint (requires authentication) - app.get("/api/user/profile", (c) => { - const user = c.get("authenticatedUser"); - return c.json({ user }); // user is guaranteed to exist - }); + // Console-only route + app.get("/api/admin/metrics", (c) => c.json({ ok: true })); - // All custom routes are protected unless in publicRoutes - app.post("/api/data", async (c) => { + // User route (JWT required) + app.get("/api/user/profile", (c) => { const user = c.get("authenticatedUser"); - const body = await c.req.json(); - // Process authenticated request - return c.json({ success: true, userId: user.id }); + return c.json({ user }); }); }, }), }); ``` -**Benefits of Opt-Out Mode**: +Notes: + +- `consoleRoutes` replaces the default console list. Include `DEFAULT_CONSOLE_ROUTES` if you want the built-in console endpoints to stay console-protected. +- If `NODE_ENV` is not `"production"`, you can send `x-voltagent-dev: true` to bypass auth. + +### Legacy auth (Deprecated) -- ✅ Automatic protection for all custom endpoints -- ✅ No need to manually check authentication in each route -- ✅ Better security by default (fail-safe) -- ✅ Easier to maintain when using third-party auth providers (Clerk, Auth0) -- ✅ Consistent auth behavior across all routes +Legacy `auth` uses `DEFAULT_PUBLIC_ROUTES` and `PROTECTED_ROUTES`. Custom routes are public by default unless you set `defaultPrivate: true`. See [Authentication](./authentication.md) for details. ## Best Practices diff --git a/website/docs/getting-started/migration-guide.md b/website/docs/getting-started/migration-guide.md index 2eafdb4e2..a12bfa61e 100644 --- a/website/docs/getting-started/migration-guide.md +++ b/website/docs/getting-started/migration-guide.md @@ -143,8 +143,11 @@ new VoltAgent({ configureApp: (app) => { app.get("/api/health", (c) => c.json({ status: "ok" })); }, - // JWT auth (optional) - // auth: jwtAuth({ secret: process.env.JWT_SECRET!, publicRoutes: ["/health", "/metrics"] }), + // Auth (optional) + // authNext: { + // provider: jwtAuth({ secret: process.env.JWT_SECRET! }), + // publicRoutes: ["GET /health", "GET /metrics"], + // }, }), }); ``` @@ -507,18 +510,19 @@ new VoltAgent({ ### Authentication (optional) -`@voltagent/server-hono` provides JWT auth. Example: +Use `authNext` to separate public, console, and user routes: ```ts -import { honoServer, jwtAuth } from "@voltagent/server-hono"; +import { honoServer } from "@voltagent/server-hono"; +import { jwtAuth } from "@voltagent/server-core"; new VoltAgent({ agents: { agent }, server: honoServer({ - auth: jwtAuth({ - secret: process.env.JWT_SECRET!, - publicRoutes: ["/health", "/metrics"], - }), + authNext: { + provider: jwtAuth({ secret: process.env.JWT_SECRET! }), + publicRoutes: ["GET /health", "GET /metrics"], + }, }), }); ``` From 8ca41abc2eacfbb784b763fbc0bc2d16a25f7170 Mon Sep 17 00:00:00 2001 From: Omer Aplak Date: Tue, 23 Dec 2025 08:19:07 -0800 Subject: [PATCH 4/4] chore: fix default.ts --- packages/server-core/src/auth/defaults.ts | 52 +++++++++++++++++++++-- packages/server-core/src/auth/next.ts | 21 +-------- website/docs/api/authentication.md | 8 ++-- website/docs/api/custom-endpoints.md | 2 +- website/recipes/authentication.md | 2 +- 5 files changed, 55 insertions(+), 30 deletions(-) diff --git a/packages/server-core/src/auth/defaults.ts b/packages/server-core/src/auth/defaults.ts index 099132dda..0cd782cb5 100644 --- a/packages/server-core/src/auth/defaults.ts +++ b/packages/server-core/src/auth/defaults.ts @@ -3,10 +3,9 @@ */ /** - * Routes that don't require authentication by default - * These are typically used by VoltOps and management tools + * Routes that don't require authentication by default (legacy auth) */ -export const DEFAULT_PUBLIC_ROUTES = [ +export const DEFAULT_LEGACY_PUBLIC_ROUTES = [ // Agent management endpoints (VoltOps uses these) "GET /agents", // List all agents "GET /agents/:id", // Get agent details @@ -32,6 +31,51 @@ export const DEFAULT_PUBLIC_ROUTES = [ "GET /agents/:id/card", ]; +// Backward compatibility alias +export const DEFAULT_PUBLIC_ROUTES = DEFAULT_LEGACY_PUBLIC_ROUTES; + +/** + * Routes that require console access when authNext is enabled + */ +export const DEFAULT_CONSOLE_ROUTES = [ + // Agent management endpoints (VoltOps uses these) + "GET /agents", // List all agents + "GET /agents/:id", // Get agent details + + // Workflow management endpoints + "GET /workflows", // List all workflows + "GET /workflows/:id", // Get workflow details + + // Tool management endpoints + "GET /tools", // List all tools + + // API documentation + "GET /doc", // OpenAPI spec + "GET /ui", // Swagger UI + "GET /", // Landing page + + // MCP (public discovery) + "GET /mcp/servers", + "GET /mcp/servers/:serverId", + "GET /mcp/servers/:serverId/tools", + + // A2A (agent-to-agent discovery) + "GET /agents/:id/card", + + "GET /agents/:id/history", + "GET /workflows/executions", + "GET /workflows/:id/executions/:executionId/state", + "GET /api/logs", + "POST /setup-observability", + "/observability/*", + "GET /updates", + "POST /updates", + "POST /updates/:packageName", + "WS /ws", + "WS /ws/logs", + "WS /ws/observability/**", +]; + /** * Routes that require authentication by default * These endpoints execute operations, modify state, or access sensitive data @@ -171,7 +215,7 @@ export function requiresAuth( defaultPrivate?: boolean, ): boolean { // Check if it's a default public route - for (const publicRoute of DEFAULT_PUBLIC_ROUTES) { + for (const publicRoute of DEFAULT_LEGACY_PUBLIC_ROUTES) { if (publicRoute.includes(" ")) { // Route with method specified const [routeMethod, routePath] = publicRoute.split(" "); diff --git a/packages/server-core/src/auth/next.ts b/packages/server-core/src/auth/next.ts index fc1726a33..eac9e74cd 100644 --- a/packages/server-core/src/auth/next.ts +++ b/packages/server-core/src/auth/next.ts @@ -1,4 +1,4 @@ -import { DEFAULT_PUBLIC_ROUTES, pathMatches } from "./defaults"; +import { DEFAULT_CONSOLE_ROUTES, pathMatches } from "./defaults"; import type { AuthProvider } from "./types"; export type AuthNextAccess = "public" | "console" | "user"; @@ -24,25 +24,6 @@ export function normalizeAuthNextConfig( return isAuthNextConfig(value) ? value : { provider: value }; } -/** - * Console routes require console access when authNext is enabled. - */ -export const DEFAULT_CONSOLE_ROUTES = [ - ...DEFAULT_PUBLIC_ROUTES, - "GET /agents/:id/history", - "GET /workflows/executions", - "GET /workflows/:id/executions/:executionId/state", - "GET /api/logs", - "POST /setup-observability", - "/observability/*", - "GET /updates", - "POST /updates", - "POST /updates/:packageName", - "WS /ws", - "WS /ws/logs", - "WS /ws/observability/**", -]; - function routeMatches(method: string, path: string, routePattern: string): boolean { const parts = routePattern.split(" "); if (parts.length === 2) { diff --git a/website/docs/api/authentication.md b/website/docs/api/authentication.md index e63776864..e310207b3 100644 --- a/website/docs/api/authentication.md +++ b/website/docs/api/authentication.md @@ -67,7 +67,7 @@ When auth is enabled, VoltAgent evaluates each request based on the configured m ### Legacy auth -1. Routes in `DEFAULT_PUBLIC_ROUTES` are always public. +1. Routes in `DEFAULT_LEGACY_PUBLIC_ROUTES` (alias `DEFAULT_PUBLIC_ROUTES`) are always public. 2. Routes in `PROTECTED_ROUTES` require a user token (JWT). 3. `defaultPrivate: true` applies to custom routes only. @@ -154,7 +154,7 @@ By default, authNext treats these as **console** routes (Console Key required): - `WS /ws/logs` - `WS /ws/observability/**` -This list is defined in `packages/server-core/src/auth/next.ts`. +This list is defined in `packages/server-core/src/auth/defaults.ts`. ### Route Pattern Syntax @@ -589,14 +589,14 @@ if (process.env.NODE_ENV === "production") { Legacy auth uses two default lists: -- **DEFAULT_PUBLIC_ROUTES**: management, docs, discovery +- **DEFAULT_LEGACY_PUBLIC_ROUTES** (alias `DEFAULT_PUBLIC_ROUTES`): management, docs, discovery - **PROTECTED_ROUTES**: execution, tool execution, observability, updates When `auth` is enabled: - **Execution endpoints** require JWT - **Management and docs** remain public -- `defaultPrivate: true` only protects **custom/unknown routes**, but does **not** override DEFAULT_PUBLIC_ROUTES +- `defaultPrivate: true` only protects **custom/unknown routes**, but does **not** override `DEFAULT_LEGACY_PUBLIC_ROUTES` (alias `DEFAULT_PUBLIC_ROUTES`) If you need `/agents`, `/workflows`, `/doc`, or `/ui` protected, use **authNext**. diff --git a/website/docs/api/custom-endpoints.md b/website/docs/api/custom-endpoints.md index 87c3d74e1..a511f1091 100644 --- a/website/docs/api/custom-endpoints.md +++ b/website/docs/api/custom-endpoints.md @@ -556,7 +556,7 @@ Notes: ### Legacy auth (Deprecated) -Legacy `auth` uses `DEFAULT_PUBLIC_ROUTES` and `PROTECTED_ROUTES`. Custom routes are public by default unless you set `defaultPrivate: true`. See [Authentication](./authentication.md) for details. +Legacy `auth` uses `DEFAULT_LEGACY_PUBLIC_ROUTES` (alias `DEFAULT_PUBLIC_ROUTES`) and `PROTECTED_ROUTES`. Custom routes are public by default unless you set `defaultPrivate: true`. See [Authentication](./authentication.md) for details. ## Best Practices diff --git a/website/recipes/authentication.md b/website/recipes/authentication.md index 90e340adb..87d7ba509 100644 --- a/website/recipes/authentication.md +++ b/website/recipes/authentication.md @@ -15,7 +15,7 @@ Defaults with authNext: - Console routes require `VOLTAGENT_CONSOLE_ACCESS_KEY`. - Execution routes require a user token (JWT). -For details, see [Authentication API](../api/authentication.md). +For details, see [Authentication API](/docs/api/authentication). ## Quick Setup