@@ -0,0 +1,530 @@
13+ . "github.com/onsi/ginkgo"
14+ . "github.com/onsi/ginkgo/extensions/table"
15+ . "github.com/onsi/gomega"
20+ profilePath = "/userinfo"
21+ authorizedAccessToken = "valid_access_token"
22+ basicIDTokenPayload = `{
23+ "user": "idTokenUser",
24+ "email": "idTokenEmail",
30+ basicProfileURLPayload = `{
31+ "user": "profileUser",
32+ "email": "profileEmail",
38+ nestedClaimPayload = `{
41+ "username": "nestedUser"
45+ complexGroupsPayload = `{
53+ "roles": ["user", "employee"]
59+var _ = Describe("Claim Extractor Suite", func() {
60+ Context("Claim Extractor", func() {
61+ type newClaimExtractorTableInput struct {
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))
72+ Expect(err).ToNot(HaveOccurred())
75+ Entry("with a valid JWT", newClaimExtractorTableInput{
76+ idToken: createJWTFromPayload(basicIDTokenPayload),
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')"),
83+ Entry("with an IDToken with the wrong number of parts", newClaimExtractorTableInput{
85+ expectedError: errors.New("failed to parse ID Token: oidc: malformed jwt, expected 3 parts got 1"),
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"),
93+ type getClaimTableInput struct {
94+ testClaimExtractorOpts
96+ expectedValue interface{}
101+ DescribeTable("GetClaim",
102+ func(in getClaimTableInput) {
103+ claimExtractor, serverClose, err := newTestClaimExtractor(in.testClaimExtractorOpts)
104+ Expect(err).ToNot(HaveOccurred())
105+ if serverClose != nil {
109+ value, exists, err := claimExtractor.GetClaim(in.claim)
110+ if in.expectedError != nil {
111+ Expect(err).To(MatchError(in.expectedError))
115+ Expect(err).ToNot(HaveOccurred())
116+ if in.expectedValue != nil {
117+ Expect(value).To(Equal(in.expectedValue))
119+ Expect(value).To(BeNil())
122+ Expect(exists).To(Equal(in.expectExists))
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,
133+ expectedValue: "idTokenUser",
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,
145+ expectedValue: []interface{}{"idTokenGroup1", "idTokenGroup2"},
148+ Entry("when the requested claim is the empty string", getClaimTableInput{
149+ testClaimExtractorOpts: testClaimExtractorOpts{
150+ idTokenPayload: basicIDTokenPayload,
153+ expectExists: false,
157+ Entry("when the requested claim is the not found (with no profile URL)", getClaimTableInput{
158+ testClaimExtractorOpts: testClaimExtractorOpts{
159+ idTokenPayload: basicIDTokenPayload,
160+ profileRequestHeaders: newAuthorizedHeader(),
163+ expectExists: false,
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,
175+ expectExists: false,
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,
187+ expectExists: false,
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,
199+ expectExists: false,
201+ expectedError: errors.New("failed to fetch claims from profile URL: error making request to profile URL: unexpected status \"403\": Unauthorized"),
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,
212+ expectedValue: "profileUser",
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,
222+ claim: "auth.user.username",
224+ expectedValue: "nestedUser",
230+ It("GetClaim should only call the profile URL once", func() {
232+ countRequestsHandler := func(rw http.ResponseWriter, _ *http.Request) {
233+ atomic.AddInt32(&counter, 1)
234+ rw.Write([]byte(basicProfileURLPayload))
237+ claimExtractor, serverClose, err := newTestClaimExtractor(testClaimExtractorOpts{
238+ idTokenPayload: "{}",
239+ setProfileURL: true,
240+ profileRequestHeaders: newAuthorizedHeader(),
241+ profileRequestHandler: countRequestsHandler,
243+ Expect(err).ToNot(HaveOccurred())
244+ if serverClose != nil {
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))
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))
262+ type getClaimIntoTableInput struct {
263+ testClaimExtractorOpts
266+ expectedValue interface{}
271+ DescribeTable("GetClaimInto",
272+ func(in getClaimIntoTableInput) {
273+ claimExtractor, serverClose, err := newTestClaimExtractor(in.testClaimExtractorOpts)
274+ Expect(err).ToNot(HaveOccurred())
275+ if serverClose != nil {
279+ exists, err := claimExtractor.GetClaimInto(in.claim, in.into)
280+ if in.expectedError != nil {
281+ Expect(err).To(MatchError(in.expectedError))
285+ Expect(err).ToNot(HaveOccurred())
286+ if in.expectedValue != nil {
287+ Expect(in.into).To(Equal(in.expectedValue))
289+ Expect(in.into).To(BeEmpty())
292+ Expect(exists).To(Equal(in.expectExists))
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,
302+ into: stringPointer(""),
304+ expectedValue: stringPointer("idTokenUser"),
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,
315+ into: stringSlicePointer([]string{}),
317+ expectedValue: stringSlicePointer([]string{"idTokenUser"}),
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,
328+ into: stringSlicePointer([]string{}),
330+ expectedValue: stringSlicePointer([]string{"idTokenGroup1", "idTokenGroup2"}),
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,
341+ into: stringPointer(""),
343+ expectedValue: stringPointer("[\"idTokenGroup1\",\"idTokenGroup2\"]"),
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,
355+ expectExists: false,
357+ expectedError: errors.New("could no coerce claim: unknown type for destination: string"),
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,
367+ into: stringSlicePointer([]string{}),
369+ expectedValue: stringSlicePointer([]string{
370+ "{\"groupID\":\"group1\",\"roles\":[\"admin\"]}",
371+ "{\"groupID\":\"group2\",\"roles\":[\"user\",\"employee\"]}",
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,
383+ into: stringPointer(""),
384+ expectExists: false,
385+ expectedValue: stringPointer(""),
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,
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"),
403+ type coerceClaimTableInput struct {
406+ expectedDst interface{}
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))
418+ Expect(err).ToNot(HaveOccurred())
419+ Expect(in.dst).To(Equal(in.expectedDst))
421+ Entry("coerces a string to a string", coerceClaimTableInput{
422+ value: "some_string",
423+ dst: stringPointer(""),
424+ expectedDst: stringPointer("some_string"),
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"}),
431+ Entry("coerces a bool to a bool", coerceClaimTableInput{
433+ dst: boolPointer(false),
434+ expectedDst: boolPointer(true),
436+ Entry("coerces a string to a bool", coerceClaimTableInput{
438+ dst: boolPointer(false),
439+ expectedDst: boolPointer(true),
441+ Entry("coerces a map to a string", coerceClaimTableInput{
442+ value: map[string]interface{}{
443+ "foo": []interface{}{"bar", "baz"},
445+ dst: stringPointer(""),
446+ expectedDst: stringPointer("{\"foo\":[\"bar\",\"baz\"]}"),
451+// ******************************************
452+// Helpers for setting up the claim extractor
453+// ******************************************
455+type testClaimExtractorOpts struct {
456+ idTokenPayload string
458+ profileRequestHeaders http.Header
459+ profileRequestHandler http.HandlerFunc
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
470+ profileURL, err = url.Parse("http://" + server.Listener.Addr().String() + profilePath)
471+ Expect(err).ToNot(HaveOccurred())
474+ rawIDToken := createJWTFromPayload(in.idTokenPayload)
476+ claimExtractor, err := NewClaimExtractor(context.Background(), rawIDToken, profileURL, in.profileRequestHeaders)
477+ return claimExtractor, closeServer, err
480+func createJWTFromPayload(payload string) string {
481+ header := base64.RawURLEncoding.EncodeToString([]byte(emptyJSON))
482+ payloadJSON := base64.RawURLEncoding.EncodeToString([]byte(payload))
484+ return fmt.Sprintf("%s.%s.%s", header, payloadJSON, header)
487+func newAuthorizedHeader() http.Header {
488+ headers := make(http.Header)
489+ headers.Add("Authorization", "Bearer "+authorizedAccessToken)
493+func hasAuthorizedHeader(headers http.Header) bool {
494+ return headers.Get("Authorization") == "Bearer "+authorizedAccessToken
497+// ***********************
498+// Typed Pointer Functions
499+// ***********************
501+func stringPointer(in string) *string {
505+func stringSlicePointer(in []string) *[]string {
509+func boolPointer(in bool) *bool {
513+// ******************************
514+// Different profile URL handlers
515+// ******************************
517+func shouldNotBeRequestedProfileHandler(_ http.ResponseWriter, _ *http.Request) {
518+ defer GinkgoRecover()
519+ Expect(true).To(BeFalse(), "Unexpected request to profile URL")
522+func requiresAuthProfileHandler(rw http.ResponseWriter, req *http.Request) {
523+ if !hasAuthorizedHeader(req.Header) {
525+ rw.Write([]byte("Unauthorized"))
529+ rw.Write([]byte(basicProfileURLPayload))