Skip to content

Commit 8661611

Browse files
backend: oidc: impersonate instead of forwarding oidc token to k8s api
1 parent e0c9f14 commit 8661611

File tree

5 files changed

+104
-2
lines changed

5 files changed

+104
-2
lines changed

backend/cmd/headlamp.go

+69-1
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ type HeadlampConfig struct {
5454
oidcClientID string
5555
oidcClientSecret string
5656
oidcIdpIssuerURL string
57+
oidcImpersonate bool
58+
oidcImpersonateClaim string
5759
baseURL string
5860
oidcScopes []string
5961
proxyURLs []string
@@ -359,7 +361,7 @@ func createHeadlampHandler(config *HeadlampConfig) http.Handler {
359361
if config.useInCluster {
360362
context, err := kubeconfig.GetInClusterContext(config.oidcIdpIssuerURL,
361363
config.oidcClientID, config.oidcClientSecret,
362-
strings.Join(config.oidcScopes, ","))
364+
strings.Join(config.oidcScopes, ","), config.oidcImpersonate)
363365
if err != nil {
364366
logger.Log(logger.LevelError, nil, err, "Failed to get in-cluster context")
365367
}
@@ -883,10 +885,76 @@ func (c *HeadlampConfig) OIDCTokenRefreshMiddleware(next http.Handler) http.Hand
883885
})
884886
}
885887

888+
func (c *HeadlampConfig) OIDCImpersonateMiddleware(next http.Handler) http.Handler {
889+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
890+
// skip if not cluster request
891+
if !strings.HasPrefix(r.URL.String(), "/clusters/") {
892+
next.ServeHTTP(w, r)
893+
return
894+
}
895+
896+
// parse cluster and token
897+
cluster, token := parseClusterAndToken(r)
898+
if cluster == "" || token == "" {
899+
next.ServeHTTP(w, r)
900+
return
901+
}
902+
903+
idToken, err := c.getValidToken(r.Context(), token)
904+
if err != nil {
905+
next.ServeHTTP(w, r)
906+
return
907+
}
908+
909+
var claims map[string]interface{}
910+
if err := idToken.Claims(&claims); err != nil {
911+
next.ServeHTTP(w, r)
912+
return
913+
}
914+
user, ok := claims[c.oidcImpersonateClaim].(string)
915+
if !ok || user == "" {
916+
next.ServeHTTP(w, r)
917+
return
918+
}
919+
920+
context, err := c.kubeConfigStore.GetContext(cluster)
921+
if err != nil {
922+
next.ServeHTTP(w, r)
923+
return
924+
}
925+
926+
// User was successfully authenticated, execute request as this user
927+
r.Header.Set("Authorization", "Bearer "+context.BearerToken)
928+
r.Header.Set("Impersonate-User", user)
929+
next.ServeHTTP(w, r)
930+
})
931+
}
932+
933+
func (c *HeadlampConfig) getValidToken(ctx context.Context, token string) (*oidc.IDToken, error) {
934+
provider, err := oidc.NewProvider(ctx, c.oidcIdpIssuerURL)
935+
if err != nil {
936+
logger.Log(logger.LevelError, map[string]string{"idpIssuerURL": c.oidcIdpIssuerURL},
937+
err, "failed to get oidc provider")
938+
return nil, fmt.Errorf("failed to get oidc provider: %w", err)
939+
}
940+
oidcConfig := &oidc.Config{
941+
ClientID: c.oidcClientID,
942+
}
943+
oauth2Token, err := provider.Verifier(oidcConfig).Verify(ctx, token)
944+
if err != nil {
945+
return nil, fmt.Errorf("token is not valid: %w", err)
946+
}
947+
return oauth2Token, nil
948+
}
949+
886950
func StartHeadlampServer(config *HeadlampConfig) {
887951
handler := createHeadlampHandler(config)
888952

889953
handler = config.OIDCTokenRefreshMiddleware(handler)
954+
if config.oidcImpersonate {
955+
handler = config.OIDCImpersonateMiddleware(handler)
956+
logger.Log(logger.LevelInfo, nil, nil, "OIDC impersonate active")
957+
}
890958

891959
// Start server
892960
err := http.ListenAndServe(fmt.Sprintf(":%d", config.port), handler) //nolint:gosec

backend/cmd/server.go

+2
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ func main() {
4848
oidcClientSecret: conf.OidcClientSecret,
4949
oidcIdpIssuerURL: conf.OidcIdpIssuerURL,
5050
oidcScopes: strings.Split(conf.OidcScopes, ","),
51+
oidcImpersonate: conf.OidcImpersonate,
52+
oidcImpersonateClaim: conf.OidcImpersonateClaim,
5153
baseURL: conf.BaseURL,
5254
proxyURLs: strings.Split(conf.ProxyURLs, ","),
5355
enableHelm: conf.EnableHelm,

backend/pkg/config/config.go

+6
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ type Config struct {
3535
OidcClientSecret string `koanf:"oidc-client-secret"`
3636
OidcIdpIssuerURL string `koanf:"oidc-idp-issuer-url"`
3737
OidcScopes string `koanf:"oidc-scopes"`
38+
OidcImpersonate bool `koanf:"oidc-impersonate"`
39+
OidcImpersonateClaim string `koanf:"oidc-impersonate-claim"`
3840
}
3941

4042
func (c *Config) Validate() error {
@@ -166,6 +168,10 @@ func flagset() *flag.FlagSet {
166168
f.String("oidc-idp-issuer-url", "", "Identity provider issuer URL for OIDC")
167169
f.String("oidc-scopes", "profile,email",
168170
"A comma separated list of scopes needed from the OIDC provider")
171+
f.Bool("oidc-impersonate", false,
172+
"Kubernetes API calls are done with the service account and impersonated as the user in the OIDC token")
173+
f.String("oidc-impersonate-claim", "preferred_username",
174+
"The claim of the OIDC token to be used when -oidc-impersonate=true")
169175

170176
return f
171177
}

backend/pkg/kubeconfig/kubeconfig.go

+8-1
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ type Context struct {
4343
Source int `json:"source"`
4444
OidcConf *OidcConfig `json:"oidcConfig"`
4545
proxy *httputil.ReverseProxy `json:"-"`
46+
BearerToken string `json:"bearerToken"`
4647
Internal bool `json:"internal"`
4748
Error string `json:"error"`
4849
}
@@ -867,7 +868,7 @@ func splitKubeConfigPath(path string) []string {
867868
// GetInClusterContext returns the in-cluster context.
868869
func GetInClusterContext(oidcIssuerURL string,
869870
oidcClientID string, oidcClientSecret string,
870-
oidcScopes string,
871+
oidcScopes string, oidcImpersonate bool,
871872
) (*Context, error) {
872873
clusterConfig, err := rest.InClusterConfig()
873874
if err != nil {
@@ -888,6 +889,7 @@ func GetInClusterContext(oidcIssuerURL string,
888889
inClusterAuthInfo := &api.AuthInfo{}
889890

890891
var oidcConf *OidcConfig
892+
var bearerToken string
891893

892894
if oidcClientID != "" && oidcClientSecret != "" && oidcIssuerURL != "" && oidcScopes != "" {
893895
oidcConf = &OidcConfig{
@@ -896,6 +898,10 @@ func GetInClusterContext(oidcIssuerURL string,
896898
IdpIssuerURL: oidcIssuerURL,
897899
Scopes: strings.Split(oidcScopes, ","),
898900
}
901+
902+
if oidcImpersonate {
903+
bearerToken = clusterConfig.BearerToken
904+
}
899905
}
900906

901907
return &Context{
@@ -904,6 +910,7 @@ func GetInClusterContext(oidcIssuerURL string,
904910
Cluster: cluster,
905911
AuthInfo: inClusterAuthInfo,
906912
OidcConf: oidcConf,
913+
BearerToken: bearerToken,
907914
}, nil
908915
}
909916

docs/installation/in-cluster/oidc.md

+19
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,25 @@ then add them all to the option:
3333
used by Dex and other services, but since it's not part of the default spec,
3434
it was removed in the mentioned version.
3535

36+
### Impersonation
37+
38+
Headlamp's default OIDC authentication flow involves sending the OIDC token
39+
directly to the Kubernetes API for evaluation. However, in environments where
40+
Kubernetes is unable to directly verify OIDC tokens (e.g., due to configuration
41+
constraints), Headlamp offers an impersonation feature. This allows Headlamp to
42+
validate the OIDC token itself and then use a pre-configured service account to
43+
impersonate the user identified in the token's `preferred_username` claim.
44+
45+
To enable this impersonation behavior, set the `-oidc-impersonate` flag to `true`:
46+
47+
`-oidc-impersonate=true`
48+
49+
The username is extracted from the OIDC JWT token, by default from the
50+
`preferred_username` field. If you want to use another claim, it can be changed
51+
with:
52+
53+
`-oidc-impersonate-claim=email`
54+
3655
### Example: OIDC with Keycloak in Minikube
3756

3857
If you are interested in a comprehensive example of using OIDC and Headlamp,

0 commit comments

Comments
 (0)