@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.
- package/README.md +47 -0
- package/dist/client/connect.d.ts.map +1 -1
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/oauth.d.ts +36 -0
- package/dist/client/oauth.d.ts.map +1 -1
- package/dist/client/pipe.d.ts +3 -0
- package/dist/client/pipe.d.ts.map +1 -1
- package/dist/client/stream.d.ts +3 -0
- package/dist/client/stream.d.ts.map +1 -1
- package/dist/client/types.d.ts +4 -0
- package/dist/client/types.d.ts.map +1 -1
- package/dist/constants.d.ts +3 -1
- package/dist/constants.d.ts.map +1 -1
- package/dist/dispatch/describe.d.ts.map +1 -1
- package/dist/dispatch/stream.d.ts +2 -1
- package/dist/dispatch/stream.d.ts.map +1 -1
- package/dist/dispatch/unary.d.ts +2 -1
- package/dist/dispatch/unary.d.ts.map +1 -1
- package/dist/external.d.ts +45 -0
- package/dist/external.d.ts.map +1 -0
- package/dist/gcs.d.ts +38 -0
- package/dist/gcs.d.ts.map +1 -0
- package/dist/http/auth.d.ts +13 -2
- package/dist/http/auth.d.ts.map +1 -1
- package/dist/http/bearer.d.ts +34 -0
- package/dist/http/bearer.d.ts.map +1 -0
- package/dist/http/dispatch.d.ts +2 -0
- package/dist/http/dispatch.d.ts.map +1 -1
- package/dist/http/handler.d.ts.map +1 -1
- package/dist/http/index.d.ts +4 -0
- package/dist/http/index.d.ts.map +1 -1
- package/dist/http/jwt.d.ts +2 -2
- package/dist/http/jwt.d.ts.map +1 -1
- package/dist/http/mtls.d.ts +78 -0
- package/dist/http/mtls.d.ts.map +1 -0
- package/dist/http/pages.d.ts +9 -0
- package/dist/http/pages.d.ts.map +1 -0
- package/dist/http/types.d.ts +17 -1
- package/dist/http/types.d.ts.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1119 -230
- package/dist/index.js.map +24 -20
- package/dist/otel.d.ts +47 -0
- package/dist/otel.d.ts.map +1 -0
- package/dist/s3.d.ts +43 -0
- package/dist/s3.d.ts.map +1 -0
- package/dist/server.d.ts +6 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/types.d.ts +30 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +44 -1
- package/src/client/connect.ts +13 -5
- package/src/client/index.ts +10 -1
- package/src/client/introspect.ts +1 -1
- package/src/client/oauth.ts +94 -1
- package/src/client/pipe.ts +19 -4
- package/src/client/stream.ts +20 -7
- package/src/client/types.ts +4 -0
- package/src/constants.ts +4 -1
- package/src/dispatch/describe.ts +20 -0
- package/src/dispatch/stream.ts +7 -1
- package/src/dispatch/unary.ts +6 -1
- package/src/external.ts +209 -0
- package/src/gcs.ts +86 -0
- package/src/http/auth.ts +67 -4
- package/src/http/bearer.ts +107 -0
- package/src/http/dispatch.ts +26 -6
- package/src/http/handler.ts +81 -4
- package/src/http/index.ts +10 -0
- package/src/http/jwt.ts +17 -3
- package/src/http/mtls.ts +298 -0
- package/src/http/pages.ts +298 -0
- package/src/http/types.ts +17 -1
- package/src/index.ts +25 -0
- package/src/otel.ts +161 -0
- package/src/s3.ts +94 -0
- package/src/server.ts +42 -8
- package/src/types.ts +34 -0
package/src/http/dispatch.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
package/src/http/handler.ts
CHANGED
|
@@ -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 ?? "
|
|
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=
|
|
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(
|
|
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
|
-
|
|
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>);
|
package/src/http/mtls.ts
ADDED
|
@@ -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
|
+
}
|