Skip to main content

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;