ocre/jwt.rs
1//! JSON Web Tokens (HS256) for API clients.
2//!
3//! For API clients that cannot keep a session cookie (mobile apps, scripts):
4//! they send `Authorization: Bearer <token>` instead. [`token_from`] also
5//! reads it from a query parameter or a cookie, trying several
6//! [`Location`]s in order (Loco's `auth.jwt.location`).
7//!
8//! The signing key is derived from the `SECRET_KEY_BASE` Worker secret
9//! (HMAC-SHA256 of a fixed label), so apps have one secret to create, upload
10//! and rotate: rotating it signs everyone out of sessions and tokens at once,
11//! unless the old value is kept in `SECRET_KEY_BASE_PREVIOUS` for a while.
12//! Tokens carry only the subject (usually the user id), `iat` and `exp`;
13//! [`decode`] rejects expired tokens, bad signatures and any `alg` other than
14//! [`ALGORITHM`] (including `none`) with [`Error::Unauthorized`] (401). Keep
15//! TTLs short (`ocre g auth` issues one-hour tokens) and use
16//! [`crate::token`] API keys for long-lived access.
17//!
18//! Cost: one HMAC-SHA256 over a few hundred bytes; no D1, KV or other
19//! binding is used.
20//!
21//! [`encode`] and [`decode`] read the key from the Worker's environment;
22//! [`encode_with`] and [`decode_with`] take a [`Key`] and run anywhere.
23//!
24//! ```rust,no_run
25//! use axum::{extract::State, http::{HeaderMap, Uri}};
26//! use ocre::{Ctx, Error, Result, jwt::{self, Claims, Location}};
27//!
28//! // POST /api/auth/token, after checking the password:
29//! async fn token(State(ctx): State<Ctx>) -> Result<String> {
30//! let user_id = 42;
31//! jwt::encode(&ctx, &Claims::new(user_id.to_string(), 3600))
32//! }
33//!
34//! // Later, from `Authorization: Bearer <token>` or, for browsers, a cookie:
35//! async fn me(State(ctx): State<Ctx>, headers: HeaderMap, uri: Uri) -> Result<String> {
36//! let locations = [Location::Bearer, Location::Cookie("token")];
37//! let token = jwt::token_from(&headers, &uri, &locations).ok_or(Error::Unauthorized)?;
38//! let claims = jwt::decode(&ctx, &token)?; // 401 when invalid or expired
39//! let user_id: i64 = claims.sub.parse().map_err(|_| Error::Unauthorized)?;
40//! Ok(user_id.to_string())
41//! }
42//! ```
43
44use axum::http::{HeaderMap, Uri, header};
45use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
46use cookie::Cookie;
47use hmac::{Hmac, Mac as _};
48use serde::{Deserialize, Serialize};
49use sha2::Sha256;
50
51pub use crate::runtime::jwt::{decode, encode};
52use crate::{Error, Result, session::checked_secret};
53
54/// The only JWT algorithm Ocre signs with and accepts: `HS256` (HMAC-SHA256).
55///
56/// Tokens whose header names any other `alg`, including `none`, fail
57/// [`decode`] and [`decode_with`] with [`Error::Unauthorized`].
58///
59/// # Examples
60///
61/// ```
62/// assert_eq!(ocre::jwt::ALGORITHM, "HS256");
63/// ```
64pub const ALGORITHM: &str = "HS256";
65/// Label mixed into `SECRET_KEY_BASE` so the JWT key differs from the session key.
66const KEY_LABEL: &[u8] = b"ocre/jwt/hs256";
67/// `{"alg":"HS256","typ":"JWT"}`, the header of every token Ocre issues.
68const HEADER: &str = r#"{"alg":"HS256","typ":"JWT"}"#;
69
70/// Where [`token_from`] looks for a token (Loco's `auth.jwt.location`).
71///
72/// # Examples
73///
74/// ```
75/// use ocre::jwt::Location;
76///
77/// // Browsers keep the token in a cookie; API clients send a Bearer header.
78/// let locations = [Location::Bearer, Location::Cookie("token")];
79/// assert_eq!(locations[1], Location::Cookie("token"));
80/// ```
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum Location {
83 /// `Authorization: Bearer <token>`, the default for API clients.
84 Bearer,
85 /// A query parameter, e.g. `Query("token")` for `?token=...`. URLs end up
86 /// in logs and `Referer` headers: use it only where a header is
87 /// impossible (an `EventSource`, a WebSocket from a browser, a download
88 /// link), with short-lived tokens.
89 Query(&'static str),
90 /// A cookie, e.g. `Cookie("token")`. Set it `HttpOnly` and `SameSite`:
91 /// cookies are sent automatically, so the CSRF check applies.
92 Cookie(&'static str),
93}
94
95/// The token of the first `locations` entry that has a non-empty one.
96///
97/// Locations are tried in order, so `[Location::Bearer, Location::Cookie("token")]`
98/// accepts API clients and browsers (Loco's multiple JWT locations with
99/// fallback). It only finds the token: verify it with [`decode`]. Works for
100/// API keys ([`crate::token`]) too. No binding call.
101///
102/// # Examples
103///
104/// ```
105/// use axum::http::{HeaderMap, HeaderValue, Uri};
106/// use ocre::jwt::{Location, token_from};
107///
108/// let mut headers = HeaderMap::new();
109/// headers.insert("cookie", HeaderValue::from_static("theme=dark; token=abc"));
110/// let uri: Uri = "/events?token=xyz".parse().unwrap();
111///
112/// assert_eq!(token_from(&headers, &uri, &[Location::Bearer]), None);
113/// assert_eq!(token_from(&headers, &uri, &[Location::Bearer, Location::Cookie("token")]).as_deref(), Some("abc"));
114/// assert_eq!(token_from(&headers, &uri, &[Location::Query("token")]).as_deref(), Some("xyz"));
115/// headers.insert("authorization", HeaderValue::from_static("Bearer 123"));
116/// assert_eq!(token_from(&headers, &uri, &[Location::Bearer, Location::Cookie("token")]).as_deref(), Some("123"));
117/// ```
118pub fn token_from(headers: &HeaderMap, uri: &Uri, locations: &[Location]) -> Option<String> {
119 locations.iter().find_map(|location| {
120 match *location {
121 Location::Bearer => headers
122 .get(header::AUTHORIZATION)
123 .and_then(|value| value.to_str().ok())
124 .and_then(|value| value.strip_prefix("Bearer "))
125 .map(|token| token.trim().to_owned()),
126 Location::Query(name) => serde_urlencoded::from_str::<Vec<(String, String)>>(uri.query().unwrap_or(""))
127 .ok()?
128 .into_iter()
129 .find_map(|(key, value)| (key == name).then_some(value)),
130 Location::Cookie(name) => headers
131 .get_all(header::COOKIE)
132 .iter()
133 .filter_map(|value| value.to_str().ok())
134 .flat_map(Cookie::split_parse_encoded)
135 .filter_map(std::result::Result::ok)
136 .find(|cookie| cookie.name() == name)
137 .map(|cookie| cookie.value().to_owned()),
138 }
139 .filter(|token| !token.is_empty())
140 })
141}
142
143/// The payload of a token: who it is for and when it expires.
144///
145/// Serialized as the JWT's JSON payload `{"sub":...,"iat":...,"exp":...}`.
146/// Build it with [`Claims::new`]; [`decode`] returns it once the token is
147/// verified.
148///
149/// # Examples
150///
151/// ```
152/// let claims = ocre::jwt::Claims { sub: "42".to_owned(), iat: 1_767_225_600, exp: 1_767_229_200 };
153/// assert_eq!(serde_json::to_string(&claims)?, r#"{"sub":"42","iat":1767225600,"exp":1767229200}"#);
154/// # Ok::<(), serde_json::Error>(())
155/// ```
156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
157pub struct Claims {
158 /// Subject: the user id, as a string (the JWT standard's type).
159 pub sub: String,
160 /// Issued at, in Unix seconds.
161 pub iat: i64,
162 /// Expires at, in Unix seconds; the token is rejected from this second on.
163 pub exp: i64,
164}
165
166impl Claims {
167 /// Claims for `sub`, issued now ([`crate::now`]) and valid for `ttl_seconds`.
168 ///
169 /// # Examples
170 ///
171 /// ```
172 /// let claims = ocre::jwt::Claims::new("42", 3600);
173 /// assert_eq!(claims.sub, "42");
174 /// assert_eq!(claims.exp - claims.iat, 3600);
175 /// ```
176 pub fn new(sub: impl Into<String>, ttl_seconds: i64) -> Self {
177 let iat = crate::now();
178 Self { sub: sub.into(), iat, exp: iat + ttl_seconds }
179 }
180}
181
182/// An HS256 signing key, derived from `SECRET_KEY_BASE`.
183///
184/// Handlers do not build one: [`encode`] and [`decode`] derive it from the
185/// Worker secret. Use it with [`encode_with`] and [`decode_with`] in tests,
186/// tools and scripts.
187///
188/// # Examples
189///
190/// ```
191/// let key = ocre::jwt::Key::from_secret_key_base(&"x".repeat(64));
192/// let token = ocre::jwt::encode_with(&key, &ocre::jwt::Claims::new("42", 60));
193/// assert_eq!(ocre::jwt::decode_with(&key, &token, ocre::now())?.sub, "42");
194/// # Ok::<(), ocre::Error>(())
195/// ```
196pub struct Key([u8; 32]);
197
198impl Key {
199 /// Derives the key from a `SECRET_KEY_BASE` value: HMAC-SHA256 of a fixed label.
200 ///
201 /// The label makes the JWT key differ from the session cookie key derived
202 /// from the same secret. Any length is accepted here; the Worker secret
203 /// read by [`encode`] and [`decode`] must be 64 characters or more.
204 ///
205 /// # Examples
206 ///
207 /// ```
208 /// use ocre::jwt::{Claims, Key, decode_with, encode_with};
209 ///
210 /// let key = Key::from_secret_key_base(&"x".repeat(64));
211 /// let token = encode_with(&key, &Claims::new("42", 60));
212 /// assert_eq!(decode_with(&key, &token, ocre::now())?.sub, "42");
213 /// // Another secret, another key: the token no longer verifies.
214 /// assert!(decode_with(&Key::from_secret_key_base(&"y".repeat(64)), &token, ocre::now()).is_err());
215 /// # Ok::<(), ocre::Error>(())
216 /// ```
217 pub fn from_secret_key_base(secret: &str) -> Self {
218 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("HMAC takes keys of any length");
219 mac.update(KEY_LABEL);
220 Self(mac.finalize().into_bytes().into())
221 }
222
223 /// The key for the Worker's `SECRET_KEY_BASE`, or an internal error
224 /// naming the fix when it is missing or too short.
225 pub(crate) fn from_secret(secret: Option<String>) -> Result<Self> {
226 checked_secret(secret).map(|secret| Self::from_secret_key_base(&secret)).map_err(Error::Internal)
227 }
228
229 fn mac(&self) -> Hmac<Sha256> {
230 Hmac::<Sha256>::new_from_slice(&self.0).expect("HMAC takes keys of any length")
231 }
232}
233
234/// Signs `claims` with `key` and returns the compact token `header.payload.signature`.
235///
236/// The header is always `{"alg":"HS256","typ":"JWT"}`; the three parts are
237/// URL-safe base64 without padding. Handlers use [`encode`], which takes the
238/// key from `SECRET_KEY_BASE`.
239///
240/// # Examples
241///
242/// ```
243/// use ocre::jwt::{Claims, Key, encode_with};
244///
245/// let key = Key::from_secret_key_base(&"x".repeat(64));
246/// let token = encode_with(&key, &Claims { sub: "42".to_owned(), iat: 0, exp: 60 });
247/// assert!(token.starts_with("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9."));
248/// assert_eq!(token.split('.').count(), 3);
249/// ```
250pub fn encode_with(key: &Key, claims: &Claims) -> String {
251 let payload = serde_json::to_vec(claims).expect("claims serialize");
252 let signing_input = format!("{}.{}", URL_SAFE_NO_PAD.encode(HEADER), URL_SAFE_NO_PAD.encode(payload));
253 let mut mac = key.mac();
254 mac.update(signing_input.as_bytes());
255 format!("{signing_input}.{}", URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()))
256}
257
258/// Verifies `token` with `key` at Unix time `now` (seconds) and returns its claims.
259///
260/// Checks, in order: the shape, the header's `alg` ([`ALGORITHM`] only), the
261/// signature (constant-time comparison), then the expiry (`exp > now`).
262/// Handlers use [`decode`], which takes the key from `SECRET_KEY_BASE` and
263/// the current time.
264///
265/// # Errors
266///
267/// [`Error::Unauthorized`] (401) for any failure: malformed token, other
268/// algorithm, bad signature, expired. The error does not say which check
269/// failed.
270///
271/// # Examples
272///
273/// ```
274/// use ocre::jwt::{Claims, Key, decode_with, encode_with};
275///
276/// let key = Key::from_secret_key_base(&"x".repeat(64));
277/// let token = encode_with(&key, &Claims { sub: "42".to_owned(), iat: 0, exp: 60 });
278/// assert_eq!(decode_with(&key, &token, 59)?.sub, "42");
279/// assert!(matches!(decode_with(&key, &token, 60), Err(ocre::Error::Unauthorized))); // expired
280/// assert!(decode_with(&key, "not.a.token", 0).is_err());
281/// # Ok::<(), ocre::Error>(())
282/// ```
283pub fn decode_with(key: &Key, token: &str, now: i64) -> Result<Claims> {
284 verify(key, token, now).ok_or(Error::Unauthorized)
285}
286
287fn verify(key: &Key, token: &str, now: i64) -> Option<Claims> {
288 #[derive(Deserialize)]
289 struct Header {
290 alg: String,
291 }
292 let (signing_input, signature) = token.rsplit_once('.')?;
293 let (header, payload) = signing_input.split_once('.')?;
294 // The algorithm is fixed: a token cannot pick `none` or another scheme.
295 let header: Header = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(header).ok()?).ok()?;
296 if header.alg != ALGORITHM {
297 return None;
298 }
299 let mut mac = key.mac();
300 mac.update(signing_input.as_bytes());
301 // `verify_slice` compares in constant time.
302 mac.verify_slice(&URL_SAFE_NO_PAD.decode(signature).ok()?).ok()?;
303 let claims: Claims = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload).ok()?).ok()?;
304 (claims.exp > now).then_some(claims)
305}
306
307#[cfg(test)]
308#[path = "../tests/jwt.rs"]
309mod tests;