From 501d50fc245799aeff876011fe082e6201f0c3fd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 22:32:44 +0000 Subject: [PATCH] Client passwords: a forwarding realm that accepts none guards its own accounts A credential-forwarding realm with `accept_client_passwords: false` was left out of the clients' password chain, so its users' passwords went on to the upstream of a credential-forwarding realm after it. It now keeps its place there as a reject-only guard (`ForwardingRealmGuard`): a name of one of its accounts (`find_upstream_user(username, realm_id)`) is refused as `NotAccepted`, which now ends the chain from any provider, so the password reaches no upstream. Any other name goes on. Fixes #756 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012e6DJawoR76E4QRqqppNmL --- crates/core/src/auth/chain.rs | 61 +++++++--- crates/core/src/auth/mod.rs | 5 + crates/core/src/auth/provider.rs | 11 +- crates/core/src/auth/realm_guard.rs | 64 ++++++++++ crates/core/src/auth/token.rs | 5 +- crates/server/src/auth/realms.rs | 78 ++++++++----- crates/server/tests/client_passwords.rs | 148 ++++++++++++++++++++++++ docs/changes/2.2.0/756.security.md | 11 ++ docs/deployment.md | 8 ++ 9 files changed, 337 insertions(+), 54 deletions(-) create mode 100644 crates/core/src/auth/realm_guard.rs create mode 100644 docs/changes/2.2.0/756.security.md diff --git a/crates/core/src/auth/chain.rs b/crates/core/src/auth/chain.rs index 9b1b51f6..ac5bed03 100644 --- a/crates/core/src/auth/chain.rs +++ b/crates/core/src/auth/chain.rs @@ -18,10 +18,12 @@ //! user's typed password would leak upstream. A rejection //! from a non-local provider is remembered but does **not** stop the chain //! (a username may exist in a later provider). On its own it never grants. -//! [`AuthOutcome::NotAccepted`] (a local account whose realm takes no -//! password on this chain) is terminal the same way, and folds into -//! [`ChainOutcome::NotAccepted`]: denied like a rejection, but not counted -//! towards the sign-in lockout. +//! [`AuthOutcome::NotAccepted`] (an account whose realm, local or +//! credential-forwarding, takes no password on this chain) is terminal +//! from **any** provider, and folds into [`ChainOutcome::NotAccepted`]: +//! denied like a rejection, but not counted towards the sign-in lockout. +//! The account's password then never reaches the upstream of a realm +//! behind its own. //! 4. [`AuthOutcome::Unavailable`] is remembered but does **not** grant. //! **Fail closed:** an unreachable upstream can never open a hole — if no //! provider authenticates and an `Unavailable` was seen, the chain reports @@ -65,9 +67,10 @@ pub enum ChainOutcome { /// No provider authenticated and at least one definitively **rejected** the /// credential. The caller returns 401 (bad credentials), never anonymous. Rejected, - /// A local provider owns the username but takes no password for it on - /// this chain ([`AuthOutcome::NotAccepted`]): the chain stopped there. The - /// caller denies exactly as for [`ChainOutcome::Rejected`] (same status, + /// A provider (a local or credential-forwarding realm) owns the username + /// but takes no password for it on this chain + /// ([`AuthOutcome::NotAccepted`]): the chain stopped there. The caller + /// denies exactly as for [`ChainOutcome::Rejected`] (same status, /// body and timing), but no password was checked, so the attempt does not /// count towards the sign-in lockout. NotAccepted, @@ -155,12 +158,11 @@ impl Chain { AuthOutcome::Rejected if provider.source() == AuthSource::Local => { return (ChainOutcome::Rejected, source); } - // A local account whose realm takes no password here: final - // like a wrong password (never forwarded), but nothing was - // checked. - AuthOutcome::NotAccepted if provider.source() == AuthSource::Local => { - return (ChainOutcome::NotAccepted, source); - } + // An account whose realm (local or credential-forwarding) + // takes no password here: final like a local wrong password, + // so it is never forwarded to another realm's upstream, but + // nothing was checked. + AuthOutcome::NotAccepted => return (ChainOutcome::NotAccepted, source), // The local store could not decide (DB error, argon2 overload): // it may still own this username, so do not forward (rule 4). AuthOutcome::Unavailable if provider.source() == AuthSource::Local => { @@ -168,9 +170,7 @@ impl Chain { } // The database failed: no later provider can decide either. AuthOutcome::Failed => return (ChainOutcome::Failed, source), - AuthOutcome::Rejected | AuthOutcome::NotAccepted => { - saw_rejected = saw_rejected.or(source) - } + AuthOutcome::Rejected => saw_rejected = saw_rejected.or(source), AuthOutcome::Unavailable => saw_unavailable = saw_unavailable.or(source), AuthOutcome::Skip => {} } @@ -629,6 +629,35 @@ mod tests { assert_eq!(c2.load(Ordering::SeqCst), 0, "upstream never consulted"); } + #[tokio::test] + async fn an_upstream_realms_not_accepted_is_terminal_and_never_forwarded() { + // #756: a credential-forwarding realm that takes no password from + // clients owns the name: its account's password stops at its guard + // and never reaches the next realm's upstream. + let (guard, c1) = StubProvider::new( + "realm:b", + AuthSource::UpstreamBasic, + AuthOutcome::NotAccepted, + ); + let (next, c2) = StubProvider::new( + "realm:a", + AuthSource::UpstreamBasic, + authed("bob", AuthSource::UpstreamBasic), + ); + let chain = Chain::new(vec![Box::new(guard), Box::new(next)]); + + assert_eq!( + chain.authenticate(&cred("bob", "pw")).await, + ChainOutcome::NotAccepted + ); + assert_eq!(c1.load(Ordering::SeqCst), 1); + assert_eq!( + c2.load(Ordering::SeqCst), + 0, + "next upstream never consulted" + ); + } + #[tokio::test] async fn local_unavailable_is_terminal_and_never_forwarded() { // The local store could not decide (lookup failed, argon2 overloaded): diff --git a/crates/core/src/auth/mod.rs b/crates/core/src/auth/mod.rs index f416cc5a..7b00e4f7 100644 --- a/crates/core/src/auth/mod.rs +++ b/crates/core/src/auth/mod.rs @@ -12,6 +12,9 @@ //! upstream-basic providers plug in here; OIDC stays its own interactive //! path. [`captured_password`] stands in for upstream-basic once a realm //! stops forwarding and signs its accounts in with the hashes it kept. +//! [`realm_guard`] holds the place of a credential-forwarding realm that +//! accepts no passwords from clients in their chain, as a guard of its +//! accounts' names. //! * [`cred_cache`] — the validated-credential cache: an HMAC-keyed //! in-process + Postgres cache of upstream verdicts so NuGet's per-request //! Basic auth does not hammer the upstream. Plaintext is never stored; @@ -31,6 +34,7 @@ pub mod local_password; pub mod login_throttle; pub mod principal; pub mod provider; +pub mod realm_guard; pub mod scheme; pub mod session_source; pub mod token; @@ -58,6 +62,7 @@ pub use principal::{AuthMethod, Principal, TrustedPublisherMatch, UserPrincipal} pub use provider::{ AuthOutcome, AuthProvider, AuthSource, CapturedPasswordHash, PasswordCredential, ProvenIdentity, }; +pub use realm_guard::ForwardingRealmGuard; pub use session_source::{ cookie_value, session_cookie_name, unique_cookie_value, DuplicateCookie, SessionCookieSource, SECURE_SESSION_COOKIE, SESSION_COOKIE, diff --git a/crates/core/src/auth/provider.rs b/crates/core/src/auth/provider.rs index f8eec69a..3d20b31c 100644 --- a/crates/core/src/auth/provider.rs +++ b/crates/core/src/auth/provider.rs @@ -180,11 +180,12 @@ pub enum AuthOutcome { /// is [`AuthSource::Local`], otherwise the chain continues. Rejected, /// The provider owns this username but takes no password for it here - /// (a local realm that accepts no passwords from clients, see - /// `LocalPasswordProvider::reject_only`): refused like a wrong password, - /// but nothing was checked, so it is no guess and does not count towards - /// the sign-in lockout. Terminal when the provider's source is - /// [`AuthSource::Local`], otherwise treated like [`Self::Rejected`]. + /// (a local or credential-forwarding realm that accepts no passwords from + /// clients, see `LocalPasswordProvider::reject_only` and + /// `ForwardingRealmGuard`): refused like a wrong password, but nothing + /// was checked, so it is no guess and does not count towards the sign-in + /// lockout. Always terminal, whatever the provider's source: the + /// password is never forwarded to a later provider. NotAccepted, /// The provider can't decide — typically "this username isn't mine". /// The chain advances to the next provider. diff --git a/crates/core/src/auth/realm_guard.rs b/crates/core/src/auth/realm_guard.rs new file mode 100644 index 00000000..cfdfbc55 --- /dev/null +++ b/crates/core/src/auth/realm_guard.rs @@ -0,0 +1,64 @@ +//! The reject-only guard of a credential-forwarding realm in the clients' +//! password chain (#756). +//! +//! A credential-forwarding realm that accepts no passwords from clients +//! (`accept_client_passwords: false`) does not check them there, but its +//! accounts' names stay its own: [`ForwardingRealmGuard`] takes its place in +//! that chain and refuses a name the realm owns +//! ([`forge_db::user_dao::find_upstream_user`] of that realm) as +//! [`AuthOutcome::NotAccepted`], which ends the chain. The password then +//! never reaches the upstream of a credential-forwarding realm behind it, as +//! a closed local realm's passwords never do +//! ([`super::LocalPasswordProvider::reject_only`]). A name the realm does not +//! own goes on to the next provider. + +use sqlx::PgPool; + +use super::provider::{AuthOutcome, AuthProvider, AuthSource, PasswordCredential}; + +/// Refuses, without checking any password, the names of the accounts of one +/// credential-forwarding realm; skips every other name. Asks no upstream. +pub struct ForwardingRealmGuard { + id: String, + realm_id: i64, + pool: PgPool, +} + +impl ForwardingRealmGuard { + /// The guard of realm `realm_id`; `id` is the realm's provider id + /// (`realm:`), for the logs and the sign-in metrics. + pub fn new(id: impl Into, realm_id: i64, pool: PgPool) -> Self { + Self { + id: id.into(), + realm_id, + pool, + } + } +} + +#[async_trait::async_trait] +impl AuthProvider for ForwardingRealmGuard { + fn id(&self) -> &str { + &self.id + } + + fn source(&self) -> AuthSource { + AuthSource::UpstreamBasic + } + + /// An account of the realm, active or not: `NotAccepted`. Any other name: + /// `Skip`. A failed lookup: `Failed`, so the password is not forwarded + /// to the next realm either. + async fn authenticate(&self, cred: &PasswordCredential<'_>) -> AuthOutcome { + match forge_db::user_dao::find_upstream_user(&self.pool, cred.username, Some(self.realm_id)) + .await + { + Ok(Some(_)) => AuthOutcome::NotAccepted, + Ok(None) => AuthOutcome::Skip, + Err(e) => { + tracing::warn!(provider = %self.id, error = %e, "forwarding realm guard: user lookup failed"); + AuthOutcome::Failed + } + } + } +} diff --git a/crates/core/src/auth/token.rs b/crates/core/src/auth/token.rs index a2bd7797..1fd95e11 100644 --- a/crates/core/src/auth/token.rs +++ b/crates/core/src/auth/token.rs @@ -488,8 +488,9 @@ impl PrincipalSource for BasicAuthSource { attempt.failed(); return SourceOutcome::Invalid; } - // A local account whose realm takes no password from clients: - // denied like a wrong password, but nothing was checked, so it + // An account whose realm (local or credential-forwarding) + // takes no password from clients: denied like a wrong + // password, but nothing was checked, so it // does not count towards the lockout (an old client still // sending it must not lock its owner out of the browser login). ChainOutcome::NotAccepted => { diff --git a/crates/server/src/auth/realms.rs b/crates/server/src/auth/realms.rs index 364f0941..bc7e5de3 100644 --- a/crates/server/src/auth/realms.rs +++ b/crates/server/src/auth/realms.rs @@ -39,8 +39,8 @@ use forge_cache::Cache; use forge_core::auth::local_password::DEFAULT_QUEUE_TIMEOUT; use forge_core::auth::{ AuthProvider, CapturedPasswordProvider, Chain, ChainSource, ChainUnavailable, CredKeyPepper, - ForwardLimits, LocalPasswordProvider, PasswordHashLimiter, UpstreamBasicProvider, - ValidatedCredentialCache, + ForwardLimits, ForwardingRealmGuard, LocalPasswordProvider, PasswordHashLimiter, + UpstreamBasicProvider, ValidatedCredentialCache, }; use forge_core::net::ssrf; use forge_core::oidc::{AssertionClaims, ClientAuth}; @@ -652,9 +652,10 @@ pub struct RealmSnapshot { pub chain: Option>, /// The chain of the passwords clients send (HTTP Basic on the feeds and /// `/v2/token`, `npm login --auth-type=legacy`): the realms that accept - /// them (`accept_client_passwords`), and a local realm that does not as a - /// reject-only guard of its names - /// ([`LocalPasswordProvider::reject_only`]). `None` when no realm accepts + /// them (`accept_client_passwords`), and each realm that does not as a + /// reject-only guard of its names: the local realm's + /// ([`LocalPasswordProvider::reject_only`]), a credential-forwarding + /// realm's ([`ForwardingRealmGuard`]). `None` when no realm accepts /// passwords from clients: the path is off, and a client signs in with a /// token. pub client_chain: Option>, @@ -888,8 +889,8 @@ impl RealmAuth { let realms = auth_realm_dao::list_realms(&self.pool).await?; // The credential-forwarding providers built, in order, each with - // whether its realm accepts passwords from clients. - let mut forwarding: Vec<(Arc, bool)> = Vec::new(); + // whether its realm accepts passwords from clients, and the realm's id. + let mut forwarding: Vec<(Arc, bool, i64)> = Vec::new(); let mut oidc: Option = None; let mut workload: Vec = Vec::new(); let mut workload_configured_issuers: Vec = Vec::new(); @@ -918,7 +919,7 @@ impl RealmAuth { ))) } KIND_CRED_FORWARD => match self.build_cred_forward(realm, &config) { - Ok(built) => forwarding.push(built), + Ok((provider, accepts)) => forwarding.push((provider, accepts, realm.id)), // One malformed row must not take down the other realms; // its own logins fail (closed) until an admin fixes it. Err(e) => { @@ -975,25 +976,37 @@ impl RealmAuth { } } - let chain = self.password_chain(local_at.map(|(at, _)| (at, true)), &forwarding); - // The clients' chain: the realms that accept their passwords, and a - // local realm that does not as a guard of its names. Without any - // realm that accepts them, the path is off. - let accepted = local_at.is_some_and(|(_, accepts)| accepts) - || forwarding.iter().any(|(_, accepts)| *accepts); - let client_forwarding: Vec<(Arc, bool)> = forwarding + // The browser login's chain: every realm checks its passwords. + let browser_forwarding: Vec<(Arc, bool)> = forwarding .iter() - .filter(|(_, accepts)| *accepts) - .cloned() + .map(|(provider, _, _)| (provider.clone(), true)) .collect(); + let chain = self.password_chain(local_at.map(|(at, _)| (at, true)), &browser_forwarding); + // The clients' chain: the realms that accept their passwords, and + // each realm that does not as a reject-only guard of its names, in + // its place, so that its accounts' passwords stop there and never + // reach the upstream of a credential-forwarding realm behind it + // (#647 for the local realm, #756 for a forwarding one). Without any + // realm that accepts them, the path is off. + let accepted = local_at.is_some_and(|(_, accepts)| accepts) + || forwarding.iter().any(|(_, accepts, _)| *accepts); let client_chain = if accepted { - // The local provider keeps its place among the forwarding - // providers that stay. - let client_local = local_at.map(|(at, accepts)| { - let ahead = forwarding[..at].iter().filter(|(_, a)| *a).count(); - (ahead, accepts) - }); - self.password_chain(client_local, &client_forwarding) + let client_forwarding: Vec<(Arc, bool)> = forwarding + .iter() + .map(|(provider, accepts, realm_id)| { + if *accepts { + (provider.clone(), true) + } else { + let guard: Arc = Arc::new(ForwardingRealmGuard::new( + provider.id(), + *realm_id, + self.pool.clone(), + )); + (guard, false) + } + }) + .collect(); + self.password_chain(local_at, &client_forwarding) } else { None }; @@ -1020,10 +1033,12 @@ impl RealmAuth { }) } - /// A password chain of the `forwarding` providers, with the local provider - /// inserted after the first `at` of them when `local` is `Some((at, - /// verifies))` (checking passwords, or [`LocalPasswordProvider::reject_only`]). - /// `None` when it holds no provider. + /// A password chain of the `forwarding` providers, each with whether it + /// asks an upstream (a [`ForwardingRealmGuard`] does not), with the local + /// provider inserted after the first `at` of them when `local` is + /// `Some((at, verifies))` (checking passwords, or + /// [`LocalPasswordProvider::reject_only`]). `None` when it holds no + /// provider. fn password_chain( &self, local: Option<(usize, bool)>, @@ -1038,13 +1053,14 @@ impl RealmAuth { // not own go upstream next, and the local provider need not // equalize their timing with a dummy hash. Decided from the // providers actually built: a forwarding realm that is skipped - // (fail closed) makes no upstream call, so the local provider must - // pay the dummy hash itself. + // (fail closed) makes no upstream call, and neither does a + // realm's guard, so the local provider must pay the dummy hash + // itself. let provider = LocalPasswordProvider::with_limiter( self.pool.clone(), self.password_limiter.clone(), ) - .forwarding_follows(at < forwarding.len()); + .forwarding_follows(forwarding[at..].iter().any(|(_, asks)| *asks)); let provider = if verifies { provider } else { diff --git a/crates/server/tests/client_passwords.rs b/crates/server/tests/client_passwords.rs index 16e5540d..d33432a0 100644 --- a/crates/server/tests/client_passwords.rs +++ b/crates/server/tests/client_passwords.rs @@ -18,6 +18,13 @@ const FORWARDED: &str = "fwd"; /// The password the mock upstream accepts for [`FORWARDED`]. const UPSTREAM_PASSWORD: &str = "upstream-pw-1"; +/// The local part of the account of the closed credential-forwarding realm +/// B ([`Setup::closed_realm_ahead_of_nexus`]). +const B_USER: &str = "bob"; + +/// The password B's upstream accepts for [`B_USER`]. +const B_PASSWORD: &str = "b-upstream-pw"; + /// The file every test reads from the raw repository. const RAW_FILE: &str = "hello.txt"; @@ -256,6 +263,79 @@ impl Setup { .unwrap(); assert_eq!(resp.status(), 200, "{}", resp.text().await.unwrap()); } + + /// Add realm B, `corp`: credential forwarding over a mock upstream of its + /// own that accepts [`B_USER`] / [`B_PASSWORD`], accepting no passwords + /// from clients, with its account `bob@example.com` (a writer), and move + /// it ahead of `nexus` (A): local, corp, nexus. + async fn closed_realm_ahead_of_nexus(&self) -> ClosedRealm { + let srv = &self.srv; + let upstream = MockServer::start().await; + let accepted = format!( + "Basic {}", + base64::engine::general_purpose::STANDARD.encode(format!("{B_USER}:{B_PASSWORD}")) + ); + Mock::given(matchers::header("authorization", accepted.as_str())) + .respond_with(ResponseTemplate::new(200)) + .mount(&upstream) + .await; + Mock::given(matchers::any()) + .respond_with(ResponseTemplate::new(401)) + .mount(&upstream) + .await; + let resp = admin(srv, reqwest::Method::POST, "/admin/realms") + .json(&json!({ + "kind": "credential_forwarding", + "name": "corp", + "config": { + "url": format!("{}/status", upstream.uri()), + "key_pepper": "pepper-b", + "accept_client_passwords": false, + }, + })) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 201, "{}", resp.text().await.unwrap()); + let user = format!("{B_USER}@example.com"); + let resp = admin(srv, reqwest::Method::POST, "/admin/users") + .json(&json!({"username": user, "role": "writer", "realm": "corp"})) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 201, "{}", resp.text().await.unwrap()); + + let (corp, nexus) = (self.realm_id("corp").await, self.realm_id("nexus").await); + let realms: Vec = admin(srv, reqwest::Method::GET, "/admin/realms") + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let mut order: Vec = realms + .iter() + .map(|r| r["id"].as_i64().unwrap()) + .filter(|id| *id != corp) + .collect(); + let at = order.iter().position(|id| *id == nexus).unwrap(); + order.insert(at, corp); + let resp = admin(srv, reqwest::Method::PUT, "/admin/realms/order") + .json(&json!({ "order": order })) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200, "{}", resp.text().await.unwrap()); + ClosedRealm { upstream, user } + } +} + +/// Realm B of [`Setup::closed_realm_ahead_of_nexus`]. +struct ClosedRealm { + /// B's own upstream. + upstream: MockServer, + /// B's account, `bob@example.com`. + user: String, } /// The issue's case: a credential-forwarding realm accepts its users' @@ -430,6 +510,74 @@ async fn each_realm_opens_and_closes_its_own_passwords() { srv.login(&admin_user, &admin_password).await; } +/// #756: a credential-forwarding realm that accepts no passwords from +/// clients stays in their chain as a guard of its own accounts. With realm B +/// (forwarding, closed) ahead of realm A (`nexus`, forwarding, open), B's +/// user signing a client in with their B password is refused, and the +/// password reaches neither B's upstream nor A's. A's own users still get +/// past B's guard to A, and the browser login still takes B's password. +#[tokio::test] +async fn a_closed_forwarding_realms_passwords_never_reach_the_next_realms_upstream() { + let setup = Setup::new().await; + let b = setup.closed_realm_ahead_of_nexus().await; + + // B's password, right or wrong, is refused by every client and answers + // alike, and no upstream receives it. + let right = setup.clients(&b.user, B_PASSWORD).await; + let wrong = setup.clients(&b.user, "not-the-password").await; + assert_eq!( + right, wrong, + "a closed realm's password answers like a wrong one" + ); + for (what, status, body) in &right { + assert_eq!(*status, 401, "{what}: {body}"); + } + assert_eq!( + setup.forwarded_for(B_USER).await, + 0, + "B's password never reaches A's upstream" + ); + assert_eq!( + b.upstream + .received_requests() + .await + .unwrap_or_default() + .len(), + 0, + "B takes no password from clients" + ); + + // A's own account is no account of B's: it goes on to A. + setup + .assert_clients(&setup.forwarded, UPSTREAM_PASSWORD, true) + .await; + + // The browser login checks B's password at B's upstream, and only there. + setup.srv.login(&b.user, B_PASSWORD).await; + assert_eq!(setup.forwarded_for(B_USER).await, 0); +} + +/// #756: the guard holds for a deactivated account of the closed forwarding +/// realm too: its password is no more forwarded to the next realm's upstream +/// than an active account's. +#[tokio::test] +async fn a_closed_forwarding_realm_guards_its_deactivated_accounts() { + let setup = Setup::new().await; + let b = setup.closed_realm_ahead_of_nexus().await; + sqlx::query("UPDATE app_user SET active = false WHERE username = $1") + .bind(&b.user) + .execute(&setup.srv.pool) + .await + .unwrap(); + + setup.assert_clients(&b.user, B_PASSWORD, false).await; + assert_eq!( + setup.forwarded_for(B_USER).await, + 0, + "a deactivated B account's password never reaches A's upstream" + ); +} + /// The admin API shows and validates the flag: the local realm's is off and /// a credential-forwarding realm's on until set, and the local realm takes /// no other config. diff --git a/docs/changes/2.2.0/756.security.md b/docs/changes/2.2.0/756.security.md new file mode 100644 index 00000000..60ceca48 --- /dev/null +++ b/docs/changes/2.2.0/756.security.md @@ -0,0 +1,11 @@ +A credential-forwarding realm that accepts no passwords from clients +(`accept_client_passwords: false`) let its users' passwords go to the next +credential-forwarding realm's upstream: when one of its users signed a client +in with their password (HTTP Basic on the feeds and `/v2/token`, +`npm login --auth-type=legacy`), a credential-forwarding realm after it in the +realm order forwarded the name and the password to its own upstream. The +sign-in was refused, but the password had reached a server that is not its +realm's. Such a realm now keeps its accounts' names in the clients' chain: +their passwords are refused there like a wrong one, without counting towards +the sign-in lockout, and never reach another realm's upstream, as a local +realm's passwords never do. diff --git a/docs/deployment.md b/docs/deployment.md index c0efc4ac..c1bc26c9 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -663,6 +663,14 @@ registry's token service, `npm login --auth-type=legacy`) is set **per realm**: sends for a local account is refused with the 401 of a wrong password (the lockout counts it), and is never forwarded to a credential-forwarding realm behind it. +- **So does a closed credential-forwarding realm.** A password a client + sends for one of its accounts is refused with the 401 of a wrong password + (without counting towards the lockout: nothing is checked), is not sent + to the realm's own upstream, and is never forwarded to a + credential-forwarding realm behind it. A name that is no account of the + realm goes on to the realms after it. An account the realm has not + created yet (a user who never signed in) is not known to it, so its + password goes on like any other name's. Before 2.2 this was one setting, `allow_password_basic` under **Admin → Settings**, and an enabled credential-forwarding realm turned it on for every