diff --git a/go.mod b/go.mod index 372b95c..86fc49d 100644 --- a/go.mod +++ b/go.mod @@ -5,39 +5,51 @@ 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/google/uuid v1.6.0 // indirect - github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect + github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 // 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/net v0.58.0 // indirect + golang.org/x/oauth2 v0.36.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 diff --git a/go.sum b/go.sum index e3f05ca..0cac63b 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,19 @@ 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= @@ -23,8 +31,12 @@ 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= @@ -33,6 +45,10 @@ 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= @@ -41,6 +57,8 @@ 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= @@ -48,6 +66,12 @@ 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= @@ -87,6 +111,8 @@ 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= diff --git a/internal/auth/jwe_test.go b/internal/auth/jwe_test.go new file mode 100644 index 0000000..7e665d3 --- /dev/null +++ b/internal/auth/jwe_test.go @@ -0,0 +1,21 @@ +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) + } +} diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go index 8838103..9da98d1 100644 --- a/internal/auth/jwt.go +++ b/internal/auth/jwt.go @@ -3,37 +3,141 @@ 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" ) -// 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, "/") +// 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//"). 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+"/") } -// 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. +// 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. type Validator struct { - 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 + issuer string // Expected issuer (iss claim); empty disables the domain check + audience string // Expected audience; currently logged only (any non-empty aud 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 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). +// 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). func NewValidator(issuer, audience, jwksURL string) *Validator { return &Validator{ issuer: issuer, // deprecated param, kept for compat @@ -87,8 +191,31 @@ func (v *Validator) ValidateBearerToken(authHeader string) (jwt.MapClaims, error } // Ensure JWKS is loaded (lazy) - if err := v.ensureJWKS(); err != nil { - return nil, err + 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 } // Parse and validate @@ -120,34 +247,35 @@ func (v *Validator) ValidateBearerToken(authHeader string) (jwt.MapClaims, error } } - // 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 + // Check iss against the configured issuer (exact match or sub-path). if iss, ok := claims["iss"].(string); !ok { return nil, fmt.Errorf("missing issuer claim") - } else if !isValidIssuer(iss) { - return nil, fmt.Errorf("invalid issuer: %s", iss) + } else if !issuerMatches(iss, v.issuer) { + return nil, fmt.Errorf("invalid issuer: %s (want %s)", iss, v.issuer) } - // 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 { + // Check aud - Zitadel sends an array; accept a string or array. + if auds, ok := audienceValues(claims); !ok { return nil, fmt.Errorf("missing audience claim") - } else if aud == "" { + } else if len(auds) == 0 { return nil, fmt.Errorf("empty audience claim") } - // Note: Not hardcoding expected audience. Any audience from a valid Authentik - // issuer is accepted, since all service accounts are under the same trust boundary. + // The signature check plus the issuer check establish the trust boundary; + // the audience value itself is not hardcoded. return claims, nil } // CheckPermissions checks if claims contain required permission(s). -// Checks both "permissions" claim (for users) and "roles" claim (for service accounts). +// 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) +// // Returns true if any required permission is found or wildcard "*" exists. func (v *Validator) CheckPermissions(claims jwt.MapClaims, required ...string) bool { - // Try permissions claim first (for user tokens) + // 1. permissions claim (array of strings) if permsIface, ok := claims["permissions"]; ok { if perms, ok := permsIface.([]interface{}); ok { if v.checkPermList(perms, required...) { @@ -156,15 +284,45 @@ func (v *Validator) CheckPermissions(claims jwt.MapClaims, required ...string) b } } - // Fall back to roles claim (for service account tokens) + // 2. roles claim: array (legacy) or map keyed by role name if rolesIface, ok := claims["roles"]; ok { - if roles, ok := rolesIface.([]interface{}); ok { + switch roles := rolesIface.(type) { + case []interface{}: 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 } diff --git a/internal/auth/zitadel.go b/internal/auth/zitadel.go new file mode 100644 index 0000000..0d4ee49 --- /dev/null +++ b/internal/auth/zitadel.go @@ -0,0 +1,157 @@ +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 " 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 +} diff --git a/internal/auth/zitadel_test.go b/internal/auth/zitadel_test.go new file mode 100644 index 0000000..dc26e8e --- /dev/null +++ b/internal/auth/zitadel_test.go @@ -0,0 +1,294 @@ +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() +} diff --git a/internal/config/config.go b/internal/config/config.go index 2c09268..5c1d437 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -66,12 +66,23 @@ type AuthConfig struct { JWKSURL string // RequiredCapability is the permission required for LLM inference (e.g., "llm:inference"). RequiredCapability string - // TokenURL is the Authentik token endpoint for password/refresh grants. + // TokenURL is the OIDC token endpoint used for token exchange + // (client_credentials / password 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. diff --git a/internal/config/loader.go b/internal/config/loader.go index a21375c..37a38ed 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -20,13 +20,16 @@ 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"` + 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"` } // rawRoute represents a single route in the YAML configuration. @@ -184,14 +187,17 @@ 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"), + 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, } return routes, models, adapters, authConfig, nil diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index 50d413d..5c48558 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -98,8 +98,29 @@ func New(cfg *config.Config) *Handler { defaultMaxBodySize: 100 * 1024 * 1024, } - // Initialize JWT validator if auth is enabled - if cfg.Auth.Enabled && cfg.Auth.JWKSURL != "" { + // 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 != "" { h.jwtValidator = auth.NewValidator( cfg.Auth.Issuer, cfg.Auth.Audience, @@ -722,16 +743,17 @@ func (h *Handler) authenticateV1(w http.ResponseWriter, r *http.Request) bool { return false } identity.Inject(r, claims) - if h.config.Auth.RequiredCapability != "" { - if !h.jwtValidator.CheckPermissions(claims, h.config.Auth.RequiredCapability, "*") { + if caps := requiredCapabilities(h.config.Auth.RequiredCapability); len(caps) > 0 { + args := append(caps, "*") + if !h.jwtValidator.CheckPermissions(claims, args...) { writeProblemDetail(w, http.StatusForbidden, "https://api.example.com/problems/insufficient-permissions", "Insufficient Permissions", - fmt.Sprintf("Required capability: %s", h.config.Auth.RequiredCapability), + fmt.Sprintf("Required capability: %s", strings.Join(caps, " or ")), nil) logging.Errorf("auth failed", fmt.Errorf("insufficient permissions"), map[string]string{ "path": r.URL.Path, - "required": h.config.Auth.RequiredCapability, + "required": strings.Join(caps, ","), }) return false } @@ -739,6 +761,22 @@ 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 { diff --git a/k8s/gateway-config-secret.yaml b/k8s/gateway-config-secret.yaml index 03cd15a..b9afb99 100644 --- a/k8s/gateway-config-secret.yaml +++ b/k8s/gateway-config-secret.yaml @@ -7,4 +7,4 @@ metadata: app: api-gateway type: Opaque stringData: - config.yaml: "# PRODUCTION GATEWAY CONFIGURATION\n# ==========================================\n# All upstream services MUST use Kubernetes internal service DNS names\n# Format: ..svc.cluster.local\n# \n# This ensures:\n# - Communication within cluster network only (no external IP exposure)\n# - Pod-to-pod service discovery via internal DNS\n# - Security policy enforcement at network level\n# - Service-level load balancing via kube-proxy\n#\n# Routing Pattern:\n# PREFERRED: X-Service header routing (e.g., X-Service: workflow)\n# Legacy: Path-based routing (e.g., /workflow) - being deprecated\n#\nauth:\n enabled: true\n issuer: \"https://authentik.riotpiao.com/application/o/api-gw/\"\n audience: \"api-gw\"\n jwksUrl: \"http://authentik-server.iam.svc.cluster.local/application/o/api-gw/jwks/\"\n requiredCapability: \"llm:inference\"\n tokenUrl: \"http://authentik-server.iam.svc.cluster.local/application/o/token/\"\n clientId: \"api-gw\"\nroutes: []\nmodels:\n# All model services use internal Kubernetes DNS (llm-serving namespace)\n- name: \"reasoning\"\n address: \"reasoning-predictor.llm-serving.svc.cluster.local:80\"\n path: \"/v1/chat/completions\"\n- name: \"ornith:35b\"\n address: \"ornith-predictor.llm-serving.svc.cluster.local:80\"\n path: \"/v1/chat/completions\"\n- name: \"qwen2.5:3b-instruct\"\n address: \"qwen-cpu.llm-serving.svc.cluster.local:80\"\n path: \"/v1/chat/completions\"\n- name: \"nomic-ai/nomic-embed-text-v2-moe\"\n address: \"embeddings-predictor.llm-serving.svc.cluster.local:80\"\n path: \"/v1/embeddings\"\n- name: \"BAAI/bge-reranker-base\"\n address: \"reranker-predictor.llm-serving.svc.cluster.local:80\"\n path: \"/v1/rerank\"\nadapters:\n- serviceName: sqs\n upstream:\n url: http://management-service.sqs.svc.cluster.local:9090\n timeoutSeconds: 30\n auth:\n required: true\n resources:\n - name: send-message\n methods:\n - verb: POST\n upstreamPath: /sqs/send\n - name: receive-message\n methods:\n - verb: POST\n upstreamPath: /sqs/receive\n - name: list-queues\n methods:\n - verb: GET\n upstreamPath: /sqs/queues\n- serviceName: workflow\n upstream:\n url: grpc://temporal-frontend.temporal.svc.cluster.local:7233\n timeoutSeconds: 60\n auth:\n required: false\n resources:\n - name: execute\n methods:\n - verb: POST\n upstreamPath: /temporal.api.workflowservice.v1.WorkflowService/ExecuteWorkflow\n - name: describe\n methods:\n - verb: GET\n upstreamPath: /temporal.api.workflowservice.v1.WorkflowService/DescribeWorkflowExecution\n - name: list\n methods:\n - verb: GET\n upstreamPath: /temporal.api.workflowservice.v1.WorkflowService/ListWorkflowExecutions\n- serviceName: memory\n upstream:\n url: http://poimen-memory.poimen.svc.cluster.local:8080\n timeoutSeconds: 30\n auth:\n required: false\n resources:\n - name: query\n methods:\n - verb: POST\n upstreamPath: /memory/query\n - name: ingest\n methods:\n - verb: POST\n upstreamPath: /memory/ingest\n - name: skills\n methods:\n - verb: GET\n upstreamPath: /memory/skills\n- serviceName: s3\n upstream:\n url: http://minio.storage.svc.cluster.local:80\n timeoutSeconds: 30\n auth:\n required: false\n resources:\n - name: list-objects\n methods:\n - verb: GET\n upstreamPath: /\n - name: get-object\n methods:\n - verb: GET\n upstreamPath: /\n - name: put-object\n methods:\n - verb: PUT\n upstreamPath: /\n- serviceName: iam\n upstream:\n url: http://authentik-server.iam.svc.cluster.local:80\n timeoutSeconds: 30\n auth:\n required: false\n resources:\n - name: list-roles\n methods:\n - verb: GET\n upstreamPath: /api/v3/roles\n - name: list-users\n methods:\n - verb: GET\n upstreamPath: /api/v3/users\n - name: create-role\n methods:\n - verb: POST\n upstreamPath: /api/v3/roles\n" + config.yaml: "# PRODUCTION GATEWAY CONFIGURATION\n# ==========================================\n# All upstream services MUST use Kubernetes internal service DNS names\n# Format: ..svc.cluster.local\n# \n# This ensures:\n# - Communication within cluster network only (no external IP exposure)\n# - Pod-to-pod service discovery via internal DNS\n# - Security policy enforcement at network level\n# - Service-level load balancing via kube-proxy\n#\n# Routing Pattern:\n# PREFERRED: X-Service header routing (e.g., X-Service: workflow)\n# Legacy: Path-based routing (e.g., /workflow) - being deprecated\n#\nauth:\n enabled: true\n # ZITADEL is the only identity provider. CoreDNS rewrites\n # zitadel.riotpiao.com to ingress-nginx, so discovery and JWKS stay inside\n # the cluster and TLS is the wildcard certificate.\n issuer: \"https://zitadel.riotpiao.com\"\n # zitadelDomain enables the official ZITADEL Go SDK path: OIDC discovery,\n # remote JWKS cache, and op.AccessTokenVerifier.\n zitadelDomain: \"zitadel.riotpiao.com\"\n # Audience allow-list: a caller token must carry one of these client IDs.\n # Add a consumer here before it may call the LLM models.\n zitadelClientIds:\n - \"396757800874672511\" # api-gw\n - \"396757803559027071\" # paperless (paperless-ai-agent)\n - \"396757803877794175\" # poimen\n - \"396757801210282367\" # argocd\n - \"396757804213403649\" # queue-sqs\n # Two spellings accepted: ZITADEL role keys cannot carry a colon,\n # Authentik permission strings could.\n requiredCapability: \"llm-inference,llm:inference\"\n # token exchange (client_credentials) against the same instance\n tokenUrl: \"https://zitadel.riotpiao.com/oauth/v2/token\"\n clientId: \"396757800874672511\"\n # jwksUrl is intentionally unset: the SDK discovers it.\nroutes: []\nmodels:\n# All model services use internal Kubernetes DNS (llm-serving namespace)\n- name: \"reasoning\"\n address: \"reasoning-predictor.llm-serving.svc.cluster.local:80\"\n path: \"/v1/chat/completions\"\n- name: \"ornith:35b\"\n address: \"ornith-predictor.llm-serving.svc.cluster.local:80\"\n path: \"/v1/chat/completions\"\n- name: \"qwen2.5:3b-instruct\"\n address: \"qwen-cpu.llm-serving.svc.cluster.local:80\"\n path: \"/v1/chat/completions\"\n- name: \"nomic-ai/nomic-embed-text-v2-moe\"\n address: \"embeddings-predictor.llm-serving.svc.cluster.local:80\"\n path: \"/v1/embeddings\"\n- name: \"BAAI/bge-reranker-base\"\n address: \"reranker-predictor.llm-serving.svc.cluster.local:80\"\n path: \"/v1/rerank\"\nadapters:\n- serviceName: sqs\n upstream:\n url: http://management-service.sqs.svc.cluster.local:9090\n timeoutSeconds: 30\n auth:\n required: true\n resources:\n - name: send-message\n methods:\n - verb: POST\n upstreamPath: /sqs/send\n - name: receive-message\n methods:\n - verb: POST\n upstreamPath: /sqs/receive\n - name: list-queues\n methods:\n - verb: GET\n upstreamPath: /sqs/queues\n- serviceName: workflow\n upstream:\n url: grpc://temporal-frontend.temporal.svc.cluster.local:7233\n timeoutSeconds: 60\n auth:\n required: false\n resources:\n - name: execute\n methods:\n - verb: POST\n upstreamPath: /temporal.api.workflowservice.v1.WorkflowService/ExecuteWorkflow\n - name: describe\n methods:\n - verb: GET\n upstreamPath: /temporal.api.workflowservice.v1.WorkflowService/DescribeWorkflowExecution\n - name: list\n methods:\n - verb: GET\n upstreamPath: /temporal.api.workflowservice.v1.WorkflowService/ListWorkflowExecutions\n- serviceName: memory\n upstream:\n url: http://poimen-memory.poimen.svc.cluster.local:8080\n timeoutSeconds: 30\n auth:\n required: false\n resources:\n - name: query\n methods:\n - verb: POST\n upstreamPath: /memory/query\n - name: ingest\n methods:\n - verb: POST\n upstreamPath: /memory/ingest\n - name: skills\n methods:\n - verb: GET\n upstreamPath: /memory/skills\n- serviceName: s3\n upstream:\n url: http://minio.storage.svc.cluster.local:80\n timeoutSeconds: 30\n auth:\n required: false\n resources:\n - name: list-objects\n methods:\n - verb: GET\n upstreamPath: /\n - name: get-object\n methods:\n - verb: GET\n upstreamPath: /\n - name: put-object\n methods:\n - verb: PUT\n upstreamPath: /\n- serviceName: iam\n upstream:\n url: http://zitadel.iam.svc.cluster.local:8080\n timeoutSeconds: 30\n auth:\n required: false\n resources:\n - name: list-roles\n methods:\n - verb: GET\n upstreamPath: /api/v3/roles\n - name: list-users\n methods:\n - verb: GET\n upstreamPath: /api/v3/users\n - name: create-role\n methods:\n - verb: POST\n upstreamPath: /api/v3/roles\n" diff --git a/k8s/network-policy.yaml b/k8s/network-policy.yaml index 619dace..871c266 100644 --- a/k8s/network-policy.yaml +++ b/k8s/network-policy.yaml @@ -99,19 +99,21 @@ spec: ports: - protocol: TCP port: 8080 - # 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 + # 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. - to: - namespaceSelector: matchLabels: kubernetes.io/metadata.name: iam ports: - protocol: TCP - port: 9000 + port: 8080 - protocol: TCP - port: 9443 + port: 3000 # Allow to SQS (queue management service) - to: - namespaceSelector: