Skip to content

Commit 9320326

Browse files
authored
feat: add authNext and deprecate legacy auth (#883)
* feat: add authNext and deprecate legacy auth * docs: update docs * chore: docs update * chore: fix default.ts
1 parent 528baca commit 9320326

File tree

19 files changed

+1040
-598
lines changed

19 files changed

+1040
-598
lines changed

.changeset/floppy-dogs-hear.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
"@voltagent/server-core": patch
3+
"@voltagent/server-hono": patch
4+
---
5+
6+
feat: add authNext and deprecate legacy auth
7+
8+
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.
9+
10+
AuthNext example:
11+
12+
```ts
13+
import { jwtAuth } from "@voltagent/server-core";
14+
import { honoServer } from "@voltagent/server-hono";
15+
16+
const server = honoServer({
17+
authNext: {
18+
provider: jwtAuth({ secret: process.env.JWT_SECRET! }),
19+
publicRoutes: ["GET /health"],
20+
},
21+
});
22+
```
23+
24+
Behavior summary:
25+
26+
- When `authNext` is set, all routes are private by default.
27+
- Console endpoints (agents, workflows, tools, docs, observability, updates) require a Console Access Key.
28+
- Execution endpoints require a user token (JWT).
29+
30+
Console access uses `VOLTAGENT_CONSOLE_ACCESS_KEY`:
31+
32+
```bash
33+
VOLTAGENT_CONSOLE_ACCESS_KEY=your-console-key
34+
```
35+
36+
```bash
37+
curl http://localhost:3141/agents \
38+
-H "x-console-access-key: your-console-key"
39+
```
40+
41+
Legacy `auth` remains supported but is deprecated. Use `authNext` for new integrations.

examples/with-auth/src/index.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { openai } from "@ai-sdk/openai";
22
import { Agent, Memory, VoltAgent } from "@voltagent/core";
33
import { createPinoLogger } from "@voltagent/logger";
4-
import { honoServer, jwtAuth } from "@voltagent/server-hono";
4+
import { authNext, honoServer, jwtAuth } from "@voltagent/server-hono";
55

66
// Import Memory and TelemetryStore from core
77
import { AiSdkEmbeddingAdapter, InMemoryVectorAdapter } from "@voltagent/core";
@@ -34,11 +34,12 @@ const agent = new Agent({
3434
new VoltAgent({
3535
agents: { agent },
3636
server: honoServer({
37-
auth: jwtAuth({
38-
secret: "super-secret",
39-
defaultPrivate: true,
40-
publicRoutes: ["GET /api/health"],
41-
}),
37+
authNext: {
38+
provider: jwtAuth({
39+
secret: "super-secret",
40+
}),
41+
publicRoutes: ["/api/health"],
42+
},
4243
configureApp: (app) => {
4344
app.get("/api/health", (c) => c.json({ status: "ok" }));
4445
app.get("/api/protected", (c) => c.json({ message: "This is protected" }));

packages/server-core/src/auth/defaults.ts

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,9 @@
33
*/
44

55
/**
6-
* Routes that don't require authentication by default
7-
* These are typically used by VoltOps and management tools
6+
* Routes that don't require authentication by default (legacy auth)
87
*/
9-
export const DEFAULT_PUBLIC_ROUTES = [
8+
export const DEFAULT_LEGACY_PUBLIC_ROUTES = [
109
// Agent management endpoints (VoltOps uses these)
1110
"GET /agents", // List all agents
1211
"GET /agents/:id", // Get agent details
@@ -32,6 +31,51 @@ export const DEFAULT_PUBLIC_ROUTES = [
3231
"GET /agents/:id/card",
3332
];
3433

34+
// Backward compatibility alias
35+
export const DEFAULT_PUBLIC_ROUTES = DEFAULT_LEGACY_PUBLIC_ROUTES;
36+
37+
/**
38+
* Routes that require console access when authNext is enabled
39+
*/
40+
export const DEFAULT_CONSOLE_ROUTES = [
41+
// Agent management endpoints (VoltOps uses these)
42+
"GET /agents", // List all agents
43+
"GET /agents/:id", // Get agent details
44+
45+
// Workflow management endpoints
46+
"GET /workflows", // List all workflows
47+
"GET /workflows/:id", // Get workflow details
48+
49+
// Tool management endpoints
50+
"GET /tools", // List all tools
51+
52+
// API documentation
53+
"GET /doc", // OpenAPI spec
54+
"GET /ui", // Swagger UI
55+
"GET /", // Landing page
56+
57+
// MCP (public discovery)
58+
"GET /mcp/servers",
59+
"GET /mcp/servers/:serverId",
60+
"GET /mcp/servers/:serverId/tools",
61+
62+
// A2A (agent-to-agent discovery)
63+
"GET /agents/:id/card",
64+
65+
"GET /agents/:id/history",
66+
"GET /workflows/executions",
67+
"GET /workflows/:id/executions/:executionId/state",
68+
"GET /api/logs",
69+
"POST /setup-observability",
70+
"/observability/*",
71+
"GET /updates",
72+
"POST /updates",
73+
"POST /updates/:packageName",
74+
"WS /ws",
75+
"WS /ws/logs",
76+
"WS /ws/observability/**",
77+
];
78+
3579
/**
3680
* Routes that require authentication by default
3781
* These endpoints execute operations, modify state, or access sensitive data
@@ -171,7 +215,7 @@ export function requiresAuth(
171215
defaultPrivate?: boolean,
172216
): boolean {
173217
// Check if it's a default public route
174-
for (const publicRoute of DEFAULT_PUBLIC_ROUTES) {
218+
for (const publicRoute of DEFAULT_LEGACY_PUBLIC_ROUTES) {
175219
if (publicRoute.includes(" ")) {
176220
// Route with method specified
177221
const [routeMethod, routePath] = publicRoute.split(" ");

packages/server-core/src/auth/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
export * from "./types";
77
export * from "./defaults";
8+
export * from "./next";
89
export * from "./utils";
910

1011
// Export auth providers
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { describe, expect, it } from "vitest";
2+
import { resolveAuthNextAccess } from "./next";
3+
import type { AuthProvider } from "./types";
4+
5+
const mockProvider: AuthProvider = {
6+
type: "jwt",
7+
verifyToken: async () => ({ id: "user-1" }),
8+
publicRoutes: ["GET /provider-public"],
9+
};
10+
11+
describe("resolveAuthNextAccess", () => {
12+
it("treats explicit publicRoutes as public", () => {
13+
const config = { provider: mockProvider, publicRoutes: ["GET /health"] };
14+
expect(resolveAuthNextAccess("GET", "/health", config)).toBe("public");
15+
});
16+
17+
it("treats provider publicRoutes as public", () => {
18+
const config = { provider: mockProvider };
19+
expect(resolveAuthNextAccess("GET", "/provider-public", config)).toBe("public");
20+
});
21+
22+
it("treats default console routes as console", () => {
23+
const config = { provider: mockProvider };
24+
expect(resolveAuthNextAccess("GET", "/agents", config)).toBe("console");
25+
});
26+
27+
it("treats non-console routes as user", () => {
28+
const config = { provider: mockProvider };
29+
expect(resolveAuthNextAccess("POST", "/agents/test-agent/text", config)).toBe("user");
30+
});
31+
32+
it("treats websocket test connection as console", () => {
33+
const config = { provider: mockProvider };
34+
expect(resolveAuthNextAccess("WS", "/ws", config)).toBe("console");
35+
});
36+
37+
it("allows publicRoutes to override console routes", () => {
38+
const config = { provider: mockProvider, publicRoutes: ["GET /agents"] };
39+
expect(resolveAuthNextAccess("GET", "/agents", config)).toBe("public");
40+
});
41+
42+
it("uses custom consoleRoutes when provided", () => {
43+
const config = { provider: mockProvider, consoleRoutes: ["GET /custom-console"] };
44+
expect(resolveAuthNextAccess("GET", "/custom-console", config)).toBe("console");
45+
expect(resolveAuthNextAccess("GET", "/agents", config)).toBe("user");
46+
});
47+
});
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { DEFAULT_CONSOLE_ROUTES, pathMatches } from "./defaults";
2+
import type { AuthProvider } from "./types";
3+
4+
export type AuthNextAccess = "public" | "console" | "user";
5+
6+
export interface AuthNextRoutesConfig {
7+
publicRoutes?: string[];
8+
consoleRoutes?: string[];
9+
}
10+
11+
export interface AuthNextConfig<TRequest = any> extends AuthNextRoutesConfig {
12+
provider: AuthProvider<TRequest>;
13+
}
14+
15+
export function isAuthNextConfig<TRequest>(
16+
value: AuthProvider<TRequest> | AuthNextConfig<TRequest>,
17+
): value is AuthNextConfig<TRequest> {
18+
return typeof (value as AuthNextConfig<TRequest>).provider !== "undefined";
19+
}
20+
21+
export function normalizeAuthNextConfig<TRequest>(
22+
value: AuthProvider<TRequest> | AuthNextConfig<TRequest>,
23+
): AuthNextConfig<TRequest> {
24+
return isAuthNextConfig(value) ? value : { provider: value };
25+
}
26+
27+
function routeMatches(method: string, path: string, routePattern: string): boolean {
28+
const parts = routePattern.split(" ");
29+
if (parts.length === 2) {
30+
const [routeMethod, routePath] = parts;
31+
if (method.toUpperCase() !== routeMethod.toUpperCase()) {
32+
return false;
33+
}
34+
return pathMatches(path, routePath);
35+
}
36+
37+
return pathMatches(path, routePattern);
38+
}
39+
40+
function matchesAnyRoute(method: string, path: string, routes?: string[]): boolean {
41+
if (!routes || routes.length === 0) {
42+
return false;
43+
}
44+
45+
return routes.some((route) => routeMatches(method, path, route));
46+
}
47+
48+
export function resolveAuthNextAccess<TRequest>(
49+
method: string,
50+
path: string,
51+
authNext: AuthNextConfig<TRequest> | AuthProvider<TRequest>,
52+
): AuthNextAccess {
53+
const config = normalizeAuthNextConfig(authNext);
54+
const publicRoutes = [...(config.publicRoutes ?? []), ...(config.provider.publicRoutes ?? [])];
55+
56+
if (matchesAnyRoute(method, path, publicRoutes)) {
57+
return "public";
58+
}
59+
60+
const consoleRoutes = config.consoleRoutes ?? DEFAULT_CONSOLE_ROUTES;
61+
if (matchesAnyRoute(method, path, consoleRoutes)) {
62+
return "console";
63+
}
64+
65+
return "user";
66+
}

packages/server-core/src/auth/utils.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ export function isDevRequest(req: Request): boolean {
5252
* // Production with console key
5353
* NODE_ENV=production + x-console-access-key=valid-key → true
5454
*
55+
* // Production with console key in query param
56+
* NODE_ENV=production + ?key=valid-key → true
57+
*
5558
* // Production without key
5659
* NODE_ENV=production + no key → false
5760
*
@@ -68,9 +71,11 @@ export function hasConsoleAccess(req: Request): boolean {
6871

6972
// 2. Console Access Key check (for production)
7073
const consoleKey = req.headers.get("x-console-access-key");
74+
const url = new URL(req.url, "http://localhost");
75+
const queryKey = url.searchParams.get("key");
7176
const configuredKey = process.env.VOLTAGENT_CONSOLE_ACCESS_KEY;
7277

73-
if (configuredKey && consoleKey === configuredKey) {
78+
if (configuredKey && (consoleKey === configuredKey || queryKey === configuredKey)) {
7479
return true;
7580
}
7681

0 commit comments

Comments
 (0)