Skip to content

Commit 1ca9926

Browse files
committed
feat(hm-cloud): AuthProvider owns login/logout/whoami; review fixes
Rework AuthProvider to hold {app_ctx, config} and build its own clients (anonymous for login, authenticated for whoami and the post-login readback) rather than taking an injected one — so it can own the whole `hm cloud auth` surface. logout and whoami move onto it; hm's login.rs/ logout.rs/whoami.rs become thin adapters, and the old settings::client- based whoami is dropped. Review fixes to the login flow: - Gate the browser flow on !is_ci() so a CI runner with a display fails fast instead of hanging on the loopback. - Poll for the token directly while the spawned listener serves the redirect concurrently, instead of waiting out the redirect timeout first (BrowserAuth becomes a side-effecting open()). Also fold NO_COLOR detection into Term (wants_color(), flag applied by callers), drop the now-unused hm-render::color_enabled, inline the trailing-slash trim in BackendDomain, and expose app() as a Url.
1 parent d3db78d commit 1ca9926

13 files changed

Lines changed: 146 additions & 122 deletions

File tree

‎Cargo.lock‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎crates/hm-cloud/Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ dialoguer = "0.11"
1717
harmont-cloud = { workspace = true }
1818
hm-common = { workspace = true }
1919
hm-core = { workspace = true }
20+
secrecy = "0.10"
2021
tokio = { workspace = true }
2122
thiserror = { workspace = true }
2223
tracing = { workspace = true }

‎crates/hm-cloud/src/auth.rs‎

Lines changed: 91 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,12 @@ use std::time::{Duration, Instant};
44
use harmont_cloud::{HarmontClient, HarmontError};
55
use hm_common::url_nonce::UrlNonce;
66
use hm_core::{app_ctx::AppCtx, config::ResolvedCloudConfig};
7-
use tokio::{io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, net::TcpListener, task::JoinHandle, time::error::Elapsed};
7+
use secrecy::ExposeSecret as _;
8+
use tokio::{io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, net::TcpListener};
89
use thiserror::Error;
910
use tracing::{info, instrument, warn};
1011
use url::Url;
1112

12-
const LOGIN_TIMEOUT: Duration = Duration::from_mins(3);
13-
1413
/// How long to poll for the token before giving up.
1514
const CLAIM_TIMEOUT: Duration = Duration::from_mins(3);
1615

@@ -26,17 +25,14 @@ pub enum BrowserAuthError {
2625
CouldNotDeduceAddress(std::io::Error),
2726
}
2827

29-
/// The code-path taken to open the browser and allow the user to click a button to log in.
30-
#[derive(Debug)]
31-
struct BrowserAuth {
32-
accept: JoinHandle<()>,
33-
}
28+
/// The code-path that opens the browser and serves the loopback redirect.
29+
struct BrowserAuth;
3430

3531
impl BrowserAuth {
3632
/// Bind the loopback listener, open the browser to the login page, and
3733
/// spawn the task that serves the redirect.
3834
#[instrument]
39-
async fn new(app: Url, nonce: &UrlNonce) -> Result<Self, BrowserAuthError> {
35+
async fn open(app: Url, nonce: &UrlNonce) -> Result<(), BrowserAuthError> {
4036
let listener = TcpListener::bind("127.0.0.1:0").await
4137
.map_err(BrowserAuthError::CouldNotCreateListener)?;
4238
let port = listener.local_addr()
@@ -53,9 +49,8 @@ impl BrowserAuth {
5349
warn!("couldn't open a browser automatically. open this URL manually:\n {url}");
5450
}
5551

56-
let accept = tokio::spawn(Self::accept(listener));
57-
58-
Ok(Self { accept })
52+
tokio::spawn(Self::accept(listener));
53+
Ok(())
5954
}
6055

6156
async fn accept(listener: TcpListener) {
@@ -83,11 +78,6 @@ impl BrowserAuth {
8378
writer.write_all(response.as_bytes()).await.ok();
8479
writer.shutdown().await.ok();
8580
}
86-
87-
/// Wait for the login from the user.
88-
async fn login(&mut self) -> Result<(), Elapsed> {
89-
tokio::time::timeout(LOGIN_TIMEOUT, &mut self.accept).await.map(|_| ())
90-
}
9181
}
9282

9383
/// A failure during the paste-in login flow.
@@ -205,61 +195,116 @@ pub enum LoginError {
205195
Paste(#[from] PasteAuthError),
206196
}
207197

198+
/// A failure while reading the current user.
199+
#[derive(Debug, Error)]
200+
pub enum WhoamiError {
201+
/// No credentials are stored.
202+
#[error("not logged in — run `hm cloud auth login`")]
203+
NotLoggedIn,
204+
/// The user profile could not be read.
205+
#[error("could not read user profile: {0}")]
206+
Fetch(String),
207+
}
208+
208209
#[derive(Debug)]
209-
pub struct AuthProvider<'app, 'client, 'config> {
210+
pub struct AuthProvider<'app, 'config> {
210211
app_ctx: &'app AppCtx,
211-
harmont_client: &'client HarmontClient,
212212
config: &'config ResolvedCloudConfig,
213213
}
214214

215-
impl<'app, 'client, 'config> AuthProvider<'app, 'client, 'config> {
215+
impl<'app, 'config> AuthProvider<'app, 'config> {
216216
/// Create a new authentication provider.
217217
#[must_use]
218-
pub const fn new(
219-
app_ctx: &'app AppCtx,
220-
client: &'client HarmontClient,
221-
config: &'config ResolvedCloudConfig,
222-
) -> Self {
223-
Self { app_ctx, harmont_client: client, config }
218+
pub const fn new(app_ctx: &'app AppCtx, config: &'config ResolvedCloudConfig) -> Self {
219+
Self { app_ctx, config }
224220
}
225221

226222
/// Log in — browser-loopback when a GUI is available, otherwise the
227-
/// paste-in flow — persisting and returning the resulting token.
223+
/// paste-in flow — persisting the token and confirming the signed-in user.
228224
///
229225
/// # Errors
230226
///
231227
/// [`LoginError::Unsupported`] when there is neither a browser nor an
232228
/// interactive terminal; [`LoginError::Browser`], [`LoginError::Claim`],
233229
/// or [`LoginError::Paste`] when the chosen flow fails.
234-
pub async fn try_login(&self) -> Result<String, LoginError> {
235-
let token = if self.app_ctx.term().has_gui() {
236-
self.login_browser().await?
237-
} else if self.app_ctx.term().is_interactive() {
238-
self.login_paste().await?
230+
pub async fn try_login(&self) -> Result<(), LoginError> {
231+
let client = HarmontClient::anonymous(self.config.domain.api_url());
232+
let term = self.app_ctx.term();
233+
let token = if term.has_gui() && !term.is_ci() {
234+
self.login_browser(&client).await?
235+
} else if term.is_interactive() {
236+
self.login_paste(&client).await?
239237
} else {
240238
return Err(LoginError::Unsupported);
241239
};
242-
243240
self.app_ctx.creds().set(&token).await;
244-
Ok(token)
241+
242+
// Confirm by reading the user back — best-effort, the token is valid.
243+
match self.fetch_user(&token).await {
244+
Ok((name, email, _)) => info!("logged in as {name} ({email})"),
245+
Err(e) => warn!("logged in, but could not read user profile: {e}"),
246+
}
247+
Ok(())
245248
}
246249

247-
/// Open the browser, wait for its redirect, then claim the parked token.
248-
async fn login_browser(&self) -> Result<String, LoginError> {
249-
let nonce = UrlNonce::random();
250-
let mut browser = BrowserAuth::new(self.config.domain.app(), &nonce).await?;
250+
/// Clear the stored credentials.
251+
///
252+
/// # Errors
253+
///
254+
/// Returns an error if the credential store cannot be cleared.
255+
pub async fn logout(&self) -> std::io::Result<()> {
256+
self.app_ctx.creds().clear().await?;
257+
info!("logged out");
258+
Ok(())
259+
}
251260

252-
// Wait for the redirect so the tab can show "done", but claim by nonce
253-
// regardless — a lost or slow redirect doesn't mean the login failed.
254-
if let Err(elapsed) = browser.login().await {
255-
warn!(%elapsed, "no browser redirect yet; claiming the token anyway");
256-
}
261+
/// Print the user the stored token belongs to.
262+
///
263+
/// # Errors
264+
///
265+
/// [`WhoamiError::NotLoggedIn`] when no token is stored;
266+
/// [`WhoamiError::Fetch`] when the profile cannot be read.
267+
pub async fn whoami(&self) -> Result<(), WhoamiError> {
268+
let token = self
269+
.app_ctx
270+
.creds()
271+
.get()
272+
.await
273+
.ok_or(WhoamiError::NotLoggedIn)?;
274+
let (name, email, id) = self
275+
.fetch_user(token.expose_secret())
276+
.await
277+
.map_err(WhoamiError::Fetch)?;
278+
info!("{name} <{email}> (id {id})");
279+
Ok(())
280+
}
281+
282+
/// The display name, email, and id of the user `token` authenticates as.
283+
async fn fetch_user(&self, token: &str) -> Result<(String, String, String), String> {
284+
let client = HarmontClient::with_base_url(token.to_owned(), self.config.domain.api_url());
285+
let me = client
286+
.raw()
287+
.get_current_user()
288+
.await
289+
.map_err(|e| e.to_string())?
290+
.into_inner();
291+
let name = me.name.clone().unwrap_or_else(|| me.email.clone());
292+
Ok((name, me.email.clone(), me.id.to_string()))
293+
}
294+
295+
/// Open the browser, then claim the token the SPA parks under the nonce.
296+
async fn login_browser(&self, client: &HarmontClient) -> Result<String, LoginError> {
297+
let nonce = UrlNonce::random();
298+
BrowserAuth::open(self.config.domain.app(), &nonce).await?;
257299

258-
Ok(ClaimPoller::new(self.harmont_client, nonce).poll().await?)
300+
// The token comes from polling; the spawned listener serves the browser
301+
// redirect concurrently, so a lost or slow redirect never blocks us —
302+
// the poll's retry loop is the wait.
303+
Ok(ClaimPoller::new(client, nonce).poll().await?)
259304
}
260305

261306
/// Show the paste page and redeem the code the user enters.
262-
async fn login_paste(&self) -> Result<String, LoginError> {
263-
Ok(PasteTokenAuth::login(self.harmont_client, self.config.domain.app()).await?)
307+
async fn login_paste(&self, client: &HarmontClient) -> Result<String, LoginError> {
308+
Ok(PasteTokenAuth::login(client, self.config.domain.app()).await?)
264309
}
265310
}

‎crates/hm-core/src/config/domain.rs‎

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -52,13 +52,13 @@ impl BackendDomain {
5252
/// The API base URL (`https://api.<domain>`), without a trailing slash.
5353
#[must_use]
5454
pub fn api_url(&self) -> String {
55-
trim_slash(&self.subdomain("api"))
55+
self.subdomain("api").as_str().trim_end_matches('/').to_owned()
5656
}
5757

5858
/// The dashboard base URL (`https://app.<domain>`), without a trailing slash.
5959
#[must_use]
6060
pub fn app_url(&self) -> String {
61-
trim_slash(&self.app())
61+
self.app().as_str().trim_end_matches('/').to_owned()
6262
}
6363

6464
/// The dashboard base URL (`https://app.<domain>`), for extending with a
@@ -80,11 +80,6 @@ impl BackendDomain {
8080
}
8181
}
8282

83-
/// A URL rendered without its trailing slash.
84-
fn trim_slash(url: &Url) -> String {
85-
url.as_str().trim_end_matches('/').to_owned()
86-
}
87-
8883
impl Default for BackendDomain {
8984
fn default() -> Self {
9085
#[allow(

‎crates/hm-core/src/term.rs‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ pub struct Term {
1616
ci: bool,
1717
ssh: bool,
1818
display: bool,
19+
no_color: bool,
1920
}
2021

2122
impl Term {
@@ -32,6 +33,7 @@ impl Term {
3233
|| env_present("SSH_TTY")
3334
|| env_present("SSH_CLIENT"),
3435
display: env_present("DISPLAY") || env_present("WAYLAND_DISPLAY"),
36+
no_color: std::env::var_os("NO_COLOR").is_some(),
3537
}
3638
}
3739

@@ -42,6 +44,13 @@ impl Term {
4244
self.stdin && self.stdout && !self.ci
4345
}
4446

47+
/// Whether the environment permits ANSI color: `NO_COLOR` is unset and
48+
/// stderr is a terminal.
49+
#[must_use]
50+
pub const fn wants_color(&self) -> bool {
51+
!self.no_color && self.stderr
52+
}
53+
4554
/// Whether stdin is a terminal.
4655
#[must_use]
4756
pub const fn stdin_is_tty(&self) -> bool {

‎crates/hm-render/src/lib.rs‎

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,6 @@ use std::fmt;
1010

1111
use hm_plugin_protocol::BuildEvent;
1212

13-
/// Whether ANSI color should be used, given an explicit no-color flag and
14-
/// whether stderr is a TTY: enabled when the flag is unset, `NO_COLOR` is
15-
/// unset, and stderr is a terminal.
16-
#[must_use]
17-
pub fn color_enabled(no_color_flag: bool, stderr_is_tty: bool) -> bool {
18-
!no_color_flag && std::env::var_os("NO_COLOR").is_none() && stderr_is_tty
19-
}
20-
2113
pub mod human;
2214
pub mod json;
2315
pub mod progress;
Lines changed: 6 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,15 @@
1-
//! `hm cloud auth login` — drive the shared browser/paste login flow via
2-
//! [`hm_cloud::auth::AuthProvider`], then confirm by reading the user back.
1+
//! `hm cloud auth login` — drive the shared login flow via
2+
//! [`hm_cloud::auth::AuthProvider`].
33
44
use anyhow::Result;
5-
use harmont_cloud::HarmontClient;
65
use hm_core::app_ctx::AppCtx;
7-
use hm_core::config::ResolvedCloudConfig;
86

97
use crate::commands::cloud::settings;
108

119
pub(crate) async fn run(app: &AppCtx) -> Result<()> {
12-
let (client, domain) = settings::anon_client(app);
13-
let api = domain.api_url();
14-
let config = ResolvedCloudConfig {
15-
domain,
16-
org: None,
17-
repo: None,
18-
default_pipeline: None,
19-
};
20-
21-
let token = hm_cloud::auth::AuthProvider::new(app, &client, &config)
10+
let config = settings::auth_config(app);
11+
hm_cloud::auth::AuthProvider::new(app, &config)
2212
.try_login()
23-
.await?;
24-
25-
// Confirm by reading back the authenticated user.
26-
let authed = HarmontClient::with_base_url(token, &api);
27-
match authed.raw().get_current_user().await {
28-
Ok(resp) => {
29-
let me = resp.into_inner();
30-
tracing::info!(
31-
"logged in as {} ({})",
32-
me.name.clone().unwrap_or_else(|| me.email.clone()),
33-
me.email,
34-
);
35-
}
36-
Err(e) => tracing::warn!("logged in, but could not read user profile: {e}"),
37-
}
38-
Ok(())
13+
.await
14+
.map_err(anyhow::Error::from)
3915
}
Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
1-
//! `hm cloud logout` — clears the stored bearer token.
2-
3-
use std::collections::BTreeMap;
1+
//! `hm cloud auth logout` — clear the stored bearer token.
42
53
use anyhow::Result;
64
use hm_core::app_ctx::AppCtx;
75

8-
pub(crate) async fn run(_env: &BTreeMap<String, String>, app: &AppCtx) -> Result<()> {
9-
app.creds().clear().await?;
10-
tracing::info!("logged out");
11-
Ok(())
6+
use crate::commands::cloud::settings;
7+
8+
pub(crate) async fn run(app: &AppCtx) -> Result<()> {
9+
let config = settings::auth_config(app);
10+
hm_cloud::auth::AuthProvider::new(app, &config)
11+
.logout()
12+
.await
13+
.map_err(anyhow::Error::from)
1214
}

‎crates/hm/src/commands/cloud/auth/mod.rs‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
//! `hm cloud login | logout | whoami`.
1+
//! `hm cloud auth login | logout | whoami`.
22
//!
3-
//! Each submodule exposes a single `run(env, ...)` entry point.
3+
//! Thin adapters over [`hm_cloud::auth::AuthProvider`]; each exposes a single
4+
//! `run(app)` entry point.
45
56
pub(crate) mod login;
67
pub(crate) mod logout;

0 commit comments

Comments
 (0)