Skip to main content

yapi_core/mcp/
sign_in.rs

1//! OAuth sign-in for remote MCP servers. Port of `extensions/mcp/oauth.ts` in
2//! pi `v1.0.0`.
3//!
4//! Connections never start a browser flow on their own. They send the stored
5//! access token and, after a 401, try the stored refresh token. When that is
6//! not possible they fail with [`McpError::SignInRequired`], and the user
7//! signs in with `/mcp login` or `yapi mcp login`, which run the
8//! authorization code flow with Proof Key for Code Exchange (PKCE) and
9//! dynamic client registration against a loopback callback.
10//!
11//! Credentials live in `<agent-dir>/mcp-auth.json`, keyed by server name and
12//! URL, in pi's format, so pi and yapi share sign-ins.
13
14use std::path::{Path, PathBuf};
15use std::sync::{Arc, Mutex};
16use std::time::Duration;
17
18use aws_lc_rs::digest::{SHA256, digest};
19use futures_util::future::BoxFuture;
20use reqwest::Url;
21use serde_json::Value;
22use tokio_util::sync::CancellationToken;
23use yapi_ai::auth::AuthError;
24use yapi_ai::auth::callback::{Reply, Server, error_page, query, success_page};
25use yapi_ai::auth::lock::FileLock;
26use yapi_types::config::ConfigFile;
27use yapi_types::sync::lock;
28
29use super::config::{OAuthConfig, namespace};
30use super::connection::resolve_value;
31use super::jsonrpc::McpError;
32use super::oauth::{
33    Challenge, Fetch, FlowOptions, FlowResult, OAuthError, Object, Provider, authorize,
34    parse_www_authenticate, text,
35};
36use crate::config::APP_NAME;
37
38const CALLBACK_HOST: &str = "127.0.0.1";
39/// Redirect URI for refreshes when none is stored. Refreshing never redirects
40/// the user.
41const FALLBACK_REDIRECT_URL: &str = "http://127.0.0.1/callback";
42/// Access tokens this close to expiry are refreshed before they are sent.
43const REFRESH_SKEW_MS: f64 = 30_000.0;
44/// Bounds each request of a refresh.
45const REFRESH_REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
46/// How long the callback server waits for the browser.
47const CALLBACK_TIMEOUT: Duration = Duration::from_secs(5 * 60);
48
49fn mcp_error(error: OAuthError) -> McpError {
50    match error {
51        OAuthError::Network(message) => McpError::Network(message),
52        error => McpError::Other(error.to_string()),
53    }
54}
55
56fn failed(error: impl std::fmt::Display) -> AuthError {
57    AuthError::Failed(error.to_string())
58}
59
60// ---------------------------------------------------------------------------
61// Credential store
62// ---------------------------------------------------------------------------
63
64/// pi's `McpOAuthCredentialStore`: the OAuth state of every server (client
65/// registration, tokens, pending PKCE verifier) in `mcp-auth.json`.
66#[derive(Clone, Debug)]
67pub struct CredentialStore {
68    path: PathBuf,
69}
70
71/// The state's keys: by name and URL, so servers sharing a URL keep separate
72/// accounts, and the legacy key by URL alone.
73fn store_keys(name: &str, server_url: &str) -> (String, String) {
74    let legacy = Url::parse(server_url).map_or_else(|_| server_url.to_owned(), String::from);
75    (format!("{}|{legacy}", namespace(name)), legacy)
76}
77
78fn parse_states(content: &str) -> Result<Object, AuthError> {
79    if content.trim().is_empty() {
80        return Ok(Object::new());
81    }
82    match serde_json::from_str::<Value>(content) {
83        Ok(Value::Object(states)) => Ok(states),
84        Ok(_) => Ok(Object::new()),
85        Err(error) => Err(failed(error)),
86    }
87}
88
89impl CredentialStore {
90    /// The store in `agent_dir`.
91    pub fn new(agent_dir: &Path) -> CredentialStore {
92        CredentialStore {
93            path: agent_dir.join(ConfigFile::McpAuth.file_name()),
94        }
95    }
96
97    /// The state of server `name` at `server_url`.
98    pub fn for_server(&self, name: &str, server_url: &str) -> ServerStore {
99        let (key, legacy_key) = store_keys(name, server_url);
100        ServerStore {
101            store: self.clone(),
102            key,
103            legacy_key,
104        }
105    }
106
107    /// Runs `change` on the stored states under the file lock, and writes
108    /// them back when it returns true.
109    async fn with_states<T>(
110        &self,
111        change: impl FnOnce(&mut Object) -> (T, bool),
112    ) -> Result<T, McpError> {
113        yapi_ai::auth::store::with_file_lock(&self.path, |current| {
114            let mut states = parse_states(current)?;
115            let (result, write) = change(&mut states);
116            let next = match write {
117                true => Some(ConfigFile::McpAuth.render(&states).map_err(failed)?),
118                false => None,
119            };
120            Ok((result, next))
121        })
122        .await
123        .map_err(|error| McpError::Other(error.to_string()))
124    }
125
126    /// The stored tokens of a server, for noticing sign-ins of another
127    /// process. Does not take over legacy state.
128    pub async fn tokens(&self, name: &str, server_url: &str) -> Result<Option<Value>, McpError> {
129        let (key, legacy) = store_keys(name, server_url);
130        self.with_states(|states| {
131            let state = states.get(&key).or_else(|| states.get(&legacy));
132            (state.and_then(|state| state.get("tokens")).cloned(), false)
133        })
134        .await
135    }
136
137    /// Removes the server's credentials. Returns whether any were stored.
138    pub async fn remove(&self, name: &str, server_url: &str) -> Result<bool, McpError> {
139        let (key, legacy) = store_keys(name, server_url);
140        self.with_states(|states| {
141            let removed =
142                states.shift_remove(&key).is_some() || states.shift_remove(&legacy).is_some();
143            (removed, removed)
144        })
145        .await
146    }
147}
148
149/// One server's state in the [`CredentialStore`].
150#[derive(Clone, Debug)]
151pub struct ServerStore {
152    store: CredentialStore,
153    key: String,
154    legacy_key: String,
155}
156
157impl ServerStore {
158    /// The stored state. The first server to load legacy state takes it over.
159    pub async fn load(&self) -> Result<Option<Object>, McpError> {
160        self.store
161            .with_states(|states| {
162                if states.contains_key(&self.key) || !states.contains_key(&self.legacy_key) {
163                    return (
164                        states.get(&self.key).and_then(Value::as_object).cloned(),
165                        false,
166                    );
167                }
168                let state = states.shift_remove(&self.legacy_key).unwrap_or_default();
169                states.insert(self.key.clone(), state.clone());
170                (state.as_object().cloned(), true)
171            })
172            .await
173    }
174
175    /// Replaces the stored state.
176    pub async fn save(&self, state: Object) -> Result<(), McpError> {
177        self.store
178            .with_states(|states| {
179                states.insert(self.key.clone(), Value::Object(state));
180                ((), true)
181            })
182            .await
183    }
184
185    /// Runs `run` while no other process refreshes the server's tokens, with
186    /// pi's lock file for the server next to `mcp-auth.json`, whose directory
187    /// exists once the tokens were loaded.
188    async fn with_refresh_lock<T>(
189        &self,
190        run: impl Future<Output = Result<T, McpError>>,
191    ) -> Result<T, McpError> {
192        let digest = digest(&SHA256, self.key.as_bytes());
193        let hash = yapi_types::time::hex(&digest.as_ref()[..8]);
194        let _lock = FileLock::acquire(
195            &self
196                .store
197                .path
198                .with_file_name(format!("mcp-auth-refresh-{hash}")),
199            &CancellationToken::new(),
200        )
201        .await
202        .map_err(|error| McpError::Other(error.to_string()))?;
203        run.await
204    }
205}
206
207// ---------------------------------------------------------------------------
208// Auth provider
209// ---------------------------------------------------------------------------
210
211/// Where the loopback callback server listens and the redirect URI it serves.
212struct CallbackSettings {
213    /// Address to listen on.
214    host: String,
215    /// Host name in the redirect URI.
216    redirect_host: String,
217    port: Option<u16>,
218    path: String,
219    /// The exact redirect URI, when the port is fixed.
220    fixed_redirect_url: Option<String>,
221}
222
223fn callback_settings(settings: &OAuthConfig) -> CallbackSettings {
224    let configured = settings.callback_url.as_deref();
225    let url = Url::parse(configured.unwrap_or(FALLBACK_REDIRECT_URL)).ok();
226    let address = url
227        .as_ref()
228        .and_then(Url::host_str)
229        .unwrap_or(CALLBACK_HOST)
230        .trim_start_matches('[')
231        .trim_end_matches(']')
232        .to_owned();
233    let url_port = url.as_ref().and_then(Url::port);
234    let port = url_port.or(settings.callback_port);
235    let path = url
236        .as_ref()
237        .map_or_else(|| "/callback".to_owned(), |url| url.path().to_owned());
238    // A configured URI with a port is sent exactly as written, since servers compare it as a string.
239    let fixed_redirect_url = match (url_port, port, url) {
240        (Some(_), _, _) => configured.map(str::to_owned),
241        (None, Some(port), Some(mut url)) => {
242            let _ = url.set_port(Some(port));
243            Some(url.to_string())
244        }
245        _ => None,
246    };
247    CallbackSettings {
248        // `localhost` is served on 127.0.0.1, which browsers fall back to when ::1 refuses.
249        host: if address == "localhost" {
250            CALLBACK_HOST.to_owned()
251        } else {
252            address.clone()
253        },
254        redirect_host: address,
255        port,
256        path,
257        fixed_redirect_url,
258    }
259}
260
261/// pi's `mergeScopes`: the scopes of every list, each once.
262fn merge_scopes(lists: &[Option<&str>]) -> Option<String> {
263    let mut scopes: Vec<&str> = Vec::new();
264    for word in lists
265        .iter()
266        .flatten()
267        .flat_map(|list| list.split_whitespace())
268    {
269        if !scopes.contains(&word) {
270            scopes.push(word);
271        }
272    }
273    (!scopes.is_empty()).then(|| scopes.join(" "))
274}
275
276fn registered_redirect_urls(state: Option<&Object>) -> Vec<String> {
277    state
278        .and_then(|state| state.get("clientInformation"))
279        .and_then(|client| client.get("redirect_uris"))
280        .and_then(Value::as_array)
281        .map(|urls| {
282            urls.iter()
283                .filter_map(Value::as_str)
284                .map(str::to_owned)
285                .collect()
286        })
287        .unwrap_or_default()
288}
289
290fn tokens(state: Option<&Object>) -> Option<&Object> {
291    state?.get("tokens")?.as_object()
292}
293
294fn access_token(state: Option<&Object>) -> Option<String> {
295    tokens(state)
296        .and_then(|tokens| text(tokens, "access_token"))
297        .map(str::to_owned)
298}
299
300fn has_refresh_token(state: Option<&Object>) -> bool {
301    tokens(state)
302        .and_then(|tokens| text(tokens, "refresh_token"))
303        .is_some_and(|token| !token.is_empty())
304}
305
306/// pi's `McpSignInPrompt`: what a sign-in asks of the user.
307pub struct SignInPrompt {
308    /// Shows the authorization URL and opens it in a browser.
309    pub show_authorization_url: Box<dyn Fn(&str) + Send + Sync>,
310    /// Asks for the redirect URL from the browser's address bar, for when the
311    /// browser cannot reach the loopback callback. The token withdraws the
312    /// question once the callback arrives. `None` or empty text cancels the
313    /// sign-in.
314    pub redirect_url:
315        Box<dyn Fn(CancellationToken) -> BoxFuture<'static, Option<String>> + Send + Sync>,
316}
317
318/// The OAuth credentials of one server: pi's `createMcpAuthProvider`. Sends
319/// the stored access token and refreshes it when it is about to expire or
320/// after a 401. Fails with [`McpError::SignInRequired`] when the user has to
321/// sign in, including when the server asks for more scope.
322///
323/// Requests in this process refresh one at a time, and other processes are
324/// kept out by pi's refresh lock, held from reading the tokens to saving new
325/// ones. Tokens that changed meanwhile are used without refreshing, since
326/// servers that rotate refresh tokens reject a second refresh with the old one.
327pub struct McpAuth {
328    name: String,
329    server_url: String,
330    store: ServerStore,
331    config: OAuthConfig,
332    /// The server's last challenge, whose resource metadata URL and scope
333    /// sign-in uses.
334    challenge: Mutex<Option<Challenge>>,
335    refreshing: tokio::sync::Mutex<()>,
336}
337
338impl McpAuth {
339    /// The credentials of server `name` at `server_url`, with its `oauth`
340    /// settings.
341    pub fn new(
342        name: &str,
343        server_url: &str,
344        credentials: &CredentialStore,
345        config: OAuthConfig,
346    ) -> McpAuth {
347        McpAuth {
348            name: name.to_owned(),
349            server_url: server_url.to_owned(),
350            store: credentials.for_server(name, server_url),
351            config,
352            challenge: Mutex::new(None),
353            refreshing: tokio::sync::Mutex::new(()),
354        }
355    }
356
357    /// The `oauth` settings with the client secret resolved.
358    pub async fn settings(&self) -> Result<OAuthConfig, McpError> {
359        let mut settings = self.config.clone();
360        if let Some(secret) = &self.config.client_secret {
361            let description = format!("MCP server \"{}\" oauth.clientSecret", self.name);
362            settings.client_secret = Some(resolve_value(secret, &description).await?);
363        }
364        Ok(settings)
365    }
366
367    /// The last challenge the server sent.
368    fn challenge(&self) -> Option<Challenge> {
369        lock(&self.challenge).clone()
370    }
371
372    /// The access token to send, refreshed first when it is about to expire.
373    /// A failed refresh sends the old token and lets a 401 decide.
374    pub async fn token(&self) -> Result<Option<String>, McpError> {
375        drop(self.refreshing.lock().await);
376        let state = self.store.load().await?;
377        let token = access_token(state.as_ref());
378        let expired = state
379            .as_ref()
380            .and_then(|state| state.get("tokensExpireAt"))
381            .and_then(Value::as_f64)
382            .is_some_and(|at| at - REFRESH_SKEW_MS <= yapi_types::time::now_ms() as f64);
383        if !expired || !has_refresh_token(state.as_ref()) {
384            return Ok(token);
385        }
386        let _ = self.refresh(token.as_deref(), None).await;
387        Ok(access_token(self.store.load().await?.as_ref()))
388    }
389
390    /// Handles a 401, or a 403 asking for more scope, to a request that sent
391    /// `token`: refreshes, or fails when the user has to sign in.
392    pub async fn on_unauthorized(
393        &self,
394        www_authenticate: Option<&str>,
395        token: Option<&str>,
396    ) -> Result<(), McpError> {
397        let challenge = parse_www_authenticate(www_authenticate);
398        *lock(&self.challenge) = Some(challenge.clone());
399        // A refresh keeps the granted scope, so more scope needs a new sign-in.
400        if challenge.error.as_deref() == Some("insufficient_scope") {
401            return Err(McpError::SignInRequired);
402        }
403        self.refresh(token, Some(&challenge)).await
404    }
405
406    /// Waits for a running refresh, so shutdown does not drop rotated tokens
407    /// before they are saved.
408    pub async fn settled(&self) {
409        drop(self.refreshing.lock().await);
410    }
411
412    /// Replaces `stale`, the access token that expired or was rejected.
413    async fn refresh(
414        &self,
415        stale: Option<&str>,
416        challenge: Option<&Challenge>,
417    ) -> Result<(), McpError> {
418        let _running = self.refreshing.lock().await;
419        self.store
420            .with_refresh_lock(async {
421                let state = self.store.load().await?;
422                if access_token(state.as_ref()).as_deref() != stale {
423                    return Ok(());
424                }
425                if !has_refresh_token(state.as_ref()) {
426                    return Err(McpError::SignInRequired);
427                }
428                let settings = self.settings().await?;
429                let redirect_url = callback_settings(&settings)
430                    .fixed_redirect_url
431                    .or_else(|| registered_redirect_urls(state.as_ref()).into_iter().next())
432                    .unwrap_or_else(|| FALLBACK_REDIRECT_URL.to_owned());
433                let provider = self.provider(&settings, &redirect_url).map_err(mcp_error)?;
434                let options = FlowOptions {
435                    server_url: self.server_url.clone(),
436                    resource_metadata_url: challenge
437                        .and_then(|challenge| challenge.resource_metadata_url.clone()),
438                    authorization_server_metadata_url: settings.auth_server_metadata_url.clone(),
439                    scope: challenge.and_then(|challenge| challenge.scope.clone()),
440                    fetch: Fetch {
441                        timeout: Some(REFRESH_REQUEST_TIMEOUT),
442                    },
443                    ..FlowOptions::default()
444                };
445                match authorize(&provider, &options).await.map_err(mcp_error)? {
446                    FlowResult::Authorized => Ok(()),
447                    FlowResult::Redirect(_) => Err(McpError::SignInRequired),
448                }
449            })
450            .await
451    }
452
453    fn provider<'a>(
454        &'a self,
455        settings: &OAuthConfig,
456        redirect_url: &str,
457    ) -> Result<Provider<'a>, OAuthError> {
458        Provider::new(
459            &self.server_url,
460            redirect_url,
461            settings.client_name.as_deref().unwrap_or(APP_NAME),
462            settings.client_id.as_deref(),
463            settings.client_secret.as_deref(),
464            &self.store,
465        )
466    }
467
468    /// pi's `signInMcpServer`: uses the stored refresh token when possible,
469    /// otherwise runs the browser authorization code flow. Tokens are saved
470    /// to the credential store, and the challenge that asked for the sign-in
471    /// is answered.
472    pub async fn sign_in(&self, prompt: &SignInPrompt) -> Result<(), AuthError> {
473        let settings = self.settings().await.map_err(failed)?;
474        let challenge = self.challenge();
475        let stored = self.store.load().await.map_err(failed)?;
476        let step_up = challenge
477            .as_ref()
478            .is_some_and(|challenge| challenge.error.as_deref() == Some("insufficient_scope"));
479        let callback = callback_settings(&settings);
480        // Reuse the port of the registered redirect URI so the registered client stays valid.
481        let registered = registered_redirect_urls(stored.as_ref());
482        let preferred_port = callback.port.or_else(|| {
483            registered
484                .first()
485                .and_then(|url| Url::parse(url).ok())
486                .and_then(|url| url.port())
487        });
488        let expected = Arc::new(Mutex::new(None));
489        let mut server = listen(&callback, preferred_port, Arc::clone(&expected)).await?;
490        let redirect_host = if callback.redirect_host.contains(':') {
491            format!("[{}]", callback.redirect_host)
492        } else {
493            callback.redirect_host.clone()
494        };
495        let redirect_url = callback.fixed_redirect_url.clone().unwrap_or_else(|| {
496            format!("http://{redirect_host}:{}{}", server.port(), callback.path)
497        });
498        if let Some(stored) = &stored {
499            let mut next = stored.clone();
500            // Every sign-in gets a fresh `state` parameter.
501            next.shift_remove("oauthState");
502            // A registered client cannot use another redirect URI, and its tokens belong to it.
503            if settings.client_id.as_deref().is_none_or(str::is_empty)
504                && !registered.contains(&redirect_url)
505            {
506                for key in ["clientInformation", "tokens", "tokensExpireAt"] {
507                    next.shift_remove(key);
508                }
509            }
510            self.store.save(next).await.map_err(failed)?;
511        }
512        let provider = self.provider(&settings, &redirect_url).map_err(failed)?;
513        // A server asking for more scope gets it on top of the configured scope and, since the
514        // challenge may list only the missing scopes, on top of the scope granted so far.
515        let challenged = challenge
516            .as_ref()
517            .and_then(|challenge| challenge.scope.as_deref());
518        let granted = (step_up && challenged.is_some())
519            .then(|| tokens(stored.as_ref()).and_then(|tokens| text(tokens, "scope")))
520            .flatten();
521        let flow = FlowOptions {
522            server_url: self.server_url.clone(),
523            resource_metadata_url: challenge
524                .as_ref()
525                .and_then(|challenge| challenge.resource_metadata_url.clone()),
526            authorization_server_metadata_url: settings.auth_server_metadata_url.clone(),
527            scope: merge_scopes(&[settings.scope.as_deref(), granted, challenged]),
528            // A refresh keeps the granted scope, so more scope needs the browser flow.
529            skip_refresh: step_up,
530            ..FlowOptions::default()
531        };
532        if let FlowResult::Redirect(url) = authorize(&provider, &flow).await.map_err(failed)? {
533            let state = provider.state().await.map_err(failed)?;
534            (prompt.show_authorization_url)(&url);
535            let (code, iss) = wait_for_response(&mut server, &expected, &state, prompt).await?;
536            let exchange = FlowOptions {
537                authorization_code: Some(code),
538                iss,
539                skip_refresh: false,
540                ..flow
541            };
542            authorize(&provider, &exchange).await.map_err(failed)?;
543        }
544        *lock(&self.challenge) = None;
545        Ok(())
546    }
547}
548
549type Callback = (String, Option<String>);
550
551/// Listens on `port`, or on a free port when it is taken and not fixed by the
552/// settings. The server answers like pi-mcp's callback server.
553async fn listen(
554    settings: &CallbackSettings,
555    port: Option<u16>,
556    expected: Arc<Mutex<Option<String>>>,
557) -> Result<Server<Callback>, AuthError> {
558    let cancel = CancellationToken::new();
559    let start = |port: u16| {
560        let expected = Arc::clone(&expected);
561        let path = settings.path.clone();
562        let handler = move |_method: &str, url: &Url, _handled: bool| -> Reply<Callback> {
563            if url.path() != path {
564                return Reply::page(404, error_page("Not found", None));
565            }
566            let state = query(url, "state");
567            let mut pending = lock(&expected);
568            if state.is_none() || *pending != state {
569                return Reply::page(400, error_page("Invalid or expired OAuth state", None));
570            }
571            *pending = None;
572            if let Some(error) = query(url, "error") {
573                let description = query(url, "error_description").unwrap_or(error);
574                return Reply {
575                    status: 200,
576                    html: error_page(
577                        "Authorization failed. You may close this window.",
578                        Some(&description),
579                    ),
580                    outcome: Some(Err(AuthError::Failed(description))),
581                };
582            }
583            let Some(code) = query(url, "code") else {
584                return Reply {
585                    status: 400,
586                    html: error_page("Missing authorization code", None),
587                    outcome: Some(Err(failed(
588                        "OAuth callback did not include an authorization code",
589                    ))),
590                };
591            };
592            let iss = query(url, "iss").filter(|iss| !iss.is_empty());
593            Reply {
594                status: 200,
595                html: success_page("Signed in to the MCP server. You may now close this page."),
596                outcome: Some(Ok((code, iss))),
597            }
598        };
599        Server::start(
600            &settings.host,
601            port,
602            &settings.path,
603            &cancel,
604            Box::new(handler),
605        )
606    };
607    match start(port.unwrap_or(0)).await {
608        Ok(server) => Ok(server),
609        Err(error) if settings.port.is_some() || port.is_none() => Err(failed(error)),
610        Err(_) => start(0).await.map_err(failed),
611    }
612}
613
614fn response_from_redirect_url(input: &str, state: &str) -> Result<Callback, AuthError> {
615    let url = Url::parse(input.trim())
616        .map_err(|_| failed("Expected the full redirect URL from the browser address bar"))?;
617    if let Some(error) = query(&url, "error") {
618        return Err(AuthError::Failed(
619            query(&url, "error_description").unwrap_or(error),
620        ));
621    }
622    if query(&url, "state").as_deref() != Some(state) {
623        return Err(failed("The redirect URL belongs to a different sign-in"));
624    }
625    let code = query(&url, "code")
626        .ok_or_else(|| failed("The redirect URL does not contain an authorization code"))?;
627    Ok((code, query(&url, "iss")))
628}
629
630/// The browser callback or a pasted redirect URL, whichever comes first.
631async fn wait_for_response(
632    server: &mut Server<Callback>,
633    expected: &Mutex<Option<String>>,
634    state: &str,
635    prompt: &SignInPrompt,
636) -> Result<Callback, AuthError> {
637    *lock(expected) = Some(state.to_owned());
638    let withdraw = CancellationToken::new();
639    let _withdraw_on_return = withdraw.clone().drop_guard();
640    let from_user = (prompt.redirect_url)(withdraw);
641    tokio::select! {
642        received = tokio::time::timeout(CALLBACK_TIMEOUT, server.wait()) => {
643            received.unwrap_or_else(|_| Err(failed("OAuth callback timed out")))
644        }
645        input = from_user => match input {
646            Some(input) if !input.trim().is_empty() => response_from_redirect_url(&input, state),
647            _ => Err(AuthError::Cancelled),
648        },
649    }
650}
651
652#[cfg(test)]
653mod tests {
654    use super::*;
655    use serde_json::json;
656
657    #[test]
658    fn derives_callback_settings() {
659        let settings = |url: Option<&str>, port: Option<u16>| {
660            callback_settings(&OAuthConfig {
661                callback_url: url.map(str::to_owned),
662                callback_port: port,
663                ..OAuthConfig::default()
664            })
665        };
666        let default = settings(None, None);
667        assert_eq!(default.host, "127.0.0.1");
668        assert_eq!(default.path, "/callback");
669        assert_eq!(default.fixed_redirect_url, None);
670        let fixed = settings(None, Some(8080));
671        assert_eq!(
672            fixed.fixed_redirect_url.as_deref(),
673            Some("http://127.0.0.1:8080/callback")
674        );
675        let localhost = settings(Some("http://localhost:9000/oauth/cb"), None);
676        assert_eq!(localhost.host, "127.0.0.1");
677        assert_eq!(localhost.redirect_host, "localhost");
678        assert_eq!(localhost.port, Some(9000));
679        assert_eq!(
680            localhost.fixed_redirect_url.as_deref(),
681            Some("http://localhost:9000/oauth/cb")
682        );
683        assert_eq!(settings(Some("http://[::1]/cb"), None).host, "::1");
684    }
685
686    #[test]
687    fn merges_scopes() {
688        assert_eq!(
689            merge_scopes(&[Some("a b"), None, Some("b c a")]).as_deref(),
690            Some("a b c")
691        );
692        assert_eq!(merge_scopes(&[None, Some("")]), None);
693    }
694
695    #[test]
696    fn reads_pasted_redirects() {
697        assert_eq!(
698            response_from_redirect_url(" http://127.0.0.1:1/callback?code=c&state=s&iss=i ", "s"),
699            Ok(("c".into(), Some("i".into())))
700        );
701        let error = |input: &str| {
702            response_from_redirect_url(input, "s")
703                .err()
704                .map(|error| error.to_string())
705        };
706        assert_eq!(
707            error("code=c").as_deref(),
708            Some("Expected the full redirect URL from the browser address bar")
709        );
710        assert_eq!(
711            error("http://x/?error=denied&error_description=No").as_deref(),
712            Some("No")
713        );
714        assert_eq!(
715            error("http://x/?code=c&state=t").as_deref(),
716            Some("The redirect URL belongs to a different sign-in")
717        );
718        assert_eq!(
719            error("http://x/?state=s").as_deref(),
720            Some("The redirect URL does not contain an authorization code")
721        );
722    }
723
724    fn temp_store(name: &str) -> (PathBuf, CredentialStore) {
725        let dir = std::env::temp_dir().join(format!("yapi-mcp-auth-{name}-{}", std::process::id()));
726        let _ = std::fs::remove_dir_all(&dir);
727        let store = CredentialStore::new(&dir);
728        (dir, store)
729    }
730
731    const URL: &str = "https://mcp.example.com/mcp";
732
733    fn state(token: &str) -> Object {
734        json!({"serverUrl": URL, "tokens": {"access_token": token, "token_type": "Bearer"}})
735            .as_object()
736            .cloned()
737            .unwrap_or_default()
738    }
739
740    #[tokio::test]
741    async fn shared_url_separate_credentials() {
742        let (dir, store) = temp_store("shared");
743        store
744            .for_server("work", URL)
745            .save(state("work-token"))
746            .await
747            .unwrap();
748        store
749            .for_server("personal", URL)
750            .save(state("personal-token"))
751            .await
752            .unwrap();
753        let token = |name: &'static str| {
754            let store = store.clone();
755            async move { access_token(store.for_server(name, URL).load().await.unwrap().as_ref()) }
756        };
757        assert_eq!(token("work").await.as_deref(), Some("work-token"));
758        assert_eq!(token("personal").await.as_deref(), Some("personal-token"));
759        assert!(store.remove("work", URL).await.unwrap());
760        assert_eq!(store.for_server("work", URL).load().await.unwrap(), None);
761        assert_eq!(
762            store.tokens("personal", URL).await.unwrap(),
763            Some(json!({"access_token": "personal-token", "token_type": "Bearer"}))
764        );
765        let text = std::fs::read_to_string(dir.join("mcp-auth.json")).unwrap();
766        assert!(text.starts_with(
767            "{\n  \"mcp__personal|https://mcp.example.com/mcp\": {\n    \"serverUrl\""
768        ));
769        assert!(text.ends_with("}\n"));
770        let _ = std::fs::remove_dir_all(dir);
771    }
772
773    #[tokio::test]
774    async fn migrates_legacy_credentials() {
775        let (dir, store) = temp_store("legacy");
776        std::fs::create_dir_all(&dir).unwrap();
777        let legacy = json!({URL: state("legacy-token")});
778        std::fs::write(dir.join("mcp-auth.json"), legacy.to_string()).unwrap();
779        // Reading tokens does not take the legacy state over.
780        assert!(store.tokens("work", URL).await.unwrap().is_some());
781        let loaded = store.for_server("my_work", URL).load().await.unwrap();
782        assert_eq!(
783            access_token(loaded.as_ref()).as_deref(),
784            Some("legacy-token")
785        );
786        // Names differing only in `-` and `_` are the same server.
787        assert!(store.tokens("my-work", URL).await.unwrap().is_some());
788        assert_eq!(
789            store.for_server("personal", URL).load().await.unwrap(),
790            None
791        );
792        let text = std::fs::read_to_string(dir.join("mcp-auth.json")).unwrap();
793        let keys: Vec<String> = serde_json::from_str::<Object>(&text)
794            .unwrap()
795            .keys()
796            .cloned()
797            .collect();
798        assert_eq!(keys, [format!("mcp__my_work|{URL}")]);
799        assert!(store.remove("my-work", URL).await.unwrap());
800        assert!(!store.remove("my-work", URL).await.unwrap());
801        let _ = std::fs::remove_dir_all(dir);
802    }
803}