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;
|
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";
|
||||||
|
|
|
@ -4,4 +4,5 @@
|
||||||
|
|
||||||
args: {
|
args: {
|
||||||
twitterchiver = import ./twitterchiver 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