From d36e01bfa4aedcc158c3fa30a12218f58115d4b5 Mon Sep 17 00:00:00 2001 From: nichmor Date: Mon, 4 May 2026 12:30:15 +0300 Subject: [PATCH 1/9] feat: add ouath default login --- crates/rattler/src/cli/auth.rs | 66 +++++++++++++++++++++++++++- crates/rattler/src/cli/auth/oauth.rs | 11 +++++ 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/crates/rattler/src/cli/auth.rs b/crates/rattler/src/cli/auth.rs index 9bd24f191f..9a38950ac6 100644 --- a/crates/rattler/src/cli/auth.rs +++ b/crates/rattler/src/cli/auth.rs @@ -167,6 +167,31 @@ pub enum AuthenticationCLIError { OAuthError(#[from] oauth::OAuthError), } +/// Returns true when the host should default to OAuth login if the user did +/// not pass any other authentication method. +#[cfg(feature = "oauth")] +fn host_supports_default_oauth(host: &str) -> bool { + let host = host.trim_start_matches("*."); + let host = if let Ok(url) = url::Url::parse(host) { + url.host_str().map(str::to_string).unwrap_or_default() + } else { + host.to_string() + }; + host == "prefix.dev" || host.ends_with(".prefix.dev") +} + +/// Returns true when the user passed no explicit auth method, so we should +/// fall back to OAuth for OAuth-capable hosts. +#[cfg(feature = "oauth")] +fn should_default_to_oauth(args: &LoginArgs) -> bool { + let no_explicit_method = args.token.is_none() + && args.username.is_none() + && args.password.is_none() + && args.conda_token.is_none() + && args.s3_access_key_id.is_none(); + no_explicit_method && host_supports_default_oauth(&args.host) +} + fn get_url(url: &str) -> Result { // parse as url and extract host without scheme or port let host = if url.contains("://") { @@ -203,9 +228,16 @@ async fn login( args: LoginArgs, storage: AuthenticationStorage, ) -> Result<(), AuthenticationCLIError> { - // OAuth flow (when --oauth is set) + // explicit `--oauth` *or* no explicit method on an OAuth-capable host #[cfg(feature = "oauth")] - if args.oauth { + if args.oauth || should_default_to_oauth(&args) { + if !args.oauth { + eprintln!( + "No credentials provided; using OAuth browser login for {}.", + args.host + ); + } + let issuer_url = args .oauth_issuer_url .unwrap_or_else(|| format!("https://{}", args.host)); @@ -637,4 +669,34 @@ mod tests { let result = login(args, storage).await; assert!(matches!(result, Err(AuthenticationCLIError::S3BadMethod))); } + + #[cfg(feature = "oauth")] + #[test] + fn test_host_supports_default_oauth() { + assert!(host_supports_default_oauth("prefix.dev")); + assert!(host_supports_default_oauth("repo.prefix.dev")); + assert!(host_supports_default_oauth("https://prefix.dev")); + assert!(host_supports_default_oauth("*.prefix.dev")); + + assert!(!host_supports_default_oauth("example.com")); + // Suffix-injection guard: hostname containing "prefix.dev" must not match. + assert!(!host_supports_default_oauth("evil-prefix.dev.attacker.com")); + assert!(!host_supports_default_oauth("notprefix.dev")); + } + + #[cfg(feature = "oauth")] + #[test] + fn test_should_default_to_oauth() { + // No explicit method on prefix.dev → OAuth + assert!(should_default_to_oauth(&create_login_args("prefix.dev"))); + + // Explicit method blocks the OAuth default, even on prefix.dev. + let mut args = create_login_args("prefix.dev"); + args.token = Some("t".into()); + assert!(!should_default_to_oauth(&args)); + + // No explicit method on a non-OAuth host → still falls through to existing + // NoAuthenticationMethod error. + assert!(!should_default_to_oauth(&create_login_args("example.com"))); + } } diff --git a/crates/rattler/src/cli/auth/oauth.rs b/crates/rattler/src/cli/auth/oauth.rs index 17e4bbed9b..9562a5ad8f 100644 --- a/crates/rattler/src/cli/auth/oauth.rs +++ b/crates/rattler/src/cli/auth/oauth.rs @@ -54,6 +54,9 @@ type ExtendedCoreProviderMetadata = ProviderMetadata< CoreSubjectIdentifierType, >; +/// Default OAuth scopes used when the caller passes none. +pub const DEFAULT_OAUTH_SCOPES: &[&str] = &["openid", "profile", "offline_access", "channel:read"]; + /// Configuration for an OAuth login flow. pub struct OAuthConfig { /// The OIDC issuer URL. @@ -149,6 +152,14 @@ struct CallbackResult { /// Perform an OAuth/OIDC login and return the resulting /// `Authentication::OAuth`. pub async fn perform_oauth_login(config: OAuthConfig) -> Result { + let mut config = config; + if config.scopes.is_empty() { + config.scopes = DEFAULT_OAUTH_SCOPES + .iter() + .map(|&s| s.to_string()) + .collect(); + } + let http_client = reqwest::Client::builder() .redirect(reqwest::redirect::Policy::none()) .build() From cae7cad7ba8e2074b861b06f66b35b6bacf37c3a Mon Sep 17 00:00:00 2001 From: nichmor Date: Tue, 5 May 2026 15:20:58 +0300 Subject: [PATCH 2/9] feat: add ouath for upload --- crates/rattler-bin/Cargo.toml | 1 + crates/rattler/src/cli/auth.rs | 105 +++++++++++-- crates/rattler/src/cli/auth/oauth.rs | 113 +++++++++++++- .../src/authentication_middleware.rs | 140 +---------------- .../src/authentication_storage/storage.rs | 18 +++ crates/rattler_networking/src/lib.rs | 1 + .../rattler_networking/src/oauth_refresh.rs | 141 ++++++++++++++++++ crates/rattler_upload/src/upload/prefix.rs | 42 ++++-- 8 files changed, 398 insertions(+), 163 deletions(-) create mode 100644 crates/rattler_networking/src/oauth_refresh.rs diff --git a/crates/rattler-bin/Cargo.toml b/crates/rattler-bin/Cargo.toml index 6f22825111..1fb16f5081 100644 --- a/crates/rattler-bin/Cargo.toml +++ b/crates/rattler-bin/Cargo.toml @@ -34,6 +34,7 @@ rustls-tls = [ ] s3 = ["rattler_networking/s3", "rattler_upload/s3"] gcs = ["rattler_networking/gcs"] +oauth = ["rattler/oauth"] [dependencies] anyhow = { workspace = true } diff --git a/crates/rattler/src/cli/auth.rs b/crates/rattler/src/cli/auth.rs index 9a38950ac6..5814561999 100644 --- a/crates/rattler/src/cli/auth.rs +++ b/crates/rattler/src/cli/auth.rs @@ -167,17 +167,49 @@ pub enum AuthenticationCLIError { OAuthError(#[from] oauth::OAuthError), } +/// Normalize a user-supplied host into its canonical hostname form. +/// +/// Strips a leading `*.` wildcard, scheme, path, port, and trailing +/// slashes — so `prefix.dev`, `prefix.dev/`, `https://prefix.dev/`, +/// and `*.prefix.dev` all collapse to `prefix.dev`. Used both for the +/// OAuth host allow-list check and as the storage key when writing +/// OAuth credentials, so that login and lookup agree on the key. +fn normalize_login_host(host: &str) -> String { + let host = host.trim_start_matches("*."); + + // Try parsing as-is first (handles inputs like `https://prefix.dev`). + // We only accept the result if it actually yielded a hostname — `Url` + // happily parses `localhost:8080` as a `localhost`-scheme URL with no + // host, so we have to check `host_str()` rather than just parse success. + if let Some(h) = url::Url::parse(host) + .ok() + .and_then(|u| u.host_str().map(str::to_string)) + { + return h; + } + + // Fall back to prepending a scheme (handles bare `prefix.dev`, + // `prefix.dev/`, `localhost:8080`, etc.). + url::Url::parse(&format!("https://{host}")) + .ok() + .and_then(|u| u.host_str().map(str::to_string)) + .unwrap_or_else(|| host.trim_end_matches('/').to_string()) +} + /// Returns true when the host should default to OAuth login if the user did /// not pass any other authentication method. +/// +/// Loopback addresses (`localhost`, `127.0.0.1`, `[::1]`) are included so +/// developers running prefix.dev or a compatible OIDC-capable backend +/// locally don't have to pass `--oauth` explicitly. #[cfg(feature = "oauth")] fn host_supports_default_oauth(host: &str) -> bool { - let host = host.trim_start_matches("*."); - let host = if let Ok(url) = url::Url::parse(host) { - url.host_str().map(str::to_string).unwrap_or_default() - } else { - host.to_string() - }; - host == "prefix.dev" || host.ends_with(".prefix.dev") + let host = normalize_login_host(host); + host == "prefix.dev" + || host.ends_with(".prefix.dev") + || host == "localhost" + || host == "127.0.0.1" + || host == "[::1]" } /// Returns true when the user passed no explicit auth method, so we should @@ -238,9 +270,23 @@ async fn login( ); } - let issuer_url = args - .oauth_issuer_url - .unwrap_or_else(|| format!("https://{}", args.host)); + // Default issuer URL: `https://` for normal hosts, but `http://` + // for loopback addresses since local dev servers rarely have TLS set + // up. The `--oauth-issuer-url` flag still takes precedence for any + // host where the user wants to override (e.g. point at Hydra on a + // different port). + let issuer_url = args.oauth_issuer_url.unwrap_or_else(|| { + let normalized = normalize_login_host(&args.host); + let scheme = if normalized == "localhost" + || normalized == "127.0.0.1" + || normalized == "[::1]" + { + "http" + } else { + "https" + }; + format!("{scheme}://{}", args.host) + }); let client_id = args .oauth_client_id .unwrap_or_else(|| "rattler".to_string()); @@ -250,17 +296,33 @@ async fn login( _ => oauth::OAuthFlow::Auto, }; + // If the user didn't pass any `--oauth-scope` flags, pick a + // host-appropriate default set (e.g. `["openid"]` for Anaconda, + // the prefix.dev-flavored set everywhere else). + let scopes: std::collections::HashSet = if args.oauth_scopes.is_empty() { + oauth::default_scopes_for_host(&args.host) + .iter() + .map(|&s| s.to_string()) + .collect() + } else { + args.oauth_scopes.into_iter().collect() + }; + let config = oauth::OAuthConfig { issuer_url, client_id, client_secret: args.oauth_client_secret, flow, - scopes: args.oauth_scopes.into_iter().collect(), + scopes, }; let auth = oauth::perform_oauth_login(config).await?; - // OAuth credentials are issuer-specific, skip wildcard conversion - let host = args.host.clone(); + // OAuth credentials are issuer-specific, skip wildcard conversion. + // Normalize the host so that `prefix.dev` and `prefix.dev/` (and + // any `https://...` form) write to the same storage key — without + // this, login and the upload-side `storage.get_by_url(...)` lookup + // disagree about the canonical key. + let host = normalize_login_host(&args.host); storage.store(&host, &auth)?; eprintln!("Credentials stored for {host}."); return Ok(()); @@ -678,10 +740,27 @@ mod tests { assert!(host_supports_default_oauth("https://prefix.dev")); assert!(host_supports_default_oauth("*.prefix.dev")); + // Normalization: trailing slash and full URLs should still match. + assert!(host_supports_default_oauth("prefix.dev/")); + assert!(host_supports_default_oauth("https://prefix.dev/")); + assert!(host_supports_default_oauth("https://repo.prefix.dev/")); + + // Loopback addresses for local development. The normalization step + // strips the port so `localhost:8080` collapses to `localhost`. + assert!(host_supports_default_oauth("localhost")); + assert!(host_supports_default_oauth("localhost:8080")); + assert!(host_supports_default_oauth("http://localhost:8080")); + assert!(host_supports_default_oauth("127.0.0.1")); + assert!(host_supports_default_oauth("127.0.0.1:8080")); + assert!(host_supports_default_oauth("http://127.0.0.1:8080/")); + assert!(!host_supports_default_oauth("example.com")); // Suffix-injection guard: hostname containing "prefix.dev" must not match. assert!(!host_supports_default_oauth("evil-prefix.dev.attacker.com")); assert!(!host_supports_default_oauth("notprefix.dev")); + // Loopback-spoofing guard: hostnames *containing* "localhost" must not match. + assert!(!host_supports_default_oauth("localhost.attacker.com")); + assert!(!host_supports_default_oauth("notlocalhost")); } #[cfg(feature = "oauth")] diff --git a/crates/rattler/src/cli/auth/oauth.rs b/crates/rattler/src/cli/auth/oauth.rs index 9562a5ad8f..6b0a7a3da6 100644 --- a/crates/rattler/src/cli/auth/oauth.rs +++ b/crates/rattler/src/cli/auth/oauth.rs @@ -54,8 +54,56 @@ type ExtendedCoreProviderMetadata = ProviderMetadata< CoreSubjectIdentifierType, >; -/// Default OAuth scopes used when the caller passes none. -pub const DEFAULT_OAUTH_SCOPES: &[&str] = &["openid", "profile", "offline_access", "channel:read"]; +/// Default OAuth scopes used when the caller passes none and the host +/// has no specific profile registered in [`HOST_SCOPE_PROFILES`]. +pub const DEFAULT_OAUTH_SCOPES: &[&str] = &[ + "openid", + "profile", + "offline_access", + "channel:read", + "channel:upload", +]; + +/// Default scopes for Anaconda hosts. The OAuth access token here is +/// only used to mint an Anaconda API key (the long-lived credential +/// stored as a `CondaToken`), so we only need identity. Anaconda's IDP +/// does not recognize prefix.dev-style `channel:*` scopes. +pub const DEFAULT_ANACONDA_OAUTH_SCOPES: &[&str] = &["openid"]; + +/// A predefined OAuth scope profile keyed off a substring of the login +/// host. Used to choose sensible defaults when the user does not pass +/// `--oauth-scope` flags explicitly. +struct HostScopeProfile { + /// Substring matched against the login host (e.g. `"anaconda.org"`). + host_pattern: &'static str, + /// Scopes requested when the user provides none for this host. + scopes: &'static [&'static str], +} + +/// Per-host scope profiles, checked in order. The first match wins. +/// Hosts that do not match any entry fall back to +/// [`DEFAULT_OAUTH_SCOPES`]. +const HOST_SCOPE_PROFILES: &[HostScopeProfile] = &[ + HostScopeProfile { + host_pattern: "anaconda.org", + scopes: DEFAULT_ANACONDA_OAUTH_SCOPES, + }, + HostScopeProfile { + host_pattern: "anaconda.com", + scopes: DEFAULT_ANACONDA_OAUTH_SCOPES, + }, +]; + +/// Look up the default OAuth scopes for the given host. +/// +/// Returns the host-specific profile if one is registered (e.g. +/// Anaconda), otherwise [`DEFAULT_OAUTH_SCOPES`]. +pub fn default_scopes_for_host(host: &str) -> &'static [&'static str] { + HOST_SCOPE_PROFILES + .iter() + .find(|profile| host.contains(profile.host_pattern)) + .map_or(DEFAULT_OAUTH_SCOPES, |profile| profile.scopes) +} /// Configuration for an OAuth login flow. pub struct OAuthConfig { @@ -154,7 +202,15 @@ struct CallbackResult { pub async fn perform_oauth_login(config: OAuthConfig) -> Result { let mut config = config; if config.scopes.is_empty() { - config.scopes = DEFAULT_OAUTH_SCOPES + // Derive the host from the issuer URL so the fallback picks the + // right per-host profile (e.g. Anaconda → just `openid`). Falls + // back to the empty string on parse failure, which won't match + // any profile and so lands on `DEFAULT_OAUTH_SCOPES`. + let host = Url::parse(&config.issuer_url) + .ok() + .and_then(|u| u.host_str().map(str::to_string)) + .unwrap_or_default(); + config.scopes = default_scopes_for_host(&host) .iter() .map(|&s| s.to_string()) .collect(); @@ -647,3 +703,54 @@ pub async fn revoke_tokens( } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn anaconda_hosts_get_minimal_scopes() { + assert_eq!( + default_scopes_for_host("anaconda.org"), + DEFAULT_ANACONDA_OAUTH_SCOPES + ); + assert_eq!( + default_scopes_for_host("api.anaconda.com"), + DEFAULT_ANACONDA_OAUTH_SCOPES + ); + } + + #[test] + fn unknown_hosts_get_default_scopes() { + assert_eq!(default_scopes_for_host("prefix.dev"), DEFAULT_OAUTH_SCOPES); + assert_eq!( + default_scopes_for_host("repo.example.com"), + DEFAULT_OAUTH_SCOPES + ); + } + + /// The issuer-URL-derived fallback inside `perform_oauth_login` should + /// route Anaconda issuer URLs to the Anaconda profile. + #[test] + fn issuer_url_host_extraction_matches_anaconda() { + let host = Url::parse("https://auth.anaconda.com/api/auth") + .ok() + .and_then(|u| u.host_str().map(str::to_string)) + .unwrap_or_default(); + assert_eq!( + default_scopes_for_host(&host), + DEFAULT_ANACONDA_OAUTH_SCOPES + ); + } + + /// A malformed issuer URL should fall through to the catch-all defaults + /// rather than panic. + #[test] + fn malformed_issuer_url_falls_back_to_default() { + let host = Url::parse("not a url") + .ok() + .and_then(|u| u.host_str().map(str::to_string)) + .unwrap_or_default(); + assert_eq!(default_scopes_for_host(&host), DEFAULT_OAUTH_SCOPES); + } +} diff --git a/crates/rattler_networking/src/authentication_middleware.rs b/crates/rattler_networking/src/authentication_middleware.rs index 0fd9c70920..6dbd0c6509 100644 --- a/crates/rattler_networking/src/authentication_middleware.rs +++ b/crates/rattler_networking/src/authentication_middleware.rs @@ -8,22 +8,13 @@ use std::{ use base64::{prelude::BASE64_STANDARD, Engine}; use reqwest::{Request, Response}; use reqwest_middleware::{Middleware, Next}; -use serde::Deserialize; use url::Url; use crate::{ - authentication_storage::AuthenticationStorageError, Authentication, AuthenticationStorage, + authentication_storage::AuthenticationStorageError, oauth_refresh, Authentication, + AuthenticationStorage, }; -/// Response from an OAuth token refresh request (standard `OAuth2` token -/// response). -#[derive(Deserialize)] -struct TokenRefreshResponse { - access_token: String, - refresh_token: Option, - expires_in: Option, -} - /// `reqwest` middleware to authenticate requests #[derive(Clone)] pub struct AuthenticationMiddleware { @@ -53,10 +44,10 @@ impl Middleware for AuthenticationMiddleware { Ok((url, auth_with_key)) => { // If this is an OAuth token, attempt refresh if expired let auth = match auth_with_key { - Some((matched_key, oauth_auth @ Authentication::OAuth { .. })) => { - self.maybe_refresh_oauth(oauth_auth, &matched_key).await + Some((matched_key, auth)) => { + oauth_refresh::maybe_refresh_oauth(&self.auth_storage, auth, &matched_key) + .await } - Some((_, auth)) => Some(auth), None => None, }; @@ -156,127 +147,6 @@ impl AuthenticationMiddleware { Ok(req) } } - - /// Check if an OAuth token is expired and attempt to refresh it. - /// - /// Returns the (possibly refreshed) authentication. If refresh fails, - /// returns the original auth so the request proceeds with the existing - /// (possibly expired) token — the server will return 401 which is clearer - /// than a middleware error. - async fn maybe_refresh_oauth( - &self, - auth: Authentication, - matched_key: &str, - ) -> Option { - let Authentication::OAuth { - ref access_token, - ref refresh_token, - expires_at, - ref token_endpoint, - ref revocation_endpoint, - ref client_id, - } = auth - else { - return Some(auth); - }; - - // Check if token is expired (with 5 minute buffer for clock skew) - let is_expired = expires_at.is_some_and(|exp| { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() as i64; - exp - now < 300 // 5 minute buffer - }); - - if !is_expired { - return Some(auth); - } - - let Some(refresh_token_val) = refresh_token.as_deref() else { - tracing::warn!("OAuth token is expired but no refresh token is available"); - return Some(auth); - }; - - tracing::debug!("OAuth token expired, attempting refresh"); - - let client = reqwest::Client::new(); - let params = [ - ("grant_type", "refresh_token"), - ("refresh_token", refresh_token_val), - ("client_id", client_id), - ]; - - let response = match client - .post(token_endpoint.as_str()) - .form(¶ms) - .send() - .await - { - Ok(resp) => resp, - Err(e) => { - tracing::warn!("Failed to refresh OAuth token: {e}"); - return Some(auth); - } - }; - - if !response.status().is_success() { - let status = response.status(); - let hint = match response.json::().await { - Ok(body) => { - let error_code = body - .get("error") - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); - if error_code == "invalid_grant" { - "refresh token is expired or revoked — please re-authenticate".to_string() - } else { - format!("error code: {error_code}") - } - } - Err(_) => format!("HTTP {status}"), - }; - tracing::warn!("OAuth token refresh failed ({hint})"); - return Some(auth); - } - - let token_response: TokenRefreshResponse = match response.json().await { - Ok(body) => body, - Err(e) => { - tracing::warn!("Failed to read OAuth refresh response body: {e}"); - return Some(auth); - } - }; - - let new_expires_at = token_response.expires_in.map(|secs| { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() as i64 - + secs - }); - - let refreshed = Authentication::OAuth { - access_token: token_response.access_token, - refresh_token: token_response - .refresh_token - .or_else(|| refresh_token.clone()), - expires_at: new_expires_at, - token_endpoint: token_endpoint.clone(), - revocation_endpoint: revocation_endpoint.clone(), - client_id: client_id.clone(), - }; - - // Store the refreshed token back (best-effort) - if let Err(e) = self.auth_storage.store(matched_key, &refreshed) { - tracing::warn!("Failed to store refreshed OAuth token: {e}"); - } - - // Invalidate the cache entry for the old token - let _ = access_token; - - Some(refreshed) - } } /// Returns the default auth storage directory used by rattler. diff --git a/crates/rattler_networking/src/authentication_storage/storage.rs b/crates/rattler_networking/src/authentication_storage/storage.rs index 9a76e42238..1dc234880f 100644 --- a/crates/rattler_networking/src/authentication_storage/storage.rs +++ b/crates/rattler_networking/src/authentication_storage/storage.rs @@ -243,6 +243,24 @@ impl AuthenticationStorage { Ok((url, auth.map(|(_, credentials)| credentials))) } + /// Like [`get_by_url`](Self::get_by_url), but additionally refreshes + /// expired OAuth access tokens via the provider's token endpoint + /// before returning. Refreshed credentials are written back to the + /// storage so subsequent calls see the new token. + pub async fn get_by_url_refreshed( + &self, + url: U, + ) -> Result<(Url, Option), reqwest::Error> { + let (url, auth_with_key) = self.get_by_url_with_host(url)?; + let auth = match auth_with_key { + Some((matched_key, auth)) => { + crate::oauth_refresh::maybe_refresh_oauth(self, auth, &matched_key).await + } + None => None, + }; + Ok((url, auth)) + } + /// Delete the authentication information for the given host pub fn delete(&self, host: &str) -> Result<()> { { diff --git a/crates/rattler_networking/src/lib.rs b/crates/rattler_networking/src/lib.rs index 9850aef7c6..6f4ed83fca 100644 --- a/crates/rattler_networking/src/lib.rs +++ b/crates/rattler_networking/src/lib.rs @@ -19,6 +19,7 @@ pub use s3_middleware::S3Middleware; pub mod authentication_middleware; pub mod authentication_storage; +pub mod oauth_refresh; mod lazy_client; pub mod mirror_middleware; diff --git a/crates/rattler_networking/src/oauth_refresh.rs b/crates/rattler_networking/src/oauth_refresh.rs new file mode 100644 index 0000000000..8a8b2e5ba3 --- /dev/null +++ b/crates/rattler_networking/src/oauth_refresh.rs @@ -0,0 +1,141 @@ +//! Refresh logic for `Authentication::OAuth` credentials. +//! +//! This module is independent of the `reqwest` middleware so that callers +//! that don't go through the middleware (such as upload code paths) can +//! still get auto-refreshed OAuth tokens. + +use serde::Deserialize; + +use crate::{Authentication, AuthenticationStorage}; + +/// Standard OAuth 2.0 token response (RFC 6749 §5.1). +#[derive(Deserialize)] +struct TokenRefreshResponse { + access_token: String, + refresh_token: Option, + expires_in: Option, +} + +/// Number of seconds before `expires_at` at which a token is considered +/// "about to expire" — gives some headroom for clock skew and request +/// in-flight time. +const EXPIRY_SKEW_SECONDS: i64 = 300; + +/// Refresh an OAuth token if it is expired (or close to expiring) and +/// store the refreshed credential back to `storage`. +/// +/// Returns the (possibly refreshed) authentication. If refresh fails, +/// returns the original auth so the caller can proceed with the existing +/// (possibly expired) token — the server's 401 is clearer than a +/// middleware error. +/// +/// For non-OAuth credentials this is a pass-through. +pub async fn maybe_refresh_oauth( + storage: &AuthenticationStorage, + auth: Authentication, + matched_key: &str, +) -> Option { + let Authentication::OAuth { + access_token: _, + ref refresh_token, + expires_at, + ref token_endpoint, + ref revocation_endpoint, + ref client_id, + } = auth + else { + return Some(auth); + }; + + let is_expired = expires_at.is_some_and(|exp| { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + exp - now < EXPIRY_SKEW_SECONDS + }); + + if !is_expired { + return Some(auth); + } + + let Some(refresh_token_val) = refresh_token.as_deref() else { + tracing::warn!("OAuth token is expired but no refresh token is available"); + return Some(auth); + }; + + tracing::debug!("OAuth token expired, attempting refresh"); + + let client = reqwest::Client::new(); + let params = [ + ("grant_type", "refresh_token"), + ("refresh_token", refresh_token_val), + ("client_id", client_id), + ]; + + let response = match client + .post(token_endpoint.as_str()) + .form(¶ms) + .send() + .await + { + Ok(resp) => resp, + Err(e) => { + tracing::warn!("Failed to refresh OAuth token: {e}"); + return Some(auth); + } + }; + + if !response.status().is_success() { + let status = response.status(); + let hint = match response.json::().await { + Ok(body) => { + let error_code = body + .get("error") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + if error_code == "invalid_grant" { + "refresh token is expired or revoked — please re-authenticate".to_string() + } else { + format!("error code: {error_code}") + } + } + Err(_) => format!("HTTP {status}"), + }; + tracing::warn!("OAuth token refresh failed ({hint})"); + return Some(auth); + } + + let token_response: TokenRefreshResponse = match response.json().await { + Ok(body) => body, + Err(e) => { + tracing::warn!("Failed to read OAuth refresh response body: {e}"); + return Some(auth); + } + }; + + let new_expires_at = token_response.expires_in.map(|secs| { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64 + + secs + }); + + let refreshed = Authentication::OAuth { + access_token: token_response.access_token, + refresh_token: token_response + .refresh_token + .or_else(|| refresh_token.clone()), + expires_at: new_expires_at, + token_endpoint: token_endpoint.clone(), + revocation_endpoint: revocation_endpoint.clone(), + client_id: client_id.clone(), + }; + + if let Err(e) = storage.store(matched_key, &refreshed) { + tracing::warn!("Failed to store refreshed OAuth token: {e}"); + } + + Some(refreshed) +} diff --git a/crates/rattler_upload/src/upload/prefix.rs b/crates/rattler_upload/src/upload/prefix.rs index bf7dfcebe9..534e26c553 100644 --- a/crates/rattler_upload/src/upload/prefix.rs +++ b/crates/rattler_upload/src/upload/prefix.rs @@ -62,7 +62,7 @@ pub enum PrefixUploadError { AttestationWithApiKey, /// The server returned an authentication error (HTTP 401 or 403). - #[error("authentication failed (HTTP {status})")] + #[error("authentication failed (HTTP {status}): {body}")] AuthenticationFailed { /// The HTTP status code. status: u16, @@ -163,21 +163,33 @@ async fn create_upload_form( Ok(form) } -/// Uploads package files to a prefix.dev server. -pub async fn upload_package_to_prefix( +/// Look up a bearer-style token for `url` from `storage`, automatically +/// refreshing OAuth tokens whose access token is close to expiring. +/// +/// Returns the raw token string suitable for use in an `Authorization: +/// Bearer ...` header. Both `BearerToken` and the access token portion of +/// an `OAuth` credential are accepted. +async fn fetch_token_from_storage( storage: &AuthenticationStorage, - package_files: &Vec, - prefix_data: PrefixData, -) -> Result<(), PrefixUploadError> { - let check_storage = || match storage.get_by_url(Url::from(prefix_data.url.clone())) { + url: &Url, +) -> Result { + match storage.get_by_url_refreshed(url.clone()).await { Ok((_, Some(Authentication::BearerToken(token)))) => Ok(token), + Ok((_, Some(Authentication::OAuth { access_token, .. }))) => Ok(access_token), Ok((_, Some(_))) => Err(PrefixUploadError::WrongAuthenticationType), Ok((_, None)) => Err(PrefixUploadError::MissingApiKey), Err(e) => Err(PrefixUploadError::KeychainError { message: e.to_string(), }), - }; + } +} +/// Uploads package files to a prefix.dev server. +pub async fn upload_package_to_prefix( + storage: &AuthenticationStorage, + package_files: &Vec, + prefix_data: PrefixData, +) -> Result<(), PrefixUploadError> { let client = get_client_with_retry().into_diagnostic()?; let wants_attestation = !matches!(prefix_data.attestation, AttestationSource::NoAttestation); @@ -211,14 +223,20 @@ pub async fn upload_package_to_prefix( if wants_attestation { return Err(PrefixUploadError::AttestationRequiresTrustedPublishing); } - (check_storage()?, false) + ( + fetch_token_from_storage(storage, &prefix_data.url).await?, + false, + ) } TrustedPublishResult::Ignored(err) => { tracing::warn!("Checked for trusted publishing but failed with {err}"); if wants_attestation { return Err(PrefixUploadError::AttestationRequiresTrustedPublishing); } - (check_storage()?, false) + ( + fetch_token_from_storage(storage, &prefix_data.url).await?, + false, + ) } }, }; @@ -232,14 +250,14 @@ pub async fn upload_package_to_prefix( if wants_attestation { return Err(PrefixUploadError::AttestationRequiresTrustedPublishing); } - check_storage()? + fetch_token_from_storage(storage, &prefix_data.url).await? } TrustedPublishResult::Ignored(err) => { tracing::warn!("Checked for trusted publishing but failed with {err}"); if wants_attestation { return Err(PrefixUploadError::AttestationRequiresTrustedPublishing); } - check_storage()? + fetch_token_from_storage(storage, &prefix_data.url).await? } }, }; From f9a529ae9070e14185e8cc5007b14fdd87c94999 Mon Sep 17 00:00:00 2001 From: nichmor Date: Wed, 6 May 2026 11:11:35 +0300 Subject: [PATCH 3/9] misc: adjust default scopes for prefix.dev --- crates/rattler/src/cli/auth.rs | 5 +- crates/rattler/src/cli/auth/oauth.rs | 117 ++++----------------------- 2 files changed, 15 insertions(+), 107 deletions(-) diff --git a/crates/rattler/src/cli/auth.rs b/crates/rattler/src/cli/auth.rs index 5814561999..eb3652606c 100644 --- a/crates/rattler/src/cli/auth.rs +++ b/crates/rattler/src/cli/auth.rs @@ -296,11 +296,8 @@ async fn login( _ => oauth::OAuthFlow::Auto, }; - // If the user didn't pass any `--oauth-scope` flags, pick a - // host-appropriate default set (e.g. `["openid"]` for Anaconda, - // the prefix.dev-flavored set everywhere else). let scopes: std::collections::HashSet = if args.oauth_scopes.is_empty() { - oauth::default_scopes_for_host(&args.host) + oauth::DEFAULT_OAUTH_SCOPES .iter() .map(|&s| s.to_string()) .collect() diff --git a/crates/rattler/src/cli/auth/oauth.rs b/crates/rattler/src/cli/auth/oauth.rs index 6b0a7a3da6..39c6dba348 100644 --- a/crates/rattler/src/cli/auth/oauth.rs +++ b/crates/rattler/src/cli/auth/oauth.rs @@ -54,57 +54,27 @@ type ExtendedCoreProviderMetadata = ProviderMetadata< CoreSubjectIdentifierType, >; -/// Default OAuth scopes used when the caller passes none and the host -/// has no specific profile registered in [`HOST_SCOPE_PROFILES`]. +/// Default OAuth scopes used when the caller passes none. +/// +/// Mirrors the server's "Full access" channel-access preset so an +/// out-of-the-box `rattler auth login` grants the full set of channel +/// capabilities (create, read, upload, yank, delete, settings, member +/// management, lifecycle) plus identity (openid/profile) and refresh +/// tokens (offline_access). pub const DEFAULT_OAUTH_SCOPES: &[&str] = &[ "openid", "profile", "offline_access", + "channel:create", "channel:read", "channel:upload", + "channel:yank", + "channel:delete-package", + "channel:settings", + "channel:members", + "channel:lifecycle", ]; -/// Default scopes for Anaconda hosts. The OAuth access token here is -/// only used to mint an Anaconda API key (the long-lived credential -/// stored as a `CondaToken`), so we only need identity. Anaconda's IDP -/// does not recognize prefix.dev-style `channel:*` scopes. -pub const DEFAULT_ANACONDA_OAUTH_SCOPES: &[&str] = &["openid"]; - -/// A predefined OAuth scope profile keyed off a substring of the login -/// host. Used to choose sensible defaults when the user does not pass -/// `--oauth-scope` flags explicitly. -struct HostScopeProfile { - /// Substring matched against the login host (e.g. `"anaconda.org"`). - host_pattern: &'static str, - /// Scopes requested when the user provides none for this host. - scopes: &'static [&'static str], -} - -/// Per-host scope profiles, checked in order. The first match wins. -/// Hosts that do not match any entry fall back to -/// [`DEFAULT_OAUTH_SCOPES`]. -const HOST_SCOPE_PROFILES: &[HostScopeProfile] = &[ - HostScopeProfile { - host_pattern: "anaconda.org", - scopes: DEFAULT_ANACONDA_OAUTH_SCOPES, - }, - HostScopeProfile { - host_pattern: "anaconda.com", - scopes: DEFAULT_ANACONDA_OAUTH_SCOPES, - }, -]; - -/// Look up the default OAuth scopes for the given host. -/// -/// Returns the host-specific profile if one is registered (e.g. -/// Anaconda), otherwise [`DEFAULT_OAUTH_SCOPES`]. -pub fn default_scopes_for_host(host: &str) -> &'static [&'static str] { - HOST_SCOPE_PROFILES - .iter() - .find(|profile| host.contains(profile.host_pattern)) - .map_or(DEFAULT_OAUTH_SCOPES, |profile| profile.scopes) -} - /// Configuration for an OAuth login flow. pub struct OAuthConfig { /// The OIDC issuer URL. @@ -202,15 +172,7 @@ struct CallbackResult { pub async fn perform_oauth_login(config: OAuthConfig) -> Result { let mut config = config; if config.scopes.is_empty() { - // Derive the host from the issuer URL so the fallback picks the - // right per-host profile (e.g. Anaconda → just `openid`). Falls - // back to the empty string on parse failure, which won't match - // any profile and so lands on `DEFAULT_OAUTH_SCOPES`. - let host = Url::parse(&config.issuer_url) - .ok() - .and_then(|u| u.host_str().map(str::to_string)) - .unwrap_or_default(); - config.scopes = default_scopes_for_host(&host) + config.scopes = DEFAULT_OAUTH_SCOPES .iter() .map(|&s| s.to_string()) .collect(); @@ -703,54 +665,3 @@ pub async fn revoke_tokens( } } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn anaconda_hosts_get_minimal_scopes() { - assert_eq!( - default_scopes_for_host("anaconda.org"), - DEFAULT_ANACONDA_OAUTH_SCOPES - ); - assert_eq!( - default_scopes_for_host("api.anaconda.com"), - DEFAULT_ANACONDA_OAUTH_SCOPES - ); - } - - #[test] - fn unknown_hosts_get_default_scopes() { - assert_eq!(default_scopes_for_host("prefix.dev"), DEFAULT_OAUTH_SCOPES); - assert_eq!( - default_scopes_for_host("repo.example.com"), - DEFAULT_OAUTH_SCOPES - ); - } - - /// The issuer-URL-derived fallback inside `perform_oauth_login` should - /// route Anaconda issuer URLs to the Anaconda profile. - #[test] - fn issuer_url_host_extraction_matches_anaconda() { - let host = Url::parse("https://auth.anaconda.com/api/auth") - .ok() - .and_then(|u| u.host_str().map(str::to_string)) - .unwrap_or_default(); - assert_eq!( - default_scopes_for_host(&host), - DEFAULT_ANACONDA_OAUTH_SCOPES - ); - } - - /// A malformed issuer URL should fall through to the catch-all defaults - /// rather than panic. - #[test] - fn malformed_issuer_url_falls_back_to_default() { - let host = Url::parse("not a url") - .ok() - .and_then(|u| u.host_str().map(str::to_string)) - .unwrap_or_default(); - assert_eq!(default_scopes_for_host(&host), DEFAULT_OAUTH_SCOPES); - } -} From 6712c79d2bd894844012570948db16784233dd16 Mon Sep 17 00:00:00 2001 From: nichmor Date: Wed, 6 May 2026 12:17:59 +0300 Subject: [PATCH 4/9] misc: request per host config --- crates/rattler/src/cli/auth.rs | 154 ++++++++++-------- crates/rattler/src/cli/auth/oauth.rs | 4 +- .../rattler_networking/src/oauth_refresh.rs | 16 +- crates/rattler_upload/src/upload/prefix.rs | 6 +- 4 files changed, 95 insertions(+), 85 deletions(-) diff --git a/crates/rattler/src/cli/auth.rs b/crates/rattler/src/cli/auth.rs index eb3652606c..e576a921c0 100644 --- a/crates/rattler/src/cli/auth.rs +++ b/crates/rattler/src/cli/auth.rs @@ -168,12 +168,6 @@ pub enum AuthenticationCLIError { } /// Normalize a user-supplied host into its canonical hostname form. -/// -/// Strips a leading `*.` wildcard, scheme, path, port, and trailing -/// slashes — so `prefix.dev`, `prefix.dev/`, `https://prefix.dev/`, -/// and `*.prefix.dev` all collapse to `prefix.dev`. Used both for the -/// OAuth host allow-list check and as the storage key when writing -/// OAuth credentials, so that login and lookup agree on the key. fn normalize_login_host(host: &str) -> String { let host = host.trim_start_matches("*."); @@ -196,24 +190,53 @@ fn normalize_login_host(host: &str) -> String { .unwrap_or_else(|| host.trim_end_matches('/').to_string()) } -/// Returns true when the host should default to OAuth login if the user did -/// not pass any other authentication method. +/// Built-in OAuth defaults for a known host. +/// +/// Returned by [`default_oauth_config_for_host`] for hosts where rattler +/// ships an out-of-the-box OAuth configuration. Carries everything needed +/// to start a login flow without the user passing any flags. +#[cfg(feature = "oauth")] +struct DefaultOAuthConfig { + issuer_url: String, + client_id: String, + scopes: Vec, +} + +/// Returns the built-in OAuth configuration for a host, if rattler ships one. +/// +/// Currently recognizes the prefix.dev family (over `https`) and loopback +/// addresses (over `http`, since local dev servers rarely terminate TLS). +/// Returns `None` for any other host. /// -/// Loopback addresses (`localhost`, `127.0.0.1`, `[::1]`) are included so -/// developers running prefix.dev or a compatible OIDC-capable backend -/// locally don't have to pass `--oauth` explicitly. +/// The presence of a returned config is also the signal for whether rattler +/// should default to OAuth when the user provides no auth method at all — +/// see [`should_default_to_oauth`]. #[cfg(feature = "oauth")] -fn host_supports_default_oauth(host: &str) -> bool { - let host = normalize_login_host(host); - host == "prefix.dev" - || host.ends_with(".prefix.dev") - || host == "localhost" - || host == "127.0.0.1" - || host == "[::1]" +fn default_oauth_config_for_host(host: &str) -> Option { + let normalized = normalize_login_host(host); + + let is_prefix_dev = normalized == "prefix.dev" || normalized.ends_with(".prefix.dev"); + let is_loopback = + normalized == "localhost" || normalized == "127.0.0.1" || normalized == "[::1]"; + + if !is_prefix_dev && !is_loopback { + return None; + } + + let scheme = if is_loopback { "http" } else { "https" }; + + Some(DefaultOAuthConfig { + issuer_url: format!("{scheme}://{host}"), + client_id: "rattler".to_string(), + scopes: oauth::DEFAULT_OAUTH_SCOPES + .iter() + .map(|&s| s.to_string()) + .collect(), + }) } /// Returns true when the user passed no explicit auth method, so we should -/// fall back to OAuth for OAuth-capable hosts. +/// fall back to OAuth for hosts that ship a built-in OAuth config. #[cfg(feature = "oauth")] fn should_default_to_oauth(args: &LoginArgs) -> bool { let no_explicit_method = args.token.is_none() @@ -221,7 +244,7 @@ fn should_default_to_oauth(args: &LoginArgs) -> bool { && args.password.is_none() && args.conda_token.is_none() && args.s3_access_key_id.is_none(); - no_explicit_method && host_supports_default_oauth(&args.host) + no_explicit_method && default_oauth_config_for_host(&args.host).is_some() } fn get_url(url: &str) -> Result { @@ -270,39 +293,34 @@ async fn login( ); } - // Default issuer URL: `https://` for normal hosts, but `http://` - // for loopback addresses since local dev servers rarely have TLS set - // up. The `--oauth-issuer-url` flag still takes precedence for any - // host where the user wants to override (e.g. point at Hydra on a - // different port). - let issuer_url = args.oauth_issuer_url.unwrap_or_else(|| { - let normalized = normalize_login_host(&args.host); - let scheme = if normalized == "localhost" - || normalized == "127.0.0.1" - || normalized == "[::1]" - { - "http" - } else { - "https" - }; - format!("{scheme}://{}", args.host) - }); + // Look up the host's built-in OAuth defaults, if any. + let host_default = default_oauth_config_for_host(&args.host); + + let issuer_url = args + .oauth_issuer_url + .or_else(|| host_default.as_ref().map(|c| c.issuer_url.clone())) + .unwrap_or_else(|| format!("https://{}", args.host)); + let client_id = args .oauth_client_id + .or_else(|| host_default.as_ref().map(|c| c.client_id.clone())) .unwrap_or_else(|| "rattler".to_string()); + let flow = match args.oauth_flow.as_deref() { Some("auth-code") => oauth::OAuthFlow::AuthCode, Some("device-code") => oauth::OAuthFlow::DeviceCode, _ => oauth::OAuthFlow::Auto, }; - let scopes: std::collections::HashSet = if args.oauth_scopes.is_empty() { + let scopes: std::collections::HashSet = if !args.oauth_scopes.is_empty() { + args.oauth_scopes.into_iter().collect() + } else if let Some(default) = host_default { + default.scopes.into_iter().collect() + } else { oauth::DEFAULT_OAUTH_SCOPES .iter() .map(|&s| s.to_string()) .collect() - } else { - args.oauth_scopes.into_iter().collect() }; let config = oauth::OAuthConfig { @@ -314,11 +332,8 @@ async fn login( }; let auth = oauth::perform_oauth_login(config).await?; - // OAuth credentials are issuer-specific, skip wildcard conversion. // Normalize the host so that `prefix.dev` and `prefix.dev/` (and - // any `https://...` form) write to the same storage key — without - // this, login and the upload-side `storage.get_by_url(...)` lookup - // disagree about the canonical key. + // any `https://...` form) write to the same storage key let host = normalize_login_host(&args.host); storage.store(&host, &auth)?; eprintln!("Credentials stored for {host}."); @@ -731,33 +746,44 @@ mod tests { #[cfg(feature = "oauth")] #[test] - fn test_host_supports_default_oauth() { - assert!(host_supports_default_oauth("prefix.dev")); - assert!(host_supports_default_oauth("repo.prefix.dev")); - assert!(host_supports_default_oauth("https://prefix.dev")); - assert!(host_supports_default_oauth("*.prefix.dev")); + fn test_default_oauth_config_for_host() { + let has_default = |h: &str| default_oauth_config_for_host(h).is_some(); + + assert!(has_default("prefix.dev")); + assert!(has_default("repo.prefix.dev")); + assert!(has_default("https://prefix.dev")); + assert!(has_default("*.prefix.dev")); // Normalization: trailing slash and full URLs should still match. - assert!(host_supports_default_oauth("prefix.dev/")); - assert!(host_supports_default_oauth("https://prefix.dev/")); - assert!(host_supports_default_oauth("https://repo.prefix.dev/")); + assert!(has_default("prefix.dev/")); + assert!(has_default("https://prefix.dev/")); + assert!(has_default("https://repo.prefix.dev/")); // Loopback addresses for local development. The normalization step // strips the port so `localhost:8080` collapses to `localhost`. - assert!(host_supports_default_oauth("localhost")); - assert!(host_supports_default_oauth("localhost:8080")); - assert!(host_supports_default_oauth("http://localhost:8080")); - assert!(host_supports_default_oauth("127.0.0.1")); - assert!(host_supports_default_oauth("127.0.0.1:8080")); - assert!(host_supports_default_oauth("http://127.0.0.1:8080/")); - - assert!(!host_supports_default_oauth("example.com")); + assert!(has_default("localhost")); + assert!(has_default("localhost:8080")); + assert!(has_default("http://localhost:8080")); + assert!(has_default("127.0.0.1")); + assert!(has_default("127.0.0.1:8080")); + assert!(has_default("http://127.0.0.1:8080/")); + + assert!(!has_default("example.com")); // Suffix-injection guard: hostname containing "prefix.dev" must not match. - assert!(!host_supports_default_oauth("evil-prefix.dev.attacker.com")); - assert!(!host_supports_default_oauth("notprefix.dev")); + assert!(!has_default("evil-prefix.dev.attacker.com")); + assert!(!has_default("notprefix.dev")); // Loopback-spoofing guard: hostnames *containing* "localhost" must not match. - assert!(!host_supports_default_oauth("localhost.attacker.com")); - assert!(!host_supports_default_oauth("notlocalhost")); + assert!(!has_default("localhost.attacker.com")); + assert!(!has_default("notlocalhost")); + + // Returned config carries the right scheme + client_id for each family. + let prefix = default_oauth_config_for_host("prefix.dev").unwrap(); + assert_eq!(prefix.issuer_url, "https://prefix.dev"); + assert_eq!(prefix.client_id, "rattler"); + assert!(!prefix.scopes.is_empty()); + + let local = default_oauth_config_for_host("localhost:8080").unwrap(); + assert_eq!(local.issuer_url, "http://localhost:8080"); } #[cfg(feature = "oauth")] diff --git a/crates/rattler/src/cli/auth/oauth.rs b/crates/rattler/src/cli/auth/oauth.rs index 39c6dba348..4797638279 100644 --- a/crates/rattler/src/cli/auth/oauth.rs +++ b/crates/rattler/src/cli/auth/oauth.rs @@ -59,8 +59,8 @@ type ExtendedCoreProviderMetadata = ProviderMetadata< /// Mirrors the server's "Full access" channel-access preset so an /// out-of-the-box `rattler auth login` grants the full set of channel /// capabilities (create, read, upload, yank, delete, settings, member -/// management, lifecycle) plus identity (openid/profile) and refresh -/// tokens (offline_access). +/// management, lifecycle) plus identity (`openid`/`profile`) and refresh +/// tokens (`offline_access`). pub const DEFAULT_OAUTH_SCOPES: &[&str] = &[ "openid", "profile", diff --git a/crates/rattler_networking/src/oauth_refresh.rs b/crates/rattler_networking/src/oauth_refresh.rs index 8a8b2e5ba3..2a21d44320 100644 --- a/crates/rattler_networking/src/oauth_refresh.rs +++ b/crates/rattler_networking/src/oauth_refresh.rs @@ -1,14 +1,10 @@ //! Refresh logic for `Authentication::OAuth` credentials. -//! -//! This module is independent of the `reqwest` middleware so that callers -//! that don't go through the middleware (such as upload code paths) can -//! still get auto-refreshed OAuth tokens. use serde::Deserialize; use crate::{Authentication, AuthenticationStorage}; -/// Standard OAuth 2.0 token response (RFC 6749 §5.1). +/// Standard OAuth 2.0 token response. #[derive(Deserialize)] struct TokenRefreshResponse { access_token: String, @@ -17,19 +13,11 @@ struct TokenRefreshResponse { } /// Number of seconds before `expires_at` at which a token is considered -/// "about to expire" — gives some headroom for clock skew and request -/// in-flight time. +/// "about to expire" const EXPIRY_SKEW_SECONDS: i64 = 300; /// Refresh an OAuth token if it is expired (or close to expiring) and /// store the refreshed credential back to `storage`. -/// -/// Returns the (possibly refreshed) authentication. If refresh fails, -/// returns the original auth so the caller can proceed with the existing -/// (possibly expired) token — the server's 401 is clearer than a -/// middleware error. -/// -/// For non-OAuth credentials this is a pass-through. pub async fn maybe_refresh_oauth( storage: &AuthenticationStorage, auth: Authentication, diff --git a/crates/rattler_upload/src/upload/prefix.rs b/crates/rattler_upload/src/upload/prefix.rs index 534e26c553..f016ceda6a 100644 --- a/crates/rattler_upload/src/upload/prefix.rs +++ b/crates/rattler_upload/src/upload/prefix.rs @@ -62,7 +62,7 @@ pub enum PrefixUploadError { AttestationWithApiKey, /// The server returned an authentication error (HTTP 401 or 403). - #[error("authentication failed (HTTP {status}): {body}")] + #[error("authentication failed (HTTP {status})")] AuthenticationFailed { /// The HTTP status code. status: u16, @@ -165,10 +165,6 @@ async fn create_upload_form( /// Look up a bearer-style token for `url` from `storage`, automatically /// refreshing OAuth tokens whose access token is close to expiring. -/// -/// Returns the raw token string suitable for use in an `Authorization: -/// Bearer ...` header. Both `BearerToken` and the access token portion of -/// an `OAuth` credential are accepted. async fn fetch_token_from_storage( storage: &AuthenticationStorage, url: &Url, From b0e8c7f74362d7a2a6669f888407144bb233e5b2 Mon Sep 17 00:00:00 2001 From: nichmor Date: Wed, 6 May 2026 15:14:39 +0300 Subject: [PATCH 5/9] misc: push the default scopes for rattler --- crates/rattler/src/cli/auth.rs | 202 +++++++++++------- crates/rattler/src/cli/auth/oauth.rs | 25 +-- .../src/authentication_storage/storage.rs | 5 + 3 files changed, 136 insertions(+), 96 deletions(-) diff --git a/crates/rattler/src/cli/auth.rs b/crates/rattler/src/cli/auth.rs index e576a921c0..3d354b1704 100644 --- a/crates/rattler/src/cli/auth.rs +++ b/crates/rattler/src/cli/auth.rs @@ -172,9 +172,8 @@ fn normalize_login_host(host: &str) -> String { let host = host.trim_start_matches("*."); // Try parsing as-is first (handles inputs like `https://prefix.dev`). - // We only accept the result if it actually yielded a hostname — `Url` - // happily parses `localhost:8080` as a `localhost`-scheme URL with no - // host, so we have to check `host_str()` rather than just parse success. + // Only accept the result if it actually yielded a hostname — not every + // parse-successful string contains a host component. if let Some(h) = url::Url::parse(host) .ok() .and_then(|u| u.host_str().map(str::to_string)) @@ -190,6 +189,27 @@ fn normalize_login_host(host: &str) -> String { .unwrap_or_else(|| host.trim_end_matches('/').to_string()) } +/// prefix.dev's "Full access" channel-access scopes plus standard identity +/// and refresh-token scopes. +#[cfg(feature = "oauth")] +const PREFIX_DEV_OAUTH_SCOPES: &[&str] = &[ + "openid", + "profile", + "offline_access", + "channel:create", + "channel:read", + "channel:upload", + "channel:yank", + "channel:delete-package", + "channel:settings", + "channel:members", + "channel:lifecycle", +]; + +/// anaconda.org's default OIDC scopes. +#[cfg(feature = "oauth")] +const ANACONDA_OAUTH_SCOPES: &[&str] = &["openid", "email", "profile", "offline_access"]; + /// Built-in OAuth defaults for a known host. /// /// Returned by [`default_oauth_config_for_host`] for hosts where rattler @@ -204,47 +224,56 @@ struct DefaultOAuthConfig { /// Returns the built-in OAuth configuration for a host, if rattler ships one. /// -/// Currently recognizes the prefix.dev family (over `https`) and loopback -/// addresses (over `http`, since local dev servers rarely terminate TLS). -/// Returns `None` for any other host. +/// Currently recognizes: +/// * the prefix.dev family (over `https`) — full channel-access scopes +/// * the anaconda.org family (over `https`) — standard OIDC + `email` +/// * loopback addresses (over `http`, since local dev servers rarely +/// terminate TLS) — treated as a local prefix.dev-style server /// -/// The presence of a returned config is also the signal for whether rattler -/// should default to OAuth when the user provides no auth method at all — -/// see [`should_default_to_oauth`]. +/// Returns `None` for any other host. #[cfg(feature = "oauth")] fn default_oauth_config_for_host(host: &str) -> Option { let normalized = normalize_login_host(host); let is_prefix_dev = normalized == "prefix.dev" || normalized.ends_with(".prefix.dev"); + let is_anaconda = normalized == "anaconda.org" || normalized.ends_with(".anaconda.org"); let is_loopback = normalized == "localhost" || normalized == "127.0.0.1" || normalized == "[::1]"; - if !is_prefix_dev && !is_loopback { + let scopes: &[&str] = if is_anaconda { + ANACONDA_OAUTH_SCOPES + } else if is_prefix_dev || is_loopback { + PREFIX_DEV_OAUTH_SCOPES + } else { return None; - } + }; let scheme = if is_loopback { "http" } else { "https" }; Some(DefaultOAuthConfig { issuer_url: format!("{scheme}://{host}"), client_id: "rattler".to_string(), - scopes: oauth::DEFAULT_OAUTH_SCOPES - .iter() - .map(|&s| s.to_string()) - .collect(), + scopes: scopes.iter().map(|&s| s.to_string()).collect(), }) } -/// Returns true when the user passed no explicit auth method, so we should -/// fall back to OAuth for hosts that ship a built-in OAuth config. +/// Returns the built-in OAuth config for an implicit (flag-less) login — +/// i.e. when the user passed no explicit auth method and the host ships +/// an out-of-the-box OAuth configuration. The presence of `Some` is the +/// signal that `login()` should fall back to OAuth. #[cfg(feature = "oauth")] -fn should_default_to_oauth(args: &LoginArgs) -> bool { +fn default_oauth_for_login(args: &LoginArgs) -> Option { let no_explicit_method = args.token.is_none() && args.username.is_none() && args.password.is_none() && args.conda_token.is_none() && args.s3_access_key_id.is_none(); - no_explicit_method && default_oauth_config_for_host(&args.host).is_some() + + if !no_explicit_method { + return None; + } + + default_oauth_config_for_host(&args.host) } fn get_url(url: &str) -> Result { @@ -285,59 +314,63 @@ async fn login( ) -> Result<(), AuthenticationCLIError> { // explicit `--oauth` *or* no explicit method on an OAuth-capable host #[cfg(feature = "oauth")] - if args.oauth || should_default_to_oauth(&args) { - if !args.oauth { - eprintln!( - "No credentials provided; using OAuth browser login for {}.", - args.host - ); - } - - // Look up the host's built-in OAuth defaults, if any. - let host_default = default_oauth_config_for_host(&args.host); - - let issuer_url = args - .oauth_issuer_url - .or_else(|| host_default.as_ref().map(|c| c.issuer_url.clone())) - .unwrap_or_else(|| format!("https://{}", args.host)); - - let client_id = args - .oauth_client_id - .or_else(|| host_default.as_ref().map(|c| c.client_id.clone())) - .unwrap_or_else(|| "rattler".to_string()); - - let flow = match args.oauth_flow.as_deref() { - Some("auth-code") => oauth::OAuthFlow::AuthCode, - Some("device-code") => oauth::OAuthFlow::DeviceCode, - _ => oauth::OAuthFlow::Auto, - }; - - let scopes: std::collections::HashSet = if !args.oauth_scopes.is_empty() { - args.oauth_scopes.into_iter().collect() - } else if let Some(default) = host_default { - default.scopes.into_iter().collect() - } else { - oauth::DEFAULT_OAUTH_SCOPES - .iter() - .map(|&s| s.to_string()) - .collect() - }; - - let config = oauth::OAuthConfig { - issuer_url, - client_id, - client_secret: args.oauth_client_secret, - flow, - scopes, - }; + { + let auto_default = default_oauth_for_login(&args); + if args.oauth || auto_default.is_some() { + if !args.oauth { + eprintln!( + "No credentials provided; using OAuth browser login for {}.", + args.host + ); + } - let auth = oauth::perform_oauth_login(config).await?; - // Normalize the host so that `prefix.dev` and `prefix.dev/` (and - // any `https://...` form) write to the same storage key - let host = normalize_login_host(&args.host); - storage.store(&host, &auth)?; - eprintln!("Credentials stored for {host}."); - return Ok(()); + // Reuse the implicit-default config when present; otherwise + // (`--oauth` was set explicitly) fall back to a fresh lookup. + let host_default = auto_default.or_else(|| default_oauth_config_for_host(&args.host)); + + let issuer_url = args + .oauth_issuer_url + .or_else(|| host_default.as_ref().map(|c| c.issuer_url.clone())) + .unwrap_or_else(|| format!("https://{}", args.host)); + + let client_id = args + .oauth_client_id + .or_else(|| host_default.as_ref().map(|c| c.client_id.clone())) + .unwrap_or_else(|| "rattler".to_string()); + + let flow = match args.oauth_flow.as_deref() { + Some("auth-code") => oauth::OAuthFlow::AuthCode, + Some("device-code") => oauth::OAuthFlow::DeviceCode, + _ => oauth::OAuthFlow::Auto, + }; + + let scopes: std::collections::HashSet = if !args.oauth_scopes.is_empty() { + args.oauth_scopes.into_iter().collect() + } else if let Some(default) = host_default { + default.scopes.into_iter().collect() + } else { + oauth::DEFAULT_OAUTH_SCOPES + .iter() + .map(|&s| s.to_string()) + .collect() + }; + + let config = oauth::OAuthConfig { + issuer_url, + client_id, + client_secret: args.oauth_client_secret, + flow, + scopes, + }; + + let auth = oauth::perform_oauth_login(config).await?; + // Normalize the host so that `prefix.dev` and `prefix.dev/` (and + // any `https://...` form) write to the same storage key + let host = normalize_login_host(&args.host); + storage.store(&host, &auth)?; + eprintln!("Credentials stored for {host}."); + return Ok(()); + } } let auth = if let Some(conda_token) = args.conda_token { @@ -776,11 +809,23 @@ mod tests { assert!(!has_default("localhost.attacker.com")); assert!(!has_default("notlocalhost")); + // anaconda.org family is recognized too. + assert!(has_default("anaconda.org")); + assert!(has_default("repo.anaconda.org")); + assert!(has_default("https://anaconda.org/")); + // Suffix-injection guard. + assert!(!has_default("notanaconda.org")); + // Returned config carries the right scheme + client_id for each family. let prefix = default_oauth_config_for_host("prefix.dev").unwrap(); assert_eq!(prefix.issuer_url, "https://prefix.dev"); assert_eq!(prefix.client_id, "rattler"); - assert!(!prefix.scopes.is_empty()); + assert!(prefix.scopes.iter().any(|s| s == "channel:upload")); + + let anaconda = default_oauth_config_for_host("anaconda.org").unwrap(); + assert_eq!(anaconda.issuer_url, "https://anaconda.org"); + assert!(anaconda.scopes.iter().any(|s| s == "email")); + assert!(!anaconda.scopes.iter().any(|s| s.starts_with("channel:"))); let local = default_oauth_config_for_host("localhost:8080").unwrap(); assert_eq!(local.issuer_url, "http://localhost:8080"); @@ -788,17 +833,20 @@ mod tests { #[cfg(feature = "oauth")] #[test] - fn test_should_default_to_oauth() { - // No explicit method on prefix.dev → OAuth - assert!(should_default_to_oauth(&create_login_args("prefix.dev"))); + fn test_default_oauth_for_login() { + // No explicit method on prefix.dev → OAuth default kicks in + assert!(default_oauth_for_login(&create_login_args("prefix.dev")).is_some()); + + // anaconda.org now also has built-in defaults. + assert!(default_oauth_for_login(&create_login_args("anaconda.org")).is_some()); // Explicit method blocks the OAuth default, even on prefix.dev. let mut args = create_login_args("prefix.dev"); args.token = Some("t".into()); - assert!(!should_default_to_oauth(&args)); + assert!(default_oauth_for_login(&args).is_none()); // No explicit method on a non-OAuth host → still falls through to existing // NoAuthenticationMethod error. - assert!(!should_default_to_oauth(&create_login_args("example.com"))); + assert!(default_oauth_for_login(&create_login_args("example.com")).is_none()); } } diff --git a/crates/rattler/src/cli/auth/oauth.rs b/crates/rattler/src/cli/auth/oauth.rs index 4797638279..54fb05166d 100644 --- a/crates/rattler/src/cli/auth/oauth.rs +++ b/crates/rattler/src/cli/auth/oauth.rs @@ -54,26 +54,13 @@ type ExtendedCoreProviderMetadata = ProviderMetadata< CoreSubjectIdentifierType, >; -/// Default OAuth scopes used when the caller passes none. +/// Generic OIDC scopes used when no host-specific defaults apply. /// -/// Mirrors the server's "Full access" channel-access preset so an -/// out-of-the-box `rattler auth login` grants the full set of channel -/// capabilities (create, read, upload, yank, delete, settings, member -/// management, lifecycle) plus identity (`openid`/`profile`) and refresh -/// tokens (`offline_access`). -pub const DEFAULT_OAUTH_SCOPES: &[&str] = &[ - "openid", - "profile", - "offline_access", - "channel:create", - "channel:read", - "channel:upload", - "channel:yank", - "channel:delete-package", - "channel:settings", - "channel:members", - "channel:lifecycle", -]; +/// Limited to the standard identity scopes (`openid`/`profile`) and +/// `offline_access` for refresh tokens. Provider-specific scopes +/// (e.g. prefix.dev's `channel:*` or anaconda.org's `email`) live in the +/// per-host config tables in `auth.rs`. +pub const DEFAULT_OAUTH_SCOPES: &[&str] = &["openid", "profile", "offline_access"]; /// Configuration for an OAuth login flow. pub struct OAuthConfig { diff --git a/crates/rattler_networking/src/authentication_storage/storage.rs b/crates/rattler_networking/src/authentication_storage/storage.rs index 1dc234880f..09d0010e40 100644 --- a/crates/rattler_networking/src/authentication_storage/storage.rs +++ b/crates/rattler_networking/src/authentication_storage/storage.rs @@ -247,12 +247,17 @@ impl AuthenticationStorage { /// expired OAuth access tokens via the provider's token endpoint /// before returning. Refreshed credentials are written back to the /// storage so subsequent calls see the new token. + /// + /// Non-OAuth credentials (bearer tokens, basic auth, S3, etc.) are + /// returned unchanged. pub async fn get_by_url_refreshed( &self, url: U, ) -> Result<(Url, Option), reqwest::Error> { let (url, auth_with_key) = self.get_by_url_with_host(url)?; let auth = match auth_with_key { + // `maybe_refresh_oauth` is a no-op for non-OAuth variants and + // returns them as-is, so this branch covers every auth type. Some((matched_key, auth)) => { crate::oauth_refresh::maybe_refresh_oauth(self, auth, &matched_key).await } From 555f5e43a706a966401af1ece419bc776c982fc5 Mon Sep 17 00:00:00 2001 From: nichmor Date: Wed, 6 May 2026 15:35:39 +0300 Subject: [PATCH 6/9] misc: change the default scopes --- crates/rattler/src/cli/auth.rs | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/crates/rattler/src/cli/auth.rs b/crates/rattler/src/cli/auth.rs index 3d354b1704..e6b17e528f 100644 --- a/crates/rattler/src/cli/auth.rs +++ b/crates/rattler/src/cli/auth.rs @@ -189,21 +189,27 @@ fn normalize_login_host(host: &str) -> String { .unwrap_or_else(|| host.trim_end_matches('/').to_string()) } -/// prefix.dev's "Full access" channel-access scopes plus standard identity -/// and refresh-token scopes. +/// prefix.dev's default channel scopes for an out-of-the-box `rattler auth +/// login`. +/// +/// Limited to the two non-destructive channel operations a typical user +/// actually performs from the CLI: browsing channels (`channel:read`) and +/// pushing packages (`channel:upload`). Destructive scopes (yank, delete, +/// settings, member management, lifecycle) are intentionally **not** +/// included — least-privilege default — and are available on demand via +/// `--oauth-scope channel:yank`, etc. +/// +/// Identity scopes (`openid`, `profile`) and `offline_access` (refresh +/// tokens) are kept because they enable orthogonal features: ID-token +/// claims drive the "Authenticated as: ..." line on login, and +/// `offline_access` is what makes the refresh-on-use machinery work at all. #[cfg(feature = "oauth")] const PREFIX_DEV_OAUTH_SCOPES: &[&str] = &[ "openid", "profile", "offline_access", - "channel:create", "channel:read", "channel:upload", - "channel:yank", - "channel:delete-package", - "channel:settings", - "channel:members", - "channel:lifecycle", ]; /// anaconda.org's default OIDC scopes. From 09e53bd74a373ff4321a54c0e61ef2362951379c Mon Sep 17 00:00:00 2001 From: nichmor Date: Wed, 6 May 2026 16:05:49 +0300 Subject: [PATCH 7/9] misc: remove comments --- crates/rattler/src/cli/auth.rs | 23 +--------- .../src/authentication_storage/storage.rs | 43 +++++++++++++++++++ 2 files changed, 44 insertions(+), 22 deletions(-) diff --git a/crates/rattler/src/cli/auth.rs b/crates/rattler/src/cli/auth.rs index e6b17e528f..1956a31e81 100644 --- a/crates/rattler/src/cli/auth.rs +++ b/crates/rattler/src/cli/auth.rs @@ -189,20 +189,7 @@ fn normalize_login_host(host: &str) -> String { .unwrap_or_else(|| host.trim_end_matches('/').to_string()) } -/// prefix.dev's default channel scopes for an out-of-the-box `rattler auth -/// login`. -/// -/// Limited to the two non-destructive channel operations a typical user -/// actually performs from the CLI: browsing channels (`channel:read`) and -/// pushing packages (`channel:upload`). Destructive scopes (yank, delete, -/// settings, member management, lifecycle) are intentionally **not** -/// included — least-privilege default — and are available on demand via -/// `--oauth-scope channel:yank`, etc. -/// -/// Identity scopes (`openid`, `profile`) and `offline_access` (refresh -/// tokens) are kept because they enable orthogonal features: ID-token -/// claims drive the "Authenticated as: ..." line on login, and -/// `offline_access` is what makes the refresh-on-use machinery work at all. +/// prefix.dev's default channel scopes #[cfg(feature = "oauth")] const PREFIX_DEV_OAUTH_SCOPES: &[&str] = &[ "openid", @@ -229,14 +216,6 @@ struct DefaultOAuthConfig { } /// Returns the built-in OAuth configuration for a host, if rattler ships one. -/// -/// Currently recognizes: -/// * the prefix.dev family (over `https`) — full channel-access scopes -/// * the anaconda.org family (over `https`) — standard OIDC + `email` -/// * loopback addresses (over `http`, since local dev servers rarely -/// terminate TLS) — treated as a local prefix.dev-style server -/// -/// Returns `None` for any other host. #[cfg(feature = "oauth")] fn default_oauth_config_for_host(host: &str) -> Option { let normalized = normalize_login_host(host); diff --git a/crates/rattler_networking/src/authentication_storage/storage.rs b/crates/rattler_networking/src/authentication_storage/storage.rs index 09d0010e40..9a43342593 100644 --- a/crates/rattler_networking/src/authentication_storage/storage.rs +++ b/crates/rattler_networking/src/authentication_storage/storage.rs @@ -299,3 +299,46 @@ impl AuthenticationStorage { } } } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + use crate::authentication_storage::backends::memory::MemoryStorage; + + fn storage_with(host: &str, auth: Authentication) -> AuthenticationStorage { + let mut storage = AuthenticationStorage::empty(); + storage.add_backend(Arc::new(MemoryStorage::new())); + storage.store(host, &auth).unwrap(); + storage + } + + /// Non-OAuth credentials must pass through `get_by_url_refreshed` + /// unchanged — the refresh path only applies to OAuth. + #[tokio::test] + async fn get_by_url_refreshed_passes_through_non_oauth() { + let cases = [ + Authentication::BearerToken("bearer".into()), + Authentication::CondaToken("conda".into()), + Authentication::BasicHTTP { + username: "u".into(), + password: "p".into(), + }, + Authentication::S3Credentials { + access_key_id: "k".into(), + secret_access_key: "s".into(), + session_token: None, + }, + ]; + + for auth in cases { + let storage = storage_with("example.com", auth.clone()); + let (_, retrieved) = storage + .get_by_url_refreshed("https://example.com/foo") + .await + .unwrap(); + assert_eq!(retrieved, Some(auth)); + } + } +} From 0322a09fbe867366a57651406f9623e3f6bd4641 Mon Sep 17 00:00:00 2001 From: nichmor Date: Thu, 7 May 2026 10:00:43 +0300 Subject: [PATCH 8/9] misc: add anaconda part --- crates/rattler/src/cli/auth.rs | 87 +++++++++++++++----- crates/rattler/src/cli/auth/oauth.rs | 119 +++++++++++++++++++++++---- 2 files changed, 170 insertions(+), 36 deletions(-) diff --git a/crates/rattler/src/cli/auth.rs b/crates/rattler/src/cli/auth.rs index 1956a31e81..d19ad6f158 100644 --- a/crates/rattler/src/cli/auth.rs +++ b/crates/rattler/src/cli/auth.rs @@ -82,6 +82,19 @@ struct LoginArgs { help_heading = "OAuth/OIDC Authentication" )] oauth_scopes: Vec, + + /// OAuth redirect URI (defaults to a random localhost port). Set + /// this when the OAuth client on the `IdP` side is registered with + /// a specific redirect URI such as `http://127.0.0.1:8000/auth/oidc`. + #[cfg(feature = "oauth")] + #[clap(long, requires = "oauth", help_heading = "OAuth/OIDC Authentication")] + oauth_redirect_uri: Option, + + /// User-Agent header sent to the OAuth provider (defaults to + /// `rattler/`) + #[cfg(feature = "oauth")] + #[clap(long, requires = "oauth", help_heading = "OAuth/OIDC Authentication")] + oauth_user_agent: Option, } #[derive(Parser, Debug)] @@ -213,6 +226,7 @@ struct DefaultOAuthConfig { issuer_url: String, client_id: String, scopes: Vec, + redirect_uri: Option, } /// Returns the built-in OAuth configuration for a host, if rattler ships one. @@ -220,25 +234,35 @@ struct DefaultOAuthConfig { fn default_oauth_config_for_host(host: &str) -> Option { let normalized = normalize_login_host(host); - let is_prefix_dev = normalized == "prefix.dev" || normalized.ends_with(".prefix.dev"); - let is_anaconda = normalized == "anaconda.org" || normalized.ends_with(".anaconda.org"); - let is_loopback = - normalized == "localhost" || normalized == "127.0.0.1" || normalized == "[::1]"; + // anaconda.com use a different identity provider at a + // different subdomain (`auth.anaconda.com`) and registers a specific + // client + redirect URI on the IdP side, so all four fields are + // hard-coded rather than derived from the input host. + if normalized == "anaconda.com" || normalized.ends_with(".anaconda.com") { + return Some(DefaultOAuthConfig { + issuer_url: "https://auth.anaconda.com/api/auth".to_string(), + client_id: "b4ad7f1d-c784-46b5-a9fe-106e50441f5a".to_string(), + scopes: ANACONDA_OAUTH_SCOPES + .iter() + .map(|&s| s.to_string()) + .collect(), + redirect_uri: Some("http://127.0.0.1:8000/auth/oidc".to_string()), + }); + } - let scopes: &[&str] = if is_anaconda { + let scopes: &[&str] = if normalized == "anaconda.org" || normalized.ends_with(".anaconda.org") { ANACONDA_OAUTH_SCOPES - } else if is_prefix_dev || is_loopback { + } else if normalized == "prefix.dev" || normalized.ends_with(".prefix.dev") { PREFIX_DEV_OAUTH_SCOPES } else { return None; }; - let scheme = if is_loopback { "http" } else { "https" }; - Some(DefaultOAuthConfig { - issuer_url: format!("{scheme}://{host}"), + issuer_url: format!("https://{host}"), client_id: "rattler".to_string(), scopes: scopes.iter().map(|&s| s.to_string()).collect(), + redirect_uri: None, }) } @@ -329,6 +353,10 @@ async fn login( _ => oauth::OAuthFlow::Auto, }; + let redirect_uri = args + .oauth_redirect_uri + .or_else(|| host_default.as_ref().and_then(|c| c.redirect_uri.clone())); + let scopes: std::collections::HashSet = if !args.oauth_scopes.is_empty() { args.oauth_scopes.into_iter().collect() } else if let Some(default) = host_default { @@ -346,6 +374,8 @@ async fn login( client_secret: args.oauth_client_secret, flow, scopes, + redirect_uri, + user_agent: args.oauth_user_agent, }; let auth = oauth::perform_oauth_login(config).await?; @@ -573,6 +603,10 @@ mod tests { oauth_flow: None, #[cfg(feature = "oauth")] oauth_scopes: vec![], + #[cfg(feature = "oauth")] + oauth_redirect_uri: None, + #[cfg(feature = "oauth")] + oauth_user_agent: None, } } @@ -777,22 +811,17 @@ mod tests { assert!(has_default("https://prefix.dev/")); assert!(has_default("https://repo.prefix.dev/")); - // Loopback addresses for local development. The normalization step - // strips the port so `localhost:8080` collapses to `localhost`. - assert!(has_default("localhost")); - assert!(has_default("localhost:8080")); - assert!(has_default("http://localhost:8080")); - assert!(has_default("127.0.0.1")); - assert!(has_default("127.0.0.1:8080")); - assert!(has_default("http://127.0.0.1:8080/")); + // Loopback addresses are not auto-recognized: local dev servers + // could be running anything, so the user passes `--oauth` and + // their own `--oauth-scope` flags explicitly. + assert!(!has_default("localhost")); + assert!(!has_default("localhost:8080")); + assert!(!has_default("127.0.0.1")); assert!(!has_default("example.com")); // Suffix-injection guard: hostname containing "prefix.dev" must not match. assert!(!has_default("evil-prefix.dev.attacker.com")); assert!(!has_default("notprefix.dev")); - // Loopback-spoofing guard: hostnames *containing* "localhost" must not match. - assert!(!has_default("localhost.attacker.com")); - assert!(!has_default("notlocalhost")); // anaconda.org family is recognized too. assert!(has_default("anaconda.org")); @@ -812,8 +841,22 @@ mod tests { assert!(anaconda.scopes.iter().any(|s| s == "email")); assert!(!anaconda.scopes.iter().any(|s| s.starts_with("channel:"))); - let local = default_oauth_config_for_host("localhost:8080").unwrap(); - assert_eq!(local.issuer_url, "http://localhost:8080"); + // anaconda.com routes to the auth.anaconda.com identity provider + // and uses a specific registered client + redirect URI. + assert!(has_default("anaconda.com")); + assert!(has_default("repo.anaconda.com")); + assert!(!has_default("notanaconda.com")); + + let anaconda_com = default_oauth_config_for_host("anaconda.com").unwrap(); + assert_eq!( + anaconda_com.issuer_url, + "https://auth.anaconda.com/api/auth" + ); + assert_eq!( + anaconda_com.redirect_uri.as_deref(), + Some("http://127.0.0.1:8000/auth/oidc") + ); + assert_ne!(anaconda_com.client_id, "rattler"); } #[cfg(feature = "oauth")] diff --git a/crates/rattler/src/cli/auth/oauth.rs b/crates/rattler/src/cli/auth/oauth.rs index 54fb05166d..2d86ec7bcb 100644 --- a/crates/rattler/src/cli/auth/oauth.rs +++ b/crates/rattler/src/cli/auth/oauth.rs @@ -17,8 +17,8 @@ use openidconnect::{ CoreResponseType, CoreSubjectIdentifierType, }, AdditionalProviderMetadata, AuthorizationCode, ClientId, ClientSecret, CsrfToken, - DeviceAuthorizationUrl, IssuerUrl, Nonce, OAuth2TokenResponse, PkceCodeChallenge, - ProviderMetadata, RedirectUrl, Scope, TokenResponse, + DeviceAuthorizationUrl, IssuerUrl, Nonce, OAuth2TokenResponse, + PkceCodeChallenge, ProviderMetadata, RedirectUrl, Scope, TokenResponse, }; use rattler_networking::Authentication; use serde::{Deserialize, Serialize}; @@ -55,11 +55,6 @@ type ExtendedCoreProviderMetadata = ProviderMetadata< >; /// Generic OIDC scopes used when no host-specific defaults apply. -/// -/// Limited to the standard identity scopes (`openid`/`profile`) and -/// `offline_access` for refresh tokens. Provider-specific scopes -/// (e.g. prefix.dev's `channel:*` or anaconda.org's `email`) live in the -/// per-host config tables in `auth.rs`. pub const DEFAULT_OAUTH_SCOPES: &[&str] = &["openid", "profile", "offline_access"]; /// Configuration for an OAuth login flow. @@ -74,6 +69,13 @@ pub struct OAuthConfig { pub flow: OAuthFlow, /// Additional OAuth scopes to request. pub scopes: HashSet, + /// Fixed redirect URI for the auth-code flow. When `None`, rattler + /// binds to a random localhost port. Required when the OAuth client + /// is registered with a specific redirect URI on the `IdP` side. + pub redirect_uri: Option, + /// Override for the User-Agent header. When `None`, defaults to + /// `rattler/`. + pub user_agent: Option, } /// Which OAuth flow to attempt. @@ -165,8 +167,14 @@ pub async fn perform_oauth_login(config: OAuthConfig) -> Result Result Result Result Result { + let discovery_url = format!( + "{}/.well-known/openid-configuration", + issuer.url().as_str().trim_end_matches('/'), + ); + + let bytes = http_client + .get(&discovery_url) + .send() + .await? + .error_for_status()? + .bytes() + .await?; + + let mut value: serde_json::Value = serde_json::from_slice(&bytes) + .map_err(|e| OAuthError::Discovery(format!("invalid discovery JSON: {e}")))?; + + if let Some(obj) = value.as_object_mut() { + obj.entry("subject_types_supported") + .or_insert_with(|| serde_json::json!(["public"])); + obj.entry("id_token_signing_alg_values_supported") + .or_insert_with(|| serde_json::json!(["RS256"])); + } + + let metadata: ExtendedCoreProviderMetadata = serde_json::from_value(value) + .map_err(|e| OAuthError::Discovery(format!("invalid discovery document: {e}")))?; + + // Mirror the strict path's issuer-claim check: the discovery + // document's `issuer` field must match the URL we discovered from. + // Without this, a malicious server could impersonate someone else's + // issuer through a non-compliant discovery doc. + if metadata.issuer() != issuer { + return Err(OAuthError::Discovery(format!( + "issuer claim {} does not match requested issuer {}", + metadata.issuer().as_str(), + issuer.as_str(), + ))); + } + + Ok(metadata) +} + /// Authorization code flow with PKCE. /// /// 1. Binds a local TCP listener for the redirect @@ -297,12 +373,27 @@ async fn auth_code_flow( client_id: &str, client_secret: Option<&str>, scopes: &HashSet, + redirect_uri: Option<&str>, http_client: &reqwest::Client, ) -> Result { - // Bind to a random port on localhost - let listener = TcpListener::bind("127.0.0.1:0").await?; - let local_addr = listener.local_addr()?; - let redirect_url = format!("http://127.0.0.1:{}", local_addr.port()); + // If the caller pinned a redirect URI (because the IdP requires an + // exact match against what was registered), bind there. Otherwise + // pick a random localhost port and use that. + let (listener, redirect_url) = if let Some(uri) = redirect_uri { + let parsed = Url::parse(uri).map_err(OAuthError::UrlParse)?; + let host = parsed + .host_str() + .ok_or_else(|| OAuthError::Authorization(format!("redirect URI has no host: {uri}")))?; + let port = parsed.port().ok_or_else(|| { + OAuthError::Authorization(format!("redirect URI has no explicit port: {uri}")) + })?; + let listener = TcpListener::bind(format!("{host}:{port}")).await?; + (listener, uri.to_string()) + } else { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let local_addr = listener.local_addr()?; + (listener, format!("http://127.0.0.1:{}", local_addr.port())) + }; let mut client = CoreClient::from_provider_metadata( endpoints.provider_metadata.clone(), From 482449d9f12aadea6b024ff30a3383cfc524a288 Mon Sep 17 00:00:00 2001 From: nichmor Date: Thu, 7 May 2026 10:19:55 +0300 Subject: [PATCH 9/9] feat: add oauth_upload example for manual testing --- .../rattler_upload/examples/oauth_upload.rs | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 crates/rattler_upload/examples/oauth_upload.rs diff --git a/crates/rattler_upload/examples/oauth_upload.rs b/crates/rattler_upload/examples/oauth_upload.rs new file mode 100644 index 0000000000..99ab9a2678 --- /dev/null +++ b/crates/rattler_upload/examples/oauth_upload.rs @@ -0,0 +1,91 @@ +//! Manual smoke test for the OAuth upload code path. +//! +//! Usage: +//! cargo run -p rattler_upload --example oauth_upload -- \ +//! --host prefix.dev \ +//! --channel my-channel \ +//! path/to/package.conda +//! +//! This reads the credential at `--host` from the configured +//! `AuthenticationStorage` (keyring / `~/.rattler/auth.json` / +//! `$RATTLER_AUTH_FILE`), and uploads the given package via +//! `rattler_upload::upload::upload_package_to_prefix`. If the stored +//! credential is an `Authentication::OAuth { ... }`, the access token +//! is auto-refreshed when expired. +//! +//! Run `rattler auth login --oauth` first to populate the +//! credential. + +use std::path::PathBuf; + +use rattler_networking::AuthenticationStorage; +use rattler_upload::upload::{ + opt::{AttestationSource, ForceOverwrite, PrefixData, SkipExisting}, + upload_package_to_prefix, +}; +use url::Url; + +#[derive(Debug)] +struct Args { + host: String, + channel: String, + package: PathBuf, +} + +fn parse_args() -> Args { + let mut host = None; + let mut channel = None; + let mut package = None; + + let mut iter = std::env::args().skip(1); + while let Some(arg) = iter.next() { + match arg.as_str() { + "--host" => host = iter.next(), + "--channel" => channel = iter.next(), + other if package.is_none() => package = Some(PathBuf::from(other)), + other => panic!("unexpected argument: {other}"), + } + } + + Args { + host: host.expect("--host is required"), + channel: channel.expect("--channel is required"), + package: package.expect("package path is required"), + } +} + +#[tokio::main] +async fn main() { + let args = parse_args(); + eprintln!( + "uploading {:?} to {}/{}", + args.package, args.host, args.channel + ); + + let storage = AuthenticationStorage::from_env_and_defaults() + .expect("failed to construct AuthenticationStorage"); + + let url: Url = format!("https://{}", args.host) + .parse() + .expect("invalid host"); + + let prefix_data = PrefixData::new( + url, + args.channel, + // None = force the upload code to look up the credential from + // storage; this is the path that exercises OAuth. + None, + AttestationSource::NoAttestation, + SkipExisting(false), + ForceOverwrite(false), + false, + ); + + match upload_package_to_prefix(&storage, &vec![args.package], prefix_data).await { + Ok(()) => eprintln!("upload succeeded"), + Err(e) => { + eprintln!("upload failed: {e}"); + std::process::exit(1); + } + } +}