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:
parent
9bd61285a7
commit
04c3a8431b
5 changed files with 349 additions and 0 deletions
|
@ -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";
|
||||
|
|
|
@ -4,4 +4,5 @@
|
|||
|
||||
args: {
|
||||
twitterchiver = import ./twitterchiver args;
|
||||
openshiftauth = import ./openshiftauth args;
|
||||
}
|
||||
|
|
28
go/openshiftauth/default.nix
Normal file
28
go/openshiftauth/default.nix
Normal 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
|
||||
];
|
||||
};
|
||||
}
|
34
go/openshiftauth/example/example.go
Normal file
34
go/openshiftauth/example/example.go
Normal 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)
|
||||
}
|
285
go/openshiftauth/openshiftauth.go
Normal file
285
go/openshiftauth/openshiftauth.go
Normal 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
|
||||
}
|
Loading…
Reference in a new issue