Files
2026-07-13 12:32:21 +08:00

472 lines
13 KiB
Lua

-- JWT verification module
-- Adapted version of x25/luajwt for Kong. It provides various improvements and
-- an OOP architecture allowing the JWT to be parsed and verified separately,
-- avoiding multiple parsings.
--
-- @see https://github.com/x25/luajwt
local json = require "cjson"
local b64 = require "ngx.base64"
local buffer = require "string.buffer"
local openssl_digest = require "resty.openssl.digest"
local openssl_mac = require "resty.openssl.mac"
local openssl_pkey = require "resty.openssl.pkey"
local rep = string.rep
local sub = string.sub
local find = string.find
local type = type
local time = ngx.time
local pairs = pairs
local error = error
local pcall = pcall
local insert = table.insert
local unpack = unpack
local assert = assert
local tostring = tostring
local setmetatable = setmetatable
local getmetatable = getmetatable
local encode_base64url = b64.encode_base64url
local decode_base64url = b64.decode_base64url
--- Supported algorithms for signing tokens.
local alg_sign = {
HS256 = function(data, key) return openssl_mac.new(key, "HMAC", nil, "sha256"):final(data) end,
HS384 = function(data, key) return openssl_mac.new(key, "HMAC", nil, "sha384"):final(data) end,
HS512 = function(data, key) return openssl_mac.new(key, "HMAC", nil, "sha512"):final(data) end,
RS256 = function(data, key)
local digest = openssl_digest.new("sha256")
assert(digest:update(data))
return assert(openssl_pkey.new(key):sign(digest))
end,
RS384 = function(data, key)
local digest = openssl_digest.new("sha384")
assert(digest:update(data))
return assert(openssl_pkey.new(key):sign(digest))
end,
RS512 = function(data, key)
local digest = openssl_digest.new("sha512")
assert(digest:update(data))
return assert(openssl_pkey.new(key):sign(digest))
end,
ES256 = function(data, key)
local pkey = openssl_pkey.new(key)
local sig = assert(pkey:sign(data, "sha256", nil, { ecdsa_use_raw = true }))
if not sig then
return nil
end
return sig
end,
ES384 = function(data, key)
local pkey = openssl_pkey.new(key)
local sig = assert(pkey:sign(data, "sha384", nil, { ecdsa_use_raw = true }))
if not sig then
return nil
end
return sig
end,
ES512 = function(data, key)
local pkey = openssl_pkey.new(key)
local sig = assert(pkey:sign(data, "sha512", nil, { ecdsa_use_raw = true }))
if not sig then
return nil
end
return sig
end,
PS256 = function(data, key)
local pkey = openssl_pkey.new(key)
local sig = assert(pkey:sign(data, "sha256", openssl_pkey.PADDINGS.RSA_PKCS1_PSS_PADDING))
if not sig then
return nil
end
return sig
end,
PS384 = function(data, key)
local pkey = openssl_pkey.new(key)
local sig = assert(pkey:sign(data, "sha384", openssl_pkey.PADDINGS.RSA_PKCS1_PSS_PADDING))
if not sig then
return nil
end
return sig
end,
PS512 = function(data, key)
local pkey = openssl_pkey.new(key)
local sig = assert(pkey:sign(data, "sha512", openssl_pkey.PADDINGS.RSA_PKCS1_PSS_PADDING))
if not sig then
return nil
end
return sig
end,
EdDSA = function(data, key)
local pkey = assert(openssl_pkey.new(key))
return assert(pkey:sign(data))
end
}
--- Supported algorithms for verifying tokens.
local alg_verify = {
HS256 = function(data, signature, key) return signature == alg_sign.HS256(data, key) end,
HS384 = function(data, signature, key) return signature == alg_sign.HS384(data, key) end,
HS512 = function(data, signature, key) return signature == alg_sign.HS512(data, key) end,
RS256 = function(data, signature, key)
local pkey, _ = openssl_pkey.new(key)
assert(pkey, "Consumer Public Key is Invalid")
return pkey:verify(signature, data, "sha256")
end,
RS384 = function(data, signature, key)
local pkey, _ = openssl_pkey.new(key)
assert(pkey, "Consumer Public Key is Invalid")
return pkey:verify(signature, data, "sha384")
end,
RS512 = function(data, signature, key)
local pkey, _ = openssl_pkey.new(key)
assert(pkey, "Consumer Public Key is Invalid")
return pkey:verify(signature, data, "sha512")
end,
-- https://www.rfc-editor.org/rfc/rfc7518#section-3.4
ES256 = function(data, signature, key)
local pkey, _ = openssl_pkey.new(key)
assert(pkey, "Consumer Public Key is Invalid")
-- The ECDSA P-256 SHA-256 digital signature for a JWS is validated as
-- follows:
-- 1. The JWS Signature value MUST be a 64-octet sequence. If it is
-- not a 64-octet sequence, the validation has failed.
assert(#signature == 64, "Signature must be 64 bytes.")
return pkey:verify(signature, data, "sha256", nil, { ecdsa_use_raw = true })
end,
ES384 = function(data, signature, key)
-- Signing and validation with the ECDSA P-384 SHA-384 and ECDSA P-521
-- SHA-512 algorithms is performed identically to the procedure for
-- ECDSA P-256 SHA-256 -- just using the corresponding hash algorithms
-- with correspondingly larger result values. For ECDSA P-384 SHA-384,
-- R and S will be 384 bits each, resulting in a 96-octet sequence. For
-- ECDSA P-521 SHA-512, R and S will be 521 bits each, resulting in a
-- 132-octet sequence.
local pkey, _ = openssl_pkey.new(key)
assert(pkey, "Consumer Public Key is Invalid")
assert(#signature == 96, "Signature must be 96 bytes.")
return pkey:verify(signature, data, "sha384", nil, { ecdsa_use_raw = true })
end,
ES512 = function(data, signature, key)
-- Signing and validation with the ECDSA P-384 SHA-384 and ECDSA P-521
-- SHA-512 algorithms is performed identically to the procedure for
-- ECDSA P-256 SHA-256 -- just using the corresponding hash algorithms
-- with correspondingly larger result values. For ECDSA P-384 SHA-384,
-- R and S will be 384 bits each, resulting in a 96-octet sequence. For
-- ECDSA P-521 SHA-512, R and S will be 521 bits each, resulting in a
-- 132-octet sequence.
local pkey, _ = openssl_pkey.new(key)
assert(pkey, "Consumer Public Key is Invalid")
assert(#signature == 132, "Signature must be 132 bytes.")
return pkey:verify(signature, data, "sha512", nil, { ecdsa_use_raw = true })
end,
PS256 = function(data, signature, key)
local pkey, _ = openssl_pkey.new(key)
assert(pkey, "Consumer Public Key is Invalid")
assert(#signature == 256, "Signature must be 256 bytes")
return pkey:verify(signature, data, "sha256", openssl_pkey.PADDINGS.RSA_PKCS1_PSS_PADDING)
end,
PS384 = function(data, signature, key)
local pkey, _ = openssl_pkey.new(key)
assert(pkey, "Consumer Public Key is Invalid")
assert(#signature == 256, "Signature must be 256 bytes")
return pkey:verify(signature, data, "sha384", openssl_pkey.PADDINGS.RSA_PKCS1_PSS_PADDING)
end,
PS512 = function(data, signature, key)
local pkey, _ = openssl_pkey.new(key)
assert(pkey, "Consumer Public Key is Invalid")
assert(#signature == 256, "Signature must be 256 bytes")
return pkey:verify(signature, data, "sha512", openssl_pkey.PADDINGS.RSA_PKCS1_PSS_PADDING)
end,
EdDSA = function(data, signature, key)
-- Support of EdDSA alg typ according to RFC 8037
-- https://www.rfc-editor.org/rfc/rfc8037
local pkey, _ = openssl_pkey.new(key)
assert(pkey, "Consumer Public Key is Invalid")
return pkey:verify(signature, data)
end
}
--- base 64 encoding
-- @param input String to base64 encode
-- @return Base64 encoded string
local function base64_encode(input)
local result = encode_base64url(input)
return result
end
--- base 64 decode
-- @param input String to base64 decode
-- @return Base64 decoded string
local function base64_decode(input)
local remainder = #input % 4
if remainder > 0 then
local padlen = 4 - remainder
input = input .. rep("=", padlen)
end
return decode_base64url(input)
end
--- Tokenize a string by delimiter
-- Used to separate the header, claims and signature part of a JWT
-- @param str String to tokenize
-- @param div Delimiter
-- @param len Number of parts to retrieve
-- @return A table of strings
local function tokenize(str, div, len)
local result, idx, pos = {}, 1, 0
local iter = function()
return find(str, div, pos, true)
end
for st, sp in iter do
result[idx] = sub(str, pos, st - 1)
idx = idx + 1
pos = sp + 1
len = len - 1
if len <= 1 then
break
end
end
result[idx] = sub(str, pos)
return result
end
--- Parse a JWT
-- Parse a JWT and validate header values.
-- @param token JWT to parse
-- @return A table containing base64 and decoded headers, claims and signature
local function decode_token(token)
-- Get b64 parts
local header_64, claims_64, signature_64 = unpack(tokenize(token, ".", 3))
-- Decode JSON
local ok, header, claims, signature = pcall(function()
return json.decode(base64_decode(header_64)),
json.decode(base64_decode(claims_64)),
base64_decode(signature_64)
end)
if not ok then
return nil, "invalid JSON"
end
if not header.alg or type(header.alg) ~= "string" or not alg_verify[header.alg] then
return nil, "invalid alg"
end
if not claims then
return nil, "invalid claims"
end
if not signature then
return nil, "invalid signature"
end
return {
token = token,
header_64 = header_64,
claims_64 = claims_64,
signature_64 = signature_64,
header = header,
claims = claims,
signature = signature
}
end
-- For test purposes
local function encode_token(data, key, alg, header)
if type(data) ~= "table" then
error("Argument #1 must be table", 2)
end
if type(key) ~= "string" then
error("Argument #2 must be string", 2)
end
if header and type(header) ~= "table" then
error("Argument #4 must be a table", 2)
end
alg = alg or "HS256"
if not alg_sign[alg] then
error("Algorithm not supported", 2)
end
local header = header or { typ = "JWT", alg = alg }
local buf = buffer.new()
buf:put(base64_encode(json.encode(header))):put(".")
:put(base64_encode(json.encode(data)))
local signature = alg_sign[alg](buf:tostring(), key)
buf:put(".")
:put(base64_encode(signature))
return buf:get()
end
local err_list_mt = {}
local function add_error(errors, k, v)
if not errors then
errors = {}
end
if errors and errors[k] then
if getmetatable(errors[k]) ~= err_list_mt then
errors[k] = setmetatable({errors[k]}, err_list_mt)
end
insert(errors[k], v)
else
errors[k] = v
end
return errors
end
--[[
JWT public interface
]]--
local _M = {}
_M.__index = _M
--- Instantiate a JWT parser
-- Parse a JWT and instantiate a JWT parser for further operations
-- Return errors instead of an instance if any encountered
-- @param token JWT to parse
-- @return JWT parser
-- @return error if any
function _M:new(token)
if type(token) ~= "string" then
error("Token must be a string, got " .. tostring(token), 2)
end
local token, err = decode_token(token)
if err then
return nil, err
end
return setmetatable(token, _M)
end
--- Verify a JWT signature
-- Verify the current JWT signature against a given key
-- @param key Key against which to verify the signature
-- @return A boolean indicating if the signature if verified or not
function _M:verify_signature(key)
return alg_verify[self.header.alg](self.header_64 .. "." .. self.claims_64, self.signature, key)
end
function _M:base64_decode(input)
return base64_decode(input)
end
--- Registered claims according to RFC 7519 Section 4.1
local registered_claims = {
nbf = {
type = "number",
check = function(nbf)
if nbf > time() then
return "token not valid yet"
end
end
},
exp = {
type = "number",
check = function(exp)
if exp <= time() then
return "token expired"
end
end
}
}
--- Verify registered claims (according to RFC 7519 Section 4.1)
-- Claims are verified by type and a check.
-- @param claims_to_verify A list of claims to verify.
-- @return A boolean indicating true if no errors zere found
-- @return A list of errors
function _M:verify_registered_claims(claims_to_verify)
if not claims_to_verify then
claims_to_verify = {}
end
local errors
local claim
local claim_rules
for _, claim_name in pairs(claims_to_verify) do
claim = self.claims[claim_name]
claim_rules = registered_claims[claim_name]
if type(claim) ~= claim_rules.type then
errors = add_error(errors, claim_name, "must be a " .. claim_rules.type)
else
local check_err = claim_rules.check(claim)
if check_err then
errors = add_error(errors, claim_name, check_err)
end
end
end
return errors == nil, errors
end
--- Check that the maximum allowed expiration is not reached
-- @param maximum_expiration of the claim
-- @return A Boolean indicating true if the claim has reached the maximum
-- allowed expiration time
-- @return error if any
function _M:check_maximum_expiration(maximum_expiration)
if maximum_expiration <= 0 then
return true
end
local exp = self.claims.exp
if exp == nil or exp - time() > maximum_expiration then
return false, { exp = "exceeds maximum allowed expiration" }
end
return true
end
_M.encode = encode_token
return _M