javascript-solid-server 0.0.21 → 0.0.23
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 +31 -1
- package/README.md +24 -10
- package/bin/jss.js +10 -4
- package/package.json +2 -1
- package/src/auth/nostr.js +197 -0
- package/src/auth/token.js +13 -1
- package/src/config.js +3 -1
- package/src/handlers/resource.js +10 -4
- package/src/mashlib/index.js +34 -41
- package/src/rdf/conneg.js +3 -2
- package/src/server.js +37 -2
- package/src/wac/checker.js +3 -3
- package/src/wac/parser.js +25 -8
- package/test/wac.test.js +27 -8
- package/test-nostr-auth.js +114 -0
- package/cth-config/application.yaml +0 -2
- package/cth-config/jss.ttl +0 -6
- package/cth-config/test-subjects.ttl +0 -14
- package/test-data-idp-accounts/.idp/accounts/318e7a79-23d5-4e0b-8fa1-b63cfaee87e1.json +0 -10
- package/test-data-idp-accounts/.idp/accounts/_email_index.json +0 -3
- package/test-data-idp-accounts/.idp/accounts/_username_index.json +0 -3
- package/test-data-idp-accounts/.idp/accounts/_webid_index.json +0 -3
- package/test-data-idp-accounts/.idp/keys/jwks.json +0 -22
- package/test-dpop-flow.js +0 -148
|
@@ -67,7 +67,37 @@
|
|
|
67
67
|
"Bash(pm2 save:*)",
|
|
68
68
|
"Bash(gh issue create:*)",
|
|
69
69
|
"Bash(gh issue view:*)",
|
|
70
|
-
"Bash(gh issue edit:*)"
|
|
70
|
+
"Bash(gh issue edit:*)",
|
|
71
|
+
"WebFetch(domain:nostrcg.github.io)",
|
|
72
|
+
"WebFetch(domain:melvincarvalho.github.io)",
|
|
73
|
+
"WebFetch(domain:dev.to)",
|
|
74
|
+
"WebFetch(domain:solidproject.org)",
|
|
75
|
+
"WebFetch(domain:www.w3.org)",
|
|
76
|
+
"Bash(wc:*)",
|
|
77
|
+
"Bash(TOKEN=\"eyJraW5kIjoyNzIzNSwidGFncyI6W1sidSIsImh0dHA6Ly9sb2NhbGhvc3Q6NDAwMC9kZW1vL25vc3RyLXpvbmUvIl0sWyJtZXRob2QiLCJHRVQiXV0sImNyZWF0ZWRfYXQiOjE3NjY5MzQ1NjksImNvbnRlbnQiOiIiLCJwdWJrZXkiOiI4OTg5OWNmOWEyNGE5ZTdlMTNmODU3MGRkMGI1MmJiOTQyMjllNDI2OGM1MGQ1OWZhNjdhMzQ0MGQ0NmFhZTdkIiwiaWQiOiJiNTUyMDUyOTVmYmQwYzhjZDYwMzk1NTgwOWYxZGM5Y2MwMjdlY2U4N2NjYmNlNzcwNWY2MjdmNmQ0ODk1MGJkIiwic2lnIjoiOWYzN2Y0NzIyZDlkNmFmZGQ5OTNkYTM0MDg2MWQ2YzQ4MmY1NzQ1MmFmZTIwZmY2YmI5OTAxNGIwOTU3NjUwMWZiNTgyZjEzNzNlZmVhNjI4ZDI5ZjlhMzhmZTgyODU0ODlmMzAzYzlmYmJjYWE0OTQxZjUyZGZlMWYxNzVkOWMifQ==\")",
|
|
78
|
+
"WebFetch(domain:solid-lite.org)",
|
|
79
|
+
"Bash(git push:*)",
|
|
80
|
+
"WebFetch(domain:linkedwebstorage.com)",
|
|
81
|
+
"WebFetch(domain:w3c.github.io)",
|
|
82
|
+
"WebFetch(domain:socialdocs.org)",
|
|
83
|
+
"WebFetch(domain:nosdav.com)",
|
|
84
|
+
"WebFetch(domain:sandy-mount.com)",
|
|
85
|
+
"WebFetch(domain:ditto.pub)",
|
|
86
|
+
"WebFetch(domain:blocktrails.org)",
|
|
87
|
+
"WebFetch(domain:microfed.org)",
|
|
88
|
+
"WebFetch(domain:soliddocs.org)",
|
|
89
|
+
"WebFetch(domain:agenticalliance.com)",
|
|
90
|
+
"WebFetch(domain:activitypub.rocks)",
|
|
91
|
+
"WebFetch(domain:nostrgit.org)",
|
|
92
|
+
"Bash(convert:*)",
|
|
93
|
+
"WebFetch(domain:instantdomainsearch.com)",
|
|
94
|
+
"Bash(for domain in jss.dev jss.sh jss.io jss.app solidserver.dev solid-server.dev)",
|
|
95
|
+
"Bash(do echo -n '$domain: ')",
|
|
96
|
+
"Bash(whois $domain)",
|
|
97
|
+
"Bash(done)",
|
|
98
|
+
"Bash(for domain in jss.dev jss.sh jss.io jss.app solidserver.dev)",
|
|
99
|
+
"Bash(host:*)",
|
|
100
|
+
"WebFetch(domain:nostr-components.github.io)"
|
|
71
101
|
]
|
|
72
102
|
}
|
|
73
103
|
}
|
package/README.md
CHANGED
|
@@ -54,7 +54,7 @@ npm run benchmark
|
|
|
54
54
|
|
|
55
55
|
## Features
|
|
56
56
|
|
|
57
|
-
### Implemented (v0.0.
|
|
57
|
+
### Implemented (v0.0.23)
|
|
58
58
|
|
|
59
59
|
- **LDP CRUD Operations** - GET, PUT, POST, DELETE, HEAD
|
|
60
60
|
- **N3 Patch** - Solid's native patch format for RDF updates
|
|
@@ -66,11 +66,13 @@ npm run benchmark
|
|
|
66
66
|
- **Container Management** - Create, list, and manage containers
|
|
67
67
|
- **Multi-user Pods** - Path-based (`/alice/`) or subdomain-based (`alice.example.com`)
|
|
68
68
|
- **Subdomain Mode** - XSS protection via origin isolation
|
|
69
|
-
- **Mashlib Data Browser** - Optional SolidOS UI
|
|
69
|
+
- **Mashlib Data Browser** - Optional SolidOS UI (CDN or local hosting)
|
|
70
70
|
- **WebID Profiles** - JSON-LD structured data in HTML at pod root
|
|
71
71
|
- **Web Access Control (WAC)** - `.acl` file-based authorization
|
|
72
72
|
- **Solid-OIDC Identity Provider** - Built-in IdP with DPoP, dynamic registration
|
|
73
73
|
- **Solid-OIDC Resource Server** - Accept DPoP-bound access tokens from external IdPs
|
|
74
|
+
- **NSS-style Registration** - Username/password auth compatible with Solid apps
|
|
75
|
+
- **Nostr Authentication** - NIP-98 HTTP Auth with Schnorr signatures
|
|
74
76
|
- **Simple Auth Tokens** - Built-in token authentication for development
|
|
75
77
|
- **Content Negotiation** - Optional Turtle <-> JSON-LD conversion
|
|
76
78
|
- **CORS Support** - Full cross-origin resource sharing
|
|
@@ -139,8 +141,9 @@ jss --help # Show help
|
|
|
139
141
|
| `--idp-issuer <url>` | IdP issuer URL | (auto) |
|
|
140
142
|
| `--subdomains` | Enable subdomain-based pods | false |
|
|
141
143
|
| `--base-domain <domain>` | Base domain for subdomains | - |
|
|
142
|
-
| `--mashlib` | Enable Mashlib
|
|
143
|
-
| `--mashlib-
|
|
144
|
+
| `--mashlib` | Enable Mashlib (local mode) | false |
|
|
145
|
+
| `--mashlib-cdn` | Enable Mashlib (CDN mode) | false |
|
|
146
|
+
| `--mashlib-version <ver>` | Mashlib CDN version | 2.0.0 |
|
|
144
147
|
| `-q, --quiet` | Suppress logs | false |
|
|
145
148
|
|
|
146
149
|
### Environment Variables
|
|
@@ -407,24 +410,35 @@ createServer({
|
|
|
407
410
|
notifications: false, // Enable WebSocket notifications (default: false)
|
|
408
411
|
subdomains: false, // Enable subdomain-based pods (default: false)
|
|
409
412
|
baseDomain: null, // Base domain for subdomains (e.g., "example.com")
|
|
410
|
-
mashlib: false, // Enable Mashlib data browser (default: false)
|
|
411
|
-
|
|
413
|
+
mashlib: false, // Enable Mashlib data browser - local mode (default: false)
|
|
414
|
+
mashlibCdn: false, // Enable Mashlib data browser - CDN mode (default: false)
|
|
415
|
+
mashlibVersion: '2.0.0', // Mashlib version for CDN mode
|
|
412
416
|
});
|
|
413
417
|
```
|
|
414
418
|
|
|
415
419
|
### Mashlib Data Browser
|
|
416
420
|
|
|
417
|
-
Enable the [SolidOS Mashlib](https://github.com/SolidOS/mashlib) data browser for RDF resources:
|
|
421
|
+
Enable the [SolidOS Mashlib](https://github.com/SolidOS/mashlib) data browser for RDF resources. Two modes are available:
|
|
418
422
|
|
|
423
|
+
**CDN Mode** (recommended for getting started):
|
|
419
424
|
```bash
|
|
420
|
-
jss start --mashlib --conneg
|
|
425
|
+
jss start --mashlib-cdn --conneg
|
|
421
426
|
```
|
|
427
|
+
Loads mashlib from unpkg.com CDN. Zero footprint - no local files needed.
|
|
422
428
|
|
|
423
|
-
|
|
429
|
+
**Local Mode** (for production/offline):
|
|
430
|
+
```bash
|
|
431
|
+
jss start --mashlib --conneg
|
|
432
|
+
```
|
|
433
|
+
Serves mashlib from `src/mashlib-local/dist/`. Requires building mashlib locally:
|
|
434
|
+
```bash
|
|
435
|
+
cd src/mashlib-local
|
|
436
|
+
npm install && npm run build
|
|
437
|
+
```
|
|
424
438
|
|
|
425
439
|
**How it works:**
|
|
426
440
|
1. Browser requests `/alice/public/data.ttl` with `Accept: text/html`
|
|
427
|
-
2. Server returns Mashlib HTML wrapper
|
|
441
|
+
2. Server returns Mashlib HTML wrapper
|
|
428
442
|
3. Mashlib fetches the actual data via content negotiation
|
|
429
443
|
4. Mashlib renders an interactive, editable view
|
|
430
444
|
|
package/bin/jss.js
CHANGED
|
@@ -50,9 +50,10 @@ program
|
|
|
50
50
|
.option('--subdomains', 'Enable subdomain-based pods (XSS protection)')
|
|
51
51
|
.option('--no-subdomains', 'Disable subdomain-based pods')
|
|
52
52
|
.option('--base-domain <domain>', 'Base domain for subdomain pods (e.g., "example.com")')
|
|
53
|
-
.option('--mashlib', 'Enable Mashlib data browser
|
|
53
|
+
.option('--mashlib', 'Enable Mashlib data browser (local mode, requires mashlib in node_modules)')
|
|
54
|
+
.option('--mashlib-cdn', 'Enable Mashlib data browser (CDN mode, no local files needed)')
|
|
54
55
|
.option('--no-mashlib', 'Disable Mashlib data browser')
|
|
55
|
-
.option('--mashlib-version <version>', 'Mashlib version
|
|
56
|
+
.option('--mashlib-version <version>', 'Mashlib version for CDN mode (default: 2.0.0)')
|
|
56
57
|
.option('-q, --quiet', 'Suppress log output')
|
|
57
58
|
.option('--print-config', 'Print configuration and exit')
|
|
58
59
|
.action(async (options) => {
|
|
@@ -91,7 +92,8 @@ program
|
|
|
91
92
|
root: config.root,
|
|
92
93
|
subdomains: config.subdomains,
|
|
93
94
|
baseDomain: config.baseDomain,
|
|
94
|
-
mashlib: config.mashlib,
|
|
95
|
+
mashlib: config.mashlib || config.mashlibCdn,
|
|
96
|
+
mashlibCdn: config.mashlibCdn,
|
|
95
97
|
mashlibVersion: config.mashlibVersion,
|
|
96
98
|
});
|
|
97
99
|
|
|
@@ -106,7 +108,11 @@ program
|
|
|
106
108
|
if (config.notifications) console.log(' WebSocket: enabled');
|
|
107
109
|
if (config.idp) console.log(` IdP: ${idpIssuer}`);
|
|
108
110
|
if (config.subdomains) console.log(` Subdomains: ${config.baseDomain} (XSS protection enabled)`);
|
|
109
|
-
if (config.
|
|
111
|
+
if (config.mashlibCdn) {
|
|
112
|
+
console.log(` Mashlib: v${config.mashlibVersion} (CDN mode)`);
|
|
113
|
+
} else if (config.mashlib) {
|
|
114
|
+
console.log(` Mashlib: local (data browser enabled)`);
|
|
115
|
+
}
|
|
110
116
|
console.log('\n Press Ctrl+C to stop\n');
|
|
111
117
|
}
|
|
112
118
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "javascript-solid-server",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.23",
|
|
4
4
|
"description": "A minimal, fast Solid server",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"fs-extra": "^11.2.0",
|
|
32
32
|
"jose": "^6.1.3",
|
|
33
33
|
"n3": "^1.26.0",
|
|
34
|
+
"nostr-tools": "^2.19.4",
|
|
34
35
|
"oidc-provider": "^9.6.0"
|
|
35
36
|
},
|
|
36
37
|
"engines": {
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nostr NIP-98 Authentication
|
|
3
|
+
*
|
|
4
|
+
* Implements HTTP authentication using Schnorr signatures as defined in:
|
|
5
|
+
* - NIP-98: https://nips.nostr.com/98
|
|
6
|
+
* - JIP-0001: https://github.com/JavaScriptSolidServer/jips/blob/main/jip-0001.md
|
|
7
|
+
*
|
|
8
|
+
* Authorization header format: "Nostr <base64-encoded-event>"
|
|
9
|
+
*
|
|
10
|
+
* The authenticated identity is returned as a did:nostr URI:
|
|
11
|
+
* did:nostr:<64-char-hex-pubkey>
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { verifyEvent } from 'nostr-tools';
|
|
15
|
+
import crypto from 'crypto';
|
|
16
|
+
|
|
17
|
+
// NIP-98 event kind (references RFC 7235)
|
|
18
|
+
const HTTP_AUTH_KIND = 27235;
|
|
19
|
+
|
|
20
|
+
// Timestamp tolerance in seconds
|
|
21
|
+
const TIMESTAMP_TOLERANCE = 60;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Check if request has Nostr authentication
|
|
25
|
+
* @param {object} request - Fastify request object
|
|
26
|
+
* @returns {boolean}
|
|
27
|
+
*/
|
|
28
|
+
export function hasNostrAuth(request) {
|
|
29
|
+
const authHeader = request.headers.authorization;
|
|
30
|
+
return authHeader && authHeader.startsWith('Nostr ');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Extract token from Nostr authorization header
|
|
35
|
+
* @param {string} authHeader - Authorization header value
|
|
36
|
+
* @returns {string|null}
|
|
37
|
+
*/
|
|
38
|
+
export function extractNostrToken(authHeader) {
|
|
39
|
+
if (!authHeader || !authHeader.startsWith('Nostr ')) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
return authHeader.slice(6).trim();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Decode NIP-98 event from base64 token
|
|
47
|
+
* @param {string} token - Base64 encoded event
|
|
48
|
+
* @returns {object|null} Decoded event or null
|
|
49
|
+
*/
|
|
50
|
+
function decodeEvent(token) {
|
|
51
|
+
try {
|
|
52
|
+
const decoded = Buffer.from(token, 'base64').toString('utf8');
|
|
53
|
+
return JSON.parse(decoded);
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Get tag value from event
|
|
61
|
+
* @param {object} event - Nostr event
|
|
62
|
+
* @param {string} tagName - Tag name (e.g., 'u', 'method')
|
|
63
|
+
* @returns {string|null} Tag value or null
|
|
64
|
+
*/
|
|
65
|
+
function getTagValue(event, tagName) {
|
|
66
|
+
if (!event.tags || !Array.isArray(event.tags)) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
const tag = event.tags.find(t => Array.isArray(t) && t[0] === tagName);
|
|
70
|
+
return tag ? tag[1] : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Convert Nostr pubkey to did:nostr URI
|
|
75
|
+
* @param {string} pubkey - 64-char hex public key
|
|
76
|
+
* @returns {string} did:nostr URI
|
|
77
|
+
*/
|
|
78
|
+
export function pubkeyToDidNostr(pubkey) {
|
|
79
|
+
return `did:nostr:${pubkey.toLowerCase()}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Verify NIP-98 authentication and return agent identity
|
|
84
|
+
* @param {object} request - Fastify request object
|
|
85
|
+
* @returns {Promise<{webId: string|null, error: string|null}>}
|
|
86
|
+
*/
|
|
87
|
+
export async function verifyNostrAuth(request) {
|
|
88
|
+
const token = extractNostrToken(request.headers.authorization);
|
|
89
|
+
|
|
90
|
+
if (!token) {
|
|
91
|
+
return { webId: null, error: 'Missing Nostr token' };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Decode the event
|
|
95
|
+
const event = decodeEvent(token);
|
|
96
|
+
if (!event) {
|
|
97
|
+
return { webId: null, error: 'Invalid token format: could not decode base64 JSON' };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Validate event kind (must be 27235)
|
|
101
|
+
if (event.kind !== HTTP_AUTH_KIND) {
|
|
102
|
+
return { webId: null, error: `Invalid event kind: expected ${HTTP_AUTH_KIND}, got ${event.kind}` };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Validate timestamp (within ±60 seconds)
|
|
106
|
+
const now = Math.floor(Date.now() / 1000);
|
|
107
|
+
const eventTime = event.created_at;
|
|
108
|
+
if (!eventTime || Math.abs(now - eventTime) > TIMESTAMP_TOLERANCE) {
|
|
109
|
+
return { webId: null, error: 'Event timestamp outside acceptable window (±60s)' };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Build full URL for validation
|
|
113
|
+
const protocol = request.protocol || 'http';
|
|
114
|
+
const host = request.headers.host || request.hostname;
|
|
115
|
+
const fullUrl = `${protocol}://${host}${request.url}`;
|
|
116
|
+
|
|
117
|
+
// Validate URL tag matches request URL
|
|
118
|
+
const eventUrl = getTagValue(event, 'u');
|
|
119
|
+
if (!eventUrl) {
|
|
120
|
+
return { webId: null, error: 'Missing URL tag in event' };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Compare URLs (normalize by removing trailing slashes)
|
|
124
|
+
const normalizedEventUrl = eventUrl.replace(/\/$/, '');
|
|
125
|
+
const normalizedRequestUrl = fullUrl.replace(/\/$/, '');
|
|
126
|
+
const normalizedRequestUrlNoQuery = fullUrl.split('?')[0].replace(/\/$/, '');
|
|
127
|
+
|
|
128
|
+
if (normalizedEventUrl !== normalizedRequestUrl && normalizedEventUrl !== normalizedRequestUrlNoQuery) {
|
|
129
|
+
return { webId: null, error: `URL mismatch: event URL "${eventUrl}" does not match request URL "${fullUrl}"` };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Validate method tag matches request method
|
|
133
|
+
const eventMethod = getTagValue(event, 'method');
|
|
134
|
+
if (!eventMethod) {
|
|
135
|
+
return { webId: null, error: 'Missing method tag in event' };
|
|
136
|
+
}
|
|
137
|
+
if (eventMethod.toUpperCase() !== request.method.toUpperCase()) {
|
|
138
|
+
return { webId: null, error: `Method mismatch: expected ${request.method}, got ${eventMethod}` };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Validate payload hash if present and request has body
|
|
142
|
+
const payloadTag = getTagValue(event, 'payload');
|
|
143
|
+
if (payloadTag && request.body) {
|
|
144
|
+
let bodyString;
|
|
145
|
+
if (typeof request.body === 'string') {
|
|
146
|
+
bodyString = request.body;
|
|
147
|
+
} else if (Buffer.isBuffer(request.body)) {
|
|
148
|
+
bodyString = request.body.toString();
|
|
149
|
+
} else {
|
|
150
|
+
bodyString = JSON.stringify(request.body);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const expectedHash = crypto.createHash('sha256').update(bodyString).digest('hex');
|
|
154
|
+
if (payloadTag.toLowerCase() !== expectedHash.toLowerCase()) {
|
|
155
|
+
return { webId: null, error: 'Payload hash mismatch' };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Validate pubkey exists
|
|
160
|
+
if (!event.pubkey || typeof event.pubkey !== 'string' || event.pubkey.length !== 64) {
|
|
161
|
+
return { webId: null, error: 'Invalid or missing pubkey' };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Verify Schnorr signature
|
|
165
|
+
const isValid = verifyEvent(event);
|
|
166
|
+
if (!isValid) {
|
|
167
|
+
return { webId: null, error: 'Invalid Schnorr signature' };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Return did:nostr as the agent identifier
|
|
171
|
+
const didNostr = pubkeyToDidNostr(event.pubkey);
|
|
172
|
+
|
|
173
|
+
return { webId: didNostr, error: null };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Get Nostr pubkey from request if authenticated via NIP-98
|
|
178
|
+
* @param {object} request - Fastify request object
|
|
179
|
+
* @returns {Promise<string|null>} Hex pubkey or null
|
|
180
|
+
*/
|
|
181
|
+
export async function getNostrPubkey(request) {
|
|
182
|
+
if (!hasNostrAuth(request)) {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const token = extractNostrToken(request.headers.authorization);
|
|
187
|
+
if (!token) {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
const event = decodeEvent(token);
|
|
193
|
+
return event?.pubkey || null;
|
|
194
|
+
} catch {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
}
|
package/src/auth/token.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Token-based authentication
|
|
3
3
|
*
|
|
4
|
-
* Supports
|
|
4
|
+
* Supports multiple modes:
|
|
5
5
|
* 1. Simple tokens (for local/dev use): base64(JSON({webId, iat, exp})) + HMAC signature
|
|
6
6
|
* 2. Solid-OIDC DPoP tokens (for federation): verified via external IdP JWKS
|
|
7
|
+
* 3. Nostr NIP-98 tokens: Schnorr signatures, returns did:nostr identity
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
10
|
import crypto from 'crypto';
|
|
10
11
|
import { verifySolidOidc, hasSolidOidcAuth } from './solid-oidc.js';
|
|
12
|
+
import { verifyNostrAuth, hasNostrAuth } from './nostr.js';
|
|
11
13
|
|
|
12
14
|
// Secret for signing tokens (in production, use env var)
|
|
13
15
|
const SECRET = process.env.TOKEN_SECRET || 'dev-secret-change-in-production';
|
|
@@ -151,6 +153,11 @@ export function getWebIdFromRequest(request) {
|
|
|
151
153
|
return null;
|
|
152
154
|
}
|
|
153
155
|
|
|
156
|
+
// Skip Nostr tokens - use async version for those
|
|
157
|
+
if (authHeader && authHeader.startsWith('Nostr ')) {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
154
161
|
const token = extractToken(authHeader);
|
|
155
162
|
|
|
156
163
|
if (!token) {
|
|
@@ -178,6 +185,11 @@ export async function getWebIdFromRequestAsync(request) {
|
|
|
178
185
|
return verifySolidOidc(request);
|
|
179
186
|
}
|
|
180
187
|
|
|
188
|
+
// Try Nostr NIP-98 (Schnorr signatures)
|
|
189
|
+
if (hasNostrAuth(request)) {
|
|
190
|
+
return verifyNostrAuth(request);
|
|
191
|
+
}
|
|
192
|
+
|
|
181
193
|
// Fall back to simple Bearer tokens
|
|
182
194
|
const token = extractToken(authHeader);
|
|
183
195
|
if (!token) {
|
package/src/config.js
CHANGED
|
@@ -39,6 +39,7 @@ export const defaults = {
|
|
|
39
39
|
|
|
40
40
|
// Mashlib data browser
|
|
41
41
|
mashlib: false,
|
|
42
|
+
mashlibCdn: false,
|
|
42
43
|
mashlibVersion: '2.0.0',
|
|
43
44
|
|
|
44
45
|
// Logging
|
|
@@ -68,6 +69,7 @@ const envMap = {
|
|
|
68
69
|
JSS_SUBDOMAINS: 'subdomains',
|
|
69
70
|
JSS_BASE_DOMAIN: 'baseDomain',
|
|
70
71
|
JSS_MASHLIB: 'mashlib',
|
|
72
|
+
JSS_MASHLIB_CDN: 'mashlibCdn',
|
|
71
73
|
JSS_MASHLIB_VERSION: 'mashlibVersion',
|
|
72
74
|
};
|
|
73
75
|
|
|
@@ -201,6 +203,6 @@ export function printConfig(config) {
|
|
|
201
203
|
console.log(` Notifications: ${config.notifications}`);
|
|
202
204
|
console.log(` IdP: ${config.idp ? (config.idpIssuer || 'enabled') : 'disabled'}`);
|
|
203
205
|
console.log(` Subdomains: ${config.subdomains ? (config.baseDomain || 'enabled') : 'disabled'}`);
|
|
204
|
-
console.log(` Mashlib: ${config.
|
|
206
|
+
console.log(` Mashlib: ${config.mashlibCdn ? `CDN v${config.mashlibVersion}` : config.mashlib ? 'local' : 'disabled'}`);
|
|
205
207
|
console.log('─'.repeat(40));
|
|
206
208
|
}
|
package/src/handlers/resource.js
CHANGED
|
@@ -145,7 +145,9 @@ export async function handleGet(request, reply) {
|
|
|
145
145
|
// Check if we should serve Mashlib data browser
|
|
146
146
|
// Only for RDF resources when Accept: text/html is requested
|
|
147
147
|
if (shouldServeMashlib(request, request.mashlibEnabled, storedContentType)) {
|
|
148
|
-
|
|
148
|
+
// Pass CDN version if using CDN mode, null for local mode
|
|
149
|
+
const cdnVersion = request.mashlibCdn ? request.mashlibVersion : null;
|
|
150
|
+
const html = generateDatabrowserHtml(resourceUrl, cdnVersion);
|
|
149
151
|
const headers = getAllHeaders({
|
|
150
152
|
isContainer: false,
|
|
151
153
|
etag: stats.etag,
|
|
@@ -155,6 +157,10 @@ export async function handleGet(request, reply) {
|
|
|
155
157
|
connegEnabled
|
|
156
158
|
});
|
|
157
159
|
headers['Vary'] = 'Accept';
|
|
160
|
+
headers['X-Frame-Options'] = 'DENY';
|
|
161
|
+
headers['Content-Security-Policy'] = "frame-ancestors 'none'";
|
|
162
|
+
// Don't cache the HTML wrapper - always negotiate fresh
|
|
163
|
+
headers['Cache-Control'] = 'no-store';
|
|
158
164
|
|
|
159
165
|
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
160
166
|
return reply.type('text/html').send(html);
|
|
@@ -191,7 +197,7 @@ export async function handleGet(request, reply) {
|
|
|
191
197
|
resourceUrl,
|
|
192
198
|
connegEnabled
|
|
193
199
|
});
|
|
194
|
-
headers['Vary'] = getVaryHeader(connegEnabled);
|
|
200
|
+
headers['Vary'] = getVaryHeader(connegEnabled, request.mashlibEnabled);
|
|
195
201
|
|
|
196
202
|
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
197
203
|
return reply.send(outputContent);
|
|
@@ -209,7 +215,7 @@ export async function handleGet(request, reply) {
|
|
|
209
215
|
resourceUrl,
|
|
210
216
|
connegEnabled
|
|
211
217
|
});
|
|
212
|
-
headers['Vary'] = getVaryHeader(connegEnabled);
|
|
218
|
+
headers['Vary'] = getVaryHeader(connegEnabled, request.mashlibEnabled);
|
|
213
219
|
|
|
214
220
|
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
215
221
|
return reply.send(content);
|
|
@@ -353,7 +359,7 @@ export async function handlePut(request, reply) {
|
|
|
353
359
|
const origin = request.headers.origin;
|
|
354
360
|
const headers = getAllHeaders({ isContainer: false, origin, resourceUrl, connegEnabled });
|
|
355
361
|
headers['Location'] = resourceUrl;
|
|
356
|
-
headers['Vary'] = getVaryHeader(connegEnabled);
|
|
362
|
+
headers['Vary'] = getVaryHeader(connegEnabled, request.mashlibEnabled);
|
|
357
363
|
|
|
358
364
|
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
359
365
|
|
package/src/mashlib/index.js
CHANGED
|
@@ -6,51 +6,38 @@
|
|
|
6
6
|
* we return this wrapper which then fetches and renders the data.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
const CDN_BASE = 'https://unpkg.com/mashlib';
|
|
10
|
-
|
|
11
9
|
/**
|
|
12
10
|
* Generate Mashlib databrowser HTML
|
|
13
|
-
*
|
|
14
|
-
* @param {string}
|
|
11
|
+
*
|
|
12
|
+
* @param {string} resourceUrl - The URL of the resource being viewed (unused, kept for API compatibility)
|
|
13
|
+
* @param {string} cdnVersion - If provided, load mashlib from unpkg CDN (e.g., "2.0.0")
|
|
15
14
|
* @returns {string} HTML content
|
|
16
15
|
*/
|
|
17
|
-
export function generateDatabrowserHtml(resourceUrl,
|
|
18
|
-
|
|
16
|
+
export function generateDatabrowserHtml(resourceUrl, cdnVersion = null) {
|
|
17
|
+
if (cdnVersion) {
|
|
18
|
+
// CDN mode - use script.onload to ensure mashlib is fully loaded before init
|
|
19
|
+
// This avoids race conditions with defer + DOMContentLoaded
|
|
20
|
+
const cdnBase = `https://unpkg.com/mashlib@${cdnVersion}/dist`;
|
|
21
|
+
return `<!doctype html><html><head><meta charset="utf-8"/><title>SolidOS Web App</title>
|
|
22
|
+
<link href="${cdnBase}/mash.css" rel="stylesheet"></head>
|
|
23
|
+
<body id="PageBody"><header id="PageHeader"></header>
|
|
24
|
+
<div class="TabulatorOutline" id="DummyUUID" role="main"><table id="outline"></table><div id="GlobalDashboard"></div></div>
|
|
25
|
+
<footer id="PageFooter"></footer>
|
|
26
|
+
<script>
|
|
27
|
+
(function() {
|
|
28
|
+
var s = document.createElement('script');
|
|
29
|
+
s.src = '${cdnBase}/mashlib.min.js';
|
|
30
|
+
s.onload = function() { panes.runDataBrowser(); };
|
|
31
|
+
s.onerror = function() { document.body.innerHTML = '<p>Failed to load Mashlib from CDN</p>'; };
|
|
32
|
+
document.head.appendChild(s);
|
|
33
|
+
})();
|
|
34
|
+
</script></body></html>`;
|
|
35
|
+
}
|
|
19
36
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
25
|
-
<title>SolidOS - ${escapeHtml(resourceUrl)}</title>
|
|
26
|
-
<script defer src="${cdnUrl}/mashlib.min.js"></script>
|
|
27
|
-
<link href="${cdnUrl}/mash.css" rel="stylesheet">
|
|
28
|
-
<script>
|
|
29
|
-
document.addEventListener('DOMContentLoaded', function() {
|
|
30
|
-
// runDataBrowser uses window.location to determine what to fetch
|
|
31
|
-
panes.runDataBrowser();
|
|
32
|
-
});
|
|
33
|
-
</script>
|
|
34
|
-
<style>
|
|
35
|
-
/* Loading indicator */
|
|
36
|
-
body:not(.loaded) #PageBody::before {
|
|
37
|
-
content: 'Loading SolidOS...';
|
|
38
|
-
display: block;
|
|
39
|
-
padding: 2em;
|
|
40
|
-
text-align: center;
|
|
41
|
-
color: #666;
|
|
42
|
-
}
|
|
43
|
-
</style>
|
|
44
|
-
</head>
|
|
45
|
-
<body id="PageBody">
|
|
46
|
-
<header id="PageHeader"></header>
|
|
47
|
-
<div class="TabulatorOutline" id="DummyUUID" role="main">
|
|
48
|
-
<table id="outline"></table>
|
|
49
|
-
<div id="GlobalDashboard"></div>
|
|
50
|
-
</div>
|
|
51
|
-
<footer id="PageFooter"></footer>
|
|
52
|
-
</body>
|
|
53
|
-
</html>`;
|
|
37
|
+
// Local mode - use defer (reliable when served locally)
|
|
38
|
+
return `<!doctype html><html><head><meta charset="utf-8"/><title>SolidOS Web App</title><script>document.addEventListener('DOMContentLoaded', function() {
|
|
39
|
+
panes.runDataBrowser()
|
|
40
|
+
})</script><script defer="defer" src="/mashlib.min.js"></script><link href="/mash.css" rel="stylesheet"></head><body id="PageBody"><header id="PageHeader"></header><div class="TabulatorOutline" id="DummyUUID" role="main"><table id="outline"></table><div id="GlobalDashboard"></div></div><footer id="PageFooter"></footer></body></html>`;
|
|
54
41
|
}
|
|
55
42
|
|
|
56
43
|
/**
|
|
@@ -61,11 +48,17 @@ export function generateDatabrowserHtml(resourceUrl, version = '2.0.0') {
|
|
|
61
48
|
* @returns {boolean}
|
|
62
49
|
*/
|
|
63
50
|
export function shouldServeMashlib(request, mashlibEnabled, contentType) {
|
|
51
|
+
const accept = request.headers.accept || '';
|
|
52
|
+
const secFetchDest = request.headers['sec-fetch-dest'] || '';
|
|
53
|
+
|
|
64
54
|
if (!mashlibEnabled) {
|
|
65
55
|
return false;
|
|
66
56
|
}
|
|
67
57
|
|
|
68
|
-
|
|
58
|
+
// Don't serve mashlib for iframe/embed requests (prevents recursive loop)
|
|
59
|
+
if (secFetchDest === 'iframe' || secFetchDest === 'embed' || secFetchDest === 'object') {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
69
62
|
|
|
70
63
|
// Must explicitly accept HTML
|
|
71
64
|
if (!accept.includes('text/html')) {
|
package/src/rdf/conneg.js
CHANGED
|
@@ -188,9 +188,10 @@ export async function fromJsonLd(jsonLd, targetType, baseUri, connegEnabled = fa
|
|
|
188
188
|
|
|
189
189
|
/**
|
|
190
190
|
* Get Vary header value for content negotiation
|
|
191
|
+
* Include Accept when conneg or mashlib is enabled (response varies by Accept header)
|
|
191
192
|
*/
|
|
192
|
-
export function getVaryHeader(connegEnabled) {
|
|
193
|
-
return connegEnabled ? 'Accept, Origin' : 'Origin';
|
|
193
|
+
export function getVaryHeader(connegEnabled, mashlibEnabled = false) {
|
|
194
|
+
return (connegEnabled || mashlibEnabled) ? 'Accept, Origin' : 'Origin';
|
|
194
195
|
}
|
|
195
196
|
|
|
196
197
|
/**
|
package/src/server.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import Fastify from 'fastify';
|
|
2
|
+
import { readFile } from 'fs/promises';
|
|
3
|
+
import { join, dirname } from 'path';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
2
5
|
import { handleGet, handleHead, handlePut, handleDelete, handleOptions, handlePatch } from './handlers/resource.js';
|
|
3
6
|
import { handlePost, handleCreatePod } from './handlers/container.js';
|
|
4
7
|
import { getCorsHeaders } from './ldp/headers.js';
|
|
@@ -6,6 +9,8 @@ import { authorize, handleUnauthorized } from './auth/middleware.js';
|
|
|
6
9
|
import { notificationsPlugin } from './notifications/index.js';
|
|
7
10
|
import { idpPlugin } from './idp/index.js';
|
|
8
11
|
|
|
12
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
|
|
9
14
|
/**
|
|
10
15
|
* Create and configure Fastify server
|
|
11
16
|
* @param {object} options - Server options
|
|
@@ -31,7 +36,9 @@ export function createServer(options = {}) {
|
|
|
31
36
|
const subdomainsEnabled = options.subdomains ?? false;
|
|
32
37
|
const baseDomain = options.baseDomain || null;
|
|
33
38
|
// Mashlib data browser is OFF by default
|
|
39
|
+
// mashlibCdn: if true, load from CDN; if false, serve locally
|
|
34
40
|
const mashlibEnabled = options.mashlib ?? false;
|
|
41
|
+
const mashlibCdn = options.mashlibCdn ?? false;
|
|
35
42
|
const mashlibVersion = options.mashlibVersion ?? '2.0.0';
|
|
36
43
|
|
|
37
44
|
// Set data root via environment variable if provided
|
|
@@ -70,6 +77,7 @@ export function createServer(options = {}) {
|
|
|
70
77
|
fastify.decorateRequest('baseDomain', null);
|
|
71
78
|
fastify.decorateRequest('podName', null);
|
|
72
79
|
fastify.decorateRequest('mashlibEnabled', null);
|
|
80
|
+
fastify.decorateRequest('mashlibCdn', null);
|
|
73
81
|
fastify.decorateRequest('mashlibVersion', null);
|
|
74
82
|
fastify.addHook('onRequest', async (request) => {
|
|
75
83
|
request.connegEnabled = connegEnabled;
|
|
@@ -78,6 +86,7 @@ export function createServer(options = {}) {
|
|
|
78
86
|
request.subdomainsEnabled = subdomainsEnabled;
|
|
79
87
|
request.baseDomain = baseDomain;
|
|
80
88
|
request.mashlibEnabled = mashlibEnabled;
|
|
89
|
+
request.mashlibCdn = mashlibCdn;
|
|
81
90
|
request.mashlibVersion = mashlibVersion;
|
|
82
91
|
|
|
83
92
|
// Extract pod name from subdomain if enabled
|
|
@@ -122,11 +131,13 @@ export function createServer(options = {}) {
|
|
|
122
131
|
// Authorization hook - check WAC permissions
|
|
123
132
|
// Skip for pod creation endpoint (needs special handling)
|
|
124
133
|
fastify.addHook('preHandler', async (request, reply) => {
|
|
125
|
-
// Skip auth for pod creation, OPTIONS, IdP routes, and well-known endpoints
|
|
134
|
+
// Skip auth for pod creation, OPTIONS, IdP routes, mashlib, and well-known endpoints
|
|
135
|
+
const mashlibPaths = ['/mashlib.min.js', '/mash.css', '/841.mashlib.min.js'];
|
|
126
136
|
if (request.url === '/.pods' ||
|
|
127
137
|
request.method === 'OPTIONS' ||
|
|
128
138
|
request.url.startsWith('/idp/') ||
|
|
129
|
-
request.url.startsWith('/.well-known/')
|
|
139
|
+
request.url.startsWith('/.well-known/') ||
|
|
140
|
+
mashlibPaths.some(p => request.url === p || request.url.startsWith(p + '.'))) {
|
|
130
141
|
return;
|
|
131
142
|
}
|
|
132
143
|
|
|
@@ -144,6 +155,30 @@ export function createServer(options = {}) {
|
|
|
144
155
|
// Pod creation endpoint
|
|
145
156
|
fastify.post('/.pods', handleCreatePod);
|
|
146
157
|
|
|
158
|
+
// Mashlib static files (served from root like NSS does)
|
|
159
|
+
if (mashlibEnabled) {
|
|
160
|
+
const mashlibDir = join(__dirname, 'mashlib-local', 'dist');
|
|
161
|
+
const mashlibFiles = {
|
|
162
|
+
'/mashlib.min.js': { file: 'mashlib.min.js', type: 'application/javascript' },
|
|
163
|
+
'/mashlib.min.js.map': { file: 'mashlib.min.js.map', type: 'application/json' },
|
|
164
|
+
'/mash.css': { file: 'mash.css', type: 'text/css' },
|
|
165
|
+
'/mash.css.map': { file: 'mash.css.map', type: 'application/json' },
|
|
166
|
+
'/841.mashlib.min.js': { file: '841.mashlib.min.js', type: 'application/javascript' },
|
|
167
|
+
'/841.mashlib.min.js.map': { file: '841.mashlib.min.js.map', type: 'application/json' }
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
for (const [path, config] of Object.entries(mashlibFiles)) {
|
|
171
|
+
fastify.get(path, async (request, reply) => {
|
|
172
|
+
try {
|
|
173
|
+
const content = await readFile(join(mashlibDir, config.file));
|
|
174
|
+
return reply.type(config.type).send(content);
|
|
175
|
+
} catch {
|
|
176
|
+
return reply.code(404).send({ error: 'Not Found' });
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
147
182
|
// LDP routes - using wildcard routing
|
|
148
183
|
fastify.get('/*', handleGet);
|
|
149
184
|
fastify.head('/*', handleHead);
|
package/src/wac/checker.js
CHANGED
|
@@ -64,7 +64,7 @@ async function findApplicableAcl(resourceUrl, resourcePath, isContainer) {
|
|
|
64
64
|
const content = await storage.read(resourceAclPath);
|
|
65
65
|
if (content) {
|
|
66
66
|
const aclUrl = getAclUrl(resourceUrl, isContainer);
|
|
67
|
-
const authorizations = parseAcl(content.toString(), aclUrl);
|
|
67
|
+
const authorizations = await parseAcl(content.toString(), aclUrl);
|
|
68
68
|
return { authorizations, isDefault: false, targetUrl: resourceUrl };
|
|
69
69
|
}
|
|
70
70
|
}
|
|
@@ -80,7 +80,7 @@ async function findApplicableAcl(resourceUrl, resourcePath, isContainer) {
|
|
|
80
80
|
const content = await storage.read(parentAclPath);
|
|
81
81
|
if (content) {
|
|
82
82
|
const parentUrl = resourceUrl.substring(0, resourceUrl.lastIndexOf(currentPath)) + parentPath;
|
|
83
|
-
const authorizations = parseAcl(content.toString(), parentAclPath);
|
|
83
|
+
const authorizations = await parseAcl(content.toString(), parentAclPath);
|
|
84
84
|
return { authorizations, isDefault: true, targetUrl: parentUrl };
|
|
85
85
|
}
|
|
86
86
|
}
|
|
@@ -93,7 +93,7 @@ async function findApplicableAcl(resourceUrl, resourcePath, isContainer) {
|
|
|
93
93
|
const content = await storage.read('/.acl');
|
|
94
94
|
if (content) {
|
|
95
95
|
const rootUrl = resourceUrl.substring(0, resourceUrl.indexOf('/', 8) + 1);
|
|
96
|
-
const authorizations = parseAcl(content.toString(), '/.acl');
|
|
96
|
+
const authorizations = await parseAcl(content.toString(), '/.acl');
|
|
97
97
|
return { authorizations, isDefault: true, targetUrl: rootUrl };
|
|
98
98
|
}
|
|
99
99
|
}
|
package/src/wac/parser.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* WAC (Web Access Control) Parser
|
|
3
|
-
* Parses JSON-LD
|
|
3
|
+
* Parses ACL files (JSON-LD or Turtle) into authorization rules
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { turtleToJsonLd } from '../rdf/turtle.js';
|
|
7
|
+
|
|
6
8
|
const ACL = 'http://www.w3.org/ns/auth/acl#';
|
|
7
9
|
const FOAF = 'http://xmlns.com/foaf/0.1/';
|
|
8
10
|
|
|
@@ -21,16 +23,31 @@ export const AgentClass = {
|
|
|
21
23
|
};
|
|
22
24
|
|
|
23
25
|
/**
|
|
24
|
-
* Parse
|
|
25
|
-
* @param {string|object} content - JSON-LD
|
|
26
|
+
* Parse an ACL document (JSON-LD or Turtle)
|
|
27
|
+
* @param {string|object} content - ACL content (JSON-LD string/object or Turtle string)
|
|
26
28
|
* @param {string} aclUrl - URL of the ACL document
|
|
27
|
-
* @returns {Array<Authorization
|
|
29
|
+
* @returns {Promise<Array<Authorization>>} List of authorization rules
|
|
28
30
|
*/
|
|
29
|
-
export function parseAcl(content, aclUrl) {
|
|
31
|
+
export async function parseAcl(content, aclUrl) {
|
|
30
32
|
let doc;
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
33
|
+
|
|
34
|
+
// If already an object, use it directly
|
|
35
|
+
if (typeof content === 'object' && content !== null) {
|
|
36
|
+
doc = content;
|
|
37
|
+
} else if (typeof content === 'string') {
|
|
38
|
+
// Try JSON-LD first
|
|
39
|
+
try {
|
|
40
|
+
doc = JSON.parse(content);
|
|
41
|
+
} catch {
|
|
42
|
+
// Not JSON, try Turtle
|
|
43
|
+
try {
|
|
44
|
+
doc = await turtleToJsonLd(content, aclUrl);
|
|
45
|
+
} catch (turtleError) {
|
|
46
|
+
// Neither JSON-LD nor valid Turtle
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
} else {
|
|
34
51
|
return [];
|
|
35
52
|
}
|
|
36
53
|
|
package/test/wac.test.js
CHANGED
|
@@ -18,7 +18,7 @@ import { checkAccess, getRequiredMode } from '../src/wac/checker.js';
|
|
|
18
18
|
|
|
19
19
|
describe('WAC Parser', () => {
|
|
20
20
|
describe('parseAcl', () => {
|
|
21
|
-
it('should parse a simple ACL', () => {
|
|
21
|
+
it('should parse a simple ACL', async () => {
|
|
22
22
|
const acl = {
|
|
23
23
|
'@context': { 'acl': 'http://www.w3.org/ns/auth/acl#' },
|
|
24
24
|
'@graph': [{
|
|
@@ -30,7 +30,7 @@ describe('WAC Parser', () => {
|
|
|
30
30
|
}]
|
|
31
31
|
};
|
|
32
32
|
|
|
33
|
-
const auths = parseAcl(JSON.stringify(acl), 'https://alice.example/.acl');
|
|
33
|
+
const auths = await parseAcl(JSON.stringify(acl), 'https://alice.example/.acl');
|
|
34
34
|
|
|
35
35
|
assert.strictEqual(auths.length, 1);
|
|
36
36
|
assert.ok(auths[0].agents.includes('https://alice.example/#me'));
|
|
@@ -38,7 +38,7 @@ describe('WAC Parser', () => {
|
|
|
38
38
|
assert.ok(auths[0].modes.includes(AccessMode.WRITE));
|
|
39
39
|
});
|
|
40
40
|
|
|
41
|
-
it('should parse public access', () => {
|
|
41
|
+
it('should parse public access', async () => {
|
|
42
42
|
const acl = {
|
|
43
43
|
'@context': { 'acl': 'http://www.w3.org/ns/auth/acl#', 'foaf': 'http://xmlns.com/foaf/0.1/' },
|
|
44
44
|
'@graph': [{
|
|
@@ -50,14 +50,14 @@ describe('WAC Parser', () => {
|
|
|
50
50
|
}]
|
|
51
51
|
};
|
|
52
52
|
|
|
53
|
-
const auths = parseAcl(JSON.stringify(acl), 'https://alice.example/public/.acl');
|
|
53
|
+
const auths = await parseAcl(JSON.stringify(acl), 'https://alice.example/public/.acl');
|
|
54
54
|
|
|
55
55
|
assert.strictEqual(auths.length, 1);
|
|
56
56
|
assert.ok(auths[0].agentClasses.includes('foaf:Agent'));
|
|
57
57
|
assert.ok(auths[0].modes.includes(AccessMode.READ));
|
|
58
58
|
});
|
|
59
59
|
|
|
60
|
-
it('should parse default authorizations for containers', () => {
|
|
60
|
+
it('should parse default authorizations for containers', async () => {
|
|
61
61
|
const acl = {
|
|
62
62
|
'@context': { 'acl': 'http://www.w3.org/ns/auth/acl#' },
|
|
63
63
|
'@graph': [{
|
|
@@ -69,16 +69,35 @@ describe('WAC Parser', () => {
|
|
|
69
69
|
}]
|
|
70
70
|
};
|
|
71
71
|
|
|
72
|
-
const auths = parseAcl(JSON.stringify(acl), 'https://alice.example/folder/.acl');
|
|
72
|
+
const auths = await parseAcl(JSON.stringify(acl), 'https://alice.example/folder/.acl');
|
|
73
73
|
|
|
74
74
|
assert.strictEqual(auths.length, 1);
|
|
75
75
|
assert.ok(auths[0].default.includes('https://alice.example/folder/'));
|
|
76
76
|
});
|
|
77
77
|
|
|
78
|
-
it('should handle invalid JSON gracefully', () => {
|
|
79
|
-
const auths = parseAcl('not valid json', 'https://example.com/.acl');
|
|
78
|
+
it('should handle invalid JSON gracefully', async () => {
|
|
79
|
+
const auths = await parseAcl('not valid json', 'https://example.com/.acl');
|
|
80
80
|
assert.strictEqual(auths.length, 0);
|
|
81
81
|
});
|
|
82
|
+
|
|
83
|
+
it('should parse Turtle ACL format', async () => {
|
|
84
|
+
const turtleAcl = `
|
|
85
|
+
@prefix acl: <http://www.w3.org/ns/auth/acl#>.
|
|
86
|
+
|
|
87
|
+
<#owner>
|
|
88
|
+
a acl:Authorization;
|
|
89
|
+
acl:agent <did:nostr:abc123>;
|
|
90
|
+
acl:accessTo <https://example.com/resource>;
|
|
91
|
+
acl:mode acl:Read, acl:Write.
|
|
92
|
+
`;
|
|
93
|
+
|
|
94
|
+
const auths = await parseAcl(turtleAcl, 'https://example.com/.acl');
|
|
95
|
+
|
|
96
|
+
assert.strictEqual(auths.length, 1);
|
|
97
|
+
assert.ok(auths[0].agents.includes('did:nostr:abc123'));
|
|
98
|
+
assert.ok(auths[0].modes.includes(AccessMode.READ));
|
|
99
|
+
assert.ok(auths[0].modes.includes(AccessMode.WRITE));
|
|
100
|
+
});
|
|
82
101
|
});
|
|
83
102
|
|
|
84
103
|
describe('generateOwnerAcl', () => {
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test script for Nostr NIP-98 authentication
|
|
3
|
+
*
|
|
4
|
+
* Usage: node test-nostr-auth.js
|
|
5
|
+
*
|
|
6
|
+
* This script:
|
|
7
|
+
* 1. Generates a Nostr keypair
|
|
8
|
+
* 2. Creates a NIP-98 auth event
|
|
9
|
+
* 3. Makes authenticated request to JSS
|
|
10
|
+
* 4. Verifies the did:nostr identity is recognized
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools';
|
|
14
|
+
import { getToken } from 'nostr-tools/nip98';
|
|
15
|
+
|
|
16
|
+
const BASE_URL = process.env.TEST_URL || 'http://localhost:4000';
|
|
17
|
+
|
|
18
|
+
async function main() {
|
|
19
|
+
console.log('=== Nostr NIP-98 Authentication Test ===\n');
|
|
20
|
+
|
|
21
|
+
// Generate a new keypair
|
|
22
|
+
const sk = generateSecretKey();
|
|
23
|
+
const pk = getPublicKey(sk);
|
|
24
|
+
|
|
25
|
+
console.log('1. Generated keypair');
|
|
26
|
+
console.log(` Public key: ${pk}`);
|
|
27
|
+
console.log(` did:nostr: did:nostr:${pk}\n`);
|
|
28
|
+
|
|
29
|
+
// Create NIP-98 token for GET request to a public resource
|
|
30
|
+
const testUrl = `${BASE_URL}/`;
|
|
31
|
+
const method = 'GET';
|
|
32
|
+
|
|
33
|
+
console.log(`2. Creating NIP-98 token for ${method} ${testUrl}`);
|
|
34
|
+
|
|
35
|
+
const token = await getToken(testUrl, method, (event) => finalizeEvent(event, sk));
|
|
36
|
+
|
|
37
|
+
console.log(` Token length: ${token.length} chars\n`);
|
|
38
|
+
|
|
39
|
+
// Make authenticated request
|
|
40
|
+
console.log('3. Making authenticated request...');
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const response = await fetch(testUrl, {
|
|
44
|
+
method,
|
|
45
|
+
headers: {
|
|
46
|
+
'Authorization': `Nostr ${token}`,
|
|
47
|
+
'Accept': 'application/json'
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
console.log(` Status: ${response.status} ${response.statusText}`);
|
|
52
|
+
|
|
53
|
+
// Check headers for any auth info
|
|
54
|
+
const wwwAuth = response.headers.get('www-authenticate');
|
|
55
|
+
if (wwwAuth) {
|
|
56
|
+
console.log(` WWW-Authenticate: ${wwwAuth}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// For a protected resource, we'd check if access was granted
|
|
60
|
+
// For now, just verify the request went through
|
|
61
|
+
if (response.ok) {
|
|
62
|
+
console.log(' Request succeeded!\n');
|
|
63
|
+
} else {
|
|
64
|
+
const body = await response.text();
|
|
65
|
+
console.log(` Response: ${body.slice(0, 200)}\n`);
|
|
66
|
+
}
|
|
67
|
+
} catch (err) {
|
|
68
|
+
console.error(` Error: ${err.message}\n`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Test with a protected resource (if exists)
|
|
72
|
+
console.log('4. Testing access to a container...');
|
|
73
|
+
|
|
74
|
+
const containerUrl = `${BASE_URL}/demo/public/`;
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const containerToken = await getToken(containerUrl, 'GET', (event) => finalizeEvent(event, sk));
|
|
78
|
+
|
|
79
|
+
const response = await fetch(containerUrl, {
|
|
80
|
+
headers: {
|
|
81
|
+
'Authorization': `Nostr ${containerToken}`,
|
|
82
|
+
'Accept': 'text/turtle'
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
console.log(` ${containerUrl}`);
|
|
87
|
+
console.log(` Status: ${response.status} ${response.statusText}`);
|
|
88
|
+
|
|
89
|
+
if (response.status === 200) {
|
|
90
|
+
console.log(' Container accessible with Nostr auth!');
|
|
91
|
+
} else if (response.status === 403) {
|
|
92
|
+
console.log(' 403 Forbidden - auth worked but no ACL grant for did:nostr');
|
|
93
|
+
console.log(` (Add did:nostr:${pk} to ACL to grant access)`);
|
|
94
|
+
} else if (response.status === 404) {
|
|
95
|
+
console.log(' 404 Not Found - container does not exist');
|
|
96
|
+
}
|
|
97
|
+
} catch (err) {
|
|
98
|
+
console.error(` Error: ${err.message}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
console.log('\n=== Test Complete ===');
|
|
102
|
+
console.log('\nTo grant this identity access, add to an ACL file:');
|
|
103
|
+
console.log(`
|
|
104
|
+
@prefix acl: <http://www.w3.org/ns/auth/acl#>.
|
|
105
|
+
|
|
106
|
+
<#nostrAuth>
|
|
107
|
+
a acl:Authorization;
|
|
108
|
+
acl:agent <did:nostr:${pk}>;
|
|
109
|
+
acl:accessTo <./>;
|
|
110
|
+
acl:mode acl:Read, acl:Write.
|
|
111
|
+
`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
main().catch(console.error);
|
package/cth-config/jss.ttl
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
@prefix doap: <http://usefulinc.com/ns/doap#> .
|
|
2
|
-
@prefix earl: <http://www.w3.org/ns/earl#> .
|
|
3
|
-
@prefix solid-test: <https://github.com/solid-contrib/specification-tests/blob/main/vocab.ttl#> .
|
|
4
|
-
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
|
|
5
|
-
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
|
|
6
|
-
|
|
7
|
-
<jss>
|
|
8
|
-
a earl:Software, earl:TestSubject ;
|
|
9
|
-
doap:name "JavaScript Solid Server" ;
|
|
10
|
-
doap:description "A minimal, fast, JSON-LD native Solid server" ;
|
|
11
|
-
doap:programming-language "JavaScript" ;
|
|
12
|
-
solid-test:serverRoot <http://localhost:4000/> ;
|
|
13
|
-
solid-test:skip "acp" ;
|
|
14
|
-
rdfs:comment "Uses WAC for access control" .
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"id": "318e7a79-23d5-4e0b-8fa1-b63cfaee87e1",
|
|
3
|
-
"username": "credtest",
|
|
4
|
-
"email": "credtest@example.com",
|
|
5
|
-
"passwordHash": "$2b$10$ITkxFeVH56JBgjDqYASbfuounFozpoVQpBvtsYxCszx2I0PBEX0hq",
|
|
6
|
-
"webId": "http://localhost:3101/credtest/#me",
|
|
7
|
-
"podName": "credtest",
|
|
8
|
-
"createdAt": "2025-12-27T14:33:50.756Z",
|
|
9
|
-
"lastLogin": "2025-12-27T14:33:51.196Z"
|
|
10
|
-
}
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"jwks": {
|
|
3
|
-
"keys": [
|
|
4
|
-
{
|
|
5
|
-
"kty": "EC",
|
|
6
|
-
"x": "Nf8dDZkLGjtbhOI4-NdDeJpP7jFZ1yRIsLGbg4wWFIU",
|
|
7
|
-
"y": "RlENuTLrM8M6a1UQorqtB3NIS5VXq_gI9lqJMUKDjo8",
|
|
8
|
-
"crv": "P-256",
|
|
9
|
-
"d": "WZKOZkoJBrwF7JfwLXPzpJY2XXNgab-YfqUSIT2Xpfs",
|
|
10
|
-
"kid": "91ebc94d-1ed9-4ded-b017-70f51f2aff2b",
|
|
11
|
-
"use": "sig",
|
|
12
|
-
"alg": "ES256",
|
|
13
|
-
"iat": 1766846030
|
|
14
|
-
}
|
|
15
|
-
]
|
|
16
|
-
},
|
|
17
|
-
"cookieKeys": [
|
|
18
|
-
"V7_pksFGkYdBgSRG_lC9AWIki50H1qzj9-L_T-Q7OC0",
|
|
19
|
-
"hmJQwz_B5QLiHUkncYUHZC7xOtGLrLvQVyBmJ5r-nIo"
|
|
20
|
-
],
|
|
21
|
-
"createdAt": "2025-12-27T14:33:50.653Z"
|
|
22
|
-
}
|
package/test-dpop-flow.js
DELETED
|
@@ -1,148 +0,0 @@
|
|
|
1
|
-
import * as jose from 'jose';
|
|
2
|
-
import crypto from 'crypto';
|
|
3
|
-
|
|
4
|
-
const BASE = 'http://localhost:4000';
|
|
5
|
-
|
|
6
|
-
// Create DPoP proof
|
|
7
|
-
async function createDpopProof(privateKey, publicJwk, method, url, ath = null) {
|
|
8
|
-
const payload = {
|
|
9
|
-
jti: crypto.randomUUID(),
|
|
10
|
-
htm: method,
|
|
11
|
-
htu: url,
|
|
12
|
-
iat: Math.floor(Date.now() / 1000),
|
|
13
|
-
};
|
|
14
|
-
if (ath) payload.ath = ath;
|
|
15
|
-
|
|
16
|
-
return new jose.SignJWT(payload)
|
|
17
|
-
.setProtectedHeader({ alg: 'ES256', typ: 'dpop+jwt', jwk: publicJwk })
|
|
18
|
-
.sign(privateKey);
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
async function main() {
|
|
22
|
-
console.log('=== Testing DPoP Auth Flow ===\n');
|
|
23
|
-
|
|
24
|
-
// 1. Generate key pair
|
|
25
|
-
const { privateKey, publicKey } = await jose.generateKeyPair('ES256');
|
|
26
|
-
const publicJwk = await jose.exportJWK(publicKey);
|
|
27
|
-
const jkt = await jose.calculateJwkThumbprint(publicJwk, 'sha256');
|
|
28
|
-
console.log('1. Generated DPoP key pair, thumbprint:', jkt.substring(0, 20) + '...\n');
|
|
29
|
-
|
|
30
|
-
// 2. Register client dynamically
|
|
31
|
-
console.log('2. Registering client...');
|
|
32
|
-
const regRes = await fetch(`${BASE}/idp/reg`, {
|
|
33
|
-
method: 'POST',
|
|
34
|
-
headers: { 'Content-Type': 'application/json' },
|
|
35
|
-
body: JSON.stringify({
|
|
36
|
-
redirect_uris: ['https://tester'],
|
|
37
|
-
token_endpoint_auth_method: 'none',
|
|
38
|
-
grant_types: ['authorization_code'],
|
|
39
|
-
response_types: ['code'],
|
|
40
|
-
}),
|
|
41
|
-
});
|
|
42
|
-
const client = await regRes.json();
|
|
43
|
-
console.log(' Client ID:', client.client_id, '\n');
|
|
44
|
-
|
|
45
|
-
// 3. Generate PKCE
|
|
46
|
-
const codeVerifier = crypto.randomBytes(32).toString('base64url');
|
|
47
|
-
const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url');
|
|
48
|
-
console.log('3. Generated PKCE challenge\n');
|
|
49
|
-
|
|
50
|
-
// 4. Authorization request - WITH dpop_jkt parameter
|
|
51
|
-
console.log('4. Starting authorization (with dpop_jkt)...');
|
|
52
|
-
const authUrl = new URL(`${BASE}/idp/auth`);
|
|
53
|
-
authUrl.searchParams.set('client_id', client.client_id);
|
|
54
|
-
authUrl.searchParams.set('redirect_uri', 'https://tester');
|
|
55
|
-
authUrl.searchParams.set('response_type', 'code');
|
|
56
|
-
authUrl.searchParams.set('scope', 'openid');
|
|
57
|
-
authUrl.searchParams.set('code_challenge', codeChallenge);
|
|
58
|
-
authUrl.searchParams.set('code_challenge_method', 'S256');
|
|
59
|
-
authUrl.searchParams.set('dpop_jkt', jkt); // KEY: Include dpop_jkt!
|
|
60
|
-
|
|
61
|
-
const authRes = await fetch(authUrl, { redirect: 'manual' });
|
|
62
|
-
const interactionUrl = authRes.headers.get('location');
|
|
63
|
-
console.log(' Redirected to:', interactionUrl ? interactionUrl.substring(0, 50) + '...' : 'none');
|
|
64
|
-
console.log(' Status:', authRes.status, '\n');
|
|
65
|
-
|
|
66
|
-
// 5. Get interaction session cookie
|
|
67
|
-
const rawCookies = authRes.headers.get('set-cookie') || '';
|
|
68
|
-
// Extract just name=value from each Set-Cookie, ignore attributes
|
|
69
|
-
const cookieValues = rawCookies.split(/, (?=[^;]+=[^;]+)/).map(c => c.split(';')[0]).join('; ');
|
|
70
|
-
console.log('5. Got cookies:', cookieValues ? cookieValues.substring(0, 80) + '...' : 'none\n');
|
|
71
|
-
|
|
72
|
-
// 6. Login
|
|
73
|
-
console.log('6. Logging in...');
|
|
74
|
-
const uid = interactionUrl ? interactionUrl.match(/interaction\/([^/?]+)/)?.[1] : null;
|
|
75
|
-
if (!uid) {
|
|
76
|
-
console.log(' ERROR: No interaction UID found');
|
|
77
|
-
return;
|
|
78
|
-
}
|
|
79
|
-
const loginRes = await fetch(`${BASE}/idp/interaction/${uid}`, {
|
|
80
|
-
method: 'POST',
|
|
81
|
-
headers: {
|
|
82
|
-
'Content-Type': 'application/json',
|
|
83
|
-
Cookie: cookieValues,
|
|
84
|
-
},
|
|
85
|
-
body: JSON.stringify({ email: 'alice@example.com', password: 'alicepassword123' }),
|
|
86
|
-
});
|
|
87
|
-
let loginBody;
|
|
88
|
-
const loginText = await loginRes.text();
|
|
89
|
-
try {
|
|
90
|
-
loginBody = JSON.parse(loginText);
|
|
91
|
-
} catch (e) {
|
|
92
|
-
console.log(' Login response (text):', loginText.substring(0, 200));
|
|
93
|
-
return;
|
|
94
|
-
}
|
|
95
|
-
console.log(' Login response:', loginRes.status, loginBody.location ? loginBody.location.substring(0, 50) : '');
|
|
96
|
-
|
|
97
|
-
// 7. Follow auth resume
|
|
98
|
-
console.log('\n7. Following auth resume...');
|
|
99
|
-
const resumeUrl = loginBody.location;
|
|
100
|
-
if (!resumeUrl) {
|
|
101
|
-
console.log(' ERROR: No resume URL');
|
|
102
|
-
return;
|
|
103
|
-
}
|
|
104
|
-
const fullResumeUrl = resumeUrl.startsWith('http') ? resumeUrl : `${BASE}${resumeUrl}`;
|
|
105
|
-
const resumeRes = await fetch(fullResumeUrl, {
|
|
106
|
-
redirect: 'manual',
|
|
107
|
-
headers: { Cookie: cookieValues },
|
|
108
|
-
});
|
|
109
|
-
const callbackUrl = resumeRes.headers.get('location');
|
|
110
|
-
console.log(' Resume status:', resumeRes.status);
|
|
111
|
-
console.log(' Callback URL:', callbackUrl ? callbackUrl.substring(0, 80) + '...' : 'none');
|
|
112
|
-
|
|
113
|
-
// 8. Extract code
|
|
114
|
-
const codeMatch = callbackUrl ? callbackUrl.match(/code=([^&]+)/) : null;
|
|
115
|
-
const code = codeMatch ? codeMatch[1] : null;
|
|
116
|
-
if (!code) {
|
|
117
|
-
console.log(' ERROR: No code in callback');
|
|
118
|
-
return;
|
|
119
|
-
}
|
|
120
|
-
console.log(' Code:', code.substring(0, 20) + '...\n');
|
|
121
|
-
|
|
122
|
-
// 9. Token exchange with DPoP
|
|
123
|
-
console.log('8. Exchanging code for token (with DPoP)...');
|
|
124
|
-
const dpopProof = await createDpopProof(privateKey, publicJwk, 'POST', `${BASE}/idp/token`);
|
|
125
|
-
const tokenRes = await fetch(`${BASE}/idp/token`, {
|
|
126
|
-
method: 'POST',
|
|
127
|
-
headers: {
|
|
128
|
-
'Content-Type': 'application/x-www-form-urlencoded',
|
|
129
|
-
DPoP: dpopProof,
|
|
130
|
-
},
|
|
131
|
-
body: new URLSearchParams({
|
|
132
|
-
grant_type: 'authorization_code',
|
|
133
|
-
code: code,
|
|
134
|
-
redirect_uri: 'https://tester',
|
|
135
|
-
client_id: client.client_id,
|
|
136
|
-
code_verifier: codeVerifier,
|
|
137
|
-
}).toString(),
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
console.log(' Token response status:', tokenRes.status);
|
|
141
|
-
const tokenBody = await tokenRes.text();
|
|
142
|
-
console.log(' Token response:', tokenBody.substring(0, 300));
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
main().catch(err => {
|
|
146
|
-
console.error('Error:', err.message);
|
|
147
|
-
console.error(err.stack);
|
|
148
|
-
});
|