Autenticación con llave de acceso

En esta guía, se detalla cómo implementar la autenticación de usuarios recurrentes con credenciales detectables, tanto a través de activadores de botones explícitos como de sugerencias de autocompletado del navegador sin interrupciones (IU condicional).

Del servidor

Generación de opciones

Crea un extremo que genere parámetros de solicitud de WebAuthn con una biblioteca verificada según los estándares.

  1. Usa el ID de RP predefinido: Usa el ID de RP adecuado predefinido como una cadena constante.
  2. Genera un desafío: Genera un búfer aleatorio criptográficamente seguro y de alta entropía, almacénalo de forma segura en la sesión del usuario y codifícalo como Base64URL.
  3. Asignación de credenciales detectables: Especifica un array vacío [] para allowCredentials. Esto solicita credenciales detectables, lo que significa que el usuario no necesita ingresar su nombre de usuario primero; el proveedor de llaves de acceso presentará las cuentas disponibles.
  4. Nivel de verificación del usuario: Establece userVerification: "preferred" (o "required" si lo exigen explícitamente las políticas de cumplimiento corporativas).
    • El nivel de restricción userVerification solicitado DEBE conservarse dentro del registro de la sesión del servidor en el extremo de opciones, en lugar de que el cliente lo devuelva a través de cadenas de consulta. Esto permite que el extremo de verificación aplique restricciones de coincidencia estrictas de forma segura sin riesgo de manipulación por parte del cliente.
// Options generation example (discoverable flow)
const options = {
  challenge: serverGeneratedBase64UrlChallenge, // High-entropy random challenge stored in session
  rpId: "example.com",
  allowCredentials: [], // Request discoverable passkeys
  userVerification: "preferred",
};

// Persist expected UV level to user session
req.session.expectedUserVerification = "preferred";

Extremo de verificación

Verifica de forma segura la aserción que devuelve el cliente para autenticar al usuario:

  1. Validar el desafío de la sesión: Aplica una coincidencia estricta del desafío entre la respuesta del cliente y el desafío esperado almacenado en la sesión.
  2. Aplicar las preferencias de UV:
    • Permite autenticadores sin UV (p.ej., bloqueos de pantalla del autenticador inhabilitados) si la expectedUserVerification de la sesión solicitó "preferred", pasando requireUserVerification: false a tu biblioteca de verificación del servidor. Si se solicita "required", aplica estrictamente la entrada de datos biométricos o PIN.
  3. Error de servidor limpio 404: Si el ID de credencial que devuelve el cliente no se encuentra en la base de datos, devuelve un error HTTP 404 explícito para que el cliente pueda activar la API de Signal.

Lógica del cliente

Anotación de formulario HTML

Anota tus entradas de nombre de usuario y contraseña para aprovechar de forma nativa la IU condicional. Los tokens de autocompletado combinan los parámetros de la especificación de webauthn, y el enfoque automático activa la ventana emergente de autocompletado del navegador de inmediato cuando se enfoca la entrada.

<!-- Autocomplete tokens must contain webauthn space-separated -->
<form id="signin-form">
  <input
    type="text"
    name="username"
    autocomplete="username webauthn"
    autofocus
    data-testid="username-field"
  />
  <input type="password" name="password" autocomplete="current-password" />
  <button type="submit">Sign in</button>
</form>

Flujo de botones explícito

Activa la autenticación con llave de acceso cuando un usuario haga clic en el botón "Acceder con llave de acceso". Anula cualquier llamada de autocompletado de formularios (Conditional Get) en curso antes de invocar el mensaje de la llave de acceso.

Flujo de mediación condicional (autocompletado de formularios)

Activa las sugerencias de autocompletado de formularios cuando se cargue la página para ofrecer la autenticación con llave de acceso de forma nativa cuando los usuarios se enfoquen en los campos de acceso:

  1. Detección de funciones: Llama a PublicKeyCredential.getClientCapabilities() cuando se cargue la página y omite el acceso con llave de acceso si conditionalGet no está disponible.
  2. Opciones de decodificación: Decodifica el objeto JSON de credenciales recuperado con PublicKeyCredential.parseRequestOptionsFromJSON().
  3. Invoca la obtención condicional: Llama a navigator.credentials.get() con mediation: "conditional" y pasa un indicador AbortController. Esto registra la función de autocompletar de forma silenciosa sin renderizar un diálogo de llave de acceso.
  4. Segregación de excepciones try/catch: Une la llamada navigator.credentials.get en el bloque try/catch:
    • NotAllowedError: El usuario canceló el mensaje de acceso con llave de acceso o se agotó el tiempo de espera.
    • AbortError: La solicitud de autenticación se canceló de manera programática.
  5. API de Call Signal: Une la llamada de verificación del servidor fetch() en un bloque try/catch:
    • Mostrar un mensaje de error para que el usuario comprenda qué salió mal
    • Llama a signalUnknownCredential() SOLO cuando el servidor responda de forma explícita con el estado HTTP 404 (No se encontró la credencial) y el usuario no esté autenticado.
    • El parámetro credentialId que se pasa a signalUnknownCredential() DEBE ser estrictamente la cadena de ID de credencial codificada en Base64URL (p.ej., encoded.id), NO el objeto ArrayBuffer sin procesar credential.rawId.
  6. Codifica la respuesta: Codifica la credencial AuthenticatorAssertionResponse con .toJSON() antes de enviarla al servidor para su verificación.
// optionsFetch and loginVerifyFetch are app-defined HTTP methods
import { optionsFetch, loginVerifyFetch } from "./api.js";

let autofillAbortController = new AbortController();

async function initializeConditionalAutofill() {
  // Feature detect Conditional Get autofill support
  const capabilities = await PublicKeyCredential.getClientCapabilities();
  if (capabilities.conditionalGet === true) {
    const loginOptionsJSON = await optionsFetch();
    const publicKey =
      PublicKeyCredential.parseRequestOptionsFromJSON(loginOptionsJSON);

    try {
      // Initiate Conditional UI form autofill suggestions
      const credential = await navigator.credentials.get({
        publicKey,
        signal: autofillAbortController.signal,
        mediation: "conditional",
      });

      // Segregated verification fetch
      const encoded = credential.toJSON();
      const response = await loginVerifyFetch(encoded);
      if (!response.ok && response.status === 404) {
        // Note: this code path runs pre-authentication, satisfying the unauth precondition
        if (PublicKeyCredential.signalUnknownCredential) {
          await PublicKeyCredential.signalUnknownCredential({
            rpId, // RP ID must match the one defined on the server
            credentialId: encoded.id,
          });
        }
      }
    } catch (err) {
      // Silently swallow expected client WebAuthn exceptions
      if (["NotAllowedError", "AbortError"].includes(err.name)) {
        return;
      }
      console.error("Unexpected conditional get error:", err);
    }
  }
}

async function triggerButtonAuthentication() {
  // Abort any pending Conditional Get call to prevent passkey prompt collisions
  autofillAbortController.abort();
  autofillAbortController = new AbortController(); // Reset controller for next triggers

  const loginOptionsJSON = await optionsFetch();
  const publicKey =
    PublicKeyCredential.parseRequestOptionsFromJSON(loginOptionsJSON);

  let credential;
  try {
    // Passkey explicit prompt trigger
    credential = await navigator.credentials.get({
      publicKey,
      signal: autofillAbortController.signal,
    });
  } catch (err) {
    if (err.name === "NotAllowedError") {
      console.log("User cancelled passkey login.");
    } else if (err.name === "AbortError") {
      console.log("The authentication operation was aborted.");
    }
    // Re-arm Conditional autofill Suggestions after cancelled explicit button prompts
    initializeConditionalAutofill();
    return; // Safe exit
  }

  // Segregated verification try/catch (HTTP 404 trigger)
  const encoded = credential.toJSON();
  try {
    const response = await loginVerifyFetch(encoded);
    if (!response.ok && response.status === 404) {
      // Note: this code path runs pre-authentication, satisfying the unauth precondition
      await PublicKeyCredential.signalUnknownCredential({
        rpId, // RP ID must match the one defined on the server
        credentialId: encoded.id, // Base64URL-encoded credential ID
      });
    }
  } catch (serverErr) {
    console.error("Verification request error:", serverErr);
  }
}

// Trigger Conditional Get on load
window.addEventListener("DOMContentLoaded", initializeConditionalAutofill);

Estrategias de resguardo

Resguardo de la detección de funciones de llaves de acceso

Instala siempre "webauthn-polyfills" y, luego, impórtalo en el contexto. Ten en cuenta que, siempre que se admita PublicKeyCredential, también se admitirá PublicKeyCredential.getClientCapabilities.

import 'webauthn-polyfills';

Resguardo de sincronización de la API de Signal

La API de Signal de WebAuthn (webauthn-signals) es una optimización progresiva que se usa para mantener los administradores de contraseñas sincronizados con el estado de las credenciales del servidor.

  • Experiencia de resguardo: Se restringe a través de if (PublicKeyCredential.signalUnknownCredential). Si no se admite, la sincronización de la verificación en segundo plano se omite sin problemas y sin arrojar excepciones del navegador.

Resguardo de serialización de JSON sencillo

Instala siempre "webauthn-polyfills" y, luego, impórtalo en el contexto. Ten en cuenta que, siempre que se admita PublicKeyCredential, también se admitirán PublicKeyCredential.parseRequestOptionsFromJSON y PublicKeyCredential.prototype.toJSON.

import 'webauthn-polyfills';