Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions packages/core/src/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
5 changes: 5 additions & 0 deletions packages/core/src/login/ILoginOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
5 changes: 5 additions & 0 deletions packages/core/src/login/oidc/IOidcOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] {
Expand Down
53 changes: 53 additions & 0 deletions packages/node/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
1 change: 1 addition & 0 deletions packages/node/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
1 change: 1 addition & 0 deletions packages/node/src/ClientAuthentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export default class ClientAuthentication extends ClientAuthenticationBase {
eventEmitter,
keepAlive: config.keepAlive,
customScopes: options.customScopes,
customFetch: config.customFetch,
});

if (loginReturn !== undefined) {
Expand Down
49 changes: 48 additions & 1 deletion packages/node/src/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@
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 {
/**
Expand Down Expand Up @@ -84,6 +86,40 @@
* 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;
}

/**
Expand Down Expand Up @@ -133,6 +169,8 @@

private clientAuthentication: ClientAuthentication;

private http2Fetch: Http2Fetch | undefined;

private tokenRequestInProgress = false;

private lastTimeoutHandle = 0;
Expand Down Expand Up @@ -354,9 +392,14 @@
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.
Expand Down Expand Up @@ -414,7 +457,7 @@
*/
fetch: typeof fetch = async (url, init) => {
if (!this.info.isLoggedIn) {
return fetch(url, init);
return (this.http2Fetch ?? fetch)(url, init);

Check failure

Code scanning / CodeQL

Server-side request forgery Critical

The
URL
of this request depends on a
user-provided value
.
The
URL
of this request depends on a
user-provided value
.

Copilot Autofix

AI 5 days ago

In general, the problem is that user-controlled resource is used as the URL argument to fetch with no constraints. To fix this without breaking existing semantics, we should validate and normalize the URL before passing it to fetch, and reject or constrain values that don’t meet safe criteria (e.g., enforce HTTPS and disallow internal IPs / localhost in the test server). Since the library Session.fetch is a general-purpose method, we keep it flexible, but we can add a minimal safeguard to ensure the unauthenticated, “raw” fetch path only accepts absolute URLs with allowed protocols. The core SSRF surface exposed over HTTP is the Express test app; there we can be stricter and apply explicit checks.

Concretely:

  1. In e2e/node/server/express.ts:

    • In both /legacy/fetch and /tokens/fetch, after confirming resource is a string, parse it with new URL(resource) inside a try/catch.
    • Enforce that url.protocol is http: or https:.
    • Optionally (and safely) add a basic blocklist for obviously sensitive hosts (loopback / link-local / private networks). Since we must not assume extra infrastructure, we can at minimum prevent localhost and 127.0.0.0/8 and document that this is a safety check for the test server.
    • If the URL fails validation, respond with HTTP 400 and do not call fetch.
    • Use the parsed and validated url.toString() for the fetch call (or keep the original string once validated, but parsing already proves it is a valid URL).
  2. In packages/node/src/Session.ts:

    • Add a shallow check in the unauthenticated branch of fetch: ensure url is either a URL object or a string that parses to a URL with http: or https: protocol. This is mostly defense-in-depth and satisfies CodeQL that we’re not blindly using attacker-controlled protocols; it should not affect normal usage where callers use standard URLs.
    • If the check fails, throw an error, as that’s better than making a dangerous request.

Implementation elements:

  • Use the built-in URL class (global in Node 18+, or import { URL } from "url"; if needed – but Node’s global is likely already available; we can call new URL(...) directly).
  • We do not add any third-party dependencies; native URL handling is sufficient.

The lines to change:

  • e2e/node/server/express.ts:
    • Around lines 146–162 and 164–188 (within /legacy/fetch and /tokens/fetch), wrap resource in validation logic before fetch(resource).
  • packages/node/src/Session.ts:
    • Around line 458–462, extend the unauthenticated branch to validate url before calling (this.http2Fetch ?? fetch)(url, init).

Suggested changeset 2
packages/node/src/Session.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/packages/node/src/Session.ts b/packages/node/src/Session.ts
--- a/packages/node/src/Session.ts
+++ b/packages/node/src/Session.ts
@@ -457,7 +457,23 @@
    */
   fetch: typeof fetch = async (url, init) => {
     if (!this.info.isLoggedIn) {
-      return (this.http2Fetch ?? fetch)(url, init);
+      // Basic defense-in-depth: ensure that the URL used for unauthenticated
+      // requests is a valid HTTP(S) URL.
+      let normalizedUrl = url;
+      if (typeof url === "string") {
+        try {
+          const parsed = new URL(url);
+          if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
+            throw new Error("Unsupported URL protocol");
+          }
+          normalizedUrl = parsed.toString();
+        } catch (e) {
+          throw new Error(
+            `Invalid URL passed to fetch: ${(e as Error).message}`,
+          );
+        }
+      }
+      return (this.http2Fetch ?? fetch)(normalizedUrl as any, init);
     }
     return this.clientAuthentication.fetch(url, init);
   };
EOF
@@ -457,7 +457,23 @@
*/
fetch: typeof fetch = async (url, init) => {
if (!this.info.isLoggedIn) {
return (this.http2Fetch ?? fetch)(url, init);
// Basic defense-in-depth: ensure that the URL used for unauthenticated
// requests is a valid HTTP(S) URL.
let normalizedUrl = url;
if (typeof url === "string") {
try {
const parsed = new URL(url);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error("Unsupported URL protocol");
}
normalizedUrl = parsed.toString();
} catch (e) {
throw new Error(
`Invalid URL passed to fetch: ${(e as Error).message}`,
);
}
}
return (this.http2Fetch ?? fetch)(normalizedUrl as any, init);
}
return this.clientAuthentication.fetch(url, init);
};
e2e/node/server/express.ts
Outside changed files

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/e2e/node/server/express.ts b/e2e/node/server/express.ts
--- a/e2e/node/server/express.ts
+++ b/e2e/node/server/express.ts
@@ -151,10 +151,23 @@
       return;
     }
 
+    let url: URL;
+    try {
+      url = new URL(resource);
+    } catch (_e) {
+      res.status(400).send("invalid resource URL").end();
+      return;
+    }
+
+    if (url.protocol !== "http:" && url.protocol !== "https:") {
+      res.status(400).send("unsupported URL protocol").end();
+      return;
+    }
+
     const session = await getSessionFromStorage(req.session!.sessionId);
 
     const { fetch } = session ?? new Session();
-    const response = await fetch(resource);
+    const response = await fetch(url.toString());
     res
       .status(response.status)
       .send(await response.text())
@@ -169,6 +179,19 @@
       return;
     }
 
+    let url: URL;
+    try {
+      url = new URL(resource);
+    } catch (_e) {
+      res.status(400).send("invalid resource URL").end();
+      return;
+    }
+
+    if (url.protocol !== "http:" && url.protocol !== "https:") {
+      res.status(400).send("unsupported URL protocol").end();
+      return;
+    }
+
     let session;
     const sessionTokenSet = sessionTokenSets.get(req.session!.sessionId);
     if (sessionTokenSet) {
@@ -180,7 +203,7 @@
 
     const { fetch } = session ?? new Session();
 
-    const response = await fetch(resource);
+    const response = await fetch(url.toString());
     res
       .status(response.status)
       .send(await response.text())
EOF
@@ -151,10 +151,23 @@
return;
}

let url: URL;
try {
url = new URL(resource);
} catch (_e) {
res.status(400).send("invalid resource URL").end();
return;
}

if (url.protocol !== "http:" && url.protocol !== "https:") {
res.status(400).send("unsupported URL protocol").end();
return;
}

const session = await getSessionFromStorage(req.session!.sessionId);

const { fetch } = session ?? new Session();
const response = await fetch(resource);
const response = await fetch(url.toString());
res
.status(response.status)
.send(await response.text())
@@ -169,6 +179,19 @@
return;
}

let url: URL;
try {
url = new URL(resource);
} catch (_e) {
res.status(400).send("invalid resource URL").end();
return;
}

if (url.protocol !== "http:" && url.protocol !== "https:") {
res.status(400).send("unsupported URL protocol").end();
return;
}

let session;
const sessionTokenSet = sessionTokenSets.get(req.session!.sessionId);
if (sessionTokenSet) {
@@ -180,7 +203,7 @@

const { fetch } = session ?? new Session();

const response = await fetch(resource);
const response = await fetch(url.toString());
res
.status(response.status)
.send(await response.text())
Copilot is powered by AI and may make mistakes. Always verify output.
}
return this.clientAuthentication.fetch(url, init);
};
Expand Down Expand Up @@ -466,6 +509,10 @@
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);
Expand Down
Loading
Loading