⚠️ Warning ⚠️
This article was written 4 years and 9 months ago. The informations it contains may be outdated or no longer relevant.
⚡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:
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 🤗