-- 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