Scout APM is PHP application performance monitoring designed for developers. With tracing logic that ties issues back to the line of code causing them, you can pinpoint n+1 queries, memory leaks, and other abnormalities in real time so you can knock them out and get back to building a great product. Start your free 14-day trial today and get the performance insight you need in less than 4 minutes.

Selling digital products using Laravel part 5: Using Satis to install private packages

Original – by Freek Van der Herten – 7 minute read

Some of our products, like Mailcoach and Media Library Pro, are PHP packages for which people can buy a time-limited license. Of course, we want customers to be able to install our paid packages using Composer. Our paid packages are not registered on Packagist, because Packagist is meant to be used for free packages, and there's no way to handle licenses. Let's take a look at how we can solve this using Satis.

Satis is an open-source Composer repository generator. You can think of it as a very lightweight, self-hosted version of Packagist that you can configure any way you want. We installed satis at satis.spatie.be

For our paid packages, we instruct users to put this in their composer.json file.

"repositories": [
    {
        "type": "composer",
        "url": "https://satis.spatie.be"
    }
],

This will make Composer look for packages - that it can't find on packagist.org - on satis.spatie.be. As a human, you can also check what's available on our Satis installation by visiting https://satis.spatie.be.

Satis itself is dead simple to configure. All it needs is a satis.json file that contains the repositories that should be made available through it. Here is the content of satis.json

{
  "name": "spatie/satis.spatie.be",
  "homepage": "https://satis.spatie.be",
  "output-dir": "public",
  "repositories": [
    { "type": "vcs", "url": "https://github.com/spatie/mailcoach" },
    { "type": "vcs", "url": "https://github.com/spatie/mailcoach-ui" },
    { "type": "vcs", "url": "https://github.com/spatie/laravel-mailcoach" },
    { "type": "vcs", "url": "https://github.com/spatie/laravel-mailcoach-mailgun-feedback" },
    { "type": "vcs", "url": "https://github.com/spatie/laravel-mailcoach-ses-feedback" },
    { "type": "vcs", "url": "https://github.com/spatie/laravel-mailcoach-sendgrid-feedback" },
    { "type": "vcs", "url": "https://github.com/spatie/laravel-mailcoach-postmark-feedback" },
    { "type": "vcs", "url": "https://github.com/spatie/laravel-mailcoach-unlayer" },
    { "type": "vcs", "url": "https://github.com/spatie/laravel-mailcoach-monaco" },
    { "type": "vcs", "url": "https://github.com/spatie/laravel-medialibrary-pro" }
  ],
  "archive": {
    "directory": "dist",
    "skip-dev": false
  },
  "require-all": true
}

Giving Satis access to private repositories

If you'd only want to use public repositories, then this configuration would be enough. But, in our case, those repositories are private. We need to give Satis the credentials to be able to access those private repos. Under the hood, Satis uses Composer to fetch the packages. You can give Composer the right credentials by creating an auth.json file and put that in Composer's home directory.

On GitHub, you should create a personal access token on a user that has access to the private repos. That token should be put in auth.json. On our server, this is the content of ~/.composer/auth.json

{
    "github-oauth": {
        "github.com": "put-your-github-token-here"
    }
}

With this in place, Satis can access the private GitHub repositories. In the satis.json above, setting the archive.directory option to dist will make Satis copy all released versions of a repository to the public/dist directory. For all releases of a repo, a zip file will be created.

Authorizing package downloads

If we left it like this, everyone would be able to download the zips in the dist directory. We don't want that. Only people with a valid license for a particular product should be able to download the zips that contain that product.

We use Nginx to serve Satis. In the Nginx config for we've put these lines in the server block.

server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name satis.spatie.be;
    server_tokens off;
    root /home/forge/satis.spatie.be/public;
    
    # Some other stuff
    # ...
    
    location /dist {
        auth_request /auth; 
        
        try_files $uri $uri/ /index.php?$query_string;
    }
    
    location = /auth {
        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;
    }
}

With his configuration, Satis will not serve up the zip files in the dist directory anymore for anyone. When a request comes in for a file in /dist, Nginx will call the internal /auth endpoint first. This internal endpoint will pass on the original request to https://spatie.be/api/satis/. If that endpoint on spatie.be returns a 2XX response, the request to download the zip is allowed; otherwise, it is denied. If you want to know more about this kind of authentication, head over to the relevant Nginx docs.

Handling license requests in Laravel

Let's take a look at the route that handles the request to /api/satis/.

Route::post('satis/authenticate', SatisAuthenticationController::class)->middleware('auth:license-api');

We're using the license-api guard to authenticate the request. In the auth.php config file, we've set things up so that the license-api guard uses the license-key driver.

'guards' => [
     // ...

    'license-api' => [
        'driver' => 'license-key',
    ],

The license-key driver is defined in AuthServiceProvider.

class AuthServiceProvider extends ServiceProvider
{
    public function boot()
    {
        Auth::viaRequest('license-key', function (Request $request) {
            $license = License::query()
                ->where('key', $request->getPassword())
                ->first();

            if (! $license) {
                abort(401, 'License key invalid');
            }

            if ($license->isExpired()) {
                abort(401, 'This license is expired');
            }

            $license->increment('satis_authentication_count');

            return $license;
        });
    }
}

The above license-key driver will try to find a license whose key matches the password used for the request. You might find it strange that we use the password there. Well, when Composer tries to authenticate, it will ask the user for a password. We instruct our users to put their license key there.

When no valid license is found, we abort the request and send an unauthorized response. If a valid license is found, the auth middleware will not stop the request, and eventually, the SatisAuthenticationController is called. Here is the full code of that controller. I've put in some comments to explain what is going on.

namespace App\Http\Api;

use App\Models\License;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;

class SatisAuthenticationController extends Controller
{
    public function __invoke(Authenticatable $license, Request $request)
    {
        /** @var $license \App\Models\License */
        if (! $license instanceof License) {
            abort(401);
        }

        /*
         * We have a master key that we use for our projects that grants
         * us access to anything available on Satis.
         */
        if ($license->isMasterKey()) {
            return response('valid');
        }

        /*
         * Find the package that is being requested.
         */
        $package = $this->getRequestedPackage($request);

        /*
         * Composer can only store one authentication method per repository.
         * This means the user is probably going to try to authenticate with a license
         * key for the wrong package. We have to check the user's other licenses
         * as well.
         */
        $hasAccess = License::query()
            ->with(['purchasable'])
            ->whereNotExpired()
            ->where('user_id', $license->user_id)
            ->get()
            ->contains(
                fn (License $license) => $license->purchasable->includesPackageAccess($package)
            );

        abort_unless($hasAccess, 401);

        return response('valid');
    }

    protected function getRequestedPackage(Request $request): string
    {
        $originalUrl = $request->header('X-Original-URI', '');

        preg_match('#^/dist/(?<package>spatie/[^/]*)/#', $originalUrl, $matches);

        if (! key_exists('package', $matches)) {
            abort(401, 'Missing X-Original-URI header');
        }

        return $matches['package'];
    }
}

If this controller determines that the user can access the requested package, a response with code 200 is returned. This will signal to Nginx that the requested zip in the dist directory may be downloaded. When a license has expired, SatisAuthenticationController will not return a 200 response anymore, and the user loses access to the package.

And that is how we use Satis to handle private packages that have a time-limited license.

This series is continued in part 6: Building a video section using Vimeo.

Stay up to date with all things Laravel, PHP, and JavaScript.

Follow me on Twitter. I regularly tweet out programming tips, and what I myself have learned in ongoing projects.

Every two weeks I send out a newsletter containing lots of interesting stuff for the modern PHP developer.

Expect quick tips & tricks, interesting tutorials, opinions and packages. Because I work with Laravel every day there is an emphasis on that framework.

Rest assured that I will only use your email address to send you the newsletter and will not use it for any other purposes.

Comments

Webmentions

Percy Astocaza retweeted on 20th October 2020
Percy Astocaza liked on 20th October 2020
Derick Alangi © liked on 20th October 2020
Paulund liked on 20th October 2020
Istvan Szana liked on 20th October 2020
Burhan liked on 20th October 2020
sandip liked on 20th October 2020
Rati Wannapanop liked on 20th October 2020
Beeen E nya tiga liked on 20th October 2020
Sam Snelling liked on 20th October 2020
Shane Oliver liked on 20th October 2020
merlot99 liked on 20th October 2020
Spatie retweeted on 20th October 2020
Zach Williams liked on 19th October 2020
Guus liked on 19th October 2020
Bert van Hoekelen liked on 19th October 2020
Placebo Domingo retweeted on 19th October 2020
Alejandro Pérez liked on 19th October 2020
Philipp Grimm liked on 19th October 2020
Alejandro Pérez replied on 19th October 2020
This is extremely valuable information!! The approach for handling access to private repos looks both simple and powerful. Thanks for sharing!
Toan Nguyen retweeted on 19th October 2020
Domingos Cruz liked on 19th October 2020
Niels liked on 19th October 2020
José Cage retweeted on 19th October 2020
leunggamciu retweeted on 19th October 2020
Kevin Ullyott liked on 19th October 2020
José Cage liked on 19th October 2020
Steve Bauman liked on 19th October 2020
Jesil Jose liked on 19th October 2020
Michael Aguiar liked on 19th October 2020
imagdic liked on 19th October 2020
Andrés Santibáñez retweeted on 19th October 2020
PHP Synopsis retweeted on 19th October 2020
Kamau Wanyee retweeted on 19th October 2020
Axel Pardemann retweeted on 19th October 2020
Jiří Žižka liked on 19th October 2020
Tipu Danny liked on 19th October 2020
Axel Pardemann liked on 19th October 2020
jaccogardner7 liked on 19th October 2020
Wade Striebel liked on 19th October 2020
江亞倫 liked on 19th October 2020
Momen Albelbeisi retweeted on 19th October 2020
Christopher Pearse liked on 19th October 2020
ali ali liked on 19th October 2020
Luiyi Nuñez 🇩🇴 liked on 19th October 2020
Rens Bultynck liked on 19th October 2020
Tom de Wit liked on 19th October 2020
Paulius Jasiulis retweeted on 19th October 2020
Paulius Jasiulis liked on 19th October 2020
Bader retweeted on 19th October 2020
Oung liked on 19th October 2020
Tauseef shah liked on 19th October 2020
Bader liked on 19th October 2020
mohamed amine liked on 19th October 2020
Richard Radermacher liked on 19th October 2020
ahgood liked on 19th October 2020
ArielMejiaDev retweeted on 19th October 2020
dSoto liked on 19th October 2020
ArielMejiaDev liked on 19th October 2020
Claudio Dekker liked on 19th October 2020
Daniel Lucas liked on 19th October 2020
Luigi Cruz liked on 19th October 2020
Miguel Piedrafita 🚀 liked on 19th October 2020
Robin Dirksen liked on 13th October 2020
Richard Radermacher liked on 13th October 2020