diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index ce6507c..ccc82b6 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -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") diff --git a/internal/proxy/proxy_test.go b/internal/proxy/proxy_test.go index 3942dfe..b092004 100644 --- a/internal/proxy/proxy_test.go +++ b/internal/proxy/proxy_test.go @@ -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. //