Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/idp/accounts.js
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,19 @@ export async function createAccount({ username, password, webId, podName, email
return safeAccount;
}

/**
* Verify a password against an account's stored hash without side effects.
* Use this for re-auth proofs (e.g. password rotation) where stamping
* lastLogin would falsify the audit trail.
* @param {object} account - Account object with passwordHash
* @param {string} password - Plain text password
* @returns {Promise<boolean>}
*/
export async function verifyPassword(account, password) {
if (!account?.passwordHash) return false;
return bcrypt.compare(password, account.passwordHash);
}

/**
* Authenticate a user with username/email and password
* @param {string} identifier - Username or email
Expand Down
76 changes: 75 additions & 1 deletion src/idp/credentials.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@

import * as jose from 'jose';
import crypto from 'crypto';
import { authenticate } from './accounts.js';
import { authenticate, findByWebId, updatePassword, verifyPassword } from './accounts.js';
import { getJwks } from './keys.js';
import { getWebIdFromRequestAsync } from '../auth/token.js';

/**
* Handle POST /idp/credentials
Expand Down Expand Up @@ -198,6 +199,79 @@ async function validateDpopProof(proof, method, url) {
return thumbprint;
}

/**
* Handle PUT /idp/credentials
* Authenticated owner rotates their own password.
*
* Auth: caller must be authenticated (Bearer/DPoP/Nostr-NIP-98).
* Body (JSON): { currentPassword, newPassword }
*
* Responses:
* 200 { ok: true, webid, passwordChangedAt }
* 400 missing fields
* 401 unauthenticated, or currentPassword wrong
* 403 caller's WebID does not match any account
*/
export async function handleChangePassword(request, reply) {
// 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
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 newPassword = body?.newPassword;

if (typeof currentPassword !== 'string' || typeof newPassword !== 'string'
|| !currentPassword || !newPassword) {
return reply.code(400).send({
error: 'invalid_request',
error_description: 'currentPassword and newPassword are required (strings)',
});
Comment on lines +231 to +239

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 6ea89c7 — both fields now require typeof === 'string' and non-empty. Non-string values return a clean 400 invalid_request instead of reaching bcrypt.

}
Comment on lines +225 to +240

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declining: this endpoint's contract is JSON-only — the spec in #351 lists only application/json, and any caller already sends JSON for the auth header (Bearer/DPoP/Nostr). Adding a 415 path or form-encoded support is bytes for a hypothetical client that doesn't exist. The current behavior — non-JSON body → 400 "missing fields" — is acceptable for a contract-violating request.

The other three comments (Pragma header, JSDoc 403 wording, DATA_ROOT save/restore) are all addressed in f0556c4.


// 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). Side-effect-free — does NOT
// stamp lastLogin, since password rotation isn't a login event.
if (!(await verifyPassword(account, currentPassword))) {
return reply.code(401).send({
error: 'invalid_grant',
error_description: 'Current password is incorrect',
});
}

// 5. Rotate
await updatePassword(account.id, newPassword);

// Re-read to surface passwordChangedAt
const updated = await findByWebId(webId);

reply.header('Cache-Control', 'no-store');
reply.header('Pragma', 'no-cache');
return {
ok: true,
webid: account.webId,
passwordChangedAt: updated?.passwordChangedAt,
};
}

/**
* Handle GET /idp/credentials
* Returns info about the credentials endpoint
Expand Down
14 changes: 14 additions & 0 deletions src/idp/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
import {
handleCredentials,
handleCredentialsInfo,
handleChangePassword,
} from './credentials.js';
import * as passkey from './passkey.js';
import { addTrustedIssuer } from '../auth/solid-oidc.js';
Expand Down Expand Up @@ -264,6 +265,19 @@ export async function idpPlugin(fastify, options) {
return handleCredentials(request, reply, issuer);
});

// PUT credentials - authenticated owner rotates their own password (#351)
fastify.put('/idp/credentials', {
config: {
rateLimit: {
max: 10,
timeWindow: '1 minute',
keyGenerator: (request) => request.ip
}
Comment on lines +269 to +275

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred to a separate issue: #356. The same keyGenerator: (request) => request.ip is on existing routes (POST /idp/credentials, POST /idp/interaction/:uid); fixing only PUT here would be inconsistent and the right fix is server-wide — audit trustProxy (default to 'loopback'), document deployment requirements. Tracking in #356.

}
}, async (request, reply) => {
return handleChangePassword(request, reply);
});

// Interaction routes (our custom login/consent UI)
// These bypass oidc-provider and use our handlers

Expand Down
206 changes: 206 additions & 0 deletions test/idp-change-password.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
/**
* PUT /idp/credentials — authenticated owner rotates their own password (#351)
*/

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 { 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('PUT /idp/credentials — change password', () => {
let server;
let baseUrl;
let originalDataRoot;
const DATA_DIR = './test-data-change-password';

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;
});
Comment on lines +52 to +73

it('rejects unauthenticated request with 401', async () => {
const res = await fetch(`${baseUrl}/idp/credentials`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ currentPassword: 'a', newPassword: 'b' }),
});
assert.strictEqual(res.status, 401);
});

it('rejects missing fields with 400', async () => {
const id = `alice${Date.now()}`;
await createPod(baseUrl, id, `${id}@example.com`, 'oldpassword123');
const token = await loginToken(baseUrl, `${id}@example.com`, 'oldpassword123');

const res = await fetch(`${baseUrl}/idp/credentials`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ currentPassword: 'oldpassword123' }),
});
assert.strictEqual(res.status, 400);
});

it('rejects wrong current password with 401, hash unchanged', async () => {
const id = `bob${Date.now()}`;
await createPod(baseUrl, id, `${id}@example.com`, 'oldpassword123');
const token = await loginToken(baseUrl, `${id}@example.com`, 'oldpassword123');

const res = await fetch(`${baseUrl}/idp/credentials`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
currentPassword: 'wrongpassword',
newPassword: 'newpassword456',
}),
});
assert.strictEqual(res.status, 401);

// Original password still works
const reLogin = await fetch(`${baseUrl}/idp/credentials`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: `${id}@example.com`, password: 'oldpassword123' }),
});
assert.strictEqual(reLogin.status, 200);
});

it('happy path: rotates password, old fails, new succeeds', async () => {
const id = `carol${Date.now()}`;
await createPod(baseUrl, id, `${id}@example.com`, 'oldpassword123');
const token = await loginToken(baseUrl, `${id}@example.com`, 'oldpassword123');

const res = await fetch(`${baseUrl}/idp/credentials`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
currentPassword: 'oldpassword123',
newPassword: 'newpassword456',
}),
});
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.ok(body.passwordChangedAt, 'response carries passwordChangedAt');

// Old password rejected
const oldRes = await fetch(`${baseUrl}/idp/credentials`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: `${id}@example.com`, password: 'oldpassword123' }),
});
assert.strictEqual(oldRes.status, 401);

// New password accepted
const newRes = await fetch(`${baseUrl}/idp/credentials`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: `${id}@example.com`, password: 'newpassword456' }),
});
assert.strictEqual(newRes.status, 200);
});

it('cross-account write: A authenticated cannot rotate B by sending B\'s currentPassword', async () => {
const aId = `dave${Date.now()}`;
const bId = `eve${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 currentPassword → server resolves account from A's WebID, so the
// currentPassword must match A's, not B's. With B's password it must fail 401
// (and crucially must NOT touch B's account).
const res = await fetch(`${baseUrl}/idp/credentials`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${aToken}`,
},
body: JSON.stringify({
currentPassword: 'bpassword123',
newPassword: 'hijack',
}),
});
assert.strictEqual(res.status, 401);

// B's password unchanged
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);

// A's password also unchanged
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);
});
});