diff --git a/authorize/grpc.go b/authorize/grpc.go index 04b25fe13..5028a2144 100644 --- a/authorize/grpc.go +++ b/authorize/grpc.go @@ -292,9 +292,17 @@ func getCheckRequestURL(req *envoy_service_auth_v2.CheckRequest) *url.URL { u.Path = path } - if h.GetHeaders() != nil { - if fwdProto, ok := h.GetHeaders()["x-forwarded-proto"]; ok { - u.Scheme = fwdProto + // check to make sure this is _not_ a verify endpoint and that forwarding + // headers are set. If so, infer the true authorization location from thos + if u.Path != "/verify" && h.GetHeaders() != nil { + if val, ok := h.GetHeaders()["x-forwarded-proto"]; ok && val != "" { + u.Scheme = val + } + if val, ok := h.GetHeaders()["x-forwarded-host"]; ok && val != "" { + u.Host = val + } + if val, ok := h.GetHeaders()["x-forwarded-uri"]; ok && val != "" && val != "/" { + u.Path = val } } return u diff --git a/internal/controlplane/xds_listeners_test.go b/internal/controlplane/xds_listeners_test.go index 9d6f351ff..314a0b117 100644 --- a/internal/controlplane/xds_listeners_test.go +++ b/internal/controlplane/xds_listeners_test.go @@ -93,6 +93,15 @@ func Test_buildMainHTTPConnectionManagerFilter(t *testing.T) { "name": "example.com", "domains": ["example.com"], "routes": [ + { + "name": "pomerium-protected-path-/.pomerium/jwt", + "match": { + "path": "/.pomerium/jwt" + }, + "route": { + "cluster": "pomerium-control-plane-http" + } + }, { "name": "pomerium-path-/ping", "match": { @@ -204,6 +213,15 @@ func Test_buildMainHTTPConnectionManagerFilter(t *testing.T) { "name": "catch-all", "domains": ["*"], "routes": [ + { + "name": "pomerium-protected-path-/.pomerium/jwt", + "match": { + "path": "/.pomerium/jwt" + }, + "route": { + "cluster": "pomerium-control-plane-http" + } + }, { "name": "pomerium-path-/ping", "match": { diff --git a/internal/controlplane/xds_routes.go b/internal/controlplane/xds_routes.go index 36f068725..12a58f8b8 100644 --- a/internal/controlplane/xds_routes.go +++ b/internal/controlplane/xds_routes.go @@ -16,6 +16,11 @@ import ( "github.com/pomerium/pomerium/config" "github.com/pomerium/pomerium/internal/httputil" + "github.com/pomerium/pomerium/internal/urlutil" +) + +const ( + httpCluster = "pomerium-control-plane-http" ) func buildGRPCRoutes() []*envoy_config_route_v3.Route { @@ -43,6 +48,9 @@ func buildGRPCRoutes() []*envoy_config_route_v3.Route { func buildPomeriumHTTPRoutes(options *config.Options, domain string) []*envoy_config_route_v3.Route { routes := []*envoy_config_route_v3.Route{ + // enable ext_authz + buildControlPlaneProtectedPathRoute("/.pomerium/jwt"), + // disable ext_authz and passthrough to proxy handlers buildControlPlanePathRoute("/ping"), buildControlPlanePathRoute("/healthz"), buildControlPlanePathRoute("/.pomerium"), @@ -60,11 +68,80 @@ func buildPomeriumHTTPRoutes(options *config.Options, domain string) []*envoy_co } // if we're the proxy and this is the forward-auth url if config.IsProxy(options.Services) && options.ForwardAuthURL != nil && hostMatchesDomain(options.GetForwardAuthURL(), domain) { - routes = append(routes, buildControlPlanePrefixRoute("/")) + routes = append(routes, + // disable ext_authz and pass request to proxy handlers that enable authN flow + buildControlPlanePathAndQueryRoute("/verify", []string{urlutil.QueryForwardAuthURI, urlutil.QuerySessionEncrypted, urlutil.QueryRedirectURI}), + buildControlPlanePathAndQueryRoute("/", []string{urlutil.QueryForwardAuthURI, urlutil.QuerySessionEncrypted, urlutil.QueryRedirectURI}), + buildControlPlanePathAndQueryRoute("/", []string{urlutil.QueryForwardAuthURI}), + // otherwise, enforce ext_authz; pass all other requests through to an upstream + // handler that will simply respond with http status 200 / OK indicating that + // the fronting forward-auth proxy can continue. + buildControlPlaneProtectedPrefixRoute("/")) } return routes } +func buildControlPlaneProtectedPrefixRoute(prefix string) *envoy_config_route_v3.Route { + return &envoy_config_route_v3.Route{ + Name: "pomerium-protected-prefix-" + prefix, + Match: &envoy_config_route_v3.RouteMatch{ + PathSpecifier: &envoy_config_route_v3.RouteMatch_Prefix{Prefix: prefix}, + }, + Action: &envoy_config_route_v3.Route_Route{ + Route: &envoy_config_route_v3.RouteAction{ + ClusterSpecifier: &envoy_config_route_v3.RouteAction_Cluster{ + Cluster: httpCluster, + }, + }, + }, + } +} + +func buildControlPlaneProtectedPathRoute(path string) *envoy_config_route_v3.Route { + return &envoy_config_route_v3.Route{ + Name: "pomerium-protected-path-" + path, + Match: &envoy_config_route_v3.RouteMatch{ + PathSpecifier: &envoy_config_route_v3.RouteMatch_Path{Path: path}, + }, + Action: &envoy_config_route_v3.Route_Route{ + Route: &envoy_config_route_v3.RouteAction{ + ClusterSpecifier: &envoy_config_route_v3.RouteAction_Cluster{ + Cluster: httpCluster, + }, + }, + }, + } +} + +func buildControlPlanePathAndQueryRoute(path string, queryparams []string) *envoy_config_route_v3.Route { + var queryParameterMatchers []*envoy_config_route_v3.QueryParameterMatcher + for _, q := range queryparams { + queryParameterMatchers = append(queryParameterMatchers, + &envoy_config_route_v3.QueryParameterMatcher{ + Name: q, + QueryParameterMatchSpecifier: &envoy_config_route_v3.QueryParameterMatcher_PresentMatch{PresentMatch: true}, + }) + } + + return &envoy_config_route_v3.Route{ + Name: "pomerium-path-and-query" + path, + Match: &envoy_config_route_v3.RouteMatch{ + PathSpecifier: &envoy_config_route_v3.RouteMatch_Path{Path: path}, + QueryParameters: queryParameterMatchers, + }, + Action: &envoy_config_route_v3.Route_Route{ + Route: &envoy_config_route_v3.RouteAction{ + ClusterSpecifier: &envoy_config_route_v3.RouteAction_Cluster{ + Cluster: httpCluster, + }, + }, + }, + TypedPerFilterConfig: map[string]*any.Any{ + "envoy.filters.http.ext_authz": disableExtAuthz, + }, + } +} + func buildControlPlanePathRoute(path string) *envoy_config_route_v3.Route { return &envoy_config_route_v3.Route{ Name: "pomerium-path-" + path, @@ -74,7 +151,7 @@ func buildControlPlanePathRoute(path string) *envoy_config_route_v3.Route { Action: &envoy_config_route_v3.Route_Route{ Route: &envoy_config_route_v3.RouteAction{ ClusterSpecifier: &envoy_config_route_v3.RouteAction_Cluster{ - Cluster: "pomerium-control-plane-http", + Cluster: httpCluster, }, }, }, @@ -93,7 +170,7 @@ func buildControlPlanePrefixRoute(prefix string) *envoy_config_route_v3.Route { Action: &envoy_config_route_v3.Route_Route{ Route: &envoy_config_route_v3.RouteAction{ ClusterSpecifier: &envoy_config_route_v3.RouteAction_Cluster{ - Cluster: "pomerium-control-plane-http", + Cluster: httpCluster, }, }, }, diff --git a/internal/controlplane/xds_routes_test.go b/internal/controlplane/xds_routes_test.go index e236e4c70..5978e83ea 100644 --- a/internal/controlplane/xds_routes_test.go +++ b/internal/controlplane/xds_routes_test.go @@ -59,7 +59,17 @@ func Test_buildPomeriumHTTPRoutes(t *testing.T) { } }` } - + protectedRouteString := func(typ, name string) string { + return `{ + "name": "pomerium-protected-` + typ + `-` + name + `", + "match": { + "` + typ + `": "` + name + `" + }, + "route": { + "cluster": "pomerium-control-plane-http" + } + }` + } t.Run("authenticate", func(t *testing.T) { options := &config.Options{ Services: "all", @@ -70,6 +80,7 @@ func Test_buildPomeriumHTTPRoutes(t *testing.T) { routes := buildPomeriumHTTPRoutes(options, "authenticate.example.com") testutil.AssertProtoJSONEqual(t, `[ + `+protectedRouteString("path", "/.pomerium/jwt")+`, `+routeString("path", "/ping")+`, `+routeString("path", "/healthz")+`, `+routeString("path", "/.pomerium")+`, @@ -96,6 +107,7 @@ func Test_buildPomeriumHTTPRoutes(t *testing.T) { routes := buildPomeriumHTTPRoutes(options, "from.example.com") testutil.AssertProtoJSONEqual(t, `[ + `+protectedRouteString("path", "/.pomerium/jwt")+`, `+routeString("path", "/ping")+`, `+routeString("path", "/healthz")+`, `+routeString("path", "/.pomerium")+`, @@ -122,6 +134,7 @@ func Test_buildPomeriumHTTPRoutes(t *testing.T) { routes := buildPomeriumHTTPRoutes(options, "from.example.com") testutil.AssertProtoJSONEqual(t, `[ + `+protectedRouteString("path", "/.pomerium/jwt")+`, `+routeString("path", "/ping")+`, `+routeString("path", "/healthz")+`, `+routeString("path", "/.pomerium")+`, diff --git a/internal/urlutil/query_params.go b/internal/urlutil/query_params.go index 2ef32249b..c56b92590 100644 --- a/internal/urlutil/query_params.go +++ b/internal/urlutil/query_params.go @@ -15,6 +15,7 @@ const ( QuerySessionEncrypted = "pomerium_session_encrypted" QueryRedirectURI = "pomerium_redirect_uri" QueryProgrammaticToken = "pomerium_programmatic_token" + QueryForwardAuthURI = "uri" ) // URL signature based query params used for verifying the authenticity of a URL. diff --git a/internal/urlutil/url.go b/internal/urlutil/url.go index 8ea47003e..f23bd3b80 100644 --- a/internal/urlutil/url.go +++ b/internal/urlutil/url.go @@ -101,3 +101,18 @@ func GetDomainsForURL(u *url.URL) []string { // for everything else we return two routes: 'example.com' and 'example.com:443' return []string{u.Hostname(), net.JoinHostPort(u.Hostname(), defaultPort)} } + +// ParseEnvoyQueryParams returns a new URL with queryparams parsed from envoy format. +func ParseEnvoyQueryParams(u *url.URL) *url.URL { + nu := &url.URL{ + Scheme: u.Scheme, + Host: u.Host, + Path: u.Path, + } + + path := u.Path + if idx := strings.Index(path, "?"); idx != -1 { + nu.Path, nu.RawQuery = path[:idx], path[idx+1:] + } + return nu +} diff --git a/internal/urlutil/url_test.go b/internal/urlutil/url_test.go index e6e6ecad6..25eb2b33d 100644 --- a/internal/urlutil/url_test.go +++ b/internal/urlutil/url_test.go @@ -157,3 +157,23 @@ func TestGetDomainsForURL(t *testing.T) { }) } } + +func TestParseEnvoyQueryParams(t *testing.T) { + tests := []struct { + name string + u *url.URL + want *url.URL + }{ + {"empty", &url.URL{}, &url.URL{}}, + {"basic example", &url.URL{Host: "pomerium.io", Path: "/?uri=https://pomerium.com/"}, &url.URL{Host: "pomerium.io", Path: "/", RawQuery: "uri=https://pomerium.com/"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + got := ParseEnvoyQueryParams(tt.u) + if diff := cmp.Diff(got, tt.want); diff != "" { + t.Errorf("ParseEnvoyQueryParams() = %v", diff) + } + }) + } +} diff --git a/proxy/forward_auth.go b/proxy/forward_auth.go index 03aad001f..fce70a173 100644 --- a/proxy/forward_auth.go +++ b/proxy/forward_auth.go @@ -7,7 +7,6 @@ import ( "net/url" "github.com/pomerium/pomerium/internal/httputil" - "github.com/pomerium/pomerium/internal/sessions" "github.com/pomerium/pomerium/internal/urlutil" ) @@ -17,11 +16,6 @@ import ( // see : https://www.pomerium.io/configuration/#forward-auth func (p *Proxy) registerFwdAuthHandlers() http.Handler { r := httputil.NewRouter() - r.StrictSlash(true) - r.Use(func(h http.Handler) http.Handler { - return sessions.RetrieveSession(p.state.Load().sessionStore)(h) - }) - r.Use(p.jwtClaimMiddleware(true)) // NGNIX's forward-auth capabilities are split across two settings: // `auth-url` and `auth-signin` which correspond to `verify` and `auth-url` @@ -31,21 +25,29 @@ func (p *Proxy) registerFwdAuthHandlers() http.Handler { // nginx 3: save the returned session post authenticate flow r.Handle("/verify", httputil.HandlerFunc(p.nginxCallback)). - Queries("uri", "{uri}", urlutil.QuerySessionEncrypted, "", urlutil.QueryRedirectURI, "") + Queries(urlutil.QueryForwardAuthURI, "{uri}", + urlutil.QuerySessionEncrypted, "", + urlutil.QueryRedirectURI, "") - // nginx 1: verify. Return 401 if invalid and NGINX will call `auth-signin` - r.Handle("/verify", p.Verify(true)).Queries("uri", "{uri}") + // nginx 1: verify, fronted by ext_authz + r.Handle("/verify", httputil.HandlerFunc(p.allowUpstream)). + Queries(urlutil.QueryForwardAuthURI, "{uri}") // nginx 4: redirect the user back to their originally requested location. r.Handle("/", httputil.HandlerFunc(p.nginxPostCallbackRedirect)). - Queries("uri", "{uri}", urlutil.QuerySessionEncrypted, "", urlutil.QueryRedirectURI, "") + Queries(urlutil.QueryForwardAuthURI, "{uri}", + urlutil.QuerySessionEncrypted, "", + urlutil.QueryRedirectURI, "") // traefik 2: save the returned session post authenticate flow r.Handle("/", httputil.HandlerFunc(p.forwardedURIHeaderCallback)). HeadersRegexp(httputil.HeaderForwardedURI, urlutil.QuerySessionEncrypted) + r.Handle("/", httputil.HandlerFunc(p.startAuthN)). + Queries(urlutil.QueryForwardAuthURI, "{uri}") + // nginx 2 / traefik 1: verify and then start authenticate flow - r.Handle("/", p.Verify(false)) + r.Handle("/", httputil.HandlerFunc(p.allowUpstream)) return r } @@ -53,8 +55,15 @@ func (p *Proxy) registerFwdAuthHandlers() http.Handler { // nginxPostCallbackRedirect redirects the user to their original destination // in order to drop the authenticate related query params func (p *Proxy) nginxPostCallbackRedirect(w http.ResponseWriter, r *http.Request) error { - w.Header().Set("Content-Type", "text/plain; charset=utf-8") - httputil.Redirect(w, r, r.FormValue(urlutil.QueryRedirectURI), http.StatusFound) + u, err := url.Parse(r.FormValue(urlutil.QueryRedirectURI)) + if err != nil { + return httputil.NewError(http.StatusBadRequest, err) + } + u = urlutil.ParseEnvoyQueryParams(u) + q := u.Query() + q.Del(urlutil.QueryForwardAuthURI) + u.RawQuery = q.Encode() + httputil.Redirect(w, r, u.String(), http.StatusFound) return nil } @@ -67,9 +76,7 @@ func (p *Proxy) nginxCallback(w http.ResponseWriter, r *http.Request) error { if _, err := p.saveCallbackSession(w, r, encryptedSession); err != nil { return httputil.NewError(http.StatusBadRequest, err) } - w.Header().Set("Content-Type", "text/plain; charset=utf-8") - w.WriteHeader(http.StatusUnauthorized) - return nil + return httputil.NewError(http.StatusUnauthorized, errors.New("mock error to restart redirect flow")) } // forwardedURIHeaderCallback handles the post-authentication callback from @@ -86,74 +93,42 @@ func (p *Proxy) forwardedURIHeaderCallback(w http.ResponseWriter, r *http.Reques if _, err := p.saveCallbackSession(w, r, encryptedSession); err != nil { return httputil.NewError(http.StatusBadRequest, err) } - w.Header().Set("Content-Type", "text/plain; charset=utf-8") httputil.Redirect(w, r, redirectURLString, http.StatusFound) return nil } -// Verify checks a user's credentials for an arbitrary host. If the user -// is properly authenticated and is authorized to access the supplied host, -// a `200` http status code is returned. If the user is not authenticated, they -// will be redirected to the authenticate service to sign in with their identity -// provider. If the user is unauthorized, a `401` error is returned. -func (p *Proxy) Verify(verifyOnly bool) http.Handler { - return httputil.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { - state := p.state.Load() - - var err error - if status := r.FormValue("auth_status"); status == fmt.Sprint(http.StatusForbidden) { - return httputil.NewError(http.StatusForbidden, errors.New(http.StatusText(http.StatusForbidden))) - } - - uri, err := getURIStringFromRequest(r) - if err != nil { - return httputil.NewError(http.StatusBadRequest, err) - } - - ar, err := p.isAuthorized(w, r) - if err != nil { - return httputil.NewError(http.StatusBadRequest, err) - } - - if ar.authorized { - w.Header().Set("Content-Type", "text/plain; charset=utf-8") - w.WriteHeader(http.StatusOK) - fmt.Fprintf(w, "Access to %s is allowed.", uri.Host) - return nil - } - - unAuthenticated := ar.statusCode == http.StatusUnauthorized - if unAuthenticated { - state.sessionStore.ClearSession(w, r) - } - - _, err = sessions.FromContext(r.Context()) - hasSession := err == nil - if hasSession && !unAuthenticated { - return httputil.NewError(http.StatusForbidden, errors.New("access denied")) - } - - if verifyOnly { - return httputil.NewError(http.StatusUnauthorized, err) - } - - p.forwardAuthRedirectToSignInWithURI(w, r, uri) - return nil - }) +// allowUpstream will return status 200 (OK) unless auth_status is set to forbidden. +// This handler is expected to be behind a routed protected by envoy's control plane (ext_authz). +func (p *Proxy) allowUpstream(w http.ResponseWriter, r *http.Request) error { + if status := r.FormValue("auth_status"); status == fmt.Sprint(http.StatusForbidden) { + return httputil.NewError(http.StatusForbidden, errors.New(http.StatusText(http.StatusForbidden))) + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.WriteHeader(http.StatusOK) + fmt.Fprintln(w, http.StatusText(http.StatusOK)) + return nil } -// forwardAuthRedirectToSignInWithURI redirects request to authenticate signin url, -// with all necessary information extracted from given input uri. -func (p *Proxy) forwardAuthRedirectToSignInWithURI(w http.ResponseWriter, r *http.Request, uri *url.URL) { +// startAuthN redirects an unauthenticated user to start forward-auth +// authentication flow +func (p *Proxy) startAuthN(w http.ResponseWriter, r *http.Request) error { state := p.state.Load() - - // Traefik set the uri in the header, we must set it in redirect uri if present. Otherwise, request like - // https://example.com/foo will be redirected to https://example.com after authentication. + uriString := r.FormValue(urlutil.QueryForwardAuthURI) + if uriString == "" { + uriString = "https://" + // always use HTTPS for external urls + r.Header.Get(httputil.HeaderForwardedHost) + + r.Header.Get(httputil.HeaderForwardedURI) + } + uri, err := urlutil.ParseAndValidateURL(uriString) + if err != nil { + return httputil.NewError(http.StatusBadRequest, err) + } + // add any non-empty existing path from the forwarded URI if xfu := r.Header.Get(httputil.HeaderForwardedURI); xfu != "" && xfu != "/" { uri.Path = xfu } - // redirect to authenticate authN := *state.authenticateSigninURL q := authN.Query() q.Set(urlutil.QueryCallbackURI, uri.String()) @@ -161,25 +136,5 @@ func (p *Proxy) forwardAuthRedirectToSignInWithURI(w http.ResponseWriter, r *htt q.Set(urlutil.QueryForwardAuth, urlutil.StripPort(r.Host)) // add fwd auth to trusted audience authN.RawQuery = q.Encode() httputil.Redirect(w, r, urlutil.NewSignedURL(state.sharedKey, &authN).String(), http.StatusFound) -} - -func getURIStringFromRequest(r *http.Request) (*url.URL, error) { - // the route to validate will be pulled from the uri queryparam - // or inferred from forwarding headers - uriString := r.FormValue("uri") - if uriString == "" { - if r.Header.Get(httputil.HeaderForwardedHost) == "" { - return nil, errors.New("no uri to validate") - } - // Always assume HTTPS for application callback - uriString = "https://" + - r.Header.Get(httputil.HeaderForwardedHost) + - r.Header.Get(httputil.HeaderForwardedURI) - } - - uri, err := urlutil.ParseAndValidateURL(uriString) - if err != nil { - return nil, err - } - return uri, nil + return nil } diff --git a/proxy/forward_auth_test.go b/proxy/forward_auth_test.go index 9e3bfde9e..510fce788 100644 --- a/proxy/forward_auth_test.go +++ b/proxy/forward_auth_test.go @@ -63,13 +63,10 @@ func TestProxy_ForwardAuth(t *testing.T) { wantStatus int wantBody string }{ - {"good redirect not required", opts, nil, http.MethodGet, nil, nil, "https://some.domain.example/", "https://some.domain.example", &mock.Encoder{}, &mstore.Store{Session: &sessions.State{Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, allowClient, http.StatusOK, "Access to some.domain.example is allowed."}, {"good verify only, no redirect", opts, nil, http.MethodGet, nil, nil, "https://some.domain.example/verify", "https://some.domain.example", &mock.Encoder{}, &mstore.Store{Session: &sessions.State{Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, allowClient, http.StatusOK, ""}, - {"bad empty domain uri", opts, nil, http.MethodGet, nil, map[string]string{"uri": ""}, "https://some.domain.example/", "", &mock.Encoder{}, &mstore.Store{Session: &sessions.State{Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, allowClient, http.StatusBadRequest, "{\"Status\":400,\"Error\":\"Bad Request: no uri to validate\"}\n"}, + {"bad empty domain uri", opts, nil, http.MethodGet, nil, map[string]string{"uri": ""}, "https://some.domain.example/", "", &mock.Encoder{}, &mstore.Store{Session: &sessions.State{Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, allowClient, http.StatusBadRequest, "{\"Status\":400,\"Error\":\"Bad Request: https: url does contain a valid hostname\"}\n"}, {"bad naked domain uri", opts, nil, http.MethodGet, nil, nil, "https://some.domain.example/", "a.naked.domain", &mock.Encoder{}, &mstore.Store{Session: &sessions.State{Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, allowClient, http.StatusBadRequest, "{\"Status\":400,\"Error\":\"Bad Request: a.naked.domain url does contain a valid scheme\"}\n"}, - {"bad naked domain uri verify only", opts, nil, http.MethodGet, nil, nil, "https://some.domain.example/verify", "a.naked.domain", &mock.Encoder{}, &mstore.Store{Session: &sessions.State{Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, allowClient, http.StatusBadRequest, "{\"Status\":400,\"Error\":\"Bad Request: a.naked.domain url does contain a valid scheme\"}\n"}, {"bad empty verification uri", opts, nil, http.MethodGet, nil, nil, "https://some.domain.example/", " ", &mock.Encoder{}, &mstore.Store{Session: &sessions.State{Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, allowClient, http.StatusBadRequest, "{\"Status\":400,\"Error\":\"Bad Request: %20 url does contain a valid scheme\"}\n"}, - {"bad empty verification uri verify only", opts, nil, http.MethodGet, nil, nil, "https://some.domain.example/verify", " ", &mock.Encoder{}, &mstore.Store{Session: &sessions.State{Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, allowClient, http.StatusBadRequest, "{\"Status\":400,\"Error\":\"Bad Request: %20 url does contain a valid scheme\"}\n"}, // traefik {"good traefik callback", opts, nil, http.MethodGet, map[string]string{httputil.HeaderForwardedURI: "https://some.domain.example?" + urlutil.QuerySessionEncrypted + "=" + goodEncryptionString}, nil, "https://some.domain.example/", "https://some.domain.example", &mock.Encoder{}, &mstore.Store{Session: &sessions.State{Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, allowClient, http.StatusFound, ""}, {"bad traefik callback bad session", opts, nil, http.MethodGet, map[string]string{httputil.HeaderForwardedURI: "https://some.domain.example?" + urlutil.QuerySessionEncrypted + "=" + goodEncryptionString + "garbage"}, nil, "https://some.domain.example/", "https://some.domain.example", &mock.Encoder{}, &mstore.Store{Session: &sessions.State{Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, allowClient, http.StatusBadRequest, ""}, @@ -90,7 +87,6 @@ func TestProxy_ForwardAuth(t *testing.T) { } p.OnConfigChange(&config.Config{Options: tt.options}) state := p.state.Load() - state.authzClient = tt.authorizer state.sessionStore = tt.sessionStore signer, err := jws.NewHS256Signer(nil, "mock") if err != nil { diff --git a/proxy/handlers.go b/proxy/handlers.go index c05c94618..f2e5d8831 100644 --- a/proxy/handlers.go +++ b/proxy/handlers.go @@ -2,6 +2,7 @@ package proxy import ( "encoding/base64" + "errors" "fmt" "io" "net/http" @@ -160,28 +161,19 @@ func (p *Proxy) ProgrammaticLogin(w http.ResponseWriter, r *http.Request) error w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.WriteHeader(http.StatusOK) - w.Write([]byte(response)) + _, _ = io.WriteString(w, response) return nil } +// jwtAssertion returns the current user's/request's JWT (rfc7519#section-10.3.1) that should be +// added from the downstream request. func (p *Proxy) jwtAssertion(w http.ResponseWriter, r *http.Request) error { - res, err := p.authorizeCheck(r) - if err != nil { - return httputil.NewError(http.StatusInternalServerError, err) + assertionJWT := r.Header.Get(httputil.HeaderPomeriumJWTAssertion) + if assertionJWT == "" { + return httputil.NewError(http.StatusNotFound, errors.New("jwt not found")) } - - headers := append(res.GetOkResponse().GetHeaders(), res.GetDeniedResponse().GetHeaders()...) - for _, h := range headers { - if h.GetHeader().GetKey() == httputil.HeaderPomeriumJWTAssertion { - w.Header().Set("Content-Type", "application/jwt") - w.WriteHeader(http.StatusOK) - _, _ = io.WriteString(w, h.GetHeader().GetValue()) - return nil - } - } - - w.Header().Set("Content-Type", "text/plain") - w.WriteHeader(http.StatusNotFound) - _, _ = io.WriteString(w, "jwt not found") + w.Header().Set("Content-Type", "application/jwt") + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, assertionJWT) return nil } diff --git a/proxy/handlers_test.go b/proxy/handlers_test.go index 5231d7156..47c03dabe 100644 --- a/proxy/handlers_test.go +++ b/proxy/handlers_test.go @@ -11,21 +11,17 @@ import ( "testing" "time" - envoy_api_v2_core "github.com/envoyproxy/go-control-plane/envoy/api/v2/core" - envoy_service_auth_v2 "github.com/envoyproxy/go-control-plane/envoy/service/auth/v2" - "github.com/stretchr/testify/assert" - - mstore "github.com/pomerium/pomerium/internal/sessions/mock" - "github.com/pomerium/pomerium/pkg/cryptutil" - "github.com/pomerium/pomerium/config" "github.com/pomerium/pomerium/internal/encoding" "github.com/pomerium/pomerium/internal/encoding/mock" "github.com/pomerium/pomerium/internal/httputil" "github.com/pomerium/pomerium/internal/sessions" + mstore "github.com/pomerium/pomerium/internal/sessions/mock" "github.com/pomerium/pomerium/internal/urlutil" + "github.com/pomerium/pomerium/pkg/cryptutil" "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/assert" "gopkg.in/square/go-jose.v2/jwt" ) @@ -68,39 +64,6 @@ func TestProxy_Signout(t *testing.T) { } } -func TestProxy_jwt(t *testing.T) { - authzClient := &mockCheckClient{ - response: &envoy_service_auth_v2.CheckResponse{ - HttpResponse: &envoy_service_auth_v2.CheckResponse_OkResponse{ - OkResponse: &envoy_service_auth_v2.OkHttpResponse{ - Headers: []*envoy_api_v2_core.HeaderValueOption{ - {Header: &envoy_api_v2_core.HeaderValue{ - Key: httputil.HeaderPomeriumJWTAssertion, - Value: "MOCK_JWT", - }}, - }, - }, - }, - }, - } - - req, _ := http.NewRequest("GET", "https://www.example.com/.pomerium/jwt", nil) - w := httptest.NewRecorder() - - proxy := &Proxy{ - state: newAtomicProxyState(&proxyState{ - authzClient: authzClient, - }), - } - err := proxy.jwtAssertion(w, req) - if !assert.NoError(t, err) { - return - } - - assert.Equal(t, "application/jwt", w.Header().Get("Content-Type")) - assert.Equal(t, w.Body.String(), "MOCK_JWT") -} - func TestProxy_UserDashboard(t *testing.T) { opts := testOptions(t) err := ValidateOptions(opts) @@ -532,3 +495,29 @@ func TestProxy_ProgrammaticCallback(t *testing.T) { }) } } + +func TestProxy_jwt(t *testing.T) { + + // without downstream headers being set + req, _ := http.NewRequest("GET", "https://www.example.com/.pomerium/jwt", nil) + w := httptest.NewRecorder() + + proxy := &Proxy{ + state: newAtomicProxyState(&proxyState{}), + } + err := proxy.jwtAssertion(w, req) + if !assert.Error(t, err) { + return + } + + // with downstream request headers being set + req, _ = http.NewRequest("GET", "https://www.example.com/.pomerium/jwt", nil) + w = httptest.NewRecorder() + req.Header.Set(httputil.HeaderPomeriumJWTAssertion, "MOCK_JWT") + err = proxy.jwtAssertion(w, req) + if !assert.NoError(t, err) { + return + } + assert.Equal(t, "application/jwt", w.Header().Get("Content-Type")) + assert.Equal(t, w.Body.String(), "MOCK_JWT") +} diff --git a/proxy/middleware.go b/proxy/middleware.go deleted file mode 100644 index 28328422c..000000000 --- a/proxy/middleware.go +++ /dev/null @@ -1,156 +0,0 @@ -package proxy - -import ( - "fmt" - "net/http" - "strings" - "time" - - envoy_service_auth_v2 "github.com/envoyproxy/go-control-plane/envoy/service/auth/v2" - "github.com/golang/protobuf/ptypes" - "github.com/gorilla/mux" - "github.com/rs/zerolog" - - "github.com/pomerium/pomerium/internal/httputil" - "github.com/pomerium/pomerium/internal/log" - "github.com/pomerium/pomerium/internal/sessions" -) - -type authorizeResponse struct { - authorized bool - statusCode int32 -} - -func (p *Proxy) isAuthorized(w http.ResponseWriter, r *http.Request) (*authorizeResponse, error) { - res, err := p.authorizeCheck(r) - if err != nil { - return nil, httputil.NewError(http.StatusInternalServerError, err) - } - - ar := &authorizeResponse{} - switch res.HttpResponse.(type) { - case *envoy_service_auth_v2.CheckResponse_OkResponse: - for _, hdr := range res.GetOkResponse().GetHeaders() { - w.Header().Set(hdr.GetHeader().GetKey(), hdr.GetHeader().GetValue()) - } - ar.authorized = true - ar.statusCode = res.GetStatus().Code - case *envoy_service_auth_v2.CheckResponse_DeniedResponse: - ar.statusCode = int32(res.GetDeniedResponse().GetStatus().Code) - default: - ar.statusCode = http.StatusInternalServerError - } - return ar, nil -} - -func (p *Proxy) authorizeCheck(r *http.Request) (*envoy_service_auth_v2.CheckResponse, error) { - state := p.state.Load() - - tm, err := ptypes.TimestampProto(time.Now()) - if err != nil { - return nil, httputil.NewError(http.StatusInternalServerError, fmt.Errorf("error creating protobuf timestamp from current time: %w", err)) - } - - httpAttrs := &envoy_service_auth_v2.AttributeContext_HttpRequest{ - Method: "GET", - Headers: map[string]string{}, - Path: r.URL.Path, - Host: r.Host, - Scheme: r.URL.Scheme, - Fragment: r.URL.Fragment, - } - for k := range r.Header { - httpAttrs.Headers[k] = r.Header.Get(k) - } - if r.URL.RawQuery != "" { - // envoy expects the query string in the path - httpAttrs.Path += "?" + r.URL.RawQuery - } - - return state.authzClient.Check(r.Context(), &envoy_service_auth_v2.CheckRequest{ - Attributes: &envoy_service_auth_v2.AttributeContext{ - Request: &envoy_service_auth_v2.AttributeContext_Request{ - Time: tm, - Http: httpAttrs, - }, - }, - }) -} - -// jwtClaimMiddleware logs and propagates JWT claim information via request headers -// -// if returnJWTInfo is set to true, it will also return JWT claim information in the response -func (p *Proxy) jwtClaimMiddleware(returnJWTInfo bool) mux.MiddlewareFunc { - return func(next http.Handler) http.Handler { - return httputil.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { - defer next.ServeHTTP(w, r) - - state := p.state.Load() - - jwt, err := sessions.FromContext(r.Context()) - if err != nil { - log.Error().Err(err).Msg("proxy: could not locate session from context") - return nil // best effort decoding - } - - formattedJWTClaims, err := p.getFormatedJWTClaims([]byte(jwt)) - if err != nil { - log.Error().Err(err).Msg("proxy: failed to format jwt claims") - return nil // best effort formatting - } - - // log group, email, user claims - l := log.Ctx(r.Context()) - for _, claimName := range []string{"groups", "email", "user"} { - - l.UpdateContext(func(c zerolog.Context) zerolog.Context { - return c.Str(claimName, fmt.Sprintf("%v", formattedJWTClaims[claimName])) - }) - - } - - // set headers for any claims specified by config - for _, claimName := range state.jwtClaimHeaders { - if _, ok := formattedJWTClaims[claimName]; ok { - - headerName := fmt.Sprintf("x-pomerium-claim-%s", claimName) - r.Header.Set(headerName, formattedJWTClaims[claimName]) - if returnJWTInfo { - w.Header().Add(headerName, formattedJWTClaims[claimName]) - } - } - } - - return nil - }) - } -} - -// getFormatJWTClaims reformats jwtClaims into something resembling map[string]string -func (p *Proxy) getFormatedJWTClaims(jwt []byte) (map[string]string, error) { - state := p.state.Load() - - formattedJWTClaims := make(map[string]string) - - var jwtClaims map[string]interface{} - if err := state.encoder.Unmarshal(jwt, &jwtClaims); err != nil { - return formattedJWTClaims, err - } - - for claim, value := range jwtClaims { - var formattedClaim string - if cv, ok := value.([]interface{}); ok { - elements := make([]string, len(cv)) - - for i, v := range cv { - elements[i] = fmt.Sprintf("%v", v) - } - formattedClaim = strings.Join(elements, ",") - } else { - formattedClaim = fmt.Sprintf("%v", value) - } - formattedJWTClaims[claim] = formattedClaim - } - - return formattedJWTClaims, nil -} diff --git a/proxy/middleware_test.go b/proxy/middleware_test.go deleted file mode 100644 index b7f79eb22..000000000 --- a/proxy/middleware_test.go +++ /dev/null @@ -1,55 +0,0 @@ -package proxy - -import ( - "net/http" - "net/http/httptest" - "testing" - "time" - - "gopkg.in/square/go-jose.v2/jwt" - - "github.com/pomerium/pomerium/internal/encoding/jws" - "github.com/pomerium/pomerium/internal/sessions" -) - -func Test_jwtClaimMiddleware(t *testing.T) { - claimHeaders := []string{"email", "groups", "missing"} - sharedKey := "80ldlrU2d7w+wVpKNfevk6fmb8otEx6CqOfshj2LwhQ=" - - session := &sessions.State{Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Second))} - encoder, _ := jws.NewHS256Signer([]byte(sharedKey), "https://authenticate.pomerium.example") - state, err := encoder.Marshal(session) - - if err != nil { - t.Errorf("failed to marshal state: %s", err) - } - - a := Proxy{ - state: newAtomicProxyState(&proxyState{ - sharedKey: sharedKey, - cookieSecret: []byte("80ldlrU2d7w+wVpKNfevk6fmb8otEx6CqOfshj2LwhQ="), - encoder: encoder, - jwtClaimHeaders: claimHeaders, - }), - } - - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - }) - - r := httptest.NewRequest(http.MethodGet, "/", nil) - ctx := r.Context() - ctx = sessions.NewContext(ctx, string(state), nil) - r = r.WithContext(ctx) - w := httptest.NewRecorder() - proxyHandler := a.jwtClaimMiddleware(true)(handler) - proxyHandler.ServeHTTP(w, r) - - t.Run("missing claim", func(t *testing.T) { - absentHeader := r.Header.Get("x-pomerium-claim-missing") - if absentHeader != "" { - t.Errorf("found claim that should not exist, got=%q", absentHeader) - } - }) - -} diff --git a/proxy/proxy.go b/proxy/proxy.go index fa164f404..de2c96f91 100644 --- a/proxy/proxy.go +++ b/proxy/proxy.go @@ -44,9 +44,6 @@ func ValidateOptions(o *config.Options) error { return fmt.Errorf("proxy: invalid 'AUTHENTICATE_SERVICE_URL': %w", err) } - if err := urlutil.ValidateURL(o.AuthorizeURL); err != nil { - return fmt.Errorf("proxy: invalid 'AUTHORIZE_SERVICE_URL': %w", err) - } return nil } diff --git a/proxy/proxy_test.go b/proxy/proxy_test.go index 898d92ba3..f0483d587 100644 --- a/proxy/proxy_test.go +++ b/proxy/proxy_test.go @@ -13,7 +13,6 @@ import ( func testOptions(t *testing.T) *config.Options { opts := config.NewDefaultOptions() opts.AuthenticateURLString = "https://authenticate.example" - opts.AuthorizeURLString = "https://authorize.example" testPolicy := config.Policy{From: "https://corp.example.example", To: "https://example.example"} opts.Policies = []config.Policy{testPolicy} @@ -38,10 +37,6 @@ func TestOptions_Validate(t *testing.T) { authurl, _ := url.Parse("authenticate.corp.beyondperimeter.com") authenticateBadScheme := testOptions(t) authenticateBadScheme.AuthenticateURL = authurl - authorizeBadSCheme := testOptions(t) - authorizeBadSCheme.AuthorizeURL = authurl - authorizeNil := testOptions(t) - authorizeNil.AuthorizeURL = nil emptyCookieSecret := testOptions(t) emptyCookieSecret.CookieSecret = "" invalidCookieSecret := testOptions(t) @@ -64,8 +59,6 @@ func TestOptions_Validate(t *testing.T) { {"nil options", &config.Options{}, true}, {"authenticate service url", badAuthURL, true}, {"authenticate service url no scheme", authenticateBadScheme, true}, - {"authorize service url no scheme", authorizeBadSCheme, true}, - {"authorize service cannot be nil", authorizeNil, true}, {"no cookie secret", emptyCookieSecret, true}, {"invalid cookie secret", invalidCookieSecret, true}, {"short cookie secret", shortCookieLength, true}, diff --git a/proxy/state.go b/proxy/state.go index 745831dba..70fe99858 100644 --- a/proxy/state.go +++ b/proxy/state.go @@ -7,8 +7,6 @@ import ( "sync/atomic" "time" - envoy_service_auth_v2 "github.com/envoyproxy/go-control-plane/envoy/service/auth/v2" - "github.com/pomerium/pomerium/config" "github.com/pomerium/pomerium/internal/encoding" "github.com/pomerium/pomerium/internal/encoding/jws" @@ -19,14 +17,12 @@ import ( "github.com/pomerium/pomerium/internal/sessions/queryparam" "github.com/pomerium/pomerium/internal/urlutil" "github.com/pomerium/pomerium/pkg/cryptutil" - "github.com/pomerium/pomerium/pkg/grpc" ) type proxyState struct { sharedKey string sharedCipher cipher.AEAD - authorizeURL *url.URL authenticateURL *url.URL authenticateDashboardURL *url.URL authenticateSigninURL *url.URL @@ -39,7 +35,6 @@ type proxyState struct { sessionStore sessions.SessionStore sessionLoaders []sessions.SessionLoader jwtClaimHeaders []string - authzClient envoy_service_auth_v2.AuthorizationClient } func newProxyStateFromConfig(cfg *config.Config) (*proxyState, error) { @@ -63,7 +58,6 @@ func newProxyStateFromConfig(cfg *config.Config) (*proxyState, error) { state.jwtClaimHeaders = cfg.Options.JWTClaimsHeaders // errors checked in ValidateOptions - state.authorizeURL, _ = urlutil.DeepCopy(cfg.Options.AuthorizeURL) state.authenticateURL, _ = urlutil.DeepCopy(cfg.Options.AuthenticateURL) state.authenticateDashboardURL = state.authenticateURL.ResolveReference(&url.URL{Path: dashboardPath}) state.authenticateSigninURL = state.authenticateURL.ResolveReference(&url.URL{Path: signinURL}) @@ -87,21 +81,6 @@ func newProxyStateFromConfig(cfg *config.Config) (*proxyState, error) { header.NewStore(state.encoder, httputil.AuthorizationTypePomerium), queryparam.NewStore(state.encoder, "pomerium_session")} - authzConn, err := grpc.GetGRPCClientConn("authorize", &grpc.Options{ - Addr: state.authorizeURL, - OverrideCertificateName: cfg.Options.OverrideCertificateName, - CA: cfg.Options.CA, - CAFile: cfg.Options.CAFile, - RequestTimeout: cfg.Options.GRPCClientTimeout, - ClientDNSRoundRobin: cfg.Options.GRPCClientDNSRoundRobin, - WithInsecure: cfg.Options.GRPCInsecure, - ServiceName: cfg.Options.Services, - }) - if err != nil { - return nil, err - } - state.authzClient = envoy_service_auth_v2.NewAuthorizationClient(authzConn) - return state, nil }