Skip to main content

stormchaser_cli/commands/
auth.rs

1use 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 an SSO token for a Stormchaser JWT
10    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    // The client uses the standard reqwest client, not the wrapped one, for this specific call because it's external
106    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    // To mirror typical CLI login experience, let's parse the response and print the token directly
138    // so the user can easily export it
139    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}