Skip to content

Commit 76a41bf

Browse files
author
medvedev_k_a
committed
backend: headlamp: Add pkce support
1 parent 088b32e commit 76a41bf

File tree

3 files changed

+100
-10
lines changed

3 files changed

+100
-10
lines changed

backend/cmd/headlamp.go

Lines changed: 97 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import (
2020
"bytes"
2121
"compress/gzip"
2222
"context"
23+
"crypto/rand"
24+
"crypto/sha256"
2325
"crypto/tls"
2426
"encoding/base64"
2527
"encoding/json"
@@ -75,6 +77,7 @@ type HeadlampConfig struct {
7577
oidcIdpIssuerURL string
7678
oidcValidatorIdpIssuerURL string
7779
oidcUseAccessToken bool
80+
oidcUsePKCE bool
7881
cache cache.Cache[interface{}]
7982
multiplexer *Multiplexer
8083
telemetryConfig cfg.Config
@@ -113,9 +116,29 @@ type spaHandler struct {
113116
}
114117

115118
type OauthConfig struct {
116-
Config *oauth2.Config
117-
Verifier *oidc.IDTokenVerifier
118-
Ctx context.Context
119+
Config *oauth2.Config
120+
Verifier *oidc.IDTokenVerifier
121+
Ctx context.Context
122+
CodeVerifier string // PKCE code verifier
123+
}
124+
125+
// generateCodeVerifier generates a PKCE code verifier (high-entropy cryptographic random string).
126+
func generateCodeVerifier() (string, error) {
127+
// RFC 7636 recommends 43-128 characters for code verifier. We use 43 characters.
128+
// Each character is a base64url character, so we need 32 random bytes to get 43 chars
129+
bytes := make([]byte, 32)
130+
if _, err := rand.Read(bytes); err != nil {
131+
return "", err
132+
}
133+
// Use base64url encoding without padding
134+
return base64.RawURLEncoding.EncodeToString(bytes), nil
135+
}
136+
137+
// generateCodeChallenge generates a PKCE code challenge from the verifier using SHA256.
138+
func generateCodeChallenge(verifier string) string {
139+
hash := sha256.Sum256([]byte(verifier))
140+
// Use base64url encoding without padding
141+
return base64.RawURLEncoding.EncodeToString(hash[:])
119142
}
120143

121144
func (h spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -664,12 +687,53 @@ func createHeadlampHandler(config *HeadlampConfig) http.Handler {
664687
RedirectURL: getOidcCallbackURL(r, config),
665688
Scopes: append([]string{oidc.ScopeOpenID}, oidcAuthConfig.Scopes...),
666689
}
667-
/* we encode the cluster to base64 and set it as state so that when getting redirected
668-
by oidc we can use this state value to get cluster name
669-
*/
670-
state := base64.StdEncoding.EncodeToString([]byte(cluster))
671-
oauthRequestMap[state] = &OauthConfig{Config: oauthConfig, Verifier: verifier, Ctx: ctx}
672-
http.Redirect(w, r, oauthConfig.AuthCodeURL(state), http.StatusFound)
690+
691+
var codeVerifier string
692+
var authURL string
693+
694+
// Generate PKCE parameters if enabled
695+
if config.oidcUsePKCE {
696+
var err error
697+
codeVerifier, err = generateCodeVerifier()
698+
if err != nil {
699+
logger.Log(logger.LevelError, map[string]string{"cluster": cluster},
700+
err, "failed to generate PKCE code verifier")
701+
http.Error(w, "Failed to generate PKCE parameters: "+err.Error(), http.StatusInternalServerError)
702+
return
703+
}
704+
codeChallenge := generateCodeChallenge(codeVerifier)
705+
706+
/* we encode the cluster to base64 and set it as state so that when getting redirected
707+
by oidc we can use this state value to get cluster name
708+
*/
709+
state := base64.StdEncoding.EncodeToString([]byte(cluster))
710+
oauthRequestMap[state] = &OauthConfig{
711+
Config: oauthConfig,
712+
Verifier: verifier,
713+
Ctx: ctx,
714+
CodeVerifier: codeVerifier,
715+
}
716+
717+
// Create authorization URL with PKCE parameters
718+
authURL = oauthConfig.AuthCodeURL(state,
719+
oauth2.SetAuthURLParam("code_challenge", codeChallenge),
720+
oauth2.SetAuthURLParam("code_challenge_method", "S256"))
721+
} else {
722+
/* we encode the cluster to base64 and set it as state so that when getting redirected
723+
by oidc we can use this state value to get cluster name
724+
*/
725+
state := base64.StdEncoding.EncodeToString([]byte(cluster))
726+
oauthRequestMap[state] = &OauthConfig{
727+
Config: oauthConfig,
728+
Verifier: verifier,
729+
Ctx: ctx,
730+
}
731+
732+
// Create standard authorization URL
733+
authURL = oauthConfig.AuthCodeURL(state)
734+
}
735+
736+
http.Redirect(w, r, authURL, http.StatusFound)
673737
}).Queries("cluster", "{cluster}")
674738

675739
r.HandleFunc("/portforward", func(w http.ResponseWriter, r *http.Request) {
@@ -711,7 +775,26 @@ func createHeadlampHandler(config *HeadlampConfig) http.Handler {
711775

712776
//nolint:nestif
713777
if oauthConfig, ok := oauthRequestMap[state]; ok {
714-
oauth2Token, err := oauthConfig.Config.Exchange(oauthConfig.Ctx, r.URL.Query().Get("code"))
778+
var oauth2Token *oauth2.Token
779+
780+
var err error
781+
782+
// Exchange authorization code for token, with or without PKCE
783+
if config.oidcUsePKCE && oauthConfig.CodeVerifier != "" {
784+
// Use PKCE code verifier for token exchange
785+
oauth2Token, err = oauthConfig.Config.Exchange(
786+
oauthConfig.Ctx,
787+
r.URL.Query().Get("code"),
788+
oauth2.SetAuthURLParam("code_verifier", oauthConfig.CodeVerifier),
789+
)
790+
} else {
791+
// Standard token exchange without PKCE
792+
oauth2Token, err = oauthConfig.Config.Exchange(
793+
oauthConfig.Ctx,
794+
r.URL.Query().Get("code"),
795+
)
796+
}
797+
715798
if err != nil {
716799
logger.Log(logger.LevelError, nil, err, "failed to exchange token")
717800
http.Error(w, "Failed to exchange token: "+err.Error(), http.StatusInternalServerError)
@@ -773,6 +856,10 @@ func createHeadlampHandler(config *HeadlampConfig) http.Handler {
773856
}
774857

775858
redirectURL += fmt.Sprintf("auth?cluster=%1s&token=%2s", decodedState, rawUserToken)
859+
860+
// Clean up the OAuth state to prevent memory leaks
861+
delete(oauthRequestMap, state)
862+
776863
http.Redirect(w, r, redirectURL, http.StatusSeeOther)
777864
} else {
778865
http.Error(w, "invalid request", http.StatusBadRequest)

backend/cmd/server.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ func main() {
7070
oidcValidatorIdpIssuerURL: conf.OidcValidatorIdpIssuerURL,
7171
oidcScopes: strings.Split(conf.OidcScopes, ","),
7272
oidcUseAccessToken: conf.OidcUseAccessToken,
73+
oidcUsePKCE: conf.OidcUsePKCE,
7374
cache: cache,
7475
multiplexer: multiplexer,
7576
telemetryConfig: config.Config{

backend/pkg/config/config.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ type Config struct {
4141
OidcValidatorIdpIssuerURL string `koanf:"oidc-validator-idp-issuer-url"`
4242
OidcScopes string `koanf:"oidc-scopes"`
4343
OidcUseAccessToken bool `koanf:"oidc-use-access-token"`
44+
OidcUsePKCE bool `koanf:"oidc-use-pkce"`
4445
// telemetry configs
4546
ServiceName string `koanf:"service-name"`
4647
ServiceVersion *string `koanf:"service-version"`
@@ -260,6 +261,7 @@ func flagset() *flag.FlagSet {
260261
f.String("oidc-scopes", "profile,email",
261262
"A comma separated list of scopes needed from the OIDC provider")
262263
f.Bool("oidc-use-access-token", false, "Setup oidc to pass through the access_token instead of the default id_token")
264+
f.Bool("oidc-use-pkce", true, "Use PKCE (Proof Key for Code Exchange) for enhanced security in OIDC flow")
263265
// Telemetry flags.
264266
f.String("service-name", "headlamp", "Service name for telemetry")
265267
f.String("service-version", "0.30.0", "Service version for telemetry")

0 commit comments

Comments
 (0)