Private Satis authentication backed by Laravel

In part one of this two-part series we looked at setting up a Satis repository for our private GitHub packages. Now that we've got a basic Satis server running, let's look at the options to secure this server and our precious packages.

Composer and HTTP basic auth

Composer knows how to deal with private repositories out of the box. If one of the repositories configured in composer.json returns a HTTP 401 (unauthorised) or 403 (forbidden), composer will try to fall back to using basic HTTP Authorization headers.

Using the composer CLI that looks like this:

~/Projects/spatie.be: composer update
Loading composer repositories with package information
    Authentication required (satis.spatie.be):
      Username: my-username
      Password:

Behind the scenes Composer will then try to request the /packages.json file from the configured satis server with the Authorization header filled in according to the basic HTTP auth scheme.

Basic auth is a simple standard for stateless authentication using the Authorization header. The easiest way to handle this flow is to authenticate it using NGINX. It also supports multiple users using the .htpasswd file. If you're just looking to safely distribute private packages to a small team or a couple of clients that don't change too much, this is the way to go. The documentation NGINX docs on this topic contains a tutorial to set this up.

Even better, when using Laravel Forge you can configure basic auth access to your Satis server directly from the Forge UI in the "Security" section.

However, for our use-case at Spatie we're looking for a more dynamic solution that allows us to add and remove users (licenses) on the fly. We could probably set-up automatic configuration for the .htpasswd file but that sounds like a lot of work. Let's take it one step further and look at another option.

Basic auth backed by an external API

We're already managing purchases and licensing for all products on the Spatie site. Ideally, this means that the Satis server contacts the spatie.be API to check each license key before serving a package download. This way the Spatie site stays the single source of truth for licenses.

To keep the authentication flow short and simple, we also want to use Composer's default fallback to HTTP basic auth as briefly discussed above. This way, when a customer installs one of our private packages, composer will automatically ask for a username and password, in this case, the customer's email address and license key.

So TL;DR: we want to use the spatie.be API as a HTTP basic authentication server for Satis:

┌────────────┐
│Composer CLI│
└────────────┘
 ▲
 │ Download request with basic auth headers
 ▼
┌─────┐
│Satis│
└─────┘
 ▲
 │ Authentication request with forwarded basic auth header
 ▼
┌──────────┐
│Spatie API│
└──────────┘

External basic auth using NGINX' auth_request

Thankfully NGINX has got us covered for proxying HTTP basic authentication to a different server. I've annotated some interesting parts of our Satis NGINX configuration below. You can also find the entire config file here.

server {
    server_name satis.spatie.be;

    location / {
        # Satis UI and packages.json file publicly available
        
        try_files $uri $uri/ /index.php?$query_string;
    }
    
    location /dist {
        # Package downloads require authentication using 
        # the internal auth endpoint found below.
    
        auth_request /_oauth2_token_introspection; 
        
        try_files $uri $uri/ /index.php?$query_string;
    }
    
    location = /_oauth2_token_introspection {
        # Forward the request, including basic auth headers
        # to the Spatie API.
    
        internal;
        proxy_method      POST;
        proxy_set_header  Accept "application/json";
        proxy_set_header  X-Original-URI $request_uri;
        proxy_pass        https://spatie.be/api/satis/authenticate;
    }
}

The meat and bones of this NGINX config is the auth_request directive. It's used to start a subrequest authentication flow. In short this means NGINX will authenticate every request to /dist using a separate request to another server. In this case, that server is https://spatie.be/api/satis.

We're also passing the original request URL in the X-Original-URI header as that contains the requested package name. We'll use the contents of this header to determine what package the customer is trying to download and to make sure that they have actually purchased the requested package.

The /api/satis/authenticate endpoint

Now all that's left to do is handle the HTTP basic auth request on the spatie.be API. We've already got the Spatie site set-up with a License model and a $user->licenses() relationship. The API endpoint to check if a license key belongs to a user looks like this:

class SatisAuthenticationController extends Controller
{
    public function __invoke()
    {
       $licenseKey = $request->getPassword();
    
        $license = License::query()
            ->where('key', $licenseKey)
            ->first();

        abort_unless($license, 401, 'License key invalid');
        
        return response('valid', 200);
    }
}

Most of this code should feel pretty familiar, aside from the $request->getPassword() call. As explained above in "A more dynamic solution", the customer's license key will be passed as a basic auth password in the Authorization header. Thanks to a little helper method on the Request class we can easily get that password (= license key) using $request->getPassword().

This controller would also be the perfect place to check if $licenseKey is a pre-configured master key with access to all packages or to parse the X-Original-URI header to authenticate access to specific packages.

A quick test using curl or httpie shows us everything seems to be working as expected:

Without basic auth:

▶ http -h post https://spatie.be/api/satis/authenticate
HTTP/1.1 401 Unauthorized

With basic auth:

▶ http 
      --headers 
      --auth alex@spatie.be:MY-LICENSE-KEY-123 
      post https://spatie.be/api/satis/authenticate
HTTP/1.1 200 OK

Wrapping up

You've now got a private packagist repository with a cute public UI and dynamic access control using a Laravel application. Slap some SSL on that bad boy and call it a day.