From 34d0f2d7e323acdc48cf91b0dc8514b6753de5d5 Mon Sep 17 00:00:00 2001 From: Mike Crute Date: Thu, 31 Aug 2017 04:28:14 +0000 Subject: Implement JWKS fetching --- cautious_http_client.go | 80 ++++++++++++++++++++++++++++++++ main.go | 119 +++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 192 insertions(+), 7 deletions(-) create mode 100644 cautious_http_client.go diff --git a/cautious_http_client.go b/cautious_http_client.go new file mode 100644 index 0000000..66179f2 --- /dev/null +++ b/cautious_http_client.go @@ -0,0 +1,80 @@ +package main + +import ( + "encoding/json" + "net" + "net/http" + "net/url" + "time" +) + +type CautiousHTTPClient interface { + Get(string) (*http.Response, error) + GetJSON(string, interface{}) error +} + +type cautiousHttpClient struct { + client *http.Client +} + +func NewCautiousHTTPClient() CautiousHTTPClient { + // May Need: TLSClientConfig *tls.Config + CautiousTransport := &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{ + Timeout: 1 * time.Second, + KeepAlive: 30 * time.Second, + DualStack: true, + }).DialContext, + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 3 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + ResponseHeaderTimeout: 5 * time.Second, + MaxResponseHeaderBytes: 500000, // .5 MB + } + + return &cautiousHttpClient{ + client: &http.Client{ + Transport: CautiousTransport, + Timeout: 30 * time.Second, + }, + } +} + +func (c *cautiousHttpClient) Get(gurl string) (*http.Response, error) { + u, err := url.Parse(gurl) + if err != nil { + return nil, err + } + + // TODO + /* + if u.Scheme != "https" { + return nil, fmt.Errorf("URL for GET must be secure") + } + */ + + r, err := c.client.Get(u.String()) + if err != nil { + return nil, err + } + r.Body = http.MaxBytesReader(nil, r.Body, 1000000) + return r, err +} + +func (c *cautiousHttpClient) GetJSON(url string, rv interface{}) error { + r, err := c.Get(url) + if err != nil { + return err + } + defer r.Body.Close() + + d := json.NewDecoder(r.Body) + err = d.Decode(rv) + if err != nil { + return err + } + + return nil +} diff --git a/main.go b/main.go index a96f0eb..44501c0 100644 --- a/main.go +++ b/main.go @@ -4,7 +4,8 @@ import ( "context" "crypto/rand" "encoding/hex" - _ "github.com/dgrijalva/jwt-go" + "fmt" + "gopkg.in/square/go-jose.v2" "log" "net/http" "net/http/httputil" @@ -33,12 +34,58 @@ type ProxyConfig struct { UpstreamURL string ListenOn string TrustedCACert string + IsOptional bool RequestMFA bool AllowedMFAMethods []string // An OR set RequiredMFAMethods []string // An AND set reverseProxy *httputil.ReverseProxy } +type IdPConfig struct { + AuthorizationEndpoint string `json:"authorization_endpoint"` + Issuer string `json:"issuer"` + JwksUri string `json:"jwks_uri"` + SupportedGrantTypes []string `json:"grant_types_supported"` + IdTokenSigningAlgs []string `json:"id_token_signing_alg_values_supported"` + ResponseModes []string `json:"response_modes_supported"` + ResponseTypes []string `json:"response_types_supported"` + Scopes []string `json:"scopes_supported"` + SubjectTypes []string `json:"subject_types_supported"` +} + +func FetchIdPConfig(h CautiousHTTPClient, idp_url string) (*IdPConfig, error) { + u, err := url.Parse(idp_url) + if err != nil { + return nil, err + } + u.Path = "/.well-known/openid-configuration" + + var idpc IdPConfig + err = h.GetJSON(u.String(), &idpc) + if err != nil { + return nil, err + } + + return &idpc, nil +} + +// TODO: Optimization to fetch only if expired (per http headers) +func FetchJWKS(h CautiousHTTPClient, jwks_url string) (map[string]jose.JSONWebKey, error) { + var jwks jose.JSONWebKeySet + err := h.GetJSON(jwks_url, &jwks) + if err != nil { + return nil, err + } + + keys := make(map[string]jose.JSONWebKey, len(jwks.Keys)) + + for _, k := range jwks.Keys { + keys[k.KeyID] = k + } + + return keys, nil +} + func URLMustParse(u string) *url.URL { o, err := url.Parse(u) if err != nil { @@ -91,15 +138,18 @@ func DownloadCRL() { func ValidateCertificate() { } +// TODO func MakeClientID(r *http.Request) string { if strings.Contains(r.Host, ":") { return r.Host } + return "" } // TODO func RedirectToIDP(w http.ResponseWriter, r *http.Request) { - nonce := GenerateNonce() + nonce, _ := GenerateNonce() + _ = nonce nonceh := "" // SHA256 nonce // Set nonce cookie @@ -117,13 +167,34 @@ func SetTokenCookieAndRedirect(w http.ResponseWriter, r *http.Request, token str } // TODO +// Occasionally refresh IDP config (per HTTP caching headers) +// +// Fetch ${IDP_HOST}/.well-known/openid-configuration +// - validate certificate chains to a trusted root +// - validate scopes_supported contains "openid" +// - validate response_types_supported contains "id_token" +// - validate grant_types_supported contains "implicit" +// - validate id_token_signing_alg_values_supported contains a supported signing type (see below) +// - Cache authorization_endpoint for redirecting users +// +// Fetch jwks_uri endpoint +// - Build key map indexed by kid for all keys that are suppored by our rules +// - kty == RSA +// - alg header must be one of [PS256, PS385, PS512] +// - pem decode x5c and validate the certificate chain as below +// - validate first item of x5c matches n and e +func RefreshIDPConfig() { +} + +// TODO +// If x5u exists in header // Fetch cert from x5u URL // Get CRL from cert, fetch (connect timeout 1s, read timeout 30s, read size 1M) // // exp claim has passed +- 5 minutes // iat claim is greater than 24 hours +- 5 minutes // aud claim is exact match for client_id -// iss claim is exact match for idp (ex: foo.example.com +// iss claim is exact match for idp (ex: foo.example.com) // if other aud claims validate that they are known // nonce in JWT must be SHA256 of rfp cookie value // Validate cert @@ -151,8 +222,9 @@ func RequestHasForwardedUser(w http.ResponseWriter, r *http.Request) bool { } } -func RequestIsOverSecureChannel(w http.ResponseWriter, r *http.Request) { - if https, ok := r.Header["X-Forwarded-Proto"]; !ok || len(https) != 1 { +func RequestIsOverSecureChannel(w http.ResponseWriter, r *http.Request) bool { + https, ok := r.Header["X-Forwarded-Proto"] + if !ok || len(https) != 1 { log.Printf("ERROR: Request does not contain X-Forwarded-Proto header") http.Error(w, "Bad Request", http.StatusBadRequest) return false @@ -202,13 +274,13 @@ func AuthProxyController(w http.ResponseWriter, r *http.Request) { tokenc, err := r.Cookie(TOKEN_COOKIE_NAME) if err != nil { log.Printf("ERROR: No token cookie") - RedirectToIDP() + RedirectToIDP(w, r) return } if !ValidateJWT(tokenc.Value, rfpc.Value) { log.Printf("ERROR: Token is invalid") - RedirectToIDP() + RedirectToIDP(w, r) return } @@ -235,6 +307,15 @@ func LogoutController(w http.ResponseWriter, r *http.Request) { } // TODO +func LoginController(w http.ResponseWriter, r *http.Request) { +} + +// TODO +// Optional login allows for applications that can operate in anonymous mode or +// authenticated mode. When in anonmyous mode the request is proxied through +// without an X-Forwarded-User header. Upstream servers should either expose or +// map a URL for /.oidc/login to allow users to login. On successful login the +// user will be redirected back to the main page for the site (/) func parseConfig() *ProxyConfig { return &ProxyConfig{ IDProviderURL: "", @@ -242,13 +323,37 @@ func parseConfig() *ProxyConfig { UpstreamURL: "http://localhost:9991/", ListenOn: ":9992", TrustedCACert: "", + IsOptional: false, } } func main() { + h := NewCautiousHTTPClient() + + idpc, err := FetchIdPConfig(h, "http://mcrute-virt:9993") + if err != nil { + fmt.Printf("%s\n", err) + return + } + + jwks, err := FetchJWKS(h, idpc.JwksUri) + if err != nil { + fmt.Printf("%s\n", err) + return + } + fmt.Printf("%+v\n", jwks) + return + cfg := parseConfig() cfg.reverseProxy = httputil.NewSingleHostReverseProxy(URLMustParse(cfg.UpstreamURL)) + if cfg.IsOptional { + http.HandleFunc("/.oidc/login", func(w http.ResponseWriter, r *http.Request) { + LoginController(w, + r.WithContext(context.WithValue(r.Context(), "ProxyConfig", cfg))) + }) + } + http.HandleFunc("/.oidc/logout", func(w http.ResponseWriter, r *http.Request) { LogoutController(w, r.WithContext(context.WithValue(r.Context(), "ProxyConfig", cfg))) -- cgit v1.2.3