Pular para o conteúdo

Exemplo completo em Node

Este exemplo mínimo tem duas partes: um servidor que recebe webhooks e verifica a assinatura, e um cliente que obtém um token de app e chama GET /apps/v1/instalacao. Ele usa apenas a biblioteca padrão do Node (versão 24 ou superior), sem dependências. Os valores de segredo e identificadores são de exemplo.

  • Node 24 ou superior.
  • Para a parte do cliente em produção: identificador, segredo do aplicativo e identificador da instalação.
  • Para a parte do receptor em produção: o segredo do webhook e uma URL https pública.
  1. Crie uma pasta com os três arquivos abaixo.
  2. Rode os testes com node --test.
  3. Em produção, defina OWIEW_CLIENT_ID, OWIEW_CLIENT_SECRET e OWIEW_INSTALLATION_ID no ambiente do seu backend e chame as funções do cliente. Se precisar apontar para outros endereços, defina OWIEW_IAM_URL e OWIEW_APPS_API_URL.
  4. Publique o receptor atrás de https e informe a URL à owiew.

O receptor lê os bytes brutos do corpo, verifica a assinatura e o relógio, e trata cada entrega uma única vez.

import { createHmac, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";
const TOLERANCIA_S = 300; // 5 minutos para mais ou para menos
const LIMITE_CORPO = 256 * 1024; // o owiew nunca envia mais que 256 KiB
/**
* Confere a assinatura de uma entrega do owiew.
* Entradas: segredos (o atual e, durante a rotação, o anterior), cabeçalhos em
* minúsculas, corpo bruto (Buffer) e o relógio em segundos (injetável no teste).
* Saída: { ok: true } ou { ok: false, motivo }.
*/
export function verificar(segredos, cabecalhos, corpo, agoraS = Math.floor(Date.now() / 1000)) {
const ts = cabecalhos["x-owiew-timestamp"];
const assinatura = cabecalhos["x-owiew-signature"];
if (!/^\d{1,12}$/.test(ts ?? "") || !assinatura) return { ok: false, motivo: "cabecalho" };
if (Math.abs(agoraS - Number(ts)) > TOLERANCIA_S) return { ok: false, motivo: "relogio" };
// A assinatura cobre "<timestamp>.<corpo bruto>". Use os bytes recebidos, nunca o JSON reserializado.
const recebidas = assinatura.split(",").map((p) => p.trim()).filter((p) => p.startsWith("v1="));
let confere = false;
for (const segredo of segredos) {
const esperada = createHmac("sha256", segredo).update(`${ts}.`).update(corpo).digest("hex");
for (const r of recebidas) {
const a = Buffer.from(r.slice(3));
const b = Buffer.from(esperada);
if (a.length === b.length && timingSafeEqual(a, b)) confere = true; // sem curto-circuito
}
}
return confere ? { ok: true } : { ok: false, motivo: "assinatura" };
}
/**
* Cria o servidor que recebe as entregas. `tratar(evento)` roda uma vez por
* X-Owiew-Delivery-Id; reenvios da mesma entrega recebem 200 sem repetir o efeito.
* Em produção, troque o Set por uma tabela com índice único no id da entrega.
*/
export function criarReceptor({ segredos, tratar, agora }) {
const vistos = new Set();
return createServer((req, res) => {
if (req.method !== "POST" || req.url !== "/webhook") {
res.writeHead(404).end();
return;
}
const partes = [];
let total = 0;
req.on("data", (c) => {
total += c.length;
if (total > LIMITE_CORPO) req.destroy();
else partes.push(c);
});
req.on("end", async () => {
const corpo = Buffer.concat(partes);
const r = verificar(segredos(), req.headers, corpo, agora?.());
if (!r.ok) {
res.writeHead(401).end(); // não diga o motivo a quem chama
return;
}
const id = req.headers["x-owiew-delivery-id"];
try {
if (!vistos.has(id)) {
await tratar(JSON.parse(corpo.toString("utf8")), id);
vistos.add(id); // só marca depois de tratar com sucesso
}
res.writeHead(200).end();
} catch {
res.writeHead(500).end(); // o owiew repete a entrega
}
});
});
}

O cliente autentica com client_secret_basic, recebe um token de 2 minutos e chama a rota. Em uso real, guarde o token em memória e peça outro quando expiraEm se aproximar.

/** Endereços lidos na hora da chamada; o padrão é a produção. */
const iam = () => process.env.OWIEW_IAM_URL ?? "https://auth.owiew.com";
const api = () => process.env.OWIEW_APPS_API_URL ?? "https://api.owiew.com";
/**
* Obtém um token de app (vale 2 minutos) para UM projeto da instalação.
* O segredo do app vai no cabeçalho Authorization (Basic), nunca no corpo nem na URL.
*/
export async function obterToken({ clientId, segredo, instalacaoId, projetoId }) {
const corpo = new URLSearchParams({ grant_type: "client_credentials", installation_id: instalacaoId });
if (projetoId) corpo.set("project_id", projetoId);
const basic = Buffer.from(`${encodeURIComponent(clientId)}:${encodeURIComponent(segredo)}`).toString("base64");
const resp = await fetch(`${iam()}/oauth2/token`, {
method: "POST",
headers: { authorization: `Basic ${basic}`, "content-type": "application/x-www-form-urlencoded" },
body: corpo,
});
const json = await resp.json();
if (!resp.ok) throw new Error(`token recusado: ${json.error}`);
return { token: json.access_token, expiraEm: Date.now() + (json.expires_in - 10) * 1000 };
}
/** Chama GET /apps/v1/instalacao. Lança erro com o código do problema em caso de falha. */
export async function lerInstalacao(token) {
const resp = await fetch(`${api()}/apps/v1/instalacao`, { headers: { authorization: `Bearer ${token}` } });
const json = await resp.json();
if (!resp.ok) throw new Error(`${resp.status} ${json.codigo ?? ""}`.trim());
return json;
}

Uso em produção:

import { obterToken, lerInstalacao } from "./cliente.mjs";
const { token } = await obterToken({
clientId: process.env.OWIEW_CLIENT_ID,
segredo: process.env.OWIEW_CLIENT_SECRET,
instalacaoId: process.env.OWIEW_INSTALLATION_ID,
});
console.log(await lerInstalacao(token));

Os testes usam servidores falsos em memória, sem rede externa e sem credencial real. Eles precisam de IPv6 de retorno (::1) disponível na máquina.

import assert from "node:assert/strict";
import { createHmac } from "node:crypto";
import { createServer, request } from "node:http";
import { test } from "node:test";
import { obterToken, lerInstalacao } from "./cliente.mjs";
import { criarReceptor, verificar } from "./receptor.mjs";
const SEGREDO = "segredo-de-exemplo";
const corpo = Buffer.from(JSON.stringify({ tipo: "instalacao.teste.v1", dados: { mensagem: "evento de teste" } }));
const assinar = (seg, ts, c) => createHmac("sha256", seg).update(`${ts}.`).update(c).digest("hex");
const agora = 1_800_000_000;
const cab = (ts, sig) => ({ "x-owiew-timestamp": String(ts), "x-owiew-signature": sig });
test("aceita assinatura válida e as duas durante a rotação", () => {
const nova = `v1=${assinar("nova", agora, corpo)}, v1=${assinar("antiga", agora, corpo)}`;
assert.equal(verificar([SEGREDO], cab(agora, `v1=${assinar(SEGREDO, agora, corpo)}`), corpo, agora).ok, true);
assert.equal(verificar(["antiga"], cab(agora, nova), corpo, agora).ok, true);
});
test("bate com o vetor publicado na documentação", () => {
const c = Buffer.from('{"tipo":"instalacao.teste.v1"}');
assert.equal(assinar("segredo-de-exemplo", 1800000000, c), "328f40657fa58788f8633269a9d7c02fed3dfc84ef8f127f41c9e437fbc46807");
});
test("recusa corpo alterado, segredo errado, relógio fora e cabeçalho ausente", () => {
const sig = `v1=${assinar(SEGREDO, agora, corpo)}`;
assert.equal(verificar([SEGREDO], cab(agora, sig), Buffer.from("{}"), agora).motivo, "assinatura");
assert.equal(verificar(["outro"], cab(agora, sig), corpo, agora).motivo, "assinatura");
assert.equal(verificar([SEGREDO], cab(agora - 301, `v1=${assinar(SEGREDO, agora - 301, corpo)}`), corpo, agora).motivo, "relogio");
assert.equal(verificar([SEGREDO], {}, corpo, agora).motivo, "cabecalho");
});
/** POST sem literal de endereço: o Node usa o nome local por padrão. */
function postar(porta, cabecalhos, c) {
return new Promise((ok, falha) => {
const r = request({ port: porta, path: "/webhook", method: "POST", headers: cabecalhos }, (res) => {
res.resume();
res.on("end", () => ok(res.statusCode));
});
r.on("error", falha);
r.end(c);
});
}
test("servidor: 200 válido, idempotente por delivery id, 401 inválido", async () => {
const tratados = [];
const s = criarReceptor({ segredos: () => [SEGREDO], tratar: (e, id) => tratados.push(id), agora: () => agora });
await new Promise((r) => s.listen(0, r));
const porta = s.address().port;
const h = { ...cab(agora, `v1=${assinar(SEGREDO, agora, corpo)}`), "x-owiew-delivery-id": "entrega-1" };
assert.equal(await postar(porta, h, corpo), 200);
assert.equal(await postar(porta, h, corpo), 200); // reenvio
assert.deepEqual(tratados, ["entrega-1"]);
assert.equal(await postar(porta, { ...h, "x-owiew-signature": "v1=00" }, corpo), 401);
s.close();
});
test("cliente: troca credenciais por token e chama a rota", async () => {
const visto = {};
const iam = createServer((req, res) => {
let b = "";
req.on("data", (c) => (b += c));
req.on("end", () => {
visto.auth = req.headers.authorization;
visto.corpo = b;
res.setHeader("content-type", "application/json");
res.end(JSON.stringify({ access_token: "token-de-exemplo", expires_in: 120 }));
});
});
const api = createServer((req, res) => {
visto.bearer = req.headers.authorization;
res.setHeader("content-type", "application/json");
res.end(JSON.stringify({ installation_id: "ins_EXEMPLO", escopos: ["instalacao:read"] }));
});
await Promise.all([new Promise((r) => iam.listen(0, "::1", r)), new Promise((r) => api.listen(0, "::1", r))]);
process.env.OWIEW_IAM_URL = `http://[::1]:${iam.address().port}`;
process.env.OWIEW_APPS_API_URL = `http://[::1]:${api.address().port}`;
const t = await obterToken({ clientId: "app_EXEMPLO", segredo: "owa_EXEMPLO", instalacaoId: "ins_EXEMPLO" });
const inst = await lerInstalacao(t.token);
assert.equal(inst.installation_id, "ins_EXEMPLO");
assert.match(visto.auth, /^Basic /);
assert.match(visto.corpo, /grant_type=client_credentials/);
assert.equal(visto.bearer, "Bearer token-de-exemplo");
iam.close();
api.close();
});

node --test termina com 5 testes passando: assinatura válida (inclusive durante a rotação), vetor da documentação, recusas, receptor idempotente e cliente.

  • A assinatura nunca confere em produção. Confirme que você usa o corpo bruto e o segredo certo, e que nenhum middleware leu o corpo antes.
  • Todas as entregas chegam com 401. Verifique o relógio do servidor. A tolerância é de 5 minutos.
  • O teste do cliente falha ao abrir a porta. A máquina não tem ::1. Ajuste a interface do teste para o endereço de retorno disponível.
  • invalid_client no token. O segredo está errado, vencido ou revogado. Emita outro.
  • invalid_grant no token. A instalação não existe, foi desinstalada ou o project_id não é de um projeto consentido.