Compare commits

..
Author SHA1 Message Date
Admin Bot d71c88498b fix(proxy): address review findings on admission control (#64)
- tracing: responseWriter now forwards Flush and exposes Unwrap. Without it
  w.(http.Flusher) failed for every request passing the tracing middleware, so
  the parked-stream keep-alive never started: a parked streaming request wrote
  nothing for up to ADMISSION_MAX_PARK_SECONDS, which is exactly the client
  timeout the keep-alive was added to prevent.
- proxy: once the SSE status line is spent, ModifyResponse frames an upstream
  response >= 400 as an event: error instead of letting a raw error body land
  inside the stream, where a streaming client reads it as an empty completion
  and reports success. net/http keeps the first status, so the upstream status
  cannot be forwarded after the park has written one.
- engine_state: new Snapshot() returns the state and its tick channel under one
  lock. Get and Tick taken separately can miss the poll that lands between the
  two calls, parking a request for an extra interval.
- engine_state: observer_degraded now tracks current parsability rather than
  being raised once on the first poll and never cleared, which pinned it at 1
  after a single truncated response and never reported drift that appeared
  later. markDegraded only touches the exporter when the value changes.
- deployment: ADMISSION_MAX_INFLIGHT is set explicitly to 4, documenting the
  coupling to the engine's --max-num-seqs, instead of silently defaulting to
  VLLM_MAX_NUM_SEQS.
2026-09-30 19:32:10 +08:00
Admin Bot c5a91cf647 feat(proxy): engine-wide LLM admission control via vLLM gauges (#64)
CI / CI (pull_request) Successful in 34m5s
The per-pod semaphore was not a cap: 3 replicas x 4 slots advertised 12
concurrent requests while the engine runs 4, and nothing coordinated the
replicas. Admission is now gated on the engine's own published gauges,
which is global by construction because every replica contends for the
same engine.

- new internal/proxy/engine_state.go: 2s scrape of the backend /metrics,
  hand-rolled exposition parse (no new dependency), unhealthy after 3
  consecutive failed polls, and a startup self-check that raises
  observer_degraded rather than falling back to fail-open silently
- gate: park while kv_cache_usage_perc >= 0.90 or running + waiting >=
  ADMISSION_MAX_INFLIGHT, bounded by ADMISSION_MAX_PARK_SECONDS, then
  503 + Retry-After: 5. Unknown state fails open on purpose
- streaming requests receive SSE keep-alive heartbeats while parked
- router: extract only the top-level model field instead of unmarshalling
  the whole body into a map; body cap from MAX_BODY_BYTES (1 MiB, the
  model path previously inherited the 100 MB default)
- GOMAXPROCS from the cgroup CPU quota: the container is limited to 500m
  while Go defaulted to the host core count and was CFS-throttled
- VLLM_MAX_QUEUE_DEPTH default 20 -> 8
- delete k8s/configmap.yaml: absent from kustomization.yaml, and the live
  ConfigMap of that name carries an unrelated key
- correct the llm-client NetworkPolicy comment; llm-serving-ingress now
  selects by namespace, not by that label

Observers run only for models named in ADMISSION_OBSERVE_MODELS.
reasoning is the only vLLM engine here; ornith and qwen-cpu are Ollama
and embeddings/rerank are TEI, none of which publish vllm:* series, so
observing them would hold observer_degraded at 1 permanently.

Companion change (engine flags + dashboard panels) is
riotpiao-poimen/homelab#51.
2026-09-30 17:39:47 +08:00
11 changed files with 65 additions and 796 deletions
+3 -15
View File
@@ -5,51 +5,39 @@ go 1.26.0
require (
github.com/MicahParks/keyfunc/v2 v2.1.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/zitadel/oidc/v3 v3.49.2
github.com/zitadel/zitadel-go/v3 v3.30.0
go.opentelemetry.io/otel v1.46.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0
go.opentelemetry.io/otel/sdk v1.46.0
go.opentelemetry.io/otel/trace v1.46.0
go.temporal.io/api v1.63.5
go.temporal.io/sdk v1.48.0
golang.org/x/net v0.58.0
google.golang.org/grpc v1.83.2
google.golang.org/protobuf v1.36.12
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect
github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a // indirect
github.com/go-chi/chi/v5 v5.3.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-logr/logr v1.4.4 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/mock v1.6.0 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 // indirect
github.com/muhlemmer/gu v0.3.1 // indirect
github.com/muhlemmer/httpforwarded v0.1.0 // indirect
github.com/nexus-rpc/nexus-proto-annotations v0.1.0 // indirect
github.com/nexus-rpc/sdk-go v0.7.0 // indirect
github.com/robfig/cron v1.2.0 // indirect
github.com/rs/cors v1.11.1 // indirect
github.com/stretchr/objx v0.5.3 // indirect
github.com/stretchr/testify v1.12.1 // indirect
github.com/zitadel/schema v1.3.2 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 // indirect
go.opentelemetry.io/otel/metric v1.46.0 // indirect
go.opentelemetry.io/proto/otlp v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
-26
View File
@@ -1,19 +1,11 @@
github.com/MicahParks/keyfunc/v2 v2.1.0 h1:6ZXKb9Rp6qp1bDbJefnG7cTH8yMN1IC/4nf+GVjO99k=
github.com/MicahParks/keyfunc/v2 v2.1.0/go.mod h1:rW42fi+xgLJ2FRRXAfNx9ZA8WpD4OeE/yHVMteCkw9k=
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds=
github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a h1:yDWHCSQ40h88yih2JAcL6Ls/kVkSE8GFACTGVnMPruw=
github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a/go.mod h1:7Ga40egUymuWXxAe151lTNnCv97MddSOVsjpPPkityA=
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -31,12 +23,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 h1:sGm2vDRFUrQJO/Veii4h4zG2vvqG6uWNkBHSTqXOZk0=
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2/go.mod h1:wd1YpapPLivG6nQgbf7ZkG1hhSOXDhhn4MLTknx2aAc=
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 h1:B+8ClL/kCQkRiU82d9xajRPKYMrB7E0MbtzWVi1K4ns=
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3/go.mod h1:NbCUVmiS4foBGBHOYlCT25+YmGpJ32dZPi75pGEUpj4=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
@@ -45,10 +33,6 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/muhlemmer/gu v0.3.1 h1:7EAqmFrW7n3hETvuAdmFmn4hS8W+z3LgKtrnow+YzNM=
github.com/muhlemmer/gu v0.3.1/go.mod h1:YHtHR+gxM+bKEIIs7Hmi9sPT3ZDUvTN/i88wQpZkrdM=
github.com/muhlemmer/httpforwarded v0.1.0 h1:x4DLrzXdliq8mprgUMR0olDvHGkou5BJsK/vWUetyzY=
github.com/muhlemmer/httpforwarded v0.1.0/go.mod h1:yo9czKedo2pdZhoXe+yDkGVbU0TJ0q9oQ90BVoDEtw0=
github.com/nexus-rpc/nexus-proto-annotations v0.1.0 h1:2fELd+9sqUtNu6Fg//pw8YFsxOvp8vZ8hfP0nHhNI80=
github.com/nexus-rpc/nexus-proto-annotations v0.1.0/go.mod h1:n3UjF1bPCW8llR8tHvbxJ+27yPWrhpo8w/Yg1IOuY0Y=
github.com/nexus-rpc/sdk-go v0.7.0 h1:38NrfY5rLnZAiMMs2ZfCKI/CSDzdfJG+27iAgfA8bUI=
@@ -57,8 +41,6 @@ github.com/robfig/cron v1.2.0 h1:ZjScXvvxeQ63Dbyxy76Fj3AT3Ut0aKsyd2/tl3DTMuQ=
github.com/robfig/cron v1.2.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
@@ -66,12 +48,6 @@ github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUm
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/zitadel/oidc/v3 v3.49.2 h1:yKvB2Hx6rVWp0vOTk1pEyXAPxJusgIHNmr20AxOohmQ=
github.com/zitadel/oidc/v3 v3.49.2/go.mod h1:HwoguOGo0eem0RK5Gb+P6Q4aQLVinJ9LhomlVEA57ck=
github.com/zitadel/schema v1.3.2 h1:gfJvt7dOMfTmxzhscZ9KkapKo3Nei3B6cAxjav+lyjI=
github.com/zitadel/schema v1.3.2/go.mod h1:IZmdfF9Wu62Zu6tJJTH3UsArevs3Y4smfJIj3L8fzxw=
github.com/zitadel/zitadel-go/v3 v3.30.0 h1:yk/surQ+atLS/ocGaaJdHD/R6VbveFScihxJhIkGZ3Q=
github.com/zitadel/zitadel-go/v3 v3.30.0/go.mod h1:4R+aki0JFMwIPsr4CIVLYq5DRTVEAfLbehJpFNhFKQw=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc=
@@ -111,8 +87,6 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-21
View File
@@ -1,21 +0,0 @@
package auth
import (
"strings"
"testing"
)
func TestValidateBearerToken_JWEGetsClearError(t *testing.T) {
ti := newTestIssuer(t)
host, port, _ := strings.Cut(strings.TrimPrefix(ti.server.URL, "http://"), ":")
v, err := NewZitadelValidator(t.Context(), ti.server.URL, nil, ZitadelConfig{
Domain: host, InsecurePort: port, HTTPClient: ti.server.Client(),
})
if err != nil {
t.Fatalf("NewZitadelValidator: %v", err)
}
_, err = v.ValidateBearerToken("Bearer a.b.c.d.e")
if err == nil || !strings.Contains(err.Error(), "encrypted JWE") {
t.Fatalf("expected a JWE-specific error, got %v", err)
}
}
+32 -190
View File
@@ -3,141 +3,37 @@ package auth
import (
"context"
"fmt"
gohttp "net/http"
"strings"
"sync"
"time"
"github.com/MicahParks/keyfunc/v2"
"github.com/golang-jwt/jwt/v5"
"github.com/zitadel/zitadel-go/v3/pkg/authorization/oauth"
)
// issuerMatches reports whether iss is the configured issuer or a sub-path of
// it. Zitadel emits exactly "https://zitadel.riotpiao.com"; providers that
// mount an application path append a suffix (Authentik used
// "/application/o/<app>/"). A trailing slash on either side is ignored.
//
// An empty configured issuer disables the domain check; the JWKS signature
// check still applies. Production configuration always sets an issuer.
func issuerMatches(iss, configured string) bool {
if configured == "" {
return iss != ""
}
iss = strings.TrimSuffix(iss, "/")
configured = strings.TrimSuffix(configured, "/")
return iss == configured || strings.HasPrefix(iss, configured+"/")
// isValidIssuer checks if issuer is from Authentik (any provider/app).
// Accepts: https://authentik.riotpiao.com/application/o/{provider}/
func isValidIssuer(iss string) bool {
return strings.Contains(iss, "authentik.riotpiao.com/application/o/") &&
strings.HasSuffix(iss, "/")
}
// audienceValues normalises the aud claim. Zitadel access tokens carry an
// array, older providers carry a single string.
func audienceValues(claims jwt.MapClaims) ([]string, bool) {
switch v := claims["aud"].(type) {
case string:
return []string{v}, v != ""
case []string:
return v, len(v) > 0
case []interface{}:
out := make([]string, 0, len(v))
for _, item := range v {
if s, ok := item.(string); ok {
out = append(out, s)
}
}
return out, len(out) > 0
case jwt.ClaimStrings:
return []string(v), len(v) > 0
}
return nil, false
}
// Validator validates JWTs against the JWKS of an OIDC provider (Zitadel).
// The issuer is configuration-driven: tokens whose iss claim equals the
// configured issuer, or lives under it as a sub-path, are accepted.
//
// When a ZitadelVerifier is attached (see NewZitadelValidator), validation
// delegates to the official ZITADEL Go SDK and this type only adapts the
// result to jwt.MapClaims for the rest of the gateway.
// Validator validates JWTs against Authentik JWKS.
// Supports multi-issuer: any Authentik service account provider is accepted
// (portfolio-agent, memory-agent, api-gw, etc.) because all share the same
// JWKS signing key.
type Validator struct {
issuer string // Expected issuer (iss claim); empty disables the domain check
audience string // Expected audience; currently logged only (any non-empty aud is accepted)
issuer string // Not used for validation (kept for logging); issuer regex check is sufficient
audience string // Not used for validation; any audience from valid Authentik issuer is accepted
jwksURL string
jwks *keyfunc.JWKS
mu sync.Mutex
zitadel *ZitadelVerifier // non-nil when backed by the ZITADEL SDK
}
// NewZitadelValidator builds a Validator backed by the ZITADEL Go SDK.
// issuer is the expected iss claim, used for logging and for the legacy
// claim checks if the SDK verifier is ever bypassed. clientIDs is the
// audience allow-list. Discovery runs here, so a wrong domain fails at
// startup.
func NewZitadelValidator(ctx context.Context, issuer string, clientIDs []string, cfg ZitadelConfig) (*Validator, error) {
return NewZitadelValidatorWithClient(ctx, issuer, clientIDs, cfg, nil)
}
// NewZitadelValidatorWithClient is NewZitadelValidator with an injected HTTP
// client, for tests that serve discovery and JWKS from httptest.
func NewZitadelValidatorWithClient(ctx context.Context, issuer string, clientIDs []string, cfg ZitadelConfig, httpClient *gohttp.Client) (*Validator, error) {
cfg.ClientIDs = clientIDs
if cfg.HTTPClient == nil {
cfg.HTTPClient = httpClient
}
sdk, err := NewZitadelVerifier(ctx, cfg)
if err != nil {
return nil, err
}
return &Validator{
issuer: issuer,
jwksURL: "", // unused on the SDK path
zitadel: sdk,
}, nil
}
// claimsFromIntrospection converts the SDK's verified claim set into
// jwt.MapClaims so the rest of the gateway keeps one claim representation.
// The SDK has already checked the signature, exp, nbf and iss; this only
// copies values across.
func claimsFromIntrospection(authCtx *oauth.IntrospectionContext) jwt.MapClaims {
claims := jwt.MapClaims{}
if authCtx == nil {
return claims
}
for k, v := range authCtx.Claims {
claims[k] = v
}
if authCtx.Issuer != "" {
claims["iss"] = authCtx.Issuer
}
if authCtx.Subject != "" {
claims["sub"] = authCtx.Subject
}
if len(authCtx.Audience) > 0 {
aud := make([]interface{}, 0, len(authCtx.Audience))
for _, a := range authCtx.Audience {
aud = append(aud, a)
}
claims["aud"] = aud
}
if authCtx.Expiration != 0 {
claims["exp"] = float64(authCtx.Expiration)
}
if authCtx.NotBefore != 0 {
claims["nbf"] = float64(authCtx.NotBefore)
}
if authCtx.IssuedAt != 0 {
claims["iat"] = float64(authCtx.IssuedAt)
}
if authCtx.ClientID != "" {
claims["client_id"] = authCtx.ClientID
}
return claims
}
// NewValidator creates a new JWT validator for a service.
// issuer is the expected iss claim (exact value or prefix). audience is kept
// for compatibility; only non-emptiness is enforced. JWKS fetching is lazy
// (deferred until first validation).
// issuer and audience params are deprecated (ignored for validation) but kept
// for backward compatibility. Multi-issuer validation via isValidIssuer() is used instead.
// JWKS fetching is lazy (deferred until first validation).
func NewValidator(issuer, audience, jwksURL string) *Validator {
return &Validator{
issuer: issuer, // deprecated param, kept for compat
@@ -191,31 +87,8 @@ func (v *Validator) ValidateBearerToken(authHeader string) (jwt.MapClaims, error
}
// Ensure JWKS is loaded (lazy)
if v.jwks != nil || v.zitadel == nil {
if err := v.ensureJWKS(); err != nil {
return nil, err
}
}
// SDK path: ZITADEL Go SDK performs signature, expiry and issuer checks.
if v.zitadel != nil {
// ZITADEL issues two access-token shapes. A plain JWS has three
// dot-separated segments and can be verified locally. A JWE has five,
// is encrypted with the instance key, and only ZITADEL can read it.
// Report that case explicitly so the operator changes the client to
// OIDC_TOKEN_TYPE_BEARER instead of debugging a signature failure.
if strings.Count(tokenString, ".") == 4 {
return nil, fmt.Errorf("token is an encrypted JWE (5 segments); " +
"set accessTokenType=OIDC_TOKEN_TYPE_BEARER on the ZITADEL client so the gateway can verify it locally")
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
authCtx, err := v.zitadel.CheckAuthorization(ctx, authHeader)
if err != nil {
return nil, fmt.Errorf("token validation failed: %v", err)
}
return claimsFromIntrospection(authCtx), nil
if err := v.ensureJWKS(); err != nil {
return nil, err
}
// Parse and validate
@@ -247,35 +120,34 @@ func (v *Validator) ValidateBearerToken(authHeader string) (jwt.MapClaims, error
}
}
// Check iss against the configured issuer (exact match or sub-path).
// Check iss (issuer) - accept any Authentik provider issuer
// (portfolio-agent, memory-agent, api-gw, etc.)
// All use same signing key so JWKS validation is sufficient
if iss, ok := claims["iss"].(string); !ok {
return nil, fmt.Errorf("missing issuer claim")
} else if !issuerMatches(iss, v.issuer) {
return nil, fmt.Errorf("invalid issuer: %s (want %s)", iss, v.issuer)
} else if !isValidIssuer(iss) {
return nil, fmt.Errorf("invalid issuer: %s", iss)
}
// Check aud - Zitadel sends an array; accept a string or array.
if auds, ok := audienceValues(claims); !ok {
// Check aud (audience) - accept any Authentik-provided audience
// since all Authentik service accounts use the same signing key.
// The issuer check above is sufficient to ensure JWT came from Authentik.
if aud, ok := claims["aud"].(string); !ok {
return nil, fmt.Errorf("missing audience claim")
} else if len(auds) == 0 {
} else if aud == "" {
return nil, fmt.Errorf("empty audience claim")
}
// The signature check plus the issuer check establish the trust boundary;
// the audience value itself is not hardcoded.
// Note: Not hardcoding expected audience. Any audience from a valid Authentik
// issuer is accepted, since all service accounts are under the same trust boundary.
return claims, nil
}
// CheckPermissions checks if claims contain required permission(s).
// Understands three token shapes:
// - "permissions": []string (generic / legacy tokens)
// - "roles": []string or map[string]… (legacy service-account tokens)
// - "urn:zitadel:iam:org:project:roles": map[string]… (Zitadel access tokens,
// where the map keys are role keys)
//
// Checks both "permissions" claim (for users) and "roles" claim (for service accounts).
// Returns true if any required permission is found or wildcard "*" exists.
func (v *Validator) CheckPermissions(claims jwt.MapClaims, required ...string) bool {
// 1. permissions claim (array of strings)
// Try permissions claim first (for user tokens)
if permsIface, ok := claims["permissions"]; ok {
if perms, ok := permsIface.([]interface{}); ok {
if v.checkPermList(perms, required...) {
@@ -284,45 +156,15 @@ func (v *Validator) CheckPermissions(claims jwt.MapClaims, required ...string) b
}
}
// 2. roles claim: array (legacy) or map keyed by role name
// Fall back to roles claim (for service account tokens)
if rolesIface, ok := claims["roles"]; ok {
switch roles := rolesIface.(type) {
case []interface{}:
if roles, ok := rolesIface.([]interface{}); ok {
if v.checkPermList(roles, required...) {
return true
}
case map[string]interface{}:
if v.checkPermMap(roles, required...) {
return true
}
}
}
// 3. Zitadel access-token role assertion: map keyed by role key
if rolesIface, ok := claims["urn:zitadel:iam:org:project:roles"]; ok {
if roles, ok := rolesIface.(map[string]interface{}); ok {
if v.checkPermMap(roles, required...) {
return true
}
}
}
return false
}
// checkPermMap reports whether any key in a role map matches a required
// permission, or the map carries a wildcard key.
func (v *Validator) checkPermMap(roles map[string]interface{}, required ...string) bool {
for roleKey := range roles {
if roleKey == "*" {
return true
}
for _, req := range required {
if roleKey == req {
return true
}
}
}
return false
}
-157
View File
@@ -1,157 +0,0 @@
package auth
import (
"context"
"errors"
"fmt"
gohttp "net/http"
"slices"
"strings"
"github.com/zitadel/oidc/v3/pkg/client"
"github.com/zitadel/oidc/v3/pkg/client/rp"
"github.com/zitadel/oidc/v3/pkg/oidc"
"github.com/zitadel/oidc/v3/pkg/op"
"github.com/zitadel/zitadel-go/v3/pkg/authorization/oauth"
"github.com/zitadel/zitadel-go/v3/pkg/zitadel"
)
// ErrAudienceNotAllowed reports a token whose aud claim names none of the
// configured resource-server client IDs.
var ErrAudienceNotAllowed = errors.New("audience not allowed")
// ZitadelConfig configures the ZITADEL-backed verifier.
type ZitadelConfig struct {
// Domain is the ZITADEL instance host, for example "zitadel.riotpiao.com".
Domain string
// InsecurePort, when non-empty, speaks plain HTTP to that port instead of
// HTTPS on 443. Use only for a local port-forward.
InsecurePort string
// ClientIDs is the audience allow-list: a token is accepted when its aud
// claim contains at least one of these client IDs. Empty accepts any
// non-empty audience, which trusts every client of the same instance.
ClientIDs []string
// HTTPClient overrides the client used for discovery and JWKS fetches.
HTTPClient *gohttp.Client
}
// ZitadelVerifier validates ZITADEL access tokens with the official
// github.com/zitadel/zitadel-go SDK.
//
// It reuses the SDK's building blocks rather than reimplementing JWT parsing:
// - zitadel.New + Origin to address the instance
// - client.Discover for OIDC discovery (issuer and JWKS URI)
// - rp.NewRemoteKeySet for cached, auto-refreshing signing keys
// - op.NewAccessTokenVerifier / op.VerifyAccessToken for signature, expiry
// and issuer checks
// - oauth.IntrospectionContext for the verified claim set and its
// IsGrantedRole helper, which already understands the
// urn:zitadel:iam:org:project:roles claim
//
// The only behaviour added on top of the SDK is a multi-value audience
// allow-list, because this gateway is a shared resource server: callers hold
// their own client IDs (paperless, poimen, api-gw, ...) and the SDK's
// oauth.WithJWT helper accepts exactly one.
type ZitadelVerifier struct {
instance *zitadel.Zitadel
verifier *op.AccessTokenVerifier
clientIDs []string
}
// NewZitadelVerifier performs OIDC discovery and returns a ready verifier.
// Discovery happens once at startup so a misconfigured domain fails fast
// instead of on the first request.
func NewZitadelVerifier(ctx context.Context, cfg ZitadelConfig) (*ZitadelVerifier, error) {
if cfg.Domain == "" {
return nil, errors.New("zitadel: domain is required")
}
httpClient := cfg.HTTPClient
if httpClient == nil {
httpClient = gohttp.DefaultClient
}
var opts []zitadel.Option
if cfg.InsecurePort != "" {
opts = append(opts, zitadel.WithInsecure(cfg.InsecurePort))
}
instance := zitadel.New(cfg.Domain, opts...)
discovery, err := client.Discover(ctx, instance.Origin(), httpClient)
if err != nil {
return nil, fmt.Errorf("zitadel: OIDC discovery at %s: %w", instance.Origin(), err)
}
keySet := rp.NewRemoteKeySet(httpClient, discovery.JwksURI)
verifier := op.NewAccessTokenVerifier(discovery.Issuer, keySet)
return &ZitadelVerifier{
instance: instance,
verifier: verifier,
clientIDs: append([]string(nil), cfg.ClientIDs...),
}, nil
}
// Origin returns the instance origin used for discovery, for logging.
func (z *ZitadelVerifier) Origin() string {
if z == nil || z.instance == nil {
return ""
}
return z.instance.Origin()
}
// CheckAuthorization validates an "Authorization: Bearer <token>" header and
// returns the SDK's verified introspection context.
func (z *ZitadelVerifier) CheckAuthorization(ctx context.Context, authHeader string) (*oauth.IntrospectionContext, error) {
accessToken, ok := strings.CutPrefix(authHeader, oidc.BearerToken)
if !ok {
return nil, fmt.Errorf("%w: expected %q prefix", oauth.ErrInvalidAuthorizationHeader, oidc.BearerToken)
}
accessToken = strings.TrimSpace(accessToken)
if accessToken == "" {
return nil, fmt.Errorf("%w: empty bearer token", oauth.ErrInvalidAuthorizationHeader)
}
claims, err := op.VerifyAccessToken[*oidc.AccessTokenClaims](ctx, accessToken, z.verifier)
if err != nil {
return nil, fmt.Errorf("%w: %v", oauth.ErrInvalidToken, err)
}
if len(claims.Audience) == 0 {
return nil, fmt.Errorf("%w: empty aud", oauth.ErrInvalidToken)
}
if !z.audienceAllowed(claims.Audience) {
return nil, fmt.Errorf("%w: %v", ErrAudienceNotAllowed, claims.Audience)
}
authCtx := &oauth.IntrospectionContext{
IntrospectionResponse: oidc.IntrospectionResponse{
Active: true,
Issuer: claims.Issuer,
Subject: claims.Subject,
Audience: claims.Audience,
Expiration: claims.Expiration,
IssuedAt: claims.IssuedAt,
NotBefore: claims.NotBefore,
ClientID: claims.ClientID,
JWTID: claims.JWTID,
Claims: claims.Claims,
},
}
authCtx.SetToken(accessToken)
return authCtx, nil
}
// audienceAllowed reports whether any aud entry is in the allow-list.
// An empty allow-list accepts every non-empty audience.
func (z *ZitadelVerifier) audienceAllowed(audience []string) bool {
if len(z.clientIDs) == 0 {
return true
}
for _, aud := range audience {
if slices.Contains(z.clientIDs, aud) {
return true
}
}
return false
}
-294
View File
@@ -1,294 +0,0 @@
package auth
import (
"context"
"crypto/rand"
"crypto/rsa"
"encoding/base64"
"encoding/json"
"fmt"
"math/big"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
)
// testIssuer mimics one ZITADEL instance: OIDC discovery on
// /.well-known/openid-configuration and the JWKS on /oauth/v2/keys.
type testIssuer struct {
server *httptest.Server
key *rsa.PrivateKey
kid string
jwksHit int
}
func newTestIssuer(t *testing.T) *testIssuer {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("generate key: %v", err)
}
ti := &testIssuer{key: key, kid: "test-kid-1"}
mux := http.NewServeMux()
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"issuer": ti.server.URL,
"jwks_uri": ti.server.URL + "/oauth/v2/keys",
})
})
mux.HandleFunc("/oauth/v2/keys", func(w http.ResponseWriter, r *http.Request) {
ti.jwksHit++
pub := key.PublicKey
_ = json.NewEncoder(w).Encode(map[string]any{
"keys": []map[string]any{{
"kty": "RSA",
"kid": ti.kid,
"use": "sig",
"alg": "RS256",
"n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()),
"e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pub.E)).Bytes()),
}},
})
})
ti.server = httptest.NewServer(mux)
t.Cleanup(ti.server.Close)
return ti
}
// sign mints an access token with the given audience and project roles.
func (ti *testIssuer) sign(t *testing.T, aud []string, roles map[string]any) string {
t.Helper()
claims := jwt.MapClaims{
"iss": ti.server.URL,
"sub": "396757800000000001",
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
}
if len(aud) > 0 {
audClaim := make([]interface{}, 0, len(aud))
for _, a := range aud {
audClaim = append(audClaim, a)
}
claims["aud"] = audClaim
}
if roles != nil {
claims["urn:zitadel:iam:org:project:roles"] = roles
}
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
tok.Header["kid"] = ti.kid
signed, err := tok.SignedString(ti.key)
if err != nil {
t.Fatalf("sign token: %v", err)
}
return signed
}
// verifierFor builds a ZitadelVerifier pointed at the httptest instance.
// The SDK is given the loopback host and its port, so discovery goes to the
// test server rather than a real domain.
func verifierFor(t *testing.T, ti *testIssuer, clientIDs []string) *ZitadelVerifier {
t.Helper()
host, port, ok := strings.Cut(strings.TrimPrefix(ti.server.URL, "http://"), ":")
if !ok {
t.Fatalf("unexpected test server URL %q", ti.server.URL)
}
v, err := NewZitadelVerifier(context.Background(), ZitadelConfig{
Domain: host,
InsecurePort: port,
ClientIDs: clientIDs,
HTTPClient: ti.server.Client(),
})
if err != nil {
t.Fatalf("NewZitadelVerifier: %v", err)
}
return v
}
func TestZitadelVerifier_AcceptsTokenWithAllowedAudience(t *testing.T) {
ti := newTestIssuer(t)
v := verifierFor(t, ti, []string{"396757803559027071", "396757800874672511"})
token := ti.sign(t, []string{"396757803559027071"}, map[string]any{
"llm:inference": map[string]any{"396757796898471937": "homelab"},
})
authCtx, err := v.CheckAuthorization(context.Background(), "Bearer "+token)
if err != nil {
t.Fatalf("expected token to pass, got %v", err)
}
if !authCtx.IsAuthorized() {
t.Error("expected IsAuthorized() true")
}
if want := "396757800000000001"; authCtx.UserID() != want {
t.Errorf("UserID() = %q, want %q", authCtx.UserID(), want)
}
if !authCtx.IsGrantedRole("llm:inference") {
t.Error("expected SDK IsGrantedRole to see llm:inference from the project roles claim")
}
if ti.jwksHit == 0 {
t.Error("expected the SDK to fetch the JWKS")
}
}
func TestZitadelVerifier_RejectsForeignAudience(t *testing.T) {
ti := newTestIssuer(t)
v := verifierFor(t, ti, []string{"396757803559027071"})
token := ti.sign(t, []string{"999999999999999999"}, nil)
_, err := v.CheckAuthorization(context.Background(), "Bearer "+token)
if err == nil {
t.Fatal("expected rejection for an audience outside the allow-list")
}
if !strings.Contains(err.Error(), ErrAudienceNotAllowed.Error()) {
t.Errorf("expected ErrAudienceNotAllowed, got %v", err)
}
}
func TestZitadelVerifier_AcceptsAnyAudienceWhenAllowListEmpty(t *testing.T) {
ti := newTestIssuer(t)
v := verifierFor(t, ti, nil)
token := ti.sign(t, []string{"396757803877794175"}, nil)
if _, err := v.CheckAuthorization(context.Background(), "Bearer "+token); err != nil {
t.Fatalf("empty allow-list should accept any audience, got %v", err)
}
}
func TestZitadelVerifier_RejectsMissingBearerPrefix(t *testing.T) {
ti := newTestIssuer(t)
v := verifierFor(t, ti, nil)
token := ti.sign(t, []string{"396757803877794175"}, nil)
if _, err := v.CheckAuthorization(context.Background(), token); err == nil {
t.Fatal("expected rejection when the Bearer prefix is missing")
}
}
func TestZitadelVerifier_RejectsExpiredToken(t *testing.T) {
ti := newTestIssuer(t)
v := verifierFor(t, ti, nil)
claims := jwt.MapClaims{
"iss": ti.server.URL,
"sub": "u1",
"aud": []interface{}{"396757803877794175"},
"exp": time.Now().Add(-2 * time.Hour).Unix(),
"iat": time.Now().Add(-3 * time.Hour).Unix(),
}
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
tok.Header["kid"] = ti.kid
signed, err := tok.SignedString(ti.key)
if err != nil {
t.Fatalf("sign token: %v", err)
}
if _, err := v.CheckAuthorization(context.Background(), "Bearer "+signed); err == nil {
t.Fatal("expected rejection for an expired token")
}
}
// TestValidator_SDKPathFeedsCheckPermissions proves the adapter from the SDK
// introspection context to jwt.MapClaims keeps the capability gate working.
func TestValidator_SDKPathFeedsCheckPermissions(t *testing.T) {
ti := newTestIssuer(t)
host, port, _ := strings.Cut(strings.TrimPrefix(ti.server.URL, "http://"), ":")
validator, err := NewZitadelValidator(context.Background(), ti.server.URL, []string{"396757803559027071"}, ZitadelConfig{
Domain: host,
InsecurePort: port,
HTTPClient: ti.server.Client(),
})
if err != nil {
t.Fatalf("NewZitadelValidator: %v", err)
}
good := ti.sign(t, []string{"396757803559027071"}, map[string]any{
"llm:inference": map[string]any{"396757796898471937": "homelab"},
})
claims, err := validator.ValidateBearerToken("Bearer " + good)
if err != nil {
t.Fatalf("ValidateBearerToken: %v", err)
}
if !validator.CheckPermissions(claims, "llm:inference") {
t.Error("expected CheckPermissions to accept llm:inference via the SDK path")
}
if validator.CheckPermissions(claims, "sqs:write") {
t.Error("expected CheckPermissions to reject a role the token does not carry")
}
noRole := ti.sign(t, []string{"396757803559027071"}, nil)
claims, err = validator.ValidateBearerToken("Bearer " + noRole)
if err != nil {
t.Fatalf("ValidateBearerToken without roles: %v", err)
}
if validator.CheckPermissions(claims, "llm:inference") {
t.Error("expected CheckPermissions to reject a token with no project roles")
}
}
func TestIssuerMatches(t *testing.T) {
cases := []struct {
iss string
configured string
want bool
}{
{"https://zitadel.riotpiao.com", "https://zitadel.riotpiao.com", true},
{"https://zitadel.riotpiao.com/", "https://zitadel.riotpiao.com", true},
{"https://zitadel.riotpiao.com/oauth/v2", "https://zitadel.riotpiao.com", true},
{"https://evil.example.com", "https://zitadel.riotpiao.com", false},
{"https://zitadel.riotpiao.com.evil.com", "https://zitadel.riotpiao.com", false},
{"anything", "", true},
}
for _, c := range cases {
if got := issuerMatches(c.iss, c.configured); got != c.want {
t.Errorf("issuerMatches(%q, %q) = %v, want %v", c.iss, c.configured, got, c.want)
}
}
}
func TestClaimsFromIntrospection_Nil(t *testing.T) {
if got := claimsFromIntrospection(nil); got == nil || len(got) != 0 {
t.Errorf("expected empty non-nil claims, got %v", got)
}
}
func TestAudienceValues(t *testing.T) {
cases := []struct {
name string
claim any
want int
ok bool
}{
{"string", "api-gw", 1, true},
{"empty string", "", 1, false},
{"string slice", []string{"a", "b"}, 2, true},
{"iface slice", []interface{}{"a", 5}, 1, true},
{"missing", nil, 0, false},
}
for _, c := range cases {
claims := jwt.MapClaims{}
if c.claim != nil {
claims["aud"] = c.claim
}
got, ok := audienceValues(claims)
if ok != c.ok || len(got) != c.want {
t.Errorf("%s: audienceValues = (%v, %v), want len %d ok %v", c.name, got, ok, c.want, c.ok)
}
}
_ = fmt.Sprint()
}
+1 -12
View File
@@ -66,23 +66,12 @@ type AuthConfig struct {
JWKSURL string
// RequiredCapability is the permission required for LLM inference (e.g., "llm:inference").
RequiredCapability string
// TokenURL is the OIDC token endpoint used for token exchange
// (client_credentials / password grants).
// TokenURL is the Authentik token endpoint for password/refresh grants.
TokenURL string
// ClientID is the OAuth2 client ID for token exchange.
ClientID string
// ClientSecret is the OAuth2 client secret (loaded from env, never from config file).
ClientSecret string
// ZitadelDomain is the ZITADEL instance host (for example
// "zitadel.riotpiao.com"). When set, token validation uses the official
// ZITADEL Go SDK instead of the raw JWKS path.
ZitadelDomain string
// ZitadelInsecurePort, when non-empty, makes the SDK speak plain HTTP to
// that port. Only for a local port-forward.
ZitadelInsecurePort string
// ZitadelClientIDs is the audience allow-list for the SDK verifier. Empty
// accepts any audience issued by the same instance.
ZitadelClientIDs []string
}
// Route represents a single route and its upstream configuration.
+15 -21
View File
@@ -20,16 +20,13 @@ type rawConfig struct {
// rawAuth represents auth configuration in YAML.
type rawAuth struct {
Enabled bool `yaml:"enabled"`
Issuer string `yaml:"issuer"`
Audience string `yaml:"audience"`
JWKSURL string `yaml:"jwksUrl"`
RequiredCapability string `yaml:"requiredCapability"`
TokenURL string `yaml:"tokenUrl"`
ClientID string `yaml:"clientId"`
ZitadelDomain string `yaml:"zitadelDomain"`
ZitadelInsecurePort string `yaml:"zitadelInsecurePort"`
ZitadelClientIDs []string `yaml:"zitadelClientIds"`
Enabled bool `yaml:"enabled"`
Issuer string `yaml:"issuer"`
Audience string `yaml:"audience"`
JWKSURL string `yaml:"jwksUrl"`
RequiredCapability string `yaml:"requiredCapability"`
TokenURL string `yaml:"tokenUrl"`
ClientID string `yaml:"clientId"`
}
// rawRoute represents a single route in the YAML configuration.
@@ -187,17 +184,14 @@ func LoadRoutesAndModelsFromFile(path string) (map[string]*Route, map[string]*Mo
// Parse auth config
authConfig := AuthConfig{
Enabled: raw.Auth.Enabled,
Issuer: raw.Auth.Issuer,
Audience: raw.Auth.Audience,
JWKSURL: raw.Auth.JWKSURL,
RequiredCapability: raw.Auth.RequiredCapability,
TokenURL: raw.Auth.TokenURL,
ClientID: raw.Auth.ClientID,
ClientSecret: os.Getenv("AUTH_CLIENT_SECRET"),
ZitadelDomain: raw.Auth.ZitadelDomain,
ZitadelInsecurePort: raw.Auth.ZitadelInsecurePort,
ZitadelClientIDs: raw.Auth.ZitadelClientIDs,
Enabled: raw.Auth.Enabled,
Issuer: raw.Auth.Issuer,
Audience: raw.Auth.Audience,
JWKSURL: raw.Auth.JWKSURL,
RequiredCapability: raw.Auth.RequiredCapability,
TokenURL: raw.Auth.TokenURL,
ClientID: raw.Auth.ClientID,
ClientSecret: os.Getenv("AUTH_CLIENT_SECRET"),
}
return routes, models, adapters, authConfig, nil
+7 -51
View File
@@ -98,29 +98,8 @@ func New(cfg *config.Config) *Handler {
defaultMaxBodySize: 100 * 1024 * 1024,
}
// Initialize JWT validator if auth is enabled.
//
// Two paths, in order of preference:
// 1. ZitadelDomain set -> official ZITADEL Go SDK (discovery + remote JWKS
// + op.AccessTokenVerifier). This is the production path.
// 2. JWKSURL set -> legacy raw-JWKS validation, kept for tests and for
// any deployment that has not moved to ZITADEL yet.
if cfg.Auth.Enabled && cfg.Auth.ZitadelDomain != "" {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
v, err := auth.NewZitadelValidator(ctx, cfg.Auth.Issuer, cfg.Auth.ZitadelClientIDs, auth.ZitadelConfig{
Domain: cfg.Auth.ZitadelDomain,
InsecurePort: cfg.Auth.ZitadelInsecurePort,
ClientIDs: cfg.Auth.ZitadelClientIDs,
})
if err != nil {
logging.Errorf("zitadel: verifier init failed; auth disabled until restart", err, map[string]string{
"domain": cfg.Auth.ZitadelDomain,
})
} else {
h.jwtValidator = v
}
} else if cfg.Auth.Enabled && cfg.Auth.JWKSURL != "" {
// Initialize JWT validator if auth is enabled
if cfg.Auth.Enabled && cfg.Auth.JWKSURL != "" {
h.jwtValidator = auth.NewValidator(
cfg.Auth.Issuer,
cfg.Auth.Audience,
@@ -743,17 +722,16 @@ func (h *Handler) authenticateV1(w http.ResponseWriter, r *http.Request) bool {
return false
}
identity.Inject(r, claims)
if caps := requiredCapabilities(h.config.Auth.RequiredCapability); len(caps) > 0 {
args := append(caps, "*")
if !h.jwtValidator.CheckPermissions(claims, args...) {
if h.config.Auth.RequiredCapability != "" {
if !h.jwtValidator.CheckPermissions(claims, h.config.Auth.RequiredCapability, "*") {
writeProblemDetail(w, http.StatusForbidden,
"https://api.example.com/problems/insufficient-permissions",
"Insufficient Permissions",
fmt.Sprintf("Required capability: %s", strings.Join(caps, " or ")),
fmt.Sprintf("Required capability: %s", h.config.Auth.RequiredCapability),
nil)
logging.Errorf("auth failed", fmt.Errorf("insufficient permissions"), map[string]string{
"path": r.URL.Path,
"required": strings.Join(caps, ","),
"required": h.config.Auth.RequiredCapability,
})
return false
}
@@ -761,22 +739,6 @@ func (h *Handler) authenticateV1(w http.ResponseWriter, r *http.Request) bool {
return true
}
// requiredCapabilities splits the comma-separated requiredCapability setting
// into a list. Several spellings are accepted because the same capability is
// named differently by each identity provider: Authentik used "llm:inference"
// as a permission string, while ZITADEL uses role keys such as
// "llm-inference". A token satisfies the gate when it carries any one of them.
func requiredCapabilities(raw string) []string {
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
// enforceBodyLimit caps request body size for non-chat routes.
// Returns false when the request is rejected (response already written).
func (h *Handler) enforceBodyLimit(w http.ResponseWriter, r *http.Request, route *Route) bool {
@@ -835,14 +797,8 @@ func (h *Handler) setupCAGProxy(ctx context.Context, w http.ResponseWriter, r *h
if resp.StatusCode < http.StatusBadRequest {
return nil
}
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 4096))
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
resp.Body.Close()
if readErr != nil {
logging.Warnf("reading upstream error body failed", map[string]string{
"upstream": capturedRoute.Upstream.Address,
"err": readErr.Error(),
})
}
logging.Warnf("upstream error after parked stream started", map[string]string{
"upstream": capturedRoute.Upstream.Address,
"status": strconv.Itoa(resp.StatusCode),
File diff suppressed because one or more lines are too long
+6 -8
View File
@@ -99,21 +99,19 @@ spec:
ports:
- protocol: TCP
port: 8080
# Allow to IAM (Zitadel) for JWKS fetch + API
# Zitadel API listens on 8080 (h2c/plaintext in-cluster); the login UI
# listens on 3000. Public access goes through ingress-nginx on 443 (already
# covered by the 10.96.0.0/12 CIDR below), but 8080 keeps direct in-cluster
# calls working if CoreDNS rewrite is bypassed.
# NetworkPolicy matches destination pod port, not service port.
# Allow to IAM (Authentik) for JWKS fetch + API
# Authentik pod listens on 9000 (http) and 9443 (https)
# Service translates 80→9000, 443→9443
# NetworkPolicy matches destination pod port, not service port
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: iam
ports:
- protocol: TCP
port: 8080
port: 9000
- protocol: TCP
port: 3000
port: 9443
# Allow to SQS (queue management service)
- to:
- namespaceSelector: