synaps_cli/core/auth/
callback.rs1use 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
34pub 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
51pub 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}