@@ -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+}