Skip to content

Commit b92f2ed

Browse files
author
eGames
committed
feat: support X-Api-Key header for basic authentication
X-Api-Key takes priority over Authorization when present: a client can carry TinyAuth basic credentials (Basic base64(user:pass)) alongside an application token (Authorization: Bearer ...) in the same request — the collision that made bearer-token APIs behind TinyAuth impossible to protect. A malformed or non-Basic X-Api-Key is rejected without fallback so a half-configured client fails loudly. Without the header the behaviour is unchanged. Semantics mirror the production-tested implementation from the maposia/tinyauth fork (commit 2e94981) referenced by the official Remnawave nginx guide.
1 parent 653b747 commit b92f2ed

2 files changed

Lines changed: 112 additions & 13 deletions

File tree

‎internal/middleware/context_middleware.go‎

Lines changed: 62 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package middleware
22

33
import (
44
"context"
5+
"encoding/base64"
56
"errors"
67
"fmt"
78
"net/http"
@@ -94,23 +95,28 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc {
9495
}
9596
}
9697

97-
username, password, ok := c.Request.BasicAuth()
98-
99-
if ok {
100-
userContext, headers, err := m.basicAuth(username, password)
101-
102-
if err != nil {
103-
m.log.App.Error().Msgf("Error authenticating basic auth: %v", err)
104-
c.Next()
98+
// X-Api-Key takes priority when present: it lets a client carry
99+
// TinyAuth basic credentials alongside an application token in the
100+
// Authorization header (e.g. "Authorization: Bearer ..." APIs behind
101+
// the proxy). A malformed or non-Basic X-Api-Key is rejected WITHOUT
102+
// falling back to Authorization — a half-configured client must fail
103+
// loudly instead of silently degrading.
104+
if apiKey := c.Request.Header.Get("X-Api-Key"); apiKey != "" {
105+
username, password, ok := parseAPIKeyBasicAuth(apiKey)
106+
if !ok {
107+
m.log.App.Debug().Msg("Invalid basic auth in X-Api-Key header")
108+
c.AbortWithStatus(http.StatusUnauthorized)
105109
return
106110
}
107111

108-
for k, v := range headers {
109-
c.Header(k, v)
110-
}
112+
m.handleBasicAuth(c, username, password)
113+
return
114+
}
111115

112-
c.Set("context", userContext)
113-
c.Next()
116+
username, password, ok := c.Request.BasicAuth()
117+
118+
if ok {
119+
m.handleBasicAuth(c, username, password)
114120
return
115121
}
116122

@@ -359,3 +365,46 @@ func (m *ContextMiddleware) tailscaleWhois(ip string) (*model.TailscaleContext,
359365

360366
return &uctx, nil
361367
}
368+
369+
// handleBasicAuth authenticates via the shared basic auth path and, on a
370+
// lock or error, still continues the chain with headers set (matching the
371+
// previous inline behaviour).
372+
func (m *ContextMiddleware) handleBasicAuth(c *gin.Context, username string, password string) {
373+
userContext, headers, err := m.basicAuth(username, password)
374+
375+
if err != nil {
376+
m.log.App.Error().Msgf("Error authenticating basic auth: %v", err)
377+
c.Next()
378+
return
379+
}
380+
381+
for k, v := range headers {
382+
c.Header(k, v)
383+
}
384+
385+
c.Set("context", userContext)
386+
c.Next()
387+
}
388+
389+
// parseAPIKeyBasicAuth parses an X-Api-Key value in the form
390+
// "Basic base64(username:password)". ok is false for a wrong scheme or a
391+
// malformed payload — callers treat that as a hard reject without fallback.
392+
func parseAPIKeyBasicAuth(header string) (username string, password string, ok bool) {
393+
const prefix = "Basic "
394+
395+
if len(header) < len(prefix) || !strings.EqualFold(header[:len(prefix)], prefix) {
396+
return "", "", false
397+
}
398+
399+
payload, err := base64.StdEncoding.DecodeString(header[len(prefix):])
400+
if err != nil {
401+
return "", "", false
402+
}
403+
404+
username, password, ok = strings.Cut(string(payload), ":")
405+
if !ok {
406+
return "", "", false
407+
}
408+
409+
return username, password, true
410+
}

‎internal/middleware/context_middleware_test.go‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,56 @@ func TestContextMiddleware(t *testing.T) {
246246
assert.True(t, userCtx.Authenticated)
247247
},
248248
},
249+
{
250+
description: "Valid X-Api-Key sets authenticated local context",
251+
run: func(t *testing.T, args runArgs) {
252+
req := httptest.NewRequest("GET", "/api/test", nil)
253+
req.Header.Set("X-Api-Key", basicAuthHeader("testuser", "password"))
254+
userCtx, _ := args.do(req)
255+
256+
require.NotNil(t, userCtx)
257+
assert.Equal(t, model.ProviderLocal, userCtx.Provider)
258+
assert.Equal(t, "testuser", userCtx.GetUsername())
259+
assert.True(t, userCtx.Authenticated)
260+
},
261+
},
262+
{
263+
description: "X-Api-Key takes priority over Authorization",
264+
run: func(t *testing.T, args runArgs) {
265+
req := httptest.NewRequest("GET", "/api/test", nil)
266+
req.Header.Set("X-Api-Key", basicAuthHeader("testuser", "password"))
267+
req.Header.Set("Authorization", basicAuthHeader("testuser", "wrongpassword"))
268+
userCtx, _ := args.do(req)
269+
270+
require.NotNil(t, userCtx)
271+
assert.Equal(t, "testuser", userCtx.GetUsername())
272+
assert.True(t, userCtx.Authenticated)
273+
},
274+
},
275+
{
276+
description: "Malformed X-Api-Key is rejected without fallback to Authorization",
277+
run: func(t *testing.T, args runArgs) {
278+
req := httptest.NewRequest("GET", "/api/test", nil)
279+
req.Header.Set("X-Api-Key", "Basic !!!not-base64!!!")
280+
req.Header.Set("Authorization", basicAuthHeader("testuser", "password"))
281+
userCtx, recorder := args.do(req)
282+
283+
assert.Nil(t, userCtx)
284+
assert.Equal(t, http.StatusUnauthorized, recorder.Code)
285+
},
286+
},
287+
{
288+
description: "Non-Basic scheme in X-Api-Key is rejected without fallback",
289+
run: func(t *testing.T, args runArgs) {
290+
req := httptest.NewRequest("GET", "/api/test", nil)
291+
req.Header.Set("X-Api-Key", "Bearer some-token")
292+
req.Header.Set("Authorization", basicAuthHeader("testuser", "password"))
293+
userCtx, recorder := args.do(req)
294+
295+
assert.Nil(t, userCtx)
296+
assert.Equal(t, http.StatusUnauthorized, recorder.Code)
297+
},
298+
},
249299
}
250300

251301
ctx := context.TODO()

0 commit comments

Comments
 (0)