Skip to content
Brok UI

Loading…

No results

Passkey

Open source

A backend-agnostic WebAuthn ceremony: sign in with or add a passkey against your own options and verify endpoints, or laravel/passkeys through a preset, with clear pending, unsupported, cancelled, error and success states.

Version
v1.1.2
Stability
stable
License
MIT
Related
Button
Form
Input OTP

Preview

previews.components.passkey.default.blade.php Blade
{{-- Sign in with a passkey. Point the URLs at your WebAuthn endpoints. The
     docs site answers these URLs with demo endpoints that keep no accounts:
     the browser prompt is real, and verification always explains why it
     cannot sign you in. --}}
<div class="w-full max-w-xs">
    <x-ui.passkey options-url="/passkeys/login/options" verify-url="/passkeys/login" redirect="/dashboard" />
</div>

Installation

terminal
php artisan ui:add passkey

Note

This component ships an Alpine behavior module at resources/js/ui/passkey.js. Import it once from your bundle so it registers on alpine:init:

resources/js/ui/index.js JS
import './passkey.js';

Registry contract

php artisan ui:add passkey writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.

  • blade resources/views/components/ui/passkey.blade.php
  • blade resources/views/components/ui/passkey/button.blade.php
  • blade resources/views/components/ui/passkey/status.blade.php
  • js resources/js/ui/passkey.js
Registry dependencies
button
Packages
composer: jml/brok:^0.2
npm: alpinejs

Use with AI

A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.

passkey.md
# Brok UI: Passkey (`passkey`)

A backend-agnostic WebAuthn ceremony: sign in with or add a passkey against your own options and verify endpoints, or laravel/passkeys through a preset, with clear pending, unsupported, cancelled, error and success states.

Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.

## Install

```bash
php artisan ui:add passkey
```

## Usage

```blade
{{-- Sign in with a passkey. Point the URLs at your WebAuthn endpoints. The
     docs site answers these URLs with demo endpoints that keep no accounts:
     the browser prompt is real, and verification always explains why it
     cannot sign you in. --}}
<div class="w-full max-w-xs">
    <x-ui.passkey options-url="/passkeys/login/options" verify-url="/passkeys/login" redirect="/dashboard" />
</div>
```

## Props

- `mode` (authenticate|register, default `authenticate`) — authenticate calls navigator.credentials.get() to sign in; register calls navigator.credentials.create() to add a passkey.
- `optionsUrl` (string, default ``) — Endpoint that returns WebAuthn JSON options (under optionsKey, publicKey or at the top level). A POST in register mode sends { name }. Empty uses the preset route.
- `verifyUrl` (string, default ``) — POST endpoint that receives the credential JSON (under credentialKey when set, with the body fields; register mode adds name) and verifies it. A JSON redirect in the response is followed. Empty uses the preset route.
- `redirect` (string|null, default `null`) — Where to go after success when the response has no redirect.
- `reload` (bool, default `false`) — Reload the page after success when there is no redirect, so a list shows the new passkey.
- `requireName` (bool, default `true`) — Register mode: refuse to start until the bound name field has a value.
- `label` (string|null, default `null`) — Label of the default button: 'Sign in with a passkey' or 'Add passkey'.
- `preset` (string|null, default `null`) — A known backend contract. 'laravel-passkeys' sets optionsMethod GET, optionsKey options, credentialKey credential and the laravel/passkeys routes for the mode (/passkeys/login/options and /passkeys/login, or /user/passkeys/options and /user/passkeys). Explicit props win.
- `optionsMethod` (GET|POST|null, default `null`) — HTTP method of the options request. Null uses the preset, else POST. GET sends no body.
- `optionsKey` (string|null, default `null`) — Response key that wraps the options, for example options. Null uses the preset, else publicKey or the top-level object.
- `credentialKey` (string|null, default `null`) — Verify body field that holds the credential, for example credential. Null uses the preset, else the credential is the body.
- `body` (array, default `[]`) — Extra verify body fields, for example ['remember' => true]. Bind them to fields in the slot with x-model="extra.<field>".
- `type` (string, default `button`) — Declared by @props in the registry Blade source.
- `variant` (string, default `default`) — Declared by @props in the registry Blade source.

## Use when

- Use when users must enter freeform information that cannot be reliably selected from a list.
- Sign-in or security settings offer passkeys and the server already exposes WebAuthn options and verify endpoints (laravel/passkeys with preset="laravel-passkeys", another PHP package or your own code).
- You need the browser ceremony with honest states for unsupported browsers, a dismissed prompt and server errors.

## Avoid when

- Do not choose a freeform field when a constrained choice would reduce errors or cognitive load.
- The server has no WebAuthn endpoints yet; the component only runs the browser side.
- A one-time code is the second factor; use input-otp in auth-two-factor.

## Anti-patterns

- Using freeform entry for a bounded answer set

## Rules

- Use the `<brok:passkey>` tag (or `<x-ui.passkey>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.

## Links

- Docs: https://brokui.dev/docs/components/passkey
- Registry JSON (files, props, contract): https://brokui.dev/r/open/passkey.json

Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.

Examples

laravel-passkeys.blade.php Blade
{{-- laravel/passkeys with no adapter: `preset="laravel-passkeys"` asks for the
     options with GET, reads them from `options`, posts the credential under
     `credential` and uses the package routes for the mode. Sign-in binds the
     package's `remember` field; registration posts `name`. The registration
     routes sit behind password.confirm, so show them after a confirmed
     password. The docs site answers these routes with demo endpoints. --}}
<div class="grid w-full max-w-sm gap-8">
    <x-ui.passkey preset="laravel-passkeys" redirect="/dashboard" :body="['remember' => false]">
        <label class="flex items-center gap-2 text-sm text-foreground">
            <x-ui.checkbox x-model="extra.remember" />
            {{ __('Remember me') }}
        </label>
        <x-ui.passkey.button>{{ __('Sign in with a passkey') }}</x-ui.passkey.button>
        <x-ui.passkey.status />
    </x-ui.passkey>

    <x-ui.passkey mode="register" preset="laravel-passkeys" :reload="true">
        <form x-on:submit.prevent="start()" class="flex flex-col gap-2 sm:flex-row sm:items-end">
            <div class="flex min-w-0 flex-1 flex-col gap-2">
                <x-ui.label for="passkey-laravel-name">{{ __('Passkey name') }}</x-ui.label>
                <x-ui.input id="passkey-laravel-name" x-model="name" required maxlength="255" autocomplete="off" :placeholder="__('Work laptop')" />
            </div>
            <x-ui.passkey.button type="submit" class="sm:w-auto">{{ __('Add passkey') }}</x-ui.passkey.button>
        </form>
        <x-ui.passkey.status />
    </x-ui.passkey>
</div>
long-content.blade.php Blade
{{-- A long label and a long server message wrap inside a narrow column. --}}
<div class="w-full max-w-xs">
    <x-ui.passkey
        options-url="/passkeys/login/options"
        verify-url="/passkeys/login"
        label="Sign in with the passkey saved on this device or on a phone nearby"
        x-init="setState('error', {{ \Illuminate\Support\Js::from(__('The passkey could not be verified because this account has no passkey for the selected device. Try another device or sign in with your email address.')) }})"
    />
</div>
register.blade.php Blade
{{-- Add a passkey: a named form whose submit starts the ceremony, so the
     native `required` check runs first. --}}
<div class="w-full max-w-sm">
    <x-ui.passkey mode="register" options-url="/passkeys/options" verify-url="/passkeys" :reload="true">
        <form x-on:submit.prevent="start()" class="flex flex-col gap-2 sm:flex-row sm:items-end">
            <div class="flex min-w-0 flex-1 flex-col gap-2">
                <x-ui.label for="passkey-preview-name">{{ __('Passkey name') }}</x-ui.label>
                <x-ui.input id="passkey-preview-name" x-model="name" required maxlength="64" autocomplete="off" :placeholder="__('Work laptop')" />
            </div>
            <x-ui.passkey.button type="submit" class="sm:w-auto">{{ __('Add passkey') }}</x-ui.passkey.button>
        </form>
        <x-ui.passkey.status />
    </x-ui.passkey>
</div>
states.blade.php Blade
{{-- Every status the ceremony can end in, set directly for review. --}}
<div class="grid w-full max-w-md gap-6">
    @foreach (['pending', 'cancelled', 'error', 'success', 'unsupported'] as $state)
        <x-ui.passkey options-url="/passkeys/login/options" verify-url="/passkeys/login" x-init="setState('{{ $state }}')" />
    @endforeach
</div>

API

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
Prop Type Default Description
mode authenticate | register authenticate authenticate calls navigator.credentials.get() to sign in; register calls navigator.credentials.create() to add a passkey.
optionsUrl string Endpoint that returns WebAuthn JSON options (under optionsKey, publicKey or at the top level). A POST in register mode sends { name }. Empty uses the preset route.
verifyUrl string POST endpoint that receives the credential JSON (under credentialKey when set, with the body fields; register mode adds name) and verifies it. A JSON redirect in the response is followed. Empty uses the preset route.
redirect string | null null Where to go after success when the response has no redirect.
reload bool false Reload the page after success when there is no redirect, so a list shows the new passkey.
requireName bool true Register mode: refuse to start until the bound name field has a value.
label string | null null Label of the default button: 'Sign in with a passkey' or 'Add passkey'.
preset string | null null A known backend contract. 'laravel-passkeys' sets optionsMethod GET, optionsKey options, credentialKey credential and the laravel/passkeys routes for the mode (/passkeys/login/options and /passkeys/login, or /user/passkeys/options and /user/passkeys). Explicit props win.
optionsMethod GET | POST | null null HTTP method of the options request. Null uses the preset, else POST. GET sends no body.
optionsKey string | null null Response key that wraps the options, for example options. Null uses the preset, else publicKey or the top-level object.
credentialKey string | null null Verify body field that holds the credential, for example credential. Null uses the preset, else the credential is the body.
body array [] Extra verify body fields, for example ['remember' => true]. Bind them to fields in the slot with x-model="extra.<field>".
type string button Declared by @props in the registry Blade source.
variant string default Declared by @props in the registry Blade source.

Slots

  • default — Custom layout. Empty renders the button and status; otherwise compose <x-ui.passkey.button> (type="submit" inside a form with x-on:submit.prevent="start()") and <x-ui.passkey.status>, bind a name field with x-model="name" and extra body fields with x-model="extra.<field>".
  • x-ui.passkey.button — Installed subcomponent from the registry item.
  • x-ui.passkey.status — Installed subcomponent from the registry item.

Data slots

Stable hooks for CSS overrides and browser tests.

passkey passkey-spinner passkey-status

Behavior

  • The options request is a POST (or a GET with optionsMethod) and the verify request a JSON POST, both with the CSRF token (X-CSRF-TOKEN), Accept: application/json and same-origin credentials. A non-2xx response shows the first validation error, else its message (for example the 423 password confirmation message of laravel/passkeys), else a generic error.
  • Options are converted with PublicKeyCredential.parseRequestOptionsFromJSON/parseCreationOptionsFromJSON when available and by base64url decoding otherwise, including the binary extension inputs (prf eval and evalByCredential, largeBlob.write, credBlob); the credential is sent with toJSON() or an equivalent encoding in which every binary client extension output is base64url.
  • States: unsupported (no PublicKeyCredential; the button is disabled and the status says to use another way), pending (button busy), cancelled (NotAllowedError or AbortError: the prompt was dismissed or timed out), error (server error, InvalidStateError for a duplicate passkey, SecurityError), success.
  • Dispatches passkey-success { mode, response } and passkey-error { mode, state, name }. One abort signal covers the options request, the browser prompt and the verify request: removing the component or calling cancel() stops all three, and a late response never changes state, dispatches or navigates.
  • With preset="laravel-passkeys" the component speaks the laravel/passkeys contract with no app-side adapter: GET options wrapped in options, then a POST of { credential, ...body } to sign in (add ['remember' => true] through body or extra.remember) or { credential, name } to register. Registration routes sit behind password.confirm, so confirm the password first.
  • Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
  • Declares registry capability flags: a11y, interactive, behaviorTest, authoredStateFixtures, responsive, rtl, darkMode, localized.

Guidance

Freeform text input

Collect unpredictable freeform information.

Use when

  • Use when users must enter freeform information that cannot be reliably selected from a list.
  • Sign-in or security settings offer passkeys and the server already exposes WebAuthn options and verify endpoints (laravel/passkeys with preset="laravel-passkeys", another PHP package or your own code).
  • You need the browser ceremony with honest states for unsupported browsers, a dismissed prompt and server errors.

Avoid when

  • Do not choose a freeform field when a constrained choice would reduce errors or cognitive load.
  • The server has no WebAuthn endpoints yet; the component only runs the browser side.
  • A one-time code is the second factor; use input-otp in auth-two-factor.

Use instead

  • Radio or checkbox for bounded choices
  • Select or combobox for known options

Anti-patterns

  • Using freeform entry for a bounded answer set
Anatomy
root button status
Theming hooks
button status tones

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
managed
Focus
none
  • The status line is a polite live region, so state changes are announced without interrupting the browser prompt.
  • The button stays a real button with aria-busy while the ceremony runs and is disabled with a written reason when passkeys are unsupported.
  • Always offer another way to sign in: a passkey must never be the only path.
  • Semantic HTML and a stable data-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, so keyboard focus is always visible.
  • Disabled and invalid states are conveyed to assistive tech, not by color alone.
  • Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
  • Labels go through __() and layout uses logical properties (ms-*, text-start), so it mirrors under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:key

Add a stable wire:key when Livewire can reorder this interactive component.

livewire-component.blade.php Blade
<div wire:key="passkey-{{ $record->id }}">
    {{-- Sign in with a passkey. Point the URLs at your WebAuthn endpoints. The
         docs site answers these URLs with demo endpoints that keep no accounts:
         the browser prompt is real, and verification always explains why it
         cannot sign you in. --}}
    <div class="w-full max-w-xs">
        <x-ui.passkey options-url="/passkeys/login/options" verify-url="/passkeys/login" redirect="/dashboard" />
    </div>
</div>

Source

The exact, editable files ui:add writes into your app. Previews render this same code; there are no preview-only components.

resources/views/components/ui/passkey.blade.php Blade
{{--
    Passkey: runs a WebAuthn ceremony against your own endpoints, with no
    dependency on a PHP package. `mode="authenticate"` signs in,
    `mode="register"` adds a passkey. The server returns WebAuthn JSON options
    from `optionsUrl` and verifies the credential posted to `verifyUrl`.

    The request shape is configurable, so no app-side adapter is needed:
    `optionsMethod` (POST or GET), `optionsKey` (the key that wraps the options
    in the response), `credentialKey` (the field that wraps the credential in
    the verify body) and `body` (extra verify fields). `preset="laravel-passkeys"`
    sets all three and the laravel/passkeys routes; explicit props still win.

    With an empty slot it renders its button and status line. Compose your own
    layout with <x-ui.passkey.button> and <x-ui.passkey.status> inside the slot;
    in register mode bind a name field with x-model="name", and bind extra body
    fields with x-model="extra.<field>" (for example extra.remember).
--}}
@props([
    'mode' => 'authenticate',
    'optionsUrl' => '',
    'verifyUrl' => '',
    // Where to go after success. A `redirect` in the verify response wins.
    'redirect' => null,
    // Reload the page after success when there is no redirect (a list that
    // shows the new passkey).
    'reload' => false,
    // Register mode: refuse to start until the name field has a value.
    'requireName' => true,
    'label' => null,
    // A known backend contract: null or 'laravel-passkeys'.
    'preset' => null,
    // GET or POST for the options request. Null: the preset, else POST.
    'optionsMethod' => null,
    // The response key that wraps the options, for example 'options'. Null:
    // the preset, else `publicKey` or the top-level object.
    'optionsKey' => null,
    // The verify body field that holds the credential, for example
    // 'credential'. Null: the preset, else the credential is the body.
    'credentialKey' => null,
    // Extra fields for the verify body, for example ['remember' => true].
    'body' => [],
])

@php
    $mode = $mode === 'register' ? 'register' : 'authenticate';
    $label ??= $mode === 'register' ? 'Add passkey' : 'Sign in with a passkey';
    $contract = [
        'laravel-passkeys' => [
            'optionsMethod' => 'GET',
            'optionsKey' => 'options',
            'credentialKey' => 'credential',
            'authenticate' => ['/passkeys/login/options', '/passkeys/login'],
            'register' => ['/user/passkeys/options', '/user/passkeys'],
        ],
    ][$preset] ?? [];
    $optionsMethod = strtoupper((string) ($optionsMethod ?? $contract['optionsMethod'] ?? 'POST')) === 'GET' ? 'GET' : 'POST';
    $config = [
        'mode' => $mode,
        'optionsUrl' => (string) (filled($optionsUrl) ? $optionsUrl : ($contract[$mode][0] ?? '')),
        'verifyUrl' => (string) (filled($verifyUrl) ? $verifyUrl : ($contract[$mode][1] ?? '')),
        'optionsMethod' => $optionsMethod,
        'optionsKey' => $optionsKey ?? $contract['optionsKey'] ?? null,
        'credentialKey' => $credentialKey ?? $contract['credentialKey'] ?? null,
        'body' => (object) (array) $body,
        'redirect' => $redirect,
        'reload' => (bool) $reload,
        'requireName' => (bool) $requireName,
        'csrf' => csrf_token(),
        'messages' => [
            'unsupported' => __('This browser does not support passkeys. Use another way to continue.'),
            'pending' => $mode === 'register' ? __('Follow the prompt from your browser to create the passkey.') : __('Follow the prompt from your browser to use your passkey.'),
            'cancelled' => __('The passkey request was cancelled or timed out. Try again when you are ready.'),
            'success' => $mode === 'register' ? __('Passkey added.') : __('Signed in.'),
            'error' => __('Something went wrong with the passkey. Try again.'),
            'duplicate' => __('This device already has a passkey for your account.'),
            'notAvailable' => __('Passkeys are not available on this site or device.'),
            'nameRequired' => __('Enter a name for the passkey.'),
        ],
    ];
@endphp

<div
    data-slot="passkey"
    data-mode="{{ $mode }}"
    x-data="uiPasskey(@js($config))"
    x-bind:data-state="state"
    {{ $attributes->merge(['class' => 'flex min-w-0 flex-col gap-2']) }}
>
    @if ($slot->isEmpty())
        <x-ui.passkey.button>{{ __($label) }}</x-ui.passkey.button>
        <x-ui.passkey.status />
    @else
        {{ $slot }}
    @endif
    <noscript>
        <p class="text-sm text-muted-foreground">{{ __('Passkeys need JavaScript. Use another way to continue.') }}</p>
    </noscript>
</div>
resources/views/components/ui/passkey/button.blade.php Blade
{{--
    Starts the ceremony of the surrounding <x-ui.passkey>. As type="submit"
    inside a form, the form's submit (x-on:submit.prevent="start()") starts it
    instead, so native `required` validation runs first. Disabled while a
    request runs and when the browser has no passkey support.
--}}
@props([
    'type' => 'button',
    'variant' => 'default',
])

@php
    $styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
    $variant = $styles['normalizeVariant']($variant);
@endphp

<x-ui.button
    :type="$type === 'submit' ? 'submit' : 'button'"
    :variant="$variant"
    x-bind:disabled="busy || unsupported"
    x-bind:aria-busy="busy ? 'true' : null"
    x-on:click="$el.type === 'submit' || start()"
    {{ $attributes->merge(['class' => 'w-full']) }}
>
    <x-slot:icon>
        <svg x-show="!busy" class="size-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="8" cy="15" r="4" /><path d="m10.85 12.15 7.65-7.65M18 5l2 2M15 8l2 2" /></svg>
        {{-- The button's own loading spinner, shown while the ceremony runs. --}}
        <svg x-show="busy" x-cloak data-slot="passkey-spinner" class="size-4 shrink-0 animate-spin motion-reduce:animate-none" viewBox="0 0 24 24" fill="none" aria-hidden="true">
            <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
            <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"></path>
        </svg>
    </x-slot:icon>
    {{ $slot }}
</x-ui.button>
resources/views/components/ui/passkey/status.blade.php Blade
{{--
    Live status of the surrounding <x-ui.passkey>: waiting for the browser,
    cancelled, error, success or no browser support. Polite, so the prompt the
    browser shows is never interrupted.
--}}
<p
    data-slot="passkey-status"
    role="status"
    aria-live="polite"
    x-text="message"
    x-bind:data-state="state"
    x-bind:class="{
        'text-destructive-text': state === 'error',
        'text-success-text': state === 'success',
        'text-muted-foreground': !['error', 'success'].includes(state),
    }"
    {{ $attributes->merge(['class' => 'min-h-5 break-words text-sm']) }}
></p>
resources/js/ui/passkey.js JS
/**
 * Passkey (WebAuthn) ceremony, backend-agnostic.
 *
 * 1. POST (or GET, `optionsMethod`) `optionsUrl` → the server returns
 *    PublicKeyCredential request (sign in) or creation (register) options in
 *    the WebAuthn JSON form: under `optionsKey` when set, else under
 *    `publicKey` or at the top level.
 * 2. navigator.credentials.get()/create() runs the browser ceremony.
 * 3. POST `verifyUrl` with the credential as JSON, under `credentialKey` when
 *    set, merged with the `extra` fields (registration adds `name`).
 *    A JSON response may carry `redirect`; otherwise `redirect`/`reload` from
 *    the config apply, and `passkey-success` is dispatched either way.
 *
 * States: idle, unsupported, pending, cancelled, error, success. `message`
 * holds the text for the current state; the Blade status region reads it.
 */

function toBytes(value) {
    const base64 = String(value).replace(/-/g, '+').replace(/_/g, '/');
    const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
    const binary = atob(padded);
    const bytes = new Uint8Array(binary.length);
    for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);

    return bytes.buffer;
}

function isBinary(value) {
    return value instanceof ArrayBuffer || ArrayBuffer.isView(value);
}

function toBase64Url(buffer) {
    if (!buffer) return null;
    const bytes = ArrayBuffer.isView(buffer)
        ? new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength)
        : new Uint8Array(buffer);
    let binary = '';
    for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);

    return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

function withIds(list) {
    return Array.isArray(list) ? list.map((item) => ({ ...item, id: toBytes(item.id) })) : list;
}

function prfValues(values) {
    if (!values || typeof values !== 'object') return values;

    return {
        ...values,
        first: toBytes(values.first),
        ...(values.second != null ? { second: toBytes(values.second) } : {}),
    };
}

/**
 * Client extension inputs from JSON: the binary members the WebAuthn JSON
 * form carries as base64url (prf, largeBlob.write, credBlob) become bytes.
 */
function parseExtensions(extensions) {
    if (!extensions || typeof extensions !== 'object') return extensions;
    const parsed = { ...extensions };
    if (extensions.prf && typeof extensions.prf === 'object') {
        const prf = { ...extensions.prf };
        if (prf.eval) prf.eval = prfValues(prf.eval);
        if (prf.evalByCredential && typeof prf.evalByCredential === 'object') {
            prf.evalByCredential = Object.fromEntries(
                Object.entries(prf.evalByCredential).map(([id, values]) => [id, prfValues(values)]),
            );
        }
        parsed.prf = prf;
    }
    if (extensions.largeBlob && typeof extensions.largeBlob.write === 'string') {
        parsed.largeBlob = { ...extensions.largeBlob, write: toBytes(extensions.largeBlob.write) };
    }
    if (typeof extensions.credBlob === 'string') parsed.credBlob = toBytes(extensions.credBlob);

    return parsed;
}

/** Client extension outputs to JSON: every binary value becomes base64url. */
function serializeExtensionResults(value) {
    if (isBinary(value)) return toBase64Url(value);
    if (Array.isArray(value)) return value.map(serializeExtensionResults);
    if (value && typeof value === 'object') {
        return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, serializeExtensionResults(item)]));
    }

    return value;
}

function parseOptions(json, mode) {
    const options = json?.publicKey ?? json;
    if (mode === 'register') {
        if (typeof PublicKeyCredential.parseCreationOptionsFromJSON === 'function') {
            return PublicKeyCredential.parseCreationOptionsFromJSON(options);
        }

        return {
            ...options,
            challenge: toBytes(options.challenge),
            user: { ...options.user, id: toBytes(options.user.id) },
            excludeCredentials: withIds(options.excludeCredentials),
            ...(options.extensions ? { extensions: parseExtensions(options.extensions) } : {}),
        };
    }
    if (typeof PublicKeyCredential.parseRequestOptionsFromJSON === 'function') {
        return PublicKeyCredential.parseRequestOptionsFromJSON(options);
    }

    return {
        ...options,
        challenge: toBytes(options.challenge),
        allowCredentials: withIds(options.allowCredentials),
        ...(options.extensions ? { extensions: parseExtensions(options.extensions) } : {}),
    };
}

function serialize(credential) {
    if (typeof credential.toJSON === 'function') return credential.toJSON();
    const response = credential.response;

    return {
        id: credential.id,
        rawId: toBase64Url(credential.rawId),
        type: credential.type,
        authenticatorAttachment: credential.authenticatorAttachment ?? null,
        clientExtensionResults: serializeExtensionResults(credential.getClientExtensionResults?.() ?? {}),
        response: {
            clientDataJSON: toBase64Url(response.clientDataJSON),
            ...(response.attestationObject ? {
                attestationObject: toBase64Url(response.attestationObject),
                transports: response.getTransports?.() ?? [],
            } : {
                authenticatorData: toBase64Url(response.authenticatorData),
                signature: toBase64Url(response.signature),
                userHandle: toBase64Url(response.userHandle),
            }),
        },
    };
}

class ServerError extends Error {}

document.addEventListener('alpine:init', () => {
    window.Alpine.data('uiPasskey', (config = {}) => ({
        mode: config.mode === 'register' ? 'register' : 'authenticate',
        state: 'idle',
        message: '',
        name: '',
        // Extra verify body fields; bind with x-model="extra.remember".
        extra: { ...(config.body ?? {}) },
        controller: null,

        get busy() {
            return this.state === 'pending';
        },

        get unsupported() {
            return this.state === 'unsupported';
        },

        init() {
            if (!window.PublicKeyCredential || !navigator.credentials) {
                this.state = 'unsupported';
                this.message = config.messages?.unsupported ?? '';
            }
        },

        // Removed from the page (Livewire morph, navigation): stop the
        // ceremony and both requests, and never act on a late response.
        destroy() {
            this.controller?.abort();
            this.controller = null;
        },

        /** Stop a running ceremony; its late results are ignored. */
        cancel() {
            if (!this.controller) return;
            this.controller.abort();
            this.controller = null;
            this.setState('cancelled');
        },

        setState(state, message = null) {
            this.state = state;
            this.message = message ?? config.messages?.[state] ?? '';
        },

        async request(url, body, signal, method = 'POST') {
            const get = method === 'GET';
            const response = await fetch(url, {
                method,
                credentials: 'same-origin',
                signal,
                headers: {
                    ...(get ? {} : { 'Content-Type': 'application/json' }),
                    Accept: 'application/json',
                    'X-Requested-With': 'XMLHttpRequest',
                    ...(config.csrf ? { 'X-CSRF-TOKEN': config.csrf } : {}),
                },
                ...(get ? {} : { body: JSON.stringify(body) }),
            });
            const json = await response.json().catch(() => null);
            if (!response.ok) {
                // Laravel validation errors: the first field message, else `message`.
                const first = json?.errors ? Object.values(json.errors).flat()[0] : null;
                throw new ServerError(first ?? json?.message ?? '');
            }

            return json ?? {};
        },

        async start() {
            if (this.busy || this.unsupported) return;
            if (this.mode === 'register' && this.name.trim() === '' && config.requireName) {
                this.setState('error', config.messages?.nameRequired);

                return;
            }

            this.setState('pending');
            this.controller?.abort();
            // One signal for this ceremony: the options request, the browser
            // prompt and the verify request. Once it aborts (destroy() or
            // cancel()), nothing below may change state, dispatch or navigate.
            const controller = new AbortController();
            const { signal } = controller;
            this.controller = controller;

            try {
                const named = this.mode === 'register' ? { name: this.name.trim() } : {};
                const response = await this.request(config.optionsUrl, named, signal, config.optionsMethod);
                const options = parseOptions(config.optionsKey ? response?.[config.optionsKey] : response, this.mode);
                if (signal.aborted) return;
                const credential = this.mode === 'register'
                    ? await navigator.credentials.create({ publicKey: options, signal })
                    : await navigator.credentials.get({ publicKey: options, signal });
                if (signal.aborted) return;
                if (!credential) {
                    this.setState('cancelled');

                    return;
                }

                const payload = serialize(credential);
                const credentialBody = config.credentialKey ? { [config.credentialKey]: payload } : payload;
                const result = await this.request(config.verifyUrl, { ...this.extra, ...credentialBody, ...named }, signal);
                if (signal.aborted) return;

                this.setState('success');
                this.$dispatch('passkey-success', { mode: this.mode, response: result });
                const redirect = result.redirect ?? config.redirect;
                if (redirect) window.location.assign(redirect);
                else if (config.reload) window.location.reload();
                else if (this.mode === 'register') this.name = '';
            } catch (error) {
                if (!signal.aborted) this.fail(error);
            } finally {
                if (this.controller === controller) this.controller = null;
            }
        },

        fail(error) {
            const messages = config.messages ?? {};
            if (error instanceof ServerError) {
                this.setState('error', error.message || messages.error);
            } else if (error?.name === 'NotAllowedError' || error?.name === 'AbortError') {
                // The user dismissed the prompt, or it timed out.
                this.setState('cancelled');
            } else if (error?.name === 'InvalidStateError' && this.mode === 'register') {
                this.setState('error', messages.duplicate);
            } else if (error?.name === 'SecurityError' || error?.name === 'NotSupportedError') {
                this.setState('error', messages.notAvailable);
            } else {
                this.setState('error', messages.error);
            }
            this.$dispatch('passkey-error', { mode: this.mode, state: this.state, name: error?.name ?? null });
        },
    }));
});

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
1.1.2
Status
Stable
License
open
Accessibility reviewed
No review date recorded
Last breaking change
No date recorded
Deprecation
Not deprecated
Contract
v6
Foundation
≥ 1.0.0