Skip to main content

protocol_droid/domain/oauth/
issued_token.rs

1//! What a token endpoint hands back, and what it refuses to.
2//!
3//! RFC 6749 §5.1 defines the success shape and §5.2 the failure shape. Both
4//! arrive as JSON, and both are worth modelling as types rather than as a
5//! `serde_json::Value` a caller interrogates — the difference between a token
6//! this crate can present and one it cannot is exactly the difference between
7//! a parsed `IssuedToken` and a map that happened to have the right keys.
8//!
9//! # What is strict, and why
10//!
11//! `access_token` and `token_type` are both REQUIRED by §5.1, so a body
12//! missing either is refused rather than defaulted. `token_type` is checked
13//! against `Bearer` case-insensitively — §5.1 makes the value case
14//! insensitive, and a server sending `bearer` is conforming, not broken.
15//! A token of any *other* type is refused: this crate has no way to present a
16//! DPoP-bound or Mac-authenticated token, and accepting one would mean
17//! constructing an `IssuedToken` that looks usable and is not.
18//!
19//! # What is not strict
20//!
21//! Unrecognised members are preserved in [`IssuedToken::extra`] rather than
22//! dropped, for the same reason the IR keeps unknown fields: a member this
23//! build does not model may be one the peer relies on, and discarding it
24//! silently is how a client quietly stops interoperating after a server
25//! upgrade.
26
27use std::fmt;
28use std::sync::Arc;
29use std::time::Duration;
30
31use serde::Deserialize;
32
33/// An access token, as a bearer credential.
34///
35/// `Debug` is redacted. `Display` is full, because `Display` here is the
36/// presentation API — a caller putting the token in an `Authorization` header
37/// is using it the way it was issued — and `as_str` is the escape hatch for
38/// everything else.
39#[derive(Clone)]
40pub struct AccessToken {
41    value: Arc<str>,
42    expires_in: Option<Duration>,
43}
44
45impl AccessToken {
46    /// A token, optionally with the lifetime the server reported.
47    #[must_use]
48    pub fn new(value: impl AsRef<str>, expires_in: Option<Duration>) -> Self {
49        Self {
50            value: Arc::from(value.as_ref()),
51            expires_in,
52        }
53    }
54
55    /// The token itself.
56    #[must_use]
57    pub fn as_str(&self) -> &str {
58        &self.value
59    }
60
61    /// How long the server said this token lives.
62    ///
63    /// A client is free to cache for exactly this long and no longer. An
64    /// absent `expires_in` is not "never expires" — RFC 6749 §5.1 makes it
65    /// RECOMMENDED rather than REQUIRED, so its absence says the server did
66    /// not say, and the honest reading is that the client should not cache.
67    #[must_use]
68    pub const fn expires_in(&self) -> Option<Duration> {
69        self.expires_in
70    }
71}
72
73impl fmt::Debug for AccessToken {
74    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
75        formatter.write_str("AccessToken(…)")
76    }
77}
78
79impl fmt::Display for AccessToken {
80    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
81        formatter.write_str(&self.value)
82    }
83}
84
85/// A refresh token, as a bearer credential.
86///
87/// Same redaction contract as [`AccessToken`], and for the same reason: it is
88/// presented as a credential and must not ride out on a `dbg!`.
89#[derive(Clone)]
90pub struct RefreshToken(Arc<str>);
91
92impl RefreshToken {
93    /// The token the server issued.
94    #[must_use]
95    pub fn issued(value: impl AsRef<str>) -> Self {
96        Self(Arc::from(value.as_ref()))
97    }
98
99    /// The token itself.
100    #[must_use]
101    pub fn as_str(&self) -> &str {
102        &self.0
103    }
104}
105
106impl fmt::Debug for RefreshToken {
107    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
108        formatter.write_str("RefreshToken(…)")
109    }
110}
111
112impl fmt::Display for RefreshToken {
113    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
114        formatter.write_str(&self.0)
115    }
116}
117
118/// A successfully redeemed grant.
119///
120/// Obtained only from [`IssuedToken::parse`], so an `IssuedToken` that exists
121/// is one that parsed. `token_type` is not carried because the only value that
122/// can produce this type is `Bearer` — the check happened at the door.
123#[derive(Debug)]
124pub struct IssuedToken {
125    access_token: AccessToken,
126    refresh_token: Option<RefreshToken>,
127    scope: Option<Arc<str>>,
128    extra: serde_json::Map<String, serde_json::Value>,
129}
130
131impl IssuedToken {
132    /// The token to present as a bearer credential.
133    #[must_use]
134    pub const fn access_token(&self) -> &AccessToken {
135        &self.access_token
136    }
137
138    /// The refresh token, if the server issued one.
139    #[must_use]
140    pub const fn refresh_token(&self) -> Option<&RefreshToken> {
141        self.refresh_token.as_ref()
142    }
143
144    /// The scope actually granted.
145    ///
146    /// RFC 6749 §5.1 makes this OPTIONAL, and an absent value does **not**
147    /// mean "everything that was asked for" — §5.1 says the server treated it
148    /// as equal to the requested scope, and §3.3 lets it narrow that
149    /// silently. A client that reads `None` as "unlimited" would present a
150    /// token for a grant it never received.
151    #[must_use]
152    pub fn scope(&self) -> Option<&str> {
153        self.scope.as_deref()
154    }
155
156    /// Members of the response this build does not model.
157    #[must_use]
158    pub const fn extra(&self) -> &serde_json::Map<String, serde_json::Value> {
159        &self.extra
160    }
161
162    /// Parse an RFC 6749 §5.1 success body, or an RFC 6749 §5.2 error body.
163    ///
164    /// # Errors
165    ///
166    /// [`TokenResponseError::AuthorizationServer`] when the body names an
167    /// `error`, carrying the server's own `error` code rather than a guess.
168    /// [`TokenResponseError::Malformed`] when the body is not JSON, or omits
169    /// the two REQUIRED members. [`TokenResponseError::UnsupportedTokenType`]
170    /// when `token_type` is something other than `Bearer` under a
171    /// case-insensitive comparison.
172    ///
173    /// # Examples
174    ///
175    /// ```
176    /// # use protocol_droid::domain::oauth::issued_token::{IssuedToken, TokenResponseError};
177    /// let body = br#"{"access_token":"at-1","token_type":"Bearer","expires_in":3600}"#;
178    /// let issued = IssuedToken::parse(body)?;
179    ///
180    /// assert_eq!(issued.access_token().as_str(), "at-1");
181    /// assert!(issued.refresh_token().is_none());
182    ///
183    /// let refused = br#"{"error":"invalid_grant","error_description":"expired"}"#;
184    /// assert!(matches!(
185    ///     IssuedToken::parse(refused),
186    ///     Err(TokenResponseError::AuthorizationServer { error, .. })
187    ///         if error == "invalid_grant"
188    /// ));
189    /// # Ok::<(), TokenResponseError>(())
190    /// ```
191    pub fn parse(body: &[u8]) -> Result<Self, TokenResponseError> {
192        let wire: Wire =
193            serde_json::from_slice(body).map_err(|failure| TokenResponseError::Malformed {
194                reason: failure.to_string(),
195            })?;
196
197        // §5.2 is checked first and on the member's presence rather than on the
198        // HTTP status: a server that answers 400 with a §5.1 body is broken,
199        // and one that answers 200 with a §5.2 body must not be read as a
200        // success with missing fields.
201        if let Some(error) = wire.error {
202            return Err(TokenResponseError::AuthorizationServer {
203                error,
204                description: wire.error_description,
205            });
206        }
207
208        let access_token = wire
209            .access_token
210            .ok_or_else(|| TokenResponseError::Malformed {
211                reason: "missing access_token".to_owned(),
212            })?;
213
214        let token_type = wire
215            .token_type
216            .ok_or_else(|| TokenResponseError::Malformed {
217                reason: "missing token_type".to_owned(),
218            })?;
219
220        // §5.1: "Values used in the access token type are case insensitive."
221        if !token_type.eq_ignore_ascii_case("Bearer") {
222            return Err(TokenResponseError::UnsupportedTokenType { token_type });
223        }
224
225        let mut extra = wire.extra;
226        // Everything the named members carried is removed, so `extra` holds
227        // only what this build genuinely does not model. Leaving `scope` in
228        // both places would give a caller two answers to "what was granted".
229        for modelled in [
230            "access_token",
231            "token_type",
232            "refresh_token",
233            "scope",
234            "expires_in",
235        ] {
236            extra.remove(modelled);
237        }
238        extra.remove("error");
239        extra.remove("error_description");
240
241        Ok(Self {
242            access_token: AccessToken::new(access_token, wire.expires_in.map(Duration::from_secs)),
243            refresh_token: wire.refresh_token.map(RefreshToken::issued),
244            scope: wire.scope.map(Arc::from),
245            extra,
246        })
247    }
248}
249
250/// The wire shape, read leniently and validated strictly.
251///
252/// Every member is optional here because the validation belongs in
253/// [`IssuedToken::parse`], where each absence can carry its own reason. A
254/// `serde` `deny_unknown_fields` would refuse a server that sends a member we
255/// do not model, which is the opposite of what this crate wants.
256#[derive(Deserialize)]
257struct Wire {
258    #[serde(default)]
259    access_token: Option<String>,
260    #[serde(default)]
261    token_type: Option<String>,
262    #[serde(default)]
263    refresh_token: Option<String>,
264    #[serde(default)]
265    scope: Option<String>,
266    #[serde(default)]
267    expires_in: Option<u64>,
268    #[serde(default)]
269    error: Option<String>,
270    #[serde(default)]
271    error_description: Option<String>,
272    #[serde(flatten)]
273    extra: serde_json::Map<String, serde_json::Value>,
274}
275
276/// Why a token response was refused.
277#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
278pub enum TokenResponseError {
279    /// The body was not JSON, or was missing a REQUIRED member.
280    #[error("the token response could not be read: {reason}")]
281    Malformed {
282        /// What was wrong. Never contains the token.
283        reason: String,
284    },
285    /// §5.2: the server named its own refusal.
286    ///
287    /// The `error` code is the part that goes in the message; `description` is
288    /// kept as a field because a server's own words are worth having in a log
289    /// and are not worth interpolating into an error string that might be
290    /// pasted somewhere.
291    #[error("the authorization server refused: {error}")]
292    AuthorizationServer {
293        /// The server's `error` code, verbatim.
294        error: String,
295        /// The server's `error_description`, if it sent one.
296        description: Option<String>,
297    },
298    /// A `token_type` this crate cannot present.
299    #[error("this crate cannot present a token of type {token_type}")]
300    UnsupportedTokenType {
301        /// The type the server named, verbatim.
302        token_type: String,
303    },
304}
305
306#[cfg(test)]
307mod tests {
308    use std::time::Duration;
309
310    use assert_matches::assert_matches;
311
312    use super::{AccessToken, IssuedToken, RefreshToken, TokenResponseError};
313
314    #[test]
315    fn a_minimal_success_body_parses() -> Result<(), TokenResponseError> {
316        let issued = IssuedToken::parse(br#"{"access_token":"at-1","token_type":"Bearer"}"#)?;
317
318        assert_eq!(issued.access_token().as_str(), "at-1");
319        assert_eq!(issued.access_token().expires_in(), None);
320        assert!(issued.extra().is_empty());
321        Ok(())
322    }
323
324    /// §5.1 makes the value case insensitive, so a conforming server sending
325    /// `bearer` must not be refused for its capitalisation.
326    #[test]
327    fn the_token_type_is_compared_case_insensitively() {
328        for spelling in ["Bearer", "bearer", "BEARER", "bEaReR"] {
329            let body = format!(r#"{{"access_token":"at","token_type":"{spelling}"}}"#);
330            assert!(
331                IssuedToken::parse(body.as_bytes()).is_ok(),
332                "{spelling} should be accepted"
333            );
334        }
335    }
336
337    /// The strictness that matters: a token this crate cannot present must
338    /// never become an `IssuedToken`, because every `IssuedToken` downstream
339    /// assumes it is presentable as a bearer credential.
340    #[test]
341    fn a_token_of_another_type_is_refused() {
342        assert_matches!(
343            IssuedToken::parse(br#"{"access_token":"at","token_type":"DPoP"}"#),
344            Err(TokenResponseError::UnsupportedTokenType { token_type }) if token_type == "DPoP"
345        );
346    }
347
348    #[test]
349    fn both_required_members_are_required() {
350        assert_matches!(
351            IssuedToken::parse(br#"{"token_type":"Bearer"}"#),
352            Err(TokenResponseError::Malformed { .. })
353        );
354        assert_matches!(
355            IssuedToken::parse(br#"{"access_token":"at"}"#),
356            Err(TokenResponseError::Malformed { .. })
357        );
358    }
359
360    /// §5.2 checked on the member's presence, so a server answering 200 with an
361    /// error body cannot be read as a success with missing fields.
362    #[test]
363    fn an_error_body_is_refused_even_though_it_looks_complete() {
364        assert_matches!(
365            IssuedToken::parse(
366                br#"{"access_token":"at","token_type":"Bearer","error":"invalid_grant"}"#
367            ),
368            Err(TokenResponseError::AuthorizationServer { error, .. })
369                if error == "invalid_grant"
370        );
371    }
372
373    #[test]
374    fn the_servers_own_error_code_is_carried_verbatim() {
375        let body = br#"{"error":"invalid_client","error_description":"unknown client"}"#;
376
377        assert_matches!(
378            IssuedToken::parse(body),
379            Err(TokenResponseError::AuthorizationServer { error, description: Some(description) })
380                if error == "invalid_client" && description == "unknown client"
381        );
382    }
383
384    /// The pass-through that keeps a client interoperating after a server
385    /// upgrade. Dropping `id_token` silently is how that stops happening
386    /// without an error.
387    #[test]
388    fn an_unmodelled_member_survives() -> Result<(), TokenResponseError> {
389        let issued = IssuedToken::parse(
390            br#"{"access_token":"at","token_type":"Bearer","id_token":"jwt","ext":7}"#,
391        )?;
392
393        assert_eq!(
394            issued.extra().get("id_token"),
395            Some(&serde_json::json!("jwt"))
396        );
397        assert_eq!(issued.extra().get("ext"), Some(&serde_json::json!(7)));
398        Ok(())
399    }
400
401    /// The two answers to "what was granted" must not disagree. A `scope` left
402    /// in `extra` while also modelled gives a caller two sources of truth.
403    #[test]
404    fn a_modelled_member_is_not_also_left_in_the_passthrough() -> Result<(), TokenResponseError> {
405        let issued = IssuedToken::parse(
406            br#"{"access_token":"at","token_type":"Bearer","scope":"read","expires_in":60}"#,
407        )?;
408
409        assert_eq!(issued.scope(), Some("read"));
410        assert_eq!(
411            issued.access_token().expires_in(),
412            Some(Duration::from_secs(60))
413        );
414        assert!(
415            issued.extra().is_empty(),
416            "modelled members leaked into extra"
417        );
418        Ok(())
419    }
420
421    /// §5.1 makes `scope` OPTIONAL, and §3.3 lets a server narrow it without
422    /// saying. Reading `None` as "everything requested" would hand the client
423    /// a grant it never received.
424    #[test]
425    fn an_absent_scope_is_not_the_requested_scope() -> Result<(), TokenResponseError> {
426        let issued = IssuedToken::parse(br#"{"access_token":"at","token_type":"Bearer"}"#)?;
427
428        assert_eq!(issued.scope(), None);
429        Ok(())
430    }
431
432    /// A member of the wrong JSON *type* must be refused, not coerced. A server
433    /// answering `token_type: true` is broken, and a parser that coerced it
434    /// would be guessing at whether the grant is usable.
435    #[test]
436    fn a_member_of_the_wrong_type_is_refused() {
437        for body in [
438            br#"{"access_token":42,"token_type":"Bearer"}"#.as_slice(),
439            br#"{"access_token":"at","token_type":true}"#.as_slice(),
440            br#"{"access_token":"at","token_type":"Bearer","expires_in":"soon"}"#.as_slice(),
441        ] {
442            assert_matches!(
443                IssuedToken::parse(body),
444                Err(TokenResponseError::Malformed { .. }),
445                "{}",
446                String::from_utf8_lossy(body)
447            );
448        }
449    }
450
451    /// `#[serde(flatten)]` makes the whole body a buffered map, so the shape
452    /// itself has to be checked: an array or a bare string is not a token
453    /// response, whatever serde would otherwise do with it.
454    #[test]
455    fn a_body_that_is_not_an_object_is_refused() {
456        for body in [
457            br#"["access_token","Bearer"]"#.as_slice(),
458            br#""Bearer""#.as_slice(),
459            br"42".as_slice(),
460            br"null".as_slice(),
461        ] {
462            assert_matches!(
463                IssuedToken::parse(body),
464                Err(TokenResponseError::Malformed { .. }),
465                "{}",
466                String::from_utf8_lossy(body)
467            );
468        }
469    }
470
471    #[test]
472    fn a_refresh_token_is_carried_when_issued() -> Result<(), TokenResponseError> {
473        let issued = IssuedToken::parse(
474            br#"{"access_token":"at","token_type":"Bearer","refresh_token":"rt"}"#,
475        )?;
476
477        assert_eq!(issued.refresh_token().map(RefreshToken::as_str), Some("rt"));
478        Ok(())
479    }
480
481    #[test]
482    fn a_body_that_is_not_json_is_malformed() {
483        assert_matches!(
484            IssuedToken::parse(b"<html>502</html>"),
485            Err(TokenResponseError::Malformed { .. })
486        );
487    }
488
489    // --- redaction ---
490
491    /// The whole reason `Debug` is hand-written. A panic, an `assert_eq!` and
492    /// a `dbg!` all expand to it, so a derived `Debug` here is the path a
493    /// credential escapes through by accident.
494    #[test]
495    fn no_bearer_credential_rides_out_on_debug() -> Result<(), TokenResponseError> {
496        let issued = IssuedToken::parse(
497            br#"{"access_token":"at-secret","token_type":"Bearer","refresh_token":"rt-secret"}"#,
498        )?;
499
500        let rendered = format!("{issued:?}");
501        assert!(!rendered.contains("at-secret"), "{rendered}");
502        assert!(!rendered.contains("rt-secret"), "{rendered}");
503
504        let access = format!("{:?}", issued.access_token());
505        assert_eq!(access, "AccessToken(…)");
506        Ok(())
507    }
508
509    /// `Display` is the presentation API and stays full; a client assembling an
510    /// `Authorization` header needs the value.
511    #[test]
512    fn display_carries_the_value() -> Result<(), TokenResponseError> {
513        let issued = IssuedToken::parse(br#"{"access_token":"at-1","token_type":"Bearer"}"#)?;
514
515        assert_eq!(issued.access_token().to_string(), "at-1");
516        Ok(())
517    }
518
519    #[test]
520    fn an_access_token_can_be_built_directly() {
521        let token = AccessToken::new("at", Some(Duration::from_secs(30)));
522
523        assert_eq!(token.as_str(), "at");
524        assert_eq!(format!("{token:?}"), "AccessToken(…)");
525    }
526}