npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@tjpa/n8n-nodes-keycloak-guard

v0.17.4

Published

Community node for n8n that protects webhooks with Keycloak: injects Authorization Code + PKCE login into HTML pages and validates the returning token.

Downloads

2,691

Readme

@tjpa/n8n-nodes-keycloak-guard

Protege páginas e APIs feitas no n8n com o login do Keycloak.

O usuário abre a página, entra com a conta do Tribunal numa janelinha, e pronto. Depois disso a página sabe quem ele é, e você pode esconder pedaços da tela de quem não é do grupo certo. As chamadas de API que a página fizer já saem assinadas — você não precisa mexer em nada no JavaScript.

Os dois nodes

| Node | Onde você usa | O que faz | |---|---|---| | Keycloak Guard | no webhook que devolve HTML | põe o login na página | | Keycloak Verify | no webhook que devolve dados | confere o token e diz quem é o usuário |

O que dá para fazer com cada um, em uma linha cada:

| Quero… | Node | Como | |---|---|---| | Exigir login para ver a página | Guard | é o padrão, basta pôr o node no fluxo | | Recusar quem está fora de um grupo/lotação | Guard | Access ControlRequired Groups / Required Units | | Esconder um botão de quem não pode | Guard | data-kcg-group / data-kcg-unit / data-kcg-deny-group no HTML | | Mostrar o nome de quem está logado | Guard | <span data-kcg-user="name">, sem script | | Chamar minha API já autenticado | Guard | fetch comum dentro de keycloakGuard.ready() | | Chamar uma API em outro domínio | Guard | listar o domínio em Allowed API Origins | | Trocar logo, fundo e título da tela de login | Guard | Appearance | | Fugir do CORS na troca do code | Guard + Verify | Token Exchange URL + operação Exchange Code | | Barrar de verdade, no servidor | Verify | operação Verify Token | | Pegar token revogado na hora | Verify | Verify Mode = Introspection | | Validar rápido, sem ida ao Keycloak | Verify | Verify Mode = Offline (JWKS) | | Exigir role, grupo ou lotação na API | Verify | os quatro gates, todos opcionais | | Saber quem chamou, no fluxo | Verify | $json.user na saída authorized | | Devolver 403 em vez de 200 | Verify | ligar a saída unauthorized num Respond to Webhook |

Começando

  1. Crie um client no Keycloak (público) para a página, e outro (confidencial) para o servidor. Detalhes em Os dois clients.
  2. Preencha a credencial Keycloak Guard API no n8n com o endereço do Keycloak, o realm e os dois clients. O botão Test bate no /.well-known/openid-configuration do realm; em Server URL tanto faz https://sso.exemplo, https://sso.exemplo/ ou https://sso.exemplo/realms — a barra e o /realms no fim são removidos sozinhos.
  3. No webhook da página, ponha o Keycloak Guard entre quem gera o HTML e o Respond to Webhook.
  4. No webhook de dados, ponha o Keycloak Verify logo depois do Webhook. Ligue a saída authorized ao resto do fluxo e a saída unauthorized a um Respond to Webhook.

Escondendo partes da página

Marque qualquer elemento do seu HTML e ele desaparece para quem não tem acesso:

<p data-kcg-group="DIA">Só quem é do DIA vê isto.</p>
<p data-kcg-group="DIA,ASSETIC">Do DIA ou do ASSETIC.</p>
<p data-kcg-group="SETIC">Qualquer pessoa da SETIC, inclusive das áreas abaixo dela.</p>
<p data-kcg-unit="4013">Só quem é lotado na unidade 4013.</p>
<p data-kcg-deny-group="ESTAGIO">Todo mundo, menos estagiários.</p>

Você escreve o nome do grupo ou o caminho inteiro, tanto faz. Escrever o nome de uma área de cima libera todas as de baixo.

Importante: isso serve para organizar a tela, não para guardar segredo. O texto já foi enviado ao navegador e alguém curioso consegue ler. Informação que não pode vazar precisa ficar fora do HTML — busque ela por uma chamada de dados protegida pelo Keycloak Verify.

Buscando dados

O código é seu: escreva fetch normal, num <script> normal. O que o guard faz é anexar o token — você não escreve nada de autenticação.

<script>
  keycloakGuard.ready(async (user) => {
    const r = await fetch('/webhook/meus-dados');   // sai com o Bearer
    const dados = await r.json();
    document.querySelector('#saida').textContent = JSON.stringify(dados, null, 2);
  });
</script>

O ready() existe porque o login acontece numa janelinha: um script no fim do <body> roda antes de existir token. Dentro dele a página já está autenticada, já passou pelo controle de acesso e já assina as chamadas. Ele vale por promessa, por callback ou por evento — o que combinar com seu código:

await keycloakGuard.ready();                       // promessa
keycloakGuard.ready((user) => { ... });            // callback, recebe o usuário
document.addEventListener('keycloak-guard:ready', (e) => e.detail.user);

Quem chega atrasado não fica esperando à toa: um handler registrado depois roda no tique seguinte. Se a pessoa não passar no controle de acesso, o ready() não dispara — é de propósito, o código da página não deve rodar — e o motivo aparece no console. Já se o guard não conseguir sequer subir (por exemplo, página fora de HTTPS, onde não existe crypto.subtle), o ready() falha em vez de ficar pendurado para sempre:

keycloakGuard.ready(carregar).catch((e) => console.error('guard não subiu:', e.message));

Quando o script da página quebra

Um erro de sintaxe no seu <script> acontece enquanto o navegador ainda está lendo a página — antes do guard existir. Ele não tem como capturar, seu ready() nunca chega a ser chamado, e a página fica parada. Isso é idêntico, na tela, a um login que não terminou; e são bugs bem diferentes.

Por isso o guard escuta desde a primeira linha do documento. Todo erro não tratado e toda promessa rejeitada sem catch vão para o console com o prefixo [keycloak-guard] e, assim que o overlay sai, aparecem numa barra no rodapé da página, com arquivo e linha:

O script desta página falhou — o guard entrou normalmente.
Unexpected end of input  (https://n8n.tjpa.jus.br/webhook/abc:112:5)

A barra continua registrando o que quebrar depois, e tem um × para fechar.

Seu <script> pode ficar em qualquer lugar da página. O guard entra no fim do <body>, onde consegue montar a tela de login, mas antes dele o node injeta um keycloakGuard.ready() mínimo lá no topo do documento, que só guarda seus handlers até o guard chegar e assumir a fila.

Se quiser menos cerimônia, há dois atalhos:

const dados = await keycloakGuard.json('/webhook/meus-dados');  // já parseado, erra em não-2xx
const quem  = keycloakGuard.user();                             // nome, grupos, lotações

Para mostrar quem está logado, aí sim sem script nenhum:

<span data-kcg-user="name"></span>          <!-- nome da pessoa -->
<span data-kcg-user="preferred_username"></span>
<span data-kcg-user="groups"></span>        <!-- lista, separada por vírgula -->
<pre data-kcg-user></pre>                   <!-- tudo, formatado -->

Qualquer fetch ou XMLHttpRequest comum da página sai autenticado, sem você fazer nada — vale também para bibliotecas que usam fetch por baixo. Se a API estiver em outro endereço, liste esse endereço em Allowed API Origins; fora dessa lista o token nunca é anexado.

Como a coisa toda se encaixa

Página:  [Webhook] → [gera o HTML] → [Keycloak Guard] → [Respond to Webhook]

         no navegador: abre uma janelinha do Keycloak, o usuário entra, a janela fecha.
         A partir daí a página conhece o usuário e assina as chamadas sozinha.

Dados:   [Webhook] → [Keycloak Verify] ─ authorized   → seu fluxo (com $json.user)
                                       └ unauthorized → Respond 401/403

Devolvendo 401 e 403 de verdade

Recusar o item e responder ao chamador são duas coisas. O Keycloak Verify faz a primeira: ele roteia o item para unauthorized. Quem devolve status HTTP é o Respond to Webhook. Se o resultado que você vê é 200 mesmo com o token errado, é quase sempre um destes três:

1. O Webhook não está esperando o Respond. No node Webhook, o campo Respond precisa estar em Using 'Respond to Webhook' Node. O default do n8n é responder imediatamente, com 200, assim que o webhook dispara — os nodes seguintes até rodam, mas a resposta já foi embora. Este é o motivo mais comum.

2. O Respond to Webhook está com o código padrão. Ele responde 200 a menos que você mande outra coisa. Abra o node → OptionsResponse Code.

3. As saídas ficaram soltas. Sem nenhum Respond to Webhook no caminho, o n8n devolve a resposta padrão dele — de novo, 200. Aí parece que passou, quando na verdade foi recusado.

Para não ter que decidir o número na mão, o node já entrega o status certo em cada item:

{ "keycloakGuard": { "status": "authorized",   "httpStatus": 200 } }
{ "keycloakGuard": { "status": "unauthorized", "reason": "no_token",      "httpStatus": 401 } }
{ "keycloakGuard": { "status": "unauthorized", "reason": "missing_group", "httpStatus": 403 } }

Então, no Response Code de cada Respond to Webhook, basta a mesma expressão:

{{ $json.keycloakGuard.httpStatus }}

Não precisa de IF nem de Switch, e não precisa duplicar o Webhook. A mesma expressão serve nas duas saídas, porque as duas carregam o campo.

A diferença entre 401 e 403 é a que o navegador e o cliente usam para decidir o que fazer: 401 diz "você não provou quem é, outro token pode funcionar"; 403 diz "provou, e não adianta". Mandar um usuário já logado se autenticar de novo por causa de um grupo em que ele nunca vai estar é exatamente o engano que essa separação evita.

| reason | httpStatus | |---|:--:| | no_token | 401 | | inactive_token | 401 | | refused_by_keycloak | 401 | | missing_realm_role | 403 | | missing_client_role | 403 | | missing_group | 403 | | missing_unit | 403 |

E o 500? Não é seu problema. Se o Keycloak estiver fora do ar, ou o JWKS não carregar, o node lança o erro em vez de desviá-lo — e aí o n8n faz o que já sabe: falha a execução, responde 500 e respeita o On Error do node. Se você quiser tratar isso no fluxo, ligue Settings → On Error → Continue (using error output), que é o mecanismo do próprio n8n e vale para qualquer node.

Configuração dos 2 clients no Keycloak

  1. Client público (ex.: n8n-frontend): Standard Flow habilitado, PKCE S256,
    • Standard flow habilitado; Direct access grants pode ficar desligado.

    • PKCE Code Challenge Method = S256 (Advanced settings). Opcional, mas com isso o Keycloak passa a recusar pedidos sem PKCE em vez de apenas aceitar quando vem.

    • Valid redirect URIs: a URL do webhook com o marcador de callback, ex.: https://n8n.tjpa.jus.br/webhook/<id>?kcg_callback=1 (ou https://n8n.tjpa.jus.br/webhook/* se preferir cobrir todos).

    • Web Origins: precisa autorizar o origin opaco desta página, que o browser manda como Origin: null na troca code → token. Um origin literal não cobre isso. Verificado contra o sso.tjpa.jus.br: o Keycloak ecoa o Origin recebido de volta — responde access-control-allow-origin: null — desde que o valor esteja liberado no client. Portanto: Só é necessário se você NÃO usar a operação Exchange Code. Com o relay ligado o browser nunca fala com o Keycloak, e não há origin a liberar.

      Se optar pela chamada direta: use a entrada null — a string literal, não o curinga. Pelo DefaultCors.java a comparação é exata (allowedOrigins.contains(origin)), então null casa apenas com quem manda Origin: null: documentos de origin opaco. Não expande para mais nada.

      Não use *. Como o Keycloak também manda access-control-allow-credentials: true e ecoa o origin, o curinga deixaria qualquer site fazer request credenciado a esses endpoints. null é uma entrada estreita; * não é.

      Prefira ainda um client dedicado (ex. n8n-webhook-guard) só para esta página, com null apenas nele — assim o n8n-frontend fica intocado e o raio de ação fica contido ao acesso desta página.

  2. Client confidential (ex.: n8n): usado só pelo Keycloak Verify para introspection.

Detalhes técnicos

Daqui para baixo é o porquê das decisões. Para usar o node, o que está acima basta.

Por que o login é por popup (e não por redirect)

Desde a n8n 1.103.0 toda resposta HTML de webhook sai com um header CSP sandbox. Isso dá ao documento um origin opaco: sessionStorage, localStorage e document.cookie lançam exceção. O keycloak-js não consegue logar ali de jeito nenhum — o createLoginUrl() persiste o code_verifier do PKCE via callbackStorage de forma incondicional (keycloak.js:1292), e não existe adapter de popup (só default, cordova, cordova-native). Login por redirect é impossível: o redirect destrói a página, e não há onde guardar o verifier.

A saída é o popup, e ela funciona porque o sandbox do n8n já concede as flags necessárias (packages/core/src/html-sandbox.ts):

sandbox allow-downloads allow-forms allow-modals allow-orientation-lock allow-pointer-lock
        allow-popups allow-popups-to-escape-sandbox allow-presentation allow-scripts
        allow-top-navigation-by-user-activation allow-top-navigation-to-custom-protocols

allow-popups deixa a popup abrir; allow-popups-to-escape-sandbox faz com que ela não herde o sandbox, então ao navegar para o Keycloak ela tem um origin real e cookies normais. O SSO acontece lá.

O sandbox continua ligado para a instância inteira. Nada é servido de origin novo, não há proxy nem segundo serviço, e N8N_INSECURE_DISABLE_WEBHOOK_IFRAME_SANDBOX não é usado.

O que carrega o PKCE

O ponto que dispensa storage: na versão popup, a página do iframe nunca navega. Persistência só era necessária porque o redirect destruía a página. Aqui o code_verifier vive numa variável de closure (attempt) enquanto a popup vai e volta, e é usado na troca code → token feita pela própria página. Nenhum caminho de código toca storage ou cookie no origin opaco — há teste garantindo que as strings sessionStorage, localStorage e document.cookie nem aparecem no script injetado.

O caminho de volta

A redirect_uri é a própria página do webhook mais o marcador ?kcg_callback=1. Quando a popup volta do Keycloak, o mesmo script injetado detecta o marcador, entrega { code, state } ao window.opener via postMessage e fecha. Essa perna só lê a query string — nada que um origin opaco proíba.

Atenção ao workflow: a volta da popup é um segundo hit no webhook, então o workflow executa de novo. Se ele tiver efeito colateral, ponha um IF logo no início que desvie quando {{ $json.query.kcg_callback }} estiver setado e responda com um HTML mínimo — o script injetado faz o resto.

Validação do postMessage

O event.origin não pode carregar essa validação sozinho: todo documento que o n8n sandboxa reporta origin === 'null', inclusive a nossa página de callback. Uma allowlist de origin teria que aceitar 'null' e, com isso, não distinguiria o nosso callback de qualquer outro frame sandboxed. Por isso são três checagens, e a primeira é a que sustenta o resto:

  1. event.source === popup — identidade da janela. Só a janela que nós abrimos é aquele objeto; isso não é forjável por outra aba ou frame.
  2. event.origin contra a allowlist ['null', origin da página, origin do Keycloak].
  3. data.state igual ao state gerado em memória nesta tentativa.

Mensagens sem o marcador source são ignoradas em silêncio. E mesmo que um code falso passasse, ele é inútil: sem o code_verifier — que nunca sai da memória desta página — o Keycloak recusa a troca. É exatamente para isso que o PKCE existe.

Keycloak Guard

O node client side: injeta o login por popup nas páginas HTML servidas pelo webhook.

Parâmetros:

  • HTML Field (htmlField, default html) — nome do campo em item.json que contém a string HTML a modificar (nem sempre se chama html).
  • Allowed API Origins (apiOrigins, CSV) — origens além do mesmo-origin que também podem receber o header Authorization. Qualquer outro destino nunca recebe o token do usuário. * libera todas e emite um aviso no console — evite.
  • Access Control (coleção, tudo opcional) — Required Groups, Require All Groups, Required Units, Require All Units. Mesma escrita e mesma regra do Keycloak Verify. Vazio desliga o gate. Isto é UX: quem barra de verdade é o Verify, no servidor.
  • Appearance (coleção, tudo opcional) — Brand Title, Logo URL, Background URL. Sem nada preenchido, usa a identidade do TJPA embutida no pacote; preenchendo, as imagens padrão não são embutidas no HTML gerado, então a página fica bem menor.
  • Token Exchange URL (exchangeUrl) — se preenchido, a troca code → token vai para esse endereço em vez de ir direto ao Keycloak. É o par da operação Exchange Code do Verify, e existe para não precisar liberar null em Web Origins. Veja Exchange Code.

O node injeta, logo antes de </body> (ou no final da string, se não houver </body>), um <script> autocontido — sem CDN, sem keycloak-js, sem dependência externa:

  1. Um overlay institucional que cobre a página e tenta abrir o Keycloak automaticamente, com o botão Entrar com Keycloak como fallback. O window.open precisa ser o efeito direto do clique, senão o popup blocker derruba; por isso o code_verifier, o state e a URL de autorização são preparados antes, na carga, e o handler do clique é totalmente síncrono.
  2. Equivale a onLoad: 'login-required', mas como overlay bloqueante em vez de redirect automático: um popup automático, sem gesto do usuário, seria bloqueado por todo navegador. (Como antes, esse bloqueio é cosmético — o HTML já está no DOM. Quem protege de verdade é o Keycloak Verify, no servidor.)
  3. Authorization Code + PKCE S256: verifier de 64 bytes por crypto.getRandomValues, challenge por crypto.subtle.digest('SHA-256', …), ambos em base64url sem padding.
  4. Troca code → token via fetch para o token endpoint, application/x-www-form-urlencoded (request simples, sem preflight). Tokens ficam em memória.
  5. Um interceptor global de window.fetch e XMLHttpRequest.prototype.send que renova o token quando faltam menos de 30s para expirar e só anexa Authorization: Bearer <token> se o destino for o mesmo origin da página ou estiver em Allowed API Origins. Qualquer outro destino nunca recebe o token.
  6. window.keycloakGuard com ready(), user(), json(), getToken(), isAuthenticated() e logout(), mais o evento keycloak-guard:ready no document.

Autorização por grupo e lotação

Os grupos do Keycloak chegam como caminho completo. Uma regra só, sem modo a escolher: a entrada casa quando seus segmentos aparecem em sequência dentro do caminho do usuário.

Para um usuário em /…/Secretarias/SETIC/COAPL/DIA:

| Você digita | Casa | Por quê | |---|:--:|---| | SETIC | ✔ | pai concede toda a subárvore | | COAPL | ✔ | idem | | DIA | ✔ | folha concede só ela | | SETIC/COAPL | ✔ | caminho parcial, em ordem | | caminho completo | ✔ | ele mesmo e o que estiver abaixo | | DIU | ✘ | outro segmento | | SETICX | ✘ | casa segmento inteiro, não fragmento | | SETIC/DIA | ✘ | não são consecutivos |

Comparação é case-insensitive e ignora barras nas pontas. Required Groups: DIA,ASSETIC com Require All desligado (default) é "quem estiver no DIA ou no ASSETIC".

Required Units faz o mesmo com o claim membership_unit_ref (id de lotação no DW).

Configure nos dois nodes: o Keycloak Verify é quem realmente barra, server-side; o Keycloak Guard só mostra uma recusa clara em vez de uma página cujas chamadas todas dão 401.

Dois níveis, um mesmo casamento

| Nível | Onde se configura | Quando não passa | |---|---|---| | Página inteira | Access Control, no node | tela "Acesso não autorizado" | | Componente | data-kcg-* no HTML | o elemento some, o resto carrega |

Os dois usam a mesma regra: nome do grupo ou caminho completo, tanto faz. DIA, SETIC, SETIC/COAPL ou o path inteiro — todos válidos, e um pai sempre concede a subárvore.

A tela de não autorizado mostra quem está logado, lista os grupos que a pessoa tem e oferece "Entrar com outra conta". Listar os grupos é o que transforma "não funciona" num chamado que alguém consegue resolver.

Elementos por grupo no HTML

Marque qualquer elemento e ele some para quem não qualifica:

<p data-kcg-group="DIA,ASSETIC">Só o DIA ou o ASSETIC veem isto.</p>
<div data-kcg-group="SETIC">Toda a SETIC vê, incluindo subárvore.</div>
<span data-kcg-unit="4013">Só a lotação 4013.</span>
<p data-kcg-deny-group="ESTAGIO">Todos, menos estagiários.</p>

Regras: data-kcg-group e data-kcg-unit aceitam lista separada por vírgula e passam com qualquer uma; se o elemento tiver os dois, basta um qualificar. data-kcg-deny-group tem precedência. Os elementos são removidos do DOM, não escondidos por CSS, e isso acontece antes de a página ser revelada.

Isto é apresentação, não proteção. O HTML já foi entregue ao browser — qualquer pessoa lê a resposta crua. O que não pode chegar a um usuário não autorizado tem de ficar fora do HTML, decidido no workflow antes de montar a página.

Identidade visual

A tela de login usa a paleta amostrada do próprio brasão do TJPA (nodes/shared/brand.ts): navy #12225F, azul do brasão #183090, vermelho #C8102E, dourado #C9A227, creme #F7F4EC. Fundo é a foto do edifício-sede sob um gradiente navy, com o brasão e um card claro por cima.

Brasão e foto são data URIs embutidos, não URLs: a página roda no webhook sandboxed e não deve depender de nenhum host externo estar no ar. O brasão foi reduzido de 400×400 (174 KB) para 128×128 (20 KB). Custo: o script injetado fica em ~92 KB por resposta. Se isso pesar, preencha Logo URL e Background Image URL apontando para assets hospedados — aí os bytes embutidos saem do payload e o script cai para ~18 KB.

F5 e a sessão

Os tokens vivem só em memória — não há storage no origin opaco — então um reload zera a sessão da página. A sessão no Keycloak continua de pé, e é dela que a página se recupera.

Ao carregar, antes de mostrar qualquer coisa, o script faz uma re-autenticação silenciosa: um <iframe> oculto vai ao authorization endpoint com prompt=none. Se o cookie SSO do Keycloak estiver válido, ele redireciona na hora com um code novo, o iframe entrega por postMessage e some. Resultado do F5: nenhuma popup, nenhum clique, nenhuma tela de login — só o card "Verificando sua sessão..." por uma fração de segundo.

O botão nasce escondido justamente para que um reload bem-sucedido não pisque um prompt de login antes de sumir.

Ordem de tentativas:

  1. iframe com prompt=none — silencioso, não precisa de clique nem de permissão de popup. Ponto fraco: a requisição ao Keycloak é third-party, então um navegador que bloqueia cookies de terceiros responde login_required. Por isso não é o único caminho.
  2. popup automática — se o silencioso não resolveu.
  3. botão — se a popup foi bloqueada.

O iframe passa pelas mesmas três checagens do popup (source, identidade da janela via contentWindow, e state), e tem timeout de 8s para não pendurar a página se um proxy engolir o redirect.

Custo de PKCE: zero. O code_verifier do caminho silencioso é o mesmo da tentativa preparada na carga, e continua só em memória.

Abertura automática do popup

A tela tenta abrir o Keycloak sozinha, sem clique. Navegadores bloqueiam popup sem gesto do usuário por padrão, então isso só passa onde o visitante já liberou popups para o endereço — e nesse caso o login abre direto. Quando o navegador recusa, a tentativa é tratada como o caso normal que é: nada de erro no console, só o botão Entrar com Keycloak habilitado com um convite ao clique. Bloqueio no clique manual (aí sim inesperado) vira mensagem vermelha na tela mais console.error.

Keycloak Verify

O node server side. Duas operações:

| Operação | Para que serve | |---|---| | Verify Token (default) | Valida o bearer token nas chamadas de API/webhook | | Exchange Code | Relay da troca code → token, para a página não falar com o Keycloak |

Parâmetros:

  • Token Key (tokenKey, default headers.authorization) — caminho (dot path) dentro de item.json onde está o token. O nome do header é comparado sem diferenciar maiúsculas, e um header repetido (que chega como lista) também é lido. É o caminho no item que chega neste node, não no webhook: se houver um Code ou um Set no meio que reescreva o item, headers já não existe mais e o resultado é sempre no_token. É mais um motivo para o Verify vir logo depois do Webhook. O prefixo Bearer é removido automaticamente, case-insensitive. A extração é feita por item, dentro do loop — diferente do node original, que tinha um bug de ler o token só do primeiro item e validar todos os outros com ele.
  • Include Other Input Fields (includeOtherFields, default ligado) — ligado, a saída é o item que entrou mais user e keycloakGuard. Desligado, só esses dois saem. Vale saber que o item de um webhook carrega o token cru em headers.authorization: ligar esse item direto num Respond to Webhook devolve o token de quem chamou para ele mesmo — inofensivo ali, e nem um pouco inofensivo se a resposta for parar em outro lugar.
  • Operation (operation) — Verify Token (default) guarda um webhook de API; Exchange Code é outro trabalho, num webhook próprio (veja Exchange Code).
  • Verify Mode (verifyMode):
    • introspection (default): POST em ${base}/realms/${realm}/protocol/openid-connect/token/introspect, com Content-Type: application/x-www-form-urlencoded e Authorization: Basic base64(backendClientId:backendClientSecret). Se active=false, o item vai para unauthorized. Pega tokens revogados.
    • offline: valida a assinatura localmente via JWKS (${base}/realms/${realm}/protocol/openid-connect/certs), com cache (TTL configurável, default 1h) e checagem de exp/iss. Mais rápido, porém não detecta revogação — se isso importa para o seu caso de uso, prefira introspection.
  • JWKS Cache TTL (Seconds) — só aparece com verifyMode = offline.

Os quatro gates abaixo são todos opcionais, e campo vazio não opina. Com os quatro vazios o node responde uma pergunta só — o token é válido? — e qualquer pessoa autenticada no realm sai por authorized. É uma configuração legítima (você recebe $json.user e decide depois no fluxo), mas é também a que se fica por descuido.

| Campo | Claim conferida | Exemplo | Vazio | |---|---|---|---| | Required Realm Roles | realm_access.roles | gestor,auditor | gate desligado | | Required Client Roles | resource_access[client].roles | meu-app:admin | gate desligado | | Required Groups | groups | DIA,ASSETIC | gate desligado | | Require All Groups | — | desligado (default) = ou; ligado = e | — | | Required Units | membership_unit_ref | 4013,920 | gate desligado | | Require All Units | — | desligado (default) = ou; ligado = e | — |

Os gates são conferidos em ordem, e o primeiro que falhar é o que vira o motivo. Junto do código vem uma frase pronta em message, para o fluxo mostrar algo melhor que "acesso negado" — e para você não precisar decorar tabela nenhuma ao ler a saída:

{
  "keycloakGuard": {
    "status": "unauthorized",
    "reason": "inactive_token",
    "message": "O Keycloak recusou o token: expirado, revogado ou de outro realm",
    "httpStatus": 401
  }
}

A linha divisória é essa: 401 = não provou quem é (o token nem passou, os gates nem foram consultados); 403 = provou, e não pode.

| reason | O que aconteceu | |---|---| | no_token | não veio token no caminho de Token Key — o item traz junto tokenKey (onde procurou) e itemKeys (o que havia no item), que costuma responder na hora | | inactive_token | o Keycloak disse active: false, ou a assinatura não confere | | refused_by_keycloak | o Keycloak respondeu 4xx à requisição (code expirado, replay, verifier que não bate) | | missing_realm_role | passou no token, faltou realm role | | missing_client_role | passou nas anteriores, faltou client role | | missing_group | passou nas anteriores, faltou grupo | | missing_unit | passou nas anteriores, faltou lotação |

Em Required Groups vale a mesma escrita do Keycloak Guard: nome, não caminho. DIA, SETIC, SETIC/COAPL ou o path inteiro — todos válidos, com ou sem barra na frente, e a mesma regra de casamento dos dois lados (veja Autorização por grupo e lotação). Há um teste que confere, caso a caso, que o navegador e o servidor nunca discordam — uma página que mostra uma seção que a API depois recusa é pior que qualquer uma das duas respostas sozinha.

Três exemplos do mesmo node, configurado de três jeitos:

# 1. só autenticação: qualquer usuário do realm passa
Required Realm Roles   (vazio)
Required Client Roles  (vazio)
Required Groups        (vazio)
Required Units         (vazio)

# 2. quem estiver no DIA OU no ASSETIC
Required Groups        DIA,ASSETIC
Require All Groups     desligado

# 3. quem estiver no SETIC E for da lotação 4013
Required Groups        SETIC
Required Units         4013
Require All Groups     ligado

Independentemente do modo escolhido, o JWT é sempre decodificado para extrair as claims — introspection decide se o token está active, o decode do JWT popula os dados do usuário (a introspection nem sempre retorna todas as claims).

2 saídas: authorized e unauthorized.

O que não tem saída própria é a falha de infraestrutura — Keycloak inacessível, JWKS que não carrega, realm que não responde. Isso o node lança, e o n8n cuida: execução falha, resposta 500, e o On Error do node é respeitado. Quem quiser uma saída de erro liga Settings → On Error → Continue (using error output) — é do n8n, funciona em qualquer node, e não faz sentido este pacote reimplementar.

No item que sai por authorized, é anexado item.json.user enxuto:

{
  "sub": "...",
  "preferred_username": "...",
  "name": "...",
  "given_name": "...",
  "family_name": "...",
  "email": "...",
  "cpf": "...",
  "groups": ["..."],
  "roles": ["..."],
  "membership_unit_ref": "..." 
}

(membership_unit_ref só aparece se presente no token.) O token completo nunca é anexado ao item, e nem o token nem o secret são logados em nenhum momento.

Operação Exchange Code: a troca sem CORS

A página do webhook tem origin opaco, então uma chamada direta ao token endpoint do Keycloak sai com Origin: null — e só passa se o client tiver null em Web Origins. Se você não quer mexer no Keycloak, use o relay: a página manda o mesmo corpo para um webhook do n8n, e o n8n chama o Keycloak server-to-server, onde header Origin não existe e CORS não se aplica.

Monte um workflow de 3 nós:

[Webhook  POST  /webhook/keycloak-exchange]
        ↓
[Keycloak Verify  ·  Operation: Exchange Code]
        ↓ authorized
[Respond to Webhook  ·  Respond With: JSON  ·  First Incoming Item]

Depois preencha Token Exchange URL no Keycloak Guard com a URL desse webhook. Pronto — nenhuma configuração de CORS em lugar nenhum.

As saídas seguem a semântica que o Verify já tem: sucesso em authorized; recusa do próprio Keycloak (código expirado, replay, verifier que não bate — qualquer 4xx) em unauthorized; requisição malformada ou Keycloak fora do ar lança, e o n8n responde 500. Ligue unauthorized a um Respond, senão a página fica esperando uma resposta que não vem.

Detalhes que importam:

  • O corpo é idêntico ao que iria para o Keycloak (application/x-www-form-urlencoded), então o relay é pass-through. Continua sendo request simples: sem preflight OPTIONS.
  • O client_id usado é sempre o da credencial, nunca o que vem no corpo. Sem isso, o endpoint relayaria pedido de token para qualquer client que o atacante nomeasse.
  • O client secret do backend não é usado aqui. O relay só redime o que o browser já poderia redimir sozinho; quem protege o code continua sendo o PKCE.
  • grant_type aceito é só authorization_code e refresh_token. client_credentials e password são recusados — senão o webhook viraria um oráculo de tokens.
  • authorization_code sem code_verifier é recusado: PKCE não é opcional nesse caminho.
  • O PKCE não muda: o code_verifier continua nascendo e vivendo na memória da página, e só viaja no corpo do POST. Nada de storage, nada de verifier na URL.

Configuração via CREDENTIALS_OVERWRITE_DATA

A credencial é preenchida globalmente pelo ambiente do n8n — no editor, o usuário só cria a credencial vazia (os campos ficam ocultos) e seleciona. Não usa prefixo N8N_:

{
  "keycloakGuardApi": {
    "serverUrl": "https://sso.tjpa.jus.br",
    "realm": "tjpa",
    "frontendClientId": "n8n-frontend",
    "backendClientId": "n8n-backend",
    "backendClientSecret": "<secret>"
  }
}

As cinco chaves acima são os nomes internos dos campos, não os rótulos da tela: Server URL é serverUrl, Backend Client Secret é backendClientSecret, e assim por diante. Errar um nome não dá erro nenhum — o campo simplesmente continua vazio e visível no editor, o que é a forma mais silenciosa possível de errar. Há um teste que compara este bloco JSON com os campos reais da credencial, então ele não vai divergir sem alguém ficar sabendo.

O botão Test continua funcionando com os campos sobrescritos: a expressão lê o valor injetado, não o que está na tela.

Para o secret, prefira CREDENTIALS_OVERWRITE_DATA_FILE (arquivo montado read-only) em vez de CREDENTIALS_OVERWRITE_DATA inline — a documentação do n8n avisa que variáveis de ambiente podem vazar para os usuários (ex.: em nodes que exibem env vars).

Tudo que aparece no painel

Cada campo dos dois nodes tem um "?" ao lado do rótulo com a explicação, em português — não é preciso vir ao README para preencher o painel. Dois testes garantem isso: um campo sem descrição quebra a suíte, e uma descrição com sobra de inglês (Whether, Comma-separated, Empty disables) também. Os rótulos seguem em inglês de propósito: são o nome pelo qual o campo é procurado, e é como aparecem aqui e na documentação do n8n.

Keycloak Verify

| Campo | Default | Para quê | |---|---|---| | Operation | Verify Token | guardar a API, ou relayar a troca do code | | Token Key | headers.authorization | onde está o token no item que chega | | Include Other Input Fields | ligado | carregar o item de entrada junto da saída | | Verify Mode | Introspection | perguntar ao Keycloak, ou conferir a assinatura local | | JWKS Cache TTL | 3600 | só com Offline; quanto tempo guardar as chaves | | Required Realm Roles | vazio | gate por realm_access.roles | | Required Client Roles | vazio | gate por resource_access[client].roles | | Required Groups | vazio | gate por groups | | Require All Groups | desligado | exigir todos em vez de qualquer um | | Required Units | vazio | gate por membership_unit_ref | | Require All Units | desligado | exigir todas em vez de qualquer uma |

Keycloak Guard

| Campo | Default | Para quê | |---|---|---| | HTML Field | html | qual campo do item traz o HTML | | Appearance | vazio | Brand Title, Logo URL, Background URL | | Access Control | vazio | os mesmos quatro gates, para a recusa na tela | | Token Exchange URL | vazio | par do Exchange Code, para fugir do CORS | | Allowed API Origins | vazio | outras origens que podem receber o token |

Testando localmente

npm run build

Copie as pastas resultantes de dist/ para ~/.n8n/custom/ do container e reinicie o n8n.

Distribuição via npmjs.com

O pacote é publicado apenas no registry público do npmjs.com.

  1. Publicar: não precisa de tag. O job publish_npm do .gitlab-ci.yml roda a cada push na main — subir um bump de versão no package.json é o release. Se a versão já estiver no registry, o job só avisa e sai verde, então commits normais na main não quebram o pipeline.

    Precisa da variável de CI NPM_TOKEN (masked + protected) com um token que bypassa 2FA: um classic token do tipo Automation ou um Granular Access Token com Read+Write no scope @tjpa inteiro. Um classic token do tipo Publish não serve — ele autentica (o npm whoami passa) e só falha no publish, com 403 pedindo OTP.

    O publishConfig do package.json já aponta o scope @tjpa para https://registry.npmjs.org/ com "access": "public".

  2. Instalar via imagem Docker customizada, com ENV N8N_CUSTOM_EXTENSIONS=/home/node/custom-nodes e pnpm install (o n8n usa pnpm; npm install -g não funciona mais nas imagens atuais). Por ser um pacote público, não é preciso .npmrc nem token para instalar. Veja docker/Dockerfile.

Kubernetes em queue mode

A execução dos nodes roda nos workers, não no main — a imagem com este pacote precisa estar presente no main e em todos os workers. Ao atualizar a versão, é preciso "rolar" (rolling restart) todos os pods. O CREDENTIALS_OVERWRITE_DATA/_FILE também precisa estar presente em todos os pods (main + workers), senão a credencial fica vazia em parte deles.

Testes

npm test

Cobre: que todo campo dos dois painéis tem descrição (o "?" do n8n) e toda opção de lista também; o botão Test da credencial (a expressão é avaliada de verdade, e conferida contra a normalização que os nodes usam — as duas implementam a mesma regra, e um Test que passa enquanto o node falha seria pior que não ter Test); os defaults do painel; normalização de serverUrl em todas as variações aceitas; injeção antes de </body> (incluindo case-insensitive) e fallback de append quando não há </body>; ausência de backendClientId/backendClientSecret no HTML gerado; extração de token com/sem Bearer case-insensitive; extração independente por item (regressão do bug do node original); active=false sempre resultando em unauthorized; gates de role/grupo autorizando e negando; e o formato enxuto do user na saída authorized.

Licença

MIT. Ver LICENSE. Este projeto é uma reescrita do n8n-nodes-keycloak, também MIT.