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}