Login OIDC em JavaScript que falha na volta: redirect URI, CORS e state por ambiente
Cadastre a URI de retorno exata de cada ambiente, libere o CORS do endpoint de token para a origem certa, sirva a página de callback e evite o erro de state perdido no oidc-client-ts.
Em desenvolvimento, o login do painel funciona. Em homologação, o botão "Entrar" leva para o IdentityServer, a pessoa digita a senha e volta para uma página de erro dizendo que o pedido é inválido. Em produção, a volta acontece, mas o console mostra No matching state found in storage e a tela fica em branco. O código é o mesmo nos três ambientes. A resposta curta: quase toda falha de cliente JavaScript no OIDC está na volta, não no botão, e vem de três configurações que precisam bater com o cliente cadastrado no provedor de identidade: a URI de retorno exata de cada ambiente, a origem liberada no CORS para a troca do código pelo token, e a mesma origem no início e no fim do fluxo, para a biblioteca encontrar o state que guardou.
Se o sintoma é especificamente "erro de rede" no console, o guia erro de rede no oidc-client vai direto ao CORS. Se só falha num navegador, veja login OIDC que falha num browser só. Este guia monta a configuração completa por ambiente, que evita os dois.
O caminho de ida e volta, e onde cada peça quebra
No fluxo Authorization Code com PKCE, que é o recomendado para aplicação no navegador, a biblioteca gera um state e um verificador de código, guarda os dois no storage do navegador e redireciona para o provedor com o redirect_uri configurado. O provedor autentica a pessoa e só redireciona de volta se aquele redirect_uri estiver cadastrado para o cliente, com comparação exata. Na volta, a página de callback lê o code e o state da URL, procura o state no storage e faz um POST para o endpoint de token, do navegador direto para o provedor, o que exige CORS.
Cada etapa tem a sua falha típica:
- O provedor recusa voltar: o
redirect_urienviado não é idêntico a nenhum cadastrado. No IdentityServer, o log registra o redirect_uri inválido com o valor recebido. - A volta cai num 404: a rota
/callbackexiste no Vue Router, mas o servidor web não devolve oindex.htmlpara ela. - O state não é encontrado: o fluxo começou numa origem e terminou em outra, o storage foi limpo, ou o callback foi processado duas vezes.
- Erro de rede na troca do token: a origem do app não está liberada no CORS do cliente.
Por que "comparação exata" pega tanta gente
Para o provedor, https://painel.exemplo.com.br/callback, https://painel.exemplo.com.br/callback/ e http://painel.exemplo.com.br/callback são três URIs diferentes. Também são diferentes http://localhost:5173 e http://127.0.0.1:5173, e qualquer troca de porta. Para o navegador, as duas formas de localhost são origens diferentes, cada uma com o seu storage: se o fluxo começa em uma e volta para a outra, o state não está lá.
A tentação é cadastrar curinga para cobrir tudo. Não faça isso. URI de retorno aberta permite que um atacante peça ao provedor para entregar o código de autorização num endereço controlado por ele. A lista exata é a proteção, não a burocracia.
Como configurar por ambiente
- Liste as origens reais de cada ambiente: esquema, host e porta. No exemplo,
http://localhost:5173,https://homolog.painel.exemplo.com.brehttps://painel.exemplo.com.br. - Prefira um cliente por ambiente no provedor. O cliente de produção não deve aceitar retorno para localhost.
- Cadastre a URI de retorno, a de pós-logout e a origem do CORS de cada cliente, exatamente como o navegador vai usar.
- Derive o
redirect_urida origem atual no front, em vez de escrever o endereço fixo em cada build. - Configure o servidor web para devolver o
index.htmlna rota de callback. - Processe o callback uma única vez e troque a URL depois, para recarregar a página não repetir o processamento.
- Teste numa janela anônima em cada ambiente, sem storage antigo nem extensões.
O cliente no IdentityServer
new Client
{
ClientId = "painel-spa-homolog",
ClientName = "Painel (homologação)",
AllowedGrantTypes = GrantTypes.Code,
RequirePkce = true,
RequireClientSecret = false,
RedirectUris = { "https://homolog.painel.exemplo.com.br/callback" },
PostLogoutRedirectUris = { "https://homolog.painel.exemplo.com.br/" },
AllowedCorsOrigins = { "https://homolog.painel.exemplo.com.br" },
AllowedScopes = { "openid", "profile", "api.pedidos" }
}
Repare que AllowedCorsOrigins recebe a origem, sem caminho e sem barra no fim, enquanto RedirectUris recebe a URI completa. RequireClientSecret = false porque código no navegador não guarda segredo; a proteção da troca vem do PKCE. Os clientes de desenvolvimento e produção seguem o mesmo molde, cada um com as suas URIs.
O front com oidc-client-ts
// src/auth.js
import { UserManager } from 'oidc-client-ts';
const origem = window.location.origin;
export const userManager = new UserManager({
authority: import.meta.env.VITE_OIDC_AUTHORITY,
client_id: import.meta.env.VITE_OIDC_CLIENT_ID,
redirect_uri: origem + '/callback',
post_logout_redirect_uri: origem + '/',
response_type: 'code',
scope: 'openid profile api.pedidos'
});
let callbackEmAndamento = null;
export function concluirLogin() {
if (!callbackEmAndamento) {
callbackEmAndamento = userManager.signinRedirectCallback();
}
return callbackEmAndamento;
}
// componente da rota /callback
onMounted(async () => {
try {
await concluirLogin();
router.replace('/');
} catch (erro) {
console.error('Falha ao concluir login:', erro.message);
mensagem.value = 'Não foi possível concluir o login. Tente entrar de novo.';
}
});
O redirect_uri derivado de window.location.origin garante que o fluxo volta para a mesma origem em que começou. O que muda entre ambientes, a autoridade e o id do cliente, vem das variáveis do Vite. A promessa guardada em callbackEmAndamento impede o segundo processamento, comum quando o componente é montado duas vezes; o segundo signinRedirectCallback não acharia mais o state, que é removido depois do primeiro uso. O router.replace tira o code e o state da barra de endereço.
O servidor web devolvendo o callback
Com o Vue Router em modo histórico, /callback não é um arquivo. No nginx, a regra comum é a abaixo; no IIS ou em outros servidores, o equivalente é uma regra de reescrita para o index.html.
location / {
try_files $uri $uri/ /index.html;
}
Como diagnosticar quando ainda falha
Abra o DevTools com a aba Network em modo de preservar log e faça o login inteiro. Na requisição para o endpoint de autorização, copie o parâmetro redirect_uri e compare caractere por caractere com o cadastrado. Na volta, confira se a URL tem code e state, ou error e error_description, que dizem o motivo. Na requisição POST para o endpoint de token, veja se ela falhou por CORS. E no log do provedor, procure a entrada do mesmo horário: ele quase sempre diz qual configuração não bateu. Para entender quem é o provedor e o que ele resolve, veja IdentityServer não é login caseiro.
Erros comuns
- Cadastrar uma URI só e usar em todos os ambientes. Homologação e produção recebem erro de redirect inválido depois da senha.
- Barra no fim, porta ou esquema diferente do cadastrado. A comparação exata recusa, e o erro parece mistério porque "é a mesma URL".
- Curinga ou lista aberta de retorno. O código de autorização pode ser entregue a um endereço controlado por outra pessoa.
- Esquecer o CORS da origem do app. A troca do código pelo token falha como erro de rede, e a culpa vai para a biblioteca.
- Começar em localhost e voltar em 127.0.0.1. São origens diferentes, com storages diferentes, e o state não é encontrado.
- Processar o callback duas vezes ou não ter rota no servidor. O segundo processamento não acha o state, e a falta de reescrita devolve 404.
Checklist
- Cada ambiente tem o seu cliente, ou pelo menos as suas URIs exatas cadastradas.
- O cliente de produção não aceita retorno para localhost.
- URI de retorno, pós-logout e origem de CORS estão cadastradas para cada ambiente.
- O
redirect_urido front é derivado da origem atual. - A rota de callback devolve o
index.htmlno servidor web. - O callback é processado uma vez e a URL é limpa depois.
- O login foi testado numa janela anônima em cada ambiente.
Copie o redirect_uri da última requisição de login em homologação e compare com o que está cadastrado no provedor: os dois são idênticos, inclusive na barra final e na porta?
Referência: https://docs.identityserver.io/en/latest/quickstarts/4_javascript_client.html