phpauthenticationsymfonynginxlexikjwtauthbundle

Conflicting Basic & Bearer Authorization between nginx and my webapp


My problem

I'm currently developing a Symfony webapp, for which I have a testing and production nginx server. My API, which I am currently working on, is supposed to use Bearer tokens for Authorization.

My production server lies under https://api.mydomain.de while my test environment is at https://dev.api.mydomain.de

For now I have set-up a Basic Auth system as following:

location / {
    try_files $uri /index.php$is_args$args;

    auth_basic "Restricted Access";
    auth_basic_user_file /etc/nginx/passwd/dev_passwd.txt;
}

When entering the dev server, we need the Authorization header as Authorization: Basic <token>

But when testing out my user system I realized that this won't work as well as I thought, when my application also tries authentication using the header.

This means I either request the server using valid login credentials for the app, but nginx doesn't approve my request or the other way around.

What I've tried

I had a few ideas on how to fix this, but was not sure how to realize them or even which one to choose:

1. Check the Bearer token using auth_request

I considered using the auth_request directive to validate the Bearer token by making a sub-request to an endpoint like /user/validate-token/{token}. I encountered issues with the auth_request block not being allowed inside if statements and had to deal with 502 errors I couldn't fix.

Here's what I had so far:

location / {
    try_files $uri /index.php$is_args$args;

    # Check if the Authorization header contains a Bearer token
    if ($http_authorization ~* ^Bearer\s+(.*)) {
        set $bearer_token $1;

        # Use an internal location to validate the token
        auth_request /internal/token-validation;
    }

    auth_basic "Restricted Access";
    auth_basic_user_file /etc/nginx/passwd/dev_passwd.txt;
}

location = /internal/token-validation {
    internal;

    proxy_pass http://localhost/user/validate-token/$bearer_token;
    proxy_pass_request_body off;
    proxy_set_header Content-Length "";
}

# Maybe remove auth_basic here ?
location /user/validate-token {
   try_files $uri /index.php$is_args$args;
   auth_basic "Restricted Access";
   auth_basic_user_file /etc/nginx/passwd/dev_passwd.txt;
}

error_page 401 = @error_401;
location @error_401 {
    return 401 "Unauthorized";
}

2. Using internal locations / routes in nginx

I also thought about creating separate internal locations for handling Basic Authentication and Bearer token validation. This would allow me to keep the logic clean and separate, but I encountered similiar issues. Also, my application recognized all request urls either as /basic_auth or /validate_bearer_token, cause the request was rewritten internally.

Here's what I had:

    location /basic_auth {
        auth_basic "Restricted Access";
        auth_basic_user_file /etc/nginx/passwd/dev_passwd.txt;

        rewrite ^/basic_auth(/.*)$ $1 break;
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ ^/validate_bearer_token/(.*) {
        internal;  # Internal only, not accessible from outside
        set $auth_token $1;

        # Pass the request to the validation endpoint
        proxy_pass http://127.0.0.1/user/validate-token/$auth_token;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # Intercept errors to handle token validation response
        proxy_intercept_errors on;
        error_page 401 = @error_401;
        # I couldn't use 200 for some reason
        error_page 399 = @auth_success;
    }
# [...]

3. Renaming the Authorization header in my app

I also considered renaming the Authorization header in my application to avoid conflicts between Basic Auth and Bearer token authentication. However, I wasn't sure, for one, if this is the best way to deal with this issue, and also I didn't know how to do that, since I am using Symfony with the LexikJWTBundle, which expects the standard Authorization header format. I don't know if this would complicate the integration and could lead to further issues down the line? Is it even possible?

4. Not using basic auth

Another approach I thought about was to have a kind of password header, which acts in place of an Authorization Basic header and is checked by my Symfony app, instead of nginx itself. However I can also see how this causes problems, especially when testing out my frontend on https://dev.mydomain.de, but I am open for this solution as well.

I was curious to know, which one of these solutions is the objectively best to use or if there's something I have missed?


Solution

  • As LexikJWTAuthenticationBundle documentation states:

    By default only the authorization header mode is enabled: Authorization: Bearer {token}

    See the configuration reference document to enable query string parameter mode or change the header value prefix.

    And in the aforementioned configuration reference, we see the following configuration example:

    # config/packages/lexik_jwt_authentication.yaml
    lexik_jwt_authentication:
        ...
        # token extraction settings
        token_extractors:
            # look for a token as Authorization Header
            authorization_header:
                enabled: true
                prefix:  Bearer
                name:    Authorization
    
            # check token in a cookie
            cookie:
                enabled: false
                name:    BEARER
    
            # check token in query string parameter
            query_parameter:
                enabled: false
                name:    bearer
    
            # check token in a cookie
            split_cookie:
                enabled: false
                cookies:
                    - jwt_hp
                    - jwt_s
    

    This means you can adjust the token extraction settings to use either a different token-check method or an alternative custom HTTP header, e.g. X-Bearer-Auth. Moreover, you can substitute this custom HTTP header as the Authorization header value when passing the request to the API server:

    server {
        ...
        location / {
            try_files $uri /index.php$is_args$args;
    
            auth_basic "Restricted Access";
            auth_basic_user_file /etc/nginx/passwd/dev_passwd.txt;
        }
        location ~ \.php$ {
            include fastcgi_params;
            # overwrite `Authorization` header for PHP-FPM engine
            # with the custom `X-Bearer-Auth` header value
            fastcgi_param HTTP_AUTHORIZATION $http_x_bearer_auth;
            fastcgi_param SCRIPT_FILENAME $document_root$uri;
            fastcgi_pass /path/to/php-fpm.sock;
        }
        ...
    

    (Note: The original Authorization header value will still be present in the FastCGI request payload; however, only the second, overwritten value will appear in the PHP $_SERVER array as the $_SERVER['HTTP_AUTHORIZATION'] item.)

    You can also conditionally check whether the Authorization header is of the Basic or Bearer type by using both auth_basic and auth_request methods. Even if your initial negotiation with the server is secured by basic authentication, adding an Authorization: Bearer ... header programmatically to an AJAX request using XMLHttpRequest():

    xhr.setRequestHeader("Authorization", "Bearer " + token);
    

    or fetch():

    fetch(url, {
        method: "POST",
        headers: {
            "Authorization", "Bearer " + token,
            ...
         },
         ...
    

    will prevent the browser from including an additional Authorization: Basic ... header in the request (which is beneficial, as nginx responds with an HTTP 400 Bad Request error if multiple Authorization headers are present). The approach you initially chose isn't correct. The if directive in nginx, when specified in the location context, differs from typical if statements in regular programming languages (if you're interested, you can read this answer to find out why it is so.) However, instead you can use two map blocks along with the following configuration:

    map $http_authorization $auth_realm {
        ~^Bearer  off;
        default   "Restricted Access";
    }
    
    map $http_authorization $auth_basic {
        ~^Basic   1;
        # default is empty string
    }
    
    server {
        ...
        location / {
            # auth_basic is off in case we have an "Authorization: Bearer ..." header
            auth_basic $auth_realm;
            auth_basic_user_file /etc/nginx/passwd/dev_passwd.txt;
            auth_request /internal/validate/;
            try_files $uri /index.php$is_args$args;
        }
        location /internal/validate/ {
            internal;
            # skip further processing in case we have an "Authorization: Basic ..." header
            if ($auth_basic) { return 200; }
    
            include fastcgi_params;
    
            # the following script validate the "Authorization: Bearer ..." header
            # (accessible in PHP script via $_SERVER['HTTP_AUTHORIZATION'])
            # script shoud return an HTTP 2xx status code on success or 401/403 otherwise
            fastcgi_param SCRIPT_FILENAME $document_root/relative/path/to/validator.php;
    
            fastcgi_pass /path/to/php-fpm.sock;
        }
        ...
    

    You can even define a separate Symfony route for token verification, e.g. /verify_token. To use this route for validation request, redefine the REQUEST_URI FastCGI parameter before passing the request to the Symfony controller, modifying the internal location from the previous example as follows:

        location /internal/validate/ {
            internal;
            # skip further processing in case we have an "Authorization: Basic ..." header
            if ($auth_basic) { return 200; }
    
            include fastcgi_params;
    
            # overwrite the route seen by Symfony
            fastcgi_param REQUEST_URI "/validate_token";
            # pass the request to Symfony's main controller
            fastcgi_param SCRIPT_FILENAME $document_root/index.php;
    
            fastcgi_pass /path/to/php-fpm.sock;
        }