mirror of
https://github.com/oauth2-proxy/oauth2-proxy.git
synced 2024-12-10 11:10:27 +02:00
bd867b5138
* Validate jsonpath in claim extractor Signed-off-by: Joseph Weigl <joseph.weigl@audi.de> * Add test and changelog for claim extractor json path --------- Signed-off-by: Joseph Weigl <joseph.weigl@audi.de> Co-authored-by: Joel Speed <Joel.speed@hotmail.co.uk>
565 lines
18 KiB
Go
565 lines
18 KiB
Go
package util
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"sync/atomic"
|
|
|
|
. "github.com/onsi/ginkgo"
|
|
. "github.com/onsi/ginkgo/extensions/table"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
const (
|
|
emptyJSON = "{}"
|
|
profilePath = "/userinfo"
|
|
authorizedAccessToken = "valid_access_token"
|
|
basicIDTokenPayload = `{
|
|
"user": "idTokenUser",
|
|
"email": "idTokenEmail",
|
|
"groups": [
|
|
"idTokenGroup1",
|
|
"idTokenGroup2"
|
|
],
|
|
"https://groups.test": [
|
|
"fqdnGroup1",
|
|
"fqdnGroup2"
|
|
]
|
|
}`
|
|
basicProfileURLPayload = `{
|
|
"user": "profileUser",
|
|
"email": "profileEmail",
|
|
"groups": [
|
|
"profileGroup1",
|
|
"profileGroup2"
|
|
]
|
|
}`
|
|
nestedClaimPayload = `{
|
|
"auth": {
|
|
"user": {
|
|
"username": "nestedUser"
|
|
}
|
|
}
|
|
}`
|
|
complexGroupsPayload = `{
|
|
"groups": [
|
|
{
|
|
"groupID": "group1",
|
|
"roles": ["admin"]
|
|
},
|
|
{
|
|
"groupID": "group2",
|
|
"roles": ["user", "employee"]
|
|
}
|
|
]
|
|
}`
|
|
)
|
|
|
|
var _ = Describe("Claim Extractor Suite", func() {
|
|
Context("Claim Extractor", func() {
|
|
type newClaimExtractorTableInput struct {
|
|
idToken string
|
|
expectedError error
|
|
}
|
|
|
|
DescribeTable("NewClaimExtractor",
|
|
func(in newClaimExtractorTableInput) {
|
|
_, err := NewClaimExtractor(context.Background(), in.idToken, nil, nil)
|
|
if in.expectedError != nil {
|
|
Expect(err).To(MatchError(in.expectedError))
|
|
} else {
|
|
Expect(err).ToNot(HaveOccurred())
|
|
}
|
|
},
|
|
Entry("with a valid JWT", newClaimExtractorTableInput{
|
|
idToken: createJWTFromPayload(basicIDTokenPayload),
|
|
expectedError: nil,
|
|
}),
|
|
Entry("with a JWT with a non-json payload", newClaimExtractorTableInput{
|
|
idToken: createJWTFromPayload("this is not JSON"),
|
|
expectedError: errors.New("failed to parse ID Token payload: invalid character 'h' in literal true (expecting 'r')"),
|
|
}),
|
|
Entry("with an IDToken with the wrong number of parts", newClaimExtractorTableInput{
|
|
idToken: "eyJeyJ",
|
|
expectedError: errors.New("failed to parse ID Token: oidc: malformed jwt, expected 3 parts got 1"),
|
|
}),
|
|
Entry("with an non-base64 IDToken", newClaimExtractorTableInput{
|
|
idToken: "{metadata}.{payload}.{signature}",
|
|
expectedError: errors.New("failed to parse ID Token: oidc: malformed jwt payload: illegal base64 data at input byte 0"),
|
|
}),
|
|
)
|
|
|
|
type getClaimTableInput struct {
|
|
testClaimExtractorOpts
|
|
claim string
|
|
expectedValue interface{}
|
|
expectExists bool
|
|
expectedError error
|
|
}
|
|
|
|
DescribeTable("GetClaim",
|
|
func(in getClaimTableInput) {
|
|
claimExtractor, serverClose, err := newTestClaimExtractor(in.testClaimExtractorOpts)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
if serverClose != nil {
|
|
defer serverClose()
|
|
}
|
|
|
|
value, exists, err := claimExtractor.GetClaim(in.claim)
|
|
if in.expectedError != nil {
|
|
Expect(err).To(MatchError(in.expectedError))
|
|
return
|
|
}
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
if in.expectedValue != nil {
|
|
Expect(value).To(Equal(in.expectedValue))
|
|
} else {
|
|
Expect(value).To(BeNil())
|
|
}
|
|
|
|
Expect(exists).To(Equal(in.expectExists))
|
|
},
|
|
Entry("retrieves a string claim from ID Token when present", getClaimTableInput{
|
|
testClaimExtractorOpts: testClaimExtractorOpts{
|
|
idTokenPayload: basicIDTokenPayload,
|
|
setProfileURL: true,
|
|
profileRequestHeaders: newAuthorizedHeader(),
|
|
profileRequestHandler: shouldNotBeRequestedProfileHandler,
|
|
},
|
|
claim: "user",
|
|
expectExists: true,
|
|
expectedValue: "idTokenUser",
|
|
expectedError: nil,
|
|
}),
|
|
Entry("retrieves a slice claim from ID Token when present", getClaimTableInput{
|
|
testClaimExtractorOpts: testClaimExtractorOpts{
|
|
idTokenPayload: basicIDTokenPayload,
|
|
setProfileURL: true,
|
|
profileRequestHeaders: newAuthorizedHeader(),
|
|
profileRequestHandler: shouldNotBeRequestedProfileHandler,
|
|
},
|
|
claim: "groups",
|
|
expectExists: true,
|
|
expectedValue: []interface{}{"idTokenGroup1", "idTokenGroup2"},
|
|
expectedError: nil,
|
|
}),
|
|
Entry("when the requested claim is the empty string", getClaimTableInput{
|
|
testClaimExtractorOpts: testClaimExtractorOpts{
|
|
idTokenPayload: basicIDTokenPayload,
|
|
},
|
|
claim: "",
|
|
expectExists: false,
|
|
expectedValue: nil,
|
|
expectedError: nil,
|
|
}),
|
|
Entry("when the requested claim is the not found (with no profile URL)", getClaimTableInput{
|
|
testClaimExtractorOpts: testClaimExtractorOpts{
|
|
idTokenPayload: basicIDTokenPayload,
|
|
profileRequestHeaders: newAuthorizedHeader(),
|
|
},
|
|
claim: "not_found",
|
|
expectExists: false,
|
|
expectedValue: nil,
|
|
expectedError: nil,
|
|
}),
|
|
Entry("when the requested claim is the not found (with profile URL)", getClaimTableInput{
|
|
testClaimExtractorOpts: testClaimExtractorOpts{
|
|
idTokenPayload: basicIDTokenPayload,
|
|
setProfileURL: true,
|
|
profileRequestHeaders: newAuthorizedHeader(),
|
|
profileRequestHandler: requiresAuthProfileHandler,
|
|
},
|
|
claim: "not_found",
|
|
expectExists: false,
|
|
expectedValue: nil,
|
|
expectedError: nil,
|
|
}),
|
|
Entry("when the requested claim is the not found (with no profile Headers)", getClaimTableInput{
|
|
testClaimExtractorOpts: testClaimExtractorOpts{
|
|
idTokenPayload: basicIDTokenPayload,
|
|
setProfileURL: true,
|
|
profileRequestHeaders: nil,
|
|
profileRequestHandler: shouldNotBeRequestedProfileHandler,
|
|
},
|
|
claim: "not_found",
|
|
expectExists: false,
|
|
expectedValue: nil,
|
|
expectedError: nil,
|
|
}),
|
|
Entry("when the profile URL is unauthorized", getClaimTableInput{
|
|
testClaimExtractorOpts: testClaimExtractorOpts{
|
|
idTokenPayload: emptyJSON,
|
|
setProfileURL: true,
|
|
profileRequestHeaders: make(http.Header),
|
|
profileRequestHandler: requiresAuthProfileHandler,
|
|
},
|
|
claim: "user",
|
|
expectExists: false,
|
|
expectedValue: nil,
|
|
expectedError: errors.New("failed to fetch claims from profile URL: error making request to profile URL: unexpected status \"403\": Unauthorized"),
|
|
}),
|
|
Entry("retrieves a string claim from profile URL when not present in the ID Token", getClaimTableInput{
|
|
testClaimExtractorOpts: testClaimExtractorOpts{
|
|
idTokenPayload: emptyJSON,
|
|
setProfileURL: true,
|
|
profileRequestHeaders: newAuthorizedHeader(),
|
|
profileRequestHandler: requiresAuthProfileHandler,
|
|
},
|
|
claim: "user",
|
|
expectExists: true,
|
|
expectedValue: "profileUser",
|
|
expectedError: nil,
|
|
}),
|
|
Entry("retrieves a string claim from a nested path", getClaimTableInput{
|
|
testClaimExtractorOpts: testClaimExtractorOpts{
|
|
idTokenPayload: nestedClaimPayload,
|
|
setProfileURL: true,
|
|
profileRequestHeaders: newAuthorizedHeader(),
|
|
profileRequestHandler: shouldNotBeRequestedProfileHandler,
|
|
},
|
|
claim: "auth.user.username",
|
|
expectExists: true,
|
|
expectedValue: "nestedUser",
|
|
expectedError: nil,
|
|
}),
|
|
Entry("retrieves claim for with FQDN", getClaimTableInput{
|
|
testClaimExtractorOpts: testClaimExtractorOpts{
|
|
idTokenPayload: basicIDTokenPayload,
|
|
setProfileURL: true,
|
|
profileRequestHeaders: newAuthorizedHeader(),
|
|
profileRequestHandler: shouldNotBeRequestedProfileHandler,
|
|
},
|
|
claim: "https://groups.test",
|
|
expectExists: true,
|
|
expectedValue: []interface{}{"fqdnGroup1", "fqdnGroup2"},
|
|
expectedError: nil,
|
|
}),
|
|
)
|
|
})
|
|
|
|
It("GetClaim should only call the profile URL once", func() {
|
|
var counter int32
|
|
countRequestsHandler := func(rw http.ResponseWriter, _ *http.Request) {
|
|
atomic.AddInt32(&counter, 1)
|
|
rw.Write([]byte(basicProfileURLPayload))
|
|
}
|
|
|
|
claimExtractor, serverClose, err := newTestClaimExtractor(testClaimExtractorOpts{
|
|
idTokenPayload: "{}",
|
|
setProfileURL: true,
|
|
profileRequestHeaders: newAuthorizedHeader(),
|
|
profileRequestHandler: countRequestsHandler,
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
if serverClose != nil {
|
|
defer serverClose()
|
|
}
|
|
|
|
value, exists, err := claimExtractor.GetClaim("user")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(exists).To(BeTrue())
|
|
Expect(value).To(Equal("profileUser"))
|
|
Expect(counter).To(BeEquivalentTo(1))
|
|
|
|
// Check a different claim, but expect the count not to increase
|
|
value, exists, err = claimExtractor.GetClaim("email")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(exists).To(BeTrue())
|
|
Expect(value).To(Equal("profileEmail"))
|
|
Expect(counter).To(BeEquivalentTo(1))
|
|
})
|
|
|
|
It("GetClaim should not return an error with a non-nil empty ProfileURL", func() {
|
|
claims, serverClose, err := newTestClaimExtractor(testClaimExtractorOpts{
|
|
idTokenPayload: "{}",
|
|
profileRequestHeaders: newAuthorizedHeader(),
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
if serverClose != nil {
|
|
defer serverClose()
|
|
}
|
|
// Set the ProfileURL to be empty, but not nil
|
|
claims.(*claimExtractor).profileURL = &url.URL{}
|
|
|
|
value, exists, err := claims.GetClaim("user")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(exists).To(BeFalse())
|
|
Expect(value).To(BeNil())
|
|
})
|
|
|
|
type getClaimIntoTableInput struct {
|
|
testClaimExtractorOpts
|
|
into interface{}
|
|
claim string
|
|
expectedValue interface{}
|
|
expectExists bool
|
|
expectedError error
|
|
}
|
|
|
|
DescribeTable("GetClaimInto",
|
|
func(in getClaimIntoTableInput) {
|
|
claimExtractor, serverClose, err := newTestClaimExtractor(in.testClaimExtractorOpts)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
if serverClose != nil {
|
|
defer serverClose()
|
|
}
|
|
|
|
exists, err := claimExtractor.GetClaimInto(in.claim, in.into)
|
|
if in.expectedError != nil {
|
|
Expect(err).To(MatchError(in.expectedError))
|
|
return
|
|
}
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
if in.expectedValue != nil {
|
|
Expect(in.into).To(Equal(in.expectedValue))
|
|
} else {
|
|
Expect(in.into).To(BeEmpty())
|
|
}
|
|
|
|
Expect(exists).To(Equal(in.expectExists))
|
|
},
|
|
Entry("retrieves a string claim from ID Token when present into a string", getClaimIntoTableInput{
|
|
testClaimExtractorOpts: testClaimExtractorOpts{
|
|
idTokenPayload: basicIDTokenPayload,
|
|
setProfileURL: true,
|
|
profileRequestHeaders: newAuthorizedHeader(),
|
|
profileRequestHandler: shouldNotBeRequestedProfileHandler,
|
|
},
|
|
claim: "user",
|
|
into: stringPointer(""),
|
|
expectExists: true,
|
|
expectedValue: stringPointer("idTokenUser"),
|
|
expectedError: nil,
|
|
}),
|
|
Entry("retrieves a string claim from ID Token when present into a string slice", getClaimIntoTableInput{
|
|
testClaimExtractorOpts: testClaimExtractorOpts{
|
|
idTokenPayload: basicIDTokenPayload,
|
|
setProfileURL: true,
|
|
profileRequestHeaders: newAuthorizedHeader(),
|
|
profileRequestHandler: shouldNotBeRequestedProfileHandler,
|
|
},
|
|
claim: "user",
|
|
into: stringSlicePointer([]string{}),
|
|
expectExists: true,
|
|
expectedValue: stringSlicePointer([]string{"idTokenUser"}),
|
|
expectedError: nil,
|
|
}),
|
|
Entry("retrieves a string slice claim from ID Token when present into a string slice", getClaimIntoTableInput{
|
|
testClaimExtractorOpts: testClaimExtractorOpts{
|
|
idTokenPayload: basicIDTokenPayload,
|
|
setProfileURL: true,
|
|
profileRequestHeaders: newAuthorizedHeader(),
|
|
profileRequestHandler: shouldNotBeRequestedProfileHandler,
|
|
},
|
|
claim: "groups",
|
|
into: stringSlicePointer([]string{}),
|
|
expectExists: true,
|
|
expectedValue: stringSlicePointer([]string{"idTokenGroup1", "idTokenGroup2"}),
|
|
expectedError: nil,
|
|
}),
|
|
Entry("retrieves a string slice claim from ID Token when present into a string", getClaimIntoTableInput{
|
|
testClaimExtractorOpts: testClaimExtractorOpts{
|
|
idTokenPayload: basicIDTokenPayload,
|
|
setProfileURL: true,
|
|
profileRequestHeaders: newAuthorizedHeader(),
|
|
profileRequestHandler: shouldNotBeRequestedProfileHandler,
|
|
},
|
|
claim: "groups",
|
|
into: stringPointer(""),
|
|
expectExists: true,
|
|
expectedValue: stringPointer("[\"idTokenGroup1\",\"idTokenGroup2\"]"),
|
|
expectedError: nil,
|
|
}),
|
|
Entry("returns an error when a non-pointer is passed for the destination", getClaimIntoTableInput{
|
|
testClaimExtractorOpts: testClaimExtractorOpts{
|
|
idTokenPayload: basicIDTokenPayload,
|
|
setProfileURL: true,
|
|
profileRequestHeaders: newAuthorizedHeader(),
|
|
profileRequestHandler: shouldNotBeRequestedProfileHandler,
|
|
},
|
|
claim: "user",
|
|
into: "",
|
|
expectExists: false,
|
|
expectedValue: "",
|
|
expectedError: errors.New("could no coerce claim: unknown type for destination: string"),
|
|
}),
|
|
Entry("flattens a complex claim value into a JSON string", getClaimIntoTableInput{
|
|
testClaimExtractorOpts: testClaimExtractorOpts{
|
|
idTokenPayload: complexGroupsPayload,
|
|
setProfileURL: true,
|
|
profileRequestHeaders: newAuthorizedHeader(),
|
|
profileRequestHandler: shouldNotBeRequestedProfileHandler,
|
|
},
|
|
claim: "groups",
|
|
into: stringSlicePointer([]string{}),
|
|
expectExists: true,
|
|
expectedValue: stringSlicePointer([]string{
|
|
"{\"groupID\":\"group1\",\"roles\":[\"admin\"]}",
|
|
"{\"groupID\":\"group2\",\"roles\":[\"user\",\"employee\"]}",
|
|
}),
|
|
expectedError: nil,
|
|
}),
|
|
Entry("does not return an error when the claim does not exist", getClaimIntoTableInput{
|
|
testClaimExtractorOpts: testClaimExtractorOpts{
|
|
idTokenPayload: basicIDTokenPayload,
|
|
setProfileURL: true,
|
|
profileRequestHeaders: newAuthorizedHeader(),
|
|
profileRequestHandler: requiresAuthProfileHandler,
|
|
},
|
|
claim: "not_found",
|
|
into: stringPointer(""),
|
|
expectExists: false,
|
|
expectedValue: stringPointer(""),
|
|
expectedError: nil,
|
|
}),
|
|
Entry("returns an error when the profile request is unauthorized", getClaimIntoTableInput{
|
|
testClaimExtractorOpts: testClaimExtractorOpts{
|
|
idTokenPayload: emptyJSON,
|
|
setProfileURL: true,
|
|
profileRequestHeaders: make(http.Header),
|
|
profileRequestHandler: requiresAuthProfileHandler,
|
|
},
|
|
claim: "user",
|
|
into: stringPointer(""),
|
|
expectExists: false,
|
|
expectedValue: stringPointer(""),
|
|
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"),
|
|
}),
|
|
)
|
|
|
|
type coerceClaimTableInput struct {
|
|
value interface{}
|
|
dst interface{}
|
|
expectedDst interface{}
|
|
expectedError error
|
|
}
|
|
|
|
DescribeTable("coerceClaim",
|
|
func(in coerceClaimTableInput) {
|
|
err := coerceClaim(in.value, in.dst)
|
|
if in.expectedError != nil {
|
|
Expect(err).To(MatchError(in.expectedError))
|
|
return
|
|
}
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(in.dst).To(Equal(in.expectedDst))
|
|
},
|
|
Entry("coerces a string to a string", coerceClaimTableInput{
|
|
value: "some_string",
|
|
dst: stringPointer(""),
|
|
expectedDst: stringPointer("some_string"),
|
|
}),
|
|
Entry("coerces a slice to a string slice", coerceClaimTableInput{
|
|
value: []interface{}{"a", "b"},
|
|
dst: stringSlicePointer([]string{}),
|
|
expectedDst: stringSlicePointer([]string{"a", "b"}),
|
|
}),
|
|
Entry("coerces a bool to a bool", coerceClaimTableInput{
|
|
value: true,
|
|
dst: boolPointer(false),
|
|
expectedDst: boolPointer(true),
|
|
}),
|
|
Entry("coerces a string to a bool", coerceClaimTableInput{
|
|
value: "true",
|
|
dst: boolPointer(false),
|
|
expectedDst: boolPointer(true),
|
|
}),
|
|
Entry("coerces a map to a string", coerceClaimTableInput{
|
|
value: map[string]interface{}{
|
|
"foo": []interface{}{"bar", "baz"},
|
|
},
|
|
dst: stringPointer(""),
|
|
expectedDst: stringPointer("{\"foo\":[\"bar\",\"baz\"]}"),
|
|
}),
|
|
)
|
|
})
|
|
|
|
// ******************************************
|
|
// Helpers for setting up the claim extractor
|
|
// ******************************************
|
|
|
|
type testClaimExtractorOpts struct {
|
|
idTokenPayload string
|
|
setProfileURL bool
|
|
profileRequestHeaders http.Header
|
|
profileRequestHandler http.HandlerFunc
|
|
}
|
|
|
|
func newTestClaimExtractor(in testClaimExtractorOpts) (ClaimExtractor, func(), error) {
|
|
var profileURL *url.URL
|
|
var closeServer func()
|
|
if in.setProfileURL {
|
|
server := httptest.NewServer(http.HandlerFunc(in.profileRequestHandler))
|
|
closeServer = server.Close
|
|
|
|
var err error
|
|
profileURL, err = url.Parse("http://" + server.Listener.Addr().String() + profilePath)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
}
|
|
|
|
rawIDToken := createJWTFromPayload(in.idTokenPayload)
|
|
|
|
claimExtractor, err := NewClaimExtractor(context.Background(), rawIDToken, profileURL, in.profileRequestHeaders)
|
|
return claimExtractor, closeServer, err
|
|
}
|
|
|
|
func createJWTFromPayload(payload string) string {
|
|
header := base64.RawURLEncoding.EncodeToString([]byte(emptyJSON))
|
|
payloadJSON := base64.RawURLEncoding.EncodeToString([]byte(payload))
|
|
|
|
return fmt.Sprintf("%s.%s.%s", header, payloadJSON, header)
|
|
}
|
|
|
|
func newAuthorizedHeader() http.Header {
|
|
headers := make(http.Header)
|
|
headers.Add("Authorization", "Bearer "+authorizedAccessToken)
|
|
return headers
|
|
}
|
|
|
|
func hasAuthorizedHeader(headers http.Header) bool {
|
|
return headers.Get("Authorization") == "Bearer "+authorizedAccessToken
|
|
}
|
|
|
|
// ***********************
|
|
// Typed Pointer Functions
|
|
// ***********************
|
|
|
|
func stringPointer(in string) *string {
|
|
return &in
|
|
}
|
|
|
|
func stringSlicePointer(in []string) *[]string {
|
|
return &in
|
|
}
|
|
|
|
func boolPointer(in bool) *bool {
|
|
return &in
|
|
}
|
|
|
|
// ******************************
|
|
// Different profile URL handlers
|
|
// ******************************
|
|
|
|
func shouldNotBeRequestedProfileHandler(_ http.ResponseWriter, _ *http.Request) {
|
|
defer GinkgoRecover()
|
|
Expect(true).To(BeFalse(), "Unexpected request to profile URL")
|
|
}
|
|
|
|
func requiresAuthProfileHandler(rw http.ResponseWriter, req *http.Request) {
|
|
if !hasAuthorizedHeader(req.Header) {
|
|
rw.WriteHeader(403)
|
|
rw.Write([]byte("Unauthorized"))
|
|
return
|
|
}
|
|
|
|
rw.Write([]byte(basicProfileURLPayload))
|
|
}
|