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)
e59f7c1af su
CHANGELOG.md+1
@@ -10,6 +10,7 @@
10
10
11- [#3352](https://github.com/oauth2-proxy/oauth2-proxy/pull/3352) fix: backend logout URL call on sign out (#3172)(@vsejpal)
11- [#3352](https://github.com/oauth2-proxy/oauth2-proxy/pull/3352) fix: backend logout URL call on sign out (#3172)(@vsejpal)
12- [#3332](https://github.com/oauth2-proxy/oauth2-proxy/pull/3332) ci: distribute windows binary with .exe extension (@igitur)
12- [#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)
13
14
14# V7.14.3
15# V7.14.3
15
16
docs/docs/configuration/alpha_config.md+1
@@ -526,6 +526,7 @@ Provider holds all configuration for a single provider
526| `scope` | _string_ | Scope is the OAuth scope specification |
526| `scope` | _string_ | Scope is the OAuth scope specification |
527| `allowedGroups` | _[]string_ | AllowedGroups is a list of restrict logins to members of this group |
527| `allowedGroups` | _[]string_ | AllowedGroups is a list of restrict logins to members of this group |
528| `code_challenge_method` | _string_ | The code challenge method |
528| `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. |
529| `backendLogoutURL` | _string_ | URL to call to perform backend logout, `{id_token}` would be replaced by the actual `id_token` if available in the session |
530| `backendLogoutURL` | _string_ | URL to call to perform backend logout, `{id_token}` would be replaced by the actual `id_token` if available in the session |
530
531
531### ProviderType
532### ProviderType
oauthproxy.go+6−4
@@ -721,15 +721,17 @@ func (p *OAuthProxy) UserInfo(rw http.ResponseWriter, req *http.Request) {
721 }
721 }
722
722
723 userInfo := struct {
723 userInfo := struct {
724− User string `json:"user"`
724+ User string `json:"user"`
725− Email string `json:"email"`
725+ Email string `json:"email"`
726− Groups []string `json:"groups,omitempty"`
726+ Groups []string `json:"groups,omitempty"`
727− PreferredUsername string `json:"preferredUsername,omitempty"`
727+ PreferredUsername string `json:"preferredUsername,omitempty"`
728+ AdditionalClaims map[string]interface{} `json:"additionalClaims,omitempty"`
728 }{
729 }{
729 User: session.User,
730 User: session.User,
730 Email: session.Email,
731 Email: session.Email,
731 Groups: session.Groups,
732 Groups: session.Groups,
732 PreferredUsername: session.PreferredUsername,
733 PreferredUsername: session.PreferredUsername,
734+ AdditionalClaims: session.AdditionalClaims,
733 }
735 }
734
736
735 if err := json.NewEncoder(rw).Encode(userInfo); err != nil {
737 if err := json.NewEncoder(rw).Encode(userInfo); err != nil {
oauthproxy_test.go+14
@@ -1032,6 +1032,20 @@ func TestUserInfoEndpointAccepted(t *testing.T) {
1032 },
1032 },
1033 expectedResponse: "{\"user\":\"john.doe\",\"email\":\"john.doe@example.com\",\"groups\":[\"example\",\"groups\"],\"preferredUsername\":\"john\"}\n",
1033 expectedResponse: "{\"user\":\"john.doe\",\"email\":\"john.doe@example.com\",\"groups\":[\"example\",\"groups\"],\"preferredUsername\":\"john\"}\n",
1034 },
1034 },
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+ },
1035 }
1049 }
1036
1050
1037 for _, tc := range testCases {
1051 for _, tc := range testCases {
pkg/apis/options/providers.go+3
@@ -134,6 +134,9 @@ type Provider struct {
134 // The code challenge method
134 // The code challenge method
135 CodeChallengeMethod string `yaml:"code_challenge_method,omitempty"`
135 CodeChallengeMethod string `yaml:"code_challenge_method,omitempty"`
136
136
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+
137 // URL to call to perform backend logout, `{id_token}` would be replaced by the actual `id_token` if available in the session
140 // URL to call to perform backend logout, `{id_token}` would be replaced by the actual `id_token` if available in the session
138 BackendLogoutURL string `yaml:"backendLogoutURL"`
141 BackendLogoutURL string `yaml:"backendLogoutURL"`
139}
142}
pkg/apis/sessions/session_state.go+15−1
@@ -8,6 +8,7 @@ import (
8 "time"
8 "time"
9
9
10 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/encryption"
10 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/encryption"
11+ "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/util"
11 "github.com/pierrec/lz4/v4"
12 "github.com/pierrec/lz4/v4"
12 "github.com/vmihailenco/msgpack/v5"
13 "github.com/vmihailenco/msgpack/v5"
13)
14)
@@ -28,6 +29,9 @@ type SessionState struct {
28 Groups []string `msgpack:"g,omitempty"`
29 Groups []string `msgpack:"g,omitempty"`
29 PreferredUsername string `msgpack:"pu,omitempty"`
30 PreferredUsername string `msgpack:"pu,omitempty"`
30
31
32+ // Additional claims
33+ AdditionalClaims map[string]interface{} `msgpack:"ac,omitempty"`
34+
31 // Internal helpers, not serialized
35 // Internal helpers, not serialized
32 Clock func() time.Time `msgpack:"-"` // override for time.Now, for testing
36 Clock func() time.Time `msgpack:"-"` // override for time.Now, for testing
33 Lock Lock `msgpack:"-"`
37 Lock Lock `msgpack:"-"`
@@ -156,8 +160,18 @@ func (s *SessionState) GetClaim(claim string) []string {
156 case "preferred_username":
160 case "preferred_username":
157 return []string{s.PreferredUsername}
161 return []string{s.PreferredUsername}
158 default:
162 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+ }
160 }
173 }
174+ return []string{}
161}
175}
162
176
163// CheckNonce compares the Nonce against a potential hash of it
177// 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) {
222 Nonce: []byte("abcdef1234567890abcdef1234567890"),
222 Nonce: []byte("abcdef1234567890abcdef1234567890"),
223 Groups: []string{"group-a", "group-b"},
223 Groups: []string{"group-a", "group-b"},
224 },
224 },
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+ },
225 }
242 }
226
243
227 for _, secretSize := range []int{16, 24, 32} {
244 for _, secretSize := range []int{16, 24, 32} {
@@ -289,3 +306,50 @@ func compareSessionStates(t *testing.T, expected *SessionState, actual *SessionS
289 act.ExpiresOn = nil
306 act.ExpiresOn = nil
290 assert.Equal(t, exp, act)
307 assert.Equal(t, exp, act)
291}
308}
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
3import (
3import (
4 "context"
4 "context"
5 "encoding/base64"
5 "encoding/base64"
6− "encoding/json"
7 "fmt"
6 "fmt"
8 "mime"
7 "mime"
9 "net/http"
8 "net/http"
@@ -12,17 +11,17 @@ import (
12
11
13 "github.com/bitly/go-simplejson"
12 "github.com/bitly/go-simplejson"
14 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/requests"
13 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/requests"
15− "github.com/spf13/cast"
14+ "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/util"
16)
15)
17
16
18// ClaimExtractor is used to extract claim values from an ID Token, or, if not
17// ClaimExtractor is used to extract claim values from an ID Token, or, if not
19// present, from the profile URL.
18// present, from the profile URL.
20type ClaimExtractor interface {
19type ClaimExtractor interface {
21 // GetClaim fetches a named claim and returns the value.
20 // GetClaim fetches a named claim and returns the value.
22− GetClaim(claim string) (interface{}, bool, error)
21+ GetClaim(claim string) (any, bool, error)
23
22
24 // GetClaimInto fetches a named claim and puts the value into the destination.
23 // 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)
26}
25}
27
26
28// NewClaimExtractor constructs a new ClaimExtractor from the raw ID Token.
27// NewClaimExtractor constructs a new ClaimExtractor from the raw ID Token.
@@ -31,12 +30,12 @@ type ClaimExtractor interface {
31func NewClaimExtractor(ctx context.Context, idToken string, profileURL *url.URL, profileRequestHeaders http.Header) (ClaimExtractor, error) {
30func NewClaimExtractor(ctx context.Context, idToken string, profileURL *url.URL, profileRequestHeaders http.Header) (ClaimExtractor, error) {
32 payload, err := parseJWT(idToken)
31 payload, err := parseJWT(idToken)
33 if err != nil {
32 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)
35 }
34 }
36
35
37 tokenClaims, err := simplejson.NewJson(payload)
36 tokenClaims, err := simplejson.NewJson(payload)
38 if err != nil {
37 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)
40 }
39 }
41
40
42 return &claimExtractor{
41 return &claimExtractor{
@@ -59,7 +58,7 @@ type claimExtractor struct {
59// GetClaim will return the value claim if it exists.
58// GetClaim will return the value claim if it exists.
60// It will only return an error if the profile URL needs to be fetched due to
59// It will only return an error if the profile URL needs to be fetched due to
61// the claim not being present in the ID Token.
60// 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) {
63 if claim == "" {
62 if claim == "" {
64 return nil, false, nil
63 return nil, false, nil
65 }
64 }
@@ -124,7 +123,7 @@ func (c *claimExtractor) loadProfileClaims() (*simplejson.Json, error) {
124// GetClaimInto loads a claim and places it into the destination interface.
123// GetClaimInto loads a claim and places it into the destination interface.
125// This will attempt to coerce the claim into the specified type.
124// This will attempt to coerce the claim into the specified type.
126// If it cannot be coerced, an error may be returned.
125// 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) {
128 value, exists, err := c.GetClaim(claim)
127 value, exists, err := c.GetClaim(claim)
129 if err != nil {
128 if err != nil {
130 return false, fmt.Errorf("could not get claim %q: %v", claim, err)
129 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
132 if !exists {
131 if !exists {
133 return false, nil
132 return false, nil
134 }
133 }
135− if err := coerceClaim(value, dst); err != nil {
134+ if err := util.CoerceClaim(value, dst); err != nil {
136− return false, fmt.Errorf("could no coerce claim: %v", err)
135+ return false, fmt.Errorf("could not coerce claim: %v", err)
137 }
136 }
138
137
139 return true, nil
138 return true, nil
@@ -156,73 +155,10 @@ func parseJWT(p string) ([]byte, error) {
156// getClaimFrom gets a claim from a Json object.
155// getClaimFrom gets a claim from a Json object.
157// It can accept either a single claim name or a json path. The claim is always evaluated first as a single claim name.
156// It can accept either a single claim name or a json path. The claim is always evaluated first as a single claim name.
158// Paths with indexes are not supported.
157// Paths with indexes are not supported.
159−func getClaimFrom(claim string, src *simplejson.Json) interface{} {
158+func getClaimFrom(claim string, src *simplejson.Json) any {
160 if value, ok := src.CheckGet(claim); ok {
159 if value, ok := src.CheckGet(claim); ok {
161 return value.Interface()
160 return value.Interface()
162 }
161 }
163 claimParts := strings.Split(claim, ".")
162 claimParts := strings.Split(claim, ".")
164 return src.GetPath(claimParts...).Interface()
163 return src.GetPath(claimParts...).Interface()
165}
164}
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() {
76 func(in newClaimExtractorTableInput) {
76 func(in newClaimExtractorTableInput) {
77 _, err := NewClaimExtractor(context.Background(), in.idToken, nil, nil)
77 _, err := NewClaimExtractor(context.Background(), in.idToken, nil, nil)
78 if in.expectedError != nil {
78 if in.expectedError != nil {
79− Expect(err).To(MatchError(in.expectedError))
79+ Expect(err).To(MatchError(in.expectedError.Error()))
80 } else {
80 } else {
81 Expect(err).ToNot(HaveOccurred())
81 Expect(err).ToNot(HaveOccurred())
82 }
82 }
@@ -405,7 +405,7 @@ var _ = Describe("Claim Extractor Suite", func() {
405 into: "",
405 into: "",
406 expectExists: false,
406 expectExists: false,
407 expectedValue: "",
407 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"),
409 }),
409 }),
410 Entry("flattens a complex claim value into a JSON string", getClaimIntoTableInput{
410 Entry("flattens a complex claim value into a JSON string", getClaimIntoTableInput{
411 testClaimExtractorOpts: testClaimExtractorOpts{
411 testClaimExtractorOpts: testClaimExtractorOpts{
@@ -451,53 +451,6 @@ var _ = Describe("Claim Extractor Suite", func() {
451 }),
451 }),
452 )
452 )
453
453
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−
501 It("should extract claims from a JWT response", func() {
454 It("should extract claims from a JWT response", func() {
502 jwtResponsePayload := `{
455 jwtResponsePayload := `{
503 "user": "jwtUser",
456 "user": "jwtUser",
@@ -605,10 +558,6 @@ func stringSlicePointer(in []string) *[]string {
605 return &in
558 return &in
606}
559}
607
560
608−func boolPointer(in bool) *bool {
609− return &in
610−}
611−
612// ******************************
561// ******************************
613// Different profile URL handlers
562// Different profile URL handlers
614// ******************************
563// ******************************
pkg/util/util.go+64
@@ -5,6 +5,7 @@ import (
5 "crypto/rsa"
5 "crypto/rsa"
6 "crypto/x509"
6 "crypto/x509"
7 "crypto/x509/pkix"
7 "crypto/x509/pkix"
8+ "encoding/json"
8 "fmt"
9 "fmt"
9 "math/big"
10 "math/big"
10 "net"
11 "net"
@@ -12,6 +13,8 @@ import (
12 "os"
13 "os"
13 "strings"
14 "strings"
14 "time"
15 "time"
16+
17+ "github.com/spf13/cast"
15)
18)
16
19
17func GetCertPool(paths []string, useSystemPool bool) (*x509.CertPool, error) {
20func GetCertPool(paths []string, useSystemPool bool) (*x509.CertPool, error) {
@@ -191,3 +194,64 @@ func RemoveDuplicateStr(strSlice []string) []string {
191 }
194 }
192 return list
195 return list
193}
196}
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
2
2
3import (
3import (
4 "crypto/x509"
4 "crypto/x509"
5+ "encoding/json"
5 "encoding/pem"
6 "encoding/pem"
6 "os"
7 "os"
8+ "reflect"
7 "testing"
9 "testing"
8
10
11+ "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/util/ptr"
9 "github.com/stretchr/testify/assert"
12 "github.com/stretchr/testify/assert"
10)
13)
11
14
@@ -253,3 +256,70 @@ func TestGetCertPool(t *testing.T) {
253 assert.Error(t, err3)
256 assert.Error(t, err3)
254 }
257 }
255}
258}
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 {
45 SupportedCodeChallengeMethods []string `json:"code_challenge_methods_supported,omitempty"`
45 SupportedCodeChallengeMethods []string `json:"code_challenge_methods_supported,omitempty"`
46
46
47 // Common OIDC options for any OIDC-based providers to consume
47 // Common OIDC options for any OIDC-based providers to consume
48− AllowUnverifiedEmail bool
48+ AllowUnverifiedEmail bool
49+
50+ // UserClaim is the claim to use for populating the SessionState.User field. Defaults to "sub" if not set.
49− UserClaim string
51+ UserClaim string
50− EmailClaim 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.
51− GroupsClaim string
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.
52− Verifier internaloidc.IDTokenVerifier
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.
53 SkipClaimsFromProfileURL bool
68 SkipClaimsFromProfileURL bool
54
69
55 // Universal Group authorization data structure
70 // Universal Group authorization data structure
@@ -268,6 +283,10 @@ func (p *ProviderData) buildSessionFromClaims(rawIDToken, accessToken string) (*
268 }
283 }
269 }
284 }
270
285
286+ if p.AdditionalClaims != nil {
287+ p.extractAdditionalClaims(extractor, ss)
288+ }
289+
271 // `email_verified` must be present and explicitly set to `false` to be
290 // `email_verified` must be present and explicitly set to `false` to be
272 // considered unverified.
291 // considered unverified.
273 verifyEmail := (p.EmailClaim == options.OIDCEmailClaim) && !p.AllowUnverifiedEmail
292 verifyEmail := (p.EmailClaim == options.OIDCEmailClaim) && !p.AllowUnverifiedEmail
@@ -301,6 +320,22 @@ func (p *ProviderData) getClaimExtractor(rawIDToken, accessToken string) (util.C
301 return extractor, nil
320 return extractor, nil
302}
321}
303
322
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+
304// checkNonce compares the session's nonce with the IDToken's nonce claim
339// checkNonce compares the session's nonce with the IDToken's nonce claim
305func (p *ProviderData) checkNonce(s *sessions.SessionState) error {
340func (p *ProviderData) checkNonce(s *sessions.SessionState) error {
306 extractor, err := p.getClaimExtractor(s.IDToken, "")
341 extractor, err := p.getClaimExtractor(s.IDToken, "")
providers/provider_data_test.go+23
@@ -237,6 +237,7 @@ func TestProviderData_buildSessionFromClaims(t *testing.T) {
237 ExpectedError error
237 ExpectedError error
238 ExpectedSession *sessions.SessionState
238 ExpectedSession *sessions.SessionState
239 ExpectProfileURLCalled bool
239 ExpectProfileURLCalled bool
240+ AdditionalClaims []string
240 }{
241 }{
241 "Standard": {
242 "Standard": {
242 IDToken: defaultIDToken,
243 IDToken: defaultIDToken,
@@ -417,6 +418,27 @@ func TestProviderData_buildSessionFromClaims(t *testing.T) {
417 SkipClaimsFromProfileURL: true,
418 SkipClaimsFromProfileURL: true,
418 ExpectedSession: &sessions.SessionState{},
419 ExpectedSession: &sessions.SessionState{},
419 },
420 },
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+ },
420 }
442 }
421 for testName, tc := range testCases {
443 for testName, tc := range testCases {
422 t.Run(testName, func(t *testing.T) {
444 t.Run(testName, func(t *testing.T) {
@@ -453,6 +475,7 @@ func TestProviderData_buildSessionFromClaims(t *testing.T) {
453 provider.EmailClaim = tc.EmailClaim
475 provider.EmailClaim = tc.EmailClaim
454 provider.GroupsClaim = tc.GroupsClaim
476 provider.GroupsClaim = tc.GroupsClaim
455 provider.SkipClaimsFromProfileURL = tc.SkipClaimsFromProfileURL
477 provider.SkipClaimsFromProfileURL = tc.SkipClaimsFromProfileURL
478+ provider.AdditionalClaims = tc.AdditionalClaims
456
479
457 rawIDToken, err := newSignedTestIDToken(tc.IDToken)
480 rawIDToken, err := newSignedTestIDToken(tc.IDToken)
458 g.Expect(err).ToNot(HaveOccurred())
481 g.Expect(err).ToNot(HaveOccurred())
providers/providers.go+1
@@ -84,6 +84,7 @@ func newProviderDataFromConfig(providerConfig options.Provider) (*ProviderData,
84 ClientSecret: providerConfig.ClientSecret,
84 ClientSecret: providerConfig.ClientSecret,
85 ClientSecretFile: providerConfig.ClientSecretFile,
85 ClientSecretFile: providerConfig.ClientSecretFile,
86 AuthRequestResponseMode: providerConfig.AuthRequestResponseMode,
86 AuthRequestResponseMode: providerConfig.AuthRequestResponseMode,
87+ AdditionalClaims: providerConfig.AdditionalClaims,
87 }
88 }
88
89
89 needsVerifier, err := providerRequiresOIDCProviderVerifier(providerConfig.Type)
90 needsVerifier, err := providerRequiresOIDCProviderVerifier(providerConfig.Type)