Skip to content

Instantly share code, notes, and snippets.

@thebrubaker
Last active October 1, 2023 20:47
Show Gist options
  • Save thebrubaker/563d2638d1122eaca1baaa442a2cd103 to your computer and use it in GitHub Desktop.
Save thebrubaker/563d2638d1122eaca1baaa442a2cd103 to your computer and use it in GitHub Desktop.
Laravel Passport: SPA Frontend Authentication
# Added to the bottom of my file
PROXY_OAUTH_CLIENT_ID=2
PROXY_OAUTH_CLIENT_SECRET=SECRET-GENERATED-KEY-HERE
PROXY_OAUTH_GRANT_TYPE=password
<?php
namespace App\Http\Controllers\Api\Auth;
use App\Proxy\HttpKernelProxy;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use App\Http\Controllers\Api\Controller;
use Illuminate\Validation\ValidationException;
use Illuminate\Foundation\Auth\ThrottlesLogins;
class AccessTokensController extends Controller
{
use ThrottlesLogins;
/**
* A tool for proxying requests to the existing application.
*
* @var HttpKernelProxy
*/
protected $proxy;
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct(HttpKernelProxy $proxy)
{
$this->middleware('auth:api')->except(['store', 'update']);
$this->proxy = $proxy;
}
/**
* Get the login username to be used by the controller.
*
* @return string
*/
public function username()
{
return 'email';
}
/**
* Generate a new access token.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function store(Request $request)
{
$request->validate([
'username' => 'required|string',
'password' => 'required|string',
]);
if ($this->hasTooManyLoginAttempts($request)) {
$this->fireLockoutEvent($request);
return $this->sendLockoutResponse($request);
}
return $this->requestPasswordGrant($request);
}
/**
* Refresh an access token.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function update(Request $request)
{
$token = $request->cookie('refresh_token');
if (!$token) {
throw ValidationException::withMessages([
'refresh_token' => trans('oauth.missing_refresh_token')
]);
}
$response = $this->proxy->postJson('oauth/token', [
'client_id' => config('auth.proxy.client_id'),
'client_secret' => config('auth.proxy.client_secret'),
'grant_type' => 'refresh_token',
'refresh_token' => $token,
'scopes' => '[*]',
]);
if ($response->isSuccessful()) {
return $this->sendSuccessResponse($response);
}
return response($response->getContent(), $response->getStatusCode());
}
/**
* Remove the specified resource from storage.
*
* @param int $id
* @return \Illuminate\Http\Response
*/
public function destroy()
{
$this->guard()->user()->token()->revoke();
return response(null, 204);
}
/**
* Create a new access token from a password grant client.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function requestPasswordGrant(Request $request)
{
$response = $this->proxy->postJson('oauth/token', [
'client_id' => config('auth.proxy.client_id'),
'client_secret' => config('auth.proxy.client_secret'),
'grant_type' => config('auth.proxy.grant_type'),
'username' => $request->username,
'password' => $request->password,
'scopes' => '[*]'
]);
if ($response->isSuccessful()) {
$this->clearLoginAttempts($request);
return $this->sendSuccessResponse($response);
}
$this->incrementLoginAttempts($request);
return response($response->getContent(), $response->getStatusCode());
}
/**
* Return a successful response for requesting an api token.
*
* @param \Illuminate\Http\Response $response
* @return \Illuminate\Http\Response
*/
public function sendSuccessResponse(Response $response)
{
$data = json_decode($response->getContent());
$content = [
'access_token' => $data->access_token,
'expires_in' => $data->expires_in,
];
return response($content, $response->getStatusCode())->cookie(
'refresh_token',
$data->refresh_token,
10 * 24 * 60,
"",
"",
true,
true
);
}
}
<?php
namespace Tests\Api\Auth;
use App\User;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
class AccessTokenTest extends TestCase
{
use RefreshDatabase;
/**
* A basic test example.
*
* @return void
*/
public function testAccessTokenCanBeGranted()
{
// My application seeds a dummy admin user that I test against
$response = $this->post('/api/auth/access-tokens', [
'username' => env('SEED_EMAIL'),
'password' => env('SEED_PASSWORD'),
]);
$response->assertStatus(200);
$response->assertJsonStructure([
'access_token',
'expires_in',
]);
$response->assertDontSee('refresh_token');
$response->assertCookie('refresh_token');
}
}
<?php
/* Added to my config/auth.php file */
return [
/*
|--------------------------------------------------------------------------
| OAuth Proxy Authentication
|--------------------------------------------------------------------------
|
| If you are planning to use your application to self-authenticate as a
| proxy, you can define the client and grant type to use here. This is
| sometimes the case when a trusted Single Page Application doesn't
| use a backend to send the authentication request, but instead
| relies on the API to handle proxying the request to itself.
|
*/
'proxy' => [
'client_id' => env('PROXY_OAUTH_CLIENT_ID'),
'client_secret' => env('PROXY_OAUTH_CLIENT_SECRET'),
'grant_type' => env('PROXY_OAUTH_GRANT_TYPE'),
],
];
<?php
namespace App\Proxy;
use Illuminate\Support\Str;
use Illuminate\Http\Request;
use Illuminate\Contracts\Container\Container;
use Illuminate\Contracts\Http\Kernel as HttpKernel;
use Symfony\Component\HttpFoundation\Request as SymfonyRequest;
use Symfony\Component\HttpFoundation\File\UploadedFile as SymfonyUploadedFile;
class HttpKernelProxy
{
/**
* Additional headers for the request.
*
* @var array
*/
protected $defaultHeaders = [];
/**
* Additional server variables for the request.
*
* @var array
*/
protected $serverVariables = [];
/**
* Indicates whether redirects should be followed.
*
* @var bool
*/
protected $followRedirects = false;
/**
* The constructor for the proxy.
*
* @param \Illuminate\Contracts\Container\Container $app
*/
public function __construct(Container $app) {
$this->app = $app;
}
/**
* Define additional headers to be sent with the request.
*
* @param array $headers
* @return $this
*/
public function withHeaders(array $headers)
{
$this->defaultHeaders = array_merge($this->defaultHeaders, $headers);
return $this;
}
/**
* Add a header to be sent with the request.
*
* @param string $name
* @param string $value
* @return $this
*/
public function withHeader(string $name, string $value)
{
$this->defaultHeaders[$name] = $value;
return $this;
}
/**
* Flush all the configured headers.
*
* @return $this
*/
public function flushHeaders()
{
$this->defaultHeaders = [];
return $this;
}
/**
* Define a set of server variables to be sent with the requests.
*
* @param array $server
* @return $this
*/
public function withServerVariables(array $server)
{
$this->serverVariables = $server;
return $this;
}
/**
* Automatically follow any redirects returned from the response.
*
* @return $this
*/
public function followingRedirects()
{
$this->followRedirects = true;
return $this;
}
/**
* Set the referer header to simulate a previous request.
*
* @param string $url
* @return $this
*/
public function from(string $url)
{
return $this->withHeader('referer', $url);
}
/**
* Visit the given URI with a GET request.
*
* @param string $uri
* @param array $headers
* @return \Illuminate\Http\Response
*/
public function get($uri, array $headers = [])
{
$server = $this->transformHeadersToServerVars($headers);
return $this->call('GET', $uri, [], [], [], $server);
}
/**
* Visit the given URI with a GET request, expecting a JSON response.
*
* @param string $uri
* @param array $headers
* @return \Illuminate\Http\Response
*/
public function getJson($uri, array $headers = [])
{
return $this->json('GET', $uri, [], $headers);
}
/**
* Visit the given URI with a POST request.
*
* @param string $uri
* @param array $data
* @param array $headers
* @return \Illuminate\Http\Response
*/
public function post($uri, array $data = [], array $headers = [])
{
$server = $this->transformHeadersToServerVars($headers);
return $this->call('POST', $uri, $data, [], [], $server);
}
/**
* Visit the given URI with a POST request, expecting a JSON response.
*
* @param string $uri
* @param array $data
* @param array $headers
* @return \Illuminate\Http\Response
*/
public function postJson($uri, array $data = [], array $headers = [])
{
return $this->json('POST', $uri, $data, $headers);
}
/**
* Visit the given URI with a PUT request.
*
* @param string $uri
* @param array $data
* @param array $headers
* @return \Illuminate\Http\Response
*/
public function put($uri, array $data = [], array $headers = [])
{
$server = $this->transformHeadersToServerVars($headers);
return $this->call('PUT', $uri, $data, [], [], $server);
}
/**
* Visit the given URI with a PUT request, expecting a JSON response.
*
* @param string $uri
* @param array $data
* @param array $headers
* @return \Illuminate\Http\Response
*/
public function putJson($uri, array $data = [], array $headers = [])
{
return $this->json('PUT', $uri, $data, $headers);
}
/**
* Visit the given URI with a PATCH request.
*
* @param string $uri
* @param array $data
* @param array $headers
* @return \Illuminate\Http\Response
*/
public function patch($uri, array $data = [], array $headers = [])
{
$server = $this->transformHeadersToServerVars($headers);
return $this->call('PATCH', $uri, $data, [], [], $server);
}
/**
* Visit the given URI with a PATCH request, expecting a JSON response.
*
* @param string $uri
* @param array $data
* @param array $headers
* @return \Illuminate\Http\Response
*/
public function patchJson($uri, array $data = [], array $headers = [])
{
return $this->json('PATCH', $uri, $data, $headers);
}
/**
* Visit the given URI with a DELETE request.
*
* @param string $uri
* @param array $data
* @param array $headers
* @return \Illuminate\Http\Response
*/
public function delete($uri, array $data = [], array $headers = [])
{
$server = $this->transformHeadersToServerVars($headers);
return $this->call('DELETE', $uri, $data, [], [], $server);
}
/**
* Visit the given URI with a DELETE request, expecting a JSON response.
*
* @param string $uri
* @param array $data
* @param array $headers
* @return \Illuminate\Http\Response
*/
public function deleteJson($uri, array $data = [], array $headers = [])
{
return $this->json('DELETE', $uri, $data, $headers);
}
/**
* Call the given URI with a JSON request.
*
* @param string $method
* @param string $uri
* @param array $data
* @param array $headers
* @return \Illuminate\Http\Response
*/
public function json($method, $uri, array $data = [], array $headers = [])
{
$files = $this->extractFilesFromDataArray($data);
$content = json_encode($data);
$headers = array_merge([
'CONTENT_LENGTH' => mb_strlen($content, '8bit'),
'CONTENT_TYPE' => 'application/json',
'Accept' => 'application/json',
], $headers);
return $this->call(
$method, $uri, [], [], $files, $this->transformHeadersToServerVars($headers), $content
);
}
/**
* Call the given URI and return the Response.
*
* @param string $method
* @param string $uri
* @param array $parameters
* @param array $cookies
* @param array $files
* @param array $server
* @param string $content
* @return \Illuminate\Http\Response
*/
public function call($method, $uri, $parameters = [], $cookies = [], $files = [], $server = [], $content = null)
{
$kernel = $this->app->make(HttpKernel::class);
$files = array_merge($files, $this->extractFilesFromDataArray($parameters));
$symfonyRequest = SymfonyRequest::create(
$this->prepareUrlForRequest($uri), $method, $parameters,
$cookies, $files, array_replace($this->serverVariables, $server), $content
);
$response = $kernel->handle(
$request = Request::createFromBase($symfonyRequest)
);
if ($this->followRedirects) {
$response = $this->followRedirects($response);
}
$kernel->terminate($request, $response);
return $response;
}
/**
* Turn the given URI into a fully qualified URL.
*
* @param string $uri
* @return string
*/
protected function prepareUrlForRequest($uri)
{
if (Str::startsWith($uri, '/')) {
$uri = substr($uri, 1);
}
if (! Str::startsWith($uri, 'http')) {
$uri = config('app.url').'/'.$uri;
}
return trim($uri, '/');
}
/**
* Transform headers array to array of $_SERVER vars with HTTP_* format.
*
* @param array $headers
* @return array
*/
protected function transformHeadersToServerVars(array $headers)
{
return collect(array_merge($this->defaultHeaders, $headers))->mapWithKeys(function ($value, $name) {
$name = strtr(strtoupper($name), '-', '_');
return [$this->formatServerHeaderKey($name) => $value];
})->all();
}
/**
* Format the header name for the server array.
*
* @param string $name
* @return string
*/
protected function formatServerHeaderKey($name)
{
if (! Str::startsWith($name, 'HTTP_') && $name != 'CONTENT_TYPE' && $name != 'REMOTE_ADDR') {
return 'HTTP_'.$name;
}
return $name;
}
/**
* Extract the file uploads from the given data array.
*
* @param array $data
* @return array
*/
protected function extractFilesFromDataArray(&$data)
{
$files = [];
foreach ($data as $key => $value) {
if ($value instanceof SymfonyUploadedFile) {
$files[$key] = $value;
unset($data[$key]);
}
if (is_array($value)) {
$files[$key] = $this->extractFilesFromDataArray($value);
$data[$key] = $value;
}
}
return $files;
}
/**
* Follow a redirect chain until a non-redirect is received.
*
* @param \Illuminate\Http\Response $response
* @return \Illuminate\Http\Response
*/
protected function followRedirects($response)
{
while ($response->isRedirect()) {
$response = $this->get($response->headers->get('Location'));
}
$this->followRedirects = false;
return $response;
}
}
// My bad solution on the frontend SPA
const loginPayload = {
username: '[email protected]',
password: 'secret',
client_id: 1,
client_secret: 'the-client-secret-here',
grant_type: 'password',
scope: '*',
}
@martinherweg
Copy link

Great article on medium and thanks for the gist, it would be interesting to see your frontend implentation of this as well :)

@kaidesu
Copy link

kaidesu commented Nov 15, 2018

@luceos I thought the same, but quickly realized you can't test against that implementation with Guzzle (though I'm sure you could mock the request in your tests) - with the proxy it will properly resolve to the correct endpoint for you. Otherwise when testing, the request is sent to your local dev environment, resulting in tests always failing.

Instead of creating a class that's injected, I moved the proxy class above to a MakesHttpRequests trait to clean things up.

@MovingGifts
Copy link

MovingGifts commented Dec 19, 2018

Does anyone know when we are supposed to call AccessTokensController@update to refresh the token? I don't see anywhere where this should be called in the code. Any idea?
cc: @thebrubaker @luceos @mrb2590 @robjbrain @martinherweg @kaidesu

@lHumanizado
Copy link

My question is, how do you protect that route from other people to consume it?

@sergiuwd
Copy link

sergiuwd commented Mar 8, 2019

My question is, how do you protect that route from other people to consume it?

Maybe by disabling CORS on that route if the Vue app hosted within Laravel or, if the Vue app is stored somewhere else, by creating a custom middleware that would check if the request is made by that server/referer.

@lorisleiva
Copy link

You don't even need Guzzle for that. Laravel applications can proxy their own requests.

$proxyRequest = Request::create('/oauth/token', 'POST', [
    'grant_type' => 'password',
    'username' => $request->email,
    'password' => $request->password,
    'client_id' => $request->client_id,
    'client_secret' => $request->client_secret,
]);

$response = app()->handle($proxyRequest);

@ndberg
Copy link

ndberg commented Jan 10, 2020

Laravel Passport Supports now Authorization Code Grant with PKCE which is the preferred way to go: https://laravel.com/docs/6.x/passport#code-grant-pkce

@yelnyafacee
Copy link

Although I totally stole your concept, I'm not sure implementing the Proxy Client makes any sense. You could have easily instantiated Guzzle to do all this:

    public function login(Request $request, Client $client)
    {
        $this->validateLogin($request);

        if ($this->hasTooManyLoginAttempts($request)) {
            $this->fireLockoutEvent($request);

            return $this->sendLockoutResponse($request);
        }

        /** @var \Laravel\Passport\Client $proxy */
        $proxy = app('proxy.grant.client');

        $response = $client->post(config('app.url') . '/oauth/token', [
            'form_params' => [
                'client_id' => $proxy->id,
                'client_secret' => $proxy->secret,
                'grant_type' => 'password',
                'username' => $request->email,
                'password' => $request->password,
                'scopes' => '[*]'
            ]
        ]);

        if ($response->getStatusCode() === 200) {
            $this->clearLoginAttempts($request);

           return response($response->getBody()->getContents(), $response->getStatusCode());
        }

        $this->incrementLoginAttempts($request);

        return response($response->getBody()->getContents(), $response->getStatusCode());
    }

hi, am new to this, dont quite understand how this code snippets work

should I replace app('proxy.grant.client'); with app('\Laravel\Passport\Client'); ?

which class should I import for Client $client? Thanks

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment