Skip to main content

cratefield_auth_client/
lib.rs

1//! Verify auth-service tokens in a consuming app (issue #11).
2//!
3//! Six ventures each writing their own JWT validation is six chances to
4//! forget the audience check. This crate does it once.
5//!
6//! ```ignore
7//! let auth = AuthClient::new(http, clock, "https://auth.acme.example", "client-kontinuum");
8//! async fn handler(Authenticated(claims): Authenticated) -> String { claims.sub }
9//! ```
10//!
11//! **What it checks, in this order**: the header names `ES256` and a
12//! `kid` we hold; the signature verifies against that key; `iss` equals
13//! the configured issuer; `aud` equals *this* client's id; `exp` has not
14//! passed and `nbf`/`iat` are not implausibly far in the future. Every
15//! one of those is a way a token from somewhere else gets accepted if it
16//! is skipped, so none of them is optional and none is configurable.
17//!
18//! **Algorithm confusion is refused structurally.** The header's `alg`
19//! must be exactly `ES256`. `none` and the HMAC families are rejected
20//! before any key is looked up, so a token asking to be verified with
21//! the public key as an HMAC secret never reaches verification.
22//!
23//! **wasm-safe.** Time comes from the harness `Clock` port and HTTP from
24//! its `HttpClient` port, so this compiles for `wasm32-unknown-unknown`
25//! where `std::time` and `chrono::Utc::now` both panic.
26
27#![forbid(unsafe_code)]
28#![doc = include_str!("../README.md")]
29
30mod extractor;
31mod jwks;
32mod port;
33mod verify;
34
35pub use extractor::{AuthState, Authenticated, UNAUTHENTICATED};
36pub use jwks::{AuthClient, JwksCache};
37pub use verify::{Claims, VerifyError, token_fingerprint};
38
39/// The only signature algorithm this client accepts.
40pub const ALGORITHM: &str = "ES256";
41
42/// How long a fetched key set is trusted before it is refetched.
43pub const JWKS_TTL_SECS: i64 = 3600;
44
45/// The shortest gap between two forced refetches triggered by an unknown
46/// `kid`. Without it, a stream of tokens naming keys that do not exist
47/// would let anyone drive unbounded traffic at the auth service.
48pub const JWKS_MIN_REFETCH_SECS: i64 = 60;
49
50/// Clock skew tolerated on `exp`, `nbf` and `iat`.
51pub const LEEWAY_SECS: i64 = 60;
52
53/// The `Auth` port a deployment gets from its environment (issue #153).
54///
55/// `AUTH_ISSUER` and `AUTH_CLIENT_ID` together give a real verifier.
56/// Either one missing gives a [`cratefield_core::Unconfigured`], which
57/// answers 503 to a request that presents a credential and leaves an
58/// anonymous one anonymous — and the warning here names what is missing,
59/// because the alternative is an operator reading 503s with no cause.
60#[must_use]
61pub fn from_config(
62    config: &dyn cratefield_core::Config,
63    http: std::sync::Arc<dyn cratefield_core::HttpClient>,
64    clock: std::sync::Arc<dyn cratefield_core::Clock>,
65) -> std::sync::Arc<dyn cratefield_core::Auth> {
66    let issuer = config.get("AUTH_ISSUER").filter(|v| !v.is_empty());
67    let client_id = config.get("AUTH_CLIENT_ID").filter(|v| !v.is_empty());
68    match (issuer, client_id) {
69        (Some(issuer), Some(client_id)) => {
70            std::sync::Arc::new(jwks::AuthClient::new(http, clock, issuer, client_id))
71        }
72        (issuer, client_id) => {
73            let mut missing = Vec::new();
74            if issuer.is_none() {
75                missing.push("AUTH_ISSUER");
76            }
77            if client_id.is_none() {
78                missing.push("AUTH_CLIENT_ID");
79            }
80            let why = format!(
81                "this deployment cannot identify a caller: {} is not set",
82                missing.join(" and ")
83            );
84            tracing::warn!(missing = %missing.join(","), "{why}");
85            std::sync::Arc::new(cratefield_core::Unconfigured::new(why))
86        }
87    }
88}