1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
// SPDX-License-Identifier: MIT OR Apache-2.0
// Copyright (C) 2026 Matthew Jackson
//! RFC 7523 section 3 time claims that cannot be represented as a `SystemTime`.
//!
//! `exp`, `nbf` and `iat` come out of the assertion's own JSON, which is written by whoever sent
//! the request: an assertion is checked BEFORE the client is authenticated (it IS the
//! authentication), so these values are unauthenticated attacker input. A value large enough that
//! `UNIX_EPOCH + Duration::from_secs(value)` cannot be represented made std panic, and this crate
//! is a library, so that panic unwinds into the host's request handler.
//!
//! Each test asserts the REFUSAL rather than `should_panic`: the property is that no panic happens,
//! and that the refusal is the one the module already had for a time claim it will not accept.
#![cfg(all(feature = "client-assertion", feature = "jwt-p256"))]
// Requires `jwt-p256`, the built-in ES256 backend, because every test below has to PRODUCE a
// signature. `jwt` alone carries the `Es256Signer`/`Es256Verifier` seam and no curve arithmetic at
// all, so in that build there is nothing here that could run.
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use oauth_as::client_assertion::{verify_assertion, AssertionFailure, AssertionKeys};
use oauth_as::jwt::{compact_jws, EcdsaP256Key};
/// The crate's built-in ES256 backend. Verification now goes through the [`oauth_as::jwt::Es256Verifier`] seam,
/// so a verifier is a per-call argument; this is the one a consumer who enables `jwt-p256` gets by
/// default, which is what keeps these tests measuring the behaviour they always measured.
#[cfg(feature = "jwt-p256")]
const VERIFIER: &oauth_as::jwt::P256Verifier = &oauth_as::jwt::P256Verifier;
const TOKEN_ENDPOINT: &str = "https://as.example/token";
const CLIENT_ID: &str = "pkjwt";
fn now() -> SystemTime {
UNIX_EPOCH + Duration::from_secs(1_700_000_000)
}
fn keys(key: &EcdsaP256Key) -> AssertionKeys {
AssertionKeys::PublicKeys {
keys: vec![key.to_public_jwk()],
}
}
fn sign(key: &EcdsaP256Key, claims: &serde_json::Value) -> String {
compact_jws(
br#"{"alg":"ES256","typ":"JWT"}"#,
&serde_json::to_vec(claims).unwrap(),
|input| key.sign_signing_input(input).unwrap(),
)
}
/// THE ATTACK: `{"exp": 18446744073709551615}` on an otherwise well-formed assertion.
///
/// `exp` is read and converted to an instant BEFORE either bound is compared, so the panic happened
/// on a request that a correct server refuses outright: RFC 7523 section 3 (4) plus this module's
/// `MAX_ASSERTION_LIFETIME` cap mean an `exp` that far out was never acceptable.
#[test]
fn an_exp_of_u64_max_is_refused_rather_than_panicking() {
let key = EcdsaP256Key::generate("client-key");
let assertion = sign(
&key,
&serde_json::json!({
"iss": CLIENT_ID,
"sub": CLIENT_ID,
"aud": TOKEN_ENDPOINT,
"exp": u64::MAX,
"jti": "a-1",
}),
);
assert_eq!(
verify_assertion(
Some(VERIFIER),
&keys(&key),
&assertion,
CLIENT_ID,
&[TOKEN_ENDPOINT],
now()
),
Err(AssertionFailure::Expired),
"an exp past MAX_ASSERTION_LIFETIME is refused, and one that cannot be represented is too"
);
}
/// The same for `nbf` and for `iat`, which are optional and are checked only when present. Both are
/// compared against a horizon of `now + CLOCK_SKEW_LEEWAY`, and a value that cannot be represented
/// is beyond every horizon.
#[test]
fn an_nbf_or_iat_of_u64_max_is_refused_rather_than_panicking() {
let key = EcdsaP256Key::generate("client-key");
for claim in ["nbf", "iat"] {
let mut claims = serde_json::json!({
"iss": CLIENT_ID,
"sub": CLIENT_ID,
"aud": TOKEN_ENDPOINT,
"exp": 1_700_000_120u64,
"jti": "a-2",
});
claims[claim] = serde_json::json!(u64::MAX);
let assertion = sign(&key, &claims);
assert_eq!(
verify_assertion(
Some(VERIFIER),
&keys(&key),
&assertion,
CLIENT_ID,
&[TOKEN_ENDPOINT],
now()
),
Err(AssertionFailure::NotYetValid),
"{claim} of u64::MAX is in the future by more than any leeway"
);
}
}