@@ -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
8993const 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+
14681615func (c * HeadlampConfig ) getClusters () []Cluster {
14691616 clusters := []Cluster {}
14701617
0 commit comments