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.
|
// authenticateV1 validates JWT for /v1/* paths.
|
||||||
// Returns false when auth fails (response already written).
|
// Returns false when auth fails (response already written).
|
||||||
func (h *Handler) authenticateV1(w http.ResponseWriter, r *http.Request) bool {
|
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
|
return true
|
||||||
}
|
}
|
||||||
authHeader := r.Header.Get("Authorization")
|
authHeader := r.Header.Get("Authorization")
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
package proxy
|
package proxy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"math/big"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"runtime"
|
"runtime"
|
||||||
@@ -10,6 +15,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
|
||||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
||||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/observability"
|
"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
|
// TestProxyQueueFull verifies that a 503 is returned and the queue_full metric
|
||||||
// is incremented when the backend queue is at capacity.
|
// is incremented when the backend queue is at capacity.
|
||||||
//
|
//
|
||||||
|
|||||||
Reference in New Issue
Block a user