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}