Skip to content

Client passwords: a closed local realm's refusal no longer counts towards the login lockout - #845

Merged
schmitch merged 1 commit into
mainfrom
claude/795-closed-realm-password-lockout
Nov 3, 2026
Merged

schmitch merged 1 commit into
mainfrom
claude/795-closed-realm-password-lockout

Conversation

@schmitch

@schmitch schmitch commented Nov 3, 2026

Copy link
Copy Markdown
Member

Fixes #795

Problem

When a local realm accepts no passwords from clients, the clients' chain holds its provider as reject_only. That provider refused a local account's password as AuthOutcome::Rejected. BasicAuthSource and verify_password_login (the npm legacy login) then counted it as a failure on the same (network, username) lockout that POST /auth/login checks. A client still configured with the password (an IDE's NuGet restore, docker) locked its owner out of the browser login. That is the login the upgrade note sends them to, so they can get a token.

Change

  • AuthOutcome::NotAccepted (new): a provider owns the name but takes no password for it here. LocalPasswordProvider returns it instead of Rejected for every name it owns when it is reject_only: active, inactive, invited or service account. It still runs the dummy argon2 verification first, so the timing is the same as before.
  • ChainOutcome::NotAccepted (new): it is terminal for a local provider in the same way as Rejected, so the password is never forwarded upstream (Auth realms: a credential-forwarding realm that accepts no client passwords lets its users' passwords go to the next realm's upstream #756 is unchanged). From a non-local provider it folds like Rejected. The metrics record it as Rejected.
  • BasicAuthSource and verify_password_login answer it exactly like a wrong password (SourceOutcome::Invalid / LoginRefusal::Invalid, the same status and body) but call attempt.released() instead of attempt.failed(). Nothing was verified, so there is nothing to brute-force.
  • Wrong passwords on realms that check them count exactly as before.
  • Changelog fragment docs/changes/2.2.0/795.fixed.md.

No admin API or UI change.

Tests

  • crates/server/tests/client_passwords.rs, a_closed_local_realms_password_from_clients_does_not_lock_the_browser_login:
    • 15 Basic requests with a closed local realm's correct password, all 401;
    • 15 npm login --auth-type=legacy with it, all 401 (with the old mapping this fails: npm got 429);
    • POST /auth/login with it from the same address then succeeds;
    • after that, login_max_failures wrong browser passwords still lock the pair (429).
  • crates/core/tests/local_password.rs:
    • the existing reject-only test now expects NotAccepted;
    • new a_reject_only_refusal_is_not_counted_towards_the_lockout: the chain gives NotAccepted, Basic answers 401, the throttle tracks nothing and the upstream is never called, while a wrong password through a checking provider is still counted.
  • Ran: cargo clippy -p forge-core -p forge-server --all-targets -- -D warnings, cargo fmt, cargo xtask changelog --check, the forge-core auth unit tests, forge-core local_password and upstream_basic, and forge-server client_passwords, auth_realms, auth_settings and npm. All pass.

Note

There is one remaining difference between names. When the local realm is last in the clients' chain (no forwarding realm after it), a name the local store does not own still counts towards the lockout, and a local account's name now does not. Over the npm legacy login, the lockout's 429 can therefore tell those names apart after 10 tries. With a forwarding realm after the local one, a local account was already told apart (by timing, as forwarding_follows documents). The issue asks for the release, so this PR keeps it.


Generated by Claude Code

A local realm that accepts no passwords from clients holds its provider
reject-only in the clients' chain. It refused a password for one of its
accounts as Rejected, which BasicAuthSource and the npm legacy login
counted as a failed sign-in on the (network, username) lockout that
POST /auth/login shares. A client still configured with the password
locked its owner out of the browser login.

The reject-only provider now answers NotAccepted, folded by the chain
into ChainOutcome::NotAccepted, which both paths answer like a wrong
password (401, same dummy-hash timing) but release the attempt: nothing
was checked, so it is no guess. Wrong passwords on realms that check
them still count.

Fixes #795

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012e6DJZsSSxZHKzUeJ3TMiG
@schmitch
schmitch added this pull request to the merge queue Nov 3, 2026
Merged via the queue into main with commit 6c2917e Nov 3, 2026
19 checks passed
@schmitch
schmitch deleted the claude/795-closed-realm-password-lockout branch November 3, 2026 17:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants