Skip to content

Commit 9a6761c

Browse files
committed
backend: Add /clusters/clusterName/me endpoint
1 parent d6e056e commit 9a6761c

File tree

6 files changed

+201
-0
lines changed

6 files changed

+201
-0
lines changed

backend/cmd/headlamp.go

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import (
4343
"github.com/google/uuid"
4444
"github.com/gorilla/handlers"
4545
"github.com/gorilla/mux"
46+
"github.com/jmespath/go-jmespath"
4647
auth "github.com/kubernetes-sigs/headlamp/backend/pkg/auth"
4748
"github.com/kubernetes-sigs/headlamp/backend/pkg/cache"
4849
cfg "github.com/kubernetes-sigs/headlamp/backend/pkg/config"
@@ -84,6 +85,9 @@ type HeadlampConfig struct {
8485
telemetryConfig cfg.Config
8586
oidcScopes []string
8687
telemetryHandler *telemetry.RequestHandler
88+
meUsernamePaths string
89+
meEmailPaths string
90+
meGroupsPaths string
8791
}
8892

8993
const DrainNodeCacheTTL = 20 // seconds
@@ -477,6 +481,9 @@ func createHeadlampHandler(config *HeadlampConfig) http.Handler {
477481
portforward.GetPortForwardByID(config.cache, w, r)
478482
}).Methods("GET")
479483

484+
// Expose user info so the frontend can show the current user in the top bar using the per-cluster auth cookie.
485+
r.HandleFunc("/clusters/{clusterName}/me", config.handleMe).Methods("GET")
486+
480487
config.handleClusterRequests(r)
481488

482489
r.HandleFunc("/externalproxy", func(w http.ResponseWriter, r *http.Request) {
@@ -1465,6 +1472,146 @@ func (c *HeadlampConfig) handleClusterRequests(router *mux.Router) {
14651472
handleClusterAPI(c, router)
14661473
}
14671474

1475+
// handleMe returns user info extracted from the per-cluster OIDC cookie so the UI can display the current user.
1476+
// Claim discovery is controlled via the Headlamp configuration (HEADLAMP_CONFIG_ME_* env / --me-*-path flags),
1477+
// which accept comma-separated JMESPath expressions to support different identity providers and nested claim layouts.
1478+
func (c *HeadlampConfig) handleMe(w http.ResponseWriter, r *http.Request) {
1479+
clusterName := mux.Vars(r)["clusterName"]
1480+
if clusterName == "" {
1481+
http.Error(w, "cluster not specified", http.StatusBadRequest)
1482+
return
1483+
}
1484+
1485+
token, err := auth.GetTokenFromCookie(r, clusterName)
1486+
if err != nil || token == "" {
1487+
http.Error(w, "unauthorized", http.StatusUnauthorized)
1488+
return
1489+
}
1490+
1491+
parts := strings.SplitN(token, ".", 3)
1492+
if len(parts) != 3 || parts[1] == "" {
1493+
http.Error(w, "invalid token", http.StatusUnauthorized)
1494+
return
1495+
}
1496+
1497+
claims, err := auth.DecodeBase64JSON(parts[1])
1498+
if err != nil {
1499+
http.Error(w, "invalid token claims", http.StatusUnauthorized)
1500+
return
1501+
}
1502+
1503+
usernamePaths := c.meUsernamePaths
1504+
if strings.TrimSpace(usernamePaths) == "" {
1505+
usernamePaths = cfg.DefaultMeUsernamePath
1506+
}
1507+
1508+
emailPaths := c.meEmailPaths
1509+
if strings.TrimSpace(emailPaths) == "" {
1510+
emailPaths = cfg.DefaultMeEmailPath
1511+
}
1512+
1513+
groupsPaths := c.meGroupsPaths
1514+
if strings.TrimSpace(groupsPaths) == "" {
1515+
groupsPaths = cfg.DefaultMeGroupsPath
1516+
}
1517+
1518+
username := stringValueFromJMESPaths(claims, usernamePaths)
1519+
email := stringValueFromJMESPaths(claims, emailPaths)
1520+
groups := stringSliceFromJMESPaths(claims, groupsPaths)
1521+
1522+
// Prevent caching to avoid 304 Not Modified responses from intermediaries/dev server
1523+
w.Header().Set("Content-Type", "application/json")
1524+
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, private")
1525+
w.Header().Set("Pragma", "no-cache")
1526+
w.Header().Set("Expires", "0")
1527+
w.Header().Del("ETag")
1528+
_ = json.NewEncoder(w).Encode(map[string]interface{}{
1529+
"username": username,
1530+
"email": email,
1531+
"groups": groups,
1532+
})
1533+
}
1534+
1535+
// stringValueFromJMESPaths tries multiple comma-separated JMESPath expressions and returns the first string result.
1536+
func stringValueFromJMESPaths(payload map[string]interface{}, pathCSV string) string {
1537+
for _, p := range strings.Split(pathCSV, ",") {
1538+
p = strings.TrimSpace(p)
1539+
if p == "" {
1540+
continue
1541+
}
1542+
1543+
res, err := jmespath.Search(p, payload)
1544+
if err != nil || res == nil {
1545+
continue
1546+
}
1547+
1548+
switch v := res.(type) {
1549+
case string:
1550+
if v != "" {
1551+
return v
1552+
}
1553+
case fmt.Stringer:
1554+
vs := v.String()
1555+
if vs != "" {
1556+
return vs
1557+
}
1558+
case float64:
1559+
return fmt.Sprintf("%v", v)
1560+
case int64:
1561+
return fmt.Sprintf("%v", v)
1562+
case map[string]interface{}:
1563+
b, _ := json.Marshal(v)
1564+
if len(b) > 0 {
1565+
return string(b)
1566+
}
1567+
}
1568+
}
1569+
1570+
return ""
1571+
}
1572+
1573+
// stringSliceFromJMESPaths tries multiple comma-separated JMESPath expressions and returns the first []string result.
1574+
func stringSliceFromJMESPaths(payload map[string]interface{}, pathCSV string) []string {
1575+
for _, p := range strings.Split(pathCSV, ",") {
1576+
p = strings.TrimSpace(p)
1577+
if p == "" {
1578+
continue
1579+
}
1580+
1581+
res, err := jmespath.Search(p, payload)
1582+
if err != nil || res == nil {
1583+
continue
1584+
}
1585+
1586+
switch v := res.(type) {
1587+
case []interface{}:
1588+
out := make([]string, 0, len(v))
1589+
1590+
for _, it := range v {
1591+
switch s := it.(type) {
1592+
case string:
1593+
out = append(out, s)
1594+
case float64:
1595+
out = append(out, fmt.Sprintf("%v", s))
1596+
case int64:
1597+
out = append(out, fmt.Sprintf("%v", s))
1598+
default:
1599+
b, _ := json.Marshal(s)
1600+
if len(b) > 0 {
1601+
out = append(out, string(b))
1602+
}
1603+
}
1604+
}
1605+
1606+
return out
1607+
case []string:
1608+
return v
1609+
}
1610+
}
1611+
1612+
return []string{}
1613+
}
1614+
14681615
func (c *HeadlampConfig) getClusters() []Cluster {
14691616
clusters := []Cluster{}
14701617

backend/cmd/server.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,9 @@ func createHeadlampConfig(conf *config.Config) *HeadlampConfig {
107107
oidcScopes: strings.Split(conf.OidcScopes, ","),
108108
oidcSkipTLSVerify: conf.OidcSkipTLSVerify,
109109
oidcUseAccessToken: conf.OidcUseAccessToken,
110+
meUsernamePaths: conf.MeUsernamePath,
111+
meEmailPaths: conf.MeEmailPath,
112+
meGroupsPaths: conf.MeGroupsPath,
110113
cache: cache,
111114
multiplexer: multiplexer,
112115
telemetryConfig: buildTelemetryConfig(conf),

backend/go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ require (
3030
require (
3131
github.com/coreos/go-oidc/v3 v3.11.0
3232
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
33+
github.com/jmespath/go-jmespath v0.4.0
3334
github.com/prometheus/client_golang v1.22.0
3435
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0
3536
go.opentelemetry.io/otel v1.35.0

backend/go.sum

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,7 @@ github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI
290290
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
291291
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
292292
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
293+
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
293294
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
294295
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
295296
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=

backend/pkg/config/config.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ import (
2020

2121
const defaultPort = 4466
2222

23+
const (
24+
DefaultMeUsernamePath = "preferred_username,username,name"
25+
DefaultMeEmailPath = "email"
26+
DefaultMeGroupsPath = "groups,realm_access.roles"
27+
)
28+
2329
type Config struct {
2430
InCluster bool `koanf:"in-cluster"`
2531
DevMode bool `koanf:"dev"`
@@ -46,6 +52,9 @@ type Config struct {
4652
OidcUseAccessToken bool `koanf:"oidc-use-access-token"`
4753
OidcSkipTLSVerify bool `koanf:"oidc-skip-tls-verify"`
4854
OidcCAFile string `koanf:"oidc-ca-file"`
55+
MeUsernamePath string `koanf:"me-username-path"`
56+
MeEmailPath string `koanf:"me-email-path"`
57+
MeGroupsPath string `koanf:"me-groups-path"`
4958
// telemetry configs
5059
ServiceName string `koanf:"service-name"`
5160
ServiceVersion *string `koanf:"service-version"`
@@ -218,6 +227,21 @@ func setKubeConfigPath(config *Config) {
218227
}
219228
}
220229

230+
// setMeDefaults ensures the /clusters/{clusterName}/me claim paths fall back to defaults when unset.
231+
func setMeDefaults(config *Config) {
232+
if strings.TrimSpace(config.MeUsernamePath) == "" {
233+
config.MeUsernamePath = DefaultMeUsernamePath
234+
}
235+
236+
if strings.TrimSpace(config.MeEmailPath) == "" {
237+
config.MeEmailPath = DefaultMeEmailPath
238+
}
239+
240+
if strings.TrimSpace(config.MeGroupsPath) == "" {
241+
config.MeGroupsPath = DefaultMeGroupsPath
242+
}
243+
}
244+
221245
// Parse Loads the config from flags and env.
222246
// env vars should start with HEADLAMP_CONFIG_ and use _ as separator
223247
// If a value is set both in flags and env then flag takes priority.
@@ -266,6 +290,7 @@ func Parse(args []string) (*Config, error) {
266290
// 7. Post-process: patch plugin flag and kubeconfig path.
267291
patchWatchPluginsChanges(&config, explicitFlags)
268292
setKubeConfigPath(&config)
293+
setMeDefaults(&config)
269294

270295
// 8. Validate parsed config.
271296
if err := config.Validate(); err != nil {
@@ -347,6 +372,12 @@ func flagset() *flag.FlagSet {
347372
f.Bool("oidc-skip-tls-verify", false, "Skip TLS verification for OIDC")
348373
f.String("oidc-ca-file", "", "CA file for OIDC")
349374
f.Bool("oidc-use-access-token", false, "Setup oidc to pass through the access_token instead of the default id_token")
375+
f.String("me-username-path", DefaultMeUsernamePath,
376+
"Comma separated JMESPath expressions used to read username from the JWT payload")
377+
f.String("me-email-path", DefaultMeEmailPath,
378+
"Comma separated JMESPath expressions used to read email from the JWT payload")
379+
f.String("me-groups-path", DefaultMeGroupsPath,
380+
"Comma separated JMESPath expressions used to read groups from the JWT payload")
350381
// Telemetry flags.
351382
f.String("service-name", "headlamp", "Service name for telemetry")
352383
f.String("service-version", "0.30.0", "Service version for telemetry")

backend/pkg/config/config_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,17 @@ func TestParseBasic(t *testing.T) {
4141
assert.Equal(t, "", conf.ListenAddr)
4242
assert.Equal(t, uint(4466), conf.Port)
4343
assert.Equal(t, "profile,email", conf.OidcScopes)
44+
assert.Equal(t, config.DefaultMeUsernamePath, conf.MeUsernamePath)
45+
assert.Equal(t, config.DefaultMeEmailPath, conf.MeEmailPath)
46+
assert.Equal(t, config.DefaultMeGroupsPath, conf.MeGroupsPath)
4447
},
4548
},
4649
{
4750
name: "with_args",
4851
args: []string{"go run ./cmd", "--port=3456"},
4952
verify: func(t *testing.T, conf *config.Config) {
5053
assert.Equal(t, uint(3456), conf.Port)
54+
assert.Equal(t, config.DefaultMeUsernamePath, conf.MeUsernamePath)
5155
},
5256
},
5357
}
@@ -90,6 +94,20 @@ var ParseWithEnvTests = []struct {
9094
assert.Equal(t, uint(9876), conf.Port)
9195
},
9296
},
97+
{
98+
name: "me_paths",
99+
args: []string{"go run ./cmd"},
100+
env: map[string]string{
101+
"HEADLAMP_CONFIG_ME_USERNAME_PATH": "user.name",
102+
"HEADLAMP_CONFIG_ME_EMAIL_PATH": "user.email",
103+
"HEADLAMP_CONFIG_ME_GROUPS_PATH": "user.groups",
104+
},
105+
verify: func(t *testing.T, conf *config.Config) {
106+
assert.Equal(t, "user.name", conf.MeUsernamePath)
107+
assert.Equal(t, "user.email", conf.MeEmailPath)
108+
assert.Equal(t, "user.groups", conf.MeGroupsPath)
109+
},
110+
},
93111
{
94112
name: "kubeconfig_from_default_env",
95113
args: []string{"go run ./cmd"},

0 commit comments

Comments
 (0)