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
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
//! P5-2 (COMPOSABLE-HARNESS-DESIGN.md §2 module 15 D7 row 2 "OAuth"; §2.1
//! dep "`model.oauth` → trust-grade token storage" — the same security
//! class applies here): OAuth PROTOCOL support for authenticated remote MCP
//! servers.
//!
//! **Scope, stated plainly.** This module implements:
//! - the OAuth 2.0 Device Authorization Grant (RFC 8628) — the
//! non-interactive path: no browser/redirect listener needed, just a
//! `user_code`/`verification_uri` a caller prints and a background poll;
//! - refresh-token exchange.
//!
//! It does **NOT** implement the interactive authorization-code + PKCE +
//! local-redirect-listener browser flow — that needs a UI to open a
//! browser and a local HTTP listener to catch the redirect, which is
//! `tui`'s job (P5 item #4, not yet built). This is a **tui-deferred**
//! citation, not a silent gap: a server that only offers the browser flow
//! (no device-code grant) simply isn't reachable through this module yet.
//!
//! **Token storage is NOT this module's job.** This module only speaks the
//! wire protocol and returns [`McpOAuthTokens`] values — persisting them is
//! a CLI-layer concern (`crates/cli/src/userconfig.rs`'s
//! `save_mcp_oauth_tokens`/`load_mcp_oauth_tokens`), same trust-grade
//! posture (owner-only permissions, user/global-directory-only, never
//! project-readable) as `Config::api_key`/`save_api_key` (§3.2 S13) — this
//! crate never touches a filesystem for a credential.
use std::time::Duration;
use serde::Deserialize;
use crate::error::{Error, Result};
/// The endpoints/identity an MCP server's OAuth device-code flow needs.
/// Carries no token — see the module doc comment.
#[derive(Debug, Clone)]
pub struct OAuthEndpoints {
/// RFC 8628 device authorization endpoint.
pub device_authorization_endpoint: String,
/// Token endpoint (also used for the refresh-token grant).
pub token_endpoint: String,
/// OAuth client id.
pub client_id: String,
/// Optional scope string.
pub scope: Option<String>,
}
/// A trust-grade credential pair — see the module doc comment for why this
/// crate never persists one itself.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct McpOAuthTokens {
/// The bearer access token.
pub access_token: String,
/// The refresh token, if the server issued one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub refresh_token: Option<String>,
/// Unix-epoch seconds this access token expires at, if known.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at_secs: Option<u64>,
}
impl McpOAuthTokens {
/// Whether this token is expired (or about to expire within `skew`
/// seconds) as of `now_secs`. `expires_at_secs: None` (unknown
/// lifetime) is treated as NOT expired — a server that never told us
/// when the token dies is assumed valid until it actually fails.
pub fn is_expired_at(&self, now_secs: u64, skew_secs: u64) -> bool {
self.expires_at_secs
.map(|exp| now_secs.saturating_add(skew_secs) >= exp)
.unwrap_or(false)
}
}
/// The RFC 8628 device-authorization-response fields this module needs.
#[derive(Debug, Clone)]
pub struct DeviceAuthorization {
/// Opaque device code the client polls the token endpoint with.
pub device_code: String,
/// Short code the USER enters at `verification_uri`.
pub user_code: String,
/// The URL the user visits.
pub verification_uri: String,
/// A URL that already embeds `user_code`, if the server provided one —
/// print this instead of `verification_uri` + `user_code` separately
/// when present.
pub verification_uri_complete: Option<String>,
/// How often (seconds) the client should poll the token endpoint.
pub interval_secs: u64,
/// How long (seconds) `device_code` remains valid.
pub expires_in_secs: u64,
}
#[derive(Deserialize)]
struct DeviceAuthResponse {
device_code: String,
user_code: String,
verification_uri: String,
#[serde(default)]
verification_uri_complete: Option<String>,
#[serde(default = "default_interval")]
interval: u64,
#[serde(default = "default_expires_in")]
expires_in: u64,
}
fn default_interval() -> u64 {
5
}
fn default_expires_in() -> u64 {
600
}
#[derive(Deserialize)]
struct TokenResponse {
access_token: String,
#[serde(default)]
refresh_token: Option<String>,
#[serde(default)]
expires_in: Option<u64>,
}
#[derive(Deserialize)]
struct TokenErrorResponse {
error: String,
}
/// Step 1 of RFC 8628: request a device/user code pair.
pub async fn start_device_authorization(
client: &reqwest::Client,
ep: &OAuthEndpoints,
) -> Result<DeviceAuthorization> {
let mut form = vec![("client_id", ep.client_id.as_str())];
if let Some(scope) = &ep.scope {
form.push(("scope", scope.as_str()));
}
let resp = client
.post(&ep.device_authorization_endpoint)
.form(&form)
.send()
.await
.map_err(|e| Error::tool("mcp_oauth", format!("device authorization request: {e}")))?;
if !resp.status().is_success() {
return Err(Error::tool(
"mcp_oauth",
format!("device authorization: http status {}", resp.status()),
));
}
let body: DeviceAuthResponse = resp.json().await.map_err(|e| {
Error::tool(
"mcp_oauth",
format!("decoding device authorization response: {e}"),
)
})?;
Ok(DeviceAuthorization {
device_code: body.device_code,
user_code: body.user_code,
verification_uri: body.verification_uri,
verification_uri_complete: body.verification_uri_complete,
interval_secs: body.interval,
expires_in_secs: body.expires_in,
})
}
/// One poll of the token endpoint for a device code — RFC 8628 §3.5. The
/// server replies `authorization_pending` until the user finishes at
/// `verification_uri`; the caller (e.g. `poll_until_authorized`) is
/// expected to sleep `interval_secs` and retry.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DevicePollOutcome {
/// The user hasn't completed authorization yet — keep polling at the
/// same interval.
Pending,
/// The user hasn't completed authorization yet AND the server wants
/// polling slowed down (RFC 8628 §3.5) — the next poll should wait
/// `interval + 5s`, not just `interval`.
SlowDown,
/// The user completed authorization; tokens are attached.
Authorized(McpOAuthTokens),
/// The user (or the server) denied/cancelled — stop polling.
Denied,
/// The device code expired before authorization completed.
Expired,
}
/// A single token-endpoint poll for the device-code grant.
pub async fn poll_device_token(
client: &reqwest::Client,
ep: &OAuthEndpoints,
device_code: &str,
) -> Result<DevicePollOutcome> {
let form = [
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
("device_code", device_code),
("client_id", ep.client_id.as_str()),
];
let resp = client
.post(&ep.token_endpoint)
.form(&form)
.send()
.await
.map_err(|e| Error::tool("mcp_oauth", format!("token poll: {e}")))?;
if resp.status().is_success() {
let body: TokenResponse = resp
.json()
.await
.map_err(|e| Error::tool("mcp_oauth", format!("decoding token response: {e}")))?;
return Ok(DevicePollOutcome::Authorized(McpOAuthTokens {
access_token: body.access_token,
refresh_token: body.refresh_token,
expires_at_secs: body.expires_in.map(|secs| now_secs() + secs),
}));
}
let body: TokenErrorResponse = resp.json().await.unwrap_or(TokenErrorResponse {
error: "unknown_error".to_string(),
});
match body.error.as_str() {
"authorization_pending" => Ok(DevicePollOutcome::Pending),
"slow_down" => Ok(DevicePollOutcome::SlowDown),
"expired_token" => Ok(DevicePollOutcome::Expired),
_ => Ok(DevicePollOutcome::Denied),
}
}
/// The whole non-interactive device-code flow: start authorization, invoke
/// `on_prompt` exactly once with the [`DeviceAuthorization`] (so the caller
/// can print `verification_uri`/`user_code` for the user), then poll until
/// authorized/denied/expired — bounded by `expires_in_secs`, sleeping
/// `interval_secs` between attempts (never faster, per RFC 8628's
/// `slow_down` semantics — a `slow_down` response widens the interval by a
/// further 5s, same as the spec recommends).
pub async fn run_device_flow(
client: &reqwest::Client,
ep: &OAuthEndpoints,
on_prompt: impl FnOnce(&DeviceAuthorization),
) -> Result<McpOAuthTokens> {
let auth = start_device_authorization(client, ep).await?;
on_prompt(&auth);
let deadline = now_secs() + auth.expires_in_secs;
let mut interval = auth.interval_secs.max(1);
loop {
tokio::time::sleep(Duration::from_secs(interval)).await;
match poll_device_token(client, ep, &auth.device_code).await? {
DevicePollOutcome::Authorized(tokens) => return Ok(tokens),
DevicePollOutcome::Pending => {
if now_secs() >= deadline {
return Err(Error::tool(
"mcp_oauth",
"device code expired while polling",
));
}
}
DevicePollOutcome::SlowDown => {
interval += 5;
if now_secs() >= deadline {
return Err(Error::tool(
"mcp_oauth",
"device code expired while polling",
));
}
}
DevicePollOutcome::Denied => {
return Err(Error::tool("mcp_oauth", "authorization was denied"));
}
DevicePollOutcome::Expired => {
return Err(Error::tool("mcp_oauth", "device code expired"));
}
}
}
}
/// Exchange a refresh token for a new access token.
pub async fn refresh_token(
client: &reqwest::Client,
ep: &OAuthEndpoints,
refresh_token: &str,
) -> Result<McpOAuthTokens> {
let form = [
("grant_type", "refresh_token"),
("refresh_token", refresh_token),
("client_id", ep.client_id.as_str()),
];
let resp = client
.post(&ep.token_endpoint)
.form(&form)
.send()
.await
.map_err(|e| Error::tool("mcp_oauth", format!("refresh request: {e}")))?;
if !resp.status().is_success() {
return Err(Error::tool(
"mcp_oauth",
format!("refresh: http status {}", resp.status()),
));
}
let body: TokenResponse = resp
.json()
.await
.map_err(|e| Error::tool("mcp_oauth", format!("decoding refresh response: {e}")))?;
Ok(McpOAuthTokens {
access_token: body.access_token,
// A server that omits `refresh_token` on refresh means "reuse the
// same one" per RFC 6749 §6 — the caller (which already has the OLD
// refresh token) is responsible for keeping it if this is `None`.
refresh_token: body.refresh_token,
expires_at_secs: body.expires_in.map(|secs| now_secs() + secs),
})
}
fn now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
/// Build the `Authorization: Bearer <token>` header value for a stored
/// [`McpOAuthTokens`] — the shape [`crate::mcp::McpClient::connect_http`]/
/// `connect_sse`'s `headers` map expects.
pub fn bearer_header(tokens: &McpOAuthTokens) -> (String, String) {
(
"Authorization".to_string(),
format!("Bearer {}", tokens.access_token),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_expired_at_treats_unknown_lifetime_as_not_expired() {
let t = McpOAuthTokens {
access_token: "x".into(),
refresh_token: None,
expires_at_secs: None,
};
assert!(!t.is_expired_at(u64::MAX / 2, 0));
}
#[test]
fn is_expired_at_honors_skew() {
let t = McpOAuthTokens {
access_token: "x".into(),
refresh_token: None,
expires_at_secs: Some(1000),
};
assert!(!t.is_expired_at(900, 30));
assert!(t.is_expired_at(980, 30)); // within skew of expiry
assert!(t.is_expired_at(1000, 0));
}
#[test]
fn bearer_header_has_the_expected_shape() {
let t = McpOAuthTokens {
access_token: "secret123".into(),
refresh_token: None,
expires_at_secs: None,
};
let (name, value) = bearer_header(&t);
assert_eq!(name, "Authorization");
assert_eq!(value, "Bearer secret123");
}
}