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.
- package/.claude/settings.local.json +29 -1
- package/README.md +63 -3
- package/bin/jss.js +7 -0
- package/package.json +1 -1
- package/src/auth/middleware.js +6 -3
- package/src/auth/solid-oidc.js +2 -2
- package/src/auth/token.js +45 -30
- package/src/auth/webid-tls.js +270 -0
- package/src/config.js +9 -0
- package/src/handlers/resource.js +104 -6
- package/src/ldp/headers.js +3 -2
- package/src/mashlib/index.js +107 -0
- package/src/notifications/index.js +5 -2
- package/src/notifications/websocket.js +63 -3
- package/src/server.js +47 -1
- package/src/storage/filesystem.js +22 -0
- package/test/helpers.js +2 -0
- package/test/notifications.test.js +90 -0
- package/test/range.test.js +145 -0
- package/test/webid-tls.test.js +119 -0
|
@@ -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.
|
|
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: **
|
|
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
|
-
│
|
|
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
package/src/auth/middleware.js
CHANGED
|
@@ -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
|
-
|
|
121
|
-
|
|
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
|
}
|
package/src/auth/solid-oidc.js
CHANGED
|
@@ -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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
}
|
|
225
|
+
// Try Nostr NIP-98 (Schnorr signatures)
|
|
226
|
+
if (hasNostrAuth(request)) {
|
|
227
|
+
return verifyNostrAuth(request);
|
|
228
|
+
}
|
|
236
229
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
//
|
|
244
|
-
|
|
245
|
-
if (
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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:
|
|
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
|
}
|