← All posts

Nosso login travou por exatamente 10 segundos. Nossos próprios cabeçalhos de segurança fizeram isso.

Authagonal·July 23, 2026
oidcssocspfrontenddebuggingwar-story

Por cerca de uma semana, usuários recorrentes que abriam nosso portal viam a palavra "Carregando…" parada na tela por dez segundos antes de a página de login aparecer. Não às vezes. Não aproximadamente. Dez segundos, todas as vezes, e depois tudo funcionava perfeitamente. Visitantes de primeira viagem nunca viam isso. Apenas pessoas que já haviam feito login antes.

Um bug que leva um tempo aleatório é um problema de desempenho. Um bug que leva exatamente dez segundos é uma confissão. Nada em uma requisição web saudável se arredonda para uma potência exata de dez. Esse número não é a soma de algum trabalho real; é o teto de um timeout, e um timeout significa que algo, em algum lugar, está pacientemente esperando por uma coisa que nunca vai chegar.

Esta é a história do que ele estava esperando, e por que aquilo que ele esperava estava bloqueado por um cabeçalho de segurança do qual tínhamos orgulho.

O que o portal faz ao montar

Nosso portal é um aplicativo de página única. Quando ele carrega, antes de mostrar qualquer coisa, tenta responder a uma pergunta: você já está autenticado? A maneira educada de fazer isso com OIDC é uma verificação silenciosa. O aplicativo pergunta ao provedor de identidade "se este navegador já tem uma sessão, me entregue um token novo sem incomodar o usuário." Nossa biblioteca cliente, oidc-client-ts, expõe isso como signinSilent(), e nós a chamávamos na montagem a partir de um auxiliar renewSession().

Existem duas maneiras de essa verificação silenciosa rodar. Se o aplicativo estiver segurando um refresh token, a biblioteca faz uma troca discreta por back-channel, sem UI envolvida, e você está dentro. Se ele não estiver segurando um refresh token, a biblioteca recorre ao mecanismo mais antigo: abre um iframe oculto apontado para o endpoint de authorize do provedor de identidade com prompt=none, e espera que esse iframe poste um resultado de volta. Todo o propósito do iframe é ser invisível. Você nunca deveria vê-lo, e em uma configuração saudável você nunca vê, porque ele se resolve em milissegundos.

O nosso nunca se resolvia.

A parede que nós mesmos construímos

O iframe carrega o host de autenticação. E o host de autenticação, como todo host que operamos que tenha qualquer pretensão de ser levado a sério, envia dois cabeçalhos cuja função inteira é dizer "você não pode me colocar em um frame":

  • X-Frame-Options: DENY
  • Content-Security-Policy: frame-ancestors 'none'

Essas são defesas contra clickjacking, e estão corretas. Um atacante que consiga colocar sua página de login em um iframe pode flutuá-la sob um chamariz, enganar um usuário para que digite credenciais reais no que parece ser algo inofensivo, e coletá-las. frame-ancestors 'none' é a instrução moderna de que nenhuma origem, nem mesmo a nossa, pode incorporar esta página. Nós a ativamos deliberadamente. É exatamente o tipo de coisa que uma revisão de segurança procura e recompensa.

Então, quando o oidc-client-ts abriu seu iframe oculto contra aquele host, o navegador fez precisamente o que havíamos mandado: ele se recusou a renderizar a página em um frame. E aqui está a parte cruel. Um frame recusado não lança exceção. Não há evento de erro para a biblioteca capturar, nenhuma promise rejeitada, nenhuma linha no console. O iframe simplesmente fica ali, vazio, indefinidamente. Do ponto de vista da biblioteca, nada aconteceu ainda, então ela faz a única coisa que pode. Ela espera pelo seu timeout. Esse timeout, o silentRequestTimeout padrão, é dez segundos.

Dez segundos de um iframe oculto encarando uma parede em branco, então a biblioteca desiste, a promise finalmente rejeita, o aplicativo dá de ombros e redireciona você para a página de login real, e tudo funciona. O travamento nunca foi uma falha. Foi um sucesso que fez o caminho panorâmico através de um iframe condenado.

Duas coisas corretas, uma costura ruim

O que tornou isso genuinamente difícil de enxergar é que nada estava quebrado. Os cabeçalhos de segurança estavam certos. O fallback de silent-renew estava certo, um padrão de OIDC legítimo e amplamente usado. Cada componente se comportou exatamente como projetado e exatamente como qualquer revisor gostaria. O travamento de dez segundos não morava dentro de nenhum deles. Morava no espaço entre eles, na suposição que cada um fazia sobre o outro. A biblioteca de SSO supunha que podia colocar o provedor de identidade em um frame. O provedor de identidade supunha que ninguém jamais deveria ter permissão para colocá-lo em um frame. Ambas as suposições eram defensáveis. Elas eram simplesmente incompatíveis, e nenhum arquivo isolado continha a contradição.

Por que ele recorreu ao iframe, afinal

Isso ainda deixava uma pergunta. O caminho rápido, a troca de refresh token, teria pulado o iframe por completo. Por que os usuários recorrentes estavam caindo no caminho lento? Porque não tinham nenhum refresh token para segurar. E não tinham refresh token porque nossos próprios clientes OAuth do portal haviam sido provisionados sem AllowOfflineAccess, a flag que autoriza um cliente a receber um. Sem acesso offline, sem refresh token, sem caminho rápido, e cada usuário recorrente era desviado para o iframe que nunca conseguia carregar.

Esse era o defeito real, e era um problema de dados espalhado por todos os tenants, não uma mudança de código de uma linha que pudéssemos lançar uma única vez. Então a correção é um serviço de reconciliação que reaplica AllowOfflineAccess aos clientes de portal de cada tenant na inicialização, corrigindo toda a frota no próximo deploy sem ninguém tocar em um tenant manualmente. Os refresh tokens voltaram a fluir, e o caminho rápido voltou à vida por conta própria.

A correção, e a lição

O serviço de reconciliação corrigiu a causa. Mas um login não deveria travar por dez segundos mesmo quando acaba caindo no caminho lento, então também reforçamos a costura. renewSession() agora inspeciona primeiro o usuário armazenado: se não há nenhum refresh token à mão, ele faz um curto-circuito e retorna nada imediatamente, pula o iframe que já sabe estar condenado, e envia o usuário direto para o login interativo. O caminho rápido do refresh token permanece intocado. E como salvaguarda para qualquer renovação em segundo plano que ainda abra um iframe, cortamos o timeout de dez segundos para cinco, para que o pior caso seja metade tão ruim.

A lição que de fato guardamos é sobre uma categoria de bug, não sobre esta instância única. Um travamento é um bug, mesmo que não emita nenhum erro, nenhuma exceção, nenhuma linha vermelha em um log. A única evidência que ele deixa é o tempo decorrido. E quando esse tempo é um número redondo e exato, não saia à caça de trabalho lento para otimizar. Saia à caça de um timeout, e então encontre a coisa do outro lado dele que está, silenciosa e permanentemente, jamais a responder. A nossa era um iframe, batendo educadamente em uma porta que havíamos trancado de propósito.

Se você prefere que seu fluxo de login já saiba que um host de autenticação trancado e um iframe de silent-renew não combinam, essa é uma costura na qual já esbarramos para que você nunca precise. Authagonal entrega a infraestrutura de SSO e os cabeçalhos de segurança como um único sistema testado em conjunto, não como duas metades corretas que você acaba descobrindo serem incompatíveis a dez segundos por carregamento de página.