mirror of
https://github.com/pomerium/pomerium.git
synced 2025-06-03 11:22:45 +02:00
forward-auth: validate using forwarded uri header (#600)
Signed-off-by: Bobby DeSimone <bobbydesimone@gmail.com> Co-authored-by: Bobby DeSimone <bobbydesimone@gmail.com>
This commit is contained in:
parent
262d35d482
commit
0de3c431a6
7 changed files with 62 additions and 18 deletions
|
@ -416,7 +416,7 @@ tracing_jaeger_agent_endpoint | Send spans to jaeger-agent at this address.
|
|||
- Config File Key: `forward_auth_url`
|
||||
- Type: `URL` (must contain a scheme and hostname)
|
||||
- Example: `https://forwardauth.corp.example.com`
|
||||
- Resulting Verification URL: `https://forwardauth.corp.example.com/.pomerium/verify/{URL-TO-VERIFY}`
|
||||
- Resulting Verification URL: `https://forwardauth.corp.example.com/?uri={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.
|
||||
|
|
|
@ -82,6 +82,8 @@ Also, please note that while this guide uses [NGINX Ingress Controller], Pomeriu
|
|||
NGINX Ingress controller can be installed via [Helm] from the official charts repository. To install the chart with the release name `helm-nginx-ingress`:
|
||||
|
||||
```bash
|
||||
helm repo add stable https://kubernetes-charts.storage.googleapis.com
|
||||
helm repo update # important to make sure we get >.30
|
||||
helm install helm-nginx-ingress stable/nginx-ingress
|
||||
```
|
||||
|
||||
|
|
|
@ -11,32 +11,54 @@ import (
|
|||
"github.com/pomerium/pomerium/internal/urlutil"
|
||||
)
|
||||
|
||||
// registerFwdAuthHandlers returns a set of handlers that support using pomerium
|
||||
// as a "forward-auth" provider with other reverse proxies like nginx, traefik.
|
||||
//
|
||||
// see : https://www.pomerium.io/configuration/#forward-auth
|
||||
func (p *Proxy) registerFwdAuthHandlers() http.Handler {
|
||||
r := httputil.NewRouter()
|
||||
r.StrictSlash(true)
|
||||
r.Use(sessions.RetrieveSession(p.sessionStore))
|
||||
|
||||
// NGNIX's forward-auth capabilities are split across two settings:
|
||||
// `auth-url` and `auth-signin` which correspond to `verify` and `auth-url`
|
||||
//
|
||||
// NOTE: Route order matters here which makes the request flow confusing
|
||||
// to reason about so each step has a postfix order step.
|
||||
|
||||
// nginx 3: save the returned session post authenticate flow
|
||||
r.Handle("/verify", httputil.HandlerFunc(p.nginxCallback)).
|
||||
Queries("uri", "{uri}", urlutil.QuerySessionEncrypted, "", urlutil.QueryRedirectURI, "")
|
||||
r.Handle("/", httputil.HandlerFunc(p.postSessionSetNOP)).
|
||||
Queries("uri", "{uri}",
|
||||
urlutil.QuerySessionEncrypted, "",
|
||||
urlutil.QueryRedirectURI, "")
|
||||
r.Handle("/", httputil.HandlerFunc(p.traefikCallback)).
|
||||
HeadersRegexp(httputil.HeaderForwardedURI, urlutil.QuerySessionEncrypted)
|
||||
r.Handle("/", p.Verify(false)).Queries("uri", "{uri}")
|
||||
|
||||
// nginx 1: verify. Return 401 if invalid and NGINX will call `auth-signin`
|
||||
r.Handle("/verify", p.Verify(true)).Queries("uri", "{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, "")
|
||||
|
||||
// traefik 2: save the returned session post authenticate flow
|
||||
r.Handle("/", httputil.HandlerFunc(p.forwardedURIHeaderCallback)).
|
||||
HeadersRegexp(httputil.HeaderForwardedURI, urlutil.QuerySessionEncrypted)
|
||||
|
||||
// nginx 2 / traefik 1: verify and then start authenticate flow
|
||||
r.Handle("/", p.Verify(false))
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// postSessionSetNOP after successfully setting the
|
||||
func (p *Proxy) postSessionSetNOP(w http.ResponseWriter, r *http.Request) error {
|
||||
// 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)
|
||||
return nil
|
||||
}
|
||||
|
||||
// nginxCallback saves the returned session post callback and then returns an
|
||||
// unauthorized status in order to restart the request flow process. Strangely
|
||||
// we need to throw a 401 after saving the session to redirect the user
|
||||
// to their originally desired location.
|
||||
func (p *Proxy) nginxCallback(w http.ResponseWriter, r *http.Request) error {
|
||||
encryptedSession := r.FormValue(urlutil.QuerySessionEncrypted)
|
||||
if _, err := p.saveCallbackSession(w, r, encryptedSession); err != nil {
|
||||
|
@ -47,7 +69,9 @@ func (p *Proxy) nginxCallback(w http.ResponseWriter, r *http.Request) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (p *Proxy) traefikCallback(w http.ResponseWriter, r *http.Request) error {
|
||||
// forwardedURIHeaderCallback handles the post-authentication callback from
|
||||
// forwarding proxies that support the `X-Forwarded-Uri`.
|
||||
func (p *Proxy) forwardedURIHeaderCallback(w http.ResponseWriter, r *http.Request) error {
|
||||
forwardedURL, err := url.Parse(r.Header.Get(httputil.HeaderForwardedURI))
|
||||
if err != nil {
|
||||
return httputil.NewError(http.StatusBadRequest, err)
|
||||
|
@ -75,10 +99,23 @@ func (p *Proxy) Verify(verifyOnly bool) http.Handler {
|
|||
if status := r.FormValue("auth_status"); status == fmt.Sprint(http.StatusForbidden) {
|
||||
return httputil.NewError(http.StatusForbidden, errors.New(http.StatusText(http.StatusForbidden)))
|
||||
}
|
||||
uri, err := urlutil.ParseAndValidateURL(r.FormValue("uri"))
|
||||
|
||||
// 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.HeaderForwardedProto) == "" || r.Header.Get(httputil.HeaderForwardedHost) == "" {
|
||||
return httputil.NewError(http.StatusBadRequest, errors.New("no uri to validate"))
|
||||
|
||||
}
|
||||
uriString = r.Header.Get(httputil.HeaderForwardedProto) + "://" + r.Header.Get(httputil.HeaderForwardedHost)
|
||||
}
|
||||
|
||||
uri, err := urlutil.ParseAndValidateURL(uriString)
|
||||
if err != nil {
|
||||
return httputil.NewError(http.StatusBadRequest, err)
|
||||
}
|
||||
|
||||
if _, err := sessions.FromContext(r.Context()); err != nil {
|
||||
if verifyOnly {
|
||||
return httputil.NewError(http.StatusUnauthorized, err)
|
||||
|
@ -94,6 +131,8 @@ func (p *Proxy) Verify(verifyOnly bool) http.Handler {
|
|||
}
|
||||
|
||||
r.Host = uri.Host
|
||||
r.URL = uri
|
||||
r.RequestURI = uri.String()
|
||||
if err := p.authorize(w, r); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
@ -46,18 +46,21 @@ func TestProxy_ForwardAuth(t *testing.T) {
|
|||
}{
|
||||
{"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{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, client.MockAuthorize{AuthorizeResponse: &pb.IsAuthorizedReply{Allow: true}}, 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{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, client.MockAuthorize{AuthorizeResponse: &pb.IsAuthorizedReply{Allow: true}}, 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{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, client.MockAuthorize{AuthorizeResponse: &pb.IsAuthorizedReply{Allow: true}}, http.StatusBadRequest, "{\"Status\":400,\"Error\":\"Bad Request: no uri to validate\"}\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{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, client.MockAuthorize{AuthorizeResponse: &pb.IsAuthorizedReply{Allow: true}}, 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{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, client.MockAuthorize{AuthorizeResponse: &pb.IsAuthorizedReply{Allow: true}}, 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{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, client.MockAuthorize{AuthorizeResponse: &pb.IsAuthorizedReply{Allow: true}}, 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{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, client.MockAuthorize{AuthorizeResponse: &pb.IsAuthorizedReply{Allow: true}}, http.StatusBadRequest, "{\"Status\":400,\"Error\":\"Bad Request: %20 url does contain a valid scheme\"}\n"},
|
||||
{"not authorized", opts, nil, http.MethodGet, nil, nil, "https://some.domain.example/", "https://some.domain.example", &mock.Encoder{}, &mstore.Store{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, client.MockAuthorize{AuthorizeResponse: &pb.IsAuthorizedReply{Allow: false}}, http.StatusUnauthorized, "{\"Status\":401,\"Error\":\"Unauthorized: request denied\"}\n"},
|
||||
{"not authorized verify endpoint", opts, nil, http.MethodGet, nil, nil, "https://some.domain.example/verify", "https://some.domain.example", &mock.Encoder{}, &mstore.Store{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, client.MockAuthorize{AuthorizeResponse: &pb.IsAuthorizedReply{Allow: false}}, http.StatusUnauthorized, "{\"Status\":401,\"Error\":\"Unauthorized: request denied\"}\n"},
|
||||
{"not authorized", opts, nil, http.MethodGet, nil, nil, "https://some.domain.example/", "https://some.domain.example", &mock.Encoder{}, &mstore.Store{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, client.MockAuthorize{AuthorizeResponse: &pb.IsAuthorizedReply{Allow: false}}, http.StatusForbidden, "{\"Status\":403,\"Error\":\"Forbidden: request denied\"}\n"},
|
||||
{"not authorized verify endpoint", opts, nil, http.MethodGet, nil, nil, "https://some.domain.example/verify", "https://some.domain.example", &mock.Encoder{}, &mstore.Store{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, client.MockAuthorize{AuthorizeResponse: &pb.IsAuthorizedReply{Allow: false}}, http.StatusForbidden, "{\"Status\":403,\"Error\":\"Forbidden: request denied\"}\n"},
|
||||
{"not authorized because of error", opts, nil, http.MethodGet, nil, nil, "https://some.domain.example/", "https://some.domain.example", &mock.Encoder{}, &mstore.Store{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, client.MockAuthorize{AuthorizeError: errors.New("authz error")}, http.StatusInternalServerError, "{\"Status\":500,\"Error\":\"Internal Server Error: authz error\"}\n"},
|
||||
{"expired", opts, nil, http.MethodGet, nil, nil, "https://some.domain.example/", "https://some.domain.example", &mock.Encoder{}, &mstore.Store{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(-10 * time.Minute))}}, client.MockAuthorize{AuthorizeResponse: &pb.IsAuthorizedReply{Allow: false}}, http.StatusUnauthorized, "{\"Status\":401,\"Error\":\"Unauthorized: request denied\"}\n"},
|
||||
{"expired", opts, nil, http.MethodGet, nil, nil, "https://some.domain.example/", "https://some.domain.example", &mock.Encoder{}, &mstore.Store{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(-10 * time.Minute))}}, client.MockAuthorize{AuthorizeResponse: &pb.IsAuthorizedReply{Allow: false}}, http.StatusForbidden, "{\"Status\":403,\"Error\":\"Forbidden: request denied\"}\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{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, client.MockAuthorize{AuthorizeResponse: &pb.IsAuthorizedReply{Allow: true}}, 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{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, client.MockAuthorize{AuthorizeResponse: &pb.IsAuthorizedReply{Allow: true}}, http.StatusBadRequest, ""},
|
||||
{"bad traefik callback bad url", opts, nil, http.MethodGet, map[string]string{httputil.HeaderForwardedURI: urlutil.QuerySessionEncrypted + ""}, nil, "https://some.domain.example/", "https://some.domain.example", &mock.Encoder{}, &mstore.Store{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, client.MockAuthorize{AuthorizeResponse: &pb.IsAuthorizedReply{Allow: true}}, http.StatusBadRequest, ""},
|
||||
{"good traefik verify uri from headers", opts, nil, http.MethodGet, map[string]string{httputil.HeaderForwardedProto: "https", httputil.HeaderForwardedHost: "some.domain.example:8080"}, nil, "https://some.domain.example/", "", &mock.Encoder{}, &mstore.Store{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, client.MockAuthorize{AuthorizeResponse: &pb.IsAuthorizedReply{Allow: true}}, http.StatusOK, ""},
|
||||
|
||||
// // nginx
|
||||
{"good nginx callback redirect", opts, nil, http.MethodGet, nil, map[string]string{urlutil.QueryRedirectURI: "https://some.domain.example/", urlutil.QuerySessionEncrypted: goodEncryptionString}, "https://some.domain.example/", "https://some.domain.example", &mock.Encoder{}, &mstore.Store{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, client.MockAuthorize{AuthorizeResponse: &pb.IsAuthorizedReply{Allow: true}}, http.StatusFound, ""},
|
||||
{"good nginx callback set session okay but return unauthorized", opts, nil, http.MethodGet, nil, map[string]string{urlutil.QueryRedirectURI: "https://some.domain.example/", urlutil.QuerySessionEncrypted: goodEncryptionString}, "https://some.domain.example/verify", "https://some.domain.example", &mock.Encoder{}, &mstore.Store{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}}, client.MockAuthorize{AuthorizeResponse: &pb.IsAuthorizedReply{Allow: true}}, http.StatusUnauthorized, ""},
|
||||
|
|
|
@ -133,7 +133,7 @@ func (p *Proxy) authorize(w http.ResponseWriter, r *http.Request) error {
|
|||
Bool("allow", authz.GetAllow()).
|
||||
Bool("expired", authz.GetSessionExpired()).
|
||||
Msg("proxy/authorize: deny")
|
||||
return httputil.NewError(http.StatusUnauthorized, errors.New("request denied"))
|
||||
return httputil.NewError(http.StatusForbidden, errors.New("request denied"))
|
||||
}
|
||||
|
||||
r.Header.Set(httputil.HeaderPomeriumJWTAssertion, authz.GetSignedJwt())
|
||||
|
|
|
@ -158,10 +158,10 @@ func TestProxy_AuthorizeSession(t *testing.T) {
|
|||
wantStatus int
|
||||
}{
|
||||
{"user is authorized", 200, &mstore.Store{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Second))}}, client.MockAuthorize{AuthorizeResponse: &authorize.IsAuthorizedReply{Allow: true}}, nil, identity.MockProvider{}, http.StatusOK},
|
||||
{"user is not authorized", 200, &mstore.Store{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Second))}}, client.MockAuthorize{AuthorizeResponse: &authorize.IsAuthorizedReply{Allow: false}}, nil, identity.MockProvider{}, http.StatusUnauthorized},
|
||||
{"user is not authorized", 200, &mstore.Store{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Second))}}, client.MockAuthorize{AuthorizeResponse: &authorize.IsAuthorizedReply{Allow: false}}, nil, identity.MockProvider{}, http.StatusForbidden},
|
||||
{"ctx error", 200, &mstore.Store{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Second))}}, client.MockAuthorize{AuthorizeResponse: &authorize.IsAuthorizedReply{Allow: true}}, errors.New("hi"), identity.MockProvider{}, http.StatusInternalServerError},
|
||||
{"authz client error", 200, &mstore.Store{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Second))}}, client.MockAuthorize{AuthorizeError: errors.New("err")}, nil, identity.MockProvider{}, http.StatusInternalServerError},
|
||||
{"expired, reauth failed", 200, &mstore.Store{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Second))}}, client.MockAuthorize{AuthorizeResponse: &authorize.IsAuthorizedReply{SessionExpired: true}}, nil, identity.MockProvider{}, http.StatusUnauthorized},
|
||||
{"expired, reauth failed", 200, &mstore.Store{Session: &sessions.State{Email: "user@test.example", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Second))}}, client.MockAuthorize{AuthorizeResponse: &authorize.IsAuthorizedReply{SessionExpired: true}}, nil, identity.MockProvider{}, http.StatusForbidden},
|
||||
//todo(bdd): it's a bit tricky to test the refresh flow
|
||||
}
|
||||
for _, tt := range tests {
|
||||
|
|
0
proxy/proxy.go
Executable file → Normal file
0
proxy/proxy.go
Executable file → Normal file
Loading…
Add table
Add a link
Reference in a new issue