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.
O que você precisa
Seção intitulada “O que você precisa”- 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.
- Crie uma pasta com os arquivos abaixo e um
go.modcomgo 1.24. - Rode
go test -race ./.... - Em produção, leia os segredos do ambiente, monte o
Clientee oReceptor, e publique o receptor atrás de https.
receptor.go
Seção intitulada “receptor.go”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)}cliente.go
Seção intitulada “cliente.go”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 errinst, 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 },})app_test.go
Seção intitulada “app_test.go”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) }}Resultado esperado
Seção intitulada “Resultado esperado”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.
Se algo der errado
Seção intitulada “Se algo der errado”- 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.