Skip to main content

protocol_droid/adapters/secondary/oauth/
introspection.rs

1//! RFC 7662 token introspection: asking the authorization server what a token
2//! means.
3//!
4//! The counterpart to the JWT/JWKS source, and the one that needs no crypto of
5//! its own — the authorization server did the signing, so asking it is a
6//! complete authenticity check. It costs a round trip per request, which is
7//! the trade against local signature verification.
8//!
9//! # What is not trusted
10//!
11//! RFC 7662 §2.2 is emphatic that an inactive token's response "**MUST NOT**"
12//! carry other information, and the obvious abuse is a server that returns
13//! `active: false` alongside a plausible `aud`, `exp` and `scope` — an answer
14//! shaped for a client that reads fields before reading `active`. So
15//! `claims_from` returns at the first `active: false` and never looks at
16//! anything after it.
17//!
18//! A *missing* `active` is a third case, and neither of the obvious ones. It is
19//! not a refusal — the token is not known to be bad, and the caller must not be
20//! sent off to re-authenticate. It is not a default either — absent means nobody
21//! said yes. It is a malfunctioning authorization server, so it surfaces as
22//! [`SourceError::Unavailable`] and says exactly that.
23//!
24//! # What is not logged
25//!
26//! No error this module produces contains the token. The request is a POST body
27//! field precisely because the token is a bearer credential, and the one place
28//! it could leak is a formatted message — so [`SourceError`] carries statuses
29//! and reasons, and nothing borrows from the token.
30
31use std::sync::Arc;
32use std::time::{Duration, SystemTime, UNIX_EPOCH};
33
34use serde::Deserialize;
35
36use crate::adapters::secondary::oauth::http_body::{describe, read_capped};
37use crate::application::oauth::{Refusal, SourceError, TokenClaims, TokenSource};
38
39/// Asks an authorization server, over HTTP, what a presented token means.
40#[derive(Debug, Clone)]
41pub struct IntrospectionSource {
42    endpoint: Arc<str>,
43    client_id: Arc<str>,
44    client_secret: Arc<str>,
45    http: reqwest::Client,
46}
47
48impl IntrospectionSource {
49    /// A source pointed at `endpoint`.
50    ///
51    /// Opens no socket and reads no clock, so a typo in `endpoint` surfaces on
52    /// the first call rather than at construction — unlike the MCP client, the
53    /// endpoint here is an authorization server's, which a deployment discovers
54    /// from a metadata document rather than writes.
55    #[must_use]
56    pub fn new(
57        endpoint: impl Into<Arc<str>>,
58        client_id: impl Into<Arc<str>>,
59        client_secret: impl Into<Arc<str>>,
60    ) -> Self {
61        Self {
62            endpoint: endpoint.into(),
63            client_id: client_id.into(),
64            client_secret: client_secret.into(),
65            // Built once so a per-request TLS handshake and a fresh pool do not
66            // happen on the authorization path. No timeout is set here: an
67            // endpoint that stops answering must eventually be reported as
68            // unavailable, and the transport's own timeout is where a
69            // deployment decides how patient to be.
70            http: reqwest::Client::new(),
71        }
72    }
73
74    /// Replaces the HTTP client, for a caller that needs a timeout, a proxy or
75    /// custom TLS settings.
76    ///
77    /// There is deliberately no `with_timeout` shortcut: `reqwest`'s builder
78    /// returns a `Result`, and a shortcut would either swallow that error or
79    /// force every caller through a `Result` for a knob they can already reach.
80    #[must_use]
81    pub fn with_http_client(mut self, http: reqwest::Client) -> Self {
82        self.http = http;
83        self
84    }
85
86    /// The endpoint this source asks.
87    #[must_use]
88    pub fn endpoint(&self) -> &str {
89        &self.endpoint
90    }
91}
92
93impl TokenSource for IntrospectionSource {
94    async fn claims(&self, presented: &str) -> Result<TokenClaims, SourceError> {
95        // RFC 7662 §2.1: form-encoded, with the token as a body field and the
96        // client authenticating per RFC 6749 §2.3.1. `token_type_hint` is a
97        // hint, not a filter — an authorization server is free to ignore it,
98        // and one that misimplements it as a filter would answer 401 for a
99        // perfectly good token.
100        let form = [("token", presented), ("token_type_hint", "access_token")];
101
102        let response = self
103            .http
104            .post(&*self.endpoint)
105            .basic_auth(&*self.client_id, Some(&*self.client_secret))
106            .form(&form)
107            .send()
108            .await
109            .map_err(|failure| SourceError::Unavailable(describe(failure)))?;
110
111        let status = response.status();
112        if !status.is_success() {
113            // Deliberately *not* a refusal. A 401 here is this resource server
114            // failing to authenticate *to* the authorization server — a
115            // misconfigured `client_id` or `client_secret` — and telling the
116            // end user's client that its token was rejected sends it off to
117            // re-authenticate against a server that is working fine.
118            return Err(SourceError::Unavailable(format!(
119                "introspection responded {}",
120                status.as_u16(),
121            )));
122        }
123
124        let body = read_capped(response)
125            .await
126            .map_err(SourceError::Unavailable)?;
127        let parsed: IntrospectionResponse = serde_json::from_slice(&body)
128            .map_err(|failure| SourceError::Unavailable(describe(failure)))?;
129
130        claims_from(parsed)
131    }
132}
133
134// The bounded read and the credential-free failure text now live in
135// `http_body`, shared with the redemption adapter. A size cap copied into two
136// files is a cap that will eventually be fixed in one of them.
137
138/// The RFC 7662 response, as it arrives.
139///
140/// Kept private and separate from [`TokenClaims`] because it is a wire shape:
141/// `active` is required, `aud` is string-or-array, and the times are unix
142/// seconds. It is turned into claims exactly once, by `claims_from`.
143#[derive(Debug, Deserialize)]
144struct IntrospectionResponse {
145    /// Whether the token is currently valid.
146    ///
147    /// `Option` rather than `bool` only so a *missing* field can be told apart
148    /// from a malformed one in `claims_from`. RFC 7662 §2.2 makes it
149    /// required, and the absence is handled as a malfunction rather than as a
150    /// serde error, because the two deserve different log lines.
151    active: Option<bool>,
152    #[serde(default)]
153    scope: Option<String>,
154    #[serde(default)]
155    username: Option<String>,
156    #[serde(default)]
157    iss: Option<String>,
158    #[serde(default)]
159    sub: Option<String>,
160    #[serde(default)]
161    aud: Option<Audience>,
162    #[serde(default)]
163    exp: Option<u64>,
164    #[serde(default)]
165    nbf: Option<u64>,
166}
167
168/// RFC 7519 §4.1.3: `aud` is a string or an array of them.
169#[derive(Debug, Deserialize)]
170#[serde(untagged)]
171enum Audience {
172    One(String),
173    Many(Vec<String>),
174}
175
176/// Turns one introspection response into claims, or refuses.
177///
178/// Split out from the HTTP so the rule that matters — `active` gates
179/// everything — is testable without a socket, and so the next source (JWKS)
180/// cannot accidentally acquire a laxer version of it.
181///
182/// # Errors
183///
184/// [`SourceError::Unavailable`] when the response does not say whether the token
185/// is active at all, and [`SourceError::Refused`] when it says it is not. Every
186/// other absence is tolerated: RFC 7662 allows a response to omit any field but
187/// `active`, and [`verify`](crate::application::oauth::verify) is the stage that
188/// decides whether what is left is enough.
189fn claims_from(response: IntrospectionResponse) -> Result<TokenClaims, SourceError> {
190    match response.active {
191        Some(true) => {}
192        Some(false) => {
193            // Nothing after this point is read. RFC 7662 §2.2 says an inactive
194            // token's response carries no other information, and a server that
195            // sent some anyway is one a client must not believe.
196            return Err(SourceError::Refused(Refusal::Unscoped));
197        }
198        None => {
199            return Err(SourceError::Unavailable(
200                "the introspection response has no `active` field".to_owned(),
201            ));
202        }
203    }
204
205    let mut claims = TokenClaims::new();
206    if let Some(subject) = response.sub.or(response.username) {
207        claims = claims.with_subject(subject);
208    }
209    if let Some(issuer) = response.iss {
210        claims = claims.with_issuer(issuer);
211    }
212    match response.aud {
213        Some(Audience::One(one)) => claims = claims.with_audience_string(one),
214        Some(Audience::Many(many)) => {
215            for audience in many {
216                claims = claims.with_audience_string(audience);
217            }
218        }
219        None => {}
220    }
221    if let Some(exp) = response.exp.and_then(system_time) {
222        claims = claims.with_expiry(exp);
223    }
224    if let Some(nbf) = response.nbf.and_then(system_time) {
225        claims = claims.with_not_before(nbf);
226    }
227    if let Some(scope) = response.scope {
228        // RFC 6749 §3.3: space-delimited, not comma-delimited. Splitting on
229        // commas would fold two scopes into one that the authorization server
230        // never issued.
231        for scope in scope.split(' ') {
232            if !scope.is_empty() {
233                claims = claims.with_scope(scope);
234            }
235        }
236    }
237    Ok(claims)
238}
239
240/// Unix seconds to a [`SystemTime`], or `None` for a value that cannot be one.
241fn system_time(seconds: u64) -> Option<SystemTime> {
242    UNIX_EPOCH.checked_add(Duration::from_secs(seconds))
243}
244
245#[cfg(test)]
246#[path = "introspection/tests.rs"]
247mod tests;