git.iko.soy/typeable/oauth2-proxy
on master help sign in
Add claim extractor provider util
commit537e596904fe6e1f01e86d97c1997129925a1f00 authorJoel Speed <joel.speed@hotmail.co.uk> dateSat 26 Jun 2021, 11:48 (5 years ago) parent44dc3ca
4 files changed+758
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
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+}