auto-collect header claims into provider AdditionalClaims

When alpha config injects request/response headers from OIDC claims,
users must manually duplicate those claim names into each provider's
additionalClaims list or get empty values. This is error-prone and
undocumented.

collectHeaderClaimsIntoProviders scans InjectRequestHeaders and
InjectResponseHeaders for ClaimSource entries, skips built-in
session fields (email, groups, etc.), and appends the remainder
to every provider's AdditionalClaims list with deduplication.

Signed-off-by: June Kim <kimjune01@gmail.com>
This commit is contained in:
June Kim 2026-05-09 09:22:38 -07:00
parent 65037b086c
commit 7f686206e8
2 changed files with 242 additions and 0 deletions

View File

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

View File

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