1use std::time::Duration;
28
29use serde::Deserialize;
30
31use crate::error::{Error, Result};
32
33#[derive(Debug, Clone)]
36pub struct OAuthEndpoints {
37 pub device_authorization_endpoint: String,
39 pub token_endpoint: String,
41 pub client_id: String,
43 pub scope: Option<String>,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
50pub struct McpOAuthTokens {
51 pub access_token: String,
53 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub refresh_token: Option<String>,
56 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub expires_at_secs: Option<u64>,
59}
60
61impl McpOAuthTokens {
62 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#[derive(Debug, Clone)]
75pub struct DeviceAuthorization {
76 pub device_code: String,
78 pub user_code: String,
80 pub verification_uri: String,
82 pub verification_uri_complete: Option<String>,
86 pub interval_secs: u64,
88 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
125pub 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#[derive(Debug, Clone, PartialEq, Eq)]
167pub enum DevicePollOutcome {
168 Pending,
171 SlowDown,
175 Authorized(McpOAuthTokens),
177 Denied,
179 Expired,
181}
182
183pub 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
222pub 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
269pub 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 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
313pub 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)); 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}