Skip to content
Draft
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
1 change: 1 addition & 0 deletions crates/rattler-bin/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ rustls-tls = [
]
s3 = ["rattler_networking/s3", "rattler_upload/s3"]
gcs = ["rattler_networking/gcs"]
oauth = ["rattler/oauth"]

[dependencies]
anyhow = { workspace = true }
Expand Down
294 changes: 267 additions & 27 deletions crates/rattler/src/cli/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,19 @@ struct LoginArgs {
help_heading = "OAuth/OIDC Authentication"
)]
oauth_scopes: Vec<String>,

/// 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<String>,

/// User-Agent header sent to the OAuth provider (defaults to
/// `rattler/<version>`)
#[cfg(feature = "oauth")]
#[clap(long, requires = "oauth", help_heading = "OAuth/OIDC Authentication")]
oauth_user_agent: Option<String>,
}

#[derive(Parser, Debug)]
Expand Down Expand Up @@ -167,6 +180,111 @@ pub enum AuthenticationCLIError {
OAuthError(#[from] oauth::OAuthError),
}

/// Normalize a user-supplied host into its canonical hostname form.
fn normalize_login_host(host: &str) -> String {
let host = host.trim_start_matches("*.");

// Try parsing as-is first (handles inputs like `https://prefix.dev`).
// 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))
{
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())
}

/// prefix.dev's default channel scopes
#[cfg(feature = "oauth")]
const PREFIX_DEV_OAUTH_SCOPES: &[&str] = &[
"openid",
"profile",
"offline_access",
"channel:read",
"channel:upload",
];

/// 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
/// 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<String>,
redirect_uri: Option<String>,
}

/// Returns the built-in OAuth configuration for a host, if rattler ships one.
#[cfg(feature = "oauth")]
fn default_oauth_config_for_host(host: &str) -> Option<DefaultOAuthConfig> {
let normalized = normalize_login_host(host);

// 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 normalized == "anaconda.org" || normalized.ends_with(".anaconda.org") {
ANACONDA_OAUTH_SCOPES
} else if normalized == "prefix.dev" || normalized.ends_with(".prefix.dev") {
PREFIX_DEV_OAUTH_SCOPES
} else {
return None;
};

Some(DefaultOAuthConfig {
issuer_url: format!("https://{host}"),
client_id: "rattler".to_string(),
scopes: scopes.iter().map(|&s| s.to_string()).collect(),
redirect_uri: None,
})
}

/// 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 default_oauth_for_login(args: &LoginArgs) -> Option<DefaultOAuthConfig> {
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();

if !no_explicit_method {
return None;
}

default_oauth_config_for_host(&args.host)
}

fn get_url(url: &str) -> Result<String, AuthenticationCLIError> {
// parse as url and extract host without scheme or port
let host = if url.contains("://") {
Expand Down Expand Up @@ -203,35 +321,71 @@ 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 {
let issuer_url = args
.oauth_issuer_url
.unwrap_or_else(|| format!("https://{}", args.host));
let client_id = args
.oauth_client_id
.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 config = oauth::OAuthConfig {
issuer_url,
client_id,
client_secret: args.oauth_client_secret,
flow,
scopes: args.oauth_scopes.into_iter().collect(),
};
{
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?;
// OAuth credentials are issuer-specific, skip wildcard conversion
let host = args.host.clone();
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 redirect_uri = args
.oauth_redirect_uri
.or_else(|| host_default.as_ref().and_then(|c| c.redirect_uri.clone()));

let scopes: std::collections::HashSet<String> = 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,
redirect_uri,
user_agent: args.oauth_user_agent,
};

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 {
Expand Down Expand Up @@ -449,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,
}
}

Expand Down Expand Up @@ -637,4 +795,86 @@ mod tests {
let result = login(args, storage).await;
assert!(matches!(result, Err(AuthenticationCLIError::S3BadMethod)));
}

#[cfg(feature = "oauth")]
#[test]
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!(has_default("prefix.dev/"));
assert!(has_default("https://prefix.dev/"));
assert!(has_default("https://repo.prefix.dev/"));

// 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"));

// 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.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:")));

// 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")]
#[test]
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!(default_oauth_for_login(&args).is_none());

// No explicit method on a non-OAuth host → still falls through to existing
// NoAuthenticationMethod error.
assert!(default_oauth_for_login(&create_login_args("example.com")).is_none());
}
}
Loading
Loading