Files
Stanko K.R. 017bcc9ce1 Polish up Passkey interface
- Fix indentation
- Split awkward initializer into separate methods
- Rename credentials to passkeys
- Simplify authenticator registry loading
- Flatten nested errors under ActionPack::WebAuthn
- Set passkey current params only requests that use it
- Inline anemic methods
- Replace custom validation with ActiveModel::Validation
- Rename identity to holder in Passkey
- Rename credentials to passkeys in JS
- Extract framework library out of controllers
- Pass params hashes down to ActionPack
- Attempt to simplify public interface
- Push data decoding down to the classes representing the data
- Introduce has_passkeys
- Add CBOR bigint support
- Rename ActionPack::WebAuthn::Passkey to ActionPack::Passkey
- Add create_passkey_button helper
- Rename public-key to creation-options
- Add sign_in_with_passkey_button helper
- Dispatch events for the whole Passkey lifecycle
- Add ED25519 support
- Prevent crash on missing meta tag
- Validate resident key options
- Don't clobber existing ActionPack config options
- Validate cryptographic params
- Move CurrentWebAuthnRequest into ActionPack::Passkey
- Use ActiveModel::Attributes for Options objects
- Implement expiring challanges
- Add lifecycle events
- Extract param helpers into Request
- Add passkey_creation_options and passkey_request_options helpers
- Add create_passkey_challenge to make it easier to override the create method if needed
- Prefix all view helpers with passkey_
- Auto-include ActionPack::Passkey::Holder
- Make the passkey challange url configurable
- Add a reminder about Passkeys to the magic link email
2026-03-18 11:51:10 +01:00

84 lines
3.2 KiB
JavaScript

// Thin wrapper around the browser WebAuthn API (navigator.credentials).
//
// Handles the base64url ↔ ArrayBuffer conversions required by the spec so
// callers can work with plain JSON objects from the server-rendered meta tags.
// Call navigator.credentials.create() with the given creation options.
// Returns { client_data_json, attestation_object, transports } with all
// binary fields encoded as base64url strings ready for form submission.
export async function register(options) {
const publicKey = prepareCreationOptions(options)
const credential = await navigator.credentials.create({ publicKey })
return {
client_data_json: new TextDecoder().decode(credential.response.clientDataJSON),
attestation_object: bufferToBase64url(credential.response.attestationObject),
transports: credential.response.getTransports?.() || []
}
}
// Call navigator.credentials.get() with the given request options.
// Accepts an optional signal (AbortSignal) and mediation hint ("conditional"
// for autofill UI). Returns { id, client_data_json, authenticator_data, signature }
// with binary fields encoded as base64url strings.
export async function authenticate(options, { signal, mediation } = {}) {
const publicKey = prepareRequestOptions(options)
const credential = await navigator.credentials.get({ publicKey, signal, mediation })
return {
id: credential.id,
client_data_json: new TextDecoder().decode(credential.response.clientDataJSON),
authenticator_data: bufferToBase64url(credential.response.authenticatorData),
signature: bufferToBase64url(credential.response.signature)
}
}
// Convert JSON creation options into the format expected by the browser:
// decode base64url challenge, user.id, and excludeCredentials[].id into ArrayBuffers.
function prepareCreationOptions(options) {
return {
...options,
challenge: base64urlToBuffer(options.challenge),
user: { ...options.user, id: base64urlToBuffer(options.user.id) },
excludeCredentials: (options.excludeCredentials || []).map(cred => ({
...cred,
id: base64urlToBuffer(cred.id)
}))
}
}
// Convert JSON request options into the format expected by the browser:
// decode base64url challenge and allowCredentials[].id into ArrayBuffers.
// Strips allowCredentials entirely when empty so the browser prompts for
// any available credential (required for conditional mediation).
function prepareRequestOptions(options) {
const prepared = {
...options,
challenge: base64urlToBuffer(options.challenge)
}
if (options.allowCredentials?.length) {
prepared.allowCredentials = options.allowCredentials.map(cred => ({
...cred,
id: base64urlToBuffer(cred.id)
}))
} else {
delete prepared.allowCredentials
}
return prepared
}
function base64urlToBuffer(base64url) {
const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/")
const padding = "=".repeat((4 - base64.length % 4) % 4)
const binary = atob(base64 + padding)
return Uint8Array.from(binary, c => c.charCodeAt(0)).buffer
}
function bufferToBase64url(buffer) {
const bytes = new Uint8Array(buffer)
const binary = String.fromCharCode(...bytes)
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
}