Skip to content

Commit 72ef722

Browse files
itpickclaude
andcommitted
Add GCP OAuth authentication support for GKE clusters
This implementation adds GCP OAuth 2.0 authentication to Headlamp, replacing the deprecated Identity Service for GKE. Users can authenticate with their Google Cloud account, and the authentication tokens are used to access Kubernetes resources with proper RBAC. Backend changes: - New GCP authenticator package with RFC 7636-compliant PKCE support - OAuth HTTP handlers for login, callback, and token refresh - Configuration via environment variables - Token caching and automatic refresh mechanisms - Input validation to prevent injection attacks Frontend changes: - GCPLoginButton component for Google sign-in - GKE cluster detection based on server URL patterns - Integration into existing authentication chooser UI - Comprehensive test coverage Documentation: - Complete setup guide for GKE deployments - RBAC configuration examples - Troubleshooting guide 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 0cfeae0 commit 72ef722

File tree

32 files changed

+2773
-39
lines changed

32 files changed

+2773
-39
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,4 @@ node_modules
1414
plugins/headlamp-plugin/headlamp-myfancy/
1515
plugins/examples/*/dist/
1616
.DS_Store
17+
scripts/backend/headlamp

backend/cmd/headlamp.go

Lines changed: 67 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import (
4747
auth "github.com/kubernetes-sigs/headlamp/backend/pkg/auth"
4848
"github.com/kubernetes-sigs/headlamp/backend/pkg/cache"
4949
cfg "github.com/kubernetes-sigs/headlamp/backend/pkg/config"
50+
"github.com/kubernetes-sigs/headlamp/backend/pkg/gcp"
5051
"github.com/kubernetes-sigs/headlamp/backend/pkg/serviceproxy"
5152

5253
headlampcfg "github.com/kubernetes-sigs/headlamp/backend/pkg/headlampconfig"
@@ -95,6 +96,11 @@ type HeadlampConfig struct {
9596
meGroupsPaths string
9697
// meUserInfoURL is the URL to fetch additional user info for the /me endpoint. /oauth2/userinfo
9798
meUserInfoURL string
99+
// GCP OAuth fields for GKE authentication
100+
gcpOAuthEnabled bool
101+
gcpClientID string
102+
gcpClientSecret string
103+
gcpRedirectURL string
98104
}
99105

100106
const DrainNodeCacheTTL = 20 // seconds
@@ -391,6 +397,36 @@ func addPluginListRoute(config *HeadlampConfig, r *mux.Router) {
391397
}).Methods("GET")
392398
}
393399

400+
// setupInClusterContext configures the in-cluster Kubernetes context.
401+
func setupInClusterContext(config *HeadlampConfig) {
402+
context, err := kubeconfig.GetInClusterContext(config.oidcIdpIssuerURL,
403+
config.oidcClientID, config.oidcClientSecret,
404+
strings.Join(config.oidcScopes, ","),
405+
config.oidcSkipTLSVerify,
406+
config.oidcCACert)
407+
if err != nil {
408+
logger.Log(logger.LevelError, nil, err, "Failed to get in-cluster context")
409+
return
410+
}
411+
412+
context.Source = kubeconfig.InCluster
413+
414+
// When GCP OAuth is enabled, clear the auth info so users must authenticate via GCP OAuth
415+
if config.gcpOAuthEnabled {
416+
context.AuthInfo = &api.AuthInfo{}
417+
418+
logger.Log(logger.LevelInfo, nil, nil, "Added in-cluster context without service account auth (GCP OAuth enabled)")
419+
}
420+
421+
if err := context.SetupProxy(); err != nil {
422+
logger.Log(logger.LevelError, nil, err, "Failed to setup proxy for in-cluster context")
423+
}
424+
425+
if err := config.KubeConfigStore.AddContext(context); err != nil {
426+
logger.Log(logger.LevelError, nil, err, "Failed to add in-cluster context")
427+
}
428+
}
429+
394430
//nolint:gocognit,funlen,gocyclo
395431
func createHeadlampHandler(config *HeadlampConfig) http.Handler {
396432
kubeConfigPath := config.KubeConfigPath
@@ -450,26 +486,7 @@ func createHeadlampHandler(config *HeadlampConfig) http.Handler {
450486

451487
// In-cluster
452488
if config.UseInCluster {
453-
context, err := kubeconfig.GetInClusterContext(config.oidcIdpIssuerURL,
454-
config.oidcClientID, config.oidcClientSecret,
455-
strings.Join(config.oidcScopes, ","),
456-
config.oidcSkipTLSVerify,
457-
config.oidcCACert)
458-
if err != nil {
459-
logger.Log(logger.LevelError, nil, err, "Failed to get in-cluster context")
460-
}
461-
462-
context.Source = kubeconfig.InCluster
463-
464-
err = context.SetupProxy()
465-
if err != nil {
466-
logger.Log(logger.LevelError, nil, err, "Failed to setup proxy for in-cluster context")
467-
}
468-
469-
err = config.KubeConfigStore.AddContext(context)
470-
if err != nil {
471-
logger.Log(logger.LevelError, nil, err, "Failed to add in-cluster context")
472-
}
489+
setupInClusterContext(config)
473490
}
474491

475492
if config.StaticDir != "" {
@@ -884,6 +901,36 @@ func createHeadlampHandler(config *HeadlampConfig) http.Handler {
884901
http.Redirect(w, r, redirectURL, http.StatusSeeOther)
885902
})
886903

904+
// GCP OAuth routes for GKE authentication
905+
if config.gcpOAuthEnabled {
906+
gcpAuth := gcp.NewGCPAuthenticator(
907+
config.gcpClientID,
908+
config.gcpClientSecret,
909+
config.gcpRedirectURL,
910+
config.cache,
911+
)
912+
913+
r.HandleFunc("/gcp-auth/login", auth.HandleGCPAuthLogin(gcpAuth, config.BaseURL)).Methods("GET")
914+
r.HandleFunc("/gcp-auth/callback", auth.HandleGCPAuthCallback(gcpAuth, config.BaseURL)).Methods("GET")
915+
r.HandleFunc("/gcp-auth/refresh", auth.HandleGCPTokenRefresh(gcpAuth, config.BaseURL)).Methods("POST")
916+
917+
logger.Log(logger.LevelInfo, nil, nil, "GCP OAuth authentication enabled for GKE clusters")
918+
}
919+
920+
// Endpoint to check if GCP OAuth is enabled.
921+
// This is intentionally outside the gcpOAuthEnabled conditional block so the frontend
922+
// can always query this endpoint to determine whether to show the GCP OAuth login button.
923+
r.HandleFunc("/gcp-auth/enabled", func(w http.ResponseWriter, r *http.Request) {
924+
w.Header().Set("Content-Type", "application/json")
925+
if config.gcpOAuthEnabled {
926+
w.WriteHeader(http.StatusOK)
927+
_, _ = w.Write([]byte(`{"enabled": true}`))
928+
} else {
929+
w.WriteHeader(http.StatusOK)
930+
_, _ = w.Write([]byte(`{"enabled": false}`))
931+
}
932+
}).Methods("GET")
933+
887934
// Serve the frontend if needed
888935
if spa.UseEmbeddedFiles {
889936
r.PathPrefix("/").Handler(spa.NewEmbeddedHandler(spa.StaticFilesEmbed, "index.html", config.BaseURL))

backend/cmd/server.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,11 @@ func createHeadlampConfig(conf *config.Config) *HeadlampConfig {
139139
cache: cache,
140140
multiplexer: multiplexer,
141141
telemetryConfig: buildTelemetryConfig(conf),
142+
// GCP OAuth fields
143+
gcpOAuthEnabled: conf.GCPOAuthEnabled,
144+
gcpClientID: conf.GCPClientID,
145+
gcpClientSecret: conf.GCPClientSecret,
146+
gcpRedirectURL: conf.GCPRedirectURL,
142147
}
143148

144149
if conf.OidcCAFile != "" {

backend/go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ require (
5050
)
5151

5252
require (
53+
cloud.google.com/go/compute/metadata v0.9.0 // indirect
5354
dario.cat/mergo v1.0.1 // indirect
5455
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240716105424-66b64c4bb379 // indirect
5556
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect

backend/go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
22
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
3+
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
4+
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
35
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
46
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
57
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=

0 commit comments

Comments
 (0)