Luke Granger-Brown
04c3a8431b
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"}}
285 lines
7.8 KiB
Go
285 lines
7.8 KiB
Go
// 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
|
|
}
|