envoy: implement refresh session (#674)

* authorize: refresh session WIP

* remove upstream cookie with lua

* only refresh session on expired

* authorize: handle session expiration

* authorize: add refresh test, fix isExpired check

* proxy: implement preserve host header option

* authorize: allow CORS preflight requests

* proxy: add request headers

* authenticate: use id token expiry
This commit is contained in:
Caleb Doxsey 2020-05-11 07:29:23 -06:00 committed by Travis Groth
parent ae3049baca
commit 0d9a372182
7 changed files with 284 additions and 27 deletions

View file

@ -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
}

View file

@ -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{}},
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 {

View file

@ -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 {

View file

@ -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

View file

@ -178,6 +178,7 @@ local PomeriumDeployment = function(svc) {
ip: '10.96.1.1',
hostnames: [
'openid.localhost.pomerium.io',
'authenticate.localhost.pomerium.io'
],
}],
initContainers: [{

View file

@ -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"
)

View file

@ -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",
},