From eaa1e7a4fbe7432605d6512cfd23393383039d3f Mon Sep 17 00:00:00 2001 From: Bobby DeSimone Date: Thu, 3 Oct 2019 21:22:44 -0700 Subject: [PATCH] proxy: support external access control requests (#324) Signed-off-by: Bobby DeSimone --- authenticate/handlers.go | 9 +- docs/docs/CHANGELOG.md | 35 +- docs/docs/reference/img/auth-flow-diagram.svg | 399 ++++++++++++++++++ docs/docs/reference/reference.md | 85 +++- internal/config/options.go | 19 + internal/config/policy.go | 9 + internal/templates/templates.go | 2 +- proxy/handlers.go | 51 +++ proxy/handlers_test.go | 229 +++++----- proxy/middleware.go | 20 +- proxy/proxy.go | 5 + 11 files changed, 730 insertions(+), 133 deletions(-) create mode 100644 docs/docs/reference/img/auth-flow-diagram.svg diff --git a/authenticate/handlers.go b/authenticate/handlers.go index 59a19bf74..e26a450b3 100644 --- a/authenticate/handlers.go +++ b/authenticate/handlers.go @@ -113,6 +113,13 @@ func (a *Authenticate) SignIn(w http.ResponseWriter, r *http.Request) { httputil.ErrorResponse(w, r, httputil.Error("malformed redirect_uri", http.StatusBadRequest, err)) return } + // Add query param to let downstream apps (or auth endpoints) know + // this request followed authentication. Useful for auth-forward-endpoint + // redirecting + q := redirectURL.Query() + q.Add("pomerium-auth-callback", "true") + redirectURL.RawQuery = q.Encode() + http.Redirect(w, r, redirectURL.String(), http.StatusFound) } @@ -142,8 +149,6 @@ func (a *Authenticate) SignOut(w http.ResponseWriter, r *http.Request) { // user to their respective identity provider. This function also builds the // 'state' parameter which is encrypted and includes authenticating data // for validation. -// 'state' is : nonce|timestamp|redirect_url|encrypt(redirect_url)+mac(nonce,ts)) - // https://openid.net/specs/openid-connect-core-1_0-final.html#AuthRequest // https://tools.ietf.org/html/rfc6749#section-4.2.1 func (a *Authenticate) redirectToIdentityProvider(w http.ResponseWriter, r *http.Request) { diff --git a/docs/docs/CHANGELOG.md b/docs/docs/CHANGELOG.md index 7423453f9..44be758ca 100644 --- a/docs/docs/CHANGELOG.md +++ b/docs/docs/CHANGELOG.md @@ -4,29 +4,31 @@ ### New -- Add ability to override HTTPS backend's TLS Server Name. [GH-297](https://github.com/pomerium/pomerium/pull/297) -- Add ability to set pomerium's encrypted session in a auth bearer token, or query param. -- Add host to the main request logger middleware. [GH-308](https://github.com/pomerium/pomerium/issues/308) +- Add endpoint to support "forward-auth" integration with third-party ingresses and proxies. Supports [nginx]https://docs.nginx.com/nginx/admin-guide/security-controls/configuring-subrequest-authentication/, [nginx-ingress](https://kubernetes.github.io/ingress-nginx/examples/auth/oauth-external-auth/), and [Traefik](https://docs.traefik.io/middlewares/forwardauth/). [GH-324] +- Add insecure transport support. [GH-328] +- Add setting to override HTTPS backend's TLS Server Name. [GH-297] +- Add setting to set pomerium's encrypted session in a auth bearer token, or query param. +- Add host to the main request logger middleware. [GH-308] ### Security -- The user's original intended location before completing the authentication process is now encrypted and kept confidential from the identity provider. [GH-316](https://github.com/pomerium/pomerium/pull/316) +- The user's original intended location before completing the authentication process is now encrypted and kept confidential from the identity provider. [GH-316] - Under certain circumstances, where debug logging was enabled, pomerium's shared secret could be leaked to http access logs as a query param. ### Fixed -- Fixed an issue where CSRF would fail if multiple tabs were open. [GH-306](https://github.com/pomerium/pomerium/issues/306) -- Fixed an issue where pomerium would clean double slashes from paths.[GH-262](https://github.com/pomerium/pomerium/issues/262) -- Fixed a bug where the impersonate form would persist an empty string for groups value if none set.[GH-303](https://github.com/pomerium/pomerium/issues/303) +- Fixed an issue where CSRF would fail if multiple tabs were open. [GH-306] +- Fixed an issue where pomerium would clean double slashes from paths. [GH-262] +- Fixed a bug where the impersonate form would persist an empty string for groups value if none set. [GH-303] ### Changed -- The healthcheck endpoints (`/ping`) now returns the http status `405` StatusMethodNotAllowed for non-`GET` requests. [GH-319](https://github.com/pomerium/pomerium/issues/319) +- The healthcheck endpoints (`/ping`) now returns the http status `405` StatusMethodNotAllowed for non-`GET` requests. - Authenticate service no longer uses gRPC. - The global request logger now captures the full array of proxies from `X-Forwarded-For`, in addition to just the client IP. -- Options code refactored to eliminate global Viper state. [GH-332](https://github.com/pomerium/pomerium/pull/332/files) -- Pomerium will no longer default to looking for certificates in the root directory. [GH-328](https://github.com/pomerium/pomerium/issues/328) -- Pomerium will validate that either `insecure_server`, or a valid certificate bundle is set. [GH-328](https://github.com/pomerium/pomerium/issues/328) +- Options code refactored to eliminate global Viper state. [GH-332] +- Pomerium will no longer default to looking for certificates in the root directory. [GH-328] +- Pomerium will validate that either `insecure_server`, or a valid certificate bundle is set. [GH-328] ### Removed @@ -52,7 +54,7 @@ ### Changed -- Pomerium will now strip `_csrf` cookies in addition to session cookies. [GG-285] +- Pomerium will now strip `_csrf` cookies in addition to session cookies. [GH-285] - Disabled gRPC service config. [GH-280] - A policy's custom certificate authority can set as a file or a base64 encoded blob(`tls_custom_ca`/`tls_custom_ca_file`). [GH-259] @@ -272,8 +274,17 @@ [gh-259]: https://github.com/pomerium/pomerium/pull/259 [gh-259]: https://github.com/pomerium/pomerium/pull/259 [gh-261]: https://github.com/pomerium/pomerium/pull/261 +[gh-262]: https://github.com/pomerium/pomerium/issues/262 [gh-266]: https://github.com/pomerium/pomerium/pull/266 [gh-272]: https://github.com/pomerium/pomerium/pull/272 [gh-280]: https://github.com/pomerium/pomerium/issues/280 [gh-284]: https://github.com/pomerium/pomerium/pull/284 [gh-285]: https://github.com/pomerium/pomerium/issues/285 +[gh-297]: https://github.com/pomerium/pomerium/pull/297 +[gh-303]: https://github.com/pomerium/pomerium/issues/303 +[gh-306]: https://github.com/pomerium/pomerium/issues/306 +[gh-308]: https://github.com/pomerium/pomerium/issues/308 +[gh-316]: https://github.com/pomerium/pomerium/pull/316 +[gh-319]: https://github.com/pomerium/pomerium/issues/319 +[gh-328]: https://github.com/pomerium/pomerium/issues/328 +[gh-332]: https://github.com/pomerium/pomerium/pull/332/ diff --git a/docs/docs/reference/img/auth-flow-diagram.svg b/docs/docs/reference/img/auth-flow-diagram.svg new file mode 100644 index 000000000..f361156fe --- /dev/null +++ b/docs/docs/reference/img/auth-flow-diagram.svg @@ -0,0 +1,399 @@ +BrowserIdentity ProviderIngressPomeriumPomerium AuthNPomerium AuthZappGET /app/verify/appAuthenticated?No!HTTP 301 sign in callback urlHTTP 301: Oauth2 callback endpointSave sessionHTTP 301 app/verify/appAuthenticated?Yes!Authorized?Yes?HTTP 200OK!BrowserIdentity ProviderIngressPomeriumPomerium AuthNPomerium AuthZapp \ No newline at end of file diff --git a/docs/docs/reference/reference.md b/docs/docs/reference/reference.md index b2cd953fc..b765311a5 100644 --- a/docs/docs/reference/reference.md +++ b/docs/docs/reference/reference.md @@ -163,7 +163,7 @@ Timeouts set the global server timeouts. For route-specific timeouts, see [polic These settings control upstream connections to the Authorize service. -## GRPC Address +### GRPC Address - Environmental Variable: `GRPC_ADDRESS` - Config File Key: `grpc_address` @@ -173,7 +173,7 @@ These settings control upstream connections to the Authorize service. Address specifies the host and port to serve GRPC requests from. Defaults to `:443` (or `:5443` in all in one mode). -## GRPC Insecure +### GRPC Insecure - Environmental Variable: `GRPC_INSECURE` - Config File Key: `grpc_insecure` @@ -284,6 +284,82 @@ Each unit work is called a Span in a trace. Spans include metadata about the wor ![jaeger example trace](./img/jaeger.png) pomerium_config_last_reload_success_timestamp | Gauge | The timestamp of the last successful configuration reload by service pomerium_build_info | Gauge | Pomerium build metadata by git revision, service, version and goversion +## Forward Auth + +- Environmental Variable: `FORWARD_AUTH_URL` +- Config File Key: `forward_auth_url` +- Type: `URL` (must contain a scheme and hostname) +- Example: `https://fwdauth.corp.example.com` +- Resulting Verification URL: `https://fwdauth.corp.example.com/.pomerium/verify/{URL-TO-VERIFY}` +- Optional + +Forward authentication creates an endpoint that can be used with third-party proxies that do not have rich access control capabilities ([nginx](http://nginx.org/en/docs/http/ngx_http_auth_request_module.html), [nginx-ingress](https://kubernetes.github.io/ingress-nginx/examples/auth/oauth-external-auth/), [ambassador](https://www.getambassador.io/reference/services/auth-service/), [traefik](https://docs.traefik.io/middlewares/forwardauth/)). Forward authentication allow you to delegate authentication and authorization for each request to Pomerium. + +### Request flow + +![pomerium forward auth request flow](./img/auth-flow-diagram.svg) + +### Examples + +#### NGINX Ingress + +Some reverse-proxies, such as nginx split access control flow into two parts: verification and sign-in redirection. Notice the additional the additional `?no_redirect=true` query param in `auth-rul` which tells Pomerium to return a `401` instead of redirecting and starting the sign-in process. + +```yaml +apiVersion: extensions/v1beta1 +kind: Ingress +metadata: + name: httpbin + annotations: + kubernetes.io/ingress.class: "nginx" + certmanager.k8s.io/issuer: "letsencrypt-prod" + nginx.ingress.kubernetes.io/auth-url: https://fwdauth.corp.example.com/.pomerium/verify/httpbin.corp.example.com?no_redirect=true + nginx.ingress.kubernetes.io/auth-signin: https://fwdauth.corp.example.com/.pomerium/verify/httpbin.corp.example.com +spec: + tls: + - hosts: + - httpbin.corp.example.com + secretName: quickstart-example-tls + rules: + - host: httpbin.corp.example.com + http: + paths: + - path: / + backend: + serviceName: httpbin + servicePort: 80 +``` + +### Traefik docker-compose + +```yml +version: "3" + +services: + traefik: + # The official v2.0 Traefik docker image + image: traefik:v2.0 + # Enables the web UI and tells Traefik to listen to docker + command: --api.insecure=true --providers.docker + ports: + # The HTTP port + - "80:80" + # The Web UI (enabled by --api.insecure=true) + - "8080:8080" + volumes: + # So that Traefik can listen to the Docker events + - /var/run/docker.sock:/var/run/docker.sock + httpbin: + # A container that exposes an API to show its IP address + image: kennethreitz/httpbin:latest + labels: + - "traefik.http.routers.httpbin.rule=Host(`httpbin.corp.example.com`)" + # Create a middleware named `foo-add-prefix` + - "traefik.http.middlewares.test-auth.forwardauth.authResponseHeaders=X-Pomerium-Authenticated-User-Email,x-pomerium-authenticated-user-id,x-pomerium-authenticated-user-groups,x-pomerium-jwt-assertion" + - "traefik.http.middlewares.test-auth.forwardauth.address=http://fwdauth.corp.example.com/.pomerium/verify/httpbin.corp.example.com" + - "traefik.http.routers.httpbin.middlewares=test-auth@docker" +``` + ## Policy - Environmental Variable: `POLICY` @@ -564,7 +640,9 @@ Certificate Authority is set when behind-the-ingress service communication uses Strict-Transport-Security:max-age=31536000; includeSubDomains; preload, ``` - Headers specifies a mapping of [HTTP Header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers) to be added to proxied requests. _Nota bene_ Downstream application headers will be overwritten by Pomerium's headers on conflict. +``` + +Headers specifies a mapping of [HTTP Header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers) to be added to proxied requests. _Nota bene_ Downstream application headers will be overwritten by Pomerium's headers on conflict. By default, conservative [secure HTTP headers](https://www.owasp.org/index.php/OWASP_Secure_Headers_Project) are set. @@ -599,3 +677,4 @@ Default Upstream Timeout is the default timeout applied to a proxied route when [script]: https://github.com/pomerium/pomerium/blob/master/scripts/generate_wildcard_cert.sh [toml]: https://en.wikipedia.org/wiki/TOML [yaml]: https://en.wikipedia.org/wiki/YAML +``` diff --git a/internal/config/options.go b/internal/config/options.go index 99f2c9c98..f0a882c4e 100644 --- a/internal/config/options.go +++ b/internal/config/options.go @@ -160,6 +160,17 @@ type Options struct { GRPCClientTimeout time.Duration `mapstructure:"grpc_client_timeout"` GRPCClientDNSRoundRobin bool `mapstructure:"grpc_client_dns_roundrobin"` + // ForwardAuthEndpoint allows for a given route to be used as a forward-auth + // endpoint instead of a reverse proxy. Some third-party proxies that do not + // have rich access control capabilities (nginx, envoy, ambassador, traefik) + // allow you to delegate and authenticate each request to your website + // with an external server or service. Pomerium can be configured to accept + // these requests with this switch + // + // todo(bdd): link to docs + ForwardAuthURLString string `mapstructure:"forward_auth_url"` + ForwardAuthURL *url.URL + viper *viper.Viper } @@ -407,6 +418,14 @@ func (o *Options) Validate() error { o.AuthorizeURL = u } + if o.ForwardAuthURLString != "" { + u, err := urlutil.ParseAndValidateURL(o.ForwardAuthURLString) + if err != nil { + return fmt.Errorf("internal/config: bad forward-auth-url %s : %w", o.ForwardAuthURLString, err) + } + o.ForwardAuthURL = u + } + if o.PolicyFile != "" { return errors.New("internal/config: policy file setting is deprecated") } diff --git a/internal/config/policy.go b/internal/config/policy.go index 672800bf5..d47f6049b 100644 --- a/internal/config/policy.go +++ b/internal/config/policy.go @@ -65,6 +65,15 @@ type Policy struct { TLSClientCertFile string `mapstructure:"tls_client_cert_file" yaml:"tls_client_cert_file"` TLSClientKeyFile string `mapstructure:"tls_client_key_file" yaml:"tls_client_key_file"` ClientCertificate *tls.Certificate + + // IsForwardAuthEndpoint allows for a given route to be used as a forward-auth + // endpoint instead of a reverse proxy. Some third-party proxies that do not + // have rich access control capabilities (nginx, envoy, ambassador, traefik) + // allow you to delegate and authenticate each request to your website + // with an external server or service. Pomerium can be configured to accept + // these requests with this switch + // todo(bdd): link to docs + IsForwardAuthEndpoint bool } // Validate checks the validity of a policy. diff --git a/internal/templates/templates.go b/internal/templates/templates.go index 3df4a225f..004d02a05 100644 --- a/internal/templates/templates.go +++ b/internal/templates/templates.go @@ -267,7 +267,7 @@ func New() *template.Template {

{{if .Message}}{{.Message}}
{{end}} - {{if .CanDebug}}Troubleshoot your session.
{{end}} + {{if .CanDebug}}Troubleshoot your session.
{{end}} {{if .RequestID}} Request {{.RequestID}}
{{end}}

diff --git a/proxy/handlers.go b/proxy/handlers.go index 67ba61c74..89857cf0d 100644 --- a/proxy/handlers.go +++ b/proxy/handlers.go @@ -19,8 +19,11 @@ import ( // registerHelperHandlers returns the proxy service's ServeMux func (p *Proxy) registerHelperHandlers(r *mux.Router) *mux.Router { h := r.PathPrefix(dashboardURL).Subrouter() + // 1. Retrieve the user session and add it to the request context h.Use(sessions.RetrieveSession(p.sessionStore)) + // 2. AuthN - Verify the user is authenticated. Set email, group, & id headers h.Use(p.AuthenticateSession) + // 3. Enforce CSRF protections for any non-idempotent http method h.Use(csrf.Protect( p.cookieSecret, csrf.Path("/"), @@ -32,6 +35,7 @@ func (p *Proxy) registerHelperHandlers(r *mux.Router) *mux.Router { h.HandleFunc("/impersonate", p.Impersonate).Methods(http.MethodPost) h.HandleFunc("/sign_out", p.SignOut).Methods(http.MethodGet, http.MethodPost) h.HandleFunc("/refresh", p.ForceRefresh).Methods(http.MethodPost) + h.HandleFunc("/verify/{hostname}", p.Verify).Methods(http.MethodGet) return r } @@ -147,3 +151,50 @@ func (p *Proxy) Impersonate(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, dashboardURL, http.StatusFound) } + +// 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, they will be given a 403 http status. +func (p *Proxy) Verify(w http.ResponseWriter, r *http.Request) { + // retrieve the target destination hostname from the url path + hostname, ok := mux.Vars(r)["hostname"] + if !ok { + httputil.ErrorResponse(w, r, httputil.Error("no hostname set", http.StatusBadRequest, nil)) + return + } + // attempt to retrieve the user session from the request context, validity + // of the identity session is asserted by the middleware chain + s, err := sessions.FromContext(r.Context()) + if err != nil { + httputil.ErrorResponse(w, r, err) + return + } + // query the authorization service to see if the session's user has + // the appropriate authorization to access the given hostname + authorized, err := p.AuthorizeClient.Authorize(r.Context(), hostname, s) + if err != nil { + httputil.ErrorResponse(w, r, err) + return + } else if !authorized { + errMsg := fmt.Sprintf("%s is not authorized for this route", s.RequestEmail()) + httputil.ErrorResponse(w, r, httputil.Error(errMsg, http.StatusForbidden, nil)) + return + } + // check the queryparams to see if this check immediately followed + // authentication. If so, redirect back to the originally requested hostname. + if isCallback := r.URL.Query().Get(disableCallback); isCallback == "true" { + http.Redirect(w, r, "http://"+hostname, http.StatusFound) + return + } + + // User is authenticated and authorized for the given host. + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set(HeaderUserID, s.User) + w.Header().Set(HeaderEmail, s.RequestEmail()) + w.Header().Set(HeaderGroups, s.RequestGroups()) + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, "%s is authorized for %s.", s.Email, hostname) +} diff --git a/proxy/handlers_test.go b/proxy/handlers_test.go index c02a0f563..9c5fb3502 100644 --- a/proxy/handlers_test.go +++ b/proxy/handlers_test.go @@ -11,6 +11,8 @@ import ( "testing" "time" + "github.com/gorilla/mux" + "github.com/pomerium/pomerium/internal/config" "github.com/pomerium/pomerium/internal/cryptutil" "github.com/pomerium/pomerium/internal/sessions" @@ -62,7 +64,7 @@ func TestProxy_authenticate(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/oauth-start", nil) rr := httptest.NewRecorder() - proxy.authenticate(rr, req) + proxy.reqNeedsAuthentication(rr, req) // expect oauth redirect if status := rr.Code; status != http.StatusFound { t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusFound) @@ -75,115 +77,6 @@ func TestProxy_authenticate(t *testing.T) { } } -// func TestProxy_PomeriumHandler(t *testing.T) { -// proxy, err := New(testOptions(t)) -// if err != nil { -// t.Fatal(err) -// } -// h := proxy.registerHelperHandlers() -// if h == nil { -// t.Error("handler cannot be nil") -// } -// mux := http.NewServeMux() -// mux.Handle("/", h) -// req := httptest.NewRequest(http.MethodGet, "/", nil) -// rr := httptest.NewRecorder() -// mux.ServeHTTP(rr, req) -// if rr.Code != http.StatusNotFound { -// t.Errorf("expected 404 route not found for empty route") -// } -// } - -// func TestProxy_Proxy(t *testing.T) { -// goodSession := &sessions.State{ -// AccessToken: "AccessToken", -// RefreshToken: "RefreshToken", -// RefreshDeadline: time.Now().Add(20 * time.Second), -// } - -// ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { -// w.Header().Set("Content-Type", "text/plain; charset=utf-8") -// w.Header().Set("X-Content-Type-Options", "nosniff") -// fmt.Fprintln(w, "RVSI FILIVS CAISAR") -// w.WriteHeader(http.StatusOK) - -// })) -// defer ts.Close() - -// opts := testOptionsTestServer(t, ts.URL) -// optsCORS := testOptionsWithCORS(t, ts.URL) -// optsPublic := testOptionsWithPublicAccess(t, ts.URL) -// optsNoPolicies := testOptionsWithEmptyPolicies(t, ts.URL) - -// defaultHeaders, goodCORSHeaders, badCORSHeaders, headersWs := http.Header{}, http.Header{}, http.Header{}, http.Header{} -// goodCORSHeaders.Set("origin", "anything") -// goodCORSHeaders.Set("access-control-request-method", "anything") -// // missing "Origin" -// badCORSHeaders.Set("access-control-request-method", "anything") -// headersWs.Set("Connection", "Upgrade") -// headersWs.Set("Upgrade", "websocket") - -// tests := []struct { -// name string -// options config.Options -// method string -// header http.Header -// host string -// session sessions.SessionStore -// authorizer clients.Authorizer -// wantStatus int -// }{ -// {"good", opts, http.MethodGet, defaultHeaders, "https://httpbin.corp.example/", &sessions.MockSessionStore{Session: &sessions.State{AccessToken: "AccessToken", RefreshToken: "RefreshToken", RefreshDeadline: time.Now().Add(20 * time.Second)}}, clients.MockAuthorize{AuthorizeResponse: true}, http.StatusOK}, -// {"good cors preflight", optsCORS, http.MethodOptions, goodCORSHeaders, "https://httpbin.corp.example/", &sessions.MockSessionStore{Session: goodSession}, clients.MockAuthorize{AuthorizeResponse: false}, http.StatusOK}, -// {"good email impersonation", opts, http.MethodGet, defaultHeaders, "https://httpbin.corp.example/", &sessions.MockSessionStore{Session: &sessions.State{AccessToken: "AccessToken", RefreshToken: "RefreshToken", RefreshDeadline: time.Now().Add(10 * time.Second), ImpersonateEmail: "test@user.example"}}, clients.MockAuthorize{AuthorizeResponse: true}, http.StatusOK}, -// {"good group impersonation", opts, http.MethodGet, defaultHeaders, "https://httpbin.corp.example/", &sessions.MockSessionStore{Session: &sessions.State{AccessToken: "AccessToken", RefreshToken: "RefreshToken", RefreshDeadline: time.Now().Add(10 * time.Second), ImpersonateGroups: []string{"group1", "group2"}}}, clients.MockAuthorize{AuthorizeResponse: true}, http.StatusOK}, -// // same request as above, but with cors_allow_preflight=false in the policy -// {"valid cors, but not allowed", opts, http.MethodOptions, goodCORSHeaders, "https://httpbin.corp.example/", &sessions.MockSessionStore{Session: goodSession}, clients.MockAuthorize{AuthorizeResponse: false}, http.StatusForbidden}, -// // cors allowed, but the request is missing proper headers -// {"invalid cors headers", optsCORS, http.MethodOptions, badCORSHeaders, "https://httpbin.corp.example/", &sessions.MockSessionStore{Session: goodSession}, clients.MockAuthorize{AuthorizeResponse: false}, http.StatusForbidden}, -// // redirect to start auth process -// {"unknown host", opts, http.MethodGet, defaultHeaders, "https://nothttpbin.corp.example", &sessions.MockSessionStore{Session: goodSession}, clients.MockAuthorize{AuthorizeResponse: true}, http.StatusNotFound}, -// {"user not authorized", opts, http.MethodGet, defaultHeaders, "https://httpbin.corp.example/", &sessions.MockSessionStore{Session: goodSession}, clients.MockAuthorize{AuthorizeResponse: false}, http.StatusForbidden}, -// {"authorization call failed", opts, http.MethodGet, defaultHeaders, "https://httpbin.corp.example/", &sessions.MockSessionStore{Session: goodSession}, clients.MockAuthorize{AuthorizeError: errors.New("error")}, http.StatusInternalServerError}, -// // authenticate errors -// {"session expired,redirect to authn", opts, http.MethodGet, defaultHeaders, "https://httpbin.corp.example/", &sessions.MockSessionStore{LoadError: sessions.ErrExpired}, clients.MockAuthorize{AuthorizeResponse: true}, http.StatusFound}, -// {"public access", optsPublic, http.MethodGet, defaultHeaders, "https://httpbin.corp.example/", &sessions.MockSessionStore{Session: goodSession}, clients.MockAuthorize{AuthorizeResponse: false}, http.StatusOK}, -// {"public access, but unknown host", optsPublic, http.MethodGet, defaultHeaders, "https://nothttpbin.corp.example", &sessions.MockSessionStore{Session: goodSession}, clients.MockAuthorize{AuthorizeResponse: false}, http.StatusNotFound}, -// {"no http found (no session),redirect to authn", opts, http.MethodGet, defaultHeaders, "https://httpbin.corp.example/", &sessions.MockSessionStore{LoadError: http.ErrNoCookie}, clients.MockAuthorize{AuthorizeResponse: true}, http.StatusFound}, -// {"No policies", optsNoPolicies, http.MethodGet, defaultHeaders, "https://httpbin.corp.example/", &sessions.MockSessionStore{Session: goodSession}, clients.MockAuthorize{AuthorizeResponse: true}, http.StatusNotFound}, -// } - -// for _, tt := range tests { -// t.Run(tt.name, func(t *testing.T) { -// err := ValidateOptions(tt.options) -// if err != nil { -// t.Fatal(err) -// } -// p, err := New(tt.options) -// if err != nil { -// t.Fatal(err) -// } -// p.encoder = &cryptutil.MockEncoder{MarshalResponse: "foo"} -// p.sessionStore = tt.session -// p.AuthorizeClient = tt.authorizer -// r := httptest.NewRequest(tt.method, tt.host, nil) -// r.Header = tt.header -// r.Header.Set("Accept", "application/json") -// state, _ := tt.session.LoadSession(r) -// ctx := r.Context() -// ctx = sessions.NewContext(ctx, state, nil) -// r = r.WithContext(ctx) - -// w := httptest.NewRecorder() -// p.Proxy(w, r) -// if status := w.Code; status != tt.wantStatus { -// t.Errorf("handler returned wrong status code: got %v want %v \n body %s", status, tt.wantStatus, w.Body.String()) -// } - -// }) -// } -// } - func TestProxy_UserDashboard(t *testing.T) { opts := testOptions(t) tests := []struct { @@ -393,3 +286,119 @@ func uriParseHelper(s string) *url.URL { } return uri } +func TestProxy_VerifyWithMiddleware(t *testing.T) { + t.Parallel() + opts := testOptions(t) + tests := []struct { + name string + options config.Options + ctxError error + method string + qp string + path string + + cipher cryptutil.SecureEncoder + sessionStore sessions.SessionStore + authorizer clients.Authorizer + wantStatus int + }{ + {"good", opts, nil, http.MethodGet, "false", "/.pomerium/verify/some.domain.name", &cryptutil.MockEncoder{}, &sessions.MockSessionStore{Session: &sessions.State{Email: "user@test.example", RefreshDeadline: time.Now().Add(10 * time.Second)}}, clients.MockAuthorize{AuthorizeResponse: true}, http.StatusOK}, + {"good post auth redirect", opts, nil, http.MethodGet, "true", "/.pomerium/verify/some.domain.name", &cryptutil.MockEncoder{}, &sessions.MockSessionStore{Session: &sessions.State{Email: "user@test.example", RefreshDeadline: time.Now().Add(10 * time.Second)}}, clients.MockAuthorize{AuthorizeResponse: true}, http.StatusFound}, + {"not authorized", opts, nil, http.MethodGet, "false", "/.pomerium/verify/some.domain.name", &cryptutil.MockEncoder{}, &sessions.MockSessionStore{Session: &sessions.State{Email: "user@test.example", RefreshDeadline: time.Now().Add(10 * time.Second)}}, clients.MockAuthorize{AuthorizeResponse: false}, http.StatusForbidden}, + {"not authorized expired, redirect to auth", opts, nil, http.MethodGet, "false", "/.pomerium/verify/some.domain.name", &cryptutil.MockEncoder{}, &sessions.MockSessionStore{Session: &sessions.State{Email: "user@test.example", RefreshDeadline: time.Now().Add(-10 * time.Second)}}, clients.MockAuthorize{AuthorizeResponse: false}, http.StatusFound}, + {"not authorized expired, don't redirect!", opts, nil, http.MethodGet, "true", "/.pomerium/verify/some.domain.name?no_redirect=true", &cryptutil.MockEncoder{}, &sessions.MockSessionStore{Session: &sessions.State{Email: "user@test.example", RefreshDeadline: time.Now().Add(-10 * time.Second)}}, clients.MockAuthorize{AuthorizeResponse: false}, http.StatusUnauthorized}, + {"not authorized because of error", opts, nil, http.MethodGet, "false", "/.pomerium/verify/some.domain.name", &cryptutil.MockEncoder{}, &sessions.MockSessionStore{Session: &sessions.State{Email: "user@test.example", RefreshDeadline: time.Now().Add(10 * time.Second)}}, clients.MockAuthorize{AuthorizeError: errors.New("authz error")}, http.StatusInternalServerError}, + {"bad context retrieval error", opts, errors.New("oh no"), http.MethodGet, "false", "/.pomerium/verify/some.domain.name", &cryptutil.MockEncoder{}, &sessions.MockSessionStore{Session: &sessions.State{Email: "user@test.example", RefreshDeadline: time.Now().Add(10 * time.Second)}}, clients.MockAuthorize{AuthorizeResponse: true}, http.StatusOK}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p, err := New(tt.options) + if err != nil { + t.Fatal(err) + } + p.encoder = tt.cipher + p.sessionStore = tt.sessionStore + p.AuthorizeClient = tt.authorizer + p.UpdateOptions(tt.options) + uri := &url.URL{Path: tt.path} + queryString := uri.Query() + if tt.qp == "true" { + queryString.Set("pomerium-auth-callback", tt.qp) + } + uri.RawQuery = queryString.Encode() + + r := httptest.NewRequest(tt.method, uri.String(), nil) + state, err := tt.sessionStore.LoadSession(r) + if err != nil { + t.Fatal(err) + } + ctx := r.Context() + ctx = sessions.NewContext(ctx, state, tt.ctxError) + r = r.WithContext(ctx) + r.Header.Set("Authorization", "Bearer blah") + r.Header.Set("Accept", "application/json") + + w := httptest.NewRecorder() + router := mux.NewRouter() + router.StrictSlash(true) + router = p.registerHelperHandlers(router) + router.ServeHTTP(w, r) + if status := w.Code; status != tt.wantStatus { + t.Errorf("status code: got %v want %v", status, tt.wantStatus) + t.Errorf("\n%+v", w.Body.String()) + } + }) + } +} + +func TestProxy_Verify(t *testing.T) { + t.Parallel() + opts := testOptions(t) + tests := []struct { + name string + options config.Options + ctxError error + method string + qp string + path string + + cipher cryptutil.SecureEncoder + sessionStore sessions.SessionStore + authorizer clients.Authorizer + wantStatus int + }{ + {"bad - no hostname in path", opts, nil, http.MethodGet, "false", "/ok", &cryptutil.MockEncoder{}, &sessions.MockSessionStore{Session: &sessions.State{Email: "user@test.example", RefreshDeadline: time.Now().Add(10 * time.Second)}}, clients.MockAuthorize{AuthorizeResponse: true}, http.StatusBadRequest}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p, err := New(tt.options) + if err != nil { + t.Fatal(err) + } + p.encoder = tt.cipher + p.sessionStore = tt.sessionStore + p.AuthorizeClient = tt.authorizer + uri := &url.URL{Path: tt.path} + queryString := uri.Query() + queryString.Set("pomerium-auth-callback", tt.qp) + uri.RawQuery = queryString.Encode() + + r := httptest.NewRequest(tt.method, uri.String(), nil) + state, err := tt.sessionStore.LoadSession(r) + if err != nil { + t.Fatal(err) + } + ctx := r.Context() + ctx = sessions.NewContext(ctx, state, tt.ctxError) + r = r.WithContext(ctx) + r.Header.Set("Authorization", "Bearer blah") + r.Header.Set("Accept", "application/json") + w := httptest.NewRecorder() + p.Verify(w, r) + if status := w.Code; status != tt.wantStatus { + t.Errorf("status code: got %v want %v", status, tt.wantStatus) + t.Errorf("\n%+v", w.Body.String()) + } + }) + } +} diff --git a/proxy/middleware.go b/proxy/middleware.go index ca23934d3..ff985f1cf 100644 --- a/proxy/middleware.go +++ b/proxy/middleware.go @@ -22,6 +22,8 @@ const ( HeaderEmail = "x-pomerium-authenticated-user-email" // HeaderGroups is the header key containing the user's groups. HeaderGroups = "x-pomerium-authenticated-user-groups" + + disableCallback = "pomerium-auth-callback" ) // AuthenticateSession is middleware to enforce a valid authentication @@ -33,14 +35,15 @@ func (p *Proxy) AuthenticateSession(next http.Handler) http.Handler { s, err := sessions.FromContext(r.Context()) if err != nil { log.Debug().Str("cause", err.Error()).Msg("proxy: re-authenticating due to session state error") - p.authenticate(w, r) + p.reqNeedsAuthentication(w, r) return } if err := s.Valid(); err != nil { log.Debug().Str("cause", err.Error()).Msg("proxy: re-authenticating due to invalid session") - p.authenticate(w, r) + p.reqNeedsAuthentication(w, r) return } + // add pomerium's headers to the downstream request r.Header.Set(HeaderUserID, s.User) r.Header.Set(HeaderEmail, s.RequestEmail()) r.Header.Set(HeaderGroups, s.RequestGroups()) @@ -89,15 +92,22 @@ func (p *Proxy) SignRequest(signer cryptutil.JWTSigner) func(next http.Handler) log.Warn().Err(err).Msg("proxy: failed signing jwt") } else { r.Header.Set(HeaderJWT, jwt) + w.Header().Set(HeaderJWT, jwt) } next.ServeHTTP(w, r.WithContext(ctx)) }) } } -// Authenticate begins the authenticate flow, encrypting the redirect url -// in a request to the provider's sign in endpoint. -func (p *Proxy) authenticate(w http.ResponseWriter, r *http.Request) { +// reqNeedsAuthentication begins the authenticate flow, encrypting the +// redirect url in a request to the provider's sign in endpoint. +func (p *Proxy) reqNeedsAuthentication(w http.ResponseWriter, r *http.Request) { + // some proxies like nginx won't follow redirects, and treat any + // non 2xx or 4xx status as an internal service error. + // https://nginx.org/en/docs/http/ngx_http_auth_request_module.html + if _, ok := r.URL.Query()[disableCallback]; ok { + http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + } uri := urlutil.SignedRedirectURL(p.SharedKey, p.authenticateSigninURL, urlutil.GetAbsoluteURL(r)) http.Redirect(w, r, uri.String(), http.StatusFound) } diff --git a/proxy/proxy.go b/proxy/proxy.go index 0930fccc8..d088f4379 100755 --- a/proxy/proxy.go +++ b/proxy/proxy.go @@ -173,6 +173,11 @@ func (p *Proxy) UpdatePolicies(opts *config.Options) error { r.HandleFunc("/robots.txt", p.RobotsTxt).Methods(http.MethodGet) r = p.registerHelperHandlers(r) + if opts.ForwardAuthURL != nil { + // create a route to handle forward auth requests + r.Host(opts.ForwardAuthURL.Host).Subrouter().PathPrefix("/") + } + for _, policy := range opts.Policies { if err := policy.Validate(); err != nil { return fmt.Errorf("proxy: invalid policy %s", err)