Jibé Barth

🇫🇷 Web developer

⚡Recreate Github CLI OAuth feature in a Symfony command ⚡

Published Feb 16, 2020

⚡Recreate Github CLI OAuth feature in a Symfony command ⚡

Github recently released a CLI tool to manage issues and PR directly from your terminal. As I work on some open source projects, I downloaded it to give a try.

And at first launch, the CLI ask to connect by using OAuth. It propose to press "Enter" to open github.com in my browser, and catch correctly the access_token.

That .. blown my mind 🤯 I didn't expect we can connect through terminal like this. So, as it's open source, I dived into the code source.

There is two main feature to handle authorization like this. First, you have to launch your browser to a specified url. Then, you have to handle response on redirected uri.

Let's see how to reproduce this into a Symfony Application.

Initialization

Create a symfony project from skeleton, and create a command into it.

composer create-project symfony/skeleton cli-oauth
cd cli-oauth
composer req maker
php bin/console make:command app:oauth-login

Clean a little the command, to get ready to work :

// src/Command/OAuthLoginCommand.php
<?php

declare(strict_types=1);

namespace App\Command;

use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;

final class OauthLoginDemoCommand extends Command
{
    protected static $defaultName = 'app:oauth-login-demo';

    protected function configure()
    {
        $this
            ->setDescription('Login via OAuth')
        ;
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $io = new SymfonyStyle($input, $output);
        $io->note('Authentication Required');
       
        // TODO

        $io->success('You are successfully connected.');

        return 0;
    }
}

Open browser

To open browser from Terminal, we could launch it via a Process.

composer require symfony/process

Then, if I use Google Chrome, I can launch it like this:

use Symfony\Component\Process\Process;
$process = new Process(['google-chrome', 'http://github.com']);
$process->run();

It's works, but not anyone use Google Chrome. We have to use default browser for user. To do this, we have to check if a BROWSER is in environnement variables, or fallback into default system mechanism.

Let's create a Browser class:

//src/Browser.php
<?php

declare(strict_types=1);

namespace App;

use Symfony\Component\Process\Process;

final class Browser
{
    public function open(string $url): void
    {
        // Validate URL
        if (false === filter_var($url, FILTER_VALIDATE_URL)) {
            throw new \InvalidArgumentException(sprintf('"%s" is not a valid URL', $url));
        }
        
        $process = $this->getCommand($url);
        $process->run();
    }
    
    private function getCommand(string $url): Process
    {
        $browser = getenv('BROWSER');
        if ($browser) {
            return new Process([$browser, $url]);
        }
        return $this->systemFallBack($url);
    }
    
    private function systemFallBack($url): Process
    {
        switch (PHP_OS_FAMILY) {
            case 'Darwin':
                return new Process(['open', $url]);
            case 'Windows':
                return new Process(['cmd', '/c', 'start', $url]);
            default:
                return new Process(['xdg-open', $url]);
        }
    }
}

And call it from our command :

//src/Command/OAuthLogin.php
// ...
    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        // ...
        $io->note('Authentication Required');
        $io->ask('Press enter to open github.com in your browser...');
        (new Browser())->open('http://github.com')
        // TODO
        // ...

The first feature is implemented. Let's see how we can retrieve access_token !

Create a local WebServer on demand

As we can see when using [Github CLI], the OAuth flow redirect to http://localhost:custom_port

So we need to create a WebServer on localhost, to be able to catch code.

To do this, we'll use reactphp:

composer require react/http

And let's create a basic WebServer class:

//src/OAuth/WebServer.php
<?php

declare(strict_types=1);

namespace App\OAuth;

use Psr\Http\Message\ServerRequestInterface;
use React\EventLoop\Factory;
use React\Http\Response;
use React\Http\Server as HttpServer;
use React\Socket\Server as SocketServer;

final class WebServer
{
    public function launch(): void
    {
        $loop = Factory::create();
        $socket = new SocketServer('127.0.0.1:8000', $loop);
        $http = new HttpServer(
            static function(ServerRequestInterface $request) {
                // TODO handle $request
                return new Response(200, ['content-type' => 'text/plain'], 'Hello World');
            }
        );
        $http->listen($socket);
        $loop->run();
    }
}

Use it together with our new Browser class in the command:

// src/Command/OAuthLoginCommand.php
// ...
    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        // ...
        $io->note('Authentication Required');
        $io->ask('Press enter to open github.com in your browser...');
        $browser = new Browser();
        $server = new Webserver();
        $browser->open('http://localhost:8000');
        $server->launch();

By launching command, a new tab should open in the browser, and display "Hello World".

Congrats, you can now implement your OAuth logic !

Handle OAuth logic with Github

Let's begin by create a new OAuth app on github. In app settings, set http://localhost/ for Authorization callback URL.

Add credentials into the .env file

#.env
#...
OAUTH_GITHUB_ID=githubappid
OAUTH_GITHUB_SECRET=githubappsecret

Then, require league/oauth2-client and league/oauth2-github :

composer require league/oauth2-client league/oauth2-github

And create a service to configure the Github provider:

# config/services.yaml

services:
    #...
    League\OAuth2\Client\Provider\Github:
        class: League\OAuth2\Client\Provider\Github
        arguments:
            - {clientId: '%env(OAUTH_GITHUB_ID)%', clientSecret: '%env(OAUTH_GITHUB_SECRET)%'}

When github will redirect you on your custom WebServer, we need to intercept request to retrieve information. We could do this by add a callable in function launch:

// src/OAuth/WebServer.php
// ...

    public function launch(callable $callback): void
    {
        // ...
        $http = new HttpServer(
            static function(ServerRequestInterface $request) use ($callback) {
                $callback($request);
                // stop loop after return response
                $loop->futureTick(fn() => $loop->stop());
                
                return new Response(200, ['content-type' => 'text/plain'], 'You can now close this tab.');
            }
        );
        // ...
    }

Back to our OAuthLoginCommand, inject the Github Provider, create a callback, and pass the good url to the browser:

// src/Command/OAuthLoginCommand.php
// ...
use League\OAuth2\Client\Provider\Github;
// ...
    private Github $github;
	private string $accessToken;    

    public function __construct(Github $github)
    {
        $this->github = $github;
    }
    // ...
    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        // ...
        $io->note('Authentication Required');
        $io->ask('Press enter to open github.com in your browser...');
        // github authorization url
            $githubUrl = $this->github->getAuthorizationUrl(['redirect_uri' => 'http://localhost:8000']);
            $callback = function (ServerRequestInterface $request) {
            $code = $request->getQueryParams()['code'];
            $accessToken = $this->github->getAccessToken('authorization_code', [
                'code' => $code
            ]);
            $this->accessToken = $accessToken->getToken();
        };
        
        (new Browser())->open($githubUrl);
        (new WebServer())->launch($callback);

        if (null === $this->accessToken) {
            throw new \LogicException('Unable to fetch accessToken');
        }
        
        // Now you should have the accessToken. 
        // Retrieve resourceOwner and display it
        $token = new AccessToken(['access_token' => $this->accessToken]);
        $user = $this->github->getResourceOwner($token);
		
        $io->success('You are successfully connected. Welcome ' . $user->getNickName());
    }

You should now have a functionnal flow:

cli-oauth

Going further

I'll stop here for explanation. But, you can go further. You can find below my source code, a way to manage multiple oauth-providers, and to use a Symfony Controller Route for a better rendering on redirect.

Note also that there is not storing for accessToken, maybe we should store it somewhere and reuse it instead of reopen browser each time we launch command.

Thanks for reading 🤗