TeslaGov / ngx-http-auth-jwt-module

Secure your NGINX locations with JWT
MIT License
308 stars 118 forks source link

Document issues using `if` and `return` with headers #100

Open pgthompson24 opened 1 year ago

pgthompson24 commented 1 year ago

Hello, I am currently looking to only allow a particular user (JWT subject) to access a specific endpoint on my web server. So I am using the following configuration to do so:

        location /endpoint {
            proxy_set_header Host $http_host;
            auth_jwt_enabled on;
            auth_jwt_algorithm RS384;
            auth_jwt_validate_sub on;
            auth_jwt_extract_request_claims sub;
            if ($http_jwt_sub != "super-user") {
                return 401 [$http_jwt_sub];
            }
            auth_jwt_use_keyfile on;
            auth_jwt_keyfile_path "<mysecretlocation>";
            auth_jwt_location COOKIE=token;
            proxy_pass http://localhost:3000;

        }

This configuration works without the bit where I try to validate the claims. It even allows access with the auth_jwt_validate_sub on; config. It validates the sub exists but my page yields empty brackets [] on return (i.e. the $http_jwt_sub variable is empty). I have tested and found that it fails to extract any values for other parameters of my JWT payload as well. And I can confirm that my JWT does in fact contain these fields: image

Has anyone else experienced this or is there some syntax I am not following properly?

JoshMcCullough commented 1 year ago

Can you confirm which version of NGINX you're using, please?

pgthompson24 commented 1 year ago

@JoshMcCullough The system is using NGINX version 1.21.1.

JoshMcCullough commented 1 year ago

In our tests, we ended up using a header to store the value of the extracted claim(s) because we, too, noticed that return does not seem to interpolate the variable. Although the documentation states that it does, and the code agrees.

All we are doing in the module is pulling the claim out of the JWT and storing it in the reqeuest and/or response headers. We format the name of the header as JWT_<claim_name e.g. JWT_sub, but headers are treated as case-insensitive, so we access the value of the header as $http_jwt_sub. I tried also changing our prefix to jwt_ (lower case) but it did not fix the problem.


This works when writing the claim value to response header:

add_header "Test" "sub=$http_jwt_sub";

But not when using return:

return 200 "test ... $http_jwt_sub ... ";

Nor when using set:

set $sub $http_jwt_sub;
return 200 "test ... $sub ... ";

In fact, when using return at all, it seems that any headers set are not available:

add_header "Test" "sub=$http_jwt_sub";

return 200 "test ... $http_jwt_sub ...";

Outputs:

< HTTP/1.1 200 OK < Server: nginx/1.24.0 < Date: Fri, 09 Jun 2023 16:01:38 GMT < Content-Type: application/octet-stream < Content-Length: 13 < Connection: keep-alive < Test: sub= < { [13 bytes data]

  • Connection #0 to host nginx left intact test ... ...

This may be a case of the rewrite module overwriting the headers, as is seen when you use add_header at a higher level (e.g. in the server block) and then again at a lower level (e.g. in a location block) -- the headers array are not "merged", they are overwritten. So when using return, this seems to be what is happening but I haven't verified it via code.

Also, it is not generally recommended to use if. I wonder if you can re-work your use case to use headers instead of return, and not use an if (use a map instead)?

pgthompson24 commented 1 year ago

Thanks for the response. In my case, I don't actually need to return the value of sub I was just attempting to return it for debugging purposes. Considering your advice, I can't think of a way to conditionally return 401 without using an if statement. So I attempted to evaluate the http_jwt_sub variable using a map:

http {
    auth_jwt_algorithm RS384;
    auth_jwt_use_keyfile on;
    auth_jwt_keyfile_path "<mysecretlocation>";
    auth_jwt_location COOKIE=token;
    auth_jwt_extract_request_claims sub;
    map $http_jwt_sub $valid {
        "super-user"    1;
        default         0;
    }
    server {
        listen       8080;
        error_log /opt/data/NGINX.log debug;
        client_max_body_size 1G;

        location /endpoint{
            auth_jwt_enabled on;
            proxy_set_header Host $http_host;
            if ($valid = 0) {
                return 401 "Unauthorized user";
            }
            proxy_pass http://localhost:3000;

        }

But, perhaps unsurprisingly, the $valid variable appears to default to 0 when it should be 1. Is there a different approach I can take to evaluate the $http_jwt_sub variable that subverts having to use any of these operators that seem to handle it improperly? Apologies in advance as my NGINX knowledge is somewhat limited.

JoshMcCullough commented 1 year ago

Yeah, there's no need to use map since you are using if anyway. So reverting to your original code should work.

pgthompson24 commented 1 year ago

In my original code, the if statement does not appear to properly evaluate the $http_jwt_sub variable.

JoshMcCullough commented 1 year ago

Ah, sorry I misunderstood that. I've been messing around with this and I don't know if it's a bug or not, but it seems like as soon as you use return, you can't access and request or response headers. I tried altering our JWT module code but cannot get this to work. Even when you do add_header Test abc123 and then immediately return 200 test=$http_test, the header is not read / returned.

I don't have an answer for you currently. But I will backtrack again on my if comment because they way you're using it is how it was intended to be used.

I'll continue looking for a solution and will report back if I can find something...

pgthompson24 commented 1 year ago

FWIW I did find a workaround for my issue. The service at my target endpoint supports role based access so I used a map on the $http_jwt_sub variable like so:

http {
    auth_jwt_algorithm RS384;
    auth_jwt_use_keyfile on;
    auth_jwt_keyfile_path "<pubkeypath>";
    auth_jwt_location COOKIE=token;
    auth_jwt_extract_request_claims sub;
    map $http_jwt_sub $user {
        "super-user"    "admin";
        default         "notadmin";
    }
    server {
        listen       8080;

        client_max_body_size 1G;

        # Grafana endpoint
        # Authenticate using the JWT that gets stored in the `token` cookie when we log in to the local UI
        location /enpoint {
            auth_jwt_enabled on;
            proxy_set_header Host $http_host;
            proxy_set_header X-WEBAUTH-USER $user;
            proxy_pass http://localhost:3000;
        }

And the $http_jwt_sub variable was successfully interpreted by the map. Avoiding if and return seems like the way to go in a case like this.

JoshMcCullough commented 1 year ago

Excellent! I'll close this issue as there's nothing for us to do.

JoshMcCullough commented 1 year ago

Actually, we can use this ticket to add some docs regarding this.