Fork of daniellemaywood.uk/gleam — Wasm codegen work
2

Configure Feed

Select the types of activity you want to include in your feed.

gleam / compiler-cli / src / hex / auth.rs
15 kB 405 lines
1// SPDX-License-Identifier: Apache-2.0 2// SPDX-FileCopyrightText: 2024 The Gleam contributors 3 4use crate::{cli, http::HttpClient}; 5use ecow::EcoString; 6use gleam_core::{ 7 Error, Result, encryption, 8 error::{FileIoAction, FileKind}, 9 io::HttpClient as _, 10 paths, 11}; 12use hexpm::OAuthTokens; 13use sha2::Digest as _; 14 15pub const HEX_OAUTH_CLIENT_ID: &str = "877731e8-cb88-45e1-9b84-9214de7da421"; 16 17pub const LOCAL_PASS_PROMPT: &str = "Local password"; 18pub const API_ENV_NAME: &str = "HEXPM_API_KEY"; 19pub const READONLY_API_ENV_NAME: &str = "HEXPM_READ_API_KEY"; 20 21#[derive(Debug)] 22pub struct EncryptedLegacyApiKey { 23 pub name: String, 24} 25 26pub struct HexAuthentication<'runtime> { 27 runtime: &'runtime tokio::runtime::Runtime, 28 http: &'runtime HttpClient, 29 local_password: Option<EcoString>, 30 hex_config: hexpm::Config, 31} 32 33impl<'runtime> HexAuthentication<'runtime> { 34 /// Reads the stored API key from disc, if it exists. 35 /// 36 pub fn new( 37 runtime: &'runtime tokio::runtime::Runtime, 38 http: &'runtime HttpClient, 39 hex_config: hexpm::Config, 40 ) -> Self { 41 Self { 42 runtime, 43 http, 44 local_password: None, 45 hex_config, 46 } 47 } 48 49 /// Create new OAuth tokens by sending the user through the OAuth flow. 50 /// 51 /// Any already-existing tokens stored on the file system are revoked. 52 /// 53 pub fn create_and_store_new_credentials_via_oauth(&mut self) -> Result<hexpm::Credentials> { 54 let previous_oauth_token = self.read_stored_encrypted_oauth_refresh_token()?; 55 let previous_legacy_key = self.read_stored_legacy_api_key()?; 56 57 // Create a device authorisation with Hex, starting the oauth flow. 58 let mut device_authorisation = self.create_oauth_device_authorisation()?; 59 60 // Show the user their code, and send them to Hex to log in. 61 send_user_to_oauth_verification_url(&device_authorisation); 62 63 // The user has been sent to the Hex website to authenticate. 64 // Poll the Hex API until they accept or reject the request, or it times out. 65 let tokens = loop { 66 let next = self.poll_for_oauth_next_step(&mut device_authorisation)?; 67 match next { 68 hexpm::PollStep::Done(tokens) => break tokens, 69 hexpm::PollStep::SleepThenPollAgain(duration) => std::thread::sleep(duration), 70 } 71 }; 72 73 // We are creating a new API key, so we need a new local password 74 // to encrypt it with. 75 self.ask_for_new_local_password()?; 76 self.encrypt_and_store_oauth_refresh_token(&tokens)?; 77 78 let credentials = tokens.as_credentials(); 79 80 // Dispose of old tokens, if there was any. 81 self.revoke_old_tokens(&credentials, previous_oauth_token, previous_legacy_key)?; 82 self.delete_legacy_api_key_from_filesystem()?; 83 84 Ok(credentials) 85 } 86 87 fn poll_for_oauth_next_step( 88 &mut self, 89 device_authorisation: &mut hexpm::OAuthDeviceAuthorisation, 90 ) -> Result<hexpm::PollStep, Error> { 91 let request = device_authorisation.poll_token_request(&self.hex_config); 92 let response = self.runtime.block_on(self.http.send(request))?; 93 let next = device_authorisation 94 .poll_token_response(response) 95 .map_err(Error::hex)?; 96 Ok(next) 97 } 98 99 fn create_oauth_device_authorisation( 100 &mut self, 101 ) -> Result<hexpm::OAuthDeviceAuthorisation, Error> { 102 // Create a recognisable name for the client, so folks can more easily understand which 103 // session is which in the Hex console. 104 // It is expected that we can always get the hostname. 105 let hostname = hostname::get().expect("hostname"); 106 let client_name = format!("Gleam ({})", hostname.to_string_lossy()); 107 108 let request = hexpm::oauth_device_authorisation_request( 109 HEX_OAUTH_CLIENT_ID, 110 &client_name, 111 &self.hex_config, 112 ); 113 let response = self.runtime.block_on(self.http.send(request))?; 114 hexpm::oauth_device_authorisation_response(HEX_OAUTH_CLIENT_ID.to_string(), response) 115 .map_err(Error::hex) 116 } 117 118 fn encrypt_and_store_oauth_refresh_token(&mut self, tokens: &OAuthTokens) -> Result<(), Error> { 119 let path = paths::global_hexpm_oauth_credentials_path(); 120 let local_password = self.get_local_password()?; 121 let encrypted_refresh_token = 122 encryption::encrypt_with_passphrase(tokens.refresh_token.as_bytes(), &local_password) 123 .map_err(|e| Error::FailedToEncryptLocalHexApiKey { 124 detail: e.to_string(), 125 })?; 126 127 let credentials = StoredOAuthCredentials { 128 hexpm: StoredOAuthRepoCredentials { 129 api: self.hex_config.api_base.clone(), 130 repository: self.hex_config.repository_base.clone(), 131 refresh_token: encrypted_refresh_token, 132 refresh_token_hash: { 133 let mut hasher = sha2::Sha256::new(); 134 hasher.update(tokens.refresh_token.as_bytes()); 135 base16::encode_lower(&hasher.finalize()) 136 }, 137 }, 138 }; 139 let toml = toml::to_string(&credentials).expect("OAuth credentials TOML encoding"); 140 crate::fs::write(&path, &toml)?; 141 Ok(()) 142 } 143 144 /// Create a new local password. 145 /// 146 /// The password must be long enough. 147 /// 148 /// The old password will be discarded, and the new one will be both 149 /// returned and stored in `self.local_password` 150 /// 151 fn ask_for_new_local_password(&mut self) -> Result<()> { 152 let required_length = 8; 153 self.local_password = None; 154 println!( 155 "Please enter a new unique password, at least {required_length} characters long. 156It will be used to locally encrypt your Hex API tokens. 157" 158 ); 159 160 loop { 161 let password = cli::ask_password(LOCAL_PASS_PROMPT)?; 162 if password.chars().count() < required_length { 163 println!("\nPlease use a password at least {required_length} characters long.\n"); 164 } else { 165 self.local_password = Some(password); 166 return Ok(()); 167 } 168 } 169 } 170 171 fn get_local_password(&mut self) -> Result<EcoString> { 172 if let Some(password) = self.local_password.as_ref() { 173 return Ok(password.clone()); 174 } 175 176 let password = cli::ask_password(LOCAL_PASS_PROMPT)?; 177 self.local_password = Some(password.clone()); 178 Ok(password) 179 } 180 181 /// Get a token that can be used to authenticate with the Hex API. 182 /// In order, it will try these sources: 183 /// 184 /// 1. An API key from the HEXPM_API_KEY environment variable. 185 /// 2. An OAuth refresh token from the file system, which is then exchanged for 186 /// an access token. 187 /// 3. The OAuth flow. 188 pub fn get_or_create_api_credentials(&mut self) -> Result<hexpm::Credentials> { 189 if let Some(key) = read_env_api_key() { 190 return Ok(key); 191 } 192 193 match self.read_and_decrypt_and_refresh_stored_tokens() { 194 Ok(Some(tokens)) => return Ok(tokens.as_credentials()), 195 Ok(None) => {} 196 Err(Error::HexSessionRevoked) => { 197 // This error occurs when the refresh token is no longer valid, typically 198 // because the user manually revoked the session in the Hex console. 199 // This is a recoverable error so instead of erroring out with a 200 // non-actionable message, we catch this and proceed to the OAuth flow 201 // to create a fresh set of credentials. 202 // Since the old token is already invalid, there is no need to revoke it. 203 } 204 Err(e) => return Err(e), 205 } 206 207 self.create_and_store_new_credentials_via_oauth() 208 } 209 210 /// Read, decrypt, and refresh OAuth keys stored on the filesystem. 211 /// 212 /// The new refresh is encrypted and stored on the file system for next use. 213 /// 214 /// If there is no stored key then this return `Ok(None)`, and you'll 215 /// need to create a new one. 216 /// 217 fn read_and_decrypt_and_refresh_stored_tokens(&mut self) -> Result<Option<OAuthTokens>> { 218 let Some(encrypted_credentials) = self.read_stored_encrypted_oauth_refresh_token()? else { 219 return Ok(None); 220 }; 221 222 let local_password = self.get_local_password()?; 223 let refresh_token = encryption::decrypt_with_passphrase( 224 encrypted_credentials.refresh_token.as_bytes(), 225 &local_password, 226 ) 227 .map_err(|_| Error::FailedToDecryptLocalHexApiKey)?; 228 229 // Use a refresh token, consuming it to get a new set of tokens. 230 let request = hexpm::oauth_refresh_token_request( 231 HEX_OAUTH_CLIENT_ID, 232 &refresh_token, 233 &self.hex_config, 234 ); 235 let response = self.runtime.block_on(self.http.send(request))?; 236 let tokens = hexpm::oauth_refresh_token_response(response).map_err(Error::hex)?; 237 238 // Store the refresh token for future use. 239 self.encrypt_and_store_oauth_refresh_token(&tokens)?; 240 241 Ok(Some(tokens)) 242 } 243 244 fn read_stored_encrypted_oauth_refresh_token( 245 &self, 246 ) -> Result<Option<StoredOAuthRepoCredentials>> { 247 let path = paths::global_hexpm_oauth_credentials_path(); 248 if !path.exists() { 249 return Ok(None); 250 } 251 let toml = crate::fs::read(&path)?; 252 let credentials: StoredOAuthCredentials = 253 toml::from_str(&toml).map_err(|e| Error::FileIo { 254 action: FileIoAction::Parse, 255 kind: FileKind::File, 256 path, 257 err: Some(e.to_string()), 258 })?; 259 Ok(Some(credentials.hexpm)) 260 } 261 262 fn delete_legacy_api_key_from_filesystem(&self) -> Result<()> { 263 let path = paths::global_hexpm_legacy_credentials_path(); 264 if !path.exists() { 265 return Ok(()); 266 } 267 crate::fs::delete_file(&path) 268 } 269 270 fn read_stored_legacy_api_key(&self) -> Result<Option<EncryptedLegacyApiKey>> { 271 let path = paths::global_hexpm_legacy_credentials_path(); 272 if !path.exists() { 273 return Ok(None); 274 } 275 let text = crate::fs::read(&path)?; 276 let mut chunks = text.splitn(2, '\n'); 277 let Some(name) = chunks.next() else { 278 return Ok(None); 279 }; 280 // We no longer use this encrypted key, but we still parse it to ensure that 281 // the file is the correct format. If it is not then we cannot safely use it. 282 let Some(_encrypted) = chunks.next() else { 283 return Ok(None); 284 }; 285 Ok(Some(EncryptedLegacyApiKey { 286 name: name.to_string(), 287 })) 288 } 289 290 fn revoke_old_tokens( 291 &self, 292 credentials: &hexpm::Credentials, 293 previous_oauth_token: Option<StoredOAuthRepoCredentials>, 294 previous_legacy_key: Option<EncryptedLegacyApiKey>, 295 ) -> Result<()> { 296 if previous_oauth_token.is_none() && previous_legacy_key.is_none() { 297 return Ok(()); 298 } 299 300 println!("\nRevoking old authentication credentials"); 301 let credentials = crate::hex::write_credentials(credentials)?; 302 303 if let Some(token) = previous_oauth_token { 304 let hash = token.refresh_token_hash; 305 let request = 306 hexpm::revoke_oauth_token_by_hash_request(&hash, &credentials, &self.hex_config); 307 let response = self.runtime.block_on(self.http.send(request))?; 308 hexpm::revoke_oauth_token_by_hash_response(response).map_err(Error::hex)?; 309 } 310 311 if let Some(key) = previous_legacy_key { 312 let request = 313 hexpm::api_remove_api_key_request(&key.name, &credentials, &self.hex_config); 314 let response = self.runtime.block_on(self.http.send(request))?; 315 hexpm::api_remove_api_key_response(response).map_err(Error::hex)?; 316 } 317 318 Ok(()) 319 } 320} 321 322fn send_user_to_oauth_verification_url(device_authorisation: &hexpm::OAuthDeviceAuthorisation) { 323 // We make them press Enter to open the browser instead of going there immediately, 324 // to make sure they see the code before the browser window potentially hides the 325 // terminal window. 326 let uri = &device_authorisation.verification_uri; 327 println!( 328 "Your Hex verification code: 329 330 {code} 331 332Verify this code matches what is shown in your browser. 333 334Press Enter to open {uri}", 335 code = device_authorisation.user_code, 336 ); 337 338 let _ = std::io::stdin() 339 .read_line(&mut String::new()) 340 .expect("stdin read_line"); 341 if opener::open_browser(uri).is_err() { 342 println!("\nFailed to open the browser, please navigate to {uri}"); 343 } 344} 345 346#[derive(Debug, serde::Serialize, serde::Deserialize)] 347struct StoredOAuthRepoCredentials { 348 #[serde(with = "http_serde::uri")] 349 api: http::Uri, 350 #[serde(with = "http_serde::uri")] 351 repository: http::Uri, 352 /// An encrypted refresh token. 353 refresh_token: String, 354 /// The hash of the token, so it can be revoked even if it cannot be encrypted. 355 refresh_token_hash: String, 356} 357 358#[derive(Debug, serde::Serialize, serde::Deserialize)] 359struct StoredOAuthCredentials { 360 hexpm: StoredOAuthRepoCredentials, 361} 362 363/// Read a Hex API key from the `HEXPM_API_KEY` environment variable, if one is set. 364/// 365/// This authenticates write commands such as `gleam publish`. 366fn read_env_api_key() -> Option<hexpm::Credentials> { 367 api_key_credentials(&std::env::var(API_ENV_NAME).unwrap_or_default()) 368} 369 370/// Read a Hex API key from the `HEXPM_READ_API_KEY` environment variable, if one 371/// is set. 372/// 373/// This is used to authenticate otherwise anonymous read requests (dependency 374/// resolution and package downloads) so that they are subject to Hex's higher 375/// per-user rate limits rather than the stricter per-IP limits. 376pub fn read_env_readonly_api_key() -> Option<hexpm::Credentials> { 377 api_key_credentials(&std::env::var(READONLY_API_ENV_NAME).unwrap_or_default()) 378} 379 380fn api_key_credentials(api_key: &str) -> Option<hexpm::Credentials> { 381 let api_key = api_key.trim(); 382 if api_key.is_empty() { 383 None 384 } else { 385 Some(hexpm::Credentials::ApiKey(EcoString::from(api_key))) 386 } 387} 388 389#[cfg(test)] 390mod tests { 391 use super::*; 392 393 #[test] 394 fn api_key_credentials_trims_surrounding_whitespace() { 395 assert_eq!( 396 api_key_credentials(" secret\n"), 397 Some(hexpm::Credentials::ApiKey("secret".into())), 398 ); 399 } 400 401 #[test] 402 fn api_key_credentials_blank_is_none() { 403 assert_eq!(api_key_credentials(" \n"), None); 404 } 405}