lua-resty-jwt-verification

JWT verification library for OpenResty.

$ opm get anvouk/lua-resty-jwt-verification

JWT verification for openresty

JWT verification library for OpenResty.

Description

JWT verification library for OpenResty.

The project's goal is to be a modern and slimmer replacement for lua-resty-jwt with built-in support for JWKS.

This project does not provide JWT manipulation or creation features: you can only verify/decrypt tokens.

Status

Ready for testing: looking for more people to take it for a spin and provide feedback.

The APIs should be stable; I'll provide a migration document in case breaking changes happen in future releases.

Library non-goals

  • JWT creation/modification

  • Feature complete for the sake of RFCs completeness.

  • Senseless and unsafe RFCs features (e.g. alg none) won't be implemented.

Differences from lua-resty-jwt

Main differences are:

  • No JWT manipulation of any kind (you can only decrypt/verify them)

  • Simpler internal structure reliant on more recent lua-resty-openssl and OpenSSL versions.

  • Supports different JWE algorithms (see tables above).

  • Automatic JWT verification given JWKS HTTP endpoint.

If any of the points above are a problem, or you need compatibility with older OpenResty versions, I recommend sticking with lua-resty-jwt.

Supported features

  • JWS verification: with symmetric or asymmetric keys.

  • JWE decryption: with symmetric or asymmetric keys.

  • Asymmetric keys format supported:

    • PEM

    • DER

    • JWK

  • JWT claims validation.

  • Automatic JWKS fetching and JWT validation.

    • optional caching strategies.

JWS Verification

| Claims | Implemented | |:--------:|:------------------:| | alg | :white_check_mark: | | jku | :x: | | jwk | :x: | | kid | :white_check_mark: | | x5u | :x: | | x5c | :x: | | x5t | :x: | | x5t#S256 | :x: | | typ | :white_check_mark: | | cty | :x: | | crit | :white_check_mark: |

| Alg | Implemented | |:-----:|:------------------:| | HS256 | :white_check_mark: | | HS384 | :white_check_mark: | | HS512 | :white_check_mark: | | RS256 | :white_check_mark: | | RS384 | :white_check_mark: | | RS512 | :white_check_mark: | | ES256 | :white_check_mark: | | ES384 | :white_check_mark: | | ES512 | :white_check_mark: | | PS256 | :white_check_mark: | | PS384 | :white_check_mark: | | PS512 | :white_check_mark: | | none | :x: |

JWE Decryption

| Claims | Implemented | |:--------:|:------------------:| | alg | :white_check_mark: | | enc | :white_check_mark: | | zip | :x: | | jku | :x: | | jwk | :x: | | kid | :white_check_mark: | | x5u | :x: | | x5c | :x: | | x5t | :x: | | x5t#S256 | :x: | | typ | :white_check_mark: | | cty | :x: | | crit | :white_check_mark: |

| Alg | Implemented | Requirements | |:------------------:|:-------------------:|:-------------:| | RSA1_5 | :x: | | | RSA-OAEP | :x: | | | RSA-OAEP-256 | :x: | | | A128KW | :white_check_mark: | *OpenSSL 3.0+ | | A192KW | :white_check_mark: | *OpenSSL 3.0+ | | A256KW | :white_check_mark: | *OpenSSL 3.0+ | | dir | :white_check_mark: | | | ECDH-ES | :x: | | | A128GCMKW | :x: | | | A192GCMKW | :x: | | | A256GCMKW | :x: | | | PBES2-HS256+A128KW | :x: | | | PBES2-HS384+A192KW | :x: | | | PBES2-HS512+A256KW | :x: | |

> *The first official release of OpenResty including OpenSSL 3.0+ is OpenResty 1.27.1.1 > which shipped with OpenSSL 3.0.15 (Yes, the godawful slow OpenSSL 3.0 series...). > > So, please, go with OpenResty 1.27.1.2 as a minimum, which shipped > with OpenSSL 3.4.1.

| Enc | Implemented | |:-------------:|:------------------:| | A128CBC-HS256 | :white_check_mark: | | A192CBC-HS384 | :white_check_mark: | | A256CBC-HS512 | :white_check_mark: | | A128GCM | :white_check_mark: | | A192GCM | :white_check_mark: | | A256GCM | :white_check_mark: |

JWKS retrieval cache strategies

| Cache Strategy | Implemented | |:-------------------:|:------------------:| | no cache | :white_check_mark: | | local (shared_dict) | :white_check_mark: | | redis | :x: |

Planned missing features

This is a list of missing features I'd like to implement when given enough time:

  • Implement JWE validation with at least 1 asymmetric alg.

  • Nested JWT (i.e. JWT in JWE).

  • JWKS Redis cache strategy.

  • Automatic JWKS validation for JWE.

Dependencies

    luarocks install lua-cjson
    luarocks install lua-resty-openssl
    luarocks install lua-resty-http

JWT verification usage

jwt.decode_header_unsafe

syntax: header, err = jwt.decode_header_unsafe(token)

Read a jwt header and convert it to a lua table.

> Important: this method does not validate JWT signature! Only use if you need to inspect the token's header > without having to perform the full validation.

    local jwt = require("resty.jwt-verification")
    
    local token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIiLCJpYXQiOjE3MTY2NDkwNzJ9._MwFdsBPSyci9iARpoAaulReGcn1q7mKiPZjR2JDvdY"
    local header, err = jwt.decode_header_unsafe(token)
    if not header then
        return nil, "malformed jwt: " .. err
    end
    print("alg: " .. header.alg) -- alg: HS256

jwt.verify

syntax: decoded_token, err = jwt.verify(token, secret, options?)

Validate a JWS token and convert it to a lua table.

The optional parameter options can be passed to configure the token validator. Valid fields are:

  • valid_signing_algorithms (dict<string, string> | nil): a dict containing allowed alg claims used to validate the JWT.

  • typ (string | nil): if non-null, ensure JWT claim typ matches the passed value.

  • issuer (string | nil): if non-null, ensure JWT claim iss matches the passed value.

  • audiences (string | table | nil): if non-null, ensure JWT claim aud matches one of the supplied values.

  • subject (string | nil): if non-null, ensure JWT claim sub matches the passed value.

  • jwtid (string | nil): if non-null, ensure JWT claim jti matches the passed value.

  • ignore_not_before (bool): If true, the JWT claim nbf will be ignored.

  • ignore_expiration (bool): If true, the JWT claim exp will be ignored.

  • current_unix_timestamp (datetime | nil): the JWT nbf and exp claims will be validated against this timestamp. If null, will use the current datetime supplied by ngx.time().

  • timestamp_skew_seconds (int):

Default values for options fields:

    local verify_default_options = {
        valid_signing_algorithms = {
            ["HS256"]="HS256", ["HS384"]="HS384", ["HS512"]="HS512",
            ["RS256"]="RS256", ["RS384"]="RS384", ["RS512"]="RS512",
            ["ES256"]="ES256", ["ES384"]="ES384", ["ES512"]="ES512",
            ["PS256"]="PS256", ["PS384"]="PS384", ["PS512"]="PS512",
        },
        typ = nil,
        issuer = nil,
        audiences = nil,
        subject = nil,
        jwtid = nil,
        ignore_not_before = false,
        ignore_expiration = false,
        current_unix_timestamp = nil,
        timestamp_skew_seconds = 1,
    }

Minimal example with symmetric keys:

    local jwt = require("resty.jwt-verification")
    
    local token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIiLCJpYXQiOjE3MTY2NTUwMTV9.NuEhIzUuufJgPZ8CmCPnD4Vrw7EnTyWD8bGtYCwuDZ0"
    local decoded_token, err = jwt.verify(token, "superSecretKey")
    if not decoded_token then
        return nil, "invalid jwt: " .. err
    end
    print(decoded_token.header.alg) -- HS256
    print(decoded_token.payload.foo) -- bar

Minimal example with asymmetric keys:

    local jwt = require("resty.jwt-verification")
    
    local token = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIiLCJpYXQiOjE3MTY2Njg2Mzd9.H6PE-zLizMMqefx8DG4X5glVjyxR9UNT225Tq2yufHhu4k9K0IGttpykjMCG8Ck_4Qt2ezEWIgoiWhSn1rv_zwxe7Pv-B09fDs7h1hbASi5MZ0YVAmK9ID1RCKM_NTBEnPLot_iopKZRj2_J5F7lvXwJDZSzEAFJZdrgjKeBS4saDZAv7SIL9Nk75rdhgY-RgRwsjmTYSksj7eioRJJLHifrMnlQDbdrBD5_Qk5tD6VPcssO-vIVBUAYrYYTa7M7A_v47UH84zDtzNYBbk9NrDbyq5-tYs0lZwNhIX8t-0VAxjuCyrrGZvv8_O01pdi90kQmntFIbaiDiD-1WlGcGA"
    local decoded_token, err = jwt.verify(token, "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvXFhNyhFWuWtFSJqfOAw\np42lLIn9kB9oaciiKgNAYZ8SYw5t9Fo+Zh7IciVijn+cVS2/aoBNg2HhfdYgfpQ/\nsb6jwbRqFMln2GmG+X2aJ2wXMJ/QfxrPFdO9L36bAEwkubUTYXwgMSm1KqWRN8xX\n+oBu+dbyzw7iUbrmw0ybzXKZLJvetCvmt0reU5TvdwoczOWFBSKeYnzBrC6hISD8\n8TYDJ4tiw1EWVOupQGqgel0KjC7iwdIYi7PROn6/1MMnF48zlBbT/7/zORj84Z/y\nDnmxZu1MQ07kHqXDRYumSfCerg5Xw5vde7Tz8O0TWtaYV3HJXNa0VpN5OI3L4y7P\nhwIDAQAB\n-----END PUBLIC KEY-----")
    if not decoded_token then
        return nil, "invalid jwt: " .. err
    end
    print(decoded_token.header.alg) -- RS256
    print(decoded_token.payload.foo) -- bar

Examples with custom options:

    local jwt = require("resty.jwt-verification")
    
    local token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIiLCJpYXQiOjE3MTY2NTUwMTV9.NuEhIzUuufJgPZ8CmCPnD4Vrw7EnTyWD8bGtYCwuDZ0"
    local decoded_token, err = jwt.verify(token, "superSecretKey", {
        valid_signing_algorithms = {["HS256"]="HS256", ["HS384"]="HS384", ["HS512"]="HS512"}, -- only allow HS family algs
        audiences = {"user", "admin"}, -- `aud` must be one of the following
        ignore_not_before = true -- ignore `nbf` claim (not recommended)
    })
    if not decoded_token then
        return nil, "invalid jwt: " .. err
    end
    print(decoded_token.header.alg) -- HS256
    print(decoded_token.payload.foo) -- bar

jwt.decrypt

syntax: decoded_token, err = jwt.decrypt(token, secret, options?)

Decrypt and validate a JWE token and convert it to a lua table.

The optional parameter options can be passed to configure the token validator. Valid fields are:

  • valid_encryption_alg_algorithms (dict<string, string> | nil): a dict containing allowed alg claims used to decrypt the JWT.

  • valid_encryption_enc_algorithms (dict<string, string> | nil): a dict containing allowed enc claims used to decrypt the JWT.

  • typ (string | nil): if non-null, ensure JWT claim typ matches the passed value.

  • issuer (string | nil): if non-null, ensure JWT claim iss matches the passed value.

  • audiences (string | table | nil): if non-null, ensure JWT claim aud matches one of the supplied values.

  • subject (string | nil): if non-null, ensure JWT claim sub matches the passed value.

  • jwtid (string | nil): if non-null, ensure JWT claim jti matches the passed value.

  • ignore_not_before (bool): If true, the JWT claim nbf will be ignored.

  • ignore_expiration (bool): If true, the JWT claim exp will be ignored.

  • current_unix_timestamp (datetime | nil): the JWT nbf and exp claims will be validated against this timestamp. If null, will use the current datetime supplied by ngx.time().

  • timestamp_skew_seconds (int):

Default values for options fields:

    local decrypt_default_options = {
        valid_encryption_alg_algorithms = {
            ["A128KW"]="A128KW", ["A192KW"]="A192KW", ["A256KW"]="A256KW",
            ["dir"]="dir",
        },
        valid_encryption_enc_algorithms = {
            ["A128CBC-HS256"]="A128CBC-HS256",
            ["A192CBC-HS384"]="A192CBC-HS384",
            ["A256CBC-HS512"]="A256CBC-HS512",
            ["A128GCM"]="A128GCM",
            ["A192GCM"]="A192GCM",
            ["A256GCM"]="A256GCM",
        },
        typ = nil,
        issuer = nil,
        audiences = nil,
        subject = nil,
        jwtid = nil,
        ignore_not_before = false,
        ignore_expiration = false,
        current_unix_timestamp = nil,
        timestamp_skew_seconds = 1,
    }

Minimal example with symmetric keys:

    local jwt = require("resty.jwt-verification")
    
    local token = "eyJhbGciOiJBMTI4S1ciLCJlbmMiOiJBMTI4Q0JDLUhTMjU2In0.zAIq7qVAEO-eCG6gOdd3ld8_IHzeq3UlaWLHF2IDn6nNUuHh5n_i4w.5CM864cgiBgFPwluW4ViRg.mUeX7zHDVNsXhys0XO5S4w.t3yAR_HU0GDTEyCbpRa6BQ"
    local decoded_token, err = jwt.decrypt(token, "superSecretKey12")
    if not decoded_token then
        return nil, "invalid jwt: " .. err
    end
    print(decoded_token.header.alg) -- A128KW
    print(decoded_token.header.enc) -- A128CBC-HS256
    print(decoded_token.payload.foo) -- bar

Minimal example with asymmetric keys: TODO: not implemented

Examples with custom options:

    local jwt = require("resty.jwt-verification")
    
    local token = "eyJhbGciOiJBMTI4S1ciLCJlbmMiOiJBMTI4Q0JDLUhTMjU2In0.zAIq7qVAEO-eCG6gOdd3ld8_IHzeq3UlaWLHF2IDn6nNUuHh5n_i4w.5CM864cgiBgFPwluW4ViRg.mUeX7zHDVNsXhys0XO5S4w.t3yAR_HU0GDTEyCbpRa6BQ"
    local decoded_token, err = jwt.decrypt(token, "superSecretKey12", {
        valid_encryption_alg_algorithms = {["A128KW"]="A128KW"}, -- only allow A128KW family algs (requires OpenSSL 3.0+)
        valid_encryption_enc_algorithms = {["A128CBC-HS256"]="A128CBC-HS256"}, -- only allow A128CBC family encs
        audiences = {"user", "admin"}, -- `aud` must be one of the following
        ignore_not_before = true -- ignore `nbf` claim (not recommended)
    })
    if not decoded_token then
        return nil, "invalid jwt: " .. err
    end
    print(decoded_token.header.alg) -- A128KW
    print(decoded_token.header.enc) -- A128CBC-HS256
    print(decoded_token.payload.foo) -- bar

JWKS verification usage

The resty.jwt-verification-jwks module implements automatic JWKS retrieval from an HTTP endpoint and subsequent JWT validation with fetched keys.

The resty.jwt-verification-jwks-cache-* modules implement optional JWKS caching strategies. Only one caching strategy can be enabled at a time; if none are enabled, the JWKS endpoint will be called once for every JWT to validate.

jwks.enable_cache_strategy_local

syntax: ok, err = jwks.enable_cache_strategy_local()

Enables the JWKS cache strategy using the OpenResty built-in shared memory dictionaries.

This works on a per OpenResty instance and does not perform any network call on cache hit.

    local jwks = require("resty.jwt-verification-jwks")
    
    local ok, err = jwks.enable_cache_strategy_local()
    if not ok then
        ngx.say("Error enable cache strategy: ", err)
    end

jwks.set_http_timeouts_ms

syntax: jwks.set_http_timeouts_ms(connect, send, read)

Set HTTP client timeouts in milliseconds used for fetching JWKS.

    local jwks = require("resty.jwt-verification-jwks")
    
    jwks.enable_cache_strategy_local(5000, 5000, 5000)

jwks.set_http_ssl_verify

syntax: jwks.set_http_ssl_verify(enabled)

Enable/disable TLS verification used by HTTP client for fetching JWKS.

By default, all TLS certificates are verified. If the JWKS endpoint is using self-signed certificates, either add the respective root CA to the OS certs store or disable certificates verification with this endpoint (it's unsafe).

    local jwks = require("resty.jwt-verification-jwks")
    
    jwks.set_http_ssl_verify(false)

jwks.fetch_jwks

syntax: payload, err = jwks.fetch_jwks(endpoint)

Manually fetch JWKS from HTTP endpoint; the returned payload, in case of success, is the HTTP response body as string: No check is performed whatsoever whether the payload contains JWKS or something else.

If a caching strategy has been enabled, the endpoint will try to fetch it from the cache first. After a cache miss and successful JWKS retrieval via HTTP, the cache will be updated with the result.

    local jwks = require("resty.jwt-verification-jwks")
    
    payload, err = jwks.fetch_jwks("https://www.googleapis.com/oauth2/v3/certs")
    if payload == nil then
        print("failed fetching JWKS: ", err)
        return
    end
    print(payload) -- '{"keys":[{"alg":"RS256","e":"AQAB","kid":"882503a5fd56e9f734dfba5c50d7bf48db284ae9","kty":"RSA","n":"woRUr445_ODXrFeynz5L208aJkABOKQHEzbfGM_V1ijkYZWZKY0PXKPP_wRKcE4C6OyjDNd5gHh3dF5QsVhVDZCfR9QjTf94o4asngrHzdOcfQ0pZIvzu_vzaVG82VGLM-2rKQp8uz06A6TbUzbIv9wQ8wQpYDIdujNkLqL22Mkb2drPxm9Y9I05PmVdkkvAbu4Q_KRJWxykOigHp-hVBmpYS2P3xuX56gM7ZRcXXJKKUfrGel4nDhSIAAD1wBNcVVgKbb0TYfZmVpRSCji_b6JHjqYhYjUasdotYJzWl7quAFsN_X_4j-cHZ30OS81j--OiIxWpL11y1kcbE0u-Dw","use":"sig"},{"n":"m7GlcF1ExRB4braT7sDnZvlY3wpqX9krkVRqcVA-m43FWFYBtuSpd-lc0EV8R8TO180y0tSgJc7hviI1IBJQlNa7XkjVGhY0ZFUp5rTpC45QbA9Smo4CLa5HQIf-69rkkovjFNMuDQvNiYCgRPLyRjmQbN2uHl4fU3hhf5qFqKTKo7eLCZiEMjrOkTXziA7xJJigUGe-ab8U-AXNH1fnCbejzHEIxL0eUG_4r4xddImOxETDO5T65AQCeqs7vtYos2xq5SLFuaUsithRQ-IMm3OlcVhMjBYt6uvGS6IdMjKon4wThCxEqAEXg0nahiGjnQCW176SNF152__TOjQVwQ","alg":"RS256","kty":"RSA","use":"sig","kid":"8e8fc8e556f7a76d08d35829d6f90ae2e12cfd0d","e":"AQAB"}]}'

jwks.verify_jwt_with_jwks

syntax: jwt, err = jwks.verify_jwt_with_jwks(jwt_token, jwks_endpoint, jwt_options)

Given a jwt_token as a string, verify its signature with JWKS provided by the HTTP service found at jwks_endpoint.

On success, the decrypted/verified JWT is returned as a lua table, otherwise nil and an error are returned.

The optional parameter jwt_options can be passed to configure the token validator when calling jwt.verify after having successfully fetched the JWKS. See jwt.verify respective docs for more info about which options can be passed.

> Note: As of this document, It's possible to verify any JWS using this method but no JWE: It's in the planned > features to implement section.

    local jwks = require("resty.jwt-verification-jwks")
    
    jwt, err = jwks.verify_jwt_with_jwks("<MY_JWT>", "http://myservice:8888/.well-known/jwks.json", nil)
    if jwt == nil then
        print("failed verifying jwt: ", err)
        return
    end
    print(jwt.header.alg)
    print(tostring(jwt.payload))

RFCs used as reference

  • RFC 7515 JSON Web Signature (JWS)

  • RFC 7516 JSON Web Encryption (JWE)

  • RFC 7517 JSON Web Key (JWK)

  • RFC 7518 JSON Web Algorithms (JWA)

  • RFC 7519 JSON Web Token (JWT)

  • RFC 7520 Examples of Protecting Content Using JSON Object Signing and Encryption (JOSE)

Run tests

Setup

Install test suit:

    sudo cpan Test::Nginx

Install openresty: see https://openresty.org/en/linux-packages.html

Run

    export PATH=/usr/local/openresty/nginx/sbin:$PATH
    prove -r t

Authors

Andrea Vouk (anvouk)

License

3bsd

Dependencies

ledgetech/lua-resty-http >= 0.17.1, fffonion/lua-resty-openssl >= 1.3.1, luajit, openresty

Versions