diff --git a/.gitignore b/.gitignore index 997ca2f..f345d3d 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -.vagrant \ No newline at end of file +.vagrant +tmp \ No newline at end of file diff --git a/README.md b/README.md index d5229e6..37ff974 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,19 @@ with a space and surround it with double quotes: setenv OAUTH_AUDIENCE "https://api.mywebsite.com https://api2.mywebsite.com" ``` +## Support for multiple keys + +This library support specifying multiple keys values in the JWT token. They should be specified as a JSON array of strings. +You can also accept multiple audience values in the `OAUTH_KID` and `OAUTH_PUBKEY_PATH` environment variables in the **haproxy.cfg** file. Separate each value +with a space and surround it with double quotes: + +``` +setenv OAUTH_PUBKEY_PATH "/etc/haproxy/pem/pubkey.pem /etc/haproxy/pem/pubkey2.pem" +setenv OAUTH_KID "key1 key2" +``` + +Make sure that the order of the paths to the keys matches the index of the relevant identifier. + ## Output variables After calling `http-request lua.jwtverify`, you get access to variables for each of the claims in the token. @@ -74,16 +87,16 @@ Try it out using the Docker Compose. | permission | description | |-------------|-----------------------| | read:myapp | Read access to my app | - | write:myapp | Write access to myapp | + | write:myapp | Write access to myapp | 1. Now that you have an API defined in Auth0, add an application that is allowed to authenticate to it. Go to the "Applications" tab and add a new "Machine to Machine Application" and select the API you just created. Give it the "read:myapp" and "write:myapp"permissions (or only one or the other). 1. On the Settings page for the new application, go to **Advanced Settings > Certificates** and download the certificate in PEM format. HAProxy will validate the access tokens against this certificate, which was signed by the OAuth provider, Auth0. 1. Convert it first using `openssl x509 -pubkey -noout -in ./mycert.pem > pubkey.pem` and save **pubkey.pem** to **/example/haproxy/pem/pubkey.pem**. -1. Edit **example/haproxy/haproxy.cfg**: +1. Edit **example/haproxy/haproxy.cfg**: - * replace the `OAUTH_ISSUER` variable in the global section with the Auth0 domain URL with your own, such as https://myaccount.auth0.com/. - * replace the `OAUTH_AUDIENCE` variable with your API name in Auth0, such as "https://api.mywebsite.com". + * replace the `OAUTH_ISSUER` variable in the global section with the Auth0 domain URL with your own, such as https://myaccount.auth0.com/. + * replace the `OAUTH_AUDIENCE` variable with your API name in Auth0, such as "https://api.mywebsite.com". * replace the `OAUTH_PUBKEY_PATH` variable with the path to your PEM certificate. (also update the docker-compose file) 1. Create the environment with Docker Compose: diff --git a/docker-compose.ubuntu.example.yml b/docker-compose.ubuntu.example.yml index 6ce31ca..919073d 100644 --- a/docker-compose.ubuntu.example.yml +++ b/docker-compose.ubuntu.example.yml @@ -17,7 +17,9 @@ services: volumes: - ./example/haproxy/haproxy.cfg:/etc/haproxy/haproxy.cfg - ./example/haproxy/pem/pubkey.pem:/etc/haproxy/pem/pubkey.pem + - ./example/haproxy/pem/pubkey2.pem:/etc/haproxy/pem/pubkey2.pem - ./example/haproxy/pem/test.com.pem:/etc/haproxy/pem/test.com.pem + - ./lib/jwtverify.lua:/usr/local/share/lua/5.4/jwtverify.lua ports: - "80:80" - "443:443" diff --git a/example/haproxy/haproxy.cfg b/example/haproxy/haproxy.cfg index 2415762..9aba837 100644 --- a/example/haproxy/haproxy.cfg +++ b/example/haproxy/haproxy.cfg @@ -10,7 +10,9 @@ global # Replace the Auth0 URL with your own: setenv OAUTH_ISSUER https://youraccount.auth0.com/ setenv OAUTH_AUDIENCE https://api.mywebsite.com - setenv OAUTH_PUBKEY_PATH /etc/haproxy/pem/pubkey.pem + # Note that that you can use multiple keys, just make sure that kid length matches the number of keys + setenv OAUTH_PUBKEY_PATH "/etc/haproxy/pem/pubkey.pem /etc/haproxy/pem/pubkey2.pem" + setenv OAUTH_KID "key1 key2" defaults log global @@ -29,7 +31,7 @@ frontend api_gateway http-request deny unless { var(txn.authorized) -m bool } http-request deny if { path_beg /api/myapp } { method GET } ! { var(txn.oauth.scope) -m sub read:myapp } http-request deny if { path_beg /api/myapp } { method POST PUT DELETE } ! { var(txn.oauth.scope) -m sub write:myapp } - + backend apiservers balance roundrobin server server1 server1:80 diff --git a/example/haproxy/pem/pubkey.pem b/example/haproxy/pem/pubkey.pem index 77c09c8..6bc6e8d 100644 --- a/example/haproxy/pem/pubkey.pem +++ b/example/haproxy/pem/pubkey.pem @@ -1,9 +1,14 @@ -----BEGIN PUBLIC KEY----- -MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvIL8bebCh+pi68Rt0CCu -104VqR10kuD0E1yzwaywvaEiyhfUeDDKAyKC8yS5ilu9xyWK/pg/84RiWq7WoqhU -m8L06jtknn/ZCOuyUdkn1QcdOG10lbbrUF1AOduTIvFYyT4zHrIcKt6MyeQUO0kH -cXQU7lvM2C62BboAasZFupDts1m1kPZMWaiSjLrE1eruhl8NrfipiPWMZJSJoYCQ -cmtN3REXk9z8X7ZPgcMJ9hNN+Kv0fTYLZI4wS4TpHscVfbK18cL4uLrTCcip7jNe -y2KZ/YdbeHgmmcQAdiB4veH4I2dAyqIdsy8Jk+KTs3Ae8qp+S3XtC8z/uXMbN7lR -AwIDAQAB +MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA2Z1w2ZZ0oHnpxYX3Nnee +zVwI4fHtOQTdNz9vek6QZlkl4JY9yxcVfG8ssJq0k55po4MelioNyybpR4+9AE/Q +PwGUm7xEsAjlP/BAmpTz5PAeBAMd1sn+frHgKaICfQO6feob9/JaLo2ixRN0zzPd +5dhUrRaW/Az5a4mcXLSEGUAtIgtCg+ZnXra5Bn503xSOqOJkp8R2qnozmsVidAeL +/bOMe/Yb7QpDjJgv565G3gbbzcLn6+IG8IWnXNxLD7C6mcPA0yS3MrJUpRFRzWrW +MMPWOvBHWCm/2NnNQGHGSpImQ/BZ1DOoFqXO6DRuZpYiBzE/H74ojaatfoxSzpJz +618j4u3CcTKwYafkdXXbUoSMK9FWXdCsSVppXqBLIOtaS2MZMtnuUP23EucoK65l +BSUiuQ7gztzuDTKSLjlm3oMS2Z6Z3j0e0dHXnNZQTZKvhjjTyi65n3Xq7EBhNsbp +r6zN4RGbimoliRvyuNQpY9ottbB5Md2PkNRx2WwMtOEMCNCDvmSMwJ7SeUEcs0n+ +J7v4WyEA5TH2OYdwRPOfyAbZbxyP8ZEICCe6Xhn8QKVZO5nTIHzuQuHW5z8Q9gA/ +3waj/ksN9CGP211lRCxDf2iINw1EkPYjeWM5MAr1N1BwyhItqbP3DAIsajFb6CtY +dZKcoUh45cl6d6DaXgWq4L8CAwEAAQ== -----END PUBLIC KEY----- diff --git a/example/haproxy/pem/pubkey2.pem b/example/haproxy/pem/pubkey2.pem new file mode 100644 index 0000000..6922cba --- /dev/null +++ b/example/haproxy/pem/pubkey2.pem @@ -0,0 +1,14 @@ +-----BEGIN PUBLIC KEY----- +MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAu9TovLhy2PFHJ6U3+lDj +8uiqtigAJ+cdBTbe4NotSJiNKikZlUSzJElIwAYXeHpRsDuCml76e0axB22XWGZ1 +QPII/2Etd6ZjWu5mp27i9EqpJHnd5xpdeNnBUf1KWH2uVUJjPVEZWAU1rqVl/FhI +owRyjqq3KtZo36u4ZD3264SOMXzIZIn4+dDuwNavGUen0mug+r3istTa5fQy8DVu +DhSU24MBLKQwAlNOWfUUf/h9SqpE25w2mehJwhJ+qXP0OwlzCw0tJIjcSkycrB02 ++xF9ucALZzZgX72et242gIak0p7NkRcjuYWPMhhmvrkVXgz4XSfszHIlgnvD+hG9 +EZOTgq2hL7O1BHB4FIyQrZBtCT/Q7pcjlHb0VMiRW2tv4GYW1tiSnf2Tww3nJOwf +YetCSuT0zhamEC7LEQFlju9ZZvQThTtXrEYhryp+Tw2UxEsUtiiVFcncX0St4lw8 +XBzQkIUOsUDdVHenpdfPqTLsFZ1CwydX7DmQX1tVDHY2J2jQGK2ZBJrNWPD6SlWA +7hDMaTZykTiVdQXGczVqHJi8YUB8YwBffLOHxF+TISF6Nj+6eB9DXmXmAyVsHaYu +5TEZccPOGvSRKzUJo/vwusZ8pmSZigaaD42M+xqWKxYlvx1Mli8lBtA9Q3jMotSl +YEAQiWgiAK/Vev1vo3i2sf8CAwEAAQ== +-----END PUBLIC KEY----- diff --git a/lib/jwtverify.lua b/lib/jwtverify.lua index c3086d0..6bcb0dc 100644 --- a/lib/jwtverify.lua +++ b/lib/jwtverify.lua @@ -25,6 +25,7 @@ if not config then config = { debug = true, publicKey = nil, + kid = nil, issuer = nil, audience = nil, hmacSecret = nil @@ -130,7 +131,23 @@ local function algorithmIsValid(token) return true end -local function rs256SignatureIsValid(token, publicKey) +local function rs256SignatureIsValid(token, keys, kids) + -- Check if a kid if provided, if so verify it exists in the kids array + local token_kid = token.headerdecoded.kid + local publicKey + if kids ~= nil then + if not contains(kids, token_kid) then + log("The kid provided in the token (" .. token_kid .. ") does not match the kid provided in the configuration.") + return false + end + + -- get the key from the keys list at the index of the correct kid + publicKey = keys[token_kid] + else + -- if no kid is provided, use the first key in the list + publicKey = keys[next(keys)] + end + local digest = openssl.digest.new('SHA256') digest:update(token.header .. '.' .. token.payload) local vkey = openssl.pkey.new(publicKey) @@ -160,10 +177,10 @@ end -- Checks if the audience in the token is listed in the -- OAUTH_AUDIENCE environment variable. Both the token audience --- and the environment variable can contain multiple audience values, +-- and the environment variable can contain multiple audience values, -- separated by commas. Each value will be checked. local function audienceIsValid(token, expectedAudienceParam) - + -- Convert OAUTH_AUDIENCE environment variable to a table, -- even if it contains only one value local expectedAudiences = expectedAudienceParam @@ -172,8 +189,14 @@ local function audienceIsValid(token, expectedAudienceParam) expectedAudiences = core.tokenize(expectedAudienceParam, " ") end - -- Convert 'aud' claim to a table, even if it contains only one value local receivedAudiences = token.payloaddecoded.aud + + -- Check if 'aud' exists and handle cases where it's missing + if receivedAudiences == nil then + return false + end + + -- Convert 'aud' claim to a table, even if it contains only one value if type(token.payloaddecoded.aud) == "string" then receivedAudiences ={} receivedAudiences[1] = token.payloaddecoded.aud @@ -195,7 +218,8 @@ local function setVariablesFromPayload(txn, decodedPayload) end local function jwtverify(txn) - local pem = config.publicKey + local keys = config.publicKeys + local kid = config.kid local issuer = config.issuer local audience = config.audience local hmacSecret = config.hmacSecret @@ -219,7 +243,7 @@ local function jwtverify(txn) -- 3. Verify the signature with the certificate if token.headerdecoded.alg == 'RS256' then - if rs256SignatureIsValid(token, pem) == false then + if rs256SignatureIsValid(token, keys, kid) == false then log("Signature not valid.") goto out end @@ -271,18 +295,44 @@ end core.register_init(function() config.issuer = os.getenv("OAUTH_ISSUER") config.audience = os.getenv("OAUTH_AUDIENCE") - + + -- when using multiple keys, parse the kid list + local kid = os.getenv("OAUTH_KID") + if kid ~= nil then + config.kid = core.tokenize(kid, " ") + end + -- when using an RS256 signature - local publicKeyPath = os.getenv("OAUTH_PUBKEY_PATH") + local publicKeyPath = os.getenv("OAUTH_PUBKEY_PATH") if publicKeyPath ~= nil then - local pem = readAll(publicKeyPath) - config.publicKey = pem + -- tokenize the path in case multiple keys are provided + keyPaths = core.tokenize(publicKeyPath, " ") + + -- Check if there is more than one file path then we must have kid identifiers + if #keyPaths > 1 and config.kid == nil then + log("Multiple public keys provided but no key identifiers.") + return + end + + -- Make sure that the kid size matches the keyPaths size + if config.kid ~= nil and #config.kid ~= #keyPaths then + log("The number of keys does not match the number of key identifiers.") + return + end + + -- Read all the keys and store them in the config + config.publicKeys = {} + for i, keyPath in ipairs(keyPaths) do + local pem = readAll(keyPath) + config.publicKeys[config.kid[i]] = pem + end end - + -- when using an HS256 or HS512 signature config.hmacSecret = os.getenv("OAUTH_HMAC_SECRET") - + log("PublicKeyPath: " .. (publicKeyPath or "")) + log("KeyIdentifiers: " .. (kid or "")) log("Issuer: " .. (config.issuer or "")) log("Audience: " .. (config.audience or "")) end) diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..972aa52 --- /dev/null +++ b/test.sh @@ -0,0 +1,316 @@ +#!/bin/bash + +# Function to wait for containers to be up +wait_for_containers() { + # Wait for at least two containers to be up and running + echo "Waiting for at least 2 containers to be running..." + while true; do + container_count=$(docker compose -f "$COMPOSE_FILE" ps -q | wc -l) + if [ "$container_count" -ge 2 ]; then + echo "At least 2 containers are running." + break + fi + echo "Currently $container_count containers are running. Waiting..." + sleep 2 # Wait for 2 seconds before checking again + done + + # Now check each container's status + local service_names=$(docker compose -f "$COMPOSE_FILE" ps --services) + for service in $service_names; do + echo "Waiting for $service to be up..." + while true; do + status=$(docker compose -f "$COMPOSE_FILE" ps "$service" | grep "$service" | grep "Up") + if [ -n "$status" ]; then + echo "$service is up!" + break + fi + sleep 2 # Wait for 2 seconds before checking again + done + done +} + +# Log function with color and timestamp +log() { + local GREEN="\033[0;32m" + local YELLOW="\033[1;33m" + local RED="\033[0;31m" + local NC="\033[0m" # No Color + + # Get log level and message + local log_level=$1 + shift + local log_message="$@" + + # Get current date and time + local timestamp=$(date +"%Y-%m-%d %H:%M:%S") + + # Set color based on log level + case $log_level in + DEBUG) + echo -e "${NC}[$timestamp] [INFO] ${log_message}${NC}" + ;; + INFO) + echo -e "${GREEN}[$timestamp] [INFO] ${log_message}${NC}" + ;; + WARNING) + echo -e "${YELLOW}[$timestamp] [WARNING] ${log_message}${NC}" + ;; + ERROR) + echo -e "${RED}[$timestamp] [ERROR] ${log_message}${NC}" + ;; + *) + echo -e "[$timestamp] [UNKNOWN] ${log_message}" + ;; + esac +} + +base64_url_encode() { + openssl base64 -e -A | tr '+/' '-_' | tr -d '=' +} + +create_jwt() { + local header=$1 + local payload=$2 + local private_key=$3 + + local encoded_header=$(echo -n "$header" | base64_url_encode) + local encoded_payload=$(echo -n "$payload" | base64_url_encode) + + local unsigned_token="${encoded_header}.${encoded_payload}" + local signature=$(echo -n "$unsigned_token" | openssl dgst -sha256 -sign "$private_key" | base64_url_encode) + + echo "${unsigned_token}.${signature}" +} + +# Constants +WORKDIR="$(pwd)" +TMP_DIR="${WORKDIR}/tmp" +PRIVATE_KEY="${TMP_DIR}/private.pem" +PUBLIC_KEY="${TMP_DIR}/public.pem" +CERT_FILE="${TMP_DIR}/cert.pem" +PRIVATE2_KEY="${TMP_DIR}/private2.pem" +PUBLIC2_KEY="${TMP_DIR}/public2.pem" +CERT2_FILE="${TMP_DIR}/cert2.pem" +COMPOSE_FILE="${WORKDIR}/docker-compose.ubuntu.example.yml" + +mkdir -p ${TMP_DIR} + +## Setup +generate_keys() { + log INFO "Generating keys..." + openssl req -x509 \ + -newkey rsa:4096 \ + -keyout "${PRIVATE_KEY}" \ + -out "${CERT_FILE}" \ + -days 365 \ + -nodes \ + -subj "/CN=youraccount.auth0.com" + openssl x509 -pubkey -noout -in "${CERT_FILE}" > "${PUBLIC_KEY}" + openssl req -x509 \ + -newkey rsa:4096 \ + -keyout "${PRIVATE2_KEY}" \ + -out "${CERT2_FILE}" \ + -days 365 \ + -nodes \ + -subj "/CN=youraccount.auth0.com" + openssl x509 -pubkey -noout -in "${CERT2_FILE}" > "${PUBLIC2_KEY}" +} + +generate_keys + + +log INFO "Overriding the public key used to verify the signature" +cp "${PUBLIC_KEY}" "${WORKDIR}/example/haproxy/pem/pubkey.pem" +cp "${PUBLIC2_KEY}" "${WORKDIR}/example/haproxy/pem/pubkey2.pem" + +log info "Take down any old project" +docker compose -f ${COMPOSE_FILE} down || true + +log INFO "Start the compose project" +docker compose -f ${COMPOSE_FILE} up -d +docker compose -f ${COMPOSE_FILE} logs -f & + +log INFO "Waiting for containers to go up" +wait_for_containers + +###### Tests ###### +test_sanity() { + # Header for RS256 JWT + header='{ + "alg": "RS256", + "typ": "JWT", + "kid": "key1" + }' + + log DEBUG Payload \(modify as needed\) + payload='{ + "iss": "https://youraccount.auth0.com/", + "aud": "https://api.mywebsite.com", + "sub": "user@example.com", + "exp": '$(($(date +%s) + 3600))', + "scope": "read:myapp" + }' + + log DEBUG Create jwt + jwt=$(create_jwt "$header" "$payload" "$PRIVATE_KEY") + + log DEBUG "Testing the API" + curl --request GET \ + -k --fail \ + --url https://localhost/api/myapp \ + --header "authorization: Bearer ${jwt}" + + # We expect the API to pass + [[ $? -eq 0 ]] +} + +test_no_audience() { + # Header for RS256 JWT + header='{ + "alg": "RS256", + "typ": "JWT", + "kid": "key1" + }' + + log DEBUG Payload \(modify as needed\) + payload='{ + "iss": "https://youraccount.auth0.com/", + "sub": "user@example.com", + "exp": '$(($(date +%s) + 3600))', + "scope": "read:myapp" + }' + + log DEBUG Create jwt + jwt=$(create_jwt "$header" "$payload" "$PRIVATE_KEY") + + log DEBUG "Testing the API" + curl --request GET \ + -k --fail \ + --url https://localhost/api/myapp \ + --header "authorization: Bearer ${jwt}" + + # We expect the API to return an error + [[ $? -ne 0 ]] +} + +test_incorrect_key_id() { + # Header for RS256 JWT + header='{ + "alg": "RS256", + "typ": "JWT", + "kid": "key195" + }' + + log DEBUG Payload \(modify as needed\) + payload='{ + "iss": "https://youraccount.auth0.com/", + "aud": "https://api.mywebsite.com", + "sub": "user@example.com", + "exp": '$(($(date +%s) + 3600))', + "scope": "read:myapp" + }' + + log DEBUG Create jwt + jwt=$(create_jwt "$header" "$payload" "$PRIVATE_KEY") + + log DEBUG "Testing the API" + curl --request GET \ + -k --fail \ + --url https://localhost/api/myapp \ + --header "authorization: Bearer ${jwt}" + + # We expect the API to return an error + [[ $? -ne 0 ]] +} + +test_second_key_usage_pass() { + # Header for RS256 JWT + header='{ + "alg": "RS256", + "typ": "JWT", + "kid": "key2" + }' + + log DEBUG Payload \(modify as needed\) + payload='{ + "iss": "https://youraccount.auth0.com/", + "aud": "https://api.mywebsite.com", + "sub": "user@example.com", + "exp": '$(($(date +%s) + 3600))', + "scope": "read:myapp" + }' + + log DEBUG Create jwt + jwt=$(create_jwt "$header" "$payload" "$PRIVATE2_KEY") + + log DEBUG "Testing the API" + curl --request GET \ + -k --fail \ + --url https://localhost/api/myapp \ + --header "authorization: Bearer ${jwt}" + + # We expect the API to pass + [[ $? -eq 0 ]] +} + +test_second_key_usage_fail() { + # Header for RS256 JWT + header='{ + "alg": "RS256", + "typ": "JWT", + "kid": "key" + }' + + log DEBUG Payload \(modify as needed\) + payload='{ + "iss": "https://youraccount.auth0.com/", + "aud": "https://api.mywebsite.com", + "sub": "user@example.com", + "exp": '$(($(date +%s) + 3600))', + "scope": "read:myapp" + }' + + log DEBUG Create jwt + jwt=$(create_jwt "$header" "$payload" "$PRIVATE2_KEY") + + log DEBUG "Testing the API" + curl --request GET \ + -k --fail \ + --url https://localhost/api/myapp \ + --header "authorization: Bearer ${jwt}" + + # We expect the API to return an error + [[ $? -ne 0 ]] +} + +###### Run tests ###### +# Define the array of test functions +tests=("test_sanity" "test_no_audience" "test_incorrect_key_id" "test_second_key_usage_pass" "test_second_key_usage_fail") +all_tests_passed=true + +# Loop through the array of test functions and run them +for test in "${tests[@]}"; do + log INFO "Running $test..." + $test # Call the test function + + # Check the result of the test + if [ $? -eq 0 ]; then + log INFO "$test passed" + else + log ERROR "$test failed" + all_tests_passed=false + fi +done + +# Terminate the compose project +log INFO "Take down any old project" +docker compose -f ${COMPOSE_FILE} down || true + +# Check if all tests passed +if [ "$all_tests_passed" = true ]; then + log INFO "All tests passed. The run was successful." + exit 0 +else + log ERROR "Some tests failed. The run failed." + exit 1 +fi \ No newline at end of file