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.