santoku
santoku

santoku

A Lua framework for building applications end to end

Lua 5.1 from the build system to the server to the browser. Every example on this site runs in your browser. Edit one and run it.


santoku targets Lua 5.1, because 5.1 is the version its platforms actually share. LuaJIT implements 5.1 and OpenResty embeds LuaJIT, so server code, client code and the build harness all run the same language. Compiled to WebAssembly, the 5.1 core is smaller to embed, and its single number type sidesteps the integer and float split introduced in 5.3, which keeps numeric values crossing the JavaScript boundary simple. Support for newer versions is meant to come from compatibility shims layered over the 5.1 core rather than version branches in the tree; contributions and design ideas for that layer are welcome.

santoku-jwt

View on LuaRocks

JSON Web Tokens over a pluggable crypto backend: ES256 everywhere through santoku-monocypher, RS256 under OpenResty, claim checks, and cached remote JWKS

santoku-jwt signs, parses and verifies JSON Web Tokens, checks their claims, and builds and caches JWKS documents. The core is plain Lua and takes a crypto backend, the way santoku-http takes a socket backend: require("santoku.jwt")(backend). santoku.jwt.monocypher signs and verifies ES256 natively, in WebAssembly and under OpenResty, so the examples on this page run in your browser. santoku-resty's santoku.resty.jwt.openssl signs and verifies ES256 and RS256 inside an nginx worker. Private keys are JWKs, so a key made by one backend works in the other.

Keys, signing and verifying

keypair(alg) returns a private JWK and its public JWK; alg defaults to ES256. The public JWK carries kty, crv, x, y, alg, use sig, and the RFC 7638 thumbprint as kid. sign(claims, private) returns a compact token whose header names the private key's alg and kid. jwks(jwk, ...) wraps public JWKs into the { keys } document you serve, and keyset(doc) turns one into the kid lookup verify takes. verify(token, get_jwk[, opts]) returns true and { header, payload }, or false and a reason. Keep the private JWK out of the repo: its d member is the secret.

local utc = require("santoku.utc") local jwt = require("santoku.jwt")(require("santoku.jwt.monocypher")) local private, jwk = jwt.keypair() print("kid:", jwk.kid, "has d:", jwk.d ~= nil) local token = jwt.sign({ sub = "u1", aud = "app", exp = utc.time() + 3600 }, private) local ok, data = jwt.verify(token, jwt.keyset(jwt.jwks(jwk))) print("verified:", ok, "sub:", data.payload.sub, "alg:", data.header.alg) return ok

Claims and rejections

Without opts.claims, verify requires exp to be a number after the current time. claims({ aud, iss, leeway, time }) builds a claim spec that also checks aud (a string, or an array containing it), iss, and nbf when present, allowing leeway seconds of clock skew; pass it as opts.claims. opts.time replaces the backend's clock, and opts.algs narrows the accepted algorithms. A failed check returns false and its message: expired, not yet valid, wrong audience, wrong issuer. Before the claims, verify rejects with Malformed token, Unsupported alg (including none and every HMAC alg), No kid, No jwk for kid, Unsupported key type, Key alg mismatch, or Bad signature.

local utc = require("santoku.utc") local jwt = require("santoku.jwt")(require("santoku.jwt.monocypher")) local private, jwk = jwt.keypair() local keys = jwt.keyset(jwt.jwks(jwk)) local now = utc.time() local spec = jwt.claims({ aud = "app", iss = "https://auth.example", leeway = 30 }) local good = jwt.sign({ aud = "app", iss = "https://auth.example", exp = now + 60 }, private) print("good:", jwt.verify(good, keys, { claims = spec })) print("other aud:", jwt.verify(jwt.sign({ aud = "x", iss = "https://auth.example", exp = now + 60 }, private), keys, { claims = spec })) print("expired:", jwt.verify(jwt.sign({ exp = now - 1 }, private), keys)) print("rs256 only:", jwt.verify(good, keys, { algs = { "RS256" } })) return select(2, jwt.verify("not.a.token", keys))

parse and the RFC 7515 example

parse(token) splits a token without verifying it and returns header, payload, signing_input and the raw signature, or nil and Malformed token. A backend's verify(alg, jwk, signing_input, signature) checks one signature directly. The token and key below are RFC 7515's ES256 example (appendix A.3); its header has no kid, so verify itself would answer No kid.

local backend = require("santoku.jwt.monocypher") local jwt = require("santoku.jwt")(backend) local obj = jwt.parse("eyJhbGciOiJFUzI1NiJ9" .. ".eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ" .. ".DtEhU3ljbEg8L38VWAfUAqOyKAM6-Xx-F4GawxaepmXFCgfTjDxw5djxLa8ISlSApmWQxfKTUJqPP3-Kg6NU1Q") print("iss:", obj.payload.iss, "exp:", obj.payload.exp) return backend.verify("ES256", { kty = "EC", crv = "P-256", x = "f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU", y = "x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0", }, obj.signing_input, obj.signature)

A remote JWKS

remote_jwks(url, opts) returns a kid lookup backed by a fetched JWKS. Pass opts.client, any santoku-http client, or opts.fetch(url) returning status and body. It refetches after opts.ttl seconds (3600) and on an unknown kid at most once per opts.min_interval seconds (60), and keeps the last good set when a fetch fails or returns a document without a keys array. Callers that arrive while a fetch is in flight wait on the backend's lock for up to opts.wait seconds (10). Create the lookup once and reuse it, or every call starts with an empty cache.

local http = require("santoku.http")(require("santoku.socket")) local jwt = require("santoku.jwt")(require("santoku.jwt.monocypher")) local certs = jwt.remote_jwks("https://auth.example/.well-known/jwks.json", { client = http }) local ok, data = jwt.verify(token, certs, { claims = jwt.claims({ aud = "app" }) }) print(ok, ok and data.payload.sub or data)
Illustrative: runs outside the browser.

Backends

A backend is a table: algs lists what it handles, and the core refuses any other alg before calling it. sign(alg, private, data) returns the raw signature (for ES256, the 64-byte r and s that JWS uses). verify(alg, jwk, data, sig) returns a boolean. keypair(alg, opts) returns a private JWK. now() returns epoch seconds. lock() returns { wait(seconds), release() } for remote_jwks; under OpenResty it is an ngx.semaphore so concurrent requests share one fetch, and elsewhere a no-op. santoku.resty.jwt.openssl implements ES256 and RS256 on the OpenSSL linked into nginx; its keypair("RS256", { bits }) defaults to 2048 bits.

local jwt = require("santoku.jwt")(require("santoku.resty.jwt.openssl")) local private, jwk = jwt.keypair("RS256") local token = jwt.sign({ sub = "u1", exp = ngx.time() + 3600 }, private) ngx.say(jwt.verify(token, jwt.keyset(jwt.jwks(jwk))))
Illustrative: runs outside the browser.