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;