Skip to main content

vtcode_auth/
oauth_server.rs

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">&gt; 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('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;")
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}