Skip to content

Commit 45ea784

Browse files
committed
refactor(webhook): authenticate /v1/invoke with bearer token
Swap the bespoke X-Mithras-Token header for a standard "Authorization: Bearer <token>" challenge. The scheme match is case-insensitive per RFC 7235 and the token comparison stays constant-time. 401 responses now include WWW-Authenticate: Bearer realm="mithras" so clients can react correctly. Tests cover the new parser's edge cases (missing header, wrong scheme, empty token, mixed-case scheme). Assisted-by: Claude Opus 4.7 via Claude Code Signed-off-by: Xe Iaso <xe@tigrisdata.com>
1 parent 8170b54 commit 45ea784

4 files changed

Lines changed: 95 additions & 10 deletions

File tree

docs/architecture.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,8 +143,9 @@ HTTP-facing pieces of `webhookd`:
143143

144144
- `Router` wires `POST /v1/invoke` (auth-required) and `GET /healthz`,
145145
with `recover500` and `requestLog` middleware on top.
146-
- `requireToken` does constant-time comparison of the `X-Mithras-Token`
147-
header against the shared secret.
146+
- `requireToken` extracts the token from an `Authorization: Bearer …`
147+
header and constant-time compares it against the shared secret. Failed
148+
requests get a `WWW-Authenticate: Bearer realm="mithras"` response.
148149
- `AgentRunner` builds a fresh `agentloop.Impl` per request so conversation
149150
histories do not bleed between webhooks.
150151
- `BackgroundLauncher` spawns each runner call on a `sync.WaitGroup` so

internal/webhook/handler.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ type InvokeResponse struct {
2626

2727
// Router returns an http.Handler wired up for the webhookd service.
2828
//
29-
// secret is the shared value required in the X-Mithras-Token header on
30-
// /v1/invoke. maxBody caps request bodies (in bytes).
29+
// secret is the shared value required in the Authorization: Bearer header
30+
// on /v1/invoke. maxBody caps request bodies (in bytes).
3131
func Router(launcher Launcher, secret string, maxBody int64, lg *slog.Logger) http.Handler {
3232
mux := http.NewServeMux()
3333

internal/webhook/handler_test.go

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ func TestInvokeHandler(t *testing.T) {
165165
req.Header.Set("Content-Type", tt.contentType)
166166
}
167167
if tt.token != "" {
168-
req.Header.Set("X-Mithras-Token", tt.token)
168+
req.Header.Set("Authorization", "Bearer "+tt.token)
169169
}
170170
rec := httptest.NewRecorder()
171171
h.ServeHTTP(rec, req)
@@ -200,6 +200,68 @@ func TestInvokeHandler(t *testing.T) {
200200
}
201201
}
202202

203+
func TestInvokeHandler_authorizationHeader(t *testing.T) {
204+
t.Parallel()
205+
206+
const secret = "s3kret"
207+
208+
for _, tt := range []struct {
209+
name string
210+
header string
211+
wantStatus int
212+
}{
213+
{
214+
name: "missing header is 401",
215+
header: "",
216+
wantStatus: http.StatusUnauthorized,
217+
},
218+
{
219+
name: "wrong scheme is 401",
220+
header: "Basic " + secret,
221+
wantStatus: http.StatusUnauthorized,
222+
},
223+
{
224+
name: "bearer without token is 401",
225+
header: "Bearer ",
226+
wantStatus: http.StatusUnauthorized,
227+
},
228+
{
229+
name: "case-insensitive scheme accepted",
230+
header: "bearer " + secret,
231+
wantStatus: http.StatusAccepted,
232+
},
233+
{
234+
name: "valid bearer accepted",
235+
header: "Bearer " + secret,
236+
wantStatus: http.StatusAccepted,
237+
},
238+
} {
239+
t.Run(tt.name, func(t *testing.T) {
240+
t.Parallel()
241+
242+
_, h := newTestRouter(t, secret, 1<<20)
243+
req := httptest.NewRequest(http.MethodPost, "/v1/invoke", bytes.NewBufferString(`{}`))
244+
req.Header.Set("Content-Type", "application/json")
245+
if tt.header != "" {
246+
req.Header.Set("Authorization", tt.header)
247+
}
248+
249+
rec := httptest.NewRecorder()
250+
h.ServeHTTP(rec, req)
251+
252+
if rec.Code != tt.wantStatus {
253+
t.Fatalf("status = %d, want %d (body=%s)", rec.Code, tt.wantStatus, rec.Body.String())
254+
}
255+
256+
if rec.Code == http.StatusUnauthorized {
257+
if got := rec.Header().Get("WWW-Authenticate"); got == "" {
258+
t.Error("missing WWW-Authenticate header on 401")
259+
}
260+
}
261+
})
262+
}
263+
}
264+
203265
func TestInvokeHandler_bodyTooLarge(t *testing.T) {
204266
t.Parallel()
205267

@@ -209,7 +271,7 @@ func TestInvokeHandler_bodyTooLarge(t *testing.T) {
209271
req := httptest.NewRequest(http.MethodPost, "/v1/invoke",
210272
bytes.NewBufferString(`{"pad":"`+big+`"}`))
211273
req.Header.Set("Content-Type", "application/json")
212-
req.Header.Set("X-Mithras-Token", "x")
274+
req.Header.Set("Authorization", "Bearer x")
213275

214276
rec := httptest.NewRecorder()
215277
h.ServeHTTP(rec, req)

internal/webhook/middleware.go

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,26 +8,48 @@ import (
88
"net"
99
"net/http"
1010
"runtime/debug"
11+
"strings"
1112
)
1213

1314
// ErrHijackNotSupported is returned by [statusRecorder.Hijack] when the wrapped
1415
// [http.ResponseWriter] does not implement [http.Hijacker].
1516
var ErrHijackNotSupported = errors.New("webhook: underlying ResponseWriter does not support hijacking")
1617

17-
// requireToken returns middleware that 401s any request whose X-Mithras-Token
18-
// header does not equal secret. Comparison is constant time.
18+
// requireToken returns middleware that 401s any request whose
19+
// "Authorization: Bearer <token>" header does not equal secret. The scheme
20+
// match is case-insensitive per RFC 7235; the token comparison is constant
21+
// time.
1922
func requireToken(secret string, lg *slog.Logger, next http.Handler) http.Handler {
2023
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
21-
got := r.Header.Get("X-Mithras-Token")
22-
if subtle.ConstantTimeCompare([]byte(got), []byte(secret)) != 1 {
24+
token, ok := bearerToken(r.Header.Get("Authorization"))
25+
if !ok || subtle.ConstantTimeCompare([]byte(token), []byte(secret)) != 1 {
2326
lg.Warn("rejected unauthenticated request", "path", r.URL.Path, "remote", r.RemoteAddr)
27+
w.Header().Set("WWW-Authenticate", `Bearer realm="mithras"`)
2428
http.Error(w, "Unauthorized", http.StatusUnauthorized)
2529
return
2630
}
2731
next.ServeHTTP(w, r)
2832
})
2933
}
3034

35+
// bearerToken parses the value of the Authorization header and returns the
36+
// token portion when the scheme is Bearer. The second return is false when
37+
// the header is missing, malformed, or uses a different scheme.
38+
func bearerToken(authHeader string) (string, bool) {
39+
const prefix = "Bearer "
40+
if len(authHeader) < len(prefix) {
41+
return "", false
42+
}
43+
if !strings.EqualFold(authHeader[:len(prefix)], prefix) {
44+
return "", false
45+
}
46+
token := strings.TrimSpace(authHeader[len(prefix):])
47+
if token == "" {
48+
return "", false
49+
}
50+
return token, true
51+
}
52+
3153
// recover500 converts panics in downstream handlers into a 500 response and
3254
// logs the panic value with its stack trace.
3355
func recover500(lg *slog.Logger, next http.Handler) http.Handler {

0 commit comments

Comments
 (0)