1use anyhow::{Context, Result};
2use axum::{
3 Router,
4 extract::{Query, State},
5 response::Html,
6 routing::get,
7};
8use serde::{Deserialize, Serialize};
9use std::fmt;
10use std::net::SocketAddr;
11use std::str::FromStr;
12use std::sync::Arc;
13use std::time::Duration;
14use tokio::sync::{mpsc, oneshot};
15
16const DEFAULT_CALLBACK_TIMEOUT_SECS: u64 = 300;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
19#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
20#[serde(rename_all = "snake_case")]
21pub enum OAuthProvider {
22 OpenAi,
23 OpenRouter,
24}
25
26impl OAuthProvider {
27 #[must_use]
28 pub fn slug(self) -> &'static str {
29 match self {
30 Self::OpenAi => "openai",
31 Self::OpenRouter => "openrouter",
32 }
33 }
34
35 #[must_use]
36 pub fn display_name(self) -> &'static str {
37 match self {
38 Self::OpenAi => "OpenAI",
39 Self::OpenRouter => "OpenRouter",
40 }
41 }
42
43 #[must_use]
44 pub fn subtitle(self) -> &'static str {
45 match self {
46 Self::OpenAi => "Your ChatGPT subscription is now connected.",
47 Self::OpenRouter => "Your OpenRouter account is now connected.",
48 }
49 }
50
51 #[must_use]
52 pub fn failure_subtitle(self) -> &'static str {
53 match self {
54 Self::OpenAi => "Unable to connect your ChatGPT subscription.",
55 Self::OpenRouter => "Unable to connect your OpenRouter account.",
56 }
57 }
58
59 #[must_use]
60 pub fn retry_hint(self) -> String {
61 format!("You can try again anytime using /login {}", self.slug())
62 }
63
64 #[must_use]
65 pub fn supports_manual_refresh(self) -> bool {
66 matches!(self, Self::OpenAi)
67 }
68}
69
70impl fmt::Display for OAuthProvider {
71 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72 f.write_str(self.slug())
73 }
74}
75
76impl FromStr for OAuthProvider {
77 type Err = ();
78
79 fn from_str(value: &str) -> std::result::Result<Self, Self::Err> {
80 match value.trim().to_ascii_lowercase().as_str() {
81 "openai" => Ok(Self::OpenAi),
82 "openrouter" => Ok(Self::OpenRouter),
83 _ => Err(()),
84 }
85 }
86}
87
88#[derive(Debug, Clone, Copy)]
89pub struct OAuthCallbackPage {
90 provider_slug: &'static str,
91 success_subtitle: &'static str,
92 failure_subtitle: &'static str,
93 retry_hint: &'static str,
94}
95
96impl OAuthCallbackPage {
97 #[must_use]
98 pub fn new(provider: OAuthProvider) -> Self {
99 match provider {
100 OAuthProvider::OpenAi => Self {
101 provider_slug: "openai",
102 success_subtitle: "Your ChatGPT subscription is now connected.",
103 failure_subtitle: "Unable to connect your ChatGPT subscription.",
104 retry_hint: "You can try again anytime using /login openai",
105 },
106 OAuthProvider::OpenRouter => Self {
107 provider_slug: "openrouter",
108 success_subtitle: "Your OpenRouter account is now connected.",
109 failure_subtitle: "Unable to connect your OpenRouter account.",
110 retry_hint: "You can try again anytime using /login openrouter",
111 },
112 }
113 }
114
115 #[must_use]
116 pub fn custom(
117 provider_slug: &'static str,
118 success_subtitle: &'static str,
119 failure_subtitle: &'static str,
120 retry_hint: &'static str,
121 ) -> Self {
122 Self {
123 provider_slug,
124 success_subtitle,
125 failure_subtitle,
126 retry_hint,
127 }
128 }
129
130 #[must_use]
131 pub fn provider_slug(&self) -> &'static str {
132 self.provider_slug
133 }
134
135 #[must_use]
136 pub fn success_subtitle(&self) -> &'static str {
137 self.success_subtitle
138 }
139
140 #[must_use]
141 pub fn failure_subtitle(&self) -> &'static str {
142 self.failure_subtitle
143 }
144
145 #[must_use]
146 pub fn retry_hint(&self) -> &'static str {
147 self.retry_hint
148 }
149}
150
151#[derive(Debug, Clone)]
152pub enum AuthCallbackOutcome {
153 Code(String),
154 Cancelled,
155 Error(String),
156}
157
158pub struct AuthCodeCallbackServer {
159 timeout: Duration,
160 result_rx: mpsc::Receiver<AuthCallbackOutcome>,
161 shutdown_tx: Option<oneshot::Sender<()>>,
162 server_handle: Option<tokio::task::JoinHandle<()>>,
163}
164
165impl AuthCodeCallbackServer {
166 pub async fn start(
167 port: u16,
168 timeout_secs: u64,
169 page: OAuthCallbackPage,
170 expected_state: Option<String>,
171 ) -> Result<Self> {
172 let (result_tx, result_rx) = mpsc::channel::<AuthCallbackOutcome>(1);
173 let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
174 let state = Arc::new(AuthCallbackState { page, expected_state, result_tx });
175
176 let app = Router::new()
177 .route("/callback", get(handle_callback))
178 .route("/auth/callback", get(handle_callback))
179 .route("/cancel", get(handle_cancel))
180 .route("/health", get(|| async { "OK" }))
181 .with_state(state);
182
183 let addr = SocketAddr::from(([127, 0, 0, 1], port));
184 let listener = tokio::net::TcpListener::bind(addr)
185 .await
186 .with_context(|| format!("failed to bind localhost callback server on port {port}"))?;
187
188 let server = axum::serve(listener, app).with_graceful_shutdown(async move {
189 let _ = shutdown_rx.await;
190 });
191 let server_handle = tokio::spawn(async move {
192 if let Err(err) = server.await {
193 tracing::error!("OAuth callback server error: {}", err);
194 }
195 });
196
197 Ok(Self {
198 timeout: callback_timeout(timeout_secs),
199 result_rx,
200 shutdown_tx: Some(shutdown_tx),
201 server_handle: Some(server_handle),
202 })
203 }
204
205 pub async fn wait(mut self) -> Result<AuthCallbackOutcome> {
206 let result = tokio::select! {
207 Some(result) = self.result_rx.recv() => result,
208 _ = tokio::time::sleep(self.timeout) => {
209 AuthCallbackOutcome::Error(format!(
210 "OAuth flow timed out after {} seconds",
211 self.timeout.as_secs()
212 ))
213 }
214 };
215
216 self.shutdown().await;
217 Ok(result)
218 }
219
220 async fn shutdown(&mut self) {
221 if let Some(shutdown_tx) = self.shutdown_tx.take() {
222 let _ = shutdown_tx.send(());
223 }
224 if let Some(server_handle) = self.server_handle.take() {
225 let _ = server_handle.await;
226 }
227 }
228}
229
230impl Drop for AuthCodeCallbackServer {
231 fn drop(&mut self) {
232 if let Some(shutdown_tx) = self.shutdown_tx.take() {
233 let _ = shutdown_tx.send(());
234 }
235 if let Some(server_handle) = self.server_handle.take() {
236 server_handle.abort();
237 }
238 }
239}
240
241#[derive(Debug, Deserialize)]
242struct AuthCallbackParams {
243 code: Option<String>,
244 error: Option<String>,
245 error_description: Option<String>,
246 state: Option<String>,
247}
248
249struct AuthCallbackState {
250 page: OAuthCallbackPage,
251 expected_state: Option<String>,
252 result_tx: mpsc::Sender<AuthCallbackOutcome>,
253}
254
255pub async fn start_auth_code_callback_server(
256 port: u16,
257 timeout_secs: u64,
258 page: OAuthCallbackPage,
259 expected_state: Option<String>,
260) -> Result<AuthCodeCallbackServer> {
261 AuthCodeCallbackServer::start(port, timeout_secs, page, expected_state).await
262}
263
264pub async fn run_auth_code_callback_server(
265 port: u16,
266 timeout_secs: u64,
267 page: OAuthCallbackPage,
268 expected_state: Option<String>,
269) -> Result<AuthCallbackOutcome> {
270 start_auth_code_callback_server(port, timeout_secs, page, expected_state)
271 .await?
272 .wait()
273 .await
274}
275
276async fn handle_callback(
277 State(state): State<Arc<AuthCallbackState>>,
278 Query(params): Query<AuthCallbackParams>,
279) -> Html<String> {
280 tracing::info!(
281 provider = state.page.provider_slug(),
282 has_code = params.code.is_some(),
283 has_error = params.error.is_some(),
284 "received oauth callback"
285 );
286 if let Some(expected_state) = state.expected_state.as_deref() {
287 match params.state.as_deref() {
288 Some(actual_state) if actual_state == expected_state => {}
289 _ => {
290 let message = "OAuth error: state mismatch".to_string();
291 let _ = state.result_tx.send(AuthCallbackOutcome::Error(message.clone())).await;
292 return Html(error_html(state.page, &message));
293 }
294 }
295 }
296
297 if let Some(error) = params.error {
298 let message = match params.error_description {
299 Some(description) if !description.trim().is_empty() => {
300 format!("OAuth error: {error} - {description}")
301 }
302 _ => format!("OAuth error: {error}"),
303 };
304 let _ = state.result_tx.send(AuthCallbackOutcome::Error(message.clone())).await;
305 return Html(error_html(state.page, &message));
306 }
307
308 let Some(code) = params.code else {
309 let message = "Missing authorization code".to_string();
310 let _ = state.result_tx.send(AuthCallbackOutcome::Error(message.clone())).await;
311 return Html(error_html(state.page, &message));
312 };
313
314 let _ = state.result_tx.send(AuthCallbackOutcome::Code(code)).await;
315 Html(success_html(state.page))
316}
317
318async fn handle_cancel(State(state): State<Arc<AuthCallbackState>>) -> Html<String> {
319 let _ = state.result_tx.send(AuthCallbackOutcome::Cancelled).await;
320 Html(cancelled_html(state.page))
321}
322
323fn success_html(page: OAuthCallbackPage) -> String {
324 base_html(
325 "Authentication Successful",
326 page.success_subtitle(),
327 Some("You may now close this window and return to VT Code."),
328 "✓",
329 "#22c55e",
330 None,
331 )
332}
333
334fn error_html(page: OAuthCallbackPage, error: &str) -> String {
335 base_html("Authentication Failed", page.failure_subtitle(), None, "✕", "#ef4444", Some(error))
336}
337
338fn cancelled_html(page: OAuthCallbackPage) -> String {
339 base_html("Authentication Cancelled", page.retry_hint(), None, "—", "#71717a", None)
340}
341
342fn base_html(
343 title: &str,
344 subtitle: &str,
345 close_note: Option<&str>,
346 icon: &str,
347 accent: &str,
348 error: Option<&str>,
349) -> String {
350 let close_note_html = close_note
351 .map(|value| format!(r#"<p class="close-note">{}</p>"#, html_escape(value)))
352 .unwrap_or_default();
353 let error_html = error
354 .map(|value| format!(r#"<div class="error">{}</div>"#, html_escape(value)))
355 .unwrap_or_default();
356 let auto_close = if close_note.is_some() {
357 r#"<script>setTimeout(() => window.close(), 3000);</script>"#
358 } else {
359 ""
360 };
361
362 format!(
363 r##"<!DOCTYPE html>
364<html>
365<head>
366 <title>VT Code - {title}</title>
367 <style>
368 @font-face {{
369 font-family: 'SF Pro Display';
370 src: local('SF Pro Display'), local('.SF NS Display'), local('Helvetica Neue');
371 }}
372 @font-face {{
373 font-family: 'SF Mono';
374 src: local('SF Mono'), local('Menlo'), local('Monaco');
375 }}
376 :root {{
377 color-scheme: dark;
378 --bg: #0a0a0a;
379 --panel: #111111;
380 --panel-border: #262626;
381 --text: #fafafa;
382 --muted: #a1a1aa;
383 --subtle: #52525b;
384 --code-bg: #18181b;
385 --code-border: #27272a;
386 --accent: {accent};
387 }}
388 * {{ box-sizing: border-box; }}
389 body {{
390 font-family: 'SF Pro Display', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
391 display: flex;
392 justify-content: center;
393 align-items: center;
394 min-height: 100vh;
395 margin: 0;
396 background:
397 radial-gradient(circle at top, rgba(255,255,255,0.04), transparent 32%),
398 linear-gradient(180deg, var(--bg), #050505);
399 color: var(--text);
400 padding: 24px;
401 }}
402 .container {{
403 text-align: center;
404 padding: 2.75rem 3rem;
405 border: 1px solid var(--panel-border);
406 border-radius: 14px;
407 background: rgba(17, 17, 17, 0.92);
408 max-width: 460px;
409 width: 100%;
410 box-shadow: 0 30px 90px rgba(0, 0, 0, 0.35);
411 }}
412 .logo {{
413 margin-bottom: 1.5rem;
414 }}
415 .logo-mark {{
416 display: inline-flex;
417 align-items: center;
418 justify-content: center;
419 font-size: 0.95rem;
420 letter-spacing: 0.24em;
421 text-transform: uppercase;
422 color: var(--muted);
423 }}
424 .status-icon {{
425 width: 52px;
426 height: 52px;
427 margin: 0 auto 1.25rem;
428 border: 2px solid var(--accent);
429 border-radius: 50%;
430 display: flex;
431 align-items: center;
432 justify-content: center;
433 font-size: 1.25rem;
434 color: var(--accent);
435 }}
436 h1 {{
437 margin: 0 0 0.75rem 0;
438 font-size: 1.25rem;
439 font-weight: 600;
440 letter-spacing: -0.02em;
441 }}
442 p {{
443 color: var(--muted);
444 margin: 0;
445 font-size: 0.92rem;
446 line-height: 1.55;
447 }}
448 .close-note {{
449 margin-top: 1.25rem;
450 font-size: 0.78rem;
451 color: var(--subtle);
452 }}
453 .error {{
454 margin-top: 1.35rem;
455 padding: 0.95rem 1rem;
456 background: var(--code-bg);
457 border: 1px solid var(--code-border);
458 border-radius: 10px;
459 font-family: 'SF Mono', Menlo, Monaco, monospace;
460 font-size: 0.75rem;
461 color: #d4d4d8;
462 word-break: break-word;
463 text-align: left;
464 }}
465 </style>
466</head>
467<body>
468 <div class="container">
469 <div class="logo">
470 <div class="logo-mark">> VT Code</div>
471 </div>
472 <div class="status-icon">{icon}</div>
473 <h1>{title}</h1>
474 <p>{subtitle}</p>
475 {close_note_html}
476 {error_html}
477 </div>
478 {auto_close}
479</body>
480</html>"##,
481 title = html_escape(title),
482 subtitle = html_escape(subtitle),
483 icon = icon,
484 accent = accent,
485 close_note_html = close_note_html,
486 error_html = error_html,
487 auto_close = auto_close,
488 )
489}
490
491fn html_escape(value: &str) -> String {
492 value.replace('&', "&").replace('<', "<").replace('>', ">")
493}
494
495fn callback_timeout(timeout_secs: u64) -> Duration {
496 Duration::from_secs(if timeout_secs == 0 {
497 DEFAULT_CALLBACK_TIMEOUT_SECS
498 } else {
499 timeout_secs
500 })
501}
502
503#[cfg(test)]
504mod tests {
505 use super::*;
506 use axum::extract::{Query, State};
507 use reqwest::Client;
508
509 #[test]
510 fn oauth_provider_parses_known_providers() {
511 assert_eq!("openai".parse::<OAuthProvider>(), Ok(OAuthProvider::OpenAi));
512 assert_eq!("openrouter".parse::<OAuthProvider>(), Ok(OAuthProvider::OpenRouter));
513 assert!("other".parse::<OAuthProvider>().is_err());
514 }
515
516 #[test]
517 fn success_html_mentions_vtcode_and_autoclose() {
518 let html = success_html(OAuthCallbackPage::new(OAuthProvider::OpenAi));
519 assert!(html.contains("VT Code"));
520 assert!(html.contains("Authentication Successful"));
521 assert!(html.contains("window.close"));
522 }
523
524 #[tokio::test]
525 async fn callback_rejects_state_mismatch() {
526 let (result_tx, mut result_rx) = mpsc::channel(1);
527 let state = Arc::new(AuthCallbackState {
528 page: OAuthCallbackPage::new(OAuthProvider::OpenAi),
529 expected_state: Some("expected-state".to_string()),
530 result_tx,
531 });
532
533 let html = handle_callback(
534 State(state),
535 Query(AuthCallbackParams {
536 code: Some("auth-code".to_string()),
537 error: None,
538 error_description: None,
539 state: Some("wrong-state".to_string()),
540 }),
541 )
542 .await;
543
544 let outcome = result_rx.recv().await.expect("callback outcome");
545 match outcome {
546 AuthCallbackOutcome::Error(message) => {
547 assert!(message.contains("state mismatch"));
548 }
549 _ => panic!("expected error outcome"),
550 }
551 assert!(html.0.contains("Authentication Failed"));
552 }
553
554 #[tokio::test]
555 async fn callback_server_starts_listening_before_wait() {
556 let listener = match std::net::TcpListener::bind(("127.0.0.1", 0)) {
557 Ok(listener) => listener,
558 Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => return,
559 Err(err) => panic!("bind temp port: {err}"),
560 };
561 let port = listener.local_addr().expect("local addr").port();
562 drop(listener);
563
564 let server = start_auth_code_callback_server(
565 port,
566 5,
567 OAuthCallbackPage::new(OAuthProvider::OpenAi),
568 None,
569 )
570 .await
571 .expect("start callback server");
572 let client = Client::builder().no_proxy().build().expect("build http client");
573
574 let health = client
575 .get(format!("http://127.0.0.1:{port}/health"))
576 .send()
577 .await
578 .expect("health request should succeed");
579 assert!(health.status().is_success());
580
581 let cancel = client
582 .get(format!("http://127.0.0.1:{port}/cancel"))
583 .send()
584 .await
585 .expect("cancel request should succeed");
586 assert!(cancel.status().is_success());
587
588 assert!(matches!(
589 server.wait().await.expect("wait for callback outcome"),
590 AuthCallbackOutcome::Cancelled
591 ));
592 }
593}