Files
mrcharles 5bbb307b12 feat: webhook + SSO secret helpers (HMAC verification)
The server side of talking to Discourse, all stdlib crypto:
VerifyWebhook checks X-Discourse-Event-Signature (sha256 HMAC hex,
constant-time, with or without the sha256= prefix) over the raw
body; ParseWebhook/WebhookHandler turn that into a drop-in
http.HandlerFunc that 403s mis-signed deliveries before dispatch.
VerifySSO decodes + verifies a Discourse Connect login redirect
(HMAC over the base64 string), BuildSSOResponse signs the identity
answer (nonce + email/external_id/username/...), and SSOLogin does
the whole dance in one call, returning the redirect URL. Secrets
load from DISCOURSE_WEBHOOK_SECRET / DISCOURSE_SSO_SECRET, are
trimmed, never logged and never echoed in errors.

Part of Redmine 507 (Discourse Go client): the webhook helper is
what the fleet receiver and 495 delivery loop verify pushes with.
2026-08-29 16:43:59 -05:00

205 lines
7.2 KiB
Go

// Webhook + SSO helpers: the secret-handling side of talking to
// Discourse from a server.
//
// Webhooks: Discourse signs each delivery's raw body with the webhook
// secret as HMAC-SHA256 hex in X-Discourse-Event-Signature (with a
// "sha256=" prefix). Never trust an unsigned or mis-signed delivery —
// the endpoint is internet-facing by nature.
//
// SSO (Discourse Connect): Discourse redirects users to your site with
// ?sso=<base64 payload>&sig=<hex HMAC-SHA256 of that base64 string>
// signed with the shared SSO secret; you answer by redirecting back to
// return_sso_url with a payload signed the same way.
//
// Secrets arrive via environment (DISCOURSE_WEBHOOK_SECRET /
// DISCOURSE_SSO_SECRET, see WebhookSecretFromEnv / SSOSecretFromEnv) —
// never flags, never arguments, never logged, never echoed in errors.
package discourse
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
)
// ErrBadSignature: HMAC verification failed (webhook or SSO). The
// payload must be treated as attacker-controlled and dropped.
var ErrBadSignature = errors.New("discourse: bad signature")
// WebhookSecretFromEnv reads DISCOURSE_WEBHOOK_SECRET (trimmed).
func WebhookSecretFromEnv() ([]byte, error) {
return secretFromEnv("DISCOURSE_WEBHOOK_SECRET")
}
// SSOSecretFromEnv reads DISCOURSE_SSO_SECRET (trimmed).
func SSOSecretFromEnv() ([]byte, error) {
return secretFromEnv("DISCOURSE_SSO_SECRET")
}
func secretFromEnv(name string) ([]byte, error) {
v := strings.TrimSpace(os.Getenv(name))
if v == "" {
return nil, fmt.Errorf("%w: %s is not set", ErrInvalidRequest, name)
}
return []byte(v), nil
}
// VerifyWebhook checks a Discourse webhook signature: HMAC-SHA256 hex
// digest of the raw request body under secret, constant-time compared.
// Discourse sends the digest with a "sha256=" prefix; both forms pass.
func VerifyWebhook(secret, body []byte, signature string) error {
sig := strings.TrimPrefix(strings.TrimSpace(signature), "sha256=")
want, err := hex.DecodeString(sig)
if err != nil || len(want) != sha256.Size {
return fmt.Errorf("%w: signature is not a sha256 hex digest", ErrBadSignature)
}
m := hmac.New(sha256.New, secret)
m.Write(body)
if !hmac.Equal(want, m.Sum(nil)) {
return ErrBadSignature
}
return nil
}
// WebhookEvent is one verified webhook delivery.
type WebhookEvent struct {
ID string // X-Discourse-Event-Id (delivery id)
Type string // X-Discourse-Event-Type ("post", "topic", "user", ...)
Event string // X-Discourse-Event ("post_created", ...)
Body json.RawMessage // raw JSON payload, unmodified
}
// ParseWebhook verifies and decodes an incoming webhook request. It
// consumes r.Body, so wrap with http.MaxBytesReader first if the
// endpoint is exposed. Signature failure returns ErrBadSignature.
func ParseWebhook(secret []byte, r *http.Request) (*WebhookEvent, error) {
body, err := io.ReadAll(io.LimitReader(r.Body, 8<<20))
if err != nil {
return nil, fmt.Errorf("%w: read body: %v", ErrUnreachable, err)
}
if err := VerifyWebhook(secret, body, r.Header.Get("X-Discourse-Event-Signature")); err != nil {
return nil, err
}
if !json.Valid(body) {
return nil, fmt.Errorf("%w: payload is not JSON", ErrMalformedResponse)
}
return &WebhookEvent{
ID: r.Header.Get("X-Discourse-Event-Id"),
Type: r.Header.Get("X-Discourse-Event-Type"),
Event: r.Header.Get("X-Discourse-Event"),
Body: json.RawMessage(body),
}, nil
}
// WebhookHandler adapts ParseWebhook to net/http: bad signatures get
// 403 and are never dispatched; verified deliveries go to next, which
// must not panic-write after the handler returns.
func WebhookHandler(secret []byte, next func(*WebhookEvent)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
evt, err := ParseWebhook(secret, r)
if err != nil {
http.Error(w, "invalid signature", http.StatusForbidden)
return
}
next(evt)
w.WriteHeader(http.StatusOK)
}
}
// VerifySSO verifies a Discourse Connect (SSO) login redirect: sso is
// the base64 payload, sig the hex HMAC-SHA256 of the base64 string
// (not of the decoded payload). Returns the payload's fields — always
// nonce and return_sso_url.
func VerifySSO(secret []byte, sso, sig string) (url.Values, error) {
sigBytes, err := hex.DecodeString(strings.TrimSpace(sig))
if err != nil || len(sigBytes) != sha256.Size {
return nil, fmt.Errorf("%w: sso signature is not a sha256 hex digest", ErrBadSignature)
}
m := hmac.New(sha256.New, secret)
m.Write([]byte(sso))
if !hmac.Equal(sigBytes, m.Sum(nil)) {
return nil, ErrBadSignature
}
raw, err := base64.StdEncoding.DecodeString(sso)
if err != nil {
return nil, fmt.Errorf("%w: sso payload is not base64: %v", ErrBadSignature, err)
}
vals, err := url.ParseQuery(string(raw))
if err != nil {
return nil, fmt.Errorf("%w: sso payload is not a querystring: %v", ErrBadSignature, err)
}
return vals, nil
}
// BuildSSOResponse builds the signed response payload: nonce (copied
// from the incoming request) plus the identity fields Discourse accepts
// (email, external_id, username, name, avatar_url, admin, moderator,
// add_groups, remove_groups, ...). Returns the base64 payload and its
// hex signature, ready for SSORedirectURL.
func BuildSSOResponse(secret []byte, nonce string, params map[string]string) (string, string, error) {
if strings.TrimSpace(nonce) == "" {
return "", "", fmt.Errorf("%w: sso response requires the request's nonce", ErrInvalidRequest)
}
vals := url.Values{}
vals.Set("nonce", nonce)
for k, v := range params {
if v != "" {
vals.Set(k, v)
}
}
raw := vals.Encode()
b64 := base64.StdEncoding.EncodeToString([]byte(raw))
m := hmac.New(sha256.New, secret)
m.Write([]byte(b64))
return b64, hex.EncodeToString(m.Sum(nil)), nil
}
// SSORedirectURL assembles the return redirect: returnURL with sso and
// sig query parameters appended.
func SSORedirectURL(returnURL, sso, sig string) (string, error) {
u, err := url.Parse(returnURL)
if err != nil || u.Scheme == "" || u.Host == "" {
return "", fmt.Errorf("%w: return_sso_url %q is not absolute", ErrInvalidRequest, returnURL)
}
q := u.Query()
q.Set("sso", sso)
q.Set("sig", sig)
u.RawQuery = q.Encode()
return u.String(), nil
}
// SSOLogin is the full server-side SSO dance for one request: verify
// the incoming sso/sig pair, build the signed response with the user's
// identity, and return the URL to redirect the user's browser to.
// The context is unused today (pure crypto) but keeps the signature
// future-proof for lookups (e.g. fetching the external user's groups).
func SSOLogin(ctx context.Context, secret []byte, sso, sig string, params map[string]string) (string, error) {
vals, err := VerifySSO(secret, sso, sig)
if err != nil {
return "", err
}
nonce := vals.Get("nonce")
if nonce == "" {
return "", fmt.Errorf("%w: sso payload carries no nonce", ErrBadSignature)
}
returnURL := vals.Get("return_sso_url")
if returnURL == "" {
return "", fmt.Errorf("%w: sso payload carries no return_sso_url", ErrBadSignature)
}
b64, respSig, err := BuildSSOResponse(secret, nonce, params)
if err != nil {
return "", err
}
return SSORedirectURL(returnURL, b64, respSig)
}