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