Skip to content

Commit aaeae1e

Browse files
idp: self-service DELETE /idp/account endpoint (JavaScriptSolidServer#352) (JavaScriptSolidServer#391)
* idp: self-service DELETE /idp/account endpoint (JavaScriptSolidServer#352) Authenticated owner can now delete their own account via HTTP. Mirrors the password-rotation pattern from JavaScriptSolidServer#351 (PUT /idp/credentials): caller authenticates with their existing session, re-proves possession by sending currentPassword in the body, server resolves the account from the caller's WebID and deletes it. Owner-only by construction — the cross-account attack (A authenticated, sends B's password) returns 401 and touches nothing. Two new shape elements vs. password rotation: 1. Single-user mode rejects with 403. Deleting the single account would brick the IdP until re-seed; the operator path stays the CLI (`jss account delete`). Test asserts both 403 status and account-still-functional after. 2. Optional `purgeData: true` body flag mirrors the CLI's `--purge`. Removes the pod's filesystem tree at <dataRoot>/<username>/. Path is normalized + verified to be a proper child of dataRoot (belt-and-suspenders against future config drift; usernames are already validated by registration). Default is false — account record gone, pod data preserved, matches the CLI's default. Endpoint surface: DELETE /idp/account Headers: Authorization (existing session / DPoP) Body: { currentPassword: string, purgeData?: boolean } Returns: 200 { ok, webid, purged } — success 400 — missing/invalid currentPassword 401 — unauthenticated, or wrong password 403 — single-user mode, or no account for caller's WebID Rate-limited 5/min/IP. UI deferred — the issue allows it; auth for a /idp HTML page is its own concern (existing /idp landing is unauthenticated). Endpoint + tests is the unit of value here. Doctor-app or pane work in a follow-up. Tests cover: unauthenticated, missing field, wrong password, happy path (subsequent login fails), purgeData=true wipes filesystem, purgeData=false preserves it, cross-account safety, single-user 403. 8 new tests; 627/627 full suite passes. * idp: address Copilot review on JavaScriptSolidServer#391 — JSDoc accuracy, purge best-effort Two fixes per the review: 1. JSDoc no longer claims a 404 failure mode the code doesn't return. "No account for caller's WebID" lands at 403, not 404 — the caller had a valid token (so they're identifying themselves), just not for an account this server holds. Added the rationale to the comment. 2. Purge is now best-effort. fs.remove() can throw (permissions, FS races, etc.); the account is deleted *before* the purge, so a throw would have surfaced as a 500 over a partially-completed deletion (account gone, pod data lingering). New shape: - try/catch around fs.remove - on throw: log server-side via request.log.error (with err / path / username for the operator log) and continue with purged: false - response.purged accurately signals outcome; an operator can finish the cleanup with the CLI `--purge` against the orphaned directory if needed - raw err message NOT surfaced to the user — file paths and permission detail are sensitive 8/8 delete-account tests still pass. * idp: Copilot review pass 2 on JavaScriptSolidServer#391 — purge by podName, not username Real bug. createAccount() in src/idp/accounts.js normalizes username to lowercase (`username.toLowerCase().trim()`), but the pod directory on disk is created using the original-case input to handleCreatePod. On case-sensitive filesystems these can differ: Account record: { username: "alice", podName: "Alice" } On disk: <dataRoot>/Alice/ The previous purge logic derived its path from account.username, so it would either no-op (path didn't exist) or hit a different directory if one happened to exist at the lowercased name. Both outcomes are wrong: pod data lingers, or the wrong tree is removed. Fix: use account.podName, falling back to account.username only if podName is somehow absent (older account records or edge cases). Same fix applied to the CLI at bin/jss.js:776 — same code path, same bug, would only have been hidden on case-insensitive devboxes (macOS default). Test: new "purgeData: true removes the on-disk pod dir even when it has uppercase letters" — creates a pod with mixed-case name "Greta…", verifies the on-disk dir at the mixed-case path is gone after purge. This would fail on the previous code path on a case-sensitive FS. Also corrected the inline comment about pod-name validation: it was describing the slug regex (allows dots), not the pod-name regex (/^[a-zA-Z0-9_-]+$/, no dots, allows underscores and uppercase). 9/9 delete-account tests pass. * idp: Copilot review pass 3 on JavaScriptSolidServer#391 — JSDoc match, FS-root-safe purge Two fixes: 1. JSDoc no longer says `<dataRoot>/<username>/` — corrected to `<podName>` to match the implementation (which now uses account.podName, falling back to username only if podName is absent). 2. The "candidate is a proper child of dataRoot" guard switched from `candidate.startsWith(root + path.sep)` to `path.relative(root, candidate)`. The startsWith check failed at filesystem roots: if DATA_ROOT is `/`, then `root` is `/` (1 char) and `root + path.sep` is `//` (2 chars), so any legitimate child like `/alice` fails the prefix check and the purge silently no-ops. path.relative handles that correctly — and rejects `..` traversal up front, which is tighter anyway. 9/9 delete-account tests still pass. * idp: pass 4 on JavaScriptSolidServer#391 — fix the OTHER JSDoc line that still said <username> I caught the inline comment in pass 3 but missed this higher up in the function-level JSDoc. Same correction: <username> → <podName> with fallback note.
1 parent a39b9db commit aaeae1e

4 files changed

Lines changed: 501 additions & 3 deletions

File tree

bin/jss.js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -773,11 +773,15 @@ accountCmd
773773

774774
if (options.purge) {
775775
const dataRoot = process.env.DATA_ROOT || './data';
776-
const podPath = path.join(dataRoot, account.username);
776+
// Use podName, not username — createAccount lowercases the
777+
// username but pod directories on disk preserve the original
778+
// case. On case-sensitive filesystems they can differ.
779+
const podPath = path.join(dataRoot, account.podName || account.username);
777780
await fs.remove(podPath);
778781
console.log(`\nDeleted account ${account.username}. Pod data removed from ${podPath}.\n`);
779782
} else {
780-
console.log(`\nDeleted account ${account.username}. Pod data preserved at <dataRoot>/${account.username}/ (use --purge to remove).\n`);
783+
const podDir = account.podName || account.username;
784+
console.log(`\nDeleted account ${account.username}. Pod data preserved at <dataRoot>/${podDir}/ (use --purge to remove).\n`);
781785
}
782786
} catch (err) {
783787
console.error(`Error: ${err.message}`);

src/idp/credentials.js

Lines changed: 145 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55

66
import * as jose from 'jose';
77
import crypto from 'crypto';
8-
import { authenticate, findByWebId, updatePassword, verifyPassword } from './accounts.js';
8+
import fs from 'fs-extra';
9+
import path from 'path';
10+
import { authenticate, findByWebId, updatePassword, verifyPassword, deleteAccount } from './accounts.js';
911
import { getJwks } from './keys.js';
1012
import { getWebIdFromRequestAsync } from '../auth/token.js';
1113

@@ -272,6 +274,148 @@ export async function handleChangePassword(request, reply) {
272274
};
273275
}
274276

277+
/**
278+
* Handle DELETE /idp/account (#352)
279+
*
280+
* Owner-initiated account deletion. Authenticated caller proves
281+
* possession via re-entering currentPassword (matches the
282+
* password-rotation pattern in #351). Optional `purgeData: true` also
283+
* removes the pod's filesystem tree at `<dataRoot>/<podName>/` (falling
284+
* back to `<username>` only if podName is absent on the account record).
285+
*
286+
* Failure modes:
287+
* 401 — unauthenticated, or wrong currentPassword
288+
* 400 — invalid request body / missing password
289+
* 403 — single-user mode (deletion would brick the server until
290+
* re-seed; operator should use the CLI), or no account for the
291+
* caller's WebID. The "no account" case lands here rather than
292+
* 404 because the caller had a valid token — they're proving
293+
* identity, just not for an account this server holds.
294+
*
295+
* Out of scope: invalidating in-flight access tokens. Tokens reference
296+
* the WebID; once the account record is gone, follow-up auth attempts
297+
* fail at findByWebId(). Existing bearer tokens that don't round-trip
298+
* through findByWebId() will appear valid until they expire — same
299+
* shape as the password-change endpoint.
300+
*
301+
* @param {object} request - Fastify request
302+
* @param {object} reply - Fastify reply
303+
* @param {object} options
304+
* @param {boolean} [options.singleUser] - When true, the endpoint
305+
* refuses (deletion would leave the server with no IDP account).
306+
*/
307+
export async function handleDeleteAccount(request, reply, options = {}) {
308+
// Single-user mode: deletion via HTTP is blocked. The single-user
309+
// pod has exactly one account; deleting it bricks the server until
310+
// re-seed. The CLI (`jss account delete`) stays available for the
311+
// operator who has filesystem access.
312+
if (options.singleUser) {
313+
return reply.code(403).send({
314+
error: 'forbidden',
315+
error_description: 'Account deletion via HTTP is disabled in single-user mode. Use the `jss account delete` CLI on the server.',
316+
});
317+
}
318+
319+
// 1. Authenticate caller
320+
const { webId, error: authError } = await getWebIdFromRequestAsync(request);
321+
if (!webId) {
322+
return reply.code(401).send({
323+
error: 'invalid_token',
324+
error_description: authError || 'Authentication required',
325+
});
326+
}
327+
328+
// 2. Parse body — same flexible shape as handleChangePassword
329+
let body = request.body;
330+
if (Buffer.isBuffer(body)) body = body.toString('utf-8');
331+
if (typeof body === 'string') {
332+
try { body = JSON.parse(body); } catch { body = {}; }
333+
}
334+
const currentPassword = body?.currentPassword;
335+
const purgeData = body?.purgeData === true;
336+
337+
if (typeof currentPassword !== 'string' || !currentPassword) {
338+
return reply.code(400).send({
339+
error: 'invalid_request',
340+
error_description: 'currentPassword is required (string)',
341+
});
342+
}
343+
344+
// 3. Resolve account from caller's WebID
345+
const account = await findByWebId(webId);
346+
if (!account) {
347+
return reply.code(403).send({
348+
error: 'forbidden',
349+
error_description: 'No account found for authenticated WebID',
350+
});
351+
}
352+
353+
// 4. Verify currentPassword (re-auth proof)
354+
if (!(await verifyPassword(account, currentPassword))) {
355+
return reply.code(401).send({
356+
error: 'invalid_grant',
357+
error_description: 'Current password is incorrect',
358+
});
359+
}
360+
361+
// 5. Delete the account record + indexes
362+
await deleteAccount(account.id);
363+
364+
// 6. Optionally purge the pod's filesystem data. Mirrors the CLI
365+
// `--purge` semantics. The path is `<dataRoot>/<podName>/`.
366+
//
367+
// Use account.podName, NOT account.username: createAccount normalizes
368+
// username to lowercase (`username.toLowerCase().trim()`) but the pod
369+
// directory on disk is created with the original case (per the input
370+
// to handleCreatePod). On case-sensitive filesystems, deriving the
371+
// purge path from username would either no-op (path doesn't exist)
372+
// or hit a different directory if one exists at the lowercased name.
373+
// Pod-name validation regex is /^[a-zA-Z0-9_-]+$/ (alphanum + dash +
374+
// underscore; no dots, no traversal sequences) so podName is safe to
375+
// join — defensive normalize stays as belt-and-suspenders.
376+
//
377+
// Best-effort: if fs.remove throws (permissions, transient FS error,
378+
// race with another consumer), the account is already deleted and we
379+
// shouldn't 500 over the leftover files. Log server-side and return
380+
// purged: false so the caller knows pod data may still exist; an
381+
// operator can finish the cleanup with a follow-up `rm -rf` or
382+
// CLI `--purge` against the now-orphaned directory.
383+
let purged = false;
384+
if (purgeData) {
385+
const dataRoot = process.env.DATA_ROOT || './data';
386+
const candidate = path.resolve(dataRoot, account.podName || account.username);
387+
const root = path.resolve(dataRoot);
388+
// Belt-and-suspenders: refuse to remove anything that isn't a
389+
// proper child of the data root. Won't trigger on registered pod
390+
// names; protects against config drift / future bugs. Use
391+
// path.relative so the check works when dataRoot is a filesystem
392+
// root like `/` (where startsWith(root + path.sep) would compare
393+
// against `//`, false-negative all valid children).
394+
const rel = path.relative(root, candidate);
395+
const isProperChild = rel && rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
396+
if (isProperChild) {
397+
try {
398+
await fs.remove(candidate);
399+
purged = true;
400+
} catch (err) {
401+
request.log.error({ err, path: candidate, username: account.username },
402+
'Pod data purge failed after account deletion');
403+
// Don't surface the raw error to the user (file paths,
404+
// permission detail leak); response.purged signals the
405+
// outcome.
406+
}
407+
}
408+
}
409+
410+
reply.header('Cache-Control', 'no-store');
411+
reply.header('Pragma', 'no-cache');
412+
return {
413+
ok: true,
414+
webid: account.webId,
415+
purged,
416+
};
417+
}
418+
275419
/**
276420
* Handle GET /idp/credentials
277421
* Returns info about the credentials endpoint

src/idp/index.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
handleCredentials,
2424
handleCredentialsInfo,
2525
handleChangePassword,
26+
handleDeleteAccount,
2627
} from './credentials.js';
2728
import * as passkey from './passkey.js';
2829
import { addTrustedIssuer } from '../auth/solid-oidc.js';
@@ -279,6 +280,21 @@ export async function idpPlugin(fastify, options) {
279280
return handleChangePassword(request, reply);
280281
});
281282

283+
// DELETE account - authenticated owner deletes their own account (#352).
284+
// Single-user mode is rejected at the handler (deletion would leave the
285+
// server with no IDP account until re-seed; CLI is the operator path).
286+
fastify.delete('/idp/account', {
287+
config: {
288+
rateLimit: {
289+
max: 5,
290+
timeWindow: '1 minute',
291+
keyGenerator: (request) => request.ip
292+
}
293+
}
294+
}, async (request, reply) => {
295+
return handleDeleteAccount(request, reply, { singleUser });
296+
});
297+
282298
// Interaction routes (our custom login/consent UI)
283299
// These bypass oidc-provider and use our handlers
284300

0 commit comments

Comments
 (0)