keryx 0.22.1 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,5 +13,9 @@ export const configServerMcp = {
13
13
  60 * 60 * 24 * 30,
14
14
  ), // 30 days, in seconds
15
15
  oauthCodeTtl: await loadFromEnvIfSet("MCP_OAUTH_CODE_TTL", 300), // 5 minutes, in seconds
16
+ oauthRefreshTtl: await loadFromEnvIfSet(
17
+ "MCP_OAUTH_REFRESH_TTL",
18
+ 60 * 60 * 24 * 30,
19
+ ), // 30 days, in seconds
16
20
  markdownDepthLimit: await loadFromEnvIfSet("MCP_MARKDOWN_DEPTH_LIMIT", 5),
17
21
  };
@@ -10,10 +10,14 @@ import {
10
10
  import {
11
11
  handleAuthorizeGet,
12
12
  handleAuthorizePost,
13
+ handleIntrospect,
13
14
  handleMetadata,
14
15
  handleProtectedResourceMetadata,
15
16
  handleRegister,
17
+ handleRevoke,
16
18
  handleToken,
19
+ OAUTH_PATHS,
20
+ OAUTH_WELL_KNOWN_PATHS,
17
21
  type TokenData,
18
22
  } from "../util/oauthHandlers";
19
23
  import {
@@ -70,7 +74,7 @@ export class OAuthInitializer extends Initializer {
70
74
  return new Response(null, { status: 204, headers: corsHeaders });
71
75
  }
72
76
 
73
- const prmPrefix = "/.well-known/oauth-protected-resource";
77
+ const prmPrefix = OAUTH_WELL_KNOWN_PATHS.protectedResource;
74
78
  if (path.startsWith(prmPrefix) && method === "GET") {
75
79
  const resourcePath = path.slice(prmPrefix.length) || "";
76
80
  return appendHeaders(
@@ -79,7 +83,7 @@ export class OAuthInitializer extends Initializer {
79
83
  );
80
84
  }
81
85
  if (
82
- path === "/.well-known/oauth-authorization-server" &&
86
+ path === OAUTH_WELL_KNOWN_PATHS.authorizationServer &&
83
87
  method === "GET"
84
88
  ) {
85
89
  return appendHeaders(handleMetadata(origin), corsHeaders);
@@ -89,13 +93,15 @@ export class OAuthInitializer extends Initializer {
89
93
  if (
90
94
  config.rateLimit.enabled &&
91
95
  ip &&
92
- (path === "/oauth/register" ||
93
- path === "/oauth/authorize" ||
94
- path === "/oauth/token")
96
+ (path === OAUTH_PATHS.register ||
97
+ path === OAUTH_PATHS.authorize ||
98
+ path === OAUTH_PATHS.token ||
99
+ path === OAUTH_PATHS.introspect ||
100
+ path === OAUTH_PATHS.revoke)
95
101
  ) {
96
102
  // /oauth/register gets a stricter, dedicated rate limit
97
103
  const overrides =
98
- path === "/oauth/register"
104
+ path === OAUTH_PATHS.register
99
105
  ? {
100
106
  limit: config.rateLimit.oauthRegisterLimit,
101
107
  windowMs: config.rateLimit.oauthRegisterWindowMs,
@@ -123,18 +129,24 @@ export class OAuthInitializer extends Initializer {
123
129
  }
124
130
  }
125
131
 
126
- if (path === "/oauth/register" && method === "POST") {
132
+ if (path === OAUTH_PATHS.register && method === "POST") {
127
133
  return appendHeaders(await handleRegister(req), corsHeaders);
128
134
  }
129
- if (path === "/oauth/authorize" && method === "GET") {
135
+ if (path === OAUTH_PATHS.authorize && method === "GET") {
130
136
  return handleAuthorizeGet(url, templates);
131
137
  }
132
- if (path === "/oauth/authorize" && method === "POST") {
138
+ if (path === OAUTH_PATHS.authorize && method === "POST") {
133
139
  return handleAuthorizePost(req, templates);
134
140
  }
135
- if (path === "/oauth/token" && method === "POST") {
141
+ if (path === OAUTH_PATHS.token && method === "POST") {
136
142
  return appendHeaders(await handleToken(req), corsHeaders);
137
143
  }
144
+ if (path === OAUTH_PATHS.introspect && method === "POST") {
145
+ return appendHeaders(await handleIntrospect(req), corsHeaders);
146
+ }
147
+ if (path === OAUTH_PATHS.revoke && method === "POST") {
148
+ return appendHeaders(await handleRevoke(req), corsHeaders);
149
+ }
138
150
 
139
151
  return null;
140
152
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keryx",
3
- "version": "0.22.1",
3
+ "version": "0.23.0",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,170 @@
1
+ import { randomUUID } from "crypto";
2
+ import { api } from "../../api";
3
+ import type { Action, OAuthActionResponse } from "../../classes/Action";
4
+ import { Connection } from "../../classes/Connection";
5
+ import { config } from "../../config";
6
+ import { redirectUrisMatch } from "../oauth";
7
+ import {
8
+ type AuthPageParams,
9
+ type OAuthTemplates,
10
+ renderAuthPage,
11
+ renderSuccessPage,
12
+ } from "../oauthTemplates";
13
+ import { clientKey, codeKey } from "./keys";
14
+ import type { AuthCode, OAuthClient } from "./types";
15
+
16
+ /** OAuth protocol fields that should not be forwarded to login/signup actions. */
17
+ const OAUTH_FIELDS = new Set([
18
+ "mode",
19
+ "client_id",
20
+ "redirect_uri",
21
+ "code_challenge",
22
+ "code_challenge_method",
23
+ "response_type",
24
+ "state",
25
+ ]);
26
+
27
+ function findAuthActions() {
28
+ return {
29
+ loginAction: api.actions.actions.find((a: Action) => a.mcp?.isLoginAction),
30
+ signupAction: api.actions.actions.find(
31
+ (a: Action) => a.mcp?.isSignupAction,
32
+ ),
33
+ };
34
+ }
35
+
36
+ /** Render the OAuth authorize page (GET). */
37
+ export function handleAuthorizeGet(
38
+ url: URL,
39
+ templates: OAuthTemplates,
40
+ ): Response {
41
+ const params: AuthPageParams = {
42
+ clientId: url.searchParams.get("client_id") ?? "",
43
+ redirectUri: url.searchParams.get("redirect_uri") ?? "",
44
+ codeChallenge: url.searchParams.get("code_challenge") ?? "",
45
+ codeChallengeMethod: url.searchParams.get("code_challenge_method") ?? "",
46
+ responseType: url.searchParams.get("response_type") ?? "",
47
+ state: url.searchParams.get("state") ?? "",
48
+ error: "",
49
+ };
50
+
51
+ return renderAuthPage(params, templates, findAuthActions());
52
+ }
53
+
54
+ /**
55
+ * Run the registered login or signup action inside an ephemeral OAuth
56
+ * connection and return the authenticated user id. Returns an error string if
57
+ * the action is not configured or the action itself returned an error.
58
+ */
59
+ async function runAuthAction(
60
+ mode: "signup" | "login",
61
+ fields: Record<string, string>,
62
+ ): Promise<{ userId: number } | { error: string }> {
63
+ const isSignup = mode === "signup";
64
+ const action = api.actions.actions.find((a: Action) =>
65
+ isSignup ? a.mcp?.isSignupAction : a.mcp?.isLoginAction,
66
+ );
67
+ if (!action) {
68
+ return {
69
+ error: isSignup
70
+ ? "No signup action configured"
71
+ : "No login action configured",
72
+ };
73
+ }
74
+
75
+ const actionParams: Record<string, unknown> = {};
76
+ for (const [key, value] of Object.entries(fields)) {
77
+ if (!OAUTH_FIELDS.has(key)) actionParams[key] = value;
78
+ }
79
+
80
+ const connection = new Connection(
81
+ "oauth",
82
+ isSignup ? "oauth-signup" : "oauth-login",
83
+ );
84
+ try {
85
+ const { response, error } = await connection.act(action.name, actionParams);
86
+ if (error) return { error: error.message };
87
+ return { userId: (response as OAuthActionResponse).user.id };
88
+ } finally {
89
+ connection.destroy();
90
+ }
91
+ }
92
+
93
+ /** Handle the OAuth authorize form POST (signin/signup). */
94
+ export async function handleAuthorizePost(
95
+ req: Request,
96
+ templates: OAuthTemplates,
97
+ ): Promise<Response> {
98
+ let fields: Record<string, string>;
99
+ try {
100
+ const contentType = req.headers.get("content-type") ?? "";
101
+ if (contentType.includes("application/x-www-form-urlencoded")) {
102
+ const text = await req.text();
103
+ const params = new URLSearchParams(text);
104
+ fields = Object.fromEntries(params.entries());
105
+ } else {
106
+ const form = await req.formData();
107
+ fields = {};
108
+ form.forEach((value, key) => {
109
+ fields[key] = String(value);
110
+ });
111
+ }
112
+ } catch {
113
+ return new Response("Bad Request", { status: 400 });
114
+ }
115
+
116
+ const oauthParams: AuthPageParams = {
117
+ clientId: fields.client_id ?? "",
118
+ redirectUri: fields.redirect_uri ?? "",
119
+ codeChallenge: fields.code_challenge ?? "",
120
+ codeChallengeMethod: fields.code_challenge_method ?? "",
121
+ responseType: fields.response_type ?? "",
122
+ state: fields.state ?? "",
123
+ error: "",
124
+ };
125
+ const authActions = findAuthActions();
126
+
127
+ const renderError = (error: string) => {
128
+ oauthParams.error = error;
129
+ return renderAuthPage(oauthParams, templates, authActions);
130
+ };
131
+
132
+ const clientRaw = await api.redis.redis.get(clientKey(oauthParams.clientId));
133
+ if (!clientRaw) return renderError("Unknown client");
134
+ const client = JSON.parse(clientRaw) as OAuthClient;
135
+
136
+ const uriMatch = client.redirect_uris.some((registered) =>
137
+ redirectUrisMatch(registered, oauthParams.redirectUri),
138
+ );
139
+ if (!uriMatch) return renderError("Invalid redirect URI");
140
+
141
+ if (oauthParams.codeChallengeMethod !== "S256") {
142
+ return renderError("code_challenge_method must be S256");
143
+ }
144
+
145
+ const mode = fields.mode === "signup" ? "signup" : "login";
146
+ const result = await runAuthAction(mode, fields);
147
+ if ("error" in result) return renderError(result.error);
148
+
149
+ const code = randomUUID();
150
+ const codeData: AuthCode = {
151
+ clientId: oauthParams.clientId,
152
+ userId: result.userId,
153
+ codeChallenge: oauthParams.codeChallenge,
154
+ redirectUri: oauthParams.redirectUri,
155
+ };
156
+
157
+ await api.redis.redis.set(
158
+ codeKey(code),
159
+ JSON.stringify(codeData),
160
+ "EX",
161
+ config.server.mcp.oauthCodeTtl,
162
+ );
163
+
164
+ const redirectUrl = new URL(oauthParams.redirectUri);
165
+ redirectUrl.searchParams.set("code", code);
166
+ if (oauthParams.state)
167
+ redirectUrl.searchParams.set("state", oauthParams.state);
168
+
169
+ return renderSuccessPage(redirectUrl.toString(), templates);
170
+ }
@@ -0,0 +1,17 @@
1
+ export { handleAuthorizeGet, handleAuthorizePost } from "./authorize";
2
+ export { handleIntrospect } from "./introspect";
3
+ export {
4
+ handleMetadata,
5
+ handleProtectedResourceMetadata,
6
+ } from "./metadata";
7
+ export { handleRegister } from "./register";
8
+ export { handleRevoke } from "./revoke";
9
+ export { handleToken } from "./token";
10
+ export {
11
+ type AuthCode,
12
+ OAUTH_PATHS,
13
+ OAUTH_WELL_KNOWN_PATHS,
14
+ type OAuthClient,
15
+ type RefreshTokenData,
16
+ type TokenData,
17
+ } from "./types";
@@ -0,0 +1,59 @@
1
+ import { api } from "../../api";
2
+ import { clientKey, tokenLookupOrder } from "./keys";
3
+ import { jsonNoStoreResponse, oauthError, parseFormBody } from "./responses";
4
+ import type { RefreshTokenData, TokenData } from "./types";
5
+
6
+ const NOSTORE_HEADER = { "Cache-Control": "no-store" };
7
+
8
+ /**
9
+ * OAuth 2.0 Token Introspection endpoint (RFC 7662).
10
+ *
11
+ * Requires a registered `client_id` in the form body; unknown clients get 401
12
+ * so random callers cannot probe token state. Looks up `token` in both the
13
+ * access-token and refresh-token keyspaces (honoring `token_type_hint` for
14
+ * lookup order). Returns `{ active: false }` for any miss so we do not leak
15
+ * whether a token existed and expired versus never existed.
16
+ */
17
+ export async function handleIntrospect(req: Request): Promise<Response> {
18
+ const body = await parseFormBody(req);
19
+ const token = body.get("token");
20
+ const hint = body.get("token_type_hint");
21
+ const clientId = body.get("client_id");
22
+
23
+ if (!clientId) {
24
+ return oauthError(
25
+ "invalid_client",
26
+ "client_id is required",
27
+ 401,
28
+ NOSTORE_HEADER,
29
+ );
30
+ }
31
+
32
+ const clientRaw = await api.redis.redis.get(clientKey(clientId));
33
+ if (!clientRaw) {
34
+ return oauthError("invalid_client", "Unknown client", 401, NOSTORE_HEADER);
35
+ }
36
+
37
+ if (!token) return jsonNoStoreResponse({ active: false });
38
+
39
+ for (const key of tokenLookupOrder(token, hint)) {
40
+ const raw = await api.redis.redis.get(key);
41
+ if (!raw) continue;
42
+
43
+ const data = JSON.parse(raw) as TokenData | RefreshTokenData;
44
+ const ttlSeconds = await api.redis.redis.ttl(key);
45
+ const nowSeconds = Math.floor(Date.now() / 1000);
46
+ const exp = ttlSeconds > 0 ? nowSeconds + ttlSeconds : undefined;
47
+
48
+ return jsonNoStoreResponse({
49
+ active: true,
50
+ client_id: data.clientId,
51
+ scope: data.scopes.join(" "),
52
+ token_type: "Bearer",
53
+ exp,
54
+ sub: String(data.userId),
55
+ });
56
+ }
57
+
58
+ return jsonNoStoreResponse({ active: false });
59
+ }
@@ -0,0 +1,19 @@
1
+ /** Redis key builders for OAuth records. */
2
+ export const clientKey = (id: string) => `oauth:client:${id}`;
3
+ export const codeKey = (code: string) => `oauth:code:${code}`;
4
+ export const accessKey = (token: string) => `oauth:token:${token}`;
5
+ export const refreshKey = (token: string) => `oauth:refresh:${token}`;
6
+
7
+ /**
8
+ * When looking up an unknown bearer token (introspect/revoke), the caller may
9
+ * pass `token_type_hint` to hint at which keyspace to try first. Tokens are
10
+ * UUIDs so there is no collision risk; the hint is purely an optimization.
11
+ */
12
+ export function tokenLookupOrder(
13
+ token: string,
14
+ hint: string | null,
15
+ ): [string, string] {
16
+ return hint === "refresh_token"
17
+ ? [refreshKey(token), accessKey(token)]
18
+ : [accessKey(token), refreshKey(token)];
19
+ }
@@ -0,0 +1,38 @@
1
+ import { jsonResponse } from "./responses";
2
+ import { OAUTH_PATHS } from "./types";
3
+
4
+ /**
5
+ * RFC 9728 — Protected Resource Metadata.
6
+ * MCP clients fetch this first to discover the authorization server.
7
+ */
8
+ export function handleProtectedResourceMetadata(
9
+ origin: string,
10
+ resourcePath: string,
11
+ ): Response {
12
+ const resource = resourcePath ? `${origin}${resourcePath}` : origin;
13
+ return jsonResponse({
14
+ resource,
15
+ authorization_servers: [origin],
16
+ scopes_supported: ["mcp"],
17
+ });
18
+ }
19
+
20
+ /** OAuth 2.1 authorization server metadata endpoint (RFC 8414). */
21
+ export function handleMetadata(origin: string): Response {
22
+ const issuer = origin;
23
+ return jsonResponse({
24
+ issuer,
25
+ authorization_endpoint: `${issuer}${OAUTH_PATHS.authorize}`,
26
+ token_endpoint: `${issuer}${OAUTH_PATHS.token}`,
27
+ registration_endpoint: `${issuer}${OAUTH_PATHS.register}`,
28
+ introspection_endpoint: `${issuer}${OAUTH_PATHS.introspect}`,
29
+ revocation_endpoint: `${issuer}${OAUTH_PATHS.revoke}`,
30
+ response_types_supported: ["code"],
31
+ grant_types_supported: ["authorization_code", "refresh_token"],
32
+ code_challenge_methods_supported: ["S256"],
33
+ token_endpoint_auth_methods_supported: ["none"],
34
+ introspection_endpoint_auth_methods_supported: ["none"],
35
+ revocation_endpoint_auth_methods_supported: ["none"],
36
+ client_id_metadata_document_supported: false,
37
+ });
38
+ }
@@ -0,0 +1,58 @@
1
+ import { randomUUID } from "crypto";
2
+ import { api } from "../../api";
3
+ import { config } from "../../config";
4
+ import { validateRedirectUri } from "../oauth";
5
+ import { clientKey } from "./keys";
6
+ import { jsonResponse, oauthError } from "./responses";
7
+ import type { OAuthClient } from "./types";
8
+
9
+ /** Dynamic client registration endpoint (RFC 7591). */
10
+ export async function handleRegister(req: Request): Promise<Response> {
11
+ let body: any;
12
+ try {
13
+ body = await req.json();
14
+ } catch {
15
+ return oauthError("invalid_request", "Invalid JSON body");
16
+ }
17
+
18
+ if (
19
+ !body.redirect_uris ||
20
+ !Array.isArray(body.redirect_uris) ||
21
+ body.redirect_uris.length === 0
22
+ ) {
23
+ return oauthError("invalid_request", "redirect_uris is required");
24
+ }
25
+
26
+ for (const uri of body.redirect_uris) {
27
+ if (typeof uri !== "string") {
28
+ return oauthError(
29
+ "invalid_request",
30
+ "Each redirect_uri must be a string",
31
+ );
32
+ }
33
+ const validation = validateRedirectUri(uri);
34
+ if (!validation.valid) {
35
+ // `error` is always populated when `valid` is false; see oauth.ts
36
+ return oauthError("invalid_request", validation.error!);
37
+ }
38
+ }
39
+
40
+ const clientId = randomUUID();
41
+ const client: OAuthClient = {
42
+ client_id: clientId,
43
+ redirect_uris: body.redirect_uris,
44
+ client_name: body.client_name,
45
+ grant_types: body.grant_types ?? ["authorization_code"],
46
+ response_types: body.response_types ?? ["code"],
47
+ token_endpoint_auth_method: body.token_endpoint_auth_method ?? "none",
48
+ };
49
+
50
+ await api.redis.redis.set(
51
+ clientKey(clientId),
52
+ JSON.stringify(client),
53
+ "EX",
54
+ config.server.mcp.oauthClientTtl,
55
+ );
56
+
57
+ return jsonResponse(client, 201);
58
+ }
@@ -0,0 +1,56 @@
1
+ const JSON_HEADERS = { "Content-Type": "application/json" };
2
+
3
+ /** Headers for endpoints that must not be cached (RFC 7662 §4, RFC 7009 §2.2). */
4
+ const JSON_NOSTORE_HEADERS = {
5
+ "Content-Type": "application/json",
6
+ "Cache-Control": "no-store",
7
+ };
8
+
9
+ /** JSON response with status 200 by default. */
10
+ export function jsonResponse(
11
+ body: unknown,
12
+ status = 200,
13
+ extraHeaders?: Record<string, string>,
14
+ ): Response {
15
+ return new Response(JSON.stringify(body), {
16
+ status,
17
+ headers: { ...JSON_HEADERS, ...extraHeaders },
18
+ });
19
+ }
20
+
21
+ /** JSON response with `Cache-Control: no-store`. */
22
+ export function jsonNoStoreResponse(body: unknown, status = 200): Response {
23
+ return new Response(JSON.stringify(body), {
24
+ status,
25
+ headers: JSON_NOSTORE_HEADERS,
26
+ });
27
+ }
28
+
29
+ /** Standard OAuth error-shape response (`{ error, error_description }`). */
30
+ export function oauthError(
31
+ code: string,
32
+ description: string,
33
+ status = 400,
34
+ extraHeaders?: Record<string, string>,
35
+ ): Response {
36
+ return jsonResponse(
37
+ { error: code, error_description: description },
38
+ status,
39
+ extraHeaders,
40
+ );
41
+ }
42
+
43
+ /**
44
+ * Parse a form-urlencoded body (with JSON fallback) into URLSearchParams.
45
+ * OAuth endpoints accept both encodings for client convenience; RFC 6749
46
+ * specifies form-urlencoded, but several libraries default to JSON.
47
+ */
48
+ export async function parseFormBody(req: Request): Promise<URLSearchParams> {
49
+ const contentType = req.headers.get("content-type") ?? "";
50
+ if (contentType.includes("application/json")) {
51
+ const json = await req.json();
52
+ return new URLSearchParams(json as Record<string, string>);
53
+ }
54
+ const text = await req.text();
55
+ return new URLSearchParams(text);
56
+ }
@@ -0,0 +1,49 @@
1
+ import { api } from "../../api";
2
+ import { accessKey, refreshKey, tokenLookupOrder } from "./keys";
3
+ import { parseFormBody } from "./responses";
4
+ import type { RefreshTokenData, TokenData } from "./types";
5
+
6
+ const ok = () =>
7
+ new Response(null, {
8
+ status: 200,
9
+ headers: { "Cache-Control": "no-store" },
10
+ });
11
+
12
+ /**
13
+ * OAuth 2.0 Token Revocation endpoint (RFC 7009).
14
+ *
15
+ * Always returns 200 (even for unknown tokens or client mismatches) so callers
16
+ * cannot probe token state. When a token is found and the supplied `client_id`
17
+ * matches, the paired access+refresh keys are deleted together.
18
+ */
19
+ export async function handleRevoke(req: Request): Promise<Response> {
20
+ const body = await parseFormBody(req);
21
+ const token = body.get("token");
22
+ const hint = body.get("token_type_hint");
23
+ const clientId = body.get("client_id");
24
+
25
+ if (!token || !clientId) return ok();
26
+
27
+ const accessK = accessKey(token);
28
+ for (const key of tokenLookupOrder(token, hint)) {
29
+ const raw = await api.redis.redis.get(key);
30
+ if (!raw) continue;
31
+
32
+ const data = JSON.parse(raw) as TokenData | RefreshTokenData;
33
+ if (data.clientId !== clientId) return ok();
34
+
35
+ // Discriminate by which keyspace matched, not by data shape: a seeded
36
+ // access token may legitimately omit `refreshToken`.
37
+ const pairedKey =
38
+ key === accessK
39
+ ? (data as TokenData).refreshToken
40
+ ? refreshKey((data as TokenData).refreshToken!)
41
+ : null
42
+ : accessKey((data as RefreshTokenData).accessToken);
43
+
44
+ await api.redis.redis.del(...(pairedKey ? [key, pairedKey] : [key]));
45
+ return ok();
46
+ }
47
+
48
+ return ok();
49
+ }
@@ -0,0 +1,148 @@
1
+ import { randomUUID } from "crypto";
2
+ import { api } from "../../api";
3
+ import { config } from "../../config";
4
+ import { base64UrlEncode } from "../oauth";
5
+ import { accessKey, codeKey, refreshKey } from "./keys";
6
+ import { jsonResponse, oauthError, parseFormBody } from "./responses";
7
+ import type { AuthCode, RefreshTokenData, TokenData } from "./types";
8
+
9
+ /**
10
+ * Mint a new access/refresh token pair, store both in Redis with
11
+ * cross-references, and return them together with the access-token TTL.
12
+ *
13
+ * The cross-reference (access token's `refreshToken`, refresh token's
14
+ * `accessToken`) lets `/oauth/revoke` and the `refresh_token` grant cascade the
15
+ * deletion to both keys without a secondary index.
16
+ */
17
+ async function issueTokenPair(
18
+ userId: number,
19
+ clientId: string,
20
+ scopes: string[],
21
+ ): Promise<{ accessToken: string; refreshToken: string; expiresIn: number }> {
22
+ const accessToken = randomUUID();
23
+ const refreshToken = randomUUID();
24
+
25
+ const tokenData: TokenData = { userId, clientId, scopes, refreshToken };
26
+ const refreshData: RefreshTokenData = {
27
+ userId,
28
+ clientId,
29
+ scopes,
30
+ accessToken,
31
+ };
32
+
33
+ await api.redis.redis.set(
34
+ accessKey(accessToken),
35
+ JSON.stringify(tokenData),
36
+ "EX",
37
+ config.session.ttl,
38
+ );
39
+ await api.redis.redis.set(
40
+ refreshKey(refreshToken),
41
+ JSON.stringify(refreshData),
42
+ "EX",
43
+ config.server.mcp.oauthRefreshTtl,
44
+ );
45
+
46
+ return { accessToken, refreshToken, expiresIn: config.session.ttl };
47
+ }
48
+
49
+ function tokenResponse(
50
+ accessToken: string,
51
+ refreshToken: string,
52
+ expiresIn: number,
53
+ ): Response {
54
+ return jsonResponse({
55
+ access_token: accessToken,
56
+ refresh_token: refreshToken,
57
+ token_type: "Bearer",
58
+ expires_in: expiresIn,
59
+ });
60
+ }
61
+
62
+ async function handleAuthorizationCodeGrant(
63
+ body: URLSearchParams,
64
+ ): Promise<Response> {
65
+ const code = body.get("code");
66
+ const codeVerifier = body.get("code_verifier");
67
+ const redirectUri = body.get("redirect_uri");
68
+ const clientId = body.get("client_id");
69
+
70
+ if (!code || !codeVerifier) {
71
+ return oauthError("invalid_request", "code and code_verifier are required");
72
+ }
73
+
74
+ const codeRaw = await api.redis.redis.get(codeKey(code));
75
+ if (!codeRaw) {
76
+ return oauthError("invalid_grant", "Invalid or expired authorization code");
77
+ }
78
+
79
+ const codeData = JSON.parse(codeRaw) as AuthCode;
80
+ // Delete the code immediately (single use)
81
+ await api.redis.redis.del(codeKey(code));
82
+
83
+ if (clientId !== codeData.clientId) {
84
+ return oauthError("invalid_grant", "client_id mismatch");
85
+ }
86
+ if (redirectUri && redirectUri !== codeData.redirectUri) {
87
+ return oauthError("invalid_grant", "redirect_uri mismatch");
88
+ }
89
+
90
+ const digest = await crypto.subtle.digest(
91
+ "SHA-256",
92
+ new TextEncoder().encode(codeVerifier),
93
+ );
94
+ const computedChallenge = base64UrlEncode(new Uint8Array(digest));
95
+ if (computedChallenge !== codeData.codeChallenge) {
96
+ return oauthError("invalid_grant", "PKCE verification failed");
97
+ }
98
+
99
+ const pair = await issueTokenPair(codeData.userId, codeData.clientId, []);
100
+ return tokenResponse(pair.accessToken, pair.refreshToken, pair.expiresIn);
101
+ }
102
+
103
+ async function handleRefreshTokenGrant(
104
+ body: URLSearchParams,
105
+ ): Promise<Response> {
106
+ const refreshToken = body.get("refresh_token");
107
+ const clientId = body.get("client_id");
108
+
109
+ if (!refreshToken) {
110
+ return oauthError("invalid_request", "refresh_token is required");
111
+ }
112
+
113
+ const raw = await api.redis.redis.get(refreshKey(refreshToken));
114
+ if (!raw) {
115
+ return oauthError("invalid_grant", "Invalid or expired refresh token");
116
+ }
117
+
118
+ const stored = JSON.parse(raw) as RefreshTokenData;
119
+ if (clientId !== stored.clientId) {
120
+ return oauthError("invalid_grant", "client_id mismatch");
121
+ }
122
+
123
+ // Rotate: invalidate the old pair before issuing a new one. Deleting first
124
+ // closes the replay window if the new-pair write fails partway through.
125
+ await api.redis.redis.del(
126
+ refreshKey(refreshToken),
127
+ accessKey(stored.accessToken),
128
+ );
129
+
130
+ const pair = await issueTokenPair(
131
+ stored.userId,
132
+ stored.clientId,
133
+ stored.scopes,
134
+ );
135
+ return tokenResponse(pair.accessToken, pair.refreshToken, pair.expiresIn);
136
+ }
137
+
138
+ /** OAuth token exchange endpoint (RFC 6749). */
139
+ export async function handleToken(req: Request): Promise<Response> {
140
+ const body = await parseFormBody(req);
141
+ const grantType = body.get("grant_type");
142
+
143
+ if (grantType === "authorization_code")
144
+ return handleAuthorizationCodeGrant(body);
145
+ if (grantType === "refresh_token") return handleRefreshTokenGrant(body);
146
+
147
+ return jsonResponse({ error: "unsupported_grant_type" }, 400);
148
+ }
@@ -0,0 +1,44 @@
1
+ export type OAuthClient = {
2
+ client_id: string;
3
+ redirect_uris: string[];
4
+ client_name?: string;
5
+ grant_types?: string[];
6
+ response_types?: string[];
7
+ token_endpoint_auth_method?: string;
8
+ };
9
+
10
+ export type AuthCode = {
11
+ clientId: string;
12
+ userId: number;
13
+ codeChallenge: string;
14
+ redirectUri: string;
15
+ };
16
+
17
+ export type TokenData = {
18
+ userId: number;
19
+ clientId: string;
20
+ scopes: string[];
21
+ refreshToken?: string;
22
+ };
23
+
24
+ export type RefreshTokenData = {
25
+ userId: number;
26
+ clientId: string;
27
+ scopes: string[];
28
+ accessToken: string;
29
+ };
30
+
31
+ /** Route paths for the OAuth endpoints served by this framework. */
32
+ export const OAUTH_PATHS = {
33
+ register: "/oauth/register",
34
+ authorize: "/oauth/authorize",
35
+ token: "/oauth/token",
36
+ introspect: "/oauth/introspect",
37
+ revoke: "/oauth/revoke",
38
+ } as const;
39
+
40
+ /** Well-known metadata paths (RFC 8414 + RFC 9728). */
41
+ export const OAUTH_WELL_KNOWN_PATHS = {
42
+ authorizationServer: "/.well-known/oauth-authorization-server",
43
+ protectedResource: "/.well-known/oauth-protected-resource",
44
+ } as const;
@@ -1,9 +1,34 @@
1
+ import { realpath } from "node:fs/promises";
1
2
  import path from "node:path";
2
3
  import type { parse } from "node:url";
3
4
  import { logger } from "../api";
4
5
  import { config } from "../config";
5
6
  import { getSecurityHeaders } from "./webResponse";
6
7
 
8
+ /**
9
+ * Verify that the resolved on-disk path, with symlinks followed, stays
10
+ * inside the static root. Returns the real base path + target path if
11
+ * safe, or `null` if the target escapes (via symlink or otherwise).
12
+ */
13
+ async function resolveWithinStaticRoot(
14
+ targetPath: string,
15
+ basePath: string,
16
+ ): Promise<string | null> {
17
+ try {
18
+ const realBase = await realpath(basePath);
19
+ const realTarget = await realpath(targetPath);
20
+ if (
21
+ realTarget !== realBase &&
22
+ !realTarget.startsWith(realBase + path.sep)
23
+ ) {
24
+ return null;
25
+ }
26
+ return realTarget;
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
7
32
  /**
8
33
  * Attempt to serve a static file for the given request. Returns a `Response`
9
34
  * if a matching file is found, or `null` to let other handlers deal with it.
@@ -54,6 +79,9 @@ export async function handleStaticFile(
54
79
  const indexFile = Bun.file(indexPath);
55
80
  const indexExists = await indexFile.exists();
56
81
  if (indexExists) {
82
+ if ((await resolveWithinStaticRoot(indexPath, basePath)) === null) {
83
+ return null;
84
+ }
57
85
  return buildStaticFileResponse(
58
86
  req,
59
87
  indexFile,
@@ -64,6 +92,12 @@ export async function handleStaticFile(
64
92
  return null; // File not found, let other handlers deal with it
65
93
  }
66
94
 
95
+ // Follow symlinks and confirm the real path stays inside staticDir —
96
+ // prevents symlinks inside staticDir from serving files outside it.
97
+ if ((await resolveWithinStaticRoot(fullPath, basePath)) === null) {
98
+ return null;
99
+ }
100
+
67
101
  return buildStaticFileResponse(req, file, finalPath);
68
102
  } catch (error) {
69
103
  logger.error(`Error serving static file ${finalPath}: ${error}`);
@@ -1,463 +0,0 @@
1
- import { randomUUID } from "crypto";
2
- import { api } from "../api";
3
- import type { Action, OAuthActionResponse } from "../classes/Action";
4
- import { Connection } from "../classes/Connection";
5
- import { config } from "../config";
6
- import {
7
- base64UrlEncode,
8
- redirectUrisMatch,
9
- validateRedirectUri,
10
- } from "./oauth";
11
- import {
12
- type AuthPageParams,
13
- type OAuthTemplates,
14
- renderAuthPage,
15
- renderSuccessPage,
16
- } from "./oauthTemplates";
17
-
18
- export type OAuthClient = {
19
- client_id: string;
20
- redirect_uris: string[];
21
- client_name?: string;
22
- grant_types?: string[];
23
- response_types?: string[];
24
- token_endpoint_auth_method?: string;
25
- };
26
-
27
- export type AuthCode = {
28
- clientId: string;
29
- userId: number;
30
- codeChallenge: string;
31
- redirectUri: string;
32
- };
33
-
34
- export type TokenData = {
35
- userId: number;
36
- clientId: string;
37
- scopes: string[];
38
- };
39
-
40
- /** OAuth protocol fields that should not be forwarded to login/signup actions. */
41
- const OAUTH_FIELDS = new Set([
42
- "mode",
43
- "client_id",
44
- "redirect_uri",
45
- "code_challenge",
46
- "code_challenge_method",
47
- "response_type",
48
- "state",
49
- ]);
50
-
51
- /**
52
- * RFC 9728 — Protected Resource Metadata.
53
- * MCP clients fetch this first to discover the authorization server.
54
- */
55
- export function handleProtectedResourceMetadata(
56
- origin: string,
57
- resourcePath: string,
58
- ): Response {
59
- const resource = resourcePath ? `${origin}${resourcePath}` : origin;
60
- return new Response(
61
- JSON.stringify({
62
- resource,
63
- authorization_servers: [origin],
64
- scopes_supported: ["mcp"],
65
- }),
66
- {
67
- status: 200,
68
- headers: { "Content-Type": "application/json" },
69
- },
70
- );
71
- }
72
-
73
- /** OAuth 2.1 authorization server metadata endpoint. */
74
- export function handleMetadata(origin: string): Response {
75
- const issuer = origin;
76
- return new Response(
77
- JSON.stringify({
78
- issuer,
79
- authorization_endpoint: `${issuer}/oauth/authorize`,
80
- token_endpoint: `${issuer}/oauth/token`,
81
- registration_endpoint: `${issuer}/oauth/register`,
82
- response_types_supported: ["code"],
83
- grant_types_supported: ["authorization_code"],
84
- code_challenge_methods_supported: ["S256"],
85
- token_endpoint_auth_methods_supported: ["none"],
86
- client_id_metadata_document_supported: false,
87
- }),
88
- {
89
- status: 200,
90
- headers: { "Content-Type": "application/json" },
91
- },
92
- );
93
- }
94
-
95
- /** Dynamic client registration endpoint (RFC 7591). */
96
- export async function handleRegister(req: Request): Promise<Response> {
97
- let body: any;
98
- try {
99
- body = await req.json();
100
- } catch {
101
- return new Response(
102
- JSON.stringify({
103
- error: "invalid_request",
104
- error_description: "Invalid JSON body",
105
- }),
106
- { status: 400, headers: { "Content-Type": "application/json" } },
107
- );
108
- }
109
-
110
- if (
111
- !body.redirect_uris ||
112
- !Array.isArray(body.redirect_uris) ||
113
- body.redirect_uris.length === 0
114
- ) {
115
- return new Response(
116
- JSON.stringify({
117
- error: "invalid_request",
118
- error_description: "redirect_uris is required",
119
- }),
120
- { status: 400, headers: { "Content-Type": "application/json" } },
121
- );
122
- }
123
-
124
- for (const uri of body.redirect_uris) {
125
- if (typeof uri !== "string") {
126
- return new Response(
127
- JSON.stringify({
128
- error: "invalid_request",
129
- error_description: "Each redirect_uri must be a string",
130
- }),
131
- { status: 400, headers: { "Content-Type": "application/json" } },
132
- );
133
- }
134
- const validation = validateRedirectUri(uri);
135
- if (!validation.valid) {
136
- return new Response(
137
- JSON.stringify({
138
- error: "invalid_request",
139
- error_description: validation.error,
140
- }),
141
- { status: 400, headers: { "Content-Type": "application/json" } },
142
- );
143
- }
144
- }
145
-
146
- const clientId = randomUUID();
147
- const client: OAuthClient = {
148
- client_id: clientId,
149
- redirect_uris: body.redirect_uris,
150
- client_name: body.client_name,
151
- grant_types: body.grant_types ?? ["authorization_code"],
152
- response_types: body.response_types ?? ["code"],
153
- token_endpoint_auth_method: body.token_endpoint_auth_method ?? "none",
154
- };
155
-
156
- await api.redis.redis.set(
157
- `oauth:client:${clientId}`,
158
- JSON.stringify(client),
159
- "EX",
160
- config.server.mcp.oauthClientTtl,
161
- );
162
-
163
- return new Response(JSON.stringify(client), {
164
- status: 201,
165
- headers: { "Content-Type": "application/json" },
166
- });
167
- }
168
-
169
- /** Render the OAuth authorize page (GET). */
170
- export function handleAuthorizeGet(
171
- url: URL,
172
- templates: OAuthTemplates,
173
- ): Response {
174
- const params: AuthPageParams = {
175
- clientId: url.searchParams.get("client_id") ?? "",
176
- redirectUri: url.searchParams.get("redirect_uri") ?? "",
177
- codeChallenge: url.searchParams.get("code_challenge") ?? "",
178
- codeChallengeMethod: url.searchParams.get("code_challenge_method") ?? "",
179
- responseType: url.searchParams.get("response_type") ?? "",
180
- state: url.searchParams.get("state") ?? "",
181
- error: "",
182
- };
183
-
184
- return renderAuthPage(params, templates, {
185
- loginAction: api.actions.actions.find((a: Action) => a.mcp?.isLoginAction),
186
- signupAction: api.actions.actions.find(
187
- (a: Action) => a.mcp?.isSignupAction,
188
- ),
189
- });
190
- }
191
-
192
- /** Handle the OAuth authorize form POST (signin/signup). */
193
- export async function handleAuthorizePost(
194
- req: Request,
195
- templates: OAuthTemplates,
196
- ): Promise<Response> {
197
- let fields: Record<string, string>;
198
- try {
199
- const contentType = req.headers.get("content-type") ?? "";
200
- if (contentType.includes("application/x-www-form-urlencoded")) {
201
- const text = await req.text();
202
- const params = new URLSearchParams(text);
203
- fields = Object.fromEntries(params.entries());
204
- } else {
205
- const form = await req.formData();
206
- fields = {};
207
- form.forEach((value, key) => {
208
- fields[key] = String(value);
209
- });
210
- }
211
- } catch {
212
- return new Response("Bad Request", { status: 400 });
213
- }
214
-
215
- const mode = fields.mode ?? "";
216
- const clientId = fields.client_id ?? "";
217
- const redirectUri = fields.redirect_uri ?? "";
218
- const codeChallenge = fields.code_challenge ?? "";
219
- const codeChallengeMethod = fields.code_challenge_method ?? "";
220
- const responseType = fields.response_type ?? "";
221
- const state = fields.state ?? "";
222
-
223
- const oauthParams: AuthPageParams = {
224
- clientId,
225
- redirectUri,
226
- codeChallenge,
227
- codeChallengeMethod,
228
- responseType,
229
- state,
230
- error: "",
231
- };
232
-
233
- const authActions = {
234
- loginAction: api.actions.actions.find((a: Action) => a.mcp?.isLoginAction),
235
- signupAction: api.actions.actions.find(
236
- (a: Action) => a.mcp?.isSignupAction,
237
- ),
238
- };
239
-
240
- // Validate client
241
- const clientRaw = await api.redis.redis.get(`oauth:client:${clientId}`);
242
- if (!clientRaw) {
243
- oauthParams.error = "Unknown client";
244
- return renderAuthPage(oauthParams, templates, authActions);
245
- }
246
- const client = JSON.parse(clientRaw) as OAuthClient;
247
-
248
- const uriMatch = client.redirect_uris.some((registered) =>
249
- redirectUrisMatch(registered, redirectUri),
250
- );
251
- if (!uriMatch) {
252
- oauthParams.error = "Invalid redirect URI";
253
- return renderAuthPage(oauthParams, templates, authActions);
254
- }
255
-
256
- if (codeChallengeMethod !== "S256") {
257
- oauthParams.error = "code_challenge_method must be S256";
258
- return renderAuthPage(oauthParams, templates, authActions);
259
- }
260
-
261
- // Build action params from all non-OAuth fields
262
- const actionParams: Record<string, unknown> = {};
263
- for (const [key, value] of Object.entries(fields)) {
264
- if (!OAUTH_FIELDS.has(key)) {
265
- actionParams[key] = value;
266
- }
267
- }
268
-
269
- let userId: number;
270
-
271
- if (mode === "signup") {
272
- const signupAction = api.actions.actions.find(
273
- (a: Action) => a.mcp?.isSignupAction,
274
- );
275
- if (!signupAction) {
276
- oauthParams.error = "No signup action configured";
277
- return renderAuthPage(oauthParams, templates, authActions);
278
- }
279
- const connection = new Connection("oauth", "oauth-signup");
280
- try {
281
- const { response, error } = await connection.act(
282
- signupAction.name,
283
- actionParams,
284
- );
285
- if (error) {
286
- oauthParams.error = error.message;
287
- return renderAuthPage(oauthParams, templates, authActions);
288
- }
289
- userId = (response as OAuthActionResponse).user.id;
290
- } finally {
291
- connection.destroy();
292
- }
293
- } else {
294
- const loginAction = api.actions.actions.find(
295
- (a: Action) => a.mcp?.isLoginAction,
296
- );
297
- if (!loginAction) {
298
- oauthParams.error = "No login action configured";
299
- return renderAuthPage(oauthParams, templates, authActions);
300
- }
301
- const connection = new Connection("oauth", "oauth-login");
302
- try {
303
- const { response, error } = await connection.act(
304
- loginAction.name,
305
- actionParams,
306
- );
307
- if (error) {
308
- oauthParams.error = error.message;
309
- return renderAuthPage(oauthParams, templates, authActions);
310
- }
311
- userId = (response as OAuthActionResponse).user.id;
312
- } finally {
313
- connection.destroy();
314
- }
315
- }
316
-
317
- // Generate auth code
318
- const code = randomUUID();
319
- const codeData: AuthCode = {
320
- clientId,
321
- userId,
322
- codeChallenge,
323
- redirectUri,
324
- };
325
-
326
- await api.redis.redis.set(
327
- `oauth:code:${code}`,
328
- JSON.stringify(codeData),
329
- "EX",
330
- config.server.mcp.oauthCodeTtl,
331
- );
332
-
333
- const redirectUrl = new URL(redirectUri);
334
- redirectUrl.searchParams.set("code", code);
335
- if (state) redirectUrl.searchParams.set("state", state);
336
-
337
- return renderSuccessPage(redirectUrl.toString(), templates);
338
- }
339
-
340
- /** OAuth token exchange endpoint. */
341
- export async function handleToken(req: Request): Promise<Response> {
342
- let body: URLSearchParams;
343
- const contentType = req.headers.get("content-type") ?? "";
344
-
345
- if (contentType.includes("application/x-www-form-urlencoded")) {
346
- const text = await req.text();
347
- body = new URLSearchParams(text);
348
- } else if (contentType.includes("application/json")) {
349
- const json = await req.json();
350
- body = new URLSearchParams(json as Record<string, string>);
351
- } else {
352
- // Try form-urlencoded as default
353
- const text = await req.text();
354
- body = new URLSearchParams(text);
355
- }
356
-
357
- const grantType = body.get("grant_type");
358
- const code = body.get("code");
359
- const codeVerifier = body.get("code_verifier");
360
- const redirectUri = body.get("redirect_uri");
361
- const clientId = body.get("client_id");
362
-
363
- if (grantType !== "authorization_code") {
364
- return new Response(JSON.stringify({ error: "unsupported_grant_type" }), {
365
- status: 400,
366
- headers: { "Content-Type": "application/json" },
367
- });
368
- }
369
-
370
- if (!code || !codeVerifier) {
371
- return new Response(
372
- JSON.stringify({
373
- error: "invalid_request",
374
- error_description: "code and code_verifier are required",
375
- }),
376
- { status: 400, headers: { "Content-Type": "application/json" } },
377
- );
378
- }
379
-
380
- // Look up auth code
381
- const codeRaw = await api.redis.redis.get(`oauth:code:${code}`);
382
- if (!codeRaw) {
383
- return new Response(
384
- JSON.stringify({
385
- error: "invalid_grant",
386
- error_description: "Invalid or expired authorization code",
387
- }),
388
- { status: 400, headers: { "Content-Type": "application/json" } },
389
- );
390
- }
391
-
392
- const codeData = JSON.parse(codeRaw) as AuthCode;
393
-
394
- // Delete the code immediately (single use)
395
- await api.redis.redis.del(`oauth:code:${code}`);
396
-
397
- // Validate client_id matches
398
- if (clientId !== codeData.clientId) {
399
- return new Response(
400
- JSON.stringify({
401
- error: "invalid_grant",
402
- error_description: "client_id mismatch",
403
- }),
404
- { status: 400, headers: { "Content-Type": "application/json" } },
405
- );
406
- }
407
-
408
- // Validate redirect_uri matches
409
- if (redirectUri && redirectUri !== codeData.redirectUri) {
410
- return new Response(
411
- JSON.stringify({
412
- error: "invalid_grant",
413
- error_description: "redirect_uri mismatch",
414
- }),
415
- { status: 400, headers: { "Content-Type": "application/json" } },
416
- );
417
- }
418
-
419
- // Verify PKCE: BASE64URL(SHA256(code_verifier)) === stored code_challenge
420
- const encoder = new TextEncoder();
421
- const digest = await crypto.subtle.digest(
422
- "SHA-256",
423
- encoder.encode(codeVerifier),
424
- );
425
- const computedChallenge = base64UrlEncode(new Uint8Array(digest));
426
-
427
- if (computedChallenge !== codeData.codeChallenge) {
428
- return new Response(
429
- JSON.stringify({
430
- error: "invalid_grant",
431
- error_description: "PKCE verification failed",
432
- }),
433
- { status: 400, headers: { "Content-Type": "application/json" } },
434
- );
435
- }
436
-
437
- // Generate access token
438
- const accessToken = randomUUID();
439
- const tokenData: TokenData = {
440
- userId: codeData.userId,
441
- clientId: codeData.clientId,
442
- scopes: [],
443
- };
444
-
445
- await api.redis.redis.set(
446
- `oauth:token:${accessToken}`,
447
- JSON.stringify(tokenData),
448
- "EX",
449
- config.session.ttl,
450
- );
451
-
452
- return new Response(
453
- JSON.stringify({
454
- access_token: accessToken,
455
- token_type: "Bearer",
456
- expires_in: config.session.ttl,
457
- }),
458
- {
459
- status: 200,
460
- headers: { "Content-Type": "application/json" },
461
- },
462
- );
463
- }