Skip to content

account-pane: phase 1 — change-password form (#12) - #13

Merged
melvincarvalho merged 9 commits into
gh-pagesfrom
issue-12-account-pane-phase1
May 3, 2026
Merged

melvincarvalho merged 9 commits into
gh-pagesfrom
issue-12-account-pane-phase1

Conversation

@melvincarvalho

Copy link
Copy Markdown
Contributor

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

  • `canHandle` — only on the authenticated user's own WebID profile. Compares subject document URI with `window.xlogin.id` document URI. Anonymous users never see this pane.
  • IDP endpoint resolution — reads `solid:oidcIssuer` from the profile JSON-LD (the very resource we render on always carries it). Endpoint is `${issuer}idp/credentials`. Falls back to a friendly error if no `oidcIssuer` triple is found.
  • Auth — `window.xlogin.authFetch` (Bearer/DPoP added automatically by xlogin).
  • Validation — client-side: non-empty, new !== current, new === confirm. Server-side: rest of the contract from #355 (401 wrong password, 400 missing, 403 cross-account).
  • Status mapping — 200 → green "Password updated", 401 → "Current password is incorrect", 400/403/other → contextual.

Test plan

  • Not browser-tested locally — verify before merge:
    • Authenticated as `melvin@solid.social` → navigate to `melvin.solid.social/profile/card` → "Account" tab appears
    • Change password happy path: form submits, success toast, can re-login with new password
    • Wrong current password: red error, fields preserved (current cleared optionally)
    • Mismatched new + confirm: client-side error, no request fired
    • Anonymous viewer: tab does NOT appear (canHandle returns false)
    • Resource that's not the user's profile (e.g. `/public/`): tab does NOT appear

Out of scope

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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.js with a change-password form, client-side validation, and status/error handling for PUT /idp/credentials.
  • Resolves the IDP endpoint from solid:oidcIssuer on the rendered profile document.
  • Registers the new pane in mashlib.js so 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.

Comment thread panes/account-pane.js Outdated
Comment on lines +5 to +6
function readOidcIssuer(store, subjectValue) {
var node = store.get(subjectValue) || store.get('#me') || store.get('#this')
Comment thread panes/account-pane.js
Comment on lines +61 to +64
if (res.status === 200) {
status = 'ok'
values = { current: '', next: '', confirm: '' }
} else if (res.status === 401) {
Comment thread panes/account-pane.js Outdated
Comment on lines +95 to +104
<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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Comment thread panes/account-pane.js Outdated
Comment on lines +27 to +36
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'
Comment thread panes/account-pane.js
Comment on lines +69 to +74
// 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()
}

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. 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.

Comment thread panes/account-pane.js
Comment on lines +41 to +51
// 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 }

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 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.

Comment thread panes/account-pane.js Outdated
Comment on lines +133 to +137
<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">
Comment thread panes/account-pane.js Outdated
Comment on lines +115 to +124
<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}"

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.

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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Comment thread panes/account-pane.js
Comment on lines +27 to +37
// 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)

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.

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.

Comment thread panes/account-pane.js Outdated
Comment on lines +28 to +35
// 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

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 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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Comment thread panes/account-pane.js Outdated
Comment on lines +67 to +70
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>'

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 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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Comment thread panes/account-pane.js
Comment on lines +85 to +94
// 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')

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.

Documented residual risk in 9e86bc7 and filed #14 for the proper zero-mutation fix (GET-based discovery in JSS 0.0.166). In practice WAC rejects unauth PUT before the wildcard runs; the world-writable-ACL scenario is self-inflicted misconfiguration. Probe stays for now, follow-up tracked.

Comment thread panes/account-pane.js
Comment on lines +52 to +55
if (!(window.xlogin && window.xlogin.id)) return false
var subjDoc = subject.value.replace(/#.*$/, '')
var myDoc = window.xlogin.id.replace(/#.*$/, '')
return subjDoc === myDoc

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 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).

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Comment thread panes/account-pane.js Outdated
Comment on lines +36 to +40
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

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 453a41e — normalizedPort() resolves "" to "443"/"80" based on protocol before comparison. https://example.com:443/ and https://example.com/ now match.

Comment thread panes/account-pane.js Outdated
Comment on lines +5 to +6
function pickIssuer(node) {
return node && (node['oidcIssuer'] || node['solid:oidcIssuer'] || node[SOLID + 'oidcIssuer'])

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 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".

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Comment thread panes/account-pane.js Outdated
Comment on lines +58 to +71
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)

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 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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Comment thread panes/account-pane.js
Comment on lines +75 to +77
// 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)

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 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.

Comment thread panes/account-pane.js
Comment on lines +64 to +71
// 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

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.

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.

 round 7)

Tab list is built once during boot when canHandle ran. If the user
logs out between boot and clicking the tab, render() crashes on
window.xlogin.id deref. Guard at the top of render shows a friendly
'Log in to manage your account' empty-state instead.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Comment thread panes/account-pane.js
Comment on lines +78 to +84
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>
`)
Comment thread panes/account-pane.js
Comment on lines +47 to +55
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 }
@melvincarvalho
melvincarvalho merged commit 800440c into gh-pages May 3, 2026
4 checks passed
@melvincarvalho
melvincarvalho deleted the issue-12-account-pane-phase1 branch May 3, 2026 16:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants