[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 <lllamnyp@gmail.com>
This commit is contained in:
Timofei Larkin 2026-02-04 07:50:10 +03:00
parent 4fa7eed058
commit 23e399bd9a
5 changed files with 258 additions and 49 deletions

View file

@ -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
)

View file

@ -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=

View file

@ -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(`
</body>
</html>`))
/* ----------------------------- 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(),
}

View file

@ -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 }}

View file

@ -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: