javascript-solid-server 0.0.73 → 0.0.76

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.
@@ -228,7 +228,35 @@
228
228
  "Bash(git pull:*)",
229
229
  "Bash(gh pr:*)",
230
230
  "Bash(node --test:*)",
231
- "Bash(TOKEN_SECRET=test node --test:*)"
231
+ "Bash(TOKEN_SECRET=test node --test:*)",
232
+ "Bash(gh search issues:*)",
233
+ "Bash(timeout 60 bash:*)",
234
+ "Bash(timeout 90 bash -c:*)",
235
+ "Bash(git commit -m \"$\\(cat <<''EOF''\nsecurity: add ACL check on WebSocket subscription requests\n\nCheck WAC read permission before allowing subscription to prevent\ninformation leakage via notifications. Unauthorized subscriptions\nnow receive ''err <url> forbidden'' response.\n\nSecurity improvements:\n- Check ACL read access before allowing subscription\n- Validate URLs are on this server \\(prevents SSRF-like probing\\)\n- Add subscription limit and URL length validation\n\nFixes #62\nEOF\n\\)\")",
236
+ "Bash(gh repo fork:*)",
237
+ "Bash(timeout 180 npm test:*)",
238
+ "Bash(git show:*)",
239
+ "Bash(git -C /home/melvin/remote/github.com/nodeSolidServer/node-solid-server log --oneline -30 -- \"test/integration/acl-tls-test.mjs\" \"test-esm/integration/acl-tls-test.js\")",
240
+ "Bash(git -C /home/melvin/remote/github.com/nodeSolidServer/node-solid-server log --oneline --follow -30 -- \"test/integration/acl-tls-test.mjs\")",
241
+ "Bash(git -C /home/melvin/remote/github.com/nodeSolidServer/node-solid-server show 778095ad --stat)",
242
+ "Bash(git -C /home/melvin/remote/github.com/nodeSolidServer/node-solid-server show 778095ad)",
243
+ "Bash(git -C /home/melvin/remote/github.com/nodeSolidServer/node-solid-server show b183c7a0)",
244
+ "Bash(git -C /home/melvin/remote/github.com/nodeSolidServer/node-solid-server log --oneline --before=\"2019-10-29\" --after=\"2019-10-01\" -20)",
245
+ "Bash(git -C /home/melvin/remote/github.com/nodeSolidServer/node-solid-server show 1a92a912 --stat)",
246
+ "Bash(git -C /home/melvin/remote/github.com/nodeSolidServer/node-solid-server log --oneline --all --grep=jaxoncreed)",
247
+ "Bash(git -C /home/melvin/remote/github.com/nodeSolidServer/node-solid-server log --oneline --author=\"jaxoncreed\" -30)",
248
+ "Bash(git -C /home/melvin/remote/github.com/nodeSolidServer/node-solid-server log --oneline --author=\"[Dd]mitri\" -20)",
249
+ "Bash(git -C /home/melvin/remote/github.com/nodeSolidServer/node-solid-server log --oneline --all --grep=\"oidc\" -20)",
250
+ "Bash(npm install)",
251
+ "Bash(timeout 60 npx mocha:*)",
252
+ "Bash(timeout 120 npx mocha:*)",
253
+ "Bash(timeout 30 npx mocha:*)",
254
+ "Bash(openssl x509:*)",
255
+ "Bash(gh pr checks:*)",
256
+ "Bash(gh run view:*)",
257
+ "Bash(gh pr edit:*)",
258
+ "WebFetch(domain:patch-diff.githubusercontent.com)",
259
+ "Bash(git rebase:*)"
232
260
  ]
233
261
  }
234
262
  }
package/README.md CHANGED
@@ -6,7 +6,7 @@ A minimal, fast, JSON-LD native Solid server.
6
6
 
7
7
  ## Features
8
8
 
9
- ### Implemented (v0.0.61)
9
+ ### Implemented (v0.0.75)
10
10
 
11
11
  - **ActivityPub Federation** - Fediverse integration with WebFinger, inbox/outbox, HTTP signatures
12
12
  - **LDP CRUD Operations** - GET, PUT, POST, DELETE, HEAD
@@ -26,6 +26,7 @@ A minimal, fast, JSON-LD native Solid server.
26
26
  - **Solid-OIDC Resource Server** - Accept DPoP-bound access tokens from external IdPs
27
27
  - **NSS-style Registration** - Username/password auth compatible with Solid apps
28
28
  - **Nostr Authentication** - NIP-98 HTTP Auth with Schnorr signatures, did:nostr → WebID resolution
29
+ - **WebID-TLS** - Client certificate authentication for backend services and CLI tools
29
30
  - **Simple Auth Tokens** - Built-in token authentication for development
30
31
  - **Content Negotiation** - Turtle <-> JSON-LD conversion, including HTML data islands
31
32
  - **CORS Support** - Full cross-origin resource sharing
@@ -121,11 +122,13 @@ jss --help # Show help
121
122
  | `--mashlib` | Enable Mashlib (local mode) | false |
122
123
  | `--mashlib-cdn` | Enable Mashlib (CDN mode) | false |
123
124
  | `--mashlib-version <ver>` | Mashlib CDN version | 2.0.0 |
125
+ | `--solidos-ui` | Enable modern SolidOS UI (requires --mashlib) | false |
124
126
  | `--git` | Enable Git HTTP backend | false |
125
127
  | `--nostr` | Enable Nostr relay | false |
126
128
  | `--nostr-path <path>` | Nostr relay WebSocket path | /relay |
127
129
  | `--nostr-max-events <n>` | Max events in relay memory | 1000 |
128
130
  | `--invite-only` | Require invite code for registration | false |
131
+ | `--webid-tls` | Enable WebID-TLS client certificate auth | false |
129
132
  | `--default-quota <size>` | Default storage quota per pod (e.g., 50MB) | 50MB |
130
133
  | `--activitypub` | Enable ActivityPub federation | false |
131
134
  | `--ap-username <name>` | ActivityPub username | me |
@@ -148,6 +151,7 @@ export JSS_BASE_DOMAIN=example.com
148
151
  export JSS_MASHLIB=true
149
152
  export JSS_NOSTR=true
150
153
  export JSS_INVITE_ONLY=true
154
+ export JSS_WEBID_TLS=true
151
155
  export JSS_DEFAULT_QUOTA=100MB
152
156
  export JSS_ACTIVITYPUB=true
153
157
  export JSS_AP_USERNAME=alice
@@ -330,6 +334,18 @@ npm install && npm run build
330
334
 
331
335
  **Note:** Mashlib works best with `--conneg` enabled for Turtle support.
332
336
 
337
+ **Modern UI (SolidOS UI):**
338
+ ```bash
339
+ jss start --mashlib --solidos-ui --conneg
340
+ ```
341
+ Serves a modern Nextcloud-style UI shell while reusing mashlib's data layer. The `--solidos-ui` flag swaps the classic databrowser interface for a cleaner, mobile-friendly design with:
342
+ - Modern file browser with breadcrumb navigation
343
+ - Profile, Contacts, Sharing, and Settings views
344
+ - Path-based URLs (browser URL reflects current resource)
345
+ - Responsive design for mobile devices
346
+
347
+ Requires solidos-ui dist files in `src/mashlib-local/dist/solidos-ui/`. See [solidos-ui](https://github.com/solidos/solidos/tree/main/workspaces/solidos-ui) for details.
348
+
333
349
  ### Profile Pages
334
350
 
335
351
  Pod profiles (`/alice/`) use HTML with embedded JSON-LD data islands and are rendered using:
@@ -664,6 +680,49 @@ curl -H "Authorization: DPoP ACCESS_TOKEN" \
664
680
  http://localhost:3000/alice/private/
665
681
  ```
666
682
 
683
+ ### WebID-TLS (Client Certificates)
684
+
685
+ For backend services, CLI tools, and automated agents that need non-interactive authentication:
686
+
687
+ ```bash
688
+ jss start --ssl-key key.pem --ssl-cert cert.pem --webid-tls
689
+ ```
690
+
691
+ **How it works:**
692
+ 1. Client presents X.509 certificate during TLS handshake
693
+ 2. Certificate's `SubjectAlternativeName` contains a WebID URI
694
+ 3. Server fetches the WebID profile
695
+ 4. Server verifies the certificate's public key matches one in the profile
696
+
697
+ **Testing with curl:**
698
+
699
+ ```bash
700
+ # Generate self-signed cert with WebID in SAN
701
+ openssl req -x509 -newkey rsa:2048 -keyout client-key.pem -out client-cert.pem -days 365 \
702
+ -subj "/CN=Test" -addext "subjectAltName=URI:https://example.com/alice/#me" -nodes
703
+
704
+ # Make authenticated request
705
+ curl --cert client-cert.pem --key client-key.pem https://localhost:8443/alice/private/
706
+ ```
707
+
708
+ **Profile requirement:** Your WebID profile must contain the certificate's public key:
709
+
710
+ ```turtle
711
+ @prefix cert: <http://www.w3.org/ns/auth/cert#> .
712
+
713
+ <#me> cert:key [
714
+ a cert:RSAPublicKey;
715
+ cert:modulus "abc123..."^^xsd:hexBinary;
716
+ cert:exponent 65537
717
+ ] .
718
+ ```
719
+
720
+ **Use cases:**
721
+ - Enterprise backend services with existing PKI
722
+ - Server-to-server communication
723
+ - CLI tools and scripts
724
+ - IoT devices with embedded certificates
725
+
667
726
  ## Pod Structure
668
727
 
669
728
  ```
@@ -810,7 +869,7 @@ npm run benchmark
810
869
  npm test
811
870
  ```
812
871
 
813
- Currently passing: **191 tests** (including 27 conformance tests)
872
+ Currently passing: **213 tests** (including 27 conformance tests)
814
873
 
815
874
  ### Conformance Test Harness (CTH)
816
875
 
@@ -861,7 +920,8 @@ src/
861
920
  │ ├── token.js # Simple token auth
862
921
  │ ├── solid-oidc.js # DPoP verification
863
922
  │ ├── nostr.js # NIP-98 Nostr authentication
864
- └── did-nostr.js # did:nostr → WebID resolution
923
+ ├── did-nostr.js # did:nostr → WebID resolution
924
+ │ └── webid-tls.js # WebID-TLS client certificate auth
865
925
  ├── wac/
866
926
  │ ├── parser.js # ACL parsing
867
927
  │ └── checker.js # Permission checking
package/bin/jss.js CHANGED
@@ -57,6 +57,7 @@ program
57
57
  .option('--mashlib-cdn', 'Enable Mashlib data browser (CDN mode, no local files needed)')
58
58
  .option('--no-mashlib', 'Disable Mashlib data browser')
59
59
  .option('--mashlib-version <version>', 'Mashlib version for CDN mode (default: 2.0.0)')
60
+ .option('--solidos-ui', 'Enable modern Nextcloud-style UI (requires --mashlib)')
60
61
  .option('--git', 'Enable Git HTTP backend (clone/push support)')
61
62
  .option('--no-git', 'Disable Git HTTP backend')
62
63
  .option('--nostr', 'Enable Nostr relay')
@@ -71,6 +72,8 @@ program
71
72
  .option('--ap-nostr-pubkey <hex>', 'Nostr pubkey for identity linking')
72
73
  .option('--invite-only', 'Require invite code for registration')
73
74
  .option('--no-invite-only', 'Allow open registration')
75
+ .option('--webid-tls', 'Enable WebID-TLS client certificate authentication')
76
+ .option('--no-webid-tls', 'Disable WebID-TLS authentication')
74
77
  .option('-q, --quiet', 'Suppress log output')
75
78
  .option('--print-config', 'Print configuration and exit')
76
79
  .action(async (options) => {
@@ -112,6 +115,7 @@ program
112
115
  mashlib: config.mashlib || config.mashlibCdn,
113
116
  mashlibCdn: config.mashlibCdn,
114
117
  mashlibVersion: config.mashlibVersion,
118
+ solidosUi: config.solidosUi,
115
119
  git: config.git,
116
120
  nostr: config.nostr,
117
121
  nostrPath: config.nostrPath,
@@ -122,6 +126,7 @@ program
122
126
  apSummary: config.apSummary,
123
127
  apNostrPubkey: config.apNostrPubkey,
124
128
  inviteOnly: config.inviteOnly,
129
+ webidTls: config.webidTls,
125
130
  });
126
131
 
127
132
  await server.listen({ port: config.port, host: config.host });
@@ -140,10 +145,12 @@ program
140
145
  } else if (config.mashlib) {
141
146
  console.log(` Mashlib: local (data browser enabled)`);
142
147
  }
148
+ if (config.solidosUi) console.log(' SolidOS UI: enabled (modern interface)');
143
149
  if (config.git) console.log(' Git: enabled (clone/push support)');
144
150
  if (config.nostr) console.log(` Nostr: enabled (${config.nostrPath})`);
145
151
  if (config.activitypub) console.log(` ActivityPub: enabled (@${config.apUsername || 'me'})`);
146
152
  if (config.inviteOnly) console.log(' Registration: invite-only');
153
+ if (config.webidTls) console.log(' WebID-TLS: enabled (client certificate auth)');
147
154
  console.log('\n Press Ctrl+C to stop\n');
148
155
  }
149
156
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javascript-solid-server",
3
- "version": "0.0.73",
3
+ "version": "0.0.76",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -9,7 +9,7 @@ import { checkAccess, getRequiredMode } from '../wac/checker.js';
9
9
  import { AccessMode } from '../wac/parser.js';
10
10
  import * as storage from '../storage/filesystem.js';
11
11
  import { getEffectiveUrlPath } from '../utils/url.js';
12
- import { generateDatabrowserHtml } from '../mashlib/index.js';
12
+ import { generateDatabrowserHtml, generateSolidosUiHtml } from '../mashlib/index.js';
13
13
 
14
14
  /**
15
15
  * Check if request is authorized
@@ -117,8 +117,11 @@ export function handleUnauthorized(request, reply, isAuthenticated, wacAllow, au
117
117
  // If mashlib is enabled, serve mashlib instead of static error page
118
118
  // Mashlib has built-in login functionality via panes.runDataBrowser()
119
119
  if (request.mashlibEnabled) {
120
- const cdnVersion = request.mashlibCdn ? request.mashlibVersion : null;
121
- return reply.code(statusCode).type('text/html').send(generateDatabrowserHtml(request.url, cdnVersion));
120
+ // Use SolidOS UI if enabled, otherwise fallback to classic mashlib
121
+ const html = request.solidosUiEnabled
122
+ ? generateSolidosUiHtml()
123
+ : generateDatabrowserHtml(request.url, request.mashlibCdn ? request.mashlibVersion : null);
124
+ return reply.code(statusCode).type('text/html').send(html);
122
125
  }
123
126
  return reply.code(statusCode).type('text/html').send(getErrorPage(statusCode, isAuthenticated, request));
124
127
  }
@@ -56,8 +56,8 @@ function cleanupJtiCache() {
56
56
  }
57
57
  }
58
58
 
59
- // Start periodic cleanup
60
- setInterval(cleanupJtiCache, JTI_CACHE_CLEANUP_INTERVAL);
59
+ // Start periodic cleanup (unref so it doesn't keep process alive during tests)
60
+ setInterval(cleanupJtiCache, JTI_CACHE_CLEANUP_INTERVAL).unref();
61
61
 
62
62
  /**
63
63
  * Check if a jti has been used (replay attack prevention)
package/src/auth/token.js CHANGED
@@ -10,6 +10,7 @@
10
10
  import crypto from 'crypto';
11
11
  import { verifySolidOidc, hasSolidOidcAuth } from './solid-oidc.js';
12
12
  import { verifyNostrAuth, hasNostrAuth } from './nostr.js';
13
+ import { webIdTlsAuth, hasClientCertificate } from './webid-tls.js';
13
14
 
14
15
  // Secret for signing tokens
15
16
  // SECURITY: In production, TOKEN_SECRET must be set via environment variable
@@ -214,41 +215,55 @@ export function getWebIdFromRequest(request) {
214
215
  export async function getWebIdFromRequestAsync(request) {
215
216
  const authHeader = request.headers.authorization;
216
217
 
217
- if (!authHeader) {
218
- return { webId: null, error: null };
219
- }
220
-
221
- // Try Solid-OIDC first (DPoP tokens)
222
- if (hasSolidOidcAuth(request)) {
223
- return verifySolidOidc(request);
224
- }
225
-
226
- // Try Nostr NIP-98 (Schnorr signatures)
227
- if (hasNostrAuth(request)) {
228
- return verifyNostrAuth(request);
229
- }
218
+ // Try Authorization header methods first
219
+ if (authHeader) {
220
+ // Try Solid-OIDC first (DPoP tokens)
221
+ if (hasSolidOidcAuth(request)) {
222
+ return verifySolidOidc(request);
223
+ }
230
224
 
231
- // Fall back to Bearer tokens
232
- const token = extractToken(authHeader);
233
- if (!token) {
234
- return { webId: null, error: null };
235
- }
225
+ // Try Nostr NIP-98 (Schnorr signatures)
226
+ if (hasNostrAuth(request)) {
227
+ return verifyNostrAuth(request);
228
+ }
236
229
 
237
- // Try simple 2-part token first
238
- const payload = verifyToken(token);
239
- if (payload?.webId) {
240
- return { webId: payload.webId, error: null };
230
+ // Fall back to Bearer tokens
231
+ const token = extractToken(authHeader);
232
+ if (token) {
233
+ // Try simple 2-part token first
234
+ const payload = verifyToken(token);
235
+ if (payload?.webId) {
236
+ return { webId: payload.webId, error: null };
237
+ }
238
+
239
+ // If 3-part JWT, verify against IdP's JWKS
240
+ const parts = token.split('.');
241
+ if (parts.length === 3) {
242
+ const jwtPayload = await verifyJwtFromIdp(token);
243
+ if (jwtPayload?.webId) {
244
+ return { webId: jwtPayload.webId, error: null };
245
+ }
246
+ return { webId: null, error: 'Invalid or unverifiable JWT token' };
247
+ }
248
+
249
+ return { webId: null, error: 'Invalid token' };
250
+ }
241
251
  }
242
252
 
243
- // If 3-part JWT, verify against IdP's JWKS
244
- const parts = token.split('.');
245
- if (parts.length === 3) {
246
- const jwtPayload = await verifyJwtFromIdp(token);
247
- if (jwtPayload?.webId) {
248
- return { webId: jwtPayload.webId, error: null };
253
+ // Try WebID-TLS (client certificate authentication)
254
+ // This works even without Authorization header
255
+ if (hasClientCertificate(request)) {
256
+ try {
257
+ const webId = await webIdTlsAuth(request);
258
+ if (webId) {
259
+ return { webId, error: null };
260
+ }
261
+ // Certificate present but verification failed
262
+ return { webId: null, error: 'WebID-TLS certificate verification failed' };
263
+ } catch (err) {
264
+ return { webId: null, error: `WebID-TLS error: ${err.message}` };
249
265
  }
250
- return { webId: null, error: 'Invalid or unverifiable JWT token' };
251
266
  }
252
267
 
253
- return { webId: null, error: 'Invalid token' };
268
+ return { webId: null, error: null };
254
269
  }
@@ -0,0 +1,270 @@
1
+ /**
2
+ * WebID-TLS Authentication
3
+ *
4
+ * Authenticates clients via TLS client certificates.
5
+ * The certificate's SubjectAlternativeName (SAN) contains a WebID URI.
6
+ * The server fetches the WebID profile and verifies the certificate's
7
+ * public key matches one published in the profile.
8
+ *
9
+ * References:
10
+ * - https://dvcs.w3.org/hg/WebID/raw-file/tip/spec/tls-respec.html
11
+ * - https://www.w3.org/ns/auth/cert#
12
+ */
13
+
14
+ import { turtleToJsonLd } from '../rdf/turtle.js';
15
+
16
+ // cert: ontology namespace
17
+ const CERT_NS = 'http://www.w3.org/ns/auth/cert#';
18
+
19
+ // Cache for verified WebIDs (reduces profile fetches)
20
+ const cache = new Map();
21
+ const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
22
+
23
+ /**
24
+ * Fetch with timeout
25
+ */
26
+ async function fetchWithTimeout(url, options = {}, timeout = 5000) {
27
+ const controller = new AbortController();
28
+ const id = setTimeout(() => controller.abort(), timeout);
29
+ try {
30
+ const response = await fetch(url, { ...options, signal: controller.signal });
31
+ clearTimeout(id);
32
+ return response;
33
+ } catch (err) {
34
+ clearTimeout(id);
35
+ throw err;
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Extract WebID URI from certificate's SubjectAlternativeName
41
+ * @param {string} subjectaltname - Certificate's SAN field
42
+ * @returns {string|null} WebID URI or null
43
+ */
44
+ export function extractWebIdFromSAN(subjectaltname) {
45
+ if (!subjectaltname) return null;
46
+
47
+ // SAN format: "URI:https://alice.example/card#me, DNS:example.com"
48
+ const match = subjectaltname.match(/URI:([^,\s]+)/);
49
+ return match ? match[1] : null;
50
+ }
51
+
52
+ /**
53
+ * Parse certificate keys from WebID profile (JSON-LD format)
54
+ * Handles both inline objects and arrays
55
+ * @param {object|Array} jsonLd - Parsed JSON-LD profile
56
+ * @param {string} webId - The WebID to find keys for
57
+ * @returns {Array<{modulus: string, exponent: string}>} Array of keys
58
+ */
59
+ function extractCertKeys(jsonLd, webId) {
60
+ const keys = [];
61
+
62
+ // Normalize to array
63
+ const nodes = Array.isArray(jsonLd) ? jsonLd : [jsonLd];
64
+
65
+ for (const node of nodes) {
66
+ // Check if this node is the WebID subject
67
+ const nodeId = node['@id'];
68
+ if (nodeId && !nodeId.endsWith('#me') && nodeId !== webId) {
69
+ continue;
70
+ }
71
+
72
+ // Look for cert:key property (various forms)
73
+ const keyProps = [
74
+ node['cert:key'],
75
+ node[CERT_NS + 'key'],
76
+ node['http://www.w3.org/ns/auth/cert#key']
77
+ ];
78
+
79
+ for (const keyProp of keyProps) {
80
+ if (!keyProp) continue;
81
+
82
+ const keyValues = Array.isArray(keyProp) ? keyProp : [keyProp];
83
+ for (const keyValue of keyValues) {
84
+ const key = parseKeyObject(keyValue);
85
+ if (key) {
86
+ keys.push(key);
87
+ }
88
+ }
89
+ }
90
+ }
91
+
92
+ return keys;
93
+ }
94
+
95
+ /**
96
+ * Parse a single key object from JSON-LD
97
+ * @param {object} keyObj - Key object (may be nested or have @id)
98
+ * @returns {{modulus: string, exponent: string}|null}
99
+ */
100
+ function parseKeyObject(keyObj) {
101
+ if (!keyObj || typeof keyObj !== 'object') return null;
102
+
103
+ // Extract modulus (various forms)
104
+ let modulus = keyObj['cert:modulus'] ||
105
+ keyObj[CERT_NS + 'modulus'] ||
106
+ keyObj['http://www.w3.org/ns/auth/cert#modulus'];
107
+
108
+ // Extract exponent (various forms)
109
+ let exponent = keyObj['cert:exponent'] ||
110
+ keyObj[CERT_NS + 'exponent'] ||
111
+ keyObj['http://www.w3.org/ns/auth/cert#exponent'];
112
+
113
+ // Handle @value wrapper
114
+ if (modulus && typeof modulus === 'object' && modulus['@value']) {
115
+ modulus = modulus['@value'];
116
+ }
117
+ if (exponent && typeof exponent === 'object' && exponent['@value']) {
118
+ exponent = exponent['@value'];
119
+ }
120
+
121
+ // Convert exponent to string if number
122
+ if (typeof exponent === 'number') {
123
+ exponent = exponent.toString();
124
+ }
125
+
126
+ if (!modulus || !exponent) return null;
127
+
128
+ return {
129
+ modulus: String(modulus).toLowerCase().replace(/[\s:]/g, ''),
130
+ exponent: String(exponent)
131
+ };
132
+ }
133
+
134
+ /**
135
+ * Fetch and parse WebID profile
136
+ * @param {string} webId - WebID URI to fetch
137
+ * @returns {Promise<Array<{modulus: string, exponent: string}>>}
138
+ */
139
+ async function fetchProfileKeys(webId) {
140
+ const response = await fetchWithTimeout(webId, {
141
+ headers: {
142
+ 'Accept': 'application/ld+json, text/turtle, application/json'
143
+ }
144
+ });
145
+
146
+ if (!response.ok) {
147
+ throw new Error(`Failed to fetch WebID profile: ${response.status}`);
148
+ }
149
+
150
+ const contentType = response.headers.get('content-type') || '';
151
+ const text = await response.text();
152
+
153
+ let jsonLd;
154
+
155
+ if (contentType.includes('text/turtle') || contentType.includes('text/n3')) {
156
+ // Parse Turtle to JSON-LD
157
+ jsonLd = await turtleToJsonLd(text, webId);
158
+ } else if (contentType.includes('text/html')) {
159
+ // Try to extract JSON-LD from HTML data island
160
+ const jsonLdMatch = text.match(/<script\s+type=["']application\/ld\+json["']\s*>([\s\S]*?)<\/script>/i);
161
+ if (jsonLdMatch) {
162
+ jsonLd = JSON.parse(jsonLdMatch[1]);
163
+ } else {
164
+ throw new Error('No JSON-LD found in HTML profile');
165
+ }
166
+ } else {
167
+ // Assume JSON-LD
168
+ jsonLd = JSON.parse(text);
169
+ }
170
+
171
+ return extractCertKeys(jsonLd, webId);
172
+ }
173
+
174
+ /**
175
+ * Verify certificate against WebID profile
176
+ * @param {object} certificate - Node.js TLS certificate object
177
+ * @param {string} webId - WebID URI
178
+ * @returns {Promise<boolean>} True if certificate matches profile
179
+ */
180
+ export async function verifyWebIdTls(certificate, webId) {
181
+ if (!certificate.modulus || !certificate.exponent) {
182
+ throw new Error('Certificate missing modulus or exponent');
183
+ }
184
+
185
+ // Normalize certificate values
186
+ const certModulus = certificate.modulus.toLowerCase().replace(/[\s:]/g, '');
187
+ // Certificate exponent is hex, convert to decimal string
188
+ const certExponent = parseInt(certificate.exponent, 16).toString();
189
+
190
+ // Check cache
191
+ const cacheKey = `${webId}:${certModulus}`;
192
+ const cached = cache.get(cacheKey);
193
+ if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
194
+ return cached.verified;
195
+ }
196
+
197
+ try {
198
+ const profileKeys = await fetchProfileKeys(webId);
199
+
200
+ // Check if any key matches
201
+ const verified = profileKeys.some(key =>
202
+ key.modulus === certModulus && key.exponent === certExponent
203
+ );
204
+
205
+ cache.set(cacheKey, { verified, timestamp: Date.now() });
206
+ return verified;
207
+ } catch (err) {
208
+ console.error(`WebID-TLS verification error for ${webId}:`, err.message);
209
+ cache.set(cacheKey, { verified: false, timestamp: Date.now() });
210
+ return false;
211
+ }
212
+ }
213
+
214
+ /**
215
+ * WebID-TLS authentication middleware
216
+ * Extracts WebID from client certificate and verifies against profile
217
+ *
218
+ * @param {object} request - Fastify request object
219
+ * @returns {Promise<string|null>} WebID if verified, null otherwise
220
+ */
221
+ export async function webIdTlsAuth(request) {
222
+ // Get socket from request
223
+ const socket = request.raw?.socket || request.socket;
224
+
225
+ if (!socket?.getPeerCertificate) {
226
+ return null; // Not a TLS connection or no cert support
227
+ }
228
+
229
+ const cert = socket.getPeerCertificate();
230
+
231
+ // No certificate or empty certificate
232
+ if (!cert || Object.keys(cert).length === 0) {
233
+ return null;
234
+ }
235
+
236
+ // Extract WebID from SAN
237
+ const webId = extractWebIdFromSAN(cert.subjectaltname);
238
+ if (!webId) {
239
+ return null; // No WebID in certificate
240
+ }
241
+
242
+ // Only accept https:// WebIDs for now
243
+ if (!webId.startsWith('https://')) {
244
+ return null;
245
+ }
246
+
247
+ // Verify certificate against profile
248
+ const verified = await verifyWebIdTls(cert, webId);
249
+ return verified ? webId : null;
250
+ }
251
+
252
+ /**
253
+ * Check if request has a client certificate
254
+ * @param {object} request - Fastify request object
255
+ * @returns {boolean}
256
+ */
257
+ export function hasClientCertificate(request) {
258
+ const socket = request.raw?.socket || request.socket;
259
+ if (!socket?.getPeerCertificate) return false;
260
+
261
+ const cert = socket.getPeerCertificate();
262
+ return cert && Object.keys(cert).length > 0;
263
+ }
264
+
265
+ /**
266
+ * Clear verification cache (for testing)
267
+ */
268
+ export function clearCache() {
269
+ cache.clear();
270
+ }
package/src/config.js CHANGED
@@ -42,6 +42,9 @@ export const defaults = {
42
42
  mashlibCdn: false,
43
43
  mashlibVersion: '2.0.0',
44
44
 
45
+ // SolidOS UI (modern Nextcloud-style interface)
46
+ solidosUi: false,
47
+
45
48
  // Git HTTP backend
46
49
  git: false,
47
50
 
@@ -60,6 +63,9 @@ export const defaults = {
60
63
  // Invite-only registration
61
64
  inviteOnly: false,
62
65
 
66
+ // WebID-TLS client certificate authentication
67
+ webidTls: false,
68
+
63
69
  // Storage quota (bytes) - 50MB default
64
70
  defaultQuota: 50 * 1024 * 1024,
65
71
 
@@ -92,6 +98,7 @@ const envMap = {
92
98
  JSS_MASHLIB: 'mashlib',
93
99
  JSS_MASHLIB_CDN: 'mashlibCdn',
94
100
  JSS_MASHLIB_VERSION: 'mashlibVersion',
101
+ JSS_SOLIDOS_UI: 'solidosUi',
95
102
  JSS_GIT: 'git',
96
103
  JSS_NOSTR: 'nostr',
97
104
  JSS_NOSTR_PATH: 'nostrPath',
@@ -102,6 +109,7 @@ const envMap = {
102
109
  JSS_AP_SUMMARY: 'apSummary',
103
110
  JSS_AP_NOSTR_PUBKEY: 'apNostrPubkey',
104
111
  JSS_INVITE_ONLY: 'inviteOnly',
112
+ JSS_WEBID_TLS: 'webidTls',
105
113
  JSS_DEFAULT_QUOTA: 'defaultQuota',
106
114
  };
107
115
 
@@ -254,5 +262,6 @@ export function printConfig(config) {
254
262
  console.log(` IdP: ${config.idp ? (config.idpIssuer || 'enabled') : 'disabled'}`);
255
263
  console.log(` Subdomains: ${config.subdomains ? (config.baseDomain || 'enabled') : 'disabled'}`);
256
264
  console.log(` Mashlib: ${config.mashlibCdn ? `CDN v${config.mashlibVersion}` : config.mashlib ? 'local' : 'disabled'}`);
265
+ console.log(` SolidOS UI: ${config.solidosUi ? 'enabled' : 'disabled'}`);
257
266
  console.log('─'.repeat(40));
258
267
  }