Skip to content

fix: don't hard-fail on an undecryptable saved password - #10174

Open
kundansable wants to merge 2 commits into
pgadmin-org:masterfrom
kundansable:fix-10140-oidc-decrypt-nonfatal
Open

fix: don't hard-fail on an undecryptable saved password#10174
kundansable wants to merge 2 commits into
pgadmin-org:masterfrom
kundansable:fix-10140-oidc-decrypt-nonfatal

Conversation

@kundansable

@kundansable kundansable commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #10140 — some OIDC/OAuth2 users intermittently get a hard Failed to decrypt the saved password error on every connection attempt, and the account stays broken even after being deleted and recreated via OIDC.

Root Cause

Connection._decode_password() calls decrypt() (AES-CFB8, unauthenticated) on the stored password ciphertext and lets any UnicodeDecodeError from a failed decrypt propagate as a hard error. For OIDC/OAuth2 logins, the crypt key used to encrypt a saved password can differ between sessions; if it does, the ciphertext saved under a previous key can never be decoded correctly under the current key, and every connection attempt hits this same decode failure — surfacing the scary, unrecoverable-looking "Failed to decrypt the saved password" error. Deleting and recreating the pgAdmin user doesn't help because the problem is in the stored ciphertext for the server record, not the user record.

Fix

Catch the decode failure in _decode_password(), log a warning, and return an empty/no-password result instead of propagating the exception — i.e. treat an undecryptable saved password the same as "no saved password was ever set." connect() then falls through to its normal password-prompt path, so the user is prompted for and can re-enter the correct password, making the account recoverable instead of permanently stuck. OIDC/OAuth2 crypt-key derivation itself is untouched by this change.

Test Steps

(Exact repro requires an OIDC/OAuth2 setup where the crypt key can rotate between sessions; a close approximation can be done by directly corrupting a stored password's ciphertext in the config DB for a test server.)

  1. Set up a server with a saved password (Save Password enabled).
  2. Simulate a crypt-key change or corrupt the stored password ciphertext for that server record so it no longer decrypts under the current key.
  3. Attempt to connect to that server.
  4. Before the fix: connection attempt fails immediately with "Failed to decrypt the saved password", with no path to recovery.
  5. After the fix: the bad saved password is silently discarded; pgAdmin instead prompts for the password normally, and entering the correct password connects successfully.
  6. Regression: confirm a server with a valid, correctly-encrypted saved password still connects automatically without any prompt (no over-broad fallback).

Summary by CodeRabbit

  • Bug Fixes
    • Improved connection handling when a saved password fails to decrypt.
    • If the stored encrypted password can’t be recovered, it’s now ignored and cleared.
    • Instead of an immediate connection failure, the app proceeds by prompting you to enter your password again.

Connection._decode_password() let UnicodeDecodeError from decrypt()
propagate as a hard error. For OIDC/OAuth2 users whose crypt key can
differ between sessions, a password saved under a previous key can
never be decoded, and every connection attempt surfaced "Failed to
decrypt the saved password" — even after deleting and recreating the
pgAdmin user, since the corrupted ciphertext is what's stored, not
anything tied to the user record.

Catch the decode failure, log a warning, and treat it as "no saved
password" instead of propagating the error. connect() then falls
through to the normal password prompt, so the account is recoverable
instead of permanently stuck.

Fixes pgadmin-org#10140
@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 001a2542-ef8e-4691-b2fe-e354b8df387c

📥 Commits

Reviewing files that changed from the base of the PR and between 63c71ba and 4039f35.

📒 Files selected for processing (1)
  • web/pgadmin/utils/driver/psycopg3/connection.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • web/pgadmin/utils/driver/psycopg3/connection.py

Walkthrough

The psycopg3 connection driver now treats saved-password decryption failures as non-fatal, clears cached password values, logs a warning, and allows the connection flow to request a password again.

Changes

Saved password recovery

Layer / File(s) Summary
Non-fatal decryption fallback
web/pgadmin/utils/driver/psycopg3/connection.py
_decode_password clears cached passwords and returns (False, '', None) when saved-password decryption fails, without stopping the SSH tunnel or aborting the connection.

Estimated code review effort: 2 (Simple) | ~10 minutes

Possibly related PRs

Suggested reviewers: hiteshjambhale

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: handling undecryptable saved passwords without hard-failing.
Linked Issues check ✅ Passed The change meets #10140 by ignoring undecryptable saved passwords, clearing cached values, and letting the user re-enter a password.
Out of Scope Changes check ✅ Passed The diff is narrowly scoped to password decryption failure handling and does not introduce unrelated changes.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

🧹 Nitpick comments (2)
web/pgadmin/utils/driver/psycopg3/connection.py (2)

261-274: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add regression coverage for the recovery contract.

Test invalid ciphertext, valid encrypted passwords, passexec fallback, and an SSH-tunnel server with save_password=True; assert that the invalid credential is not reused and the response prompts for a password.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/pgadmin/utils/driver/psycopg3/connection.py` around lines 261 - 274,
Extend the connection tests covering the saved-password handling around the
decryption failure path to cover invalid ciphertext, valid encrypted passwords,
passexec fallback, and an SSH-tunnel server configured with save_password=True.
Assert that invalid credentials are discarded rather than reused and that the
resulting response prompts the user for a password, while preserving successful
reuse for valid encrypted passwords and existing passexec behavior.

255-274: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Narrow the exception scope.

except Exception converts unrelated crypto-helper or programming failures into “bad saved password,” logs them as a warning, and continues with None. Catch the concrete invalid-ciphertext/decryption exceptions plus UnicodeDecodeError, after verifying the repository’s decrypt() contract, and let unexpected failures surface.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/pgadmin/utils/driver/psycopg3/connection.py` around lines 255 - 274, In
the password-decryption block of the connection logic, replace the broad `except
Exception` with the concrete invalid-ciphertext/decryption exceptions raised by
`decrypt()` plus `UnicodeDecodeError`, verifying the helper’s contract first.
Preserve the existing warning and `(False, '', None)` fallback only for those
expected bad-password cases, while allowing unrelated failures to propagate.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@web/pgadmin/utils/driver/psycopg3/connection.py`:
- Around line 261-274: Update the invalid saved-password handling in the
connection credential flow to clear the cached ciphertext held by self.password
and return an explicit invalid-password state from the decryption helper. Ensure
connect() and the downstream get_response_for_password/SSH prompt flow consume
that state so passexec fallback is not skipped and the UI reliably prompts for a
new password.

---

Nitpick comments:
In `@web/pgadmin/utils/driver/psycopg3/connection.py`:
- Around line 261-274: Extend the connection tests covering the saved-password
handling around the decryption failure path to cover invalid ciphertext, valid
encrypted passwords, passexec fallback, and an SSH-tunnel server configured with
save_password=True. Assert that invalid credentials are discarded rather than
reused and that the resulting response prompts the user for a password, while
preserving successful reuse for valid encrypted passwords and existing passexec
behavior.
- Around line 255-274: In the password-decryption block of the connection logic,
replace the broad `except Exception` with the concrete
invalid-ciphertext/decryption exceptions raised by `decrypt()` plus
`UnicodeDecodeError`, verifying the helper’s contract first. Preserve the
existing warning and `(False, '', None)` fallback only for those expected
bad-password cases, while allowing unrelated failures to propagate.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: b6638f40-221e-49e2-a5e5-2c2ff1ab7df6

📥 Commits

Reviewing files that changed from the base of the PR and between b15c745 and 63c71ba.

📒 Files selected for processing (1)
  • web/pgadmin/utils/driver/psycopg3/connection.py

Comment on lines +261 to +274
# The saved password could not be decrypted. This happens
# when the stored ciphertext was encrypted with a different
# key (e.g. OIDC/OAuth2 logins where the derived encryption
# key changed between sessions), leaving un-decodable bytes
# (typically a "'utf-8' codec can't decode byte 0x.." error).
# Instead of failing every connection attempt permanently,
# discard the bad saved password and continue so the user is
# prompted for the password again.
current_app.logger.warning(
'Ignoring the saved password as it could not be '
'decrypted. The user will be prompted for the password. '
'Error: {0}'.format(str(e))
)
return False, '', None

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Propagate the invalid-saved-password state to the prompt flow.

Returning (False, '', None) at Line 274 does not clear the ciphertext or identify that the saved credential was unusable. connect() has already stored it in self.password, and the truthy encpass skips the passexec fallback at Lines 323-334. The supplied downstream handler also passes not server.save_password to get_response_for_password; for this stale record that is false, so the SSH path can return prompt_password=False. Clear the cached ciphertext and propagate an explicit invalid-password state so the UI reliably re-prompts.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/pgadmin/utils/driver/psycopg3/connection.py` around lines 261 - 274,
Update the invalid saved-password handling in the connection credential flow to
clear the cached ciphertext held by self.password and return an explicit
invalid-password state from the decryption helper. Ensure connect() and the
downstream get_response_for_password/SSH prompt flow consume that state so
passexec fallback is not skipped and the UI reliably prompts for a new password.

Per CodeRabbit review on PR pgadmin-org#10174: _decode_password() discarded the
undecryptable password for the current decode call, but the stale
ciphertext was already cached on self.password (set earlier in
connect(), before decoding) and never cleared. That meant:

- Every subsequent connect() attempt re-decoded and re-warned on the
  same bad ciphertext instead of the "no saved password" state
  sticking.
- If a server also had passexec_cmd configured, the passexec
  fallback in connect() (which only runs when neither password nor
  encpass is set) was skipped, since the stale encpass was still
  truthy.

Clear self.password and manager.password alongside discarding the
password in _decode_password's except branch, so the bad ciphertext
doesn't linger.

The SSH-tunnel prompt path in get_response_for_password() trusting
`not server.save_password` (which can be true even for an
undecryptable saved password) is a separate, pre-existing issue and
is intentionally left out of this narrower fix.
@kundansable kundansable added this to the 9.18 milestone Aug 18, 2026

@dpage dpage left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for picking this one up. The direction is right: turning an undecryptable saved password into a warning plus the normal prompt path is exactly what the issue asks for, and the change is pep8-clean and nicely contained. I do have a few things I would like addressed before this goes in, plus one observation about the root cause that I think matters for how we close #10140.

Should fix

1. The blanket except Exception is now silent, so real bugs disappear. (connection.py:260) The catch was already broad, but it used logger.exception and surfaced an error, so a TypeError from a bad crypt key, a binascii.Error from a truncated column, or any future breakage in key management stayed visible. After this change all of those become a one-line warning with no traceback, and the user just sees an unexplained password prompt on every connect. Could we narrow the catch to the failures actually being targeted (UnicodeDecodeError, binascii.Error, ValueError) and let anything else keep the old behaviour, or at the very least log with exc_info=True so the traceback survives?

2. Clearing self.password/manager.password does not clear the local encpass in connect(), and that leaks in two ways. (connection.py:274 versus connection.py:310-313 and connection.py:425)

  • At the end of connect(), if status and is_update_password: manager._update_password(encpass) re-caches the bad ciphertext into the manager and into every open connection (server_manager.py:512-517), whenever the connection nonetheless succeeds without a password: trust or peer auth, a .pgpass/passfile, a service definition, or Kerberos. That silently undoes the clearing and re-arms the remaining hard-fail sites in point 4.
  • The passexec fallback at connection.py:319 (if not password and not encpass and manager.passexec) is still skipped on this attempt, because encpass is unchanged. That is the half of CodeRabbit's open thread which still stands.

Both close cleanly if _decode_password() signals "discard the ciphertext" back to the caller (a fourth return value, or returning encpass alongside password) and connect() then sets encpass = None.

3. The comment overstates what the clearing achieves. (connection.py:270-273) The claim that the ciphertext "isn't silently reused on the next connect() attempt" holds only for the no-argument reconnect paths (_restore_connections()). The main connect route calls manager.update(server) when the server is not connected, and update() unconditionally re-populates self.password = server.password from the config DB (server_manager.py:78), after which the route passes conn_passwd or server.password straight back in (browser/server_groups/servers/__init__.py:1673-1682). The behaviour is still fine, since the user gets prompted either way, but the comment should describe what the clearing really buys.

4. Sibling decrypt sites with the same root cause still hard-fail, so an affected user is only partly unstuck. Once a user's stored ciphertext is undecryptable under the current key they will still hit an uncaught UnicodeDecodeError in ServerManager.export_password_env() (server_manager.py:548, used by Backup, Restore and Import/Export), and Connection._decrypt_password() (connection.py:1464, used by reset()) has the same uncaught .decode(), masked only for as long as the in-memory clearing survives, which per point 3 is not long. The SSH tunnel password path (server_manager.py:565-579) still returns the analogous "Failed to decrypt the SSH tunnel password" hard error. The tunnel path at least deserves the same treatment here, since a key change breaks both passwords identically.

5. For SSH-tunnel servers the prompt this fix relies on may never appear. (credit to CodeRabbit, this half of the thread is valid) On failure the route calls get_response_for_password(server, 401, not server.save_password, ...) (servers/__init__.py:1731-1733), and for a server with save_password = 1 that third argument is False. The non-tunnel branch hardcodes "prompt_password": True (servers/__init__.py:2219) so it is fine, but the use_ssh_tunnel branch passes the argument through (servers/__init__.py:2198), so an SSH-tunnel server with a saved-but-undecryptable password gets a dialog with no server-password field and stays stuck. It is pre-existing, but it defeats the recovery path this PR is built on, so I think it belongs in scope.

On the root cause

The description says the OIDC crypt key "can differ between sessions" without saying why, and I think it is worth recording, because it changes how we should close the issue. Two facts from the code: authenticate/oauth2.py:690 sets session['pass_enc_key'] = session['oauth2_token']['access_token'], so the key protecting every saved server password is the OAuth2 access token, and utils/crypto.py:pad() truncates any key to its first 32 bytes. The rest is inference rather than something I can prove about the reporter's deployment: for a JWT access token those first 32 characters are the base64url-encoded header prefix, constant as long as the header content and field ordering are, which is why it works for most people; any deployment or moment where those bytes vary (a kid appearing early in the header and rotating, a different alg, or an IdP issuing opaque non-JWT tokens) produces a different AES key and permanently undecryptable ciphertext. That fits the reported pattern of a subset of users, no deterministic repro, and survival across user recreation.

So I am happy to take this as the mitigation, but affected users will keep being re-prompted whenever the token prefix shifts, and the real fix is a stable KEK for OAuth2 sessions (MASTER_PASSWORD_HOOK, the OS keyring, or a per-user key wrapped at first login). I would rather we opened a separate issue for the key derivation than closed #10140 as fully solved on the back of this.

Minor

  • After the password is discarded the user sees libpq's fe_sendauth: no password supplied in the prompt dialog, which is fairly opaque. The route already relays errmsg to the dialog, so a short explanatory message ("the saved password could not be decrypted, please re-enter it") would make the recovery obvious.
  • No test accompanies the change. web/pgadmin/utils/driver/psycopg3/tests/ currently holds only __init__.py, so it would be new ground, but a unit test patching decrypt to raise and asserting _decode_password() returns (False, '', None) whilst clearing both caches would be cheap and would pin down the contract that points 2 and 3 are about. Not a blocker.
  • Dropping manager.stop_ssh_tunnel() from this path is correct now that we continue and still need the tunnel; noting it only so it is on the record as deliberate.

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.

OIDC users intermittently fail with "Failed to decrypt the saved password" and cannot be recovered by recreating the user

2 participants