Pular para o conteúdo

Exemplo completo em Go

Este exemplo mínimo tem duas partes: um servidor HTTP que recebe webhooks e verifica a assinatura, e um cliente que obtém um token de app e chama GET /apps/v1/instalacao. Usa apenas a biblioteca padrão do Go (1.24 ou superior). Valores de segredo e identificadores são de exemplo.

  • Go 1.24 ou superior.
  • Em produção, para o cliente: identificador, segredo do aplicativo e identificador da instalação.
  • Em produção, para o receptor: o segredo do webhook e uma URL https pública.
  1. Crie uma pasta com os arquivos abaixo e um go.mod com go 1.24.
  2. Rode go test -race ./....
  3. Em produção, leia os segredos do ambiente, monte o Cliente e o Receptor, e publique o receptor atrás de https.
package appowiew
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"io"
"net/http"
"strconv"
"strings"
"sync"
"time"
)
const (
// Tolerancia é a diferença máxima aceita entre o relógio local e X-Owiew-Timestamp.
Tolerancia = 5 * time.Minute
limiteCorpo = 256 << 10 // o owiew nunca envia mais que 256 KiB
)
// ErrAssinatura cobre qualquer recusa; quem chama não deve saber o motivo.
var ErrAssinatura = errors.New("assinatura inválida")
// Verificar confere a assinatura de uma entrega.
// segredos: o atual e, durante a rotação, o anterior. corpo: os bytes exatos recebidos.
func Verificar(segredos [][]byte, h http.Header, corpo []byte, agora time.Time) error {
ts, err := strconv.ParseInt(h.Get("X-Owiew-Timestamp"), 10, 64)
if err != nil {
return ErrAssinatura
}
if d := agora.Sub(time.Unix(ts, 0)); d > Tolerancia || d < -Tolerancia {
return ErrAssinatura
}
ok := false
for _, parte := range strings.Split(h.Get("X-Owiew-Signature"), ",") {
recebida, achou := strings.CutPrefix(strings.TrimSpace(parte), "v1=")
if !achou {
continue
}
for _, s := range segredos {
// hmac.Equal compara em tempo constante; não há curto-circuito entre pares.
if hmac.Equal([]byte(Assinar(s, ts, corpo)), []byte(recebida)) {
ok = true
}
}
}
if !ok {
return ErrAssinatura
}
return nil
}
// Assinar calcula HMAC-SHA256(segredo, "<timestamp>.<corpo>") em hex minúsculo.
func Assinar(segredo []byte, ts int64, corpo []byte) string {
m := hmac.New(sha256.New, segredo)
m.Write([]byte(strconv.FormatInt(ts, 10) + "."))
m.Write(corpo)
return hex.EncodeToString(m.Sum(nil))
}
// Receptor recebe entregas, verifica a assinatura e trata cada entrega uma única vez.
type Receptor struct {
Segredos func() [][]byte
Tratar func(corpo []byte, entregaID string) error
Agora func() time.Time
mu sync.Mutex
vistos map[string]bool // em produção: tabela com índice único no id da entrega
}
func (r *Receptor) ServeHTTP(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "", http.StatusMethodNotAllowed)
return
}
corpo, err := io.ReadAll(http.MaxBytesReader(w, req.Body, limiteCorpo))
if err != nil {
http.Error(w, "", http.StatusRequestEntityTooLarge)
return
}
agora := time.Now()
if r.Agora != nil {
agora = r.Agora()
}
if Verificar(r.Segredos(), req.Header, corpo, agora) != nil {
http.Error(w, "", http.StatusUnauthorized) // sem dizer o motivo
return
}
id := req.Header.Get("X-Owiew-Delivery-Id")
r.mu.Lock()
defer r.mu.Unlock()
if r.vistos == nil {
r.vistos = map[string]bool{}
}
if !r.vistos[id] {
if err := r.Tratar(corpo, id); err != nil {
http.Error(w, "", http.StatusInternalServerError) // o owiew repete a entrega
return
}
r.vistos[id] = true // só marca depois de tratar com sucesso
}
w.WriteHeader(http.StatusOK)
}
package appowiew
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
)
// Cliente fala com o IAM (token) e com a API de apps.
type Cliente struct {
IAM, API string // padrão: https://auth.owiew.com e https://api.owiew.com
ClientID string
Segredo string // owa_..., vem do ambiente, nunca do código
InstalacaoID string
HTTP *http.Client
}
func (c *Cliente) http() *http.Client {
if c.HTTP != nil {
return c.HTTP
}
return &http.Client{Timeout: 10 * time.Second}
}
// Token pede um token de app (vale 2 minutos) para UM projeto da instalação.
func (c *Cliente) Token(ctx context.Context, projetoID string) (string, error) {
f := url.Values{"grant_type": {"client_credentials"}, "installation_id": {c.InstalacaoID}}
if projetoID != "" {
f.Set("project_id", projetoID)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.IAM+"/oauth2/token", strings.NewReader(f.Encode()))
if err != nil {
return "", err
}
req.SetBasicAuth(url.QueryEscape(c.ClientID), url.QueryEscape(c.Segredo))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.http().Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
var out struct {
AccessToken string `json:"access_token"`
Erro string `json:"error"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return "", err
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("token recusado: %s", out.Erro)
}
return out.AccessToken, nil
}
// Instalacao chama GET /apps/v1/instalacao com o token.
func (c *Cliente) Instalacao(ctx context.Context, token string) (map[string]any, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.API+"/apps/v1/instalacao", nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := c.http().Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var out map[string]any
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%d %v", resp.StatusCode, out["codigo"])
}
return out, nil
}

Uso em produção:

c := &appowiew.Cliente{
IAM: "https://auth.owiew.com", API: "https://api.owiew.com",
ClientID: os.Getenv("OWIEW_CLIENT_ID"), Segredo: os.Getenv("OWIEW_CLIENT_SECRET"),
InstalacaoID: os.Getenv("OWIEW_INSTALLATION_ID"),
}
token, err := c.Token(ctx, "") // informe o projeto se a instalação tiver mais de um
// ... trate err
inst, err := c.Instalacao(ctx, token)
mux := http.NewServeMux()
mux.Handle("POST /webhook", &appowiew.Receptor{
Segredos: func() [][]byte { return [][]byte{[]byte(os.Getenv("OWIEW_WEBHOOK_SECRET"))} },
Tratar: func(corpo []byte, entregaID string) error { /* grave e processe */ return nil },
})

Os testes usam httptest, sem rede externa e sem credencial real.

package appowiew
import (
"context"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
)
var (
segredo = []byte("segredo-de-exemplo")
corpo = []byte(`{"tipo":"instalacao.teste.v1"}`)
agora = time.Unix(1_800_000_000, 0)
)
func cab(ts int64, sig string) http.Header {
h := http.Header{}
h.Set("X-Owiew-Timestamp", strconv.FormatInt(ts, 10))
h.Set("X-Owiew-Signature", sig)
return h
}
func TestVerificar(t *testing.T) {
ts := agora.Unix()
boa := "v1=" + Assinar(segredo, ts, corpo)
rotacao := "v1=" + Assinar([]byte("nova"), ts, corpo) + ", v1=" + Assinar([]byte("antiga"), ts, corpo)
casos := []struct {
nome string
segs [][]byte
h http.Header
c []byte
ok bool
}{
{"valida", [][]byte{segredo}, cab(ts, boa), corpo, true},
{"rotacao com o segredo antigo", [][]byte{[]byte("antiga")}, cab(ts, rotacao), corpo, true},
{"corpo alterado", [][]byte{segredo}, cab(ts, boa), []byte("{}"), false},
{"segredo errado", [][]byte{[]byte("outro")}, cab(ts, boa), corpo, false},
{"relogio fora", [][]byte{segredo}, cab(ts-301, "v1="+Assinar(segredo, ts-301, corpo)), corpo, false},
{"sem cabecalhos", [][]byte{segredo}, http.Header{}, corpo, false},
}
for _, c := range casos {
if got := Verificar(c.segs, c.h, c.c, agora) == nil; got != c.ok {
t.Errorf("%s: ok=%v, esperado %v", c.nome, got, c.ok)
}
}
}
func TestVetorDaDocumentacao(t *testing.T) {
got := Assinar([]byte("segredo-de-exemplo"), 1800000000, []byte(`{"tipo":"instalacao.teste.v1"}`))
if got != "328f40657fa58788f8633269a9d7c02fed3dfc84ef8f127f41c9e437fbc46807" {
t.Fatalf("assinatura %s difere do vetor publicado", got)
}
}
func TestReceptorIdempotente(t *testing.T) {
var tratados []string
r := &Receptor{
Segredos: func() [][]byte { return [][]byte{segredo} },
Agora: func() time.Time { return agora },
Tratar: func(_ []byte, id string) error { tratados = append(tratados, id); return nil },
}
srv := httptest.NewServer(r)
defer srv.Close()
post := func(sig string) int {
req, _ := http.NewRequest(http.MethodPost, srv.URL, strings.NewReader(string(corpo)))
req.Header = cab(agora.Unix(), sig)
req.Header.Set("X-Owiew-Delivery-Id", "entrega-1")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
return resp.StatusCode
}
boa := "v1=" + Assinar(segredo, agora.Unix(), corpo)
if post(boa) != 200 || post(boa) != 200 {
t.Fatal("entrega válida e reenvio devem dar 200")
}
if len(tratados) != 1 {
t.Fatalf("tratou %d vezes, esperado 1", len(tratados))
}
if post("v1=00") != 401 {
t.Fatal("assinatura inválida deve dar 401")
}
}
func TestCliente(t *testing.T) {
var auth, bearer string
iam := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth = r.Header.Get("Authorization")
_ = r.ParseForm()
if r.PostForm.Get("grant_type") != "client_credentials" {
t.Error("grant_type inesperado")
}
_, _ = w.Write([]byte(`{"access_token":"token-de-exemplo","expires_in":120}`))
}))
defer iam.Close()
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
bearer = r.Header.Get("Authorization")
_, _ = w.Write([]byte(`{"installation_id":"ins_EXEMPLO"}`))
}))
defer api.Close()
c := &Cliente{IAM: iam.URL, API: api.URL, ClientID: "app_EXEMPLO", Segredo: "owa_EXEMPLO", InstalacaoID: "ins_EXEMPLO"}
tok, err := c.Token(context.Background(), "")
if err != nil {
t.Fatal(err)
}
inst, err := c.Instalacao(context.Background(), tok)
if err != nil || inst["installation_id"] != "ins_EXEMPLO" {
t.Fatalf("instalação: %v %v", inst, err)
}
if !strings.HasPrefix(auth, "Basic ") || bearer != "Bearer token-de-exemplo" {
t.Fatalf("cabeçalhos errados: %q %q", auth, bearer)
}
}

go test -race ./... termina com ok. Os testes cobrem a assinatura válida (com rotação), o vetor da documentação, as recusas, a idempotência do receptor e o cliente.

  • A assinatura nunca confere. Confirme que o corpo lido é o bruto e que o segredo é o da inscrição, não o do aplicativo.
  • Todas as entregas chegam com 401. Sincronize o relógio. A tolerância é de 5 minutos.
  • token recusado: invalid_client. Segredo errado, vencido ou revogado.
  • token recusado: invalid_grant. Instalação inexistente, desinstalada ou projeto não consentido.