git.iko.soy/typeable/oauth2-proxy
on master help sign in
feat: allow arbitrary claims from the IDToken and IdentityProvider UserInfo endpoint to be added to the session state (#2685)

* feat: support additional claims Signed-off-by: afsu <suaf2020@163.com> Signed-off-by: af su <saf@zjuici.com> * docs: clarify that AdditionalClaims may come from id_token or userinfo endpoint Signed-off-by: afsu <suaf2020@163.com> Signed-off-by: af su <saf@zjuici.com> * feat: include AdditionalClaims in /oauth2/userinfo response (#834) Signed-off-by: afsu <suaf2020@163.com> Signed-off-by: af su <saf@zjuici.com> * refactor: extract coerceClaim logic into util Signed-off-by: afsu <suaf2020@163.com> Signed-off-by: af su <saf@zjuici.com> * doc: add changelog entry for #2685 Signed-off-by: Jan Larwig <jan@larwig.com> * refactor: added more verbose comments to some struct fields and minor code cleanup Signed-off-by: Jan Larwig <jan@larwig.com> --------- Signed-off-by: afsu <suaf2020@163.com> Signed-off-by: af su <saf@zjuici.com> Signed-off-by: Jan Larwig <jan@larwig.com> Co-authored-by: af su <saf@zjuici.com> Co-authored-by: Jan Larwig <jan@larwig.com>

commite59f7c1549aeee85e96f771694ac55cf8a594edb authoraf su <suaf2020@163.com> dateSat 14 Mar 2026, 12:04 (7 months ago) parentc6355ee
14 files changed+314−137
CHANGELOG.md+1 docs/docs/configuration/alpha_config.md+1 oauthproxy.go+6−4 oauthproxy_test.go+14 pkg/apis/options/providers.go+3 pkg/apis/sessions/session_state.go+15−1 pkg/apis/sessions/session_state_test.go+64 pkg/providers/util/claim_extractor.go+10−74 pkg/providers/util/claim_extractor_test.go+2−53 pkg/util/util.go+64 pkg/util/util_test.go+70 providers/provider_data.go+40−5 providers/provider_data_test.go+23 providers/providers.go+1
CHANGELOG.md+1
@@ -10,6 +10,7 @@
1010
1111- [#3352](https://github.com/oauth2-proxy/oauth2-proxy/pull/3352) fix: backend logout URL call on sign out (#3172)(@vsejpal)
1212- [#3332](https://github.com/oauth2-proxy/oauth2-proxy/pull/3332) ci: distribute windows binary with .exe extension (@igitur)
13+- [#2685](https://github.com/oauth2-proxy/oauth2-proxy/pull/2685) feat: allow arbitrary claims from the IDToken and IdentityProvider UserInfo endpoint to be added to the session state (@vegetablest)
1314
1415# V7.14.3
1516
docs/docs/configuration/alpha_config.md+1
@@ -526,6 +526,7 @@ Provider holds all configuration for a single provider
526526| `scope` | _string_ | Scope is the OAuth scope specification |
527527| `allowedGroups` | _[]string_ | AllowedGroups is a list of restrict logins to members of this group |
528528| `code_challenge_method` | _string_ | The code challenge method |
529+| `additionalClaims` | _[]string_ | Additional claims to be obtained from the upstream IDP, either from the id_token or from the userinfo endpoint if configured. |
529530| `backendLogoutURL` | _string_ | URL to call to perform backend logout, `{id_token}` would be replaced by the actual `id_token` if available in the session |
530531
531532### ProviderType
oauthproxy.go+6−4
@@ -721,15 +721,17 @@ func (p *OAuthProxy) UserInfo(rw http.ResponseWriter, req *http.Request) {
721721 }
722722
723723 userInfo := struct {
724− User string `json:"user"`
725− Email string `json:"email"`
726− Groups []string `json:"groups,omitempty"`
727− PreferredUsername string `json:"preferredUsername,omitempty"`
724+ User string `json:"user"`
725+ Email string `json:"email"`
726+ Groups []string `json:"groups,omitempty"`
727+ PreferredUsername string `json:"preferredUsername,omitempty"`
728+ AdditionalClaims map[string]interface{} `json:"additionalClaims,omitempty"`
728729 }{
729730 User: session.User,
730731 Email: session.Email,
731732 Groups: session.Groups,
732733 PreferredUsername: session.PreferredUsername,
734+ AdditionalClaims: session.AdditionalClaims,
733735 }
734736
735737 if err := json.NewEncoder(rw).Encode(userInfo); err != nil {
oauthproxy_test.go+14
@@ -1032,6 +1032,20 @@ func TestUserInfoEndpointAccepted(t *testing.T) {
10321032 },
10331033 expectedResponse: "{\"user\":\"john.doe\",\"email\":\"john.doe@example.com\",\"groups\":[\"example\",\"groups\"],\"preferredUsername\":\"john\"}\n",
10341034 },
1035+ {
1036+ name: "With Additional Claim",
1037+ session: &sessions.SessionState{
1038+ User: "john.doe",
1039+ PreferredUsername: "john",
1040+ Email: "john.doe@example.com",
1041+ Groups: []string{"example", "groups"},
1042+ AccessToken: "my_access_token",
1043+ AdditionalClaims: map[string]interface{}{
1044+ "foo": "bar",
1045+ },
1046+ },
1047+ expectedResponse: "{\"user\":\"john.doe\",\"email\":\"john.doe@example.com\",\"groups\":[\"example\",\"groups\"],\"preferredUsername\":\"john\",\"additionalClaims\":{\"foo\":\"bar\"}}\n",
1048+ },
10351049 }
10361050
10371051 for _, tc := range testCases {
pkg/apis/options/providers.go+3
@@ -134,6 +134,9 @@ type Provider struct {
134134 // The code challenge method
135135 CodeChallengeMethod string `yaml:"code_challenge_method,omitempty"`
136136
137+ // Additional claims to be obtained from the upstream IDP, either from the id_token or from the userinfo endpoint if configured.
138+ AdditionalClaims []string `yaml:"additionalClaims,omitempty"`
139+
137140 // URL to call to perform backend logout, `{id_token}` would be replaced by the actual `id_token` if available in the session
138141 BackendLogoutURL string `yaml:"backendLogoutURL"`
139142}
pkg/apis/sessions/session_state.go+15−1
@@ -8,6 +8,7 @@ import (
88 "time"
99
1010 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/encryption"
11+ "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/util"
1112 "github.com/pierrec/lz4/v4"
1213 "github.com/vmihailenco/msgpack/v5"
1314)
@@ -28,6 +29,9 @@ type SessionState struct {
2829 Groups []string `msgpack:"g,omitempty"`
2930 PreferredUsername string `msgpack:"pu,omitempty"`
3031
32+ // Additional claims
33+ AdditionalClaims map[string]interface{} `msgpack:"ac,omitempty"`
34+
3135 // Internal helpers, not serialized
3236 Clock func() time.Time `msgpack:"-"` // override for time.Now, for testing
3337 Lock Lock `msgpack:"-"`
@@ -156,8 +160,18 @@ func (s *SessionState) GetClaim(claim string) []string {
156160 case "preferred_username":
157161 return []string{s.PreferredUsername}
158162 default:
159− return []string{}
163+ return s.getAdditionalClaim(claim)
164+ }
165+}
166+
167+func (s *SessionState) getAdditionalClaim(claim string) []string {
168+ if value, ok := s.AdditionalClaims[claim]; ok {
169+ var result []string
170+ if err := util.CoerceClaim(value, &result); err == nil {
171+ return result
172+ }
160173 }
174+ return []string{}
161175}
162176
163177// CheckNonce compares the Nonce against a potential hash of it
pkg/apis/sessions/session_state_test.go+64
@@ -222,6 +222,23 @@ func TestEncodeAndDecodeSessionState(t *testing.T) {
222222 Nonce: []byte("abcdef1234567890abcdef1234567890"),
223223 Groups: []string{"group-a", "group-b"},
224224 },
225+ "With additional claims": {
226+ Email: "username@example.com",
227+ User: "username",
228+ PreferredUsername: "preferred.username",
229+ AccessToken: "AccessToken.12349871293847fdsaihf9238h4f91h8fr.1349f831y98fd7",
230+ IDToken: "IDToken.12349871293847fdsaihf9238h4f91h8fr.1349f831y98fd7",
231+ CreatedAt: &created,
232+ ExpiresOn: &expires,
233+ RefreshToken: "RefreshToken.12349871293847fdsaihf9238h4f91h8fr.1349f831y98fd7",
234+ Nonce: []byte("abcdef1234567890abcdef1234567890"),
235+ Groups: []string{"group-a", "group-b"},
236+ AdditionalClaims: map[string]interface{}{
237+ "custom_claim_1": "value1",
238+ "custom_claim_2": true,
239+ "custom_claim_3": []interface{}{"item1", "item2"},
240+ },
241+ },
225242 }
226243
227244 for _, secretSize := range []int{16, 24, 32} {
@@ -289,3 +306,50 @@ func compareSessionStates(t *testing.T, expected *SessionState, actual *SessionS
289306 act.ExpiresOn = nil
290307 assert.Equal(t, exp, act)
291308}
309+
310+func TestGetClaim(t *testing.T) {
311+ createdAt := time.Now()
312+ expiresOn := createdAt.Add(1 * time.Hour)
313+
314+ ss := &SessionState{
315+ CreatedAt: &createdAt,
316+ ExpiresOn: &expiresOn,
317+ AccessToken: "AccessToken.12349871293847fdsaihf9238h4f91h8fr.1349f831y98fd7",
318+ IDToken: "IDToken.12349871293847fdsaihf9238h4f91h8fr.1349f831y98fd7",
319+ RefreshToken: "RefreshToken.12349871293847fdsaihf9238h4f91h8fr.1349f831y98fd7",
320+ Email: "user@example.com",
321+ User: "user123",
322+ Groups: []string{"group1", "group2"},
323+ PreferredUsername: "preferred_user",
324+ AdditionalClaims: map[string]interface{}{
325+ "custom_claim_1": "value1",
326+ "custom_claim_2": true,
327+ "custom_claim_3": []string{"item1", "item2"},
328+ },
329+ }
330+
331+ tests := []struct {
332+ claim string
333+ want []string
334+ }{
335+ {"access_token", []string{"AccessToken.12349871293847fdsaihf9238h4f91h8fr.1349f831y98fd7"}},
336+ {"id_token", []string{"IDToken.12349871293847fdsaihf9238h4f91h8fr.1349f831y98fd7"}},
337+ {"refresh_token", []string{"RefreshToken.12349871293847fdsaihf9238h4f91h8fr.1349f831y98fd7"}},
338+ {"created_at", []string{createdAt.String()}},
339+ {"expires_on", []string{expiresOn.String()}},
340+ {"email", []string{"user@example.com"}},
341+ {"user", []string{"user123"}},
342+ {"groups", []string{"group1", "group2"}},
343+ {"preferred_username", []string{"preferred_user"}},
344+ {"custom_claim_1", []string{"value1"}},
345+ {"custom_claim_2", []string{"true"}},
346+ {"custom_claim_3", []string{"[\"item1\",\"item2\"]"}},
347+ }
348+
349+ for _, tt := range tests {
350+ t.Run(tt.claim, func(t *testing.T) {
351+ gs := NewWithT(t)
352+ gs.Expect(ss.GetClaim(tt.claim)).To(Equal(tt.want))
353+ })
354+ }
355+}
pkg/providers/util/claim_extractor.go+10−74
@@ -3,7 +3,6 @@ package util
33import (
44 "context"
55 "encoding/base64"
6− "encoding/json"
76 "fmt"
87 "mime"
98 "net/http"
@@ -12,17 +11,17 @@ import (
1211
1312 "github.com/bitly/go-simplejson"
1413 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/requests"
15− "github.com/spf13/cast"
14+ "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/util"
1615)
1716
1817// ClaimExtractor is used to extract claim values from an ID Token, or, if not
1918// present, from the profile URL.
2019type ClaimExtractor interface {
2120 // GetClaim fetches a named claim and returns the value.
22− GetClaim(claim string) (interface{}, bool, error)
21+ GetClaim(claim string) (any, bool, error)
2322
2423 // GetClaimInto fetches a named claim and puts the value into the destination.
25− GetClaimInto(claim string, dst interface{}) (bool, error)
24+ GetClaimInto(claim string, dst any) (bool, error)
2625}
2726
2827// NewClaimExtractor constructs a new ClaimExtractor from the raw ID Token.
@@ -31,12 +30,12 @@ type ClaimExtractor interface {
3130func NewClaimExtractor(ctx context.Context, idToken string, profileURL *url.URL, profileRequestHeaders http.Header) (ClaimExtractor, error) {
3231 payload, err := parseJWT(idToken)
3332 if err != nil {
34− return nil, fmt.Errorf("failed to parse ID Token: %v", err)
33+ return nil, fmt.Errorf("failed to parse ID Token: %w", err)
3534 }
3635
3736 tokenClaims, err := simplejson.NewJson(payload)
3837 if err != nil {
39− return nil, fmt.Errorf("failed to parse ID Token payload: %v", err)
38+ return nil, fmt.Errorf("failed to parse ID Token payload: %w", err)
4039 }
4140
4241 return &claimExtractor{
@@ -59,7 +58,7 @@ type claimExtractor struct {
5958// GetClaim will return the value claim if it exists.
6059// It will only return an error if the profile URL needs to be fetched due to
6160// the claim not being present in the ID Token.
62−func (c *claimExtractor) GetClaim(claim string) (interface{}, bool, error) {
61+func (c *claimExtractor) GetClaim(claim string) (any, bool, error) {
6362 if claim == "" {
6463 return nil, false, nil
6564 }
@@ -124,7 +123,7 @@ func (c *claimExtractor) loadProfileClaims() (*simplejson.Json, error) {
124123// GetClaimInto loads a claim and places it into the destination interface.
125124// This will attempt to coerce the claim into the specified type.
126125// If it cannot be coerced, an error may be returned.
127−func (c *claimExtractor) GetClaimInto(claim string, dst interface{}) (bool, error) {
126+func (c *claimExtractor) GetClaimInto(claim string, dst any) (bool, error) {
128127 value, exists, err := c.GetClaim(claim)
129128 if err != nil {
130129 return false, fmt.Errorf("could not get claim %q: %v", claim, err)
@@ -132,8 +131,8 @@ func (c *claimExtractor) GetClaimInto(claim string, dst interface{}) (bool, erro
132131 if !exists {
133132 return false, nil
134133 }
135− if err := coerceClaim(value, dst); err != nil {
136− return false, fmt.Errorf("could no coerce claim: %v", err)
134+ if err := util.CoerceClaim(value, dst); err != nil {
135+ return false, fmt.Errorf("could not coerce claim: %v", err)
137136 }
138137
139138 return true, nil
@@ -156,73 +155,10 @@ func parseJWT(p string) ([]byte, error) {
156155// getClaimFrom gets a claim from a Json object.
157156// It can accept either a single claim name or a json path. The claim is always evaluated first as a single claim name.
158157// Paths with indexes are not supported.
159−func getClaimFrom(claim string, src *simplejson.Json) interface{} {
158+func getClaimFrom(claim string, src *simplejson.Json) any {
160159 if value, ok := src.CheckGet(claim); ok {
161160 return value.Interface()
162161 }
163162 claimParts := strings.Split(claim, ".")
164163 return src.GetPath(claimParts...).Interface()
165164}
166−
167−// coerceClaim tries to convert the value into the destination interface type.
168−// If it can convert the value, it will then store the value in the destination
169−// interface.
170−func coerceClaim(value, dst interface{}) error {
171− switch d := dst.(type) {
172− case *string:
173− str, err := toString(value)
174− if err != nil {
175− return fmt.Errorf("could not convert value to string: %v", err)
176− }
177− *d = str
178− case *[]string:
179− strSlice, err := toStringSlice(value)
180− if err != nil {
181− return fmt.Errorf("could not convert value to string slice: %v", err)
182− }
183− *d = strSlice
184− case *bool:
185− *d = cast.ToBool(value)
186− default:
187− return fmt.Errorf("unknown type for destination: %T", dst)
188− }
189− return nil
190−}
191−
192−// toStringSlice converts an interface (either a slice or single value) into
193−// a slice of strings.
194−func toStringSlice(value interface{}) ([]string, error) {
195− var sliceValues []interface{}
196− switch v := value.(type) {
197− case []interface{}:
198− sliceValues = v
199− case interface{}:
200− sliceValues = []interface{}{v}
201− default:
202− sliceValues = cast.ToSlice(value)
203− }
204−
205− out := []string{}
206− for _, v := range sliceValues {
207− str, err := toString(v)
208− if err != nil {
209− return nil, fmt.Errorf("could not convert slice entry to string %v: %v", v, err)
210− }
211− out = append(out, str)
212− }
213− return out, nil
214−}
215−
216−// toString coerces a value into a string.
217−// If it is non-string, marshal it into JSON.
218−func toString(value interface{}) (string, error) {
219− if str, err := cast.ToStringE(value); err == nil {
220− return str, nil
221− }
222−
223− jsonStr, err := json.Marshal(value)
224− if err != nil {
225− return "", err
226− }
227− return string(jsonStr), nil
228−}
pkg/providers/util/claim_extractor_test.go+2−53
@@ -76,7 +76,7 @@ var _ = Describe("Claim Extractor Suite", func() {
7676 func(in newClaimExtractorTableInput) {
7777 _, err := NewClaimExtractor(context.Background(), in.idToken, nil, nil)
7878 if in.expectedError != nil {
79− Expect(err).To(MatchError(in.expectedError))
79+ Expect(err).To(MatchError(in.expectedError.Error()))
8080 } else {
8181 Expect(err).ToNot(HaveOccurred())
8282 }
@@ -405,7 +405,7 @@ var _ = Describe("Claim Extractor Suite", func() {
405405 into: "",
406406 expectExists: false,
407407 expectedValue: "",
408− expectedError: errors.New("could no coerce claim: unknown type for destination: string"),
408+ expectedError: errors.New("could not coerce claim: unknown type for destination: string"),
409409 }),
410410 Entry("flattens a complex claim value into a JSON string", getClaimIntoTableInput{
411411 testClaimExtractorOpts: testClaimExtractorOpts{
@@ -451,53 +451,6 @@ var _ = Describe("Claim Extractor Suite", func() {
451451 }),
452452 )
453453
454− type coerceClaimTableInput struct {
455− value interface{}
456− dst interface{}
457− expectedDst interface{}
458− expectedError error
459− }
460−
461− DescribeTable("coerceClaim",
462− func(in coerceClaimTableInput) {
463− err := coerceClaim(in.value, in.dst)
464− if in.expectedError != nil {
465− Expect(err).To(MatchError(in.expectedError))
466− return
467− }
468−
469− Expect(err).ToNot(HaveOccurred())
470− Expect(in.dst).To(Equal(in.expectedDst))
471− },
472− Entry("coerces a string to a string", coerceClaimTableInput{
473− value: "some_string",
474− dst: stringPointer(""),
475− expectedDst: stringPointer("some_string"),
476− }),
477− Entry("coerces a slice to a string slice", coerceClaimTableInput{
478− value: []interface{}{"a", "b"},
479− dst: stringSlicePointer([]string{}),
480− expectedDst: stringSlicePointer([]string{"a", "b"}),
481− }),
482− Entry("coerces a bool to a bool", coerceClaimTableInput{
483− value: true,
484− dst: boolPointer(false),
485− expectedDst: boolPointer(true),
486− }),
487− Entry("coerces a string to a bool", coerceClaimTableInput{
488− value: "true",
489− dst: boolPointer(false),
490− expectedDst: boolPointer(true),
491− }),
492− Entry("coerces a map to a string", coerceClaimTableInput{
493− value: map[string]interface{}{
494− "foo": []interface{}{"bar", "baz"},
495− },
496− dst: stringPointer(""),
497− expectedDst: stringPointer("{\"foo\":[\"bar\",\"baz\"]}"),
498− }),
499− )
500−
501454 It("should extract claims from a JWT response", func() {
502455 jwtResponsePayload := `{
503456 "user": "jwtUser",
@@ -605,10 +558,6 @@ func stringSlicePointer(in []string) *[]string {
605558 return &in
606559}
607560
608−func boolPointer(in bool) *bool {
609− return &in
610−}
611−
612561// ******************************
613562// Different profile URL handlers
614563// ******************************
pkg/util/util.go+64
@@ -5,6 +5,7 @@ import (
55 "crypto/rsa"
66 "crypto/x509"
77 "crypto/x509/pkix"
8+ "encoding/json"
89 "fmt"
910 "math/big"
1011 "net"
@@ -12,6 +13,8 @@ import (
1213 "os"
1314 "strings"
1415 "time"
16+
17+ "github.com/spf13/cast"
1518)
1619
1720func GetCertPool(paths []string, useSystemPool bool) (*x509.CertPool, error) {
@@ -191,3 +194,64 @@ func RemoveDuplicateStr(strSlice []string) []string {
191194 }
192195 return list
193196}
197+
198+// CoerceClaim tries to convert the value into the destination interface type.
199+// If it can convert the value, it will then store the value in the destination
200+// interface.
201+func CoerceClaim(value, dst any) error {
202+ switch d := dst.(type) {
203+ case *string:
204+ str, err := toString(value)
205+ if err != nil {
206+ return fmt.Errorf("could not convert value to string: %v", err)
207+ }
208+ *d = str
209+ case *[]string:
210+ strSlice, err := toStringSlice(value)
211+ if err != nil {
212+ return fmt.Errorf("could not convert value to string slice: %v", err)
213+ }
214+ *d = strSlice
215+ case *bool:
216+ *d = cast.ToBool(value)
217+ default:
218+ return fmt.Errorf("unknown type for destination: %T", dst)
219+ }
220+ return nil
221+}
222+
223+// toStringSlice converts an interface (either a slice or single value) into
224+// a slice of strings.
225+func toStringSlice(value any) ([]string, error) {
226+ var sliceValues []any
227+ switch v := value.(type) {
228+ case []any:
229+ sliceValues = v
230+ default:
231+ sliceValues = []any{v}
232+ }
233+
234+ out := []string{}
235+ for _, v := range sliceValues {
236+ str, err := toString(v)
237+ if err != nil {
238+ return nil, fmt.Errorf("could not convert slice entry to string %v: %v", v, err)
239+ }
240+ out = append(out, str)
241+ }
242+ return out, nil
243+}
244+
245+// toString coerces a value into a string.
246+// If it is non-string, marshal it into JSON.
247+func toString(value any) (string, error) {
248+ if str, err := cast.ToStringE(value); err == nil {
249+ return str, nil
250+ }
251+
252+ jsonStr, err := json.Marshal(value)
253+ if err != nil {
254+ return "", err
255+ }
256+ return string(jsonStr), nil
257+}
pkg/util/util_test.go+70
@@ -2,10 +2,13 @@ package util
22
33import (
44 "crypto/x509"
5+ "encoding/json"
56 "encoding/pem"
67 "os"
8+ "reflect"
79 "testing"
810
11+ "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/util/ptr"
912 "github.com/stretchr/testify/assert"
1013)
1114
@@ -253,3 +256,70 @@ func TestGetCertPool(t *testing.T) {
253256 assert.Error(t, err3)
254257 }
255258}
259+
260+type coerceClaimTableInput struct {
261+ name string
262+ value any
263+ dst any
264+ expectedDst any
265+ expectedError error
266+}
267+
268+func TestCoerceClaim(t *testing.T) {
269+ tests := []coerceClaimTableInput{
270+ {
271+ name: "coerces a string to a string",
272+ value: "some_string",
273+ dst: ptr.To(""),
274+ expectedDst: ptr.To("some_string"),
275+ },
276+ {
277+ name: "coerces a slice to a string slice",
278+ value: []any{"a", "b"},
279+ dst: ptr.To([]string{}),
280+ expectedDst: ptr.To([]string{"a", "b"}),
281+ },
282+ {
283+ name: "coerces a bool to a bool",
284+ value: true,
285+ dst: ptr.To(false),
286+ expectedDst: ptr.To(true),
287+ },
288+ {
289+ name: "coerces a string to a bool",
290+ value: "true",
291+ dst: ptr.To(false),
292+ expectedDst: ptr.To(true),
293+ },
294+ {
295+ name: "coerces a map to a string",
296+ value: map[string]any{
297+ "foo": []any{"bar", "baz"},
298+ },
299+ dst: ptr.To(""),
300+ expectedDst: ptr.To("{\"foo\":[\"bar\",\"baz\"]}"),
301+ },
302+ }
303+
304+ for _, tt := range tests {
305+ t.Run(tt.name, func(t *testing.T) {
306+ err := CoerceClaim(tt.value, tt.dst)
307+ if tt.expectedError != nil {
308+ if err == nil || err.Error() != tt.expectedError.Error() {
309+ t.Errorf("expected error %v, got %v", tt.expectedError, err)
310+ }
311+ return
312+ }
313+
314+ if err != nil {
315+ t.Fatalf("unexpected error: %v", err)
316+ }
317+
318+ if !reflect.DeepEqual(tt.dst, tt.expectedDst) {
319+ gotJSON, _ := json.Marshal(tt.dst)
320+ wantJSON, _ := json.Marshal(tt.expectedDst)
321+ t.Errorf("expected dst to be %+v, got %+v", string(wantJSON), string(gotJSON))
322+ }
323+ })
324+ }
325+}
providers/provider_data.go+40−5
@@ -45,11 +45,26 @@ type ProviderData struct {
4545 SupportedCodeChallengeMethods []string `json:"code_challenge_methods_supported,omitempty"`
4646
4747 // Common OIDC options for any OIDC-based providers to consume
48− AllowUnverifiedEmail bool
49− UserClaim string
50− EmailClaim string
51− GroupsClaim string
52− Verifier internaloidc.IDTokenVerifier
48+ AllowUnverifiedEmail bool
49+
50+ // UserClaim is the claim to use for populating the SessionState.User field. Defaults to "sub" if not set.
51+ UserClaim string
52+
53+ // EmailClaim is the claim to use for populating the SessionState.Email field.
54+ EmailClaim string
55+
56+ // GroupsClaim is the claim to use for populating the SessionState.Groups field.
57+ // If not set, groups will not be extracted from the ID Token or userinfo response.
58+ GroupsClaim string
59+
60+ // Verifier is the OIDC ID Token Verifier to be used by any OIDC-based providers to verify ID Tokens returned by the provider.
61+ // It must be set up by the provider implementation and is not expected to be configured directly by users.
62+ Verifier internaloidc.IDTokenVerifier
63+
64+ // Additional claims to be obtained from the upstream IDP, either from the id_token or from the userinfo endpoint if configured.
65+ AdditionalClaims []string `json:"additionalClaims,omitempty"`
66+
67+ // SkipClaimsFromProfileURL indicates that claims should not be fetched from the ProfileURL, even if it is set.
5368 SkipClaimsFromProfileURL bool
5469
5570 // Universal Group authorization data structure
@@ -268,6 +283,10 @@ func (p *ProviderData) buildSessionFromClaims(rawIDToken, accessToken string) (*
268283 }
269284 }
270285
286+ if p.AdditionalClaims != nil {
287+ p.extractAdditionalClaims(extractor, ss)
288+ }
289+
271290 // `email_verified` must be present and explicitly set to `false` to be
272291 // considered unverified.
273292 verifyEmail := (p.EmailClaim == options.OIDCEmailClaim) && !p.AllowUnverifiedEmail
@@ -301,6 +320,22 @@ func (p *ProviderData) getClaimExtractor(rawIDToken, accessToken string) (util.C
301320 return extractor, nil
302321}
303322
323+func (p *ProviderData) extractAdditionalClaims(extractor util.ClaimExtractor, ss *sessions.SessionState) {
324+ if ss.AdditionalClaims == nil {
325+ ss.AdditionalClaims = make(map[string]any)
326+ }
327+ for _, claim := range p.AdditionalClaims {
328+ value, exists, err := extractor.GetClaim(claim)
329+ if err != nil {
330+ logger.Printf("error extracting additional claim %q: %v", claim, err)
331+ continue
332+ }
333+ if exists {
334+ ss.AdditionalClaims[claim] = value
335+ }
336+ }
337+}
338+
304339// checkNonce compares the session's nonce with the IDToken's nonce claim
305340func (p *ProviderData) checkNonce(s *sessions.SessionState) error {
306341 extractor, err := p.getClaimExtractor(s.IDToken, "")
providers/provider_data_test.go+23
@@ -237,6 +237,7 @@ func TestProviderData_buildSessionFromClaims(t *testing.T) {
237237 ExpectedError error
238238 ExpectedSession *sessions.SessionState
239239 ExpectProfileURLCalled bool
240+ AdditionalClaims []string
240241 }{
241242 "Standard": {
242243 IDToken: defaultIDToken,
@@ -417,6 +418,27 @@ func TestProviderData_buildSessionFromClaims(t *testing.T) {
417418 SkipClaimsFromProfileURL: true,
418419 ExpectedSession: &sessions.SessionState{},
419420 },
421+ "Additional claims": {
422+ IDToken: defaultIDToken,
423+ AdditionalClaims: []string{"phone_number", "picture"},
424+ ExpectedSession: &sessions.SessionState{
425+ PreferredUsername: "Jane Dobbs",
426+ AdditionalClaims: map[string]interface{}{
427+ "phone_number": "+4798765432",
428+ "picture": "http://mugbook.com/janed/me.jpg",
429+ },
430+ },
431+ },
432+ "Additional claims with missing claim": {
433+ IDToken: defaultIDToken,
434+ AdditionalClaims: []string{"phone_number", "picture1"},
435+ ExpectedSession: &sessions.SessionState{
436+ PreferredUsername: "Jane Dobbs",
437+ AdditionalClaims: map[string]interface{}{
438+ "phone_number": "+4798765432",
439+ },
440+ },
441+ },
420442 }
421443 for testName, tc := range testCases {
422444 t.Run(testName, func(t *testing.T) {
@@ -453,6 +475,7 @@ func TestProviderData_buildSessionFromClaims(t *testing.T) {
453475 provider.EmailClaim = tc.EmailClaim
454476 provider.GroupsClaim = tc.GroupsClaim
455477 provider.SkipClaimsFromProfileURL = tc.SkipClaimsFromProfileURL
478+ provider.AdditionalClaims = tc.AdditionalClaims
456479
457480 rawIDToken, err := newSignedTestIDToken(tc.IDToken)
458481 g.Expect(err).ToNot(HaveOccurred())
providers/providers.go+1
@@ -84,6 +84,7 @@ func newProviderDataFromConfig(providerConfig options.Provider) (*ProviderData,
8484 ClientSecret: providerConfig.ClientSecret,
8585 ClientSecretFile: providerConfig.ClientSecretFile,
8686 AuthRequestResponseMode: providerConfig.AuthRequestResponseMode,
87+ AdditionalClaims: providerConfig.AdditionalClaims,
8788 }
8889
8990 needsVerifier, err := providerRequiresOIDCProviderVerifier(providerConfig.Type)