javascript-solid-server 0.0.13 → 0.0.16

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.
Files changed (49) hide show
  1. package/.claude/settings.local.json +27 -1
  2. package/CTH.md +222 -0
  3. package/README.md +92 -3
  4. package/bin/jss.js +11 -1
  5. package/cth-config/application.yaml +2 -0
  6. package/cth-config/jss.ttl +6 -0
  7. package/cth-config/test-subjects.ttl +14 -0
  8. package/cth.env +19 -0
  9. package/package.json +1 -1
  10. package/scripts/test-cth-compat.js +3 -2
  11. package/src/auth/middleware.js +17 -7
  12. package/src/auth/token.js +44 -1
  13. package/src/config.js +7 -0
  14. package/src/handlers/container.js +49 -16
  15. package/src/handlers/resource.js +99 -32
  16. package/src/idp/accounts.js +11 -2
  17. package/src/idp/credentials.js +38 -38
  18. package/src/idp/index.js +112 -21
  19. package/src/idp/interactions.js +123 -11
  20. package/src/idp/provider.js +68 -2
  21. package/src/rdf/turtle.js +15 -2
  22. package/src/server.js +24 -0
  23. package/src/utils/url.js +52 -0
  24. package/src/wac/parser.js +43 -1
  25. package/test/idp.test.js +17 -14
  26. package/test/ldp.test.js +10 -5
  27. package/test-data-idp-accounts/.idp/accounts/3c1cd503-1d7f-4ba0-a3af-ebedf519594d.json +9 -0
  28. package/test-data-idp-accounts/.idp/accounts/_email_index.json +3 -0
  29. package/test-data-idp-accounts/.idp/accounts/_webid_index.json +3 -0
  30. package/test-data-idp-accounts/.idp/keys/jwks.json +22 -0
  31. package/test-dpop-flow.js +148 -0
  32. package/test-subjects.ttl +21 -0
  33. package/data/alice/.acl +0 -50
  34. package/data/alice/inbox/.acl +0 -50
  35. package/data/alice/index.html +0 -80
  36. package/data/alice/private/.acl +0 -32
  37. package/data/alice/public/test.json +0 -1
  38. package/data/alice/settings/.acl +0 -32
  39. package/data/alice/settings/prefs +0 -17
  40. package/data/alice/settings/privateTypeIndex +0 -7
  41. package/data/alice/settings/publicTypeIndex +0 -7
  42. package/data/bob/.acl +0 -50
  43. package/data/bob/inbox/.acl +0 -50
  44. package/data/bob/index.html +0 -80
  45. package/data/bob/private/.acl +0 -32
  46. package/data/bob/settings/.acl +0 -32
  47. package/data/bob/settings/prefs +0 -17
  48. package/data/bob/settings/privateTypeIndex +0 -7
  49. package/data/bob/settings/publicTypeIndex +0 -7
@@ -1,17 +1,30 @@
1
1
  import * as storage from '../storage/filesystem.js';
2
2
  import { getAllHeaders } from '../ldp/headers.js';
3
- import { isContainer } from '../utils/url.js';
3
+ import { isContainer, getEffectiveUrlPath } from '../utils/url.js';
4
4
  import { generateProfile, generatePreferences, generateTypeIndex, serialize } from '../webid/profile.js';
5
- import { generateOwnerAcl, generatePrivateAcl, generateInboxAcl, serializeAcl } from '../wac/parser.js';
5
+ import { generateOwnerAcl, generatePrivateAcl, generateInboxAcl, generatePublicFolderAcl, serializeAcl } from '../wac/parser.js';
6
6
  import { createToken } from '../auth/token.js';
7
7
  import { canAcceptInput, toJsonLd, getVaryHeader, RDF_TYPES } from '../rdf/conneg.js';
8
8
  import { emitChange } from '../notifications/events.js';
9
9
 
10
+ /**
11
+ * Get the storage path and resource URL for a request
12
+ * In subdomain mode, storage path includes pod name, URL uses subdomain
13
+ */
14
+ function getRequestPaths(request) {
15
+ const urlPath = request.url.split('?')[0];
16
+ // Storage path - includes pod name in subdomain mode
17
+ const storagePath = getEffectiveUrlPath(request);
18
+ // Resource URL - uses the actual request hostname (subdomain in subdomain mode)
19
+ const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
20
+ return { urlPath, storagePath, resourceUrl };
21
+ }
22
+
10
23
  /**
11
24
  * Handle POST request to container (create new resource)
12
25
  */
13
26
  export async function handlePost(request, reply) {
14
- const urlPath = request.url.split('?')[0];
27
+ const { urlPath, storagePath } = getRequestPaths(request);
15
28
 
16
29
  // Ensure target is a container
17
30
  if (!isContainer(urlPath)) {
@@ -32,10 +45,10 @@ export async function handlePost(request, reply) {
32
45
  }
33
46
 
34
47
  // Check container exists
35
- const stats = await storage.stat(urlPath);
48
+ const stats = await storage.stat(storagePath);
36
49
  if (!stats || !stats.isDirectory) {
37
50
  // Create container if it doesn't exist
38
- await storage.createContainer(urlPath);
51
+ await storage.createContainer(storagePath);
39
52
  }
40
53
 
41
54
  // Get slug from header or generate UUID
@@ -46,13 +59,14 @@ export async function handlePost(request, reply) {
46
59
  const isCreatingContainer = linkHeader.includes('Container') || linkHeader.includes('BasicContainer');
47
60
 
48
61
  // Generate unique filename
49
- const filename = await storage.generateUniqueFilename(urlPath, slug, isCreatingContainer);
50
- const newPath = urlPath + filename + (isCreatingContainer ? '/' : '');
51
- const resourceUrl = `${request.protocol}://${request.hostname}${newPath}`;
62
+ const filename = await storage.generateUniqueFilename(storagePath, slug, isCreatingContainer);
63
+ const newUrlPath = urlPath + filename + (isCreatingContainer ? '/' : '');
64
+ const newStoragePath = storagePath + filename + (isCreatingContainer ? '/' : '');
65
+ const resourceUrl = `${request.protocol}://${request.hostname}${newUrlPath}`;
52
66
 
53
67
  let success;
54
68
  if (isCreatingContainer) {
55
- success = await storage.createContainer(newPath);
69
+ success = await storage.createContainer(newStoragePath);
56
70
  } else {
57
71
  // Get content from request body
58
72
  let content = request.body;
@@ -80,7 +94,7 @@ export async function handlePost(request, reply) {
80
94
  }
81
95
  }
82
96
 
83
- success = await storage.write(newPath, content);
97
+ success = await storage.write(newStoragePath, content);
84
98
  }
85
99
 
86
100
  if (!success) {
@@ -153,11 +167,26 @@ export async function handleCreatePod(request, reply) {
153
167
  }
154
168
 
155
169
  // Build URIs
156
- // WebID is at pod root: /alice/#me
157
- const baseUri = `${request.protocol}://${request.hostname}`;
158
- const podUri = `${baseUri}${podPath}`;
159
- const webId = `${podUri}#me`;
160
- const issuer = baseUri;
170
+ // WebID is at pod root: /alice/#me (path mode) or alice.example.com/#me (subdomain mode)
171
+ const subdomainsEnabled = request.subdomainsEnabled;
172
+ const baseDomain = request.baseDomain;
173
+
174
+ let baseUri, podUri, webId;
175
+ if (subdomainsEnabled && baseDomain) {
176
+ // Subdomain mode: alice.example.com/
177
+ const podHost = `${name}.${baseDomain}`;
178
+ baseUri = `${request.protocol}://${baseDomain}`;
179
+ podUri = `${request.protocol}://${podHost}/`;
180
+ webId = `${podUri}#me`;
181
+ } else {
182
+ // Path mode: example.com/alice/
183
+ baseUri = `${request.protocol}://${request.hostname}`;
184
+ podUri = `${baseUri}${podPath}`;
185
+ webId = `${podUri}#me`;
186
+ }
187
+
188
+ // Issuer needs trailing slash for CTH compatibility
189
+ const issuer = baseUri + '/';
161
190
 
162
191
  try {
163
192
  // Create pod directory structure
@@ -199,6 +228,10 @@ export async function handleCreatePod(request, reply) {
199
228
  const inboxAcl = generateInboxAcl(`${podUri}inbox/`, webId);
200
229
  await storage.write(`${podPath}inbox/.acl`, serializeAcl(inboxAcl));
201
230
 
231
+ // Public folder: owner full, public read (with inheritance)
232
+ const publicAcl = generatePublicFolderAcl(`${podUri}public/`, webId);
233
+ await storage.write(`${podPath}public/.acl`, serializeAcl(publicAcl));
234
+
202
235
  } catch (err) {
203
236
  console.error('Pod creation error:', err);
204
237
  // Cleanup on failure
@@ -223,7 +256,7 @@ export async function handleCreatePod(request, reply) {
223
256
  webId,
224
257
  podUri,
225
258
  idpIssuer: issuer,
226
- loginUrl: `${issuer}/idp/auth`,
259
+ loginUrl: `${baseUri}/idp/auth`,
227
260
  });
228
261
  } catch (err) {
229
262
  console.error('Account creation error:', err);
@@ -1,7 +1,7 @@
1
1
  import * as storage from '../storage/filesystem.js';
2
2
  import { getAllHeaders } from '../ldp/headers.js';
3
3
  import { generateContainerJsonLd, serializeJsonLd } from '../ldp/container.js';
4
- import { isContainer, getContentType, isRdfContentType } from '../utils/url.js';
4
+ import { isContainer, getContentType, isRdfContentType, getEffectiveUrlPath } from '../utils/url.js';
5
5
  import { parseN3Patch, applyN3Patch, validatePatch } from '../patch/n3-patch.js';
6
6
  import { parseSparqlUpdate, applySparqlUpdate } from '../patch/sparql-update.js';
7
7
  import {
@@ -15,12 +15,25 @@ import {
15
15
  import { emitChange } from '../notifications/events.js';
16
16
  import { checkIfMatch, checkIfNoneMatchForGet, checkIfNoneMatchForWrite } from '../utils/conditional.js';
17
17
 
18
+ /**
19
+ * Get the storage path and resource URL for a request
20
+ * In subdomain mode, storage path includes pod name, URL uses subdomain
21
+ */
22
+ function getRequestPaths(request) {
23
+ const urlPath = request.url.split('?')[0];
24
+ // Storage path - includes pod name in subdomain mode
25
+ const storagePath = getEffectiveUrlPath(request);
26
+ // Resource URL - uses the actual request hostname (subdomain in subdomain mode)
27
+ const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
28
+ return { urlPath, storagePath, resourceUrl };
29
+ }
30
+
18
31
  /**
19
32
  * Handle GET request
20
33
  */
21
34
  export async function handleGet(request, reply) {
22
- const urlPath = request.url.split('?')[0]; // Remove query string
23
- const stats = await storage.stat(urlPath);
35
+ const { urlPath, storagePath, resourceUrl } = getRequestPaths(request);
36
+ const stats = await storage.stat(storagePath);
24
37
 
25
38
  if (!stats) {
26
39
  return reply.code(404).send({ error: 'Not Found' });
@@ -36,14 +49,13 @@ export async function handleGet(request, reply) {
36
49
  }
37
50
 
38
51
  const origin = request.headers.origin;
39
- const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
40
52
 
41
53
  // Handle container
42
54
  if (stats.isDirectory) {
43
55
  const connegEnabled = request.connegEnabled || false;
44
56
 
45
57
  // Check for index.html (serves as both profile and container representation)
46
- const indexPath = urlPath.endsWith('/') ? `${urlPath}index.html` : `${urlPath}/index.html`;
58
+ const indexPath = storagePath.endsWith('/') ? `${storagePath}index.html` : `${storagePath}/index.html`;
47
59
  const indexExists = await storage.exists(indexPath);
48
60
 
49
61
  if (indexExists) {
@@ -51,6 +63,46 @@ export async function handleGet(request, reply) {
51
63
  const content = await storage.read(indexPath);
52
64
  const indexStats = await storage.stat(indexPath);
53
65
 
66
+ // Check if RDF format requested via content negotiation
67
+ const acceptHeader = request.headers.accept || '';
68
+ const wantsTurtle = connegEnabled && (
69
+ acceptHeader.includes('text/turtle') ||
70
+ acceptHeader.includes('text/n3') ||
71
+ acceptHeader.includes('application/n-triples')
72
+ );
73
+
74
+ if (wantsTurtle) {
75
+ // Extract JSON-LD from HTML and convert to Turtle
76
+ try {
77
+ const htmlStr = content.toString();
78
+ const jsonLdMatch = htmlStr.match(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/);
79
+ if (jsonLdMatch) {
80
+ const jsonLd = JSON.parse(jsonLdMatch[1]);
81
+ const { content: turtleContent } = await fromJsonLd(
82
+ jsonLd,
83
+ 'text/turtle',
84
+ resourceUrl,
85
+ true
86
+ );
87
+
88
+ const headers = getAllHeaders({
89
+ isContainer: true,
90
+ etag: indexStats?.etag || stats.etag,
91
+ contentType: 'text/turtle',
92
+ origin,
93
+ resourceUrl,
94
+ connegEnabled
95
+ });
96
+
97
+ Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
98
+ return reply.send(turtleContent);
99
+ }
100
+ } catch (err) {
101
+ // Fall through to serve HTML if conversion fails
102
+ console.error('Failed to convert profile to Turtle:', err.message);
103
+ }
104
+ }
105
+
54
106
  const headers = getAllHeaders({
55
107
  isContainer: true,
56
108
  etag: indexStats?.etag || stats.etag,
@@ -65,7 +117,7 @@ export async function handleGet(request, reply) {
65
117
  }
66
118
 
67
119
  // No index.html, return JSON-LD container listing
68
- const entries = await storage.listContainer(urlPath);
120
+ const entries = await storage.listContainer(storagePath);
69
121
  const jsonLd = generateContainerJsonLd(resourceUrl, entries || []);
70
122
 
71
123
  const headers = getAllHeaders({
@@ -82,12 +134,12 @@ export async function handleGet(request, reply) {
82
134
  }
83
135
 
84
136
  // Handle resource
85
- const content = await storage.read(urlPath);
137
+ const content = await storage.read(storagePath);
86
138
  if (content === null) {
87
139
  return reply.code(500).send({ error: 'Read error' });
88
140
  }
89
141
 
90
- const storedContentType = getContentType(urlPath);
142
+ const storedContentType = getContentType(storagePath);
91
143
  const connegEnabled = request.connegEnabled || false;
92
144
 
93
145
  // Content negotiation for RDF resources
@@ -144,16 +196,15 @@ export async function handleGet(request, reply) {
144
196
  * Handle HEAD request
145
197
  */
146
198
  export async function handleHead(request, reply) {
147
- const urlPath = request.url.split('?')[0];
148
- const stats = await storage.stat(urlPath);
199
+ const { storagePath, resourceUrl } = getRequestPaths(request);
200
+ const stats = await storage.stat(storagePath);
149
201
 
150
202
  if (!stats) {
151
203
  return reply.code(404).send();
152
204
  }
153
205
 
154
206
  const origin = request.headers.origin;
155
- const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
156
- const contentType = stats.isDirectory ? 'application/ld+json' : getContentType(urlPath);
207
+ const contentType = stats.isDirectory ? 'application/ld+json' : getContentType(storagePath);
157
208
 
158
209
  const headers = getAllHeaders({
159
210
  isContainer: stats.isDirectory,
@@ -175,16 +226,36 @@ export async function handleHead(request, reply) {
175
226
  * Handle PUT request
176
227
  */
177
228
  export async function handlePut(request, reply) {
178
- const urlPath = request.url.split('?')[0];
229
+ const { urlPath, storagePath, resourceUrl } = getRequestPaths(request);
230
+ const connegEnabled = request.connegEnabled || false;
179
231
 
180
- // Don't allow PUT to containers
232
+ // Handle container creation via PUT
181
233
  if (isContainer(urlPath)) {
182
- return reply.code(409).send({ error: 'Cannot PUT to container. Use POST instead.' });
234
+ const stats = await storage.stat(storagePath);
235
+ if (stats?.isDirectory) {
236
+ // Container already exists - don't allow PUT to modify
237
+ return reply.code(409).send({ error: 'Cannot PUT to existing container' });
238
+ }
239
+
240
+ // Create the container (and any intermediate containers)
241
+ const success = await storage.createContainer(storagePath);
242
+ if (!success) {
243
+ return reply.code(500).send({ error: 'Failed to create container' });
244
+ }
245
+
246
+ const origin = request.headers.origin;
247
+ const headers = getAllHeaders({
248
+ isContainer: true,
249
+ origin,
250
+ connegEnabled
251
+ });
252
+ headers['Location'] = resourceUrl;
253
+ Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
254
+ emitChange(request.protocol + '://' + request.hostname, urlPath, 'created');
255
+ return reply.code(201).send();
183
256
  }
184
257
 
185
- const connegEnabled = request.connegEnabled || false;
186
258
  const contentType = request.headers['content-type'] || '';
187
- const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
188
259
 
189
260
  // Check if we can accept this input type
190
261
  if (!canAcceptInput(contentType, connegEnabled)) {
@@ -197,7 +268,7 @@ export async function handlePut(request, reply) {
197
268
  }
198
269
 
199
270
  // Check if resource already exists and get current ETag
200
- const stats = await storage.stat(urlPath);
271
+ const stats = await storage.stat(storagePath);
201
272
  const existed = stats !== null;
202
273
  const currentEtag = stats?.etag || null;
203
274
 
@@ -247,7 +318,7 @@ export async function handlePut(request, reply) {
247
318
  }
248
319
  }
249
320
 
250
- const success = await storage.write(urlPath, content);
321
+ const success = await storage.write(storagePath, content);
251
322
  if (!success) {
252
323
  return reply.code(500).send({ error: 'Write failed' });
253
324
  }
@@ -271,10 +342,10 @@ export async function handlePut(request, reply) {
271
342
  * Handle DELETE request
272
343
  */
273
344
  export async function handleDelete(request, reply) {
274
- const urlPath = request.url.split('?')[0];
345
+ const { storagePath, resourceUrl } = getRequestPaths(request);
275
346
 
276
347
  // Check if resource exists and get current ETag
277
- const stats = await storage.stat(urlPath);
348
+ const stats = await storage.stat(storagePath);
278
349
  if (!stats) {
279
350
  return reply.code(404).send({ error: 'Not Found' });
280
351
  }
@@ -288,13 +359,12 @@ export async function handleDelete(request, reply) {
288
359
  }
289
360
  }
290
361
 
291
- const success = await storage.remove(urlPath);
362
+ const success = await storage.remove(storagePath);
292
363
  if (!success) {
293
364
  return reply.code(500).send({ error: 'Delete failed' });
294
365
  }
295
366
 
296
367
  const origin = request.headers.origin;
297
- const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
298
368
  const headers = getAllHeaders({ isContainer: false, origin, resourceUrl });
299
369
  Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
300
370
 
@@ -310,11 +380,10 @@ export async function handleDelete(request, reply) {
310
380
  * Handle OPTIONS request
311
381
  */
312
382
  export async function handleOptions(request, reply) {
313
- const urlPath = request.url.split('?')[0];
314
- const stats = await storage.stat(urlPath);
383
+ const { urlPath, storagePath, resourceUrl } = getRequestPaths(request);
384
+ const stats = await storage.stat(storagePath);
315
385
 
316
386
  const origin = request.headers.origin;
317
- const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
318
387
  const connegEnabled = request.connegEnabled || false;
319
388
  const headers = getAllHeaders({
320
389
  isContainer: stats?.isDirectory || isContainer(urlPath),
@@ -332,7 +401,7 @@ export async function handleOptions(request, reply) {
332
401
  * Supports N3 Patch format (text/n3) and SPARQL Update for updating RDF resources
333
402
  */
334
403
  export async function handlePatch(request, reply) {
335
- const urlPath = request.url.split('?')[0];
404
+ const { urlPath, storagePath, resourceUrl } = getRequestPaths(request);
336
405
 
337
406
  // Don't allow PATCH to containers
338
407
  if (isContainer(urlPath)) {
@@ -352,7 +421,7 @@ export async function handlePatch(request, reply) {
352
421
  }
353
422
 
354
423
  // Check if resource exists
355
- const stats = await storage.stat(urlPath);
424
+ const stats = await storage.stat(storagePath);
356
425
  if (!stats) {
357
426
  return reply.code(404).send({ error: 'Not Found' });
358
427
  }
@@ -367,7 +436,7 @@ export async function handlePatch(request, reply) {
367
436
  }
368
437
 
369
438
  // Read existing content
370
- const existingContent = await storage.read(urlPath);
439
+ const existingContent = await storage.read(storagePath);
371
440
  if (existingContent === null) {
372
441
  return reply.code(500).send({ error: 'Read error' });
373
442
  }
@@ -388,8 +457,6 @@ export async function handlePatch(request, reply) {
388
457
  ? request.body.toString()
389
458
  : request.body;
390
459
 
391
- const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
392
-
393
460
  let updatedDocument;
394
461
 
395
462
  if (isSparqlUpdate) {
@@ -436,7 +503,7 @@ export async function handlePatch(request, reply) {
436
503
 
437
504
  // Write updated document
438
505
  const updatedContent = JSON.stringify(updatedDocument, null, 2);
439
- const success = await storage.write(urlPath, Buffer.from(updatedContent));
506
+ const success = await storage.write(storagePath, Buffer.from(updatedContent));
440
507
 
441
508
  if (!success) {
442
509
  return reply.code(500).send({ error: 'Write failed' });
@@ -241,13 +241,22 @@ export async function getAccountForProvider(id) {
241
241
  // Always include webid for Solid-OIDC
242
242
  result.webid = account.webId;
243
243
 
244
+ // Handle scope being a string, array, Set, or object with keys
245
+ const hasScope = (s) => {
246
+ if (typeof scope === 'string') return scope.includes(s);
247
+ if (Array.isArray(scope)) return scope.includes(s);
248
+ if (scope instanceof Set) return scope.has(s);
249
+ if (scope && typeof scope === 'object') return s in scope || Object.keys(scope).includes(s);
250
+ return false;
251
+ };
252
+
244
253
  // Profile scope
245
- if (scope.includes('profile')) {
254
+ if (hasScope('profile')) {
246
255
  result.name = account.podName;
247
256
  }
248
257
 
249
258
  // Email scope
250
- if (scope.includes('email')) {
259
+ if (hasScope('email')) {
251
260
  result.email = account.email;
252
261
  result.email_verified = false; // We don't have email verification yet
253
262
  }
@@ -5,16 +5,15 @@
5
5
 
6
6
  import * as jose from 'jose';
7
7
  import crypto from 'crypto';
8
- import { authenticate, findByEmail } from './accounts.js';
8
+ import { authenticate } from './accounts.js';
9
9
  import { getJwks } from './keys.js';
10
- import { createToken as createSimpleToken } from '../auth/token.js';
11
10
 
12
11
  /**
13
12
  * Handle POST /idp/credentials
14
- * Accepts email/password and returns access token
13
+ * Accepts email/password (or username/password) and returns access token
15
14
  *
16
15
  * Request body (JSON or form):
17
- * - email: User email
16
+ * - email or username: User email address
18
17
  * - password: User password
19
18
  *
20
19
  * Optional headers:
@@ -47,22 +46,22 @@ export async function handleCredentials(request, reply, issuer) {
47
46
  // Not valid JSON
48
47
  }
49
48
  }
50
- email = body?.email;
49
+ email = body?.email || body?.username;
51
50
  password = body?.password;
52
51
  } else if (contentType.includes('application/x-www-form-urlencoded')) {
53
52
  // Parse form-encoded body
54
53
  if (typeof body === 'string') {
55
54
  const params = new URLSearchParams(body);
56
- email = params.get('email');
55
+ email = params.get('email') || params.get('username');
57
56
  password = params.get('password');
58
57
  } else if (typeof body === 'object') {
59
- email = body?.email;
58
+ email = body?.email || body?.username;
60
59
  password = body?.password;
61
60
  }
62
61
  } else {
63
62
  // Try to parse as object
64
63
  if (typeof body === 'object') {
65
- email = body?.email;
64
+ email = body?.email || body?.username;
66
65
  password = body?.password;
67
66
  }
68
67
  }
@@ -71,7 +70,7 @@ export async function handleCredentials(request, reply, issuer) {
71
70
  if (!email || !password) {
72
71
  return reply.code(400).send({
73
72
  error: 'invalid_request',
74
- error_description: 'Email and password are required',
73
+ error_description: 'Username/email and password are required',
75
74
  });
76
75
  }
77
76
 
@@ -92,7 +91,8 @@ export async function handleCredentials(request, reply, issuer) {
92
91
  if (dpopHeader) {
93
92
  try {
94
93
  // Validate DPoP proof and extract thumbprint
95
- dpopJkt = await validateDpopProof(dpopHeader, 'POST', `${issuer}/idp/credentials`);
94
+ const credUrl = `${issuer.replace(/\/$/, '')}/idp/credentials`;
95
+ dpopJkt = await validateDpopProof(dpopHeader, 'POST', credUrl);
96
96
  } catch (err) {
97
97
  return reply.code(400).send({
98
98
  error: 'invalid_dpop_proof',
@@ -102,39 +102,38 @@ export async function handleCredentials(request, reply, issuer) {
102
102
  }
103
103
 
104
104
  const expiresIn = 3600; // 1 hour
105
- let accessToken;
106
- let tokenType;
107
105
 
106
+ // Always generate a proper JWT - CTH requires JWT format
107
+ const jwks = await getJwks();
108
+ const signingKey = jwks.keys[0];
109
+ const privateKey = await jose.importJWK(signingKey, 'ES256');
110
+
111
+ const now = Math.floor(Date.now() / 1000);
112
+ const tokenPayload = {
113
+ iss: issuer,
114
+ sub: account.id,
115
+ aud: 'solid', // Solid-OIDC requires this audience
116
+ webid: account.webId,
117
+ iat: now,
118
+ exp: now + expiresIn,
119
+ jti: crypto.randomUUID(),
120
+ client_id: 'credentials_client',
121
+ scope: 'openid webid',
122
+ };
123
+
124
+ // Add DPoP binding confirmation if DPoP proof was provided
125
+ let tokenType;
108
126
  if (dpopJkt) {
109
- // Generate DPoP-bound JWT for Solid-OIDC clients
110
- const jwks = await getJwks();
111
- const signingKey = jwks.keys[0];
112
- const privateKey = await jose.importJWK(signingKey, 'ES256');
113
-
114
- const now = Math.floor(Date.now() / 1000);
115
- const tokenPayload = {
116
- iss: issuer,
117
- sub: account.id,
118
- aud: 'solid',
119
- webid: account.webId,
120
- iat: now,
121
- exp: now + expiresIn,
122
- jti: crypto.randomUUID(),
123
- client_id: 'credentials_client',
124
- scope: 'openid webid',
125
- cnf: { jkt: dpopJkt },
126
- };
127
-
128
- accessToken = await new jose.SignJWT(tokenPayload)
129
- .setProtectedHeader({ alg: 'ES256', kid: signingKey.kid })
130
- .sign(privateKey);
127
+ tokenPayload.cnf = { jkt: dpopJkt };
131
128
  tokenType = 'DPoP';
132
129
  } else {
133
- // Generate simple token for Bearer auth (development/testing)
134
- accessToken = createSimpleToken(account.webId, expiresIn);
135
130
  tokenType = 'Bearer';
136
131
  }
137
132
 
133
+ const accessToken = await new jose.SignJWT(tokenPayload)
134
+ .setProtectedHeader({ alg: 'ES256', kid: signingKey.kid })
135
+ .sign(privateKey);
136
+
138
137
  // Response
139
138
  const response = {
140
139
  access_token: accessToken,
@@ -206,10 +205,11 @@ export function handleCredentialsInfo(request, reply, issuer) {
206
205
  return {
207
206
  endpoint: `${issuer}/idp/credentials`,
208
207
  method: 'POST',
209
- description: 'Obtain access tokens using email and password',
208
+ description: 'Obtain access tokens using email/username and password',
210
209
  content_types: ['application/json', 'application/x-www-form-urlencoded'],
211
210
  parameters: {
212
- email: 'User email address',
211
+ email: 'User email address (or use "username")',
212
+ username: 'Alias for email (for CTH compatibility)',
213
213
  password: 'User password',
214
214
  },
215
215
  optional_headers: {