// SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: 2024 The Gleam contributors use crate::{cli, http::HttpClient}; use ecow::EcoString; use gleam_core::{ Error, Result, encryption, error::{FileIoAction, FileKind}, io::HttpClient as _, paths, }; use hexpm::OAuthTokens; use sha2::Digest as _; pub const HEX_OAUTH_CLIENT_ID: &str = "877731e8-cb88-45e1-9b84-9214de7da421"; pub const LOCAL_PASS_PROMPT: &str = "Local password"; pub const API_ENV_NAME: &str = "HEXPM_API_KEY"; #[derive(Debug)] pub struct EncryptedLegacyApiKey { pub name: String, } pub struct HexAuthentication<'runtime> { runtime: &'runtime tokio::runtime::Runtime, http: &'runtime HttpClient, local_password: Option, hex_config: hexpm::Config, } impl<'runtime> HexAuthentication<'runtime> { /// Reads the stored API key from disc, if it exists. /// pub fn new( runtime: &'runtime tokio::runtime::Runtime, http: &'runtime HttpClient, hex_config: hexpm::Config, ) -> Self { Self { runtime, http, local_password: None, hex_config, } } /// Create new OAuth tokens by sending the user through the OAuth flow. /// /// Any already-existing tokens stored on the file system are revoked. /// pub fn create_and_store_new_credentials_via_oauth(&mut self) -> Result { let previous_oauth_token = self.read_stored_encrypted_oauth_refresh_token()?; let previous_legacy_key = self.read_stored_legacy_api_key()?; // Create a device authorisation with Hex, starting the oauth flow. let mut device_authorisation = self.create_oauth_device_authorisation()?; // Show the user their code, and send them to Hex to log in. send_user_to_oauth_verification_url(&device_authorisation); // The user has been sent to the Hex website to authenticate. // Poll the Hex API until they accept or reject the request, or it times out. let tokens = loop { let next = self.poll_for_oauth_next_step(&mut device_authorisation)?; match next { hexpm::PollStep::Done(tokens) => break tokens, hexpm::PollStep::SleepThenPollAgain(duration) => std::thread::sleep(duration), } }; // We are creating a new API key, so we need a new local password // to encrypt it with. self.ask_for_new_local_password()?; self.encrypt_and_store_oauth_refresh_token(&tokens)?; let credentials = tokens.as_credentials(); // Dispose of old tokens, if there was any. self.revoke_old_tokens(&credentials, previous_oauth_token, previous_legacy_key)?; self.delete_legacy_api_key_from_filesystem()?; Ok(credentials) } fn poll_for_oauth_next_step( &mut self, device_authorisation: &mut hexpm::OAuthDeviceAuthorisation, ) -> Result { let request = device_authorisation.poll_token_request(&self.hex_config); let response = self.runtime.block_on(self.http.send(request))?; let next = device_authorisation .poll_token_response(response) .map_err(Error::hex)?; Ok(next) } fn create_oauth_device_authorisation( &mut self, ) -> Result { // Create a recognisable name for the client, so folks can more easily understand which // session is which in the Hex console. // It is expected that we can always get the hostname. let hostname = hostname::get().expect("hostname"); let client_name = format!("Gleam ({})", hostname.to_string_lossy()); let request = hexpm::oauth_device_authorisation_request( HEX_OAUTH_CLIENT_ID, &client_name, &self.hex_config, ); let response = self.runtime.block_on(self.http.send(request))?; hexpm::oauth_device_authorisation_response(HEX_OAUTH_CLIENT_ID.to_string(), response) .map_err(Error::hex) } fn encrypt_and_store_oauth_refresh_token(&mut self, tokens: &OAuthTokens) -> Result<(), Error> { let path = paths::global_hexpm_oauth_credentials_path(); let local_password = self.get_local_password()?; let encrypted_refresh_token = encryption::encrypt_with_passphrase(tokens.refresh_token.as_bytes(), &local_password) .map_err(|e| Error::FailedToEncryptLocalHexApiKey { detail: e.to_string(), })?; let credentials = StoredOAuthCredentials { hexpm: StoredOAuthRepoCredentials { api: self.hex_config.api_base.clone(), repository: self.hex_config.repository_base.clone(), refresh_token: encrypted_refresh_token, refresh_token_hash: { let mut hasher = sha2::Sha256::new(); hasher.update(tokens.refresh_token.as_bytes()); base16::encode_lower(&hasher.finalize()) }, }, }; let toml = toml::to_string(&credentials).expect("OAuth credentials TOML encoding"); crate::fs::write(&path, &toml)?; Ok(()) } /// Create a new local password. /// /// The password must be long enough. /// /// The old password will be discarded, and the new one will be both /// returned and stored in `self.local_password` /// fn ask_for_new_local_password(&mut self) -> Result<()> { let required_length = 8; self.local_password = None; println!( "Please enter a new unique password, at least {required_length} characters long. It will be used to locally encrypt your Hex API tokens. " ); loop { let password = cli::ask_password(LOCAL_PASS_PROMPT)?; if password.chars().count() < required_length { println!("\nPlease use a password at least {required_length} characters long.\n") } else { self.local_password = Some(password); return Ok(()); } } } fn get_local_password(&mut self) -> Result { if let Some(password) = self.local_password.as_ref() { return Ok(password.clone()); } let password = cli::ask_password(LOCAL_PASS_PROMPT)?; self.local_password = Some(password.clone()); Ok(password) } /// Get a token that can be used to authenticate with the Hex API. /// In order, it will try these sources: /// /// 1. An API key from the HEXPM_API_KEY environment variable. /// 2. An OAuth refresh token from the file system, which is then exchanged for /// an access token. /// 3. The OAuth flow. pub fn get_or_create_api_credentials(&mut self) -> Result { if let Some(key) = Self::read_env_api_key()? { return Ok(key); } match self.read_and_decrypt_and_refresh_stored_tokens() { Ok(Some(tokens)) => return Ok(tokens.as_credentials()), Ok(None) => {} Err(Error::HexSessionRevoked) => { // This error occurs when the refresh token is no longer valid, typically // because the user manually revoked the session in the Hex console. // This is a recoverable error so instead of erroring out with a // non-actionable message, we catch this and proceed to the OAuth flow // to create a fresh set of credentials. // Since the old token is already invalid, there is no need to revoke it. } Err(e) => return Err(e), } self.create_and_store_new_credentials_via_oauth() } fn read_env_api_key() -> Result> { let api_key = std::env::var(API_ENV_NAME).unwrap_or_default(); if api_key.trim().is_empty() { Ok(None) } else { Ok(Some(hexpm::Credentials::ApiKey(EcoString::from(api_key)))) } } /// Read, decrypt, and refresh OAuth keys stored on the filesystem. /// /// The new refresh is encrypted and stored on the file system for next use. /// /// If there is no stored key then this return `Ok(None)`, and you'll /// need to create a new one. /// fn read_and_decrypt_and_refresh_stored_tokens(&mut self) -> Result> { let Some(encrypted_credentials) = self.read_stored_encrypted_oauth_refresh_token()? else { return Ok(None); }; let local_password = self.get_local_password()?; let refresh_token = encryption::decrypt_with_passphrase( encrypted_credentials.refresh_token.as_bytes(), &local_password, ) .map_err(|e| Error::FailedToDecryptLocalHexApiKey { detail: e.to_string(), })?; // Use a refresh token, consuming it to get a new set of tokens. let request = hexpm::oauth_refresh_token_request( HEX_OAUTH_CLIENT_ID, &refresh_token, &self.hex_config, ); let response = self.runtime.block_on(self.http.send(request))?; let tokens = hexpm::oauth_refresh_token_response(response).map_err(Error::hex)?; // Store the refresh token for future use. self.encrypt_and_store_oauth_refresh_token(&tokens)?; Ok(Some(tokens)) } fn read_stored_encrypted_oauth_refresh_token( &self, ) -> Result> { let path = paths::global_hexpm_oauth_credentials_path(); if !path.exists() { return Ok(None); } let toml = crate::fs::read(&path)?; let credentials: StoredOAuthCredentials = toml::from_str(&toml).map_err(|e| Error::FileIo { action: FileIoAction::Parse, kind: FileKind::File, path, err: Some(e.to_string()), })?; Ok(Some(credentials.hexpm)) } fn delete_legacy_api_key_from_filesystem(&self) -> Result<()> { let path = paths::global_hexpm_legacy_credentials_path(); if !path.exists() { return Ok(()); } crate::fs::delete_file(&path) } fn read_stored_legacy_api_key(&self) -> Result> { let path = paths::global_hexpm_legacy_credentials_path(); if !path.exists() { return Ok(None); } let text = crate::fs::read(&path)?; let mut chunks = text.splitn(2, '\n'); let Some(name) = chunks.next() else { return Ok(None); }; // We no longer use this encrypted key, but we still parse it to ensure that // the file is the correct format. If it is not then we cannot safely use it. let Some(_encrypted) = chunks.next() else { return Ok(None); }; Ok(Some(EncryptedLegacyApiKey { name: name.to_string(), })) } fn revoke_old_tokens( &self, credentials: &hexpm::Credentials, previous_oauth_token: Option, previous_legacy_key: Option, ) -> Result<()> { if previous_oauth_token.is_none() && previous_legacy_key.is_none() { return Ok(()); } println!("\nRevoking old authentication credentials"); let credentials = crate::hex::write_credentials(credentials)?; if let Some(token) = previous_oauth_token { let hash = token.refresh_token_hash; let request = hexpm::revoke_oauth_token_by_hash_request(&hash, &credentials, &self.hex_config); let response = self.runtime.block_on(self.http.send(request))?; hexpm::revoke_oauth_token_by_hash_response(response).map_err(Error::hex)?; } if let Some(key) = previous_legacy_key { let request = hexpm::api_remove_api_key_request(&key.name, &credentials, &self.hex_config); let response = self.runtime.block_on(self.http.send(request))?; hexpm::api_remove_api_key_response(response).map_err(Error::hex)?; } Ok(()) } } fn send_user_to_oauth_verification_url(device_authorisation: &hexpm::OAuthDeviceAuthorisation) { // We make them press Enter to open the browser instead of going there immediately, // to make sure they see the code before the browser window potentially hides the // terminal window. let uri = &device_authorisation.verification_uri; println!( "Your Hex verification code: {code} Verify this code matches what is shown in your browser. Press Enter to open {uri}", code = device_authorisation.user_code, ); let _ = std::io::stdin() .read_line(&mut String::new()) .expect("stdin read_line"); if opener::open_browser(uri).is_err() { println!("\nFailed to open the browser, please navigate to {uri}"); } } #[derive(Debug, serde::Serialize, serde::Deserialize)] struct StoredOAuthRepoCredentials { #[serde(with = "http_serde::uri")] api: http::Uri, #[serde(with = "http_serde::uri")] repository: http::Uri, /// An encrypted refresh token. refresh_token: String, /// The hash of the token, so it can be revoked even if it cannot be encrypted. refresh_token_hash: String, } #[derive(Debug, serde::Serialize, serde::Deserialize)] struct StoredOAuthCredentials { hexpm: StoredOAuthRepoCredentials, }