git.iko.soy/typeable/oauth2-proxy
on master help sign in
Merge pull request #1394 from oauth2-proxy/claim-extractor

Add generic claim extractor to get claims from ID Tokens

commit9832844c8a4919bf23e7197401fc49d9914a988c authorJoel Speed <Joel.speed@hotmail.co.uk> dateWed 16 Feb 2022, 10:37 (5 years ago) parent44dc3caedb1bc1
20 files changed+972−734
CHANGELOG.md+1 go.mod+1 pkg/providers/util/claim_extractor.go+210 pkg/providers/util/claim_extractor_test.go+530 pkg/providers/util/util_suite_test.go+17 providers/adfs.go+7−6 providers/adfs_test.go+2−4 providers/auth_test.go+12−1 providers/azure.go+10−9 providers/azure_test.go+24−15 providers/digitalocean.go+2 providers/facebook.go+1 providers/linkedin.go+2 providers/nextcloud.go+6−23 providers/nextcloud_test.go−72 providers/oidc.go+9−56 providers/oidc_test.go+10−333 providers/provider_data.go+55−87 providers/provider_data_test.go+73−112 providers/util.go−16
CHANGELOG.md+1
@@ -8,6 +8,7 @@
88
99## Changes since v7.2.1
1010
11+- [#1394](https://github.com/oauth2-proxy/oauth2-proxy/pull/1394) Add generic claim extractor to get claims from ID Tokens (@JoelSpeed)
1112- [#1468](https://github.com/oauth2-proxy/oauth2-proxy/pull/1468) Implement session locking with session state lock (@JoelSpeed, @Bibob7)
1213- [#1489](https://github.com/oauth2-proxy/oauth2-proxy/pull/1489) Fix Docker Buildx push to include build version (@JoelSpeed)
1314- [#1477](https://github.com/oauth2-proxy/oauth2-proxy/pull/1477) Remove provider documentation for `Microsoft Azure AD` (@omBratteng)
go.mod+1
@@ -23,6 +23,7 @@ require (
2323 github.com/onsi/gomega v1.10.2
2424 github.com/pierrec/lz4 v2.5.2+incompatible
2525 github.com/prometheus/client_golang v1.9.0
26+ github.com/spf13/cast v1.3.0
2627 github.com/spf13/pflag v1.0.5
2728 github.com/spf13/viper v1.6.3
2829 github.com/stretchr/testify v1.6.1
pkg/providers/util/claim_extractor.gonew file+210
@@ -0,0 +1,210 @@
1+package util
2+
3+import (
4+ "context"
5+ "encoding/base64"
6+ "encoding/json"
7+ "fmt"
8+ "net/http"
9+ "net/url"
10+ "strings"
11+
12+ "github.com/bitly/go-simplejson"
13+ "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/requests"
14+ "github.com/spf13/cast"
15+)
16+
17+// ClaimExtractor is used to extract claim values from an ID Token, or, if not
18+// present, from the profile URL.
19+type ClaimExtractor interface {
20+ // GetClaim fetches a named claim and returns the value.
21+ GetClaim(claim string) (interface{}, bool, error)
22+
23+ // GetClaimInto fetches a named claim and puts the value into the destination.
24+ GetClaimInto(claim string, dst interface{}) (bool, error)
25+}
26+
27+// NewClaimExtractor constructs a new ClaimExtractor from the raw ID Token.
28+// If needed, it will use the profile URL to look up a claim if it isn't present
29+// within the ID Token.
30+func NewClaimExtractor(ctx context.Context, idToken string, profileURL *url.URL, profileRequestHeaders http.Header) (ClaimExtractor, error) {
31+ payload, err := parseJWT(idToken)
32+ if err != nil {
33+ return nil, fmt.Errorf("failed to parse ID Token: %v", err)
34+ }
35+
36+ tokenClaims, err := simplejson.NewJson(payload)
37+ if err != nil {
38+ return nil, fmt.Errorf("failed to parse ID Token payload: %v", err)
39+ }
40+
41+ return &claimExtractor{
42+ ctx: ctx,
43+ profileURL: profileURL,
44+ requestHeaders: profileRequestHeaders,
45+ tokenClaims: tokenClaims,
46+ }, nil
47+}
48+
49+// claimExtractor implements the ClaimExtractor interface
50+type claimExtractor struct {
51+ profileURL *url.URL
52+ ctx context.Context
53+ requestHeaders map[string][]string
54+ tokenClaims *simplejson.Json
55+ profileClaims *simplejson.Json
56+}
57+
58+// GetClaim will return the value claim if it exists.
59+// It will only return an error if the profile URL needs to be fetched due to
60+// the claim not being present in the ID Token.
61+func (c *claimExtractor) GetClaim(claim string) (interface{}, bool, error) {
62+ if claim == "" {
63+ return nil, false, nil
64+ }
65+
66+ if value := getClaimFrom(claim, c.tokenClaims); value != nil {
67+ return value, true, nil
68+ }
69+
70+ if c.profileClaims == nil {
71+ profileClaims, err := c.loadProfileClaims()
72+ if err != nil {
73+ return nil, false, fmt.Errorf("failed to fetch claims from profile URL: %v", err)
74+ }
75+
76+ c.profileClaims = profileClaims
77+ }
78+
79+ if value := getClaimFrom(claim, c.profileClaims); value != nil {
80+ return value, true, nil
81+ }
82+
83+ return nil, false, nil
84+}
85+
86+// loadProfileClaims will fetch the profileURL using the provided headers as
87+// authentication.
88+func (c *claimExtractor) loadProfileClaims() (*simplejson.Json, error) {
89+ if c.profileURL == nil || c.requestHeaders == nil {
90+ // When no profileURL is set, we return a non-empty map so that
91+ // we don't attempt to populate the profile claims again.
92+ // If there are no headers, the request would be unauthorized so we also skip
93+ // in this case too.
94+ return simplejson.New(), nil
95+ }
96+
97+ claims, err := requests.New(c.profileURL.String()).
98+ WithContext(c.ctx).
99+ WithHeaders(c.requestHeaders).
100+ Do().
101+ UnmarshalJSON()
102+ if err != nil {
103+ return nil, fmt.Errorf("error making request to profile URL: %v", err)
104+ }
105+
106+ return claims, nil
107+}
108+
109+// GetClaimInto loads a claim and places it into the destination interface.
110+// This will attempt to coerce the claim into the specified type.
111+// If it cannot be coerced, an error may be returned.
112+func (c *claimExtractor) GetClaimInto(claim string, dst interface{}) (bool, error) {
113+ value, exists, err := c.GetClaim(claim)
114+ if err != nil {
115+ return false, fmt.Errorf("could not get claim %q: %v", claim, err)
116+ }
117+ if !exists {
118+ return false, nil
119+ }
120+ if err := coerceClaim(value, dst); err != nil {
121+ return false, fmt.Errorf("could no coerce claim: %v", err)
122+ }
123+
124+ return true, nil
125+}
126+
127+// This has been copied from https://github.com/coreos/go-oidc/blob/8d771559cf6e5111c9b9159810d0e4538e7cdc82/verify.go#L120-L130
128+// We use it to grab the raw ID Token payload so that we can parse it into the JSON library.
129+func parseJWT(p string) ([]byte, error) {
130+ parts := strings.Split(p, ".")
131+ if len(parts) < 2 {
132+ return nil, fmt.Errorf("oidc: malformed jwt, expected 3 parts got %d", len(parts))
133+ }
134+ payload, err := base64.RawURLEncoding.DecodeString(parts[1])
135+ if err != nil {
136+ return nil, fmt.Errorf("oidc: malformed jwt payload: %v", err)
137+ }
138+ return payload, nil
139+}
140+
141+// getClaimFrom gets a claim from a Json object.
142+// It can accept either a single claim name or a json path.
143+// Paths with indexes are not supported.
144+func getClaimFrom(claim string, src *simplejson.Json) interface{} {
145+ claimParts := strings.Split(claim, ".")
146+ return src.GetPath(claimParts...).Interface()
147+}
148+
149+// coerceClaim tries to convert the value into the destination interface type.
150+// If it can convert the value, it will then store the value in the destination
151+// interface.
152+func coerceClaim(value, dst interface{}) error {
153+ switch d := dst.(type) {
154+ case *string:
155+ str, err := toString(value)
156+ if err != nil {
157+ return fmt.Errorf("could not convert value to string: %v", err)
158+ }
159+ *d = str
160+ case *[]string:
161+ strSlice, err := toStringSlice(value)
162+ if err != nil {
163+ return fmt.Errorf("could not convert value to string slice: %v", err)
164+ }
165+ *d = strSlice
166+ case *bool:
167+ *d = cast.ToBool(value)
168+ default:
169+ return fmt.Errorf("unknown type for destination: %T", dst)
170+ }
171+ return nil
172+}
173+
174+// toStringSlice converts an interface (either a slice or single value) into
175+// a slice of strings.
176+func toStringSlice(value interface{}) ([]string, error) {
177+ var sliceValues []interface{}
178+ switch v := value.(type) {
179+ case []interface{}:
180+ sliceValues = v
181+ case interface{}:
182+ sliceValues = []interface{}{v}
183+ default:
184+ sliceValues = cast.ToSlice(value)
185+ }
186+
187+ out := []string{}
188+ for _, v := range sliceValues {
189+ str, err := toString(v)
190+ if err != nil {
191+ return nil, fmt.Errorf("could not convert slice entry to string %v: %v", v, err)
192+ }
193+ out = append(out, str)
194+ }
195+ return out, nil
196+}
197+
198+// toString coerces a value into a string.
199+// If it is non-string, marshal it into JSON.
200+func toString(value interface{}) (string, error) {
201+ if str, err := cast.ToStringE(value); err == nil {
202+ return str, nil
203+ }
204+
205+ jsonStr, err := json.Marshal(value)
206+ if err != nil {
207+ return "", err
208+ }
209+ return string(jsonStr), nil
210+}
pkg/providers/util/claim_extractor_test.gonew file+530
@@ -0,0 +1,530 @@
1+package util
2+
3+import (
4+ "context"
5+ "encoding/base64"
6+ "errors"
7+ "fmt"
8+ "net/http"
9+ "net/http/httptest"
10+ "net/url"
11+ "sync/atomic"
12+
13+ . "github.com/onsi/ginkgo"
14+ . "github.com/onsi/ginkgo/extensions/table"
15+ . "github.com/onsi/gomega"
16+)
17+
18+const (
19+ emptyJSON = "{}"
20+ profilePath = "/userinfo"
21+ authorizedAccessToken = "valid_access_token"
22+ basicIDTokenPayload = `{
23+ "user": "idTokenUser",
24+ "email": "idTokenEmail",
25+ "groups": [
26+ "idTokenGroup1",
27+ "idTokenGroup2"
28+ ]
29+ }`
30+ basicProfileURLPayload = `{
31+ "user": "profileUser",
32+ "email": "profileEmail",
33+ "groups": [
34+ "profileGroup1",
35+ "profileGroup2"
36+ ]
37+ }`
38+ nestedClaimPayload = `{
39+ "auth": {
40+ "user": {
41+ "username": "nestedUser"
42+ }
43+ }
44+ }`
45+ complexGroupsPayload = `{
46+ "groups": [
47+ {
48+ "groupID": "group1",
49+ "roles": ["admin"]
50+ },
51+ {
52+ "groupID": "group2",
53+ "roles": ["user", "employee"]
54+ }
55+ ]
56+ }`
57+)
58+
59+var _ = Describe("Claim Extractor Suite", func() {
60+ Context("Claim Extractor", func() {
61+ type newClaimExtractorTableInput struct {
62+ idToken string
63+ expectedError error
64+ }
65+
66+ DescribeTable("NewClaimExtractor",
67+ func(in newClaimExtractorTableInput) {
68+ _, err := NewClaimExtractor(context.Background(), in.idToken, nil, nil)
69+ if in.expectedError != nil {
70+ Expect(err).To(MatchError(in.expectedError))
71+ } else {
72+ Expect(err).ToNot(HaveOccurred())
73+ }
74+ },
75+ Entry("with a valid JWT", newClaimExtractorTableInput{
76+ idToken: createJWTFromPayload(basicIDTokenPayload),
77+ expectedError: nil,
78+ }),
79+ Entry("with a JWT with a non-json payload", newClaimExtractorTableInput{
80+ idToken: createJWTFromPayload("this is not JSON"),
81+ expectedError: errors.New("failed to parse ID Token payload: invalid character 'h' in literal true (expecting 'r')"),
82+ }),
83+ Entry("with an IDToken with the wrong number of parts", newClaimExtractorTableInput{
84+ idToken: "eyJeyJ",
85+ expectedError: errors.New("failed to parse ID Token: oidc: malformed jwt, expected 3 parts got 1"),
86+ }),
87+ Entry("with an non-base64 IDToken", newClaimExtractorTableInput{
88+ idToken: "{metadata}.{payload}.{signature}",
89+ expectedError: errors.New("failed to parse ID Token: oidc: malformed jwt payload: illegal base64 data at input byte 0"),
90+ }),
91+ )
92+
93+ type getClaimTableInput struct {
94+ testClaimExtractorOpts
95+ claim string
96+ expectedValue interface{}
97+ expectExists bool
98+ expectedError error
99+ }
100+
101+ DescribeTable("GetClaim",
102+ func(in getClaimTableInput) {
103+ claimExtractor, serverClose, err := newTestClaimExtractor(in.testClaimExtractorOpts)
104+ Expect(err).ToNot(HaveOccurred())
105+ if serverClose != nil {
106+ defer serverClose()
107+ }
108+
109+ value, exists, err := claimExtractor.GetClaim(in.claim)
110+ if in.expectedError != nil {
111+ Expect(err).To(MatchError(in.expectedError))
112+ return
113+ }
114+
115+ Expect(err).ToNot(HaveOccurred())
116+ if in.expectedValue != nil {
117+ Expect(value).To(Equal(in.expectedValue))
118+ } else {
119+ Expect(value).To(BeNil())
120+ }
121+
122+ Expect(exists).To(Equal(in.expectExists))
123+ },
124+ Entry("retrieves a string claim from ID Token when present", getClaimTableInput{
125+ testClaimExtractorOpts: testClaimExtractorOpts{
126+ idTokenPayload: basicIDTokenPayload,
127+ setProfileURL: true,
128+ profileRequestHeaders: newAuthorizedHeader(),
129+ profileRequestHandler: shouldNotBeRequestedProfileHandler,
130+ },
131+ claim: "user",
132+ expectExists: true,
133+ expectedValue: "idTokenUser",
134+ expectedError: nil,
135+ }),
136+ Entry("retrieves a slice claim from ID Token when present", getClaimTableInput{
137+ testClaimExtractorOpts: testClaimExtractorOpts{
138+ idTokenPayload: basicIDTokenPayload,
139+ setProfileURL: true,
140+ profileRequestHeaders: newAuthorizedHeader(),
141+ profileRequestHandler: shouldNotBeRequestedProfileHandler,
142+ },
143+ claim: "groups",
144+ expectExists: true,
145+ expectedValue: []interface{}{"idTokenGroup1", "idTokenGroup2"},
146+ expectedError: nil,
147+ }),
148+ Entry("when the requested claim is the empty string", getClaimTableInput{
149+ testClaimExtractorOpts: testClaimExtractorOpts{
150+ idTokenPayload: basicIDTokenPayload,
151+ },
152+ claim: "",
153+ expectExists: false,
154+ expectedValue: nil,
155+ expectedError: nil,
156+ }),
157+ Entry("when the requested claim is the not found (with no profile URL)", getClaimTableInput{
158+ testClaimExtractorOpts: testClaimExtractorOpts{
159+ idTokenPayload: basicIDTokenPayload,
160+ profileRequestHeaders: newAuthorizedHeader(),
161+ },
162+ claim: "not_found",
163+ expectExists: false,
164+ expectedValue: nil,
165+ expectedError: nil,
166+ }),
167+ Entry("when the requested claim is the not found (with profile URL)", getClaimTableInput{
168+ testClaimExtractorOpts: testClaimExtractorOpts{
169+ idTokenPayload: basicIDTokenPayload,
170+ setProfileURL: true,
171+ profileRequestHeaders: newAuthorizedHeader(),
172+ profileRequestHandler: requiresAuthProfileHandler,
173+ },
174+ claim: "not_found",
175+ expectExists: false,
176+ expectedValue: nil,
177+ expectedError: nil,
178+ }),
179+ Entry("when the requested claim is the not found (with no profile Headers)", getClaimTableInput{
180+ testClaimExtractorOpts: testClaimExtractorOpts{
181+ idTokenPayload: basicIDTokenPayload,
182+ setProfileURL: true,
183+ profileRequestHeaders: nil,
184+ profileRequestHandler: shouldNotBeRequestedProfileHandler,
185+ },
186+ claim: "not_found",
187+ expectExists: false,
188+ expectedValue: nil,
189+ expectedError: nil,
190+ }),
191+ Entry("when the profile URL is unauthorized", getClaimTableInput{
192+ testClaimExtractorOpts: testClaimExtractorOpts{
193+ idTokenPayload: emptyJSON,
194+ setProfileURL: true,
195+ profileRequestHeaders: make(http.Header),
196+ profileRequestHandler: requiresAuthProfileHandler,
197+ },
198+ claim: "user",
199+ expectExists: false,
200+ expectedValue: nil,
201+ expectedError: errors.New("failed to fetch claims from profile URL: error making request to profile URL: unexpected status \"403\": Unauthorized"),
202+ }),
203+ Entry("retrieves a string claim from profile URL when not present in the ID Token", getClaimTableInput{
204+ testClaimExtractorOpts: testClaimExtractorOpts{
205+ idTokenPayload: emptyJSON,
206+ setProfileURL: true,
207+ profileRequestHeaders: newAuthorizedHeader(),
208+ profileRequestHandler: requiresAuthProfileHandler,
209+ },
210+ claim: "user",
211+ expectExists: true,
212+ expectedValue: "profileUser",
213+ expectedError: nil,
214+ }),
215+ Entry("retrieves a string claim from a nested path", getClaimTableInput{
216+ testClaimExtractorOpts: testClaimExtractorOpts{
217+ idTokenPayload: nestedClaimPayload,
218+ setProfileURL: true,
219+ profileRequestHeaders: newAuthorizedHeader(),
220+ profileRequestHandler: shouldNotBeRequestedProfileHandler,
221+ },
222+ claim: "auth.user.username",
223+ expectExists: true,
224+ expectedValue: "nestedUser",
225+ expectedError: nil,
226+ }),
227+ )
228+ })
229+
230+ It("GetClaim should only call the profile URL once", func() {
231+ var counter int32
232+ countRequestsHandler := func(rw http.ResponseWriter, _ *http.Request) {
233+ atomic.AddInt32(&counter, 1)
234+ rw.Write([]byte(basicProfileURLPayload))
235+ }
236+
237+ claimExtractor, serverClose, err := newTestClaimExtractor(testClaimExtractorOpts{
238+ idTokenPayload: "{}",
239+ setProfileURL: true,
240+ profileRequestHeaders: newAuthorizedHeader(),
241+ profileRequestHandler: countRequestsHandler,
242+ })
243+ Expect(err).ToNot(HaveOccurred())
244+ if serverClose != nil {
245+ defer serverClose()
246+ }
247+
248+ value, exists, err := claimExtractor.GetClaim("user")
249+ Expect(err).ToNot(HaveOccurred())
250+ Expect(exists).To(BeTrue())
251+ Expect(value).To(Equal("profileUser"))
252+ Expect(counter).To(BeEquivalentTo(1))
253+
254+ // Check a different claim, but expect the count not to increase
255+ value, exists, err = claimExtractor.GetClaim("email")
256+ Expect(err).ToNot(HaveOccurred())
257+ Expect(exists).To(BeTrue())
258+ Expect(value).To(Equal("profileEmail"))
259+ Expect(counter).To(BeEquivalentTo(1))
260+ })
261+
262+ type getClaimIntoTableInput struct {
263+ testClaimExtractorOpts
264+ into interface{}
265+ claim string
266+ expectedValue interface{}
267+ expectExists bool
268+ expectedError error
269+ }
270+
271+ DescribeTable("GetClaimInto",
272+ func(in getClaimIntoTableInput) {
273+ claimExtractor, serverClose, err := newTestClaimExtractor(in.testClaimExtractorOpts)
274+ Expect(err).ToNot(HaveOccurred())
275+ if serverClose != nil {
276+ defer serverClose()
277+ }
278+
279+ exists, err := claimExtractor.GetClaimInto(in.claim, in.into)
280+ if in.expectedError != nil {
281+ Expect(err).To(MatchError(in.expectedError))
282+ return
283+ }
284+
285+ Expect(err).ToNot(HaveOccurred())
286+ if in.expectedValue != nil {
287+ Expect(in.into).To(Equal(in.expectedValue))
288+ } else {
289+ Expect(in.into).To(BeEmpty())
290+ }
291+
292+ Expect(exists).To(Equal(in.expectExists))
293+ },
294+ Entry("retrieves a string claim from ID Token when present into a string", getClaimIntoTableInput{
295+ testClaimExtractorOpts: testClaimExtractorOpts{
296+ idTokenPayload: basicIDTokenPayload,
297+ setProfileURL: true,
298+ profileRequestHeaders: newAuthorizedHeader(),
299+ profileRequestHandler: shouldNotBeRequestedProfileHandler,
300+ },
301+ claim: "user",
302+ into: stringPointer(""),
303+ expectExists: true,
304+ expectedValue: stringPointer("idTokenUser"),
305+ expectedError: nil,
306+ }),
307+ Entry("retrieves a string claim from ID Token when present into a string slice", getClaimIntoTableInput{
308+ testClaimExtractorOpts: testClaimExtractorOpts{
309+ idTokenPayload: basicIDTokenPayload,
310+ setProfileURL: true,
311+ profileRequestHeaders: newAuthorizedHeader(),
312+ profileRequestHandler: shouldNotBeRequestedProfileHandler,
313+ },
314+ claim: "user",
315+ into: stringSlicePointer([]string{}),
316+ expectExists: true,
317+ expectedValue: stringSlicePointer([]string{"idTokenUser"}),
318+ expectedError: nil,
319+ }),
320+ Entry("retrieves a string slice claim from ID Token when present into a string slice", getClaimIntoTableInput{
321+ testClaimExtractorOpts: testClaimExtractorOpts{
322+ idTokenPayload: basicIDTokenPayload,
323+ setProfileURL: true,
324+ profileRequestHeaders: newAuthorizedHeader(),
325+ profileRequestHandler: shouldNotBeRequestedProfileHandler,
326+ },
327+ claim: "groups",
328+ into: stringSlicePointer([]string{}),
329+ expectExists: true,
330+ expectedValue: stringSlicePointer([]string{"idTokenGroup1", "idTokenGroup2"}),
331+ expectedError: nil,
332+ }),
333+ Entry("retrieves a string slice claim from ID Token when present into a string", getClaimIntoTableInput{
334+ testClaimExtractorOpts: testClaimExtractorOpts{
335+ idTokenPayload: basicIDTokenPayload,
336+ setProfileURL: true,
337+ profileRequestHeaders: newAuthorizedHeader(),
338+ profileRequestHandler: shouldNotBeRequestedProfileHandler,
339+ },
340+ claim: "groups",
341+ into: stringPointer(""),
342+ expectExists: true,
343+ expectedValue: stringPointer("[\"idTokenGroup1\",\"idTokenGroup2\"]"),
344+ expectedError: nil,
345+ }),
346+ Entry("returns an error when a non-pointer is passed for the destination", getClaimIntoTableInput{
347+ testClaimExtractorOpts: testClaimExtractorOpts{
348+ idTokenPayload: basicIDTokenPayload,
349+ setProfileURL: true,
350+ profileRequestHeaders: newAuthorizedHeader(),
351+ profileRequestHandler: shouldNotBeRequestedProfileHandler,
352+ },
353+ claim: "user",
354+ into: "",
355+ expectExists: false,
356+ expectedValue: "",
357+ expectedError: errors.New("could no coerce claim: unknown type for destination: string"),
358+ }),
359+ Entry("flattens a complex claim value into a JSON string", getClaimIntoTableInput{
360+ testClaimExtractorOpts: testClaimExtractorOpts{
361+ idTokenPayload: complexGroupsPayload,
362+ setProfileURL: true,
363+ profileRequestHeaders: newAuthorizedHeader(),
364+ profileRequestHandler: shouldNotBeRequestedProfileHandler,
365+ },
366+ claim: "groups",
367+ into: stringSlicePointer([]string{}),
368+ expectExists: true,
369+ expectedValue: stringSlicePointer([]string{
370+ "{\"groupID\":\"group1\",\"roles\":[\"admin\"]}",
371+ "{\"groupID\":\"group2\",\"roles\":[\"user\",\"employee\"]}",
372+ }),
373+ expectedError: nil,
374+ }),
375+ Entry("does not return an error when the claim does not exist", getClaimIntoTableInput{
376+ testClaimExtractorOpts: testClaimExtractorOpts{
377+ idTokenPayload: basicIDTokenPayload,
378+ setProfileURL: true,
379+ profileRequestHeaders: newAuthorizedHeader(),
380+ profileRequestHandler: requiresAuthProfileHandler,
381+ },
382+ claim: "not_found",
383+ into: stringPointer(""),
384+ expectExists: false,
385+ expectedValue: stringPointer(""),
386+ expectedError: nil,
387+ }),
388+ Entry("returns an error when the profile request is unauthorized", getClaimIntoTableInput{
389+ testClaimExtractorOpts: testClaimExtractorOpts{
390+ idTokenPayload: emptyJSON,
391+ setProfileURL: true,
392+ profileRequestHeaders: make(http.Header),
393+ profileRequestHandler: requiresAuthProfileHandler,
394+ },
395+ claim: "user",
396+ into: stringPointer(""),
397+ expectExists: false,
398+ expectedValue: stringPointer(""),
399+ expectedError: errors.New("could not get claim \"user\": failed to fetch claims from profile URL: error making request to profile URL: unexpected status \"403\": Unauthorized"),
400+ }),
401+ )
402+
403+ type coerceClaimTableInput struct {
404+ value interface{}
405+ dst interface{}
406+ expectedDst interface{}
407+ expectedError error
408+ }
409+
410+ DescribeTable("coerceClaim",
411+ func(in coerceClaimTableInput) {
412+ err := coerceClaim(in.value, in.dst)
413+ if in.expectedError != nil {
414+ Expect(err).To(MatchError(in.expectedError))
415+ return
416+ }
417+
418+ Expect(err).ToNot(HaveOccurred())
419+ Expect(in.dst).To(Equal(in.expectedDst))
420+ },
421+ Entry("coerces a string to a string", coerceClaimTableInput{
422+ value: "some_string",
423+ dst: stringPointer(""),
424+ expectedDst: stringPointer("some_string"),
425+ }),
426+ Entry("coerces a slice to a string slice", coerceClaimTableInput{
427+ value: []interface{}{"a", "b"},
428+ dst: stringSlicePointer([]string{}),
429+ expectedDst: stringSlicePointer([]string{"a", "b"}),
430+ }),
431+ Entry("coerces a bool to a bool", coerceClaimTableInput{
432+ value: true,
433+ dst: boolPointer(false),
434+ expectedDst: boolPointer(true),
435+ }),
436+ Entry("coerces a string to a bool", coerceClaimTableInput{
437+ value: "true",
438+ dst: boolPointer(false),
439+ expectedDst: boolPointer(true),
440+ }),
441+ Entry("coerces a map to a string", coerceClaimTableInput{
442+ value: map[string]interface{}{
443+ "foo": []interface{}{"bar", "baz"},
444+ },
445+ dst: stringPointer(""),
446+ expectedDst: stringPointer("{\"foo\":[\"bar\",\"baz\"]}"),
447+ }),
448+ )
449+})
450+
451+// ******************************************
452+// Helpers for setting up the claim extractor
453+// ******************************************
454+
455+type testClaimExtractorOpts struct {
456+ idTokenPayload string
457+ setProfileURL bool
458+ profileRequestHeaders http.Header
459+ profileRequestHandler http.HandlerFunc
460+}
461+
462+func newTestClaimExtractor(in testClaimExtractorOpts) (ClaimExtractor, func(), error) {
463+ var profileURL *url.URL
464+ var closeServer func()
465+ if in.setProfileURL {
466+ server := httptest.NewServer(http.HandlerFunc(in.profileRequestHandler))
467+ closeServer = server.Close
468+
469+ var err error
470+ profileURL, err = url.Parse("http://" + server.Listener.Addr().String() + profilePath)
471+ Expect(err).ToNot(HaveOccurred())
472+ }
473+
474+ rawIDToken := createJWTFromPayload(in.idTokenPayload)
475+
476+ claimExtractor, err := NewClaimExtractor(context.Background(), rawIDToken, profileURL, in.profileRequestHeaders)
477+ return claimExtractor, closeServer, err
478+}
479+
480+func createJWTFromPayload(payload string) string {
481+ header := base64.RawURLEncoding.EncodeToString([]byte(emptyJSON))
482+ payloadJSON := base64.RawURLEncoding.EncodeToString([]byte(payload))
483+
484+ return fmt.Sprintf("%s.%s.%s", header, payloadJSON, header)
485+}
486+
487+func newAuthorizedHeader() http.Header {
488+ headers := make(http.Header)
489+ headers.Add("Authorization", "Bearer "+authorizedAccessToken)
490+ return headers
491+}
492+
493+func hasAuthorizedHeader(headers http.Header) bool {
494+ return headers.Get("Authorization") == "Bearer "+authorizedAccessToken
495+}
496+
497+// ***********************
498+// Typed Pointer Functions
499+// ***********************
500+
501+func stringPointer(in string) *string {
502+ return &in
503+}
504+
505+func stringSlicePointer(in []string) *[]string {
506+ return &in
507+}
508+
509+func boolPointer(in bool) *bool {
510+ return &in
511+}
512+
513+// ******************************
514+// Different profile URL handlers
515+// ******************************
516+
517+func shouldNotBeRequestedProfileHandler(_ http.ResponseWriter, _ *http.Request) {
518+ defer GinkgoRecover()
519+ Expect(true).To(BeFalse(), "Unexpected request to profile URL")
520+}
521+
522+func requiresAuthProfileHandler(rw http.ResponseWriter, req *http.Request) {
523+ if !hasAuthorizedHeader(req.Header) {
524+ rw.WriteHeader(403)
525+ rw.Write([]byte("Unauthorized"))
526+ return
527+ }
528+
529+ rw.Write([]byte(basicProfileURLPayload))
530+}
pkg/providers/util/util_suite_test.gonew file+17
@@ -0,0 +1,17 @@
1+package util
2+
3+import (
4+ "testing"
5+
6+ "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger"
7+ . "github.com/onsi/ginkgo"
8+ . "github.com/onsi/gomega"
9+)
10+
11+func TestProviderUtilSuite(t *testing.T) {
12+ logger.SetOutput(GinkgoWriter)
13+ logger.SetErrOutput(GinkgoWriter)
14+
15+ RegisterFailHandler(Fail)
16+ RunSpecs(t, "Provider Utils")
17+}
providers/adfs.go+7−6
@@ -103,16 +103,17 @@ func (p *ADFSProvider) RefreshSession(ctx context.Context, s *sessions.SessionSt
103103}
104104
105105func (p *ADFSProvider) fallbackUPN(ctx context.Context, s *sessions.SessionState) error {
106− idToken, err := p.Verifier.Verify(ctx, s.IDToken)
106+ claims, err := p.getClaimExtractor(s.IDToken, s.AccessToken)
107107 if err != nil {
108− return err
108+ return fmt.Errorf("could not extract claims: %v", err)
109109 }
110− claims, err := p.getClaims(idToken)
110+
111+ upn, found, err := claims.GetClaim(adfsUPNClaim)
111112 if err != nil {
112− return fmt.Errorf("couldn't extract claims from id_token (%v)", err)
113+ return fmt.Errorf("could not extract %s claim: %v", adfsUPNClaim, err)
113114 }
114− upn := claims.raw[adfsUPNClaim]
115− if upn != nil {
115+
116+ if found && fmt.Sprint(upn) != "" {
116117 s.Email = fmt.Sprint(upn)
117118 }
118119 return nil
providers/adfs_test.go+2−4
@@ -79,7 +79,7 @@ func testADFSBackend() *httptest.Server {
7979 {
8080 "access_token": "my_access_token",
8181 "id_token": "my_id_token",
82− "refresh_token": "my_refresh_token"
82+ "refresh_token": "my_refresh_token"
8383 }
8484 `
8585 userInfo := `
@@ -150,9 +150,7 @@ var _ = Describe("ADFS Provider Tests", func() {
150150 Context("with valid token", func() {
151151 It("should not throw an error", func() {
152152 rawIDToken, _ := newSignedTestIDToken(defaultIDToken)
153− idToken, err := p.Verifier.Verify(context.Background(), rawIDToken)
154− Expect(err).To(BeNil())
155− session, err := p.buildSessionFromClaims(idToken)
153+ session, err := p.buildSessionFromClaims(rawIDToken, "")
156154 Expect(err).To(BeNil())
157155 session.IDToken = rawIDToken
158156 err = p.EnrichSession(context.Background(), session)
providers/auth_test.go+12−1
@@ -15,9 +15,20 @@ func CreateAuthorizedSession() *sessions.SessionState {
1515}
1616
1717func IsAuthorizedInHeader(reqHeader http.Header) bool {
18− return reqHeader.Get("Authorization") == fmt.Sprintf("Bearer %s", authorizedAccessToken)
18+ return IsAuthorizedInHeaderWithToken(reqHeader, authorizedAccessToken)
19+}
20+
21+func IsAuthorizedInHeaderWithToken(reqHeader http.Header, token string) bool {
22+ return reqHeader.Get("Authorization") == fmt.Sprintf("Bearer %s", token)
1923}
2024
2125func IsAuthorizedInURL(reqURL *url.URL) bool {
2226 return reqURL.Query().Get("access_token") == authorizedAccessToken
2327}
28+
29+func isAuthorizedRefreshInURLWithToken(reqURL *url.URL, token string) bool {
30+ if token == "" {
31+ return false
32+ }
33+ return reqURL.Query().Get("refresh_token") == token
34+}
providers/azure.go+10−9
@@ -78,6 +78,7 @@ func NewAzureProvider(p *ProviderData) *AzureProvider {
7878 if p.ValidateURL == nil || p.ValidateURL.String() == "" {
7979 p.ValidateURL = p.ProfileURL
8080 }
81+ p.getAuthorizationHeaderFunc = makeAzureHeader
8182
8283 return &AzureProvider{
8384 ProviderData: p,
@@ -150,7 +151,7 @@ func (p *AzureProvider) Redeem(ctx context.Context, redirectURL, code string) (*
150151 session.CreatedAtNow()
151152 session.SetExpiresOn(time.Unix(jsonResponse.ExpiresOn, 0))
152153
153− email, err := p.verifyTokenAndExtractEmail(ctx, session.IDToken)
154+ email, err := p.verifyTokenAndExtractEmail(ctx, session.IDToken, session.AccessToken)
154155
155156 // https://github.com/oauth2-proxy/oauth2-proxy/pull/914#issuecomment-782285814
156157 // https://github.com/AzureAD/azure-activedirectory-library-for-java/issues/117
@@ -163,7 +164,7 @@ func (p *AzureProvider) Redeem(ctx context.Context, redirectURL, code string) (*
163164 }
164165
165166 if session.Email == "" {
166− email, err = p.verifyTokenAndExtractEmail(ctx, session.AccessToken)
167+ email, err = p.verifyTokenAndExtractEmail(ctx, session.AccessToken, session.AccessToken)
167168 if err == nil && email != "" {
168169 session.Email = email
169170 } else {
@@ -215,16 +216,16 @@ func (p *AzureProvider) prepareRedeem(redirectURL, code string) (url.Values, err
215216
216217// verifyTokenAndExtractEmail tries to extract email claim from either id_token or access token
217218// when oidc verifier is configured
218−func (p *AzureProvider) verifyTokenAndExtractEmail(ctx context.Context, token string) (string, error) {
219+func (p *AzureProvider) verifyTokenAndExtractEmail(ctx context.Context, rawIDToken string, accessToken string) (string, error) {
219220 email := ""
220221
221− if token != "" && p.Verifier != nil {
222− token, err := p.Verifier.Verify(ctx, token)
222+ if rawIDToken != "" && p.Verifier != nil {
223+ _, err := p.Verifier.Verify(ctx, rawIDToken)
223224 // due to issues mentioned above, id_token may not be signed by AAD
224225 if err == nil {
225− claims, err := p.getClaims(token)
226+ s, err := p.buildSessionFromClaims(rawIDToken, accessToken)
226227 if err == nil {
227− email = claims.Email
228+ email = s.Email
228229 } else {
229230 logger.Printf("unable to get claims from token: %v", err)
230231 }
@@ -287,7 +288,7 @@ func (p *AzureProvider) redeemRefreshToken(ctx context.Context, s *sessions.Sess
287288 s.CreatedAtNow()
288289 s.SetExpiresOn(time.Unix(jsonResponse.ExpiresOn, 0))
289290
290− email, err := p.verifyTokenAndExtractEmail(ctx, s.IDToken)
291+ email, err := p.verifyTokenAndExtractEmail(ctx, s.IDToken, s.AccessToken)
291292
292293 // https://github.com/oauth2-proxy/oauth2-proxy/pull/914#issuecomment-782285814
293294 // https://github.com/AzureAD/azure-activedirectory-library-for-java/issues/117
@@ -300,7 +301,7 @@ func (p *AzureProvider) redeemRefreshToken(ctx context.Context, s *sessions.Sess
300301 }
301302
302303 if s.Email == "" {
303− email, err = p.verifyTokenAndExtractEmail(ctx, s.AccessToken)
304+ email, err = p.verifyTokenAndExtractEmail(ctx, s.AccessToken, s.AccessToken)
304305 if err == nil && email != "" {
305306 s.Email = email
306307 } else {
providers/azure_test.go+24−15
@@ -13,9 +13,8 @@ import (
1313 "testing"
1414 "time"
1515
16+ "github.com/coreos/go-oidc/v3/oidc"
1617 "github.com/golang-jwt/jwt"
17−
18− oidc "github.com/coreos/go-oidc/v3/oidc"
1918 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
2019 internaloidc "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/oidc"
2120
@@ -145,11 +144,11 @@ func TestAzureSetTenant(t *testing.T) {
145144 assert.Equal(t, "openid", p.Data().Scope)
146145}
147146
148−func testAzureBackend(payload string) *httptest.Server {
149− return testAzureBackendWithError(payload, false)
147+func testAzureBackend(payload string, accessToken, refreshToken string) *httptest.Server {
148+ return testAzureBackendWithError(payload, accessToken, refreshToken, false)
150149}
151150
152−func testAzureBackendWithError(payload string, injectError bool) *httptest.Server {
151+func testAzureBackendWithError(payload string, accessToken, refreshToken string, injectError bool) *httptest.Server {
153152 path := "/v1.0/me"
154153
155154 return httptest.NewServer(http.HandlerFunc(
@@ -163,7 +162,8 @@ func testAzureBackendWithError(payload string, injectError bool) *httptest.Serve
163162 w.WriteHeader(200)
164163 }
165164 w.Write([]byte(payload))
166− } else if !IsAuthorizedInHeader(r.Header) {
165+ } else if !IsAuthorizedInHeaderWithToken(r.Header, accessToken) &&
166+ !isAuthorizedRefreshInURLWithToken(r.URL, refreshToken) {
167167 w.WriteHeader(403)
168168 } else {
169169 w.WriteHeader(200)
@@ -224,7 +224,7 @@ func TestAzureProviderEnrichSession(t *testing.T) {
224224 host string
225225 )
226226 if testCase.PayloadFromAzureBackend != "" {
227− b = testAzureBackend(testCase.PayloadFromAzureBackend)
227+ b = testAzureBackend(testCase.PayloadFromAzureBackend, authorizedAccessToken, "")
228228 defer b.Close()
229229
230230 bURL, _ := url.Parse(b.URL)
@@ -319,7 +319,7 @@ func TestAzureProviderRedeem(t *testing.T) {
319319 payloadBytes, err := json.Marshal(payload)
320320 assert.NoError(t, err)
321321
322− b := testAzureBackendWithError(string(payloadBytes), testCase.InjectRedeemURLError)
322+ b := testAzureBackendWithError(string(payloadBytes), accessTokenString, testCase.RefreshToken, testCase.InjectRedeemURLError)
323323 defer b.Close()
324324
325325 bURL, _ := url.Parse(b.URL)
@@ -353,35 +353,44 @@ func TestAzureProviderProtectedResourceConfigured(t *testing.T) {
353353
354354func TestAzureProviderRefresh(t *testing.T) {
355355 email := "foo@example.com"
356+ subject := "foo"
356357 idToken := idTokenClaims{
357− StandardClaims: jwt.StandardClaims{Audience: "cd6d4fae-f6a6-4a34-8454-2c6b598e9532"},
358− Email: email}
358+ Email: email,
359+ StandardClaims: jwt.StandardClaims{
360+ Audience: "cd6d4fae-f6a6-4a34-8454-2c6b598e9532",
361+ Subject: subject,
362+ },
363+ }
359364 idTokenString, err := newSignedTestIDToken(idToken)
360365 assert.NoError(t, err)
366+
361367 timestamp, err := time.Parse(time.RFC3339, "3006-01-02T22:04:05Z")
362368 assert.NoError(t, err)
369+
370+ newAccessToken := "new_some_access_token"
363371 payload := azureOAuthPayload{
364372 IDToken: idTokenString,
365373 RefreshToken: "new_some_refresh_token",
366− AccessToken: "new_some_access_token",
374+ AccessToken: newAccessToken,
367375 ExpiresOn: timestamp.Unix(),
368376 }
369−
370377 payloadBytes, err := json.Marshal(payload)
371378 assert.NoError(t, err)
372− b := testAzureBackend(string(payloadBytes))
379+
380+ refreshToken := "some_refresh_token"
381+ b := testAzureBackend(string(payloadBytes), newAccessToken, refreshToken)
373382 defer b.Close()
374383 bURL, _ := url.Parse(b.URL)
375384 p := testAzureProvider(bURL.Host)
376385
377386 expires := time.Now().Add(time.Duration(-1) * time.Hour)
378− session := &sessions.SessionState{AccessToken: "some_access_token", RefreshToken: "some_refresh_token", IDToken: "some_id_token", ExpiresOn: &expires}
387+ session := &sessions.SessionState{AccessToken: "some_access_token", RefreshToken: refreshToken, IDToken: "some_id_token", ExpiresOn: &expires}
379388
380389 refreshed, err := p.RefreshSession(context.Background(), session)
381390 assert.Equal(t, nil, err)
382391 assert.True(t, refreshed)
383392 assert.NotEqual(t, session, nil)
384− assert.Equal(t, "new_some_access_token", session.AccessToken)
393+ assert.Equal(t, newAccessToken, session.AccessToken)
385394 assert.Equal(t, "new_some_refresh_token", session.RefreshToken)
386395 assert.Equal(t, idTokenString, session.IDToken)
387396 assert.Equal(t, email, session.Email)
providers/digitalocean.go+2
@@ -57,6 +57,8 @@ func NewDigitalOceanProvider(p *ProviderData) *DigitalOceanProvider {
5757 validateURL: digitalOceanDefaultProfileURL,
5858 scope: digitalOceanDefaultScope,
5959 })
60+ p.getAuthorizationHeaderFunc = makeOIDCHeader
61+
6062 return &DigitalOceanProvider{ProviderData: p}
6163}
6264
providers/facebook.go+1
@@ -58,6 +58,7 @@ func NewFacebookProvider(p *ProviderData) *FacebookProvider {
5858 validateURL: facebookDefaultProfileURL,
5959 scope: facebookDefaultScope,
6060 })
61+ p.getAuthorizationHeaderFunc = makeOIDCHeader
6162 return &FacebookProvider{ProviderData: p}
6263}
6364
providers/linkedin.go+2
@@ -65,6 +65,8 @@ func NewLinkedInProvider(p *ProviderData) *LinkedInProvider {
6565 validateURL: linkedinDefaultValidateURL,
6666 scope: linkedinDefaultScope,
6767 })
68+ p.getAuthorizationHeaderFunc = makeLinkedInHeader
69+
6870 return &LinkedInProvider{ProviderData: p}
6971}
7072
providers/nextcloud.go+6−23
@@ -1,13 +1,5 @@
11package providers
22
3−import (
4− "context"
5− "fmt"
6−
7− "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
8− "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/requests"
9−)
10−
113// NextcloudProvider represents an Nextcloud based Identity Provider
124type NextcloudProvider struct {
135 *ProviderData
@@ -20,20 +12,11 @@ const nextCloudProviderName = "Nextcloud"
2012// NewNextcloudProvider initiates a new NextcloudProvider
2113func NewNextcloudProvider(p *ProviderData) *NextcloudProvider {
2214 p.ProviderName = nextCloudProviderName
23− return &NextcloudProvider{ProviderData: p}
24−}
25−
26−// GetEmailAddress returns the Account email address
27−func (p *NextcloudProvider) GetEmailAddress(ctx context.Context, s *sessions.SessionState) (string, error) {
28− json, err := requests.New(p.ValidateURL.String()).
29− WithContext(ctx).
30− WithHeaders(makeOIDCHeader(s.AccessToken)).
31− Do().
32− UnmarshalJSON()
33− if err != nil {
34− return "", fmt.Errorf("error making request: %v", err)
15+ p.getAuthorizationHeaderFunc = makeOIDCHeader
16+ if p.EmailClaim == OIDCEmailClaim {
17+ // This implies the email claim has not been overridden, we should set a default
18+ // for this provider
19+ p.EmailClaim = "ocs.data.email"
3520 }
36−
37− email, err := json.Get("ocs").Get("data").Get("email").String()
38− return email, err
21+ return &NextcloudProvider{ProviderData: p}
3922}
providers/nextcloud_test.go−72
@@ -1,18 +1,13 @@
11package providers
22
33import (
4− "context"
5− "net/http"
6− "net/http/httptest"
74 "net/url"
85 "testing"
96
10− "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
117 "github.com/stretchr/testify/assert"
128)
139
1410const formatJSON = "format=json"
15−const userPath = "/ocs/v2.php/cloud/user"
1611
1712func testNextcloudProvider(hostname string) *NextcloudProvider {
1813 p := NewNextcloudProvider(
@@ -32,23 +27,6 @@ func testNextcloudProvider(hostname string) *NextcloudProvider {
3227 return p
3328}
3429
35−func testNextcloudBackend(payload string) *httptest.Server {
36− path := userPath
37− query := formatJSON
38−
39− return httptest.NewServer(http.HandlerFunc(
40− func(w http.ResponseWriter, r *http.Request) {
41− if r.URL.Path != path || r.URL.RawQuery != query {
42− w.WriteHeader(404)
43− } else if !IsAuthorizedInHeader(r.Header) {
44− w.WriteHeader(403)
45− } else {
46− w.WriteHeader(200)
47− w.Write([]byte(payload))
48− }
49− }))
50−}
51−
5230func TestNextcloudProviderDefaults(t *testing.T) {
5331 p := testNextcloudProvider("")
5432 assert.NotEqual(t, nil, p)
@@ -87,53 +65,3 @@ func TestNextcloudProviderOverrides(t *testing.T) {
8765 assert.Equal(t, "https://example.com/test/ocs/v2.php/cloud/user?"+formatJSON,
8866 p.Data().ValidateURL.String())
8967}
90−
91−func TestNextcloudProviderGetEmailAddress(t *testing.T) {
92− b := testNextcloudBackend("{\"ocs\": {\"data\": { \"email\": \"michael.bland@gsa.gov\"}}}")
93− defer b.Close()
94−
95− bURL, _ := url.Parse(b.URL)
96− p := testNextcloudProvider(bURL.Host)
97− p.ValidateURL.Path = userPath
98− p.ValidateURL.RawQuery = formatJSON
99−
100− session := CreateAuthorizedSession()
101− email, err := p.GetEmailAddress(context.Background(), session)
102− assert.Equal(t, nil, err)
103− assert.Equal(t, "michael.bland@gsa.gov", email)
104−}
105−
106−// Note that trying to trigger the "failed building request" case is not
107−// practical, since the only way it can fail is if the URL fails to parse.
108−func TestNextcloudProviderGetEmailAddressFailedRequest(t *testing.T) {
109− b := testNextcloudBackend("unused payload")
110− defer b.Close()
111−
112− bURL, _ := url.Parse(b.URL)
113− p := testNextcloudProvider(bURL.Host)
114− p.ValidateURL.Path = userPath
115− p.ValidateURL.RawQuery = formatJSON
116−
117− // We'll trigger a request failure by using an unexpected access
118− // token. Alternatively, we could allow the parsing of the payload as
119− // JSON to fail.
120− session := &sessions.SessionState{AccessToken: "unexpected_access_token"}
121− email, err := p.GetEmailAddress(context.Background(), session)
122− assert.NotEqual(t, nil, err)
123− assert.Equal(t, "", email)
124−}
125−
126−func TestNextcloudProviderGetEmailAddressEmailNotPresentInPayload(t *testing.T) {
127− b := testNextcloudBackend("{\"foo\": \"bar\"}")
128− defer b.Close()
129−
130− bURL, _ := url.Parse(b.URL)
131− p := testNextcloudProvider(bURL.Host)
132− p.ValidateURL.Path = userPath
133− p.ValidateURL.RawQuery = formatJSON
134−
135− session := CreateAuthorizedSession()
136− email, err := p.GetEmailAddress(context.Background(), session)
137− assert.NotEqual(t, nil, err)
138− assert.Equal(t, "", email)
139−}
providers/oidc.go+9−56
@@ -5,12 +5,10 @@ import (
55 "errors"
66 "fmt"
77 "net/url"
8− "reflect"
98 "time"
109
1110 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
1211 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger"
13− "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/requests"
1412 "golang.org/x/oauth2"
1513)
1614
@@ -24,6 +22,8 @@ type OIDCProvider struct {
2422// NewOIDCProvider initiates a new OIDCProvider
2523func NewOIDCProvider(p *ProviderData) *OIDCProvider {
2624 p.ProviderName = "OpenID Connect"
25+ p.getAuthorizationHeaderFunc = makeOIDCHeader
26+
2727 return &OIDCProvider{
2828 ProviderData: p,
2929 SkipNonce: true,
@@ -68,21 +68,6 @@ func (p *OIDCProvider) Redeem(ctx context.Context, redirectURL, code string) (*s
6868// EnrichSession is called after Redeem to allow providers to enrich session fields
6969// such as User, Email, Groups with provider specific API calls.
7070func (p *OIDCProvider) EnrichSession(ctx context.Context, s *sessions.SessionState) error {
71− if p.ProfileURL.String() == "" {
72− if s.Email == "" {
73− return errors.New("id_token did not contain an email and profileURL is not defined")
74− }
75− return nil
76− }
77−
78− // Try to get missing emails or groups from a profileURL
79− if s.Email == "" || s.Groups == nil {
80− err := p.enrichFromProfileURL(ctx, s)
81− if err != nil {
82− logger.Errorf("Warning: Profile URL request failed: %v", err)
83− }
84− }
85−
8671 // If a mandatory email wasn't set, error at this point.
8772 if s.Email == "" {
8873 return errors.New("neither the id_token nor the profileURL set an email")
@@ -90,42 +75,9 @@ func (p *OIDCProvider) EnrichSession(ctx context.Context, s *sessions.SessionSta
9075 return nil
9176}
9277
93−// enrichFromProfileURL enriches a session's Email & Groups via the JSON response of
94−// an OIDC profile URL
95−func (p *OIDCProvider) enrichFromProfileURL(ctx context.Context, s *sessions.SessionState) error {
96− respJSON, err := requests.New(p.ProfileURL.String()).
97− WithContext(ctx).
98− WithHeaders(makeOIDCHeader(s.AccessToken)).
99− Do().
100− UnmarshalJSON()
101− if err != nil {
102− return err
103− }
104−
105− email, err := respJSON.Get(p.EmailClaim).String()
106− if err == nil && s.Email == "" {
107− s.Email = email
108− }
109−
110− if len(s.Groups) > 0 {
111− return nil
112− }
113− for _, group := range coerceArray(respJSON, p.GroupsClaim) {
114− formatted, err := formatGroup(group)
115− if err != nil {
116− logger.Errorf("Warning: unable to format group of type %s with error %s",
117− reflect.TypeOf(group), err)
118− continue
119− }
120− s.Groups = append(s.Groups, formatted)
121− }
122−
123− return nil
124−}
125−
12678// ValidateSession checks that the session's IDToken is still valid
12779func (p *OIDCProvider) ValidateSession(ctx context.Context, s *sessions.SessionState) bool {
128− idToken, err := p.Verifier.Verify(ctx, s.IDToken)
80+ _, err := p.Verifier.Verify(ctx, s.IDToken)
12981 if err != nil {
13082 logger.Errorf("id_token verification failed: %v", err)
13183 return false
@@ -134,7 +86,7 @@ func (p *OIDCProvider) ValidateSession(ctx context.Context, s *sessions.SessionS
13486 if p.SkipNonce {
13587 return true
13688 }
137− err = p.checkNonce(s, idToken)
89+ err = p.checkNonce(s)
13890 if err != nil {
13991 logger.Errorf("nonce verification failed: %v", err)
14092 return false
@@ -212,7 +164,7 @@ func (p *OIDCProvider) CreateSessionFromToken(ctx context.Context, token string)
212164 return nil, err
213165 }
214166
215− ss, err := p.buildSessionFromClaims(idToken)
167+ ss, err := p.buildSessionFromClaims(token, "")
216168 if err != nil {
217169 return nil, err
218170 }
@@ -235,7 +187,7 @@ func (p *OIDCProvider) CreateSessionFromToken(ctx context.Context, token string)
235187// createSession takes an oauth2.Token and creates a SessionState from it.
236188// It alters behavior if called from Redeem vs Refresh
237189func (p *OIDCProvider) createSession(ctx context.Context, token *oauth2.Token, refresh bool) (*sessions.SessionState, error) {
238− idToken, err := p.verifyIDToken(ctx, token)
190+ _, err := p.verifyIDToken(ctx, token)
239191 if err != nil {
240192 switch err {
241193 case ErrMissingIDToken:
@@ -248,14 +200,15 @@ func (p *OIDCProvider) createSession(ctx context.Context, token *oauth2.Token, r
248200 }
249201 }
250202
251− ss, err := p.buildSessionFromClaims(idToken)
203+ rawIDToken := getIDToken(token)
204+ ss, err := p.buildSessionFromClaims(rawIDToken, token.AccessToken)
252205 if err != nil {
253206 return nil, err
254207 }
255208
256209 ss.AccessToken = token.AccessToken
257210 ss.RefreshToken = token.RefreshToken
258− ss.IDToken = getIDToken(token)
211+ ss.IDToken = rawIDToken
259212
260213 ss.CreatedAtNow()
261214 ss.SetExpiresOn(token.Expiry)
providers/oidc_test.go+10−333
@@ -4,7 +4,6 @@ import (
44 "context"
55 "encoding/base64"
66 "encoding/json"
7− "errors"
87 "fmt"
98 "net/http"
109 "net/http/httptest"
@@ -54,6 +53,7 @@ func newOIDCProvider(serverURL *url.URL) *OIDCProvider {
5453 Scope: "openid profile offline_access",
5554 EmailClaim: "email",
5655 GroupsClaim: "groups",
56+ UserClaim: "sub",
5757 Verifier: internaloidc.NewVerifier(oidc.NewVerifier(
5858 oidcIssuer,
5959 mockJWKS{},
@@ -142,333 +142,6 @@ func TestOIDCProviderRedeem_custom_userid(t *testing.T) {
142142 assert.Equal(t, defaultIDToken.Phone, session.Email)
143143}
144144
145−func TestOIDCProvider_EnrichSession(t *testing.T) {
146− testCases := map[string]struct {
147− ExistingSession *sessions.SessionState
148− EmailClaim string
149− GroupsClaim string
150− ProfileJSON map[string]interface{}
151− ExpectedError error
152− ExpectedSession *sessions.SessionState
153− }{
154− "Already Populated": {
155− ExistingSession: &sessions.SessionState{
156− User: "already",
157− Email: "already@populated.com",
158− Groups: []string{"already", "populated"},
159− IDToken: idToken,
160− AccessToken: accessToken,
161− RefreshToken: refreshToken,
162− },
163− EmailClaim: "email",
164− GroupsClaim: "groups",
165− ProfileJSON: map[string]interface{}{
166− "email": "new@thing.com",
167− "groups": []string{"new", "thing"},
168− },
169− ExpectedError: nil,
170− ExpectedSession: &sessions.SessionState{
171− User: "already",
172− Email: "already@populated.com",
173− Groups: []string{"already", "populated"},
174− IDToken: idToken,
175− AccessToken: accessToken,
176− RefreshToken: refreshToken,
177− },
178− },
179− "Missing Email": {
180− ExistingSession: &sessions.SessionState{
181− User: "missing.email",
182− Groups: []string{"already", "populated"},
183− IDToken: idToken,
184− AccessToken: accessToken,
185− RefreshToken: refreshToken,
186− },
187− EmailClaim: "email",
188− GroupsClaim: "groups",
189− ProfileJSON: map[string]interface{}{
190− "email": "found@email.com",
191− "groups": []string{"new", "thing"},
192− },
193− ExpectedError: nil,
194− ExpectedSession: &sessions.SessionState{
195− User: "missing.email",
196− Email: "found@email.com",
197− Groups: []string{"already", "populated"},
198− IDToken: idToken,
199− AccessToken: accessToken,
200− RefreshToken: refreshToken,
201− },
202− },
203−
204− "Missing Email Only in Profile URL": {
205− ExistingSession: &sessions.SessionState{
206− User: "missing.email",
207− IDToken: idToken,
208− AccessToken: accessToken,
209− RefreshToken: refreshToken,
210− },
211− EmailClaim: "email",
212− GroupsClaim: "groups",
213− ProfileJSON: map[string]interface{}{
214− "email": "found@email.com",
215− },
216− ExpectedError: nil,
217− ExpectedSession: &sessions.SessionState{
218− User: "missing.email",
219− Email: "found@email.com",
220− IDToken: idToken,
221− AccessToken: accessToken,
222− RefreshToken: refreshToken,
223− },
224− },
225− "Missing Email with Custom Claim": {
226− ExistingSession: &sessions.SessionState{
227− User: "missing.email",
228− Groups: []string{"already", "populated"},
229− IDToken: idToken,
230− AccessToken: accessToken,
231− RefreshToken: refreshToken,
232− },
233− EmailClaim: "weird",
234− GroupsClaim: "groups",
235− ProfileJSON: map[string]interface{}{
236− "weird": "weird@claim.com",
237− "groups": []string{"new", "thing"},
238− },
239− ExpectedError: nil,
240− ExpectedSession: &sessions.SessionState{
241− User: "missing.email",
242− Email: "weird@claim.com",
243− Groups: []string{"already", "populated"},
244− IDToken: idToken,
245− AccessToken: accessToken,
246− RefreshToken: refreshToken,
247− },
248− },
249− "Missing Email not in Profile URL": {
250− ExistingSession: &sessions.SessionState{
251− User: "missing.email",
252− Groups: []string{"already", "populated"},
253− IDToken: idToken,
254− AccessToken: accessToken,
255− RefreshToken: refreshToken,
256− },
257− EmailClaim: "email",
258− GroupsClaim: "groups",
259− ProfileJSON: map[string]interface{}{
260− "groups": []string{"new", "thing"},
261− },
262− ExpectedError: errors.New("neither the id_token nor the profileURL set an email"),
263− ExpectedSession: &sessions.SessionState{
264− User: "missing.email",
265− Groups: []string{"already", "populated"},
266− IDToken: idToken,
267− AccessToken: accessToken,
268− RefreshToken: refreshToken,
269− },
270− },
271− "Missing Groups": {
272− ExistingSession: &sessions.SessionState{
273− User: "already",
274− Email: "already@populated.com",
275− Groups: nil,
276− IDToken: idToken,
277− AccessToken: accessToken,
278− RefreshToken: refreshToken,
279− },
280− EmailClaim: "email",
281− GroupsClaim: "groups",
282− ProfileJSON: map[string]interface{}{
283− "email": "new@thing.com",
284− "groups": []string{"new", "thing"},
285− },
286− ExpectedError: nil,
287− ExpectedSession: &sessions.SessionState{
288− User: "already",
289− Email: "already@populated.com",
290− Groups: []string{"new", "thing"},
291− IDToken: idToken,
292− AccessToken: accessToken,
293− RefreshToken: refreshToken,
294− },
295− },
296− "Missing Groups with Complex Groups in Profile URL": {
297− ExistingSession: &sessions.SessionState{
298− User: "already",
299− Email: "already@populated.com",
300− Groups: nil,
301− IDToken: idToken,
302− AccessToken: accessToken,
303− RefreshToken: refreshToken,
304− },
305− EmailClaim: "email",
306− GroupsClaim: "groups",
307− ProfileJSON: map[string]interface{}{
308− "email": "new@thing.com",
309− "groups": []map[string]interface{}{
310− {
311− "groupId": "Admin Group Id",
312− "roles": []string{"Admin"},
313− },
314− },
315− },
316− ExpectedError: nil,
317− ExpectedSession: &sessions.SessionState{
318− User: "already",
319− Email: "already@populated.com",
320− Groups: []string{"{\"groupId\":\"Admin Group Id\",\"roles\":[\"Admin\"]}"},
321− IDToken: idToken,
322− AccessToken: accessToken,
323− RefreshToken: refreshToken,
324− },
325− },
326− "Missing Groups with Singleton Complex Group in Profile URL": {
327− ExistingSession: &sessions.SessionState{
328− User: "already",
329− Email: "already@populated.com",
330− Groups: nil,
331− IDToken: idToken,
332− AccessToken: accessToken,
333− RefreshToken: refreshToken,
334− },
335− EmailClaim: "email",
336− GroupsClaim: "groups",
337− ProfileJSON: map[string]interface{}{
338− "email": "new@thing.com",
339− "groups": map[string]interface{}{
340− "groupId": "Admin Group Id",
341− "roles": []string{"Admin"},
342− },
343− },
344− ExpectedError: nil,
345− ExpectedSession: &sessions.SessionState{
346− User: "already",
347− Email: "already@populated.com",
348− Groups: []string{"{\"groupId\":\"Admin Group Id\",\"roles\":[\"Admin\"]}"},
349− IDToken: idToken,
350− AccessToken: accessToken,
351− RefreshToken: refreshToken,
352− },
353− },
354− "Empty Groups Claims": {
355− ExistingSession: &sessions.SessionState{
356− User: "already",
357− Email: "already@populated.com",
358− Groups: []string{},
359− IDToken: idToken,
360− AccessToken: accessToken,
361− RefreshToken: refreshToken,
362− },
363− EmailClaim: "email",
364− GroupsClaim: "groups",
365− ProfileJSON: map[string]interface{}{
366− "email": "new@thing.com",
367− "groups": []string{"new", "thing"},
368− },
369− ExpectedError: nil,
370− ExpectedSession: &sessions.SessionState{
371− User: "already",
372− Email: "already@populated.com",
373− Groups: []string{},
374− IDToken: idToken,
375− AccessToken: accessToken,
376− RefreshToken: refreshToken,
377− },
378− },
379− "Missing Groups with Custom Claim": {
380− ExistingSession: &sessions.SessionState{
381− User: "already",
382− Email: "already@populated.com",
383− Groups: nil,
384− IDToken: idToken,
385− AccessToken: accessToken,
386− RefreshToken: refreshToken,
387− },
388− EmailClaim: "email",
389− GroupsClaim: "roles",
390− ProfileJSON: map[string]interface{}{
391− "email": "new@thing.com",
392− "roles": []string{"new", "thing", "roles"},
393− },
394− ExpectedError: nil,
395− ExpectedSession: &sessions.SessionState{
396− User: "already",
397− Email: "already@populated.com",
398− Groups: []string{"new", "thing", "roles"},
399− IDToken: idToken,
400− AccessToken: accessToken,
401− RefreshToken: refreshToken,
402− },
403− },
404− "Missing Groups String Profile URL Response": {
405− ExistingSession: &sessions.SessionState{
406− User: "already",
407− Email: "already@populated.com",
408− Groups: nil,
409− IDToken: idToken,
410− AccessToken: accessToken,
411− RefreshToken: refreshToken,
412− },
413− EmailClaim: "email",
414− GroupsClaim: "groups",
415− ProfileJSON: map[string]interface{}{
416− "email": "new@thing.com",
417− "groups": "singleton",
418− },
419− ExpectedError: nil,
420− ExpectedSession: &sessions.SessionState{
421− User: "already",
422− Email: "already@populated.com",
423− Groups: []string{"singleton"},
424− IDToken: idToken,
425− AccessToken: accessToken,
426− RefreshToken: refreshToken,
427− },
428− },
429− "Missing Groups in both Claims and Profile URL": {
430− ExistingSession: &sessions.SessionState{
431− User: "already",
432− Email: "already@populated.com",
433− IDToken: idToken,
434− AccessToken: accessToken,
435− RefreshToken: refreshToken,
436− },
437− EmailClaim: "email",
438− GroupsClaim: "groups",
439− ProfileJSON: map[string]interface{}{
440− "email": "new@thing.com",
441− },
442− ExpectedError: nil,
443− ExpectedSession: &sessions.SessionState{
444− User: "already",
445− Email: "already@populated.com",
446− IDToken: idToken,
447− AccessToken: accessToken,
448− RefreshToken: refreshToken,
449− },
450− },
451− }
452− for testName, tc := range testCases {
453− t.Run(testName, func(t *testing.T) {
454− jsonResp, err := json.Marshal(tc.ProfileJSON)
455− assert.NoError(t, err)
456−
457− server, provider := newTestOIDCSetup(jsonResp)
458− provider.ProfileURL, err = url.Parse(server.URL)
459− assert.NoError(t, err)
460−
461− provider.EmailClaim = tc.EmailClaim
462− provider.GroupsClaim = tc.GroupsClaim
463− defer server.Close()
464−
465− err = provider.EnrichSession(context.Background(), tc.ExistingSession)
466− assert.Equal(t, tc.ExpectedError, err)
467− assert.Equal(t, *tc.ExpectedSession, *tc.ExistingSession)
468− })
469− }
470−}
471−
472145func TestOIDCProviderRefreshSessionIfNeededWithoutIdToken(t *testing.T) {
473146
474147 idToken, _ := newSignedTestIDToken(defaultIDToken)
@@ -565,11 +238,15 @@ func TestOIDCProviderCreateSessionFromToken(t *testing.T) {
565238 ExpectedGroups: []string{"test:c", "test:d"},
566239 },
567240 "Complex Groups Claim": {
568− IDToken: complexGroupsIDToken,
569− GroupsClaim: "groups",
570− ExpectedUser: "123456789",
571− ExpectedEmail: "complex@claims.com",
572− ExpectedGroups: []string{"{\"groupId\":\"Admin Group Id\",\"roles\":[\"Admin\"]}"},
241+ IDToken: complexGroupsIDToken,
242+ GroupsClaim: "groups",
243+ ExpectedUser: "123456789",
244+ ExpectedEmail: "complex@claims.com",
245+ ExpectedGroups: []string{
246+ "{\"groupId\":\"Admin Group Id\",\"roles\":[\"Admin\"]}",
247+ "12345",
248+ "Just::A::String",
249+ },
573250 },
574251 }
575252 for testName, tc := range testCases {
providers/provider_data.go+55−87
@@ -5,20 +5,23 @@ import (
55 "errors"
66 "fmt"
77 "io/ioutil"
8+ "net/http"
89 "net/url"
9− "reflect"
1010 "strings"
1111
1212 "github.com/coreos/go-oidc/v3/oidc"
1313 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
1414 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger"
1515 internaloidc "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/oidc"
16+ "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/providers/util"
1617 "golang.org/x/oauth2"
1718)
1819
1920const (
2021 OIDCEmailClaim = "email"
2122 OIDCGroupsClaim = "groups"
23+ // This is not exported as it's not currently user configurable
24+ oidcUserClaim = "sub"
2225)
2326
2427var OIDCAudienceClaims = []string{"aud"}
@@ -52,6 +55,8 @@ type ProviderData struct {
5255 // Universal Group authorization data structure
5356 // any provider can set to consume
5457 AllowedGroups map[string]struct{}
58+
59+ getAuthorizationHeaderFunc func(string) http.Header
5560}
5661
5762// Data returns the ProviderData
@@ -99,6 +104,10 @@ func (p *ProviderData) setProviderDefaults(defaults providerDefaults) {
99104 if p.Scope == "" {
100105 p.Scope = defaults.scope
101106 }
107+
108+ if p.UserClaim == "" {
109+ p.UserClaim = oidcUserClaim
110+ }
102111}
103112
104113// defaultURL will set return a default value if the given value is not set.
@@ -120,17 +129,6 @@ func defaultURL(u *url.URL, d *url.URL) *url.URL {
120129// OIDC compliant
121130// ****************************************************************************
122131
123−// OIDCClaims is a struct to unmarshal the OIDC claims from an ID Token payload
124−type OIDCClaims struct {
125− Subject string `json:"sub"`
126− Email string `json:"-"`
127− Groups []string `json:"-"`
128− Verified *bool `json:"email_verified"`
129− Nonce string `json:"nonce"`
130−
131− raw map[string]interface{}
132−}
133−
134132func (p *ProviderData) verifyIDToken(ctx context.Context, token *oauth2.Token) (*oidc.IDToken, error) {
135133 rawIDToken := getIDToken(token)
136134 if strings.TrimSpace(rawIDToken) == "" {
@@ -144,110 +142,80 @@ func (p *ProviderData) verifyIDToken(ctx context.Context, token *oauth2.Token) (
144142
145143// buildSessionFromClaims uses IDToken claims to populate a fresh SessionState
146144// with non-Token related fields.
147−func (p *ProviderData) buildSessionFromClaims(idToken *oidc.IDToken) (*sessions.SessionState, error) {
145+func (p *ProviderData) buildSessionFromClaims(rawIDToken, accessToken string) (*sessions.SessionState, error) {
148146 ss := &sessions.SessionState{}
149147
150− if idToken == nil {
148+ if rawIDToken == "" {
151149 return ss, nil
152150 }
153151
154− claims, err := p.getClaims(idToken)
152+ extractor, err := p.getClaimExtractor(rawIDToken, accessToken)
155153 if err != nil {
156− return nil, fmt.Errorf("couldn't extract claims from id_token (%v)", err)
157− }
158−
159− ss.User = claims.Subject
160− ss.Email = claims.Email
161− ss.Groups = claims.Groups
162−
163− // Allow specialized providers that embed OIDCProvider to control the User
164− // claim. Not exposed as a configuration flag to generic OIDC provider
165− // users (yet).
166− if p.UserClaim != "" {
167− user, ok := claims.raw[p.UserClaim].(string)
168− if !ok {
169− return nil, fmt.Errorf("unable to extract custom UserClaim (%s)", p.UserClaim)
154+ return nil, err
155+ }
156+
157+ // Use a slice of a struct (vs map) here in case the same claim is used twice
158+ for _, c := range []struct {
159+ claim string
160+ dst interface{}
161+ }{
162+ {p.UserClaim, &ss.User},
163+ {p.EmailClaim, &ss.Email},
164+ {p.GroupsClaim, &ss.Groups},
165+ // TODO (@NickMeves) Deprecate for dynamic claim to session mapping
166+ {"preferred_username", &ss.PreferredUsername},
167+ } {
168+ if _, err := extractor.GetClaimInto(c.claim, c.dst); err != nil {
169+ return nil, err
170170 }
171− ss.User = user
172− }
173−
174− // TODO (@NickMeves) Deprecate for dynamic claim to session mapping
175− if pref, ok := claims.raw["preferred_username"].(string); ok {
176− ss.PreferredUsername = pref
177171 }
178172
179173 // `email_verified` must be present and explicitly set to `false` to be
180174 // considered unverified.
181175 verifyEmail := (p.EmailClaim == OIDCEmailClaim) && !p.AllowUnverifiedEmail
182− if verifyEmail && claims.Verified != nil && !*claims.Verified {
183− return nil, fmt.Errorf("email in id_token (%s) isn't verified", claims.Email)
176+
177+ var verified bool
178+ exists, err := extractor.GetClaimInto("email_verified", &verified)
179+ if err != nil {
180+ return nil, err
181+ }
182+
183+ if verifyEmail && exists && !verified {
184+ return nil, fmt.Errorf("email in id_token (%s) isn't verified", ss.Email)
184185 }
185186
186187 return ss, nil
187188}
188189
189−// getClaims extracts IDToken claims into an OIDCClaims
190−func (p *ProviderData) getClaims(idToken *oidc.IDToken) (*OIDCClaims, error) {
191− claims := &OIDCClaims{}
192−
193− // Extract default claims.
194− if err := idToken.Claims(&claims); err != nil {
195− return nil, fmt.Errorf("failed to parse default id_token claims: %v", err)
196− }
197− // Extract custom claims.
198− if err := idToken.Claims(&claims.raw); err != nil {
199− return nil, fmt.Errorf("failed to parse all id_token claims: %v", err)
200− }
201−
202− email := claims.raw[p.EmailClaim]
203− if email != nil {
204− claims.Email = fmt.Sprint(email)
190+func (p *ProviderData) getClaimExtractor(rawIDToken, accessToken string) (util.ClaimExtractor, error) {
191+ extractor, err := util.NewClaimExtractor(context.TODO(), rawIDToken, p.ProfileURL, p.getAuthorizationHeader(accessToken))
192+ if err != nil {
193+ return nil, fmt.Errorf("could not initialise claim extractor: %v", err)
205194 }
206− claims.Groups = p.extractGroups(claims.raw)
207195
208− return claims, nil
196+ return extractor, nil
209197}
210198
211199// checkNonce compares the session's nonce with the IDToken's nonce claim
212−func (p *ProviderData) checkNonce(s *sessions.SessionState, idToken *oidc.IDToken) error {
213− claims, err := p.getClaims(idToken)
200+func (p *ProviderData) checkNonce(s *sessions.SessionState) error {
201+ extractor, err := p.getClaimExtractor(s.IDToken, "")
214202 if err != nil {
215203 return fmt.Errorf("id_token claims extraction failed: %v", err)
216204 }
217− if !s.CheckNonce(claims.Nonce) {
205+ var nonce string
206+ if _, err := extractor.GetClaimInto("nonce", &nonce); err != nil {
207+ return fmt.Errorf("could not extract nonce from ID Token: %v", err)
208+ }
209+
210+ if !s.CheckNonce(nonce) {
218211 return errors.New("id_token nonce claim does not match the session nonce")
219212 }
220213 return nil
221214}
222215
223−// extractGroups extracts groups from a claim to a list in a type safe manner.
224−// If the claim isn't present, `nil` is returned. If the groups claim is
225−// present but empty, `[]string{}` is returned.
226−func (p *ProviderData) extractGroups(claims map[string]interface{}) []string {
227− rawClaim, ok := claims[p.GroupsClaim]
228− if !ok {
229− return nil
230− }
231−
232− // Handle traditional list-based groups as well as non-standard singleton
233− // based groups. Both variants support complex objects if needed.
234− var claimGroups []interface{}
235− switch raw := rawClaim.(type) {
236− case []interface{}:
237− claimGroups = raw
238− case interface{}:
239− claimGroups = []interface{}{raw}
240− }
241−
242− groups := []string{}
243− for _, rawGroup := range claimGroups {
244− formattedGroup, err := formatGroup(rawGroup)
245− if err != nil {
246− logger.Errorf("Warning: unable to format group of type %s with error %s",
247− reflect.TypeOf(rawGroup), err)
248− continue
249− }
250− groups = append(groups, formattedGroup)
216+func (p *ProviderData) getAuthorizationHeader(accessToken string) http.Header {
217+ if p.getAuthorizationHeaderFunc != nil && accessToken != "" {
218+ return p.getAuthorizationHeaderFunc(accessToken)
251219 }
252− return groups
220+ return nil
253221}
providers/provider_data_test.go+73−112
@@ -60,16 +60,30 @@ var (
6060 StandardClaims: standardClaims,
6161 }
6262
63+ numericGroupsIDToken = idTokenClaims{
64+ Name: "Jane Dobbs",
65+ Email: "janed@me.com",
66+ Phone: "+4798765432",
67+ Picture: "http://mugbook.com/janed/me.jpg",
68+ Groups: []interface{}{1, 2, 3},
69+ Roles: []string{"test:c", "test:d"},
70+ Verified: &verified,
71+ Nonce: encryption.HashNonce([]byte(oidcNonce)),
72+ StandardClaims: standardClaims,
73+ }
74+
6375 complexGroupsIDToken = idTokenClaims{
6476 Name: "Complex Claim",
6577 Email: "complex@claims.com",
6678 Phone: "+5439871234",
6779 Picture: "http://mugbook.com/complex/claims.jpg",
68− Groups: []map[string]interface{}{
69− {
80+ Groups: []interface{}{
81+ map[string]interface{}{
7082 "groupId": "Admin Group Id",
7183 "roles": []string{"Admin"},
7284 },
85+ 12345,
86+ "Just::A::String",
7387 },
7488 Roles: []string{"test:simple", "test:roles"},
7589 Verified: &verified,
@@ -228,6 +242,7 @@ func TestProviderData_buildSessionFromClaims(t *testing.T) {
228242 AllowUnverified: false,
229243 EmailClaim: "email",
230244 GroupsClaim: "groups",
245+ UserClaim: "sub",
231246 ExpectedSession: &sessions.SessionState{
232247 User: "123456789",
233248 Email: "janed@me.com",
@@ -247,6 +262,7 @@ func TestProviderData_buildSessionFromClaims(t *testing.T) {
247262 AllowUnverified: true,
248263 EmailClaim: "email",
249264 GroupsClaim: "groups",
265+ UserClaim: "sub",
250266 ExpectedSession: &sessions.SessionState{
251267 User: "123456789",
252268 Email: "unverified@email.com",
@@ -259,10 +275,15 @@ func TestProviderData_buildSessionFromClaims(t *testing.T) {
259275 AllowUnverified: true,
260276 EmailClaim: "email",
261277 GroupsClaim: "groups",
278+ UserClaim: "sub",
262279 ExpectedSession: &sessions.SessionState{
263− User: "123456789",
264− Email: "complex@claims.com",
265− Groups: []string{"{\"groupId\":\"Admin Group Id\",\"roles\":[\"Admin\"]}"},
280+ User: "123456789",
281+ Email: "complex@claims.com",
282+ Groups: []string{
283+ "{\"groupId\":\"Admin Group Id\",\"roles\":[\"Admin\"]}",
284+ "12345",
285+ "Just::A::String",
286+ },
266287 PreferredUsername: "Complex Claim",
267288 },
268289 },
@@ -279,19 +300,25 @@ func TestProviderData_buildSessionFromClaims(t *testing.T) {
279300 PreferredUsername: "Jane Dobbs",
280301 },
281302 },
282− "User Claim Invalid": {
303+ "User Claim switched to non string": {
283304 IDToken: defaultIDToken,
284305 AllowUnverified: true,
285− UserClaim: "groups",
306+ UserClaim: "roles",
286307 EmailClaim: "email",
287308 GroupsClaim: "groups",
288− ExpectedError: errors.New("unable to extract custom UserClaim (groups)"),
309+ ExpectedSession: &sessions.SessionState{
310+ User: "[\"test:c\",\"test:d\"]",
311+ Email: "janed@me.com",
312+ Groups: []string{"test:a", "test:b"},
313+ PreferredUsername: "Jane Dobbs",
314+ },
289315 },
290316 "Email Claim Switched": {
291317 IDToken: unverifiedIDToken,
292318 AllowUnverified: true,
293319 EmailClaim: "phone_number",
294320 GroupsClaim: "groups",
321+ UserClaim: "sub",
295322 ExpectedSession: &sessions.SessionState{
296323 User: "123456789",
297324 Email: "+4025205729",
@@ -304,9 +331,10 @@ func TestProviderData_buildSessionFromClaims(t *testing.T) {
304331 AllowUnverified: true,
305332 EmailClaim: "roles",
306333 GroupsClaim: "groups",
334+ UserClaim: "sub",
307335 ExpectedSession: &sessions.SessionState{
308336 User: "123456789",
309− Email: "[test:c test:d]",
337+ Email: "[\"test:c\",\"test:d\"]",
310338 Groups: []string{"test:a", "test:b"},
311339 PreferredUsername: "Mystery Man",
312340 },
@@ -316,6 +344,7 @@ func TestProviderData_buildSessionFromClaims(t *testing.T) {
316344 AllowUnverified: true,
317345 EmailClaim: "aksjdfhjksadh",
318346 GroupsClaim: "groups",
347+ UserClaim: "sub",
319348 ExpectedSession: &sessions.SessionState{
320349 User: "123456789",
321350 Email: "",
@@ -328,6 +357,7 @@ func TestProviderData_buildSessionFromClaims(t *testing.T) {
328357 AllowUnverified: false,
329358 EmailClaim: "email",
330359 GroupsClaim: "roles",
360+ UserClaim: "sub",
331361 ExpectedSession: &sessions.SessionState{
332362 User: "123456789",
333363 Email: "janed@me.com",
@@ -340,6 +370,7 @@ func TestProviderData_buildSessionFromClaims(t *testing.T) {
340370 AllowUnverified: false,
341371 EmailClaim: "email",
342372 GroupsClaim: "alskdjfsalkdjf",
373+ UserClaim: "sub",
343374 ExpectedSession: &sessions.SessionState{
344375 User: "123456789",
345376 Email: "janed@me.com",
@@ -347,6 +378,32 @@ func TestProviderData_buildSessionFromClaims(t *testing.T) {
347378 PreferredUsername: "Jane Dobbs",
348379 },
349380 },
381+ "Groups Claim Numeric values": {
382+ IDToken: numericGroupsIDToken,
383+ AllowUnverified: false,
384+ EmailClaim: "email",
385+ GroupsClaim: "groups",
386+ UserClaim: "sub",
387+ ExpectedSession: &sessions.SessionState{
388+ User: "123456789",
389+ Email: "janed@me.com",
390+ Groups: []string{"1", "2", "3"},
391+ PreferredUsername: "Jane Dobbs",
392+ },
393+ },
394+ "Groups Claim string values": {
395+ IDToken: defaultIDToken,
396+ AllowUnverified: false,
397+ EmailClaim: "email",
398+ GroupsClaim: "email",
399+ UserClaim: "sub",
400+ ExpectedSession: &sessions.SessionState{
401+ User: "123456789",
402+ Email: "janed@me.com",
403+ Groups: []string{"janed@me.com"},
404+ PreferredUsername: "Jane Dobbs",
405+ },
406+ },
350407 }
351408 for testName, tc := range testCases {
352409 t.Run(testName, func(t *testing.T) {
@@ -371,10 +428,7 @@ func TestProviderData_buildSessionFromClaims(t *testing.T) {
371428 rawIDToken, err := newSignedTestIDToken(tc.IDToken)
372429 g.Expect(err).ToNot(HaveOccurred())
373430
374− idToken, err := provider.Verifier.Verify(context.Background(), rawIDToken)
375− g.Expect(err).ToNot(HaveOccurred())
376−
377− ss, err := provider.buildSessionFromClaims(idToken)
431+ ss, err := provider.buildSessionFromClaims(rawIDToken, "")
378432 if err != nil {
379433 g.Expect(err).To(Equal(tc.ExpectedError))
380434 }
@@ -418,102 +472,11 @@ func TestProviderData_checkNonce(t *testing.T) {
418472 t.Run(testName, func(t *testing.T) {
419473 g := NewWithT(t)
420474
421− verificationOptions := &internaloidc.IDTokenVerificationOptions{
422− AudienceClaims: []string{"aud"},
423− ClientID: oidcClientID,
424− }
425− provider := &ProviderData{
426− Verifier: internaloidc.NewVerifier(oidc.NewVerifier(
427− oidcIssuer,
428− mockJWKS{},
429− &oidc.Config{ClientID: oidcClientID},
430− ), verificationOptions),
431− }
432−
475+ // Ensure that the ID token in the session is valid (signed and contains a nonce)
476+ // as the nonce claim is extracted to compare with the session nonce
433477 rawIDToken, err := newSignedTestIDToken(tc.IDToken)
434478 g.Expect(err).ToNot(HaveOccurred())
435−
436− idToken, err := provider.Verifier.Verify(context.Background(), rawIDToken)
437− g.Expect(err).ToNot(HaveOccurred())
438−
439− err = provider.checkNonce(tc.Session, idToken)
440− if err != nil {
441− g.Expect(err).To(Equal(tc.ExpectedError))
442− } else {
443− g.Expect(err).ToNot(HaveOccurred())
444− }
445− })
446− }
447−}
448−
449−func TestProviderData_extractGroups(t *testing.T) {
450− testCases := map[string]struct {
451− Claims map[string]interface{}
452− GroupsClaim string
453− ExpectedGroups []string
454− }{
455− "Standard String Groups": {
456− Claims: map[string]interface{}{
457− "email": "this@does.not.matter.com",
458− "groups": []interface{}{"three", "string", "groups"},
459− },
460− GroupsClaim: "groups",
461− ExpectedGroups: []string{"three", "string", "groups"},
462− },
463− "Different Claim Name": {
464− Claims: map[string]interface{}{
465− "email": "this@does.not.matter.com",
466− "roles": []interface{}{"three", "string", "roles"},
467− },
468− GroupsClaim: "roles",
469− ExpectedGroups: []string{"three", "string", "roles"},
470− },
471− "Numeric Groups": {
472− Claims: map[string]interface{}{
473− "email": "this@does.not.matter.com",
474− "groups": []interface{}{1, 2, 3},
475− },
476− GroupsClaim: "groups",
477− ExpectedGroups: []string{"1", "2", "3"},
478− },
479− "Complex Groups": {
480− Claims: map[string]interface{}{
481− "email": "this@does.not.matter.com",
482− "groups": []interface{}{
483− map[string]interface{}{
484− "groupId": "Admin Group Id",
485− "roles": []string{"Admin"},
486− },
487− 12345,
488− "Just::A::String",
489− },
490− },
491− GroupsClaim: "groups",
492− ExpectedGroups: []string{
493− "{\"groupId\":\"Admin Group Id\",\"roles\":[\"Admin\"]}",
494− "12345",
495− "Just::A::String",
496− },
497− },
498− "Missing Groups Claim Returns Nil": {
499− Claims: map[string]interface{}{
500− "email": "this@does.not.matter.com",
501− },
502− GroupsClaim: "groups",
503− ExpectedGroups: nil,
504− },
505− "Non List Groups": {
506− Claims: map[string]interface{}{
507− "email": "this@does.not.matter.com",
508− "groups": "singleton",
509− },
510− GroupsClaim: "groups",
511− ExpectedGroups: []string{"singleton"},
512− },
513− }
514− for testName, tc := range testCases {
515− t.Run(testName, func(t *testing.T) {
516− g := NewWithT(t)
479+ tc.Session.IDToken = rawIDToken
517480
518481 verificationOptions := &internaloidc.IDTokenVerificationOptions{
519482 AudienceClaims: []string{"aud"},
@@ -526,13 +489,11 @@ func TestProviderData_extractGroups(t *testing.T) {
526489 &oidc.Config{ClientID: oidcClientID},
527490 ), verificationOptions),
528491 }
529− provider.GroupsClaim = tc.GroupsClaim
530492
531− groups := provider.extractGroups(tc.Claims)
532− if tc.ExpectedGroups != nil {
533− g.Expect(groups).To(Equal(tc.ExpectedGroups))
493+ if err := provider.checkNonce(tc.Session); err != nil {
494+ g.Expect(err).To(Equal(tc.ExpectedError))
534495 } else {
535− g.Expect(groups).To(BeNil())
496+ g.Expect(err).ToNot(HaveOccurred())
536497 }
537498 })
538499 }
providers/util.go−16
@@ -6,7 +6,6 @@ import (
66 "net/http"
77 "net/url"
88
9− "github.com/bitly/go-simplejson"
109 "golang.org/x/oauth2"
1110)
1211
@@ -83,18 +82,3 @@ func formatGroup(rawGroup interface{}) (string, error) {
8382 }
8483 return string(jsonGroup), nil
8584}
86−
87−// coerceArray extracts a field from simplejson.Json that might be a
88−// singleton or a list and coerces it into a list.
89−func coerceArray(sj *simplejson.Json, key string) []interface{} {
90− array, err := sj.Get(key).Array()
91− if err == nil {
92− return array
93− }
94−
95− single := sj.Get(key).Interface()
96− if single == nil {
97− return nil
98− }
99− return []interface{}{single}
100−}