Logins rápidos com o modo de IU imediato

Direcionar um usuário não autenticado para uma página de login dedicada quando ele clica em um link "Fazer login", inicia o pagamento, salva um item nos favoritos ou chega a um paywall de artigo causa perda de contexto e aumenta o abandono do usuário. O modo de interface imediata (uiMode: 'immediate' em navigator.credentials.get()) permite logins rápidos contextuais, apresentando um seletor de contas unificado do navegador imediatamente quando há chaves de acesso locais (publicKey) ou senhas salvas (password: true) no dispositivo.

Ao contrário dos prompts modais padrão da WebAuthn, que sempre mostram a interface do navegador e usam QR codes entre dispositivos ou chaves de segurança externas como alternativa, o modo de interface imediata falha rapidamente com um NotAllowedError se não houver credenciais qualificadas disponíveis localmente no dispositivo. Isso permite que o aplicativo autentique usuários recorrentes com um único toque, enquanto faz a transição de usuários sem credenciais locais para a página de login padrão, a compra sem login ou o formulário de login inline.

Acione o modo de IU imediata em um manipulador de eventos iniciado pelo usuário (que exige ativação temporária do usuário) em momentos de clara intenção do usuário logo antes de sair da página atual. Se a autenticação imediata for bem-sucedida, conclua a intenção do usuário imediatamente (atualizando a página atual ou acessando diretamente o destino autenticado). Se falhar com NotAllowedError, mostre um fluxo de login ou registro com um caminho para concluir a intent do usuário após a conclusão.

Possíveis casos de uso:

  • Intercepção do link ou botão de login do cabeçalho: intercepte um clique em um link global de login (event.preventDefault()) para autenticar usuários recorrentes imediatamente ou continue navegando até a página de login dedicada em NotAllowedError.
  • Login dinâmico antes da finalização da compra: autentique os compradores recorrentes com um único toque quando eles clicarem em "Finalizar compra" para que eles acessem diretamente a etapa de envio e pagamento ou façam a transição para a tela de login padrão ou de compra sem login em caso de fallback.
  • Login inline para microações (favoritar, curtir, marcar, salvar): autentique no local quando um usuário desconectado clicar em um botão de microação e concluir a ação imediatamente (por exemplo, alternar aria-pressed="true") ou abrir um modal de login substituto ou uma página de login com um parâmetro de URL de retorno.
  • Paywalls flexíveis em artigos: autentique os leitores no local quando eles clicarem em "Continuar lendo com sua conta" em um banner de paywall ou expandirem um formulário de login ou registro inline como alternativa.

Antipadrões a serem evitados:

  • NÃO use o modo de IU imediata em uma página de login dedicada: nunca anexe uiMode: 'immediate' ao botão principal "Fazer login" ou "Fazer login com uma chave de acesso" na página de login de destino final. Os usuários acessam a página de login dedicada especificamente quando não têm credenciais locais no dispositivo atual ou quando pretendem usar uma chave de acesso entre dispositivos (lendo um QR code com um smartphone) ou uma chave de segurança de hardware externa. Como uiMode: 'immediate' suprime as opções de QR code e chave de segurança entre dispositivos e gera imediatamente NotAllowedError quando não há credenciais locais, o uso dele em uma página de login dedicada deixa o usuário em um beco sem saída, em que clicar no botão não faz nada. Em uma página de login dedicada, use a interface condicional (mediation: 'conditional' com autocomplete="username webauthn") em entradas de formulário combinadas com um botão modal padrão navigator.credentials.get({ publicKey }) (consulte Autenticação com chave de acesso).
  • NÃO use o modo de interface imediata para reautenticação de nível superior: nunca use uiMode: 'immediate' para reautenticar um usuário que já fez login antes de operações sensíveis, como mudar uma senha, atualizar um endereço de entrega, editar formas de pagamento salvas ou adicionar uma nova chave de acesso. A reautenticação precisa restringir a solicitação às credenciais do usuário conectado usando uma lista allowCredentials não vazia, que o modo de IU imediata rejeita com NotAllowedError. Omitir allowCredentials permitiria que o usuário selecionasse inadvertidamente uma conta diferente da que está conectada no momento. Para a reautenticação de step-up, siga as instruções em Reautenticação com chave de acesso.

Restrições de privacidade e segurança

Os navegadores aplicam regras rígidas de privacidade e segurança quando uiMode: 'immediate' é solicitado:

  • A ativação temporária do usuário é obrigatória: você PRECISA chamar navigator.credentials.get({ uiMode: 'immediate' }) dentro de um manipulador de eventos iniciado pelo usuário (como um listener click em um elemento <button> ou <a>) para evitar a sondagem silenciosa de credenciais. Nunca a invoque automaticamente no carregamento de página (DOMContentLoaded). Chamar a API não consome a ativação temporária.
  • Não transmita uma lista allowCredentials não vazia: NÃO preencha publicKey.allowCredentials quando uiMode: 'immediate' for especificado. Os navegadores rejeitam imediatamente qualquer solicitação de modo de IU imediato com uma lista allowCredentials não vazia, gerando NotAllowedError para que as partes confiáveis não possam sondar IDs de credenciais específicos para inferir visitas anteriores ou rastrear usuários em várias sessões.
  • Não transmita um AbortSignal (signal): NÃO defina a propriedade signal em navigator.credentials.get() quando uiMode: 'immediate' for especificado, porque os navegadores não permitem o encerramento programático da caixa de diálogo de login imediato. Se a página também executar uma solicitação de interface condicional (mediation: 'conditional') em segundo plano, chame abort() nessa interface condicional AbortController antes de invocar o modo de interface imediata e omita signal da própria solicitação imediata.
  • Sessões de navegação anônima e privada: as solicitações feitas em janelas de navegação anônima ou privada sempre são rejeitadas com NotAllowedError para que as sessões privadas permaneçam indistinguíveis das sessões sem credenciais locais.

Configuração do lado do servidor

Endpoint de geração de opções

Exponha um endpoint que gere opções de solicitação detectáveis do WebAuthn para visitantes não autenticados:

  1. Gerar um desafio vinculado à sessão: crie um buffer de desafio criptograficamente aleatório, armazene-o na sessão do servidor e codifique-o como Base64URL.
  2. Persistir a preferência de verificação do usuário: defina userVerification: 'preferred' e armazene expectedUserVerification: 'preferred' na sessão.
  3. Omitir allowCredentials: não inclua uma lista allowCredentials não vazia nas opções do modo de IU imediata, porque os navegadores rejeitam listas de permissões não vazias com 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);
});

Endpoints de verificação de credenciais duplas

Como navigator.credentials.get({ password: true, publicKey, uiMode: 'immediate' }) pode ser resolvido com uma chave de acesso (PublicKeyCredential) ou uma senha salva (PasswordCredential), forneça gerenciadores de verificação para os dois tipos de credencial:

  1. Verificação de declaração do WebAuthn (/api/login/verify): verifique a declaração em relação ao desafio da sessão e à chave pública armazenada. Se o ID da credencial não for encontrado no banco de dados, retorne o status HTTP 404 para que o cliente possa notificar o gerenciador de senhas usando PublicKeyCredential.signalUnknownCredential().
  2. Verificação de senha (/api/login/password): verifique o username (cred.id) e a password (cred.password) no banco de dados do usuário e estabeleça uma sessão autenticada.

Implementação do lado do cliente

Solicitação de credencial unificada

  1. Solicitar chaves de acesso e senhas: decodifique as opções do servidor usando PublicKeyCredential.parseRequestOptionsFromJSON() e chame navigator.credentials.get() em um gerenciador de cliques iniciado pelo usuário com password: true, publicKey e uiMode: 'immediate' (sem transmitir signal).
  2. Verificação de agência por cred.type:
    • Se for cred.type === 'public-key', serialize a declaração com cred.toJSON() e envie-a por POST para o endpoint de verificação do WebAuthn. Quando o servidor responder com HTTP 404, chame PublicKeyCredential.signalUnknownCredential({ rpId: publicKey.rpId, credentialId: encoded.id }).
    • Se for cred.type === 'password', envie um POST { username: cred.id, password: cred.password } para o endpoint de verificação de senha.
  3. Processar NotAllowedError quando não houver credenciais locais: capture NotAllowedError (gerado imediatamente quando não houver chaves de acesso locais ou senhas salvas no dispositivo ou quando o usuário dispensar o seletor) e faça a transição para o fluxo de login padrão (como abrir uma caixa de diálogo de login inline ou navegar até /signin com um 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;
  }
});

Estratégias de fallback

Contingência imediata do modo de interface

  • Experiência de aprimoramento progressivo: trate uiMode: 'immediate' como um aprimoramento progressivo. Não deduza a compatibilidade pelo nome ou pela versão de um navegador. O único indicador confiável é a verificação de ambiente de execução capabilities.immediateGet usando PublicKeyCredential.getClientCapabilities(). OBRIGATÓRIO: sempre verifique capabilities.immediateGet antes de chamar navigator.credentials.get({ uiMode: 'immediate' }). Os navegadores que não reconhecem uiMode: 'immediate' ignoram a chave de dicionário desconhecida e abrem uma caixa de diálogo modal de bloqueio de chave de acesso e QR code. Quando capabilities.immediateGet for falso ou quando navigator.credentials.get() rejeitar com NotAllowedError, use a experiência de login padrão do seu aplicativo (como abrir um modal de login inline ou navegar até a página de login dedicada com um parâmetro de URL de retorno) para que os usuários em navegadores sem suporte vejam seu fluxo de login atual sem mudanças:
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' })
}

Substituto para detecção de recursos do cliente

Sempre instale o 'webauthn-polyfills' e o importe no módulo do cliente para que o PublicKeyCredential.getClientCapabilities() esteja disponível sempre que houver suporte para o PublicKeyCredential:

import 'webauthn-polyfills';

Substituição de credenciais de senha

Transmitir password: true com publicKey em navigator.credentials.get() permite que os navegadores compatíveis incluam senhas salvas do gerenciador de senhas do navegador no seletor de contas imediato unificado.

  • Experiência de substituição: os navegadores que não são compatíveis com PasswordCredential ignoram a opção password: true ou usam a verificação de recursos capabilities.immediateGet para voltar ao formulário de login padrão, em que <input autocomplete="username webauthn"> e <input type="password" autocomplete="current-password"> oferecem preenchimento automático de senha nativo.

Facilidade de fallback de serialização JSON

Sempre instale 'webauthn-polyfills' e importe-o no pacote do cliente para que PublicKeyCredential.parseRequestOptionsFromJSON() e PublicKeyCredential.prototype.toJSON() funcionem de maneira consistente em todos os navegadores compatíveis com PublicKeyCredential:

import 'webauthn-polyfills';

Substituição de sincronização da API Signal

A API WebAuthn Signal (webauthn-signals) é um aprimoramento progressivo que remove chaves de acesso desatualizadas do gerenciador de senhas do usuário quando o servidor responde com HTTP 404.

  • Experiência substituta: as chamadas são bloqueadas por if (PublicKeyCredential.signalUnknownCredential). Quando não houver suporte, pule a chamada de sinalização e mostre uma mensagem clara orientando o usuário a fazer login com um método alternativo.