-
Notifications
You must be signed in to change notification settings - Fork 9
idp: add PUT /idp/credentials for self-service password change #355
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
dfc30f8
f0556c4
6ea89c7
cf05d1c
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,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 | ||
|
|
@@ -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
+225
to
+240
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. Declining: this endpoint's contract is JSON-only — the spec in #351 lists only 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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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'; | ||
|
|
@@ -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
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. Deferred to a separate issue: #356. The same |
||
| } | ||
| }, async (request, reply) => { | ||
| return handleChangePassword(request, reply); | ||
| }); | ||
|
|
||
| // Interaction routes (our custom login/consent UI) | ||
| // These bypass oidc-provider and use our handlers | ||
|
|
||
|
|
||
| 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); | ||
| }); | ||
| }); | ||
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.
Addressed in 6ea89c7 — both fields now require
typeof === 'string'and non-empty. Non-string values return a clean 400invalid_requestinstead of reaching bcrypt.