fix(proxy): authenticateV1 Warnf + JWT test coverage (CRAP 42→9)
CI / CI (pull_request) Successful in 2m36s
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:
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
//
|
||||
|
||||
Reference in New Issue
Block a user