Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 45 additions & 16 deletions crates/core/src/auth/chain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -155,22 +158,19 @@ 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 => {
return (ChainOutcome::Unavailable, source);
}
// 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 => {}
}
Expand Down Expand Up @@ -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):
Expand Down
5 changes: 5 additions & 0 deletions crates/core/src/auth/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
11 changes: 6 additions & 5 deletions crates/core/src/auth/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
64 changes: 64 additions & 0 deletions crates/core/src/auth/realm_guard.rs
Original file line number Diff line number Diff line change
@@ -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:<name>`), for the logs and the sign-in metrics.
pub fn new(id: impl Into<String>, 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
}
}
}
}
5 changes: 3 additions & 2 deletions crates/core/src/auth/token.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down
78 changes: 47 additions & 31 deletions crates/server/src/auth/realms.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -652,9 +652,10 @@ pub struct RealmSnapshot {
pub chain: Option<Arc<Chain>>,
/// 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<Arc<Chain>>,
Expand Down Expand Up @@ -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<dyn AuthProvider>, bool)> = Vec::new();
// whether its realm accepts passwords from clients, and the realm's id.
let mut forwarding: Vec<(Arc<dyn AuthProvider>, bool, i64)> = Vec::new();
let mut oidc: Option<OidcRealmRuntime> = None;
let mut workload: Vec<super::wif::WorkloadIdentityRealm> = Vec::new();
let mut workload_configured_issuers: Vec<String> = Vec::new();
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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<dyn AuthProvider>, bool)> = forwarding
// The browser login's chain: every realm checks its passwords.
let browser_forwarding: Vec<(Arc<dyn AuthProvider>, 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<dyn AuthProvider>, bool)> = forwarding
.iter()
.map(|(provider, accepts, realm_id)| {
if *accepts {
(provider.clone(), true)
} else {
let guard: Arc<dyn AuthProvider> = Arc::new(ForwardingRealmGuard::new(
provider.id(),
*realm_id,
self.pool.clone(),
));
(guard, false)
}
})
.collect();
self.password_chain(local_at, &client_forwarding)
} else {
None
};
Expand All @@ -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)>,
Expand All @@ -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 {
Expand Down
Loading
Loading