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.
- package/.claude/settings.local.json +27 -1
- package/CTH.md +222 -0
- package/README.md +92 -3
- package/bin/jss.js +11 -1
- package/cth-config/application.yaml +2 -0
- package/cth-config/jss.ttl +6 -0
- package/cth-config/test-subjects.ttl +14 -0
- package/cth.env +19 -0
- package/package.json +1 -1
- package/scripts/test-cth-compat.js +3 -2
- package/src/auth/middleware.js +17 -7
- package/src/auth/token.js +44 -1
- package/src/config.js +7 -0
- package/src/handlers/container.js +49 -16
- package/src/handlers/resource.js +99 -32
- package/src/idp/accounts.js +11 -2
- package/src/idp/credentials.js +38 -38
- package/src/idp/index.js +112 -21
- package/src/idp/interactions.js +123 -11
- package/src/idp/provider.js +68 -2
- package/src/rdf/turtle.js +15 -2
- package/src/server.js +24 -0
- package/src/utils/url.js +52 -0
- package/src/wac/parser.js +43 -1
- package/test/idp.test.js +17 -14
- package/test/ldp.test.js +10 -5
- package/test-data-idp-accounts/.idp/accounts/3c1cd503-1d7f-4ba0-a3af-ebedf519594d.json +9 -0
- package/test-data-idp-accounts/.idp/accounts/_email_index.json +3 -0
- package/test-data-idp-accounts/.idp/accounts/_webid_index.json +3 -0
- package/test-data-idp-accounts/.idp/keys/jwks.json +22 -0
- package/test-dpop-flow.js +148 -0
- package/test-subjects.ttl +21 -0
- package/data/alice/.acl +0 -50
- package/data/alice/inbox/.acl +0 -50
- package/data/alice/index.html +0 -80
- package/data/alice/private/.acl +0 -32
- package/data/alice/public/test.json +0 -1
- package/data/alice/settings/.acl +0 -32
- package/data/alice/settings/prefs +0 -17
- package/data/alice/settings/privateTypeIndex +0 -7
- package/data/alice/settings/publicTypeIndex +0 -7
- package/data/bob/.acl +0 -50
- package/data/bob/inbox/.acl +0 -50
- package/data/bob/index.html +0 -80
- package/data/bob/private/.acl +0 -32
- package/data/bob/settings/.acl +0 -32
- package/data/bob/settings/prefs +0 -17
- package/data/bob/settings/privateTypeIndex +0 -7
- 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
|
|
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(
|
|
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(
|
|
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(
|
|
50
|
-
const
|
|
51
|
-
const
|
|
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(
|
|
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(
|
|
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
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
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: `${
|
|
259
|
+
loginUrl: `${baseUri}/idp/auth`,
|
|
227
260
|
});
|
|
228
261
|
} catch (err) {
|
|
229
262
|
console.error('Account creation error:', err);
|
package/src/handlers/resource.js
CHANGED
|
@@ -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
|
|
23
|
-
const stats = await storage.stat(
|
|
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 =
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
148
|
-
const stats = await storage.stat(
|
|
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
|
|
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
|
|
229
|
+
const { urlPath, storagePath, resourceUrl } = getRequestPaths(request);
|
|
230
|
+
const connegEnabled = request.connegEnabled || false;
|
|
179
231
|
|
|
180
|
-
//
|
|
232
|
+
// Handle container creation via PUT
|
|
181
233
|
if (isContainer(urlPath)) {
|
|
182
|
-
|
|
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(
|
|
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(
|
|
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
|
|
345
|
+
const { storagePath, resourceUrl } = getRequestPaths(request);
|
|
275
346
|
|
|
276
347
|
// Check if resource exists and get current ETag
|
|
277
|
-
const stats = await storage.stat(
|
|
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(
|
|
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
|
|
314
|
-
const stats = await storage.stat(
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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' });
|
package/src/idp/accounts.js
CHANGED
|
@@ -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 (
|
|
254
|
+
if (hasScope('profile')) {
|
|
246
255
|
result.name = account.podName;
|
|
247
256
|
}
|
|
248
257
|
|
|
249
258
|
// Email scope
|
|
250
|
-
if (
|
|
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
|
}
|
package/src/idp/credentials.js
CHANGED
|
@@ -5,16 +5,15 @@
|
|
|
5
5
|
|
|
6
6
|
import * as jose from 'jose';
|
|
7
7
|
import crypto from 'crypto';
|
|
8
|
-
import { authenticate
|
|
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: '
|
|
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
|
-
|
|
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
|
-
|
|
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: {
|