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
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
// 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(/=+$/, "")
|
||||
}
|
||||
Reference in New Issue
Block a user