From 23e399bd9af19f001035c29545cda6638bf5ac3e Mon Sep 17 00:00:00 2001 From: Timofei Larkin Date: Wed, 4 Feb 2026 07:50:10 +0300 Subject: [PATCH] [dashboard] Verify JWT token ## What this PR does When OIDC is disabled, the dashboard's token-proxy now properly validates bearer tokens against the k8s API's JWKS url. ### Release note ```release-note [dashboard] Verify bearer tokens against the issuer's JWKS url. ``` Signed-off-by: Timofei Larkin --- .../dashboard/images/token-proxy/go.mod | 17 +- .../dashboard/images/token-proxy/go.sum | 41 +++- .../dashboard/images/token-proxy/main.go | 221 ++++++++++++++---- .../dashboard/templates/gatekeeper-sa.yaml | 27 +++ .../dashboard/templates/gatekeeper.yaml | 1 - 5 files changed, 258 insertions(+), 49 deletions(-) diff --git a/packages/system/dashboard/images/token-proxy/go.mod b/packages/system/dashboard/images/token-proxy/go.mod index 4ac8c21c..01f15bc8 100644 --- a/packages/system/dashboard/images/token-proxy/go.mod +++ b/packages/system/dashboard/images/token-proxy/go.mod @@ -3,6 +3,21 @@ module token-proxy go 1.24.0 require ( - github.com/golang-jwt/jwt/v5 v5.3.0 github.com/gorilla/securecookie v1.1.2 + github.com/lestrrat-go/httprc/v3 v3.0.2 + github.com/lestrrat-go/jwx/v3 v3.0.13 +) + +require ( + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect + github.com/goccy/go-json v0.10.3 // indirect + github.com/lestrrat-go/blackmagic v1.0.4 // indirect + github.com/lestrrat-go/dsig v1.0.0 // indirect + github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect + github.com/lestrrat-go/httpcc v1.0.1 // indirect + github.com/lestrrat-go/option/v2 v2.0.0 // indirect + github.com/segmentio/asm v1.2.1 // indirect + github.com/valyala/fastjson v1.6.7 // indirect + golang.org/x/crypto v0.46.0 // indirect + golang.org/x/sys v0.39.0 // indirect ) diff --git a/packages/system/dashboard/images/token-proxy/go.sum b/packages/system/dashboard/images/token-proxy/go.sum index 68e4e8cc..f9f2e7e5 100644 --- a/packages/system/dashboard/images/token-proxy/go.sum +++ b/packages/system/dashboard/images/token-proxy/go.sum @@ -1,6 +1,43 @@ -github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= -github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= +github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= +github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA= +github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= +github.com/lestrrat-go/dsig v1.0.0 h1:OE09s2r9Z81kxzJYRn07TFM9XA4akrUdoMwr0L8xj38= +github.com/lestrrat-go/dsig v1.0.0/go.mod h1:dEgoOYYEJvW6XGbLasr8TFcAxoWrKlbQvmJgCR0qkDo= +github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY= +github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU= +github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= +github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= +github.com/lestrrat-go/httprc/v3 v3.0.2 h1:7u4HUaD0NQbf2/n5+fyp+T10hNCsAnwKfqn4A4Baif0= +github.com/lestrrat-go/httprc/v3 v3.0.2/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0= +github.com/lestrrat-go/jwx/v3 v3.0.13 h1:AdHKiPIYeCSnOJtvdpipPg/0SuFh9rdkN+HF3O0VdSk= +github.com/lestrrat-go/jwx/v3 v3.0.13/go.mod h1:2m0PV1A9tM4b/jVLMx8rh6rBl7F6WGb3EG2hufN9OQU= +github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss= +github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpBM= +github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/packages/system/dashboard/images/token-proxy/main.go b/packages/system/dashboard/images/token-proxy/main.go index a4a177d1..35fc9b70 100644 --- a/packages/system/dashboard/images/token-proxy/main.go +++ b/packages/system/dashboard/images/token-proxy/main.go @@ -1,6 +1,9 @@ package main import ( + "context" + "crypto/tls" + "crypto/x509" "encoding/base64" "encoding/json" "flag" @@ -13,10 +16,13 @@ import ( "os" "path" "strings" + "sync" "time" - "github.com/golang-jwt/jwt/v5" "github.com/gorilla/securecookie" + "github.com/lestrrat-go/httprc/v3" + "github.com/lestrrat-go/jwx/v3/jwk" + "github.com/lestrrat-go/jwx/v3/jwt" ) /* ----------------------------- flags ------------------------------------ */ @@ -26,7 +32,9 @@ var ( cookieName, cookieSecretB64 string cookieSecure bool cookieRefresh time.Duration - tokenCheckURL string + jwksURL string + saTokenPath string + saCACertPath string ) func init() { @@ -38,7 +46,70 @@ func init() { flag.StringVar(&cookieSecretB64, "cookie-secret", "", "Base64-encoded cookie secret") flag.BoolVar(&cookieSecure, "cookie-secure", false, "Set Secure flag on cookie") flag.DurationVar(&cookieRefresh, "cookie-refresh", 0, "Cookie refresh interval (e.g. 1h)") - flag.StringVar(&tokenCheckURL, "token-check-url", "", "URL for external token validation") + flag.StringVar(&jwksURL, "jwks-url", "https://kubernetes.default.svc/openid/v1/jwks", "JWKS URL for token verification") + flag.StringVar(&saTokenPath, "sa-token-path", "/var/run/secrets/kubernetes.io/serviceaccount/token", "Path to service account token") + flag.StringVar(&saCACertPath, "sa-ca-cert-path", "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt", "Path to service account CA certificate") + + flag.Parse() + + // Initialize jwkCache + ctx := context.Background() + // Load CA certificate + caCert, err := os.ReadFile(saCACertPath) + if err != nil { + jwkCacheErr := fmt.Errorf("failed to read CA cert: %w", err) + panic(jwkCacheErr) + } + caCertPool := x509.NewCertPool() + if !caCertPool.AppendCertsFromPEM(caCert) { + jwkCacheErr := fmt.Errorf("failed to parse CA cert") + panic(jwkCacheErr) + } + + // Create transport with SA token injection + transport := &saTokenTransport{ + base: &http.Transport{ + TLSClientConfig: &tls.Config{ + RootCAs: caCertPool, + }, + }, + tokenPath: saTokenPath, + } + transport.startRefresh(ctx, 5*time.Minute) + + httpClient := &http.Client{ + Transport: transport, + Timeout: 10 * time.Second, + } + + // Create httprc client with custom HTTP client + httprcClient := httprc.NewClient( + httprc.WithHTTPClient(httpClient), + ) + + // Create JWK cache + jwkCache, err = jwk.NewCache(ctx, httprcClient) + if err != nil { + jwkCacheErr := fmt.Errorf("failed to create JWK cache: %w", err) + panic(jwkCacheErr) + } + + // Register the JWKS URL with refresh settings + if err := jwkCache.Register(ctx, jwksURL, + jwk.WithMinInterval(5*time.Minute), + jwk.WithMaxInterval(15*time.Minute), + ); err != nil { + jwkCacheErr := fmt.Errorf("failed to register JWKS URL: %w", err) + panic(jwkCacheErr) + } + + // Perform initial fetch to ensure the JWKS is available + if _, err := jwkCache.Refresh(ctx, jwksURL); err != nil { + jwkCacheErr := fmt.Errorf("failed to fetch initial JWKS: %w", err) + panic(jwkCacheErr) + } + + log.Printf("JWK cache initialized with JWKS URL: %s", jwksURL) } /* ----------------------------- templates -------------------------------- */ @@ -117,42 +188,94 @@ var loginTmpl = template.Must(template.New("login").Parse(` `)) -/* ----------------------------- helpers ---------------------------------- */ +/* ----------------------------- JWK cache -------------------------------- */ -func decodeJWT(raw string) jwt.MapClaims { - if raw == "" { - return jwt.MapClaims{} - } - tkn, _, err := new(jwt.Parser).ParseUnverified(raw, jwt.MapClaims{}) - if err != nil || tkn == nil { - return jwt.MapClaims{} - } - if c, ok := tkn.Claims.(jwt.MapClaims); ok { - return c - } - return jwt.MapClaims{} +var ( + jwkCache *jwk.Cache +) + +// saTokenTransport adds the service account token to requests and refreshes it periodically. +type saTokenTransport struct { + base http.RoundTripper + tokenPath string + mu sync.RWMutex + token string } -func externalTokenCheck(raw string) error { - if tokenCheckURL == "" { +func (t *saTokenTransport) RoundTrip(req *http.Request) (*http.Response, error) { + t.mu.RLock() + token := t.token + t.mu.RUnlock() + + if token != "" { + req = req.Clone(req.Context()) + req.Header.Set("Authorization", "Bearer "+token) + } + return t.base.RoundTrip(req) +} + +func (t *saTokenTransport) refreshToken() { + data, err := os.ReadFile(t.tokenPath) + if err != nil { + log.Printf("warning: failed to read SA token: %v", err) + return + } + t.mu.Lock() + t.token = string(data) + t.mu.Unlock() +} + +func (t *saTokenTransport) startRefresh(ctx context.Context, interval time.Duration) { + t.refreshToken() + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + t.refreshToken() + } + } + }() +} + +/* ----------------------------- helpers ---------------------------------- */ + +// verifyAndParseJWT verifies the token signature and returns the parsed token. +func verifyAndParseJWT(ctx context.Context, raw string) (jwt.Token, error) { + if raw == "" { + return nil, fmt.Errorf("empty token") + } + + keySet, err := jwkCache.Lookup(ctx, jwksURL) + if err != nil { + return nil, fmt.Errorf("failed to get JWKS: %w", err) + } + + token, err := jwt.Parse([]byte(raw), jwt.WithKeySet(keySet)) + if err != nil { + return nil, fmt.Errorf("failed to verify token: %w", err) + } + + return token, nil +} + +// getClaim extracts a claim value from a verified token. +func getClaim(token jwt.Token, key string) any { + if token == nil { return nil } - req, _ := http.NewRequest(http.MethodGet, tokenCheckURL, nil) - req.Header.Set("Authorization", "Bearer "+raw) - cli := &http.Client{Timeout: 5 * time.Second} - resp, err := cli.Do(req) - if err != nil { - return err + var val any + if err := token.Get(key, &val); err != nil { + return nil } - resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("status %d", resp.StatusCode) - } - return nil + return val } func encodeSession(sc *securecookie.SecureCookie, token string, exp, issued int64) (string, error) { - v := map[string]interface{}{ + v := map[string]any{ "access_token": token, "expires": exp, "issued": issued, @@ -166,7 +289,6 @@ func encodeSession(sc *securecookie.SecureCookie, token string, exp, issued int6 /* ----------------------------- main ------------------------------------- */ func main() { - flag.Parse() if upstream == "" { log.Fatal("--upstream is required") } @@ -214,7 +336,11 @@ func main() { }{Action: signIn, Err: "Token required"}) return } - if err := externalTokenCheck(token); err != nil { + + // Verify token signature using JWKS + verifiedToken, err := verifyAndParseJWT(r.Context(), token) + if err != nil { + log.Printf("token verification failed: %v", err) _ = loginTmpl.Execute(w, struct { Action string Err string @@ -223,9 +349,8 @@ func main() { } exp := time.Now().Add(24 * time.Hour).Unix() - claims := decodeJWT(token) - if v, ok := claims["exp"].(float64); ok { - exp = int64(v) + if expTime, ok := verifiedToken.Expiration(); ok && !expTime.IsZero() { + exp = expTime.Unix() } session, _ := encodeSession(sc, token, exp, time.Now().Unix()) http.SetCookie(w, &http.Cookie{ @@ -264,7 +389,7 @@ func main() { return } var token string - var sess map[string]interface{} + var sess map[string]any if sc != nil { if err := sc.Decode(cookieName, c.Value, &sess); err != nil { http.Error(w, "unauthorized", http.StatusUnauthorized) @@ -273,19 +398,25 @@ func main() { token, _ = sess["access_token"].(string) } else { token = c.Value - sess = map[string]interface{}{ + sess = map[string]any{ "expires": time.Now().Add(24 * time.Hour).Unix(), "issued": time.Now().Unix(), } } - claims := decodeJWT(token) - out := map[string]interface{}{ + // Re-verify the token to ensure it's still valid + verifiedToken, err := verifyAndParseJWT(r.Context(), token) + if err != nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + out := map[string]any{ "token": token, - "sub": claims["sub"], - "email": claims["email"], - "preferred_username": claims["preferred_username"], - "groups": claims["groups"], + "sub": getClaim(verifiedToken, "sub"), + "email": getClaim(verifiedToken, "email"), + "preferred_username": getClaim(verifiedToken, "preferred_username"), + "groups": getClaim(verifiedToken, "groups"), "expires": sess["expires"], "issued": sess["issued"], "cookie_refresh_enable": cookieRefresh > 0, @@ -303,7 +434,7 @@ func main() { return } var token string - var sess map[string]interface{} + var sess map[string]any if sc != nil { if err := sc.Decode(cookieName, c.Value, &sess); err != nil { http.Redirect(w, r, signIn, http.StatusFound) @@ -312,7 +443,7 @@ func main() { token, _ = sess["access_token"].(string) } else { token = c.Value - sess = map[string]interface{}{ + sess = map[string]any{ "expires": time.Now().Add(24 * time.Hour).Unix(), "issued": time.Now().Unix(), } diff --git a/packages/system/dashboard/templates/gatekeeper-sa.yaml b/packages/system/dashboard/templates/gatekeeper-sa.yaml index 8f5cabcd..f9043c99 100644 --- a/packages/system/dashboard/templates/gatekeeper-sa.yaml +++ b/packages/system/dashboard/templates/gatekeeper-sa.yaml @@ -2,3 +2,30 @@ apiVersion: v1 kind: ServiceAccount metadata: name: incloud-web-gatekeeper +{{- $oidcEnabled := index .Values._cluster "oidc-enabled" }} +{{- if ne $oidcEnabled "true" }} +--- +# ClusterRole to allow token-proxy to fetch JWKS for JWT verification +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: incloud-web-gatekeeper-jwks +rules: +- nonResourceURLs: + - /openid/v1/jwks + verbs: + - get +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: incloud-web-gatekeeper-jwks +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: incloud-web-gatekeeper-jwks +subjects: +- kind: ServiceAccount + name: incloud-web-gatekeeper + namespace: {{ .Release.Namespace }} +{{- end }} diff --git a/packages/system/dashboard/templates/gatekeeper.yaml b/packages/system/dashboard/templates/gatekeeper.yaml index 984ec03e..d87c2deb 100644 --- a/packages/system/dashboard/templates/gatekeeper.yaml +++ b/packages/system/dashboard/templates/gatekeeper.yaml @@ -89,7 +89,6 @@ spec: - --cookie-name=kc-access - --cookie-secure=true - --cookie-secret=$(TOKEN_PROXY_COOKIE_SECRET) - - --token-check-url=http://incloud-web-nginx.{{ .Release.Namespace }}.svc:8080/api/clusters/default/k8s/apis/core.cozystack.io/v1alpha1/tenantnamespaces env: - name: TOKEN_PROXY_COOKIE_SECRET valueFrom: