Repository navigation
account-pane: phase 1 — change-password form (#12) - #13
Conversation
New pane that drives JSS's PUT /idp/credentials endpoint (shipped in JavaScriptSolidServer/JavaScriptSolidServer#355, JSS 0.0.165). - canHandle: only on the authenticated user's own WebID profile (subject doc URI matches window.xlogin.id's doc URI) - IDP endpoint resolved from solid:oidcIssuer in the profile JSON-LD (the very resource we render on always carries it) - Form: current / new / confirm-new with client-side validation (non-empty, new !== current, new === confirm) - Submit via window.xlogin.authFetch (Bearer/DPoP added by xlogin) - Status mapping: 200 → ok toast, 401 → wrong-password inline error, 400/403/other → contextual error - Anonymous users never see this pane (canHandle returns false when no auth) Phase 2+ (delete account, export, portability) waits on JSS endpoints in #352/#353/#354.
There was a problem hiding this comment.
Pull request overview
This PR adds a new Account pane to the LOSOS pane registry so authenticated users can change their Solid account password from their own profile document, using the IDP credentials endpoint exposed by JSS.
Changes:
- Adds
panes/account-pane.jswith a change-password form, client-side validation, and status/error handling forPUT /idp/credentials. - Resolves the IDP endpoint from
solid:oidcIssueron the rendered profile document. - Registers the new pane in
mashlib.jsso it appears alongside the existing built-in panes.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
panes/account-pane.js |
New account-management pane with password update UI and endpoint submission logic. |
mashlib.js |
Registers the new account pane in the default pane list. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| function readOidcIssuer(store, subjectValue) { | ||
| var node = store.get(subjectValue) || store.get('#me') || store.get('#this') |
| if (res.status === 200) { | ||
| status = 'ok' | ||
| values = { current: '', next: '', confirm: '' } | ||
| } else if (res.status === 401) { |
| <label style="${label}">Current password</label> | ||
| <input type="password" autocomplete="current-password" style="${input}" | ||
| value="${values.current}" oninput="${(e) => update('current', e)}" disabled="${submitting}" /> | ||
|
|
||
| <label style="${label}">New password</label> | ||
| <input type="password" autocomplete="new-password" style="${input}" | ||
| value="${values.next}" oninput="${(e) => update('next', e)}" disabled="${submitting}" /> | ||
|
|
||
| <label style="${label}">Confirm new password</label> | ||
| <input type="password" autocomplete="new-password" style="${input}" |
Critical: prior commit could leak the password to disk on JSS <0.0.165 because the wildcard LDP PUT handler caught the request and wrote the JSON body as a file at /idp/credentials. Verified happened on solid.social during testing — see PR #13 thread. Now the pane sends an UNAUTHENTICATED probe PUT first. The dedicated handler in JSS 0.0.165+ returns a specific 401 with body {error: "invalid_token", error_description: "Authentication required"}. Wildcard fallthrough on older JSS produces a different error shape (or no JSON body), so we only proceed with the real PUT after seeing the sentinel. The probe is unauthenticated and has no body, so even if it falls through to the wildcard on a misconfigured server, no credentials are leaked — at worst an empty file is created. Also: bcrypt rotation fix (e.g. solid:oidcIssuer key shape) from prior commit retained.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| var issuer = readOidcIssuer(store, subject.value) | ||
| if (!issuer) { | ||
| container.innerHTML = '<div style="max-width:520px;margin:60px auto;padding:40px;text-align:center;color:#888;font-family:-apple-system,sans-serif">' | ||
| + '<h2 style="color:#1a1a1a">\u{1F510} Account</h2>' | ||
| + '<p>No <code>solid:oidcIssuer</code> found in this profile — cannot resolve the IDP endpoint.</p></div>' | ||
| return | ||
| } | ||
|
|
||
| var doFetch = (window.xlogin && window.xlogin.authFetch) || fetch | ||
| var endpoint = issuer + 'idp/credentials' |
| // Preflight before sending the password — version-skew protection. | ||
| if (!(await preflight())) { | ||
| status = 'err' | ||
| errMsg = 'This server does not support self-service password change. The administrator needs to update JSS to 0.0.165 or later.' | ||
| return redraw() | ||
| } |
There was a problem hiding this comment.
Declining. The preflight defends against a confirmed leak vector — JSS <0.0.165 wildcard PUT writes the JSON body (with the plaintext password) to disk at /idp/credentials. Verified happened on solid.social during testing, confirmed by deleted file and JSS upgrade. The false-negative scenario you describe (proxy/CORS/error-shape change) is hypothetical and recoverable (server admin updates JSS); the leak scenario is irrecoverable (password on disk in plaintext). Trade is asymmetric — preflight stays.
| // Preflight: probe the endpoint UNAUTHENTICATED to confirm the dedicated | ||
| // PUT handler exists. The handler's 401 has a specific shape that the LDP | ||
| // wildcard fallthrough would not produce. Crucial: unauth PUT with no body | ||
| // can never leak credentials even if it does fall through. | ||
| async function preflight() { | ||
| try { | ||
| var res = await fetch(endpoint, { method: 'PUT' }) | ||
| if (res.status !== 401) return false | ||
| var body = await res.json().catch(function() { return null }) | ||
| return !!(body && body.error === 'invalid_token') | ||
| } catch (e) { return false } |
There was a problem hiding this comment.
Declining for the same reason as the preflight comment. The cost (2x rate-limit hit, 2x audit noise) on a UX action that runs at most a few times per user lifetime is acceptable; the leak-prevention benefit is not. Probe is unauthenticated with empty body, so it cannot itself leak credentials even if it falls through.
| <div style="margin-top:18px;padding:12px 16px;background:#ecfdf5;color:#065f46;border-radius:8px;font-size:14px"> | ||
| Password updated. | ||
| </div>` : null} | ||
| ${status === 'err' ? html` | ||
| <div style="margin-top:18px;padding:12px 16px;background:#fef2f2;color:#991b1b;border-radius:8px;font-size:14px"> |
| <label style="${label}">Current password</label> | ||
| <input type="password" autocomplete="current-password" style="${input}" | ||
| value="${values.current}" oninput="${(e) => update('current', e)}" disabled="${submitting}" /> | ||
|
|
||
| <label style="${label}">New password</label> | ||
| <input type="password" autocomplete="new-password" style="${input}" | ||
| value="${values.next}" oninput="${(e) => update('next', e)}" disabled="${submitting}" /> | ||
|
|
||
| <label style="${label}">Confirm new password</label> | ||
| <input type="password" autocomplete="new-password" style="${input}" |
There was a problem hiding this comment.
Same as the earlier label/for comment — addressed in 06fc656 (id + for added on all three password inputs).
Security (CRITICAL): - validateIssuer: refuse to send credentials when solid:oidcIssuer's hostname doesn't match the current page's hostname (or a parent domain — e.g. melvin.solid.social on solid.social IDP). Tampered profile cannot redirect the password to an attacker origin. Same-protocol enforcement prevents downgrade attacks. Bug fixes: - readOidcIssuer: try a wider set of common subject fragments (#me/#this/#i/#card and the doc URI itself) so profiles with non-#me fragments resolve correctly - clearInputs via refs: html.js patches attributes via setAttribute() which doesn't sync input.value. Use ref()/ref.el.value = '' to imperatively clear after success Accessibility: - label for=/input id= association on all three password fields (click-to-focus + screen reader labelling) - role="status" aria-live="polite" wrapper around success/error messages so assistive tech announces the state change Declined: - Preflight removal (Copilot: brittle gate). Leak risk strictly worse than false-negative gate; preflight stays. - Doubled-PUT cost. Same tradeoff.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Validate the issuer URL came from a profile that hasn't been tampered to | ||
| // redirect credentials. Require: same protocol as current page, AND issuer | ||
| // hostname is either the page hostname or a parent of it (e.g. page is | ||
| // melvin.solid.social, issuer is solid.social). | ||
| function validateIssuer(iss) { | ||
| try { | ||
| var u = new URL(iss) | ||
| if (u.protocol !== window.location.protocol) return false | ||
| var page = window.location.hostname | ||
| var issHost = u.hostname | ||
| return issHost === page || page.endsWith('.' + issHost) |
There was a problem hiding this comment.
Decline. The right fix is to validate against the issuer xlogin actually authenticated to, not the profile content. xlogin doesn't expose that today — filed melvincarvalho/xlogin#15 to add it. Until that lands we prefer false-negative (clear error for the rare external-IDP case) over false-positive (cross-origin password leak for everyone). Limitation now documented in validateIssuer's JSDoc.
| // redirect credentials. Require: same protocol as current page, AND issuer | ||
| // hostname is either the page hostname or a parent of it (e.g. page is | ||
| // melvin.solid.social, issuer is solid.social). | ||
| function validateIssuer(iss) { | ||
| try { | ||
| var u = new URL(iss) | ||
| if (u.protocol !== window.location.protocol) return false | ||
| var page = window.location.hostname |
There was a problem hiding this comment.
Addressed in bb8483a — port comparison added. Tampered profile can no longer redirect by changing the port.
Tampered profile could swap port (e.g. solid.social:8443) and pass the hostname check while pointing at an attacker's listener. URL.port comparison closes that. External-IDP setups (WebID points to shared IDP on unrelated host) remain rejected — known limitation; proper fix waits on melvincarvalho/xlogin#15 to expose the authenticated issuer.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| container.innerHTML = '<div style="max-width:520px;margin:60px auto;padding:40px;text-align:center;color:#991b1b;font-family:-apple-system,sans-serif">' | ||
| + '<h2 style="color:#1a1a1a">\u{1F510} Account</h2>' | ||
| + '<p><code>solid:oidcIssuer</code> in this profile (<code>' + issuer + '</code>) does not match the current origin. ' | ||
| + 'Refusing to send credentials cross-origin.</p></div>' |
There was a problem hiding this comment.
Addressed in 3fd59b4 — switched to losos/html.js's template tag which interpolates ${} as text nodes. Tampered solid:oidcIssuer containing HTML/script can no longer execute via this error branch.
…HTML
Tampered solid:oidcIssuer like <img onerror=...> would execute when
interpolated into innerHTML. Switch the validateIssuer-fail branch to
losos/html.js's template tag, which treats ${} as text nodes.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Preflight: probe the endpoint UNAUTHENTICATED to confirm the dedicated | ||
| // PUT handler exists. The handler's 401 has a specific shape that the LDP | ||
| // wildcard fallthrough would not produce. Crucial: unauth PUT with no body | ||
| // can never leak credentials even if it does fall through. | ||
| async function preflight() { | ||
| try { | ||
| var res = await fetch(endpoint, { method: 'PUT' }) | ||
| if (res.status !== 401) return false | ||
| var body = await res.json().catch(function() { return null }) | ||
| return !!(body && body.error === 'invalid_token') |
There was a problem hiding this comment.
| if (!(window.xlogin && window.xlogin.id)) return false | ||
| var subjDoc = subject.value.replace(/#.*$/, '') | ||
| var myDoc = window.xlogin.id.replace(/#.*$/, '') | ||
| return subjDoc === myDoc |
There was a problem hiding this comment.
Addressed in 9e86bc7 — canHandle now requires exact-match subject.value === window.xlogin.id. #this in the same doc no longer triggers the tab.
…round 4) canHandle: exact-match on subject.value vs window.xlogin.id. Same-doc match was too loose — profile docs with both #me and #this would show the Account tab on whichever node was rendered, breaking the contract. Preflight comment: documents the residual risk (misconfigured JSS <0.0.165 with world-writable ACL on /idp/credentials could see an empty file created during probe). WAC normally rejects unauth PUT before the wildcard runs, so this is theoretical. Proper zero-mutation fix tracked at #14 (GET-based discovery via JSS's handleCredentialsInfo, ships in JSS 0.0.166).
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| function validateIssuer(iss) { | ||
| try { | ||
| var u = new URL(iss) | ||
| if (u.protocol !== window.location.protocol) return false | ||
| if (u.port !== window.location.port) return false |
There was a problem hiding this comment.
Addressed in 453a41e — normalizedPort() resolves "" to "443"/"80" based on protocol before comparison. https://example.com:443/ and https://example.com/ now match.
| function pickIssuer(node) { | ||
| return node && (node['oidcIssuer'] || node['solid:oidcIssuer'] || node[SOLID + 'oidcIssuer']) |
There was a problem hiding this comment.
Addressed in 453a41e — pickIssuer() unwraps the array via Array.isArray(v) ? v[0] : v. JSON-LD [{"@id": ...}] form now resolves.
#13 round 5) Caught by Copilot: - URL.port is "" for default ports and "443"/"80" when explicit. The raw string comparison rejected same-origin pairs that just serialize the port differently. Normalize empty -> default-for-protocol before comparing. - pickIssuer didn't unwrap JSON-LD array values. solid:oidcIssuer can be [{"@id": "..."}] in conformant JSON-LD; previous code returned the array and typeof check fell through to "no issuer found".
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| export default { | ||
| label: 'Account', | ||
| icon: '\u{1F510}', | ||
|
|
||
| canHandle(subject, store) { | ||
| if (!(window.xlogin && window.xlogin.id)) return false | ||
| // Exact subject match — pane is "your account", not "any node in your | ||
| // profile doc". A profile that contains both #me and #this would | ||
| // otherwise show the tab on the wrong node. | ||
| return subject.value === window.xlogin.id | ||
| }, | ||
|
|
||
| render(subject, store, container, rawData) { | ||
| var issuer = readOidcIssuer(store, subject.value) |
There was a problem hiding this comment.
Addressed in 89afd9b. Took the opportunity to fix it properly: canHandle is back to same-doc match, but render() now reads the issuer from window.xlogin.id directly — not whichever subject LOSOS's findSubject() chose. The pane no longer cares which node the shell rendered on, it always operates on the user's actual WebID. Both concerns (#this-vs-#me visibility, wrong-subject lookup) are resolved together.
…round 6) Previous exact-match canHandle hid the Account tab on profiles where LOSOS's findSubject() prefers #this over #me (multi-node docs). The underlying concern (wrong-subject issuer lookup) is better solved by making render() target window.xlogin.id directly, regardless of which subject the shell picked. - canHandle: same-doc check (the pane is "this is my profile doc") - render: readOidcIssuer(store, window.xlogin.id) — always the WebID
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Always read from the user's WebID node, not whichever subject the | ||
| // shell picked — robust against #this-vs-#me ambiguity. | ||
| var issuer = readOidcIssuer(store, window.xlogin.id) |
There was a problem hiding this comment.
Addressed in f6b7180 — added an auth re-check at the top of render(). Logout-while-tab-cached now shows a 'Log in to manage your account' empty-state instead of crashing.
| // Same doc as the user's WebID. We don't require subject.value to equal | ||
| // the WebID, because LOSOS's findSubject() prefers #this over #me on | ||
| // multi-node profiles — exact match would hide the tab on those docs. | ||
| // render() always reads the issuer from window.xlogin.id directly, so | ||
| // the wrong-subject concern is moot. | ||
| var subjDoc = subject.value.replace(/#.*$/, '') | ||
| var myDoc = window.xlogin.id.replace(/#.*$/, '') | ||
| return subjDoc === myDoc |
There was a problem hiding this comment.
Decline. We just relaxed from exact-match in round 6 to fix the #this-vs-#me visibility regression. render() always operates on window.xlogin.id, so clicking the tab while viewing a different fragment in the same doc still shows the user their own account info — not the other subject's. The pane's contract is 'your account', not 'this subject's account'. Showing it whenever the user's WebID is in the loaded doc is intentional.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (!(window.xlogin && window.xlogin.id)) { | ||
| render(container, html` | ||
| <div style="max-width:520px;margin:60px auto;padding:40px;text-align:center;color:#888;font-family:-apple-system,sans-serif"> | ||
| <h2 style="color:#1a1a1a">${'\u{1F510}'} Account</h2> | ||
| <p>Log in to manage your account.</p> | ||
| </div> | ||
| `) |
| function validateIssuer(iss) { | ||
| try { | ||
| var u = new URL(iss) | ||
| if (u.protocol !== window.location.protocol) return false | ||
| if (normalizedPort(u) !== normalizedPort(window.location)) return false | ||
| var page = window.location.hostname | ||
| var issHost = u.hostname | ||
| return issHost === page || page.endsWith('.' + issHost) | ||
| } catch (e) { return false } |
Phase 1 of #12. Pairs with JSS `PUT /idp/credentials` shipped in JavaScriptSolidServer/JavaScriptSolidServer#355 (JSS 0.0.165).
What
New pane `panes/account-pane.js` that drives the change-password endpoint. 126 lines + 1 line in `mashlib.js` to register it.
How
Test plan
Out of scope