Skip to main content

cratefield_auth_client/
verify.rs

1//! Token verification: header, signature, then claims.
2//!
3//! The order matters. Nothing inside a token is trusted until its
4//! signature verifies, so the claim checks run last, on a payload that
5//! has already been proven to come from a key we hold.
6
7use base64ct::{Base64UrlUnpadded, Encoding};
8use p256::ecdsa::signature::Verifier;
9use p256::ecdsa::{Signature, VerifyingKey};
10use serde::Deserialize;
11use sha2::{Digest, Sha256};
12
13use crate::{ALGORITHM, LEEWAY_SECS};
14
15/// Why a token was refused.
16///
17/// The variants exist to be logged, not to be shown to a caller: a
18/// client that learns *why* its token failed learns something about the
19/// verifier. Handlers turn every one of these into the same 401.
20#[derive(Debug, thiserror::Error, PartialEq, Eq)]
21pub enum VerifyError {
22    #[error("token is not three base64url segments")]
23    Malformed,
24    #[error("header is not JSON, or omits alg/kid")]
25    BadHeader,
26    #[error("algorithm {0} is not accepted; only {ALGORITHM} is")]
27    Algorithm(String),
28    #[error("token type is {0:?}, expected at+jwt: only access tokens are accepted")]
29    Type(String),
30    #[error("no key with id {0} is published by the issuer")]
31    UnknownKey(String),
32    #[error("signature does not verify")]
33    Signature,
34    #[error("payload is not JSON, or omits a required claim")]
35    BadPayload,
36    #[error("issuer is {got}, expected {want}")]
37    Issuer { got: String, want: String },
38    #[error("audience is {got}, expected {want}")]
39    Audience { got: String, want: String },
40    #[error("token expired")]
41    Expired,
42    #[error("token is not valid yet")]
43    NotYetValid,
44}
45
46/// The claims a verified access token carries.
47#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
48pub struct Claims {
49    /// The user this token speaks for.
50    pub sub: String,
51    /// The client the token was minted for; checked against ours.
52    pub aud: String,
53    /// The issuing service.
54    pub iss: String,
55    /// The session behind the token. A consuming app that needs
56    /// instant revocation asks the auth service about this id; see the
57    /// crate docs for why that is the exception rather than the rule.
58    pub sid: String,
59    pub exp: i64,
60    pub iat: i64,
61    #[serde(default)]
62    pub email: Option<String>,
63    #[serde(default)]
64    pub email_verified: Option<bool>,
65    /// Authentication methods used at login (RFC 8176).
66    #[serde(default)]
67    pub amr: Vec<String>,
68    /// The enterprise SSO connection this session was signed in through
69    /// (issue #627), present only while that connection belongs to the
70    /// client the token was minted for — which is this one, or the
71    /// audience check above would already have refused the token.
72    ///
73    /// `None` for every other way in, and for a token minted before the
74    /// claim existed.
75    #[serde(default)]
76    pub sso_connection: Option<String>,
77    /// When the person last authenticated in a real ceremony, epoch seconds
78    /// (issue #912). `None` only for a token minted before the claim
79    /// existed, which [`Claims::authenticated_within`] treats as not fresh.
80    #[serde(default)]
81    pub auth_time: Option<i64>,
82    /// The account's roles in the issuing instance (issue #912), present
83    /// only for a client the instance registered to receive them. Empty
84    /// otherwise.
85    #[serde(default)]
86    pub roles: Vec<String>,
87    /// The authentication context (`acr`), `urn:cratefield:stepup` when the
88    /// session spent a step-up transaction (issue #912). Absent otherwise.
89    #[serde(default)]
90    pub acr: Option<String>,
91}
92
93impl Claims {
94    /// Whether the person proved who they are with a passkey or an email link
95    /// within the last `max_age_secs` seconds, measured from `now` (issue
96    /// #912). This is the check a payment, signature or other irreversible
97    /// approval gates on: a password, a federated sign-in or an old login does
98    /// not pass it, however recent. `false` when the token has no `auth_time`.
99    #[must_use]
100    pub fn stepped_up_within(&self, max_age_secs: i64, now: i64) -> bool {
101        // The context is the proof: the issuer sets `acr` only on the code
102        // that spent a step-up transaction, which required a passkey or email
103        // ceremony, begun after the demand, outside the account's 24-hour quiet
104        // period. A plain email or passkey login during that period carries no
105        // `acr`, and does not pass (issue #912, third review).
106        self.acr.as_deref() == Some(STEP_UP_ACR)
107            && self.authenticated_within(max_age_secs, now)
108            && self
109                .amr
110                .iter()
111                .any(|method| method == "passkey" || method == "email")
112    }
113
114    /// Whether the person authenticated within the last `max_age_secs`
115    /// seconds, measured from `now` (epoch seconds, taken from the caller's
116    /// clock). `false` when the token has no `auth_time`.
117    ///
118    /// A relying party that is about to approve something checks this itself.
119    /// Verification establishes who signed the token, not how recently the
120    /// person proved who they are.
121    #[must_use]
122    pub fn authenticated_within(&self, max_age_secs: i64, now: i64) -> bool {
123        self.auth_time.is_some_and(|at| {
124            let age = now - at;
125            age >= -LEEWAY_SECS && age <= max_age_secs
126        })
127    }
128
129    /// Whether this token came through one specific enterprise SSO
130    /// connection, as opposed to any other way in.
131    ///
132    /// Both halves are required. A token that named the connection without
133    /// `sso` in `amr` would be one from before the claim existed, or one
134    /// minted by something that did not go through the SSO flow, and a
135    /// caller that gates on enterprise membership should refuse both.
136    #[must_use]
137    pub fn signed_in_via_sso(&self, connection_id: &str) -> bool {
138        self.sso_connection.as_deref() == Some(connection_id)
139            && self.amr.iter().any(|method| method == "sso")
140    }
141}
142
143/// The `acr` a token carries when its authorization code spent a step-up
144/// transaction (issue #912). Mirrors the issuer's value.
145const STEP_UP_ACR: &str = "urn:cratefield:stepup";
146
147/// The one `typ` an access token carries (RFC 9068). An ID token is signed
148/// by the same key and has the same `aud`, so without this check it would
149/// verify as an access token.
150pub(crate) const ACCESS_TOKEN_TYPE: &str = "at+jwt";
151
152#[derive(Deserialize)]
153struct Header {
154    alg: String,
155    kid: String,
156    #[serde(default)]
157    typ: Option<String>,
158}
159
160/// The `kid` a token names, without verifying anything.
161///
162/// Used only to decide whether a forced JWKS refetch might help. A
163/// caller must never act on this: it is attacker-controlled until the
164/// signature verifies.
165pub(crate) fn peek_kid(token: &str) -> Option<String> {
166    let header = decode_header(token).ok()?;
167    (header.alg == ALGORITHM).then_some(header.kid)
168}
169
170fn decode_header(token: &str) -> Result<Header, VerifyError> {
171    let encoded = token.split('.').next().ok_or(VerifyError::Malformed)?;
172    let bytes = Base64UrlUnpadded::decode_vec(encoded).map_err(|_| VerifyError::Malformed)?;
173    serde_json::from_slice(&bytes).map_err(|_| VerifyError::BadHeader)
174}
175
176/// Verifies one token against the keys we hold.
177///
178/// `keys` maps `kid` to the public key published for it.
179pub(crate) fn verify_with(
180    token: &str,
181    keys: &[(String, VerifyingKey)],
182    issuer: &str,
183    audience: &str,
184    now: i64,
185) -> Result<Claims, VerifyError> {
186    let mut parts = token.split('.');
187    let (Some(encoded_header), Some(encoded_payload), Some(encoded_signature), None) =
188        (parts.next(), parts.next(), parts.next(), parts.next())
189    else {
190        return Err(VerifyError::Malformed);
191    };
192
193    let header: Header = {
194        let bytes =
195            Base64UrlUnpadded::decode_vec(encoded_header).map_err(|_| VerifyError::Malformed)?;
196        serde_json::from_slice(&bytes).map_err(|_| VerifyError::BadHeader)?
197    };
198
199    // Algorithm first, before a key is even looked up: `none` and the
200    // HMAC families are refused here rather than deeper in, so a token
201    // asking us to verify it with the public key as a shared secret
202    // never gets the chance.
203    if header.alg != ALGORITHM {
204        return Err(VerifyError::Algorithm(header.alg));
205    }
206    // Then the type: this crate verifies access tokens. An ID token has the
207    // same signature and audience, and is refused here, before its claims
208    // are read as if they were an access token's.
209    if header.typ.as_deref() != Some(ACCESS_TOKEN_TYPE) {
210        return Err(VerifyError::Type(header.typ.unwrap_or_default()));
211    }
212
213    let key = keys
214        .iter()
215        .find(|(kid, _)| *kid == header.kid)
216        .map(|(_, key)| key)
217        .ok_or_else(|| VerifyError::UnknownKey(header.kid.clone()))?;
218
219    let signature_bytes =
220        Base64UrlUnpadded::decode_vec(encoded_signature).map_err(|_| VerifyError::Malformed)?;
221    let signature = Signature::from_slice(&signature_bytes).map_err(|_| VerifyError::Signature)?;
222    let signing_input = format!("{encoded_header}.{encoded_payload}");
223    key.verify(signing_input.as_bytes(), &signature)
224        .map_err(|_| VerifyError::Signature)?;
225
226    // Only now is anything in the payload worth reading.
227    let payload_bytes =
228        Base64UrlUnpadded::decode_vec(encoded_payload).map_err(|_| VerifyError::Malformed)?;
229    let claims: Claims =
230        serde_json::from_slice(&payload_bytes).map_err(|_| VerifyError::BadPayload)?;
231
232    if claims.iss != issuer {
233        return Err(VerifyError::Issuer {
234            got: claims.iss,
235            want: issuer.to_owned(),
236        });
237    }
238    // The check every hand-rolled verifier forgets: a token minted for
239    // another venture is signed by the same issuer with the same key,
240    // and is valid in every respect except that it is not ours.
241    if claims.aud != audience {
242        return Err(VerifyError::Audience {
243            got: claims.aud,
244            want: audience.to_owned(),
245        });
246    }
247    if now - LEEWAY_SECS >= claims.exp {
248        return Err(VerifyError::Expired);
249    }
250    if claims.iat - LEEWAY_SECS > now {
251        return Err(VerifyError::NotYetValid);
252    }
253    Ok(claims)
254}
255
256/// The SHA-256 of a token, for logging a token without logging it.
257#[must_use]
258pub fn token_fingerprint(token: &str) -> String {
259    use std::fmt::Write as _;
260    let digest = Sha256::digest(token.as_bytes());
261    digest.iter().take(6).fold(String::new(), |mut out, byte| {
262        let _ = write!(out, "{byte:02x}");
263        out
264    })
265}