Skip to content

Commit 9725898

Browse files
authored
Merge pull request #85 from hotdata-dev/feat/auth-register
feat(auth): add hotdata auth register command
2 parents b287a30 + f320b6c commit 9725898

4 files changed

Lines changed: 274 additions & 69 deletions

File tree

src/auth.rs

Lines changed: 150 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,16 @@ struct WsListResponse { workspaces: Vec<WsItem> }
169169
struct 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());

src/command.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,13 @@ pub enum AuthCommands {
277277
/// Log in via browser (same as `hotdata auth` with no subcommand)
278278
Login,
279279

280+
/// Create a new account via browser (defaults to GitHub OAuth)
281+
Register {
282+
/// Sign up with email and password instead of GitHub
283+
#[arg(long)]
284+
email: bool,
285+
},
286+
280287
/// Remove authentication for a profile
281288
Logout,
282289

0 commit comments

Comments
 (0)