Skip to content

Commit 077c9c2

Browse files
eddietejedaclaude
andcommitted
feat(auth): add hotdata auth register command
Opens the browser to /auth/cli-register/ with PKCE params, waits for the provisioning-complete callback from the webapp, then exchanges the CLIAuthCode for a full JWT session (via mint_from_api_token) so the on-disk state is identical to a normal hotdata auth login. Changes: - auth.rs: add register(), refactor receive_callback to accept success_title/success_body for the browser confirmation page - jwt.rs: add exchange_cli_register_code — POSTs code+verifier to /v1/auth/token, gets opaque API token, mints JWT session from it - command.rs: add Register variant to AuthCommands - main.rs: dispatch AuthCommands::Register to auth::register() Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 00803f2 commit 077c9c2

4 files changed

Lines changed: 181 additions & 20 deletions

File tree

src/auth.rs

Lines changed: 134 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,13 @@ 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+
/// `success_title` and `success_body` are rendered in the browser tab on success.
173+
fn receive_callback(
174+
server: &tiny_http::Server,
175+
expected_state: &str,
176+
success_title: &str,
177+
success_body: &str,
178+
) -> Result<String, String> {
173179
let request = server.recv().map_err(|e| format!("failed to receive callback: {e}"))?;
174180
let raw_url = request.url().to_string();
175181
let params = parse_query_params(&raw_url);
@@ -187,33 +193,34 @@ fn receive_callback(server: &tiny_http::Server, expected_state: &str) -> Result<
187193
}
188194
};
189195

190-
let html = r#"<!DOCTYPE html>
196+
let html = format!(
197+
r#"<!DOCTYPE html>
191198
<html lang="en">
192199
<head>
193200
<meta charset="UTF-8" />
194201
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
195-
<title>Hotdata — Login Successful</title>
202+
<title>Hotdata — {success_title}</title>
196203
<style>
197-
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
198-
body {
204+
*, *::before, *::after {{ box-sizing: border-box; margin: 0; padding: 0; }}
205+
body {{
199206
font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
200207
background: #111827;
201208
color: #e5e7eb;
202209
display: flex;
203210
align-items: center;
204211
justify-content: center;
205212
min-height: 100vh;
206-
}
207-
.card {
213+
}}
214+
.card {{
208215
background: #1f2937;
209216
border: 1px solid #374151;
210217
border-radius: 0.5rem;
211218
padding: 2.5rem;
212219
max-width: 420px;
213220
width: 100%;
214221
text-align: center;
215-
}
216-
.icon {
222+
}}
223+
.icon {{
217224
width: 48px;
218225
height: 48px;
219226
background: #14532d;
@@ -222,10 +229,10 @@ fn receive_callback(server: &tiny_http::Server, expected_state: &str) -> Result<
222229
align-items: center;
223230
justify-content: center;
224231
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; }
232+
}}
233+
.icon svg {{ width: 24px; height: 24px; stroke: #86efac; }}
234+
h1 {{ font-size: 1.25rem; font-weight: 600; color: #f3f4f6; margin-bottom: 0.5rem; }}
235+
p {{ font-size: 0.875rem; color: #9ca3af; line-height: 1.5; }}
229236
</style>
230237
</head>
231238
<body>
@@ -235,11 +242,12 @@ fn receive_callback(server: &tiny_http::Server, expected_state: &str) -> Result<
235242
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
236243
</svg>
237244
</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>
245+
<h1>{success_title}</h1>
246+
<p>{success_body}</p>
240247
</div>
241248
</body>
242-
</html>"#;
249+
</html>"#
250+
);
243251
let response = tiny_http::Response::from_string(html).with_header(
244252
"Content-Type: text/html"
245253
.parse::<tiny_http::Header>()
@@ -318,7 +326,12 @@ pub fn login() {
318326

319327
println!("Waiting for login callback...");
320328

321-
let code = match receive_callback(&server, &state) {
329+
let code = match receive_callback(
330+
&server,
331+
&state,
332+
"Login successful",
333+
"You're now authenticated with Hotdata.<br/>You can close this tab and return to the terminal.",
334+
) {
322335
Ok(c) => c,
323336
Err(e) => {
324337
eprintln!("error: {e}");
@@ -358,6 +371,107 @@ pub fn login() {
358371
}
359372
}
360373

374+
pub fn register() {
375+
let profile_config = config::load("default").unwrap_or_default();
376+
let app_url = profile_config.app_url.to_string();
377+
378+
if is_already_signed_in(&profile_config) {
379+
println!(
380+
"{}",
381+
"You are already signed in. Use 'hotdata auth login' to log in with a different account.".green()
382+
);
383+
return;
384+
}
385+
386+
let code_verifier = generate_code_verifier();
387+
let code_challenge = generate_code_challenge(&code_verifier);
388+
let state = generate_random_string(32);
389+
390+
let server =
391+
tiny_http::Server::http("127.0.0.1:0").expect("failed to start local callback server");
392+
let port = server.server_addr().to_ip().unwrap().port();
393+
394+
let register_url = format!(
395+
"{app_url}/auth/cli-register/\
396+
?code_challenge={code_challenge}\
397+
&code_challenge_method=S256\
398+
&state={state}\
399+
&callback_port={port}",
400+
app_url = app_url.trim_end_matches('/'),
401+
);
402+
403+
println!("Opening browser to create your account...");
404+
stdout()
405+
.execute(Print("If your browser does not open, visit:\n "))
406+
.unwrap()
407+
.execute(SetForegroundColor(Color::DarkGrey))
408+
.unwrap()
409+
.execute(Print(format!("{register_url}\n")))
410+
.unwrap()
411+
.execute(ResetColor)
412+
.unwrap();
413+
414+
if let Err(e) = open::that(&register_url) {
415+
eprintln!("failed to open browser: {e}");
416+
}
417+
418+
println!("Waiting for account setup to complete...");
419+
420+
let code = match receive_callback(
421+
&server,
422+
&state,
423+
"Account created",
424+
"Your Hotdata account is ready.<br/>You can close this tab and return to the terminal.",
425+
) {
426+
Ok(c) => c,
427+
Err(e) => {
428+
eprintln!("error: {e}");
429+
std::process::exit(1);
430+
}
431+
};
432+
433+
match crate::jwt::exchange_cli_register_code(&profile_config, &code, &code_verifier) {
434+
Ok(session) => {
435+
if let Err(e) = crate::jwt::save_session(&session) {
436+
eprintln!("warning: could not save session: {e}");
437+
}
438+
stdout()
439+
.execute(SetForegroundColor(Color::Green))
440+
.unwrap()
441+
.execute(Print("Account created and logged in.\n"))
442+
.unwrap()
443+
.execute(ResetColor)
444+
.unwrap();
445+
446+
let workspaces = cache_workspaces(&profile_config, &session.access_token)
447+
.unwrap_or(profile_config.workspaces);
448+
match workspaces.first() {
449+
Some(w) => {
450+
print_row(
451+
"Workspace",
452+
&format!(
453+
"{} {}",
454+
w.name.as_str().cyan(),
455+
format!("({})", w.public_id).dark_grey()
456+
),
457+
);
458+
print_row(
459+
"",
460+
&"use 'hotdata workspaces set' to switch workspaces"
461+
.dark_grey()
462+
.to_string(),
463+
);
464+
}
465+
None => print_row("Workspace", &"None".dark_grey().to_string()),
466+
}
467+
}
468+
Err(msg) => {
469+
eprintln!("{}", msg.red());
470+
std::process::exit(1);
471+
}
472+
}
473+
}
474+
361475
/// Fetch workspaces with a freshly minted JWT and cache them in config.
362476
/// Returns the freshly fetched list so callers can display it without
363477
/// having to reload config from disk.
@@ -650,7 +764,7 @@ mod tests {
650764
.unwrap();
651765
});
652766

653-
let result = receive_callback(&server, "expected-state");
767+
let result = receive_callback(&server, "expected-state", "", "");
654768
handle.join().unwrap();
655769

656770
assert_eq!(result.unwrap(), "test-auth-code");
@@ -670,7 +784,7 @@ mod tests {
670784
.send();
671785
});
672786

673-
let result = receive_callback(&server, "expected-state");
787+
let result = receive_callback(&server, "expected-state", "", "");
674788
handle.join().unwrap();
675789

676790
assert!(result.is_err());
@@ -754,7 +868,7 @@ mod tests {
754868
.send();
755869
});
756870

757-
let result = receive_callback(&server, "expected-state");
871+
let result = receive_callback(&server, "expected-state", "", "");
758872
handle.join().unwrap();
759873

760874
assert!(result.is_err());

src/command.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,9 @@ pub enum AuthCommands {
274274
/// Log in via browser (same as `hotdata auth` with no subcommand)
275275
Login,
276276

277+
/// Create a new account via browser
278+
Register,
279+
277280
/// Remove authentication for a profile
278281
Logout,
279282

src/jwt.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,49 @@ fn redacted_form_body(params: &[(&str, &str)]) -> serde_json::Value {
153153
/// body so the caller can still parse real values out of it.
154154
const TOKEN_REDACT_KEYS: &[&str] = &["access_token", "refresh_token"];
155155

156+
/// Exchange a CLI registration PKCE code for a session.
157+
///
158+
/// The `/auth/cli-register/` flow issues a short-lived `CLIAuthCode` (not a
159+
/// full OAuth code). This function POSTs it to `/v1/auth/token` to get an
160+
/// opaque API token, then immediately mints a full JWT session via
161+
/// `mint_from_api_token` so the on-disk state is identical to a normal login.
162+
pub fn exchange_cli_register_code(
163+
profile: &config::ProfileConfig,
164+
code: &str,
165+
code_verifier: &str,
166+
) -> Result<Session, String> {
167+
let url = format!("{}/v1/auth/token", oauth_base(profile));
168+
let body = serde_json::json!({ "code": code, "code_verifier": code_verifier });
169+
let body_log = serde_json::json!({
170+
"code": util::mask_credential(code),
171+
"code_verifier": util::mask_credential(code_verifier),
172+
});
173+
174+
let client = reqwest::blocking::Client::new();
175+
let req = client.post(&url).json(&body);
176+
let (status, body_text) = util::send_debug_with_redaction(
177+
&client,
178+
req,
179+
Some(&body_log),
180+
&["token"],
181+
)
182+
.map_err(|e| format!("connection error: {e}"))?;
183+
if !status.is_success() {
184+
return Err(format!(
185+
"registration token exchange failed: HTTP {status}: {body_text}"
186+
));
187+
}
188+
189+
#[derive(Deserialize)]
190+
struct RegisterResponse {
191+
token: String,
192+
}
193+
let resp: RegisterResponse = serde_json::from_str(&body_text)
194+
.map_err(|e| format!("malformed token response: {e}"))?;
195+
196+
mint_from_api_token(profile, &resp.token)
197+
}
198+
156199
/// Exchange a PKCE authorization code for a session.
157200
pub fn mint_from_pkce_code(
158201
profile: &config::ProfileConfig,

src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ fn main() {
158158
Some(cmd) => match cmd {
159159
Commands::Auth { command } => match command {
160160
None | Some(AuthCommands::Login) => auth::login(),
161+
Some(AuthCommands::Register) => auth::register(),
161162
Some(AuthCommands::Status) => auth::status("default"),
162163
Some(AuthCommands::Logout) => auth::logout("default"),
163164
},

0 commit comments

Comments
 (0)