javascript-solid-server 0.0.175 → 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/package.json +1 -1
- package/src/idp/credentials.js +176 -37
- package/src/idp/index.js +33 -1
- package/src/idp/views.js +188 -0
- package/test/idp-delete-account.test.js +301 -0
package/package.json
CHANGED
package/src/idp/credentials.js
CHANGED
|
@@ -7,9 +7,10 @@ import * as jose from 'jose';
|
|
|
7
7
|
import crypto from 'crypto';
|
|
8
8
|
import fs from 'fs-extra';
|
|
9
9
|
import path from 'path';
|
|
10
|
-
import { authenticate, findByWebId, updatePassword, verifyPassword, deleteAccount } from './accounts.js';
|
|
10
|
+
import { authenticate, findByUsername, findByWebId, updatePassword, verifyPassword, deleteAccount } from './accounts.js';
|
|
11
11
|
import { getJwks } from './keys.js';
|
|
12
12
|
import { getWebIdFromRequestAsync } from '../auth/token.js';
|
|
13
|
+
import { accountDeletePage } from './views.js';
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* Handle POST /idp/credentials
|
|
@@ -358,39 +359,65 @@ export async function handleDeleteAccount(request, reply, options = {}) {
|
|
|
358
359
|
});
|
|
359
360
|
}
|
|
360
361
|
|
|
361
|
-
// 5. Delete the
|
|
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) {
|
|
362
414
|
await deleteAccount(account.id);
|
|
363
415
|
|
|
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
416
|
let purged = false;
|
|
384
417
|
if (purgeData) {
|
|
385
418
|
const dataRoot = process.env.DATA_ROOT || './data';
|
|
386
419
|
const candidate = path.resolve(dataRoot, account.podName || account.username);
|
|
387
420
|
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
421
|
const rel = path.relative(root, candidate);
|
|
395
422
|
const isProperChild = rel && rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
396
423
|
if (isProperChild) {
|
|
@@ -400,20 +427,132 @@ export async function handleDeleteAccount(request, reply, options = {}) {
|
|
|
400
427
|
} catch (err) {
|
|
401
428
|
request.log.error({ err, path: candidate, username: account.username },
|
|
402
429
|
'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
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');
|
|
407
439
|
}
|
|
408
440
|
}
|
|
441
|
+
return { purged };
|
|
442
|
+
}
|
|
409
443
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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 }));
|
|
417
556
|
}
|
|
418
557
|
|
|
419
558
|
/**
|
package/src/idp/index.js
CHANGED
|
@@ -24,10 +24,12 @@ import {
|
|
|
24
24
|
handleCredentialsInfo,
|
|
25
25
|
handleChangePassword,
|
|
26
26
|
handleDeleteAccount,
|
|
27
|
+
handleAccountDeleteForm,
|
|
28
|
+
setNoCacheClickjackHeaders,
|
|
27
29
|
} from './credentials.js';
|
|
28
30
|
import * as passkey from './passkey.js';
|
|
29
31
|
import { addTrustedIssuer } from '../auth/solid-oidc.js';
|
|
30
|
-
import { landingPage } from './views.js';
|
|
32
|
+
import { landingPage, accountDeletePage } from './views.js';
|
|
31
33
|
|
|
32
34
|
/**
|
|
33
35
|
* IdP Fastify Plugin
|
|
@@ -295,6 +297,36 @@ export async function idpPlugin(fastify, options) {
|
|
|
295
297
|
return handleDeleteAccount(request, reply, { singleUser });
|
|
296
298
|
});
|
|
297
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
|
+
|
|
298
330
|
// Interaction routes (our custom login/consent UI)
|
|
299
331
|
// These bypass oidc-provider and use our handlers
|
|
300
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
|
*/
|
|
@@ -269,6 +269,307 @@ describe('DELETE /idp/account — self-delete', () => {
|
|
|
269
269
|
});
|
|
270
270
|
});
|
|
271
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
|
+
|
|
272
573
|
describe('DELETE /idp/account — single-user mode', () => {
|
|
273
574
|
let server;
|
|
274
575
|
let baseUrl;
|