File size: 9,562 Bytes
afa0cbf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
//! CLI MCP OAuth login modes and bounded, hidden callback input.
//!
//! Manual input remains cancellable while the OAuth flow waits for its HTTP
//! callback, and dropping terminal input restores the terminal immediately.
//! Ctrl-C also cancels token exchange and scope retries in manual login mode.

use std::collections::HashMap;
use std::io;
use std::io::BufRead;
use std::io::IsTerminal;
use std::io::Write;
use std::sync::Arc;

use anyhow::Context;
use anyhow::Result;
use anyhow::bail;
use codex_config::types::AuthKeyringBackendKind;
use codex_config::types::OAuthCredentialsStoreMode;
use codex_exec_server::HttpClient;
use codex_mcp::ResolvedMcpOAuthScopes;
use codex_mcp::should_retry_without_scopes;
use codex_rmcp_client::McpOAuthClientRegistration;
use codex_rmcp_client::perform_oauth_login;
use codex_rmcp_client::perform_oauth_login_with_callback_input;
use crossterm::event::Event;
use crossterm::event::EventStream;
use crossterm::event::KeyCode;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use crossterm::terminal;
use futures::StreamExt;
use tokio::sync::oneshot;

const MAX_CALLBACK_BYTES: usize = 64 * 1024;

#[derive(Clone, Copy)]
pub(crate) enum McpLoginMode {
    Browser,
    PasteCallback,
}

/// Retry provider-rejected discovered scopes once, retaining the selected input mode.
#[allow(clippy::too_many_arguments)]
pub(crate) async fn perform_oauth_login_retry_without_scopes(
    name: &str,
    url: &str,
    store_mode: OAuthCredentialsStoreMode,
    keyring_backend_kind: AuthKeyringBackendKind,
    http_headers: Option<HashMap<String, String>>,
    env_http_headers: Option<HashMap<String, String>>,
    resolved_scopes: &ResolvedMcpOAuthScopes,
    oauth_client_id: Option<&str>,
    client_registration: McpOAuthClientRegistration,
    oauth_resource: Option<&str>,
    callback_port: Option<u16>,
    callback_url: Option<&str>,
    global_callback_url: Option<&str>,
    http_client: Arc<dyn HttpClient>,
    mode: McpLoginMode,
) -> Result<()> {
    let mut pending_input = None;
    // Tokio retains its process-wide signal handler, so keep listening throughout
    // token exchange and scope retries after the callback input reader is dropped.
    let ctrl_c = tokio::signal::ctrl_c();
    tokio::pin!(ctrl_c);
    for (attempt, scopes) in [resolved_scopes.scopes.as_slice(), &[]]
        .into_iter()
        .enumerate()
    {
        let login = async {
            match mode {
                McpLoginMode::Browser => {
                    perform_oauth_login(
                        name,
                        url,
                        store_mode,
                        keyring_backend_kind,
                        http_headers.clone(),
                        env_http_headers.clone(),
                        scopes,
                        oauth_client_id,
                        client_registration,
                        oauth_resource,
                        callback_port,
                        callback_url,
                        global_callback_url,
                        Arc::clone(&http_client),
                    )
                    .await
                }
                McpLoginMode::PasteCallback => {
                    let input = &mut pending_input;
                    perform_oauth_login_with_callback_input(
                        name,
                        url,
                        store_mode,
                        keyring_backend_kind,
                        http_headers.clone(),
                        env_http_headers.clone(),
                        scopes,
                        oauth_client_id,
                        client_registration,
                        oauth_resource,
                        callback_port,
                        callback_url,
                        global_callback_url,
                        Arc::clone(&http_client),
                        move |authorization_url| read_callback(authorization_url, input),
                    )
                    .await
                }
            }
        };
        let result = tokio::select! {
            result = login => result,
            result = &mut ctrl_c, if matches!(mode, McpLoginMode::PasteCallback) => {
                result.context("failed to listen for Ctrl-C")?;
                bail!("OAuth login cancelled");
            }
        };
        match result {
            Err(error) if attempt == 0 && should_retry_without_scopes(resolved_scopes, &error) => {
                println!("OAuth provider rejected discovered scopes. Retrying without scopes…");
            }
            result => return result,
        }
    }
    unreachable!("the empty-scope attempt always returns")
}

async fn read_callback(
    authorization_url: String,
    pending_input: &mut Option<oneshot::Receiver<Result<String>>>,
) -> Result<String> {
    println!(
        "Authorize the MCP server by opening this URL in your browser:\n{authorization_url}\n"
    );
    println!(
        "After signing in, copy the full URL from your browser's address bar.\n\
         If the callback page cannot load, paste that URL here anyway."
    );
    if io::stdin().is_terminal() {
        return read_terminal_callback().await;
    }
    print_callback_prompt()?;

    // A pipe may stay open after the HTTP callback wins. A detached thread avoids
    // making runtime shutdown wait for an uncancellable blocking stdin read.
    // Retain an unfinished read if an HTTP provider error triggers a scope retry.
    // Otherwise its detached reader could consume the next attempt's pasted URL.
    if pending_input.is_none() {
        let (tx, rx) = oneshot::channel();
        std::thread::Builder::new()
            .name("mcp-oauth-callback-input".to_string())
            .spawn(move || {
                let result = read_callback_line(io::stdin().lock());
                let _ = tx.send(result);
            })
            .context("failed to start callback input reader")?;
        *pending_input = Some(rx);
    }
    let reader = pending_input
        .as_mut()
        .context("callback input reader was unavailable")?;
    let result = reader.await;
    *pending_input = None;
    result.context("callback input reader stopped")?
}

fn read_callback_line(reader: impl BufRead) -> Result<String> {
    let mut bytes = Vec::new();
    reader
        .take((MAX_CALLBACK_BYTES + 3) as u64)
        .read_until(b'\n', &mut bytes)
        .context("failed to read callback URL")?;
    if bytes.ends_with(b"\n") {
        bytes.pop();
    }
    if bytes.ends_with(b"\r") {
        bytes.pop();
    }
    if bytes.len() > MAX_CALLBACK_BYTES {
        bail!("OAuth callback URL exceeds 64 KiB");
    }
    if bytes.is_empty() {
        bail!("No OAuth callback URL received before input closed");
    }
    String::from_utf8(bytes).map_err(|_| anyhow::anyhow!("OAuth callback URL must be valid UTF-8"))
}

struct TerminalInputGuard;

impl Drop for TerminalInputGuard {
    fn drop(&mut self) {
        let _ = terminal::disable_raw_mode();
        eprintln!();
    }
}

fn print_callback_prompt() -> Result<()> {
    eprint!("Callback URL (input hidden): ");
    io::stderr()
        .flush()
        .context("failed to display callback prompt")
}

async fn read_terminal_callback() -> Result<String> {
    terminal::enable_raw_mode().context("failed to hide callback input")?;
    let _guard = TerminalInputGuard;
    print_callback_prompt()?;
    let mut events = EventStream::new();
    let mut input = String::new();
    loop {
        let event = events
            .next()
            .await
            .context("callback input stream closed")?
            .context("failed to read callback input")?;
        match event {
            Event::Key(key) if key.kind != KeyEventKind::Release => match key.code {
                KeyCode::Enter => {
                    if input.is_empty() {
                        bail!("No OAuth callback URL entered");
                    }
                    return Ok(input);
                }
                KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
                    bail!("OAuth login cancelled");
                }
                KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
                    bail!("No OAuth callback URL received before input closed");
                }
                KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
                    input.clear()
                }
                KeyCode::Backspace => {
                    input.pop();
                }
                KeyCode::Char(character)
                    if !character.is_control()
                        && !key.modifiers.contains(KeyModifiers::CONTROL) =>
                {
                    if input.len() + character.len_utf8() > MAX_CALLBACK_BYTES {
                        bail!("OAuth callback URL exceeds 64 KiB");
                    }
                    input.push(character);
                }
                _ => {}
            },
            Event::Paste(paste) => {
                if input.len() + paste.len() > MAX_CALLBACK_BYTES {
                    bail!("OAuth callback URL exceeds 64 KiB");
                }
                input.push_str(&paste);
            }
            _ => {}
        }
        // Yield even with queued input so callback completion and timeout stay responsive.
        tokio::task::yield_now().await;
    }
}

#[cfg(test)]
#[path = "mcp_login_tests.rs"]
mod tests;