mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
Add MCP OAuth adapter to the SDK
Co-Authored-By: mantra <mantra@stack-auth.com>
This commit is contained in:
parent
105f03fb94
commit
2000a9229d
276
apps/e2e/tests/js/mcp-auth.test.ts
Normal file
276
apps/e2e/tests/js/mcp-auth.test.ts
Normal file
@ -0,0 +1,276 @@
|
||||
import { createMcpAuthAdapter, InvalidMcpAccessTokenError, type McpAuthAdapter } from '@hexclave/js';
|
||||
import crypto from "node:crypto";
|
||||
import { describe } from "vitest";
|
||||
import { it } from "../helpers";
|
||||
import { createApp } from "./js-helpers";
|
||||
|
||||
const RESOURCE_URL = "https://mcp-test.example.com/mcp";
|
||||
const OAUTH_BASE = "https://mcp-test.example.com/api/mcp-oauth";
|
||||
|
||||
function unescapeHtml(escaped: string): string {
|
||||
return escaped
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll(""", '"')
|
||||
.replaceAll("'", "'")
|
||||
.replaceAll("&", "&");
|
||||
}
|
||||
|
||||
function parseHiddenInputs(html: string): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const match of html.matchAll(/<input type="hidden" name="([^"]*)" value="([^"]*)">/g)) {
|
||||
result[unescapeHtml(match[1])] = unescapeHtml(match[2]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function createPkcePair() {
|
||||
const verifier = crypto.randomBytes(32).toString("base64url");
|
||||
const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
|
||||
return { verifier, challenge };
|
||||
}
|
||||
|
||||
async function setUpAdapter() {
|
||||
const { serverApp } = await createApp({
|
||||
config: {
|
||||
credentialEnabled: true,
|
||||
},
|
||||
});
|
||||
await serverApp.signUpWithCredential({
|
||||
email: "mcp-test@example.com",
|
||||
password: "password123",
|
||||
verificationCallbackUrl: "http://localhost:3000",
|
||||
});
|
||||
const user = await serverApp.getUser({ or: "throw" });
|
||||
const adapter = createMcpAuthAdapter({
|
||||
app: serverApp,
|
||||
resourceUrl: RESOURCE_URL,
|
||||
scopesSupported: ["mcp:tools"],
|
||||
});
|
||||
return { serverApp, user, adapter };
|
||||
}
|
||||
|
||||
async function registerClient(adapter: McpAuthAdapter, redirectUri: string): Promise<string> {
|
||||
const response = await adapter.handler(new Request(`${OAUTH_BASE}/register`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ redirect_uris: [redirectUri], client_name: "Test MCP Client" }),
|
||||
}));
|
||||
if (response.status !== 201) throw new Error(`Registration failed: ${await response.text()}`);
|
||||
const body = await response.json();
|
||||
return body.client_id;
|
||||
}
|
||||
|
||||
async function authorizeAndGetCode(adapter: McpAuthAdapter, options: { clientId: string, redirectUri: string, challenge: string, state?: string, deny?: boolean }): Promise<URL> {
|
||||
const authorizeUrl = new URL(`${OAUTH_BASE}/authorize`);
|
||||
authorizeUrl.searchParams.set("response_type", "code");
|
||||
authorizeUrl.searchParams.set("client_id", options.clientId);
|
||||
authorizeUrl.searchParams.set("redirect_uri", options.redirectUri);
|
||||
authorizeUrl.searchParams.set("code_challenge", options.challenge);
|
||||
authorizeUrl.searchParams.set("code_challenge_method", "S256");
|
||||
authorizeUrl.searchParams.set("scope", "mcp:tools");
|
||||
if (options.state !== undefined) authorizeUrl.searchParams.set("state", options.state);
|
||||
|
||||
const consentResponse = await adapter.handler(new Request(authorizeUrl.toString()));
|
||||
if (consentResponse.status !== 200) throw new Error(`Authorize failed: ${await consentResponse.text()}`);
|
||||
const hiddenInputs = parseHiddenInputs(await consentResponse.text());
|
||||
|
||||
const form = new URLSearchParams(hiddenInputs);
|
||||
form.set("action", options.deny ? "deny" : "approve");
|
||||
const approveResponse = await adapter.handler(new Request(authorizeUrl.toString(), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: form.toString(),
|
||||
}));
|
||||
if (approveResponse.status !== 302) throw new Error(`Consent submission failed: ${await approveResponse.text()}`);
|
||||
return new URL(approveResponse.headers.get("location") ?? throwMissingLocation());
|
||||
}
|
||||
|
||||
function throwMissingLocation(): never {
|
||||
throw new Error("Redirect response has no location header");
|
||||
}
|
||||
|
||||
async function exchangeCode(adapter: McpAuthAdapter, options: { code: string, verifier: string, clientId: string, redirectUri: string }): Promise<Response> {
|
||||
return await adapter.handler(new Request(`${OAUTH_BASE}/token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code: options.code,
|
||||
code_verifier: options.verifier,
|
||||
client_id: options.clientId,
|
||||
redirect_uri: options.redirectUri,
|
||||
}).toString(),
|
||||
}));
|
||||
}
|
||||
|
||||
describe("MCP auth adapter", () => {
|
||||
it("serves OAuth metadata endpoints", async ({ expect }) => {
|
||||
const { adapter } = await setUpAdapter();
|
||||
|
||||
const asMetadataResponse = await adapter.handler(new Request("https://mcp-test.example.com/.well-known/oauth-authorization-server"));
|
||||
expect(asMetadataResponse.status).toBe(200);
|
||||
const asMetadata = await asMetadataResponse.json();
|
||||
expect(asMetadata.issuer).toBe(OAUTH_BASE);
|
||||
expect(asMetadata.authorization_endpoint).toBe(`${OAUTH_BASE}/authorize`);
|
||||
expect(asMetadata.token_endpoint).toBe(`${OAUTH_BASE}/token`);
|
||||
expect(asMetadata.registration_endpoint).toBe(`${OAUTH_BASE}/register`);
|
||||
expect(asMetadata.code_challenge_methods_supported).toEqual(["S256"]);
|
||||
expect(asMetadata.scopes_supported).toEqual(["mcp:tools"]);
|
||||
|
||||
const prmResponse = await adapter.handler(new Request("https://mcp-test.example.com/.well-known/oauth-protected-resource/mcp"));
|
||||
expect(prmResponse.status).toBe(200);
|
||||
const prm = await prmResponse.json();
|
||||
expect(prm.resource).toBe(RESOURCE_URL);
|
||||
expect(prm.authorization_servers).toEqual([OAUTH_BASE]);
|
||||
});
|
||||
|
||||
it("completes the full authorization code flow with PKCE, token refresh, and bearer auth", async ({ expect }) => {
|
||||
const { adapter, user } = await setUpAdapter();
|
||||
const redirectUri = "https://client.example.com/callback";
|
||||
const clientId = await registerClient(adapter, redirectUri);
|
||||
const { verifier, challenge } = createPkcePair();
|
||||
|
||||
const callbackUrl = await authorizeAndGetCode(adapter, { clientId, redirectUri, challenge, state: "some-state" });
|
||||
expect(callbackUrl.origin + callbackUrl.pathname).toBe(redirectUri);
|
||||
expect(callbackUrl.searchParams.get("state")).toBe("some-state");
|
||||
const code = callbackUrl.searchParams.get("code");
|
||||
expect(code).toBeTruthy();
|
||||
|
||||
const tokenResponse = await exchangeCode(adapter, { code: code!, verifier, clientId, redirectUri });
|
||||
expect(tokenResponse.status).toBe(200);
|
||||
const tokens = await tokenResponse.json();
|
||||
expect(tokens.token_type).toBe("bearer");
|
||||
expect(tokens.access_token).toBeTruthy();
|
||||
expect(tokens.refresh_token).toBeTruthy();
|
||||
expect(tokens.expires_in).toBeGreaterThan(0);
|
||||
expect(tokens.scope).toBe("mcp:tools");
|
||||
|
||||
const authInfo = await adapter.verifyAccessToken(tokens.access_token);
|
||||
expect(authInfo.extra?.userId).toBe(user.id);
|
||||
expect(authInfo.scopes).toEqual(["mcp:tools"]);
|
||||
|
||||
const refreshResponse = await adapter.handler(new Request(`${OAUTH_BASE}/token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: tokens.refresh_token,
|
||||
}).toString(),
|
||||
}));
|
||||
expect(refreshResponse.status).toBe(200);
|
||||
const refreshed = await refreshResponse.json();
|
||||
expect(refreshed.access_token).toBeTruthy();
|
||||
const refreshedAuthInfo = await adapter.verifyAccessToken(refreshed.access_token);
|
||||
expect(refreshedAuthInfo.extra?.userId).toBe(user.id);
|
||||
|
||||
const protectedHandler = adapter.withMcpAuth(async (_req, auth) => {
|
||||
const authedUser = await auth.getUser();
|
||||
return new Response(JSON.stringify({ userId: authedUser.id }), { status: 200 });
|
||||
});
|
||||
|
||||
const unauthorizedResponse = await protectedHandler(new Request(RESOURCE_URL, { method: "POST" }));
|
||||
expect(unauthorizedResponse.status).toBe(401);
|
||||
expect(unauthorizedResponse.headers.get("www-authenticate")).toContain("resource_metadata=");
|
||||
|
||||
const authorizedResponse = await protectedHandler(new Request(RESOURCE_URL, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${tokens.access_token}` },
|
||||
}));
|
||||
expect(authorizedResponse.status).toBe(200);
|
||||
expect((await authorizedResponse.json()).userId).toBe(user.id);
|
||||
});
|
||||
|
||||
it("rejects token exchange with an incorrect PKCE verifier or redirect URI", async ({ expect }) => {
|
||||
const { adapter } = await setUpAdapter();
|
||||
const redirectUri = "https://client.example.com/callback";
|
||||
const clientId = await registerClient(adapter, redirectUri);
|
||||
const { challenge } = createPkcePair();
|
||||
|
||||
const callbackUrl = await authorizeAndGetCode(adapter, { clientId, redirectUri, challenge });
|
||||
const code = callbackUrl.searchParams.get("code")!;
|
||||
|
||||
const wrongVerifierResponse = await exchangeCode(adapter, { code, verifier: "wrong-verifier-wrong-verifier-wrong-verifier", clientId, redirectUri });
|
||||
expect(wrongVerifierResponse.status).toBe(400);
|
||||
expect((await wrongVerifierResponse.json()).error).toBe("invalid_grant");
|
||||
|
||||
const wrongRedirectResponse = await exchangeCode(adapter, { code, verifier: "irrelevant-because-redirect-is-checked-first", clientId, redirectUri: "https://client.example.com/other" });
|
||||
expect(wrongRedirectResponse.status).toBe(400);
|
||||
expect((await wrongRedirectResponse.json()).error).toBe("invalid_grant");
|
||||
});
|
||||
|
||||
it("redirects with access_denied when the user denies consent", async ({ expect }) => {
|
||||
const { adapter } = await setUpAdapter();
|
||||
const redirectUri = "https://client.example.com/callback";
|
||||
const clientId = await registerClient(adapter, redirectUri);
|
||||
const { challenge } = createPkcePair();
|
||||
|
||||
const callbackUrl = await authorizeAndGetCode(adapter, { clientId, redirectUri, challenge, state: "deny-state", deny: true });
|
||||
expect(callbackUrl.searchParams.get("error")).toBe("access_denied");
|
||||
expect(callbackUrl.searchParams.get("state")).toBe("deny-state");
|
||||
expect(callbackUrl.searchParams.get("code")).toBeNull();
|
||||
});
|
||||
|
||||
it("redirects signed-out users to the sign-in page", async ({ expect }) => {
|
||||
const { serverApp } = await setUpAdapter();
|
||||
await serverApp.signOut();
|
||||
const adapter = createMcpAuthAdapter({ app: serverApp, resourceUrl: RESOURCE_URL });
|
||||
const redirectUri = "https://client.example.com/callback";
|
||||
const clientId = await registerClient(adapter, redirectUri);
|
||||
const { challenge } = createPkcePair();
|
||||
|
||||
const authorizeUrl = new URL(`${OAUTH_BASE}/authorize`);
|
||||
authorizeUrl.searchParams.set("response_type", "code");
|
||||
authorizeUrl.searchParams.set("client_id", clientId);
|
||||
authorizeUrl.searchParams.set("redirect_uri", redirectUri);
|
||||
authorizeUrl.searchParams.set("code_challenge", challenge);
|
||||
authorizeUrl.searchParams.set("code_challenge_method", "S256");
|
||||
|
||||
const response = await adapter.handler(new Request(authorizeUrl.toString()));
|
||||
expect(response.status).toBe(302);
|
||||
const location = new URL(response.headers.get("location") ?? throwMissingLocation());
|
||||
expect(location.pathname).toContain("sign-in");
|
||||
expect(location.searchParams.get("after_auth_return_to")).toContain("/authorize");
|
||||
});
|
||||
|
||||
it("rejects authorization requests with unknown clients or unregistered redirect URIs", async ({ expect }) => {
|
||||
const { adapter } = await setUpAdapter();
|
||||
const redirectUri = "https://client.example.com/callback";
|
||||
const clientId = await registerClient(adapter, redirectUri);
|
||||
|
||||
const unknownClientUrl = new URL(`${OAUTH_BASE}/authorize`);
|
||||
unknownClientUrl.searchParams.set("response_type", "code");
|
||||
unknownClientUrl.searchParams.set("client_id", "not-a-real-client-id");
|
||||
unknownClientUrl.searchParams.set("redirect_uri", redirectUri);
|
||||
unknownClientUrl.searchParams.set("code_challenge", "abc");
|
||||
unknownClientUrl.searchParams.set("code_challenge_method", "S256");
|
||||
const unknownClientResponse = await adapter.handler(new Request(unknownClientUrl.toString()));
|
||||
expect(unknownClientResponse.status).toBe(400);
|
||||
|
||||
const wrongRedirectUrl = new URL(`${OAUTH_BASE}/authorize`);
|
||||
wrongRedirectUrl.searchParams.set("response_type", "code");
|
||||
wrongRedirectUrl.searchParams.set("client_id", clientId);
|
||||
wrongRedirectUrl.searchParams.set("redirect_uri", "https://attacker.example.com/callback");
|
||||
wrongRedirectUrl.searchParams.set("code_challenge", "abc");
|
||||
wrongRedirectUrl.searchParams.set("code_challenge_method", "S256");
|
||||
const wrongRedirectResponse = await adapter.handler(new Request(wrongRedirectUrl.toString()));
|
||||
expect(wrongRedirectResponse.status).toBe(400);
|
||||
});
|
||||
|
||||
it("rejects registration with invalid redirect URIs", async ({ expect }) => {
|
||||
const { adapter } = await setUpAdapter();
|
||||
for (const redirectUri of ["not-a-url", "javascript:alert(1)", "http://public.example.com/callback"]) {
|
||||
const response = await adapter.handler(new Request(`${OAUTH_BASE}/register`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ redirect_uris: [redirectUri] }),
|
||||
}));
|
||||
expect(response.status).toBe(400);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws InvalidMcpAccessTokenError for invalid bearer tokens", async ({ expect }) => {
|
||||
const { adapter } = await setUpAdapter();
|
||||
await expect(adapter.verifyAccessToken("not-a-valid-token")).rejects.toThrow(InvalidMcpAccessTokenError);
|
||||
});
|
||||
});
|
||||
@ -1,5 +1,7 @@
|
||||
export * from './lib/hexclave-app';
|
||||
export { getConvexProvidersConfig } from "./integrations/convex";
|
||||
export { createMcpAuthAdapter, InvalidMcpAccessTokenError } from "./integrations/mcp";
|
||||
export type { McpAuthAdapter, McpAuthAdapterOptions, McpAuthInfo } from "./integrations/mcp";
|
||||
// Hexclave aliases and legacy Stack* names — @deprecated JSDoc lives on the original
|
||||
// declarations in @hexclave/shared/config so it survives dts bundling
|
||||
// (per-specifier JSDoc on re-exports does not).
|
||||
|
||||
639
packages/template/src/integrations/mcp.ts
Normal file
639
packages/template/src/integrations/mcp.ts
Normal file
@ -0,0 +1,639 @@
|
||||
import { HexclaveServerInterface } from "@hexclave/shared/dist/interface/server-interface";
|
||||
import { AccessToken, RefreshToken } from "@hexclave/shared/dist/sessions";
|
||||
import { encodeBase64Url } from "@hexclave/shared/dist/utils/bytes";
|
||||
import { HexclaveAssertionError, throwErr } from "@hexclave/shared/dist/utils/errors";
|
||||
import { globalVar } from "@hexclave/shared/dist/utils/globals";
|
||||
import { escapeHtml } from "@hexclave/shared/dist/utils/html";
|
||||
import { deindent } from "@hexclave/shared/dist/utils/strings";
|
||||
import { urlString } from "@hexclave/shared/dist/utils/urls";
|
||||
import * as jose from "jose";
|
||||
import { JOSEError } from "jose/errors";
|
||||
import { StackServerApp } from "../lib/hexclave-app/apps/interfaces/server-app";
|
||||
import { ServerUser } from "../lib/hexclave-app/users";
|
||||
|
||||
/**
|
||||
* Structurally compatible with the `AuthInfo` type of the `@modelcontextprotocol/sdk` package, so the return value of
|
||||
* `verifyAccessToken` can be passed anywhere the MCP SDK expects an `AuthInfo` (eg. `requireBearerAuth`), without
|
||||
* this package having to depend on the MCP SDK.
|
||||
*/
|
||||
export type McpAuthInfo = {
|
||||
token: string,
|
||||
clientId: string,
|
||||
scopes: string[],
|
||||
expiresAt?: number,
|
||||
resource?: URL,
|
||||
extra?: Record<string, unknown>,
|
||||
};
|
||||
|
||||
export type McpAuthAdapterOptions = {
|
||||
/**
|
||||
* The Hexclave server app of the project whose users should be able to authenticate with the MCP server.
|
||||
*/
|
||||
app: StackServerApp<boolean, string>,
|
||||
/**
|
||||
* The full, public URL of the MCP endpoint that is being protected, eg. `https://example.com/mcp`.
|
||||
*/
|
||||
resourceUrl: string | URL,
|
||||
/**
|
||||
* The path prefix (on the same origin as `resourceUrl`) where the adapter's OAuth endpoints are mounted.
|
||||
*
|
||||
* @default "/api/mcp-oauth"
|
||||
*/
|
||||
oauthBasePath?: string,
|
||||
/**
|
||||
* The OAuth scopes that this MCP server supports. Note that Hexclave access tokens are not scoped; the granted
|
||||
* scopes are returned verbatim in the token response and in `McpAuthInfo.scopes` so the MCP server can do its own
|
||||
* scope checks.
|
||||
*/
|
||||
scopesSupported?: string[],
|
||||
/**
|
||||
* How long the session created for an MCP client should be valid (this is the lifetime of the refresh token, not
|
||||
* the access token).
|
||||
*
|
||||
* @default 30 days
|
||||
*/
|
||||
sessionExpiresInMillis?: number,
|
||||
};
|
||||
|
||||
export type McpAuthAdapter = {
|
||||
/**
|
||||
* A fetch-style handler (`Request => Response`) for all OAuth endpoints of the adapter. Mount it (eg. as Next.js
|
||||
* route handlers) such that it receives requests for:
|
||||
*
|
||||
* - `/.well-known/oauth-authorization-server` (and subpaths)
|
||||
* - `/.well-known/oauth-protected-resource` (and subpaths)
|
||||
* - `${oauthBasePath}/register|authorize|token`
|
||||
*/
|
||||
handler: (req: Request) => Promise<Response>,
|
||||
/**
|
||||
* Verifies a bearer token sent by an MCP client. Throws `InvalidMcpAccessTokenError` if the token is invalid, so
|
||||
* it can be used directly as the MCP SDK's `OAuthTokenVerifier.verifyAccessToken`.
|
||||
*/
|
||||
verifyAccessToken: (token: string) => Promise<McpAuthInfo>,
|
||||
/**
|
||||
* Wraps a fetch-style MCP request handler with bearer authentication. Responds with 401 + `WWW-Authenticate`
|
||||
* (pointing MCP clients at the protected resource metadata) when the token is missing or invalid.
|
||||
*/
|
||||
withMcpAuth: (
|
||||
handler: (req: Request, auth: { authInfo: McpAuthInfo, getUser: () => Promise<ServerUser> }) => Promise<Response>,
|
||||
) => (req: Request) => Promise<Response>,
|
||||
};
|
||||
|
||||
export class InvalidMcpAccessTokenError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "InvalidMcpAccessTokenError";
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_OAUTH_BASE_PATH = "/api/mcp-oauth";
|
||||
const DEFAULT_SESSION_EXPIRES_IN_MILLIS = 1000 * 60 * 60 * 24 * 30;
|
||||
const AUTHORIZATION_CODE_EXPIRATION = "2m";
|
||||
const CONSENT_INTERACTION_EXPIRATION = "15m";
|
||||
|
||||
type TokenUse = "client" | "interaction" | "code";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization, mcp-protocol-version",
|
||||
};
|
||||
|
||||
function jsonResponse(status: number, body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json", ...corsHeaders },
|
||||
});
|
||||
}
|
||||
|
||||
function oauthErrorResponse(status: number, error: string, description: string): Response {
|
||||
return jsonResponse(status, { error, error_description: description });
|
||||
}
|
||||
|
||||
function htmlResponse(status: number, html: string): Response {
|
||||
return new Response(html, { status, headers: { "Content-Type": "text/html; charset=utf-8" } });
|
||||
}
|
||||
|
||||
function isAllowedRedirectUri(uri: string): boolean {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(uri);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (url.protocol === "https:") return true;
|
||||
// plain HTTP is only allowed for loopback redirect URIs (native clients, RFC 8252 §7.3)
|
||||
if (url.protocol === "http:") return ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
|
||||
// custom schemes (eg. `cursor://...`) are used by native MCP clients
|
||||
return url.protocol !== "javascript:" && url.protocol !== "data:" && url.protocol !== "file:";
|
||||
}
|
||||
|
||||
async function sha256Base64Url(input: string): Promise<string> {
|
||||
const digest = await globalVar.crypto.subtle.digest("SHA-256", new TextEncoder().encode(input));
|
||||
return encodeBase64Url(new Uint8Array(digest));
|
||||
}
|
||||
|
||||
export function createMcpAuthAdapter(options: McpAuthAdapterOptions): McpAuthAdapter {
|
||||
const app = options.app;
|
||||
// The interface (and thereby the secret server key, which we derive the adapter's stateless signing key from) is
|
||||
// not part of the public StackServerApp type, so grab it off the implementation. The instanceof check validates
|
||||
// the cast at runtime.
|
||||
const iface = (app as unknown as { _interface: unknown })._interface;
|
||||
if (!(iface instanceof HexclaveServerInterface)) {
|
||||
throw new HexclaveAssertionError("createMcpAuthAdapter: expected a StackServerApp instance");
|
||||
}
|
||||
const ifaceOptions = iface.options;
|
||||
if (!("secretServerKey" in ifaceOptions)) {
|
||||
throw new HexclaveAssertionError("createMcpAuthAdapter: the app passed to the MCP auth adapter must be constructed with a secretServerKey");
|
||||
}
|
||||
const secretServerKey = ifaceOptions.secretServerKey;
|
||||
const projectId = ifaceOptions.projectId;
|
||||
const apiBaseUrl = ifaceOptions.getBaseUrl();
|
||||
|
||||
const resourceUrl = new URL(options.resourceUrl);
|
||||
const oauthBasePath = options.oauthBasePath ?? DEFAULT_OAUTH_BASE_PATH;
|
||||
if (!oauthBasePath.startsWith("/") || oauthBasePath.endsWith("/")) {
|
||||
throw new HexclaveAssertionError("createMcpAuthAdapter: oauthBasePath must start with (but not end with) a slash", { oauthBasePath });
|
||||
}
|
||||
const scopesSupported = options.scopesSupported ?? [];
|
||||
const sessionExpiresInMillis = options.sessionExpiresInMillis ?? DEFAULT_SESSION_EXPIRES_IN_MILLIS;
|
||||
|
||||
const issuer = new URL(oauthBasePath, resourceUrl.origin).toString();
|
||||
|
||||
let signingKeyPromise: Promise<Uint8Array> | null = null;
|
||||
const getSigningKey = () => {
|
||||
// The adapter is fully stateless: dynamic client registrations, consent interactions, and authorization codes
|
||||
// are all HS256 JWTs signed with a key derived from the project's secret server key.
|
||||
if (signingKeyPromise == null) {
|
||||
signingKeyPromise = (async () => {
|
||||
const digest: ArrayBuffer = await globalVar.crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
new TextEncoder().encode(JSON.stringify(["hexclave-mcp-oauth-signing-key", secretServerKey])),
|
||||
);
|
||||
return new Uint8Array(digest);
|
||||
})();
|
||||
}
|
||||
return signingKeyPromise;
|
||||
};
|
||||
|
||||
const signToken = async (tokenUse: TokenUse, payload: Record<string, unknown>, expirationTime?: string) => {
|
||||
let jwt = new jose.SignJWT({ ...payload, token_use: tokenUse })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setIssuedAt()
|
||||
.setIssuer(issuer);
|
||||
if (expirationTime !== undefined) {
|
||||
jwt = jwt.setExpirationTime(expirationTime);
|
||||
}
|
||||
return await jwt.sign(await getSigningKey());
|
||||
};
|
||||
|
||||
const verifySignedToken = async (tokenUse: TokenUse, token: string): Promise<jose.JWTPayload | null> => {
|
||||
try {
|
||||
const { payload } = await jose.jwtVerify(token, await getSigningKey(), { issuer });
|
||||
if (payload.token_use !== tokenUse) return null;
|
||||
return payload;
|
||||
} catch (e) {
|
||||
if (e instanceof JOSEError) return null;
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
type RegisteredClient = { redirectUris: string[], clientName: string | null };
|
||||
|
||||
const parseClientId = async (clientId: string): Promise<RegisteredClient | null> => {
|
||||
const payload = await verifySignedToken("client", clientId);
|
||||
if (!payload) return null;
|
||||
const redirectUris = payload.redirect_uris;
|
||||
if (!Array.isArray(redirectUris) || !redirectUris.every((u) => typeof u === "string")) return null;
|
||||
return {
|
||||
redirectUris,
|
||||
clientName: typeof payload.client_name === "string" ? payload.client_name : null,
|
||||
};
|
||||
};
|
||||
|
||||
const handleAuthorizationServerMetadata = () => {
|
||||
return jsonResponse(200, {
|
||||
issuer,
|
||||
authorization_endpoint: `${issuer}/authorize`,
|
||||
token_endpoint: `${issuer}/token`,
|
||||
registration_endpoint: `${issuer}/register`,
|
||||
response_types_supported: ["code"],
|
||||
response_modes_supported: ["query"],
|
||||
grant_types_supported: ["authorization_code", "refresh_token"],
|
||||
code_challenge_methods_supported: ["S256"],
|
||||
token_endpoint_auth_methods_supported: ["none"],
|
||||
...scopesSupported.length > 0 ? { scopes_supported: scopesSupported } : {},
|
||||
});
|
||||
};
|
||||
|
||||
const handleProtectedResourceMetadata = () => {
|
||||
return jsonResponse(200, {
|
||||
resource: resourceUrl.toString(),
|
||||
authorization_servers: [issuer],
|
||||
bearer_methods_supported: ["header"],
|
||||
...scopesSupported.length > 0 ? { scopes_supported: scopesSupported } : {},
|
||||
});
|
||||
};
|
||||
|
||||
const handleRegister = async (req: Request): Promise<Response> => {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return oauthErrorResponse(400, "invalid_client_metadata", "Request body is not valid JSON.");
|
||||
}
|
||||
if (typeof body !== "object" || body === null) {
|
||||
return oauthErrorResponse(400, "invalid_client_metadata", "Request body must be a JSON object.");
|
||||
}
|
||||
const bodyObject = body as Record<string, unknown>;
|
||||
const redirectUris = bodyObject.redirect_uris;
|
||||
if (!Array.isArray(redirectUris) || redirectUris.length === 0 || !redirectUris.every((u) => typeof u === "string")) {
|
||||
return oauthErrorResponse(400, "invalid_redirect_uri", "redirect_uris must be a non-empty array of strings.");
|
||||
}
|
||||
for (const uri of redirectUris) {
|
||||
if (!isAllowedRedirectUri(uri)) {
|
||||
return oauthErrorResponse(400, "invalid_redirect_uri", `Invalid redirect URI: ${uri}`);
|
||||
}
|
||||
}
|
||||
const clientName = typeof bodyObject.client_name === "string" ? bodyObject.client_name : undefined;
|
||||
|
||||
const clientId = await signToken("client", {
|
||||
redirect_uris: redirectUris,
|
||||
...clientName !== undefined ? { client_name: clientName } : {},
|
||||
});
|
||||
|
||||
return jsonResponse(201, {
|
||||
client_id: clientId,
|
||||
client_id_issued_at: Math.floor(Date.now() / 1000),
|
||||
redirect_uris: redirectUris,
|
||||
token_endpoint_auth_method: "none",
|
||||
grant_types: ["authorization_code", "refresh_token"],
|
||||
response_types: ["code"],
|
||||
...clientName !== undefined ? { client_name: clientName } : {},
|
||||
});
|
||||
};
|
||||
|
||||
type AuthorizeParams = {
|
||||
clientId: string,
|
||||
client: RegisteredClient,
|
||||
redirectUri: string,
|
||||
codeChallenge: string,
|
||||
state: string | null,
|
||||
scope: string | null,
|
||||
resource: string | null,
|
||||
};
|
||||
|
||||
const parseAndValidateAuthorizeParams = async (params: URLSearchParams): Promise<{ ok: true, value: AuthorizeParams } | { ok: false, response: Response } | { ok: false, redirectError: { redirectUri: string, state: string | null, error: string, description: string } }> => {
|
||||
const clientId = params.get("client_id");
|
||||
const redirectUri = params.get("redirect_uri");
|
||||
// per OAuth 2.1, errors in client_id/redirect_uri must NOT redirect to the redirect URI
|
||||
if (clientId == null || redirectUri == null) {
|
||||
return { ok: false, response: htmlResponse(400, renderErrorPage("Missing client_id or redirect_uri parameter.")) };
|
||||
}
|
||||
const client = await parseClientId(clientId);
|
||||
if (client == null) {
|
||||
return { ok: false, response: htmlResponse(400, renderErrorPage("Unknown client_id. Register the client first at the registration endpoint.")) };
|
||||
}
|
||||
if (!client.redirectUris.includes(redirectUri)) {
|
||||
return { ok: false, response: htmlResponse(400, renderErrorPage("The given redirect_uri is not registered for this client.")) };
|
||||
}
|
||||
const state = params.get("state");
|
||||
if (params.get("response_type") !== "code") {
|
||||
return { ok: false, redirectError: { redirectUri, state, error: "unsupported_response_type", description: "Only response_type=code is supported." } };
|
||||
}
|
||||
const codeChallenge = params.get("code_challenge");
|
||||
if (codeChallenge == null || params.get("code_challenge_method") !== "S256") {
|
||||
return { ok: false, redirectError: { redirectUri, state, error: "invalid_request", description: "PKCE with code_challenge_method=S256 is required." } };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
clientId,
|
||||
client,
|
||||
redirectUri,
|
||||
codeChallenge,
|
||||
state,
|
||||
scope: params.get("scope"),
|
||||
resource: params.get("resource"),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const redirectWithError = (redirectUri: string, state: string | null, error: string, description: string): Response => {
|
||||
const url = new URL(redirectUri);
|
||||
url.searchParams.set("error", error);
|
||||
url.searchParams.set("error_description", description);
|
||||
if (state != null) url.searchParams.set("state", state);
|
||||
return Response.redirect(url.toString(), 302);
|
||||
};
|
||||
|
||||
const renderErrorPage = (message: string) => deindent`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Authorization error</title></head>
|
||||
<body>
|
||||
<h1>Authorization error</h1>
|
||||
<p>${escapeHtml(message)}</p>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const renderConsentPage = (reqUrl: URL, params: AuthorizeParams, interactionToken: string, userDisplay: string) => {
|
||||
const clientDisplay = params.client.clientName ?? new URL(params.redirectUri).host;
|
||||
const hiddenFields = [
|
||||
...reqUrl.searchParams.entries(),
|
||||
["interaction_token", interactionToken],
|
||||
].map(([key, value]) => `<input type="hidden" name="${escapeHtml(key)}" value="${escapeHtml(value)}">`).join("\n");
|
||||
return deindent`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Authorize ${escapeHtml(clientDisplay)}</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; background: #f4f4f5; }
|
||||
.card { background: white; border-radius: 12px; padding: 32px; max-width: 400px; box-shadow: 0 1px 4px rgba(0,0,0,0.1); }
|
||||
.buttons { display: flex; gap: 12px; margin-top: 24px; }
|
||||
button { flex: 1; padding: 10px; border-radius: 8px; border: 1px solid #d4d4d8; background: white; cursor: pointer; font-size: 14px; }
|
||||
button.approve { background: #18181b; color: white; border-color: #18181b; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>Authorization request</h1>
|
||||
<p><strong>${escapeHtml(clientDisplay)}</strong> wants to access your account (${escapeHtml(userDisplay)}).</p>
|
||||
${params.scope != null ? `<p>Requested scopes: <code>${escapeHtml(params.scope)}</code></p>` : ""}
|
||||
<form method="POST">
|
||||
${hiddenFields}
|
||||
<div class="buttons">
|
||||
<button type="submit" name="action" value="deny">Deny</button>
|
||||
<button type="submit" name="action" value="approve" class="approve">Approve</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
};
|
||||
|
||||
const getCurrentUser = async () => {
|
||||
// uses the token store the app was constructed with (eg. "nextjs-cookie"), which contains the session of the
|
||||
// user currently signed in to the customer's app on this origin
|
||||
return await app.getUser({ or: "return-null" });
|
||||
};
|
||||
|
||||
const handleAuthorizeGet = async (req: Request): Promise<Response> => {
|
||||
const reqUrl = new URL(req.url);
|
||||
const parsed = await parseAndValidateAuthorizeParams(reqUrl.searchParams);
|
||||
if (!parsed.ok) {
|
||||
return "response" in parsed ? parsed.response : redirectWithError(parsed.redirectError.redirectUri, parsed.redirectError.state, parsed.redirectError.error, parsed.redirectError.description);
|
||||
}
|
||||
const params = parsed.value;
|
||||
|
||||
const user = await getCurrentUser();
|
||||
if (user == null) {
|
||||
// reuse the app's existing sign-in page; it redirects back to this authorize URL after authentication
|
||||
const signInUrl = new URL(app.urls.signIn, reqUrl);
|
||||
signInUrl.searchParams.set("after_auth_return_to", reqUrl.pathname + reqUrl.search);
|
||||
return Response.redirect(signInUrl.toString(), 302);
|
||||
}
|
||||
|
||||
const interactionToken = await signToken("interaction", {
|
||||
sub: user.id,
|
||||
authorize_params_hash: await sha256Base64Url(reqUrl.searchParams.toString()),
|
||||
}, CONSENT_INTERACTION_EXPIRATION);
|
||||
|
||||
return htmlResponse(200, renderConsentPage(reqUrl, params, interactionToken, user.primaryEmail ?? user.displayName ?? user.id));
|
||||
};
|
||||
|
||||
const handleAuthorizePost = async (req: Request): Promise<Response> => {
|
||||
const formData = await req.formData();
|
||||
const formParams = new URLSearchParams();
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (typeof value === "string" && key !== "interaction_token" && key !== "action") {
|
||||
formParams.append(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = await parseAndValidateAuthorizeParams(formParams);
|
||||
if (!parsed.ok) {
|
||||
return "response" in parsed ? parsed.response : redirectWithError(parsed.redirectError.redirectUri, parsed.redirectError.state, parsed.redirectError.error, parsed.redirectError.description);
|
||||
}
|
||||
const params = parsed.value;
|
||||
|
||||
const action = formData.get("action");
|
||||
if (action !== "approve") {
|
||||
return redirectWithError(params.redirectUri, params.state, "access_denied", "The user denied the authorization request.");
|
||||
}
|
||||
|
||||
const interactionTokenValue = formData.get("interaction_token");
|
||||
const interaction = typeof interactionTokenValue === "string" ? await verifySignedToken("interaction", interactionTokenValue) : null;
|
||||
if (interaction == null || interaction.authorize_params_hash !== await sha256Base64Url(formParams.toString())) {
|
||||
return htmlResponse(400, renderErrorPage("Invalid or expired authorization interaction. Please try again."));
|
||||
}
|
||||
|
||||
const user = await getCurrentUser();
|
||||
if (user == null || user.id !== interaction.sub) {
|
||||
return htmlResponse(400, renderErrorPage("Your session has changed since the authorization request was started. Please try again."));
|
||||
}
|
||||
|
||||
const code = await signToken("code", {
|
||||
sub: user.id,
|
||||
client_id_hash: await sha256Base64Url(params.clientId),
|
||||
redirect_uri: params.redirectUri,
|
||||
code_challenge: params.codeChallenge,
|
||||
...params.scope != null ? { scope: params.scope } : {},
|
||||
...params.resource != null ? { resource: params.resource } : {},
|
||||
}, AUTHORIZATION_CODE_EXPIRATION);
|
||||
|
||||
const url = new URL(params.redirectUri);
|
||||
url.searchParams.set("code", code);
|
||||
if (params.state != null) url.searchParams.set("state", params.state);
|
||||
return Response.redirect(url.toString(), 302);
|
||||
};
|
||||
|
||||
const handleToken = async (req: Request): Promise<Response> => {
|
||||
let form: URLSearchParams;
|
||||
try {
|
||||
form = new URLSearchParams(await req.text());
|
||||
} catch {
|
||||
return oauthErrorResponse(400, "invalid_request", "Request body must be application/x-www-form-urlencoded.");
|
||||
}
|
||||
const grantType = form.get("grant_type");
|
||||
switch (grantType) {
|
||||
case "authorization_code": {
|
||||
const code = form.get("code");
|
||||
const codeVerifier = form.get("code_verifier");
|
||||
const clientId = form.get("client_id");
|
||||
const redirectUri = form.get("redirect_uri");
|
||||
if (code == null || codeVerifier == null || clientId == null || redirectUri == null) {
|
||||
return oauthErrorResponse(400, "invalid_request", "code, code_verifier, client_id, and redirect_uri are required.");
|
||||
}
|
||||
const payload = await verifySignedToken("code", code);
|
||||
if (payload == null) {
|
||||
return oauthErrorResponse(400, "invalid_grant", "Invalid or expired authorization code.");
|
||||
}
|
||||
if (payload.client_id_hash !== await sha256Base64Url(clientId)) {
|
||||
return oauthErrorResponse(400, "invalid_grant", "The authorization code was issued to a different client.");
|
||||
}
|
||||
if (payload.redirect_uri !== redirectUri) {
|
||||
return oauthErrorResponse(400, "invalid_grant", "redirect_uri does not match the one used in the authorization request.");
|
||||
}
|
||||
if (payload.code_challenge !== await sha256Base64Url(codeVerifier)) {
|
||||
return oauthErrorResponse(400, "invalid_grant", "PKCE verification failed.");
|
||||
}
|
||||
const userId = payload.sub ?? throwErr("Authorization code has no sub; this should never happen because we always set it when signing");
|
||||
const user = await app.getUser(userId);
|
||||
if (user == null) {
|
||||
return oauthErrorResponse(400, "invalid_grant", "The user this authorization code was issued for no longer exists.");
|
||||
}
|
||||
const session = await user.createSession({ expiresInMillis: sessionExpiresInMillis });
|
||||
const tokens = await session.getTokens();
|
||||
if (tokens.accessToken == null || tokens.refreshToken == null) {
|
||||
throw new HexclaveAssertionError("createSession did not return tokens; this should never happen for non-impersonation sessions");
|
||||
}
|
||||
const accessToken = AccessToken.createIfValid(tokens.accessToken) ?? throwErr("Backend returned an invalid access token");
|
||||
return jsonResponse(200, {
|
||||
access_token: tokens.accessToken,
|
||||
refresh_token: tokens.refreshToken,
|
||||
token_type: "bearer",
|
||||
expires_in: Math.max(0, Math.floor((accessToken.expiresAt.getTime() - Date.now()) / 1000)),
|
||||
...typeof payload.scope === "string" ? { scope: payload.scope } : {},
|
||||
});
|
||||
}
|
||||
case "refresh_token": {
|
||||
const refreshTokenValue = form.get("refresh_token");
|
||||
if (refreshTokenValue == null) {
|
||||
return oauthErrorResponse(400, "invalid_request", "refresh_token is required.");
|
||||
}
|
||||
const accessToken = await iface.fetchNewAccessToken(new RefreshToken(refreshTokenValue));
|
||||
if (accessToken == null) {
|
||||
return oauthErrorResponse(400, "invalid_grant", "Invalid or expired refresh token.");
|
||||
}
|
||||
return jsonResponse(200, {
|
||||
access_token: accessToken.token,
|
||||
refresh_token: refreshTokenValue,
|
||||
token_type: "bearer",
|
||||
expires_in: Math.max(0, Math.floor((accessToken.expiresAt.getTime() - Date.now()) / 1000)),
|
||||
});
|
||||
}
|
||||
default: {
|
||||
return oauthErrorResponse(400, "unsupported_grant_type", `Unsupported grant_type: ${grantType ?? "<missing>"}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let remoteJwkSet: ReturnType<typeof jose.createRemoteJWKSet> | null = null;
|
||||
const getRemoteJwkSet = () => {
|
||||
remoteJwkSet ??= jose.createRemoteJWKSet(new URL(urlString`/api/v1/projects/${projectId}/.well-known/jwks.json`, apiBaseUrl));
|
||||
return remoteJwkSet;
|
||||
};
|
||||
|
||||
const verifyAccessToken = async (token: string): Promise<McpAuthInfo> => {
|
||||
let payload: jose.JWTPayload;
|
||||
try {
|
||||
// Hexclave access tokens are ES256 JWTs signed with the project's JWKS; the audience of non-anonymous,
|
||||
// non-restricted user tokens is exactly the project ID (anonymous/restricted tokens have a different audience
|
||||
// and are therefore rejected here).
|
||||
const verified = await jose.jwtVerify(token, getRemoteJwkSet(), { audience: projectId });
|
||||
payload = verified.payload;
|
||||
} catch (e) {
|
||||
if (e instanceof JOSEError) {
|
||||
throw new InvalidMcpAccessTokenError("Invalid or expired access token.");
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
const userId = payload.sub ?? throwErr("Verified access token has no sub claim; this should never happen because the backend always sets it");
|
||||
return {
|
||||
token,
|
||||
// Hexclave access tokens are regular session tokens and don't carry the (stateless) MCP OAuth client ID, so we
|
||||
// can't recover which dynamically registered client the token was issued to.
|
||||
clientId: "hexclave-mcp-oauth-client",
|
||||
scopes: scopesSupported,
|
||||
...payload.exp !== undefined ? { expiresAt: payload.exp } : {},
|
||||
extra: { userId },
|
||||
};
|
||||
};
|
||||
|
||||
const protectedResourceMetadataUrl = new URL(
|
||||
`/.well-known/oauth-protected-resource${resourceUrl.pathname === "/" ? "" : resourceUrl.pathname}`,
|
||||
resourceUrl.origin,
|
||||
).toString();
|
||||
|
||||
const withMcpAuth: McpAuthAdapter["withMcpAuth"] = (handler) => {
|
||||
return async (req: Request) => {
|
||||
const unauthorized = (description: string) => new Response(JSON.stringify({ error: "invalid_token", error_description: description }), {
|
||||
status: 401,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"WWW-Authenticate": `Bearer error="invalid_token", error_description="${description}", resource_metadata="${protectedResourceMetadataUrl}"`,
|
||||
...corsHeaders,
|
||||
},
|
||||
});
|
||||
|
||||
const authorization = req.headers.get("authorization");
|
||||
if (authorization == null || !authorization.toLowerCase().startsWith("bearer ")) {
|
||||
return unauthorized("Missing bearer token.");
|
||||
}
|
||||
let authInfo: McpAuthInfo;
|
||||
try {
|
||||
authInfo = await verifyAccessToken(authorization.slice("bearer ".length));
|
||||
} catch (e) {
|
||||
if (e instanceof InvalidMcpAccessTokenError) {
|
||||
return unauthorized("Invalid or expired access token.");
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
return await handler(req, {
|
||||
authInfo,
|
||||
getUser: async () => {
|
||||
const userId = authInfo.extra?.userId;
|
||||
if (typeof userId !== "string") throw new HexclaveAssertionError("authInfo.extra.userId is not a string; this should never happen because verifyAccessToken always sets it");
|
||||
return await app.getUser(userId) ?? throwErr("The user of a verified access token no longer exists");
|
||||
},
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
const handler = async (req: Request): Promise<Response> => {
|
||||
const url = new URL(req.url);
|
||||
const pathname = url.pathname;
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response(null, { status: 204, headers: corsHeaders });
|
||||
}
|
||||
|
||||
if (pathname === "/.well-known/oauth-authorization-server" || pathname.startsWith("/.well-known/oauth-authorization-server/")) {
|
||||
if (req.method !== "GET") return oauthErrorResponse(405, "invalid_request", "Method not allowed.");
|
||||
return handleAuthorizationServerMetadata();
|
||||
}
|
||||
if (pathname === "/.well-known/oauth-protected-resource" || pathname.startsWith("/.well-known/oauth-protected-resource/")) {
|
||||
if (req.method !== "GET") return oauthErrorResponse(405, "invalid_request", "Method not allowed.");
|
||||
return handleProtectedResourceMetadata();
|
||||
}
|
||||
switch (pathname) {
|
||||
case `${oauthBasePath}/register`: {
|
||||
if (req.method !== "POST") return oauthErrorResponse(405, "invalid_request", "Method not allowed.");
|
||||
return await handleRegister(req);
|
||||
}
|
||||
case `${oauthBasePath}/authorize`: {
|
||||
if (req.method === "GET") return await handleAuthorizeGet(req);
|
||||
if (req.method === "POST") return await handleAuthorizePost(req);
|
||||
return oauthErrorResponse(405, "invalid_request", "Method not allowed.");
|
||||
}
|
||||
case `${oauthBasePath}/token`: {
|
||||
if (req.method !== "POST") return oauthErrorResponse(405, "invalid_request", "Method not allowed.");
|
||||
return await handleToken(req);
|
||||
}
|
||||
default: {
|
||||
return jsonResponse(404, { error: "not_found", error_description: `Unknown MCP OAuth endpoint: ${pathname}` });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
handler,
|
||||
verifyAccessToken,
|
||||
withMcpAuth,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user