javascript-solid-server 0.0.174 → 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 CHANGED
@@ -773,11 +773,15 @@ accountCmd
773
773
 
774
774
  if (options.purge) {
775
775
  const dataRoot = process.env.DATA_ROOT || './data';
776
- const podPath = path.join(dataRoot, account.username);
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
- console.log(`\nDeleted account ${account.username}. Pod data preserved at <dataRoot>/${account.username}/ (use --purge to remove).\n`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javascript-solid-server",
3
- "version": "0.0.174",
3
+ "version": "0.0.175",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -5,7 +5,9 @@
5
5
 
6
6
  import * as jose from 'jose';
7
7
  import crypto from 'crypto';
8
- import { authenticate, findByWebId, updatePassword, verifyPassword } from './accounts.js';
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
 
@@ -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
+ });