javascript-solid-server 0.0.174 → 0.0.176
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 +284 -1
- package/src/idp/index.js +49 -1
- package/src/idp/views.js +188 -0
- package/test/idp-delete-account.test.js +635 -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,9 +5,12 @@
|
|
|
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, findByUsername, findByWebId, updatePassword, verifyPassword, deleteAccount } from './accounts.js';
|
|
9
11
|
import { getJwks } from './keys.js';
|
|
10
12
|
import { getWebIdFromRequestAsync } from '../auth/token.js';
|
|
13
|
+
import { accountDeletePage } from './views.js';
|
|
11
14
|
|
|
12
15
|
/**
|
|
13
16
|
* Handle POST /idp/credentials
|
|
@@ -272,6 +275,286 @@ export async function handleChangePassword(request, reply) {
|
|
|
272
275
|
};
|
|
273
276
|
}
|
|
274
277
|
|
|
278
|
+
/**
|
|
279
|
+
* Handle DELETE /idp/account (#352)
|
|
280
|
+
*
|
|
281
|
+
* Owner-initiated account deletion. Authenticated caller proves
|
|
282
|
+
* possession via re-entering currentPassword (matches the
|
|
283
|
+
* password-rotation pattern in #351). Optional `purgeData: true` also
|
|
284
|
+
* removes the pod's filesystem tree at `<dataRoot>/<podName>/` (falling
|
|
285
|
+
* back to `<username>` only if podName is absent on the account record).
|
|
286
|
+
*
|
|
287
|
+
* Failure modes:
|
|
288
|
+
* 401 — unauthenticated, or wrong currentPassword
|
|
289
|
+
* 400 — invalid request body / missing password
|
|
290
|
+
* 403 — single-user mode (deletion would brick the server until
|
|
291
|
+
* re-seed; operator should use the CLI), or no account for the
|
|
292
|
+
* caller's WebID. The "no account" case lands here rather than
|
|
293
|
+
* 404 because the caller had a valid token — they're proving
|
|
294
|
+
* identity, just not for an account this server holds.
|
|
295
|
+
*
|
|
296
|
+
* Out of scope: invalidating in-flight access tokens. Tokens reference
|
|
297
|
+
* the WebID; once the account record is gone, follow-up auth attempts
|
|
298
|
+
* fail at findByWebId(). Existing bearer tokens that don't round-trip
|
|
299
|
+
* through findByWebId() will appear valid until they expire — same
|
|
300
|
+
* shape as the password-change endpoint.
|
|
301
|
+
*
|
|
302
|
+
* @param {object} request - Fastify request
|
|
303
|
+
* @param {object} reply - Fastify reply
|
|
304
|
+
* @param {object} options
|
|
305
|
+
* @param {boolean} [options.singleUser] - When true, the endpoint
|
|
306
|
+
* refuses (deletion would leave the server with no IDP account).
|
|
307
|
+
*/
|
|
308
|
+
export async function handleDeleteAccount(request, reply, options = {}) {
|
|
309
|
+
// Single-user mode: deletion via HTTP is blocked. The single-user
|
|
310
|
+
// pod has exactly one account; deleting it bricks the server until
|
|
311
|
+
// re-seed. The CLI (`jss account delete`) stays available for the
|
|
312
|
+
// operator who has filesystem access.
|
|
313
|
+
if (options.singleUser) {
|
|
314
|
+
return reply.code(403).send({
|
|
315
|
+
error: 'forbidden',
|
|
316
|
+
error_description: 'Account deletion via HTTP is disabled in single-user mode. Use the `jss account delete` CLI on the server.',
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// 1. Authenticate caller
|
|
321
|
+
const { webId, error: authError } = await getWebIdFromRequestAsync(request);
|
|
322
|
+
if (!webId) {
|
|
323
|
+
return reply.code(401).send({
|
|
324
|
+
error: 'invalid_token',
|
|
325
|
+
error_description: authError || 'Authentication required',
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// 2. Parse body — same flexible shape as handleChangePassword
|
|
330
|
+
let body = request.body;
|
|
331
|
+
if (Buffer.isBuffer(body)) body = body.toString('utf-8');
|
|
332
|
+
if (typeof body === 'string') {
|
|
333
|
+
try { body = JSON.parse(body); } catch { body = {}; }
|
|
334
|
+
}
|
|
335
|
+
const currentPassword = body?.currentPassword;
|
|
336
|
+
const purgeData = body?.purgeData === true;
|
|
337
|
+
|
|
338
|
+
if (typeof currentPassword !== 'string' || !currentPassword) {
|
|
339
|
+
return reply.code(400).send({
|
|
340
|
+
error: 'invalid_request',
|
|
341
|
+
error_description: 'currentPassword is required (string)',
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// 3. Resolve account from caller's WebID
|
|
346
|
+
const account = await findByWebId(webId);
|
|
347
|
+
if (!account) {
|
|
348
|
+
return reply.code(403).send({
|
|
349
|
+
error: 'forbidden',
|
|
350
|
+
error_description: 'No account found for authenticated WebID',
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// 4. Verify currentPassword (re-auth proof)
|
|
355
|
+
if (!(await verifyPassword(account, currentPassword))) {
|
|
356
|
+
return reply.code(401).send({
|
|
357
|
+
error: 'invalid_grant',
|
|
358
|
+
error_description: 'Current password is incorrect',
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// 5. Delete via the shared helper (also handles optional pod-data purge).
|
|
363
|
+
// See deleteAccountAndOptionallyPurge below for the purge semantics
|
|
364
|
+
// and rationale (#391 pass 2 / pass 3).
|
|
365
|
+
const { purged } = await deleteAccountAndOptionallyPurge(request, account, purgeData);
|
|
366
|
+
|
|
367
|
+
reply.header('Cache-Control', 'no-store');
|
|
368
|
+
reply.header('Pragma', 'no-cache');
|
|
369
|
+
return {
|
|
370
|
+
ok: true,
|
|
371
|
+
webid: account.webId,
|
|
372
|
+
purged,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Apply anti-clickjacking + no-store cache headers to a response.
|
|
378
|
+
* Used on every account-deletion HTML response (form, success, error
|
|
379
|
+
* re-render, disabled-message page) and on the corresponding routes.
|
|
380
|
+
*
|
|
381
|
+
* - Cache-Control: no-store — destructive form/success page should
|
|
382
|
+
* never sit in a shared cache; if a token gets shared via the URL
|
|
383
|
+
* the browser shouldn't replay the action from cache either.
|
|
384
|
+
* - X-Frame-Options + frame-ancestors — block clickjacking.
|
|
385
|
+
* Embedding this form in a hostile iframe and tricking a user into
|
|
386
|
+
* submitting a captured action is exactly the threat shape these
|
|
387
|
+
* headers exist to mitigate.
|
|
388
|
+
*/
|
|
389
|
+
export function setNoCacheClickjackHeaders(reply) {
|
|
390
|
+
reply.header('Cache-Control', 'no-store');
|
|
391
|
+
reply.header('Pragma', 'no-cache');
|
|
392
|
+
reply.header('X-Frame-Options', 'DENY');
|
|
393
|
+
reply.header('Content-Security-Policy', "frame-ancestors 'none'");
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Internal: delete an account record + optional pod-data purge.
|
|
398
|
+
* Shared between the JSON endpoint (handleDeleteAccount) and the
|
|
399
|
+
* form-driven endpoint (handleAccountDeleteForm in #392).
|
|
400
|
+
*
|
|
401
|
+
* Best-effort purge — fs.remove can throw, but the account is already
|
|
402
|
+
* gone and we want a clean signal rather than a 500. Path is derived
|
|
403
|
+
* from account.podName (NOT username, which createAccount lowercases —
|
|
404
|
+
* pod dir on disk is original case, see #391 pass 2). Belt-and-
|
|
405
|
+
* suspenders path-relative check rejects ../traversal and works at FS
|
|
406
|
+
* roots (see #391 pass 3).
|
|
407
|
+
*
|
|
408
|
+
* @param {object} request - Fastify request, used only for log access
|
|
409
|
+
* @param {object} account - Account record (with username, podName, webId)
|
|
410
|
+
* @param {boolean} purgeData - If true, also remove the pod's filesystem tree
|
|
411
|
+
* @returns {Promise<{purged: boolean}>}
|
|
412
|
+
*/
|
|
413
|
+
async function deleteAccountAndOptionallyPurge(request, account, purgeData) {
|
|
414
|
+
await deleteAccount(account.id);
|
|
415
|
+
|
|
416
|
+
let purged = false;
|
|
417
|
+
if (purgeData) {
|
|
418
|
+
const dataRoot = process.env.DATA_ROOT || './data';
|
|
419
|
+
const candidate = path.resolve(dataRoot, account.podName || account.username);
|
|
420
|
+
const root = path.resolve(dataRoot);
|
|
421
|
+
const rel = path.relative(root, candidate);
|
|
422
|
+
const isProperChild = rel && rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
423
|
+
if (isProperChild) {
|
|
424
|
+
try {
|
|
425
|
+
await fs.remove(candidate);
|
|
426
|
+
purged = true;
|
|
427
|
+
} catch (err) {
|
|
428
|
+
request.log.error({ err, path: candidate, username: account.username },
|
|
429
|
+
'Pod data purge failed after account deletion');
|
|
430
|
+
}
|
|
431
|
+
} else {
|
|
432
|
+
// Belt-and-suspenders rejection — shouldn't trigger on registered
|
|
433
|
+
// pod names but logging it surfaces config drift (e.g. someone
|
|
434
|
+
// changed dataRoot, podName field is malformed, etc.) and makes
|
|
435
|
+
// the "purge did not complete" UX message diagnosable from the
|
|
436
|
+
// operator's logs without leaking the path to the client.
|
|
437
|
+
request.log.warn({ path: candidate, dataRoot: root, podName: account.podName, username: account.username },
|
|
438
|
+
'Pod data purge skipped: candidate path is not a proper child of dataRoot');
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
return { purged };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Handle POST /idp/account/delete (#392) — form-driven account deletion.
|
|
446
|
+
*
|
|
447
|
+
* Public unauthenticated endpoint that takes a form-encoded body with
|
|
448
|
+
* username + currentPassword + confirmUsername (+ optional keepData
|
|
449
|
+
* opt-out checkbox; default behavior is purge-on for the leaving-user
|
|
450
|
+
* UX, opposite the JSON endpoint's purge-off default). Authenticates
|
|
451
|
+
* the user via password directly (no Bearer token round-trip), validates
|
|
452
|
+
* the destructive-action UX guard, then calls into the same delete
|
|
453
|
+
* logic as the JSON endpoint via deleteAccountAndOptionallyPurge.
|
|
454
|
+
* Returns HTML directly:
|
|
455
|
+
* - success → success page
|
|
456
|
+
* - any failure → the form re-rendered with an error message and the
|
|
457
|
+
* username field pre-filled (no redirect — single response, status
|
|
458
|
+
* 200 with the rendered form)
|
|
459
|
+
*
|
|
460
|
+
* GET /idp/account/delete is a separate route that just renders the
|
|
461
|
+
* form via accountDeletePage(); see src/idp/index.js. This handler is
|
|
462
|
+
* POST-only.
|
|
463
|
+
*
|
|
464
|
+
* Single-user mode: this handler short-circuits to the disabled-message
|
|
465
|
+
* page (matches the GET route's behavior). Same policy as the JSON
|
|
466
|
+
* endpoint, which 403s with the equivalent message.
|
|
467
|
+
*
|
|
468
|
+
* @param {object} request - Fastify request
|
|
469
|
+
* @param {object} reply - Fastify reply
|
|
470
|
+
* @param {object} options
|
|
471
|
+
* @param {boolean} [options.singleUser] - Single-user mode flag
|
|
472
|
+
*/
|
|
473
|
+
export async function handleAccountDeleteForm(request, reply, options = {}) {
|
|
474
|
+
// Every response from this handler is a destructive-action surface
|
|
475
|
+
// that re-takes the user's password — never cache, never embed.
|
|
476
|
+
setNoCacheClickjackHeaders(reply);
|
|
477
|
+
|
|
478
|
+
if (options.singleUser) {
|
|
479
|
+
// 403 matches the GET route, /idp/register's disabled-route policy,
|
|
480
|
+
// and the JSON DELETE endpoint's 403 — consistent status across
|
|
481
|
+
// every disabled-in-single-user surface.
|
|
482
|
+
return reply.code(403).type('text/html').send(accountDeletePage({ singleUser: true }));
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Parse form-encoded body. JSS registers a wildcard parseAs:'buffer'
|
|
486
|
+
// content parser (server.js:190), so request.body arrives here as a
|
|
487
|
+
// Buffer. We coerce to a string and parse the application/x-www-form-
|
|
488
|
+
// urlencoded shape via URLSearchParams. (No @fastify/formbody is
|
|
489
|
+
// installed; doing it inline keeps this self-contained and matches
|
|
490
|
+
// what handleChangePassword does for JSON.)
|
|
491
|
+
let body = request.body;
|
|
492
|
+
if (Buffer.isBuffer(body)) body = body.toString('utf-8');
|
|
493
|
+
if (typeof body === 'string') {
|
|
494
|
+
try {
|
|
495
|
+
const params = new URLSearchParams(body);
|
|
496
|
+
body = Object.fromEntries(params);
|
|
497
|
+
} catch { body = {}; }
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const username = (body?.username || '').trim();
|
|
501
|
+
const currentPassword = body?.currentPassword || '';
|
|
502
|
+
const confirmUsername = (body?.confirmUsername || '').trim();
|
|
503
|
+
// Form field is `keepData` (inverse of the JSON endpoint's `purgeData`)
|
|
504
|
+
// so the form's default is purge-on: a user who is leaving the server
|
|
505
|
+
// probably wants their files gone too. Checking "Keep my pod data" is
|
|
506
|
+
// the opt-out. JSON endpoint (DELETE /idp/account) keeps the
|
|
507
|
+
// explicit `purgeData` shape that matches the CLI's default-off
|
|
508
|
+
// semantics for operator scripts; the form intentionally diverges.
|
|
509
|
+
const keepData = body?.keepData === 'on' || body?.keepData === true;
|
|
510
|
+
const purgeData = !keepData;
|
|
511
|
+
|
|
512
|
+
if (!username || !currentPassword || !confirmUsername) {
|
|
513
|
+
return reply.type('text/html').send(accountDeletePage({
|
|
514
|
+
error: 'All fields are required.',
|
|
515
|
+
username,
|
|
516
|
+
}));
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Destructive-action UX guard: the user must type the same string
|
|
520
|
+
// twice. The string-equality check is case-sensitive on the typed
|
|
521
|
+
// form values; that's purely the typing-it-again confirmation
|
|
522
|
+
// pattern (catch typos / accidental submits). Note: findByUsername()
|
|
523
|
+
// lowercases internally, so "Alice" + "Alice" both resolve to the
|
|
524
|
+
// same account record as "alice" — the case-sensitive comparison
|
|
525
|
+
// here doesn't gate which record gets deleted, only whether the
|
|
526
|
+
// user typed the same thing twice.
|
|
527
|
+
if (username !== confirmUsername) {
|
|
528
|
+
return reply.type('text/html').send(accountDeletePage({
|
|
529
|
+
error: 'Confirmation does not match the username you entered.',
|
|
530
|
+
username,
|
|
531
|
+
}));
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Look up + verify password without side effects. authenticate() is
|
|
535
|
+
// tempting (looks up + verifies in one call) but writes lastLogin on
|
|
536
|
+
// success — wrong shape for a destructive proof-of-possession check
|
|
537
|
+
// (and would fail the deletion if the account file weren't writable).
|
|
538
|
+
// Mirrors handleChangePassword / handleDeleteAccount which both use
|
|
539
|
+
// verifyPassword for the same reason.
|
|
540
|
+
const account = await findByUsername(username);
|
|
541
|
+
if (!account || !(await verifyPassword(account, currentPassword))) {
|
|
542
|
+
return reply.type('text/html').send(accountDeletePage({
|
|
543
|
+
error: 'Username or password is incorrect.',
|
|
544
|
+
username,
|
|
545
|
+
}));
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const { purged } = await deleteAccountAndOptionallyPurge(request, account, purgeData);
|
|
549
|
+
|
|
550
|
+
// If the user asked for a purge but it didn't run (fs.remove threw,
|
|
551
|
+
// path-relative check rejected, etc.), surface that on the success
|
|
552
|
+
// page. Account deletion succeeded — don't roll that back — but the
|
|
553
|
+
// operator may need to finish cleanup. Don't leak server paths.
|
|
554
|
+
const purgeFailed = purgeData && !purged;
|
|
555
|
+
return reply.type('text/html').send(accountDeletePage({ success: true, purgeFailed }));
|
|
556
|
+
}
|
|
557
|
+
|
|
275
558
|
/**
|
|
276
559
|
* Handle GET /idp/credentials
|
|
277
560
|
* Returns info about the credentials endpoint
|
package/src/idp/index.js
CHANGED
|
@@ -23,10 +23,13 @@ import {
|
|
|
23
23
|
handleCredentials,
|
|
24
24
|
handleCredentialsInfo,
|
|
25
25
|
handleChangePassword,
|
|
26
|
+
handleDeleteAccount,
|
|
27
|
+
handleAccountDeleteForm,
|
|
28
|
+
setNoCacheClickjackHeaders,
|
|
26
29
|
} from './credentials.js';
|
|
27
30
|
import * as passkey from './passkey.js';
|
|
28
31
|
import { addTrustedIssuer } from '../auth/solid-oidc.js';
|
|
29
|
-
import { landingPage } from './views.js';
|
|
32
|
+
import { landingPage, accountDeletePage } from './views.js';
|
|
30
33
|
|
|
31
34
|
/**
|
|
32
35
|
* IdP Fastify Plugin
|
|
@@ -279,6 +282,51 @@ export async function idpPlugin(fastify, options) {
|
|
|
279
282
|
return handleChangePassword(request, reply);
|
|
280
283
|
});
|
|
281
284
|
|
|
285
|
+
// DELETE account - authenticated owner deletes their own account (#352).
|
|
286
|
+
// Single-user mode is rejected at the handler (deletion would leave the
|
|
287
|
+
// server with no IDP account until re-seed; CLI is the operator path).
|
|
288
|
+
fastify.delete('/idp/account', {
|
|
289
|
+
config: {
|
|
290
|
+
rateLimit: {
|
|
291
|
+
max: 5,
|
|
292
|
+
timeWindow: '1 minute',
|
|
293
|
+
keyGenerator: (request) => request.ip
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}, async (request, reply) => {
|
|
297
|
+
return handleDeleteAccount(request, reply, { singleUser });
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// GET account-delete form (#392) - human-friendly UI for #352. Public
|
|
301
|
+
// unauthenticated page; auth happens at form submission via password.
|
|
302
|
+
// Single-user mode returns 403 to stay consistent with /idp/register's
|
|
303
|
+
// disabled-route policy and the JSON DELETE /idp/account endpoint
|
|
304
|
+
// (which also 403s in single-user mode). Body is still HTML so a
|
|
305
|
+
// browser visitor sees the explanation. Every response sets
|
|
306
|
+
// anti-clickjacking + no-store headers — destructive-action page.
|
|
307
|
+
fastify.get('/idp/account/delete', async (request, reply) => {
|
|
308
|
+
setNoCacheClickjackHeaders(reply);
|
|
309
|
+
if (singleUser) {
|
|
310
|
+
return reply.code(403).type('text/html').send(accountDeletePage({ singleUser: true }));
|
|
311
|
+
}
|
|
312
|
+
return reply.type('text/html').send(accountDeletePage({ singleUser: false }));
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
// POST account-delete form (#392) - processes the form submission.
|
|
316
|
+
// Same rate-limit as the JSON endpoint to keep the destructive-action
|
|
317
|
+
// surface consistent across both paths.
|
|
318
|
+
fastify.post('/idp/account/delete', {
|
|
319
|
+
config: {
|
|
320
|
+
rateLimit: {
|
|
321
|
+
max: 5,
|
|
322
|
+
timeWindow: '1 minute',
|
|
323
|
+
keyGenerator: (request) => request.ip
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}, async (request, reply) => {
|
|
327
|
+
return handleAccountDeleteForm(request, reply, { singleUser });
|
|
328
|
+
});
|
|
329
|
+
|
|
282
330
|
// Interaction routes (our custom login/consent UI)
|
|
283
331
|
// These bypass oidc-provider and use our handlers
|
|
284
332
|
|
package/src/idp/views.js
CHANGED
|
@@ -533,6 +533,194 @@ export function consentPage(uid, client, params, account) {
|
|
|
533
533
|
`;
|
|
534
534
|
}
|
|
535
535
|
|
|
536
|
+
/**
|
|
537
|
+
* Account-deletion form HTML (#392).
|
|
538
|
+
*
|
|
539
|
+
* Public unauthenticated page (matches the existing /idp landing and
|
|
540
|
+
* /idp/register pattern). Auth happens at submission time: the user
|
|
541
|
+
* supplies username + password, which the server validates and uses as
|
|
542
|
+
* proof-of-possession for the delete. The "type your username again to
|
|
543
|
+
* confirm" field is the destructive-action UX guard.
|
|
544
|
+
*
|
|
545
|
+
* On any failure (wrong password, mismatched confirmation, etc.) the
|
|
546
|
+
* handler re-renders this same form in place at status 200 with an
|
|
547
|
+
* error message and the identifier field pre-filled — no redirect.
|
|
548
|
+
*
|
|
549
|
+
* @param {object} opts
|
|
550
|
+
* @param {string|null} opts.error - Error message (e.g. wrong password) to display
|
|
551
|
+
* @param {string} opts.username - Pre-fill the identifier field on re-render after error
|
|
552
|
+
* @param {boolean} opts.singleUser - When true, render a disabled message
|
|
553
|
+
* instead of the form. Deletion via HTTP is blocked in single-user mode
|
|
554
|
+
* (would brick the IdP until re-seed); operator path stays the CLI.
|
|
555
|
+
* @param {boolean} opts.success - When true, render the post-delete confirmation
|
|
556
|
+
* @param {boolean} opts.purgeFailed - When true (only on success), include a
|
|
557
|
+
* notice that the user requested a pod-data purge but it didn't complete.
|
|
558
|
+
* Account deletion still succeeded.
|
|
559
|
+
*/
|
|
560
|
+
export function accountDeletePage({ error = null, username = '', singleUser = false, success = false, purgeFailed = false } = {}) {
|
|
561
|
+
if (singleUser) {
|
|
562
|
+
return `
|
|
563
|
+
<!DOCTYPE html>
|
|
564
|
+
<html lang="en">
|
|
565
|
+
<head>
|
|
566
|
+
<meta charset="UTF-8">
|
|
567
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
568
|
+
<title>Account deletion disabled - Solid IdP</title>
|
|
569
|
+
<style>${styles}</style>
|
|
570
|
+
</head>
|
|
571
|
+
<body>
|
|
572
|
+
<div class="container">
|
|
573
|
+
<div class="logo">${solidLogo}</div>
|
|
574
|
+
<h1>Account deletion disabled</h1>
|
|
575
|
+
<p>This server runs in <strong>single-user mode</strong>. Deleting the single account
|
|
576
|
+
via HTTP would leave the server with no IdP account until re-seed,
|
|
577
|
+
so this endpoint is disabled.</p>
|
|
578
|
+
<p>The operator can still delete the account at the shell with:</p>
|
|
579
|
+
<pre style="background: #f1f5f9; padding: 12px; border-radius: 6px; font-size: 13px;">jss account delete <username></pre>
|
|
580
|
+
<a href="/idp" class="btn btn-secondary" style="text-decoration: none;">Back</a>
|
|
581
|
+
</div>
|
|
582
|
+
</body>
|
|
583
|
+
</html>
|
|
584
|
+
`;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
if (success) {
|
|
588
|
+
return `
|
|
589
|
+
<!DOCTYPE html>
|
|
590
|
+
<html lang="en">
|
|
591
|
+
<head>
|
|
592
|
+
<meta charset="UTF-8">
|
|
593
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
594
|
+
<title>Account deleted - Solid IdP</title>
|
|
595
|
+
<style>${styles}</style>
|
|
596
|
+
</head>
|
|
597
|
+
<body>
|
|
598
|
+
<div class="container">
|
|
599
|
+
<div class="logo">${solidLogo}</div>
|
|
600
|
+
<h1>Account deleted</h1>
|
|
601
|
+
<p>Your account record has been removed from this server. Future sign-ins
|
|
602
|
+
with this username will fail.</p>
|
|
603
|
+
<p style="font-size: 13px; color: #64748b; margin-top: 8px;">
|
|
604
|
+
Note: any access tokens already issued may remain usable until they
|
|
605
|
+
expire — the server does not currently revoke them on account deletion.
|
|
606
|
+
</p>
|
|
607
|
+
${purgeFailed ? `
|
|
608
|
+
<div class="error" style="margin-top: 16px;">
|
|
609
|
+
Your account was deleted, but the pod-data purge did not complete on
|
|
610
|
+
this server. Some files may still exist. Contact the operator to
|
|
611
|
+
finish the cleanup if needed.
|
|
612
|
+
</div>
|
|
613
|
+
` : ''}
|
|
614
|
+
<a href="/idp" class="btn btn-primary" style="text-decoration: none; margin-top: 16px;">Return to sign-in</a>
|
|
615
|
+
</div>
|
|
616
|
+
</body>
|
|
617
|
+
</html>
|
|
618
|
+
`;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
return `
|
|
622
|
+
<!DOCTYPE html>
|
|
623
|
+
<html lang="en">
|
|
624
|
+
<head>
|
|
625
|
+
<meta charset="UTF-8">
|
|
626
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
627
|
+
<title>Delete account - Solid IdP</title>
|
|
628
|
+
<style>${styles}
|
|
629
|
+
.danger {
|
|
630
|
+
background: #fef2f2;
|
|
631
|
+
border: 1px solid #fecaca;
|
|
632
|
+
color: #991b1b;
|
|
633
|
+
padding: 14px 16px;
|
|
634
|
+
border-radius: 8px;
|
|
635
|
+
margin: 16px 0 24px;
|
|
636
|
+
font-size: 13px;
|
|
637
|
+
line-height: 1.55;
|
|
638
|
+
}
|
|
639
|
+
.danger strong { color: #7f1d1d; }
|
|
640
|
+
.btn-danger {
|
|
641
|
+
background: #dc2626;
|
|
642
|
+
color: #fff;
|
|
643
|
+
}
|
|
644
|
+
.btn-danger:hover { background: #b91c1c; }
|
|
645
|
+
.checkbox-row {
|
|
646
|
+
display: flex;
|
|
647
|
+
align-items: flex-start;
|
|
648
|
+
gap: 10px;
|
|
649
|
+
margin: 16px 0 8px;
|
|
650
|
+
padding: 10px 12px;
|
|
651
|
+
background: #f8fafc;
|
|
652
|
+
border: 1px solid #e2e8f0;
|
|
653
|
+
border-radius: 6px;
|
|
654
|
+
}
|
|
655
|
+
.checkbox-row input[type="checkbox"] { margin-top: 3px; flex-shrink: 0; }
|
|
656
|
+
.checkbox-row label {
|
|
657
|
+
margin: 0;
|
|
658
|
+
font-size: 13px;
|
|
659
|
+
line-height: 1.5;
|
|
660
|
+
color: #334155;
|
|
661
|
+
cursor: pointer;
|
|
662
|
+
}
|
|
663
|
+
.checkbox-row label strong { color: #0f172a; }
|
|
664
|
+
</style>
|
|
665
|
+
</head>
|
|
666
|
+
<body>
|
|
667
|
+
<div class="container">
|
|
668
|
+
<div class="logo">${solidLogo}</div>
|
|
669
|
+
<h1>Delete your account</h1>
|
|
670
|
+
|
|
671
|
+
<div class="danger">
|
|
672
|
+
<strong>This is permanent.</strong> Your account record and credentials will be
|
|
673
|
+
removed; future sign-ins with this username will fail. By default, your pod
|
|
674
|
+
data (every file you've stored, including your WebID profile document) is
|
|
675
|
+
also wiped — check the box below if you want to keep it. Federated references
|
|
676
|
+
(ActivityPub follows, Nostr relays, type indexes) cannot be retracted from
|
|
677
|
+
this server.
|
|
678
|
+
<br><br>
|
|
679
|
+
<span style="font-size: 12px; color: #7f1d1d;">
|
|
680
|
+
Note: access tokens already issued may remain usable until they
|
|
681
|
+
expire — the server does not currently revoke them on deletion.
|
|
682
|
+
</span>
|
|
683
|
+
</div>
|
|
684
|
+
|
|
685
|
+
${error ? `<div class="error">${escapeHtml(error)}</div>` : ''}
|
|
686
|
+
|
|
687
|
+
<form method="POST" action="/idp/account/delete">
|
|
688
|
+
<label for="username">Username</label>
|
|
689
|
+
<input type="text" id="username" name="username" required autofocus
|
|
690
|
+
value="${escapeHtml(username || '')}"
|
|
691
|
+
placeholder="alice">
|
|
692
|
+
|
|
693
|
+
<label for="currentPassword">Current password</label>
|
|
694
|
+
<input type="password" id="currentPassword" name="currentPassword" required
|
|
695
|
+
placeholder="Re-enter your password">
|
|
696
|
+
|
|
697
|
+
<label for="confirmUsername">Type your username again to confirm</label>
|
|
698
|
+
<input type="text" id="confirmUsername" name="confirmUsername" required
|
|
699
|
+
placeholder="Must match the username above">
|
|
700
|
+
|
|
701
|
+
<div class="checkbox-row">
|
|
702
|
+
<input type="checkbox" id="keepData" name="keepData" value="on">
|
|
703
|
+
<label for="keepData">
|
|
704
|
+
<strong>Keep my pod data on this server.</strong> Check only if you want
|
|
705
|
+
to delete just your account record and leave your files in place. Default
|
|
706
|
+
(unchecked) wipes the pod folder along with the account.
|
|
707
|
+
</label>
|
|
708
|
+
</div>
|
|
709
|
+
|
|
710
|
+
<button type="submit" class="btn btn-danger" style="width: 100%; margin-top: 16px;">
|
|
711
|
+
Delete my account permanently
|
|
712
|
+
</button>
|
|
713
|
+
</form>
|
|
714
|
+
|
|
715
|
+
<p style="text-align: center; margin-top: 16px; font-size: 13px;">
|
|
716
|
+
<a href="/idp">Cancel and go back</a>
|
|
717
|
+
</p>
|
|
718
|
+
</div>
|
|
719
|
+
</body>
|
|
720
|
+
</html>
|
|
721
|
+
`;
|
|
722
|
+
}
|
|
723
|
+
|
|
536
724
|
/**
|
|
537
725
|
* Error page HTML
|
|
538
726
|
*/
|
|
@@ -0,0 +1,635 @@
|
|
|
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('GET/POST /idp/account/delete — HTML form (#392)', () => {
|
|
273
|
+
let server;
|
|
274
|
+
let baseUrl;
|
|
275
|
+
let originalDataRoot;
|
|
276
|
+
const DATA_DIR = './test-data-delete-form';
|
|
277
|
+
|
|
278
|
+
before(async () => {
|
|
279
|
+
originalDataRoot = process.env.DATA_ROOT;
|
|
280
|
+
await fs.remove(DATA_DIR);
|
|
281
|
+
await fs.ensureDir(DATA_DIR);
|
|
282
|
+
const port = await getAvailablePort();
|
|
283
|
+
baseUrl = `http://${TEST_HOST}:${port}`;
|
|
284
|
+
server = createServer({
|
|
285
|
+
logger: false,
|
|
286
|
+
root: DATA_DIR,
|
|
287
|
+
idp: true,
|
|
288
|
+
idpIssuer: baseUrl,
|
|
289
|
+
forceCloseConnections: true,
|
|
290
|
+
});
|
|
291
|
+
await server.listen({ port, host: TEST_HOST });
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
after(async () => {
|
|
295
|
+
await server.close();
|
|
296
|
+
await fs.remove(DATA_DIR);
|
|
297
|
+
if (originalDataRoot === undefined) delete process.env.DATA_ROOT;
|
|
298
|
+
else process.env.DATA_ROOT = originalDataRoot;
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('GET renders the form HTML', async () => {
|
|
302
|
+
const res = await fetch(`${baseUrl}/idp/account/delete`);
|
|
303
|
+
assert.strictEqual(res.status, 200);
|
|
304
|
+
const ct = res.headers.get('content-type') || '';
|
|
305
|
+
assert.match(ct, /text\/html/);
|
|
306
|
+
const html = await res.text();
|
|
307
|
+
assert.match(html, /<form\s[^>]*action="\/idp\/account\/delete"/);
|
|
308
|
+
assert.match(html, /name="username"/);
|
|
309
|
+
assert.match(html, /name="currentPassword"/);
|
|
310
|
+
assert.match(html, /name="confirmUsername"/);
|
|
311
|
+
// Form's checkbox is `keepData` (inverse of the JSON endpoint's
|
|
312
|
+
// purgeData) so the form's default is purge-on for the leaving-user UX.
|
|
313
|
+
assert.match(html, /name="keepData"/);
|
|
314
|
+
assert.match(html, /Delete my account permanently/);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('GET sets anti-clickjacking + no-store headers', async () => {
|
|
318
|
+
// Destructive-action page (form for account deletion) — must not
|
|
319
|
+
// be cacheable or embeddable in an iframe.
|
|
320
|
+
const res = await fetch(`${baseUrl}/idp/account/delete`);
|
|
321
|
+
assert.strictEqual(res.status, 200);
|
|
322
|
+
assert.match(res.headers.get('cache-control') || '', /no-store/i);
|
|
323
|
+
assert.strictEqual(res.headers.get('x-frame-options'), 'DENY');
|
|
324
|
+
assert.match(res.headers.get('content-security-policy') || '', /frame-ancestors 'none'/);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('POST sets the same security headers on success and error responses', async () => {
|
|
328
|
+
// Error response: missing fields
|
|
329
|
+
const errRes = await fetch(`${baseUrl}/idp/account/delete`, {
|
|
330
|
+
method: 'POST',
|
|
331
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
332
|
+
body: new URLSearchParams({ username: 'x' }),
|
|
333
|
+
});
|
|
334
|
+
assert.match(errRes.headers.get('cache-control') || '', /no-store/i);
|
|
335
|
+
assert.strictEqual(errRes.headers.get('x-frame-options'), 'DENY');
|
|
336
|
+
assert.match(errRes.headers.get('content-security-policy') || '', /frame-ancestors 'none'/);
|
|
337
|
+
|
|
338
|
+
// Success response: full delete flow
|
|
339
|
+
const id = `lyle${Date.now()}`;
|
|
340
|
+
await createPod(baseUrl, id, `${id}@example.com`, 'password123');
|
|
341
|
+
const okRes = await fetch(`${baseUrl}/idp/account/delete`, {
|
|
342
|
+
method: 'POST',
|
|
343
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
344
|
+
body: new URLSearchParams({
|
|
345
|
+
username: id,
|
|
346
|
+
currentPassword: 'password123',
|
|
347
|
+
confirmUsername: id,
|
|
348
|
+
}),
|
|
349
|
+
});
|
|
350
|
+
assert.strictEqual(okRes.status, 200);
|
|
351
|
+
assert.match(okRes.headers.get('cache-control') || '', /no-store/i);
|
|
352
|
+
assert.strictEqual(okRes.headers.get('x-frame-options'), 'DENY');
|
|
353
|
+
assert.match(okRes.headers.get('content-security-policy') || '', /frame-ancestors 'none'/);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('POST happy path: deletes account AND wipes pod data by default; login fails after', async () => {
|
|
357
|
+
// Form's default is purge-on (the user is leaving — wipe everything).
|
|
358
|
+
// The JSON DELETE endpoint keeps purge-off as default (matches CLI for
|
|
359
|
+
// programmatic / operator use); the form diverges deliberately for the
|
|
360
|
+
// leaving-user UX.
|
|
361
|
+
const id = `harry${Date.now()}`;
|
|
362
|
+
await createPod(baseUrl, id, `${id}@example.com`, 'password123');
|
|
363
|
+
const podPath = path.join(DATA_DIR, id);
|
|
364
|
+
assert.strictEqual(await fs.pathExists(podPath), true,
|
|
365
|
+
'pod tree should exist before deletion');
|
|
366
|
+
|
|
367
|
+
const formBody = new URLSearchParams({
|
|
368
|
+
username: id,
|
|
369
|
+
currentPassword: 'password123',
|
|
370
|
+
confirmUsername: id,
|
|
371
|
+
// No keepData — defaults to purge-on
|
|
372
|
+
});
|
|
373
|
+
const res = await fetch(`${baseUrl}/idp/account/delete`, {
|
|
374
|
+
method: 'POST',
|
|
375
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
376
|
+
body: formBody,
|
|
377
|
+
});
|
|
378
|
+
assert.strictEqual(res.status, 200);
|
|
379
|
+
const html = await res.text();
|
|
380
|
+
assert.match(html, /Account deleted/);
|
|
381
|
+
|
|
382
|
+
// Login now fails
|
|
383
|
+
const reLogin = await fetch(`${baseUrl}/idp/credentials`, {
|
|
384
|
+
method: 'POST',
|
|
385
|
+
headers: { 'Content-Type': 'application/json' },
|
|
386
|
+
body: JSON.stringify({ email: `${id}@example.com`, password: 'password123' }),
|
|
387
|
+
});
|
|
388
|
+
assert.strictEqual(reLogin.status, 401);
|
|
389
|
+
|
|
390
|
+
// Pod data also wiped (default behavior on the form)
|
|
391
|
+
assert.strictEqual(await fs.pathExists(podPath), false,
|
|
392
|
+
'pod data should be wiped by default on the form path');
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it('POST with keepData=on opts out of the purge, account still deleted', async () => {
|
|
396
|
+
const id = `iris${Date.now()}`;
|
|
397
|
+
await createPod(baseUrl, id, `${id}@example.com`, 'password123');
|
|
398
|
+
const podPath = path.join(DATA_DIR, id);
|
|
399
|
+
assert.strictEqual(await fs.pathExists(podPath), true);
|
|
400
|
+
|
|
401
|
+
const formBody = new URLSearchParams({
|
|
402
|
+
username: id,
|
|
403
|
+
currentPassword: 'password123',
|
|
404
|
+
confirmUsername: id,
|
|
405
|
+
keepData: 'on',
|
|
406
|
+
});
|
|
407
|
+
const res = await fetch(`${baseUrl}/idp/account/delete`, {
|
|
408
|
+
method: 'POST',
|
|
409
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
410
|
+
body: formBody,
|
|
411
|
+
});
|
|
412
|
+
assert.strictEqual(res.status, 200);
|
|
413
|
+
|
|
414
|
+
// Account gone
|
|
415
|
+
const reLogin = await fetch(`${baseUrl}/idp/credentials`, {
|
|
416
|
+
method: 'POST',
|
|
417
|
+
headers: { 'Content-Type': 'application/json' },
|
|
418
|
+
body: JSON.stringify({ email: `${id}@example.com`, password: 'password123' }),
|
|
419
|
+
});
|
|
420
|
+
assert.strictEqual(reLogin.status, 401);
|
|
421
|
+
|
|
422
|
+
// Pod data preserved (the user opted to keep it)
|
|
423
|
+
assert.strictEqual(await fs.pathExists(podPath), true,
|
|
424
|
+
'keepData=on should preserve pod data even though account is deleted');
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it('POST with mismatched confirmUsername renders form with error, account untouched', async () => {
|
|
428
|
+
const id = `jack${Date.now()}`;
|
|
429
|
+
await createPod(baseUrl, id, `${id}@example.com`, 'password123');
|
|
430
|
+
|
|
431
|
+
const formBody = new URLSearchParams({
|
|
432
|
+
username: id,
|
|
433
|
+
currentPassword: 'password123',
|
|
434
|
+
confirmUsername: 'totally-different',
|
|
435
|
+
});
|
|
436
|
+
const res = await fetch(`${baseUrl}/idp/account/delete`, {
|
|
437
|
+
method: 'POST',
|
|
438
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
439
|
+
body: formBody,
|
|
440
|
+
});
|
|
441
|
+
assert.strictEqual(res.status, 200);
|
|
442
|
+
const html = await res.text();
|
|
443
|
+
assert.match(html, /Confirmation does not match/);
|
|
444
|
+
// Username pre-filled in the form for retry
|
|
445
|
+
assert.match(html, new RegExp(`value="${id}"`));
|
|
446
|
+
|
|
447
|
+
// Account intact
|
|
448
|
+
const login = await fetch(`${baseUrl}/idp/credentials`, {
|
|
449
|
+
method: 'POST',
|
|
450
|
+
headers: { 'Content-Type': 'application/json' },
|
|
451
|
+
body: JSON.stringify({ email: `${id}@example.com`, password: 'password123' }),
|
|
452
|
+
});
|
|
453
|
+
assert.strictEqual(login.status, 200);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it('POST with wrong password renders form with error, account untouched', async () => {
|
|
457
|
+
const id = `kelly${Date.now()}`;
|
|
458
|
+
await createPod(baseUrl, id, `${id}@example.com`, 'password123');
|
|
459
|
+
|
|
460
|
+
const formBody = new URLSearchParams({
|
|
461
|
+
username: id,
|
|
462
|
+
currentPassword: 'wrong',
|
|
463
|
+
confirmUsername: id,
|
|
464
|
+
});
|
|
465
|
+
const res = await fetch(`${baseUrl}/idp/account/delete`, {
|
|
466
|
+
method: 'POST',
|
|
467
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
468
|
+
body: formBody,
|
|
469
|
+
});
|
|
470
|
+
assert.strictEqual(res.status, 200);
|
|
471
|
+
const html = await res.text();
|
|
472
|
+
assert.match(html, /incorrect/i);
|
|
473
|
+
|
|
474
|
+
// Account intact
|
|
475
|
+
const login = await fetch(`${baseUrl}/idp/credentials`, {
|
|
476
|
+
method: 'POST',
|
|
477
|
+
headers: { 'Content-Type': 'application/json' },
|
|
478
|
+
body: JSON.stringify({ email: `${id}@example.com`, password: 'password123' }),
|
|
479
|
+
});
|
|
480
|
+
assert.strictEqual(login.status, 200);
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it('POST with missing fields renders form with error', async () => {
|
|
484
|
+
const formBody = new URLSearchParams({
|
|
485
|
+
username: 'someone',
|
|
486
|
+
// currentPassword and confirmUsername omitted
|
|
487
|
+
});
|
|
488
|
+
const res = await fetch(`${baseUrl}/idp/account/delete`, {
|
|
489
|
+
method: 'POST',
|
|
490
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
491
|
+
body: formBody,
|
|
492
|
+
});
|
|
493
|
+
assert.strictEqual(res.status, 200);
|
|
494
|
+
const html = await res.text();
|
|
495
|
+
assert.match(html, /required/i);
|
|
496
|
+
});
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
describe('GET/POST /idp/account/delete — single-user mode renders disabled message', () => {
|
|
500
|
+
let server;
|
|
501
|
+
let baseUrl;
|
|
502
|
+
let originalDataRoot;
|
|
503
|
+
let originalPassword;
|
|
504
|
+
const DATA_DIR = './test-data-delete-form-single';
|
|
505
|
+
|
|
506
|
+
before(async () => {
|
|
507
|
+
originalDataRoot = process.env.DATA_ROOT;
|
|
508
|
+
originalPassword = process.env.JSS_SINGLE_USER_PASSWORD;
|
|
509
|
+
process.env.JSS_SINGLE_USER_PASSWORD = 'singletest';
|
|
510
|
+
await fs.remove(DATA_DIR);
|
|
511
|
+
await fs.ensureDir(DATA_DIR);
|
|
512
|
+
const port = await getAvailablePort();
|
|
513
|
+
baseUrl = `http://${TEST_HOST}:${port}`;
|
|
514
|
+
server = createServer({
|
|
515
|
+
logger: false,
|
|
516
|
+
root: DATA_DIR,
|
|
517
|
+
idp: true,
|
|
518
|
+
idpIssuer: baseUrl,
|
|
519
|
+
singleUser: true,
|
|
520
|
+
singleUserName: 'me',
|
|
521
|
+
singleUserPassword: 'singletest',
|
|
522
|
+
forceCloseConnections: true,
|
|
523
|
+
});
|
|
524
|
+
await server.listen({ port, host: TEST_HOST });
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
after(async () => {
|
|
528
|
+
await server.close();
|
|
529
|
+
await fs.remove(DATA_DIR);
|
|
530
|
+
if (originalDataRoot === undefined) delete process.env.DATA_ROOT;
|
|
531
|
+
else process.env.DATA_ROOT = originalDataRoot;
|
|
532
|
+
if (originalPassword === undefined) delete process.env.JSS_SINGLE_USER_PASSWORD;
|
|
533
|
+
else process.env.JSS_SINGLE_USER_PASSWORD = originalPassword;
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
it('GET returns 403 with the disabled-message HTML', async () => {
|
|
537
|
+
// 403 keeps single-user disabled routes consistent: /idp/register,
|
|
538
|
+
// the JSON DELETE /idp/account, and this GET all 403 in single-user.
|
|
539
|
+
const res = await fetch(`${baseUrl}/idp/account/delete`);
|
|
540
|
+
assert.strictEqual(res.status, 403);
|
|
541
|
+
const html = await res.text();
|
|
542
|
+
assert.match(html, /single-user mode/i);
|
|
543
|
+
assert.match(html, /jss account delete/);
|
|
544
|
+
// No form
|
|
545
|
+
assert.doesNotMatch(html, /<form\s[^>]*action="\/idp\/account\/delete"/);
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
it('POST returns 403 with disabled-message HTML — does not delete', async () => {
|
|
549
|
+
const formBody = new URLSearchParams({
|
|
550
|
+
username: 'me',
|
|
551
|
+
currentPassword: 'singletest',
|
|
552
|
+
confirmUsername: 'me',
|
|
553
|
+
});
|
|
554
|
+
const res = await fetch(`${baseUrl}/idp/account/delete`, {
|
|
555
|
+
method: 'POST',
|
|
556
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
557
|
+
body: formBody,
|
|
558
|
+
});
|
|
559
|
+
assert.strictEqual(res.status, 403);
|
|
560
|
+
const html = await res.text();
|
|
561
|
+
assert.match(html, /single-user mode/i);
|
|
562
|
+
|
|
563
|
+
// Account still works
|
|
564
|
+
const login = await fetch(`${baseUrl}/idp/credentials`, {
|
|
565
|
+
method: 'POST',
|
|
566
|
+
headers: { 'Content-Type': 'application/json' },
|
|
567
|
+
body: JSON.stringify({ email: 'me', password: 'singletest' }),
|
|
568
|
+
});
|
|
569
|
+
assert.strictEqual(login.status, 200);
|
|
570
|
+
});
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
describe('DELETE /idp/account — single-user mode', () => {
|
|
574
|
+
let server;
|
|
575
|
+
let baseUrl;
|
|
576
|
+
let originalDataRoot;
|
|
577
|
+
let originalPassword;
|
|
578
|
+
const DATA_DIR = './test-data-delete-account-single';
|
|
579
|
+
|
|
580
|
+
before(async () => {
|
|
581
|
+
originalDataRoot = process.env.DATA_ROOT;
|
|
582
|
+
originalPassword = process.env.JSS_SINGLE_USER_PASSWORD;
|
|
583
|
+
process.env.JSS_SINGLE_USER_PASSWORD = 'singletest';
|
|
584
|
+
await fs.remove(DATA_DIR);
|
|
585
|
+
await fs.ensureDir(DATA_DIR);
|
|
586
|
+
const port = await getAvailablePort();
|
|
587
|
+
baseUrl = `http://${TEST_HOST}:${port}`;
|
|
588
|
+
server = createServer({
|
|
589
|
+
logger: false,
|
|
590
|
+
root: DATA_DIR,
|
|
591
|
+
idp: true,
|
|
592
|
+
idpIssuer: baseUrl,
|
|
593
|
+
singleUser: true,
|
|
594
|
+
singleUserName: 'me',
|
|
595
|
+
singleUserPassword: 'singletest',
|
|
596
|
+
forceCloseConnections: true,
|
|
597
|
+
});
|
|
598
|
+
await server.listen({ port, host: TEST_HOST });
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
after(async () => {
|
|
602
|
+
await server.close();
|
|
603
|
+
await fs.remove(DATA_DIR);
|
|
604
|
+
if (originalDataRoot === undefined) delete process.env.DATA_ROOT;
|
|
605
|
+
else process.env.DATA_ROOT = originalDataRoot;
|
|
606
|
+
if (originalPassword === undefined) delete process.env.JSS_SINGLE_USER_PASSWORD;
|
|
607
|
+
else process.env.JSS_SINGLE_USER_PASSWORD = originalPassword;
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
it('returns 403 in single-user mode (deletion would brick the server)', async () => {
|
|
611
|
+
// Even with a valid token, the endpoint refuses in single-user mode.
|
|
612
|
+
// Operator must use the CLI (`jss account delete`) instead.
|
|
613
|
+
const token = await loginToken(baseUrl, 'me', 'singletest');
|
|
614
|
+
|
|
615
|
+
const res = await fetch(`${baseUrl}/idp/account`, {
|
|
616
|
+
method: 'DELETE',
|
|
617
|
+
headers: {
|
|
618
|
+
'Content-Type': 'application/json',
|
|
619
|
+
'Authorization': `Bearer ${token}`,
|
|
620
|
+
},
|
|
621
|
+
body: JSON.stringify({ currentPassword: 'singletest' }),
|
|
622
|
+
});
|
|
623
|
+
assert.strictEqual(res.status, 403);
|
|
624
|
+
const body = await res.json();
|
|
625
|
+
assert.match(body.error_description || '', /single-user/i);
|
|
626
|
+
|
|
627
|
+
// Account still functional
|
|
628
|
+
const reLogin = await fetch(`${baseUrl}/idp/credentials`, {
|
|
629
|
+
method: 'POST',
|
|
630
|
+
headers: { 'Content-Type': 'application/json' },
|
|
631
|
+
body: JSON.stringify({ email: 'me', password: 'singletest' }),
|
|
632
|
+
});
|
|
633
|
+
assert.strictEqual(reLogin.status, 200);
|
|
634
|
+
});
|
|
635
|
+
});
|