From 789068e27acf8a0c7f28b52a8ce460a9f4ca71e7 Mon Sep 17 00:00:00 2001 From: Travis Groth Date: Thu, 9 Apr 2020 23:41:55 -0400 Subject: [PATCH] Add configurable JWT claim headers (#596) --- config/options.go | 3 ++ docs/configuration/readme.md | 11 +++++++ docs/docs/upgrading.md | 2 ++ proxy/middleware.go | 51 ++++++++++++++++++++++-------- proxy/middleware_test.go | 60 +++++++++++++++++++++++++++++++++++- proxy/proxy.go | 8 +++-- 6 files changed, 118 insertions(+), 17 deletions(-) diff --git a/config/options.go b/config/options.go index d39dc499c..7c9fe8aaa 100644 --- a/config/options.go +++ b/config/options.go @@ -135,6 +135,9 @@ type Options struct { HeadersEnv string `yaml:",omitempty"` Headers map[string]string `yaml:",omitempty"` + // List of JWT claims to insert as x-pomerium-claim-* headers on proxied requests + JWTClaimsHeaders []string `mapstructure:"jwt_claims_headers" yaml:"jwt_claims_headers,omitempty"` + // RefreshCooldown limits the rate a user can refresh her session RefreshCooldown time.Duration `mapstructure:"refresh_cooldown" yaml:"refresh_cooldown,omitempty"` diff --git a/docs/configuration/readme.md b/docs/configuration/readme.md index 7451b18ed..587bad2f3 100644 --- a/docs/configuration/readme.md +++ b/docs/configuration/readme.md @@ -674,6 +674,17 @@ Refresh cooldown is the minimum amount of time between allowed manually refreshe Default Upstream Timeout is the default timeout applied to a proxied route when no `timeout` key is specified by the policy. +### JWT Claim Headers +- Environmental Variable: `JWT_CLAIMS_HEADERS` +- Config File Key: `jwt_claims_headers` +- Type: `string list` +- Example: `email,groups`, `user` +- Optional + +Set this option for the pomerium proxy to copy JWT claim information into request headers with the name `x-pomerium-claim-*`. Any claim listed in the pomerium JWT can be placed into a corresponding header for downstream consumption. This claim information is sourced from your IDP and pomerium's own session metadata. + +Use this option if you previously relied on `x-pomerium-authenticated-user-{email|user-id|groups}` for downstream authN/Z. + ## Cache Service The cache service is used for storing user session data. diff --git a/docs/docs/upgrading.md b/docs/docs/upgrading.md index 50ceec7d9..ad63d98ec 100644 --- a/docs/docs/upgrading.md +++ b/docs/docs/upgrading.md @@ -13,6 +13,8 @@ description: >- User detail headers ( `x-pomerium-authenticated-user-id` / `x-pomerium-authenticated-user-email` / `x-pomerium-authenticated-user-groups`) have been removed in favor of using the more secure, more data rich attestation jwt header (`x-pomerium-jwt-assertion`). +If you still rely on individual claim headers, please see the `jwt_claims_headers` option [here](https://www.pomerium.io/configuration/#jwt-claim-headers). + ### Non-standard port users Non-standard port users (e.g. those not using `443`/`80` where the port _would_ be part of the client's request) will have to clear their user's session before upgrading. Starting with version v0.7.0, audience (`aud`) and issuer (`iss`) claims will be port specific. diff --git a/proxy/middleware.go b/proxy/middleware.go index 32105670f..73b3b883e 100644 --- a/proxy/middleware.go +++ b/proxy/middleware.go @@ -155,25 +155,50 @@ func SetResponseHeaders(headers map[string]string) func(next http.Handler) http. } } -func (p *Proxy) userDetailsLoggerMiddleware(next http.Handler) http.Handler { +func (p *Proxy) jwtClaimMiddleware(next http.Handler) http.Handler { return httputil.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { if jwt, err := sessions.FromContext(r.Context()); err == nil { - var s sessions.State - if err := p.encoder.Unmarshal([]byte(jwt), &s); err == nil { + var jwtClaims map[string]interface{} + if err := p.encoder.Unmarshal([]byte(jwt), &jwtClaims); err == nil { + formattedJWTClaims := make(map[string]string) + + // reformat claims into something resembling map[string]string + 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 + } + + // log group, email, user claims l := log.Ctx(r.Context()) - l.UpdateContext(func(c zerolog.Context) zerolog.Context { - return c.Strs("groups", s.Groups) - }) - l.UpdateContext(func(c zerolog.Context) zerolog.Context { - return c.Str("email", s.Email) - }) - l.UpdateContext(func(c zerolog.Context) zerolog.Context { - return c.Str("user-id", s.User) - }) + 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 p.jwtClaimHeaders { + if _, ok := formattedJWTClaims[claimName]; ok { + + headerName := fmt.Sprintf("x-pomerium-claim-%s", claimName) + r.Header.Set(headerName, formattedJWTClaims[claimName]) + } + } } } next.ServeHTTP(w, r) return nil }) - } diff --git a/proxy/middleware_test.go b/proxy/middleware_test.go index 28ad5bbfa..f0f5d261a 100644 --- a/proxy/middleware_test.go +++ b/proxy/middleware_test.go @@ -11,6 +11,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/pomerium/pomerium/internal/encoding" + "github.com/pomerium/pomerium/internal/encoding/jws" "github.com/pomerium/pomerium/internal/encoding/mock" "github.com/pomerium/pomerium/internal/grpc/authorize" "github.com/pomerium/pomerium/internal/grpc/authorize/client" @@ -72,15 +73,72 @@ func TestProxy_AuthenticateSession(t *testing.T) { r = r.WithContext(ctx) r.Header.Set("Accept", "application/json") w := httptest.NewRecorder() - got := a.userDetailsLoggerMiddleware(a.AuthenticateSession(fn)) + got := a.jwtClaimMiddleware(a.AuthenticateSession(fn)) got.ServeHTTP(w, r) if status := w.Code; status != tt.wantStatus { t.Errorf("AuthenticateSession() error = %v, wantErr %v\n%v", w.Result().StatusCode, tt.wantStatus, w.Body.String()) } + }) } } +func Test_jwtClaimMiddleware(t *testing.T) { + email := "test@pomerium.example" + groups := []string{"foo", "bar"} + claimHeaders := []string{"email", "groups", "missing"} + sharedKey := "80ldlrU2d7w+wVpKNfevk6fmb8otEx6CqOfshj2LwhQ=" + + session := &sessions.State{Email: email, Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Second)), Groups: groups} + 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{ + 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(handler) + proxyHandler.ServeHTTP(w, r) + + t.Run("email claim", func(t *testing.T) { + emailHeader := r.Header.Get("x-pomerium-claim-email") + if emailHeader != email { + t.Errorf("did not find claim email, want=%q, got=%q", email, emailHeader) + } + }) + + t.Run("groups claim", func(t *testing.T) { + groupsHeader := r.Header.Get("x-pomerium-claim-groups") + if groupsHeader != strings.Join(groups, ",") { + t.Errorf("did not find claim groups, want=%q, got=%q", groups, groupsHeader) + } + }) + + 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) + } + }) + +} + func TestProxy_AuthorizeSession(t *testing.T) { t.Parallel() fn := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/proxy/proxy.go b/proxy/proxy.go index 3d97a86b7..311f32f14 100755 --- a/proxy/proxy.go +++ b/proxy/proxy.go @@ -90,6 +90,7 @@ type Proxy struct { sessionStore sessions.SessionStore sessionLoaders []sessions.SessionLoader templates *template.Template + jwtClaimHeaders []string } // New takes a Proxy service from options and a validation function. @@ -135,7 +136,8 @@ func New(opts config.Options) (*Proxy, error) { cookieStore, header.NewStore(encoder, "Pomerium"), queryparam.NewStore(encoder, "pomerium_session")}, - templates: template.Must(frontend.NewTemplates()), + templates: template.Must(frontend.NewTemplates()), + jwtClaimHeaders: opts.JWTClaimsHeaders, } // errors checked in ValidateOptions p.authorizeURL, _ = urlutil.DeepCopy(opts.AuthorizeURL) @@ -269,8 +271,8 @@ func (p *Proxy) reverseProxyHandler(r *mux.Router, policy config.Policy) *mux.Ro rp.Use(p.AuthorizeSession) // 7. Strip the user session cookie from the downstream request rp.Use(middleware.StripCookie(p.cookieOptions.Name)) - // 8 . Add user details to the request logger context - rp.Use(p.userDetailsLoggerMiddleware) + // 8 . Add claim details to the request logger context and headers + rp.Use(p.jwtClaimMiddleware) return r }