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: '*',
}
@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