From ade3bc54f85a3ad5deabb6c8e0dc4d83955ec8ec Mon Sep 17 00:00:00 2001 From: Paul Bourhis Date: Thu, 19 Feb 2026 16:17:46 +0100 Subject: [PATCH] feat: add support for additional OIDC claims via oidcConfig.additionalClaims --- CHANGELOG.md | 5 +- docs/docs/configuration/alpha_config.md | 5 +- .../configuration/alpha_config.md | 5 +- pkg/apis/options/header.go | 3 +- pkg/apis/options/providers.go | 3 ++ pkg/apis/sessions/session_state.go | 10 +++- pkg/apis/sessions/session_state_test.go | 20 +++++++ pkg/middleware/headers_test.go | 31 +++++++++++ providers/provider_data.go | 18 +++++++ providers/provider_data_test.go | 54 +++++++++++++++++++ providers/providers.go | 1 + 11 files changed, 147 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76c506ba..32a2dcae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ## Changes since v7.14.2 - [#3183](https://github.com/oauth2-proxy/oauth2-proxy/pull/3183) fix: allow URL parameters to configure username, password and max idle connection timeout if the matching configuration is empty. +- [#3340](https://github.com/oauth2-proxy/oauth2-proxy/issues/3340) feat: allow injecting additional OIDC claims via `oidcConfig.additionalClaims` # V7.14.2 @@ -60,7 +61,7 @@ ## Important Notes -This release introduces a breaking change for Alpha Config users and moves us significantly +This release introduces a breaking change for Alpha Config users and moves us significantly closer to removing legacy configuration parameters, making the codebase of OAuth2 Proxy more future proof and extensible. @@ -137,7 +138,7 @@ the project for future maintainability and future improvements like structured l - [#3292](https://github.com/oauth2-proxy/oauth2-proxy/pull/3292) chore(deps): upgrade gomod and bump to golang v1.25.5 (@tuunit) - [#3304](https://github.com/oauth2-proxy/oauth2-proxy/pull/3304) fix: added conditional so default is not always set and env vars are honored fixes 3303 (@pixeldrew) - [#3264](https://github.com/oauth2-proxy/oauth2-proxy/pull/3264) fix: more aggressively truncate logged access_token (@MartinNowak / @tuunit) -- [#3267](https://github.com/oauth2-proxy/oauth2-proxy/pull/3267) fix: Session refresh handling in OIDC provider (@gysel) +- [#3267](https://github.com/oauth2-proxy/oauth2-proxy/pull/3267) fix: Session refresh handling in OIDC provider (@gysel) - [#3290](https://github.com/oauth2-proxy/oauth2-proxy/pull/3290) fix: WebSocket proxy to respect PassHostHeader setting (@UnsignedLong) # V7.13.0 diff --git a/docs/docs/configuration/alpha_config.md b/docs/docs/configuration/alpha_config.md index b4a75582..52af0358 100644 --- a/docs/docs/configuration/alpha_config.md +++ b/docs/docs/configuration/alpha_config.md @@ -152,7 +152,7 @@ injectResponseHeaders: value: c3VwZXItc2VjcmV0LXBhc3N3b3Jk # base64 encoded password ``` -**Value sources:** +**Value sources:** * `claimSource` - `claim` (session claims either from id token or from profile URL) * `secretSource` - `value` (base64), `fromFile` (file path) @@ -279,7 +279,7 @@ ClaimSource allows loading a header value from a claim within the session | Field | Type | Description | | ----- | ---- | ----------- | -| `claim` | _string_ | Claim is the name of the claim in the session that the value should be
loaded from. Available claims: `access_token` `id_token` `created_at`
`expires_on` `refresh_token` `email` `user` `groups` `preferred_username`. | +| `claim` | _string_ | Claim is the name of the claim in the session that the value should be
loaded from. Available claims: `access_token` `id_token` `created_at`
`expires_on` `refresh_token` `email` `user` `groups` `preferred_username`,
plus any custom claims configured in `oidcConfig.additionalClaims`. | | `prefix` | _string_ | Prefix is an optional prefix that will be prepended to the value of the
claim if it is non-empty. | | `basicAuthPassword` | _[SecretSource](#secretsource)_ | BasicAuthPassword converts this claim into a basic auth header.
Note the value of claim will become the basic auth username and the
basicAuthPassword will be used as the password value. | @@ -488,6 +488,7 @@ character. | `userIDClaim` | _string_ | UserIDClaim indicates which claim contains the user ID
default set to 'email' | | `audienceClaims` | _[]string_ | AudienceClaim allows to define any claim that is verified against the client id
By default `aud` claim is used for verification. | | `extraAudiences` | _[]string_ | ExtraAudiences is a list of additional audiences that are allowed
to pass verification in addition to the client id. | +| `additionalClaims` | _[]string_ | AdditionalClaims is a list of additional claim names to pull from the ID token
or profile URL and store in the session for use with ClaimSource. | ### Provider diff --git a/docs/versioned_docs/version-7.14.x/configuration/alpha_config.md b/docs/versioned_docs/version-7.14.x/configuration/alpha_config.md index b4a75582..52af0358 100644 --- a/docs/versioned_docs/version-7.14.x/configuration/alpha_config.md +++ b/docs/versioned_docs/version-7.14.x/configuration/alpha_config.md @@ -152,7 +152,7 @@ injectResponseHeaders: value: c3VwZXItc2VjcmV0LXBhc3N3b3Jk # base64 encoded password ``` -**Value sources:** +**Value sources:** * `claimSource` - `claim` (session claims either from id token or from profile URL) * `secretSource` - `value` (base64), `fromFile` (file path) @@ -279,7 +279,7 @@ ClaimSource allows loading a header value from a claim within the session | Field | Type | Description | | ----- | ---- | ----------- | -| `claim` | _string_ | Claim is the name of the claim in the session that the value should be
loaded from. Available claims: `access_token` `id_token` `created_at`
`expires_on` `refresh_token` `email` `user` `groups` `preferred_username`. | +| `claim` | _string_ | Claim is the name of the claim in the session that the value should be
loaded from. Available claims: `access_token` `id_token` `created_at`
`expires_on` `refresh_token` `email` `user` `groups` `preferred_username`,
plus any custom claims configured in `oidcConfig.additionalClaims`. | | `prefix` | _string_ | Prefix is an optional prefix that will be prepended to the value of the
claim if it is non-empty. | | `basicAuthPassword` | _[SecretSource](#secretsource)_ | BasicAuthPassword converts this claim into a basic auth header.
Note the value of claim will become the basic auth username and the
basicAuthPassword will be used as the password value. | @@ -488,6 +488,7 @@ character. | `userIDClaim` | _string_ | UserIDClaim indicates which claim contains the user ID
default set to 'email' | | `audienceClaims` | _[]string_ | AudienceClaim allows to define any claim that is verified against the client id
By default `aud` claim is used for verification. | | `extraAudiences` | _[]string_ | ExtraAudiences is a list of additional audiences that are allowed
to pass verification in addition to the client id. | +| `additionalClaims` | _[]string_ | AdditionalClaims is a list of additional claim names to pull from the ID token
or profile URL and store in the session for use with ClaimSource. | ### Provider diff --git a/pkg/apis/options/header.go b/pkg/apis/options/header.go index d9509de0..4ba98ce5 100644 --- a/pkg/apis/options/header.go +++ b/pkg/apis/options/header.go @@ -50,7 +50,8 @@ type HeaderValue struct { type ClaimSource struct { // Claim is the name of the claim in the session that the value should be // loaded from. Available claims: `access_token` `id_token` `created_at` - // `expires_on` `refresh_token` `email` `user` `groups` `preferred_username`. + // `expires_on` `refresh_token` `email` `user` `groups` `preferred_username`, + // plus any custom claims configured in `oidcConfig.additionalClaims`. Claim string `yaml:"claim,omitempty"` // Prefix is an optional prefix that will be prepended to the value of the diff --git a/pkg/apis/options/providers.go b/pkg/apis/options/providers.go index 94bdb592..9afa4a2d 100644 --- a/pkg/apis/options/providers.go +++ b/pkg/apis/options/providers.go @@ -318,6 +318,9 @@ type OIDCOptions struct { // ExtraAudiences is a list of additional audiences that are allowed // to pass verification in addition to the client id. ExtraAudiences []string `yaml:"extraAudiences,omitempty"` + // AdditionalClaims defines additional claims to pull from the ID token or + // profile URL and store in the session for claimSource usage. + AdditionalClaims []string `yaml:"additionalClaims,omitempty"` } type LoginGovOptions struct { diff --git a/pkg/apis/sessions/session_state.go b/pkg/apis/sessions/session_state.go index a1f807ab..7d6673d3 100644 --- a/pkg/apis/sessions/session_state.go +++ b/pkg/apis/sessions/session_state.go @@ -27,6 +27,7 @@ type SessionState struct { User string `msgpack:"u,omitempty"` Groups []string `msgpack:"g,omitempty"` PreferredUsername string `msgpack:"pu,omitempty"` + ExtraClaims map[string][]string `msgpack:"ec,omitempty"` // Internal helpers, not serialized Clock func() time.Time `msgpack:"-"` // override for time.Now, for testing @@ -156,7 +157,14 @@ func (s *SessionState) GetClaim(claim string) []string { case "preferred_username": return []string{s.PreferredUsername} default: - return []string{} + if s.ExtraClaims == nil { + return []string{} + } + values, ok := s.ExtraClaims[claim] + if !ok { + return []string{} + } + return append([]string(nil), values...) } } diff --git a/pkg/apis/sessions/session_state_test.go b/pkg/apis/sessions/session_state_test.go index 87b97614..31804f96 100644 --- a/pkg/apis/sessions/session_state_test.go +++ b/pkg/apis/sessions/session_state_test.go @@ -155,6 +155,26 @@ func TestAge(t *testing.T) { assert.Equal(t, time.Hour, ss.Age().Round(time.Minute)) } +func TestGetClaimAdditionalClaims(t *testing.T) { + ss := &SessionState{ + ExtraClaims: map[string][]string{ + "displayName": []string{"Jane D."}, + "jobTitle": []string{"Principal Consultant"}, + }, + } + + assert.Equal(t, []string{"Jane D."}, ss.GetClaim("displayName")) + assert.Equal(t, []string{"Principal Consultant"}, ss.GetClaim("jobTitle")) + assert.Equal(t, []string{}, ss.GetClaim("missing")) + + result := ss.GetClaim("displayName") + result[0] = "Mutated" + assert.Equal(t, []string{"Jane D."}, ss.GetClaim("displayName")) + + var nilSession *SessionState + assert.Equal(t, []string{}, nilSession.GetClaim("displayName")) +} + // TestEncodeAndDecodeSessionState encodes & decodes various session states // and confirms the operation is 1:1 func TestEncodeAndDecodeSessionState(t *testing.T) { diff --git a/pkg/middleware/headers_test.go b/pkg/middleware/headers_test.go index c3c402f0..dcc96370 100644 --- a/pkg/middleware/headers_test.go +++ b/pkg/middleware/headers_test.go @@ -180,6 +180,37 @@ var _ = Describe("Headers Suite", func() { }, expectedErr: "", }), + Entry("with configured additional claims in response headers", headersTableInput{ + headers: []options.Header{ + { + Name: "X-User-Display-Name", + Values: []options.HeaderValue{{ + ClaimSource: &options.ClaimSource{Claim: "displayName"}, + }}, + }, + { + Name: "X-User-Job-Title", + Values: []options.HeaderValue{{ + ClaimSource: &options.ClaimSource{Claim: "jobTitle"}, + }}, + }, + }, + initialHeaders: http.Header{ + "Foo": []string{"bar", "baz"}, + }, + session: &sessionsapi.SessionState{ + ExtraClaims: map[string][]string{ + "displayName": []string{"Jane D."}, + "jobTitle": []string{"Principal Consultant"}, + }, + }, + expectedHeaders: http.Header{ + "Foo": []string{"bar,baz"}, + "X-User-Display-Name": []string{"Jane D."}, + "X-User-Job-Title": []string{"Principal Consultant"}, + }, + expectedErr: "", + }), Entry("with an invalid basicAuthPassword claim valued header", headersTableInput{ headers: []options.Header{ { diff --git a/providers/provider_data.go b/providers/provider_data.go index 95de5c50..a9cbb408 100644 --- a/providers/provider_data.go +++ b/providers/provider_data.go @@ -51,6 +51,7 @@ type ProviderData struct { GroupsClaim string Verifier internaloidc.IDTokenVerifier SkipClaimsFromProfileURL bool + AdditionalClaims []string // Universal Group authorization data structure // any provider can set to consume @@ -268,6 +269,23 @@ func (p *ProviderData) buildSessionFromClaims(rawIDToken, accessToken string) (* } } + if len(p.AdditionalClaims) > 0 { + for _, claim := range p.AdditionalClaims { + var values []string + exists, err := extractor.GetClaimInto(claim, &values) + if err != nil { + return nil, err + } + if !exists || len(values) == 0 { + continue + } + if ss.ExtraClaims == nil { + ss.ExtraClaims = map[string][]string{} + } + ss.ExtraClaims[claim] = append([]string(nil), values...) + } + } + // `email_verified` must be present and explicitly set to `false` to be // considered unverified. verifyEmail := (p.EmailClaim == options.OIDCEmailClaim) && !p.AllowUnverifiedEmail diff --git a/providers/provider_data_test.go b/providers/provider_data_test.go index 044a77b1..9873ecdc 100644 --- a/providers/provider_data_test.go +++ b/providers/provider_data_test.go @@ -95,6 +95,20 @@ var ( RegisteredClaims: registeredClaims, } + displayNameAndJobTitleIDToken = idTokenClaims{ + Name: "Jane Dobbs", + Email: "janed@me.com", + Phone: "+4798765432", + Picture: "http://mugbook.com/janed/me.jpg", + Groups: []string{"test:a", "test:b"}, + Roles: []string{"test:c", "test:d"}, + DisplayName: "Jane D.", + JobTitle: "Principal Consultant", + Verified: &verified, + Nonce: encryption.HashNonce([]byte(oidcNonce)), + RegisteredClaims: registeredClaims, + } + unverifiedIDToken = idTokenClaims{ Name: "Mystery Man", Email: "unverified@email.com", @@ -118,6 +132,8 @@ type idTokenClaims struct { Picture string `json:"picture,omitempty"` Groups interface{} `json:"groups,omitempty"` Roles interface{} `json:"roles,omitempty"` + DisplayName string `json:"displayName,omitempty"` + JobTitle string `json:"jobTitle,omitempty"` Verified *bool `json:"email_verified,omitempty"` Nonce string `json:"nonce,omitempty"` jwt.RegisteredClaims @@ -234,6 +250,7 @@ func TestProviderData_buildSessionFromClaims(t *testing.T) { GroupsClaim string SkipClaimsFromProfileURL bool SetProfileURL bool + AdditionalClaims []string ExpectedError error ExpectedSession *sessions.SessionState ExpectProfileURLCalled bool @@ -405,6 +422,42 @@ func TestProviderData_buildSessionFromClaims(t *testing.T) { PreferredUsername: "Jane Dobbs", }, }, + "Extra Claims": { + IDToken: defaultIDToken, + AllowUnverified: true, + EmailClaim: "email", + GroupsClaim: "groups", + UserClaim: "sub", + AdditionalClaims: []string{"picture", "roles"}, + ExpectedSession: &sessions.SessionState{ + User: "123456789", + Email: "janed@me.com", + Groups: []string{"test:a", "test:b"}, + PreferredUsername: "Jane Dobbs", + ExtraClaims: map[string][]string{ + "picture": {"http://mugbook.com/janed/me.jpg"}, + "roles": {"test:c", "test:d"}, + }, + }, + }, + "Extra Claims (displayName and jobTitle)": { + IDToken: displayNameAndJobTitleIDToken, + AllowUnverified: true, + EmailClaim: "email", + GroupsClaim: "groups", + UserClaim: "sub", + AdditionalClaims: []string{"displayName", "jobTitle"}, + ExpectedSession: &sessions.SessionState{ + User: "123456789", + Email: "janed@me.com", + Groups: []string{"test:a", "test:b"}, + PreferredUsername: "Jane Dobbs", + ExtraClaims: map[string][]string{ + "displayName": {"Jane D."}, + "jobTitle": {"Principal Consultant"}, + }, + }, + }, "Request claims from ProfileURL": { IDToken: minimalIDToken, SetProfileURL: true, @@ -453,6 +506,7 @@ func TestProviderData_buildSessionFromClaims(t *testing.T) { provider.EmailClaim = tc.EmailClaim provider.GroupsClaim = tc.GroupsClaim provider.SkipClaimsFromProfileURL = tc.SkipClaimsFromProfileURL + provider.AdditionalClaims = tc.AdditionalClaims rawIDToken, err := newSignedTestIDToken(tc.IDToken) g.Expect(err).ToNot(HaveOccurred()) diff --git a/providers/providers.go b/providers/providers.go index 6af51ecf..69409de8 100644 --- a/providers/providers.go +++ b/providers/providers.go @@ -148,6 +148,7 @@ func newProviderDataFromConfig(providerConfig options.Provider) (*ProviderData, p.EmailClaim = providerConfig.OIDCConfig.EmailClaim p.GroupsClaim = providerConfig.OIDCConfig.GroupsClaim p.SkipClaimsFromProfileURL = ptr.Deref(providerConfig.SkipClaimsFromProfileURL, options.DefaultSkipClaimsFromProfileURL) + p.AdditionalClaims = append([]string(nil), providerConfig.OIDCConfig.AdditionalClaims...) // Set PKCE enabled or disabled based on discovery and force options p.CodeChallengeMethod = parseCodeChallengeMethod(providerConfig)