Skip to main content

synaps_cli/core/auth/
callback.rs

1use std::sync::Arc;
2use tokio::sync::{oneshot, Mutex};
3
4use super::{CallbackResult, CALLBACK_HOST};
5
6pub(crate) const SUCCESS_HTML: &str = r#"<!doctype html>
7<html><head><meta charset="utf-8"><title>Login Successful</title>
8<style>
9body { background: #09090b; color: #fafafa; font-family: system-ui; display: flex;
10       align-items: center; justify-content: center; min-height: 100vh; margin: 0; }
11main { text-align: center; max-width: 480px; }
12h1 { font-size: 24px; margin-bottom: 8px; }
13p { color: #a1a1aa; }
14</style></head>
15<body><main>
16<h1>✓ Authentication successful</h1>
17<p>You can close this window and return to your terminal.</p>
18</main></body></html>"#;
19
20pub(crate) const ERROR_HTML: &str = r#"<!doctype html>
21<html><head><meta charset="utf-8"><title>Login Failed</title>
22<style>
23body { background: #09090b; color: #fafafa; font-family: system-ui; display: flex;
24       align-items: center; justify-content: center; min-height: 100vh; margin: 0; }
25main { text-align: center; max-width: 480px; }
26h1 { font-size: 24px; margin-bottom: 8px; color: #ef4444; }
27p { color: #a1a1aa; }
28</style></head>
29<body><main>
30<h1>✗ Authentication failed</h1>
31<p>Something went wrong. Please try again.</p>
32</main></body></html>"#;
33
34/// Handle to shut down the callback server.
35pub struct CallbackServerHandle {
36    shutdown: Option<oneshot::Sender<()>>,
37    task: Option<tokio::task::JoinHandle<()>>,
38}
39
40impl CallbackServerHandle {
41    pub async fn shutdown(mut self) {
42        if let Some(tx) = self.shutdown.take() {
43            let _ = tx.send(());
44        }
45        if let Some(task) = self.task.take() {
46            let _ = tokio::time::timeout(std::time::Duration::from_secs(2), task).await;
47        }
48    }
49}
50
51/// Start a temporary HTTP server on localhost that captures the OAuth callback.
52/// Returns a oneshot receiver that resolves with the auth code + state.
53pub async fn start_callback_server(
54    expected_state: String,
55    port: u16,
56) -> std::result::Result<(oneshot::Receiver<CallbackResult>, CallbackServerHandle), String> {
57    let (tx, rx) = oneshot::channel::<CallbackResult>();
58    let tx = Arc::new(Mutex::new(Some(tx)));
59
60    let expected = expected_state.clone();
61    let tx_clone = tx.clone();
62
63    let handler = move |query: axum::extract::Query<std::collections::HashMap<String, String>>| {
64        let tx = tx_clone.clone();
65        let expected = expected.clone();
66        async move {
67            let code = query.get("code").cloned();
68            let state = query.get("state").cloned();
69            let error = query.get("error").cloned();
70
71            if let Some(err) = error {
72                eprintln!("OAuth error from provider: {}", err);
73                return axum::response::Html(ERROR_HTML.to_string());
74            }
75
76            let (code, state) = match (code, state) {
77                (Some(c), Some(s)) => (c, s),
78                _ => {
79                    return axum::response::Html(ERROR_HTML.to_string());
80                }
81            };
82
83            if state != expected {
84                eprintln!("State mismatch: expected {}, got {}", expected, state);
85                return axum::response::Html(ERROR_HTML.to_string());
86            }
87
88            if let Some(sender) = tx.lock().await.take() {
89                let _ = sender.send(CallbackResult {
90                    code,
91                    state,
92                });
93            }
94
95            axum::response::Html(SUCCESS_HTML.to_string())
96        }
97    };
98
99    let app = axum::Router::new()
100        .route("/callback", axum::routing::get(handler.clone()))
101        .route("/auth/callback", axum::routing::get(handler));
102
103    let addr = format!("{}:{}", CALLBACK_HOST, port);
104    let listener = tokio::net::TcpListener::bind(&addr)
105        .await
106        .map_err(|e| format!("Failed to bind callback server on {}: {}", addr, e))?;
107
108    let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
109
110    let server_handle = tokio::spawn(async move {
111        axum::serve(listener, app)
112            .with_graceful_shutdown(async {
113                let _ = shutdown_rx.await;
114            })
115            .await
116            .ok();
117    });
118
119    Ok((
120        rx,
121        CallbackServerHandle {
122            shutdown: Some(shutdown_tx),
123            task: Some(server_handle),
124        },
125    ))
126}