Implementing Spatie's Laravel-Passkey package in Inertia (Vue)

Spatie Laravel-Passkey

Implementing passkeys in Inertia

Spatie’s Laravel-Passkey package makes implementing passkeys easy, but the documentation for implementing it with Inertia didn’t really do it for me. So, here is how I cleanly implemented it for an Inertia project with VueJS, Typescript.

Installation

Run the following:

composer require spatie/laravel-passkeys
php artisan vendor:publish --tag="passkeys-migrations"
php artisan migrate
npm install @simplewebauthn/browser

User Model Updates

Don’t forget this step - kind of important:

// make sure you have HasPassKeys
class User extends Authenticatable implements MustVerifyEmail, HasPasskeys
{
    // and the trait InteractsWithPasskeys
    use HasFactory, Notifiable, InteractsWithPasskeys;
}

Setting Up Typescript

Go into your resources/js/types/global.d.ts file and drop in the following (or add to Window if it exists):

declare global {
    interface Window {
        browserSupportsWebAuthn?: () => boolean;
        startAuthentication: (options: any) => Promise<AuthenticationResponseJSON>;
        startRegistration: (options: any) => Promise<RegistrationResponseJSON>;
    }
}

From here, open up the page(s) where you need to interface with passkeys. For me, I used pages Passkeys.vue and Login.vue:

import { browserSupportsWebAuthn, startAuthentication, startRegistration } from '@simplewebauthn/browser';

window.browserSupportsWebAuthn = browserSupportsWebAuthn;
window.startAuthentication = startAuthentication;
window.startRegistration = startRegistration;

Configuring Routes

Under the hood of the package, Spatie registers a few default routes via it’s ServiceProvider. It looks like this:

// vendor/spatie/laravel-passkeys/src/LaravelPasskeysServiceProvider.php
protected function registerPasskeyRouteMacro(): self
{
    Route::macro('passkeys', function (string $prefix = 'passkeys') {
        Route::prefix($prefix)->group(function () {
            Route::get(
                'authentication-options',
                GeneratePasskeyAuthenticationOptionsController::class
            )->name('passkeys.authentication_options');

            Route::post(
                'authenticate',
                AuthenticateUsingPasskeyController::class
            )->name('passkeys.login');
        });
    });

    return $this;
}

We just need to register the routes. In routes/web.php

// Register default routes
Route::passkeys();

There are some things we do need, though: the views and a single point for Registering a passkey. For this, I added them to routes/settings.php but you can include them anywhere in your application. Here are all three you’ll want to register:

// This is the page for my users to view the passkeys
Route::get('settings/passkeys', [ProfileController::class, 'viewPassKeys'])->name('settings.passkeys');
// This is an endpoint to store a new passkey
Route::post('settings/passkeys', [ProfileController::class, 'storePasskey'])->name('settings.passkeys.store');
// This is the endpoint to remove a passkey that was added to an account
Route::delete('settings/passkeys/{passkey}', [ProfileController::class, 'deletePassKey'])->name('settings.passkeys.delete');
// Register endpoint - don't forget this.
Route::get('passkey-options', [AuthenticatedSessionController::class, 'passkeyOptions'])->name('auth.passkeys.authentication_options');

Adding Passkeys

Ok, let’s jump into the inertia end in Passkeys.vue. Since I’m using Ziggy, my routes are available via the route function. Since it’s registered with a name, we can drop it in.

const addPassKey = async () => {
    // use auth.passkeys.authentication_options - NOT the other one. The other one is for logging in.
    const passkeyOptions = await fetch(route('auth.passkeys.authentication_options'));
    const options = await passkeyOptions.json();
    const startAuthenticatingPasskey = await startRegistration({
        optionsJSON: options
    });

    // Use inertias FormHelper to create requests
    const form = useForm({
        options: JSON.stringify(options),
        passkey: JSON.stringify(startAuthenticatingPasskey),
    });

    // call our store function
    form.post(route('settings.passkeys.store'), {
        preserveScroll: true,
    });
};

And here is the PHP side. You need both of these.

// inside of AuthenticatedSessionController
public function passkeyOptions(Request $request)
{
    return app(GeneratePasskeyRegisterOptionsAction::class)->execute($request->user());
}
// my settings controller
public function storePasskey(Request $request)
{
    $request->validate([
        'options' => 'required|json',
        'passkey' => 'required|json',
    ]);

    $storePasskeyAction = app(StorePasskeyAction::class);
    $storePasskeyAction->execute(
        $request->user(),
        $request->input('passkey'),
        $request->input('options'),
        $request->getHost(),
        // note: you can customize this if you want, just prompt for a name after you have the the JSON payloads ready.
        ['name' => $request->input('name', 'Passkey added on '.now()->toDateTimeString())]
    );

    return to_route('settings.passkeys')->with('success', 'Passkey added successfully!');
}

That’s it - you’re ready to go! Hook it up to a button and fire away:

<Button @click="addPassKey">
    Add Passkey
</Button>

Removing Passkeys

This one is also simple - just make sure you’re actually scoped into the users passkeys.

public function deletePassKey(string $id, Request $request)
{
    $request->user()->passkeys()->where('id', $id)->delete();

    return to_route('settings.passkeys')->with('success', 'Passkey deleted successfully!');
}

Login

After making sure you can add a passkey, add a button to your Login.vue file to login with passkeys. Here is all you need:

const loginWithPasskey = async () => {
    // remember: this route was given to us via the Spatie package. Not the other one for registering.
    const authenticationOptions = await fetch(route('passkeys.authentication_options'));
    const options = await authenticationOptions.json();

    const startAuthenticationResponse = await window.startAuthentication({optionsJSON: options});

    const form = useForm({
        start_authentication_response: JSON.stringify(startAuthenticationResponse),
    });

    // This one too was registered for us - we dont have to do anything
    form.post(route('passkeys.login'));
};

And the button to kickstart the process:

<Button
    v-if="browserSupportsWebAuthn()"
    type="button"
    class="mt-4 w-full"
    variant="outline"
    :disabled="form.processing"
    @click="loginWithPasskey"
>
    <LoaderCircle v-if="form.processing" class="h-4 w-4 animate-spin" />
    Log in with Passkey
</Button>

All Done

That’s it! Inertia ready, simple passkeys.