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.
@@ -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.17)
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 for browsing RDF resources
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 data browser | false |
143
- | `--mashlib-version <ver>` | Mashlib version | 2.0.0 |
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
- mashlibVersion: '2.0.0', // Mashlib version to use
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
- When enabled, requesting an RDF resource with `Accept: text/html` returns an interactive data browser UI instead of raw data. Mashlib is loaded from the unpkg CDN.
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 (loads JS/CSS from CDN)
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 for RDF resources')
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 to use (default: 2.0.0)')
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.mashlib) console.log(` Mashlib: v${config.mashlibVersion} (data browser enabled)`);
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.21",
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 two modes:
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.mashlib ? `v${config.mashlibVersion}` : 'disabled'}`);
206
+ console.log(` Mashlib: ${config.mashlibCdn ? `CDN v${config.mashlibVersion}` : config.mashlib ? 'local' : 'disabled'}`);
205
207
  console.log('─'.repeat(40));
206
208
  }
@@ -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
- const html = generateDatabrowserHtml(resourceUrl, request.mashlibVersion);
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
 
@@ -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
- * @param {string} resourceUrl - The URL of the resource being viewed
14
- * @param {string} version - Mashlib version (default: '2.0.0')
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, version = '2.0.0') {
18
- const cdnUrl = `${CDN_BASE}@${version}/dist`;
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
- return `<!doctype html>
21
- <html>
22
- <head>
23
- <meta charset="utf-8"/>
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
- const accept = request.headers.accept || '';
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);
@@ -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 .acl files into authorization rules
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 a JSON-LD ACL document
25
- * @param {string|object} content - JSON-LD content (string or parsed object)
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>} List of authorization rules
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
- try {
32
- doc = typeof content === 'string' ? JSON.parse(content) : content;
33
- } catch {
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);
@@ -1,2 +0,0 @@
1
- subjects: file:/config/test-subjects.ttl
2
- target: jss
@@ -1,6 +0,0 @@
1
- @prefix test-harness: <https://github.com/solid-contrib/specification-tests/> .
2
- @prefix solid-test: <https://github.com/solid-contrib/specification-tests/blob/main/vocab.ttl#> .
3
-
4
- <jss>
5
- a solid-test:TestSubject ;
6
- solid-test:serverRoot <http://localhost:4000/> .
@@ -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,3 +0,0 @@
1
- {
2
- "credtest@example.com": "318e7a79-23d5-4e0b-8fa1-b63cfaee87e1"
3
- }
@@ -1,3 +0,0 @@
1
- {
2
- "credtest": "318e7a79-23d5-4e0b-8fa1-b63cfaee87e1"
3
- }
@@ -1,3 +0,0 @@
1
- {
2
- "http://localhost:3101/credtest/#me": "318e7a79-23d5-4e0b-8fa1-b63cfaee87e1"
3
- }
@@ -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
- });