Add configurable JWT claim headers (#596)

This commit is contained in:
Travis Groth 2020-04-09 23:41:55 -04:00 committed by GitHub
parent b08ecc624a
commit 789068e27a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 118 additions and 17 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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