Files
xarmian 329b931a12 fix(account): account deletion needs an interactive, recently signed-in session (BUG-3336) (#1733)
* fix(account): an API token cannot delete the account; confirm-only is for passwordless cloud accounts (BUG-3336)

POST /auth/delete-account deletes the user and soft-deletes every
workspace they own, and two holes let it go through with less proof than
the caller should have to show.

1. Confirm-only (no password) was accepted from ANY cloud account. The path
   exists for OAuth-only accounts, which have no password to give, but it
   never checked HasPassword, so a cloud session alone could delete an
   account that has one. It now requires !HasPassword. This matches the web
   client, which offers confirm-only only when password_set is false.
2. A PAT was accepted like a session, so a leaked write token could delete
   the account (and, with the password, on self-host too). An API token is
   now refused with 403 session_required, before the body is read, so it
   cannot probe the password either. This is the line BUG-2890 drew for
   token minting. A browser or `pad auth login` CLI session still works.
   No CLI or MCP command deletes an account.

TOTP is unchanged and still runs after either identity check.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* fix(account): confirm-only deletion needs a recent sign-in; rotation keeps the sign-in time (BUG-3336)

codex review of the first commit: password_set=false does not prove an
account has no password. An account that signed up with a password before
migration 043 and later linked OAuth reads false and still holds a real
hash, and CreateOAuthUser stores a hash too (a random placeholder), so the
data cannot tell the two apart. A session alone could still confirm-delete
such an account.

Confirm-only now also needs a session signed in within
accountDeleteReauthWindow (10 minutes, measured from sessions.created_at).
Otherwise it answers 403 reauth_required, "For your security, sign in again
to delete your account", and the settings page offers a "Sign in again"
button: it signs out and returns to /login?redirect=/console/settings,
because /login sends a signed-in visitor straight on.

A credential-change rotation (password change, 2FA, OAuth unlink) no longer
restarts the clock. CreateSessionIssuedAt gives the new session the
replaced one's created_at, which also stops a rotation from extending
SessionMaxLifetime (measured from created_at); its expiry is capped there.
ValidateSession now returns CreatedAt.

TestWorkspaceAccessChanged_AccountDeletion deleted through a PAT, the path
the first commit refuses; it now uses an interactive session.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* fix(auth): no session-minting door hands out a fresh sign-in without one (BUG-3336, BUG-3349a)

codex review, round 2: the re-auth window could be beaten by minting a
session whose created_at reads as new.

- CLI approval minted a 30-day session from any authenticated caller. It
  now refuses an API token (403 session_required), and the CLI session it
  mints carries the APPROVING session's sign-in time
  (CreateSessionIssuedAt). An approver whose session cannot be read is
  refused.
- requestSessionInfo read `Bearer padsess_` literally and missed a padded
  header that TokenAuth accepts after trimming. It now reads the header
  the way TokenAuth does, and a bearer of any kind is the credential.
- Rotation (rotateSessionsAfterCredentialChange) fell back to a fresh
  created_at when it could not find the request's session. Every caller is
  a session-only door, so it now revokes every session and asks for a new
  sign-in instead of minting one.
- oauth-unlink and PATCH /auth/me with a password change refuse an API
  token, so rotation is reachable only from a session (TOTP disable already
  was). A PAT can still edit the profile.

These three gates are BUG-3349's part (a), moved here because the re-auth
window depends on them (lead's ruling). Legs: CLI approval from an old
session or a PAT, the padded bearer, rotation with no session, and the
SessionMaxLifetime cap on rotation.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* fix(auth): admin password reset refuses an API token; the request's session is the one that authenticated it (BUG-3336)

codex review, round 3.

- An admin's PAT could reset its own account, get a temp_password back,
  and sign in as a fresh session. handleAdminResetPassword now refuses an
  API token (403 session_required).
- requestSessionInfo re-read the Authorization header. An invalid bearer
  on /auth/* falls through to the cookie in TokenAuth, so a valid cookie
  session beside a stale PAT header read as "no session": CLI approval
  answered 401, and the rotation doors revoked every session and answered
  500. It now follows ctxAuthKind, the credential the middleware actually
  used (as credentialLiveness does): a session bearer is read from the
  header, an API token is no session, and anything else from the cookie.
- Legs added:
  - the password-change, oauth-unlink and admin-reset PAT gates, each
    refusing with nothing changed or revoked behind the refusal;
  - a stale bearer beside a valid cookie;
  - a rotation with no session revoking every session.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
(cherry picked from commit 3d21213b57)
2026-10-02 19:45:50 +00:00
..