diff --git a/Makefile b/Makefile index b2b573bea..cd32782f7 100644 --- a/Makefile +++ b/Makefile @@ -185,7 +185,7 @@ $(KUBE_APPLYCONFIGURATION_GEN): GOBIN=$(GOBIN_DIR) $(GO_INSTALL) k8s.io/code-generator/cmd/$(KUBE_APPLYCONFIGURATION_GEN_BIN) $(KUBE_APPLYCONFIGURATION_GEN_BIN) $(KUBE_APPLYCONFIGURATION_GEN_VER) -codegen: WHAT ?= ./sdk/client +codegen: WHAT ?= ./sdk/client codegen: $(CONTROLLER_GEN) $(YAML_PATCH) $(CODE_GENERATOR) $(KUBE_CLIENT_GEN) $(KUBE_LISTER_GEN) $(KUBE_INFORMER_GEN) $(KUBE_APPLYCONFIGURATION_GEN) go mod download ./hack/update-codegen.sh @@ -245,8 +245,7 @@ E2E_PARALLELISM ?= 1 dex: git clone https://github.com/dexidp/dex.git dex/bin/dex: dex - cd dex - make + (cd dex; make) $(DEX): mkdir -p $(TOOLS_DIR) diff --git a/backend/cookie/session.go b/backend/cookie/session.go deleted file mode 100644 index 4f6efa13a..000000000 --- a/backend/cookie/session.go +++ /dev/null @@ -1,115 +0,0 @@ -/* -Copyright 2022 The Kube Bind Authors. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package cookie - -import ( - "bytes" - "encoding/base64" - "fmt" - "io" - "time" - - "github.com/pierrec/lz4" - "github.com/vmihailenco/msgpack/v4" -) - -type SessionState struct { - CreatedAt time.Time `msgpack:"ca,omitempty"` - ExpiresOn time.Time `msgpack:"eo,omitempty"` - - AccessToken string `msgpack:"at,omitempty"` - IDToken string `msgpack:"it,omitempty"` - RefreshToken string `msgpack:"rt,omitempty"` - - RedirectURL string `msgpack:"ru,omitempty"` - SessionID string `msgpack:"si,omitempty"` - ClusterID string `msgpack:"ci,omitempty"` -} - -func (s *SessionState) Encode() ([]byte, error) { - return msgpack.Marshal(s) -} - -func Decode(data string) (*SessionState, error) { - decoded, err := base64.RawURLEncoding.DecodeString(data) - if err != nil { - return nil, err - } - - var ss SessionState - err = msgpack.Unmarshal(decoded, &ss) - if err != nil { - return nil, fmt.Errorf("error unmarshalling data to session state: %w", err) - } - - return &ss, nil -} - -// lz4Compress compresses with LZ4 -// -// The Compress:Decompress ratio is 1:Many. LZ4 gives fastest decompress speeds -// at the expense of greater compression compared to other compression -// algorithms. -// -//nolint:unused -func lz4Compress(payload []byte) ([]byte, error) { - buf := new(bytes.Buffer) - zw := lz4.NewWriter(nil) - zw.Header = lz4.Header{ - BlockMaxSize: 65536, - CompressionLevel: 0, - } - zw.Reset(buf) - - reader := bytes.NewReader(payload) - _, err := io.Copy(zw, reader) - if err != nil { - return nil, fmt.Errorf("error copying lz4 stream to buffer: %w", err) - } - err = zw.Close() - if err != nil { - return nil, fmt.Errorf("error closing lz4 writer: %w", err) - } - - compressed, err := io.ReadAll(buf) - if err != nil { - return nil, fmt.Errorf("error reading lz4 buffer: %w", err) - } - - return compressed, nil -} - -// lz4Decompress decompresses with LZ4 -// -//nolint:unused -func lz4Decompress(compressed []byte) ([]byte, error) { - reader := bytes.NewReader(compressed) - buf := new(bytes.Buffer) - zr := lz4.NewReader(nil) - zr.Reset(reader) - _, err := io.Copy(buf, zr) - if err != nil { - return nil, fmt.Errorf("error copying lz4 stream to buffer: %w", err) - } - - payload, err := io.ReadAll(buf) - if err != nil { - return nil, fmt.Errorf("error reading lz4 buffer: %w", err) - } - - return payload, nil -} diff --git a/backend/http/handler.go b/backend/http/handler.go index 07836b856..a1dbf192d 100644 --- a/backend/http/handler.go +++ b/backend/http/handler.go @@ -31,6 +31,7 @@ import ( "github.com/gorilla/mux" "github.com/gorilla/securecookie" + "golang.org/x/oauth2" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" @@ -38,9 +39,9 @@ import ( componentbaseversion "k8s.io/component-base/version" "k8s.io/klog/v2" - "github.com/kube-bind/kube-bind/backend/cookie" "github.com/kube-bind/kube-bind/backend/kubernetes" "github.com/kube-bind/kube-bind/backend/kubernetes/resources" + "github.com/kube-bind/kube-bind/backend/session" "github.com/kube-bind/kube-bind/backend/template" bindversion "github.com/kube-bind/kube-bind/pkg/version" kubebindv1alpha2 "github.com/kube-bind/kube-bind/sdk/apis/kubebind/v1alpha2" @@ -168,8 +169,20 @@ func prepareNoCache(w http.ResponseWriter) { } } +func getLogger(r *http.Request) klog.Logger { + return klog.FromContext(r.Context()).WithValues("method", r.Method, "url", r.URL.String()) +} + +func generateCookieName(clusterID string) string { + if clusterID == "" { + return "kube-bind" + } + + return fmt.Sprintf("kube-bind-%s", clusterID) +} + func (h *handler) handleAuthorize(w http.ResponseWriter, r *http.Request) { - logger := klog.FromContext(r.Context()).WithValues("method", r.Method, "url", r.URL.String()) + logger := getLogger(r) cluster := mux.Vars(r)["cluster"] @@ -200,33 +213,22 @@ func (h *handler) handleAuthorize(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, authURL, http.StatusFound) } -func parseJWT(p string) ([]byte, error) { - parts := strings.Split(p, ".") - if len(parts) < 2 { - return nil, fmt.Errorf("oidc: malformed jwt, expected 3 parts got %d", len(parts)) - } - payload, err := base64.RawURLEncoding.DecodeString(parts[1]) - if err != nil { - return nil, fmt.Errorf("oidc: malformed jwt payload: %v", err) - } - return payload, nil -} - // handleCallback handle the authorization redirect callback from OAuth2 auth flow. func (h *handler) handleCallback(w http.ResponseWriter, r *http.Request) { - logger := klog.FromContext(r.Context()).WithValues("method", r.Method, "url", r.URL.String()) + logger := getLogger(r) if errMsg := r.Form.Get("error"); errMsg != "" { - logger.Info("failed to authorize", "error", errMsg) + logger.Error(errors.New(errMsg), "failed to authorize") http.Error(w, errMsg+": "+r.Form.Get("error_description"), http.StatusBadRequest) return } + code := r.Form.Get("code") if code == "" { code = r.URL.Query().Get("code") } if code == "" { - logger.Info("no code in request", "error", "missing code") + logger.Error(errors.New("missing code"), "no code in request") http.Error(w, fmt.Sprintf("no code in request: %q", r.Form), http.StatusBadRequest) return } @@ -237,13 +239,13 @@ func (h *handler) handleCallback(w http.ResponseWriter, r *http.Request) { } decoded, err := base64.StdEncoding.DecodeString(state) if err != nil { - logger.Info("failed to decode state", "error", err) + logger.Error(err, "failed to decode state") http.Error(w, err.Error(), http.StatusBadRequest) return } authCode := &AuthCode{} if err := json.Unmarshal(decoded, authCode); err != nil { - logger.Info("faile to unmarshal authCode", "error", err) + logger.Error(err, "failed to unmarshal authCode") http.Error(w, err.Error(), http.StatusBadRequest) return } @@ -252,54 +254,82 @@ func (h *handler) handleCallback(w http.ResponseWriter, r *http.Request) { token, err := h.oidc.OIDCProviderConfig(nil).Exchange(r.Context(), code) if err != nil { - logger.Info("failed to exchange token", "error", err) - http.Error(w, "internal error", http.StatusInternalServerError) - return - } - jwtStr, ok := token.Extra("id_token").(string) - if !ok { - logger.Info("failed to get id_token from token", "error", err) + logger.Error(err, "failed to exchange token") http.Error(w, "internal error", http.StatusInternalServerError) return } - jwt, err := parseJWT(jwtStr) + cookieName := generateCookieName(authCode.ClusterID) + sessionState, err := createSessionState(authCode, token) if err != nil { - logger.Info("failed to parse jwt", "error", err) + logger.Error(err, "failed to create session sessionState") http.Error(w, "internal error", http.StatusInternalServerError) return } - sessionCookie := cookie.SessionState{ - CreatedAt: time.Now(), - ExpiresOn: token.Expiry, - AccessToken: token.AccessToken, - IDToken: string(jwt), - RefreshToken: token.RefreshToken, - RedirectURL: authCode.RedirectURL, - SessionID: authCode.SessionID, - ClusterID: authCode.ClusterID, - } - - cookieName := "kube-bind-" + authCode.SessionID s := securecookie.New(h.cookieSigningKey, h.cookieEncryptionKey) - encoded, err := s.Encode(cookieName, sessionCookie) + encoded, err := s.Encode(cookieName, sessionState) if err != nil { - logger.Info("failed to encode secure session cookie", "error", err) + logger.Error(err, "failed to encode secure session cookie") http.Error(w, "internal error", http.StatusInternalServerError) return } - http.SetCookie(w, cookie.MakeCookie(r, cookieName, encoded, time.Duration(1)*time.Hour)) + // setting to false so it works over http://localhost + secure := false + + http.SetCookie(w, session.MakeCookie(r, cookieName, encoded, secure, 1*time.Hour)) if authCode.ClusterID == "" { - http.Redirect(w, r, "/resources?s="+authCode.SessionID, http.StatusFound) + http.Redirect(w, r, "/resources", http.StatusFound) } else { - http.Redirect(w, r, "/clusters/"+authCode.ClusterID+"/resources?s="+authCode.SessionID, http.StatusFound) + http.Redirect(w, r, "/clusters/"+authCode.ClusterID+"/resources", http.StatusFound) + } +} + +func unwrapJWT(p string) ([]byte, error) { + parts := strings.Split(p, ".") + if len(parts) < 2 { + return nil, fmt.Errorf("OIDC: malformed JWT, expected 3 parts, got %d", len(parts)) } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, fmt.Errorf("OIDC: malformed JWT payload: %w", err) + } + return payload, nil +} + +func createSessionState(authCode *AuthCode, token *oauth2.Token) (*session.State, error) { + jwtStr, ok := token.Extra("id_token").(string) + if !ok { + return nil, errors.New("no id_token value found in token") + } + + jwt, err := unwrapJWT(jwtStr) + if err != nil { + return nil, fmt.Errorf("failed to unpack ID token: %w", err) + } + + var idToken struct { + Subject string `json:"sub"` + Issuer string `json:"iss"` + } + if err := json.Unmarshal(jwt, &idToken); err != nil { + return nil, fmt.Errorf("failed to parse ID token: %w", err) + } + + return &session.State{ + Token: session.TokenInfo{ + Subject: idToken.Subject, + Issuer: idToken.Issuer, + }, + SessionID: authCode.SessionID, + ClusterID: authCode.ClusterID, + RedirectURL: authCode.RedirectURL, + }, nil } func (h *handler) handleResources(w http.ResponseWriter, r *http.Request) { - logger := klog.FromContext(r.Context()).WithValues("method", r.Method, "url", r.URL.String()) + logger := getLogger(r) prepareNoCache(w) @@ -344,12 +374,10 @@ func (h *handler) handleResources(w http.ResponseWriter, r *http.Request) { } bs := bytes.Buffer{} if err := resourcesTemplate.Execute(&bs, struct { - SessionID string Cluster string CRDs []*apiextensionsv1.CustomResourceDefinition APIResourceSchemas []kubebindv1alpha2.APIResourceSchema }{ - SessionID: r.URL.Query().Get("s"), Cluster: cluster, CRDs: rightScopedCRDs, APIResourceSchemas: apiResourceSchemas.Items, @@ -359,24 +387,27 @@ func (h *handler) handleResources(w http.ResponseWriter, r *http.Request) { return } - w.Header().Set("Content-Type", "text/html") + w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write(bs.Bytes()) //nolint:errcheck } func (h *handler) handleBind(w http.ResponseWriter, r *http.Request) { - logger := klog.FromContext(r.Context()).WithValues("method", r.Method, "url", r.URL.String()) + logger := getLogger(r) + group := r.URL.Query().Get("group") + resource := r.URL.Query().Get("resource") + cluster := mux.Vars(r)["cluster"] prepareNoCache(w) - cookieName := "kube-bind-" + r.URL.Query().Get("s") + cookieName := generateCookieName(cluster) ck, err := r.Cookie(cookieName) if err != nil { logger.Error(err, "failed to get session cookie") - http.Error(w, "internal error", http.StatusInternalServerError) + http.Error(w, "no session cookie found", http.StatusBadRequest) return } - state := cookie.SessionState{} + state := session.State{} s := securecookie.New(h.cookieSigningKey, h.cookieEncryptionKey) if err := s.Decode(cookieName, ck.Value, &state); err != nil { logger.Error(err, "failed to decode session cookie") @@ -384,21 +415,7 @@ func (h *handler) handleBind(w http.ResponseWriter, r *http.Request) { return } - var idToken struct { - Subject string `json:"sub"` - Issuer string `json:"iss"` - } - if err := json.Unmarshal([]byte(state.IDToken), &idToken); err != nil { - logger.Error(err, "failed to unmarshal id token") - http.Error(w, "internal error", http.StatusInternalServerError) - return - } - - group := r.URL.Query().Get("group") - resource := r.URL.Query().Get("resource") - cluster := mux.Vars(r)["cluster"] - - kfg, err := h.kubeManager.HandleResources(r.Context(), idToken.Subject+"#"+state.ClusterID, cluster, resource, group) + kfg, err := h.kubeManager.HandleResources(r.Context(), state.Token.Subject+"#"+state.ClusterID, cluster, resource, group) if err != nil { logger.Error(err, "failed to handle resources") http.Error(w, "internal error", http.StatusInternalServerError) @@ -430,6 +447,7 @@ func (h *handler) handleBind(w http.ResponseWriter, r *http.Request) { http.Error(w, "internal error", http.StatusInternalServerError) return } + response := kubebindv1alpha2.BindingResponse{ TypeMeta: metav1.TypeMeta{ APIVersion: kubebindv1alpha2.SchemeGroupVersion.String(), @@ -438,7 +456,7 @@ func (h *handler) handleBind(w http.ResponseWriter, r *http.Request) { Authentication: kubebindv1alpha2.BindingResponseAuthentication{ OAuth2CodeGrant: &kubebindv1alpha2.BindingResponseAuthenticationOAuth2CodeGrant{ SessionID: state.SessionID, - ID: idToken.Issuer + "/" + idToken.Subject, + ID: state.Token.Issuer + "/" + state.Token.Subject, }, }, Kubeconfig: kfg, @@ -447,7 +465,7 @@ func (h *handler) handleBind(w http.ResponseWriter, r *http.Request) { payload, err := json.Marshal(&response) if err != nil { - logger.Error(err, "failed to marshal auth response") + logger.Error(err, "failed to marshal binding response") http.Error(w, "internal error", http.StatusInternalServerError) return } @@ -456,7 +474,7 @@ func (h *handler) handleBind(w http.ResponseWriter, r *http.Request) { parsedAuthURL, err := url.Parse(state.RedirectURL) if err != nil { - logger.Error(err, "failed to parse redirect url") + logger.Error(err, "failed to parse redirect URL") http.Error(w, "internal error", http.StatusInternalServerError) return } diff --git a/backend/cookie/cookie.go b/backend/session/cookie.go similarity index 55% rename from backend/cookie/cookie.go rename to backend/session/cookie.go index ebcee017e..4fb905ea2 100644 --- a/backend/cookie/cookie.go +++ b/backend/session/cookie.go @@ -14,39 +14,22 @@ See the License for the specific language governing permissions and limitations under the License. */ -package cookie +package session import ( - "fmt" "net/http" "time" ) -func MakeCookie(req *http.Request, name string, value string, expiration time.Duration) *http.Cookie { +func MakeCookie(req *http.Request, name string, value string, secure bool, expiration time.Duration) *http.Cookie { return &http.Cookie{ Name: name, Value: value, Path: "/", // TODO: make configurable Domain: "", // TODO: add domain support Expires: time.Now().Add(expiration), - HttpOnly: true, // TODO: make configurable - // setting to false so it works over http://localhost - Secure: false, // TODO: make configurable - SameSite: ParseSameSite(""), // TODO: make configurable - } -} - -func ParseSameSite(v string) http.SameSite { - switch v { - case "lax": - return http.SameSiteLaxMode - case "strict": - return http.SameSiteStrictMode - case "none": - return http.SameSiteNoneMode - case "": - return 0 - default: - panic(fmt.Sprintf("Invalid value for SameSite: %s", v)) + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteLaxMode, } } diff --git a/backend/session/session.go b/backend/session/session.go new file mode 100644 index 000000000..387f73d93 --- /dev/null +++ b/backend/session/session.go @@ -0,0 +1,43 @@ +/* +Copyright 2022 The Kube Bind Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package session + +import ( + "github.com/vmihailenco/msgpack/v4" +) + +// State is the data stored on the clientside as a browser cookie. +// To be independent of the OIDC token sizes, it only stores the absolute minimum +// information required for this particular backend implementation. Especially +// when lots of groups and claims are involved, the tokens can grow to a size +// larger than allowed by browsers for cookies, and even compression would only +// be a small adhesive strip, not a solution. +type State struct { + Token TokenInfo `msgpack:"tok,omitempty"` + SessionID string `msgpack:"sid,omitempty"` + ClusterID string `msgpack:"cl,omitempty"` + RedirectURL string `msgpack:"red,omitempty"` +} + +type TokenInfo struct { + Subject string `msgpack:"sub,omitempty"` + Issuer string `msgpack:"iss,omitempty"` +} + +func (s *State) Encode() ([]byte, error) { + return msgpack.Marshal(s) +} diff --git a/backend/template/resources.gohtml b/backend/template/resources.gohtml index 4d395ee80..5197f4c07 100644 --- a/backend/template/resources.gohtml +++ b/backend/template/resources.gohtml @@ -13,7 +13,7 @@