stormchaser_cli/commands/
auth.rs1use crate::utils::handle_response;
2use anyhow::{Context, Result};
3use clap::Subcommand;
4use serde_json::json;
5use serde_json::Value;
6
7#[derive(Subcommand)]
8pub enum AuthCommands {
9 Exchange { sso_token: String },
11}
12
13pub async fn handle(
14 url: &str,
15 http_client: &reqwest_middleware::ClientWithMiddleware,
16 command: AuthCommands,
17) -> Result<()> {
18 match command {
19 AuthCommands::Exchange { sso_token } => {
20 let res = http_client
21 .post(format!("{}/api/v1/auth/exchange", url))
22 .json(&json!({ "sso_token": sso_token }))
23 .send()
24 .await?;
25 handle_response(res).await?;
26 }
27 }
28 Ok(())
29}
30
31pub async fn handle_login(
32 cli_url: &str,
33 issuer: &str,
34 client_id: &str,
35 http_client: &reqwest_middleware::ClientWithMiddleware,
36) -> Result<()> {
37 let redirect_uri = "http://localhost:8080/callback";
38 let auth_url = format!(
39 "{}/auth?client_id={}&redirect_uri={}&response_type=code&scope=openid+profile+email",
40 issuer.trim_end_matches('/'),
41 client_id,
42 redirect_uri
43 );
44
45 println!("Opening browser for authentication...");
46 println!("If the browser does not open automatically, please visit:");
47 println!("{}", auth_url);
48
49 if let Err(e) = open::that(&auth_url) {
50 eprintln!("Failed to open browser: {}", e);
51 }
52
53 let listener = tokio::net::TcpListener::bind("127.0.0.1:8080")
54 .await
55 .context(
56 "Failed to bind callback server on port 8080. Is another login process running?",
57 )?;
58
59 let (mut stream, _) = listener
60 .accept()
61 .await
62 .context("Failed to accept callback connection")?;
63
64 use tokio::io::{AsyncReadExt, AsyncWriteExt};
65 let mut buf = [0; 4096];
66 let mut request_str = String::new();
67 if let Ok(n) = stream.read(&mut buf).await {
68 request_str = String::from_utf8_lossy(&buf[0..n]).to_string();
69 }
70
71 let mut code = None;
72 for line in request_str.lines() {
73 if line.starts_with("GET /callback") {
74 if let Some(query) = line
75 .split_whitespace()
76 .nth(1)
77 .and_then(|p| p.split('?').nth(1))
78 {
79 for pair in query.split('&') {
80 if let Some((k, v)) = pair.split_once('=') {
81 if k == "code" {
82 code = Some(v.to_string());
83 }
84 }
85 }
86 }
87 break;
88 }
89 }
90
91 let response =
92 "HTTP/1.1 200 OK\r\nContent-Length: 44\r\n\r\nLogin successful. You can close this window.";
93 let _ = stream.write_all(response.as_bytes()).await;
94 let _ = stream.flush().await;
95
96 let code = match code {
97 Some(c) => c,
98 None => {
99 anyhow::bail!("Failed to parse authorization code from callback. Response may have been an error.");
100 }
101 };
102
103 println!("Exchanging authorization code for token...");
104
105 let client = reqwest::Client::new();
107 let token_res = client
108 .post(format!("{}/token", issuer.trim_end_matches('/')))
109 .form(&[
110 ("grant_type", "authorization_code"),
111 ("client_id", client_id),
112 ("client_secret", "stormchaser-cli-secret"),
113 ("redirect_uri", redirect_uri),
114 ("code", &code),
115 ])
116 .send()
117 .await?;
118
119 let sso_token = if token_res.status().is_success() {
120 let json: Value = token_res.json().await.unwrap_or_default();
121 if let Some(id_token) = json.get("id_token").and_then(|v| v.as_str()) {
122 id_token.to_string()
123 } else {
124 anyhow::bail!("Dex response missing id_token");
125 }
126 } else {
127 anyhow::bail!("Dex token exchange failed: {}", token_res.status());
128 };
129
130 println!("Exchanging SSO token for Stormchaser JWT...");
131 let res = http_client
132 .post(format!("{}/api/v1/auth/exchange", cli_url))
133 .json(&json!({ "sso_token": sso_token }))
134 .send()
135 .await?;
136
137 let status = res.status();
140 let body = res.text().await.unwrap_or_default();
141 if status.is_success() {
142 if let Ok(val) = serde_json::from_str::<Value>(&body) {
143 if let Some(access_token) = val.get("access_token").and_then(|t| t.as_str()) {
144 println!("\nSuccessfully logged in! Export your token to use it:");
145 println!("export STORMCHASER_TOKEN=\"{}\"", access_token);
146 } else {
147 println!("Success, but could not parse access_token from response.");
148 println!("{}", serde_json::to_string_pretty(&val).unwrap_or(body));
149 }
150 } else {
151 println!("Success:\n{}", body);
152 }
153 } else {
154 eprintln!("Error exchanging SSO token ({}): {}", status, body);
155 }
156
157 Ok(())
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163 use reqwest_middleware::ClientBuilder;
164 use wiremock::matchers::{method, path};
165 use wiremock::{Mock, MockServer, ResponseTemplate};
166
167 #[tokio::test]
168 async fn test_auth_exchange() {
169 let server = MockServer::start().await;
170 Mock::given(method("POST"))
171 .and(path("/api/v1/auth/exchange"))
172 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"access_token": "test"})))
173 .mount(&server)
174 .await;
175
176 let client = ClientBuilder::new(reqwest::Client::new()).build();
177 let cmd = AuthCommands::Exchange {
178 sso_token: "test_sso".to_string(),
179 };
180
181 let result = handle(&server.uri(), &client, cmd).await;
182 assert!(result.is_ok());
183 }
184}