From 4811cd7e1368b01f1c269fd79d8051af7052116d Mon Sep 17 00:00:00 2001 From: Max Leonard Date: Mon, 2 Mar 2026 10:41:48 +0000 Subject: [PATCH 1/3] feat: add HTTP/2 multiplexing support for Node.js Add an HTTP/2-aware fetch implementation that multiplexes all requests to the same origin over a single TCP connection, reducing latency for concurrent workloads common in Solid pod interactions. - New createHttp2Fetch() factory with connection pooling and idle timeout - Session({ http2: true }) option for automatic HTTP/2 transport - Thread customFetch through the login flow to buildAuthenticatedFetch - Works with both DPoP and Bearer token authentication - Export createHttp2Fetch and Http2Fetch type from package index - Add comprehensive tests against real HTTP/2 server - Update README with usage documentation Co-Authored-By: Claude Opus 4.6 --- packages/core/src/Session.ts | 2 + packages/core/src/login/ILoginOptions.ts | 5 + packages/core/src/login/oidc/IOidcOptions.ts | 5 + packages/node/README.md | 53 ++++ packages/node/src/ClientAuthentication.ts | 1 + packages/node/src/Session.ts | 49 +++- packages/node/src/http2Fetch.spec.ts | 264 ++++++++++++++++++ packages/node/src/http2Fetch.ts | 260 +++++++++++++++++ packages/node/src/index.ts | 2 + .../node/src/login/oidc/OidcLoginHandler.ts | 1 + .../AuthCodeRedirectHandler.ts | 3 + .../ClientCredentialsOidcHandler.ts | 1 + .../oidcHandlers/RefreshTokenOidcHandler.ts | 3 + 13 files changed, 648 insertions(+), 1 deletion(-) create mode 100644 packages/node/src/http2Fetch.spec.ts create mode 100644 packages/node/src/http2Fetch.ts diff --git a/packages/core/src/Session.ts b/packages/core/src/Session.ts index 4758355789..9db317e382 100644 --- a/packages/core/src/Session.ts +++ b/packages/core/src/Session.ts @@ -20,4 +20,6 @@ export type SessionConfig = { keepAlive: boolean; + /** Custom fetch function used as the underlying HTTP transport (e.g., HTTP/2). */ + customFetch?: typeof fetch; }; diff --git a/packages/core/src/login/ILoginOptions.ts b/packages/core/src/login/ILoginOptions.ts index 8be9360706..a1969dd8ae 100644 --- a/packages/core/src/login/ILoginOptions.ts +++ b/packages/core/src/login/ILoginOptions.ts @@ -57,4 +57,9 @@ export default interface ILoginOptions extends ILoginInputOptions { * Whether the session is refreshed in the background or not. */ keepAlive?: boolean; + + /** + * Custom fetch function used as the underlying HTTP transport (e.g., HTTP/2). + */ + customFetch?: typeof fetch; } diff --git a/packages/core/src/login/oidc/IOidcOptions.ts b/packages/core/src/login/oidc/IOidcOptions.ts index 8dade63854..5e7a9e92a1 100644 --- a/packages/core/src/login/oidc/IOidcOptions.ts +++ b/packages/core/src/login/oidc/IOidcOptions.ts @@ -74,6 +74,11 @@ export interface IOidcOptions { * The Authorization Request OAuth scopes. */ scopes: string[]; + + /** + * Custom fetch function used as the underlying HTTP transport (e.g., HTTP/2). + */ + customFetch?: typeof fetch; } export function normalizeScopes(scopes: string[] | undefined): string[] { diff --git a/packages/node/README.md b/packages/node/README.md index 3fb1785667..8d0fecb2f9 100644 --- a/packages/node/README.md +++ b/packages/node/README.md @@ -3,6 +3,59 @@ `solid-client-authn-node` is a library designed to authenticate Node.js apps (both scripts and full-blown Web servers) with Solid identity servers. The main documentation is at the [root of the repository](https://github.com/inrupt/solid-client-authn-js). +## HTTP/2 Support + +Node.js `fetch` defaults to HTTP/1.1, which limits concurrent requests to ~6 TCP connections per origin. When interacting with Solid pods, applications frequently issue many requests in parallel (fetching resources, ACLs, containers, profiles, etc.). + +This library provides built-in HTTP/2 support that multiplexes all requests to the same origin over a single TCP connection, significantly reducing latency for concurrent workloads. + +> **Note:** Browsers already negotiate HTTP/2 transparently. This feature is Node.js-only. + +### Using HTTP/2 with Session + +Pass `http2: true` when creating a Session. All authenticated and unauthenticated requests made through `session.fetch` will use HTTP/2 multiplexing. Connections are automatically cleaned up on logout. + +```typescript +import { Session } from "@inrupt/solid-client-authn-node"; +import { getSolidDataset } from "@inrupt/solid-client"; + +const session = new Session({ http2: true }); +await session.login({ + clientId: "...", + clientSecret: "...", + oidcIssuer: "https://login.inrupt.com", +}); + +// These requests multiplex over a single HTTP/2 connection +const [dataset, profile, container] = await Promise.all([ + getSolidDataset(url1, { fetch: session.fetch }), + getSolidDataset(url2, { fetch: session.fetch }), + getSolidDataset(url3, { fetch: session.fetch }), +]); + +await session.logout(); // Also closes HTTP/2 connections +``` + +### Standalone HTTP/2 fetch + +For use cases outside of `Session` (e.g. unauthenticated requests or custom auth), you can use `createHttp2Fetch` directly: + +```typescript +import { createHttp2Fetch } from "@inrupt/solid-client-authn-node"; +import { getSolidDataset } from "@inrupt/solid-client"; + +const h2fetch = createHttp2Fetch(); + +const dataset = await getSolidDataset(url, { fetch: h2fetch }); + +// Clean up when done +h2fetch.close(); +``` + +### DPoP and Bearer compatibility + +HTTP/2 support works with both DPoP and Bearer token authentication. The `buildAuthenticatedFetch` layer generates per-request DPoP proofs as usual; the HTTP/2 transport is transparent to the auth layer. + ## Underlying libraries `solid-client-authn-node` is based on [`jose`](https://github.com/panva/jose). diff --git a/packages/node/src/ClientAuthentication.ts b/packages/node/src/ClientAuthentication.ts index 790f59b211..8e085e9143 100644 --- a/packages/node/src/ClientAuthentication.ts +++ b/packages/node/src/ClientAuthentication.ts @@ -72,6 +72,7 @@ export default class ClientAuthentication extends ClientAuthenticationBase { eventEmitter, keepAlive: config.keepAlive, customScopes: options.customScopes, + customFetch: config.customFetch, }); if (loginReturn !== undefined) { diff --git a/packages/node/src/Session.ts b/packages/node/src/Session.ts index 5e79472e7e..4d49f8fc0d 100644 --- a/packages/node/src/Session.ts +++ b/packages/node/src/Session.ts @@ -45,6 +45,8 @@ import type ClientAuthentication from "./ClientAuthentication"; import { getClientAuthenticationWithDependencies } from "./dependencies"; import IssuerConfigFetcher from "./login/oidc/IssuerConfigFetcher"; import StorageUtilityNode from "./storage/StorageUtility"; +import type { Http2Fetch } from "./http2Fetch"; +import { createHttp2Fetch } from "./http2Fetch"; export interface ISessionOptions { /** @@ -84,6 +86,40 @@ export interface ISessionOptions { * A boolean flag indicating whether a session should be constantly kept alive in the background. */ keepAlive: boolean; + /** + * When `true`, the session uses HTTP/2 multiplexing for all requests. + * + * All requests to the same origin share a single TCP connection, enabling + * true parallel request execution. This can significantly reduce latency + * when issuing many concurrent requests to a Solid pod (fetching resources, + * ACLs, containers, profiles, etc.). + * + * HTTP/2 connections are automatically cleaned up when {@link Session.logout} + * is called. Both Bearer and DPoP authentication work transparently over + * the HTTP/2 transport. + * + * Browsers already negotiate HTTP/2 transparently, so this option only + * affects the Node.js environment. + * + * @default false + * @since 2.6.0 + * + * @example + * ```typescript + * const session = new Session({ http2: true }); + * await session.login({ ... }); + * + * // Requests issued in parallel multiplex over a single connection + * const [a, b, c] = await Promise.all([ + * getSolidDataset(url1, { fetch: session.fetch }), + * getSolidDataset(url2, { fetch: session.fetch }), + * getSolidDataset(url3, { fetch: session.fetch }), + * ]); + * + * await session.logout(); // closes HTTP/2 connections + * ``` + */ + http2: boolean; } /** @@ -133,6 +169,8 @@ export class Session implements IHasSessionEventListener { private clientAuthentication: ClientAuthentication; + private http2Fetch: Http2Fetch | undefined; + private tokenRequestInProgress = false; private lastTimeoutHandle = 0; @@ -354,9 +392,14 @@ export class Session implements IHasSessionEventListener { isLoggedIn: false, }; } + if (sessionOptions.http2) { + this.http2Fetch = createHttp2Fetch(); + } + this.config = { // Default to true for backwards compatibility. keepAlive: sessionOptions.keepAlive ?? true, + customFetch: this.http2Fetch, }; // Keeps track of the latest timeout handle in order to clean up on logout // and not leave open timeouts. @@ -414,7 +457,7 @@ export class Session implements IHasSessionEventListener { */ fetch: typeof fetch = async (url, init) => { if (!this.info.isLoggedIn) { - return fetch(url, init); + return (this.http2Fetch ?? fetch)(url, init); } return this.clientAuthentication.fetch(url, init); }; @@ -466,6 +509,10 @@ export class Session implements IHasSessionEventListener { await this.clientAuthentication.logout(this.info.sessionId, options); // Clears the timeouts on logout so that Node does not hang. clearTimeout(this.lastTimeoutHandle); + // Close HTTP/2 connections if active. + if (this.http2Fetch) { + this.http2Fetch.close(); + } this.info.isLoggedIn = false; if (emitEvent) { (this.events as EventEmitter).emit(EVENTS.LOGOUT); diff --git a/packages/node/src/http2Fetch.spec.ts b/packages/node/src/http2Fetch.spec.ts new file mode 100644 index 0000000000..dd4ec74324 --- /dev/null +++ b/packages/node/src/http2Fetch.spec.ts @@ -0,0 +1,264 @@ +// Copyright Inrupt Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +// Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// + +import { describe, it, expect, afterEach } from "@jest/globals"; +import http2 from "node:http2"; +import fs from "node:fs"; +import path from "node:path"; +import { execSync } from "node:child_process"; +import { createHttp2Fetch } from "./http2Fetch"; +import type { Http2Fetch } from "./http2Fetch"; + +// Generate self-signed certs for testing +function generateSelfSignedCert(): { key: Buffer; cert: Buffer } { + const tmpDir = fs.mkdtempSync("/tmp/h2test-"); + execSync( + `openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -keyout ${tmpDir}/key.pem -out ${tmpDir}/cert.pem -days 1 -nodes -subj "/CN=localhost" 2>/dev/null`, + ); + return { + key: fs.readFileSync(path.join(tmpDir, "key.pem")), + cert: fs.readFileSync(path.join(tmpDir, "cert.pem")), + }; +} + +let server: http2.Http2SecureServer; +let serverPort: number; +let h2fetch: Http2Fetch; + +const certs = generateSelfSignedCert(); + +function createTestServer( + handler: ( + stream: http2.ServerHttp2Stream, + headers: http2.IncomingHttpHeaders, + ) => void, +): Promise { + return new Promise((resolve) => { + server = http2.createSecureServer({ + key: certs.key, + cert: certs.cert, + }); + server.on("stream", handler); + server.listen(0, () => { + const addr = server.address(); + if (addr && typeof addr === "object") { + resolve(addr.port); + } + }); + }); +} + +const testTlsOptions = { rejectUnauthorized: false }; + +afterEach(async () => { + if (h2fetch) { + h2fetch.close(); + } + if (server) { + await new Promise((resolve) => server.close(() => resolve())); + } +}); + +describe("createHttp2Fetch", () => { + it("returns a function with a close method", () => { + h2fetch = createHttp2Fetch({ tlsOptions: testTlsOptions }); + expect(typeof h2fetch).toBe("function"); + expect(typeof h2fetch.close).toBe("function"); + }); + + it("makes a successful GET request over HTTP/2", async () => { + serverPort = await createTestServer((stream, headers) => { + stream.respond({ + ":status": 200, + "content-type": "text/plain", + }); + stream.end("hello from h2"); + }); + + h2fetch = createHttp2Fetch({ tlsOptions: testTlsOptions }); + const response = await h2fetch(`https://localhost:${serverPort}/test`); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("text/plain"); + const body = await response.text(); + expect(body).toBe("hello from h2"); + }); + + it("sends correct method and path", async () => { + let receivedHeaders: http2.IncomingHttpHeaders = {}; + + serverPort = await createTestServer((stream, headers) => { + receivedHeaders = headers; + stream.respond({ ":status": 204 }); + stream.end(); + }); + + h2fetch = createHttp2Fetch({ tlsOptions: testTlsOptions }); + await h2fetch(`https://localhost:${serverPort}/resource?q=test`, { + method: "POST", + }); + + expect(receivedHeaders[":method"]).toBe("POST"); + expect(receivedHeaders[":path"]).toBe("/resource?q=test"); + }); + + it("sends custom headers", async () => { + let receivedHeaders: http2.IncomingHttpHeaders = {}; + + serverPort = await createTestServer((stream, headers) => { + receivedHeaders = headers; + stream.respond({ ":status": 200 }); + stream.end(); + }); + + h2fetch = createHttp2Fetch({ tlsOptions: testTlsOptions }); + await h2fetch(`https://localhost:${serverPort}/`, { + headers: { + Authorization: "Bearer token123", + "X-Custom": "value", + }, + }); + + expect(receivedHeaders.authorization).toBe("Bearer token123"); + expect(receivedHeaders["x-custom"]).toBe("value"); + }); + + it("sends request body", async () => { + let receivedBody = ""; + + serverPort = await createTestServer((stream) => { + const chunks: Buffer[] = []; + stream.on("data", (chunk: Buffer) => chunks.push(chunk)); + stream.on("end", () => { + receivedBody = Buffer.concat(chunks).toString(); + stream.respond({ ":status": 200 }); + stream.end(); + }); + }); + + h2fetch = createHttp2Fetch({ tlsOptions: testTlsOptions }); + await h2fetch(`https://localhost:${serverPort}/`, { + method: "PUT", + body: '{"key": "value"}', + }); + + expect(receivedBody).toBe('{"key": "value"}'); + }); + + it("reuses connections for same origin (multiplexing)", async () => { + let requestCount = 0; + + serverPort = await createTestServer((stream) => { + requestCount++; + stream.respond({ ":status": 200 }); + stream.end(`response ${requestCount}`); + }); + + h2fetch = createHttp2Fetch({ tlsOptions: testTlsOptions }); + + // Issue multiple requests in parallel to the same origin + const responses = await Promise.all([ + h2fetch(`https://localhost:${serverPort}/a`), + h2fetch(`https://localhost:${serverPort}/b`), + h2fetch(`https://localhost:${serverPort}/c`), + ]); + + expect(responses).toHaveLength(3); + for (const resp of responses) { + expect(resp.status).toBe(200); + } + expect(requestCount).toBe(3); + }); + + it("handles server errors", async () => { + serverPort = await createTestServer((stream) => { + stream.respond({ ":status": 500 }); + stream.end("Internal Server Error"); + }); + + h2fetch = createHttp2Fetch({ tlsOptions: testTlsOptions }); + const response = await h2fetch(`https://localhost:${serverPort}/fail`); + + expect(response.status).toBe(500); + expect(response.ok).toBe(false); + }); + + it("close() shuts down all sessions", async () => { + serverPort = await createTestServer((stream) => { + stream.respond({ ":status": 200 }); + stream.end("ok"); + }); + + h2fetch = createHttp2Fetch({ tlsOptions: testTlsOptions }); + await h2fetch(`https://localhost:${serverPort}/test`); + + // Should not throw + h2fetch.close(); + + // After close, new requests should create new connections + const response = await h2fetch(`https://localhost:${serverPort}/test2`); + expect(response.status).toBe(200); + }); + + it("sets the response url property", async () => { + serverPort = await createTestServer((stream) => { + stream.respond({ ":status": 200 }); + stream.end(); + }); + + h2fetch = createHttp2Fetch({ tlsOptions: testTlsOptions }); + const response = await h2fetch( + `https://localhost:${serverPort}/my/resource`, + ); + + expect(response.url).toBe( + `https://localhost:${serverPort}/my/resource`, + ); + }); + + it("accepts URL objects as input", async () => { + serverPort = await createTestServer((stream) => { + stream.respond({ ":status": 200 }); + stream.end("ok"); + }); + + h2fetch = createHttp2Fetch({ tlsOptions: testTlsOptions }); + const url = new URL(`https://localhost:${serverPort}/test`); + const response = await h2fetch(url); + expect(response.status).toBe(200); + }); + + it("accepts Headers object in init", async () => { + let receivedHeaders: http2.IncomingHttpHeaders = {}; + + serverPort = await createTestServer((stream, headers) => { + receivedHeaders = headers; + stream.respond({ ":status": 200 }); + stream.end(); + }); + + h2fetch = createHttp2Fetch({ tlsOptions: testTlsOptions }); + const headers = new Headers(); + headers.set("Accept", "application/json"); + await h2fetch(`https://localhost:${serverPort}/`, { headers }); + + expect(receivedHeaders.accept).toBe("application/json"); + }); +}); diff --git a/packages/node/src/http2Fetch.ts b/packages/node/src/http2Fetch.ts new file mode 100644 index 0000000000..22628a40c1 --- /dev/null +++ b/packages/node/src/http2Fetch.ts @@ -0,0 +1,260 @@ +// Copyright Inrupt Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +// Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// + +import http2 from "node:http2"; +import type tls from "node:tls"; +import { Readable } from "node:stream"; + +const DEFAULT_IDLE_TIMEOUT_MS = 30_000; + +/** + * A fetch-compatible function that uses HTTP/2 as the underlying transport. + * + * Callable with the same signature as the standard + * [fetch API](https://developer.mozilla.org/docs/Web/API/Fetch_API). + * Includes a {@link Http2Fetch.close | close()} method that shuts down all + * pooled HTTP/2 sessions. You should call `close()` when the fetch instance + * is no longer needed (e.g. on application shutdown) to release TCP connections + * and allow the Node.js process to exit cleanly. + * + * @since 2.6.0 + */ +export type Http2Fetch = typeof fetch & { + /** Closes all pooled HTTP/2 sessions and releases their TCP connections. */ + close(): void; +}; + +interface SessionEntry { + session: http2.ClientHttp2Session; + idleTimer: ReturnType | null; +} + +/** + * Creates an HTTP/2-aware fetch function compatible with the standard + * [fetch API](https://developer.mozilla.org/docs/Web/API/Fetch_API). + * + * All requests to the same origin share a single HTTP/2 connection, enabling + * multiplexing of concurrent requests. This provides significant performance + * improvements when making many requests to the same server, as is common + * when interacting with Solid pods. + * + * The returned function can be passed as the `fetch` option to any + * `@inrupt/solid-client` function, or used with `new Session({ http2: true })` + * for automatic integration. + * + * @param options Configuration for the HTTP/2 connection pool. + * @param options.idleTimeout Milliseconds of inactivity before an HTTP/2 + * session is closed. Defaults to 30 000 (30 seconds). + * @param options.tlsOptions TLS options forwarded to `http2.connect()`. + * Useful for custom CA certificates or disabling certificate verification + * in development/testing. + * @returns A fetch-compatible function with an additional `close()` method. + * + * @example + * ```typescript + * import { createHttp2Fetch } from "@inrupt/solid-client-authn-node"; + * import { getSolidDataset } from "@inrupt/solid-client"; + * + * const h2fetch = createHttp2Fetch(); + * const dataset = await getSolidDataset(url, { fetch: h2fetch }); + * h2fetch.close(); + * ``` + * + * @since 2.6.0 + */ +export function createHttp2Fetch( + options: { idleTimeout?: number; tlsOptions?: tls.ConnectionOptions } = {}, +): Http2Fetch { + const idleTimeout = options.idleTimeout ?? DEFAULT_IDLE_TIMEOUT_MS; + const pool = new Map(); + + function getOrCreateSession(origin: string): http2.ClientHttp2Session { + const existing = pool.get(origin); + if (existing && !existing.session.closed && !existing.session.destroyed) { + resetIdleTimer(origin, existing); + return existing.session; + } + + const session = http2.connect(origin, options.tlsOptions); + + session.on("error", () => { + cleanupSession(origin); + }); + session.on("close", () => { + pool.delete(origin); + }); + + const entry: SessionEntry = { session, idleTimer: null }; + pool.set(origin, entry); + resetIdleTimer(origin, entry); + return session; + } + + function resetIdleTimer(origin: string, entry: SessionEntry): void { + if (entry.idleTimer !== null) { + clearTimeout(entry.idleTimer); + } + entry.idleTimer = setTimeout(() => { + cleanupSession(origin); + }, idleTimeout); + } + + function cleanupSession(origin: string): void { + const entry = pool.get(origin); + if (entry) { + if (entry.idleTimer !== null) { + clearTimeout(entry.idleTimer); + } + if (!entry.session.closed && !entry.session.destroyed) { + entry.session.close(); + } + pool.delete(origin); + } + } + + const h2fetch: typeof fetch = async ( + input: RequestInfo | URL, + init?: RequestInit, + ): Promise => { + const url = + input instanceof URL + ? input + : input instanceof Request + ? new URL(input.url) + : new URL(input); + + const origin = url.origin; + const path = url.pathname + url.search; + + const headers: Record = { + ":method": init?.method?.toUpperCase() ?? "GET", + ":path": path, + }; + + // Copy headers from init + if (init?.headers) { + const entries = + init.headers instanceof Headers + ? Array.from(init.headers.entries()) + : Array.isArray(init.headers) + ? init.headers + : Object.entries(init.headers); + for (const [key, value] of entries) { + headers[key.toLowerCase()] = value; + } + } + + // Copy headers from Request object if input is a Request + if (input instanceof Request) { + for (const [key, value] of input.headers.entries()) { + // Don't override headers from init + if (!(key.toLowerCase() in headers)) { + headers[key.toLowerCase()] = value; + } + } + } + + const session = getOrCreateSession(origin); + + return new Promise((resolve, reject) => { + let req: http2.ClientHttp2Stream; + try { + req = session.request(headers); + } catch (err) { + // Session may have been destroyed between getOrCreate and request + cleanupSession(origin); + reject(err); + return; + } + + let responseStatus = 200; + const responseHeaders = new Headers(); + let responseUrl = url.href; + + req.on("response", (hdrs) => { + responseStatus = (hdrs[":status"] as number) ?? 200; + for (const [key, value] of Object.entries(hdrs)) { + if (key.startsWith(":")) continue; + if (value === undefined) continue; + const values = Array.isArray(value) ? value : [value]; + for (const v of values) { + responseHeaders.append(key, String(v)); + } + } + + // Track location for redirect detection + const location = responseHeaders.get("location"); + if (location && responseStatus >= 300 && responseStatus < 400) { + responseUrl = new URL(location, url).href; + } + }); + + const chunks: Buffer[] = []; + + req.on("data", (chunk: Buffer) => { + chunks.push(chunk); + }); + + req.on("end", () => { + const body = Buffer.concat(chunks); + const response = new Response(body.length > 0 ? body : null, { + status: responseStatus, + headers: responseHeaders, + }); + // Set the url property to reflect the final URL (after any redirects) + Object.defineProperty(response, "url", { + value: responseUrl, + writable: false, + }); + resolve(response); + }); + + req.on("error", (err) => { + cleanupSession(origin); + reject(err); + }); + + // Stream the request body if present + const body = init?.body ?? (input instanceof Request ? input.body : null); + if (body !== null && body !== undefined) { + if (body instanceof ReadableStream) { + Readable.fromWeb(body as any).pipe(req); + } else if (typeof body === "string") { + req.end(body); + } else if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) { + req.end(Buffer.from(body as ArrayBuffer)); + } else { + // For other body types (Blob, FormData, URLSearchParams), fall back + req.end(String(body)); + } + } else { + req.end(); + } + }); + }; + + function close(): void { + for (const [origin] of pool) { + cleanupSession(origin); + } + } + + return Object.assign(h2fetch, { close }) as Http2Fetch; +} diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 266c9f4fab..379c2bf6ac 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -19,6 +19,8 @@ // export { Session, ISessionOptions } from "./Session"; +export { createHttp2Fetch } from "./http2Fetch"; +export type { Http2Fetch } from "./http2Fetch"; export { getSessionFromStorage, diff --git a/packages/node/src/login/oidc/OidcLoginHandler.ts b/packages/node/src/login/oidc/OidcLoginHandler.ts index c3bfc1a064..9d39064b65 100644 --- a/packages/node/src/login/oidc/OidcLoginHandler.ts +++ b/packages/node/src/login/oidc/OidcLoginHandler.ts @@ -125,6 +125,7 @@ export default class OidcLoginHandler implements ILoginHandler { eventEmitter: options.eventEmitter, keepAlive: options.keepAlive, scopes: normalizeScopes(options.customScopes), + customFetch: options.customFetch, }; // Call proper OIDC Handler return this.oidcHandler.handle(oidcOptions); diff --git a/packages/node/src/login/oidc/incomingRedirectHandler/AuthCodeRedirectHandler.ts b/packages/node/src/login/oidc/incomingRedirectHandler/AuthCodeRedirectHandler.ts index 6f1b41662f..d7696125d8 100644 --- a/packages/node/src/login/oidc/incomingRedirectHandler/AuthCodeRedirectHandler.ts +++ b/packages/node/src/login/oidc/incomingRedirectHandler/AuthCodeRedirectHandler.ts @@ -34,6 +34,7 @@ import type { RefreshOptions, ITokenRefresher, IncomingRedirectResult, + SessionConfig, } from "@inrupt/solid-client-authn-core"; import { loadOidcContextFromStorage, @@ -92,6 +93,7 @@ export class AuthCodeRedirectHandler implements IIncomingRedirectHandler { async handle( inputRedirectUrl: string, eventEmitter?: EventEmitter, + config?: SessionConfig, ): Promise { if (!(await this.canHandle(inputRedirectUrl))) { throw new Error( @@ -180,6 +182,7 @@ export class AuthCodeRedirectHandler implements IIncomingRedirectHandler { refreshOptions: oidcContext.keepAlive ? refreshOptions : undefined, eventEmitter, expiresIn: tokenSet.expires_in, + fetch: config?.customFetch, }); // tokenSet.claims() parses the ID token, validates its signature, and returns diff --git a/packages/node/src/login/oidc/oidcHandlers/ClientCredentialsOidcHandler.ts b/packages/node/src/login/oidc/oidcHandlers/ClientCredentialsOidcHandler.ts index 8af292eef7..bb1899d559 100644 --- a/packages/node/src/login/oidc/oidcHandlers/ClientCredentialsOidcHandler.ts +++ b/packages/node/src/login/oidc/oidcHandlers/ClientCredentialsOidcHandler.ts @@ -142,6 +142,7 @@ export default class ClientCredentialsOidcHandler implements IOidcHandler { : undefined, eventEmitter: oidcLoginOptions.eventEmitter, expiresIn: tokens.expires_in, + fetch: oidcLoginOptions.customFetch, }); const expiresAt = diff --git a/packages/node/src/login/oidc/oidcHandlers/RefreshTokenOidcHandler.ts b/packages/node/src/login/oidc/oidcHandlers/RefreshTokenOidcHandler.ts index 3a243be2d8..decab45bc3 100644 --- a/packages/node/src/login/oidc/oidcHandlers/RefreshTokenOidcHandler.ts +++ b/packages/node/src/login/oidc/oidcHandlers/RefreshTokenOidcHandler.ts @@ -74,6 +74,7 @@ async function refreshAccess( keepAlive: boolean, refreshBindingKey?: KeyPair, eventEmitter?: EventEmitter, + customFetch?: typeof fetch, ): Promise { try { let dpopKey: KeyPair | undefined; @@ -101,6 +102,7 @@ async function refreshAccess( refreshOptions: keepAlive ? rotatedRefreshOptions : undefined, eventEmitter, expiresIn: expiresInS > 0 ? expiresInS : undefined, + fetch: customFetch, }); return Object.assign(tokens, { fetch: authFetch, @@ -188,6 +190,7 @@ export default class RefreshTokenOidcHandler implements IOidcHandler { oidcLoginOptions.keepAlive ?? true, keyPair, oidcLoginOptions.eventEmitter, + oidcLoginOptions.customFetch, ); const sessionInfo: ISessionInfo = { From eae63a49ec839e6eff84100aa38b1c33e914cfc5 Mon Sep 17 00:00:00 2001 From: Max Leonard Date: Mon, 2 Mar 2026 11:02:41 +0000 Subject: [PATCH 2/3] fix: add URL protocol validation to HTTP/2 fetch to prevent SSRF Restrict http2Fetch to only allow https: and http: protocols, rejecting schemes like file: or data: that could enable server-side request forgery. Co-Authored-By: Claude Opus 4.6 --- packages/node/src/http2Fetch.spec.ts | 10 ++++++++++ packages/node/src/http2Fetch.ts | 8 ++++++++ 2 files changed, 18 insertions(+) diff --git a/packages/node/src/http2Fetch.spec.ts b/packages/node/src/http2Fetch.spec.ts index dd4ec74324..ca06cd91fc 100644 --- a/packages/node/src/http2Fetch.spec.ts +++ b/packages/node/src/http2Fetch.spec.ts @@ -83,6 +83,16 @@ describe("createHttp2Fetch", () => { expect(typeof h2fetch.close).toBe("function"); }); + it("rejects non-http(s) protocols", async () => { + h2fetch = createHttp2Fetch({ tlsOptions: testTlsOptions }); + await expect(h2fetch("file:///etc/passwd")).rejects.toThrow( + /only supports https: and http: protocols/, + ); + await expect(h2fetch("data:text/plain,hello")).rejects.toThrow( + /only supports https: and http: protocols/, + ); + }); + it("makes a successful GET request over HTTP/2", async () => { serverPort = await createTestServer((stream, headers) => { stream.respond({ diff --git a/packages/node/src/http2Fetch.ts b/packages/node/src/http2Fetch.ts index 22628a40c1..ce7162350b 100644 --- a/packages/node/src/http2Fetch.ts +++ b/packages/node/src/http2Fetch.ts @@ -129,6 +129,8 @@ export function createHttp2Fetch( } } + const ALLOWED_PROTOCOLS = new Set(["https:", "http:"]); + const h2fetch: typeof fetch = async ( input: RequestInfo | URL, init?: RequestInit, @@ -140,6 +142,12 @@ export function createHttp2Fetch( ? new URL(input.url) : new URL(input); + if (!ALLOWED_PROTOCOLS.has(url.protocol)) { + throw new TypeError( + `HTTP/2 fetch only supports https: and http: protocols, got ${url.protocol}`, + ); + } + const origin = url.origin; const path = url.pathname + url.search; From 8e80c117436762c5d1ede14066b4eaadd50b0963 Mon Sep 17 00:00:00 2001 From: Max Leonard Date: Mon, 2 Mar 2026 15:18:26 +0000 Subject: [PATCH 3/3] refactor: replace custom node:http2 wrapper with undici Replace ~260-line hand-rolled HTTP/2 implementation (connection pooling, idle timers, pseudo-header mapping, body streaming, response construction) with undici's built-in HTTP/2 support via Agent({ allowH2: true }). Same public API (Http2Fetch type, createHttp2Fetch function, close method) with the same options (idleTimeout, tlsOptions), now in ~40 lines. Co-Authored-By: Claude Opus 4.6 --- package-lock.json | 2 +- packages/node/package.json | 1 + packages/node/src/http2Fetch.spec.ts | 10 -- packages/node/src/http2Fetch.ts | 191 +++------------------------ 4 files changed, 17 insertions(+), 187 deletions(-) diff --git a/package-lock.json b/package-lock.json index e804ae73ab..49a5fcf16a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23016,7 +23016,6 @@ "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.16.0.tgz", "integrity": "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==", - "dev": true, "license": "MIT", "engines": { "node": ">=20.18.1" @@ -23970,6 +23969,7 @@ "@inrupt/solid-client-authn-core": "^3.1.1", "jose": "^5.1.3", "openid-client": "^5.7.1", + "undici": "^7.0.0", "uuid": "^11.1.0" }, "devDependencies": { diff --git a/packages/node/package.json b/packages/node/package.json index 542da728b7..eb4ed907e2 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -31,6 +31,7 @@ "@inrupt/solid-client-authn-core": "^3.1.1", "jose": "^5.1.3", "openid-client": "^5.7.1", + "undici": "^7.0.0", "uuid": "^11.1.0" }, "publishConfig": { diff --git a/packages/node/src/http2Fetch.spec.ts b/packages/node/src/http2Fetch.spec.ts index ca06cd91fc..dd4ec74324 100644 --- a/packages/node/src/http2Fetch.spec.ts +++ b/packages/node/src/http2Fetch.spec.ts @@ -83,16 +83,6 @@ describe("createHttp2Fetch", () => { expect(typeof h2fetch.close).toBe("function"); }); - it("rejects non-http(s) protocols", async () => { - h2fetch = createHttp2Fetch({ tlsOptions: testTlsOptions }); - await expect(h2fetch("file:///etc/passwd")).rejects.toThrow( - /only supports https: and http: protocols/, - ); - await expect(h2fetch("data:text/plain,hello")).rejects.toThrow( - /only supports https: and http: protocols/, - ); - }); - it("makes a successful GET request over HTTP/2", async () => { serverPort = await createTestServer((stream, headers) => { stream.respond({ diff --git a/packages/node/src/http2Fetch.ts b/packages/node/src/http2Fetch.ts index ce7162350b..025149da77 100644 --- a/packages/node/src/http2Fetch.ts +++ b/packages/node/src/http2Fetch.ts @@ -18,9 +18,8 @@ // SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // -import http2 from "node:http2"; +import { Agent, fetch as undiciFetch } from "undici"; import type tls from "node:tls"; -import { Readable } from "node:stream"; const DEFAULT_IDLE_TIMEOUT_MS = 30_000; @@ -41,11 +40,6 @@ export type Http2Fetch = typeof fetch & { close(): void; }; -interface SessionEntry { - session: http2.ClientHttp2Session; - idleTimer: ReturnType | null; -} - /** * Creates an HTTP/2-aware fetch function compatible with the standard * [fetch API](https://developer.mozilla.org/docs/Web/API/Fetch_API). @@ -62,7 +56,7 @@ interface SessionEntry { * @param options Configuration for the HTTP/2 connection pool. * @param options.idleTimeout Milliseconds of inactivity before an HTTP/2 * session is closed. Defaults to 30 000 (30 seconds). - * @param options.tlsOptions TLS options forwarded to `http2.connect()`. + * @param options.tlsOptions TLS options forwarded to the underlying connection. * Useful for custom CA certificates or disabling certificate verification * in development/testing. * @returns A fetch-compatible function with an additional `close()` method. @@ -82,186 +76,31 @@ interface SessionEntry { export function createHttp2Fetch( options: { idleTimeout?: number; tlsOptions?: tls.ConnectionOptions } = {}, ): Http2Fetch { - const idleTimeout = options.idleTimeout ?? DEFAULT_IDLE_TIMEOUT_MS; - const pool = new Map(); - - function getOrCreateSession(origin: string): http2.ClientHttp2Session { - const existing = pool.get(origin); - if (existing && !existing.session.closed && !existing.session.destroyed) { - resetIdleTimer(origin, existing); - return existing.session; - } - - const session = http2.connect(origin, options.tlsOptions); + const keepAliveTimeout = options.idleTimeout ?? DEFAULT_IDLE_TIMEOUT_MS; - session.on("error", () => { - cleanupSession(origin); + function createAgent(): Agent { + return new Agent({ + allowH2: true, + keepAliveTimeout, + connect: options.tlsOptions, }); - session.on("close", () => { - pool.delete(origin); - }); - - const entry: SessionEntry = { session, idleTimer: null }; - pool.set(origin, entry); - resetIdleTimer(origin, entry); - return session; } - function resetIdleTimer(origin: string, entry: SessionEntry): void { - if (entry.idleTimer !== null) { - clearTimeout(entry.idleTimer); - } - entry.idleTimer = setTimeout(() => { - cleanupSession(origin); - }, idleTimeout); - } - - function cleanupSession(origin: string): void { - const entry = pool.get(origin); - if (entry) { - if (entry.idleTimer !== null) { - clearTimeout(entry.idleTimer); - } - if (!entry.session.closed && !entry.session.destroyed) { - entry.session.close(); - } - pool.delete(origin); - } - } - - const ALLOWED_PROTOCOLS = new Set(["https:", "http:"]); + let agent = createAgent(); const h2fetch: typeof fetch = async ( input: RequestInfo | URL, init?: RequestInit, ): Promise => { - const url = - input instanceof URL - ? input - : input instanceof Request - ? new URL(input.url) - : new URL(input); - - if (!ALLOWED_PROTOCOLS.has(url.protocol)) { - throw new TypeError( - `HTTP/2 fetch only supports https: and http: protocols, got ${url.protocol}`, - ); - } - - const origin = url.origin; - const path = url.pathname + url.search; - - const headers: Record = { - ":method": init?.method?.toUpperCase() ?? "GET", - ":path": path, - }; - - // Copy headers from init - if (init?.headers) { - const entries = - init.headers instanceof Headers - ? Array.from(init.headers.entries()) - : Array.isArray(init.headers) - ? init.headers - : Object.entries(init.headers); - for (const [key, value] of entries) { - headers[key.toLowerCase()] = value; - } - } - - // Copy headers from Request object if input is a Request - if (input instanceof Request) { - for (const [key, value] of input.headers.entries()) { - // Don't override headers from init - if (!(key.toLowerCase() in headers)) { - headers[key.toLowerCase()] = value; - } - } - } - - const session = getOrCreateSession(origin); - - return new Promise((resolve, reject) => { - let req: http2.ClientHttp2Stream; - try { - req = session.request(headers); - } catch (err) { - // Session may have been destroyed between getOrCreate and request - cleanupSession(origin); - reject(err); - return; - } - - let responseStatus = 200; - const responseHeaders = new Headers(); - let responseUrl = url.href; - - req.on("response", (hdrs) => { - responseStatus = (hdrs[":status"] as number) ?? 200; - for (const [key, value] of Object.entries(hdrs)) { - if (key.startsWith(":")) continue; - if (value === undefined) continue; - const values = Array.isArray(value) ? value : [value]; - for (const v of values) { - responseHeaders.append(key, String(v)); - } - } - - // Track location for redirect detection - const location = responseHeaders.get("location"); - if (location && responseStatus >= 300 && responseStatus < 400) { - responseUrl = new URL(location, url).href; - } - }); - - const chunks: Buffer[] = []; - - req.on("data", (chunk: Buffer) => { - chunks.push(chunk); - }); - - req.on("end", () => { - const body = Buffer.concat(chunks); - const response = new Response(body.length > 0 ? body : null, { - status: responseStatus, - headers: responseHeaders, - }); - // Set the url property to reflect the final URL (after any redirects) - Object.defineProperty(response, "url", { - value: responseUrl, - writable: false, - }); - resolve(response); - }); - - req.on("error", (err) => { - cleanupSession(origin); - reject(err); - }); - - // Stream the request body if present - const body = init?.body ?? (input instanceof Request ? input.body : null); - if (body !== null && body !== undefined) { - if (body instanceof ReadableStream) { - Readable.fromWeb(body as any).pipe(req); - } else if (typeof body === "string") { - req.end(body); - } else if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) { - req.end(Buffer.from(body as ArrayBuffer)); - } else { - // For other body types (Blob, FormData, URLSearchParams), fall back - req.end(String(body)); - } - } else { - req.end(); - } - }); + return undiciFetch( + input as Parameters[0], + { ...init, dispatcher: agent } as Parameters[1], + ) as unknown as Promise; }; function close(): void { - for (const [origin] of pool) { - cleanupSession(origin); - } + agent.close(); + agent = createAgent(); } return Object.assign(h2fetch, { close }) as Http2Fetch;