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.
Preview
{{-- 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
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:
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.
-
resources/views/components/ui/passkey.blade.php -
resources/views/components/ui/passkey/button.blade.php -
resources/views/components/ui/passkey/status.blade.php -
resources/js/ui/passkey.js
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.
# 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
{{-- 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
{{-- 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>
{{-- 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>
{{-- 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
Props
| 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.
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
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
- Theming hooks
Accessibility
- 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-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, 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 underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add a stable wire:key when Livewire can reorder this interactive component.
<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.
{{--
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>
{{--
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>
{{--
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>
/**
* 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