@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
Maintainers
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 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
- Crie um client no Keycloak (público) para a página, e outro (confidencial) para o servidor. Detalhes em Os dois clients.
- 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-configurationdo realm; em Server URL tanto fazhttps://sso.exemplo,https://sso.exemplo/ouhttps://sso.exemplo/realms— a barra e o/realmsno fim são removidos sozinhos. - No webhook da página, ponha o Keycloak Guard entre quem gera o HTML e o Respond to Webhook.
- 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çõesPara 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/403Devolvendo 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
- Client público (ex.:
n8n-frontend): Standard Flow habilitado, PKCES256,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(ouhttps://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: nullna trocacode → token. Um origin literal não cobre isso. Verificado contra osso.tjpa.jus.br: o Keycloak ecoa o Origin recebido de volta — respondeaccess-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. PeloDefaultCors.javaa comparação é exata (allowedOrigins.contains(origin)), entãonullcasa apenas com quem mandaOrigin: null: documentos de origin opaco. Não expande para mais nada.Não use
*. Como o Keycloak também mandaaccess-control-allow-credentials: truee 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, comnullapenas nele — assim on8n-frontendfica intocado e o raio de ação fica contido ao acesso desta página.
- 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-protocolsallow-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:
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.event.origincontra a allowlist['null', origin da página, origin do Keycloak].data.stateigual aostategerado 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, defaulthtml) — nome do campo emitem.jsonque contém a string HTML a modificar (nem sempre se chamahtml). - Allowed API Origins (
apiOrigins, CSV) — origens além do mesmo-origin que também podem receber o headerAuthorization. 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 trocacode → tokenvai 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 liberarnullem 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:
- 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.openprecisa ser o efeito direto do clique, senão o popup blocker derruba; por isso ocode_verifier, ostatee a URL de autorização são preparados antes, na carga, e o handler do clique é totalmente síncrono. - 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.) - Authorization Code + PKCE
S256: verifier de 64 bytes porcrypto.getRandomValues, challenge porcrypto.subtle.digest('SHA-256', …), ambos em base64url sem padding. - Troca
code → tokenviafetchpara o token endpoint,application/x-www-form-urlencoded(request simples, sem preflight). Tokens ficam em memória. - Um interceptor global de
window.fetcheXMLHttpRequest.prototype.sendque renova o token quando faltam menos de 30s para expirar e só anexaAuthorization: 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. window.keycloakGuardcomready(),user(),json(),getToken(),isAuthenticated()elogout(), mais o eventokeycloak-guard:readynodocument.
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:
- 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 respondelogin_required. Por isso não é o único caminho. - popup automática — se o silencioso não resolveu.
- 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, defaultheaders.authorization) — caminho (dot path) dentro deitem.jsononde 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,headersjá não existe mais e o resultado é sempreno_token. É mais um motivo para o Verify vir logo depois do Webhook. O prefixoBeareré 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 maisuserekeycloakGuard. Desligado, só esses dois saem. Vale saber que o item de um webhook carrega o token cru emheaders.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):POSTem${base}/realms/${realm}/protocol/openid-connect/token/introspect, comContent-Type: application/x-www-form-urlencodedeAuthorization: Basic base64(backendClientId:backendClientSecret). Seactive=false, o item vai paraunauthorized. 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 deexp/iss. Mais rápido, porém não detecta revogação — se isso importa para o seu caso de uso, prefiraintrospection.
- 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 ligadoIndependentemente 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 preflightOPTIONS. - O
client_idusado é 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
codecontinua sendo o PKCE. grant_typeaceito é sóauthorization_codeerefresh_token.client_credentialsepasswordsão recusados — senão o webhook viraria um oráculo de tokens.authorization_codesemcode_verifieré recusado: PKCE não é opcional nesse caminho.- O PKCE não muda: o
code_verifiercontinua 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 buildCopie 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.
Publicar: não precisa de tag. O job
publish_npmdo.gitlab-ci.ymlroda a cada push namain— subir um bump de versão nopackage.jsoné o release. Se a versão já estiver no registry, o job só avisa e sai verde, então commits normais namainnã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@tjpainteiro. Um classic token do tipo Publish não serve — ele autentica (onpm whoamipassa) e só falha no publish, com 403 pedindo OTP.O
publishConfigdopackage.jsonjá aponta o scope@tjpaparahttps://registry.npmjs.org/com"access": "public".Instalar via imagem Docker customizada, com
ENV N8N_CUSTOM_EXTENSIONS=/home/node/custom-nodesepnpm install(o n8n usapnpm;npm install -gnão funciona mais nas imagens atuais). Por ser um pacote público, não é preciso.npmrcnem token para instalar. Vejadocker/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 testCobre: 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.
