Skip to content

Commit

Permalink
feat: Retrieval of an access token from the request body (#115)
Browse files Browse the repository at this point in the history
  • Loading branch information
dadrus authored Jul 27, 2022
1 parent 362a55a commit b336ab4
Show file tree
Hide file tree
Showing 12 changed files with 359 additions and 11 deletions.
19 changes: 19 additions & 0 deletions docs/content/docs/configuration/pipeline/configuration_types.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,25 @@ Imagine you want Heimdall to verify an access token used to protect your upstrea
----
====

=== Body Parameter Strategy

The usage of this strategy is only possible when the request payload is either JSON or `application/x-www-form-urlencoded` encoded. The `Content-Type` of the request must also either be set to `application/x-www-form-urlencoded` or to a MIME type, which contains `json`.

* *`body_parameter`*: _string_ (mandatory)
+
The name of the body parameter to use.

.Body Parameter Strategy usage
====
Imagine you want Heimdall to verify an access token used to protect your upstream service. If the client of your upstream application sends the access token in the body parameter named "access_token", you can inform Heimdall to extract it from there by configuring this strategy as follows:
[source, yaml]
----
- body_parameter: access_token
----
====

== Authentication Strategy

Authentication strategy is kind of abstract type, so you have to define which specific type to use. Each type has its own configuration options.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package extractors

import (
"net/http"
"strings"

"github.com/dadrus/heimdall/internal/heimdall"
"github.com/dadrus/heimdall/internal/pipeline/contenttype"
"github.com/dadrus/heimdall/internal/x/errorchain"
)

type BodyParameterExtractStrategy struct {
Name string
}

func (es BodyParameterExtractStrategy) GetAuthData(ctx heimdall.Context) (AuthData, error) {
decoder, err := contenttype.NewDecoder(ctx.RequestHeader("Content-Type"))
if err != nil {
return nil, errorchain.New(heimdall.ErrArgument).CausedBy(err)
}

data, err := decoder.Decode(ctx.RequestBody())
if err != nil {
return nil, errorchain.NewWithMessage(heimdall.ErrArgument,
"failed to decode request body").CausedBy(err)
}

entry, ok := data[es.Name]
if !ok {
return nil, errorchain.NewWithMessagef(heimdall.ErrArgument,
"no %s parameter present in request body", es.Name)
}

var value string

switch val := entry.(type) {
case string:
value = val
case []string:
if len(val) != 1 {
return nil, errorchain.NewWithMessagef(heimdall.ErrArgument,
"%s request body parameter is present multiple times", es.Name)
}

value = val[0]
case []any:
if len(val) != 1 {
return nil, errorchain.NewWithMessagef(heimdall.ErrArgument,
"%s request body parameter is present multiple times", es.Name)
}

value, ok = val[0].(string)
if !ok {
return nil, errorchain.NewWithMessagef(heimdall.ErrArgument,
"unexpected type for %s request body parameter", es.Name)
}
default:
return nil, errorchain.NewWithMessagef(heimdall.ErrArgument,
"unexpected type for %s request body parameter", es.Name)
}

return &bodyParameterAuthData{
name: es.Name,
value: strings.TrimSpace(value),
}, nil
}

type bodyParameterAuthData struct {
name string
value string
}

func (c *bodyParameterAuthData) ApplyTo(req *http.Request) {
panic("application of extracted body parameters to a request is not yet supported")
}

func (c *bodyParameterAuthData) Value() string {
return c.value
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
package extractors

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/dadrus/heimdall/internal/heimdall"
"github.com/dadrus/heimdall/internal/heimdall/mocks"
)

func TestExtractBodyParameter(t *testing.T) {
t.Parallel()

for _, tc := range []struct {
uc string
parameterName string
configureMocks func(t *testing.T, ctx *mocks.MockContext)
assert func(t *testing.T, err error, authData AuthData)
}{
{
uc: "unsupported content type",
parameterName: "foobar",
configureMocks: func(t *testing.T, ctx *mocks.MockContext) {
t.Helper()

ctx.On("RequestHeader", "Content-Type").Return("FooBar")
},
assert: func(t *testing.T, err error, authData AuthData) {
t.Helper()

require.Error(t, err)
assert.ErrorIs(t, err, heimdall.ErrArgument)
assert.Contains(t, err.Error(), "unsupported mime type")
},
},
{
uc: "json body decoding error",
parameterName: "foobar",
configureMocks: func(t *testing.T, ctx *mocks.MockContext) {
t.Helper()

ctx.On("RequestHeader", "Content-Type").Return("application/json")
ctx.On("RequestBody").Return([]byte("foo:?:bar"))
},
assert: func(t *testing.T, err error, authData AuthData) {
t.Helper()

require.Error(t, err)
assert.ErrorIs(t, err, heimdall.ErrArgument)
assert.Contains(t, err.Error(), "failed to decode")
},
},
{
uc: "form url encoded body decoding error",
parameterName: "foobar",
configureMocks: func(t *testing.T, ctx *mocks.MockContext) {
t.Helper()

ctx.On("RequestHeader", "Content-Type").
Return("application/x-www-form-urlencoded")
ctx.On("RequestBody").Return([]byte("foo;"))
},
assert: func(t *testing.T, err error, authData AuthData) {
t.Helper()

require.Error(t, err)
assert.ErrorIs(t, err, heimdall.ErrArgument)
assert.Contains(t, err.Error(), "failed to decode")
},
},
{
uc: "json encoded body does not contain required parameter",
parameterName: "foobar",
configureMocks: func(t *testing.T, ctx *mocks.MockContext) {
t.Helper()

ctx.On("RequestHeader", "Content-Type").
Return("application/json")
ctx.On("RequestBody").Return([]byte(`{"bar": "foo"}`))
},
assert: func(t *testing.T, err error, authData AuthData) {
t.Helper()

require.Error(t, err)
assert.ErrorIs(t, err, heimdall.ErrArgument)
assert.Contains(t, err.Error(), "no foobar parameter present")
},
},
{
uc: "form url encoded body does not contain required parameter",
parameterName: "foobar",
configureMocks: func(t *testing.T, ctx *mocks.MockContext) {
t.Helper()

ctx.On("RequestHeader", "Content-Type").
Return("application/x-www-form-urlencoded")
ctx.On("RequestBody").Return([]byte(`foo=bar`))
},
assert: func(t *testing.T, err error, authData AuthData) {
t.Helper()

require.Error(t, err)
assert.ErrorIs(t, err, heimdall.ErrArgument)
assert.Contains(t, err.Error(), "no foobar parameter present")
},
},
{
uc: "json encoded body contains required parameter multiple times",
parameterName: "foobar",
configureMocks: func(t *testing.T, ctx *mocks.MockContext) {
t.Helper()

ctx.On("RequestHeader", "Content-Type").
Return("application/json")
ctx.On("RequestBody").Return([]byte(`{"foobar": ["foo", "bar"]}`))
},
assert: func(t *testing.T, err error, authData AuthData) {
t.Helper()

require.Error(t, err)
assert.ErrorIs(t, err, heimdall.ErrArgument)
assert.Contains(t, err.Error(), "multiple times")
},
},
{
uc: "form url encoded body contains required parameter multiple times",
parameterName: "foobar",
configureMocks: func(t *testing.T, ctx *mocks.MockContext) {
t.Helper()

ctx.On("RequestHeader", "Content-Type").
Return("application/x-www-form-urlencoded")
ctx.On("RequestBody").Return([]byte(`foobar=foo&foobar=bar`))
},
assert: func(t *testing.T, err error, authData AuthData) {
t.Helper()

require.Error(t, err)
assert.ErrorIs(t, err, heimdall.ErrArgument)
assert.Contains(t, err.Error(), "multiple times")
},
},
{
uc: "json encoded body contains required parameter in wrong format #1",
parameterName: "foobar",
configureMocks: func(t *testing.T, ctx *mocks.MockContext) {
t.Helper()

ctx.On("RequestHeader", "Content-Type").
Return("application/json")
ctx.On("RequestBody").Return([]byte(`{"foobar": [1]}`))
},
assert: func(t *testing.T, err error, authData AuthData) {
t.Helper()

require.Error(t, err)
assert.ErrorIs(t, err, heimdall.ErrArgument)
assert.Contains(t, err.Error(), "unexpected type")
},
},
{
uc: "json encoded body contains required parameter in wrong format #2",
parameterName: "foobar",
configureMocks: func(t *testing.T, ctx *mocks.MockContext) {
t.Helper()

ctx.On("RequestHeader", "Content-Type").
Return("application/json")
ctx.On("RequestBody").Return([]byte(`{"foobar": { "foo": "bar" }}`))
},
assert: func(t *testing.T, err error, authData AuthData) {
t.Helper()

require.Error(t, err)
assert.ErrorIs(t, err, heimdall.ErrArgument)
assert.Contains(t, err.Error(), "unexpected type")
},
},
{
uc: "json encoded body contains required parameter",
parameterName: "foobar",
configureMocks: func(t *testing.T, ctx *mocks.MockContext) {
t.Helper()

ctx.On("RequestHeader", "Content-Type").
Return("application/json")
ctx.On("RequestBody").Return([]byte(`{"foobar": "foo"}`))
},
assert: func(t *testing.T, err error, authData AuthData) {
t.Helper()

require.NoError(t, err)
assert.Equal(t, "foo", authData.Value())
},
},
{
uc: "form url encoded body contains required parameter",
parameterName: "foobar",
configureMocks: func(t *testing.T, ctx *mocks.MockContext) {
t.Helper()

ctx.On("RequestHeader", "Content-Type").
Return("application/x-www-form-urlencoded")
ctx.On("RequestBody").Return([]byte(`foobar=foo`))
},
assert: func(t *testing.T, err error, authData AuthData) {
t.Helper()

require.NoError(t, err)
assert.Equal(t, "foo", authData.Value())
},
},
} {
t.Run("case="+tc.uc, func(t *testing.T) {
// GIVEN
ctx := &mocks.MockContext{}
tc.configureMocks(t, ctx)

strategy := BodyParameterExtractStrategy{Name: tc.parameterName}

// WHEN
authData, err := strategy.GetAuthData(ctx)

// THEN
tc.assert(t, err, authData)
})
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ func DecodeCompositeExtractStrategyHookFunc() mapstructure.DecodeHookFunc {
}

func createStrategy(data map[string]string) (AuthDataExtractStrategy, error) {
if value, ok := data["header"]; ok {
if value, ok := data["header"]; ok { // nolint: nestif
var prefix string
if p, ok := data["strip_prefix"]; ok {
prefix = p
Expand All @@ -72,6 +72,8 @@ func createStrategy(data map[string]string) (AuthDataExtractStrategy, error) {
return &CookieValueExtractStrategy{Name: value}, nil
} else if value, ok := data["query_parameter"]; ok {
return &QueryParameterExtractStrategy{Name: value}, nil
} else if value, ok := data["body_parameter"]; ok {
return &BodyParameterExtractStrategy{Name: value}, nil
} else {
return nil, errorchain.
NewWithMessage(heimdall.ErrConfiguration, "unsupported authentication source")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,10 @@ func TestUnmarshalAuthenticationDataSourceFromValidYaml(t *testing.T) {
config := []byte(`
authentication_data_source:
- cookie: foo_cookie
strip_prefix: cfoo
- header: foo_header
strip_prefix: hfoo
- query_parameter: foo_qparam
strip_prefix: qfoo
- body_parameter: foo_bparam
`)

parser := koanf.New(".")
Expand All @@ -46,7 +45,7 @@ authentication_data_source:

err = dec.Decode(settings["authentication_data_source"])
assert.NoError(t, err)
assert.Equal(t, 3, len(ces))
assert.Equal(t, 4, len(ces))

ce, ok := ces[0].(*CookieValueExtractStrategy)
require.True(t, ok)
Expand All @@ -60,4 +59,8 @@ authentication_data_source:
qe, ok := ces[2].(*QueryParameterExtractStrategy)
require.True(t, ok)
assert.Equal(t, "foo_qparam", qe.Name)

be, ok := ces[3].(*BodyParameterExtractStrategy)
require.True(t, ok)
assert.Equal(t, "foo_bparam", be.Name)
}
1 change: 1 addition & 0 deletions internal/pipeline/authenticators/jwt_authenticator.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ func newJwtAuthenticator(rawConfig map[string]any) (*jwtAuthenticator, error) {
adg = extractors.CompositeExtractStrategy{
extractors.HeaderValueExtractStrategy{Name: "Authorization", Prefix: "Bearer"},
extractors.QueryParameterExtractStrategy{Name: "access_token"},
extractors.BodyParameterExtractStrategy{Name: "access_token"},
}
} else {
adg = conf.AuthDataSource
Expand Down
Loading

0 comments on commit b336ab4

Please sign in to comment.