-
Notifications
You must be signed in to change notification settings - Fork 9
idp: self-service DELETE /idp/account endpoint (#352) #391
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
c54b330
471f759
82589e7
2e059c9
88a40cb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,7 +5,9 @@ | |
|
|
||
| import * as jose from 'jose'; | ||
| import crypto from 'crypto'; | ||
| import { authenticate, findByWebId, updatePassword, verifyPassword } from './accounts.js'; | ||
| import fs from 'fs-extra'; | ||
| import path from 'path'; | ||
| import { authenticate, findByWebId, updatePassword, verifyPassword, deleteAccount } from './accounts.js'; | ||
| import { getJwks } from './keys.js'; | ||
| import { getWebIdFromRequestAsync } from '../auth/token.js'; | ||
|
|
||
|
|
@@ -272,6 +274,148 @@ export async function handleChangePassword(request, reply) { | |
| }; | ||
| } | ||
|
|
||
| /** | ||
| * Handle DELETE /idp/account (#352) | ||
| * | ||
| * Owner-initiated account deletion. Authenticated caller proves | ||
| * possession via re-entering currentPassword (matches the | ||
| * password-rotation pattern in #351). Optional `purgeData: true` also | ||
| * removes the pod's filesystem tree at `<dataRoot>/<podName>/` (falling | ||
| * back to `<username>` only if podName is absent on the account record). | ||
| * | ||
| * Failure modes: | ||
| * 401 — unauthenticated, or wrong currentPassword | ||
| * 400 — invalid request body / missing password | ||
| * 403 — single-user mode (deletion would brick the server until | ||
| * re-seed; operator should use the CLI), or no account for the | ||
| * caller's WebID. The "no account" case lands here rather than | ||
| * 404 because the caller had a valid token — they're proving | ||
| * identity, just not for an account this server holds. | ||
| * | ||
| * Out of scope: invalidating in-flight access tokens. Tokens reference | ||
| * the WebID; once the account record is gone, follow-up auth attempts | ||
| * fail at findByWebId(). Existing bearer tokens that don't round-trip | ||
| * through findByWebId() will appear valid until they expire — same | ||
| * shape as the password-change endpoint. | ||
| * | ||
| * @param {object} request - Fastify request | ||
| * @param {object} reply - Fastify reply | ||
| * @param {object} options | ||
| * @param {boolean} [options.singleUser] - When true, the endpoint | ||
| * refuses (deletion would leave the server with no IDP account). | ||
| */ | ||
| export async function handleDeleteAccount(request, reply, options = {}) { | ||
| // Single-user mode: deletion via HTTP is blocked. The single-user | ||
| // pod has exactly one account; deleting it bricks the server until | ||
| // re-seed. The CLI (`jss account delete`) stays available for the | ||
| // operator who has filesystem access. | ||
| if (options.singleUser) { | ||
| return reply.code(403).send({ | ||
| error: 'forbidden', | ||
| error_description: 'Account deletion via HTTP is disabled in single-user mode. Use the `jss account delete` CLI on the server.', | ||
| }); | ||
| } | ||
|
|
||
| // 1. Authenticate caller | ||
| const { webId, error: authError } = await getWebIdFromRequestAsync(request); | ||
| if (!webId) { | ||
| return reply.code(401).send({ | ||
| error: 'invalid_token', | ||
| error_description: authError || 'Authentication required', | ||
| }); | ||
| } | ||
|
|
||
| // 2. Parse body — same flexible shape as handleChangePassword | ||
| let body = request.body; | ||
| if (Buffer.isBuffer(body)) body = body.toString('utf-8'); | ||
| if (typeof body === 'string') { | ||
| try { body = JSON.parse(body); } catch { body = {}; } | ||
| } | ||
| const currentPassword = body?.currentPassword; | ||
| const purgeData = body?.purgeData === true; | ||
|
|
||
| if (typeof currentPassword !== 'string' || !currentPassword) { | ||
| return reply.code(400).send({ | ||
| error: 'invalid_request', | ||
| error_description: 'currentPassword is required (string)', | ||
| }); | ||
| } | ||
|
|
||
| // 3. Resolve account from caller's WebID | ||
| const account = await findByWebId(webId); | ||
| if (!account) { | ||
| return reply.code(403).send({ | ||
| error: 'forbidden', | ||
| error_description: 'No account found for authenticated WebID', | ||
| }); | ||
| } | ||
|
|
||
| // 4. Verify currentPassword (re-auth proof) | ||
| if (!(await verifyPassword(account, currentPassword))) { | ||
| return reply.code(401).send({ | ||
| error: 'invalid_grant', | ||
| error_description: 'Current password is incorrect', | ||
| }); | ||
| } | ||
|
|
||
| // 5. Delete the account record + indexes | ||
| await deleteAccount(account.id); | ||
|
|
||
| // 6. Optionally purge the pod's filesystem data. Mirrors the CLI | ||
| // `--purge` semantics. The path is `<dataRoot>/<podName>/`. | ||
| // | ||
| // Use account.podName, NOT account.username: createAccount normalizes | ||
| // username to lowercase (`username.toLowerCase().trim()`) but the pod | ||
| // directory on disk is created with the original case (per the input | ||
| // to handleCreatePod). On case-sensitive filesystems, deriving the | ||
| // purge path from username would either no-op (path doesn't exist) | ||
| // or hit a different directory if one exists at the lowercased name. | ||
| // Pod-name validation regex is /^[a-zA-Z0-9_-]+$/ (alphanum + dash + | ||
| // underscore; no dots, no traversal sequences) so podName is safe to | ||
| // join — defensive normalize stays as belt-and-suspenders. | ||
| // | ||
| // Best-effort: if fs.remove throws (permissions, transient FS error, | ||
| // race with another consumer), the account is already deleted and we | ||
| // shouldn't 500 over the leftover files. Log server-side and return | ||
| // purged: false so the caller knows pod data may still exist; an | ||
| // operator can finish the cleanup with a follow-up `rm -rf` or | ||
| // CLI `--purge` against the now-orphaned directory. | ||
| let purged = false; | ||
| if (purgeData) { | ||
| const dataRoot = process.env.DATA_ROOT || './data'; | ||
| const candidate = path.resolve(dataRoot, account.podName || account.username); | ||
| const root = path.resolve(dataRoot); | ||
| // Belt-and-suspenders: refuse to remove anything that isn't a | ||
| // proper child of the data root. Won't trigger on registered pod | ||
| // names; protects against config drift / future bugs. Use | ||
| // path.relative so the check works when dataRoot is a filesystem | ||
| // root like `/` (where startsWith(root + path.sep) would compare | ||
| // against `//`, false-negative all valid children). | ||
| const rel = path.relative(root, candidate); | ||
| const isProperChild = rel && rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel); | ||
| if (isProperChild) { | ||
| try { | ||
| await fs.remove(candidate); | ||
| purged = true; | ||
| } catch (err) { | ||
| request.log.error({ err, path: candidate, username: account.username }, | ||
| 'Pod data purge failed after account deletion'); | ||
| // Don't surface the raw error to the user (file paths, | ||
| // permission detail leak); response.purged signals the | ||
| // outcome. | ||
| } | ||
| } | ||
|
Comment on lines
+361
to
+407
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Real concern. Fixed in 471f759: purge is now best-effort. The account is deleted before the purge attempt, so a throw was previously surfacing as a 500 over a partially-completed state. Now wrapped in try/catch — on throw the operator log gets the full error via request.log.error (err / path / username) and the response carries `purged: false` so the caller knows pod data may still exist. Raw err message not surfaced to the user (file paths / permission details are sensitive). Operator can finish cleanup with the CLI `--purge` against the orphaned directory. |
||
| } | ||
|
|
||
| reply.header('Cache-Control', 'no-store'); | ||
| reply.header('Pragma', 'no-cache'); | ||
| return { | ||
| ok: true, | ||
| webid: account.webId, | ||
| purged, | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * Handle GET /idp/credentials | ||
| * Returns info about the credentials endpoint | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Real bug, fixed in 82589e7. createAccount() lowercases the username (`username.toLowerCase().trim()`) but `account.podName` is stored as-is. On case-sensitive FS the pod dir is at `/Alice/` while `account.username` is `alice` — derived purge path didn't match. Fixed by using `account.podName` (with username fallback for older records). Same bug fixed at bin/jss.js:776 since the CLI's `--purge` had the identical issue (only hidden on case-insensitive devboxes like macOS default).