diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-05-16 14:51:10 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-05-16 14:51:10 +0900 |
| commit | 3d5a56f7c7565f0f3f9d858985af1a011b63036f (patch) | |
| tree | 68d805364f0c453fcef038caa094058f651f65aa /crates/shirabe | |
| parent | 78eb6205ccee4b8b0094b6cba7a852bb1b3a9162 (diff) | |
| download | php-shirabe-3d5a56f7c7565f0f3f9d858985af1a011b63036f.tar.gz php-shirabe-3d5a56f7c7565f0f3f9d858985af1a011b63036f.tar.zst php-shirabe-3d5a56f7c7565f0f3f9d858985af1a011b63036f.zip | |
feat(port): port AuthHelper.php
Diffstat (limited to 'crates/shirabe')
| -rw-r--r-- | crates/shirabe/src/util/auth_helper.rs | 736 |
1 files changed, 736 insertions, 0 deletions
diff --git a/crates/shirabe/src/util/auth_helper.rs b/crates/shirabe/src/util/auth_helper.rs index c9035f3..6368e58 100644 --- a/crates/shirabe/src/util/auth_helper.rs +++ b/crates/shirabe/src/util/auth_helper.rs @@ -1 +1,737 @@ //! ref: composer/src/Composer/Util/AuthHelper.php + +use anyhow::Result; +use indexmap::IndexMap; +use shirabe_external_packages::composer::pcre::preg::Preg; +use shirabe_php_shim::{ + base64_encode, explode, in_array, is_array, is_string, json_decode, parse_url, sprintf, + str_replace, strpos, strtolower, substr, trigger_error, trim, PhpMixed, E_USER_DEPRECATED, + PHP_URL_HOST, PHP_URL_PATH, PHP_URL_SCHEME, +}; + +use crate::config::Config; +use crate::downloader::transport_exception::TransportException; +use crate::io::io_interface::IOInterface; +use crate::util::bitbucket::Bitbucket; +use crate::util::github::GitHub; +use crate::util::gitlab::GitLab; + +pub struct AuthHelper { + pub(crate) io: Box<dyn IOInterface>, + pub(crate) config: Config, + /// @var array<string, string> Map of origins to message displayed + displayed_origin_authentications: IndexMap<String, String>, + /// @var array<string, bool> Map of URLs and whether they already retried with authentication from Bitbucket + bitbucket_retry: IndexMap<String, bool>, +} + +#[derive(Debug)] +pub struct PromptAuthResult { + pub retry: bool, + /// @phpstan-var 'prompt'|bool + pub store_auth: StoreAuth, +} + +#[derive(Debug, Clone)] +pub enum StoreAuth { + Bool(bool), + Prompt, +} + +impl AuthHelper { + pub fn new(io: Box<dyn IOInterface>, config: Config) -> Self { + Self { + io, + config, + displayed_origin_authentications: IndexMap::new(), + bitbucket_retry: IndexMap::new(), + } + } + + /// @param 'prompt'|bool $storeAuth + pub fn store_auth(&self, origin: &str, store_auth: StoreAuth) -> Result<()> { + // TODO(phase-b): config.get_auth_config_source() and ConfigSource methods are stubs + let mut store: Option<()> = None; + let config_source = self.config.get_auth_config_source(); + if matches!(store_auth, StoreAuth::Bool(true)) { + store = Some(()); + } else if matches!(store_auth, StoreAuth::Prompt) { + let answer = self.io.ask_and_validate( + format!( + "Do you want to store credentials for {} in {} ? [Yn] ", + origin, + config_source.get_name(), + ), + Box::new(|value: PhpMixed| -> PhpMixed { + let input = strtolower(&substr( + &trim(value.as_string().unwrap_or(""), None), + 0, + Some(1), + )); + if in_array( + PhpMixed::String(input.clone()), + &PhpMixed::List(vec![ + Box::new(PhpMixed::String("y".to_string())), + Box::new(PhpMixed::String("n".to_string())), + ]), + false, + ) { + return PhpMixed::String(input); + } + // PHP: throw new \RuntimeException('Please answer (y)es or (n)o'); + // TODO(phase-b): validator should return a recoverable error rather than panic + panic!("Please answer (y)es or (n)o"); + }), + None, + PhpMixed::String("y".to_string()), + ); + + if answer.as_string() == Some("y") { + store = Some(()); + } + } + if store.is_some() { + config_source.add_config_setting( + &format!("http-basic.{}", origin), + // TODO(phase-b): convert IOInterface auth IndexMap into PhpMixed + todo!("IOInterface.get_authentication(origin) as PhpMixed"), + )?; + } + Ok(()) + } + + /// @param int $statusCode HTTP status code that triggered this call + /// @param string|null $reason a message/description explaining why this was called + /// @param string[] $headers + /// @param int $retryCount the amount of retries already done on this URL + /// @return array containing retry (bool) and storeAuth (string|bool) keys, if retry is true the request should be + /// retried, if storeAuth is true then on a successful retry the authentication should be persisted to auth.json + /// @phpstan-return array{retry: bool, storeAuth: 'prompt'|bool} + pub fn prompt_auth_if_needed( + &mut self, + url: &str, + origin: &str, + status_code: i64, + reason: Option<&str>, + headers: Vec<String>, + retry_count: i64, + response_body: Option<&str>, + ) -> Result<PromptAuthResult> { + let mut store_auth: StoreAuth = StoreAuth::Bool(false); + + let github_domains = self.config.get("github-domains"); + let github_domain_list = match github_domains.as_array() { + Some(arr) => arr.clone(), + None => IndexMap::new(), + }; + let in_github_domains = github_domain_list + .values() + .any(|v| v.as_string() == Some(origin)); + + let gitlab_domains = self.config.get("gitlab-domains"); + let gitlab_domain_list = match gitlab_domains.as_array() { + Some(arr) => arr.clone(), + None => IndexMap::new(), + }; + let in_gitlab_domains = gitlab_domain_list + .values() + .any(|v| v.as_string() == Some(origin)); + + if in_github_domains { + let mut git_hub_util = GitHub::new( + // TODO(phase-b): clone or borrow io/config rather than moving + todo!("io clone"), + todo!("config clone"), + None, + None, + )?; + let mut message = "\n".to_string(); + + let rate_limited = git_hub_util.is_rate_limited(&headers); + let requires_sso = git_hub_util.requires_sso(&headers); + + if requires_sso { + let sso_url = git_hub_util.get_sso_url(&headers); + message = format!( + "GitHub API token requires SSO authorization. Authorize this token at {}\n", + sso_url, + ); + self.io + .write_error(PhpMixed::String(message), true, IOInterface::NORMAL); + if !self.io.is_interactive() { + return Err(TransportException::new( + format!("Could not authenticate against {}", origin), + 403, + ) + .into()); + } + self.io.ask( + "After authorizing your token, confirm that you would like to retry the request" + .to_string(), + PhpMixed::Null, + ); + + return Ok(PromptAuthResult { + retry: true, + store_auth, + }); + } + + if rate_limited { + let rate_limit = git_hub_util.get_rate_limit(&headers); + if self.io.has_authentication(origin) { + message = "Review your configured GitHub OAuth token or enter a new one to go over the API rate limit.".to_string(); + } else { + message = + "Create a GitHub OAuth token to go over the API rate limit.".to_string(); + } + + message = format!( + "{}\n", + sprintf( + &format!( + "GitHub API limit (%d calls/hr) is exhausted, could not fetch {}. {} You can also wait until %s for the rate limit to reset.", + url, message, + ), + &[ + rate_limit.get("limit").cloned().unwrap_or(PhpMixed::Null), + rate_limit.get("reset").cloned().unwrap_or(PhpMixed::Null), + ], + ), + ); + } else { + // Try to extract a more specific error message from GitHub's API response + let mut git_hub_api_message: Option<String> = None; + if let Some(body) = response_body { + let decoded = json_decode(body, true)?; + if is_array(&decoded) { + if let Some(arr) = decoded.as_array() { + if let Some(msg) = arr.get("message") { + if is_string(msg) { + git_hub_api_message = + msg.as_string().map(|s| s.to_string()); + } + } + } + } + } + + if let Some(api_message) = git_hub_api_message { + message.push_str(&format!("Could not fetch {}: {}", url, api_message)); + } else { + message.push_str(&format!("Could not fetch {}, please ", url)); + if self.io.has_authentication(origin) { + message.push_str( + "review your configured GitHub OAuth token or enter a new one to access private repos", + ); + } else { + message.push_str("create a GitHub OAuth token to access private repos"); + } + } + } + + if !git_hub_util.authorize_oauth(origin) + && (!self.io.is_interactive() + || !git_hub_util.authorize_oauth_interactively(origin, &message)) + { + return Err(TransportException::new( + format!("Could not authenticate against {}", origin), + 401, + ) + .into()); + } + } else if in_gitlab_domains { + let message = format!( + "\nCould not fetch {}, enter your {} credentials {}", + url, + origin, + if status_code == 401 { + "to access private repos" + } else { + "to go over the API rate limit" + }, + ); + let mut git_lab_util = GitLab::new( + // TODO(phase-b): clone or borrow io/config rather than moving + todo!("io clone"), + todo!("config clone"), + None, + None, + )?; + + let mut auth: Option<IndexMap<String, Option<String>>> = None; + if self.io.has_authentication(origin) { + auth = Some(self.io.get_authentication(origin)); + let password = auth + .as_ref() + .and_then(|a| a.get("password")) + .and_then(|v| v.clone()) + .unwrap_or_default(); + if in_array( + PhpMixed::String(password), + &PhpMixed::List(vec![ + Box::new(PhpMixed::String("gitlab-ci-token".to_string())), + Box::new(PhpMixed::String("private-token".to_string())), + Box::new(PhpMixed::String("oauth2".to_string())), + ]), + true, + ) { + return Err(TransportException::new( + format!("Invalid credentials for '{}', aborting.", url), + status_code, + ) + .into()); + } + } + + let scheme = parse_url(url, PHP_URL_SCHEME); + if !git_lab_util.authorize_oauth(origin) + && (!self.io.is_interactive() + || !git_lab_util.authorize_oauth_interactively( + scheme.as_string().unwrap_or(""), + origin, + &message, + )) + { + return Err(TransportException::new( + format!("Could not authenticate against {}", origin), + 401, + ) + .into()); + } + + if let Some(prev_auth) = auth { + if self.io.has_authentication(origin) { + let current_auth = self.io.get_authentication(origin); + // TODO(phase-b): IndexMap equality compares all entries by value + if prev_auth == current_auth { + return Err(TransportException::new( + format!("Invalid credentials for '{}', aborting.", url), + status_code, + ) + .into()); + } + } + } + } else if origin == "bitbucket.org" || origin == "api.bitbucket.org" { + let mut ask_for_oauth_token = true; + let origin = "bitbucket.org".to_string(); + if self.io.has_authentication(&origin) { + let auth = self.io.get_authentication(&origin); + let username = auth + .get("username") + .and_then(|v| v.clone()) + .unwrap_or_default(); + if username != "x-token-auth" { + let mut bitbucket_util = Bitbucket::new( + // TODO(phase-b): clone or borrow io/config rather than moving + todo!("io clone"), + todo!("config clone"), + None, + None, + None, + )?; + let password = auth + .get("password") + .and_then(|v| v.clone()) + .unwrap_or_default(); + let access_token = + bitbucket_util.request_token(&origin, &username, &password)?; + if !access_token.is_empty() { + self.io.set_authentication( + origin.clone(), + "x-token-auth".to_string(), + Some(access_token), + ); + ask_for_oauth_token = false; + } + } else if !self.bitbucket_retry.contains_key(url) { + // when multiple requests fire at the same time, they will all fail and the first one resets the token to be correct above but then the others + // reach the code path and without this fallback they would end up throwing below + // see https://github.com/composer/composer/pull/11464 for more details + ask_for_oauth_token = false; + self.bitbucket_retry.insert(url.to_string(), true); + } else { + return Err(TransportException::new( + format!("Could not authenticate against {}", origin), + 401, + ) + .into()); + } + } + + if ask_for_oauth_token { + let message = format!( + "\nCould not fetch {}, please create a bitbucket OAuth token to {}", + url, + if status_code == 401 || status_code == 403 { + "access private repos" + } else { + "go over the API rate limit" + }, + ); + let mut bit_bucket_util = Bitbucket::new( + // TODO(phase-b): clone or borrow io/config rather than moving + todo!("io clone"), + todo!("config clone"), + None, + None, + None, + )?; + if !bit_bucket_util.authorize_oauth(&origin) + && (!self.io.is_interactive() + || !bit_bucket_util.authorize_oauth_interactively(&origin, &message)) + { + return Err(TransportException::new( + format!("Could not authenticate against {}", origin), + 401, + ) + .into()); + } + } + } else { + // 404s are only handled for github + if status_code == 404 { + return Ok(PromptAuthResult { + retry: false, + store_auth: StoreAuth::Bool(false), + }); + } + + // fail if the console is not interactive + if !self.io.is_interactive() { + let message = if status_code == 401 { + format!( + "The '{}' URL required authentication (HTTP 401).\nYou must be using the interactive console to authenticate", + url, + ) + } else if status_code == 403 { + format!( + "The '{}' URL could not be accessed (HTTP 403): {}", + url, + reason.unwrap_or(""), + ) + } else { + format!( + "Unknown error code '{}', reason: {}", + status_code, + reason.unwrap_or(""), + ) + }; + + return Err(TransportException::new(message, status_code).into()); + } + + // fail if we already have auth + if self.io.has_authentication(origin) { + // if two or more requests are started together for the same host, and the first + // received authentication already, we let the others retry before failing them + if retry_count == 0 { + return Ok(PromptAuthResult { + retry: true, + store_auth: StoreAuth::Bool(false), + }); + } + + return Err(TransportException::new( + format!( + "Invalid credentials (HTTP {}) for '{}', aborting.", + status_code, url, + ), + status_code, + ) + .into()); + } + + self.io.write_error( + PhpMixed::String(format!( + " Authentication required (<info>{}</info>):", + origin, + )), + true, + IOInterface::NORMAL, + ); + let username = self.io.ask(" Username: ".to_string(), PhpMixed::Null); + let password = self + .io + .ask_and_hide_answer(" Password: ".to_string()); + self.io.set_authentication( + origin.to_string(), + username.as_string().unwrap_or("").to_string(), + password, + ); + // PHP: $this->config->get('store-auths') returns 'prompt'|bool + // TODO(phase-b): decode the PhpMixed result into StoreAuth + store_auth = match self.config.get("store-auths") { + PhpMixed::Bool(b) => StoreAuth::Bool(b), + PhpMixed::String(ref s) if s == "prompt" => StoreAuth::Prompt, + _ => StoreAuth::Bool(false), + }; + } + + Ok(PromptAuthResult { + retry: true, + store_auth, + }) + } + + /// @deprecated use addAuthenticationOptions instead + /// + /// @param string[] $headers + /// + /// @return string[] updated headers array + pub fn add_authentication_header( + &mut self, + headers: Vec<String>, + origin: &str, + url: &str, + ) -> Result<Vec<String>> { + trigger_error( + "AuthHelper::addAuthenticationHeader is deprecated since Composer 2.9 use addAuthenticationOptions instead.", + E_USER_DEPRECATED, + ); + + // PHP: $options = ['http' => ['header' => &$headers]]; + // PHP uses references so subsequent mutations affect $headers + let mut options: IndexMap<String, PhpMixed> = IndexMap::new(); + let mut http: IndexMap<String, Box<PhpMixed>> = IndexMap::new(); + http.insert( + "header".to_string(), + Box::new(PhpMixed::List( + headers + .iter() + .map(|h| Box::new(PhpMixed::String(h.clone()))) + .collect(), + )), + ); + options.insert("http".to_string(), PhpMixed::Array(http)); + + let options = self.add_authentication_options(options, origin, url)?; + + let http = options.get("http").and_then(|v| v.as_array()).unwrap(); + let header = http.get("header").and_then(|v| v.as_list()).unwrap(); + Ok(header + .iter() + .filter_map(|v| v.as_string().map(|s| s.to_string())) + .collect()) + } + + /// @param array<string, mixed> $options + /// + /// @return array<string, mixed> updated options + pub fn add_authentication_options( + &mut self, + mut options: IndexMap<String, PhpMixed>, + origin: &str, + url: &str, + ) -> Result<IndexMap<String, PhpMixed>> { + if !options.contains_key("http") { + options.insert("http".to_string(), PhpMixed::Array(IndexMap::new())); + } + // PHP: if (!isset($options['http']['header'])) + // TODO(phase-b): mutate nested PhpMixed in place rather than copying + { + let http_has_header = if let Some(PhpMixed::Array(http)) = options.get("http") { + http.contains_key("header") + } else { + false + }; + if !http_has_header { + if let Some(PhpMixed::Array(http)) = options.get_mut("http") { + http.insert( + "header".to_string(), + Box::new(PhpMixed::List(vec![])), + ); + } + } + } + + // PHP: $headers = &$options['http']['header']; + // TODO(phase-b): captured by reference; pushes below modify the same list + let mut headers: Vec<PhpMixed> = match options + .get("http") + .and_then(|v| v.as_array()) + .and_then(|h| h.get("header")) + .and_then(|v| v.as_list()) + { + Some(list) => list.iter().map(|b| (**b).clone()).collect(), + None => vec![], + }; + + if self.io.has_authentication(origin) { + let mut authentication_display_message: Option<String> = None; + let auth = self.io.get_authentication(origin); + let password = auth + .get("password") + .and_then(|v| v.clone()) + .unwrap_or_default(); + let username = auth + .get("username") + .and_then(|v| v.clone()) + .unwrap_or_default(); + if password == "bearer" { + headers.push(PhpMixed::String(format!( + "Authorization: Bearer {}", + username, + ))); + } else if password == "custom-headers" { + // Handle custom HTTP headers from auth.json + let mut custom_headers: PhpMixed = PhpMixed::Null; + // PHP: if (is_string($auth['username'])) + // username field is always String in our IndexMap representation + custom_headers = json_decode(&username, true)?; + if is_array(&custom_headers) { + if let Some(arr) = custom_headers.as_array() { + for header in arr.values() { + headers.push((**header).clone()); + } + } else if let Some(list) = custom_headers.as_list() { + for header in list { + headers.push((**header).clone()); + } + } + authentication_display_message = + Some("Using custom HTTP headers for authentication".to_string()); + } + } else if origin == "github.com" && password == "x-oauth-basic" { + // only add the access_token if it is actually a github API URL + if Preg::is_match(r"{^https?://api\.github\.com/}", url) { + headers.push(PhpMixed::String(format!( + "Authorization: token {}", + username, + ))); + authentication_display_message = + Some("Using GitHub token authentication".to_string()); + } + } else if in_array( + PhpMixed::String(password.clone()), + &PhpMixed::List(vec![ + Box::new(PhpMixed::String("oauth2".to_string())), + Box::new(PhpMixed::String("private-token".to_string())), + Box::new(PhpMixed::String("gitlab-ci-token".to_string())), + ]), + true, + ) && in_array( + PhpMixed::String(origin.to_string()), + &PhpMixed::List( + self.config + .get("gitlab-domains") + .as_array() + .map(|a| a.values().cloned().collect()) + .unwrap_or_default(), + ), + true, + ) { + if password == "oauth2" { + headers.push(PhpMixed::String(format!( + "Authorization: Bearer {}", + username, + ))); + authentication_display_message = + Some("Using GitLab OAuth token authentication".to_string()); + } else { + headers.push(PhpMixed::String(format!("PRIVATE-TOKEN: {}", username))); + authentication_display_message = + Some("Using GitLab private token authentication".to_string()); + } + } else if origin == "bitbucket.org" + && url != Bitbucket::OAUTH2_ACCESS_TOKEN_URL + && username == "x-token-auth" + { + if !self.is_public_bit_bucket_download(url) { + headers.push(PhpMixed::String(format!( + "Authorization: Bearer {}", + password, + ))); + authentication_display_message = + Some("Using Bitbucket OAuth token authentication".to_string()); + } + } else if username == "client-certificate" { + // PHP: $options['ssl'] = array_merge($options['ssl'] ?? [], json_decode((string) $auth['password'], true)); + let existing_ssl = options + .get("ssl") + .cloned() + .unwrap_or(PhpMixed::Array(IndexMap::new())); + let decoded = json_decode(&password, true)?; + options.insert( + "ssl".to_string(), + shirabe_php_shim::array_merge(existing_ssl, decoded), + ); + authentication_display_message = Some("Using SSL client certificate".to_string()); + } else { + let auth_str = base64_encode(&format!("{}:{}", username, password)); + headers.push(PhpMixed::String(format!( + "Authorization: Basic {}", + auth_str, + ))); + authentication_display_message = Some(format!( + "Using HTTP basic authentication with username \"{}\"", + username, + )); + } + + if let Some(display_message) = &authentication_display_message { + let already_displayed = + self.displayed_origin_authentications.get(origin) == Some(display_message); + if !already_displayed { + self.io.write_error( + PhpMixed::String(display_message.clone()), + true, + IOInterface::DEBUG, + ); + self.displayed_origin_authentications + .insert(origin.to_string(), display_message.clone()); + } + } + } else if in_array( + PhpMixed::String(origin.to_string()), + &PhpMixed::List(vec![ + Box::new(PhpMixed::String("api.bitbucket.org".to_string())), + Box::new(PhpMixed::String("api.github.com".to_string())), + ]), + true, + ) { + return self.add_authentication_options( + options, + &str_replace("api.", "", origin), + url, + ); + } + + // write headers back into options['http']['header'] + if let Some(PhpMixed::Array(http)) = options.get_mut("http") { + http.insert( + "header".to_string(), + Box::new(PhpMixed::List( + headers.into_iter().map(Box::new).collect(), + )), + ); + } + + Ok(options) + } + + /// @link https://github.com/composer/composer/issues/5584 + /// + /// @param string $urlToBitBucketFile URL to a file at bitbucket.org. + /// + /// @return bool Whether the given URL is a public BitBucket download which requires no authentication. + pub fn is_public_bit_bucket_download(&self, url_to_bit_bucket_file: &str) -> bool { + let domain = parse_url(url_to_bit_bucket_file, PHP_URL_HOST); + let domain_str = domain.as_string().unwrap_or(""); + if strpos(domain_str, "bitbucket.org").is_none() { + // Bitbucket downloads are hosted on amazonaws. + // We do not need to authenticate there at all + return true; + } + + let path = parse_url(url_to_bit_bucket_file, PHP_URL_PATH); + let path_str = path.as_string().unwrap_or(""); + + // Path for a public download follows this pattern /{user}/{repo}/downloads/{whatever} + // {@link https://blog.bitbucket.org/2009/04/12/new-feature-downloads/} + let path_parts = explode("/", path_str); + + path_parts.len() as i64 >= 4 && path_parts.get(3).map(|s| s.as_str()) == Some("downloads") + } +} |
