Skip to main content

devboy_core/
oauth.rs

1//! OAuth 2.1 client for proxy MCP upstream authentication (issue #306).
2//!
3//! Implements the client half of the MCP authorization spec against any
4//! OAuth-2.1-compliant server, using only the standard RFCs the server already
5//! advertises — no vendor-specific logic:
6//!
7//! - **Discovery** ([`discover`]): RFC 9728 protected-resource metadata (located
8//!   via the upstream's `WWW-Authenticate` challenge) → RFC 8414 authorization-
9//!   server metadata.
10//! - Dynamic registration (RFC 7591), the device grant (RFC 8628) and refresh
11//!   (RFC 6749 §6) build on the [`AuthServerMetadata`] discovered here.
12
13use chrono::{DateTime, Duration, Utc};
14use secrecy::SecretString;
15use serde::{Deserialize, Serialize};
16use thiserror::Error;
17
18/// RFC 9728 §3.2 — OAuth Protected Resource Metadata (subset we consume).
19#[derive(Debug, Clone, Deserialize)]
20pub struct ProtectedResourceMetadata {
21    /// Authorization servers that can issue tokens for this resource.
22    #[serde(default)]
23    pub authorization_servers: Vec<String>,
24}
25
26/// RFC 8414 §2 — Authorization Server Metadata (subset we consume).
27#[derive(Debug, Clone, Deserialize)]
28pub struct AuthServerMetadata {
29    /// The AS's issuer identifier.
30    pub issuer: String,
31    /// RFC 6749 token endpoint (device_code + refresh_token grants).
32    pub token_endpoint: String,
33    /// RFC 8628 §4 device authorization endpoint.
34    #[serde(default)]
35    pub device_authorization_endpoint: Option<String>,
36    /// RFC 7591 dynamic client registration endpoint.
37    #[serde(default)]
38    pub registration_endpoint: Option<String>,
39    /// Scopes the AS supports; used as a default when the config omits `scopes`.
40    #[serde(default)]
41    pub scopes_supported: Vec<String>,
42}
43
44/// Failures across the OAuth client flow.
45#[derive(Debug, Error)]
46pub enum OAuthError {
47    /// The `WWW-Authenticate` challenge lacked a `resource_metadata` parameter.
48    #[error("no `resource_metadata` in WWW-Authenticate challenge")]
49    NoResourceMetadata,
50    /// The protected-resource metadata advertised no authorization server.
51    #[error("no authorization server advertised by the resource")]
52    NoAuthorizationServer,
53    /// The AS metadata omits a device_authorization_endpoint (RFC 8628 §4).
54    #[error("authorization server does not advertise a device_authorization_endpoint")]
55    NoDeviceEndpoint,
56    /// Network/HTTP failure talking to a discovery or token endpoint.
57    #[error("HTTP error: {0}")]
58    Http(String),
59    /// A metadata or token response could not be parsed.
60    #[error("malformed response: {0}")]
61    Malformed(String),
62    /// The token endpoint returned an OAuth `error` code (RFC 6749 §5.2 /
63    /// RFC 8628 §3.5: `authorization_pending`, `slow_down`, `access_denied`,
64    /// `expired_token`, `invalid_grant`, …).
65    #[error("oauth error: {0}")]
66    Oauth(String),
67}
68
69/// Extract the `resource_metadata` URL from an RFC 9728 `WWW-Authenticate`
70/// challenge value, e.g. `Bearer resource_metadata="https://…/.well-known/…"`.
71/// Handles quoted and bare forms and ignores surrounding params (`realm`, …).
72pub(crate) fn parse_www_authenticate(value: &str) -> Option<String> {
73    let idx = value.find("resource_metadata")?;
74    let rest = value[idx + "resource_metadata".len()..]
75        .trim_start()
76        .strip_prefix('=')?
77        .trim_start();
78    let url = if let Some(quoted) = rest.strip_prefix('"') {
79        quoted.split('"').next()?
80    } else {
81        rest.split([',', ' ']).next()?
82    };
83    (!url.is_empty()).then(|| url.to_string())
84}
85
86/// Guard an outbound discovery/endpoint URL before we fetch it or POST secrets
87/// to it. Metadata and token endpoints must be reached over `https` (`http` is
88/// tolerated only for a genuine loopback host). A malicious upstream can put
89/// anything in its `WWW-Authenticate` challenge or its advertised metadata, so
90/// this parses the URL properly and rejects: credentials in the authority
91/// (`http://localhost@evil/`), non-http(s) schemes (`file://`, …), and — for
92/// https — IP literals in private/link-local/loopback ranges. That keeps a
93/// crafted value from steering the client at an internal address or a plaintext
94/// endpoint (e.g. exfiltrating a rotating refresh token).
95///
96/// Not caught here: a *hostname* that resolves to a private address (DNS
97/// rebinding). Discovery clients additionally disable redirect-following (so a
98/// single crafted hop can't downgrade the target); connect-time IP pinning is a
99/// documented follow-up.
100pub(crate) fn require_web_url(raw: &str) -> Result<(), OAuthError> {
101    let parsed = url::Url::parse(raw)
102        .map_err(|e| OAuthError::Oauth(format!("invalid discovery URL {raw:?}: {e}")))?;
103    if !parsed.username().is_empty() || parsed.password().is_some() {
104        return Err(OAuthError::Oauth(format!(
105            "discovery URL must not carry credentials: {raw}"
106        )));
107    }
108    let host = parsed
109        .host()
110        .ok_or_else(|| OAuthError::Oauth(format!("discovery URL has no host: {raw}")))?;
111    if parsed.host_str().unwrap_or("").is_empty() {
112        return Err(OAuthError::Oauth(format!(
113            "discovery URL has no host: {raw}"
114        )));
115    }
116    match parsed.scheme() {
117        "https" => {
118            if host_is_private(&host) {
119                return Err(OAuthError::Oauth(format!(
120                    "refusing discovery URL at a private/link-local/loopback address: {raw}"
121                )));
122            }
123            Ok(())
124        }
125        "http" if host_is_loopback(&host) => Ok(()),
126        "http" => Err(OAuthError::Oauth(format!(
127            "refusing non-loopback http discovery URL (use https): {raw}"
128        ))),
129        other => Err(OAuthError::Oauth(format!(
130            "refusing discovery URL with non-http(s) scheme {other:?}: {raw}"
131        ))),
132    }
133}
134
135/// A host that is unambiguously loopback: exact `localhost`, `127.0.0.0/8`, `::1`.
136fn host_is_loopback(host: &url::Host<&str>) -> bool {
137    match host {
138        url::Host::Domain(d) => d.eq_ignore_ascii_case("localhost"),
139        url::Host::Ipv4(ip) => ip.is_loopback(),
140        url::Host::Ipv6(ip) => ip.is_loopback(),
141    }
142}
143
144/// An IP-literal host in a private, link-local, loopback, or unspecified range.
145/// Domain names return `false` (see the rebinding note on [`require_web_url`]).
146fn host_is_private(host: &url::Host<&str>) -> bool {
147    match host {
148        url::Host::Domain(_) => false,
149        url::Host::Ipv4(ip) => {
150            ip.is_private() || ip.is_loopback() || ip.is_link_local() || ip.is_unspecified()
151        }
152        url::Host::Ipv6(ip) => {
153            let seg0 = ip.segments()[0];
154            ip.is_loopback()
155                || ip.is_unspecified()
156                || (seg0 & 0xfe00) == 0xfc00 // ULA fc00::/7
157                || (seg0 & 0xffc0) == 0xfe80 // link-local fe80::/10
158        }
159    }
160}
161
162/// Discover authorization-server metadata for an upstream, starting from its
163/// `WWW-Authenticate` challenge (RFC 9728 → RFC 8414).
164pub async fn discover(
165    http: &reqwest::Client,
166    www_authenticate: &str,
167) -> Result<AuthServerMetadata, OAuthError> {
168    let resource_meta_url =
169        parse_www_authenticate(www_authenticate).ok_or(OAuthError::NoResourceMetadata)?;
170    require_web_url(&resource_meta_url)?;
171    let prm: ProtectedResourceMetadata = http
172        .get(&resource_meta_url)
173        .send()
174        .await
175        .map_err(|e| OAuthError::Http(e.to_string()))?
176        .error_for_status()
177        .map_err(|e| OAuthError::Http(e.to_string()))?
178        .json()
179        .await
180        .map_err(|e| OAuthError::Malformed(e.to_string()))?;
181    let as_base = prm
182        .authorization_servers
183        .into_iter()
184        .next()
185        .ok_or(OAuthError::NoAuthorizationServer)?;
186    fetch_as_metadata(http, &as_base).await
187}
188
189/// Fetch RFC 8414 authorization-server metadata. Per RFC 8414 §3.1 / RFC 8615
190/// the well-known segment goes *after* the authority and *before* any issuer
191/// path (`https://host/tenant` → `https://host/.well-known/oauth-authorization-server/tenant`),
192/// not naively appended. The advertised `issuer` is verified to match (§3.3),
193/// and every endpoint the AS advertises is scheme/host-guarded before we POST
194/// secrets to it.
195pub async fn fetch_as_metadata(
196    http: &reqwest::Client,
197    issuer: &str,
198) -> Result<AuthServerMetadata, OAuthError> {
199    require_web_url(issuer)?;
200    let well_known = well_known_url(issuer)?;
201    let meta: AuthServerMetadata = http
202        .get(&well_known)
203        .send()
204        .await
205        .map_err(|e| OAuthError::Http(e.to_string()))?
206        .error_for_status()
207        .map_err(|e| OAuthError::Http(e.to_string()))?
208        .json()
209        .await
210        .map_err(|e| OAuthError::Malformed(e.to_string()))?;
211    // RFC 8414 §3.3: the returned issuer MUST match the one we asked about.
212    if meta.issuer.trim_end_matches('/') != issuer.trim_end_matches('/') {
213        return Err(OAuthError::Malformed(format!(
214            "issuer mismatch: requested {issuer:?}, metadata declares {:?}",
215            meta.issuer
216        )));
217    }
218    // Guard every endpoint we may POST secrets to before trusting it.
219    require_web_url(&meta.token_endpoint)?;
220    if let Some(ep) = &meta.device_authorization_endpoint {
221        require_web_url(ep)?;
222    }
223    if let Some(ep) = &meta.registration_endpoint {
224        require_web_url(ep)?;
225    }
226    Ok(meta)
227}
228
229/// Build the RFC 8414 well-known metadata URL for an issuer, inserting the
230/// well-known segment after the authority and preserving any issuer path.
231fn well_known_url(issuer: &str) -> Result<String, OAuthError> {
232    let parsed = url::Url::parse(issuer)
233        .map_err(|e| OAuthError::Oauth(format!("invalid issuer {issuer:?}: {e}")))?;
234    // Bracket IPv6 literals: `host_str()` returns "::1" (unbracketed), which
235    // would build a malformed authority ("http://::1:8080/..."). RFC 3986 wants
236    // "[::1]". `require_web_url` permits http loopback IPv6 for local dev, so
237    // this path is reachable.
238    let authority = match parsed
239        .host()
240        .ok_or_else(|| OAuthError::Oauth(format!("issuer has no host: {issuer}")))?
241    {
242        url::Host::Ipv6(addr) => format!("[{addr}]"),
243        host => host.to_string(),
244    };
245    let port = parsed.port().map(|p| format!(":{p}")).unwrap_or_default();
246    let path = parsed.path().trim_end_matches('/'); // "" or "/tenant"
247    Ok(format!(
248        "{}://{}{}/.well-known/oauth-authorization-server{}",
249        parsed.scheme(),
250        authority,
251        port,
252        path
253    ))
254}
255
256/// Device + refresh grant type identifiers this client registers for.
257pub(crate) const GRANT_DEVICE_CODE: &str = "urn:ietf:params:oauth:grant-type:device_code";
258pub(crate) const GRANT_REFRESH_TOKEN: &str = "refresh_token";
259
260/// RFC 7591 §2 dynamic client registration request. We register a **public**
261/// client (no secret, `token_endpoint_auth_method = "none"`) that uses the
262/// device-code and refresh-token grants — exactly what a CLI needs.
263///
264/// `redirect_uris` is set to the RFC 6749 out-of-band value: the device grant
265/// never redirects, but RFC 7591 §2 lists the field and some servers reject a
266/// registration without a (non-empty) `redirect_uris`. Sending the OOB URN is
267/// spec-compliant and harmless for servers that ignore it.
268#[derive(Debug, Serialize)]
269struct ClientRegistrationRequest {
270    client_name: String,
271    grant_types: Vec<String>,
272    token_endpoint_auth_method: String,
273    redirect_uris: Vec<String>,
274}
275
276/// RFC 7591 §3.2.1 registration response (subset — we only need `client_id`).
277#[derive(Debug, Clone, Deserialize)]
278pub struct ClientRegistrationResponse {
279    pub client_id: String,
280}
281
282/// Register a public device-flow client via RFC 7591 and return its `client_id`.
283/// Callers persist the id into `ProxyOAuthConfig.client_id` so re-login reuses it.
284pub async fn register_client(
285    http: &reqwest::Client,
286    registration_endpoint: &str,
287    client_name: &str,
288) -> Result<String, OAuthError> {
289    let req = ClientRegistrationRequest {
290        client_name: client_name.to_string(),
291        grant_types: vec![
292            GRANT_DEVICE_CODE.to_string(),
293            GRANT_REFRESH_TOKEN.to_string(),
294        ],
295        token_endpoint_auth_method: "none".to_string(),
296        redirect_uris: vec!["urn:ietf:wg:oauth:2.0:oob".to_string()],
297    };
298    let resp: ClientRegistrationResponse = http
299        .post(registration_endpoint)
300        .json(&req)
301        .send()
302        .await
303        .map_err(|e| OAuthError::Http(e.to_string()))?
304        .error_for_status()
305        .map_err(|e| OAuthError::Http(e.to_string()))?
306        .json()
307        .await
308        .map_err(|e| OAuthError::Malformed(e.to_string()))?;
309    Ok(resp.client_id)
310}
311
312/// RFC 8628 §3.2 device authorization response.
313#[derive(Debug, Clone, Deserialize)]
314pub struct DeviceAuthResponse {
315    pub device_code: String,
316    pub user_code: String,
317    pub verification_uri: String,
318    #[serde(default)]
319    pub verification_uri_complete: Option<String>,
320    pub expires_in: i64,
321    /// Minimum seconds between polls. RFC 8628 §3.2 default is 5.
322    #[serde(default = "default_interval")]
323    pub interval: u64,
324}
325
326fn default_interval() -> u64 {
327    5
328}
329
330/// RFC 6749 §5.1 successful token response (device_code + refresh grants).
331#[derive(Debug, Clone, Deserialize)]
332pub struct TokenResponse {
333    pub access_token: SecretString,
334    #[serde(default)]
335    pub refresh_token: Option<SecretString>,
336    /// Access-token lifetime in seconds (used to compute `expires_at`).
337    #[serde(default)]
338    pub expires_in: Option<i64>,
339    #[serde(default)]
340    pub token_type: Option<String>,
341}
342
343/// One device-token poll outcome (RFC 8628 §3.5).
344#[derive(Debug)]
345pub enum DevicePollOutcome {
346    /// Not yet approved — keep polling at the current interval.
347    Pending,
348    /// Polled too fast — the client must lengthen the interval by 5s.
349    SlowDown,
350    /// User approved — tokens issued.
351    Granted(TokenResponse),
352}
353
354/// POST the RFC 8628 §3.1 device authorization request (form-encoded).
355pub async fn request_device_authorization(
356    http: &reqwest::Client,
357    device_endpoint: &str,
358    client_id: &str,
359    scope: Option<&str>,
360    resource: Option<&str>,
361) -> Result<DeviceAuthResponse, OAuthError> {
362    let mut form: Vec<(&str, &str)> = vec![("client_id", client_id)];
363    if let Some(s) = scope {
364        form.push(("scope", s));
365    }
366    // RFC 8707 resource indicator — binds the issued token's audience to the
367    // MCP server so the resource can validate it (MCP authorization spec).
368    if let Some(r) = resource {
369        form.push(("resource", r));
370    }
371    http.post(device_endpoint)
372        .form(&form)
373        .send()
374        .await
375        .map_err(|e| OAuthError::Http(e.to_string()))?
376        .error_for_status()
377        .map_err(|e| OAuthError::Http(e.to_string()))?
378        .json()
379        .await
380        .map_err(|e| OAuthError::Malformed(e.to_string()))
381}
382
383/// Poll the token endpoint once with the device_code grant (RFC 8628 §3.4/§3.5).
384/// `authorization_pending`/`slow_down` map to non-terminal outcomes; other error
385/// codes (`access_denied`, `expired_token`, …) surface as [`OAuthError::Oauth`].
386pub async fn poll_device_token_once(
387    http: &reqwest::Client,
388    token_endpoint: &str,
389    device_code: &str,
390    client_id: &str,
391    resource: Option<&str>,
392) -> Result<DevicePollOutcome, OAuthError> {
393    let mut form: Vec<(&str, &str)> = vec![
394        ("grant_type", GRANT_DEVICE_CODE),
395        ("device_code", device_code),
396        ("client_id", client_id),
397    ];
398    if let Some(r) = resource {
399        form.push(("resource", r));
400    }
401    let resp = http
402        .post(token_endpoint)
403        .form(&form)
404        .send()
405        .await
406        .map_err(|e| OAuthError::Http(e.to_string()))?;
407    let status = resp.status();
408    let body = resp
409        .text()
410        .await
411        .map_err(|e| OAuthError::Http(e.to_string()))?;
412    if status.is_success() {
413        let tokens: TokenResponse = serde_json::from_str(&body).map_err(|_| {
414            // Don't fold the raw body into the error — it carries the tokens.
415            OAuthError::Malformed("authorization server returned a malformed token response".into())
416        })?;
417        return Ok(DevicePollOutcome::Granted(tokens));
418    }
419    match parse_oauth_error(&body).as_deref() {
420        Some("authorization_pending") => Ok(DevicePollOutcome::Pending),
421        Some("slow_down") => Ok(DevicePollOutcome::SlowDown),
422        Some(other) => Err(OAuthError::Oauth(other.to_string())),
423        None => Err(OAuthError::Oauth(format!("HTTP {status}: {body}"))),
424    }
425}
426
427/// RFC 6749 §6 refresh-token grant — exchange a refresh_token for a fresh set.
428///
429/// The DevBoy AS **rotates** the refresh_token: the response carries a new one
430/// and the old is deactivated. Callers therefore MUST (1) persist the returned
431/// pair immediately and (2) serialize refreshes (single-flight) so two requests
432/// never race on the same — now dead — refresh_token. An `invalid_grant` error
433/// means the refresh_token is spent/revoked → the caller should re-run login.
434pub async fn refresh(
435    http: &reqwest::Client,
436    token_endpoint: &str,
437    refresh_token: &str,
438    client_id: &str,
439    resource: Option<&str>,
440) -> Result<TokenResponse, OAuthError> {
441    let mut form: Vec<(&str, &str)> = vec![
442        ("grant_type", GRANT_REFRESH_TOKEN),
443        ("refresh_token", refresh_token),
444        ("client_id", client_id),
445    ];
446    // RFC 8707 resource indicator — keep the refreshed token bound to the same
447    // MCP server audience.
448    if let Some(r) = resource {
449        form.push(("resource", r));
450    }
451    let resp = http
452        .post(token_endpoint)
453        .form(&form)
454        .send()
455        .await
456        .map_err(|e| OAuthError::Http(e.to_string()))?;
457    let status = resp.status();
458    let body = resp
459        .text()
460        .await
461        .map_err(|e| OAuthError::Http(e.to_string()))?;
462    if status.is_success() {
463        // Don't fold the raw body into the error — it carries the tokens.
464        return serde_json::from_str(&body).map_err(|_| {
465            OAuthError::Malformed("authorization server returned a malformed token response".into())
466        });
467    }
468    match parse_oauth_error(&body) {
469        Some(code) => Err(OAuthError::Oauth(code)),
470        None => Err(OAuthError::Oauth(format!("HTTP {status}: {body}"))),
471    }
472}
473
474/// serde helpers for `SecretString` fields that must round-trip through the
475/// (keychain-protected) JSON blob. Exposing a secret to serialize it is done
476/// explicitly here rather than via a blanket `SerializableSecret` impl, so the
477/// only place tokens become plaintext is this well-scoped boundary.
478mod secret_serde {
479    use secrecy::{ExposeSecret, SecretString};
480    use serde::{Deserialize, Deserializer, Serializer};
481
482    pub fn serialize<S: Serializer>(s: &SecretString, ser: S) -> Result<S::Ok, S::Error> {
483        ser.serialize_str(s.expose_secret())
484    }
485    pub fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result<SecretString, D::Error> {
486        Ok(SecretString::from(String::deserialize(de)?))
487    }
488}
489
490/// A persisted OAuth token set for one proxy upstream. Serialized as JSON into
491/// the credential store under a per-server key (e.g. `proxy.<name>.oauth`).
492#[derive(Debug, Clone, Serialize, Deserialize)]
493pub struct OAuthTokens {
494    #[serde(with = "secret_serde")]
495    pub access_token: SecretString,
496    #[serde(with = "secret_serde")]
497    pub refresh_token: SecretString,
498    /// Absolute UTC instant the access token expires.
499    pub expires_at: DateTime<Utc>,
500}
501
502impl OAuthTokens {
503    /// Build from a token response, computing `expires_at` from `expires_in`
504    /// relative to `now`. Falls back to `prev_refresh` when the response omits
505    /// `refresh_token` (defensive — the DevBoy AS always rotates and re-sends it).
506    pub fn from_response(
507        resp: TokenResponse,
508        now: DateTime<Utc>,
509        prev_refresh: Option<&str>,
510    ) -> Result<Self, OAuthError> {
511        let refresh_token = resp
512            .refresh_token
513            .or_else(|| prev_refresh.map(|s| SecretString::from(s.to_string())))
514            .ok_or_else(|| OAuthError::Malformed("token response has no refresh_token".into()))?;
515        // Clamp the advertised lifetime. A malicious/buggy AS can send
516        // `expires_in = i64::MAX` (a valid JSON integer), and `now + Duration`
517        // panics on overflow — which in `OAuthAuth::refresh` would abort the
518        // running MCP proxy on a live tool call. 400 days is far beyond any real
519        // access-token lifetime; capping only means we refresh a little sooner.
520        const MAX_TTL_SECS: i64 = 400 * 24 * 60 * 60;
521        let ttl = resp.expires_in.unwrap_or(3600).clamp(0, MAX_TTL_SECS);
522        Ok(Self {
523            access_token: resp.access_token,
524            refresh_token,
525            expires_at: now + Duration::seconds(ttl),
526        })
527    }
528
529    /// True when the access token is expired or within `skew` of expiry — the
530    /// signal for a pre-flight refresh.
531    pub fn is_near_expiry(&self, now: DateTime<Utc>, skew: Duration) -> bool {
532        self.expires_at <= now + skew
533    }
534}
535
536/// Extract the `error` code from an RFC 6749 §5.2 error response body.
537pub(crate) fn parse_oauth_error(body: &str) -> Option<String> {
538    #[derive(Deserialize)]
539    struct ErrBody {
540        error: String,
541    }
542    serde_json::from_str::<ErrBody>(body).ok().map(|e| e.error)
543}
544
545#[cfg(test)]
546mod tests {
547    use super::*;
548    use secrecy::ExposeSecret;
549
550    #[test]
551    fn parse_www_authenticate_quoted() {
552        let v = r#"Bearer resource_metadata="https://app.devboy.pro/.well-known/oauth-protected-resource""#;
553        assert_eq!(
554            parse_www_authenticate(v).as_deref(),
555            Some("https://app.devboy.pro/.well-known/oauth-protected-resource")
556        );
557    }
558
559    #[test]
560    fn parse_www_authenticate_ignores_other_params() {
561        let v = r#"Bearer realm="mcp", resource_metadata="https://x/y", error="invalid_token""#;
562        assert_eq!(parse_www_authenticate(v).as_deref(), Some("https://x/y"));
563    }
564
565    #[test]
566    fn parse_www_authenticate_bare_value() {
567        let v = "Bearer resource_metadata=https://x/y error=foo";
568        assert_eq!(parse_www_authenticate(v).as_deref(), Some("https://x/y"));
569    }
570
571    #[test]
572    fn parse_www_authenticate_absent() {
573        assert!(parse_www_authenticate("Bearer realm=\"x\"").is_none());
574    }
575
576    #[test]
577    fn require_web_url_enforces_https_and_loopback() {
578        // https to a public host is fine
579        assert!(require_web_url("https://as.example.com/.well-known/x").is_ok());
580        // http tolerated only for genuine loopback hosts
581        assert!(require_web_url("http://localhost:8080/meta").is_ok());
582        assert!(require_web_url("http://127.0.0.1/meta").is_ok());
583        assert!(require_web_url("http://[::1]:9000/meta").is_ok());
584        // non-loopback http is refused (must be https)
585        assert!(require_web_url("http://evil.internal/meta").is_err());
586        assert!(require_web_url("http://169.254.169.254/latest/meta-data").is_err());
587        // non-web schemes are refused outright (SSRF surface)
588        assert!(require_web_url("file:///etc/passwd").is_err());
589        assert!(require_web_url("ftp://host/x").is_err());
590        assert!(require_web_url("gopher://host").is_err());
591        // unparsable input is rejected
592        assert!(require_web_url("not a url").is_err());
593    }
594
595    #[test]
596    fn require_web_url_blocks_the_reviewed_bypasses() {
597        // https to a private/link-local/loopback IP literal (the guard's whole
598        // point — previously only the scheme was checked).
599        assert!(require_web_url("https://169.254.169.254/latest/meta-data").is_err());
600        assert!(require_web_url("https://10.0.0.5/token").is_err());
601        assert!(require_web_url("https://192.168.1.1/x").is_err());
602        assert!(require_web_url("https://172.16.0.1/x").is_err());
603        assert!(require_web_url("https://127.0.0.1/x").is_err());
604        assert!(require_web_url("https://[::1]/x").is_err());
605        assert!(require_web_url("https://[fd00::1]/x").is_err()); // ULA
606        assert!(require_web_url("https://[fe80::1]/x").is_err()); // link-local
607        // userinfo authority-confusion: real host is evil.com, not localhost.
608        assert!(require_web_url("http://localhost:80@evil.com/x").is_err());
609        assert!(require_web_url("http://localhost@evil.com/x").is_err());
610        assert!(require_web_url("https://as.example.com@evil.com/x").is_err());
611        // look-alike domain that merely starts with "127." is NOT loopback.
612        assert!(require_web_url("http://127.0.0.1.evil.com/x").is_err());
613        assert!(require_web_url("http://localhost.evil.com/x").is_err());
614    }
615
616    #[test]
617    fn well_known_url_is_rfc8414_path_aware() {
618        // hostless issuer → segment right after the authority
619        assert_eq!(
620            well_known_url("https://as.example.com").unwrap(),
621            "https://as.example.com/.well-known/oauth-authorization-server"
622        );
623        // trailing slash normalized
624        assert_eq!(
625            well_known_url("https://as.example.com/").unwrap(),
626            "https://as.example.com/.well-known/oauth-authorization-server"
627        );
628        // path-bearing issuer → well-known BEFORE the path (RFC 8414 §3.1)
629        assert_eq!(
630            well_known_url("https://ex.com/tenant1").unwrap(),
631            "https://ex.com/.well-known/oauth-authorization-server/tenant1"
632        );
633        // port preserved
634        assert_eq!(
635            well_known_url("https://ex.com:8443/t").unwrap(),
636            "https://ex.com:8443/.well-known/oauth-authorization-server/t"
637        );
638        // IPv6 loopback issuer (allowed for http by require_web_url) → the
639        // authority must stay bracketed, else the URL is malformed.
640        assert_eq!(
641            well_known_url("http://[::1]:8080").unwrap(),
642            "http://[::1]:8080/.well-known/oauth-authorization-server"
643        );
644    }
645
646    #[test]
647    fn as_metadata_deserializes() {
648        let json = r#"{
649            "issuer": "https://as.example.com",
650            "token_endpoint": "https://as.example.com/token",
651            "device_authorization_endpoint": "https://as.example.com/device",
652            "registration_endpoint": "https://as.example.com/register",
653            "scopes_supported": ["mcp:read", "mcp:write"]
654        }"#;
655        let m: AuthServerMetadata = serde_json::from_str(json).unwrap();
656        assert_eq!(m.token_endpoint, "https://as.example.com/token");
657        assert_eq!(
658            m.device_authorization_endpoint.as_deref(),
659            Some("https://as.example.com/device")
660        );
661        assert_eq!(m.scopes_supported.len(), 2);
662    }
663
664    #[test]
665    fn as_metadata_minimal_optional_fields_default() {
666        let json = r#"{"issuer":"https://as","token_endpoint":"https://as/token"}"#;
667        let m: AuthServerMetadata = serde_json::from_str(json).unwrap();
668        assert!(m.device_authorization_endpoint.is_none());
669        assert!(m.registration_endpoint.is_none());
670        assert!(m.scopes_supported.is_empty());
671    }
672
673    #[test]
674    fn protected_resource_metadata_deserializes() {
675        let json = r#"{"authorization_servers": ["https://as.example.com"]}"#;
676        let m: ProtectedResourceMetadata = serde_json::from_str(json).unwrap();
677        assert_eq!(m.authorization_servers, vec!["https://as.example.com"]);
678    }
679
680    #[test]
681    fn registration_request_is_public_device_client() {
682        let req = ClientRegistrationRequest {
683            client_name: "devboy-cli".into(),
684            grant_types: vec![GRANT_DEVICE_CODE.into(), GRANT_REFRESH_TOKEN.into()],
685            token_endpoint_auth_method: "none".into(),
686            redirect_uris: vec!["urn:ietf:wg:oauth:2.0:oob".into()],
687        };
688        let v = serde_json::to_value(&req).unwrap();
689        assert_eq!(v["token_endpoint_auth_method"], "none");
690        assert_eq!(v["grant_types"][0], GRANT_DEVICE_CODE);
691        assert_eq!(v["grant_types"][1], "refresh_token");
692        assert_eq!(v["redirect_uris"][0], "urn:ietf:wg:oauth:2.0:oob");
693    }
694
695    #[test]
696    fn registration_response_deserializes() {
697        let json = r#"{"client_id": "cli-xyz", "client_id_issued_at": 123, "client_secret": null}"#;
698        let r: ClientRegistrationResponse = serde_json::from_str(json).unwrap();
699        assert_eq!(r.client_id, "cli-xyz");
700    }
701
702    #[test]
703    fn device_auth_response_deserializes_with_defaults() {
704        let json = r#"{
705            "device_code": "dc123",
706            "user_code": "WDJB-MJHT",
707            "verification_uri": "https://as/device",
708            "verification_uri_complete": "https://as/device?user_code=WDJB-MJHT",
709            "expires_in": 900,
710            "interval": 5
711        }"#;
712        let d: DeviceAuthResponse = serde_json::from_str(json).unwrap();
713        assert_eq!(d.user_code, "WDJB-MJHT");
714        assert_eq!(d.interval, 5);
715        assert_eq!(
716            d.verification_uri_complete.as_deref(),
717            Some("https://as/device?user_code=WDJB-MJHT")
718        );
719    }
720
721    #[test]
722    fn device_auth_interval_defaults_to_5() {
723        let json = r#"{"device_code":"d","user_code":"U","verification_uri":"https://as/d","expires_in":600}"#;
724        let d: DeviceAuthResponse = serde_json::from_str(json).unwrap();
725        assert_eq!(d.interval, 5);
726        assert!(d.verification_uri_complete.is_none());
727    }
728
729    #[test]
730    fn token_response_deserializes() {
731        let json = r#"{"access_token":"at","refresh_token":"rt","expires_in":7776000,"token_type":"Bearer"}"#;
732        let t: TokenResponse = serde_json::from_str(json).unwrap();
733        assert_eq!(t.access_token.expose_secret(), "at");
734        assert_eq!(
735            t.refresh_token.as_ref().map(|s| s.expose_secret()),
736            Some("rt")
737        );
738        assert_eq!(t.expires_in, Some(7776000));
739    }
740
741    #[test]
742    fn parse_oauth_error_extracts_code() {
743        assert_eq!(
744            parse_oauth_error(r#"{"error":"authorization_pending"}"#).as_deref(),
745            Some("authorization_pending")
746        );
747        assert_eq!(
748            parse_oauth_error(r#"{"error":"slow_down","error_description":"…"}"#).as_deref(),
749            Some("slow_down")
750        );
751        assert!(parse_oauth_error("not json").is_none());
752    }
753
754    #[tokio::test]
755    async fn refresh_returns_rotated_pair_on_success() {
756        use httpmock::prelude::*;
757        let server = MockServer::start_async().await;
758        let m = server
759            .mock_async(|when, then| {
760                when.method(POST).path("/token");
761                then.status(200).header("content-type", "application/json").body(
762                    r#"{"access_token":"new-at","refresh_token":"new-rt","expires_in":7776000,"token_type":"Bearer"}"#,
763                );
764            })
765            .await;
766        let http = reqwest::Client::new();
767        let t = refresh(
768            &http,
769            &format!("{}/token", server.base_url()),
770            "old-rt",
771            "cli-x",
772            None,
773        )
774        .await
775        .unwrap();
776        m.assert_async().await;
777        assert_eq!(t.access_token.expose_secret(), "new-at");
778        assert_eq!(
779            t.refresh_token.as_ref().map(|s| s.expose_secret()),
780            Some("new-rt")
781        ); // rotated
782    }
783
784    #[tokio::test]
785    async fn refresh_invalid_grant_surfaces_error() {
786        use httpmock::prelude::*;
787        let server = MockServer::start_async().await;
788        server
789            .mock_async(|when, then| {
790                when.method(POST).path("/token");
791                then.status(400)
792                    .header("content-type", "application/json")
793                    .body(r#"{"error":"invalid_grant"}"#);
794            })
795            .await;
796        let http = reqwest::Client::new();
797        let err = refresh(
798            &http,
799            &format!("{}/token", server.base_url()),
800            "spent",
801            "cli-x",
802            None,
803        )
804        .await
805        .unwrap_err();
806        assert!(matches!(err, OAuthError::Oauth(c) if c == "invalid_grant"));
807    }
808
809    fn epoch() -> DateTime<Utc> {
810        DateTime::from_timestamp(1_700_000_000, 0).unwrap()
811    }
812
813    #[test]
814    fn oauth_tokens_from_response_computes_expiry() {
815        let resp = TokenResponse {
816            access_token: "at".into(),
817            refresh_token: Some("rt".into()),
818            expires_in: Some(3600),
819            token_type: Some("Bearer".into()),
820        };
821        let t = OAuthTokens::from_response(resp, epoch(), None).unwrap();
822        assert_eq!(t.access_token.expose_secret(), "at");
823        assert_eq!(t.refresh_token.expose_secret(), "rt");
824        assert_eq!(t.expires_at, epoch() + Duration::seconds(3600));
825    }
826
827    #[test]
828    fn from_response_clamps_out_of_range_expires_in() {
829        // A hostile AS sends expires_in = i64::MAX (valid JSON). Previously
830        // `now + Duration::seconds(i64::MAX)` panicked; now it clamps.
831        let resp = TokenResponse {
832            access_token: "at".into(),
833            refresh_token: Some("rt".into()),
834            expires_in: Some(i64::MAX),
835            token_type: Some("Bearer".into()),
836        };
837        let t = OAuthTokens::from_response(resp, epoch(), None).expect("must not panic");
838        // Clamped to <= 400 days, so the timestamp is well within range.
839        assert!(t.expires_at <= epoch() + Duration::days(400));
840        assert!(t.expires_at > epoch());
841    }
842
843    #[test]
844    fn oauth_tokens_falls_back_to_prev_refresh() {
845        let resp = TokenResponse {
846            access_token: "at".into(),
847            refresh_token: None,
848            expires_in: Some(60),
849            token_type: None,
850        };
851        let t = OAuthTokens::from_response(resp, epoch(), Some("old-rt")).unwrap();
852        assert_eq!(t.refresh_token.expose_secret(), "old-rt");
853    }
854
855    #[test]
856    fn oauth_tokens_no_refresh_anywhere_errors() {
857        let resp = TokenResponse {
858            access_token: "at".into(),
859            refresh_token: None,
860            expires_in: Some(60),
861            token_type: None,
862        };
863        assert!(OAuthTokens::from_response(resp, epoch(), None).is_err());
864    }
865
866    #[test]
867    fn oauth_tokens_near_expiry() {
868        let t = OAuthTokens {
869            access_token: "at".into(),
870            refresh_token: "rt".into(),
871            expires_at: epoch() + Duration::seconds(100),
872        };
873        // 50s before expiry, 60s skew → near expiry
874        assert!(t.is_near_expiry(epoch() + Duration::seconds(50), Duration::seconds(60)));
875        // 10s in, 60s skew → not near yet
876        assert!(!t.is_near_expiry(epoch() + Duration::seconds(10), Duration::seconds(60)));
877    }
878
879    #[test]
880    fn oauth_tokens_json_roundtrip() {
881        let t = OAuthTokens {
882            access_token: "at".into(),
883            refresh_token: "rt".into(),
884            expires_at: epoch(),
885        };
886        let json = serde_json::to_string(&t).unwrap();
887        let back: OAuthTokens = serde_json::from_str(&json).unwrap();
888        assert_eq!(back.access_token.expose_secret(), "at");
889        assert_eq!(back.expires_at, epoch());
890    }
891}