Laravel Passport authorization with Inertia.js

But why?

Passport by default only supports a blade template for the authorization view. The rest of the app I was working with was using Inertia.js, Vue3 and Tailwind CSS with component based styling, meaning the elements used in the blade template would not look and feel the same as the rest. So instead of duplicating all the components and markup I needed in the authorization view from Vue to Blade, I set out to make it all work in Vue.

What are my options?

In the installation guide for passport they want routes to be added in the AuthServiceProvider using the Passport::routes(); method. What they do not mention is the fact that this method has two optional parameters: one for a callback function and one for Route::group options. We can use these to achieve our goal, but I opted to go with just the callback.

My solution

The callback will receive a $router parameter, which is the Passport RouteRegistrar. By default the callback will just call the all-method on this object, which looks like this:

/**
 * Register routes for transient tokens, clients, and personal access tokens.
 *
 * @return void
 */
public function all()
{
    $this->forAuthorization();
    $this->forAccessTokens();
    $this->forTransientTokens();
    $this->forClients();
    $this->forPersonalAccessTokens();
}

Each of these methods will register the required routes for their functionality. The one we are after is the forAuthorization-method. The rest can be registered as normal. My AuthServiceProvider now looks like this:

<?php

namespace App\Providers;

use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Laravel\Passport\Passport;

class AuthServiceProvider extends ServiceProvider
{
    /**
     * Register any authentication / authorization services.
     *
     * @return void
     */
    public function boot()
    {
        $this->registerPolicies();

        Passport::routes(function ($router) {
            $router->forAccessTokens();
            $router->forTransientTokens();
            $router->forClients();
            $router->forPersonalAccessTokens();
        });
    }
}

Now we can add the authorization routes separately. We reference the original Approve and Deny controllers from Passport, but substitute the Authorize controller with our own.

use Laravel\Passport\Http\Controllers\ApproveAuthorizationController;
use Laravel\Passport\Http\Controllers\DenyAuthorizationController;

Route::middleware('auth')->group(function () {
    Route::get('/oauth/authorize', [PassportInertiaController::class, 'authorize'])->name('passport.authorizations.authorize');
    Route::post('/authorize', [ApproveAuthorizationController::class, 'approve'])->name('passport.authorizations.approve');
    Route::delete('/authorize', [DenyAuthorizationController::class, 'deny'])->name('passport.authorizations.deny');
});

Our controller will extend the original AuthorizationController from Passport, but we override the route callback method to make it work nicely with Inertia.js. The main difference is that we return Inertia::render instead of the blade view, but we also need to intercept the redirection when authorization is skipped as Inertia.js requires the use of Inertia::location to properly redirect off-site.

<?php

namespace App\Http\Controllers;

use Illuminate\Support\Str;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Psr\Http\Message\ServerRequestInterface;
use Laravel\Passport\ClientRepository;
use Laravel\Passport\Http\Controllers\AuthorizationController;
use Laravel\Passport\TokenRepository;

class PassportInertiaController extends AuthorizationController
{
    /**
     * Authorize a client to access the user's account.
     *
     * @param  \Psr\Http\Message\ServerRequestInterface  $psrRequest
     * @param  \Illuminate\Http\Request  $request
     * @param  \Laravel\Passport\ClientRepository  $clients
     * @param  \Laravel\Passport\TokenRepository  $tokens
     * @return \Illuminate\Http\Response
     */
    public function authorize(ServerRequestInterface $psrRequest, Request $request, ClientRepository $clients, TokenRepository $tokens)
    {
        $authRequest = $this->withErrorHandling(function () use ($psrRequest) {
            return $this->server->validateAuthorizationRequest($psrRequest);
        });

        $scopes = $this->parseScopes($authRequest);

        $token = $tokens->findValidToken($user = $request->user(), $client = $clients->find($authRequest->getClient()->getIdentifier()));

        if (($token && $token->scopes === collect($scopes)->pluck('id')->all()) || $client->skipsAuthorization()) {
            $response = $this->approveRequest($authRequest, $user);
            if ($response->isRedirection() && $request->header('X-Inertia')) {
                return Inertia::location($response->headers->get('Location'));
            }

            return $response;
        }

        $request->session()->put('authToken', $authToken = Str::random());
        $request->session()->put('authRequest', $authRequest);

        return Inertia::render('Passport/Authorize', [
            'client' => $client,
            'user' => $user,
            'scopes' => $scopes,
            'request' => $request,
            'authToken' => $authToken,
            'csrfToken' => csrf_token(),
            'route' => [
                'approve' => route('passport.authorizations.approve'),
                'deny' => route('passport.authorizations.deny'),
            ]
        ])->toResponse($request);
    }
}

Now all we need is the actual Vue template to render the authorization view. This is more or less just a Vue-ified version of the original blade template from Passport.

<template>
	<h1>Authorization Request</h1>
	<p><strong>{{ $page.props.client.name }}</strong> is requesting permission to access your account.</p>

	<template v-if="$page.props.scopes.length > 0">
		<p><strong>This application will be able to:</strong></p>

		<ul>
			<li v-for="scope in $page.props.scopes" :key="scope">{{ scope.description }}</li>
		</ul>
	</template>

	<!-- Authorize Button -->
	<form method="post" :action="$page.props.route.approve">
		<input type="hidden" name="_token" :value="$page.props.csrfToken" />
		<input type="hidden" name="state" :value="$page.props.request.state" />
		<input type="hidden" name="client_id" :value="$page.props.client.id" />
		<input type="hidden" name="auth_token" :value="$page.props.authToken" />
		<button type="submit">Authorize</button>
	</form>

	<!-- Cancel Button -->
	<form method="post" :action="$page.props.route.deny">
		<input type="hidden" name="_method" value="DELETE" />
		<input type="hidden" name="_token" :value="$page.props.csrfToken" />
		<input type="hidden" name="state" :value="$page.props.request.state" />
		<input type="hidden" name="client_id" :value="$page.props.client.id" />
		<input type="hidden" name="auth_token" :value="$page.props.authToken" />
		<button type="submit">Cancel</button>
	</form>
</template>

Conclusion

The solution works, which was my goal, but there is stil room for improvements. In the future I would like to experiment with another solution where I utilize the other parameter for Passport::routes to set my own namespace for the Authorization views, just to clean up my routes, and then maybe get it to work with Inertia.js Forms and not the HTML forms where the csrf token has to be passed down.