Acceso rápido con el modo de IU inmediato

Dirigir a un usuario no autenticado a una página de acceso dedicada cuando hace clic en un vínculo "Acceder", inicia el proceso de confirmación de la compra, guarda un artículo en favoritos o llega a un muro de pago de un artículo provoca la pérdida de contexto y aumenta el abandono de los usuarios. El modo de IU inmediato (uiMode: 'immediate' en navigator.credentials.get()) permite accesos rápidos contextuales, ya que presenta un selector de cuentas de navegador unificado de inmediato cuando existen llaves de acceso locales (publicKey) o contraseñas guardadas (password: true) en el dispositivo.

A diferencia de los mensajes modales estándar de WebAuthn, que siempre muestran la IU del navegador y recurren a códigos QR multidispositivo o llaves de seguridad externas, el modo de IU inmediata falla rápidamente con un NotAllowedError si no hay credenciales aptas disponibles de forma local en el dispositivo. Esto permite que tu aplicación autentique a los usuarios recurrentes en el momento con un solo toque, mientras que los usuarios sin credenciales locales se dirigen sin problemas a tu página de acceso estándar, a la opción de comprar como invitado o al formulario de acceso intercalado.

Activa el modo de IU inmediata dentro de un controlador de eventos iniciado por el usuario (que requiere la activación transitoria del usuario) en momentos de intención clara del usuario justo antes de salir de la página actual. Si la autenticación inmediata se realiza correctamente, completa la intención del usuario de inmediato (ya sea actualizando la página actual o dirigiéndolo directamente a su destino autenticado). Si falla con NotAllowedError, muestra un flujo de acceso o registro con una ruta para completar el intent del usuario cuando finalice.

Posibles casos de uso:

  • Intercepción del vínculo o botón de acceso del encabezado: Intercepta un clic en un vínculo global de "Acceder" (event.preventDefault()) para autenticar a los usuarios recurrentes de inmediato o seguir navegando a la página de acceso dedicada en NotAllowedError.
  • Acceso dinámico antes de la confirmación de la compra: Autentica a los compradores recurrentes con un solo toque cuando hagan clic en "Proceder al pago" para que lleguen directamente al paso de envío y pago, o bien pasen a la pantalla de acceso estándar o de confirmación de la compra como invitado en caso de resguardo.
  • Acceso intercalado para microacciones (Favorito, Me gusta, Marcar, Guardar): Autentica en el lugar cuando un usuario que no accedió a su cuenta hace clic en un botón de microacción y completa la acción de inmediato (por ejemplo, activar o desactivar aria-pressed="true") o abre un modal de acceso de resguardo o una página de acceso con un parámetro de URL de retorno.
  • Muros de pago flexibles en el artículo: Autentica a los lectores en el momento en que hacen clic en "Seguir leyendo con tu cuenta" en un banner de muro de pago o expande un formulario de acceso o registro intercalado como resguardo.

Antipatrones que se deben evitar:

  • NO uses el modo de IU inmediata en una página de acceso dedicada: Nunca adjuntes uiMode: 'immediate' al botón principal "Acceder" o "Acceder con una llave de acceso" en tu página de acceso de destino final. Los usuarios llegan a la página de acceso dedicada específicamente cuando no tienen credenciales locales en el dispositivo actual o cuando quieren usar una llave de acceso multidispositivo (escaneando un código QR con un teléfono) o una llave de seguridad física externa. Debido a que uiMode: 'immediate' suprime las opciones de código QR y llave de seguridad multidispositivo, y arroja NotAllowedError de inmediato cuando no existen credenciales locales, usarlo en una página de acceso dedicada atrapa al usuario en un callejón sin salida en el que hacer clic en el botón no hace nada. En una página de acceso dedicada, usa la IU condicional (mediation: 'conditional' con autocomplete="username webauthn") en las entradas de formulario vinculadas con un botón modal estándar de navigator.credentials.get({ publicKey }) (consulta Autenticación con llaves de acceso).
  • NO uses el modo de IU inmediato para la autenticación de aumento de nivel: Nunca uses uiMode: 'immediate' para volver a autenticar a un usuario que ya accedió antes de realizar operaciones sensibles (como cambiar una contraseña, actualizar una dirección de envío, editar formas de pago guardadas o agregar una llave de acceso nueva). La reautenticación debe restringir la solicitud a las credenciales del usuario que accedió a través de una lista allowCredentials no vacía, que el modo de IU inmediato rechaza con NotAllowedError, y omitir allowCredentials permitiría que el usuario seleccione de forma involuntaria una cuenta diferente de la que accedió actualmente. Para la reautenticación de aumento de nivel, sigue las instrucciones de Reautenticación con llaves de acceso.

Restricciones de privacidad y seguridad

Los navegadores aplican reglas estrictas de privacidad y seguridad cuando se solicita uiMode: 'immediate':

  • Se requiere la activación transitoria del usuario: DEBES llamar a navigator.credentials.get({ uiMode: 'immediate' }) dentro de un controlador de eventos iniciado por el usuario (como un objeto de escucha click en un elemento <button> o <a>) para evitar el sondeo silencioso de credenciales. Nunca la invoques automáticamente en la carga de página (DOMContentLoaded). Llamar a la API no consume la activación transitoria.
  • No pases una lista allowCredentials que no esté vacía: NO completes publicKey.allowCredentials cuando se especifique uiMode: 'immediate'. Los navegadores rechazan de inmediato cualquier solicitud de modo de IU inmediato con una lista allowCredentials no vacía arrojando NotAllowedError para que las partes de confianza no puedan buscar IDs de credenciales específicos para inferir visitas anteriores o hacer un seguimiento de los usuarios en las sesiones.
  • No pases un AbortSignal (signal): NO configures la propiedad signal en navigator.credentials.get() cuando se especifique uiMode: 'immediate', ya que los navegadores no permiten el cierre programático del diálogo de acceso inmediato. Si tu página también ejecuta una solicitud de IU condicional (mediation: 'conditional') en segundo plano, llama a abort() en esa IU condicional AbortController antes de invocar el modo de IU inmediata y omite signal de la solicitud inmediata.
  • Sesiones de navegación privada y de incógnito: Las solicitudes que se realizan en ventanas de navegación privada o de incógnito siempre se rechazan con NotAllowedError para que las sesiones privadas no se puedan distinguir de las sesiones sin credenciales locales.

Configuración del servidor

Extremo de generación de opciones

Expón un extremo que genere opciones de solicitud de WebAuthn detectables para los visitantes no autenticados:

  1. Genera un desafío vinculado a la sesión: Crea un búfer de desafío criptográficamente aleatorio, almacénalo en la sesión del servidor y codifícalo como Base64URL.
  2. Conservar la preferencia de verificación del usuario: Establece userVerification: 'preferred' y almacena expectedUserVerification: 'preferred' en la sesión.
  3. Omite allowCredentials: No incluyas una lista allowCredentials no vacía en las opciones del modo de IU inmediato, ya que los navegadores rechazan las listas de entidades permitidas no vacías con NotAllowedError.
// Server-side options generation example (/api/login/options)
router.post('/api/login/options', (req, res) => {
  const options = {
    challenge: serverGeneratedBase64UrlChallenge, // High-entropy random challenge stored in session
    rpId: 'example.com',
    userVerification: 'preferred',
  };

  req.session.challenge = options.challenge;
  req.session.expectedUserVerification = 'preferred';
  return res.json(options);
});

Extremos de verificación de credenciales dobles

Como navigator.credentials.get({ password: true, publicKey, uiMode: 'immediate' }) se puede resolver con una llave de acceso (PublicKeyCredential) o una contraseña guardada (PasswordCredential), proporciona controladores de verificación para ambos tipos de credenciales:

  1. Verificación de aserción de WebAuthn (/api/login/verify): Verifica la aserción con el desafío de la sesión y la clave pública almacenada. Si no se encuentra el ID de credencial en la base de datos, devuelve el estado HTTP 404 para que el cliente pueda notificar al administrador de contraseñas a través de PublicKeyCredential.signalUnknownCredential().
  2. Verificación de contraseña (/api/login/password): Verifica el username (cred.id) y la password (cred.password) con la base de datos de usuarios y establece una sesión autenticada.

Implementación del cliente

Solicitud de credenciales unificada

  1. Solicita llaves de acceso y contraseñas: Decodifica las opciones del servidor con PublicKeyCredential.parseRequestOptionsFromJSON() y llama a navigator.credentials.get() dentro de un controlador de clics iniciado por el usuario con password: true, publicKey y uiMode: 'immediate' (sin pasar signal).
  2. Verificación de la sucursal por parte de cred.type:
    • Si es cred.type === 'public-key', serializa la aserción con cred.toJSON() y envíala con POST a tu extremo de verificación de WebAuthn. Cuando el servidor responda con el código HTTP 404, llama a PublicKeyCredential.signalUnknownCredential({ rpId: publicKey.rpId, credentialId: encoded.id }).
    • Si es cred.type === 'password', envía una solicitud POST { username: cred.id, password: cred.password } a tu extremo de verificación de contraseñas.
  3. Controla NotAllowedError cuando no existan credenciales locales: Captura NotAllowedError (se arroja de inmediato cuando no existen llaves de acceso locales ni contraseñas guardadas en el dispositivo, o cuando el usuario descarta el selector) y realiza la transición a tu flujo de acceso estándar (como abrir un diálogo de acceso intercalado o navegar a /signin con una URL de retorno).
let isAuthenticating = false;

async function authenticateWithImmediateUi() {
  const optionsResponse = await fetch('/api/login/options', { method: 'POST' });
  if (!optionsResponse.ok) {
    throw new Error('Failed to fetch authentication options');
  }
  const optionsJSON = await optionsResponse.json();
  const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(optionsJSON);

  // MANDATORY: Request both password and publicKey with uiMode: 'immediate' and NO signal parameter
  const cred = await navigator.credentials.get({
    password: true,
    publicKey,
    uiMode: 'immediate',
  });

  if (!cred) {
    throw new DOMException('No credential returned', 'NotAllowedError');
  }

  if (cred.type === 'public-key') {
    const encoded = cred.toJSON();
    const verifyResponse = await fetch('/api/login/verify', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(encoded),
    });

    if (verifyResponse.status === 404) {
      await PublicKeyCredential.signalUnknownCredential({
        rpId: publicKey.rpId || window.location.hostname,
        credentialId: encoded.id, // Base64URL-encoded credential ID string
      });
      throw new Error('Passkey credential is no longer recognized by the server');
    }

    if (!verifyResponse.ok) {
      throw new Error('Passkey verification failed');
    }
    return await verifyResponse.json();
  }

  if (cred.type === 'password') {
    const verifyResponse = await fetch('/api/login/password', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        username: cred.id,
        password: cred.password,
      }),
    });

    if (!verifyResponse.ok) {
      throw new Error('Password verification failed');
    }
    return await verifyResponse.json();
  }

  throw new Error(`Unsupported credential type: ${cred.type}`);
}

// Example: Intercepting an inline micro-action (Favorite button) or Header Sign-in click
const favoriteButton = document.getElementById('favorite-btn');

favoriteButton.addEventListener('click', async (event) => {
  event.preventDefault();
  if (isAuthenticating) return;

  // If already signed in, toggle the micro-action state immediately
  if (document.body.dataset.signedIn === 'true') {
    const nextPressed = favoriteButton.getAttribute('aria-pressed') !== 'true';
    favoriteButton.setAttribute('aria-pressed', String(nextPressed));
    return;
  }

  isAuthenticating = true;
  try {
    const user = await authenticateWithImmediateUi();
    // Complete the user's intended action immediately
    document.body.dataset.signedIn = 'true';
    favoriteButton.setAttribute('aria-pressed', 'true');
    updateHeaderUserState(user);
  } catch (err) {
    if (err.name === 'NotAllowedError') {
      // No local credentials available or user dismissed dialog:
      // transition smoothly to fallback sign-in UI or redirect with return URL
      showFallbackSignInUi({ returnAction: 'favorite' });
      return;
    }
    console.error('Authentication error:', err);
  } finally {
    isAuthenticating = false;
  }
});

Estrategias de resguardo

Resguardo inmediato del modo de IU

  • Experiencia de mejora progresiva: Trata uiMode: 'immediate' como una mejora progresiva. No infieras la compatibilidad a partir del nombre o la versión de un navegador. El único indicador de compatibilidad confiable es la verificación del entorno de ejecución de capabilities.immediateGet a través de PublicKeyCredential.getClientCapabilities(). OBLIGATORIO: Siempre verifica capabilities.immediateGet antes de llamar a navigator.credentials.get({ uiMode: 'immediate' }). Los navegadores que no reconocen uiMode: 'immediate' ignoran la clave de diccionario desconocida y abren un diálogo modal de llave de acceso y código QR que bloquea la pantalla. Cuando capabilities.immediateGet es falso o cuando navigator.credentials.get() rechaza con NotAllowedError, recurre a la experiencia de acceso estándar de tu aplicación (como abrir un modal de acceso intercalado o navegar a la página de acceso dedicada con un parámetro de URL de retorno), de modo que los usuarios de navegadores no compatibles vean tu flujo de acceso existente sin cambios:
async function isImmediateUiSupported() {
  if (!window.PublicKeyCredential || !PublicKeyCredential.getClientCapabilities) {
    return false;
  }
  try {
    const capabilities = await PublicKeyCredential.getClientCapabilities();
    return Boolean(capabilities?.immediateGet);
  } catch {
    return false;
  }
}

async function authenticateWithImmediateUi() {
  // MANDATORY: Check immediateGet support so unsupported browsers do not open a modal QR dialog
  const supported = await isImmediateUiSupported();
  if (!supported) {
    // Throw NotAllowedError so the caller's single catch path treats "unsupported" like "no local credentials"
    throw new DOMException('Immediate UI mode is not supported', 'NotAllowedError');
  }
  // Proceed with fetching options and calling navigator.credentials.get({ password: true, publicKey, uiMode: 'immediate' })
}

Resguardo de detección de capacidades del cliente

Instala siempre 'webauthn-polyfills' y, luego, impórtalo en tu módulo de cliente para que PublicKeyCredential.getClientCapabilities() esté disponible siempre que se admita PublicKeyCredential:

import 'webauthn-polyfills';

Resguardo de credenciales de contraseña

Pasar password: true junto con publicKey en navigator.credentials.get() permite que los navegadores compatibles incluyan contraseñas guardadas del administrador de contraseñas del navegador en el selector de cuentas inmediato unificado.

  • Experiencia de resguardo: Los navegadores que no admiten PasswordCredential ignoran la opción password: true o recurren a la verificación de funciones de capabilities.immediateGet para volver al formulario de acceso estándar, en el que <input autocomplete="username webauthn"> y <input type="password" autocomplete="current-password"> proporcionan autocompletado de contraseñas nativo.

Resguardo sencillo de serialización de JSON

Instala siempre 'webauthn-polyfills' y, luego, impórtalo en el paquete del cliente para que PublicKeyCredential.parseRequestOptionsFromJSON() y PublicKeyCredential.prototype.toJSON() funcionen de manera coherente en los navegadores que admiten PublicKeyCredential:

import 'webauthn-polyfills';

Resguardo de sincronización de la API de Signal

La API de Signal de WebAuthn (webauthn-signals) es una mejora progresiva que quita las llaves de acceso inactivas del administrador de contraseñas del usuario cuando el servidor responde con HTTP 404.

  • Experiencia de resguardo: Controla las llamadas con if (PublicKeyCredential.signalUnknownCredential). Cuando no sea compatible, omite la llamada de señalización de forma correcta y muestra un mensaje claro que guíe al usuario para que acceda con un método alternativo.