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