keryx 0.22.0 → 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.
@@ -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}`);