diff --git a/authorize/authorize.go b/authorize/authorize.go index ce0bcfe76..c9357228b 100644 --- a/authorize/authorize.go +++ b/authorize/authorize.go @@ -12,6 +12,8 @@ import ( "github.com/pomerium/pomerium/authorize/evaluator/opa" "github.com/pomerium/pomerium/config" "github.com/pomerium/pomerium/internal/cryptutil" + "github.com/pomerium/pomerium/internal/encoding" + "github.com/pomerium/pomerium/internal/encoding/jws" "github.com/pomerium/pomerium/internal/log" "github.com/pomerium/pomerium/internal/telemetry/metrics" "github.com/pomerium/pomerium/internal/telemetry/trace" @@ -31,11 +33,24 @@ func (a *atomicOptions) Store(options config.Options) { a.value.Store(options) } +type atomicMarshalUnmarshaler struct { + value atomic.Value +} + +func (a *atomicMarshalUnmarshaler) Load() encoding.MarshalUnmarshaler { + return a.value.Load().(encoding.MarshalUnmarshaler) +} + +func (a *atomicMarshalUnmarshaler) Store(encoder encoding.MarshalUnmarshaler) { + a.value.Store(encoder) +} + // Authorize struct holds type Authorize struct { pe evaluator.Evaluator currentOptions atomicOptions + currentEncoder atomicMarshalUnmarshaler } // New validates and creates a new Authorize service from a set of config options. @@ -44,8 +59,19 @@ func New(opts config.Options) (*Authorize, error) { return nil, fmt.Errorf("authorize: bad options: %w", err) } var a Authorize + + var host string + if opts.AuthenticateURL != nil { + host = opts.AuthenticateURL.Host + } + encoder, err := jws.NewHS256Signer([]byte(opts.SharedKey), host) + if err != nil { + return nil, err + } + a.currentEncoder.Store(encoder) + a.currentOptions.Store(config.Options{}) - err := a.UpdateOptions(opts) + err = a.UpdateOptions(opts) if err != nil { return nil, err } diff --git a/authorize/grpc.go b/authorize/grpc.go index 4c7ccce6e..f5cdece25 100644 --- a/authorize/grpc.go +++ b/authorize/grpc.go @@ -2,13 +2,17 @@ package authorize import ( "context" + "fmt" + "io" + "io/ioutil" "net/http" + "net/http/httptest" "net/url" "strings" "github.com/pomerium/pomerium/authorize/evaluator" "github.com/pomerium/pomerium/config" - "github.com/pomerium/pomerium/internal/encoding/jws" + "github.com/pomerium/pomerium/internal/httputil" "github.com/pomerium/pomerium/internal/log" "github.com/pomerium/pomerium/internal/sessions" "github.com/pomerium/pomerium/internal/sessions/cookie" @@ -35,10 +39,26 @@ func (a *Authorize) Check(ctx context.Context, in *envoy_service_auth_v2.CheckRe hattrs := in.GetAttributes().GetRequest().GetHttp() hdrs := getCheckRequestHeaders(in) + + var requestHeaders []*envoy_api_v2_core.HeaderValueOption sess, sesserr := a.loadSessionFromCheckRequest(in) + if a.isExpired(sess) { + log.Info().Msg("refreshing session") + if newSession, err := a.refreshSession(ctx, sess); err == nil { + sess = newSession + sesserr = nil + requestHeaders, err = a.getEnvoyRequestHeaders(sess) + if err != nil { + log.Warn().Err(err).Msg("authorize: error generating new request headers") + } + } else { + log.Warn().Err(err).Msg("authorize: error refreshing session") + } + } + requestURL := getCheckRequestURL(in) req := &evaluator.Request{ - User: sess, + User: string(sess), Header: hdrs, Host: hattrs.GetHost(), Method: hattrs.GetMethod(), @@ -65,12 +85,17 @@ func (a *Authorize) Check(ctx context.Context, in *envoy_service_auth_v2.CheckRe evt = evt.Strs("deny-reasons", reply.GetDenyReasons()) evt = evt.Str("email", reply.GetEmail()) evt = evt.Strs("groups", reply.GetGroups()) + evt = evt.Str("session", string(sess)) evt.Msg("authorize check") if reply.Allow { return &envoy_service_auth_v2.CheckResponse{ - Status: &status.Status{Code: int32(codes.OK), Message: "OK"}, - HttpResponse: &envoy_service_auth_v2.CheckResponse_OkResponse{OkResponse: &envoy_service_auth_v2.OkHttpResponse{}}, + Status: &status.Status{Code: int32(codes.OK), Message: "OK"}, + HttpResponse: &envoy_service_auth_v2.CheckResponse_OkResponse{ + OkResponse: &envoy_service_auth_v2.OkHttpResponse{ + Headers: requestHeaders, + }, + }, }, nil } @@ -136,15 +161,100 @@ func (a *Authorize) Check(ctx context.Context, in *envoy_service_auth_v2.CheckRe }, nil } -func (a *Authorize) loadSessionFromCheckRequest(req *envoy_service_auth_v2.CheckRequest) (string, error) { - opts := a.currentOptions.Load() - - // used to load and verify JWT tokens signed by the authenticate service - encoder, err := jws.NewHS256Signer([]byte(opts.SharedKey), opts.AuthenticateURL.Host) +func (a *Authorize) getEnvoyRequestHeaders(rawSession []byte) ([]*envoy_api_v2_core.HeaderValueOption, error) { + cookieStore, err := a.getCookieStore() if err != nil { - return "", err + return nil, err } + recorder := httptest.NewRecorder() + err = cookieStore.SaveSession(recorder, nil /* unused by cookie store */, string(rawSession)) + if err != nil { + return nil, fmt.Errorf("authorize: error saving cookie: %w", err) + } + + var hvos []*envoy_api_v2_core.HeaderValueOption + for k, vs := range recorder.Header() { + for _, v := range vs { + hvos = append(hvos, &envoy_api_v2_core.HeaderValueOption{ + Header: &envoy_api_v2_core.HeaderValue{ + Key: "x-pomerium-" + k, + Value: v, + }, + }) + } + } + + return hvos, nil +} + +func (a *Authorize) refreshSession(ctx context.Context, rawSession []byte) (newSession []byte, err error) { + options := a.currentOptions.Load() + encoder := a.currentEncoder.Load() + + var state sessions.State + if err := encoder.Unmarshal(rawSession, &state); err != nil { + return nil, fmt.Errorf("error unmarshaling raw session: %w", err) + } + + // 1 - build a signed url to call refresh on authenticate service + refreshURI := options.AuthenticateURL.ResolveReference(&url.URL{Path: "/.pomerium/refresh"}) + q := refreshURI.Query() + q.Set(urlutil.QueryAccessTokenID, state.AccessTokenID) // hash value points to parent token + q.Set(urlutil.QueryAudience, strings.Join(state.Audience, ",")) // request's audience, this route + refreshURI.RawQuery = q.Encode() + signedRefreshURL := urlutil.NewSignedURL(options.SharedKey, refreshURI).String() + + // 2 - http call to authenticate service + req, err := http.NewRequestWithContext(ctx, http.MethodGet, signedRefreshURL, nil) + if err != nil { + return nil, fmt.Errorf("authorize: refresh request: %w", err) + } + req.Header.Set("X-Requested-With", "XmlHttpRequest") + req.Header.Set("Accept", "application/json") + + res, err := httputil.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("authorize: client err %s: %w", signedRefreshURL, err) + } + defer res.Body.Close() + newJwt, err := ioutil.ReadAll(io.LimitReader(res.Body, 4<<10)) + if err != nil { + return nil, err + } + // auth couldn't refresh the session, delete the session and reload via 302 + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("authorize: backend refresh failed: %s", newJwt) + } + return newJwt, nil +} + +func (a *Authorize) loadSessionFromCheckRequest(req *envoy_service_auth_v2.CheckRequest) ([]byte, error) { + cookieStore, err := a.getCookieStore() + if err != nil { + return nil, err + } + + sess, err := cookieStore.LoadSession(&http.Request{ + Header: getCheckRequestHeaders(req), + }) + if err != nil { + return nil, err + } + + return []byte(sess), nil +} + +func (a *Authorize) isExpired(rawSession []byte) bool { + state := sessions.State{} + err := a.currentEncoder.Load().Unmarshal(rawSession, &state) + return err == nil && state.IsExpired() +} + +func (a *Authorize) getCookieStore() (sessions.SessionStore, error) { + opts := a.currentOptions.Load() + encoder := a.currentEncoder.Load() + cookieOptions := &cookie.Options{ Name: opts.CookieName, Domain: opts.CookieDomain, @@ -155,13 +265,9 @@ func (a *Authorize) loadSessionFromCheckRequest(req *envoy_service_auth_v2.Check cookieStore, err := cookie.NewStore(cookieOptions, encoder) if err != nil { - return "", err + return nil, err } - - sess, err := cookieStore.LoadSession(&http.Request{ - Header: http.Header(getCheckRequestHeaders(req)), - }) - return sess, err + return cookieStore, nil } func getFullURL(rawurl, host string) string { diff --git a/integration/authorization_test.go b/integration/authorization_test.go index 35f311877..4de1b80df 100644 --- a/integration/authorization_test.go +++ b/integration/authorization_test.go @@ -7,8 +7,9 @@ import ( "testing" "time" - "github.com/pomerium/pomerium/integration/internal/flows" "github.com/stretchr/testify/assert" + + "github.com/pomerium/pomerium/integration/internal/flows" ) func TestAuthorization(t *testing.T) { @@ -34,14 +35,16 @@ func TestAuthorization(t *testing.T) { t.Run("domains", func(t *testing.T) { t.Run("allowed", func(t *testing.T) { client := testcluster.NewHTTPClient() - res, err := flows.Authenticate(ctx, client, mustParseURL("https://httpdetails.localhost.pomerium.io/by-domain"), "bob@dogs.test", []string{"user"}) + res, err := flows.Authenticate(ctx, client, mustParseURL("https://httpdetails.localhost.pomerium.io/by-domain"), + flows.WithEmail("bob@dogs.test"), flows.WithGroups("user")) if assert.NoError(t, err) { assert.Equal(t, http.StatusOK, res.StatusCode, "expected OK for dogs.test") } }) t.Run("not allowed", func(t *testing.T) { client := testcluster.NewHTTPClient() - res, err := flows.Authenticate(ctx, client, mustParseURL("https://httpdetails.localhost.pomerium.io/by-domain"), "joe@cats.test", []string{"user"}) + res, err := flows.Authenticate(ctx, client, mustParseURL("https://httpdetails.localhost.pomerium.io/by-domain"), + flows.WithEmail("joe@cats.test"), flows.WithGroups("user")) if assert.NoError(t, err) { assertDeniedAccess(t, res, "expected Forbidden for cats.test") } @@ -50,14 +53,16 @@ func TestAuthorization(t *testing.T) { t.Run("users", func(t *testing.T) { t.Run("allowed", func(t *testing.T) { client := testcluster.NewHTTPClient() - res, err := flows.Authenticate(ctx, client, mustParseURL("https://httpdetails.localhost.pomerium.io/by-user"), "bob@dogs.test", []string{"user"}) + res, err := flows.Authenticate(ctx, client, mustParseURL("https://httpdetails.localhost.pomerium.io/by-user"), + flows.WithEmail("bob@dogs.test"), flows.WithGroups("user")) if assert.NoError(t, err) { assert.Equal(t, http.StatusOK, res.StatusCode, "expected OK for bob@dogs.test") } }) t.Run("not allowed", func(t *testing.T) { client := testcluster.NewHTTPClient() - res, err := flows.Authenticate(ctx, client, mustParseURL("https://httpdetails.localhost.pomerium.io/by-user"), "joe@cats.test", []string{"user"}) + res, err := flows.Authenticate(ctx, client, mustParseURL("https://httpdetails.localhost.pomerium.io/by-user"), + flows.WithEmail("joe@cats.test"), flows.WithGroups("user")) if assert.NoError(t, err) { assertDeniedAccess(t, res, "expected Forbidden for joe@cats.test") } @@ -66,19 +71,61 @@ func TestAuthorization(t *testing.T) { t.Run("groups", func(t *testing.T) { t.Run("allowed", func(t *testing.T) { client := testcluster.NewHTTPClient() - res, err := flows.Authenticate(ctx, client, mustParseURL("https://httpdetails.localhost.pomerium.io/by-group"), "bob@dogs.test", []string{"admin", "user"}) + res, err := flows.Authenticate(ctx, client, mustParseURL("https://httpdetails.localhost.pomerium.io/by-group"), + flows.WithEmail("bob@dogs.test"), flows.WithGroups("admin", "user")) if assert.NoError(t, err) { assert.Equal(t, http.StatusOK, res.StatusCode, "expected OK for admin") } }) t.Run("not allowed", func(t *testing.T) { client := testcluster.NewHTTPClient() - res, err := flows.Authenticate(ctx, client, mustParseURL("https://httpdetails.localhost.pomerium.io/by-group"), "joe@cats.test", []string{"user"}) + res, err := flows.Authenticate(ctx, client, mustParseURL("https://httpdetails.localhost.pomerium.io/by-group"), + flows.WithEmail("joe@cats.test"), flows.WithGroups("user")) if assert.NoError(t, err) { assertDeniedAccess(t, res, "expected Forbidden for user, but got %d", res.StatusCode) } }) }) + + t.Run("refresh", func(t *testing.T) { + client := testcluster.NewHTTPClient() + res, err := flows.Authenticate(ctx, client, mustParseURL("https://httpdetails.localhost.pomerium.io/by-domain"), + flows.WithEmail("bob@dogs.test"), flows.WithGroups("user"), flows.WithTokenExpiration(time.Second)) + if !assert.NoError(t, err) { + return + } + assert.Equal(t, http.StatusOK, res.StatusCode, "expected OK for dogs.test") + res.Body.Close() + + // poll till we get a new cookie because of a refreshed session + ticker := time.NewTicker(time.Millisecond * 500) + defer ticker.Stop() + deadline := time.NewTimer(time.Second * 10) + defer deadline.Stop() + for i := 0; ; i++ { + select { + case <-ticker.C: + case <-deadline.C: + t.Fatal("timed out waiting for refreshed session") + return + case <-ctx.Done(): + t.Fatal("timed out waiting for refreshed session") + return + } + + res, err = client.Get(mustParseURL("https://httpdetails.localhost.pomerium.io/by-domain").String()) + if !assert.NoError(t, err) { + return + } + res.Body.Close() + if !assert.Equal(t, http.StatusOK, res.StatusCode, "failed after %d times", i+1) { + return + } + if res.Header.Get("Set-Cookie") != "" { + break + } + } + }) } func mustParseURL(str string) *url.URL { diff --git a/integration/internal/flows/flows.go b/integration/internal/flows/flows.go index 9c9d6469b..9f8d9d097 100644 --- a/integration/internal/flows/flows.go +++ b/integration/internal/flows/flows.go @@ -6,7 +6,9 @@ import ( "fmt" "net/http" "net/url" + "strconv" "strings" + "time" "github.com/pomerium/pomerium/integration/internal/forms" ) @@ -17,9 +19,50 @@ const ( pomeriumCallbackPath = "/.pomerium/callback/" ) +type authenticateConfig struct { + email string + groups []string + tokenExpiration time.Duration +} + +// An AuthenticateOption is an option for authentication. +type AuthenticateOption func(cfg *authenticateConfig) + +func getAuthenticateConfig(options ...AuthenticateOption) *authenticateConfig { + cfg := &authenticateConfig{ + tokenExpiration: time.Hour * 24, + } + for _, option := range options { + option(cfg) + } + return cfg +} + +// WithEmail sets the email to use. +func WithEmail(email string) AuthenticateOption { + return func(cfg *authenticateConfig) { + cfg.email = email + } +} + +// WithGroups sets the groups to use. +func WithGroups(groups ...string) AuthenticateOption { + return func(cfg *authenticateConfig) { + cfg.groups = groups + } +} + +// WithTokenExpiration sets the token expiration. +func WithTokenExpiration(tokenExpiration time.Duration) AuthenticateOption { + return func(cfg *authenticateConfig) { + cfg.tokenExpiration = tokenExpiration + } +} + // Authenticate submits a request to a URL, expects a redirect to authenticate and then openid and logs in. // Finally it expects to redirect back to the original page. -func Authenticate(ctx context.Context, client *http.Client, url *url.URL, email string, groups []string) (*http.Response, error) { +func Authenticate(ctx context.Context, client *http.Client, url *url.URL, options ...AuthenticateOption) (*http.Response, error) { + cfg := getAuthenticateConfig(options...) originalHostname := url.Hostname() req, err := http.NewRequestWithContext(ctx, "GET", url.String(), nil) @@ -68,8 +111,11 @@ func Authenticate(ctx context.Context, client *http.Client, url *url.URL, email forms := forms.Parse(res.Body) if len(forms) > 0 { f := forms[0] - f.Inputs["email"] = email - f.Inputs["groups"] = strings.Join(groups, ",") + f.Inputs["email"] = cfg.email + if len(cfg.groups) > 0 { + f.Inputs["groups"] = strings.Join(cfg.groups, ",") + } + f.Inputs["token_expiration"] = strconv.Itoa(int(cfg.tokenExpiration.Seconds())) req, err = f.NewRequestWithContext(ctx, req.URL) if err != nil { return nil, err diff --git a/integration/manifests/lib/pomerium.libsonnet b/integration/manifests/lib/pomerium.libsonnet index 8c781e9e8..bf62034ca 100644 --- a/integration/manifests/lib/pomerium.libsonnet +++ b/integration/manifests/lib/pomerium.libsonnet @@ -178,6 +178,7 @@ local PomeriumDeployment = function(svc) { ip: '10.96.1.1', hostnames: [ 'openid.localhost.pomerium.io', + 'authenticate.localhost.pomerium.io' ], }], initContainers: [{ diff --git a/internal/controlplane/xds_clusters.go b/internal/controlplane/xds_clusters.go index 1a0b380c0..392f7f10a 100644 --- a/internal/controlplane/xds_clusters.go +++ b/internal/controlplane/xds_clusters.go @@ -10,6 +10,7 @@ import ( envoy_config_core_v3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" envoy_config_endpoint_v3 "github.com/envoyproxy/go-control-plane/envoy/config/endpoint/v3" "github.com/golang/protobuf/ptypes" + "github.com/pomerium/pomerium/config" "github.com/pomerium/pomerium/internal/urlutil" ) diff --git a/internal/controlplane/xds_listeners.go b/internal/controlplane/xds_listeners.go index 47b90428c..3f07bb119 100644 --- a/internal/controlplane/xds_listeners.go +++ b/internal/controlplane/xds_listeners.go @@ -9,11 +9,13 @@ import ( envoy_config_listener_v3 "github.com/envoyproxy/go-control-plane/envoy/config/listener/v3" envoy_config_route_v3 "github.com/envoyproxy/go-control-plane/envoy/config/route/v3" envoy_extensions_filters_http_ext_authz_v3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/ext_authz/v3" + envoy_extensions_filters_http_lua_v3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/lua/v3" envoy_http_connection_manager "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/network/http_connection_manager/v3" envoy_extensions_transport_sockets_tls_v3 "github.com/envoyproxy/go-control-plane/envoy/extensions/transport_sockets/tls/v3" envoy_type_v3 "github.com/envoyproxy/go-control-plane/envoy/type/v3" "github.com/golang/protobuf/ptypes" "github.com/golang/protobuf/ptypes/any" + "github.com/pomerium/pomerium/config" "github.com/pomerium/pomerium/internal/urlutil" ) @@ -99,6 +101,28 @@ func (srv *Server) buildHTTPListener(options config.Options) *envoy_config_liste }, }) + luaConfig, _ := ptypes.MarshalAny(&envoy_extensions_filters_http_lua_v3.Lua{ + InlineCode: ` +function envoy_on_request(request_handle) + local headers = request_handle:headers() + local dynamic_meta = request_handle:streamInfo():dynamicMetadata() + if headers:get("x-pomerium-set-cookie") ~= nil then + dynamic_meta:set("envoy.filters.http.lua", "pomerium_set_cookie", headers:get("x-pomerium-set-cookie")) + headers:remove("x-pomerium-set-cookie") + end +end + +function envoy_on_response(response_handle) + local headers = response_handle:headers() + local dynamic_meta = response_handle:streamInfo():dynamicMetadata() + local tbl = dynamic_meta:get("envoy.filters.http.lua") + if tbl ~= nil and tbl["pomerium_set_cookie"] ~= nil then + headers:add("set-cookie", tbl["pomerium_set_cookie"]) + end +end +`, + }) + tc, _ := ptypes.MarshalAny(&envoy_http_connection_manager.HttpConnectionManager{ CodecType: envoy_http_connection_manager.HttpConnectionManager_AUTO, StatPrefix: "ingress", @@ -115,6 +139,12 @@ func (srv *Server) buildHTTPListener(options config.Options) *envoy_config_liste TypedConfig: extAuthZ, }, }, + { + Name: "envoy.filters.http.lua", + ConfigType: &envoy_http_connection_manager.HttpFilter_TypedConfig{ + TypedConfig: luaConfig, + }, + }, { Name: "envoy.filters.http.router", },