diff --git a/bin/jss.js b/bin/jss.js index ffd5a53a..48134651 100755 --- a/bin/jss.js +++ b/bin/jss.js @@ -773,11 +773,15 @@ accountCmd if (options.purge) { const dataRoot = process.env.DATA_ROOT || './data'; - const podPath = path.join(dataRoot, account.username); + // Use podName, not username — createAccount lowercases the + // username but pod directories on disk preserve the original + // case. On case-sensitive filesystems they can differ. + const podPath = path.join(dataRoot, account.podName || account.username); await fs.remove(podPath); console.log(`\nDeleted account ${account.username}. Pod data removed from ${podPath}.\n`); } else { - console.log(`\nDeleted account ${account.username}. Pod data preserved at /${account.username}/ (use --purge to remove).\n`); + const podDir = account.podName || account.username; + console.log(`\nDeleted account ${account.username}. Pod data preserved at /${podDir}/ (use --purge to remove).\n`); } } catch (err) { console.error(`Error: ${err.message}`); diff --git a/src/idp/credentials.js b/src/idp/credentials.js index 3bf5950c..d09caa0c 100644 --- a/src/idp/credentials.js +++ b/src/idp/credentials.js @@ -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 `//` (falling + * back to `` 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 `//`. + // + // 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. + } + } + } + + 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 diff --git a/src/idp/index.js b/src/idp/index.js index c963609c..740e864f 100644 --- a/src/idp/index.js +++ b/src/idp/index.js @@ -23,6 +23,7 @@ import { handleCredentials, handleCredentialsInfo, handleChangePassword, + handleDeleteAccount, } from './credentials.js'; import * as passkey from './passkey.js'; import { addTrustedIssuer } from '../auth/solid-oidc.js'; @@ -279,6 +280,21 @@ export async function idpPlugin(fastify, options) { return handleChangePassword(request, reply); }); + // DELETE account - authenticated owner deletes their own account (#352). + // Single-user mode is rejected at the handler (deletion would leave the + // server with no IDP account until re-seed; CLI is the operator path). + fastify.delete('/idp/account', { + config: { + rateLimit: { + max: 5, + timeWindow: '1 minute', + keyGenerator: (request) => request.ip + } + } + }, async (request, reply) => { + return handleDeleteAccount(request, reply, { singleUser }); + }); + // Interaction routes (our custom login/consent UI) // These bypass oidc-provider and use our handlers diff --git a/test/idp-delete-account.test.js b/test/idp-delete-account.test.js new file mode 100644 index 00000000..f8652bd0 --- /dev/null +++ b/test/idp-delete-account.test.js @@ -0,0 +1,334 @@ +/** + * DELETE /idp/account — authenticated owner deletes their own account (#352) + */ + +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert'; +import { createServer } from '../src/server.js'; +import fs from 'fs-extra'; +import path from 'path'; +import { createServer as createNetServer } from 'net'; + +const TEST_HOST = 'localhost'; + +function getAvailablePort() { + return new Promise((resolve, reject) => { + const srv = createNetServer(); + srv.on('error', reject); + srv.listen(0, TEST_HOST, () => { + const port = srv.address().port; + srv.close(() => resolve(port)); + }); + }); +} + +async function createPod(baseUrl, name, email, password) { + const res = await fetch(`${baseUrl}/.pods`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, email, password }), + }); + const body = await res.json().catch(() => ({})); + assert.strictEqual(res.status, 201, `pod create failed: ${JSON.stringify(body)}`); + return body; +} + +async function loginToken(baseUrl, email, password) { + const res = await fetch(`${baseUrl}/idp/credentials`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + const body = await res.json().catch(() => ({})); + assert.strictEqual(res.status, 200, `login failed: ${JSON.stringify(body)}`); + return body.access_token; +} + +describe('DELETE /idp/account — self-delete', () => { + let server; + let baseUrl; + let originalDataRoot; + const DATA_DIR = './test-data-delete-account'; + + before(async () => { + originalDataRoot = process.env.DATA_ROOT; + await fs.remove(DATA_DIR); + await fs.ensureDir(DATA_DIR); + const port = await getAvailablePort(); + baseUrl = `http://${TEST_HOST}:${port}`; + server = createServer({ + logger: false, + root: DATA_DIR, + idp: true, + idpIssuer: baseUrl, + forceCloseConnections: true, + }); + await server.listen({ port, host: TEST_HOST }); + }); + + after(async () => { + await server.close(); + await fs.remove(DATA_DIR); + if (originalDataRoot === undefined) delete process.env.DATA_ROOT; + else process.env.DATA_ROOT = originalDataRoot; + }); + + it('rejects unauthenticated request with 401', async () => { + const res = await fetch(`${baseUrl}/idp/account`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ currentPassword: 'whatever' }), + }); + assert.strictEqual(res.status, 401); + }); + + it('rejects missing currentPassword with 400', async () => { + const id = `alice${Date.now()}`; + await createPod(baseUrl, id, `${id}@example.com`, 'password123'); + const token = await loginToken(baseUrl, `${id}@example.com`, 'password123'); + + const res = await fetch(`${baseUrl}/idp/account`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({}), + }); + assert.strictEqual(res.status, 400); + }); + + it('rejects wrong currentPassword with 401, account untouched', async () => { + const id = `bob${Date.now()}`; + await createPod(baseUrl, id, `${id}@example.com`, 'password123'); + const token = await loginToken(baseUrl, `${id}@example.com`, 'password123'); + + const res = await fetch(`${baseUrl}/idp/account`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ currentPassword: 'wrongpassword' }), + }); + assert.strictEqual(res.status, 401); + + // Account still works + const reLogin = await fetch(`${baseUrl}/idp/credentials`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: `${id}@example.com`, password: 'password123' }), + }); + assert.strictEqual(reLogin.status, 200); + }); + + it('happy path: deletes account; subsequent login fails with 401', async () => { + const id = `carol${Date.now()}`; + await createPod(baseUrl, id, `${id}@example.com`, 'password123'); + const token = await loginToken(baseUrl, `${id}@example.com`, 'password123'); + + const res = await fetch(`${baseUrl}/idp/account`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ currentPassword: 'password123' }), + }); + assert.strictEqual(res.status, 200); + const body = await res.json(); + assert.strictEqual(body.ok, true); + assert.ok(body.webid.includes(id), 'response carries webid'); + assert.strictEqual(body.purged, false, 'purgeData defaults to false'); + + // Login as the same user now fails + const reLogin = await fetch(`${baseUrl}/idp/credentials`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: `${id}@example.com`, password: 'password123' }), + }); + assert.strictEqual(reLogin.status, 401); + }); + + it('purgeData: true also wipes the pod filesystem tree', async () => { + const id = `dave${Date.now()}`; + await createPod(baseUrl, id, `${id}@example.com`, 'password123'); + const token = await loginToken(baseUrl, `${id}@example.com`, 'password123'); + + // Pod tree exists before deletion + const podPath = path.join(DATA_DIR, id); + assert.strictEqual(await fs.pathExists(podPath), true, + 'pod data should exist before deletion'); + + const res = await fetch(`${baseUrl}/idp/account`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ currentPassword: 'password123', purgeData: true }), + }); + assert.strictEqual(res.status, 200); + const body = await res.json(); + assert.strictEqual(body.purged, true); + + // Pod tree gone + assert.strictEqual(await fs.pathExists(podPath), false, + 'pod data should be purged'); + }); + + it('purgeData: true removes the on-disk pod dir even when it has uppercase letters', async () => { + // Regression for the bug where purge derived its path from + // account.username (which createAccount lowercases) instead of + // account.podName (which preserves the original case). On + // case-sensitive filesystems the pod dir at /Greta…/ + // wouldn't match the derived /greta…/ path. + const id = `Greta${Date.now()}`; + await createPod(baseUrl, id, `${id.toLowerCase()}@example.com`, 'password123'); + const token = await loginToken(baseUrl, `${id.toLowerCase()}@example.com`, 'password123'); + + const podPath = path.join(DATA_DIR, id); // mixed-case as created + assert.strictEqual(await fs.pathExists(podPath), true, + 'pod data should exist at the mixed-case path before deletion'); + + const res = await fetch(`${baseUrl}/idp/account`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ currentPassword: 'password123', purgeData: true }), + }); + assert.strictEqual(res.status, 200); + const body = await res.json(); + assert.strictEqual(body.purged, true, 'purge should report success'); + + assert.strictEqual(await fs.pathExists(podPath), false, + 'mixed-case pod dir should be gone (regression: not stranded by username lowercasing)'); + }); + + it('purgeData: false (default) preserves the pod filesystem tree', async () => { + const id = `frank${Date.now()}`; + await createPod(baseUrl, id, `${id}@example.com`, 'password123'); + const token = await loginToken(baseUrl, `${id}@example.com`, 'password123'); + + const podPath = path.join(DATA_DIR, id); + assert.strictEqual(await fs.pathExists(podPath), true); + + const res = await fetch(`${baseUrl}/idp/account`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + // Note: no purgeData flag at all + body: JSON.stringify({ currentPassword: 'password123' }), + }); + assert.strictEqual(res.status, 200); + + // Account is gone but pod data preserved + assert.strictEqual(await fs.pathExists(podPath), true, + 'pod data should be preserved when purgeData is omitted'); + }); + + it('cross-account: A authenticated, sending B\'s password — fails 401, neither account touched', async () => { + const aId = `eve${Date.now()}`; + const bId = `mallory${Date.now() + 1}`; + await createPod(baseUrl, aId, `${aId}@example.com`, 'apassword123'); + await createPod(baseUrl, bId, `${bId}@example.com`, 'bpassword123'); + + const aToken = await loginToken(baseUrl, `${aId}@example.com`, 'apassword123'); + + // A sends B's password — handler resolves account from A's WebID, so the + // currentPassword must match A's. With B's password it fails 401 (and + // crucially doesn't touch either account). + const res = await fetch(`${baseUrl}/idp/account`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${aToken}`, + }, + body: JSON.stringify({ currentPassword: 'bpassword123' }), + }); + assert.strictEqual(res.status, 401); + + // Both accounts still functional + const aLogin = await fetch(`${baseUrl}/idp/credentials`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: `${aId}@example.com`, password: 'apassword123' }), + }); + assert.strictEqual(aLogin.status, 200); + + const bLogin = await fetch(`${baseUrl}/idp/credentials`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: `${bId}@example.com`, password: 'bpassword123' }), + }); + assert.strictEqual(bLogin.status, 200); + }); +}); + +describe('DELETE /idp/account — single-user mode', () => { + let server; + let baseUrl; + let originalDataRoot; + let originalPassword; + const DATA_DIR = './test-data-delete-account-single'; + + before(async () => { + originalDataRoot = process.env.DATA_ROOT; + originalPassword = process.env.JSS_SINGLE_USER_PASSWORD; + process.env.JSS_SINGLE_USER_PASSWORD = 'singletest'; + await fs.remove(DATA_DIR); + await fs.ensureDir(DATA_DIR); + const port = await getAvailablePort(); + baseUrl = `http://${TEST_HOST}:${port}`; + server = createServer({ + logger: false, + root: DATA_DIR, + idp: true, + idpIssuer: baseUrl, + singleUser: true, + singleUserName: 'me', + singleUserPassword: 'singletest', + forceCloseConnections: true, + }); + await server.listen({ port, host: TEST_HOST }); + }); + + after(async () => { + await server.close(); + await fs.remove(DATA_DIR); + if (originalDataRoot === undefined) delete process.env.DATA_ROOT; + else process.env.DATA_ROOT = originalDataRoot; + if (originalPassword === undefined) delete process.env.JSS_SINGLE_USER_PASSWORD; + else process.env.JSS_SINGLE_USER_PASSWORD = originalPassword; + }); + + it('returns 403 in single-user mode (deletion would brick the server)', async () => { + // Even with a valid token, the endpoint refuses in single-user mode. + // Operator must use the CLI (`jss account delete`) instead. + const token = await loginToken(baseUrl, 'me', 'singletest'); + + const res = await fetch(`${baseUrl}/idp/account`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ currentPassword: 'singletest' }), + }); + assert.strictEqual(res.status, 403); + const body = await res.json(); + assert.match(body.error_description || '', /single-user/i); + + // Account still functional + const reLogin = await fetch(`${baseUrl}/idp/credentials`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: 'me', password: 'singletest' }), + }); + assert.strictEqual(reLogin.status, 200); + }); +});