• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

supabase / gotrue / 8370607605

21 Mar 2024 06:24AM UTC coverage: 65.015% (-0.2%) from 65.241%
8370607605

Pull #1474

github

J0
fix: split error codes
Pull Request #1474: feat: add custom sms hook

76 of 169 new or added lines in 12 files covered. (44.97%)

68 existing lines in 1 file now uncovered.

8006 of 12314 relevant lines covered (65.02%)

59.58 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

56.13
/internal/api/hooks.go
1
package api
2

3
import (
4
        "bytes"
5
        "context"
6
        "encoding/json"
7
        "errors"
8
        "fmt"
9
        "io"
10
        "net"
11
        "net/http"
12
        "strings"
13
        "time"
14

15
        "github.com/gofrs/uuid"
16
        "github.com/supabase/auth/internal/observability"
17

18
        "github.com/supabase/auth/internal/conf"
19
        "github.com/supabase/auth/internal/crypto"
20

21
        "github.com/sirupsen/logrus"
22
        "github.com/supabase/auth/internal/hooks"
23

24
        "github.com/supabase/auth/internal/storage"
25
)
26

27
const (
28
        DefaultHTTPHookTimeout  = 5 * time.Second
29
        DefaultHTTPHookRetries  = 3
30
        HTTPHookBackoffDuration = 2 * time.Second
31
        PayloadLimit            = 200 * 1024 // 200KB
32
)
33

34
func (a *API) runPostgresHook(ctx context.Context, tx *storage.Connection, name string, input, output any) ([]byte, error) {
11✔
35
        db := a.db.WithContext(ctx)
11✔
36

11✔
37
        request, err := json.Marshal(input)
11✔
38
        if err != nil {
11✔
39
                panic(err)
×
40
        }
41

42
        var response []byte
11✔
43
        invokeHookFunc := func(tx *storage.Connection) error {
22✔
44
                // We rely on Postgres timeouts to ensure the function doesn't overrun
11✔
45
                if terr := tx.RawQuery(fmt.Sprintf("set local statement_timeout TO '%d';", hooks.DefaultTimeout)).Exec(); terr != nil {
11✔
46
                        return terr
×
47
                }
×
48

49
                if terr := tx.RawQuery(fmt.Sprintf("select %s(?);", name), request).First(&response); terr != nil {
13✔
50
                        return terr
2✔
51
                }
2✔
52

53
                // reset the timeout
54
                if terr := tx.RawQuery("set local statement_timeout TO default;").Exec(); terr != nil {
9✔
55
                        return terr
×
56
                }
×
57

58
                return nil
9✔
59
        }
60

61
        if tx != nil {
16✔
62
                if err := invokeHookFunc(tx); err != nil {
5✔
63
                        return nil, err
×
64
                }
×
65
        } else {
6✔
66
                if err := db.Transaction(invokeHookFunc); err != nil {
8✔
67
                        return nil, err
2✔
68
                }
2✔
69
        }
70

71
        if err := json.Unmarshal(response, output); err != nil {
9✔
72
                return response, err
×
73
        }
×
74

75
        return response, nil
9✔
76
}
77

78
func (a *API) runHTTPHook(ctx context.Context, r *http.Request, hookConfig conf.ExtensibilityPointConfiguration, input, output any) ([]byte, error) {
3✔
79
        client := http.Client{
3✔
80
                Timeout: DefaultHTTPHookTimeout,
3✔
81
        }
3✔
82
        ctx, cancel := context.WithTimeout(ctx, DefaultHTTPHookTimeout)
3✔
83
        defer cancel()
3✔
84

3✔
85
        log := observability.GetLogEntry(r)
3✔
86
        requestURL := hookConfig.URI
3✔
87
        hookLog := log.WithFields(logrus.Fields{
3✔
88
                "component": "auth_hook",
3✔
89
                "url":       requestURL,
3✔
90
        })
3✔
91

3✔
92
        inputPayload, err := json.Marshal(input)
3✔
93
        if err != nil {
3✔
NEW
94
                return nil, err
×
NEW
95
        }
×
96
        for i := 0; i < DefaultHTTPHookRetries; i++ {
7✔
97
                hookLog.Infof("invocation attempt: %d", i)
4✔
98
                msgID := uuid.Must(uuid.NewV4())
4✔
99
                currentTime := time.Now()
4✔
100
                signatureList, err := crypto.GenerateSignatures(hookConfig.HTTPHookSecrets, msgID, currentTime, inputPayload)
4✔
101
                if err != nil {
4✔
NEW
102
                        return nil, err
×
NEW
103
                }
×
104

105
                req, err := http.NewRequestWithContext(ctx, http.MethodPost, requestURL, bytes.NewBuffer(inputPayload))
4✔
106
                if err != nil {
4✔
NEW
107
                        panic("Failed to make request object")
×
108
                }
109

110
                req.Header.Set("Content-Type", "application/json")
4✔
111
                req.Header.Set("webhook-id", msgID.String())
4✔
112
                req.Header.Set("webhook-timestamp", fmt.Sprintf("%d", currentTime.Unix()))
4✔
113
                req.Header.Set("webhook-signature", strings.Join(signatureList, ", "))
4✔
114
                // By default, Go Client sets encoding to gzip, which does not carry a content length header.
4✔
115
                req.Header.Set("Accept-Encoding", "identity")
4✔
116

4✔
117
                rsp, err := client.Do(req)
4✔
118
                if err != nil && errors.Is(err, context.DeadlineExceeded) {
4✔
NEW
119
                        return nil, unprocessableEntityError(ErrorCodeHookTimeout, fmt.Sprintf("Failed to reach hook within maximum time of %f seconds", DefaultHTTPHookTimeout.Seconds()))
×
NEW
120

×
121
                } else if err != nil {
4✔
NEW
122
                        if terr, ok := err.(net.Error); ok && terr.Timeout() || i < DefaultHTTPHookRetries-1 {
×
NEW
123
                                hookLog.Errorf("Request timed out for attempt %d with err %s", i, err)
×
NEW
124
                                time.Sleep(HTTPHookBackoffDuration)
×
NEW
125
                                continue
×
NEW
126
                        } else if i == DefaultHTTPHookRetries-1 {
×
NEW
127
                                return nil, unprocessableEntityError(ErrorCodeHookTimeoutAfterRetry, "Failed to reach hook after maximum retries")
×
NEW
128
                        } else {
×
NEW
129
                                return nil, internalServerError("Failed to trigger auth hook, error making HTTP request").WithInternalError(err)
×
NEW
130
                        }
×
131
                }
132
                defer rsp.Body.Close()
4✔
133
                switch rsp.StatusCode {
4✔
134
                case http.StatusOK, http.StatusNoContent, http.StatusAccepted:
2✔
135
                        if rsp.Body == nil {
2✔
NEW
136
                                return nil, nil
×
NEW
137
                        }
×
138
                        contentLength := rsp.ContentLength
2✔
139
                        if contentLength == -1 {
2✔
NEW
140
                                return nil, unprocessableEntityError(ErrorCodeHookPayloadUnknownSize, "Payload size not known")
×
NEW
141
                        }
×
142
                        if contentLength >= PayloadLimit {
2✔
NEW
143
                                return nil, unprocessableEntityError(ErrorCodeHookPayloadOverSizeLimit, fmt.Sprintf("Payload size is: %d bytes exceeded size limit of %d bytes", contentLength, PayloadLimit))
×
NEW
144
                        }
×
145
                        limitedReader := io.LimitedReader{R: rsp.Body, N: PayloadLimit}
2✔
146
                        body, err := io.ReadAll(&limitedReader)
2✔
147
                        if err != nil {
2✔
NEW
148
                                return nil, err
×
NEW
149
                        }
×
150
                        return body, nil
2✔
151
                case http.StatusTooManyRequests, http.StatusServiceUnavailable:
1✔
152
                        retryAfterHeader := rsp.Header.Get("retry-after")
1✔
153
                        // Check for truthy values to allow for flexibility to switch to time duration
1✔
154
                        if retryAfterHeader != "" {
2✔
155
                                continue
1✔
156
                        }
NEW
157
                        return []byte{}, internalServerError("Service currently unavailable")
×
NEW
158
                case http.StatusBadRequest:
×
NEW
159
                        return nil, badRequestError(ErrorCodeValidationFailed, "Invalid payload sent to hook")
×
NEW
160
                case http.StatusUnauthorized:
×
NEW
161
                        return []byte{}, forbiddenError(ErrorCodeNoAuthorization, "Hook requires authorization token")
×
162
                default:
1✔
163
                        return []byte{}, internalServerError("Error executing Hook")
1✔
164
                }
165
        }
NEW
166
        return nil, internalServerError("error executing hook")
×
167
}
168

NEW
169
func (a *API) invokeHTTPHook(ctx context.Context, r *http.Request, input, output any, hookURI string) error {
×
NEW
170
        switch input.(type) {
×
NEW
171
        case *hooks.CustomSMSProviderInput:
×
NEW
172
                hookOutput, ok := output.(*hooks.CustomSMSProviderOutput)
×
NEW
173
                if !ok {
×
NEW
174
                        panic("output should be *hooks.CustomSMSProviderOutput")
×
175
                }
NEW
176
                var response []byte
×
NEW
177
                var err error
×
NEW
178

×
NEW
179
                if response, err = a.runHTTPHook(ctx, r, a.config.Hook.CustomSMSProvider, input, output); err != nil {
×
NEW
180
                        return internalServerError("Error invoking custom SMS provider hook.").WithInternalError(err)
×
NEW
181
                }
×
NEW
182
                if err != nil {
×
NEW
183
                        return err
×
NEW
184
                }
×
185

NEW
186
                if err := json.Unmarshal(response, hookOutput); err != nil {
×
NEW
187
                        return internalServerError("Error unmarshaling custom SMS provider hook output.").WithInternalError(err)
×
NEW
188
                }
×
189

NEW
190
        default:
×
NEW
191
                panic("unknown HTTP hook type")
×
192
        }
NEW
193
        return nil
×
194
}
195

196
// invokePostgresHook invokes the hook code. tx can be nil, in which case a new
197
// transaction is opened. If calling invokeHook within a transaction, always
198
// pass the current transaction, as pool-exhaustion deadlocks are very easy to
199
// trigger.
200
func (a *API) invokePostgresHook(ctx context.Context, conn *storage.Connection, input, output any, hookURI string) error {
11✔
201
        config := a.config
11✔
202
        // Switch based on hook type
11✔
203
        switch input.(type) {
11✔
204
        case *hooks.MFAVerificationAttemptInput:
4✔
205
                hookOutput, ok := output.(*hooks.MFAVerificationAttemptOutput)
4✔
206
                if !ok {
4✔
207
                        panic("output should be *hooks.MFAVerificationAttemptOutput")
×
208
                }
209

210
                if _, err := a.runPostgresHook(ctx, conn, config.Hook.MFAVerificationAttempt.HookName, input, output); err != nil {
6✔
211
                        return internalServerError("Error invoking MFA verification hook.").WithInternalError(err)
2✔
212
                }
2✔
213

214
                if hookOutput.IsError() {
2✔
215
                        httpCode := hookOutput.HookError.HTTPCode
×
216

×
217
                        if httpCode == 0 {
×
218
                                httpCode = http.StatusInternalServerError
×
219
                        }
×
220

221
                        httpError := &HTTPError{
×
222
                                HTTPStatus: httpCode,
×
223
                                Message:    hookOutput.HookError.Message,
×
224
                        }
×
225

×
226
                        return httpError.WithInternalError(&hookOutput.HookError)
×
227
                }
228

229
                return nil
2✔
230
        case *hooks.PasswordVerificationAttemptInput:
2✔
231
                hookOutput, ok := output.(*hooks.PasswordVerificationAttemptOutput)
2✔
232
                if !ok {
2✔
233
                        panic("output should be *hooks.PasswordVerificationAttemptOutput")
×
234
                }
235

236
                if _, err := a.runPostgresHook(ctx, conn, config.Hook.PasswordVerificationAttempt.HookName, input, output); err != nil {
2✔
237
                        return internalServerError("Error invoking password verification hook.").WithInternalError(err)
×
238
                }
×
239

240
                if hookOutput.IsError() {
2✔
241
                        httpCode := hookOutput.HookError.HTTPCode
×
242

×
243
                        if httpCode == 0 {
×
244
                                httpCode = http.StatusInternalServerError
×
245
                        }
×
246

247
                        httpError := &HTTPError{
×
248
                                HTTPStatus: httpCode,
×
249
                                Message:    hookOutput.HookError.Message,
×
250
                        }
×
251

×
252
                        return httpError.WithInternalError(&hookOutput.HookError)
×
253
                }
254

255
                return nil
2✔
256
        case *hooks.CustomAccessTokenInput:
5✔
257
                hookOutput, ok := output.(*hooks.CustomAccessTokenOutput)
5✔
258
                if !ok {
5✔
259
                        panic("output should be *hooks.CustomAccessTokenOutput")
×
260
                }
261

262
                if _, err := a.runPostgresHook(ctx, conn, config.Hook.CustomAccessToken.HookName, input, output); err != nil {
5✔
263
                        return internalServerError("Error invoking access token hook.").WithInternalError(err)
×
264
                }
×
265

266
                if hookOutput.IsError() {
6✔
267
                        httpCode := hookOutput.HookError.HTTPCode
1✔
268

1✔
269
                        if httpCode == 0 {
1✔
270
                                httpCode = http.StatusInternalServerError
×
271
                        }
×
272

273
                        httpError := &HTTPError{
1✔
274
                                HTTPStatus: httpCode,
1✔
275
                                Message:    hookOutput.HookError.Message,
1✔
276
                        }
1✔
277

1✔
278
                        return httpError.WithInternalError(&hookOutput.HookError)
1✔
279
                }
280
                if err := validateTokenClaims(hookOutput.Claims); err != nil {
5✔
281
                        httpCode := hookOutput.HookError.HTTPCode
1✔
282

1✔
283
                        if httpCode == 0 {
2✔
284
                                httpCode = http.StatusInternalServerError
1✔
285
                        }
1✔
286

287
                        httpError := &HTTPError{
1✔
288
                                HTTPStatus: httpCode,
1✔
289
                                Message:    err.Error(),
1✔
290
                        }
1✔
291

1✔
292
                        return httpError
1✔
293
                }
294
                return nil
3✔
295

296
        default:
×
NEW
297
                panic("unknown Postgres hook input type")
×
298
        }
299
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc