Skip to content

Commit 23e399b

Browse files
committed
[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 <[email protected]>
1 parent 4fa7eed commit 23e399b

5 files changed

Lines changed: 253 additions & 44 deletions

File tree

‎packages/system/dashboard/images/token-proxy/go.mod‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,21 @@ module token-proxy
33
go 1.24.0
44

55
require (
6-
github.com/golang-jwt/jwt/v5 v5.3.0
76
github.com/gorilla/securecookie v1.1.2
7+
github.com/lestrrat-go/httprc/v3 v3.0.2
8+
github.com/lestrrat-go/jwx/v3 v3.0.13
9+
)
10+
11+
require (
12+
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
13+
github.com/goccy/go-json v0.10.3 // indirect
14+
github.com/lestrrat-go/blackmagic v1.0.4 // indirect
15+
github.com/lestrrat-go/dsig v1.0.0 // indirect
16+
github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect
17+
github.com/lestrrat-go/httpcc v1.0.1 // indirect
18+
github.com/lestrrat-go/option/v2 v2.0.0 // indirect
19+
github.com/segmentio/asm v1.2.1 // indirect
20+
github.com/valyala/fastjson v1.6.7 // indirect
21+
golang.org/x/crypto v0.46.0 // indirect
22+
golang.org/x/sys v0.39.0 // indirect
823
)
Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,43 @@
1-
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
2-
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
1+
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
2+
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
3+
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
4+
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
5+
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
6+
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
7+
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
38
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
49
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
510
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
611
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
12+
github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA=
13+
github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
14+
github.com/lestrrat-go/dsig v1.0.0 h1:OE09s2r9Z81kxzJYRn07TFM9XA4akrUdoMwr0L8xj38=
15+
github.com/lestrrat-go/dsig v1.0.0/go.mod h1:dEgoOYYEJvW6XGbLasr8TFcAxoWrKlbQvmJgCR0qkDo=
16+
github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY=
17+
github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU=
18+
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
19+
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
20+
github.com/lestrrat-go/httprc/v3 v3.0.2 h1:7u4HUaD0NQbf2/n5+fyp+T10hNCsAnwKfqn4A4Baif0=
21+
github.com/lestrrat-go/httprc/v3 v3.0.2/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0=
22+
github.com/lestrrat-go/jwx/v3 v3.0.13 h1:AdHKiPIYeCSnOJtvdpipPg/0SuFh9rdkN+HF3O0VdSk=
23+
github.com/lestrrat-go/jwx/v3 v3.0.13/go.mod h1:2m0PV1A9tM4b/jVLMx8rh6rBl7F6WGb3EG2hufN9OQU=
24+
github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss=
25+
github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg=
26+
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
27+
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
28+
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
29+
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
30+
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
31+
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
32+
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
33+
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
34+
github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpBM=
35+
github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
36+
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
37+
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
38+
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
39+
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
40+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
41+
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
42+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
43+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

‎packages/system/dashboard/images/token-proxy/main.go‎

Lines changed: 171 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package main
22

33
import (
4+
"context"
5+
"crypto/tls"
6+
"crypto/x509"
47
"encoding/base64"
58
"encoding/json"
69
"flag"
@@ -13,10 +16,13 @@ import (
1316
"os"
1417
"path"
1518
"strings"
19+
"sync"
1620
"time"
1721

18-
"github.com/golang-jwt/jwt/v5"
1922
"github.com/gorilla/securecookie"
23+
"github.com/lestrrat-go/httprc/v3"
24+
"github.com/lestrrat-go/jwx/v3/jwk"
25+
"github.com/lestrrat-go/jwx/v3/jwt"
2026
)
2127

2228
/* ----------------------------- flags ------------------------------------ */
@@ -26,7 +32,9 @@ var (
2632
cookieName, cookieSecretB64 string
2733
cookieSecure bool
2834
cookieRefresh time.Duration
29-
tokenCheckURL string
35+
jwksURL string
36+
saTokenPath string
37+
saCACertPath string
3038
)
3139

3240
func init() {
@@ -38,7 +46,70 @@ func init() {
3846
flag.StringVar(&cookieSecretB64, "cookie-secret", "", "Base64-encoded cookie secret")
3947
flag.BoolVar(&cookieSecure, "cookie-secure", false, "Set Secure flag on cookie")
4048
flag.DurationVar(&cookieRefresh, "cookie-refresh", 0, "Cookie refresh interval (e.g. 1h)")
41-
flag.StringVar(&tokenCheckURL, "token-check-url", "", "URL for external token validation")
49+
flag.StringVar(&jwksURL, "jwks-url", "https://kubernetes.default.svc/openid/v1/jwks", "JWKS URL for token verification")
50+
flag.StringVar(&saTokenPath, "sa-token-path", "/var/run/secrets/kubernetes.io/serviceaccount/token", "Path to service account token")
51+
flag.StringVar(&saCACertPath, "sa-ca-cert-path", "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt", "Path to service account CA certificate")
52+
53+
flag.Parse()
54+
55+
// Initialize jwkCache
56+
ctx := context.Background()
57+
// Load CA certificate
58+
caCert, err := os.ReadFile(saCACertPath)
59+
if err != nil {
60+
jwkCacheErr := fmt.Errorf("failed to read CA cert: %w", err)
61+
panic(jwkCacheErr)
62+
}
63+
caCertPool := x509.NewCertPool()
64+
if !caCertPool.AppendCertsFromPEM(caCert) {
65+
jwkCacheErr := fmt.Errorf("failed to parse CA cert")
66+
panic(jwkCacheErr)
67+
}
68+
69+
// Create transport with SA token injection
70+
transport := &saTokenTransport{
71+
base: &http.Transport{
72+
TLSClientConfig: &tls.Config{
73+
RootCAs: caCertPool,
74+
},
75+
},
76+
tokenPath: saTokenPath,
77+
}
78+
transport.startRefresh(ctx, 5*time.Minute)
79+
80+
httpClient := &http.Client{
81+
Transport: transport,
82+
Timeout: 10 * time.Second,
83+
}
84+
85+
// Create httprc client with custom HTTP client
86+
httprcClient := httprc.NewClient(
87+
httprc.WithHTTPClient(httpClient),
88+
)
89+
90+
// Create JWK cache
91+
jwkCache, err = jwk.NewCache(ctx, httprcClient)
92+
if err != nil {
93+
jwkCacheErr := fmt.Errorf("failed to create JWK cache: %w", err)
94+
panic(jwkCacheErr)
95+
}
96+
97+
// Register the JWKS URL with refresh settings
98+
if err := jwkCache.Register(ctx, jwksURL,
99+
jwk.WithMinInterval(5*time.Minute),
100+
jwk.WithMaxInterval(15*time.Minute),
101+
); err != nil {
102+
jwkCacheErr := fmt.Errorf("failed to register JWKS URL: %w", err)
103+
panic(jwkCacheErr)
104+
}
105+
106+
// Perform initial fetch to ensure the JWKS is available
107+
if _, err := jwkCache.Refresh(ctx, jwksURL); err != nil {
108+
jwkCacheErr := fmt.Errorf("failed to fetch initial JWKS: %w", err)
109+
panic(jwkCacheErr)
110+
}
111+
112+
log.Printf("JWK cache initialized with JWKS URL: %s", jwksURL)
42113
}
43114

44115
/* ----------------------------- templates -------------------------------- */
@@ -117,42 +188,94 @@ var loginTmpl = template.Must(template.New("login").Parse(`
117188
</body>
118189
</html>`))
119190

191+
/* ----------------------------- JWK cache -------------------------------- */
192+
193+
var (
194+
jwkCache *jwk.Cache
195+
)
196+
197+
// saTokenTransport adds the service account token to requests and refreshes it periodically.
198+
type saTokenTransport struct {
199+
base http.RoundTripper
200+
tokenPath string
201+
mu sync.RWMutex
202+
token string
203+
}
204+
205+
func (t *saTokenTransport) RoundTrip(req *http.Request) (*http.Response, error) {
206+
t.mu.RLock()
207+
token := t.token
208+
t.mu.RUnlock()
209+
210+
if token != "" {
211+
req = req.Clone(req.Context())
212+
req.Header.Set("Authorization", "Bearer "+token)
213+
}
214+
return t.base.RoundTrip(req)
215+
}
216+
217+
func (t *saTokenTransport) refreshToken() {
218+
data, err := os.ReadFile(t.tokenPath)
219+
if err != nil {
220+
log.Printf("warning: failed to read SA token: %v", err)
221+
return
222+
}
223+
t.mu.Lock()
224+
t.token = string(data)
225+
t.mu.Unlock()
226+
}
227+
228+
func (t *saTokenTransport) startRefresh(ctx context.Context, interval time.Duration) {
229+
t.refreshToken()
230+
go func() {
231+
ticker := time.NewTicker(interval)
232+
defer ticker.Stop()
233+
for {
234+
select {
235+
case <-ctx.Done():
236+
return
237+
case <-ticker.C:
238+
t.refreshToken()
239+
}
240+
}
241+
}()
242+
}
243+
120244
/* ----------------------------- helpers ---------------------------------- */
121245

122-
func decodeJWT(raw string) jwt.MapClaims {
246+
// verifyAndParseJWT verifies the token signature and returns the parsed token.
247+
func verifyAndParseJWT(ctx context.Context, raw string) (jwt.Token, error) {
123248
if raw == "" {
124-
return jwt.MapClaims{}
249+
return nil, fmt.Errorf("empty token")
125250
}
126-
tkn, _, err := new(jwt.Parser).ParseUnverified(raw, jwt.MapClaims{})
127-
if err != nil || tkn == nil {
128-
return jwt.MapClaims{}
251+
252+
keySet, err := jwkCache.Lookup(ctx, jwksURL)
253+
if err != nil {
254+
return nil, fmt.Errorf("failed to get JWKS: %w", err)
129255
}
130-
if c, ok := tkn.Claims.(jwt.MapClaims); ok {
131-
return c
256+
257+
token, err := jwt.Parse([]byte(raw), jwt.WithKeySet(keySet))
258+
if err != nil {
259+
return nil, fmt.Errorf("failed to verify token: %w", err)
132260
}
133-
return jwt.MapClaims{}
261+
262+
return token, nil
134263
}
135264

136-
func externalTokenCheck(raw string) error {
137-
if tokenCheckURL == "" {
265+
// getClaim extracts a claim value from a verified token.
266+
func getClaim(token jwt.Token, key string) any {
267+
if token == nil {
138268
return nil
139269
}
140-
req, _ := http.NewRequest(http.MethodGet, tokenCheckURL, nil)
141-
req.Header.Set("Authorization", "Bearer "+raw)
142-
cli := &http.Client{Timeout: 5 * time.Second}
143-
resp, err := cli.Do(req)
144-
if err != nil {
145-
return err
146-
}
147-
resp.Body.Close()
148-
if resp.StatusCode != http.StatusOK {
149-
return fmt.Errorf("status %d", resp.StatusCode)
270+
var val any
271+
if err := token.Get(key, &val); err != nil {
272+
return nil
150273
}
151-
return nil
274+
return val
152275
}
153276

154277
func encodeSession(sc *securecookie.SecureCookie, token string, exp, issued int64) (string, error) {
155-
v := map[string]interface{}{
278+
v := map[string]any{
156279
"access_token": token,
157280
"expires": exp,
158281
"issued": issued,
@@ -166,7 +289,6 @@ func encodeSession(sc *securecookie.SecureCookie, token string, exp, issued int6
166289
/* ----------------------------- main ------------------------------------- */
167290

168291
func main() {
169-
flag.Parse()
170292
if upstream == "" {
171293
log.Fatal("--upstream is required")
172294
}
@@ -214,7 +336,11 @@ func main() {
214336
}{Action: signIn, Err: "Token required"})
215337
return
216338
}
217-
if err := externalTokenCheck(token); err != nil {
339+
340+
// Verify token signature using JWKS
341+
verifiedToken, err := verifyAndParseJWT(r.Context(), token)
342+
if err != nil {
343+
log.Printf("token verification failed: %v", err)
218344
_ = loginTmpl.Execute(w, struct {
219345
Action string
220346
Err string
@@ -223,9 +349,8 @@ func main() {
223349
}
224350

225351
exp := time.Now().Add(24 * time.Hour).Unix()
226-
claims := decodeJWT(token)
227-
if v, ok := claims["exp"].(float64); ok {
228-
exp = int64(v)
352+
if expTime, ok := verifiedToken.Expiration(); ok && !expTime.IsZero() {
353+
exp = expTime.Unix()
229354
}
230355
session, _ := encodeSession(sc, token, exp, time.Now().Unix())
231356
http.SetCookie(w, &http.Cookie{
@@ -264,7 +389,7 @@ func main() {
264389
return
265390
}
266391
var token string
267-
var sess map[string]interface{}
392+
var sess map[string]any
268393
if sc != nil {
269394
if err := sc.Decode(cookieName, c.Value, &sess); err != nil {
270395
http.Error(w, "unauthorized", http.StatusUnauthorized)
@@ -273,19 +398,25 @@ func main() {
273398
token, _ = sess["access_token"].(string)
274399
} else {
275400
token = c.Value
276-
sess = map[string]interface{}{
401+
sess = map[string]any{
277402
"expires": time.Now().Add(24 * time.Hour).Unix(),
278403
"issued": time.Now().Unix(),
279404
}
280405
}
281-
claims := decodeJWT(token)
282406

283-
out := map[string]interface{}{
407+
// Re-verify the token to ensure it's still valid
408+
verifiedToken, err := verifyAndParseJWT(r.Context(), token)
409+
if err != nil {
410+
http.Error(w, "unauthorized", http.StatusUnauthorized)
411+
return
412+
}
413+
414+
out := map[string]any{
284415
"token": token,
285-
"sub": claims["sub"],
286-
"email": claims["email"],
287-
"preferred_username": claims["preferred_username"],
288-
"groups": claims["groups"],
416+
"sub": getClaim(verifiedToken, "sub"),
417+
"email": getClaim(verifiedToken, "email"),
418+
"preferred_username": getClaim(verifiedToken, "preferred_username"),
419+
"groups": getClaim(verifiedToken, "groups"),
289420
"expires": sess["expires"],
290421
"issued": sess["issued"],
291422
"cookie_refresh_enable": cookieRefresh > 0,
@@ -303,7 +434,7 @@ func main() {
303434
return
304435
}
305436
var token string
306-
var sess map[string]interface{}
437+
var sess map[string]any
307438
if sc != nil {
308439
if err := sc.Decode(cookieName, c.Value, &sess); err != nil {
309440
http.Redirect(w, r, signIn, http.StatusFound)
@@ -312,7 +443,7 @@ func main() {
312443
token, _ = sess["access_token"].(string)
313444
} else {
314445
token = c.Value
315-
sess = map[string]interface{}{
446+
sess = map[string]any{
316447
"expires": time.Now().Add(24 * time.Hour).Unix(),
317448
"issued": time.Now().Unix(),
318449
}

0 commit comments

Comments
 (0)