mirror of
https://github.com/pomerium/pomerium.git
synced 2025-05-18 19:47:22 +02:00
Add configurable JWT claim headers (#596)
This commit is contained in:
parent
b08ecc624a
commit
789068e27a
6 changed files with 118 additions and 17 deletions
|
@ -135,6 +135,9 @@ type Options struct {
|
||||||
HeadersEnv string `yaml:",omitempty"`
|
HeadersEnv string `yaml:",omitempty"`
|
||||||
Headers map[string]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 limits the rate a user can refresh her session
|
||||||
RefreshCooldown time.Duration `mapstructure:"refresh_cooldown" yaml:"refresh_cooldown,omitempty"`
|
RefreshCooldown time.Duration `mapstructure:"refresh_cooldown" yaml:"refresh_cooldown,omitempty"`
|
||||||
|
|
||||||
|
|
|
@ -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.
|
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
|
## Cache Service
|
||||||
|
|
||||||
The cache service is used for storing user session data.
|
The cache service is used for storing user session data.
|
||||||
|
|
|
@ -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`).
|
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
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
|
@ -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 {
|
return httputil.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
||||||
if jwt, err := sessions.FromContext(r.Context()); err == nil {
|
if jwt, err := sessions.FromContext(r.Context()); err == nil {
|
||||||
var s sessions.State
|
var jwtClaims map[string]interface{}
|
||||||
if err := p.encoder.Unmarshal([]byte(jwt), &s); err == nil {
|
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 := log.Ctx(r.Context())
|
||||||
l.UpdateContext(func(c zerolog.Context) zerolog.Context {
|
for _, claimName := range []string{"groups", "email", "user"} {
|
||||||
return c.Strs("groups", s.Groups)
|
|
||||||
})
|
l.UpdateContext(func(c zerolog.Context) zerolog.Context {
|
||||||
l.UpdateContext(func(c zerolog.Context) zerolog.Context {
|
return c.Str(claimName, fmt.Sprintf("%v", formattedJWTClaims[claimName]))
|
||||||
return c.Str("email", s.Email)
|
})
|
||||||
})
|
|
||||||
l.UpdateContext(func(c zerolog.Context) zerolog.Context {
|
}
|
||||||
return c.Str("user-id", s.User)
|
|
||||||
})
|
// 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)
|
next.ServeHTTP(w, r)
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,6 +11,7 @@ import (
|
||||||
|
|
||||||
"github.com/google/go-cmp/cmp"
|
"github.com/google/go-cmp/cmp"
|
||||||
"github.com/pomerium/pomerium/internal/encoding"
|
"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/encoding/mock"
|
||||||
"github.com/pomerium/pomerium/internal/grpc/authorize"
|
"github.com/pomerium/pomerium/internal/grpc/authorize"
|
||||||
"github.com/pomerium/pomerium/internal/grpc/authorize/client"
|
"github.com/pomerium/pomerium/internal/grpc/authorize/client"
|
||||||
|
@ -72,15 +73,72 @@ func TestProxy_AuthenticateSession(t *testing.T) {
|
||||||
r = r.WithContext(ctx)
|
r = r.WithContext(ctx)
|
||||||
r.Header.Set("Accept", "application/json")
|
r.Header.Set("Accept", "application/json")
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
got := a.userDetailsLoggerMiddleware(a.AuthenticateSession(fn))
|
got := a.jwtClaimMiddleware(a.AuthenticateSession(fn))
|
||||||
got.ServeHTTP(w, r)
|
got.ServeHTTP(w, r)
|
||||||
if status := w.Code; status != tt.wantStatus {
|
if status := w.Code; status != tt.wantStatus {
|
||||||
t.Errorf("AuthenticateSession() error = %v, wantErr %v\n%v", w.Result().StatusCode, tt.wantStatus, w.Body.String())
|
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) {
|
func TestProxy_AuthorizeSession(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
fn := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
fn := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
|
@ -90,6 +90,7 @@ type Proxy struct {
|
||||||
sessionStore sessions.SessionStore
|
sessionStore sessions.SessionStore
|
||||||
sessionLoaders []sessions.SessionLoader
|
sessionLoaders []sessions.SessionLoader
|
||||||
templates *template.Template
|
templates *template.Template
|
||||||
|
jwtClaimHeaders []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// New takes a Proxy service from options and a validation function.
|
// New takes a Proxy service from options and a validation function.
|
||||||
|
@ -135,7 +136,8 @@ func New(opts config.Options) (*Proxy, error) {
|
||||||
cookieStore,
|
cookieStore,
|
||||||
header.NewStore(encoder, "Pomerium"),
|
header.NewStore(encoder, "Pomerium"),
|
||||||
queryparam.NewStore(encoder, "pomerium_session")},
|
queryparam.NewStore(encoder, "pomerium_session")},
|
||||||
templates: template.Must(frontend.NewTemplates()),
|
templates: template.Must(frontend.NewTemplates()),
|
||||||
|
jwtClaimHeaders: opts.JWTClaimsHeaders,
|
||||||
}
|
}
|
||||||
// errors checked in ValidateOptions
|
// errors checked in ValidateOptions
|
||||||
p.authorizeURL, _ = urlutil.DeepCopy(opts.AuthorizeURL)
|
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)
|
rp.Use(p.AuthorizeSession)
|
||||||
// 7. Strip the user session cookie from the downstream request
|
// 7. Strip the user session cookie from the downstream request
|
||||||
rp.Use(middleware.StripCookie(p.cookieOptions.Name))
|
rp.Use(middleware.StripCookie(p.cookieOptions.Name))
|
||||||
// 8 . Add user details to the request logger context
|
// 8 . Add claim details to the request logger context and headers
|
||||||
rp.Use(p.userDetailsLoggerMiddleware)
|
rp.Use(p.jwtClaimMiddleware)
|
||||||
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue