fix(proxy): authenticateV1 Warnf + JWT test coverage (CRAP 42→9)
CI / CI (pull_request) Successful in 2m36s

- proxy.go: log Warnf when jwtValidator==nil so auth bypass is observable
- proxy_test.go: TestAuthenticateV1ProxyLevel covers 401/403/pass branches
  (authenticateV1 cov: 10.5% → 77.3%, CRAP: 42 → 8.5)
This commit is contained in:
Admin Bot
2026-09-27 19:09:10 +08:00
parent a9a151a8b4
commit 6257b8c201
2 changed files with 147 additions and 1 deletions
+5 -1
View File
@@ -495,7 +495,11 @@ func (h *Handler) writeModelValidationError(w http.ResponseWriter, validationErr
// authenticateV1 validates JWT for /v1/* paths.
// Returns false when auth fails (response already written).
func (h *Handler) authenticateV1(w http.ResponseWriter, r *http.Request) bool {
if h.jwtValidator == nil || !strings.HasPrefix(r.URL.Path, "/v1/") {
if h.jwtValidator == nil {
logging.Warnf("JWT auth disabled; /v1/* request unauthenticated", map[string]string{"path": r.URL.Path})
return true
}
if !strings.HasPrefix(r.URL.Path, "/v1/") {
return true
}
authHeader := r.Header.Get("Authorization")
+142
View File
@@ -1,8 +1,13 @@
package proxy
import (
"crypto/rand"
"crypto/rsa"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"math/big"
"net/http"
"net/http/httptest"
"runtime"
@@ -10,6 +15,8 @@ import (
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/observability"
)
@@ -417,6 +424,141 @@ func TestProxyPreservesBody(t *testing.T) {
}
}
// TestAuthenticateV1ProxyLevel exercises the three branches of authenticateV1:
// no-token→401, bad-token→403, valid-token→proxied (non-401/403).
func TestAuthenticateV1ProxyLevel(t *testing.T) {
// Generate RSA keypair for signing test JWTs.
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("rsa.GenerateKey: %v", err)
}
// Build minimal JWKS document from the RSA public key.
nBytes := privateKey.PublicKey.N.Bytes()
eBig := new(big.Int).SetInt64(int64(privateKey.PublicKey.E))
eBytes := eBig.Bytes()
jwksDoc, _ := json.Marshal(map[string]interface{}{
"keys": []map[string]interface{}{
{
"kty": "RSA",
"use": "sig",
"kid": "test-key",
"alg": "RS256",
"n": base64.RawURLEncoding.EncodeToString(nBytes),
"e": base64.RawURLEncoding.EncodeToString(eBytes),
},
},
})
// Serve JWKS from a local httptest server.
jwksSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write(jwksDoc)
}))
defer jwksSrv.Close()
// Upstream stub — records whether it was reached.
reached := make(chan struct{}, 1)
upstreamSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case reached <- struct{}{}:
default:
}
w.WriteHeader(http.StatusOK)
}))
defer upstreamSrv.Close()
upstreamAddr := strings.TrimPrefix(upstreamSrv.URL, "http://")
// signJWT returns a valid RS256 JWT accepted by the Authentik-pattern issuer check.
signJWT := func() string {
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
"iss": "https://authentik.riotpiao.com/application/o/test/",
"aud": "test-audience",
"sub": "test-user",
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
})
tok.Header["kid"] = "test-key"
signed, err := tok.SignedString(privateKey)
if err != nil {
t.Fatalf("sign JWT: %v", err)
}
return signed
}
// Build proxy with auth enabled.
cfg := &config.Config{
Routes: make(map[string]*config.Route),
Models: map[string]*config.ModelUpstream{
"auth-model": {
Name: "auth-model",
Address: upstreamAddr,
},
},
Auth: config.AuthConfig{
Enabled: true,
JWKSURL: jwksSrv.URL,
},
}
h := New(cfg)
defer h.Close()
srv := httptest.NewServer(h)
defer srv.Close()
mkReq := func(authHeader string) *http.Request {
body := strings.NewReader(`{"model":"auth-model","messages":[]}`)
req, _ := http.NewRequest("POST", srv.URL+"/v1/chat/completions", body)
req.Header.Set("Content-Type", "application/json")
if authHeader != "" {
req.Header.Set("Authorization", authHeader)
}
return req
}
// Branch 1: no Authorization header → 401.
t.Run("no_token_returns_401", func(t *testing.T) {
resp, err := http.DefaultClient.Do(mkReq(""))
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", resp.StatusCode)
}
})
// Branch 2: malformed/invalid JWT → 403.
t.Run("bad_token_returns_403", func(t *testing.T) {
resp, err := http.DefaultClient.Do(mkReq("Bearer this.is.garbage"))
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("expected 403, got %d", resp.StatusCode)
}
})
// Branch 3: valid RS256 JWT → proxied to upstream (not 401/403).
t.Run("valid_token_proxied", func(t *testing.T) {
resp, err := http.DefaultClient.Do(mkReq("Bearer " + signJWT()))
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
body, _ := io.ReadAll(resp.Body)
t.Errorf("expected proxied response, got %d body=%s", resp.StatusCode, body)
}
select {
case <-reached:
// upstream was hit — auth passed
default:
t.Error("upstream was not reached after valid JWT")
}
})
}
// TestProxyQueueFull verifies that a 503 is returned and the queue_full metric
// is incremented when the backend queue is at capacity.
//