Skip to main content

supercode_harness/
mcp_oauth.rs

1//! P5-2 (COMPOSABLE-HARNESS-DESIGN.md §2 module 15 D7 row 2 "OAuth"; §2.1
2//! dep "`model.oauth` → trust-grade token storage" — the same security
3//! class applies here): OAuth PROTOCOL support for authenticated remote MCP
4//! servers.
5//!
6//! **Scope, stated plainly.** This module implements:
7//! - the OAuth 2.0 Device Authorization Grant (RFC 8628) — the
8//!   non-interactive path: no browser/redirect listener needed, just a
9//!   `user_code`/`verification_uri` a caller prints and a background poll;
10//! - refresh-token exchange.
11//!
12//! It does **NOT** implement the interactive authorization-code + PKCE +
13//! local-redirect-listener browser flow — that needs a UI to open a
14//! browser and a local HTTP listener to catch the redirect, which is
15//! `tui`'s job (P5 item #4, not yet built). This is a **tui-deferred**
16//! citation, not a silent gap: a server that only offers the browser flow
17//! (no device-code grant) simply isn't reachable through this module yet.
18//!
19//! **Token storage is NOT this module's job.** This module only speaks the
20//! wire protocol and returns [`McpOAuthTokens`] values — persisting them is
21//! a CLI-layer concern (`crates/cli/src/userconfig.rs`'s
22//! `save_mcp_oauth_tokens`/`load_mcp_oauth_tokens`), same trust-grade
23//! posture (owner-only permissions, user/global-directory-only, never
24//! project-readable) as `Config::api_key`/`save_api_key` (§3.2 S13) — this
25//! crate never touches a filesystem for a credential.
26
27use std::time::Duration;
28
29use serde::Deserialize;
30
31use crate::error::{Error, Result};
32
33/// The endpoints/identity an MCP server's OAuth device-code flow needs.
34/// Carries no token — see the module doc comment.
35#[derive(Debug, Clone)]
36pub struct OAuthEndpoints {
37    /// RFC 8628 device authorization endpoint.
38    pub device_authorization_endpoint: String,
39    /// Token endpoint (also used for the refresh-token grant).
40    pub token_endpoint: String,
41    /// OAuth client id.
42    pub client_id: String,
43    /// Optional scope string.
44    pub scope: Option<String>,
45}
46
47/// A trust-grade credential pair — see the module doc comment for why this
48/// crate never persists one itself.
49#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
50pub struct McpOAuthTokens {
51    /// The bearer access token.
52    pub access_token: String,
53    /// The refresh token, if the server issued one.
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub refresh_token: Option<String>,
56    /// Unix-epoch seconds this access token expires at, if known.
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub expires_at_secs: Option<u64>,
59}
60
61impl McpOAuthTokens {
62    /// Whether this token is expired (or about to expire within `skew`
63    /// seconds) as of `now_secs`. `expires_at_secs: None` (unknown
64    /// lifetime) is treated as NOT expired — a server that never told us
65    /// when the token dies is assumed valid until it actually fails.
66    pub fn is_expired_at(&self, now_secs: u64, skew_secs: u64) -> bool {
67        self.expires_at_secs
68            .map(|exp| now_secs.saturating_add(skew_secs) >= exp)
69            .unwrap_or(false)
70    }
71}
72
73/// The RFC 8628 device-authorization-response fields this module needs.
74#[derive(Debug, Clone)]
75pub struct DeviceAuthorization {
76    /// Opaque device code the client polls the token endpoint with.
77    pub device_code: String,
78    /// Short code the USER enters at `verification_uri`.
79    pub user_code: String,
80    /// The URL the user visits.
81    pub verification_uri: String,
82    /// A URL that already embeds `user_code`, if the server provided one —
83    /// print this instead of `verification_uri` + `user_code` separately
84    /// when present.
85    pub verification_uri_complete: Option<String>,
86    /// How often (seconds) the client should poll the token endpoint.
87    pub interval_secs: u64,
88    /// How long (seconds) `device_code` remains valid.
89    pub expires_in_secs: u64,
90}
91
92#[derive(Deserialize)]
93struct DeviceAuthResponse {
94    device_code: String,
95    user_code: String,
96    verification_uri: String,
97    #[serde(default)]
98    verification_uri_complete: Option<String>,
99    #[serde(default = "default_interval")]
100    interval: u64,
101    #[serde(default = "default_expires_in")]
102    expires_in: u64,
103}
104fn default_interval() -> u64 {
105    5
106}
107fn default_expires_in() -> u64 {
108    600
109}
110
111#[derive(Deserialize)]
112struct TokenResponse {
113    access_token: String,
114    #[serde(default)]
115    refresh_token: Option<String>,
116    #[serde(default)]
117    expires_in: Option<u64>,
118}
119
120#[derive(Deserialize)]
121struct TokenErrorResponse {
122    error: String,
123}
124
125/// Step 1 of RFC 8628: request a device/user code pair.
126pub async fn start_device_authorization(
127    client: &reqwest::Client,
128    ep: &OAuthEndpoints,
129) -> Result<DeviceAuthorization> {
130    let mut form = vec![("client_id", ep.client_id.as_str())];
131    if let Some(scope) = &ep.scope {
132        form.push(("scope", scope.as_str()));
133    }
134    let resp = client
135        .post(&ep.device_authorization_endpoint)
136        .form(&form)
137        .send()
138        .await
139        .map_err(|e| Error::tool("mcp_oauth", format!("device authorization request: {e}")))?;
140    if !resp.status().is_success() {
141        return Err(Error::tool(
142            "mcp_oauth",
143            format!("device authorization: http status {}", resp.status()),
144        ));
145    }
146    let body: DeviceAuthResponse = resp.json().await.map_err(|e| {
147        Error::tool(
148            "mcp_oauth",
149            format!("decoding device authorization response: {e}"),
150        )
151    })?;
152    Ok(DeviceAuthorization {
153        device_code: body.device_code,
154        user_code: body.user_code,
155        verification_uri: body.verification_uri,
156        verification_uri_complete: body.verification_uri_complete,
157        interval_secs: body.interval,
158        expires_in_secs: body.expires_in,
159    })
160}
161
162/// One poll of the token endpoint for a device code — RFC 8628 §3.5. The
163/// server replies `authorization_pending` until the user finishes at
164/// `verification_uri`; the caller (e.g. `poll_until_authorized`) is
165/// expected to sleep `interval_secs` and retry.
166#[derive(Debug, Clone, PartialEq, Eq)]
167pub enum DevicePollOutcome {
168    /// The user hasn't completed authorization yet — keep polling at the
169    /// same interval.
170    Pending,
171    /// The user hasn't completed authorization yet AND the server wants
172    /// polling slowed down (RFC 8628 §3.5) — the next poll should wait
173    /// `interval + 5s`, not just `interval`.
174    SlowDown,
175    /// The user completed authorization; tokens are attached.
176    Authorized(McpOAuthTokens),
177    /// The user (or the server) denied/cancelled — stop polling.
178    Denied,
179    /// The device code expired before authorization completed.
180    Expired,
181}
182
183/// A single token-endpoint poll for the device-code grant.
184pub async fn poll_device_token(
185    client: &reqwest::Client,
186    ep: &OAuthEndpoints,
187    device_code: &str,
188) -> Result<DevicePollOutcome> {
189    let form = [
190        ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
191        ("device_code", device_code),
192        ("client_id", ep.client_id.as_str()),
193    ];
194    let resp = client
195        .post(&ep.token_endpoint)
196        .form(&form)
197        .send()
198        .await
199        .map_err(|e| Error::tool("mcp_oauth", format!("token poll: {e}")))?;
200    if resp.status().is_success() {
201        let body: TokenResponse = resp
202            .json()
203            .await
204            .map_err(|e| Error::tool("mcp_oauth", format!("decoding token response: {e}")))?;
205        return Ok(DevicePollOutcome::Authorized(McpOAuthTokens {
206            access_token: body.access_token,
207            refresh_token: body.refresh_token,
208            expires_at_secs: body.expires_in.map(|secs| now_secs() + secs),
209        }));
210    }
211    let body: TokenErrorResponse = resp.json().await.unwrap_or(TokenErrorResponse {
212        error: "unknown_error".to_string(),
213    });
214    match body.error.as_str() {
215        "authorization_pending" => Ok(DevicePollOutcome::Pending),
216        "slow_down" => Ok(DevicePollOutcome::SlowDown),
217        "expired_token" => Ok(DevicePollOutcome::Expired),
218        _ => Ok(DevicePollOutcome::Denied),
219    }
220}
221
222/// The whole non-interactive device-code flow: start authorization, invoke
223/// `on_prompt` exactly once with the [`DeviceAuthorization`] (so the caller
224/// can print `verification_uri`/`user_code` for the user), then poll until
225/// authorized/denied/expired — bounded by `expires_in_secs`, sleeping
226/// `interval_secs` between attempts (never faster, per RFC 8628's
227/// `slow_down` semantics — a `slow_down` response widens the interval by a
228/// further 5s, same as the spec recommends).
229pub async fn run_device_flow(
230    client: &reqwest::Client,
231    ep: &OAuthEndpoints,
232    on_prompt: impl FnOnce(&DeviceAuthorization),
233) -> Result<McpOAuthTokens> {
234    let auth = start_device_authorization(client, ep).await?;
235    on_prompt(&auth);
236    let deadline = now_secs() + auth.expires_in_secs;
237    let mut interval = auth.interval_secs.max(1);
238    loop {
239        tokio::time::sleep(Duration::from_secs(interval)).await;
240        match poll_device_token(client, ep, &auth.device_code).await? {
241            DevicePollOutcome::Authorized(tokens) => return Ok(tokens),
242            DevicePollOutcome::Pending => {
243                if now_secs() >= deadline {
244                    return Err(Error::tool(
245                        "mcp_oauth",
246                        "device code expired while polling",
247                    ));
248                }
249            }
250            DevicePollOutcome::SlowDown => {
251                interval += 5;
252                if now_secs() >= deadline {
253                    return Err(Error::tool(
254                        "mcp_oauth",
255                        "device code expired while polling",
256                    ));
257                }
258            }
259            DevicePollOutcome::Denied => {
260                return Err(Error::tool("mcp_oauth", "authorization was denied"));
261            }
262            DevicePollOutcome::Expired => {
263                return Err(Error::tool("mcp_oauth", "device code expired"));
264            }
265        }
266    }
267}
268
269/// Exchange a refresh token for a new access token.
270pub async fn refresh_token(
271    client: &reqwest::Client,
272    ep: &OAuthEndpoints,
273    refresh_token: &str,
274) -> Result<McpOAuthTokens> {
275    let form = [
276        ("grant_type", "refresh_token"),
277        ("refresh_token", refresh_token),
278        ("client_id", ep.client_id.as_str()),
279    ];
280    let resp = client
281        .post(&ep.token_endpoint)
282        .form(&form)
283        .send()
284        .await
285        .map_err(|e| Error::tool("mcp_oauth", format!("refresh request: {e}")))?;
286    if !resp.status().is_success() {
287        return Err(Error::tool(
288            "mcp_oauth",
289            format!("refresh: http status {}", resp.status()),
290        ));
291    }
292    let body: TokenResponse = resp
293        .json()
294        .await
295        .map_err(|e| Error::tool("mcp_oauth", format!("decoding refresh response: {e}")))?;
296    Ok(McpOAuthTokens {
297        access_token: body.access_token,
298        // A server that omits `refresh_token` on refresh means "reuse the
299        // same one" per RFC 6749 §6 — the caller (which already has the OLD
300        // refresh token) is responsible for keeping it if this is `None`.
301        refresh_token: body.refresh_token,
302        expires_at_secs: body.expires_in.map(|secs| now_secs() + secs),
303    })
304}
305
306fn now_secs() -> u64 {
307    std::time::SystemTime::now()
308        .duration_since(std::time::UNIX_EPOCH)
309        .map(|d| d.as_secs())
310        .unwrap_or(0)
311}
312
313/// Build the `Authorization: Bearer <token>` header value for a stored
314/// [`McpOAuthTokens`] — the shape [`crate::mcp::McpClient::connect_http`]/
315/// `connect_sse`'s `headers` map expects.
316pub fn bearer_header(tokens: &McpOAuthTokens) -> (String, String) {
317    (
318        "Authorization".to_string(),
319        format!("Bearer {}", tokens.access_token),
320    )
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    #[test]
328    fn is_expired_at_treats_unknown_lifetime_as_not_expired() {
329        let t = McpOAuthTokens {
330            access_token: "x".into(),
331            refresh_token: None,
332            expires_at_secs: None,
333        };
334        assert!(!t.is_expired_at(u64::MAX / 2, 0));
335    }
336
337    #[test]
338    fn is_expired_at_honors_skew() {
339        let t = McpOAuthTokens {
340            access_token: "x".into(),
341            refresh_token: None,
342            expires_at_secs: Some(1000),
343        };
344        assert!(!t.is_expired_at(900, 30));
345        assert!(t.is_expired_at(980, 30)); // within skew of expiry
346        assert!(t.is_expired_at(1000, 0));
347    }
348
349    #[test]
350    fn bearer_header_has_the_expected_shape() {
351        let t = McpOAuthTokens {
352            access_token: "secret123".into(),
353            refresh_token: None,
354            expires_at_secs: None,
355        };
356        let (name, value) = bearer_header(&t);
357        assert_eq!(name, "Authorization");
358        assert_eq!(value, "Bearer secret123");
359    }
360}