Skip to content

Commit 1544283

Browse files
add webhook verification
1 parent 4de2e08 commit 1544283

File tree

5 files changed

+568
-1
lines changed

5 files changed

+568
-1
lines changed

client/client.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ func (c Client) defaultHeaders() http.Header {
2828
return http.Header{
2929
"Accept": {"*/*"},
3030
"Content-Type": {"application/json"},
31-
"User-Agent": {"authsignalgo/v1"},
31+
"User-Agent": {"authsignalgo/v2"},
3232
}
3333
}
3434

client/error.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,16 @@ func NewAuthsignalAPIError(errorCode string, errorDescription string, statusCode
1919
func (e *AuthsignalAPIError) Error() string {
2020
return fmt.Sprintf("AuthsignalException: %d - %s", e.StatusCode, e.ErrorDescription)
2121
}
22+
23+
// InvalidSignatureError is returned when webhook signature verification fails.
24+
type InvalidSignatureError struct {
25+
Message string
26+
}
27+
28+
func NewInvalidSignatureError(message string) *InvalidSignatureError {
29+
return &InvalidSignatureError{Message: message}
30+
}
31+
32+
func (e *InvalidSignatureError) Error() string {
33+
return e.Message
34+
}

client/types.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,3 +349,14 @@ type RevokeSessionRequest struct {
349349
type RevokeUserSessionsRequest struct {
350350
UserId string `json:"userId"`
351351
}
352+
353+
// WebhookEvent represents an event received from an Authsignal webhook.
354+
type WebhookEvent struct {
355+
Version int `json:"version"`
356+
Type string `json:"type"`
357+
Id string `json:"id"`
358+
Source string `json:"source"`
359+
Time string `json:"time"`
360+
TenantId string `json:"tenantId"`
361+
Data map[string]interface{} `json:"data"`
362+
}

client/webhook.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
package client
2+
3+
import (
4+
"crypto/hmac"
5+
"crypto/sha256"
6+
"encoding/base64"
7+
"encoding/json"
8+
"strconv"
9+
"strings"
10+
"time"
11+
)
12+
13+
// Default tolerance (in minutes) for difference between timestamp in signature and current time
14+
// This is used to prevent replay attacks
15+
const DefaultTolerance = 5
16+
17+
const version = "v2"
18+
19+
type Webhook struct {
20+
ApiSecretKey string
21+
}
22+
23+
func NewWebhook(apiSecretKey string) *Webhook {
24+
return &Webhook{ApiSecretKey: apiSecretKey}
25+
}
26+
27+
// ConstructEvent verifies the webhook signature and returns the parsed event.
28+
// payload is the raw request body as a string.
29+
// signature is the value of the webhook signature header.
30+
// tolerance is the maximum age of the webhook in minutes (use DefaultTolerance or -1 to disable).
31+
func (w *Webhook) ConstructEvent(payload string, signature string, tolerance int) (*WebhookEvent, error) {
32+
parsedSignature, err := w.parseSignature(signature)
33+
if err != nil {
34+
return nil, err
35+
}
36+
37+
secondsSinceEpoch := time.Now().Unix()
38+
39+
if tolerance > 0 && parsedSignature.Timestamp < secondsSinceEpoch-int64(tolerance*60) {
40+
return nil, NewInvalidSignatureError("Timestamp is outside the tolerance zone.")
41+
}
42+
43+
hmacContent := strconv.FormatInt(parsedSignature.Timestamp, 10) + "." + payload
44+
45+
computedSignature := w.computeHmac(hmacContent)
46+
47+
match := false
48+
for _, sig := range parsedSignature.Signatures {
49+
// Use constant-time comparison to prevent timing attacks
50+
if hmac.Equal([]byte(sig), []byte(computedSignature)) {
51+
match = true
52+
break
53+
}
54+
}
55+
56+
if !match {
57+
return nil, NewInvalidSignatureError("Signature mismatch.")
58+
}
59+
60+
var event WebhookEvent
61+
if err := json.Unmarshal([]byte(payload), &event); err != nil {
62+
return nil, err
63+
}
64+
65+
return &event, nil
66+
}
67+
68+
// ConstructEventWithDefaultTolerance verifies the webhook signature using the default tolerance.
69+
func (w *Webhook) ConstructEventWithDefaultTolerance(payload string, signature string) (*WebhookEvent, error) {
70+
return w.ConstructEvent(payload, signature, DefaultTolerance)
71+
}
72+
73+
type signatureHeaderData struct {
74+
Signatures []string
75+
Timestamp int64
76+
}
77+
78+
func (w *Webhook) parseSignature(value string) (*signatureHeaderData, error) {
79+
if value == "" {
80+
return nil, NewInvalidSignatureError("Signature format is invalid.")
81+
}
82+
83+
result := &signatureHeaderData{
84+
Timestamp: -1,
85+
Signatures: []string{},
86+
}
87+
88+
items := strings.Split(value, ",")
89+
for _, item := range items {
90+
kv := strings.SplitN(item, "=", 2)
91+
if len(kv) != 2 {
92+
continue
93+
}
94+
95+
if kv[0] == "t" {
96+
timestamp, err := strconv.ParseInt(kv[1], 10, 64)
97+
if err != nil {
98+
continue
99+
}
100+
result.Timestamp = timestamp
101+
}
102+
103+
if kv[0] == version {
104+
result.Signatures = append(result.Signatures, kv[1])
105+
}
106+
}
107+
108+
if result.Timestamp == -1 || len(result.Signatures) == 0 {
109+
return nil, NewInvalidSignatureError("Signature format is invalid.")
110+
}
111+
112+
return result, nil
113+
}
114+
115+
func (w *Webhook) computeHmac(data string) string {
116+
mac := hmac.New(sha256.New, []byte(w.ApiSecretKey))
117+
mac.Write([]byte(data))
118+
signature := base64.StdEncoding.EncodeToString(mac.Sum(nil))
119+
// Remove trailing '=' characters to match the expected format
120+
return strings.Replace(signature, "=", "", -1)
121+
}

0 commit comments

Comments
 (0)