Documentation
¶
Overview ¶
Package webhook sends signed HTTP POSTs to other services when something happens in the auth library: a user signed up, changed or was deleted, a session started or ended, an identity was added, verified or removed, a security event fired, or anything an app or another plugin publishes (Publish). It is Clerk's and Svix's outgoing webhooks, embedded.
Requests follow the Standard Webhooks spec (https://www.standardwebhooks.com, package signature): webhook-id, webhook-timestamp and webhook-signature headers, HMAC-SHA256 under a "whsec_" secret, a JSON body of type, timestamp and data. A receiver in any language verifies them with a Standard Webhooks library; a Go receiver can use signature.Verify.
Delivery never happens inside the request. An event writes one delivery row per subscribed endpoint in the transaction that produced it, so a sign-up that rolls back sends nothing and one that commits always has its rows; the first send runs after the commit in the background (auth.RunInBackground). A failed send is retried by Maintain ("auth maintain", or the app's scheduler) with an exponential backoff, and a delivery that used every attempt is dead-lettered: kept, never sent again, listable (Deliveries) and replayable (Replay). Delivery is at least once, as every webhook system's is: a receiver de-duplicates on webhook-id.
It is not authentication, so it is its own module.
Index ¶
- Constants
- Variables
- type Delivery
- type DeliveryPage
- type DeliveryQuery
- type Endpoint
- type EndpointInput
- type EndpointUpdate
- type Envelope
- type EventType
- type IdentityData
- type Option
- type Plugin
- func (p *Plugin) CreateEndpoint(ctx context.Context, in EndpointInput) (Endpoint, string, error)
- func (p *Plugin) DataHooks() auth.DataHooks
- func (p *Plugin) DeleteEndpoint(ctx context.Context, id string) error
- func (p *Plugin) Deliveries(ctx context.Context, q DeliveryQuery) (DeliveryPage, error)
- func (p *Plugin) Delivery(ctx context.Context, id string) (Delivery, error)
- func (p *Plugin) Endpoint(ctx context.Context, id string) (Endpoint, error)
- func (p *Plugin) EndpointSecret(ctx context.Context, id string) (string, error)
- func (p *Plugin) Endpoints(ctx context.Context) ([]Endpoint, error)
- func (p *Plugin) Init(a *auth.Auth) error
- func (p *Plugin) Maintain(ctx context.Context, o auth.MaintainOptions) (map[string]int64, error)
- func (p *Plugin) Name() string
- func (p *Plugin) OptionErr() error
- func (p *Plugin) Publish(ctx context.Context, eventType EventType, data any) error
- func (p *Plugin) Replay(ctx context.Context, id string) (Delivery, error)
- func (p *Plugin) RotateSecret(ctx context.Context, id string, grace time.Duration) (string, error)
- func (p *Plugin) SecuritySink() auth.SecuritySink
- func (p *Plugin) Sweep(ctx context.Context, _ time.Time) (int64, error)
- func (p *Plugin) Tables() []sqlb.Table
- func (p *Plugin) TypedMigrations(engine string, t auth.SQLTypes) ([]auth.Migration, error)
- func (p *Plugin) UpdateEndpoint(ctx context.Context, id string, in EndpointUpdate) (Endpoint, error)
- type RetryPolicy
- type SecurityData
- type SessionData
- type SessionRevokedData
- type Status
- type UserData
Constants ¶
const ( DisabledManual = "manual" DisabledGone = "gone" )
Values of Endpoint.DisabledReason.
const ( TableNameEndpoint = "webhook_endpoint" TableNameDelivery = "webhook_delivery" ColID = "id" ColURL = "url" ColSecret = "secret" ColPrevSecret = "previous_secret" ColPrevUntil = "previous_until" ColEvents = "events" ColDesc = "description" ColEnabled = "enabled" ColDisabledBy = "disabled_reason" ColCreatedAt = "created_at" ColUpdatedAt = "updated_at" ColMessageID = "message_id" ColEndpointID = "endpoint_id" ColEventType = "event_type" ColPayload = "payload" ColStatus = "status" ColAttempts = "attempts" ColNextAttempt = "next_attempt_at" ColLastStatus = "last_status" ColLastError = "last_error" ColFinishedAt = "finished_at" // IDPrefixEndpoint and IDPrefixDelivery start the row ids. IDPrefixEndpoint = "whe" IDPrefixDelivery = "whd" )
Table and column names, prefixed with the plugin name.
const ( // DefaultTimeout bounds one POST, connection to last response byte. The // spec recommends 15 to 30 seconds; the low end keeps a slow receiver // from holding a worker, and it must stay under auth.DeliveryTimeout, // which bounds the post-commit send it runs in. //ds:def id=defaulttimeout-ksebt9na DefaultTimeout = 15 * time.Second // DefaultMaxAttempts is how many sends one delivery gets, the first // included, before it is dead-lettered. With DefaultRetryBase doubling // up to DefaultRetryMax the last one is about eight and a half hours // after the first, provided Maintain runs that often. //ds:def id=defaultmaxattempts-z3n2xy52 DefaultMaxAttempts = 10 // DefaultRetryBase is the wait after the first failed send; each later // failure doubles it. //ds:def id=defaultretrybase-kxbdpd2u DefaultRetryBase = time.Minute // DefaultRetryMax caps the wait between two sends. DefaultRetryMax = 6 * time.Hour // DefaultRetention is how long a finished delivery (delivered, dead or // cancelled) is kept for Deliveries and Replay before Sweep deletes it. //ds:def id=defaultretention-5dyjp3z8 DefaultRetention = 30 * 24 * time.Hour // DefaultBatch is the most due deliveries one Maintain run sends, so a // backlog is worked through by repeated runs rather than one run that // outlives its scheduler's timeout. DefaultBatch = 100 // DefaultConcurrency is how many sends one Maintain run makes at once: // a backlog of slow receivers drains in parallel without one run // opening a hundred connections. DefaultConcurrency = 8 // MaxEndpoints bounds the registry. Every event reads every enabled // endpoint inside the writing transaction and writes a row per match, // so the bound is also the worst fan-out of one sign-up. //ds:def id=maxendpoints-reqavdvf MaxEndpoints = 50 // MaxURLLength fits a URL in the column on every engine with room to // spare; a longer one is a mistake, not a webhook. MaxURLLength = 2048 // MaxDescriptionLength bounds an endpoint's free-text label. MaxDescriptionLength = 500 // MaxEventFilters bounds an endpoint's event filter list. MaxEventFilters = 100 // DefaultRotationGrace is how long RotateSecret keeps signing with the // previous secret as well, so a receiver has a day to switch. DefaultRotationGrace = 24 * time.Hour // DefaultPageSize and MaxPageSize shape Deliveries. DefaultPageSize = 50 MaxPageSize = 200 )
Defaults and limits.
const PluginName = "webhook"
PluginName is the plugin's permanent identity and its migration owner.
Variables ¶
var ( TableEndpoint = sqlb.Table{Name: TableNameEndpoint, Alias: "whe"} TableDelivery = sqlb.Table{Name: TableNameDelivery, Alias: "whd"} )
Tables, for an app's own queries.
var ( ErrEndpointNotFound = auth.NewError("webhook.endpoint_not_found", http.StatusNotFound, "The webhook endpoint does not exist.") ErrDeliveryNotFound = auth.NewError("webhook.delivery_not_found", http.StatusNotFound, "The webhook delivery does not exist.") ErrBadURL = auth.NewError("webhook.bad_url", http.StatusBadRequest, "The webhook URL must be an absolute https URL without credentials.") ErrDestinationRefused = auth.NewError("webhook.destination_refused", http.StatusBadRequest, "The webhook URL points at a private or local address.") ErrBadEventType = auth.NewError("webhook.bad_event_type", http.StatusBadRequest, "The event type or filter is not valid.") ErrBadSecret = auth.NewError("webhook.bad_secret", http.StatusBadRequest, "The webhook secret must be whsec_ and base64 of 24 to 64 bytes.") ErrBadDescription = auth.NewError("webhook.bad_description", http.StatusBadRequest, "The webhook description is too long.") ErrTooManyEndpoints = auth.NewError("webhook.too_many_endpoints", http.StatusConflict, "There are too many webhook endpoints.") ErrEndpointDisabled = auth.NewError("webhook.endpoint_disabled", http.StatusConflict, "The webhook endpoint is disabled.") // ErrNoEncryptionKey: neither WithSealer, auth.WithSealer nor // auth.WithSecret gives a way to seal endpoint secrets. ErrNoEncryptionKey = auth.NewError("webhook.no_encryption_key", http.StatusInternalServerError, "Webhook secrets cannot be sealed.") )
Errors. Every one is an *auth.Error, so an admin screen built on the Go API can hand it to a transport as it stands.
var EventTypeValues = []EventType{ EventUserCreated, EventUserUpdated, EventUserDeleted, EventSessionCreated, EventSessionRevoked, EventIdentityCreated, EventIdentityUpdated, EventIdentityDeleted, }
EventTypeValues lists the fixed types the plugin emits, for an admin screen that offers them as filters. Security events add SecurityEventPrefix plus each auth.SecurityKindValues entry.
var StatusValues = []Status{StatusPending, StatusDelivered, StatusDead, StatusCancelled}
StatusValues lists every status, for a filter on an admin screen.
Functions ¶
This section is empty.
Types ¶
type Delivery ¶
type Delivery struct {
ID string `json:"id"`
// MessageID is the webhook-id header: the same for every endpoint the
// event went to, every retry and every Replay, so a receiver
// de-duplicates on it.
MessageID string `json:"messageId"`
EndpointID string `json:"endpointId"`
EventType EventType `json:"eventType"`
Payload json.RawMessage `json:"payload"`
Status Status `json:"status"`
// Attempts is how many sends were started.
Attempts int `json:"attempts"`
// NextAttemptAt is when a pending delivery is due.
NextAttemptAt time.Time `json:"nextAttemptAt"`
// LastStatus is the HTTP status of the last answer, 0 when there was
// none (a refused connection, a timeout).
LastStatus int `json:"lastStatus"`
// LastError says why the last send failed: the HTTP status, or the
// network error without the URL. Never a secret or the body.
LastError string `json:"lastError"`
CreatedAt time.Time `json:"createdAt"`
FinishedAt *time.Time `json:"finishedAt,omitempty"`
}
Delivery is one message to one endpoint and how its sends went.
type DeliveryPage ¶
type DeliveryPage struct {
Deliveries []Delivery `json:"deliveries"`
Total int64 `json:"total"`
Page int `json:"page"`
Limit int `json:"limit"`
}
DeliveryPage is one page of deliveries, newest first.
type DeliveryQuery ¶
type DeliveryQuery struct {
EndpointID string
MessageID string
EventType EventType
Status Status
Page int
Limit int
}
DeliveryQuery narrows Deliveries. Every field is optional; Page starts at 1, Limit defaults to auth.WithDefaultPageSize, else DefaultPageSize, and is clamped to MaxPageSize.
type Endpoint ¶
type Endpoint struct {
ID string `json:"id"`
URL string `json:"url"`
// Events are the filters: a type ("user.created"), a prefix
// ("user.*"), or "*"; empty means every event.
Events []string `json:"events"`
Description string `json:"description"`
Enabled bool `json:"enabled"`
// DisabledReason says why Enabled is false: DisabledManual (an update
// turned it off) or DisabledGone (the receiver answered 410 Gone, which
// the spec says means "stop sending").
DisabledReason string `json:"disabledReason,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
Endpoint is a registered receiver. It never carries the secret, so an Endpoint can be logged or returned to a screen as it stands; EndpointSecret reads the secret on purpose.
type EndpointInput ¶
type EndpointInput struct {
URL string
Events []string
Description string
// Secret is an existing "whsec_" secret to sign with, for an endpoint
// moved from another sender; empty makes a new one.
Secret string
}
EndpointInput registers an endpoint.
type EndpointUpdate ¶
EndpointUpdate changes an endpoint; a nil field is left as it is. Enabled true also clears DisabledReason.
type Envelope ¶
type Envelope struct {
Type EventType `json:"type"`
// Timestamp is when the event happened, not when this attempt was sent
// (that is the webhook-timestamp header).
Timestamp time.Time `json:"timestamp"`
Data any `json:"data"`
}
Envelope is the JSON body of every webhook, the spec's payload structure.
type EventType ¶
type EventType = string
EventType names what a webhook reports, full-stop delimited as the spec asks. The constants are what the plugin emits on its own; Publish sends any other valid type, and security events are SecurityEventPrefix plus the auth.SecurityKind.
const ( // EventUserCreated: a user row was created (sign-up, import, an admin). // Data is UserData. EventUserCreated EventType = "user.created" // EventUserUpdated: the user's profile (name, image) changed. Data is // UserData, the new values. EventUserUpdated EventType = "user.updated" // EventUserDeleted: the user is being deleted; sent only if the // deletion commits. Data is UserData as it was. EventUserDeleted EventType = "user.deleted" // EventSessionCreated: a session that signs someone in was created. // A session still waiting for a second factor is not reported; the // full session that replaces it is. Data is SessionData. EventSessionCreated EventType = "session.created" // EventSessionRevoked: a session ended before the user let it expire, for // any reason (sign-out, revoke, an admin, a ban, a password change, // token-reuse theft, user deletion) or was removed by the expiry sweep. // Data is SessionRevokedData: SessionData plus the reason. EventSessionRevoked EventType = "session.revoked" // EventIdentityCreated, EventIdentityUpdated and EventIdentityDeleted: // an email address, phone number or username was added, changed // (verified, made primary) or removed. Data is IdentityData. EventIdentityCreated EventType = "identity.created" EventIdentityUpdated EventType = "identity.updated" EventIdentityDeleted EventType = "identity.deleted" // SecurityEventPrefix starts the type of every security event the // plugin's SecuritySink forwards: "security.rate.limit", // "security.credential.stuffing", … Data is SecurityData. SecurityEventPrefix = "security." )
Event types the plugin emits.
type IdentityData ¶
type IdentityData struct {
ID string `json:"id"`
UserID string `json:"userId"`
Kind string `json:"kind"`
Value string `json:"value"`
Verified bool `json:"verified"`
VerifiedAt *time.Time `json:"verifiedAt,omitempty"`
IsPrimary bool `json:"isPrimary"`
}
IdentityData is the data of the identity.* events.
type Option ¶
type Option func(*Plugin)
Option configures the plugin; pass options to New.
func AllowPrivateNetworks ¶
func AllowPrivateNetworks() Option
AllowPrivateNetworks lets endpoints point at loopback, private, link-local and other non-public addresses, and use plain http. It is for local development and tests only: in production a webhook URL is chosen by whoever can register an endpoint, and without the guard it reaches the app's own network (a metadata service, an admin port, a database) from inside it. The guard checks the address actually dialled, after DNS, so a public name that resolves to a private address is refused too.
func Retention ¶
Retention sets how long a finished delivery is kept; zero or negative keeps DefaultRetention.
func Timeout ¶
Timeout bounds one POST. It must be positive and at most auth.DeliveryTimeout, which bounds the post-commit send it runs in.
func WithSealer ¶
WithSealer seals endpoint secrets with s instead of auth.WithSealer's Sealer or a key derived from auth.WithSecret.
type Plugin ¶
type Plugin struct {
// contains filtered or unexported fields
}
Plugin is the webhook sender. Build it with New.
func (*Plugin) CreateEndpoint ¶
CreateEndpoint registers an endpoint and returns it with its secret, which the receiver needs to verify signatures. It joins the transaction in ctx.
func (*Plugin) DataHooks ¶
DataHooks turn core writes into events. Each runs inside the writing transaction, so its delivery rows commit or roll back with the write.
func (*Plugin) DeleteEndpoint ¶
DeleteEndpoint removes an endpoint and cancels its pending deliveries. Its finished deliveries stay until Sweep, so its history can still be read.
func (*Plugin) Deliveries ¶
func (p *Plugin) Deliveries(ctx context.Context, q DeliveryQuery) (DeliveryPage, error)
Deliveries lists deliveries, newest first: an endpoint's history, a message's fan-out, or every dead letter (Status: StatusDead). No authorization of its own; the app decides who sees it.
func (*Plugin) EndpointSecret ¶
EndpointSecret returns the endpoint's current secret, for an admin screen that shows it again. It is the only read of a secret; nothing else returns or logs one.
func (*Plugin) Endpoints ¶
Endpoints lists every endpoint, oldest first. The registry is bounded by MaxEndpoints, so it is one page.
func (*Plugin) Init ¶
Init captures the core and settles the sealer: WithSealer, else auth.WithSealer, else a key ring derived from auth.WithSecret. Endpoints and deliveries live in the database, so a database is required.
func (*Plugin) Maintain ¶
Maintain is auth.Maintainer: it sends every pending delivery that is due, up to DefaultBatch per run, DefaultConcurrency at a time, and counts the outcomes (delivered, retrying, dead, cancelled, skipped). A dry run counts what is due ("due") and sends nothing. Run it as often as the retry schedule should be honoured: every minute keeps DefaultRetryBase accurate.
func (*Plugin) OptionErr ¶
OptionErr is every error the options recorded; auth.New refuses to start while it is not nil.
func (*Plugin) Publish ¶
Publish sends an event of the app's own, or another plugin's, to every endpoint subscribed to eventType: an organization hook, a billing change, anything. It joins the transaction in ctx (auth.Tx), so the event is sent only if that transaction commits; outside one it is written at once. data is marshalled to JSON now. eventType must be lower-case words joined by dots ("org.member.added").
func (*Plugin) Replay ¶
Replay sends a delivery again as a new delivery to the same endpoint, with the same webhook-id and body: a dead letter after the receiver was fixed, or a delivered one the receiver lost. The original row is left as it was, so the history stays true. The endpoint must still exist and be enabled. The first send runs after the commit, as for any event.
func (*Plugin) RotateSecret ¶
RotateSecret gives the endpoint a new secret and returns it. For grace (DefaultRotationGrace when 0 or negative) every send carries two signatures, the new secret's and the old one's, so the receiver can switch at any point in that window without a failed delivery.
func (*Plugin) SecuritySink ¶
func (p *Plugin) SecuritySink() auth.SecuritySink
SecuritySink returns the sink that forwards the security event stream as security.* webhooks. Register it with auth.WithSecuritySink:
hooks := webhook.New()
a, err := auth.New(auth.WithPlugins(hooks, ...),
auth.WithSecuritySink(hooks.SecuritySink(), auth.SecurityQueue{Name: "webhook"}))
The stream is not durable and may drop events under pressure (see auth.WithSecuritySink); once an event reaches this sink its deliveries are written to the table and retried like any other.
func (*Plugin) Sweep ¶
Sweep is auth.Sweeper: it deletes finished deliveries (delivered, dead, cancelled) older than the retention. Pending ones are never swept. The core's cutoff is for spent tokens and is ignored.
func (*Plugin) TypedMigrations ¶
TypedMigrations creates both tables for engine. No foreign keys, as everywhere in the library (ADR-0002): a delivery outlives a deleted endpoint so its history can still be read.
func (*Plugin) UpdateEndpoint ¶
func (p *Plugin) UpdateEndpoint(ctx context.Context, id string, in EndpointUpdate) (Endpoint, error)
UpdateEndpoint changes an endpoint and returns it as stored.
type RetryPolicy ¶
type RetryPolicy struct {
// MaxAttempts is how many sends a delivery gets, the first included;
// 0 is DefaultMaxAttempts.
MaxAttempts int
// Base is the wait after the first failure, doubled after each later
// one; 0 is DefaultRetryBase.
Base time.Duration
// Max caps the wait; 0 is DefaultRetryMax.
Max time.Duration
}
RetryPolicy shapes retries. The zero value of each field is its default; a negative one is refused by auth.New.
type SecurityData ¶
type SecurityData struct {
ID string `json:"id"`
Kind string `json:"kind"`
Severity string `json:"severity"`
Outcome string `json:"outcome"`
Op string `json:"op,omitempty"`
Plugin string `json:"plugin,omitempty"`
Transport string `json:"transport,omitempty"`
UserID string `json:"userId,omitempty"`
IP string `json:"ip,omitempty"`
Attrs map[string]string `json:"attrs,omitempty"`
}
SecurityData is the data of the security.* events: auth.SecurityEvent, which already carries no password, token or code, with JSON names.
type SessionData ¶
type SessionData struct {
ID string `json:"id"`
UserID string `json:"userId"`
CreatedAt time.Time `json:"createdAt"`
ExpiresAt time.Time `json:"expiresAt"`
IP string `json:"ip,omitempty"`
UserAgent string `json:"userAgent,omitempty"`
ImpersonatedBy string `json:"impersonatedBy,omitempty"`
}
SessionData is the data of the session.* events. It never carries the token. IP is empty under auth.WithoutIPTracking.
type SessionRevokedData ¶
type SessionRevokedData struct {
SessionData
Reason string `json:"reason"`
}
SessionRevokedData is the data of session.revoked: the session and why it ended. Reason is an auth.SessionEndReason string ("sign_out", "revoked", "admin_revoked", "banned", "password_changed", "token_reuse", "user_deleted", "expired", …; auth.SessionEndReasonValues lists them). For "sign out everywhere" in stateless mode, where the server holds no list of sessions, ID is empty and the event means every session of UserID.
type Status ¶
type Status string
Status is where a delivery stands.
const ( // StatusPending: not delivered yet; due at NextAttemptAt. StatusPending Status = "pending" // StatusDelivered: the receiver answered 2xx. StatusDelivered Status = "delivered" // StatusDead: dead-lettered after MaxAttempts failures, or at once on a // 410 Gone. Replay sends it again as a new delivery. StatusDead Status = "dead" // StatusCancelled: its endpoint was deleted or disabled before it was // delivered. StatusCancelled Status = "cancelled" )
Statuses. Pending is the only one that is ever sent; the other three are final and only Sweep removes them.
Directories
¶
| Path | Synopsis |
|---|---|
|
Package signature signs and verifies webhooks the Standard Webhooks way (https://www.standardwebhooks.com): three headers (webhook-id, webhook-timestamp, webhook-signature), an HMAC-SHA256 over "<id>.<timestamp>.<body>", and secrets written "whsec_" plus base64.
|
Package signature signs and verifies webhooks the Standard Webhooks way (https://www.standardwebhooks.com): three headers (webhook-id, webhook-timestamp, webhook-signature), an HMAC-SHA256 over "<id>.<timestamp>.<body>", and secrets written "whsec_" plus base64. |