oauth_as/token.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2// Copyright (C) 2026 Matthew Jackson
3
4//! Token wire and storage shapes: the RFC 6749 section 5.1 success response, plus the records the
5//! server persists through [`crate::store::Storage`].
6//!
7//! Access and refresh tokens are OPAQUE random strings by default. Under the `jwt` feature the
8//! WIRE access token becomes an RFC 9068 structured token and the opaque string becomes its `jti`;
9//! the shapes here are unchanged either way, which is the point. [`IssuedToken`] is persisted
10//! whichever form went out, keyed by whatever the client will actually present, so RFC 7662
11//! introspection and RFC 7009 revocation keep working and a revoked JWT is genuinely dead at this
12//! server rather than merely deprecated.
13
14use std::fmt;
15use std::time::SystemTime;
16
17use serde::{Deserialize, Serialize};
18
19use crate::client::ClientId;
20use crate::scope::ScopeSet;
21
22/// `token_type` values this server issues: `Bearer` (RFC 6750), and `DPoP` (RFC 9449 section 5)
23/// under the `dpop` feature when the request proved possession of a key. Both registered values
24/// are case-insensitive on the wire but conventionally spelled as the renames below pin them.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26/// `#[non_exhaustive]`: the `Dpop` variant exists only under the `dpop` feature, so a host that
27/// matches this exhaustively compiles today and stops compiling the day anything in its dependency
28/// graph turns `dpop` on. Naming either variant still works; only the match needs a wildcard arm.
29/// A `token_type` is exactly the thing a host branches on when deciding how to hand the response
30/// to its client, so this is the enum most likely to be matched and the least affordable to leave
31/// open.
32#[non_exhaustive]
33pub enum TokenType {
34 /// RFC 6750 bearer token.
35 #[serde(rename = "Bearer")]
36 Bearer,
37 /// RFC 9449 section 5 sender-constrained token, bound to the key the client proved possession
38 /// of. The spelling is `DPoP`, exactly, because RFC 9449 section 7.1 makes it the HTTP
39 /// authentication scheme name the client will present the token under.
40 #[cfg(feature = "dpop")]
41 #[serde(rename = "DPoP")]
42 Dpop,
43}
44
45/// The RFC 6749 section 5.1 successful token response.
46///
47/// `Debug` is hand-written (see below) rather than derived: `access_token` and `refresh_token` are
48/// bearer credentials (RFC 6750 section 1 for the access token; RFC 9700 section 4.14.2 for the
49/// refresh token), so a host doing the obvious `tracing::debug!(?response)` must not thereby write
50/// either to its logs.
51#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
52/// `#[non_exhaustive]`: `authorization_details` appears only under the `rar` feature, so this
53/// struct's field set moves with a flag no host controls alone. This is an OUTPUT: the crate builds
54/// it and the host serializes it, so the paths that matter are unaffected, and a host that needs to
55/// build one anyway (a proxy, a test double) still has `Deserialize`, which is derived in here and
56/// so keeps working from outside.
57#[non_exhaustive]
58pub struct TokenResponse {
59 /// The access token: an opaque random string, or an RFC 9068 JWT under the `jwt` feature.
60 pub access_token: String,
61 /// `Bearer` (RFC 6750), or `DPoP` (RFC 9449 s5) when the `dpop` feature is on and the token
62 /// request carried a proof, because a sender-constrained token MUST NOT be presented as a
63 /// bearer token.
64 pub token_type: TokenType,
65 /// Lifetime in seconds (RECOMMENDED by the RFC; this server always includes it).
66 pub expires_in: u64,
67 /// The rotating refresh token, when the grant and server config produce one.
68 #[serde(skip_serializing_if = "Option::is_none")]
69 pub refresh_token: Option<String>,
70 /// Space-delimited granted scope. This server always includes it when non-empty, which also
71 /// satisfies the section 3.3 requirement to report a scope differing from the request.
72 #[serde(skip_serializing_if = "Option::is_none")]
73 pub scope: Option<String>,
74 /// The RFC 9396 authorization details as GRANTED, which section 7 makes a MUST for a response
75 /// to a request that carried them.
76 ///
77 /// It is a MUST for the same reason RFC 6749 section 3.3 has `scope` echoed when it differs
78 /// from the request: section 7.1 explicitly permits what was granted to differ from what was
79 /// asked for, because the host's consent screen may narrow or enrich it. Without this member a
80 /// client has no way to learn that what it holds is not what it requested, and would go on to
81 /// call a resource server believing it can do something it cannot.
82 ///
83 /// Omitted entirely when empty, so a deployment that never uses authorization details emits
84 /// exactly the body it emitted before this existed.
85 ///
86 /// `#[serde(default)]` is what makes that omission READABLE, and it is not optional beside a
87 /// `skip_serializing_if`. The two together are a matched pair: a member left out on the way out
88 /// has to be allowed to be absent on the way back in, or the type cannot parse the very body it
89 /// just emitted. Without it the ordinary response above is refused with `missing field
90 /// "authorization_details"`, which also falsifies the `#[non_exhaustive]` note above promising a
91 /// host that `Deserialize` "keeps working from outside": a proxy or a test double reading a
92 /// response back would break the moment `rar` appeared anywhere in its dependency graph.
93 #[cfg(feature = "rar")]
94 #[serde(
95 default,
96 skip_serializing_if = "crate::rar::AuthorizationDetails::is_empty"
97 )]
98 pub authorization_details: crate::rar::AuthorizationDetails,
99}
100
101/// Hand-written so neither `access_token` nor `refresh_token` ever prints. `refresh_token` keeps
102/// its `Some`/`None` shape (via `redact_opt`, mirrored from [`crate::server::TokenRequest`]'s
103/// hand-written `Debug`): whether a refresh token was issued at all is diagnostic, not secret, and
104/// collapsing `Some("[redacted]")` and `None` to the same output would hide that.
105impl fmt::Debug for TokenResponse {
106 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
107 fn redact_opt<T>(value: &Option<T>) -> Option<&'static str> {
108 value.as_ref().map(|_| "[redacted]")
109 }
110 f.debug_struct("TokenResponse")
111 .field("access_token", &"[redacted]")
112 .field("token_type", &self.token_type)
113 .field("expires_in", &self.expires_in)
114 .field("refresh_token", &redact_opt(&self.refresh_token))
115 .field("scope", &self.scope)
116 .finish()
117 }
118}
119
120/// The RFC 7800 section 3.1 confirmation claim: HOW a token is sender constrained, meaning
121/// what a presenter has to prove in addition to holding the string.
122///
123/// This is what a resource server checks the binding against, and it is the whole reason
124/// sender constraining is worth anything at introspection time: without it the binding is
125/// known only to the authorization server, and an RS that introspects is back to trusting a
126/// bearer string.
127///
128/// EVERY MEMBER IS OPTIONAL, and that is the design rather than an accident. RFC 7800 section
129/// 3.1 defines `cnf` as a JSON OBJECT whose members are confirmation methods, and different
130/// sender-constraining mechanisms register different members OF THE SAME OBJECT: RFC 9449
131/// section 6.1 registers `jkt` for a DPoP key binding, RFC 8705 section 3.1 registers
132/// `x5t#S256` for a certificate binding. A token can legitimately carry both, so neither may
133/// be modelled as "the" confirmation and neither may overwrite the other. Adding a mechanism
134/// means adding an optional member here; it never means replacing this type.
135/// DESERIALIZED THROUGH `ConfirmationWire`, for the reason [`IntrospectionResponse`] is
136/// deserialized through `IntrospectionWire`, and the member set above is why it has to be
137/// separate from that one. `cnf` is an OBJECT of confirmation methods and each feature registers
138/// its own, so a build with `dpop` and not `mtls` carries the OUTER member and not the inner one:
139/// the guard on `IntrospectionWire::cnf` passes (the member IS present), the interior deserializes
140/// to a `Confirmation` with nothing in it, and [`Confirmation::is_empty`] answers `true`. That is
141/// the certificate-bound-token-read-as-a-bearer-token case in full, arrived at through the guard
142/// meant to stop it. The interior needs the same treatment as the exterior, and this is it.
143#[cfg(any(feature = "dpop", feature = "mtls"))]
144#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
145#[serde(try_from = "ConfirmationWire")]
146/// `#[non_exhaustive]`: the paragraph above says adding a sender-constraining mechanism means
147/// adding an optional member here, and `dpop` and `mtls` each add one INDEPENDENTLY, so there are
148/// four different field sets this type has depending on which pair of flags is on. The attribute is
149/// what makes that promise cost a host nothing: build with `Confirmation::default()` (every member
150/// is optional by construction) and set the members the mechanism you use registers.
151#[non_exhaustive]
152pub struct Confirmation {
153 /// RFC 9449 section 6.1 `jkt`: the RFC 7638 SHA-256 thumbprint of the client's proof
154 /// key, base64url without padding.
155 #[cfg(feature = "dpop")]
156 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub jkt: Option<String>,
158 /// RFC 8705 section 3.1 `x5t#S256`: the SHA-256 thumbprint of the DER encoding of the
159 /// X.509 certificate the client presented when the token was issued. A resource server
160 /// checks it with [`Confirmation::confirms_certificate`].
161 #[cfg(feature = "mtls")]
162 #[serde(rename = "x5t#S256", default, skip_serializing_if = "Option::is_none")]
163 pub x5t_s256: Option<crate::mtls::CertificateThumbprint>,
164}
165
166#[cfg(any(feature = "dpop", feature = "mtls"))]
167impl Confirmation {
168 /// Wrap a DPoP key thumbprint.
169 #[cfg(feature = "dpop")]
170 pub fn jkt(jkt: impl Into<String>) -> Self {
171 Confirmation {
172 jkt: Some(jkt.into()),
173 #[cfg(feature = "mtls")]
174 x5t_s256: None,
175 }
176 }
177
178 /// Whether this names no confirmation method at all, which is what an ordinary bearer
179 /// token has. The `cnf` member is OMITTED for such a token rather than sent as an empty
180 /// object: an empty `cnf` claims a constraint exists and then names none, which is worse
181 /// than silence.
182 pub fn is_empty(&self) -> bool {
183 #[cfg(feature = "dpop")]
184 if self.jkt.is_some() {
185 return false;
186 }
187 #[cfg(feature = "mtls")]
188 if self.x5t_s256.is_some() {
189 return false;
190 }
191 true
192 }
193}
194
195/// The deserialize-side mirror of [`Confirmation`], and the reason that type does not derive
196/// `Deserialize` directly.
197///
198/// Same construction as [`IntrospectionWire`] and for the same reason, one level further in: a
199/// confirmation METHOD this build cannot represent is parsed and refused rather than dropped. The
200/// members are the ones RFC 9449 section 6.1 and RFC 8705 section 3.1 register, and a build has
201/// each one either as itself or as an `IgnoredAny` that remembers only that it was there.
202///
203/// NOT `#[serde(deny_unknown_fields)]`, for the reason `IntrospectionWire` is not: RFC 7800
204/// section 3.1 defines `cnf` as an open set of confirmation methods and registers others this
205/// crate does not implement, so a `cnf` naming one of those is conformant. Only the two methods
206/// this crate knows the meaning of are refused when it cannot hold them.
207#[cfg(any(feature = "dpop", feature = "mtls"))]
208#[derive(Deserialize)]
209struct ConfirmationWire {
210 #[cfg(feature = "dpop")]
211 jkt: Option<String>,
212 #[cfg(feature = "mtls")]
213 #[serde(rename = "x5t#S256")]
214 x5t_s256: Option<crate::mtls::CertificateThumbprint>,
215 // The methods this build cannot represent.
216 #[cfg(not(feature = "dpop"))]
217 jkt: Option<serde::de::IgnoredAny>,
218 #[cfg(not(feature = "mtls"))]
219 #[serde(rename = "x5t#S256")]
220 x5t_s256: Option<serde::de::IgnoredAny>,
221}
222
223#[cfg(any(feature = "dpop", feature = "mtls"))]
224impl TryFrom<ConfirmationWire> for Confirmation {
225 type Error = UnrepresentableMember;
226
227 fn try_from(wire: ConfirmationWire) -> Result<Self, Self::Error> {
228 #[cfg(not(feature = "dpop"))]
229 if wire.jkt.is_some() {
230 return Err(
231 "confirmation carries `jkt` (RFC 9449 s6.1), so the token is bound to a DPoP key \
232 and this build of oauth-as cannot represent that: rebuild with the `dpop` feature",
233 );
234 }
235 #[cfg(not(feature = "mtls"))]
236 if wire.x5t_s256.is_some() {
237 return Err(
238 "confirmation carries `x5t#S256` (RFC 8705 s3.1), so the token is bound to a \
239 client certificate and this build of oauth-as cannot represent that: rebuild \
240 with the `mtls` feature",
241 );
242 }
243 Ok(Confirmation {
244 #[cfg(feature = "dpop")]
245 jkt: wire.jkt,
246 #[cfg(feature = "mtls")]
247 x5t_s256: wire.x5t_s256,
248 })
249 }
250}
251
252/// The RFC 7009 section 2.1 `token_type_hint`. A hint the server disagrees with is not an error:
253/// section 2.1 requires it to keep looking, so this only chooses which lookup runs first.
254#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
255#[serde(rename_all = "snake_case")]
256pub enum TokenTypeHint {
257 /// The caller believes this is an access token.
258 AccessToken,
259 /// The caller believes this is a refresh token.
260 RefreshToken,
261}
262
263impl std::str::FromStr for TokenTypeHint {
264 type Err = ();
265
266 fn from_str(s: &str) -> Result<Self, Self::Err> {
267 match s {
268 "access_token" => Ok(TokenTypeHint::AccessToken),
269 "refresh_token" => Ok(TokenTypeHint::RefreshToken),
270 _ => Err(()),
271 }
272 }
273}
274
275/// The RFC 7662 section 2.2 introspection response.
276///
277/// `active` is the only REQUIRED member, and for an inactive token it is the ONLY member: section
278/// 2.2 is explicit that the server should not describe a token the caller has not proven it
279/// holds, and section 4 explains why (the endpoint would otherwise answer questions about tokens
280/// an attacker merely guessed).
281#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
282/// DESERIALIZED THROUGH a private mirror (`IntrospectionWire` in this module), which is what makes
283/// a member this build cannot represent an ERROR rather than a silent omission: `token_type` has
284/// always failed loudly for a `DPoP` token in a build without `dpop`, and the five feature-gated
285/// members beside it used to fail silently for the same class of response. What is NOT refused is
286/// an unknown member, which RFC 7662 section 2.2 explicitly permits a server to send.
287#[serde(try_from = "IntrospectionWire")]
288/// `#[non_exhaustive]`: four separate features (`consent`, `rar`, `dpop`, `mtls`) each add a member
289/// here, which is more feature-driven variation than any other wire body this crate publishes.
290/// [`IntrospectionResponse::inactive`] is the construction path and always was the sensible one:
291/// start from the one-member refusal and fill in what the token actually is, rather than writing
292/// out a literal that has to name every claim the current flag set happens to produce.
293#[non_exhaustive]
294pub struct IntrospectionResponse {
295 /// Whether the token is currently active.
296 pub active: bool,
297 /// Space-delimited granted scope.
298 #[serde(skip_serializing_if = "Option::is_none")]
299 pub scope: Option<String>,
300 /// The client the token was issued to.
301 #[serde(skip_serializing_if = "Option::is_none")]
302 pub client_id: Option<String>,
303 /// The resource owner the token acts for.
304 ///
305 /// ABSENT FOR A CLIENT-CREDENTIALS TOKEN, and that is a deliberate disagreement with the
306 /// signed token beside it. RFC 6749 section 4.4 has no resource owner, so RFC 7662 section
307 /// 2.2's "usually a machine-readable identifier of the resource owner" has nothing to name and
308 /// the member is omitted; RFC 9068 section 2.2 makes `sub` REQUIRED in a JWT access token and
309 /// directs the AS to put the `client_id` there instead. Both are right, and a resource server
310 /// reading one token through both channels therefore sees a subject in the JWT and no subject
311 /// from introspection. Neither says a user was involved.
312 #[serde(skip_serializing_if = "Option::is_none")]
313 pub sub: Option<String>,
314 /// The token type (RFC 6750 `Bearer`).
315 #[serde(skip_serializing_if = "Option::is_none")]
316 pub token_type: Option<TokenType>,
317 /// Expiry, as seconds since the Unix epoch.
318 #[serde(skip_serializing_if = "Option::is_none")]
319 pub exp: Option<u64>,
320 /// Issuance, as seconds since the Unix epoch.
321 #[serde(skip_serializing_if = "Option::is_none")]
322 pub iat: Option<u64>,
323 /// The issuer of the token.
324 #[serde(skip_serializing_if = "Option::is_none")]
325 pub iss: Option<String>,
326 /// The resource server(s) the token is for: the RFC 8707 resource indicators the grant was
327 /// narrowed to.
328 ///
329 /// RFC 7662 section 2.2 lists `aud` as OPTIONAL and defers its shape to RFC 7519 section 4.1.3,
330 /// which admits either a single string or an array. This crate always emits the ARRAY form when
331 /// it has an audience at all, because a caller that has to handle two shapes for one claim
332 /// eventually handles only one of them; and it omits the member entirely, rather than sending
333 /// an empty array, when no resource was requested. An empty array reads as "restricted to
334 /// nothing", which is the opposite of the truth.
335 ///
336 /// UNDER `jwt`, THE SIGNED `aud` MAY BE NARROWER THAN THIS MEMBER'S ABSENCE SUGGESTS. This is
337 /// the grant's RFC 8707 resource indicators and nothing else, so a grant that named none omits
338 /// the member; the signed access token for that same grant carries the DEPLOYMENT-WIDE
339 /// [`crate::jwt::JwtConfig::audience`] instead, because RFC 9068 section 2.2 makes `aud`
340 /// required and a token has to name somebody. So "no `aud` here" means "this grant was
341 /// narrowed to no particular resource server", NOT "this token is valid everywhere": a
342 /// resource server enforcing audience restriction should enforce it from the token when it has
343 /// one, and treat this member as the grant's narrowing on top.
344 #[serde(skip_serializing_if = "Option::is_none")]
345 pub aud: Option<Vec<String>>,
346 /// RFC 9470 section 6.2: when the resource owner behind this token authenticated, as seconds
347 /// since the Unix epoch (OpenID Connect Core section 2 `auth_time`).
348 ///
349 /// This is what makes a step-up challenge answerable at all: a resource server that asked for a
350 /// `max_age` has to be able to see whether the token it now holds actually satisfies it, and
351 /// RFC 9470 section 6 names introspection (section 6.2) as one of the two places it may look,
352 /// the other being the JWT itself (section 6.1). Present exactly
353 /// when the host REPORTED an authentication for the grant (see
354 /// [`crate::consent::Authentication`]), and omitted rather than sent as `null` when it did not,
355 /// because a null there reads to a careless resource server as a freshness it has checked.
356 #[cfg(feature = "consent")]
357 #[serde(skip_serializing_if = "Option::is_none")]
358 pub auth_time: Option<u64>,
359 /// RFC 9470 section 6.2: the authentication context class the host reported for the grant
360 /// (OpenID Connect Core section 2 `acr`). Opaque to this crate; see
361 /// [`crate::consent::Authentication::acr`].
362 #[cfg(feature = "consent")]
363 #[serde(skip_serializing_if = "Option::is_none")]
364 pub acr: Option<String>,
365 /// RFC 9396 section 9.2: the authorization details this token carries, as a top-level
366 /// member of the introspection response. That section is how a resource server holding
367 /// an OPAQUE token learns what the token actually authorizes, which is the whole reason
368 /// the parameter exists.
369 ///
370 /// A resource server reads this member since 0.9.2, when it is registered in
371 /// [`crate::ServerConfig::resource_servers`] and the token is addressed to it.
372 ///
373 /// Omitted rather than empty when the grant carried none, for the same reason `aud` is:
374 /// an empty array reads as "authorized for nothing in particular", which is a statement,
375 /// and the truth here is silence.
376 #[cfg(feature = "rar")]
377 #[serde(
378 default,
379 skip_serializing_if = "crate::rar::AuthorizationDetails::is_empty"
380 )]
381 pub authorization_details: crate::rar::AuthorizationDetails,
382 /// How this token is sender constrained, present exactly when it is: RFC 9449 section 6.1
383 /// `jkt` for a DPoP key, RFC 8705 section 3.2 `x5t#S256` for a client certificate, or both.
384 ///
385 /// RFC 7662 section 2.2 lets a server return any claim it likes here, and RFC 9449 section 5
386 /// and RFC 8705 section 3.2 are each explicit that a resource server has to be able to
387 /// confirm the binding. Omitted rather than sent as `null` for an unbound token, because
388 /// `"cnf": null` reads to a careless RS as a confirmation it has already checked.
389 #[cfg(any(feature = "dpop", feature = "mtls"))]
390 #[serde(skip_serializing_if = "Option::is_none")]
391 pub cnf: Option<Confirmation>,
392 /// RFC 8693 section 4.1 `act`: who authority was delegated TO, present exactly when this token
393 /// came from a DELEGATION token exchange.
394 ///
395 /// RFC 7662 section 2.2 lets a server return any claim it likes here, and this is the claim an
396 /// opaque token has nowhere else to put. Without it a resource server cannot tell "A acting
397 /// for B" from "B", which is the entire distinction RFC 8693 section 1.1 draws between
398 /// delegation and impersonation, and the reason a deployment would choose delegation at all.
399 ///
400 /// Omitted rather than sent as `null`, for the same reason `cnf` next door is: a member that
401 /// is present and null invites a careless reader to treat it as answered.
402 #[cfg(feature = "token-exchange")]
403 #[serde(skip_serializing_if = "Option::is_none")]
404 pub act: Option<crate::token_exchange::ActClaim>,
405}
406
407impl IntrospectionResponse {
408 /// The one-member answer for a token that is unknown, expired, or not the caller's.
409 pub fn inactive() -> Self {
410 IntrospectionResponse {
411 active: false,
412 scope: None,
413 client_id: None,
414 sub: None,
415 token_type: None,
416 exp: None,
417 iat: None,
418 iss: None,
419 aud: None,
420 #[cfg(feature = "consent")]
421 auth_time: None,
422 #[cfg(feature = "consent")]
423 acr: None,
424 #[cfg(feature = "rar")]
425 authorization_details: crate::rar::AuthorizationDetails::none(),
426 #[cfg(any(feature = "dpop", feature = "mtls"))]
427 cnf: None,
428 #[cfg(feature = "token-exchange")]
429 act: None,
430 }
431 }
432}
433
434/// The deserialize-side mirror of [`IntrospectionResponse`], and the reason that type does not
435/// derive `Deserialize` directly.
436///
437/// WHAT IT IS FOR: five members of the response exist only under a cargo feature (`cnf` under
438/// `dpop` or `mtls`, `act` under `token-exchange`, `auth_time` and `acr` under `consent`,
439/// `authorization_details` under `rar`), and a derived `Deserialize` in a build without the
440/// feature DROPS them without a word. Four of the five say the token is NARROWER than a plain
441/// bearer token, so the reader that loses one concludes the safest-looking thing: a
442/// certificate-bound token read as an ordinary bearer token, a delegated token read as the
443/// principal itself, a transaction-scoped token read as unrestricted. The same object's
444/// `token_type` has never had that problem, because `"DPoP"` in a build without `dpop` is an
445/// unknown enum variant and serde says so. This makes the rest of the object behave like
446/// `token_type`.
447///
448/// NOT `#[serde(deny_unknown_fields)]`, deliberately. RFC 7662 section 2.2 says specific
449/// implementations "MAY extend this structure with their own service-specific response names as
450/// top-level members", so a response carrying `username` or `nbf` is conformant; denying every
451/// unknown member would convert a silent drop into an inability to read a legitimate response at
452/// all, which is a worse failure and a more likely one. Only the members THIS CRATE KNOWS THE
453/// MEANING OF, and would therefore be discarding knowingly, are refused.
454///
455/// The field list is a mirror and mirrors drift, so `tests/introspection_feature_bounds.rs`
456/// round-trips a fully populated response for whatever flag set it is built with.
457#[derive(Deserialize)]
458struct IntrospectionWire {
459 active: bool,
460 scope: Option<String>,
461 client_id: Option<String>,
462 sub: Option<String>,
463 token_type: Option<TokenType>,
464 exp: Option<u64>,
465 iat: Option<u64>,
466 iss: Option<String>,
467 aud: Option<Vec<String>>,
468 #[cfg(feature = "consent")]
469 auth_time: Option<u64>,
470 #[cfg(feature = "consent")]
471 acr: Option<String>,
472 #[cfg(feature = "rar")]
473 #[serde(default)]
474 authorization_details: crate::rar::AuthorizationDetails,
475 #[cfg(any(feature = "dpop", feature = "mtls"))]
476 cnf: Option<Confirmation>,
477 #[cfg(feature = "token-exchange")]
478 act: Option<crate::token_exchange::ActClaim>,
479 // The members this build cannot represent. `IgnoredAny` parses the value and keeps nothing:
480 // what is wanted is the knowledge that it was THERE, which is exactly what this build would
481 // otherwise not have.
482 #[cfg(not(feature = "consent"))]
483 auth_time: Option<serde::de::IgnoredAny>,
484 #[cfg(not(feature = "consent"))]
485 acr: Option<serde::de::IgnoredAny>,
486 #[cfg(not(feature = "rar"))]
487 authorization_details: Option<serde::de::IgnoredAny>,
488 #[cfg(not(any(feature = "dpop", feature = "mtls")))]
489 cnf: Option<serde::de::IgnoredAny>,
490 #[cfg(not(feature = "token-exchange"))]
491 act: Option<serde::de::IgnoredAny>,
492}
493
494/// Why one of the members above could not be kept. `&'static str` rather than a formatted
495/// message: this is a refusal on a parse path, and naming the member and the feature that would
496/// have carried it is the whole of what a reader needs.
497type UnrepresentableMember = &'static str;
498
499impl TryFrom<IntrospectionWire> for IntrospectionResponse {
500 type Error = UnrepresentableMember;
501
502 fn try_from(wire: IntrospectionWire) -> Result<Self, Self::Error> {
503 #[cfg(not(feature = "consent"))]
504 if wire.auth_time.is_some() {
505 return Err(
506 "introspection response carries `auth_time` (RFC 9470 s6.2), which this build of \
507 oauth-as cannot represent: rebuild with the `consent` feature",
508 );
509 }
510 #[cfg(not(feature = "consent"))]
511 if wire.acr.is_some() {
512 return Err(
513 "introspection response carries `acr` (RFC 9470 s6.2), which this build of \
514 oauth-as cannot represent: rebuild with the `consent` feature",
515 );
516 }
517 #[cfg(not(feature = "rar"))]
518 if wire.authorization_details.is_some() {
519 return Err(
520 "introspection response carries `authorization_details` (RFC 9396 s9.2), which \
521 this build of oauth-as cannot represent: rebuild with the `rar` feature",
522 );
523 }
524 #[cfg(not(any(feature = "dpop", feature = "mtls")))]
525 if wire.cnf.is_some() {
526 return Err(
527 "introspection response carries `cnf` (RFC 9449 s6.1 / RFC 8705 s3.2), so the \
528 token is sender constrained and this build of oauth-as cannot represent that: \
529 rebuild with the `dpop` or `mtls` feature",
530 );
531 }
532 #[cfg(not(feature = "token-exchange"))]
533 if wire.act.is_some() {
534 return Err(
535 "introspection response carries `act` (RFC 8693 s4.1), so the token is a \
536 delegation and this build of oauth-as cannot represent that: rebuild with the \
537 `token-exchange` feature",
538 );
539 }
540 Ok(IntrospectionResponse {
541 active: wire.active,
542 scope: wire.scope,
543 client_id: wire.client_id,
544 sub: wire.sub,
545 token_type: wire.token_type,
546 exp: wire.exp,
547 iat: wire.iat,
548 iss: wire.iss,
549 aud: wire.aud,
550 #[cfg(feature = "consent")]
551 auth_time: wire.auth_time,
552 #[cfg(feature = "consent")]
553 acr: wire.acr,
554 #[cfg(feature = "rar")]
555 authorization_details: wire.authorization_details,
556 #[cfg(any(feature = "dpop", feature = "mtls"))]
557 cnf: wire.cnf,
558 #[cfg(feature = "token-exchange")]
559 act: wire.act,
560 })
561 }
562}
563
564/// The serde default for the two `grant_established_at` fields below: the epoch, because it is the
565/// fail-closed answer. Every barrier is recorded after it, so a record with no stated grant instant
566/// is REFUSED by a standing revocation rather than admitted by one. See
567/// [`IssuedToken::grant_established_at`], which states the whole argument.
568fn grant_established_at_default() -> SystemTime {
569 SystemTime::UNIX_EPOCH
570}
571
572/// A persisted access token: what introspection needs to answer for an opaque token.
573///
574/// `Debug` is hand-written (see below) rather than derived: `access_token` is a bearer credential
575/// (RFC 6750 section 1: possession of the string is the whole of the authorization), so a host
576/// doing the obvious `tracing::debug!(?record)` must not thereby write a live token to its logs.
577#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
578/// `#[non_exhaustive]`: `rar`, `dpop`, `mtls` and `consent` each add a field, so the shape of this
579/// record is decided by the flag set the final binary is linked with rather than by the host that
580/// writes against it.
581///
582/// A [`crate::store::Storage`] implementor does not lose anything: it is HANDED these records and
583/// round-trips them through the derived `Serialize`/`Deserialize`, both of which are generated in
584/// here and are unaffected. `oauth-as-postgres` persists them as a `jsonb` payload for exactly that
585/// reason and never spells a field. For everyone else, including a host seeding a store or writing
586/// a fixture, [`IssuedToken::new`] takes the fields a token cannot exist without and leaves the
587/// rest public to assign.
588///
589/// THE BOUND ON THAT SENTENCE, stated because it is not obvious: "does not lose anything" holds
590/// for ONE flag set. Two binaries built with different features over one store are a different
591/// situation, and this type has no answer for it: a reader without `rar` deserializes a record
592/// whose `authorization_details` it has no field for, the derived `Deserialize` drops the member
593/// in silence, and a rotation writes the shortened record back. Serialization is not where that
594/// gets caught -- [`IntrospectionResponse`] and [`Confirmation`] are read from a FOREIGN server
595/// and are guarded on the way in, whereas these records are this deployment's own and the guard
596/// would have to be a decision about what a store should do when it hands back a record this
597/// binary cannot represent. A MIXED-FLAG FLEET OVER ONE STORE IS THEREFORE NOT A SUPPORTED
598/// DEPLOYMENT of this crate, and the same caveat applies to [`RefreshTokenRecord`],
599/// [`crate::authorization::AuthorizationCodeRecord`] and
600/// [`crate::par::PushedAuthorizationRequest`].
601#[non_exhaustive]
602pub struct IssuedToken {
603 /// The opaque access token string (the storage key).
604 pub access_token: String,
605 /// The client the token was issued to.
606 pub client_id: ClientId,
607 /// The resource owner the token acts for; `None` for client-only grants.
608 pub subject: Option<String>,
609 /// The granted scope.
610 pub scope: ScopeSet,
611 /// The RFC 8707 resource indicators this token is restricted to; empty when the grant named
612 /// none. This is what RFC 7662 introspection reports as `aud`, and what the RFC 9068 `aud`
613 /// claim carries when the `jwt` feature signs the wire token.
614 ///
615 /// THE TWO CHANNELS PART COMPANY WHEN THIS IS EMPTY, which the sentence above used to deny.
616 /// Introspection then omits `aud` altogether (an empty array would read as "restricted to
617 /// nothing"; see [`IntrospectionResponse::aud`]), while the signed token cannot omit it,
618 /// because RFC 9068 section 2.2 makes the claim REQUIRED: it carries the deployment-wide
619 /// [`crate::jwt::JwtConfig::audience`] instead. So for a grant that named no resource a
620 /// caller reads "no restriction stated" from introspection and "restricted to the
621 /// configured audience" from the token, for one token. Both are true statements about
622 /// different things: this field is the GRANT'S narrowing, and the configured audience is the
623 /// deployment's standing one. Non-empty, they agree exactly.
624 ///
625 /// SINCE 0.9.2 THIS FIELD ALSO DECIDES WHO MAY ASK. It is what a registered resource server is
626 /// matched against, so a token whose grant named no resource is introspectable by its own
627 /// client alone; see [`crate::ServerConfig::resource_servers`]. A resource server that is
628 /// answered sees only its OWN identifiers here, not the whole set.
629 pub resource: Vec<String>,
630 /// The RFC 9396 authorization details this token carries (section 7: the AS returns the
631 /// details as granted and assigned to the access token). This is what RFC 7662
632 /// introspection reports as `authorization_details` (section 9.2) and what the RFC 9068
633 /// claim carries when the `jwt` feature signs the wire token (section 9.1).
634 ///
635 /// `#[serde(default)]`, for the same reason `grant_established_at` below has one and reached
636 /// by the other door: TURNING THE FEATURE ON must not make what is already in the store
637 /// unreadable. A record written by a build without `rar` carries no such key, this field is not
638 /// an `Option` so serde's derive supplies no default of its own, and the read fails outright
639 /// with `missing field "authorization_details"`. That is not a migration an operator can plan
640 /// around either, because cargo feature unification means a dependency can turn `rar` on
641 /// without the host asking (see `tests/host_api_shape.rs`): the build changes and every live
642 /// grant stops being readable at once. The default is the empty set, which is the truth about a
643 /// grant minted before the feature existed, and it is also the safe direction: an empty set
644 /// authorizes nothing extra.
645 #[cfg(feature = "rar")]
646 #[serde(default)]
647 pub authorization_details: crate::rar::AuthorizationDetails,
648 /// Issuance instant.
649 pub issued_at: SystemTime,
650 /// The instant the GRANT behind this token was authorized, which is NOT `issued_at`.
651 ///
652 /// For a code redemption it is when the code was minted; for a refresh rotation it is carried
653 /// forward unchanged from the chain, so every token along a chain reports the one decision that
654 /// started it. For a grant with no resource owner behind it (client credentials) it is the
655 /// instant of issuance, because the client's own registration is the only authorization there
656 /// is.
657 ///
658 /// FOR A DEVICE GRANT IT IS WHEN THE DEVICE ASKED, NOT WHEN THE USER APPROVED, and that is a
659 /// known approximation rather than the intent. RFC 8628 section 3.3 approval happens at the
660 /// host's own verification UI and [`crate::device::DeviceGrantState::Approved`] records only
661 /// the subject, so the approval instant is never persisted and there is nothing truer to carry;
662 /// [`crate::device::DeviceGrant::created_at`] is what exists. The gap is the grant's whole
663 /// lifetime (RFC 8628 section 3.2 `expires_in`, typically minutes), and it errs in the
664 /// FAIL-CLOSED direction: a barrier recorded inside that window refuses a decision the user
665 /// made after it, so a user who withdraws consent and then re-approves the same pending device
666 /// grant is refused and has to start the device flow again. A false refusal, never a false
667 /// admission. Closing it properly means persisting the approval instant, which is a breaking
668 /// change to that enum variant.
669 ///
670 /// A [`crate::store::RevocationBarrier`] is compared against this rather than against
671 /// `issued_at`: a rotation and a re-approval both WRITE at `now`, so `now` cannot tell a grant
672 /// that predates a revocation from one made after it. See
673 /// [`crate::store::RevocationWindow::recorded_at`].
674 ///
675 /// `#[serde(default)]`, and the default is the epoch, which is the FAIL-CLOSED direction.
676 /// This field is new in 0.9.1, so a record a 0.9.0 node wrote — or is still writing, during a
677 /// rolling upgrade — carries no such key, and without a default the read fails outright and
678 /// every token that release issued becomes unreadable the moment this one starts. With it, the
679 /// record deserializes and dates from before every barrier that could ever be recorded, so a
680 /// standing revocation REFUSES it rather than admitting it. A far-future default would
681 /// deserialize just as happily and ADMIT every record 0.9.0 wrote, which is exactly the
682 /// resurrection this field exists to close, reintroduced through the upgrade path. The
683 /// There is deliberately NO backfill migration: a backfill cannot reach a 0.9.0 node still
684 /// writing field-less payloads during a rolling upgrade, which is the window that matters, so
685 /// the serde default covers strictly more than one would.
686 #[serde(default = "grant_established_at_default")]
687 pub grant_established_at: SystemTime,
688 /// Expiry instant; the token is dead at and after this instant.
689 pub expires_at: SystemTime,
690 /// RFC 9449 section 6: the RFC 7638 thumbprint of the DPoP key this token is bound to, or
691 /// `None` for an ordinary bearer token.
692 ///
693 /// `Option<Box<str>>` rather than `Option<String>`, and feature gated, because this record is
694 /// written and read on every token-plane request and `tests/allocation.rs` holds it to a size
695 /// budget: the box is 16 bytes against a `String`'s 24, and a deployment without the `dpop`
696 /// feature pays neither. The value is a fixed 43-character base64url digest that is never
697 /// appended to, so the growable capacity a `String` carries would be dead weight.
698 #[cfg(feature = "dpop")]
699 pub jkt: Option<Box<str>>,
700 /// RFC 8705 section 3: the SHA-256 thumbprint of the client certificate this token is
701 /// bound to, or `None` for a token that is not certificate bound.
702 ///
703 /// Recorded on the AS side, and not only inside a signed JWT, for the same reason `jkt`
704 /// next door is: this crate's default access token is OPAQUE, and RFC 8705 section 3.2
705 /// has a resource server learn the binding by INTROSPECTING, which it can only be told
706 /// if it was persisted. The channel that carries it to a resource server arrived in 0.9.2;
707 /// the field was persisted before that, because the RECORD, not the response, is the thing
708 /// that cannot be added later.
709 ///
710 /// `Option<Box<_>>` rather than the 32-byte thumbprint inline, on the same measurement
711 /// as `jkt`: this record is written and read on every token-plane request and
712 /// `tests/allocation.rs` holds it to a size budget, so an unbound token pays one null
713 /// pointer and the allocation happens only for a token that is actually bound.
714 #[cfg(feature = "mtls")]
715 pub x5t_s256: Option<Box<crate::mtls::CertificateThumbprint>>,
716 /// The authorization grant this token belongs to (see [`RefreshTokenRecord::family_id`]).
717 ///
718 /// RFC 9700 section 4.14.2 requires that detecting refresh token reuse revokes "the tokens
719 /// issued for that authorization grant", not merely the refresh chain, so an access token has
720 /// to be reachable from the grant it came from. `None` for a grant that produced no refresh
721 /// chain (RFC 6749 section 4.4 client credentials), where there is no chain to be reused and
722 /// so nothing to revoke by family.
723 pub family_id: Option<String>,
724 /// RFC 8693 section 4.1 `act`: who authority was delegated TO, for a token issued by a
725 /// DELEGATION token exchange. `None` for every other grant, and for an impersonation exchange,
726 /// which by definition names no actor.
727 ///
728 /// # Why this is on the RECORD and not only in the response
729 ///
730 /// This crate's default access token is OPAQUE, so RFC 7662 introspection is the only channel
731 /// a resource server has for learning anything about it. A delegation that introspection
732 /// cannot see is a delegation the resource server has to take the host's word for, which is
733 /// the one thing section 1.1 delegation exists to avoid: the whole point is that the resource
734 /// can tell "A acting for B" from "B".
735 ///
736 /// The channel that carries it to a resource server arrived in 0.9.2; the field was persisted
737 /// before that, because the RECORD, not the response, is the thing that cannot be added later.
738 ///
739 /// It was left off through 0.9.0 for two reasons, and both are now spent. The first was
740 /// allocation, and [`crate::store::Storage::get_token`] returning an `Arc<IssuedToken>` ended
741 /// it: the record's shape costs a read nothing, and this field costs a deployment without the
742 /// feature zero bytes and one with it 8 bytes per token plus one allocation per DELEGATED
743 /// token. The second was the persistence contract, which was the real one: this is the record
744 /// every host's `Storage` writes, so a new field is a migration in stores this crate does not
745 /// own. That is exactly why it lands HERE, in the release that is already breaking that trait,
746 /// so a host migrates once rather than twice.
747 ///
748 /// BOXED for the same measured reason as `authentication` below: the common case is `None`,
749 /// and this record is written and read on every token-plane request.
750 #[cfg(feature = "token-exchange")]
751 #[cfg_attr(docsrs, doc(cfg(feature = "token-exchange")))]
752 pub act: Option<Box<crate::token_exchange::ActClaim>>,
753 /// What the host reported about the resource owner's authentication when this token's grant was
754 /// approved, or `None` when it reported nothing.
755 ///
756 /// BOXED, so the common `None` costs one null pointer on a record that is written and read on
757 /// every token-plane request rather than the whole struct; `tests/allocation.rs` holds this
758 /// type to a size budget precisely so that a convenience like an inline `SystemTime` plus an
759 /// `Option<String>` cannot be paid for silently. It is what BOTH halves of RFC 9470 section 6
760 /// are answered from: 6.2 at introspection time, and 6.1 at issuance, where the same report
761 /// becomes the `auth_time` and `acr` claims of the signed access token.
762 #[cfg(feature = "consent")]
763 pub authentication: Option<Box<crate::consent::Authentication>>,
764}
765
766impl IssuedToken {
767 /// The five things a persisted access token cannot be without: the string a client will
768 /// present, who it was issued to, who it acts for, what it may do, and when it lives between.
769 ///
770 /// Everything else describes a token that is more than the minimum (an audience restriction, a
771 /// sender-constraining binding, the family it can be revoked with) and is a public field on the
772 /// returned value, so a caller sets what applies and states nothing about what does not. That
773 /// split is the reason the arguments stop here rather than growing one per feature: a caller
774 /// building a record for a build with `dpop` off should not have to mention DPoP.
775 ///
776 /// `subject` is an argument rather than a field to assign because `None` is a real answer and
777 /// not an omission: it means an RFC 6749 section 4.4 client-credentials token, which acts for
778 /// no resource owner, and a caller should have to say so.
779 pub fn new(
780 access_token: impl Into<String>,
781 client_id: ClientId,
782 subject: Option<String>,
783 scope: ScopeSet,
784 issued_at: SystemTime,
785 expires_at: SystemTime,
786 ) -> Self {
787 IssuedToken {
788 // Same FAIL-CLOSED default as `RefreshTokenRecord::new`, and the same reason: a caller
789 // that has not said when its grant was authorized must not thereby outrank a standing
790 // revocation. A caller that knows sets the field on the returned value.
791 grant_established_at: SystemTime::UNIX_EPOCH,
792 access_token: access_token.into(),
793 client_id,
794 subject,
795 scope,
796 resource: Vec::new(),
797 #[cfg(feature = "rar")]
798 authorization_details: crate::rar::AuthorizationDetails::none(),
799 issued_at,
800 expires_at,
801 #[cfg(feature = "dpop")]
802 jkt: None,
803 #[cfg(feature = "mtls")]
804 x5t_s256: None,
805 family_id: None,
806 #[cfg(feature = "token-exchange")]
807 act: None,
808 #[cfg(feature = "consent")]
809 authentication: None,
810 }
811 }
812}
813
814/// Hand-written so the opaque `access_token` never prints. EVERY other field prints, because every
815/// other field is metadata ABOUT the token rather than the credential itself, and the record has to
816/// stay debuggable: `family_id` in particular is what makes an RFC 9700 section 4.14.2 family
817/// revocation traceable, and it is an internal grouping identifier, not a bearer credential.
818///
819/// "Every other field" is the whole rule and it is stated that way deliberately. This impl used to
820/// print eight of thirteen, and the five it dropped were the five added since it was written, which
821/// is what a hand-written `Debug` costs if nobody restates the rule when a field arrives. The
822/// worst omission was `grant_established_at`: it is the SOLE time input to every
823/// [`crate::store::RevocationBarrier`] comparison and its fail-closed default is the epoch, so
824/// "this token is refused by a barrier and I cannot see why" was exactly the question `{:?}` could
825/// not answer. `jkt` and `x5t_s256` are public-key and certificate THUMBPRINTS, which a resource
826/// server is given on the wire in the RFC 7800 `cnf` claim, so neither is secret; `act` is the RFC
827/// 8693 section 4.1 delegation chain, which introspection publishes; `authentication` is the RFC
828/// 9470 report, which introspection publishes as `auth_time` and `acr` (section 6.2) and which a
829/// signed access token carries under the same two names (section 6.1).
830impl fmt::Debug for IssuedToken {
831 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
832 let mut out = f.debug_struct("IssuedToken");
833 out.field("access_token", &"[redacted]")
834 .field("client_id", &self.client_id)
835 .field("subject", &self.subject)
836 .field("scope", &self.scope)
837 .field("resource", &self.resource);
838 #[cfg(feature = "rar")]
839 out.field("authorization_details", &self.authorization_details);
840 out.field("issued_at", &self.issued_at)
841 .field("grant_established_at", &self.grant_established_at)
842 .field("expires_at", &self.expires_at);
843 #[cfg(feature = "dpop")]
844 out.field("jkt", &self.jkt);
845 #[cfg(feature = "mtls")]
846 out.field("x5t_s256", &self.x5t_s256);
847 out.field("family_id", &self.family_id);
848 #[cfg(feature = "token-exchange")]
849 out.field("act", &self.act);
850 #[cfg(feature = "consent")]
851 out.field("authentication", &self.authentication);
852 out.finish()
853 }
854}
855
856/// Whether a persisted refresh token is still redeemable.
857///
858/// Rotated tokens are RETAINED in the `Spent` state rather than deleted, exactly as consumed
859/// authorization codes are (see [`crate::authorization::AuthorizationCodeState`]) and for exactly
860/// the same reason: a token deleted on rotation makes a later presentation indistinguishable from
861/// a typo, and the AS then answers the one signal it gets that a token leaked by disconnecting
862/// whichever party redeemed second, which in practice is the honest one.
863#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
864pub enum RefreshTokenState {
865 /// Live: redeemable exactly once.
866 Active,
867 /// Already rotated away. Presenting it is REUSE, which OAuth 2.1 draft section 6.1 and RFC
868 /// 9700 section 4.14.2 treat as evidence of compromise: the whole family dies.
869 Spent,
870}
871
872/// A persisted refresh token. Single use: redemption goes through
873/// [`crate::store::Storage::take_refresh_token`], and rotation issues a replacement carrying the
874/// SAME `expires_at`, so a chain has an absolute lifetime rather than a sliding one.
875///
876/// `Debug` is hand-written (see below) rather than derived: `refresh_token` is a bearer credential
877/// whose leak is exactly the compromise RFC 9700 section 4.14.2 defends against, so it must not
878/// reach a host's logs through `{:?}`.
879#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
880/// `#[non_exhaustive]`, on the same four features and the same storage argument as
881/// [`IssuedToken`]: a `Storage` implementor round-trips this through serde and never names a field,
882/// and anyone assembling one by hand goes through [`RefreshTokenRecord::new`].
883#[non_exhaustive]
884pub struct RefreshTokenRecord {
885 /// The opaque refresh token string (the storage key).
886 pub refresh_token: String,
887 /// The client the token was issued to; presentation by any other client is `invalid_grant`
888 /// and leaves the record untouched.
889 pub client_id: ClientId,
890 /// The resource owner the chain acts for.
891 pub subject: Option<String>,
892 /// The scope originally granted; refreshes may narrow, never widen.
893 pub scope: ScopeSet,
894 /// The RFC 8707 resource indicators originally granted. Carried across rotation for the same
895 /// reason `scope` is: section 2 lets a token request narrow the set and never widen it, so the
896 /// chain has to remember what it started with. Empty when the grant named none.
897 pub resource: Vec<String>,
898 /// The RFC 9396 authorization details originally granted. Carried across rotation for
899 /// the same reason `scope` and `resource` are: section 6 lets a token request narrow the
900 /// set and never widen it, so the chain has to remember what it started with, and a
901 /// rotation that narrowed must not be climbable back on the next one.
902 ///
903 /// `#[serde(default)]`, which [`IssuedToken::authorization_details`] states in full: a chain
904 /// written by a build without `rar` carries no such key, this is not an `Option` so serde
905 /// supplies no default of its own, and without one every live chain becomes unreadable the
906 /// moment anything in the host's dependency graph turns the feature on.
907 #[cfg(feature = "rar")]
908 #[serde(default)]
909 pub authorization_details: crate::rar::AuthorizationDetails,
910 /// The instant the GRANT behind this chain was authorized, CARRIED ACROSS ROTATION and never
911 /// restamped, for the same reason `scope` and `resource` are carried: the chain has to remember
912 /// the one decision that started it. Restamping it on each rotation would let a chain walk
913 /// forward past a revocation it was supposed to die to.
914 ///
915 /// See [`IssuedToken::grant_established_at`], which this is copied into on every rotation, and
916 /// which states in full why the serde default below is the epoch: a chain a 0.9.0 node wrote
917 /// carries no such key, and the epoch is the reading that a standing revocation REFUSES rather
918 /// than the one it admits.
919 #[serde(default = "grant_established_at_default")]
920 pub grant_established_at: SystemTime,
921 /// Absolute chain expiry; `None` means the chain does not expire by time.
922 ///
923 /// On a `Spent` record this doubles as the RETENTION deadline: a spent token is kept only so
924 /// that its reuse can be recognised, and a chain with no absolute expiry would otherwise keep
925 /// every superseded link forever. The server therefore stamps a spent record from a
926 /// never-expiring chain with `now + ServerConfig::refresh_reuse_window`, which is what makes
927 /// [`crate::store::Storage::sweep_expired`] able to reclaim it.
928 pub expires_at: Option<SystemTime>,
929 /// RFC 9449 section 5: the RFC 7638 thumbprint of the DPoP key this refresh chain is bound
930 /// to, or `None` for an unbound chain.
931 ///
932 /// Carried across rotation and CHECKED on redemption. Without it the binding would be
933 /// decorative for anything but the first access token: a stolen refresh token could simply be
934 /// re-bound to the thief's key on the next rotation, leaving the attacker holding a token they
935 /// can prove possession for and the victim's key the one that gets refused.
936 #[cfg(feature = "dpop")]
937 pub jkt: Option<Box<str>>,
938 /// RFC 8705 section 3: the client certificate this refresh chain is bound to, or `None`
939 /// for an unbound chain.
940 ///
941 /// Carried across rotation and CHECKED on redemption, exactly as `jkt` is and for the
942 /// same argument: without it the binding would be decorative past the first access
943 /// token, because a stolen refresh token could simply be re-bound to whatever
944 /// certificate the thief holds on the next rotation. Section 3 makes this a MUST for
945 /// public clients specifically; this crate applies it to every chain that was issued
946 /// over a certificate, because a chain whose holder proved possession of a key once
947 /// should have to keep proving it, and for a confidential mutual-TLS client the rule
948 /// costs nothing (it presents that certificate on every request anyway).
949 #[cfg(feature = "mtls")]
950 pub x5t_s256: Option<Box<crate::mtls::CertificateThumbprint>>,
951 /// The FAMILY this token belongs to: one identifier shared by every token, access or refresh,
952 /// minted from the same authorization grant, and carried across rotation unchanged.
953 ///
954 /// This is what makes RFC 9700 section 4.14.2 implementable at all. Without it the AS can
955 /// refuse a reused token but cannot reach the tokens the thief already rotated into, which is
956 /// the defence exactly inverted: the victim is locked out and the attacker is not.
957 pub family_id: String,
958 /// Whether this link is still redeemable, or is a retained rotated one.
959 pub state: RefreshTokenState,
960 /// The authentication the host reported when the grant this chain came from was approved,
961 /// carried across rotation UNCHANGED.
962 ///
963 /// Carried rather than restamped because a rotation is not a new authentication: the user is
964 /// not present, nothing has been proven again, and giving a refreshed token a fresh `auth_time`
965 /// would let any client defeat an RFC 9470 `max_age` by refreshing. See
966 /// [`IssuedToken::authentication`] for why it is boxed.
967 #[cfg(feature = "consent")]
968 pub authentication: Option<Box<crate::consent::Authentication>>,
969}
970
971impl RefreshTokenRecord {
972 /// A LIVE link: `state` is [`RefreshTokenState::Active`], because a record nobody has rotated
973 /// yet is the only kind worth minting, and a caller building a spent one for a reuse test
974 /// assigns the field afterwards rather than passing a flag that is `Active` every real time.
975 ///
976 /// `family_id` is an argument and not a default, unlike almost everything else here, because
977 /// there is no honest default for it: an invented one would put this chain in a family of its
978 /// own and quietly cost RFC 9700 section 4.14.2 the access tokens minted alongside it, which is
979 /// the failure the field exists to prevent. `expires_at` starts `None`, a chain with no
980 /// absolute lifetime, which is what [`crate::server::ServerConfig`] produces when the host has
981 /// set no refresh TTL.
982 pub fn new(
983 refresh_token: impl Into<String>,
984 client_id: ClientId,
985 subject: Option<String>,
986 scope: ScopeSet,
987 family_id: impl Into<String>,
988 ) -> Self {
989 RefreshTokenRecord {
990 refresh_token: refresh_token.into(),
991 client_id,
992 subject,
993 scope,
994 resource: Vec::new(),
995 #[cfg(feature = "rar")]
996 authorization_details: crate::rar::AuthorizationDetails::none(),
997 // UNIX_EPOCH is the FAIL-CLOSED default, and it is deliberate. A record assembled by
998 // hand has not said when its grant was authorized, and the epoch predates every
999 // revocation, so a standing barrier refuses it. The other direction would have a
1000 // hand-built record silently outrank a revocation.
1001 grant_established_at: SystemTime::UNIX_EPOCH,
1002 expires_at: None,
1003 #[cfg(feature = "dpop")]
1004 jkt: None,
1005 #[cfg(feature = "mtls")]
1006 x5t_s256: None,
1007 family_id: family_id.into(),
1008 state: RefreshTokenState::Active,
1009 #[cfg(feature = "consent")]
1010 authentication: None,
1011 }
1012 }
1013}
1014
1015/// Hand-written so the opaque `refresh_token` never prints. EVERY other field prints, on the rule
1016/// [`IssuedToken`]'s `Debug` states in full: `state` and `family_id` are precisely what an operator
1017/// debugging an RFC 9700 section 4.14.2 family revocation needs to see, `grant_established_at` is
1018/// what a [`crate::store::RevocationBarrier`] is compared against, and none of the three is a
1019/// credential.
1020impl fmt::Debug for RefreshTokenRecord {
1021 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1022 let mut out = f.debug_struct("RefreshTokenRecord");
1023 out.field("refresh_token", &"[redacted]")
1024 .field("client_id", &self.client_id)
1025 .field("subject", &self.subject)
1026 .field("scope", &self.scope)
1027 .field("resource", &self.resource);
1028 #[cfg(feature = "rar")]
1029 out.field("authorization_details", &self.authorization_details);
1030 out.field("grant_established_at", &self.grant_established_at)
1031 .field("expires_at", &self.expires_at);
1032 #[cfg(feature = "dpop")]
1033 out.field("jkt", &self.jkt);
1034 #[cfg(feature = "mtls")]
1035 out.field("x5t_s256", &self.x5t_s256);
1036 out.field("family_id", &self.family_id)
1037 .field("state", &self.state);
1038 #[cfg(feature = "consent")]
1039 out.field("authentication", &self.authentication);
1040 out.finish()
1041 }
1042}
1043
1044#[cfg(test)]
1045#[path = "tests/token.rs"]
1046mod tests;