// SPDX-FileCopyrightText: 2020 Luke Granger-Brown // // SPDX-License-Identifier: Apache-2.0 package openshiftauth import ( "context" "encoding/gob" "encoding/hex" "encoding/json" "fmt" "io/ioutil" "log" "net/http" "net/url" "os" "github.com/dghubble/gologin/v2" "github.com/dghubble/gologin/v2/oauth2" "github.com/dgrijalva/jwt-go" "github.com/gorilla/mux" "github.com/gorilla/securecookie" "github.com/gorilla/sessions" xoauth2 "golang.org/x/oauth2" ) type SecretGetter func(ctx context.Context) (string, error) func TokenDirSecretGetter(ctx context.Context) (string, error) { v, err := ioutil.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/token") if err != nil { return "", fmt.Errorf("couldn't read serviceaccount secret: %w", err) } return string(v), nil } type BaseHandler struct { Config xoauth2.Config SecretGetter SecretGetter } func (h *BaseHandler) config(ctx context.Context) (*xoauth2.Config, error) { s, err := h.SecretGetter(ctx) if err != nil { return nil, err } cfg := h.Config cfg.ClientSecret = s return &cfg, nil } func (h *BaseHandler) wrapWithConfig(f func(cfg *xoauth2.Config) http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { cfg, err := h.config(r.Context()) if err != nil { http.Error(rw, err.Error(), http.StatusInternalServerError) return } f(cfg).ServeHTTP(rw, r) }) } func (h *BaseHandler) CallbackHandler(success, failure http.Handler) http.Handler { return h.wrapWithConfig(func(cfg *xoauth2.Config) http.Handler { return oauth2.CallbackHandler(cfg, success, failure) }) } func (h *BaseHandler) LoginHandler(failure http.Handler) http.Handler { return h.wrapWithConfig(func(cfg *xoauth2.Config) http.Handler { return oauth2.LoginHandler(cfg, failure) }) } type contextKey string const ( sessionContextKey contextKey = "session" ) func AttachSessionMiddleware(sess sessions.Store) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { s, err := sess.Get(r, "openshiftauth") if err != nil { log.Printf("decoding session: %v", err) } ctx := context.WithValue(r.Context(), sessionContextKey, s) next.ServeHTTP(w, r.WithContext(ctx)) }) } } func Session(ctx context.Context) *sessions.Session { return ctx.Value(sessionContextKey).(*sessions.Session) } func UserFromContext(ctx context.Context) *User { s := Session(ctx) if s.Values["user"] != nil { return s.Values["user"].(*User) } return nil } func secretFromEnv(name string, length int) []byte { var bs []byte if bsHex, ok := os.LookupEnv(name); ok { newBS, err := hex.DecodeString(bsHex) if err != nil { log.Printf("failed to decode %v as hex: %v", name, err) } else { bs = newBS } } if bs == nil { log.Printf("generating a random %v: this is bad", name) bs = securecookie.GenerateRandomKey(length) } return bs } type User struct { Metadata struct { Name string `json:"name"` } `json:"metadata"` FullName string `json:"fullName"` Identities []string `json:"identities"` Groups []string `json:"groups"` } func init() { gob.Register(&User{}) } func fetchUserForToken(ctx context.Context, token *xoauth2.Token) (*User, error) { req, err := http.NewRequestWithContext(ctx, "GET", "https://api.k8s.lukegb.tech:6443/apis/user.openshift.io/v1/users/~", nil) if err != nil { return nil, fmt.Errorf("http.NewRequestWithContext for users/~ failed") } token.SetAuthHeader(req) resp, err := http.DefaultClient.Do(req) if err != nil { return nil, fmt.Errorf("executing request for users/~ failed: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("openshift returned non-OK status %d %v", resp.StatusCode, resp.Status) } var u User if err := json.NewDecoder(resp.Body).Decode(&u); err != nil { return nil, fmt.Errorf("decoding response from openshift: %v", err) } return &u, nil } func RequireAuth(next http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { u := UserFromContext(r.Context()) if u == nil { redirURL := r.URL.ResolveReference(r.URL) redirURL.Path = "/.login/start" redirURL.RawQuery = url.Values{ "next": []string{r.URL.String()}, }.Encode() http.Redirect(rw, r, redirURL.String(), http.StatusSeeOther) return } next.ServeHTTP(rw, r) }) } type serviceAccountDetails struct { Namespace string `json:"kubernetes.io/serviceaccount/namespace"` Name string `json:"kubernetes.io/serviceaccount/service-account.name"` FullName string `json:"sub"` } func parseServiceAccount() (*serviceAccountDetails, error) { token, err := ioutil.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/token") if err != nil { return nil, fmt.Errorf("reading service account JWT: %w", err) } // ParseUnverified is safe here because we're just using it to extract information about ourselves. p := &jwt.Parser{} claims := make(jwt.MapClaims) if _, _, err := p.ParseUnverified(string(token), claims); err != nil { return nil, fmt.Errorf("parsing JWT: %w", err) } val := func(s string) string { v, ok := claims[s] if !ok { return "" } return v.(string) } sad := &serviceAccountDetails{ Namespace: val("kubernetes.io/serviceaccount/namespace"), Name: val("kubernetes.io/serviceaccount/service-account.name"), FullName: val("sub"), } return sad, nil } func NewRouter(r *mux.Router) (*mux.Router, error) { sess := sessions.NewCookieStore( secretFromEnv("OPENSHIFT_AUTH_SESSION_SIGN_SECRET", 32), secretFromEnv("OPENSHIFT_AUTH_SESSION_ENC_SECRET", 32)) r.Use(AttachSessionMiddleware(sess)) sad, err := parseServiceAccount() if err != nil { return nil, fmt.Errorf("loading service account information: %w", err) } log.Printf("using service account token for %v", sad.FullName) bh := &BaseHandler{ Config: xoauth2.Config{ ClientID: sad.FullName, Endpoint: xoauth2.Endpoint{ AuthURL: "https://oauth-openshift.apps.k8s.lukegb.tech/oauth/authorize", TokenURL: "https://oauth-openshift.apps.k8s.lukegb.tech/oauth/token", }, RedirectURL: fmt.Sprintf("%s/.login/callback", os.Getenv("OPENSHIFT_AUTH_BASE")), Scopes: []string{"user:info"}, }, SecretGetter: TokenDirSecretGetter, } baseURL, err := url.Parse(os.Getenv("OPENSHIFT_AUTH_BASE")) if err != nil { return nil, fmt.Errorf("failed to parse OPENSHIFT_AUTH_BASE as URL: %v", err) } stateConfig := gologin.DefaultCookieConfig r.HandleFunc("/.login/start", func(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() if nextURL, err := url.Parse(r.URL.Query().Get("next")); err == nil { nextURL = baseURL.ResolveReference(nextURL) if nextURL.Scheme != baseURL.Scheme || nextURL.User != nil || nextURL.Host != baseURL.Host { nextURL = nil } if nextURL != nil { s := Session(ctx) s.AddFlash(nextURL.RequestURI(), "_openshiftauth_next") sessions.Save(r, rw) } } oauth2.StateHandler(stateConfig, bh.LoginHandler(nil)).ServeHTTP(rw, r) }) r.Handle("/.login/callback", oauth2.StateHandler(stateConfig, bh.CallbackHandler( http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() token, err := oauth2.TokenFromContext(ctx) if err != nil { http.Error(rw, "oauth2 TokenFromContext failed", http.StatusBadRequest) return } u, err := fetchUserForToken(ctx, token) if err != nil { http.Error(rw, fmt.Sprintf("fetchUserForToken: %v", err), http.StatusInternalServerError) return } s := Session(ctx) s.Values["user"] = *u sessions.Save(r, rw) next := "/" nexts := s.Flashes("_openshiftauth_next") if len(nexts) >= 1 { next = nexts[0].(string) } http.Redirect(rw, r, next, http.StatusFound) }), nil))) authR := r.PathPrefix("/").Subrouter() authR.Use(RequireAuth) return authR, nil }