@@ -4,13 +4,12 @@ use std::time::{Duration, Instant};
44use harmont_cloud:: { HarmontClient , HarmontError } ;
55use hm_common:: url_nonce:: UrlNonce ;
66use 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 } ;
89use thiserror:: Error ;
910use tracing:: { info, instrument, warn} ;
1011use url:: Url ;
1112
12- const LOGIN_TIMEOUT : Duration = Duration :: from_mins ( 3 ) ;
13-
1413/// How long to poll for the token before giving up.
1514const 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
3531impl 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}
0 commit comments