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