@@ -169,7 +169,16 @@ struct WsListResponse { workspaces: Vec<WsItem> }
169169struct WsItem { public_id : String , name : String }
170170
171171/// Wait for the browser callback, verify state, and extract the authorization code.
172- fn receive_callback ( server : & tiny_http:: Server , expected_state : & str ) -> Result < String , String > {
172+ ///
173+ /// `success_title` and `success_body` are interpolated directly into HTML
174+ /// without escaping. Callers **must** pass static, trusted strings — never
175+ /// dynamic or user-supplied content.
176+ fn receive_callback (
177+ server : & tiny_http:: Server ,
178+ expected_state : & str ,
179+ success_title : & str ,
180+ success_body : & str ,
181+ ) -> Result < String , String > {
173182 let request = server. recv ( ) . map_err ( |e| format ! ( "failed to receive callback: {e}" ) ) ?;
174183 let raw_url = request. url ( ) . to_string ( ) ;
175184 let params = parse_query_params ( & raw_url) ;
@@ -187,33 +196,34 @@ fn receive_callback(server: &tiny_http::Server, expected_state: &str) -> Result<
187196 }
188197 } ;
189198
190- let html = r#"<!DOCTYPE html>
199+ let html = format ! (
200+ r#"<!DOCTYPE html>
191201<html lang="en">
192202<head>
193203 <meta charset="UTF-8" />
194204 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
195- <title>Hotdata — Login Successful </title>
205+ <title>Hotdata — {success_title} </title>
196206 <style>
197- *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
198- body {
207+ *, *::before, *::after {{ box-sizing: border-box; margin: 0; padding: 0; } }
208+ body {{
199209 font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
200210 background: #111827;
201211 color: #e5e7eb;
202212 display: flex;
203213 align-items: center;
204214 justify-content: center;
205215 min-height: 100vh;
206- }
207- .card {
216+ }}
217+ .card {{
208218 background: #1f2937;
209219 border: 1px solid #374151;
210220 border-radius: 0.5rem;
211221 padding: 2.5rem;
212222 max-width: 420px;
213223 width: 100%;
214224 text-align: center;
215- }
216- .icon {
225+ }}
226+ .icon {{
217227 width: 48px;
218228 height: 48px;
219229 background: #14532d;
@@ -222,10 +232,10 @@ fn receive_callback(server: &tiny_http::Server, expected_state: &str) -> Result<
222232 align-items: center;
223233 justify-content: center;
224234 margin: 0 auto 1.25rem;
225- }
226- .icon svg { width: 24px; height: 24px; stroke: #86efac; }
227- h1 { font-size: 1.25rem; font-weight: 600; color: #f3f4f6; margin-bottom: 0.5rem; }
228- p { font-size: 0.875rem; color: #9ca3af; line-height: 1.5; }
235+ }}
236+ .icon svg {{ width: 24px; height: 24px; stroke: #86efac; } }
237+ h1 {{ font-size: 1.25rem; font-weight: 600; color: #f3f4f6; margin-bottom: 0.5rem; } }
238+ p {{ font-size: 0.875rem; color: #9ca3af; line-height: 1.5; } }
229239 </style>
230240</head>
231241<body>
@@ -235,11 +245,12 @@ fn receive_callback(server: &tiny_http::Server, expected_state: &str) -> Result<
235245 <path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
236246 </svg>
237247 </div>
238- <h1>Login successful </h1>
239- <p>You're now authenticated with Hotdata.<br/>You can close this tab and return to the terminal. </p>
248+ <h1>{success_title} </h1>
249+ <p>{success_body} </p>
240250 </div>
241251</body>
242- </html>"# ;
252+ </html>"#
253+ ) ;
243254 let response = tiny_http:: Response :: from_string ( html) . with_header (
244255 "Content-Type: text/html"
245256 . parse :: < tiny_http:: Header > ( )
@@ -254,99 +265,85 @@ fn is_already_signed_in(profile_config: &config::ProfileConfig) -> bool {
254265 check_status ( profile_config) == AuthStatus :: Authenticated
255266}
256267
257- pub fn login ( ) {
258- let profile_config = config:: load ( "default" ) . unwrap_or_default ( ) ;
268+ /// Shared PKCE browser-handoff loop used by both `login` and `register`.
269+ ///
270+ /// 1. Generates PKCE params and starts the local loopback callback server.
271+ /// 2. Calls `build_url(app_url, code_challenge, state, port)` to construct
272+ /// the browser URL.
273+ /// 3. Opens the browser and waits for the OAuth/registration callback.
274+ /// 4. Calls `exchange(code, code_verifier, port)` to mint a JWT session.
275+ /// 5. Saves the session, prints `success_print`, and displays the workspace.
276+ fn run_browser_auth (
277+ profile_config : & config:: ProfileConfig ,
278+ opening_msg : & str ,
279+ waiting_msg : & str ,
280+ success_print : & str ,
281+ success_title : & str ,
282+ success_body : & str ,
283+ build_url : impl Fn ( & str , & str , & str , u16 ) -> String ,
284+ exchange : impl Fn ( & str , & str , u16 ) -> Result < crate :: jwt:: Session , String > ,
285+ ) {
259286 let app_url = profile_config. app_url . to_string ( ) ;
260-
261- // Check if already authenticated
262- if is_already_signed_in ( & profile_config) {
263- println ! ( "{}" , "You are already signed in." . green( ) ) ;
264- if !crate :: util:: is_interactive ( ) {
265- return ;
266- }
267- print ! ( "Do you want to log in again? [y/N] " ) ;
268- use std:: io:: Write ;
269- std:: io:: stdout ( ) . flush ( ) . unwrap ( ) ;
270- let mut input = String :: new ( ) ;
271- std:: io:: stdin ( ) . read_line ( & mut input) . unwrap ( ) ;
272- if !input. trim ( ) . eq_ignore_ascii_case ( "y" ) {
273- return ;
274- }
275- }
276-
277287 let code_verifier = generate_code_verifier ( ) ;
278288 let code_challenge = generate_code_challenge ( & code_verifier) ;
279289 let state = generate_random_string ( 32 ) ;
280290
281- // Bind to port 0 so the OS picks an available port. DOT's consent
282- // page will redirect here with `?code=...&state=...`.
283291 let server =
284292 tiny_http:: Server :: http ( "127.0.0.1:0" ) . expect ( "failed to start local callback server" ) ;
285293 let port = server. server_addr ( ) . to_ip ( ) . unwrap ( ) . port ( ) ;
286- let redirect_uri = format ! ( "http://127.0.0.1:{port}/" ) ;
287294
288- // DOT's `/o/authorize/` endpoint is mounted off the app URL (the
289- // browser-facing one; allauth session cookies live here). We send
290- // no `scope` parameter — the consent page picks permissions and
291- // workspace scope interactively, then composes the scope string
292- // server-side (see HotdataAllowForm).
293- let login_url = format ! (
294- "{app_url}/o/authorize/\
295- ?client_id=hotdata-cli\
296- &response_type=code\
297- &redirect_uri={redirect_uri}\
298- &code_challenge={code_challenge}\
299- &code_challenge_method=S256\
300- &state={state}",
301- app_url = app_url. trim_end_matches( '/' ) ,
302- ) ;
295+ let url = build_url ( app_url. trim_end_matches ( '/' ) , & code_challenge, & state, port) ;
303296
304- println ! ( "Opening browser to log in... " ) ;
297+ println ! ( "{opening_msg} " ) ;
305298 stdout ( )
306299 . execute ( Print ( "If your browser does not open, visit:\n " ) )
307300 . unwrap ( )
308301 . execute ( SetForegroundColor ( Color :: DarkGrey ) )
309302 . unwrap ( )
310- . execute ( Print ( format ! ( "{login_url }\n " ) ) )
303+ . execute ( Print ( format ! ( "{url }\n " ) ) )
311304 . unwrap ( )
312305 . execute ( ResetColor )
313306 . unwrap ( ) ;
314307
315- if let Err ( e) = open:: that ( & login_url ) {
308+ if let Err ( e) = open:: that ( & url ) {
316309 eprintln ! ( "failed to open browser: {e}" ) ;
317310 }
318311
319- println ! ( "Waiting for login callback... " ) ;
312+ println ! ( "{waiting_msg} " ) ;
320313
321- let code = match receive_callback ( & server, & state) {
314+ let code = match receive_callback ( & server, & state, success_title , success_body ) {
322315 Ok ( c) => c,
323316 Err ( e) => {
324317 eprintln ! ( "error: {e}" ) ;
325318 std:: process:: exit ( 1 ) ;
326319 }
327320 } ;
328321
329- match crate :: jwt :: mint_from_pkce_code ( & profile_config , & code, & code_verifier, & redirect_uri ) {
322+ match exchange ( & code, & code_verifier, port ) {
330323 Ok ( session) => {
331324 if let Err ( e) = crate :: jwt:: save_session ( & session) {
332325 eprintln ! ( "warning: could not save session: {e}" ) ;
333326 }
334327 stdout ( )
335328 . execute ( SetForegroundColor ( Color :: Green ) )
336329 . unwrap ( )
337- . execute ( Print ( "Logged in successfully. \n ") )
330+ . execute ( Print ( format ! ( "{success_print} \n ") ) )
338331 . unwrap ( )
339332 . execute ( ResetColor )
340333 . unwrap ( ) ;
341334
342- // Best-effort workspace cache using the freshly minted JWT.
343- // Fall back to the existing on-disk list if the fetch fails.
344- let workspaces = cache_workspaces ( & profile_config, & session. access_token )
345- . unwrap_or ( profile_config. workspaces ) ;
335+ let workspaces = cache_workspaces ( profile_config, & session. access_token )
336+ . unwrap_or_else ( |_| profile_config. workspaces . clone ( ) ) ;
346337 match workspaces. first ( ) {
347338 Some ( w) => {
348- print_row ( "Workspace" , & format ! ( "{} {}" , w. name. as_str( ) . cyan( ) , format!( "({})" , w. public_id) . dark_grey( ) ) ) ;
349- print_row ( "" , & "use 'hotdata workspaces set' to switch workspaces" . dark_grey ( ) . to_string ( ) ) ;
339+ print_row (
340+ "Workspace" ,
341+ & format ! ( "{} {}" , w. name. as_str( ) . cyan( ) , format!( "({})" , w. public_id) . dark_grey( ) ) ,
342+ ) ;
343+ print_row (
344+ "" ,
345+ & "use 'hotdata workspaces set' to switch workspaces" . dark_grey ( ) . to_string ( ) ,
346+ ) ;
350347 }
351348 None => print_row ( "Workspace" , & "None" . dark_grey ( ) . to_string ( ) ) ,
352349 }
@@ -358,6 +355,90 @@ pub fn login() {
358355 }
359356}
360357
358+ pub fn login ( ) {
359+ let profile_config = config:: load ( "default" ) . unwrap_or_default ( ) ;
360+
361+ if is_already_signed_in ( & profile_config) {
362+ println ! ( "{}" , "You are already signed in." . green( ) ) ;
363+ if !crate :: util:: is_interactive ( ) {
364+ return ;
365+ }
366+ print ! ( "Do you want to log in again? [y/N] " ) ;
367+ use std:: io:: Write ;
368+ std:: io:: stdout ( ) . flush ( ) . unwrap ( ) ;
369+ let mut input = String :: new ( ) ;
370+ std:: io:: stdin ( ) . read_line ( & mut input) . unwrap ( ) ;
371+ if !input. trim ( ) . eq_ignore_ascii_case ( "y" ) {
372+ return ;
373+ }
374+ }
375+
376+ // DOT's `/o/authorize/` endpoint is mounted off the app URL (the
377+ // browser-facing one; allauth session cookies live here). We send
378+ // no `scope` parameter — the consent page picks permissions and
379+ // workspace scope interactively, then composes the scope string
380+ // server-side (see HotdataAllowForm).
381+ run_browser_auth (
382+ & profile_config,
383+ "Opening browser to log in..." ,
384+ "Waiting for login callback..." ,
385+ "Logged in successfully." ,
386+ "Login successful" ,
387+ "You're now authenticated with Hotdata.<br/>You can close this tab and return to the terminal." ,
388+ |app_url, code_challenge, state, port| {
389+ let redirect_uri = format ! ( "http://127.0.0.1:{port}/" ) ;
390+ format ! (
391+ "{app_url}/o/authorize/\
392+ ?client_id=hotdata-cli\
393+ &response_type=code\
394+ &redirect_uri={redirect_uri}\
395+ &code_challenge={code_challenge}\
396+ &code_challenge_method=S256\
397+ &state={state}"
398+ )
399+ } ,
400+ |code, code_verifier, port| {
401+ let redirect_uri = format ! ( "http://127.0.0.1:{port}/" ) ;
402+ crate :: jwt:: mint_from_pkce_code ( & profile_config, code, code_verifier, & redirect_uri)
403+ } ,
404+ ) ;
405+ }
406+
407+ pub fn register ( use_email : bool ) {
408+ let profile_config = config:: load ( "default" ) . unwrap_or_default ( ) ;
409+
410+ if is_already_signed_in ( & profile_config) {
411+ println ! (
412+ "{}" ,
413+ "You are already signed in. Use 'hotdata auth login' to log in with a different account." . green( )
414+ ) ;
415+ return ;
416+ }
417+
418+ let method = if use_email { "email" } else { "github" } ;
419+ run_browser_auth (
420+ & profile_config,
421+ "Opening browser to create your account..." ,
422+ "Waiting for account setup to complete..." ,
423+ "Account created and logged in." ,
424+ "Account created" ,
425+ "Your Hotdata account is ready.<br/>You can close this tab and return to the terminal." ,
426+ |app_url, code_challenge, state, port| {
427+ format ! (
428+ "{app_url}/auth/cli-register/\
429+ ?code_challenge={code_challenge}\
430+ &code_challenge_method=S256\
431+ &state={state}\
432+ &callback_port={port}\
433+ &method={method}"
434+ )
435+ } ,
436+ |code, code_verifier, _port| {
437+ crate :: jwt:: exchange_cli_register_code ( & profile_config, code, code_verifier)
438+ } ,
439+ ) ;
440+ }
441+
361442/// Fetch workspaces with a freshly minted JWT and cache them in config.
362443/// Returns the freshly fetched list so callers can display it without
363444/// having to reload config from disk.
@@ -650,7 +731,7 @@ mod tests {
650731 . unwrap ( ) ;
651732 } ) ;
652733
653- let result = receive_callback ( & server, "expected-state" ) ;
734+ let result = receive_callback ( & server, "expected-state" , "" , "" ) ;
654735 handle. join ( ) . unwrap ( ) ;
655736
656737 assert_eq ! ( result. unwrap( ) , "test-auth-code" ) ;
@@ -670,7 +751,7 @@ mod tests {
670751 . send ( ) ;
671752 } ) ;
672753
673- let result = receive_callback ( & server, "expected-state" ) ;
754+ let result = receive_callback ( & server, "expected-state" , "" , "" ) ;
674755 handle. join ( ) . unwrap ( ) ;
675756
676757 assert ! ( result. is_err( ) ) ;
@@ -754,7 +835,7 @@ mod tests {
754835 . send ( ) ;
755836 } ) ;
756837
757- let result = receive_callback ( & server, "expected-state" ) ;
838+ let result = receive_callback ( & server, "expected-state" , "" , "" ) ;
758839 handle. join ( ) . unwrap ( ) ;
759840
760841 assert ! ( result. is_err( ) ) ;
0 commit comments