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/.claude/settings.local.json +27 -1
- package/CTH.md +222 -0
- package/README.md +55 -2
- package/bin/jss.js +5 -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 +2 -1
- package/scripts/test-cth-compat.js +370 -0
- package/src/auth/middleware.js +6 -2
- package/src/auth/token.js +44 -1
- package/src/handlers/container.js +8 -3
- package/src/handlers/resource.js +65 -4
- package/src/idp/accounts.js +11 -2
- package/src/idp/credentials.js +225 -0
- package/src/idp/index.js +129 -21
- package/src/idp/interactions.js +123 -11
- package/src/idp/provider.js +68 -2
- package/src/rdf/turtle.js +15 -2
- package/src/wac/parser.js +43 -1
- package/test/idp.test.js +173 -1
- package/test/ldp.test.js +10 -5
- package/test-data-idp-accounts/.idp/accounts/292738d6-3363-4f40-9a6b-884bfd17830a.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/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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
153
|
-
|
|
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,
|
|
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,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 .
|