Skip to content

Instantly share code, notes, and snippets.

@Jibbarth
Last active April 22, 2024 23:34
Show Gist options
  • Star 6 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save Jibbarth/b225269c02a1012ebf8c11eb4d526eb7 to your computer and use it in GitHub Desktop.
Save Jibbarth/b225269c02a1012ebf8c11eb4d526eb7 to your computer and use it in GitHub Desktop.
⚡Recreate Github CLI OAuth feature in a Symfony command ⚡

⚡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 🤗

<?php
declare(strict_types=1);
namespace App\Command;
use App\Browser;
use App\OAuth\OAuthProviderCollection;
use App\OAuth\WebServer;
use League\OAuth2\Client\Provider\AbstractProvider;
use League\OAuth2\Client\Token\AccessToken;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\HttpFoundation\Request;
class OauthLoginCommand extends Command
{
protected static $defaultName = 'app:oauth-login';
private string $accessToken;
private string $providerName = 'github';
private AbstractProvider $oauthProvider;
private OAuthProviderCollection $oauthProviderCollection;
private WebServer $webServer;
public function __construct(OAuthProviderCollection $oauthProviderCollection, WebServer $webServer)
{
$this->oauthProviderCollection = $oauthProviderCollection;
$this->webServer = $webServer;
parent::__construct(self::$defaultName);
}
protected function configure()
{
$this
->setDescription('Connect a console app with OAuth')
->addOption(
'provider',
null,
InputOption::VALUE_REQUIRED,
sprintf('OAuth provider in [%s]', implode(', ', array_keys($this->oauthProviderCollection->getAll()))),
$this->providerName
)
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$this->providerName = $input->getOption('provider');
$this->oauthProvider = $this->oauthProviderCollection->getByName($this->providerName);
$io->note('Authentication required');
$redirectUri = $this->webServer->getOAuthRedirectUri();
$url = $this->getOauthUrl($redirectUri);
$io->ask(sprintf('Press Enter to open %s in your browser...', parse_url($url)['host']));
Browser::open($url);
$callback = function (Request $request) use ($redirectUri) {
$code = $request->query->get('code');
$accessToken = $this->oauthProvider->getAccessToken('authorization_code', [
'code' => $code,
'redirect_uri' => $redirectUri
]);
$this->setAccessToken($accessToken->getToken());
};
$this->webServer->launch($callback);
$token = new AccessToken(['access_token' => $this->accessToken]);
$user = $this->oauthProvider->getResourceOwner($token);
$nameMethod = $this->getNameMethod();
$io->success('You are successfully connected. Welcome ' . $user->$nameMethod());
return 0;
}
private function getNameMethod(): string
{
switch ($this->providerName) {
case 'github':
return 'getNickName';
case 'google':
return 'getName';
default:
return 'getId';
}
}
private function getOauthUrl(string $redirectUri): string
{
return $this->oauthProvider->getAuthorizationUrl(['redirect_uri' => $redirectUri]);
}
private function setAccessToken(string $accessToken): void
{
$this->accessToken = $accessToken;
}
}
<?php
declare(strict_types=1);
namespace App\OAuth;
use League\OAuth2\Client\Provider\AbstractProvider;
final class OAuthProviderCollection
{
private array $oauthProviders;
public function __construct(iterable $oauthProviders)
{
$this->oauthProviders = iterator_to_array($oauthProviders);
}
public function getByName(string $name): AbstractProvider
{
if (!array_key_exists($name, $this->oauthProviders)) {
throw new NoOAuthProviderFound(sprintf('There is no OAuth provider found for name "%s"', $name));
}
return $this->oauthProviders[$name];
}
public function getAll(): array
{
return $this->oauthProviders;
}
}
# This file is the entry point to configure your own services.
# Files in the packages/ subdirectory configure your dependencies.
# Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices/configuration.html#application-related-configuration
parameters:
services:
# default configuration for services in *this* file
_defaults:
autowire: true # Automatically injects dependencies in your services.
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
# makes classes in src/ available to be used as services
# this creates a service per class whose id is the fully-qualified class name
App\:
resource: '../src/*'
exclude: '../src/{DependencyInjection,Entity,Migrations,Tests,Kernel.php}'
# controllers are imported separately to make sure services can be injected
# as action arguments even if you don't extend any base controller class
App\Controller\:
resource: '../src/Controller'
tags: ['controller.service_arguments']
League\OAuth2\Client\Provider\Github:
class: League\OAuth2\Client\Provider\Github
arguments:
- {clientId: '%env(OAUTH_GITHUB_ID)%', clientSecret: '%env(OAUTH_GITHUB_SECRET)%'}
tags:
- {name: 'oauth_provider', key: 'github'}
League\OAuth2\Client\Provider\Google:
class: League\OAuth2\Client\Provider\Google
arguments:
- {clientId: '%env(OAUTH_GOOGLE_ID)%', clientSecret: '%env(OAUTH_GOOGLE_SECRET)%'}
tags:
- {name: 'oauth_provider', key: 'google'}
App\OAuth\OAuthProviderCollection:
arguments: [!tagged { tag: 'oauth_provider', index_by: 'key' }]
<?php
declare(strict_types=1);
namespace App\OAuth;
use Psr\Http\Message\ServerRequestInterface;
use React\EventLoop\Factory;
use React\Http\Response;
use React\Socket\Server;
use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\Routing\RouterInterface;
final class WebServer
{
private KernelInterface $kernel;
private RouterInterface $router;
private string $hostname = '127.0.0.1';
private int $port;
public function __construct(KernelInterface $kernel, RouterInterface $router)
{
$this->kernel = $kernel;
$this->router = $router;
$this->port = $this->findBestPort();
}
public function launch(callable $callback): void
{
$loop = Factory::create();
$socket = new Server($this->hostname . ':' . $this->port , $loop);
$httpFactory = new HttpFoundationFactory();
$http = new \React\Http\Server(
function (ServerRequestInterface $request) use ($httpFactory, $loop, $callback) {
try {
$request = $httpFactory->createRequest($request);
$callback($request);
$response = $this->kernel->handle($request);
if ($response->isSuccessful()) {
$loop->futureTick(fn() => $loop->stop());
}
return new Response(
$response->getStatusCode(),
$response->headers->all(),
$response->getContent()
);
} catch (\Throwable $t) {
dump($t);
}
}
);
$http->listen($socket);
$loop->run();
}
public function getOAuthRedirectUri(): string
{
return 'http://localhost:' . $this->port . $this->router->generate('callback');
}
private function findBestPort(): int
{
$port = 8000;
while (false !== $fp = @fsockopen($this->hostname, $port, $errno, $errstr, 1)) {
fclose($fp);
if ($port++ >= 8100) {
throw new \RuntimeException('Unable to find a port available to run the web server.');
}
}
return $port;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment