From 4e8c26da0af882561a2aab1174a92426408bc814 Mon Sep 17 00:00:00 2001 From: johnyeo Date: Fri, 5 Jun 2026 17:50:17 +0100 Subject: [PATCH] chore: refactored mcp auth... again --- apps/leaf/README.md | 2 +- apps/leaf/package.json | 1 + apps/leaf/src/main.ts | 18 +- .../src/mcp/auth/protectedResourceMetadata.ts | 34 ++++ apps/leaf/src/mcp/auth/resolveRequestAuth.ts | 167 ++++++++++++++++++ apps/leaf/src/mcp/constants.ts | 3 + apps/leaf/src/mcp/handlers/handleMcp.ts | 71 ++++++++ .../handleProtectedResourceMetadata.ts | 12 ++ apps/leaf/src/mcp/http.ts | 78 -------- apps/leaf/src/mcp/mcpRouter.ts | 29 +++ apps/leaf/src/mcp/types.ts | 15 ++ .../leaf/tests/unit/mcp}/oauth.test.ts | 111 ++++++------ bun.lock | 48 ++++- docker/Dockerfile | 1 + package.json | 3 +- packages/auth/package.json | 36 ++++ packages/auth/src/index.ts | 2 + packages/auth/src/oauth/index.ts | 1 + packages/auth/src/oauth/oauthUrls.ts | 32 ++++ packages/auth/src/utils/getBearerToken.ts | 13 ++ packages/auth/src/utils/index.ts | 1 + packages/auth/tsconfig.json | 34 ++++ packages/mcp/README.md | 2 +- packages/mcp/package.json | 3 +- packages/mcp/src/index.ts | 13 +- packages/mcp/src/server/auth/auth.ts | 5 +- packages/mcp/src/server/auth/oauth.ts | 112 ------------ packages/mcp/src/server/auth/utils/errors.ts | 15 -- .../mcp/src/server/auth/utils/principal.ts | 17 -- packages/mcp/src/server/auth/utils/request.ts | 67 ------- packages/mcp/src/server/auth/utils/schemas.ts | 22 --- packages/mcp/src/server/auth/utils/urls.ts | 60 ------- server/package.json | 1 + .../honoMiddlewares/secretKeyMiddleware.ts | 9 +- 34 files changed, 586 insertions(+), 452 deletions(-) create mode 100644 apps/leaf/src/mcp/auth/protectedResourceMetadata.ts create mode 100644 apps/leaf/src/mcp/auth/resolveRequestAuth.ts create mode 100644 apps/leaf/src/mcp/constants.ts create mode 100644 apps/leaf/src/mcp/handlers/handleMcp.ts create mode 100644 apps/leaf/src/mcp/handlers/handleProtectedResourceMetadata.ts delete mode 100644 apps/leaf/src/mcp/http.ts create mode 100644 apps/leaf/src/mcp/mcpRouter.ts create mode 100644 apps/leaf/src/mcp/types.ts rename {packages/mcp/tests/unit/mcp-server => apps/leaf/tests/unit/mcp}/oauth.test.ts (63%) create mode 100644 packages/auth/package.json create mode 100644 packages/auth/src/index.ts create mode 100644 packages/auth/src/oauth/index.ts create mode 100644 packages/auth/src/oauth/oauthUrls.ts create mode 100644 packages/auth/src/utils/getBearerToken.ts create mode 100644 packages/auth/src/utils/index.ts create mode 100644 packages/auth/tsconfig.json delete mode 100644 packages/mcp/src/server/auth/oauth.ts delete mode 100644 packages/mcp/src/server/auth/utils/errors.ts delete mode 100644 packages/mcp/src/server/auth/utils/principal.ts delete mode 100644 packages/mcp/src/server/auth/utils/request.ts delete mode 100644 packages/mcp/src/server/auth/utils/schemas.ts delete mode 100644 packages/mcp/src/server/auth/utils/urls.ts diff --git a/apps/leaf/README.md b/apps/leaf/README.md index ed301a3b3..5527e52e5 100644 --- a/apps/leaf/README.md +++ b/apps/leaf/README.md @@ -1,6 +1,6 @@ # Autumn Leaf -Autumn's AI service: the Slack chat bot plus the hosted MCP routes (`src/mcp/http.ts`). +Autumn's AI service: the Slack chat bot plus the hosted MCP routes (`src/mcp/mcpRouter.ts`). Local Slack testing uses a normal Slack app in a development workspace. Keep the app undistributed while testing. diff --git a/apps/leaf/package.json b/apps/leaf/package.json index 8d0684771..060ccf12c 100644 --- a/apps/leaf/package.json +++ b/apps/leaf/package.json @@ -10,6 +10,7 @@ "ts": "tsc --noEmit" }, "dependencies": { + "@autumn/auth": "workspace:*", "@autumn/logging": "workspace:*", "@autumn/mcp": "workspace:*", "@autumn/shared": "workspace:*", diff --git a/apps/leaf/src/main.ts b/apps/leaf/src/main.ts index ce9cfcc98..fb00cd9e2 100644 --- a/apps/leaf/src/main.ts +++ b/apps/leaf/src/main.ts @@ -4,7 +4,7 @@ import { Hono } from "hono"; import { chatAdapterNames } from "./bot.js"; import { env } from "./lib/env.js"; import { logger } from "./lib/logger.js"; -import { registerMcpRoutes } from "./mcp/http.js"; +import { createMcpRouter } from "./mcp/mcpRouter.js"; import { slackRoutes } from "./providers/slack/routes.js"; const app = new Hono<{ Bindings: HttpBindings }>(); @@ -18,12 +18,16 @@ app.use("*", async (c, next) => { app.get("/health", (c) => c.json({ ok: true })); -registerMcpRoutes(app, { - "oauth-enabled": true, - "oauth-environment": env.MCP_OAUTH_ENVIRONMENT, - "server-url": env.BETTER_AUTH_URL, - logger, -}); +app.route( + "", + createMcpRouter({ + "oauth-enabled": true, + "oauth-environment": env.MCP_OAUTH_ENVIRONMENT, + "server-url": env.BETTER_AUTH_URL, + logger, + resourceUrl: new URL("/mcp", env.MCP_SERVER_URL).href, + }), +); app.route("/slack", slackRoutes); diff --git a/apps/leaf/src/mcp/auth/protectedResourceMetadata.ts b/apps/leaf/src/mcp/auth/protectedResourceMetadata.ts new file mode 100644 index 000000000..6c2dd5066 --- /dev/null +++ b/apps/leaf/src/mcp/auth/protectedResourceMetadata.ts @@ -0,0 +1,34 @@ +import { + getOAuthIssuerUrl, +} from "@autumn/auth/oauth"; +import { + DEFAULT_AUTUMN_API_URL, + MCP_OAUTH_SCOPES, +} from "@autumn/mcp"; + +export class OAuthHttpError extends Error { + constructor( + readonly status: number, + message: string, + readonly error = "invalid_token", + readonly wwwAuthenticate?: string, + ) { + super(message); + } +} + +export const getProtectedResourceMetadata = ({ + resourceUrl, + serverURL, +}: { + resourceUrl: string; + serverURL?: string; +}) => ({ + resource: resourceUrl, + authorization_servers: [ + getOAuthIssuerUrl({ baseUrl: serverURL ?? DEFAULT_AUTUMN_API_URL }), + ], + scopes_supported: [...MCP_OAUTH_SCOPES], + bearer_methods_supported: ["header"], + resource_name: "Autumn MCP", +}); diff --git a/apps/leaf/src/mcp/auth/resolveRequestAuth.ts b/apps/leaf/src/mcp/auth/resolveRequestAuth.ts new file mode 100644 index 000000000..537efcb18 --- /dev/null +++ b/apps/leaf/src/mcp/auth/resolveRequestAuth.ts @@ -0,0 +1,167 @@ +import { createHash } from "node:crypto"; +import { getBearerToken } from "@autumn/auth"; +import { + getProtectedResourceMetadataUrl, + getWwwAuthenticateHeader, +} from "@autumn/auth/oauth"; +import { + DEFAULT_API_VERSION, + MCP_OAUTH_SCOPES, + type AutumnMcpAuth, + environmentSchema, + type MCPServerFlags, + type OAuthEnvironment, +} from "@autumn/mcp"; +import * as z from "zod/v4"; +import { OAuthHttpError } from "./protectedResourceMetadata.js"; + +type AuthLogger = { + warning: (message: string, data?: Record) => void; +}; + +export interface MCPOAuthFlags extends MCPServerFlags { + readonly "oauth-enabled"?: boolean | undefined; + readonly "oauth-environment"?: OAuthEnvironment | undefined; +} + +const xApiVersionSchema = z.string().default(DEFAULT_API_VERSION); +const secretKeySchema = z.string().min(1).optional(); +const failOpenSchema = z + .union([ + z.boolean(), + z.enum(["true", "false"]).transform((v) => v === "true"), + ]) + .default(true); + +const parseRequestOption = ({ + value, + schema, + message, +}: { + value: unknown; + schema: z.ZodType; + message: string; +}): T => { + const parsed = schema.safeParse(value); + if (parsed.success) return parsed.data; + + throw new OAuthHttpError(400, message, "invalid_request"); +}; + +const getEnvironment = ({ + headers, + flags, +}: { + headers: Headers; + flags: MCPOAuthFlags; +}): OAuthEnvironment => + parseRequestOption({ + value: + headers.get("x-autumn-environment") ?? + flags["oauth-environment"] ?? + "sandbox", + schema: environmentSchema, + message: "Invalid x-autumn-environment", + }); + +const getStaticApiKey = ({ + headers, + flags, +}: { + headers: Headers; + flags: MCPOAuthFlags; +}): string | undefined => { + const secretKey = headers.get("secret-key"); + if (secretKey) return secretKey; + + const bearer = getBearerToken({ headers }); + if (bearer?.startsWith("am_")) return bearer; + + return flags["oauth-enabled"] ? undefined : flags["secret-key"]; +}; + +const principalFromSecret = ({ + kind, + value, +}: { + kind: string; + value: string; +}) => { + const digest = createHash("sha256").update(value).digest("hex").slice(0, 32); + return `${kind}:${digest}`; +}; + +export const buildAuthForRequest = async ({ + headers, + flags, + logger, + resourceUrl, +}: { + headers: Headers; + flags: MCPOAuthFlags; + logger: AuthLogger; + resourceUrl: string; +}): Promise => { + const env = getEnvironment({ headers, flags }); + const xApiVersion = parseRequestOption({ + value: headers.get("x-api-version") ?? flags["x-api-version"], + schema: xApiVersionSchema, + message: "Invalid x-api-version", + }); + const failOpen = parseRequestOption({ + value: headers.get("fail-open") ?? flags["fail-open"], + schema: failOpenSchema, + message: "Invalid fail-open", + }); + const apiKey = parseRequestOption({ + value: getStaticApiKey({ headers, flags }), + schema: secretKeySchema, + message: "Invalid secret-key", + }); + + if (apiKey) { + return { + apiKey, + authMethod: "secret-key", + env, + resource: resourceUrl, + principalId: principalFromSecret({ kind: "secret-key", value: apiKey }), + scopes: [...MCP_OAUTH_SCOPES], + serverURL: flags["server-url"], + xApiVersion, + failOpen, + }; + } + + const bearer = getBearerToken({ headers }); + if (bearer) { + return { + apiKey: bearer, + authMethod: "oauth", + env, + resource: resourceUrl, + principalId: "oauth:unverified", + scopes: [...MCP_OAUTH_SCOPES], + serverURL: flags["server-url"], + xApiVersion, + failOpen, + }; + } + + if (flags["oauth-enabled"]) { + throw new OAuthHttpError( + 401, + "Missing Autumn API key bearer token", + "invalid_token", + getWwwAuthenticateHeader({ + resourceMetadataUrl: getProtectedResourceMetadataUrl({ + resourceUrl, + }), + error: "invalid_token", + }), + ); + } + + logger.warning("Missing secret-key for MCP request"); + throw new OAuthHttpError(401, "Missing secret-key", "invalid_token"); +}; diff --git a/apps/leaf/src/mcp/constants.ts b/apps/leaf/src/mcp/constants.ts new file mode 100644 index 000000000..1615e9bc7 --- /dev/null +++ b/apps/leaf/src/mcp/constants.ts @@ -0,0 +1,3 @@ +export const MCP_PATH = "/mcp" as const; +export const PROTECTED_RESOURCE_METADATA_PATH = + "/.well-known/oauth-protected-resource/mcp"; diff --git a/apps/leaf/src/mcp/handlers/handleMcp.ts b/apps/leaf/src/mcp/handlers/handleMcp.ts new file mode 100644 index 000000000..6b9e4a3fe --- /dev/null +++ b/apps/leaf/src/mcp/handlers/handleMcp.ts @@ -0,0 +1,71 @@ +import { randomUUID } from "node:crypto"; +import { + type createAutumnOperationsMCPServer, +} from "@autumn/mcp"; +import { RESPONSE_ALREADY_SENT } from "@hono/node-server/utils/response"; +import { + buildAuthForRequest, +} from "../auth/resolveRequestAuth.js"; +import { OAuthHttpError } from "../auth/protectedResourceMetadata.js"; +import type { LeafMcpContext, McpRouteOptions } from "../types.js"; + +type McpServer = ReturnType; +type McpAuth = Awaited>; + +const setIncomingAuth = ({ + c, + auth, +}: { + c: LeafMcpContext; + auth: McpAuth; +}) => { + (c.env.incoming as typeof c.env.incoming & { auth?: McpAuth }).auth = auth; +}; + +const oauthErrorResponse = (c: LeafMcpContext, error: OAuthHttpError) => { + if (error.wwwAuthenticate) { + c.header("WWW-Authenticate", error.wwwAuthenticate); + } + + return c.json( + { error: error.error, error_description: error.message }, + { status: error.status as 400 | 401 | 403 }, + ); +}; + +export const createHandleMcp = + ({ + options, + path, + server, + }: { + options: McpRouteOptions; + path: string; + server: McpServer; + }) => + async (c: LeafMcpContext) => { + let auth: McpAuth; + try { + auth = await buildAuthForRequest({ + headers: c.req.raw.headers, + flags: options, + logger: options.logger, + resourceUrl: options.resourceUrl, + }); + } catch (error) { + if (error instanceof OAuthHttpError) { + return oauthErrorResponse(c, error); + } + throw error; + } + + setIncomingAuth({ c, auth }); + await server.startHTTP({ + url: new URL(c.req.url), + httpPath: path, + req: c.env.incoming, + res: c.env.outgoing, + options: { sessionIdGenerator: randomUUID }, + }); + return RESPONSE_ALREADY_SENT; + }; diff --git a/apps/leaf/src/mcp/handlers/handleProtectedResourceMetadata.ts b/apps/leaf/src/mcp/handlers/handleProtectedResourceMetadata.ts new file mode 100644 index 000000000..4bd455c2f --- /dev/null +++ b/apps/leaf/src/mcp/handlers/handleProtectedResourceMetadata.ts @@ -0,0 +1,12 @@ +import { getProtectedResourceMetadata } from "../auth/protectedResourceMetadata.js"; +import type { LeafMcpContext, McpRouteOptions } from "../types.js"; + +export const createHandleProtectedResourceMetadata = + ({ options }: { options: McpRouteOptions }) => + (c: LeafMcpContext) => + c.json( + getProtectedResourceMetadata({ + resourceUrl: options.resourceUrl, + serverURL: options["server-url"], + }), + ); diff --git a/apps/leaf/src/mcp/http.ts b/apps/leaf/src/mcp/http.ts deleted file mode 100644 index 3186a630f..000000000 --- a/apps/leaf/src/mcp/http.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { randomUUID } from "node:crypto"; -import type { AutumnLogger } from "@autumn/logging"; -import { - buildAuthForRequest, - createAutumnOperationsMCPServer, - getAuthorizationServerMetadata, - getProtectedResourceMetadata, - type MCPServerFlags, - type OAuthEnvironment, - OAuthHttpError, -} from "@autumn/mcp"; -import type { HttpBindings } from "@hono/node-server"; -import { RESPONSE_ALREADY_SENT } from "@hono/node-server/utils/response"; -import type { Context, Hono } from "hono"; - -export interface McpRouteOptions extends MCPServerFlags { - readonly "oauth-enabled": boolean; - readonly "oauth-environment": OAuthEnvironment; - readonly logger: AutumnLogger; -} - -type AppContext = Context<{ Bindings: HttpBindings }>; -type McpPath = "/mcp"; -type McpApp = Hono<{ Bindings: HttpBindings }>; - -export function registerMcpRoutes(app: McpApp, options: McpRouteOptions) { - const mcpServer = createAutumnOperationsMCPServer(); - - app.get("/.well-known/oauth-protected-resource/mcp", (c) => - c.json(getProtectedResourceMetadata(c.req.raw.headers, options, "/mcp")), - ); - - app.get("/.well-known/oauth-authorization-server", (c) => - c.json(getAuthorizationServerMetadata(options)), - ); - - const handleMcp = async ( - c: AppContext, - path: McpPath, - server: ReturnType, - ) => { - let auth: Awaited>; - try { - auth = await buildAuthForRequest( - c.req.raw.headers, - options, - options.logger, - path, - ); - } catch (error) { - if (error instanceof OAuthHttpError) { - if (error.wwwAuthenticate) { - c.header("WWW-Authenticate", error.wwwAuthenticate); - } - return c.json( - { error: error.error, error_description: error.message }, - { status: error.status as 401 | 403 }, - ); - } - throw error; - } - - (c.env.incoming as typeof c.env.incoming & { auth?: typeof auth }).auth = - auth; - await server.startHTTP({ - url: new URL(c.req.url), - httpPath: path, - req: c.env.incoming, - res: c.env.outgoing, - options: { sessionIdGenerator: randomUUID }, - }); - return RESPONSE_ALREADY_SENT; - }; - - app.all("/mcp", (c) => handleMcp(c, "/mcp", mcpServer)); - - return app; -} diff --git a/apps/leaf/src/mcp/mcpRouter.ts b/apps/leaf/src/mcp/mcpRouter.ts new file mode 100644 index 000000000..c8c2f5888 --- /dev/null +++ b/apps/leaf/src/mcp/mcpRouter.ts @@ -0,0 +1,29 @@ +import { createAutumnOperationsMCPServer } from "@autumn/mcp"; +import type { HttpBindings } from "@hono/node-server"; +import { Hono } from "hono"; +import { MCP_PATH, PROTECTED_RESOURCE_METADATA_PATH } from "./constants.js"; +import { createHandleMcp } from "./handlers/handleMcp.js"; +import { createHandleProtectedResourceMetadata } from "./handlers/handleProtectedResourceMetadata.js"; +import type { McpRouteOptions } from "./types.js"; + +export const createMcpRouter = (options: McpRouteOptions) => { + const router = new Hono<{ Bindings: HttpBindings }>(); + const mcpServer = createAutumnOperationsMCPServer(); + + router.get( + PROTECTED_RESOURCE_METADATA_PATH, + createHandleProtectedResourceMetadata({ + options, + }), + ); + router.all( + MCP_PATH, + createHandleMcp({ + options, + path: MCP_PATH, + server: mcpServer, + }), + ); + + return router; +}; diff --git a/apps/leaf/src/mcp/types.ts b/apps/leaf/src/mcp/types.ts new file mode 100644 index 000000000..71b420b98 --- /dev/null +++ b/apps/leaf/src/mcp/types.ts @@ -0,0 +1,15 @@ +import type { AutumnLogger } from "@autumn/logging"; +import type { MCPServerFlags, OAuthEnvironment } from "@autumn/mcp"; +import type { HttpBindings } from "@hono/node-server"; +import type { Context, Hono } from "hono"; + +export interface McpRouteOptions extends MCPServerFlags { + readonly "oauth-enabled": boolean; + readonly "oauth-environment": OAuthEnvironment; + readonly logger: AutumnLogger; + readonly resourceUrl: string; +} + +export type LeafMcpContext = Context<{ Bindings: HttpBindings }>; +export type LeafMcpRouter = Hono<{ Bindings: HttpBindings }>; +export type { MCPOAuthFlags } from "./auth/resolveRequestAuth.js"; diff --git a/packages/mcp/tests/unit/mcp-server/oauth.test.ts b/apps/leaf/tests/unit/mcp/oauth.test.ts similarity index 63% rename from packages/mcp/tests/unit/mcp-server/oauth.test.ts rename to apps/leaf/tests/unit/mcp/oauth.test.ts index 70c397748..98fa81147 100644 --- a/packages/mcp/tests/unit/mcp-server/oauth.test.ts +++ b/apps/leaf/tests/unit/mcp/oauth.test.ts @@ -1,12 +1,14 @@ import { describe, expect, test } from "bun:test"; +import { MCP_OAUTH_SCOPES } from "@autumn/mcp"; import { Scopes } from "@autumn/shared/scopeDefinitions"; import { buildAuthForRequest, - getProtectedResourceMetadata, - MCP_OAUTH_SCOPES, type MCPOAuthFlags, +} from "../../../src/mcp/auth/resolveRequestAuth.js"; +import { + getProtectedResourceMetadata, type OAuthHttpError, -} from "../../../src/server/auth/oauth.js"; +} from "../../../src/mcp/auth/protectedResourceMetadata.js"; const flags = { "oauth-enabled": true, @@ -18,6 +20,9 @@ const logger = { warning: () => {}, } as never; +const resourceUrl = "http://localhost:2718/mcp"; +const internalResourceUrl = "http://localhost:2718/internal/mcp"; + describe("MCP OAuth auth resolution", () => { test("requests scopes required by public write tools", () => { expect(MCP_OAUTH_SCOPES).toEqual( @@ -32,11 +37,12 @@ describe("MCP OAuth auth resolution", () => { test("returns a WWW-Authenticate challenge without a bearer token", async () => { await expect( - buildAuthForRequest( - new Headers({ host: "localhost:2718" }), - flags as MCPOAuthFlags, + buildAuthForRequest({ + headers: new Headers(), + flags: flags as MCPOAuthFlags, logger, - ), + resourceUrl, + }), ).rejects.toMatchObject({ status: 401, error: "invalid_token", @@ -47,12 +53,12 @@ describe("MCP OAuth auth resolution", () => { test("returns an internal MCP resource challenge", async () => { await expect( - buildAuthForRequest( - new Headers({ host: "localhost:2718" }), - flags as MCPOAuthFlags, + buildAuthForRequest({ + headers: new Headers(), + flags: flags as MCPOAuthFlags, logger, - "/internal/mcp", - ), + resourceUrl: internalResourceUrl, + }), ).rejects.toMatchObject({ status: 401, error: "invalid_token", @@ -61,7 +67,7 @@ describe("MCP OAuth auth resolution", () => { } satisfies Partial); }); - test("rejects opaque bearer tokens without exchanging them", async () => { + test("passes OAuth bearer tokens through without local verification", async () => { const originalFetch = globalThis.fetch; let fetchCalled = false; const mockFetch = (async () => { @@ -71,19 +77,23 @@ describe("MCP OAuth auth resolution", () => { globalThis.fetch = mockFetch; try { - await expect( - buildAuthForRequest( - new Headers({ - authorization: "Bearer oauth_token", - host: "localhost:2718", - }), - flags as MCPOAuthFlags, - logger, - ), - ).rejects.toMatchObject({ - status: 401, - error: "invalid_token", - } satisfies Partial); + const auth = await buildAuthForRequest({ + headers: new Headers({ + authorization: "Bearer oauth_token", + }), + flags: flags as MCPOAuthFlags, + logger, + resourceUrl, + }); + + expect(auth).toMatchObject({ + apiKey: "oauth_token", + authMethod: "oauth", + env: "sandbox", + principalId: "oauth:unverified", + resource: "http://localhost:2718/mcp", + serverURL: "http://localhost:8080", + }); expect(fetchCalled).toBe(false); } finally { globalThis.fetch = originalFetch; @@ -91,14 +101,14 @@ describe("MCP OAuth auth resolution", () => { }); test("accepts a static secret-key when OAuth is enabled", async () => { - const auth = await buildAuthForRequest( - new Headers({ - host: "localhost:2718", + const auth = await buildAuthForRequest({ + headers: new Headers({ "secret-key": "am_sk_test_chat", }), - flags as MCPOAuthFlags, + flags: flags as MCPOAuthFlags, logger, - ); + resourceUrl, + }); expect(auth.apiKey).toBe("am_sk_test_chat"); expect(auth.principalId).toStartWith("secret-key:"); @@ -106,50 +116,49 @@ describe("MCP OAuth auth resolution", () => { }); test("accepts an Autumn API key bearer token when OAuth is enabled", async () => { - const auth = await buildAuthForRequest( - new Headers({ + const auth = await buildAuthForRequest({ + headers: new Headers({ authorization: "Bearer am_sk_test_chat", - host: "localhost:2718", }), - flags as MCPOAuthFlags, + flags: flags as MCPOAuthFlags, logger, - ); + resourceUrl, + }); expect(auth.apiKey).toBe("am_sk_test_chat"); expect(auth.principalId).toStartWith("secret-key:"); }); test("uses route-specific resource URLs", async () => { - const auth = await buildAuthForRequest( - new Headers({ + const auth = await buildAuthForRequest({ + headers: new Headers({ authorization: "Bearer am_sk_test_chat", - host: "localhost:2718", }), - flags as MCPOAuthFlags, + flags: flags as MCPOAuthFlags, logger, - "/internal/mcp", - ); + resourceUrl: internalResourceUrl, + }); expect(auth.resource).toBe("http://localhost:2718/internal/mcp"); expect( - getProtectedResourceMetadata( - new Headers({ host: "localhost:2718" }), - flags as MCPOAuthFlags, - "/internal/mcp", - ).resource, + getProtectedResourceMetadata({ + resourceUrl: internalResourceUrl, + serverURL: flags["server-url"], + }).resource, ).toBe("http://localhost:2718/internal/mcp"); }); test("missing static secret-key returns the auth error path", async () => { await expect( - buildAuthForRequest( - new Headers({ host: "localhost:2718" }), - { + buildAuthForRequest({ + headers: new Headers(), + flags: { ...flags, "oauth-enabled": false, } as MCPOAuthFlags, logger, - ), + resourceUrl, + }), ).rejects.toMatchObject({ status: 401, error: "invalid_token", diff --git a/bun.lock b/bun.lock index 49e04763d..1305c060a 100644 --- a/bun.lock +++ b/bun.lock @@ -87,6 +87,7 @@ "name": "@autumn/leaf", "version": "0.0.1", "dependencies": { + "@autumn/auth": "workspace:*", "@autumn/logging": "workspace:*", "@autumn/mcp": "workspace:*", "@autumn/shared": "workspace:*", @@ -241,6 +242,15 @@ "typescript": "^5", }, }, + "packages/auth": { + "name": "@autumn/auth", + "version": "0.0.1", + "devDependencies": { + "@types/bun": "^1.2.13", + "@types/node": "^18.19.3", + "typescript": "~5.8.3", + }, + }, "packages/autumn-js": { "name": "autumn-js", "version": "1.2.17", @@ -301,6 +311,7 @@ "name": "@autumn/mcp", "version": "0.0.1", "dependencies": { + "@autumn/auth": "workspace:*", "@autumn/logging": "workspace:*", "@autumn/shared": "workspace:*", "@axiomhq/js": "^1.6.1", @@ -395,6 +406,7 @@ "dependencies": { "@ai-sdk/anthropic": "^3.0.9", "@anthropic-ai/sdk": "^0.32.1", + "@autumn/auth": "workspace:*", "@autumn/ksuid": "workspace:*", "@autumn/shared": "workspace:*", "@autumn/stripe-sync": "workspace:*", @@ -730,6 +742,8 @@ "@asyncapi/specs": ["@asyncapi/specs@6.8.1", "", { "dependencies": { "@types/json-schema": "^7.0.11" } }, "sha512-czHoAk3PeXTLR+X8IUaD+IpT+g+zUvkcgMDJVothBsan+oHN3jfcFcFUNdOPAAFoUCQN1hXF1dWuphWy05THlA=="], + "@autumn/auth": ["@autumn/auth@workspace:packages/auth"], + "@autumn/docs": ["@autumn/docs@workspace:apps/docs"], "@autumn/ksuid": ["@autumn/ksuid@workspace:packages/ksuid"], @@ -5724,7 +5738,7 @@ "typed-query-selector": ["typed-query-selector@2.12.2", "", {}, "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ=="], - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], "typescript-eslint": ["typescript-eslint@8.59.4", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.59.4", "@typescript-eslint/parser": "8.59.4", "@typescript-eslint/typescript-estree": "8.59.4", "@typescript-eslint/utils": "8.59.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Rw6+44QNFaXtgHSjPy+Kw8hrJniMYzR85E9yLmOLcfZ91/rz+JXQbDTCmc6ccxMPY6K6PgAq26f0JCBfR7LIPQ=="], @@ -6014,18 +6028,20 @@ "@asyncapi/parser/node-fetch": ["node-fetch@2.6.7", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ=="], + "@autumn/auth/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], + "@autumn/leaf/@types/node": ["@types/node@25.8.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ=="], + "@autumn/leaf/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "@autumn/logging/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], - "@autumn/logging/typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], - "@autumn/mcp/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], - "@autumn/mcp/typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], - "@autumn/openapi/dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], + "@autumn/scripts/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "@autumn/server/@types/node": ["@types/node@25.8.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ=="], "@autumn/server/@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260511.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260511.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260511.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260511.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260511.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260511.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260511.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260511.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-cUyY4Sr6065280lB6hCwTMCBMTxlEIGjSLzHym28yikA5sFiEsAzlwiU0i+XkTUIqr5K5M/SzSJiioDN+vpjtA=="], @@ -6034,8 +6050,12 @@ "@autumn/server/ink": ["ink@6.8.0", "", { "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.4", "ansi-escapes": "^7.3.0", "ansi-styles": "^6.2.1", "auto-bind": "^5.0.1", "chalk": "^5.6.0", "cli-boxes": "^3.0.0", "cli-cursor": "^4.0.0", "cli-truncate": "^5.1.1", "code-excerpt": "^4.0.0", "es-toolkit": "^1.39.10", "indent-string": "^5.0.0", "is-in-ci": "^2.0.0", "patch-console": "^2.0.0", "react-reconciler": "^0.33.0", "scheduler": "^0.27.0", "signal-exit": "^3.0.7", "slice-ansi": "^8.0.0", "stack-utils": "^2.0.6", "string-width": "^8.1.1", "terminal-size": "^4.0.1", "type-fest": "^5.4.1", "widest-line": "^6.0.0", "wrap-ansi": "^9.0.0", "ws": "^8.18.0", "yoga-layout": "~3.2.1" }, "peerDependencies": { "@types/react": ">=19.0.0", "react": ">=19.0.0", "react-devtools-core": ">=6.1.2" }, "optionalPeers": ["@types/react", "react-devtools-core"] }, "sha512-sbl1RdLOgkO9isK42WCZlJCFN9hb++sX9dsklOvfd1YQ3bQ2AiFu12Q6tFlr0HvEUvzraJntQCCpfEoUe9DSzA=="], + "@autumn/server/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "@autumn/shared/@date-fns/utc": ["@date-fns/utc@2.1.0", "", {}, "sha512-176grgAgU2U303rD2/vcOmNg0kGPbhzckuH1TEP2al7n0AQipZIy9P15usd2TKQCG1g+E1jX/ZVQSzs4sUDwgA=="], + "@autumn/shared/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "@autumn/vite/@types/node": ["@types/node@22.19.19", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew=="], "@autumn/vite/date-fns": ["date-fns@3.6.0", "", {}, "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww=="], @@ -6234,6 +6254,8 @@ "@infisical/sdk/@aws-sdk/credential-providers": ["@aws-sdk/credential-providers@3.600.0", "", { "dependencies": { "@aws-sdk/client-cognito-identity": "3.600.0", "@aws-sdk/client-sso": "3.598.0", "@aws-sdk/client-sts": "3.600.0", "@aws-sdk/credential-provider-cognito-identity": "3.600.0", "@aws-sdk/credential-provider-env": "3.598.0", "@aws-sdk/credential-provider-http": "3.598.0", "@aws-sdk/credential-provider-ini": "3.598.0", "@aws-sdk/credential-provider-node": "3.600.0", "@aws-sdk/credential-provider-process": "3.598.0", "@aws-sdk/credential-provider-sso": "3.598.0", "@aws-sdk/credential-provider-web-identity": "3.598.0", "@aws-sdk/types": "3.598.0", "@smithy/credential-provider-imds": "^3.1.1", "@smithy/property-provider": "^3.1.1", "@smithy/types": "^3.1.0", "tslib": "^2.6.2" } }, "sha512-cC9uqmX0rgx1efiJGqeR+i0EXr8RQ5SAzH7M45WNBZpYiLEe6reWgIYJY9hmOxuaoMdWSi8kekuN3IjTIORRjw=="], + "@infisical/sdk/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], "@inquirer/external-editor/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], @@ -6946,8 +6968,6 @@ "@typespec/ts-http-runtime/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], - "@useautumn/sdk/typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], - "@useautumn/sdk/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "@vercel/sdk/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], @@ -7008,6 +7028,8 @@ "atmn/react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], + "atmn/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "atmn/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "autumn-js/@types/node": ["@types/node@22.19.19", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew=="], @@ -7018,6 +7040,8 @@ "autumn-js/react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], + "autumn-js/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "autumn-js/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "ava/ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="], @@ -7082,6 +7106,8 @@ "checkout/react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], + "checkout/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "clean-regexp/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], @@ -7544,6 +7570,8 @@ "sdk-test/react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="], + "sdk-test/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], @@ -7626,6 +7654,8 @@ "ts-to-zod/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + "ts-to-zod/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "ts-to-zod/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "tsc-alias/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="], @@ -7634,6 +7664,8 @@ "tshy/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + "tshy/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "tsup/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], "tsutils/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], @@ -7788,6 +7820,8 @@ "@asyncapi/parser/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "@autumn/auth/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], + "@autumn/leaf/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "@autumn/logging/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], diff --git a/docker/Dockerfile b/docker/Dockerfile index 5e8c83c38..7e9bec93a 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -27,6 +27,7 @@ COPY apps/sdk-test/package.json apps/sdk-test/ COPY apps/website/package.json apps/website/ COPY packages/atmn/package.json packages/atmn/ COPY packages/atmn-tests/package.json packages/atmn-tests/ +COPY packages/auth/package.json packages/auth/ COPY packages/autumn-js/package.json packages/autumn-js/ COPY packages/ksuid/package.json packages/ksuid/ COPY packages/logging/package.json packages/logging/ diff --git a/package.json b/package.json index 08e054b23..cecf16bbb 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "apps/sdk-test", "packages/atmn", "packages/atmn-tests", + "packages/auth", "packages/logging", "packages/mcp", "packages/sdk", @@ -137,7 +138,7 @@ "site": "cd apps/website && bun dev && cd ../..", "docs": "bun -F @autumn/docs dev", "docs:build": "bun -F @autumn/docs build", - "ts": "turbo run ts --filter=@autumn/server --filter=autumn-js --filter=@autumn/openapi --filter=atmn --filter=checkout --filter=@autumn/mcp --filter=@autumn/leaf", + "ts": "turbo run ts --filter=@autumn/server --filter=autumn-js --filter=@autumn/openapi --filter=atmn --filter=checkout --filter=@autumn/auth --filter=@autumn/mcp --filter=@autumn/leaf", "kill:ts": "while pgrep -f tsgo > /dev/null; do pkill -9 -f tsgo; sleep 0.1; done", "atmn:build": "bun -F atmn build", "openapi:ts": "bun -F @autumn/openapi ts", diff --git a/packages/auth/package.json b/packages/auth/package.json new file mode 100644 index 000000000..27c430f23 --- /dev/null +++ b/packages/auth/package.json @@ -0,0 +1,36 @@ +{ + "name": "@autumn/auth", + "version": "0.0.1", + "author": "Autumn", + "type": "module", + "sideEffects": false, + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts", + "default": "./src/index.ts" + }, + "./utils": { + "types": "./src/utils/index.ts", + "import": "./src/utils/index.ts", + "default": "./src/utils/index.ts" + }, + "./oauth": { + "types": "./src/oauth/index.ts", + "import": "./src/oauth/index.ts", + "default": "./src/oauth/index.ts" + } + }, + "files": ["src"], + "scripts": { + "build": "tsc", + "ts": "tsc --noEmit", + "prepack": "bun run build", + "prepublishOnly": "bun run build" + }, + "devDependencies": { + "@types/bun": "^1.2.13", + "@types/node": "^18.19.3", + "typescript": "~5.8.3" + } +} diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts new file mode 100644 index 000000000..68d71e8cd --- /dev/null +++ b/packages/auth/src/index.ts @@ -0,0 +1,2 @@ +export * from "./oauth/index.js"; +export * from "./utils/index.js"; diff --git a/packages/auth/src/oauth/index.ts b/packages/auth/src/oauth/index.ts new file mode 100644 index 000000000..59a766887 --- /dev/null +++ b/packages/auth/src/oauth/index.ts @@ -0,0 +1 @@ +export * from "./oauthUrls.js"; diff --git a/packages/auth/src/oauth/oauthUrls.ts b/packages/auth/src/oauth/oauthUrls.ts new file mode 100644 index 000000000..33a8ff48f --- /dev/null +++ b/packages/auth/src/oauth/oauthUrls.ts @@ -0,0 +1,32 @@ +const trimTrailingSlash = (url: string) => + url.endsWith("/") ? url.slice(0, -1) : url; + +export const getOAuthIssuerUrl = ({ + authPath = "/api/auth", + baseUrl, +}: { + authPath?: string; + baseUrl: string; +}): string => trimTrailingSlash(new URL(authPath, baseUrl).href); + +export const getProtectedResourceMetadataUrl = ({ + resourceUrl, +}: { + resourceUrl: string; +}): string => { + const url = new URL(resourceUrl); + const path = url.pathname === "/" ? "" : url.pathname; + return new URL(`/.well-known/oauth-protected-resource${path}`, url).href; +}; + +export const getWwwAuthenticateHeader = ({ + error, + resourceMetadataUrl, +}: { + error?: string; + resourceMetadataUrl: string; +}): string => { + const params = [`resource_metadata="${resourceMetadataUrl}"`]; + if (error) params.push(`error="${error}"`); + return `Bearer ${params.join(", ")}`; +}; diff --git a/packages/auth/src/utils/getBearerToken.ts b/packages/auth/src/utils/getBearerToken.ts new file mode 100644 index 000000000..a704c92b7 --- /dev/null +++ b/packages/auth/src/utils/getBearerToken.ts @@ -0,0 +1,13 @@ +const BEARER_PREFIX = "Bearer "; + +export const getBearerToken = ({ + headers, +}: { + headers: Headers; +}): string | undefined => { + const authorization = headers.get("authorization"); + if (!authorization?.startsWith(BEARER_PREFIX)) return undefined; + + const token = authorization.slice(BEARER_PREFIX.length).trim(); + return token.length ? token : undefined; +}; diff --git a/packages/auth/src/utils/index.ts b/packages/auth/src/utils/index.ts new file mode 100644 index 000000000..9ef01be09 --- /dev/null +++ b/packages/auth/src/utils/index.ts @@ -0,0 +1 @@ +export * from "./getBearerToken.js"; diff --git a/packages/auth/tsconfig.json b/packages/auth/tsconfig.json new file mode 100644 index 000000000..ef9ed3373 --- /dev/null +++ b/packages/auth/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "allowJs": true, + "allowUnreachableCode": false, + "allowUnusedLabels": false, + "checkJs": true, + "declaration": true, + "declarationMap": true, + "esModuleInterop": true, + "exactOptionalPropertyTypes": false, + "forceConsistentCasingInFileNames": true, + "incremental": false, + "isolatedModules": true, + "lib": ["dom", "dom.iterable", "es2024"], + "module": "Preserve", + "moduleResolution": "bundler", + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": false, + "noImplicitReturns": false, + "noPropertyAccessFromIndexSignature": false, + "noUncheckedIndexedAccess": false, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noEmit": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "target": "es2022", + "types": ["bun", "node"], + "useUnknownInCatchVariables": true + }, + "exclude": ["node_modules"], + "include": ["src/**/*.ts"] +} diff --git a/packages/mcp/README.md b/packages/mcp/README.md index ee1c58cf3..3cc133297 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -2,7 +2,7 @@ Mastra-backed MCP library for Autumn operations. -The hosted runtime lives in `apps/leaf` (see `src/mcp/http.ts`) and exposes a +The hosted runtime lives in `apps/leaf` (see `src/mcp/mcpRouter.ts`) and exposes a Streamable HTTP MCP route: - `/mcp` - public, API-shaped operational tools. diff --git a/packages/mcp/package.json b/packages/mcp/package.json index afddc2a5d..0cffccb4c 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -20,8 +20,9 @@ "prepublishOnly": "bun run build" }, "dependencies": { + "@autumn/auth": "workspace:*", "@autumn/logging": "workspace:*", - "@autumn/shared": "workspace:*", + "@autumn/shared": "workspace:*", "@axiomhq/js": "^1.6.1", "@mastra/core": "^1.36.0", "@mastra/mcp": "^1.8.0", diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 2144896ee..1d333828f 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -14,11 +14,14 @@ export { createConsoleLogger, } from "./console-logger.js"; export { - buildAuthForRequest, - getAuthorizationServerMetadata, - getProtectedResourceMetadata, + DEFAULT_API_VERSION, + DEFAULT_AUTUMN_API_URL, + MCP_OAUTH_SCOPES, +} from "./constants.js"; +export { + type AutumnMcpAuth, + environmentSchema, type OAuthEnvironment, - OAuthHttpError, -} from "./server/auth/oauth.js"; +} from "./server/auth/auth.js"; export type { MCPServerFlags } from "./server/flags.js"; export { createAutumnOperationsMCPServer } from "./server/server.js"; diff --git a/packages/mcp/src/server/auth/auth.ts b/packages/mcp/src/server/auth/auth.ts index 4ed0fa00d..2877c8a02 100644 --- a/packages/mcp/src/server/auth/auth.ts +++ b/packages/mcp/src/server/auth/auth.ts @@ -4,7 +4,9 @@ import { DEFAULT_API_VERSION, DEFAULT_AUTUMN_API_URL, } from "../../constants.js"; -import { environmentSchema } from "./utils/schemas.js"; + +export const environmentSchema = z.enum(["sandbox", "live"]); +export type OAuthEnvironment = z.infer; /** * Authenticated Autumn identity attached to every MCP request. Defined as a zod @@ -13,6 +15,7 @@ import { environmentSchema } from "./utils/schemas.js"; */ export const autumnMcpAuthSchema = z.object({ apiKey: z.string().min(1), + authMethod: z.enum(["secret-key", "oauth"]).optional(), env: environmentSchema, principalId: z.string(), resource: z.string(), diff --git a/packages/mcp/src/server/auth/oauth.ts b/packages/mcp/src/server/auth/oauth.ts deleted file mode 100644 index dd975f1e8..000000000 --- a/packages/mcp/src/server/auth/oauth.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { MCP_OAUTH_SCOPES } from "../../constants.js"; -import type { AutumnMcpAuth } from "./auth.js"; -import { OAuthHttpError } from "./utils/errors.js"; -import { principalFromSecret } from "./utils/principal.js"; -import { - getEnvironment, - getStaticApiKey, - parseRequestOption, -} from "./utils/request.js"; -import { - failOpenSchema, - type MCPOAuthFlags, - secretKeySchema, - xApiVersionSchema, -} from "./utils/schemas.js"; -import { - getIssuerUrl, - getResourceUrl, - getWWWAuthenticate, -} from "./utils/urls.js"; - -// Public surface consumed via `./oauth.js` (index.ts, leaf, tests). -export { MCP_OAUTH_SCOPES } from "../../constants.js"; -export { OAuthHttpError } from "./utils/errors.js"; -export type { MCPOAuthFlags, OAuthEnvironment } from "./utils/schemas.js"; - -type AuthLogger = { - warning: (message: string, data?: Record) => void; -}; - -export const getProtectedResourceMetadata = ( - headers: Headers, - flags: MCPOAuthFlags, - resourcePath = "/mcp", -) => ({ - resource: getResourceUrl({ headers, resourcePath }), - authorization_servers: [getIssuerUrl(flags)], - scopes_supported: [...MCP_OAUTH_SCOPES], - bearer_methods_supported: ["header"], - resource_name: "Autumn MCP", -}); - -export const getAuthorizationServerMetadata = (flags: MCPOAuthFlags) => { - const issuer = getIssuerUrl(flags); - return { - issuer, - authorization_endpoint: `${issuer}/oauth2/authorize`, - token_endpoint: `${issuer}/oauth2/token`, - registration_endpoint: `${issuer}/oauth2/register`, - revocation_endpoint: `${issuer}/oauth2/revoke`, - introspection_endpoint: `${issuer}/oauth2/introspect`, - response_types_supported: ["code"], - grant_types_supported: ["authorization_code", "refresh_token"], - token_endpoint_auth_methods_supported: [ - "client_secret_post", - "client_secret_basic", - "none", - ], - code_challenge_methods_supported: ["S256"], - scopes_supported: [...MCP_OAUTH_SCOPES], - }; -}; - -export const buildAuthForRequest = async ( - headers: Headers, - flags: MCPOAuthFlags, - logger: AuthLogger, - resourcePath = "/mcp", -): Promise => { - const env = getEnvironment({ headers, flags }); - const resource = getResourceUrl({ headers, resourcePath }); - const xApiVersion = parseRequestOption({ - value: headers.get("x-api-version") ?? flags["x-api-version"], - schema: xApiVersionSchema, - message: "Invalid x-api-version", - }); - const failOpen = parseRequestOption({ - value: headers.get("fail-open") ?? flags["fail-open"], - schema: failOpenSchema, - message: "Invalid fail-open", - }); - const apiKey = parseRequestOption({ - value: getStaticApiKey({ headers, flags }), - schema: secretKeySchema, - message: "Invalid secret-key", - }); - - if (apiKey) { - return { - apiKey, - env, - resource, - principalId: principalFromSecret({ kind: "secret-key", value: apiKey }), - scopes: [...MCP_OAUTH_SCOPES], - serverURL: flags["server-url"], - xApiVersion, - failOpen, - }; - } - - if (flags["oauth-enabled"]) { - throw new OAuthHttpError( - 401, - "Missing Autumn API key bearer token", - "invalid_token", - getWWWAuthenticate({ resourceUrl: resource, error: "invalid_token" }), - ); - } - - logger.warning("Missing secret-key for MCP request"); - throw new OAuthHttpError(401, "Missing secret-key", "invalid_token"); -}; diff --git a/packages/mcp/src/server/auth/utils/errors.ts b/packages/mcp/src/server/auth/utils/errors.ts deleted file mode 100644 index 36df3fa02..000000000 --- a/packages/mcp/src/server/auth/utils/errors.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Error carrying the HTTP status and OAuth metadata the MCP HTTP layer needs to - * build a spec-compliant `WWW-Authenticate` response. Lives in its own module so - * both the request helpers and the OAuth flow can throw it without import cycles. - */ -export class OAuthHttpError extends Error { - constructor( - readonly status: number, - message: string, - readonly error = "invalid_token", - readonly wwwAuthenticate?: string, - ) { - super(message); - } -} diff --git a/packages/mcp/src/server/auth/utils/principal.ts b/packages/mcp/src/server/auth/utils/principal.ts deleted file mode 100644 index f3b14258f..000000000 --- a/packages/mcp/src/server/auth/utils/principal.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { createHash } from "node:crypto"; - -/** Short, stable digest used to anonymise secrets inside principal ids. */ -const hash = (value: string) => - createHash("sha256").update(value).digest("hex").slice(0, 32); - -/** - * Builds a principal id from a secret without leaking it, e.g. - * `secret-key:`. - */ -export const principalFromSecret = ({ - kind, - value, -}: { - kind: string; - value: string; -}) => `${kind}:${hash(value)}`; diff --git a/packages/mcp/src/server/auth/utils/request.ts b/packages/mcp/src/server/auth/utils/request.ts deleted file mode 100644 index 10af5b954..000000000 --- a/packages/mcp/src/server/auth/utils/request.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type * as z from "zod/v4"; -import { OAuthHttpError } from "./errors.js"; -import { - environmentSchema, - type MCPOAuthFlags, - type OAuthEnvironment, -} from "./schemas.js"; - -/** - * Validates a request-derived value against a schema, surfacing a 400 with a - * caller-supplied message instead of zod's default error shape. - */ -export const parseRequestOption = ({ - value, - schema, - message, -}: { - value: unknown; - schema: z.ZodType; - message: string; -}): T => { - const parsed = schema.safeParse(value); - if (parsed.success) return parsed.data; - - throw new OAuthHttpError(400, message, "invalid_request"); -}; - -/** Resolves the Autumn environment from the request header, then the flag. */ -export const getEnvironment = ({ - headers, - flags, -}: { - headers: Headers; - flags: MCPOAuthFlags; -}): OAuthEnvironment => - parseRequestOption({ - value: - headers.get("x-autumn-environment") ?? - flags["oauth-environment"] ?? - "sandbox", - schema: environmentSchema, - message: "Invalid x-autumn-environment", - }); - -/** - * Extracts a directly-supplied Autumn secret key (no OAuth exchange): a - * `secret-key` header, an `am_`-prefixed bearer token, or the configured flag - * when OAuth is disabled. - */ -export const getStaticApiKey = ({ - headers, - flags, -}: { - headers: Headers; - flags: MCPOAuthFlags; -}): string | undefined => { - const secretKey = headers.get("secret-key"); - if (secretKey) return secretKey; - - const authorization = headers.get("authorization"); - const bearer = authorization?.startsWith("Bearer ") - ? authorization.slice("Bearer ".length) - : undefined; - if (bearer?.startsWith("am_")) return bearer; - - return flags["oauth-enabled"] ? undefined : flags["secret-key"]; -}; diff --git a/packages/mcp/src/server/auth/utils/schemas.ts b/packages/mcp/src/server/auth/utils/schemas.ts deleted file mode 100644 index 27c81980f..000000000 --- a/packages/mcp/src/server/auth/utils/schemas.ts +++ /dev/null @@ -1,22 +0,0 @@ -import * as z from "zod/v4"; -import { DEFAULT_API_VERSION } from "../../../constants.js"; -import type { MCPServerFlags } from "../../flags.js"; - -export const environmentSchema = z.enum(["sandbox", "live"]); -export type OAuthEnvironment = z.infer; - -export const xApiVersionSchema = z.string().default(DEFAULT_API_VERSION); - -export const failOpenSchema = z - .union([ - z.boolean(), - z.enum(["true", "false"]).transform((v) => v === "true"), - ]) - .default(true); - -export const secretKeySchema = z.string().min(1).optional(); - -export interface MCPOAuthFlags extends MCPServerFlags { - readonly "oauth-enabled"?: boolean | undefined; - readonly "oauth-environment"?: OAuthEnvironment | undefined; -} diff --git a/packages/mcp/src/server/auth/utils/urls.ts b/packages/mcp/src/server/auth/utils/urls.ts deleted file mode 100644 index 55510d869..000000000 --- a/packages/mcp/src/server/auth/utils/urls.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { DEFAULT_AUTUMN_API_URL } from "../../../constants.js"; -import { OAuthHttpError } from "./errors.js"; -import type { MCPOAuthFlags } from "./schemas.js"; - -const trimTrailingSlash = (url: string) => - url.endsWith("/") ? url.slice(0, -1) : url; - -/** Host the client reached us on, honouring Autumn's proxy forwarding headers. */ -const getForwardedHost = (headers: Headers) => - headers.get("x-autumn-forwarded-host") ?? - headers.get("x-forwarded-host") ?? - headers.get("host"); - -const getForwardedProto = (headers: Headers) => - headers.get("x-autumn-forwarded-proto") ?? - headers.get("x-forwarded-proto") ?? - "http"; - -/** Absolute URL of the MCP resource the current request is targeting. */ -export const getResourceUrl = ({ - headers, - resourcePath = "/mcp", -}: { - headers: Headers; - resourcePath?: string; -}): string => { - const host = getForwardedHost(headers); - if (!host) { - throw new OAuthHttpError(400, "Missing Host header", "invalid_request"); - } - - return new URL(resourcePath, `${getForwardedProto(headers)}://${host}`).href; -}; - -export const getProtectedResourceMetadataUrl = ( - resourceUrl: string, -): string => { - const url = new URL(resourceUrl); - const path = url.pathname === "/" ? "" : url.pathname; - return new URL(`/.well-known/oauth-protected-resource${path}`, url).href; -}; - -export const getIssuerUrl = (flags: MCPOAuthFlags): string => - trimTrailingSlash( - new URL("/api/auth", flags["server-url"] ?? DEFAULT_AUTUMN_API_URL).href, - ); - -export const getWWWAuthenticate = ({ - resourceUrl, - error, -}: { - resourceUrl: string; - error?: string; -}): string => { - const params = [ - `resource_metadata="${getProtectedResourceMetadataUrl(resourceUrl)}"`, - ]; - if (error) params.push(`error="${error}"`); - return `Bearer ${params.join(", ")}`; -}; diff --git a/server/package.json b/server/package.json index dc0b65a2b..d035d7f18 100644 --- a/server/package.json +++ b/server/package.json @@ -47,6 +47,7 @@ "dependencies": { "@ai-sdk/anthropic": "^3.0.9", "@anthropic-ai/sdk": "^0.32.1", + "@autumn/auth": "workspace:*", "@autumn/ksuid": "workspace:*", "@autumn/shared": "workspace:*", "@autumn/stripe-sync": "workspace:*", diff --git a/server/src/honoMiddlewares/secretKeyMiddleware.ts b/server/src/honoMiddlewares/secretKeyMiddleware.ts index f158eb0ca..37e3830bc 100644 --- a/server/src/honoMiddlewares/secretKeyMiddleware.ts +++ b/server/src/honoMiddlewares/secretKeyMiddleware.ts @@ -1,3 +1,4 @@ +import { getBearerToken } from "@autumn/auth"; import { AuthType, ErrCode, type Feature, RecaseError } from "@autumn/shared"; import type { Context, Next } from "hono"; import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; @@ -29,12 +30,11 @@ export const secretKeyMiddleware = async (c: Context, next: Next) => { return betterAuthMiddleware(c, next); } - const authHeader = - c.req.header("authorization") || c.req.header("Authorization"); + const apiKey = getBearerToken({ headers: c.req.raw.headers }); // Step 1 & 2: Check if Authorization header exists // If from dashboard and no Bearer token, use Better Auth session instead - if (!authHeader || !authHeader.startsWith("Bearer ")) { + if (!apiKey) { throw new RecaseError({ message: "Secret key not found in Authorization header", code: ErrCode.NoSecretKey, @@ -42,9 +42,6 @@ export const secretKeyMiddleware = async (c: Context, next: Next) => { }); } - // Step 2: Extract and validate API key format - const apiKey = authHeader.split(" ")[1]; - if (!apiKey.startsWith("am_")) { throw new RecaseError({ message: `Invalid secret key: ${maskApiKey(apiKey)}`,