keycloak-guard

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

Package Information

Released: 1 mo ago
Downloads: 0 weekly / 0 monthly
Latest Version: 0.17.4
Author: lenonn

Documentation

@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 Control → Required 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 → Options → Response 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.

Discussion