diff --git a/pkg/apis/options/alpha_options.go b/pkg/apis/options/alpha_options.go index 33daf17f..bd8cc229 100644 --- a/pkg/apis/options/alpha_options.go +++ b/pkg/apis/options/alpha_options.go @@ -74,4 +74,56 @@ func (a *AlphaOptions) MergeOptionsWithDefaults(opts *Options) { opts.Server = a.Server opts.MetricsServer = a.MetricsServer opts.Providers = a.Providers + + // Automatically add claims referenced in header injection to each + // provider's AdditionalClaims so they are extracted from the ID token. + collectHeaderClaimsIntoProviders(opts) +} + +// builtinSessionClaims are claims that are always available on the session +// without needing to be listed in AdditionalClaims. +var builtinSessionClaims = map[string]bool{ + "access_token": true, + "id_token": true, + "created_at": true, + "expires_on": true, + "refresh_token": true, + "email": true, + "user": true, + "groups": true, + "preferred_username": true, +} + +// collectHeaderClaimsIntoProviders inspects InjectRequestHeaders and +// InjectResponseHeaders for ClaimSource entries whose claim is not a +// built-in session field and adds them to every provider's +// AdditionalClaims list (deduplicated). +func collectHeaderClaimsIntoProviders(opts *Options) { + needed := map[string]bool{} + for _, header := range append(opts.InjectRequestHeaders, opts.InjectResponseHeaders...) { + for _, value := range header.Values { + if value.ClaimSource != nil && value.ClaimSource.Claim != "" { + claim := value.ClaimSource.Claim + if !builtinSessionClaims[claim] { + needed[claim] = true + } + } + } + } + + if len(needed) == 0 { + return + } + + for i := range opts.Providers { + existing := map[string]bool{} + for _, c := range opts.Providers[i].AdditionalClaims { + existing[c] = true + } + for claim := range needed { + if !existing[claim] { + opts.Providers[i].AdditionalClaims = append(opts.Providers[i].AdditionalClaims, claim) + } + } + } } diff --git a/pkg/apis/options/alpha_options_test.go b/pkg/apis/options/alpha_options_test.go new file mode 100644 index 00000000..9e126040 --- /dev/null +++ b/pkg/apis/options/alpha_options_test.go @@ -0,0 +1,190 @@ +package options + +import ( + "sort" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("AlphaOptions", func() { + Describe("collectHeaderClaimsIntoProviders", func() { + It("adds non-builtin claims from injectRequestHeaders to provider AdditionalClaims", func() { + opts := NewOptions() + opts.Providers = Providers{ + { + ID: "test", + Type: "oidc", + }, + } + opts.InjectRequestHeaders = []Header{ + { + Name: "X-Forwarded-Upn", + Values: []HeaderValue{ + {ClaimSource: &ClaimSource{Claim: "upn"}}, + }, + }, + { + Name: "X-Forwarded-GivenName", + Values: []HeaderValue{ + {ClaimSource: &ClaimSource{Claim: "given_name"}}, + }, + }, + } + + collectHeaderClaimsIntoProviders(opts) + + claims := opts.Providers[0].AdditionalClaims + sort.Strings(claims) + Expect(claims).To(ConsistOf("given_name", "upn")) + }) + + It("does not duplicate claims already in AdditionalClaims", func() { + opts := NewOptions() + opts.Providers = Providers{ + { + ID: "test", + Type: "oidc", + AdditionalClaims: []string{"upn"}, + }, + } + opts.InjectRequestHeaders = []Header{ + { + Name: "X-Forwarded-Upn", + Values: []HeaderValue{ + {ClaimSource: &ClaimSource{Claim: "upn"}}, + }, + }, + } + + collectHeaderClaimsIntoProviders(opts) + + Expect(opts.Providers[0].AdditionalClaims).To(Equal([]string{"upn"})) + }) + + It("skips builtin session claims", func() { + opts := NewOptions() + opts.Providers = Providers{ + { + ID: "test", + Type: "oidc", + }, + } + opts.InjectRequestHeaders = []Header{ + { + Name: "X-Email", + Values: []HeaderValue{ + {ClaimSource: &ClaimSource{Claim: "email"}}, + }, + }, + { + Name: "X-User", + Values: []HeaderValue{ + {ClaimSource: &ClaimSource{Claim: "user"}}, + }, + }, + { + Name: "X-Groups", + Values: []HeaderValue{ + {ClaimSource: &ClaimSource{Claim: "groups"}}, + }, + }, + { + Name: "X-Token", + Values: []HeaderValue{ + {ClaimSource: &ClaimSource{Claim: "access_token"}}, + }, + }, + } + + collectHeaderClaimsIntoProviders(opts) + + Expect(opts.Providers[0].AdditionalClaims).To(BeEmpty()) + }) + + It("also collects claims from injectResponseHeaders", func() { + opts := NewOptions() + opts.Providers = Providers{ + { + ID: "test", + Type: "oidc", + }, + } + opts.InjectResponseHeaders = []Header{ + { + Name: "X-Family-Name", + Values: []HeaderValue{ + {ClaimSource: &ClaimSource{Claim: "family_name"}}, + }, + }, + } + + collectHeaderClaimsIntoProviders(opts) + + Expect(opts.Providers[0].AdditionalClaims).To(ConsistOf("family_name")) + }) + + It("adds claims to all providers", func() { + opts := NewOptions() + opts.Providers = Providers{ + { + ID: "provider1", + Type: "oidc", + }, + { + ID: "provider2", + Type: "oidc", + }, + } + opts.InjectRequestHeaders = []Header{ + { + Name: "X-Upn", + Values: []HeaderValue{ + {ClaimSource: &ClaimSource{Claim: "upn"}}, + }, + }, + } + + collectHeaderClaimsIntoProviders(opts) + + Expect(opts.Providers[0].AdditionalClaims).To(ConsistOf("upn")) + Expect(opts.Providers[1].AdditionalClaims).To(ConsistOf("upn")) + }) + + It("ignores headers with only SecretSource values", func() { + opts := NewOptions() + opts.Providers = Providers{ + { + ID: "test", + Type: "oidc", + }, + } + opts.InjectRequestHeaders = []Header{ + { + Name: "X-Static", + Values: []HeaderValue{ + {SecretSource: &SecretSource{Value: []byte("static-value")}}, + }, + }, + } + + collectHeaderClaimsIntoProviders(opts) + + Expect(opts.Providers[0].AdditionalClaims).To(BeEmpty()) + }) + + It("does nothing when no headers are configured", func() { + opts := NewOptions() + opts.Providers = Providers{ + { + ID: "test", + Type: "oidc", + }, + } + + collectHeaderClaimsIntoProviders(opts) + + Expect(opts.Providers[0].AdditionalClaims).To(BeNil()) + }) + }) +})