diff --git a/docker-images.nix b/docker-images.nix index 68ae6b2fe9..6d8c725202 100644 --- a/docker-images.nix +++ b/docker-images.nix @@ -8,6 +8,7 @@ let lib = pkgs.lib; images = { "registry.apps.k8s.lukegb.tech/twitterchiver/archiver:latest" = depot.go.twitterchiver.archiver.dockerImage; + "registry.apps.k8s.lukegb.tech/lukegb-openshiftauth-test/example:latest" = depot.go.openshiftauth.example.dockerImage; }; crane = "${depot.nix.pkgs.crane}/bin/crane"; diff --git a/go/default.nix b/go/default.nix index ed3dc09233..cf0d6e0884 100644 --- a/go/default.nix +++ b/go/default.nix @@ -4,4 +4,5 @@ args: { twitterchiver = import ./twitterchiver args; + openshiftauth = import ./openshiftauth args; } diff --git a/go/openshiftauth/default.nix b/go/openshiftauth/default.nix new file mode 100644 index 0000000000..276f5c6a63 --- /dev/null +++ b/go/openshiftauth/default.nix @@ -0,0 +1,28 @@ +# SPDX-FileCopyrightText: 2020 Luke Granger-Brown +# +# SPDX-License-Identifier: Apache-2.0 + +{ depot, ... }: { + openshiftauth = depot.third_party.buildGo.package { + name = "hg.lukegb.com/lukegb/depot/go/openshiftauth"; + srcs = [ ./openshiftauth.go ]; + deps = with depot.third_party; [ + gopkgs."github.com".dghubble.gologin.v2 + gopkgs."github.com".dghubble.gologin.v2.oauth2 + gopkgs."github.com".dgrijalva.jwt-go + gopkgs."github.com".gorilla.mux + gopkgs."github.com".gorilla.securecookie + gopkgs."github.com".gorilla.sessions + gopkgs."golang.org".x.oauth2 + ]; + }; + + example = depot.third_party.buildGo.program { + name = "example"; + srcs = [ ./example/example.go ]; + deps = with depot.third_party; [ + depot.go.openshiftauth.openshiftauth + gopkgs."github.com".gorilla.mux + ]; + }; +} diff --git a/go/openshiftauth/example/example.go b/go/openshiftauth/example/example.go new file mode 100644 index 0000000000..c65ca85bdd --- /dev/null +++ b/go/openshiftauth/example/example.go @@ -0,0 +1,34 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + + "github.com/gorilla/mux" + "hg.lukegb.com/lukegb/depot/go/openshiftauth" +) + +func main() { + r := mux.NewRouter() + authR, err := openshiftauth.NewRouter(r) + if err != nil { + log.Fatalf("openshiftauth.NewRouter: %v", err) + } + + authR.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) { + u := openshiftauth.UserFromContext(r.Context()) + rw.Header().Set("Content-Type", "application/json") + enc := json.NewEncoder(rw) + enc.SetIndent("", " ") + enc.Encode(u) + }) + r.HandleFunc("/healthz", func(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "text/plain") + fmt.Fprintf(rw, "ok") + }) + + http.Handle("/", r) + http.ListenAndServe(":8080", nil) +} diff --git a/go/openshiftauth/openshiftauth.go b/go/openshiftauth/openshiftauth.go new file mode 100644 index 0000000000..19f0afca1c --- /dev/null +++ b/go/openshiftauth/openshiftauth.go @@ -0,0 +1,285 @@ +// 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 +}