@query-farm/vgi-rpc 0.4.0 → 0.6.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.
Files changed (80) hide show
  1. package/README.md +47 -0
  2. package/dist/client/connect.d.ts.map +1 -1
  3. package/dist/client/index.d.ts +1 -1
  4. package/dist/client/index.d.ts.map +1 -1
  5. package/dist/client/oauth.d.ts +36 -0
  6. package/dist/client/oauth.d.ts.map +1 -1
  7. package/dist/client/pipe.d.ts +3 -0
  8. package/dist/client/pipe.d.ts.map +1 -1
  9. package/dist/client/stream.d.ts +3 -0
  10. package/dist/client/stream.d.ts.map +1 -1
  11. package/dist/client/types.d.ts +4 -0
  12. package/dist/client/types.d.ts.map +1 -1
  13. package/dist/constants.d.ts +3 -1
  14. package/dist/constants.d.ts.map +1 -1
  15. package/dist/dispatch/describe.d.ts.map +1 -1
  16. package/dist/dispatch/stream.d.ts +2 -1
  17. package/dist/dispatch/stream.d.ts.map +1 -1
  18. package/dist/dispatch/unary.d.ts +2 -1
  19. package/dist/dispatch/unary.d.ts.map +1 -1
  20. package/dist/external.d.ts +45 -0
  21. package/dist/external.d.ts.map +1 -0
  22. package/dist/gcs.d.ts +38 -0
  23. package/dist/gcs.d.ts.map +1 -0
  24. package/dist/http/auth.d.ts +13 -2
  25. package/dist/http/auth.d.ts.map +1 -1
  26. package/dist/http/bearer.d.ts +34 -0
  27. package/dist/http/bearer.d.ts.map +1 -0
  28. package/dist/http/dispatch.d.ts +2 -0
  29. package/dist/http/dispatch.d.ts.map +1 -1
  30. package/dist/http/handler.d.ts.map +1 -1
  31. package/dist/http/index.d.ts +4 -0
  32. package/dist/http/index.d.ts.map +1 -1
  33. package/dist/http/jwt.d.ts +2 -2
  34. package/dist/http/jwt.d.ts.map +1 -1
  35. package/dist/http/mtls.d.ts +78 -0
  36. package/dist/http/mtls.d.ts.map +1 -0
  37. package/dist/http/pages.d.ts +9 -0
  38. package/dist/http/pages.d.ts.map +1 -0
  39. package/dist/http/types.d.ts +17 -1
  40. package/dist/http/types.d.ts.map +1 -1
  41. package/dist/index.d.ts +3 -2
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +1119 -230
  44. package/dist/index.js.map +24 -20
  45. package/dist/otel.d.ts +47 -0
  46. package/dist/otel.d.ts.map +1 -0
  47. package/dist/s3.d.ts +43 -0
  48. package/dist/s3.d.ts.map +1 -0
  49. package/dist/server.d.ts +6 -0
  50. package/dist/server.d.ts.map +1 -1
  51. package/dist/types.d.ts +30 -0
  52. package/dist/types.d.ts.map +1 -1
  53. package/package.json +44 -1
  54. package/src/client/connect.ts +13 -5
  55. package/src/client/index.ts +10 -1
  56. package/src/client/introspect.ts +1 -1
  57. package/src/client/oauth.ts +94 -1
  58. package/src/client/pipe.ts +19 -4
  59. package/src/client/stream.ts +20 -7
  60. package/src/client/types.ts +4 -0
  61. package/src/constants.ts +4 -1
  62. package/src/dispatch/describe.ts +20 -0
  63. package/src/dispatch/stream.ts +7 -1
  64. package/src/dispatch/unary.ts +6 -1
  65. package/src/external.ts +209 -0
  66. package/src/gcs.ts +86 -0
  67. package/src/http/auth.ts +67 -4
  68. package/src/http/bearer.ts +107 -0
  69. package/src/http/dispatch.ts +26 -6
  70. package/src/http/handler.ts +81 -4
  71. package/src/http/index.ts +10 -0
  72. package/src/http/jwt.ts +17 -3
  73. package/src/http/mtls.ts +298 -0
  74. package/src/http/pages.ts +298 -0
  75. package/src/http/types.ts +17 -1
  76. package/src/index.ts +25 -0
  77. package/src/otel.ts +161 -0
  78. package/src/s3.ts +94 -0
  79. package/src/server.ts +42 -8
  80. package/src/types.ts +34 -0
@@ -5,6 +5,7 @@ import { RecordBatch, RecordBatchReader, Schema } from "@query-farm/apache-arrow
5
5
  import type { AuthContext } from "../auth.js";
6
6
  import { STATE_KEY } from "../constants.js";
7
7
  import { buildDescribeBatch, DESCRIBE_SCHEMA } from "../dispatch/describe.js";
8
+ import { type ExternalLocationConfig, maybeExternalizeBatch } from "../external.js";
8
9
  import type { MethodDefinition } from "../types.js";
9
10
  import { OutputCollector } from "../types.js";
10
11
  import { conformBatchToSchema } from "../util/conform.js";
@@ -30,6 +31,7 @@ export interface DispatchContext {
30
31
  maxStreamResponseBytes?: number;
31
32
  stateSerializer: StateSerializer;
32
33
  authContext?: AuthContext;
34
+ externalLocation?: ExternalLocationConfig;
33
35
  }
34
36
 
35
37
  /** Dispatch a __describe__ request. */
@@ -61,12 +63,18 @@ export async function httpDispatchUnary(
61
63
 
62
64
  try {
63
65
  const result = await method.handler!(parsed.params, out);
64
- const resultBatch = buildResultBatch(schema, result, ctx.serverId, parsed.requestId);
66
+ let resultBatch = buildResultBatch(schema, result, ctx.serverId, parsed.requestId);
67
+ if (ctx.externalLocation) {
68
+ resultBatch = await maybeExternalizeBatch(resultBatch, ctx.externalLocation);
69
+ }
65
70
  const batches = [...out.batches.map((b) => b.batch), resultBatch];
66
71
  return arrowResponse(serializeIpcStream(schema, batches));
67
72
  } catch (error: any) {
68
73
  const errBatch = buildErrorBatch(schema, error, ctx.serverId, parsed.requestId);
69
- return arrowResponse(serializeIpcStream(schema, [errBatch]), 500);
74
+ const response = arrowResponse(serializeIpcStream(schema, [errBatch]), 500);
75
+ // Attach the error so the dispatch hook can see it
76
+ (response as any).__dispatchError = error;
77
+ return response;
70
78
  }
71
79
  }
72
80
 
@@ -98,7 +106,9 @@ export async function httpDispatchStreamInit(
98
106
  } catch (error: any) {
99
107
  const errSchema = method.headerSchema ?? EMPTY_SCHEMA;
100
108
  const errBatch = buildErrorBatch(errSchema, error, ctx.serverId, parsed.requestId);
101
- return arrowResponse(serializeIpcStream(errSchema, [errBatch]), 500);
109
+ const response = arrowResponse(serializeIpcStream(errSchema, [errBatch]), 500);
110
+ (response as any).__dispatchError = error;
111
+ return response;
102
112
  }
103
113
 
104
114
  // Support dynamic output schemas (same as pipe transport)
@@ -116,7 +126,9 @@ export async function httpDispatchStreamInit(
116
126
  headerBytes = serializeIpcStream(method.headerSchema, headerBatches);
117
127
  } catch (error: any) {
118
128
  const errBatch = buildErrorBatch(method.headerSchema, error, ctx.serverId, parsed.requestId);
119
- return arrowResponse(serializeIpcStream(method.headerSchema, [errBatch]), 500);
129
+ const response = arrowResponse(serializeIpcStream(method.headerSchema, [errBatch]), 500);
130
+ (response as any).__dispatchError = error;
131
+ return response;
120
132
  }
121
133
  }
122
134
 
@@ -226,7 +238,9 @@ export async function httpDispatchStreamExchange(
226
238
  error.stack?.split("\n").slice(0, 5).join("\n"),
227
239
  );
228
240
  const errBatch = buildErrorBatch(outputSchema, error, ctx.serverId, null);
229
- return arrowResponse(serializeIpcStream(outputSchema, [errBatch]), 500);
241
+ const response = arrowResponse(serializeIpcStream(outputSchema, [errBatch]), 500);
242
+ (response as any).__dispatchError = error;
243
+ return response;
230
244
  }
231
245
 
232
246
  // Collect emitted batches
@@ -283,6 +297,7 @@ async function produceStreamResponse(
283
297
  const allBatches: RecordBatch[] = [];
284
298
  const maxBytes = ctx.maxStreamResponseBytes;
285
299
  let estimatedBytes = 0;
300
+ let producerError: Error | undefined;
286
301
 
287
302
  while (true) {
288
303
  const out = new OutputCollector(outputSchema, true, ctx.serverId, requestId, ctx.authContext);
@@ -302,6 +317,7 @@ async function produceStreamResponse(
302
317
  if (process.env.VGI_DISPATCH_DEBUG)
303
318
  console.error(`[produceStreamResponse] error:`, error.message, error.stack?.split("\n").slice(0, 3).join("\n"));
304
319
  allBatches.push(buildErrorBatch(outputSchema, error, ctx.serverId, requestId));
320
+ producerError = error instanceof Error ? error : new Error(String(error));
305
321
  break;
306
322
  }
307
323
 
@@ -336,7 +352,11 @@ async function produceStreamResponse(
336
352
  } else {
337
353
  responseBody = dataBytes;
338
354
  }
339
- return arrowResponse(responseBody);
355
+ const response = arrowResponse(responseBody);
356
+ if (producerError) {
357
+ (response as any).__dispatchError = producerError;
358
+ }
359
+ return response;
340
360
  }
341
361
 
342
362
  function concatBytes(...arrays: Uint8Array[]): Uint8Array {
@@ -6,7 +6,7 @@ import { Schema } from "@query-farm/apache-arrow";
6
6
  import type { AuthContext } from "../auth.js";
7
7
  import { DESCRIBE_METHOD_NAME } from "../constants.js";
8
8
  import type { Protocol } from "../protocol.js";
9
- import { MethodType } from "../types.js";
9
+ import { type CallStatistics, type DispatchInfo, MethodType } from "../types.js";
10
10
  import { zstdCompress, zstdDecompress } from "../util/zstd.js";
11
11
  import { buildErrorBatch } from "../wire/response.js";
12
12
  import { buildWwwAuthenticateHeader, oauthResourceMetadataToJson, wellKnownPath } from "./auth.js";
@@ -17,6 +17,7 @@ import {
17
17
  httpDispatchStreamInit,
18
18
  httpDispatchUnary,
19
19
  } from "./dispatch.js";
20
+ import { buildDescribePage, buildLandingPage, buildNotFoundPage } from "./pages.js";
20
21
  import { type HttpHandlerOptions, jsonStateSerializer } from "./types.js";
21
22
 
22
23
  const EMPTY_SCHEMA = new Schema([]);
@@ -37,7 +38,7 @@ export function createHttpHandler(
37
38
  protocol: Protocol,
38
39
  options?: HttpHandlerOptions,
39
40
  ): (request: Request) => Response | Promise<Response> {
40
- const prefix = (options?.prefix ?? "/vgi").replace(/\/+$/, "");
41
+ const prefix = (options?.prefix ?? "").replace(/\/+$/, "");
41
42
  const signingKey = options?.signingKey ?? randomBytes(32);
42
43
  const tokenTtl = options?.tokenTtl ?? 3600;
43
44
  const corsOrigins = options?.corsOrigins;
@@ -52,6 +53,23 @@ export function createHttpHandler(
52
53
 
53
54
  const compressionLevel = options?.compressionLevel;
54
55
  const stateSerializer = options?.stateSerializer ?? jsonStateSerializer;
56
+ const dispatchHook = options?.dispatchHook;
57
+
58
+ // HTML page configuration
59
+ const enableLandingPage = options?.enableLandingPage ?? true;
60
+ const enableDescribePage = options?.enableDescribePage ?? true;
61
+ const enableNotFoundPage = options?.enableNotFoundPage ?? true;
62
+ const displayName = options?.protocolName ?? protocol.name;
63
+ const repoUrl = options?.repositoryUrl ?? null;
64
+
65
+ // Pre-render HTML pages for zero per-request overhead
66
+ const landingHtml = enableLandingPage
67
+ ? buildLandingPage(displayName, serverId, enableDescribePage ? `${prefix}/describe` : null, repoUrl)
68
+ : null;
69
+ const describeHtml = enableDescribePage ? buildDescribePage(displayName, serverId, methods, repoUrl) : null;
70
+ const notFoundHtml = enableNotFoundPage ? buildNotFoundPage(prefix, displayName) : null;
71
+
72
+ const externalLocation = options?.externalLocation;
55
73
 
56
74
  // ctx is built per-request to include authContext; base fields set here
57
75
  const baseCtx = {
@@ -60,6 +78,7 @@ export function createHttpHandler(
60
78
  serverId,
61
79
  maxStreamResponseBytes,
62
80
  stateSerializer,
81
+ externalLocation,
63
82
  };
64
83
 
65
84
  function addCorsHeaders(headers: Headers): void {
@@ -102,7 +121,7 @@ export function createHttpHandler(
102
121
  const body = JSON.stringify(oauthResourceMetadataToJson(oauthMetadata));
103
122
  const headers = new Headers({
104
123
  "Content-Type": "application/json",
105
- "Cache-Control": "public, max-age=3600",
124
+ "Cache-Control": "public, max-age=60",
106
125
  });
107
126
  addCorsHeaders(headers);
108
127
  return new Response(body, { status: 200, headers });
@@ -128,6 +147,32 @@ export function createHttpHandler(
128
147
  return new Response(null, { status: 405 });
129
148
  }
130
149
 
150
+ // HTML pages for GET requests
151
+ if (request.method === "GET") {
152
+ // Landing page: GET {prefix}/ or GET {prefix}
153
+ if (landingHtml && (path === prefix || path === `${prefix}/`)) {
154
+ const headers = new Headers({ "Content-Type": "text/html; charset=utf-8" });
155
+ addCorsHeaders(headers);
156
+ return new Response(landingHtml, { status: 200, headers });
157
+ }
158
+
159
+ // Describe page: GET {prefix}/describe
160
+ if (describeHtml && path === `${prefix}/describe`) {
161
+ const headers = new Headers({ "Content-Type": "text/html; charset=utf-8" });
162
+ addCorsHeaders(headers);
163
+ return new Response(describeHtml, { status: 200, headers });
164
+ }
165
+
166
+ // 404 page for any other GET
167
+ if (notFoundHtml) {
168
+ const headers = new Headers({ "Content-Type": "text/html; charset=utf-8" });
169
+ addCorsHeaders(headers);
170
+ return new Response(notFoundHtml, { status: 404, headers });
171
+ }
172
+
173
+ return new Response("Not Found", { status: 404 });
174
+ }
175
+
131
176
  if (request.method !== "POST") {
132
177
  return new Response("Method Not Allowed", { status: 405 });
133
178
  }
@@ -169,7 +214,17 @@ export function createHttpHandler(
169
214
  const metadataUrl = new URL(request.url);
170
215
  metadataUrl.pathname = wellKnownPath(prefix);
171
216
  metadataUrl.search = "";
172
- headers.set("WWW-Authenticate", buildWwwAuthenticateHeader(metadataUrl.toString()));
217
+ headers.set(
218
+ "WWW-Authenticate",
219
+ buildWwwAuthenticateHeader(
220
+ metadataUrl.toString(),
221
+ oauthMetadata.clientId,
222
+ oauthMetadata.clientSecret,
223
+ oauthMetadata.useIdTokenAsBearer,
224
+ oauthMetadata.deviceCodeClientId,
225
+ oauthMetadata.deviceCodeClientSecret,
226
+ ),
227
+ );
173
228
  }
174
229
  return new Response(error.message || "Unauthorized", { status: 401, headers });
175
230
  }
@@ -214,6 +269,20 @@ export function createHttpHandler(
214
269
  return compressIfAccepted(makeErrorResponse(err, 404), clientAcceptsZstd);
215
270
  }
216
271
 
272
+ const methodType = method.type === MethodType.UNARY ? "unary" : "stream";
273
+ const info: DispatchInfo = { method: methodName, methodType, serverId, requestId: null };
274
+ const stats: CallStatistics = {
275
+ inputBatches: 0,
276
+ outputBatches: 0,
277
+ inputRows: 0,
278
+ outputRows: 0,
279
+ inputBytes: 0,
280
+ outputBytes: 0,
281
+ };
282
+
283
+ const hookToken = dispatchHook?.onDispatchStart(info);
284
+ let dispatchError: Error | undefined;
285
+
217
286
  try {
218
287
  let response: Response;
219
288
 
@@ -240,13 +309,21 @@ export function createHttpHandler(
240
309
  response = await httpDispatchStreamExchange(method, body, ctx);
241
310
  }
242
311
 
312
+ // Check if the dispatch function caught an error internally
313
+ const internalError = (response as any).__dispatchError;
314
+ if (internalError) {
315
+ dispatchError = internalError instanceof Error ? internalError : new Error(String(internalError));
316
+ }
243
317
  addCorsHeaders(response.headers);
244
318
  return compressIfAccepted(response, clientAcceptsZstd);
245
319
  } catch (error: any) {
320
+ dispatchError = error instanceof Error ? error : new Error(String(error));
246
321
  if (error instanceof HttpRpcError) {
247
322
  return compressIfAccepted(makeErrorResponse(error, error.statusCode), clientAcceptsZstd);
248
323
  }
249
324
  return compressIfAccepted(makeErrorResponse(error, 500), clientAcceptsZstd);
325
+ } finally {
326
+ dispatchHook?.onDispatchEnd(hookToken, info, stats, dispatchError);
250
327
  }
251
328
  };
252
329
  }
package/src/http/index.ts CHANGED
@@ -3,10 +3,20 @@
3
3
 
4
4
  export type { AuthenticateFn, OAuthResourceMetadata } from "./auth.js";
5
5
  export { oauthResourceMetadataToJson } from "./auth.js";
6
+ export type { BearerValidateFn } from "./bearer.js";
7
+ export { bearerAuthenticate, bearerAuthenticateStatic, chainAuthenticate } from "./bearer.js";
6
8
  export { ARROW_CONTENT_TYPE } from "./common.js";
7
9
  export { createHttpHandler } from "./handler.js";
8
10
  export type { JwtAuthenticateOptions } from "./jwt.js";
9
11
  export { jwtAuthenticate } from "./jwt.js";
12
+ export type { CertValidateFn, XfccElement, XfccValidateFn } from "./mtls.js";
13
+ export {
14
+ mtlsAuthenticate,
15
+ mtlsAuthenticateFingerprint,
16
+ mtlsAuthenticateSubject,
17
+ mtlsAuthenticateXfcc,
18
+ parseXfcc,
19
+ } from "./mtls.js";
10
20
  export { type UnpackedToken, unpackStateToken } from "./token.js";
11
21
  export type { HttpHandlerOptions, StateSerializer } from "./types.js";
12
22
  export { jsonStateSerializer } from "./types.js";
package/src/http/jwt.ts CHANGED
@@ -8,8 +8,8 @@ import type { AuthenticateFn } from "./auth.js";
8
8
  export interface JwtAuthenticateOptions {
9
9
  /** The expected `iss` claim (also used to discover AS metadata). */
10
10
  issuer: string;
11
- /** The expected `aud` claim. */
12
- audience: string;
11
+ /** The expected `aud` claim. If an array, tries each audience in order. */
12
+ audience: string | string[];
13
13
  /** Explicit JWKS URI. If omitted, discovered from issuer metadata. */
14
14
  jwksUri?: string;
15
15
  /** JWT claim to use as the principal. Default: "sub". */
@@ -58,7 +58,21 @@ export function jwtAuthenticate(options: JwtAuthenticateOptions): AuthenticateFn
58
58
  }
59
59
 
60
60
  // validateJwtAccessToken throws on failure, returns claims on success
61
- const claims = await oauth.validateJwtAccessToken(as, request, audience);
61
+ // oauth4webapi only accepts a single string, so iterate if multiple audiences
62
+ const audiences = Array.isArray(audience) ? audience : [audience];
63
+ let claims: oauth.JWTAccessTokenClaims | undefined;
64
+ let lastError: unknown;
65
+ for (const aud of audiences) {
66
+ try {
67
+ claims = await oauth.validateJwtAccessToken(as, request, aud);
68
+ break;
69
+ } catch (error) {
70
+ lastError = error;
71
+ }
72
+ }
73
+ if (!claims) {
74
+ throw lastError;
75
+ }
62
76
  const principal = (claims[principalClaim] as string | undefined) ?? null;
63
77
 
64
78
  return new AuthContext(domain, true, principal, claims as unknown as Record<string, any>);
@@ -0,0 +1,298 @@
1
+ // © Copyright 2025-2026, Query.Farm LLC - https://query.farm
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { createHash, X509Certificate } from "node:crypto";
5
+ import { AuthContext } from "../auth.js";
6
+ import type { AuthenticateFn } from "./auth.js";
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // XFCC types and parser (no crypto needed)
10
+ // ---------------------------------------------------------------------------
11
+
12
+ /** A single element from an `x-forwarded-client-cert` header. */
13
+ export interface XfccElement {
14
+ hash: string | null;
15
+ cert: string | null;
16
+ subject: string | null;
17
+ uri: string | null;
18
+ dns: readonly string[];
19
+ by: string | null;
20
+ }
21
+
22
+ /** Receives a parsed XFCC element, returns an AuthContext on success. Must throw on failure. */
23
+ export type XfccValidateFn = (element: XfccElement) => AuthContext | Promise<AuthContext>;
24
+
25
+ /** Receives a parsed X509Certificate, returns an AuthContext on success. Must throw on failure. */
26
+ export type CertValidateFn = (cert: X509Certificate) => AuthContext | Promise<AuthContext>;
27
+
28
+ function splitRespectingQuotes(text: string, delimiter: string): string[] {
29
+ const parts: string[] = [];
30
+ let current: string[] = [];
31
+ let inQuotes = false;
32
+ let i = 0;
33
+ while (i < text.length) {
34
+ const ch = text[i];
35
+ if (ch === '"') {
36
+ inQuotes = !inQuotes;
37
+ current.push(ch);
38
+ } else if (ch === "\\" && inQuotes && i + 1 < text.length) {
39
+ current.push(ch);
40
+ current.push(text[i + 1]);
41
+ i++;
42
+ } else if (ch === delimiter && !inQuotes) {
43
+ parts.push(current.join(""));
44
+ current = [];
45
+ } else {
46
+ current.push(ch);
47
+ }
48
+ i++;
49
+ }
50
+ parts.push(current.join(""));
51
+ return parts;
52
+ }
53
+
54
+ function unescapeQuoted(text: string): string {
55
+ return text.replace(/\\(.)/g, "$1");
56
+ }
57
+
58
+ /** Extract the CN value from an RFC 4514 or similar DN string. */
59
+ function extractCn(subject: string): string {
60
+ for (const part of subject.split(/(?<!\\),/)) {
61
+ const trimmed = part.trim();
62
+ if (trimmed.toUpperCase().startsWith("CN=")) {
63
+ return trimmed.slice(3);
64
+ }
65
+ }
66
+ return "";
67
+ }
68
+
69
+ /**
70
+ * Parse an `x-forwarded-client-cert` header value.
71
+ *
72
+ * Handles comma-separated elements (respecting quoted values),
73
+ * semicolon-separated key=value pairs within each element, and
74
+ * URL-encoded Cert/URI/By fields.
75
+ */
76
+ export function parseXfcc(headerValue: string): XfccElement[] {
77
+ const elements: XfccElement[] = [];
78
+ for (const rawElement of splitRespectingQuotes(headerValue, ",")) {
79
+ const trimmed = rawElement.trim();
80
+ if (!trimmed) continue;
81
+ const pairs = splitRespectingQuotes(trimmed, ";");
82
+ const fields: Record<string, string | string[]> = {};
83
+ for (const pair of pairs) {
84
+ const p = pair.trim();
85
+ if (!p) continue;
86
+ const eqIdx = p.indexOf("=");
87
+ if (eqIdx < 0) continue;
88
+ const key = p.slice(0, eqIdx).trim().toLowerCase();
89
+ let value = p.slice(eqIdx + 1).trim();
90
+ if (value.length >= 2 && value[0] === '"' && value[value.length - 1] === '"') {
91
+ value = unescapeQuoted(value.slice(1, -1));
92
+ }
93
+ if (key === "cert" || key === "uri" || key === "by") {
94
+ value = decodeURIComponent(value);
95
+ }
96
+ if (key === "dns") {
97
+ const existing = fields.dns;
98
+ if (Array.isArray(existing)) {
99
+ existing.push(value);
100
+ } else {
101
+ fields.dns = [value];
102
+ }
103
+ } else {
104
+ fields[key] = value;
105
+ }
106
+ }
107
+ const dns = Array.isArray(fields.dns) ? fields.dns : [];
108
+ elements.push({
109
+ hash: typeof fields.hash === "string" ? fields.hash : null,
110
+ cert: typeof fields.cert === "string" ? fields.cert : null,
111
+ subject: typeof fields.subject === "string" ? fields.subject : null,
112
+ uri: typeof fields.uri === "string" ? fields.uri : null,
113
+ dns,
114
+ by: typeof fields.by === "string" ? fields.by : null,
115
+ });
116
+ }
117
+ return elements;
118
+ }
119
+
120
+ /**
121
+ * Create an authenticate callback from Envoy `x-forwarded-client-cert`.
122
+ *
123
+ * Parses the `x-forwarded-client-cert` header and extracts client identity.
124
+ * Does not require any crypto dependencies.
125
+ *
126
+ * **Warning:** The reverse proxy MUST strip client-supplied
127
+ * `x-forwarded-client-cert` headers before forwarding.
128
+ */
129
+ export function mtlsAuthenticateXfcc(options?: {
130
+ validate?: XfccValidateFn;
131
+ domain?: string;
132
+ selectElement?: "first" | "last";
133
+ }): AuthenticateFn {
134
+ const validate = options?.validate;
135
+ const domain = options?.domain ?? "mtls";
136
+ const selectElement = options?.selectElement ?? "first";
137
+
138
+ return async function authenticate(request: Request): Promise<AuthContext> {
139
+ const headerValue = request.headers.get("x-forwarded-client-cert");
140
+ if (!headerValue) {
141
+ throw new Error("Missing x-forwarded-client-cert header");
142
+ }
143
+ const elements = parseXfcc(headerValue);
144
+ if (elements.length === 0) {
145
+ throw new Error("Empty x-forwarded-client-cert header");
146
+ }
147
+ const element = selectElement === "first" ? elements[0] : elements[elements.length - 1];
148
+ if (validate) {
149
+ return validate(element);
150
+ }
151
+ const principal = element.subject ? extractCn(element.subject) : "";
152
+ const claims: Record<string, any> = {};
153
+ if (element.hash) claims.hash = element.hash;
154
+ if (element.subject) claims.subject = element.subject;
155
+ if (element.uri) claims.uri = element.uri;
156
+ if (element.dns.length > 0) claims.dns = [...element.dns];
157
+ if (element.by) claims.by = element.by;
158
+ return new AuthContext(domain, true, principal, claims);
159
+ };
160
+ }
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // PEM-based factories (uses node:crypto X509Certificate)
164
+ // ---------------------------------------------------------------------------
165
+
166
+ function parseCertFromHeader(request: Request, header: string): X509Certificate {
167
+ const raw = request.headers.get(header);
168
+ if (!raw) {
169
+ throw new Error(`Missing ${header} header`);
170
+ }
171
+ const pemStr = decodeURIComponent(raw);
172
+ if (!pemStr.startsWith("-----BEGIN CERTIFICATE-----")) {
173
+ throw new Error("Header value is not a PEM certificate");
174
+ }
175
+ try {
176
+ return new X509Certificate(pemStr);
177
+ } catch (exc) {
178
+ throw new Error(`Failed to parse PEM certificate: ${exc}`);
179
+ }
180
+ }
181
+
182
+ function checkCertExpiry(cert: X509Certificate): void {
183
+ const now = new Date();
184
+ const notBefore = new Date(cert.validFrom);
185
+ const notAfter = new Date(cert.validTo);
186
+ if (now < notBefore) {
187
+ throw new Error("Certificate is not yet valid");
188
+ }
189
+ if (now > notAfter) {
190
+ throw new Error("Certificate has expired");
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Create an mTLS authenticate callback with custom certificate validation.
196
+ *
197
+ * Generic factory that parses the client certificate from a proxy header
198
+ * and delegates identity extraction to a user-supplied `validate` callback.
199
+ *
200
+ * **Warning:** The reverse proxy MUST strip client-supplied certificate
201
+ * headers before forwarding.
202
+ */
203
+ export function mtlsAuthenticate(options: {
204
+ validate: CertValidateFn;
205
+ header?: string;
206
+ checkExpiry?: boolean;
207
+ }): AuthenticateFn {
208
+ const { validate, header = "X-SSL-Client-Cert", checkExpiry = false } = options;
209
+
210
+ return async function authenticate(request: Request): Promise<AuthContext> {
211
+ const cert = parseCertFromHeader(request, header);
212
+ if (checkExpiry) {
213
+ checkCertExpiry(cert);
214
+ }
215
+ return validate(cert);
216
+ };
217
+ }
218
+
219
+ const SUPPORTED_ALGORITHMS = new Set(["sha256", "sha1", "sha384", "sha512"]);
220
+
221
+ /**
222
+ * Create an mTLS authenticate callback using certificate fingerprint lookup.
223
+ *
224
+ * Computes the certificate fingerprint and looks it up in the provided
225
+ * mapping. Fingerprints must be lowercase hex without colons.
226
+ */
227
+ export function mtlsAuthenticateFingerprint(options: {
228
+ fingerprints: ReadonlyMap<string, AuthContext> | Record<string, AuthContext>;
229
+ header?: string;
230
+ algorithm?: string;
231
+ domain?: string;
232
+ checkExpiry?: boolean;
233
+ }): AuthenticateFn {
234
+ const { fingerprints, header, algorithm = "sha256", checkExpiry } = options;
235
+ if (!SUPPORTED_ALGORITHMS.has(algorithm)) {
236
+ throw new Error(`Unsupported hash algorithm: ${algorithm}`);
237
+ }
238
+ const entries: ReadonlyMap<string, AuthContext> =
239
+ fingerprints instanceof Map ? fingerprints : new Map(Object.entries(fingerprints));
240
+
241
+ function validate(cert: X509Certificate): AuthContext {
242
+ const fp = createHash(algorithm).update(cert.raw).digest("hex");
243
+ const ctx = entries.get(fp);
244
+ if (!ctx) {
245
+ throw new Error(`Unknown certificate fingerprint: ${fp}`);
246
+ }
247
+ return ctx;
248
+ }
249
+
250
+ return mtlsAuthenticate({ validate, header, checkExpiry });
251
+ }
252
+
253
+ /**
254
+ * Create an mTLS authenticate callback using certificate subject CN.
255
+ *
256
+ * Extracts the Subject Common Name as `principal` and populates
257
+ * `claims` with the full DN, serial number (hex), and `not_valid_after`.
258
+ */
259
+ export function mtlsAuthenticateSubject(options?: {
260
+ header?: string;
261
+ domain?: string;
262
+ allowedSubjects?: ReadonlySet<string> | null;
263
+ checkExpiry?: boolean;
264
+ }): AuthenticateFn {
265
+ const { header, domain = "mtls", allowedSubjects = null, checkExpiry } = options ?? {};
266
+
267
+ function validate(cert: X509Certificate): AuthContext {
268
+ // Node's cert.subject is \n-separated "KEY=value" lines
269
+ const subjectParts = cert.subject
270
+ .split("\n")
271
+ .map((s) => s.trim())
272
+ .filter(Boolean);
273
+ const subjectDn = subjectParts.join(", ");
274
+
275
+ let cn = "";
276
+ for (const part of subjectParts) {
277
+ if (part.toUpperCase().startsWith("CN=")) {
278
+ cn = part.slice(3);
279
+ break;
280
+ }
281
+ }
282
+
283
+ if (allowedSubjects !== null && !allowedSubjects.has(cn)) {
284
+ throw new Error(`Subject CN '${cn}' not in allowed subjects`);
285
+ }
286
+
287
+ const serialHex = BigInt(`0x${cert.serialNumber}`).toString(16);
288
+ const notValidAfter = new Date(cert.validTo).toISOString();
289
+
290
+ return new AuthContext(domain, true, cn, {
291
+ subject_dn: subjectDn,
292
+ serial: serialHex,
293
+ not_valid_after: notValidAfter,
294
+ });
295
+ }
296
+
297
+ return mtlsAuthenticate({ validate, header, checkExpiry });
298
+ }