Repository navigation
Expand file tree
/
Copy pathengine_webhooks.go
More file actions
401 lines (377 loc) · 13.5 KB
/
Copy pathengine_webhooks.go
File metadata and controls
401 lines (377 loc) · 13.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
package authsome
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"regexp"
"strconv"
"time"
log "github.com/xraph/go-utils/log"
"github.com/xraph/authsome/bridge"
"github.com/xraph/authsome/hook"
"github.com/xraph/authsome/id"
"github.com/xraph/authsome/internal/safeurl"
"github.com/xraph/authsome/store"
"github.com/xraph/authsome/webhook"
)
// ErrWebhooksUnavailable is returned by every webhook operation when the
// engine's relay cannot manage delivery endpoints (no relay, or a
// send-only one). A webhook that cannot deliver is not registered at all,
// so an operator never holds a subscription that silently does nothing.
var ErrWebhooksUnavailable = errors.New("authsome: webhooks unavailable: the relay cannot manage endpoints")
// ErrWebhookURLRejected wraps a URL the safety check refused.
var ErrWebhookURLRejected = errors.New("authsome: webhook url rejected")
// ErrWebhookEvents is returned when a subscription names no event, or an
// event that is not shaped like one.
var ErrWebhookEvents = errors.New("authsome: webhook events invalid")
// webhookEventName is the shape of an event type: dotted lowercase words,
// with * allowed as a segment or suffix so a subscription can say user.*.
// Plugins register their own event types with the relay at start, so the
// engine validates the shape rather than a fixed list.
var webhookEventName = regexp.MustCompile(`^[a-z0-9_]+(\.[a-z0-9_*]+)+$|^\*$`)
// endpointRelay returns the relay as an endpoint manager, or false when
// webhooks are unavailable.
func (e *Engine) endpointRelay() (bridge.EndpointRelay, bool) {
r, ok := e.relay.(bridge.EndpointRelay)
return r, ok && r != nil
}
func (e *Engine) webhookURLOptions() safeurl.Options {
return safeurl.Options{AllowInsecure: e.config.Webhooks.AllowInsecureURLs}
}
func (e *Engine) webhookVerifyTimeout() time.Duration {
if e.config.Webhooks.VerifyTimeout > 0 {
return e.config.Webhooks.VerifyTimeout
}
return 10 * time.Second
}
func validateWebhookEvents(events []string) error {
if len(events) == 0 {
return fmt.Errorf("%w: at least one event type is required", ErrWebhookEvents)
}
for _, ev := range events {
if !webhookEventName.MatchString(ev) {
return fmt.Errorf("%w: %q is not an event type", ErrWebhookEvents, ev)
}
}
return nil
}
// generateWebhookSecret generates a random hex secret for webhook signing.
func generateWebhookSecret() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return "whsec_" + hex.EncodeToString(b), nil
}
// verifyWebhookURL checks the URL may be called and then makes one test
// delivery to it, signed with the secret the receiver will hold, through a
// client that pins the resolved address and refuses redirects. Anything but
// a 2xx answer refuses the URL: a redirect in particular, since a receiver
// that redirects would carry every delivery, signature and all, somewhere
// the check never looked.
func (e *Engine) verifyWebhookURL(ctx context.Context, w *webhook.Webhook, secret string) error {
opts := e.webhookURLOptions()
if _, err := safeurl.Validate(ctx, w.URL, opts); err != nil {
return fmt.Errorf("%w: %w", ErrWebhookURLRejected, err)
}
ts := time.Now()
body, err := json.Marshal(map[string]string{
"type": "webhook.test",
"webhook_id": w.ID.String(),
"app_id": w.AppID.String(),
"env_id": w.EnvID.String(),
"sent_at": ts.UTC().Format(time.RFC3339),
})
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(ctx, e.webhookVerifyTimeout())
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, w.URL, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("%w: %w", ErrWebhookURLRejected, err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "authsome-webhook-verify")
req.Header.Set("X-Relay-Event-Type", "webhook.test")
req.Header.Set(webhook.RelayTimestampHeader, strconv.FormatInt(ts.Unix(), 10))
req.Header.Set(webhook.RelaySignatureHeader, webhook.SignRelay(secret, ts, body))
resp, err := safeurl.Client(e.webhookVerifyTimeout(), opts).Do(req)
if err != nil {
return fmt.Errorf("%w: test delivery failed: %w", ErrWebhookURLRejected, err)
}
// Only the status matters; the body is never read, so a close error
// changes nothing.
_ = resp.Body.Close()
if resp.StatusCode/100 != 2 {
return fmt.Errorf("%w: test delivery answered %d, want 2xx (redirects are refused)", ErrWebhookURLRejected, resp.StatusCode)
}
return nil
}
func (e *Engine) webhookEndpointInput(w *webhook.Webhook, secret string) bridge.EndpointInput {
return bridge.EndpointInput{
TenantID: w.AppID.String(),
URL: w.URL,
Description: "authsome webhook " + w.ID.String(),
Secret: secret,
EventTypes: w.Events,
Metadata: map[string]string{
"authsome_webhook_id": w.ID.String(),
"authsome_app_id": w.AppID.String(),
"authsome_env_id": w.EnvID.String(),
},
}
}
// CreateWebhook registers w as a delivering Relay endpoint. The URL must
// be public and answer a signed test delivery; the events must be shaped
// like event types. On return w.Secret holds the signing secret, the only
// time it is shown; the row keeps its hash and the Relay endpoint id.
func (e *Engine) CreateWebhook(ctx context.Context, w *webhook.Webhook) error {
r, ok := e.endpointRelay()
if !ok {
return ErrWebhooksUnavailable
}
if err := validateWebhookEvents(w.Events); err != nil {
return err
}
if w.ID.String() == "" {
w.ID = id.NewWebhookID()
}
secret, err := generateWebhookSecret()
if err != nil {
return fmt.Errorf("authsome: create webhook: generate secret: %w", err)
}
if verifyErr := e.verifyWebhookURL(ctx, w, secret); verifyErr != nil {
return verifyErr
}
now := time.Now()
if w.CreatedAt.IsZero() {
w.CreatedAt = now
w.UpdatedAt = now
}
endpointID, err := r.CreateEndpoint(ctx, e.webhookEndpointInput(w, secret))
if err != nil {
return fmt.Errorf("authsome: create webhook: %w", err)
}
w.RelayEndpointID = endpointID
w.SecretHash = store.HashToken(secret)
w.Secret = secret
if !w.Active {
if err := r.SetEndpointEnabled(ctx, endpointID, false); err != nil {
_ = r.DeleteEndpoint(ctx, endpointID) //nolint:errcheck // best-effort undo
return fmt.Errorf("authsome: create webhook: %w", err)
}
}
if err := e.store.CreateWebhook(ctx, w); err != nil {
_ = r.DeleteEndpoint(ctx, endpointID) //nolint:errcheck // best-effort undo: no row, no endpoint
return fmt.Errorf("authsome: create webhook: %w", err)
}
e.hooks.Emit(ctx, &hook.Event{
Action: hook.ActionWebhookCreate,
Resource: hook.ResourceWebhook,
ResourceID: w.ID.String(),
Tenant: w.AppID.String(),
})
e.relayEvent(ctx, "webhook.created", w.AppID.String(), map[string]string{
"webhook_id": w.ID.String(),
"url": w.URL,
})
return nil
}
// GetWebhook returns a webhook by ID. The secret is never part of it.
func (e *Engine) GetWebhook(ctx context.Context, webhookID id.WebhookID) (*webhook.Webhook, error) {
return e.store.GetWebhook(ctx, webhookID)
}
// UpdateWebhook mirrors w's URL, events and active state to its Relay
// endpoint and saves the row. A changed URL is checked and test-delivered
// the way a new one is; the secret is unchanged (see RotateWebhookSecret).
func (e *Engine) UpdateWebhook(ctx context.Context, w *webhook.Webhook) error {
r, ok := e.endpointRelay()
if !ok {
return ErrWebhooksUnavailable
}
if err := validateWebhookEvents(w.Events); err != nil {
return err
}
current, err := e.store.GetWebhook(ctx, w.ID)
if err != nil {
return fmt.Errorf("authsome: update webhook: %w", err)
}
if current.RelayEndpointID == "" {
return fmt.Errorf("authsome: update webhook: %w: not adopted yet", ErrWebhooksUnavailable)
}
w.RelayEndpointID, w.SecretHash, w.Secret = current.RelayEndpointID, current.SecretHash, ""
if w.URL != current.URL {
// The test delivery is signed with a throwaway secret: the real one
// is not held here, and the receiver only needs to prove it answers.
probe, err := generateWebhookSecret()
if err != nil {
return fmt.Errorf("authsome: update webhook: %w", err)
}
if err := e.verifyWebhookURL(ctx, w, probe); err != nil {
return err
}
}
// An empty secret on the input keeps Relay's; see bridge.EndpointInput.
if err := r.UpdateEndpoint(ctx, w.RelayEndpointID, e.webhookEndpointInput(w, "")); err != nil {
return fmt.Errorf("authsome: update webhook: %w", err)
}
if err := r.SetEndpointEnabled(ctx, w.RelayEndpointID, w.Active); err != nil {
return fmt.Errorf("authsome: update webhook: %w", err)
}
w.UpdatedAt = time.Now()
if err := e.store.UpdateWebhook(ctx, w); err != nil {
return fmt.Errorf("authsome: update webhook: %w", err)
}
e.hooks.Emit(ctx, &hook.Event{
Action: hook.ActionWebhookUpdate,
Resource: hook.ResourceWebhook,
ResourceID: w.ID.String(),
Tenant: w.AppID.String(),
})
return nil
}
// DeleteWebhook removes the Relay endpoint, then the row. A row with no
// endpoint (not adopted yet) is simply removed.
func (e *Engine) DeleteWebhook(ctx context.Context, webhookID id.WebhookID) error {
current, err := e.store.GetWebhook(ctx, webhookID)
if err != nil {
return fmt.Errorf("authsome: delete webhook: %w", err)
}
if current.RelayEndpointID != "" {
r, ok := e.endpointRelay()
if !ok {
return ErrWebhooksUnavailable
}
if err := r.DeleteEndpoint(ctx, current.RelayEndpointID); err != nil {
return fmt.Errorf("authsome: delete webhook: %w", err)
}
}
if err := e.store.DeleteWebhook(ctx, webhookID); err != nil {
return fmt.Errorf("authsome: delete webhook: %w", err)
}
e.hooks.Emit(ctx, &hook.Event{
Action: hook.ActionWebhookDelete,
Resource: hook.ResourceWebhook,
ResourceID: webhookID.String(),
Tenant: current.AppID.String(),
})
return nil
}
// ListWebhooks returns all webhooks for an app, without secrets.
func (e *Engine) ListWebhooks(ctx context.Context, appID id.AppID) ([]*webhook.Webhook, error) {
hooks, err := e.store.ListWebhooks(ctx, appID)
if err != nil {
return nil, err
}
for _, w := range hooks {
w.Secret = ""
}
return hooks, nil
}
// RotateWebhookSecret gives the webhook's Relay endpoint a new secret and
// returns it, the only time it is shown. Deliveries from this point are
// signed with the new secret; the receiver must switch before the old one
// stops verifying, which is at once.
func (e *Engine) RotateWebhookSecret(ctx context.Context, webhookID id.WebhookID) (string, error) {
r, ok := e.endpointRelay()
if !ok {
return "", ErrWebhooksUnavailable
}
w, err := e.store.GetWebhook(ctx, webhookID)
if err != nil {
return "", fmt.Errorf("authsome: rotate webhook secret: %w", err)
}
if w.RelayEndpointID == "" {
return "", fmt.Errorf("authsome: rotate webhook secret: %w: not adopted yet", ErrWebhooksUnavailable)
}
secret, err := r.RotateEndpointSecret(ctx, w.RelayEndpointID)
if err != nil {
return "", fmt.Errorf("authsome: rotate webhook secret: %w", err)
}
w.SecretHash = store.HashToken(secret)
w.Secret = ""
w.UpdatedAt = time.Now()
if err := e.store.UpdateWebhook(ctx, w); err != nil {
return "", fmt.Errorf("authsome: rotate webhook secret: %w", err)
}
e.hooks.Emit(ctx, &hook.Event{
Action: hook.ActionWebhookUpdate,
Resource: hook.ResourceWebhook,
ResourceID: w.ID.String(),
Tenant: w.AppID.String(),
Metadata: map[string]string{"secret_rotated": "true"},
})
return secret, nil
}
// adoptLegacyWebhooks gives every webhook row written before webhooks
// became Relay endpoints an endpoint of its own. A row that still carries
// its plaintext secret hands that same secret to Relay, so the receiver
// keeps verifying without anyone re-sharing anything; a row without one
// gets a fresh secret and starts disabled, since the secret has nowhere to
// be shown. Rows are rewritten with the hash and no plaintext. The URL is
// not re-verified: it was accepted once, and refusing it now would drop a
// subscription an operator relies on; the safety rules apply from the next
// change to it.
func (e *Engine) adoptLegacyWebhooks(ctx context.Context) {
r, ok := e.endpointRelay()
if !ok {
return
}
apps, err := e.store.ListApps(ctx)
if err != nil {
e.logger.Warn("authsome: adopting legacy webhooks: list apps", log.String("error", err.Error()))
return
}
var adopted int
for _, a := range apps {
hooks, err := e.store.ListWebhooks(ctx, a.ID)
if err != nil {
e.logger.Warn("authsome: adopting legacy webhooks: list", log.String("app", a.ID.String()), log.String("error", err.Error()))
continue
}
for _, w := range hooks {
if w.RelayEndpointID != "" {
continue
}
if err := e.adoptWebhook(ctx, r, w); err != nil {
e.logger.Warn("authsome: adopting legacy webhook failed", log.String("webhook", w.ID.String()), log.String("error", err.Error()))
continue
}
adopted++
}
}
if adopted > 0 {
e.logger.Info("authsome: adopted legacy webhooks as relay endpoints", log.Int("adopted", adopted))
}
}
func (e *Engine) adoptWebhook(ctx context.Context, r bridge.EndpointRelay, w *webhook.Webhook) error {
secret := w.Secret
active := w.Active
if secret == "" {
fresh, err := generateWebhookSecret()
if err != nil {
return err
}
secret, active = fresh, false
}
endpointID, err := r.CreateEndpoint(ctx, e.webhookEndpointInput(w, secret))
if err != nil {
return err
}
if !active {
if err := r.SetEndpointEnabled(ctx, endpointID, false); err != nil {
return err
}
}
w.RelayEndpointID = endpointID
w.SecretHash = store.HashToken(secret)
w.Secret = ""
w.Active = active
return e.store.UpdateWebhook(ctx, w)
}