Administración de llaves de acceso

En esta guía, se detalla cómo permitir que los usuarios vean, cambien el nombre y borren sus llaves de acceso registradas, a la vez que se mantienen sincronizadas las credenciales guardadas y los detalles del perfil de usuario entre el servidor y los administradores de contraseñas del usuario con la API de Signal.

Operaciones del servidor

La capa de la base de datos de backend y los extremos DEBEN admitir acciones CRUD comunes para las credenciales registradas, así como la actualización de los detalles del perfil de usuario. Desvinculado de las bibliotecas específicas del framework, el servidor expone extremos para lo siguiente:

  1. Enumera todas las credenciales de usuario: Recupera todos los registros de StoredPasskeyCredential que coincidan con el ID del usuario que accedió.
  2. Actualizar los nombres de las credenciales: Acepta un nuevo sobrenombre personalizado para un ID de credencial específico y conserva la actualización en el servidor.
  3. Borrar credenciales: Quita un ID de credencial específico de la base de datos.
  4. Actualizar los detalles de la cuenta de usuario: Acepta los valores actualizados de name (nombre de usuario) y displayName para el perfil del usuario que accedió, de modo que el cliente pueda sincronizarlos con el proveedor de llaves de acceso.
// Node.js routing example for credential CRUD and user profile rename
router.get('/api/credentials', checkUserAuthenticated, async (req, res) => {
  const list = await db.findCredentialsByUserId(req.user.id);
  return res.json(list);
});

router.put('/api/credential/:id', checkUserAuthenticated, async (req, res) => {
  const { id } = req.params;
  const { name } = req.body;
  const cred = await db.findCredentialById(id);
  if (!cred || cred.passkeyUserId !== req.user.id) {
    return res.status(404).json({ error: 'Credential not found.' });
  }
  cred.name = name;
  await db.saveCredential(cred);
  return res.json(cred);
});

router.delete('/api/credential/:id', checkUserAuthenticated, async (req, res) => {
  const { id } = req.params;
  const cred = await db.findCredentialById(id);
  if (!cred || cred.passkeyUserId !== req.user.id) {
    return res.status(404).json({ error: 'Credential not found.' });
  }
  await db.deleteCredential(id);
  return res.json({ success: true });
});

router.put('/api/user', checkUserAuthenticated, async (req, res) => {
  const { name, displayName } = req.body;
  const updatedUser = await db.updateUser(req.user.id, { name, displayName });
  return res.json(updatedUser);
});

IU de administración del cliente

Renderiza un panel de configuración dedicado que permita a los usuarios auditar y administrar fácilmente sus opciones de autenticación registradas y los detalles del perfil de la cuenta:

  1. Mostrar la lista guardada: Recupera la lista de tu extremo y renderiza las filas de credenciales individuales. Si la respuesta está vacía, renderiza un mensaje de estado vacío útil (p.ej., "No se encontraron llaves de acceso").
  2. Asigna metadatos de AAGUID: Para cada llave de acceso, busca su propiedad aaguid en tu registro local para renderizar los detalles de su proveedor. Consulta la sección Determina el proveedor de llaves de acceso a partir del AAGUID para obtener más detalles.
  3. Requisitos de la IU por elemento: Cada fila dentro del contenedor de la lista DEBE renderizar lo siguiente:
    • Ícono del proveedor: Es el URI de datos o la imagen derivada del AAGUID.
    • Nombre personalizado o del proveedor: Es el nombre derivado del AAGUID o la cadena que el usuario cambió de nombre.
    • Fecha de registro: Es la marca de tiempo de época sin procesar que se conserva en la base de datos registeredAt con el formato de una fecha legible por humanos para mostrar al cliente.
    • Last Used Date: Es la marca de tiempo de época sin procesar que se conserva en la base de datos lastUsedAt con formato de fecha legible por humanos (si está presente) para que la vea el cliente.
    • Botón Cambiar nombre: Activa una acción de cambio de nombre para actualizar el sobrenombre personalizado de la credencial en el servidor (PUT /api/credential/:id). Nota: Cambiar el sobrenombre de una llave de acceso individual es un cambio de etiqueta del servidor y NO invoca a signalCurrentUserDetails().
    • Botón Borrar: Activa la eliminación de la credencial (DELETE /api/credential/:id) y sincroniza los IDs de credenciales restantes a través de signalAllAcceptedCredentials().
  4. IU para cambiar el nombre de la cuenta de usuario:
    • Proporciona un control que permita al usuario actualizar el nombre de usuario de su cuenta (name) o displayName (PUT /api/user), y sincroniza de inmediato los detalles actualizados del usuario con el administrador de contraseñas a través de PublicKeyCredential.signalCurrentUserDetails().
  5. Botón condicional "Crear llave de acceso":
    • Ofrece un botón destacado de activación de registro "Crear llave de acceso" en la página de administración. Antes de renderizar este elemento de la IU, la página DEBE detectar las capacidades de las funciones con PublicKeyCredential.getClientCapabilities() para verificar que se admita el autenticador de la plataforma. Si no se admiten las llaves de acceso, oculta este botón y, en su lugar, fomenta de forma sutil los registros de MFA estándar.
    • Permite registrar una llave de seguridad omitiendo authenticatorSelection.authenticatorAttachment en la llamada navigator.credentials.create().

Sincronización de la API de Signal

La API de Signal permite que la aplicación comunique los estados de las credenciales a los administradores de contraseñas, lo que mantiene sincronizadas las bóvedas del usuario y la base de datos de tu backend.

  • Regla de detección de atributos y codificación de parámetros:
    • Siempre detecta las funciones de los métodos de la API de Signal antes de invocarlos (if (PublicKeyCredential.signalAllAcceptedCredentials) y if (PublicKeyCredential.signalCurrentUserDetails)).
    • Todos los parámetros de userId y de ID de credencial que se pasen a los métodos de la API de Signal (signalAllAcceptedCredentials, signalCurrentUserDetails) DEBEN ser cadenas codificadas en Base64URL.
  • Sincronización de carga de página iniciadora:
    • La aplicación DEBE invocar signalAllAcceptedCredentials() automáticamente cuando se carga la página de administración (por ejemplo, dentro de un objeto de escucha de eventos DOMContentLoaded, la inicialización del módulo o el hook de activación del componente del framework, como useEffect).
  • Sincronización de actualizaciones de administración:
    • La aplicación DEBE invocar signalAllAcceptedCredentials() de inmediato dentro del controlador de clics para borrar credenciales después de la recuperación.
    • La aplicación DEBE invocar signalCurrentUserDetails() inmediatamente dentro del controlador de clics para cambiar el nombre de tu cuenta de usuario (name o displayName) después de la recuperación.
// Client-side management synchronization ES module
import { listFetch, renameCredentialFetch, updateUserFetch, deleteFetch } from './api.js';

// Base64URL-encoded User ID string (illustration only)
const base64UrlUserId = "M2YPl-KGnA8";

async function syncAcceptedCredentials(currentCredentialsList) {
  if (!window.PublicKeyCredential || !PublicKeyCredential.signalAllAcceptedCredentials) return;
  try {
    const credentialIds = currentCredentialsList.map(c => c.id); // Array of Base64URL credential ID strings
    
    await PublicKeyCredential.signalAllAcceptedCredentials({
      rpId, // RP ID must match the one defined on the server
      userId: base64UrlUserId, // User ID Base64URL-encoded string
      allAcceptedCredentialIds: credentialIds
    });
  } catch (e) {
    console.error('SignalAllAcceptedCredentials sync failure:', e);
  }
}

async function loadManagementPanel() {
  const response = await listFetch();
  const list = await response.json();
  
  renderUI(list);
  // Sync accepted credentials on initial load
  await syncAcceptedCredentials(list);
}

async function performDelete(credentialId) {
  const response = await deleteFetch(credentialId);
  if (response.ok) {
    const updatedResponse = await listFetch();
    const updatedList = await updatedResponse.json();
    
    renderUI(updatedList);
    // Sync remaining accepted credentials after deletion
    await syncAcceptedCredentials(updatedList);
  }
}

// Renaming a passkey's nickname updates the server record only
async function performCredentialRename(credentialId, newCredentialName) {
  const response = await renameCredentialFetch(credentialId, { name: newCredentialName });
  if (response.ok) {
    const updatedResponse = await listFetch();
    renderUI(await updatedResponse.json());
  }
}

// Renaming the user's account username or displayName triggers signalCurrentUserDetails
async function performUserRename(rpId, userId, updatedName, updatedDisplayName) {
  const response = await updateUserFetch({ name: updatedName, displayName: updatedDisplayName });
  if (response.ok && window.PublicKeyCredential && PublicKeyCredential.signalCurrentUserDetails) {
    try {
      await PublicKeyCredential.signalCurrentUserDetails({
        rpId, // RP ID must match the one defined on the server
        userId, // Base64URL-encoded user ID
        name: updatedName, // Updated username
        displayName: updatedDisplayName // Updated display name
      });
    } catch (e) {
      console.error('SignalCurrentUserDetails sync failure:', e);
    }
  }
}

window.addEventListener('DOMContentLoaded', loadManagementPanel);

Determina el proveedor de la llave de acceso a partir del AAGUID

Un AAGUID (identificador único global de certificación del autenticador) es un identificador de 128 bits que representa el modelo del autenticador, no una instancia específica. Se incluye en los datos del autenticador durante el registro de la llave de acceso y se puede usar para determinar qué proveedor de llaves de acceso (p.ej., el Administrador de contraseñas de Google, el llavero de iCloud o 1Password) creó una credencial.

El AAGUID solo debe usarse para ayudar a los usuarios con la administración de llaves de acceso. Se puede modificar, a menos que se certifique de forma criptográfica, lo que actualmente no admiten las llaves de acceso de la plataforma.

1. Registro de AAGUID

En la siguiente ubicación, se encuentra disponible una asignación de JSON mantenida por la comunidad de los AAGUID a los nombres y los íconos de los proveedores:

https://raw.githubusercontent.com/passkeydeveloper/passkey-authenticator-aaguids/refs/heads/main/combined_aaguid.json

Cada entrada tiene el siguiente esquema:

{
  "<aaguid-uuid>": {
    "name": "Provider Name",
    "icon_light": "data:image/png;base64,...",
    "icon_dark": "data:image/png;base64,..."
  }
}

2. Uso del AAGUID después del registro

Después de verificar una respuesta de registro, lee el aaguid del resultado del registro y búscalo en el registro para propagar el name y el providerIcon de la credencial:

Antes de buscar el AAGUID en el registro, verifica si es igual a '00000000-0000-0000-0000-000000000000'. Si es así, omite la búsqueda en el registro y establece name en un resguardo (p.ej., el nombre del dispositivo del usuario-agente o "Proveedor de llaves de acceso desconocido") y providerIcon en undefined. Solo busca en el registro los AAGUID que no sean cero.

import aaguids from './aaguids.json' with { type: 'json' };

const { aaguid } = registrationInfo;
if (aaguid === '00000000-0000-0000-0000-000000000000') {
  // use the device name as the passkey provider based on
  // the information derived from the user agent string,
  // or just say "Unknown passkey provider"
} else {
  const provider = aaguids[aaguid];
  const credential = {
    // ...other fields
    aaguid,
    name: provider?.name || 'Unknown passkey provider',
    providerIcon: provider?.icon_light,
  };
}

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

Si el navegador no admite PublicKeyCredential.parseRequestOptionsFromJSON, usa "webauthn-polyfills":

<script type="module"> 
  if (!PublicKeyCredential.parseRequestOptionsFromJSON) { 
     await import('https://unpkg.com/webauthn-polyfills'); 
   } 
 </script> 

Esto también agregará compatibilidad con PublicKeyCredential.prototype.toJSON.