javascript-solid-server 0.0.12 → 0.0.15

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/src/rdf/turtle.js CHANGED
@@ -199,9 +199,13 @@ function jsonLdToQuads(jsonLd, baseUri) {
199
199
  const predicateUri = expandUri(key, context);
200
200
  const predicate = namedNode(predicateUri);
201
201
 
202
+ // Check if context specifies this property should be a URI (@type: "@id")
203
+ const propContext = context[key];
204
+ const isIdType = propContext && typeof propContext === 'object' && propContext['@type'] === '@id';
205
+
202
206
  const values = Array.isArray(value) ? value : [value];
203
207
  for (const v of values) {
204
- const object = valueToTerm(v, baseUri, context);
208
+ const object = valueToTerm(v, baseUri, context, isIdType);
205
209
  if (object) {
206
210
  quads.push(quad(subject, predicate, object));
207
211
  }
@@ -265,14 +269,23 @@ function termToJsonLd(term, baseUri, prefixes) {
265
269
 
266
270
  /**
267
271
  * Convert JSON-LD value to N3.js term
272
+ * @param {any} value - The value to convert
273
+ * @param {string} baseUri - Base URI for resolving relative URIs
274
+ * @param {object} context - JSON-LD context
275
+ * @param {boolean} isIdType - Whether the property context specifies @type: "@id"
268
276
  */
269
- function valueToTerm(value, baseUri, context) {
277
+ function valueToTerm(value, baseUri, context, isIdType = false) {
270
278
  if (value === null || value === undefined) {
271
279
  return null;
272
280
  }
273
281
 
274
282
  // Plain values
275
283
  if (typeof value === 'string') {
284
+ // If context says this should be a URI, treat it as a named node
285
+ if (isIdType) {
286
+ const uri = resolveUri(value, baseUri);
287
+ return namedNode(uri);
288
+ }
276
289
  return literal(value);
277
290
  }
278
291
  if (typeof value === 'number') {
package/src/wac/parser.js CHANGED
@@ -190,9 +190,11 @@ export function generateOwnerAcl(resourceUrl, ownerWebId, isContainer = false) {
190
190
  ];
191
191
 
192
192
  // Add default rules for containers
193
+ // Only owner gets default - children don't inherit public read
193
194
  if (isContainer) {
194
195
  graph[0]['acl:default'] = { '@id': resourceUrl };
195
- graph[1]['acl:default'] = { '@id': resourceUrl };
196
+ // Note: intentionally not adding default to #public
197
+ // so child resources require authentication by default
196
198
  }
197
199
 
198
200
  return {
@@ -276,6 +278,46 @@ export function generateInboxAcl(resourceUrl, ownerWebId) {
276
278
  };
277
279
  }
278
280
 
281
+ /**
282
+ * Generate a public folder ACL (owner full control, public read with inheritance)
283
+ * Used for /public/ folders where content should be publicly readable
284
+ * @param {string} resourceUrl - URL of the folder
285
+ * @param {string} ownerWebId - WebID of the owner
286
+ * @returns {object} JSON-LD ACL document
287
+ */
288
+ export function generatePublicFolderAcl(resourceUrl, ownerWebId) {
289
+ return {
290
+ '@context': {
291
+ 'acl': ACL,
292
+ 'foaf': FOAF
293
+ },
294
+ '@graph': [
295
+ {
296
+ '@id': '#owner',
297
+ '@type': 'acl:Authorization',
298
+ 'acl:agent': { '@id': ownerWebId },
299
+ 'acl:accessTo': { '@id': resourceUrl },
300
+ 'acl:default': { '@id': resourceUrl },
301
+ 'acl:mode': [
302
+ { '@id': 'acl:Read' },
303
+ { '@id': 'acl:Write' },
304
+ { '@id': 'acl:Control' }
305
+ ]
306
+ },
307
+ {
308
+ '@id': '#public',
309
+ '@type': 'acl:Authorization',
310
+ 'acl:agentClass': { '@id': 'foaf:Agent' },
311
+ 'acl:accessTo': { '@id': resourceUrl },
312
+ 'acl:default': { '@id': resourceUrl },
313
+ 'acl:mode': [
314
+ { '@id': 'acl:Read' }
315
+ ]
316
+ }
317
+ ]
318
+ };
319
+ }
320
+
279
321
  /**
280
322
  * Serialize ACL to JSON string
281
323
  */
package/test/idp.test.js CHANGED
@@ -43,7 +43,8 @@ describe('Identity Provider', () => {
43
43
  assert.strictEqual(res.status, 200);
44
44
 
45
45
  const config = await res.json();
46
- assert.strictEqual(config.issuer, BASE_URL);
46
+ // Issuer has trailing slash for CTH compatibility
47
+ assert.strictEqual(config.issuer, BASE_URL + '/');
47
48
  assert.ok(config.authorization_endpoint);
48
49
  assert.ok(config.token_endpoint);
49
50
  assert.ok(config.jwks_uri);
@@ -256,3 +257,174 @@ describe('Identity Provider - Accounts', () => {
256
257
  assert.ok(!account.password, 'should not store plain password');
257
258
  });
258
259
  });
260
+
261
+ describe('Identity Provider - Credentials Endpoint', () => {
262
+ let server;
263
+ // Use same data dir as other tests (DATA_ROOT is cached at module load)
264
+ const CREDS_DATA_DIR = './data';
265
+ const CREDS_PORT = 3101;
266
+ const CREDS_URL = `http://${TEST_HOST}:${CREDS_PORT}`;
267
+
268
+ before(async () => {
269
+ await fs.emptyDir(CREDS_DATA_DIR);
270
+
271
+ server = createServer({
272
+ logger: false,
273
+ idp: true,
274
+ idpIssuer: CREDS_URL,
275
+ });
276
+
277
+ await server.listen({ port: CREDS_PORT, host: TEST_HOST });
278
+
279
+ // Create a test user
280
+ const res = await fetch(`${CREDS_URL}/.pods`, {
281
+ method: 'POST',
282
+ headers: { 'Content-Type': 'application/json' },
283
+ body: JSON.stringify({
284
+ name: 'credtest',
285
+ email: 'credtest@example.com',
286
+ password: 'testpassword123',
287
+ }),
288
+ });
289
+ if (!res.ok) {
290
+ throw new Error(`Failed to create test user: ${res.status} ${await res.text()}`);
291
+ }
292
+ });
293
+
294
+ after(async () => {
295
+ await server.close();
296
+ await fs.emptyDir(CREDS_DATA_DIR);
297
+ });
298
+
299
+ describe('GET /idp/credentials', () => {
300
+ it('should return endpoint info', async () => {
301
+ const res = await fetch(`${CREDS_URL}/idp/credentials`);
302
+ assert.strictEqual(res.status, 200);
303
+
304
+ const info = await res.json();
305
+ assert.ok(info.endpoint);
306
+ assert.strictEqual(info.method, 'POST');
307
+ assert.ok(info.parameters.email);
308
+ assert.ok(info.parameters.password);
309
+ });
310
+ });
311
+
312
+ describe('POST /idp/credentials', () => {
313
+ it('should return 400 for missing credentials', async () => {
314
+ const res = await fetch(`${CREDS_URL}/idp/credentials`, {
315
+ method: 'POST',
316
+ headers: { 'Content-Type': 'application/json' },
317
+ body: JSON.stringify({}),
318
+ });
319
+
320
+ assert.strictEqual(res.status, 400);
321
+ const body = await res.json();
322
+ assert.strictEqual(body.error, 'invalid_request');
323
+ });
324
+
325
+ it('should return 401 for wrong password', async () => {
326
+ const res = await fetch(`${CREDS_URL}/idp/credentials`, {
327
+ method: 'POST',
328
+ headers: { 'Content-Type': 'application/json' },
329
+ body: JSON.stringify({
330
+ email: 'credtest@example.com',
331
+ password: 'wrongpassword',
332
+ }),
333
+ });
334
+
335
+ assert.strictEqual(res.status, 401);
336
+ const body = await res.json();
337
+ assert.strictEqual(body.error, 'invalid_grant');
338
+ });
339
+
340
+ it('should return 401 for unknown email', async () => {
341
+ const res = await fetch(`${CREDS_URL}/idp/credentials`, {
342
+ method: 'POST',
343
+ headers: { 'Content-Type': 'application/json' },
344
+ body: JSON.stringify({
345
+ email: 'unknown@example.com',
346
+ password: 'anypassword',
347
+ }),
348
+ });
349
+
350
+ assert.strictEqual(res.status, 401);
351
+ });
352
+
353
+ it('should return access token for valid credentials', async () => {
354
+ const res = await fetch(`${CREDS_URL}/idp/credentials`, {
355
+ method: 'POST',
356
+ headers: { 'Content-Type': 'application/json' },
357
+ body: JSON.stringify({
358
+ email: 'credtest@example.com',
359
+ password: 'testpassword123',
360
+ }),
361
+ });
362
+
363
+ assert.strictEqual(res.status, 200);
364
+ const body = await res.json();
365
+
366
+ assert.ok(body.access_token, 'should have access_token');
367
+ assert.strictEqual(body.token_type, 'Bearer');
368
+ assert.ok(body.expires_in > 0, 'should have expires_in');
369
+ assert.ok(body.webid.includes('credtest'), 'should have webid');
370
+ });
371
+
372
+ it('should return JWT token with webid claim', async () => {
373
+ const res = await fetch(`${CREDS_URL}/idp/credentials`, {
374
+ method: 'POST',
375
+ headers: { 'Content-Type': 'application/json' },
376
+ body: JSON.stringify({
377
+ email: 'credtest@example.com',
378
+ password: 'testpassword123',
379
+ }),
380
+ });
381
+
382
+ const body = await res.json();
383
+
384
+ // JWT tokens have format: header.payload.signature
385
+ const parts = body.access_token.split('.');
386
+ assert.strictEqual(parts.length, 3, 'JWT token has 3 parts');
387
+
388
+ // Decode the payload (second part)
389
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
390
+
391
+ assert.ok(payload.webid, 'token should have webid claim');
392
+ assert.ok(payload.webid.includes('credtest'), 'webid should reference user');
393
+ assert.ok(payload.exp > payload.iat, 'should have valid expiry');
394
+ });
395
+
396
+ it('should work with form-encoded body', async () => {
397
+ const res = await fetch(`${CREDS_URL}/idp/credentials`, {
398
+ method: 'POST',
399
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
400
+ body: 'email=credtest%40example.com&password=testpassword123',
401
+ });
402
+
403
+ assert.strictEqual(res.status, 200);
404
+ const body = await res.json();
405
+ assert.ok(body.access_token);
406
+ });
407
+
408
+ it('should allow using token to access protected resource', async () => {
409
+ // Get access token
410
+ const tokenRes = await fetch(`${CREDS_URL}/idp/credentials`, {
411
+ method: 'POST',
412
+ headers: { 'Content-Type': 'application/json' },
413
+ body: JSON.stringify({
414
+ email: 'credtest@example.com',
415
+ password: 'testpassword123',
416
+ }),
417
+ });
418
+
419
+ const { access_token } = await tokenRes.json();
420
+
421
+ // Try to access private resource
422
+ const res = await fetch(`${CREDS_URL}/credtest/private/`, {
423
+ headers: { 'Authorization': `Bearer ${access_token}` },
424
+ });
425
+
426
+ // Should succeed (not 401/403)
427
+ assert.ok([200, 404].includes(res.status), `expected 200 or 404, got ${res.status}`);
428
+ });
429
+ });
430
+ });
package/test/ldp.test.js CHANGED
@@ -29,7 +29,8 @@ describe('LDP CRUD Operations', () => {
29
29
 
30
30
  describe('GET', () => {
31
31
  it('should return 404 for non-existent resource', async () => {
32
- const res = await request('/ldptest/nonexistent.json');
32
+ // Must use /public/ path for unauthenticated access
33
+ const res = await request('/ldptest/public/nonexistent.json');
33
34
  assertStatus(res, 404);
34
35
  });
35
36
 
@@ -149,14 +150,18 @@ describe('LDP CRUD Operations', () => {
149
150
  assertStatus(parent, 200);
150
151
  });
151
152
 
152
- it('should reject PUT to container path', async () => {
153
- const res = await request('/ldptest/public/invalid/', {
153
+ it('should create container with PUT to path ending in slash', async () => {
154
+ // Solid spec: PUT to path with trailing / creates container
155
+ const res = await request('/ldptest/public/new-container/', {
154
156
  method: 'PUT',
155
- body: 'cannot put to container',
156
157
  auth: 'ldptest'
157
158
  });
158
159
 
159
- assertStatus(res, 409);
160
+ assertStatus(res, 201);
161
+
162
+ // Verify it's a container
163
+ const verify = await request('/ldptest/public/new-container/');
164
+ assertHeaderContains(verify, 'Link', 'Container');
160
165
  });
161
166
  });
162
167
 
@@ -0,0 +1,9 @@
1
+ {
2
+ "id": "292738d6-3363-4f40-9a6b-884bfd17830a",
3
+ "email": "credtest@example.com",
4
+ "passwordHash": "$2b$10$tvcMaMvecS7noqe/T/A5Q.VojfNu1FEPAzWhl/.3v7WXrVIH38iYC",
5
+ "webId": "http://localhost:3101/credtest/#me",
6
+ "podName": "credtest",
7
+ "createdAt": "2025-12-27T12:23:13.338Z",
8
+ "lastLogin": "2025-12-27T12:23:13.871Z"
9
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "credtest@example.com": "292738d6-3363-4f40-9a6b-884bfd17830a"
3
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "http://localhost:3101/credtest/#me": "292738d6-3363-4f40-9a6b-884bfd17830a"
3
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "jwks": {
3
+ "keys": [
4
+ {
5
+ "kty": "EC",
6
+ "x": "1gMgS0xMseqjfq5fA_aYkkq7CMqr6OOQ5ZS4D3MqG6g",
7
+ "y": "rtkAdN0tManytaX1QDFRBRE6GXoOlxqj_d3Yt5mpViA",
8
+ "crv": "P-256",
9
+ "d": "GqEv1nO1PRgrKE7n18iDNow-haou-7B6_dlMqo-ftLQ",
10
+ "kid": "102e3c82-7dda-4a6f-a296-d47d9b2e0b59",
11
+ "use": "sig",
12
+ "alg": "ES256",
13
+ "iat": 1766838193
14
+ }
15
+ ]
16
+ },
17
+ "cookieKeys": [
18
+ "PQdsUKa6PcaWNBEUm9G3IZumxoXHnd93rUcyf9VYc0w",
19
+ "T4X4hUYp3dE9LakGJX9U5fRux5pyldcrpg_t8AA4FYg"
20
+ ],
21
+ "createdAt": "2025-12-27T12:23:13.203Z"
22
+ }
@@ -0,0 +1,148 @@
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
+ });
@@ -0,0 +1,21 @@
1
+ @base <https://github.com/solid/conformance-test-harness/> .
2
+ @prefix solid-test: <https://github.com/solid/conformance-test-harness/vocab#> .
3
+ @prefix doap: <http://usefulinc.com/ns/doap#> .
4
+ @prefix earl: <http://www.w3.org/ns/earl#> .
5
+ @prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
6
+ @prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
7
+
8
+ <jss>
9
+ a earl:Software, earl:TestSubject ;
10
+ doap:name "JavaScript Solid Server"@en ;
11
+ doap:release <jss#test-subject-release> ;
12
+ doap:developer <https://github.com/JavaScriptSolidServer> ;
13
+ doap:homepage <https://github.com/JavaScriptSolidServer/JavaScriptSolidServer> ;
14
+ doap:description "A minimal, fast, JSON-LD native Solid server."@en ;
15
+ doap:programming-language "JavaScript"@en ;
16
+ solid-test:skip "acp", "wac", "wac-allow-public" ;
17
+ rdfs:comment "JSON-LD first Solid server with built-in IdP"@en .
18
+
19
+ <jss#test-subject-release>
20
+ doap:revision "0.0.14"@en ;
21
+ doap:created "2025-12-27"^^xsd:date .