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)