act_credentials/expiry.rs
1//! When a stored credential value is too close to expiry to be served.
2//!
3//! Here rather than beside the OAuth flow because it is a property of the
4//! **stored shape** — a `std:oauth2` value and its `std:expires-at` member
5//! (`ACT-CONSTANTS.md` §8.3) — not of the protocol that produced it. The host
6//! runtime has to make this decision on every `get-secret` and holds no opinion
7//! about OAuth; the crate that renews it lives further out.
8
9use serde_json::Value;
10
11/// How close to expiry is close enough to renew.
12///
13/// Sixty seconds covers the round trip to the upstream plus the clock skew
14/// between this host and the authorization server, which is the pair that
15/// decides whether a token still valid when we checked is still valid when it
16/// arrives. Shorter risks handing over a credential that expires in flight;
17/// much longer spends refreshes on tokens that had plenty of life.
18pub const SKEW_SECS: u64 = 60;
19
20/// Whether a `std:oauth2` field's value should be renewed before it is served.
21///
22/// A value with no `std:expires-at` is **not** renewed. ACT-CONSTANTS §8.3 has
23/// a consumer read a missing expiry as "no known expiry", and a host that
24/// treated absence as "expired" would refresh on every single call — burning a
25/// rotation each time against servers that rotate.
26pub fn needs_refresh(value: &Value, now: u64) -> bool {
27 match value.get("std:expires-at").and_then(Value::as_u64) {
28 Some(expires_at) => expires_at.saturating_sub(SKEW_SECS) <= now,
29 None => false,
30 }
31}
32
33#[cfg(test)]
34mod tests {
35 use super::*;
36 use serde_json::json;
37
38 #[test]
39 fn a_value_without_an_expiry_is_never_refreshed() {
40 // §8.3 reads a missing expiry as "no known expiry". Treating it as
41 // expired would refresh on every call, burning a rotation each time.
42 assert!(!needs_refresh(&json!({"std:access-token": "at"}), 1_000));
43 }
44
45 #[test]
46 fn expiry_is_compared_with_the_skew_that_covers_the_round_trip() {
47 let v = json!({"std:access-token": "at", "std:expires-at": 1_000u64});
48 assert!(!needs_refresh(&v, 1_000 - SKEW_SECS - 1), "plenty of life");
49 assert!(
50 needs_refresh(&v, 1_000 - SKEW_SECS),
51 "inside the skew is close enough: a token still valid when checked \
52 must still be valid when it arrives"
53 );
54 assert!(needs_refresh(&v, 1_000), "at the boundary");
55 assert!(needs_refresh(&v, 2_000), "long past");
56 }
57
58 #[test]
59 fn an_expiry_below_the_skew_does_not_wrap_into_the_future() {
60 // `expires_at - SKEW` on a small timestamp underflows to a huge number
61 // in release mode, which would read as "not due for centuries" — the
62 // one arithmetic mistake here that fails open.
63 let v = json!({"std:access-token": "at", "std:expires-at": 5u64});
64 assert!(needs_refresh(&v, 10), "an expiry in 1970 is long past");
65 }
66
67 #[test]
68 fn a_mistyped_expiry_is_not_an_expiry() {
69 // §8.3 requires a whole number of seconds and has a consumer treat
70 // anything else as absent. A float here must not be coerced into a
71 // refresh decision.
72 for bad in [json!(1.5), json!("1000"), json!(null)] {
73 let v = json!({"std:access-token": "at", "std:expires-at": bad});
74 assert!(!needs_refresh(&v, 10_000), "{bad} is not an expiry");
75 }
76 }
77}