feat: add support for additional OIDC claims via oidcConfig.additionalClaims

This commit is contained in:
Paul Bourhis 2026-02-19 16:17:46 +01:00
parent a279fece02
commit ade3bc54f8
11 changed files with 147 additions and 8 deletions

View File

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

View File

@ -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<br/>loaded from. Available claims: `access_token` `id_token` `created_at`<br/>`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<br/>loaded from. Available claims: `access_token` `id_token` `created_at`<br/>`expires_on` `refresh_token` `email` `user` `groups` `preferred_username`,<br/>plus any custom claims configured in `oidcConfig.additionalClaims`. |
| `prefix` | _string_ | Prefix is an optional prefix that will be prepended to the value of the<br/>claim if it is non-empty. |
| `basicAuthPassword` | _[SecretSource](#secretsource)_ | BasicAuthPassword converts this claim into a basic auth header.<br/>Note the value of claim will become the basic auth username and the<br/>basicAuthPassword will be used as the password value. |
@ -488,6 +488,7 @@ character.
| `userIDClaim` | _string_ | UserIDClaim indicates which claim contains the user ID<br/>default set to 'email' |
| `audienceClaims` | _[]string_ | AudienceClaim allows to define any claim that is verified against the client id<br/>By default `aud` claim is used for verification. |
| `extraAudiences` | _[]string_ | ExtraAudiences is a list of additional audiences that are allowed<br/>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<br/>or profile URL and store in the session for use with ClaimSource. |
### Provider

View File

@ -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<br/>loaded from. Available claims: `access_token` `id_token` `created_at`<br/>`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<br/>loaded from. Available claims: `access_token` `id_token` `created_at`<br/>`expires_on` `refresh_token` `email` `user` `groups` `preferred_username`,<br/>plus any custom claims configured in `oidcConfig.additionalClaims`. |
| `prefix` | _string_ | Prefix is an optional prefix that will be prepended to the value of the<br/>claim if it is non-empty. |
| `basicAuthPassword` | _[SecretSource](#secretsource)_ | BasicAuthPassword converts this claim into a basic auth header.<br/>Note the value of claim will become the basic auth username and the<br/>basicAuthPassword will be used as the password value. |
@ -488,6 +488,7 @@ character.
| `userIDClaim` | _string_ | UserIDClaim indicates which claim contains the user ID<br/>default set to 'email' |
| `audienceClaims` | _[]string_ | AudienceClaim allows to define any claim that is verified against the client id<br/>By default `aud` claim is used for verification. |
| `extraAudiences` | _[]string_ | ExtraAudiences is a list of additional audiences that are allowed<br/>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<br/>or profile URL and store in the session for use with ClaimSource. |
### Provider

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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