javascript-solid-server 0.0.173 → 0.0.175
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/bin/jss.js +6 -2
- package/package.json +1 -1
- package/src/idp/credentials.js +145 -1
- package/src/idp/index.js +16 -0
- package/src/webid/profile.js +33 -1
- package/test/idp-delete-account.test.js +334 -0
- package/test/webid.test.js +47 -0
package/bin/jss.js
CHANGED
|
@@ -773,11 +773,15 @@ accountCmd
|
|
|
773
773
|
|
|
774
774
|
if (options.purge) {
|
|
775
775
|
const dataRoot = process.env.DATA_ROOT || './data';
|
|
776
|
-
|
|
776
|
+
// Use podName, not username — createAccount lowercases the
|
|
777
|
+
// username but pod directories on disk preserve the original
|
|
778
|
+
// case. On case-sensitive filesystems they can differ.
|
|
779
|
+
const podPath = path.join(dataRoot, account.podName || account.username);
|
|
777
780
|
await fs.remove(podPath);
|
|
778
781
|
console.log(`\nDeleted account ${account.username}. Pod data removed from ${podPath}.\n`);
|
|
779
782
|
} else {
|
|
780
|
-
|
|
783
|
+
const podDir = account.podName || account.username;
|
|
784
|
+
console.log(`\nDeleted account ${account.username}. Pod data preserved at <dataRoot>/${podDir}/ (use --purge to remove).\n`);
|
|
781
785
|
}
|
|
782
786
|
} catch (err) {
|
|
783
787
|
console.error(`Error: ${err.message}`);
|
package/package.json
CHANGED
package/src/idp/credentials.js
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
|
|
6
6
|
import * as jose from 'jose';
|
|
7
7
|
import crypto from 'crypto';
|
|
8
|
-
import
|
|
8
|
+
import fs from 'fs-extra';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import { authenticate, findByWebId, updatePassword, verifyPassword, deleteAccount } from './accounts.js';
|
|
9
11
|
import { getJwks } from './keys.js';
|
|
10
12
|
import { getWebIdFromRequestAsync } from '../auth/token.js';
|
|
11
13
|
|
|
@@ -272,6 +274,148 @@ export async function handleChangePassword(request, reply) {
|
|
|
272
274
|
};
|
|
273
275
|
}
|
|
274
276
|
|
|
277
|
+
/**
|
|
278
|
+
* Handle DELETE /idp/account (#352)
|
|
279
|
+
*
|
|
280
|
+
* Owner-initiated account deletion. Authenticated caller proves
|
|
281
|
+
* possession via re-entering currentPassword (matches the
|
|
282
|
+
* password-rotation pattern in #351). Optional `purgeData: true` also
|
|
283
|
+
* removes the pod's filesystem tree at `<dataRoot>/<podName>/` (falling
|
|
284
|
+
* back to `<username>` only if podName is absent on the account record).
|
|
285
|
+
*
|
|
286
|
+
* Failure modes:
|
|
287
|
+
* 401 — unauthenticated, or wrong currentPassword
|
|
288
|
+
* 400 — invalid request body / missing password
|
|
289
|
+
* 403 — single-user mode (deletion would brick the server until
|
|
290
|
+
* re-seed; operator should use the CLI), or no account for the
|
|
291
|
+
* caller's WebID. The "no account" case lands here rather than
|
|
292
|
+
* 404 because the caller had a valid token — they're proving
|
|
293
|
+
* identity, just not for an account this server holds.
|
|
294
|
+
*
|
|
295
|
+
* Out of scope: invalidating in-flight access tokens. Tokens reference
|
|
296
|
+
* the WebID; once the account record is gone, follow-up auth attempts
|
|
297
|
+
* fail at findByWebId(). Existing bearer tokens that don't round-trip
|
|
298
|
+
* through findByWebId() will appear valid until they expire — same
|
|
299
|
+
* shape as the password-change endpoint.
|
|
300
|
+
*
|
|
301
|
+
* @param {object} request - Fastify request
|
|
302
|
+
* @param {object} reply - Fastify reply
|
|
303
|
+
* @param {object} options
|
|
304
|
+
* @param {boolean} [options.singleUser] - When true, the endpoint
|
|
305
|
+
* refuses (deletion would leave the server with no IDP account).
|
|
306
|
+
*/
|
|
307
|
+
export async function handleDeleteAccount(request, reply, options = {}) {
|
|
308
|
+
// Single-user mode: deletion via HTTP is blocked. The single-user
|
|
309
|
+
// pod has exactly one account; deleting it bricks the server until
|
|
310
|
+
// re-seed. The CLI (`jss account delete`) stays available for the
|
|
311
|
+
// operator who has filesystem access.
|
|
312
|
+
if (options.singleUser) {
|
|
313
|
+
return reply.code(403).send({
|
|
314
|
+
error: 'forbidden',
|
|
315
|
+
error_description: 'Account deletion via HTTP is disabled in single-user mode. Use the `jss account delete` CLI on the server.',
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// 1. Authenticate caller
|
|
320
|
+
const { webId, error: authError } = await getWebIdFromRequestAsync(request);
|
|
321
|
+
if (!webId) {
|
|
322
|
+
return reply.code(401).send({
|
|
323
|
+
error: 'invalid_token',
|
|
324
|
+
error_description: authError || 'Authentication required',
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// 2. Parse body — same flexible shape as handleChangePassword
|
|
329
|
+
let body = request.body;
|
|
330
|
+
if (Buffer.isBuffer(body)) body = body.toString('utf-8');
|
|
331
|
+
if (typeof body === 'string') {
|
|
332
|
+
try { body = JSON.parse(body); } catch { body = {}; }
|
|
333
|
+
}
|
|
334
|
+
const currentPassword = body?.currentPassword;
|
|
335
|
+
const purgeData = body?.purgeData === true;
|
|
336
|
+
|
|
337
|
+
if (typeof currentPassword !== 'string' || !currentPassword) {
|
|
338
|
+
return reply.code(400).send({
|
|
339
|
+
error: 'invalid_request',
|
|
340
|
+
error_description: 'currentPassword is required (string)',
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// 3. Resolve account from caller's WebID
|
|
345
|
+
const account = await findByWebId(webId);
|
|
346
|
+
if (!account) {
|
|
347
|
+
return reply.code(403).send({
|
|
348
|
+
error: 'forbidden',
|
|
349
|
+
error_description: 'No account found for authenticated WebID',
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// 4. Verify currentPassword (re-auth proof)
|
|
354
|
+
if (!(await verifyPassword(account, currentPassword))) {
|
|
355
|
+
return reply.code(401).send({
|
|
356
|
+
error: 'invalid_grant',
|
|
357
|
+
error_description: 'Current password is incorrect',
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// 5. Delete the account record + indexes
|
|
362
|
+
await deleteAccount(account.id);
|
|
363
|
+
|
|
364
|
+
// 6. Optionally purge the pod's filesystem data. Mirrors the CLI
|
|
365
|
+
// `--purge` semantics. The path is `<dataRoot>/<podName>/`.
|
|
366
|
+
//
|
|
367
|
+
// Use account.podName, NOT account.username: createAccount normalizes
|
|
368
|
+
// username to lowercase (`username.toLowerCase().trim()`) but the pod
|
|
369
|
+
// directory on disk is created with the original case (per the input
|
|
370
|
+
// to handleCreatePod). On case-sensitive filesystems, deriving the
|
|
371
|
+
// purge path from username would either no-op (path doesn't exist)
|
|
372
|
+
// or hit a different directory if one exists at the lowercased name.
|
|
373
|
+
// Pod-name validation regex is /^[a-zA-Z0-9_-]+$/ (alphanum + dash +
|
|
374
|
+
// underscore; no dots, no traversal sequences) so podName is safe to
|
|
375
|
+
// join — defensive normalize stays as belt-and-suspenders.
|
|
376
|
+
//
|
|
377
|
+
// Best-effort: if fs.remove throws (permissions, transient FS error,
|
|
378
|
+
// race with another consumer), the account is already deleted and we
|
|
379
|
+
// shouldn't 500 over the leftover files. Log server-side and return
|
|
380
|
+
// purged: false so the caller knows pod data may still exist; an
|
|
381
|
+
// operator can finish the cleanup with a follow-up `rm -rf` or
|
|
382
|
+
// CLI `--purge` against the now-orphaned directory.
|
|
383
|
+
let purged = false;
|
|
384
|
+
if (purgeData) {
|
|
385
|
+
const dataRoot = process.env.DATA_ROOT || './data';
|
|
386
|
+
const candidate = path.resolve(dataRoot, account.podName || account.username);
|
|
387
|
+
const root = path.resolve(dataRoot);
|
|
388
|
+
// Belt-and-suspenders: refuse to remove anything that isn't a
|
|
389
|
+
// proper child of the data root. Won't trigger on registered pod
|
|
390
|
+
// names; protects against config drift / future bugs. Use
|
|
391
|
+
// path.relative so the check works when dataRoot is a filesystem
|
|
392
|
+
// root like `/` (where startsWith(root + path.sep) would compare
|
|
393
|
+
// against `//`, false-negative all valid children).
|
|
394
|
+
const rel = path.relative(root, candidate);
|
|
395
|
+
const isProperChild = rel && rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
396
|
+
if (isProperChild) {
|
|
397
|
+
try {
|
|
398
|
+
await fs.remove(candidate);
|
|
399
|
+
purged = true;
|
|
400
|
+
} catch (err) {
|
|
401
|
+
request.log.error({ err, path: candidate, username: account.username },
|
|
402
|
+
'Pod data purge failed after account deletion');
|
|
403
|
+
// Don't surface the raw error to the user (file paths,
|
|
404
|
+
// permission detail leak); response.purged signals the
|
|
405
|
+
// outcome.
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
reply.header('Cache-Control', 'no-store');
|
|
411
|
+
reply.header('Pragma', 'no-cache');
|
|
412
|
+
return {
|
|
413
|
+
ok: true,
|
|
414
|
+
webid: account.webId,
|
|
415
|
+
purged,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
275
419
|
/**
|
|
276
420
|
* Handle GET /idp/credentials
|
|
277
421
|
* Returns info about the credentials endpoint
|
package/src/idp/index.js
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
handleCredentials,
|
|
24
24
|
handleCredentialsInfo,
|
|
25
25
|
handleChangePassword,
|
|
26
|
+
handleDeleteAccount,
|
|
26
27
|
} from './credentials.js';
|
|
27
28
|
import * as passkey from './passkey.js';
|
|
28
29
|
import { addTrustedIssuer } from '../auth/solid-oidc.js';
|
|
@@ -279,6 +280,21 @@ export async function idpPlugin(fastify, options) {
|
|
|
279
280
|
return handleChangePassword(request, reply);
|
|
280
281
|
});
|
|
281
282
|
|
|
283
|
+
// DELETE account - authenticated owner deletes their own account (#352).
|
|
284
|
+
// Single-user mode is rejected at the handler (deletion would leave the
|
|
285
|
+
// server with no IDP account until re-seed; CLI is the operator path).
|
|
286
|
+
fastify.delete('/idp/account', {
|
|
287
|
+
config: {
|
|
288
|
+
rateLimit: {
|
|
289
|
+
max: 5,
|
|
290
|
+
timeWindow: '1 minute',
|
|
291
|
+
keyGenerator: (request) => request.ip
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}, async (request, reply) => {
|
|
295
|
+
return handleDeleteAccount(request, reply, { singleUser });
|
|
296
|
+
});
|
|
297
|
+
|
|
282
298
|
// Interaction routes (our custom login/consent UI)
|
|
283
299
|
// These bypass oidc-provider and use our handlers
|
|
284
300
|
|
package/src/webid/profile.js
CHANGED
|
@@ -31,6 +31,12 @@ export function generateProfileJsonLd({ webId, name, podUri, issuer }) {
|
|
|
31
31
|
const docUrl = webId.split('#')[0];
|
|
32
32
|
|
|
33
33
|
return {
|
|
34
|
+
// CID v1 vocabulary is declared inline (rather than via an imported
|
|
35
|
+
// context URL) so JSS's JSON-LD → Turtle conneg layer can expand
|
|
36
|
+
// every term without fetching external contexts. Semantically
|
|
37
|
+
// equivalent to importing https://www.w3.org/ns/cid/v1: the IRIs
|
|
38
|
+
// each term expands to are the same. This keeps the profile a valid
|
|
39
|
+
// W3C Controlled Identifier document per LWS 1.0 (#386 Phase A).
|
|
34
40
|
'@context': {
|
|
35
41
|
'foaf': FOAF,
|
|
36
42
|
'solid': SOLID,
|
|
@@ -48,13 +54,39 @@ export function generateProfileJsonLd({ webId, name, podUri, issuer }) {
|
|
|
48
54
|
'isPrimaryTopicOf': { '@id': 'foaf:isPrimaryTopicOf', '@type': '@id' },
|
|
49
55
|
'mainEntityOfPage': { '@id': 'schema:mainEntityOfPage', '@type': '@id' },
|
|
50
56
|
'service': { '@id': 'cid:service', '@container': '@set' },
|
|
51
|
-
'serviceEndpoint': { '@id': 'cid:serviceEndpoint', '@type': '@id' }
|
|
57
|
+
'serviceEndpoint': { '@id': 'cid:serviceEndpoint', '@type': '@id' },
|
|
58
|
+
// CID v1 terms used by Phase A and prepped for Phase B (the
|
|
59
|
+
// standalone "add my keys" app). Declaring these now means the
|
|
60
|
+
// app can PATCH in verificationMethod entries without having to
|
|
61
|
+
// also rewrite the @context.
|
|
62
|
+
//
|
|
63
|
+
// verificationMethod: NO @type:@id — values are inline verification
|
|
64
|
+
// method *objects* (id/type/controller/publicKey…), not just IRI
|
|
65
|
+
// references. @container:@set so a single entry stays an array.
|
|
66
|
+
// authentication / assertionMethod: @type:@id — values reference a
|
|
67
|
+
// verificationMethod entry by its IRI. @container:@set for arrays.
|
|
68
|
+
// publicKeyJwk: @type:@json so the JWK object round-trips as a
|
|
69
|
+
// literal JSON value (rdf:JSON datatype). Note: JSS's Turtle
|
|
70
|
+
// conneg layer doesn't yet emit @type:@json literals (tracked as
|
|
71
|
+
// a Phase B blocker in the PR description); declaring here is
|
|
72
|
+
// forward-looking and spec-correct.
|
|
73
|
+
'controller': { '@id': 'cid:controller', '@type': '@id' },
|
|
74
|
+
'verificationMethod': { '@id': 'cid:verificationMethod', '@container': '@set' },
|
|
75
|
+
'authentication': { '@id': 'cid:authentication', '@type': '@id', '@container': '@set' },
|
|
76
|
+
'assertionMethod': { '@id': 'cid:assertionMethod', '@type': '@id', '@container': '@set' },
|
|
77
|
+
'publicKeyJwk': { '@id': 'cid:publicKeyJwk', '@type': '@json' },
|
|
78
|
+
'publicKeyMultibase': { '@id': 'cid:publicKeyMultibase' }
|
|
52
79
|
},
|
|
53
80
|
'@id': webId,
|
|
54
81
|
'@type': ['foaf:Person', 'schema:Person'],
|
|
55
82
|
'foaf:name': name,
|
|
56
83
|
'isPrimaryTopicOf': '',
|
|
57
84
|
'mainEntityOfPage': '',
|
|
85
|
+
// CID v1 self-control: the WebID is its own controller. Phase A of
|
|
86
|
+
// #386 ships this triple even with no verificationMethods yet, so a
|
|
87
|
+
// future Phase B "add-keys" app PATCHing in verificationMethod
|
|
88
|
+
// entries doesn't have to also wire up controllership separately.
|
|
89
|
+
'controller': webId,
|
|
58
90
|
'inbox': `${pod}inbox/`,
|
|
59
91
|
'storage': pod,
|
|
60
92
|
'oidcIssuer': issuer,
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DELETE /idp/account — authenticated owner deletes their own account (#352)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, before, after } from 'node:test';
|
|
6
|
+
import assert from 'node:assert';
|
|
7
|
+
import { createServer } from '../src/server.js';
|
|
8
|
+
import fs from 'fs-extra';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import { createServer as createNetServer } from 'net';
|
|
11
|
+
|
|
12
|
+
const TEST_HOST = 'localhost';
|
|
13
|
+
|
|
14
|
+
function getAvailablePort() {
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
const srv = createNetServer();
|
|
17
|
+
srv.on('error', reject);
|
|
18
|
+
srv.listen(0, TEST_HOST, () => {
|
|
19
|
+
const port = srv.address().port;
|
|
20
|
+
srv.close(() => resolve(port));
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function createPod(baseUrl, name, email, password) {
|
|
26
|
+
const res = await fetch(`${baseUrl}/.pods`, {
|
|
27
|
+
method: 'POST',
|
|
28
|
+
headers: { 'Content-Type': 'application/json' },
|
|
29
|
+
body: JSON.stringify({ name, email, password }),
|
|
30
|
+
});
|
|
31
|
+
const body = await res.json().catch(() => ({}));
|
|
32
|
+
assert.strictEqual(res.status, 201, `pod create failed: ${JSON.stringify(body)}`);
|
|
33
|
+
return body;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function loginToken(baseUrl, email, password) {
|
|
37
|
+
const res = await fetch(`${baseUrl}/idp/credentials`, {
|
|
38
|
+
method: 'POST',
|
|
39
|
+
headers: { 'Content-Type': 'application/json' },
|
|
40
|
+
body: JSON.stringify({ email, password }),
|
|
41
|
+
});
|
|
42
|
+
const body = await res.json().catch(() => ({}));
|
|
43
|
+
assert.strictEqual(res.status, 200, `login failed: ${JSON.stringify(body)}`);
|
|
44
|
+
return body.access_token;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('DELETE /idp/account — self-delete', () => {
|
|
48
|
+
let server;
|
|
49
|
+
let baseUrl;
|
|
50
|
+
let originalDataRoot;
|
|
51
|
+
const DATA_DIR = './test-data-delete-account';
|
|
52
|
+
|
|
53
|
+
before(async () => {
|
|
54
|
+
originalDataRoot = process.env.DATA_ROOT;
|
|
55
|
+
await fs.remove(DATA_DIR);
|
|
56
|
+
await fs.ensureDir(DATA_DIR);
|
|
57
|
+
const port = await getAvailablePort();
|
|
58
|
+
baseUrl = `http://${TEST_HOST}:${port}`;
|
|
59
|
+
server = createServer({
|
|
60
|
+
logger: false,
|
|
61
|
+
root: DATA_DIR,
|
|
62
|
+
idp: true,
|
|
63
|
+
idpIssuer: baseUrl,
|
|
64
|
+
forceCloseConnections: true,
|
|
65
|
+
});
|
|
66
|
+
await server.listen({ port, host: TEST_HOST });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
after(async () => {
|
|
70
|
+
await server.close();
|
|
71
|
+
await fs.remove(DATA_DIR);
|
|
72
|
+
if (originalDataRoot === undefined) delete process.env.DATA_ROOT;
|
|
73
|
+
else process.env.DATA_ROOT = originalDataRoot;
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('rejects unauthenticated request with 401', async () => {
|
|
77
|
+
const res = await fetch(`${baseUrl}/idp/account`, {
|
|
78
|
+
method: 'DELETE',
|
|
79
|
+
headers: { 'Content-Type': 'application/json' },
|
|
80
|
+
body: JSON.stringify({ currentPassword: 'whatever' }),
|
|
81
|
+
});
|
|
82
|
+
assert.strictEqual(res.status, 401);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('rejects missing currentPassword with 400', async () => {
|
|
86
|
+
const id = `alice${Date.now()}`;
|
|
87
|
+
await createPod(baseUrl, id, `${id}@example.com`, 'password123');
|
|
88
|
+
const token = await loginToken(baseUrl, `${id}@example.com`, 'password123');
|
|
89
|
+
|
|
90
|
+
const res = await fetch(`${baseUrl}/idp/account`, {
|
|
91
|
+
method: 'DELETE',
|
|
92
|
+
headers: {
|
|
93
|
+
'Content-Type': 'application/json',
|
|
94
|
+
'Authorization': `Bearer ${token}`,
|
|
95
|
+
},
|
|
96
|
+
body: JSON.stringify({}),
|
|
97
|
+
});
|
|
98
|
+
assert.strictEqual(res.status, 400);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('rejects wrong currentPassword with 401, account untouched', async () => {
|
|
102
|
+
const id = `bob${Date.now()}`;
|
|
103
|
+
await createPod(baseUrl, id, `${id}@example.com`, 'password123');
|
|
104
|
+
const token = await loginToken(baseUrl, `${id}@example.com`, 'password123');
|
|
105
|
+
|
|
106
|
+
const res = await fetch(`${baseUrl}/idp/account`, {
|
|
107
|
+
method: 'DELETE',
|
|
108
|
+
headers: {
|
|
109
|
+
'Content-Type': 'application/json',
|
|
110
|
+
'Authorization': `Bearer ${token}`,
|
|
111
|
+
},
|
|
112
|
+
body: JSON.stringify({ currentPassword: 'wrongpassword' }),
|
|
113
|
+
});
|
|
114
|
+
assert.strictEqual(res.status, 401);
|
|
115
|
+
|
|
116
|
+
// Account still works
|
|
117
|
+
const reLogin = await fetch(`${baseUrl}/idp/credentials`, {
|
|
118
|
+
method: 'POST',
|
|
119
|
+
headers: { 'Content-Type': 'application/json' },
|
|
120
|
+
body: JSON.stringify({ email: `${id}@example.com`, password: 'password123' }),
|
|
121
|
+
});
|
|
122
|
+
assert.strictEqual(reLogin.status, 200);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('happy path: deletes account; subsequent login fails with 401', async () => {
|
|
126
|
+
const id = `carol${Date.now()}`;
|
|
127
|
+
await createPod(baseUrl, id, `${id}@example.com`, 'password123');
|
|
128
|
+
const token = await loginToken(baseUrl, `${id}@example.com`, 'password123');
|
|
129
|
+
|
|
130
|
+
const res = await fetch(`${baseUrl}/idp/account`, {
|
|
131
|
+
method: 'DELETE',
|
|
132
|
+
headers: {
|
|
133
|
+
'Content-Type': 'application/json',
|
|
134
|
+
'Authorization': `Bearer ${token}`,
|
|
135
|
+
},
|
|
136
|
+
body: JSON.stringify({ currentPassword: 'password123' }),
|
|
137
|
+
});
|
|
138
|
+
assert.strictEqual(res.status, 200);
|
|
139
|
+
const body = await res.json();
|
|
140
|
+
assert.strictEqual(body.ok, true);
|
|
141
|
+
assert.ok(body.webid.includes(id), 'response carries webid');
|
|
142
|
+
assert.strictEqual(body.purged, false, 'purgeData defaults to false');
|
|
143
|
+
|
|
144
|
+
// Login as the same user now fails
|
|
145
|
+
const reLogin = await fetch(`${baseUrl}/idp/credentials`, {
|
|
146
|
+
method: 'POST',
|
|
147
|
+
headers: { 'Content-Type': 'application/json' },
|
|
148
|
+
body: JSON.stringify({ email: `${id}@example.com`, password: 'password123' }),
|
|
149
|
+
});
|
|
150
|
+
assert.strictEqual(reLogin.status, 401);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('purgeData: true also wipes the pod filesystem tree', async () => {
|
|
154
|
+
const id = `dave${Date.now()}`;
|
|
155
|
+
await createPod(baseUrl, id, `${id}@example.com`, 'password123');
|
|
156
|
+
const token = await loginToken(baseUrl, `${id}@example.com`, 'password123');
|
|
157
|
+
|
|
158
|
+
// Pod tree exists before deletion
|
|
159
|
+
const podPath = path.join(DATA_DIR, id);
|
|
160
|
+
assert.strictEqual(await fs.pathExists(podPath), true,
|
|
161
|
+
'pod data should exist before deletion');
|
|
162
|
+
|
|
163
|
+
const res = await fetch(`${baseUrl}/idp/account`, {
|
|
164
|
+
method: 'DELETE',
|
|
165
|
+
headers: {
|
|
166
|
+
'Content-Type': 'application/json',
|
|
167
|
+
'Authorization': `Bearer ${token}`,
|
|
168
|
+
},
|
|
169
|
+
body: JSON.stringify({ currentPassword: 'password123', purgeData: true }),
|
|
170
|
+
});
|
|
171
|
+
assert.strictEqual(res.status, 200);
|
|
172
|
+
const body = await res.json();
|
|
173
|
+
assert.strictEqual(body.purged, true);
|
|
174
|
+
|
|
175
|
+
// Pod tree gone
|
|
176
|
+
assert.strictEqual(await fs.pathExists(podPath), false,
|
|
177
|
+
'pod data should be purged');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('purgeData: true removes the on-disk pod dir even when it has uppercase letters', async () => {
|
|
181
|
+
// Regression for the bug where purge derived its path from
|
|
182
|
+
// account.username (which createAccount lowercases) instead of
|
|
183
|
+
// account.podName (which preserves the original case). On
|
|
184
|
+
// case-sensitive filesystems the pod dir at <dataRoot>/Greta…/
|
|
185
|
+
// wouldn't match the derived <dataRoot>/greta…/ path.
|
|
186
|
+
const id = `Greta${Date.now()}`;
|
|
187
|
+
await createPod(baseUrl, id, `${id.toLowerCase()}@example.com`, 'password123');
|
|
188
|
+
const token = await loginToken(baseUrl, `${id.toLowerCase()}@example.com`, 'password123');
|
|
189
|
+
|
|
190
|
+
const podPath = path.join(DATA_DIR, id); // mixed-case as created
|
|
191
|
+
assert.strictEqual(await fs.pathExists(podPath), true,
|
|
192
|
+
'pod data should exist at the mixed-case path before deletion');
|
|
193
|
+
|
|
194
|
+
const res = await fetch(`${baseUrl}/idp/account`, {
|
|
195
|
+
method: 'DELETE',
|
|
196
|
+
headers: {
|
|
197
|
+
'Content-Type': 'application/json',
|
|
198
|
+
'Authorization': `Bearer ${token}`,
|
|
199
|
+
},
|
|
200
|
+
body: JSON.stringify({ currentPassword: 'password123', purgeData: true }),
|
|
201
|
+
});
|
|
202
|
+
assert.strictEqual(res.status, 200);
|
|
203
|
+
const body = await res.json();
|
|
204
|
+
assert.strictEqual(body.purged, true, 'purge should report success');
|
|
205
|
+
|
|
206
|
+
assert.strictEqual(await fs.pathExists(podPath), false,
|
|
207
|
+
'mixed-case pod dir should be gone (regression: not stranded by username lowercasing)');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('purgeData: false (default) preserves the pod filesystem tree', async () => {
|
|
211
|
+
const id = `frank${Date.now()}`;
|
|
212
|
+
await createPod(baseUrl, id, `${id}@example.com`, 'password123');
|
|
213
|
+
const token = await loginToken(baseUrl, `${id}@example.com`, 'password123');
|
|
214
|
+
|
|
215
|
+
const podPath = path.join(DATA_DIR, id);
|
|
216
|
+
assert.strictEqual(await fs.pathExists(podPath), true);
|
|
217
|
+
|
|
218
|
+
const res = await fetch(`${baseUrl}/idp/account`, {
|
|
219
|
+
method: 'DELETE',
|
|
220
|
+
headers: {
|
|
221
|
+
'Content-Type': 'application/json',
|
|
222
|
+
'Authorization': `Bearer ${token}`,
|
|
223
|
+
},
|
|
224
|
+
// Note: no purgeData flag at all
|
|
225
|
+
body: JSON.stringify({ currentPassword: 'password123' }),
|
|
226
|
+
});
|
|
227
|
+
assert.strictEqual(res.status, 200);
|
|
228
|
+
|
|
229
|
+
// Account is gone but pod data preserved
|
|
230
|
+
assert.strictEqual(await fs.pathExists(podPath), true,
|
|
231
|
+
'pod data should be preserved when purgeData is omitted');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('cross-account: A authenticated, sending B\'s password — fails 401, neither account touched', async () => {
|
|
235
|
+
const aId = `eve${Date.now()}`;
|
|
236
|
+
const bId = `mallory${Date.now() + 1}`;
|
|
237
|
+
await createPod(baseUrl, aId, `${aId}@example.com`, 'apassword123');
|
|
238
|
+
await createPod(baseUrl, bId, `${bId}@example.com`, 'bpassword123');
|
|
239
|
+
|
|
240
|
+
const aToken = await loginToken(baseUrl, `${aId}@example.com`, 'apassword123');
|
|
241
|
+
|
|
242
|
+
// A sends B's password — handler resolves account from A's WebID, so the
|
|
243
|
+
// currentPassword must match A's. With B's password it fails 401 (and
|
|
244
|
+
// crucially doesn't touch either account).
|
|
245
|
+
const res = await fetch(`${baseUrl}/idp/account`, {
|
|
246
|
+
method: 'DELETE',
|
|
247
|
+
headers: {
|
|
248
|
+
'Content-Type': 'application/json',
|
|
249
|
+
'Authorization': `Bearer ${aToken}`,
|
|
250
|
+
},
|
|
251
|
+
body: JSON.stringify({ currentPassword: 'bpassword123' }),
|
|
252
|
+
});
|
|
253
|
+
assert.strictEqual(res.status, 401);
|
|
254
|
+
|
|
255
|
+
// Both accounts still functional
|
|
256
|
+
const aLogin = await fetch(`${baseUrl}/idp/credentials`, {
|
|
257
|
+
method: 'POST',
|
|
258
|
+
headers: { 'Content-Type': 'application/json' },
|
|
259
|
+
body: JSON.stringify({ email: `${aId}@example.com`, password: 'apassword123' }),
|
|
260
|
+
});
|
|
261
|
+
assert.strictEqual(aLogin.status, 200);
|
|
262
|
+
|
|
263
|
+
const bLogin = await fetch(`${baseUrl}/idp/credentials`, {
|
|
264
|
+
method: 'POST',
|
|
265
|
+
headers: { 'Content-Type': 'application/json' },
|
|
266
|
+
body: JSON.stringify({ email: `${bId}@example.com`, password: 'bpassword123' }),
|
|
267
|
+
});
|
|
268
|
+
assert.strictEqual(bLogin.status, 200);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
describe('DELETE /idp/account — single-user mode', () => {
|
|
273
|
+
let server;
|
|
274
|
+
let baseUrl;
|
|
275
|
+
let originalDataRoot;
|
|
276
|
+
let originalPassword;
|
|
277
|
+
const DATA_DIR = './test-data-delete-account-single';
|
|
278
|
+
|
|
279
|
+
before(async () => {
|
|
280
|
+
originalDataRoot = process.env.DATA_ROOT;
|
|
281
|
+
originalPassword = process.env.JSS_SINGLE_USER_PASSWORD;
|
|
282
|
+
process.env.JSS_SINGLE_USER_PASSWORD = 'singletest';
|
|
283
|
+
await fs.remove(DATA_DIR);
|
|
284
|
+
await fs.ensureDir(DATA_DIR);
|
|
285
|
+
const port = await getAvailablePort();
|
|
286
|
+
baseUrl = `http://${TEST_HOST}:${port}`;
|
|
287
|
+
server = createServer({
|
|
288
|
+
logger: false,
|
|
289
|
+
root: DATA_DIR,
|
|
290
|
+
idp: true,
|
|
291
|
+
idpIssuer: baseUrl,
|
|
292
|
+
singleUser: true,
|
|
293
|
+
singleUserName: 'me',
|
|
294
|
+
singleUserPassword: 'singletest',
|
|
295
|
+
forceCloseConnections: true,
|
|
296
|
+
});
|
|
297
|
+
await server.listen({ port, host: TEST_HOST });
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
after(async () => {
|
|
301
|
+
await server.close();
|
|
302
|
+
await fs.remove(DATA_DIR);
|
|
303
|
+
if (originalDataRoot === undefined) delete process.env.DATA_ROOT;
|
|
304
|
+
else process.env.DATA_ROOT = originalDataRoot;
|
|
305
|
+
if (originalPassword === undefined) delete process.env.JSS_SINGLE_USER_PASSWORD;
|
|
306
|
+
else process.env.JSS_SINGLE_USER_PASSWORD = originalPassword;
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('returns 403 in single-user mode (deletion would brick the server)', async () => {
|
|
310
|
+
// Even with a valid token, the endpoint refuses in single-user mode.
|
|
311
|
+
// Operator must use the CLI (`jss account delete`) instead.
|
|
312
|
+
const token = await loginToken(baseUrl, 'me', 'singletest');
|
|
313
|
+
|
|
314
|
+
const res = await fetch(`${baseUrl}/idp/account`, {
|
|
315
|
+
method: 'DELETE',
|
|
316
|
+
headers: {
|
|
317
|
+
'Content-Type': 'application/json',
|
|
318
|
+
'Authorization': `Bearer ${token}`,
|
|
319
|
+
},
|
|
320
|
+
body: JSON.stringify({ currentPassword: 'singletest' }),
|
|
321
|
+
});
|
|
322
|
+
assert.strictEqual(res.status, 403);
|
|
323
|
+
const body = await res.json();
|
|
324
|
+
assert.match(body.error_description || '', /single-user/i);
|
|
325
|
+
|
|
326
|
+
// Account still functional
|
|
327
|
+
const reLogin = await fetch(`${baseUrl}/idp/credentials`, {
|
|
328
|
+
method: 'POST',
|
|
329
|
+
headers: { 'Content-Type': 'application/json' },
|
|
330
|
+
body: JSON.stringify({ email: 'me', password: 'singletest' }),
|
|
331
|
+
});
|
|
332
|
+
assert.strictEqual(reLogin.status, 200);
|
|
333
|
+
});
|
|
334
|
+
});
|
package/test/webid.test.js
CHANGED
|
@@ -47,6 +47,53 @@ describe('WebID Profile', () => {
|
|
|
47
47
|
assert.ok(jsonLd['@id'], 'Should have @id');
|
|
48
48
|
});
|
|
49
49
|
|
|
50
|
+
// LWS-CID document conformance, Phase A of #386. The profile must be
|
|
51
|
+
// structurally a W3C Controlled Identifier document so a future
|
|
52
|
+
// PATCH-in-keys app (or server migration) can drop verificationMethod
|
|
53
|
+
// entries in without further plumbing. CID v1 vocabulary is declared
|
|
54
|
+
// inline rather than via context URL so JSS's conneg layer can
|
|
55
|
+
// expand every term without fetching external contexts — the IRIs
|
|
56
|
+
// are the same either way.
|
|
57
|
+
it('declares all six CID v1 terms in @context (#386 Phase A)', async () => {
|
|
58
|
+
const res = await request(profilePath);
|
|
59
|
+
const jsonLd = await res.json();
|
|
60
|
+
const ctx = jsonLd['@context'];
|
|
61
|
+
assert.ok(ctx, '@context required');
|
|
62
|
+
|
|
63
|
+
// All six CID terms must be declared and expand to the CID v1
|
|
64
|
+
// namespace. Accept either prefixed (cid:term) or full-URI
|
|
65
|
+
// (https://www.w3.org/ns/cid/v1#term) form.
|
|
66
|
+
const cidTerms = ['controller', 'verificationMethod', 'authentication', 'assertionMethod', 'publicKeyJwk', 'publicKeyMultibase'];
|
|
67
|
+
for (const term of cidTerms) {
|
|
68
|
+
const mapping = ctx[term];
|
|
69
|
+
assert.ok(mapping, `@context must define \`${term}\``);
|
|
70
|
+
const id = typeof mapping === 'string' ? mapping : mapping['@id'];
|
|
71
|
+
assert.match(id, new RegExp(`^(cid:${term}|https://www\\.w3\\.org/ns/cid/v1#${term})$`),
|
|
72
|
+
`${term} must map to the CID v1 namespace`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Container/type flags Phase B relies on:
|
|
76
|
+
// verificationMethod values are inline objects, NOT IRIs — must
|
|
77
|
+
// NOT have @type:@id (would force string-only) and SHOULD have
|
|
78
|
+
// @container:@set so a single entry is still an array.
|
|
79
|
+
assert.notStrictEqual(ctx.verificationMethod['@type'], '@id',
|
|
80
|
+
'verificationMethod values are objects, not IRIs');
|
|
81
|
+
assert.strictEqual(ctx.verificationMethod['@container'], '@set');
|
|
82
|
+
// authentication / assertionMethod reference verificationMethod
|
|
83
|
+
// entries by IRI, so @type:@id is correct.
|
|
84
|
+
assert.strictEqual(ctx.authentication['@type'], '@id');
|
|
85
|
+
assert.strictEqual(ctx.assertionMethod['@type'], '@id');
|
|
86
|
+
// JWK is a literal JSON value (rdf:JSON datatype) per JSON-LD 1.1.
|
|
87
|
+
assert.strictEqual(ctx.publicKeyJwk['@type'], '@json');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('declares self-control via controller === @id (#386 Phase A)', async () => {
|
|
91
|
+
const res = await request(profilePath);
|
|
92
|
+
const jsonLd = await res.json();
|
|
93
|
+
assert.strictEqual(jsonLd.controller, jsonLd['@id'],
|
|
94
|
+
'profile must declare itself as its own controller per CID v1');
|
|
95
|
+
});
|
|
96
|
+
|
|
50
97
|
it('should have correct WebID URI', async () => {
|
|
51
98
|
const res = await request(profilePath);
|
|
52
99
|
const jsonLd = await res.json();
|