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    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/// OAuth callback result from the local HTTP server.
152///
153/// Custom `Debug` redacts the authorization `Code` and `Error` payloads —
154/// the code is a short-lived secret exchangeable for tokens, and error
155/// strings come from untrusted callback query parameters that may contain
156/// tenant identifiers, URLs, or other sensitive values.
157#[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">&gt; 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('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;")
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}