javascript-solid-server 0.0.176 → 0.0.178
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/.claude/scheduled_tasks.lock +1 -0
- package/README.md +1 -0
- package/docs/lws.md +84 -0
- package/package.json +1 -1
- package/src/auth/cid-doc-fetch.js +202 -0
- package/src/auth/lws-cid.js +516 -0
- package/src/auth/nostr.js +397 -8
- package/src/auth/token.js +12 -1
- package/test/lws-cid.test.js +705 -0
- package/test/nostr-cid-vm.test.js +509 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"sessionId":"a05da419-92b7-4056-93b8-e97b2035d4ae","pid":3932382,"procStart":"114094236","acquiredAt":1778317500161}
|
package/README.md
CHANGED
|
@@ -26,6 +26,7 @@ A minimal, fast, JSON-LD native Solid server.
|
|
|
26
26
|
- **Nostr Relay** — Integrated NIP-01 relay (`wss://your.pod/relay`)
|
|
27
27
|
- **Nostr Auth** — NIP-98 signatures, did:nostr → WebID resolution
|
|
28
28
|
- **End-to-End Encryption** — Encrypt pod content client-side via NIP-44 / NIP-04 using `did:nostr` keys ([docs](https://jss.live/docs/features/e2ee/), zero server-side changes)
|
|
29
|
+
- **LWS / CID v1 profile shape** — New pod profiles are structurally W3C [Controlled Identifier](https://www.w3.org/TR/cid-1.0/) documents, ready for [LWS 1.0](https://www.w3.org/TR/2026/WD-lws10-authn-ssi-cid-20260423/) auth ([docs](docs/lws.md))
|
|
29
30
|
- **ActivityPub** — Fediverse federation with Mastodon-compatible API
|
|
30
31
|
- **remoteStorage** — [draft-dejong-remotestorage-22](https://remotestorage.io/spec/) file sync
|
|
31
32
|
- **MongoDB Storage** — Optional `/db/` route for JSON-LD at scale
|
package/docs/lws.md
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# LWS / Controlled Identifiers (CID v1)
|
|
2
|
+
|
|
3
|
+
JSS pod profiles are aligned with the W3C [Linked Web Storage 1.0 Authentication Suite](https://www.w3.org/news/2026/first-public-working-drafts-for-the-linked-web-storage-lws-1-0-authentication-suite/) (FPWDs published 2026-04-23) and its substrate, [W3C Controlled Identifiers v1.0](https://www.w3.org/TR/cid-1.0/).
|
|
4
|
+
|
|
5
|
+
The work is phased — see [#386](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/386) for the convergence tracker and [#319](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/319) for the FPWD-alignment audit.
|
|
6
|
+
|
|
7
|
+
## Three levels of compatibility
|
|
8
|
+
|
|
9
|
+
| | What it means | Status |
|
|
10
|
+
|---|---|---|
|
|
11
|
+
| **1. Profile shape** | A WebID profile that's structurally a W3C Controlled Identifier document — right `@context`, right vocabulary, parseable as a CID document by any LWS-aware tool | ✅ **Yes** (since v0.0.174, [#388](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/pull/388)) |
|
|
12
|
+
| **2. Profile carries keys** | The CID document actually declares `verificationMethod` entries an LWS verifier can look up by `kid` | ❌ Phase B — a separate "doctor / add-keys" app PATCHes them in after authentication. Out of JSS server scope. |
|
|
13
|
+
| **3. Server accepts LWS-CID JWTs** | An incoming request with an LWS-CID self-signed JWT (`sub`/`iss`/`client_id` triple-equality, `kid` lookup against the WebID's `verificationMethod`, signature check) | ❌ Phase 3 of [#386](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/386) — JSS still only knows OIDC/DPoP, NIP-98, passkey, simple bearer |
|
|
14
|
+
|
|
15
|
+
## What Phase A actually does
|
|
16
|
+
|
|
17
|
+
`src/webid/profile.js` declares the six CID v1 vocabulary terms — `controller`, `verificationMethod`, `authentication`, `assertionMethod`, `publicKeyJwk`, `publicKeyMultibase` — in the profile's `@context` (inline, so JSS's JSON-LD → Turtle conneg layer can expand them without fetching external contexts), and emits a `controller` triple pointing at the WebID itself per CID v1's self-control contract.
|
|
18
|
+
|
|
19
|
+
A freshly-created pod's `profile/card.jsonld` looks like this (excerpt — the existing Solid predicates `oidcIssuer`, `pim:storage`, `ldp:inbox`, `service` etc. are unchanged):
|
|
20
|
+
|
|
21
|
+
```jsonld
|
|
22
|
+
{
|
|
23
|
+
"@context": {
|
|
24
|
+
"foaf": "...", "solid": "...", "cid": "https://www.w3.org/ns/cid/v1#", "lws": "https://www.w3.org/ns/lws#",
|
|
25
|
+
"controller": { "@id": "cid:controller", "@type": "@id" },
|
|
26
|
+
"verificationMethod": { "@id": "cid:verificationMethod", "@container": "@set" },
|
|
27
|
+
"authentication": { "@id": "cid:authentication", "@type": "@id", "@container": "@set" },
|
|
28
|
+
"assertionMethod": { "@id": "cid:assertionMethod", "@type": "@id", "@container": "@set" },
|
|
29
|
+
"publicKeyJwk": { "@id": "cid:publicKeyJwk", "@type": "@json" },
|
|
30
|
+
"publicKeyMultibase": { "@id": "cid:publicKeyMultibase" }
|
|
31
|
+
},
|
|
32
|
+
"@id": "https://alice.example/profile/card.jsonld#me",
|
|
33
|
+
"@type": ["foaf:Person"],
|
|
34
|
+
"controller": "https://alice.example/profile/card.jsonld#me"
|
|
35
|
+
// verificationMethod / authentication / assertionMethod arrays are
|
|
36
|
+
// intentionally absent until Phase B's doctor app PATCHes them in.
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## What Phase B will add
|
|
41
|
+
|
|
42
|
+
A standalone web app (separate repo, no JSS coupling) where the WebID owner authenticates via existing means (OIDC, NIP-98, passkey) and PATCHes their profile with one or more verification methods:
|
|
43
|
+
|
|
44
|
+
```jsonld
|
|
45
|
+
"verificationMethod": [
|
|
46
|
+
{ "id": "...#nostr-1", "type": "Multikey", "controller": "...#me",
|
|
47
|
+
"publicKeyMultibase": "fe70102..." },
|
|
48
|
+
{ "id": "...#did-key-1", "type": "Multikey", "controller": "...#me",
|
|
49
|
+
"publicKeyMultibase": "z6MkpT..." },
|
|
50
|
+
{ "id": "...#passkey-1", "type": "JsonWebKey", "controller": "...#me",
|
|
51
|
+
"publicKeyJwk": { "kty": "EC", "crv": "P-256", "x": "...", "y": "..." } }
|
|
52
|
+
],
|
|
53
|
+
"authentication": ["...#nostr-1", "...#did-key-1", "...#passkey-1"]
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Because Phase A already declared the context terms, this is a pure data-layer PATCH — no `@context` rewrite needed.
|
|
57
|
+
|
|
58
|
+
## What Phase 3 will add (server-side verifier)
|
|
59
|
+
|
|
60
|
+
When an incoming request carries an LWS-CID JWT, JSS will:
|
|
61
|
+
|
|
62
|
+
1. Confirm `sub`/`iss`/`client_id` are the same URI (the caller's WebID)
|
|
63
|
+
2. Dereference the WebID, parse it as a CID document
|
|
64
|
+
3. Look up `kid` in the document's `verificationMethod` array
|
|
65
|
+
4. Confirm the method is in `authentication`
|
|
66
|
+
5. Verify the JWT signature with that public key
|
|
67
|
+
|
|
68
|
+
The verifier joins the existing auth methods (OIDC, NIP-98, etc.) — preference ordering tracked in [#306](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/306).
|
|
69
|
+
|
|
70
|
+
## Spec references
|
|
71
|
+
|
|
72
|
+
- [W3C CID v1.0 — Controlled Identifiers](https://www.w3.org/TR/cid-1.0/)
|
|
73
|
+
- [LWS 1.0 SSI via CID (FPWD 2026-04-23)](https://www.w3.org/TR/2026/WD-lws10-authn-ssi-cid-20260423/)
|
|
74
|
+
- [LWS 1.0 SSI via did:key (FPWD 2026-04-23)](https://www.w3.org/TR/2026/WD-lws10-authn-ssi-did-key-20260423/)
|
|
75
|
+
- [W3C announcement](https://www.w3.org/news/2026/first-public-working-drafts-for-the-linked-web-storage-lws-1-0-authentication-suite/)
|
|
76
|
+
|
|
77
|
+
## Related
|
|
78
|
+
|
|
79
|
+
- [`docs/authentication.md`](authentication.md) — current JSS auth surface (OIDC, NIP-98, passkey, etc.)
|
|
80
|
+
- [`docs/nostr.md`](nostr.md) — Nostr relay + did:nostr resolution
|
|
81
|
+
- [#386](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/386) — convergence tracker
|
|
82
|
+
- [#388](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/pull/388) — Phase A PR
|
|
83
|
+
- [#389](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/389) — `@context` array form support (turtle conneg)
|
|
84
|
+
- [#390](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/390) — `@type:'@json'` literal handling (turtle conneg)
|
package/package.json
CHANGED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared CID-document / WebID-profile fetcher.
|
|
3
|
+
*
|
|
4
|
+
* Used by both the LWS10-CID JWT verifier (`src/auth/lws-cid.js`) and
|
|
5
|
+
* the NIP-98 → WebID VM-lookup path (`src/auth/nostr.js`). Centralized
|
|
6
|
+
* so future SSRF / redirect / DoS hardening only needs to land in one
|
|
7
|
+
* place.
|
|
8
|
+
*
|
|
9
|
+
* Defenses (mirrored from the original lws-cid implementation):
|
|
10
|
+
*
|
|
11
|
+
* - URL validated through `validateExternalUrl` (loopback, private
|
|
12
|
+
* IPs, http-in-prod blocked) on the original URL AND every
|
|
13
|
+
* redirect Location.
|
|
14
|
+
* - Manual redirect handling — no automatic following — capped at
|
|
15
|
+
* MAX_REDIRECTS hops.
|
|
16
|
+
* - Cross-origin redirects refused (an open redirect on the WebID's
|
|
17
|
+
* host can't substitute an attacker-controlled CID document).
|
|
18
|
+
* - Body size cap enforced via Content-Length up front AND a
|
|
19
|
+
* streaming reader cap (cancel on overage), so untrusted hosts
|
|
20
|
+
* can't OOM us with a large payload.
|
|
21
|
+
* - 5-second timeout per request via AbortController.
|
|
22
|
+
*
|
|
23
|
+
* Throws on any failure; callers convert to whatever they want
|
|
24
|
+
* (LWS-CID surfaces the error string, the NIP-98 path treats
|
|
25
|
+
* throw-as-null and falls back to the existing did:nostr resolver).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { validateExternalUrl } from '../utils/ssrf.js';
|
|
29
|
+
|
|
30
|
+
// Default body cap (256 KB) — CID documents are tiny in practice.
|
|
31
|
+
const DEFAULT_MAX_BYTES = 256 * 1024;
|
|
32
|
+
const MAX_REDIRECTS = 5;
|
|
33
|
+
const FETCH_TIMEOUT_MS = 5000;
|
|
34
|
+
|
|
35
|
+
// Auth is on the hot path; refetching the CID document on every
|
|
36
|
+
// request is unacceptable for both latency and reliability (and would
|
|
37
|
+
// amplify self-traffic when the profile is hosted on this same
|
|
38
|
+
// server). Bounded LRU — an attacker could otherwise grow the cache
|
|
39
|
+
// without limit by sending tokens / requests with many distinct
|
|
40
|
+
// document URLs. Mirrors the pattern in did-nostr.js.
|
|
41
|
+
const profileCache = new Map(); // url -> { profile, timestamp, failureTtl?, error? }
|
|
42
|
+
const PROFILE_CACHE_TTL = 5 * 60 * 1000; // 5 minutes for hits
|
|
43
|
+
const PROFILE_FAILURE_TTL = 60 * 1000; // 1 minute for misses
|
|
44
|
+
const PROFILE_CACHE_MAX = 1000;
|
|
45
|
+
|
|
46
|
+
/** @internal — exposed for tests */
|
|
47
|
+
export function _clearProfileCacheForTests() {
|
|
48
|
+
profileCache.clear();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Fetch and parse a JSON CID document with SSRF, redirect, and
|
|
53
|
+
* body-size protections — and a bounded TTL cache so auth-path callers
|
|
54
|
+
* don't refetch on every request.
|
|
55
|
+
*
|
|
56
|
+
* Content-Type expectation: the response must declare a JSON-bearing
|
|
57
|
+
* Content-Type (matching `/json/`, e.g. `application/ld+json`,
|
|
58
|
+
* `application/json`, `application/json+ld`). A missing or non-JSON
|
|
59
|
+
* Content-Type rejects with a clear error rather than silently
|
|
60
|
+
* attempting JSON.parse — this caught real misconfigurations during
|
|
61
|
+
* #398 review where Turtle / HTML error pages were being served at
|
|
62
|
+
* profile URLs. Deployments serving WebID profiles should make sure
|
|
63
|
+
* their host returns the correct Content-Type for the CID document.
|
|
64
|
+
*
|
|
65
|
+
* Cache contract: keyed by `docUrl` only. All callers MUST pass
|
|
66
|
+
* consistent `opts` (in practice today everyone passes
|
|
67
|
+
* maxBytes = 256 KB). If a future caller needs a stricter limit, the
|
|
68
|
+
* cache key needs to incorporate it (or that caller needs its own
|
|
69
|
+
* cache) — otherwise a permissive entry would be returned to a strict
|
|
70
|
+
* caller and the cap wouldn't be re-enforced.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} docUrl - URL to fetch (untrusted — comes from JWT
|
|
73
|
+
* claims or is derived from a request).
|
|
74
|
+
* @param {object} [opts]
|
|
75
|
+
* @param {number} [opts.maxBytes=DEFAULT_MAX_BYTES]
|
|
76
|
+
* @returns {Promise<object>} parsed JSON
|
|
77
|
+
* @throws on any validation, network, redirect, size, or parse failure
|
|
78
|
+
*/
|
|
79
|
+
export async function fetchCidDocument(docUrl, opts = {}) {
|
|
80
|
+
// Cache hit (or recent failure) — return immediately. On hit we
|
|
81
|
+
// delete-then-set so this entry moves to the tail of the Map's
|
|
82
|
+
// insertion order, giving LRU eviction without a separate structure.
|
|
83
|
+
const cached = profileCache.get(docUrl);
|
|
84
|
+
if (cached) {
|
|
85
|
+
const ttl = cached.failureTtl ? PROFILE_FAILURE_TTL : PROFILE_CACHE_TTL;
|
|
86
|
+
if (Date.now() - cached.timestamp < ttl) {
|
|
87
|
+
profileCache.delete(docUrl);
|
|
88
|
+
profileCache.set(docUrl, cached);
|
|
89
|
+
if (cached.failureTtl) throw new Error(cached.error);
|
|
90
|
+
return cached.profile;
|
|
91
|
+
}
|
|
92
|
+
profileCache.delete(docUrl);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
const profile = await fetchCidDocumentNoCache(docUrl, opts);
|
|
97
|
+
setCached(docUrl, { profile, timestamp: Date.now() });
|
|
98
|
+
return profile;
|
|
99
|
+
} catch (err) {
|
|
100
|
+
setCached(docUrl, {
|
|
101
|
+
timestamp: Date.now(),
|
|
102
|
+
failureTtl: true,
|
|
103
|
+
error: err.message,
|
|
104
|
+
});
|
|
105
|
+
throw err;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Insert into the bounded LRU; evict the oldest entry past the cap. */
|
|
110
|
+
function setCached(url, entry) {
|
|
111
|
+
profileCache.set(url, entry);
|
|
112
|
+
while (profileCache.size > PROFILE_CACHE_MAX) {
|
|
113
|
+
const oldest = profileCache.keys().next().value;
|
|
114
|
+
if (oldest === undefined) break;
|
|
115
|
+
profileCache.delete(oldest);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function fetchCidDocumentNoCache(docUrl, opts = {}) {
|
|
120
|
+
const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
121
|
+
const originalOrigin = new URL(docUrl).origin;
|
|
122
|
+
let currentUrl = docUrl;
|
|
123
|
+
|
|
124
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
|
125
|
+
const isLastAllowedHop = hop === MAX_REDIRECTS;
|
|
126
|
+
const validation = await validateExternalUrl(currentUrl, {
|
|
127
|
+
requireHttps: process.env.NODE_ENV === 'production',
|
|
128
|
+
blockPrivateIPs: true,
|
|
129
|
+
resolveDNS: true,
|
|
130
|
+
});
|
|
131
|
+
if (!validation.valid) {
|
|
132
|
+
throw new Error(`SSRF protection: ${validation.error}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const controller = new AbortController();
|
|
136
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
137
|
+
let res;
|
|
138
|
+
try {
|
|
139
|
+
res = await fetch(currentUrl, {
|
|
140
|
+
headers: { Accept: 'application/ld+json, application/json;q=0.9' },
|
|
141
|
+
redirect: 'manual',
|
|
142
|
+
signal: controller.signal,
|
|
143
|
+
});
|
|
144
|
+
} finally {
|
|
145
|
+
clearTimeout(timer);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (res.status >= 300 && res.status < 400) {
|
|
149
|
+
if (isLastAllowedHop) {
|
|
150
|
+
throw new Error(`too many redirects (>${MAX_REDIRECTS})`);
|
|
151
|
+
}
|
|
152
|
+
const loc = res.headers.get('location');
|
|
153
|
+
if (!loc) throw new Error(`redirect ${res.status} without Location`);
|
|
154
|
+
const nextUrl = new URL(loc, currentUrl).toString();
|
|
155
|
+
const nextOrigin = new URL(nextUrl).origin;
|
|
156
|
+
if (nextOrigin !== originalOrigin) {
|
|
157
|
+
throw new Error(`cross-origin redirect refused: ${originalOrigin} → ${nextOrigin}`);
|
|
158
|
+
}
|
|
159
|
+
currentUrl = nextUrl;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
164
|
+
|
|
165
|
+
const ct = (res.headers.get('content-type') || '').toLowerCase();
|
|
166
|
+
if (!ct.includes('json')) {
|
|
167
|
+
throw new Error(`unexpected content-type: ${ct || '(none)'}`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const declared = Number(res.headers.get('content-length'));
|
|
171
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
172
|
+
throw new Error(`CID document too large (Content-Length=${declared} > ${maxBytes})`);
|
|
173
|
+
}
|
|
174
|
+
const text = await readBodyWithCap(res, maxBytes);
|
|
175
|
+
return JSON.parse(text);
|
|
176
|
+
}
|
|
177
|
+
throw new Error('profile fetch loop exited unexpectedly');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function readBodyWithCap(res, maxBytes) {
|
|
181
|
+
const reader = res.body?.getReader?.();
|
|
182
|
+
if (!reader) {
|
|
183
|
+
const text = await res.text();
|
|
184
|
+
if (Buffer.byteLength(text, 'utf8') > maxBytes) {
|
|
185
|
+
throw new Error(`CID document too large (>${maxBytes} bytes)`);
|
|
186
|
+
}
|
|
187
|
+
return text;
|
|
188
|
+
}
|
|
189
|
+
const chunks = [];
|
|
190
|
+
let total = 0;
|
|
191
|
+
for (;;) {
|
|
192
|
+
const { value, done } = await reader.read();
|
|
193
|
+
if (done) break;
|
|
194
|
+
total += value.byteLength;
|
|
195
|
+
if (total > maxBytes) {
|
|
196
|
+
try { await reader.cancel(); } catch { /* noop */ }
|
|
197
|
+
throw new Error(`CID document too large (>${maxBytes} bytes)`);
|
|
198
|
+
}
|
|
199
|
+
chunks.push(value);
|
|
200
|
+
}
|
|
201
|
+
return Buffer.concat(chunks.map((c) => Buffer.from(c.buffer, c.byteOffset, c.byteLength))).toString('utf8');
|
|
202
|
+
}
|