Security

JWT

JSON Web Tokens: sign and verify HS256 tokens for an API, check a token's audience and issuer, verify RS256, ES256 and EdDSA tokens against an identity provider's JWKS, peek at claims.

Version 2.0.0 · 6 functions · 2 crates

#auth#tokens#api#sso

Functions

What use jwt.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use jwt_sign, jwt_verify from jwt (Quillo 0.6).

jwt_sign(claims: any, secret: str, expires_in: int) → stror an error

A token for claims, signed with HMAC-SHA256: iat is added, and exp when expires_in (seconds) is above 0.

jwt_verify(token: str, secret: str) → anyor an error

The claims of an HS256 token signed with secret, or an error: a bad signature, an expired token, one with no exp, another algorithm.

jwt_check(token: str, secret: str, options: any) → anyor an error

jwt_verify, with options: audience (aud must name it), issuer (iss must be it), require (the claims it must have, ["exp"] unless given) and leeway (seconds).

jwt_verify_rs256(token: str, public_key_pem: str, audience: str) → anyor an error

The claims of an RS256 token, checked with the issuer's public key (PEM) and its audience (aud, empty for none).

jwt_verify_jwks(token: str, options: any) → anyor an error

The claims of an access token from an identity provider, checked against its JWKS: options has jwks_url, and audience, issuer, require, leeway as jwt_check takes them, and algorithms (RS256, ES256 and EdDSA by default; never HS256 or none). A kid the keys do not have fetches them again.

jwt_peek(token: str) → anyor an error

A token's claims without checking anything: to show or log them, never to trust them.

Get it

$ quillo install jwt               # jwt.qwer into this folder$ quillo install jwt --example     # and its example, jwt.qwe

Or with curl:

$ curl -O https://quillolang.org/plugins/files/jwt.qwer$ curl -O https://quillolang.org/plugins/files/jwt.qwe

Then, in your program, all of it or only what you call:

main.qwe
use jwt.qweruse jwt_sign, jwt_verify from jwt      // Quillo 0.6: only these

It uses these crates, which cargo fetches the first time the plugin is built:

[dependencies]
jsonwebtoken = "9"ureq = "2"

Example

jwt.qwe
// An API that hands out tokens and checks them.////   quillo run jwt.qwe      then open http://localhost:8080/token//   curl -H "authorization: Bearer <token>" localhost:8080/meuse "jwt.qwer"let SECRET = env("JWT_SECRET") ?? "change me: at least 32 random characters"route GET /token = { token: jwt_sign({ sub: "user-7", role: "admin" }, SECRET, 3600)? }route GET /me {    let token = replace(request.headers.authorization ?? "", "Bearer ", "")    let claims = jwt_verify(token, SECRET)    if is_err(claims) { reply 401 { error: claims.error } }    { user: claims.sub, role: claims.role }}// A token for another service, as an identity provider issues one: its audience and issuer// checked, and one with no `exp` refused (it would be good for ever).route GET /audience {    let t = jwt_sign({ sub: "service:shop", aud: "orders", iss: "idp" }, SECRET, 60)?    let forever = jwt_sign({ sub: "service:shop" }, SECRET, 0)?    {        orders: jwt_check(t, SECRET, { audience: "orders", issuer: "idp" })?.sub,        not_billing: error(jwt_check(t, SECRET, { audience: "billing" })),        no_exp: error(jwt_verify(forever, SECRET)),        plain: jwt_verify(t, SECRET)?.aud,    }}route GET /check {    let t = jwt_sign({ sub: "user-7" }, SECRET, 60)?    { verified: jwt_verify(t, SECRET)?.sub, wrong_secret: is_err(jwt_verify(t, "another secret")), peek: jwt_peek(t)?.sub }}
$ quillo run jwt.qwe$ curl 'localhost:8080/token'$ curl 'localhost:8080/check'$ curl 'localhost:8080/audience'

Source

jwt.qwer
//! JSON Web Tokens: sign and verify HS256 tokens, check a token's audience and issuer, and verify RS256, ES256 and EdDSA tokens against a key or an identity provider's JWKS.//! version: 2.0.0//!//! [dependencies]//! jsonwebtoken = "9"//! ureq = "2"use jsonwebtoken::{decode, decode_header, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};use serde_json::Value;use std::collections::HashMap;use std::sync::Mutex;use std::time::{Duration, Instant};fn now() -> i64 {    std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs() as i64)}/// The checks every verifier makes: the signature, `exp` (which a token must have: one without/// is good for ever), `nbf`, and 30 s of leeway for clocks that differ. `aud` is checked only when/// one is asked for, so a token that names one is not refused for it.fn validation(alg: Algorithm) -> Validation {    let mut v = Validation::new(alg);    v.required_spec_claims = ["exp".to_string()].into_iter().collect();    v.validate_nbf = true;    v.validate_aud = false;    v.leeway = 30;    v}/// `options` of `jwt_check` and `jwt_verify_jwks`: `audience`, `issuer`, `require` (claims a token/// must have, `exp` by default; `[]` for none) and `leeway`.fn apply(v: &mut Validation, options: &Value) -> Result<(), String> {    if let Value::Object(o) = options {        for k in o.keys() {            if !["audience", "issuer", "require", "leeway", "jwks_url", "algorithms"].contains(&k.as_str()) {                return Err(format!("no option `{}` (audience, issuer, require, leeway)", k));            }        }    }    if let Some(a) = options["audience"].as_str() {        v.set_audience(&[a]);        v.validate_aud = true;    }    if let Some(i) = options["issuer"].as_str() {        v.set_issuer(&[i]);    }    if let Some(r) = options["require"].as_array() {        v.required_spec_claims = r.iter().filter_map(|c| c.as_str().map(str::to_string)).collect();    }    if let Some(l) = options["leeway"].as_u64() {        v.leeway = l;    }    Ok(())}/// A token for `claims`, signed with HMAC-SHA256: `iat` is added, and `exp` when `expires_in` (seconds) is above 0.pub fn jwt_sign(claims: serde_json::Value, secret: &str, expires_in: i64) -> Result<String, String> {    let Value::Object(mut c) = claims else { return Err("the claims are a map".into()) };    c.insert("iat".into(), now().into());    if expires_in > 0 {        c.insert("exp".into(), (now() + expires_in).into());    }    encode(&Header::default(), &Value::Object(c), &EncodingKey::from_secret(secret.as_bytes())).map_err(|e| e.to_string())}/// The claims of an HS256 token signed with `secret`, or an error: a bad signature, an expired token, one with no `exp`, another algorithm.pub fn jwt_verify(token: &str, secret: &str) -> Result<Value, String> {    decode::<Value>(token, &DecodingKey::from_secret(secret.as_bytes()), &validation(Algorithm::HS256)).map(|d| d.claims).map_err(|e| e.to_string())}/// `jwt_verify`, with `options`: `audience` (`aud` must name it), `issuer` (`iss` must be it), `require` (the claims it must have, `["exp"]` unless given) and `leeway` (seconds).pub fn jwt_check(token: &str, secret: &str, options: serde_json::Value) -> Result<Value, String> {    let mut v = validation(Algorithm::HS256);    apply(&mut v, &options)?;    decode::<Value>(token, &DecodingKey::from_secret(secret.as_bytes()), &v).map(|d| d.claims).map_err(|e| e.to_string())}/// The claims of an RS256 token, checked with the issuer's public key (PEM) and its audience (`aud`, empty for none).pub fn jwt_verify_rs256(token: &str, public_key_pem: &str, audience: &str) -> Result<Value, String> {    let key = DecodingKey::from_rsa_pem(public_key_pem.as_bytes()).map_err(|e| e.to_string())?;    let mut v = validation(Algorithm::RS256);    if !audience.is_empty() {        v.set_audience(&[audience]);        v.validate_aud = true;    }    decode::<Value>(token, &key, &v).map(|d| d.claims).map_err(|e| e.to_string())}/// Each JWKS fetched: its keys, when, and when a token with a new `kid` last fetched it again.static JWKS: Mutex<Option<HashMap<String, (jsonwebtoken::jwk::JwkSet, Instant, Option<Instant>)>>> = Mutex::new(None);fn jwks(url: &str, again: bool) -> Result<jsonwebtoken::jwk::JwkSet, String> {    let held = JWKS.lock().unwrap().as_ref().and_then(|c| c.get(url).cloned());    if let Some((set, at, forced)) = &held {        // A `kid` nobody knows fetches the keys again, at most every 30 s, so a forged token cannot make us flood the provider.        let keep = if again { forced.is_some_and(|f| f.elapsed() < Duration::from_secs(30)) } else { at.elapsed() < Duration::from_secs(600) };        if keep {            return Ok(set.clone());        }    }    let local = ["http://localhost", "http://127.0.0.1", "http://[::1]"].iter().any(|p| url.starts_with(p));    if !url.starts_with("https://") && !local {        return Err(format!("the JWKS `{}` is not https", url));    }    let text = ureq::AgentBuilder::new().timeout(Duration::from_secs(10)).build().get(url).call().map_err(|e| format!("{}: {}", url, e))?.into_string().map_err(|e| e.to_string())?;    let set: jsonwebtoken::jwk::JwkSet = serde_json::from_str(&text).map_err(|e| format!("{} is not a JWKS: {}", url, e))?;    let forced = if again { Some(Instant::now()) } else { held.and_then(|h| h.2) };    JWKS.lock().unwrap().get_or_insert_with(HashMap::new).insert(url.to_string(), (set.clone(), Instant::now(), forced));    Ok(set)}/// The claims of an access token from an identity provider, checked against its JWKS: `options` has `jwks_url`, and `audience`, `issuer`, `require`, `leeway` as `jwt_check` takes them, and `algorithms` (RS256, ES256 and EdDSA by default; never HS256 or `none`). A `kid` the keys do not have fetches them again.pub fn jwt_verify_jwks(token: &str, options: serde_json::Value) -> Result<Value, String> {    let url = options["jwks_url"].as_str().ok_or("options: jwks_url, the identity provider's keys")?;    let header = decode_header(token).map_err(|e| e.to_string())?;    let allowed: Vec<String> = match options["algorithms"].as_array() {        Some(l) => l.iter().filter_map(|a| a.as_str().map(str::to_string)).collect(),        None => vec!["RS256".into(), "ES256".into(), "EdDSA".into()],    };    let name = format!("{:?}", header.alg);    if name.starts_with("HS") || !allowed.iter().any(|a| *a == name) {        return Err(format!("algorithm `{}` is refused: {} are taken", name, allowed.join(", ")));    }    let kid = header.kid.clone();    let find = |set: &jsonwebtoken::jwk::JwkSet| match &kid {        Some(k) => set.find(k).cloned(),        None if set.keys.len() == 1 => set.keys.first().cloned(),        None => None,    };    let key = match find(&jwks(url, false)?) {        Some(k) => k,        None => find(&jwks(url, true)?).ok_or_else(|| format!("no key in {} for kid {}", url, kid.as_deref().unwrap_or("(none)")))?,    };    let mut v = validation(header.alg);    apply(&mut v, &options)?;    let key = DecodingKey::from_jwk(&key).map_err(|e| e.to_string())?;    decode::<Value>(token, &key, &v).map(|d| d.claims).map_err(|e| e.to_string())}/// A token's claims without checking anything: to show or log them, never to trust them.pub fn jwt_peek(token: &str) -> Result<Value, String> {    let mut v = Validation::new(Algorithm::HS256);    v.insecure_disable_signature_validation();    v.required_spec_claims.clear();    v.validate_exp = false;    v.validate_aud = false;    v.algorithms = vec![Algorithm::HS256, Algorithm::HS384, Algorithm::HS512, Algorithm::RS256, Algorithm::RS384, Algorithm::RS512, Algorithm::ES256, Algorithm::ES384, Algorithm::PS256, Algorithm::EdDSA];    decode::<Value>(token, &DecodingKey::from_secret(&[]), &v).map(|d| d.claims).map_err(|e| e.to_string())}
Thread
78Useful? Like it, or add to its thread below.

Thread 0 posts

On JWT: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.