Fork of daniellemaywood.uk/gleam — Wasm codegen work
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}