go/openshiftauth: init

This is a small "library" for wrapping binaries with magic OAuth authentication based on the automatically-injected k8s service account tokens and OpenShift's OAuth service.

There's an example of this deployed at https://example-lukegb-openshiftauth-test.apps.k8s.lukegb.tech/.

The main pieces of setup that need to happen is:

* Set "serviceAccount" in pod definition
* Add Route for pod
* Edit serviceaccount and add metadata.annotations, e.g.:
    serviceaccounts.openshift.io/oauth-redirectreference.first: >-
      {"kind":"OAuthRedirectReference","apiVersion":"v1","reference":{"kind":"Route","name":"example"}}
This commit is contained in:
Luke Granger-Brown 2020-10-04 14:38:56 +01:00
parent 9bd61285a7
commit 04c3a8431b
5 changed files with 349 additions and 0 deletions

View file

@ -8,6 +8,7 @@ let
lib = pkgs.lib; lib = pkgs.lib;
images = { images = {
"registry.apps.k8s.lukegb.tech/twitterchiver/archiver:latest" = depot.go.twitterchiver.archiver.dockerImage; "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"; crane = "${depot.nix.pkgs.crane}/bin/crane";

View file

@ -4,4 +4,5 @@
args: { args: {
twitterchiver = import ./twitterchiver args; twitterchiver = import ./twitterchiver args;
openshiftauth = import ./openshiftauth args;
} }

View file

@ -0,0 +1,28 @@
# SPDX-FileCopyrightText: 2020 Luke Granger-Brown <depot@lukegb.com>
#
# 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
];
};
}

View file

@ -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)
}

View file

@ -0,0 +1,285 @@
// SPDX-FileCopyrightText: 2020 Luke Granger-Brown <depot@lukegb.com>
//
// 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
}