use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Barrier, RwLock};
use async_trait::async_trait;
use base64ct::{Base64UrlUnpadded, Encoding};
use bytes::Bytes;
use cratefield_auth_client::{AuthClient, VerifyError};
use cratefield_core::{Clock, HttpClient, HttpError};
use p256::ecdsa::signature::Signer;
use p256::ecdsa::{self, Signature};
use serde_json::{Value, json};
use time::OffsetDateTime;
const ISSUER: &str = "https://auth.acme.example";
const CLIENT: &str = "client-kontinuum";
const OTHER_CLIENT: &str = "client-undercover";
const NOW: i64 = 1_800_000_000;
struct FrozenClock;
impl Clock for FrozenClock {
fn now(&self) -> OffsetDateTime {
OffsetDateTime::from_unix_timestamp(NOW).expect("in range")
}
}
struct FakeJwks {
body: RwLock<String>,
calls: RwLock<usize>,
}
impl FakeJwks {
fn new(body: String) -> Arc<Self> {
Arc::new(Self {
body: RwLock::new(body),
calls: RwLock::new(0),
})
}
fn calls(&self) -> usize {
*self.calls.read().expect("uncontended")
}
}
#[async_trait]
impl HttpClient for FakeJwks {
async fn send(
&self,
_request: http::Request<Bytes>,
) -> Result<http::Response<Bytes>, HttpError> {
*self.calls.write().expect("uncontended") += 1;
let body = self.body.read().expect("uncontended").clone();
Ok(http::Response::builder()
.status(200)
.body(Bytes::from(body))
.expect("response"))
}
}
fn key(seed: u8) -> (ecdsa::SigningKey, Value) {
let mut bytes = [seed.max(1); 32];
bytes[0] = seed.max(1);
let secret = p256::SecretKey::from_slice(&bytes).expect("scalar");
let signing = ecdsa::SigningKey::from(&secret);
let point = signing.verifying_key().to_sec1_point(false);
let sec1 = point.as_bytes();
assert_eq!(sec1.len(), 65, "uncompressed point");
let jwk = json!({
"kty": "EC",
"crv": "P-256",
"kid": format!("k{seed}"),
"x": Base64UrlUnpadded::encode_string(&sec1[1..33]),
"y": Base64UrlUnpadded::encode_string(&sec1[33..65]),
});
(signing, jwk)
}
fn b64(bytes: &[u8]) -> String {
Base64UrlUnpadded::encode_string(bytes)
}
fn mint(signing: &ecdsa::SigningKey, header: &Value, claims: &Value) -> String {
let signing_input = format!(
"{}.{}",
b64(serde_json::to_string(header).expect("header").as_bytes()),
b64(serde_json::to_string(claims).expect("claims").as_bytes())
);
let signature: Signature = signing.sign(signing_input.as_bytes());
format!("{signing_input}.{}", b64(&signature.to_bytes()))
}
fn claims(aud: &str, iss: &str, exp: i64) -> Value {
json!({
"sub": "user-1", "aud": aud, "iss": iss, "sid": "session-1",
"exp": exp, "iat": NOW - 10, "email": "a@example.com",
"email_verified": true, "amr": ["passkey"],
})
}
fn client(http: Arc<dyn HttpClient>) -> AuthClient {
AuthClient::new(http, Arc::new(FrozenClock), ISSUER, CLIENT)
}
fn jwks_body(jwks: &[Value]) -> String {
json!({ "keys": jwks }).to_string()
}
#[pollster::test]
async fn a_valid_token_verifies_and_yields_its_claims() {
let (signing, jwk) = key(1);
let http = FakeJwks::new(jwks_body(&[jwk]));
let auth = client(http);
let token = mint(
&signing,
&json!({"alg": "ES256", "kid": "k1", "typ": "at+jwt"}),
&claims(CLIENT, ISSUER, NOW + 600),
);
let verified = auth.verify(&token).await.expect("verifies");
assert_eq!(verified.sub, "user-1");
assert_eq!(verified.sid, "session-1");
assert_eq!(verified.amr, vec!["passkey".to_owned()]);
assert_eq!(verified.sso_connection, None);
assert!(!verified.signed_in_via_sso("ssoc_1"));
}
#[pollster::test]
async fn an_sso_token_names_its_connection_and_only_that_one() {
let (signing, jwk) = key(1);
let auth = client(FakeJwks::new(jwks_body(&[jwk])));
let mut payload = claims(CLIENT, ISSUER, NOW + 600);
payload["sso_connection"] = json!("ssoc_1");
payload["amr"] = json!(["sso"]);
let token = mint(
&signing,
&json!({"alg": "ES256", "kid": "k1", "typ": "at+jwt"}),
&payload,
);
let verified = auth.verify(&token).await.expect("verifies");
assert_eq!(verified.sso_connection.as_deref(), Some("ssoc_1"));
assert!(verified.signed_in_via_sso("ssoc_1"));
assert!(!verified.signed_in_via_sso("ssoc_2"));
assert!(!verified.signed_in_via_sso(""));
}
#[pollster::test]
async fn an_sso_connection_without_the_amr_is_not_an_sso_sign_in() {
let (signing, jwk) = key(1);
let auth = client(FakeJwks::new(jwks_body(&[jwk])));
let mut payload = claims(CLIENT, ISSUER, NOW + 600);
payload["sso_connection"] = json!("ssoc_1");
let token = mint(
&signing,
&json!({"alg": "ES256", "kid": "k1", "typ": "at+jwt"}),
&payload,
);
let verified = auth.verify(&token).await.expect("verifies");
assert_eq!(verified.sso_connection.as_deref(), Some("ssoc_1"));
assert!(!verified.signed_in_via_sso("ssoc_1"));
}
#[pollster::test]
async fn a_token_for_another_client_is_refused() {
let (signing, jwk) = key(1);
let auth = client(FakeJwks::new(jwks_body(&[jwk])));
let token = mint(
&signing,
&json!({"alg": "ES256", "kid": "k1", "typ": "at+jwt"}),
&claims(OTHER_CLIENT, ISSUER, NOW + 600),
);
assert_eq!(
auth.verify(&token).await,
Err(VerifyError::Audience {
got: OTHER_CLIENT.to_owned(),
want: CLIENT.to_owned()
})
);
}
#[pollster::test]
async fn a_token_from_another_issuer_is_refused() {
let (signing, jwk) = key(1);
let auth = client(FakeJwks::new(jwks_body(&[jwk])));
let token = mint(
&signing,
&json!({"alg": "ES256", "kid": "k1", "typ": "at+jwt"}),
&claims(CLIENT, "https://evil.example", NOW + 600),
);
assert!(matches!(
auth.verify(&token).await,
Err(VerifyError::Issuer { .. })
));
}
#[pollster::test]
async fn an_expired_token_is_refused() {
let (signing, jwk) = key(1);
let auth = client(FakeJwks::new(jwks_body(&[jwk])));
let token = mint(
&signing,
&json!({"alg": "ES256", "kid": "k1", "typ": "at+jwt"}),
&claims(CLIENT, ISSUER, NOW - 3600),
);
assert_eq!(auth.verify(&token).await, Err(VerifyError::Expired));
}
#[pollster::test]
async fn the_none_algorithm_is_refused() {
let (_signing, jwk) = key(1);
let auth = client(FakeJwks::new(jwks_body(&[jwk])));
let header = b64(json!({"alg": "none", "kid": "k1"}).to_string().as_bytes());
let payload = b64(claims(CLIENT, ISSUER, NOW + 600).to_string().as_bytes());
let token = format!("{header}.{payload}.");
assert_eq!(
auth.verify(&token).await,
Err(VerifyError::Algorithm("none".to_owned()))
);
}
#[pollster::test]
async fn an_hmac_algorithm_is_refused() {
let (signing, jwk) = key(1);
let auth = client(FakeJwks::new(jwks_body(&[jwk])));
let token = mint(
&signing,
&json!({"alg": "HS256", "kid": "k1"}),
&claims(CLIENT, ISSUER, NOW + 600),
);
assert_eq!(
auth.verify(&token).await,
Err(VerifyError::Algorithm("HS256".to_owned()))
);
}
#[pollster::test]
async fn a_token_signed_by_an_unpublished_key_is_refused() {
let (_published, jwk) = key(1);
let (attacker, _) = key(9);
let auth = client(FakeJwks::new(jwks_body(&[jwk])));
let token = mint(
&attacker,
&json!({"alg": "ES256", "kid": "k1", "typ": "at+jwt"}),
&claims(CLIENT, ISSUER, NOW + 600),
);
assert_eq!(auth.verify(&token).await, Err(VerifyError::Signature));
}
#[pollster::test]
async fn a_tampered_payload_is_refused() {
let (signing, jwk) = key(1);
let auth = client(FakeJwks::new(jwks_body(&[jwk])));
let token = mint(
&signing,
&json!({"alg": "ES256", "kid": "k1", "typ": "at+jwt"}),
&claims(CLIENT, ISSUER, NOW + 600),
);
let mut parts: Vec<&str> = token.split('.').collect();
let swapped = b64(claims("client-elevated", ISSUER, NOW + 600)
.to_string()
.as_bytes());
parts[1] = &swapped;
let tampered = parts.join(".");
assert_eq!(auth.verify(&tampered).await, Err(VerifyError::Signature));
}
#[pollster::test]
async fn the_key_set_is_cached_across_verifications() {
let (signing, jwk) = key(1);
let http = FakeJwks::new(jwks_body(&[jwk]));
let auth = client(Arc::clone(&http) as Arc<dyn HttpClient>);
let token = mint(
&signing,
&json!({"alg": "ES256", "kid": "k1", "typ": "at+jwt"}),
&claims(CLIENT, ISSUER, NOW + 600),
);
for _ in 0..5 {
auth.verify(&token).await.expect("verifies");
}
assert_eq!(http.calls(), 1, "one fetch serves every verification");
}
#[pollster::test]
async fn an_unknown_key_forces_one_refetch_and_is_then_rate_limited() {
let (_signing, jwk) = key(1);
let (attacker, _) = key(9);
let http = FakeJwks::new(jwks_body(&[jwk]));
let auth = client(Arc::clone(&http) as Arc<dyn HttpClient>);
let token = mint(
&attacker,
&json!({"alg": "ES256", "kid": "k-not-published", "typ": "at+jwt"}),
&claims(CLIENT, ISSUER, NOW + 600),
);
for _ in 0..10 {
assert!(matches!(
auth.verify(&token).await,
Err(VerifyError::UnknownKey(_))
));
}
assert_eq!(
http.calls(),
2,
"one initial fetch plus one forced refetch, not ten"
);
}
#[pollster::test]
async fn a_private_key_in_the_published_set_is_ignored() {
let (signing, mut jwk) = key(1);
jwk["d"] = json!("dGhpcy1pcy1ub3QtYS1yZWFsLXByaXZhdGUta2V5");
let auth = client(FakeJwks::new(jwks_body(&[jwk])));
let token = mint(
&signing,
&json!({"alg": "ES256", "kid": "k1", "typ": "at+jwt"}),
&claims(CLIENT, ISSUER, NOW + 600),
);
assert!(
matches!(auth.verify(&token).await, Err(VerifyError::UnknownKey(_))),
"a key carrying `d` is refused, so the token cannot verify"
);
}
#[pollster::test]
async fn a_failed_refetch_keeps_the_previous_keys() {
struct FailsAfterFirst {
body: String,
calls: RwLock<usize>,
}
#[async_trait]
impl HttpClient for FailsAfterFirst {
async fn send(
&self,
_request: http::Request<Bytes>,
) -> Result<http::Response<Bytes>, HttpError> {
let mut calls = self.calls.write().expect("uncontended");
*calls += 1;
if *calls == 1 {
Ok(http::Response::builder()
.status(200)
.body(Bytes::from(self.body.clone()))
.expect("response"))
} else {
Ok(http::Response::builder()
.status(503)
.body(Bytes::new())
.expect("response"))
}
}
}
let (signing, jwk) = key(1);
let http = Arc::new(FailsAfterFirst {
body: jwks_body(&[jwk]),
calls: RwLock::new(0),
});
let auth = client(Arc::clone(&http) as Arc<dyn HttpClient>);
let good = mint(
&signing,
&json!({"alg": "ES256", "kid": "k1", "typ": "at+jwt"}),
&claims(CLIENT, ISSUER, NOW + 600),
);
let unknown = mint(
&signing,
&json!({"alg": "ES256", "kid": "k-missing", "typ": "at+jwt"}),
&claims(CLIENT, ISSUER, NOW + 600),
);
auth.verify(&good).await.expect("first verification");
let _ = auth.verify(&unknown).await;
auth.verify(&good)
.await
.expect("a failed refetch keeps the previous keys");
}
#[test]
fn a_cold_cache_race_fetches_the_key_set_once() {
const THREADS: usize = 8;
struct SlowJwks {
body: String,
calls: AtomicUsize,
}
#[async_trait]
impl HttpClient for SlowJwks {
async fn send(
&self,
_request: http::Request<Bytes>,
) -> Result<http::Response<Bytes>, HttpError> {
self.calls.fetch_add(1, Ordering::SeqCst);
std::thread::sleep(std::time::Duration::from_millis(50));
Ok(http::Response::builder()
.status(200)
.body(Bytes::from(self.body.clone()))
.expect("response"))
}
}
let (signing, jwk) = key(1);
let http = Arc::new(SlowJwks {
body: jwks_body(&[jwk]),
calls: AtomicUsize::new(0),
});
let auth = Arc::new(client(Arc::clone(&http) as Arc<dyn HttpClient>));
let token = mint(
&signing,
&json!({"alg": "ES256", "kid": "k1", "typ": "at+jwt"}),
&claims(CLIENT, ISSUER, NOW + 600),
);
let gate = Barrier::new(THREADS);
std::thread::scope(|scope| {
for _ in 0..THREADS {
let auth = Arc::clone(&auth);
let token = token.clone();
let gate = &gate;
scope.spawn(move || {
gate.wait();
pollster::block_on(auth.verify(&token)).expect("verifies");
});
}
});
assert_eq!(
http.calls.load(Ordering::SeqCst),
1,
"one key set, one fetch — a cold-cache race must not stampede the auth service"
);
}
#[pollster::test]
async fn malformed_tokens_are_refused_without_panicking() {
let (_signing, jwk) = key(1);
let auth = client(FakeJwks::new(jwks_body(&[jwk])));
for token in [
"",
"not-a-token",
"a.b",
"a.b.c.d",
"!!!.###.$$$",
"eyJhbGciOiJFUzI1NiJ9",
] {
assert!(
auth.verify(token).await.is_err(),
"{token:?} must be refused"
);
}
}
#[pollster::test]
async fn an_id_token_is_refused_as_an_access_token() {
let (signing, jwk) = key(1);
let auth = client(FakeJwks::new(jwks_body(&[jwk])));
let token = mint(
&signing,
&json!({"alg": "ES256", "kid": "k1", "typ": "JWT"}),
&claims(CLIENT, ISSUER, NOW + 600),
);
assert!(
matches!(auth.verify(&token).await, Err(VerifyError::Type(ref t)) if t == "JWT"),
"an ID token must not verify as an access token"
);
}
#[pollster::test]
async fn a_token_without_a_type_is_refused() {
let (signing, jwk) = key(1);
let auth = client(FakeJwks::new(jwks_body(&[jwk])));
let token = mint(
&signing,
&json!({"alg": "ES256", "kid": "k1"}),
&claims(CLIENT, ISSUER, NOW + 600),
);
assert!(matches!(
auth.verify(&token).await,
Err(VerifyError::Type(_))
));
}
#[pollster::test]
async fn auth_time_and_roles_are_read_and_checked_by_the_relying_party() {
let (signing, jwk) = key(1);
let auth = client(FakeJwks::new(jwks_body(&[jwk])));
let mut payload = claims(CLIENT, ISSUER, NOW + 600);
payload["auth_time"] = json!(NOW - 100);
payload["roles"] = json!(["owner"]);
let token = mint(
&signing,
&json!({"alg": "ES256", "kid": "k1", "typ": "at+jwt"}),
&payload,
);
let verified = auth.verify(&token).await.expect("verifies");
assert_eq!(verified.auth_time, Some(NOW - 100));
assert_eq!(verified.roles, vec!["owner".to_owned()]);
assert!(verified.authenticated_within(120, NOW));
assert!(
!verified.authenticated_within(60, NOW),
"100s is older than 60s"
);
assert!(
!verified.authenticated_within(120, NOW + 500),
"the window has passed"
);
}
#[pollster::test]
async fn a_token_without_auth_time_is_never_fresh() {
let (signing, jwk) = key(1);
let auth = client(FakeJwks::new(jwks_body(&[jwk])));
let token = mint(
&signing,
&json!({"alg": "ES256", "kid": "k1", "typ": "at+jwt"}),
&claims(CLIENT, ISSUER, NOW + 600),
);
let verified = auth.verify(&token).await.expect("verifies");
assert_eq!(verified.auth_time, None);
assert!(verified.roles.is_empty());
assert!(!verified.authenticated_within(i64::MAX, NOW));
}
#[pollster::test]
async fn stepped_up_within_requires_a_recent_passkey_or_email() {
let (signing, jwk) = key(1);
let auth = client(FakeJwks::new(jwks_body(&[jwk])));
let mint_with = |amr: Value, auth_time: i64| {
let mut payload = claims(CLIENT, ISSUER, NOW + 600);
payload["auth_time"] = json!(auth_time);
payload["amr"] = amr;
payload["acr"] = json!("urn:cratefield:stepup");
mint(
&signing,
&json!({"alg": "ES256", "kid": "k1", "typ": "at+jwt"}),
&payload,
)
};
let passkey = auth
.verify(&mint_with(json!(["passkey", "user"]), NOW - 30))
.await
.expect("verifies");
assert!(passkey.stepped_up_within(120, NOW));
assert_eq!(passkey.acr.as_deref(), Some("urn:cratefield:stepup"));
let email = auth
.verify(&mint_with(json!(["email"]), NOW - 30))
.await
.expect("verifies");
assert!(email.stepped_up_within(120, NOW));
let mut plain = claims(CLIENT, ISSUER, NOW + 600);
plain["auth_time"] = json!(NOW - 30);
plain["amr"] = json!(["email"]);
let plain_token = mint(
&signing,
&json!({"alg": "ES256", "kid": "k1", "typ": "at+jwt"}),
&plain,
);
let plain = auth.verify(&plain_token).await.expect("verifies");
assert!(!plain.stepped_up_within(120, NOW), "no acr, no step-up");
assert!(plain.authenticated_within(120, NOW));
let federated = auth
.verify(&mint_with(json!(["federated"]), NOW - 30))
.await
.expect("verifies");
assert!(
!federated.stepped_up_within(120, NOW),
"a federated sign-in is not a step-up"
);
assert!(
federated.authenticated_within(120, NOW),
"it is recent, though"
);
let password = auth
.verify(&mint_with(json!(["pwd"]), NOW - 30))
.await
.expect("verifies");
assert!(!password.stepped_up_within(120, NOW));
let stale = auth
.verify(&mint_with(json!(["passkey"]), NOW - 600))
.await
.expect("verifies");
assert!(
!stale.stepped_up_within(120, NOW),
"an old passkey sign-in is not recent"
);
}