// 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=&sig= // 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) }