Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support alternative token location #271

Merged
merged 15 commits into from
Oct 9, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .schemas/authenticators.jwt.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,38 @@
},
"scope_strategy": {
"$ref": "https://raw.githubusercontent.com/ory/oathkeeper/master/.schemas/scope_strategy.schema.json#"
},
"token_from": {
"title": "Token From",
"description": "The location of the token.\n If not configured, the token will be received from a default location - 'Authorization' header.\n One and only one location (header or query) must be specified.",
"oneOf": [
{
"type": "object",
"required": [
"header"
],
"properties": {
"header": {
"title": "Header",
"type": "string",
"description": "The header (case insensitive) that must contain a token for request authentication. It can't be set along with query_parameter."
}
}
},
{
"type": "object",
"required": [
"query_parameter"
],
"properties": {
"query_parameter": {
"title": "Query Parameter",
"type": "string",
"description": "The query parameter (case sensitive) that must contain a token for request authentication. It can't be set along with header."
}
}
}
]
}
},
"additionalProperties": false
Expand Down
32 changes: 32 additions & 0 deletions .schemas/authenticators.oauth2_introspection.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,38 @@
"items": {
"type": "string"
}
},
"token_from": {
"title": "Token From",
"description": "The location of the token.\n If not configured, the token will be received from a default location - 'Authorization' header.\n One and only one location (header or query) must be specified.",
"oneOf": [
{
"type": "object",
"required": [
"header"
],
"properties": {
"header": {
"title": "Header",
"type": "string",
"description": "The header (case insensitive) that must contain a token for request authentication.\n It can't be set along with query_parameter."
}
}
},
{
"type": "object",
"required": [
"query_parameter"
],
"properties": {
"query_parameter": {
"title": "Query Parameter",
"type": "string",
"description": "The query parameter (case sensitive) that must contain a token for request authentication.\n It can't be set along with header."
}
}
}
]
}
},
"required": [
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ require (
github.com/blang/semver v3.5.1+incompatible
github.com/bxcodec/faker v2.0.1+incompatible
github.com/cenkalti/backoff v2.1.1+incompatible
github.com/codegangsta/negroni v1.0.0 // indirect
github.com/dgrijalva/jwt-go v3.2.0+incompatible
github.com/fsnotify/fsnotify v1.4.7
github.com/ghodss/yaml v1.0.0
Expand Down
22 changes: 19 additions & 3 deletions helper/bearer.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,25 @@ import (
"strings"
)

func BearerTokenFromRequest(r *http.Request) string {
auth := r.Header.Get("Authorization")
split := strings.SplitN(auth, " ", 2)
const (
defaultAuthorizationHeader = "Authorization"
)

type BearerTokenLocation struct {
Header *string `json:"header"`
QueryParameter *string `json:"query_parameter"`
}

func BearerTokenFromRequest(r *http.Request, tokenLocation *BearerTokenLocation) string {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make sense to test this independently?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah. That might be a good idea :)
I added tests.

if tokenLocation != nil {
if tokenLocation.Header != nil {
return r.Header.Get(*tokenLocation.Header)
} else if tokenLocation.QueryParameter != nil {
return r.FormValue(*tokenLocation.QueryParameter)
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible that both if / else if do not get executed? Then token would be empty.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is not possible, as JSON schema for jwt and oauth2_introspection authenticators would not allow for that.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we make this explicit in the code by simply doing a if/else?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can, but if somehow QueryParameter would be a nil, than the program will crash on dereferencing a nil pointer. With "else if" the function will simply return an empty string, like if there was no token in the configured header/query parameter (what may be a proper behavior if there was "alternative token location" set, but not configured - even though with the current schema it is not possible).

I see an alternative solution here: returning an error saying that rule is misconfigured. But I would have to add error handling in the places where this function is called.

What do you think?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, how about simply falling back to the authorization header in the case where both are nil?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, done

}
token := r.Header.Get(defaultAuthorizationHeader)
split := strings.SplitN(token, " ", 2)
if len(split) != 2 || !strings.EqualFold(split[0], "bearer") {
return ""
}
Expand Down
68 changes: 68 additions & 0 deletions helper/bearer_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package helper_test

import (
"net/http"
"testing"

"github.com/ory/oathkeeper/helper"
"github.com/stretchr/testify/assert"
)

const (
defaultHeaderName = "Authorization"
)

func TestBearerTokenFromRequest(t *testing.T) {
t.Run("case=token should be received from default header if custom location is not set and token is present", func(t *testing.T) {
expectedToken := "token"
request := &http.Request{Header: http.Header{defaultHeaderName: {"bearer " + expectedToken}}}
token := helper.BearerTokenFromRequest(request, nil)
assert.Equal(t, expectedToken, token)
})
t.Run("case=should return empty string if custom location is not set and token is not present in default header", func(t *testing.T) {
request := &http.Request{}
token := helper.BearerTokenFromRequest(request, nil)
assert.Empty(t, token)
})
t.Run("case=should return empty string if custom location is set to header and token is not present in that header", func(t *testing.T) {
customHeaderName := "Custom-Auth-Header"
request := &http.Request{Header: http.Header{defaultHeaderName: {"bearer token"}}}
tokenLocation := helper.BearerTokenLocation{Header: &customHeaderName}
token := helper.BearerTokenFromRequest(request, &tokenLocation)
assert.Empty(t, token)
})
t.Run("case=should return empty string if custom location is set to query parameter and token is not present in that query parameter", func(t *testing.T) {
customQueryParameterName := "Custom-Auth"
request := &http.Request{Header: http.Header{defaultHeaderName: {"bearer token"}}}
tokenLocation := helper.BearerTokenLocation{QueryParameter: &customQueryParameterName}
token := helper.BearerTokenFromRequest(request, &tokenLocation)
assert.Empty(t, token)
})
t.Run("case=token should be received from custom header if custom location is set to header and token is present", func(t *testing.T) {
expectedToken := "token"
customHeaderName := "Custom-Auth-Header"
request := &http.Request{Header: http.Header{customHeaderName: {expectedToken}}}
tokenLocation := helper.BearerTokenLocation{Header: &customHeaderName}
token := helper.BearerTokenFromRequest(request, &tokenLocation)
assert.Equal(t, expectedToken, token)
})
t.Run("case=token should be received from custom header if custom location is set to query parameter and token is present", func(t *testing.T) {
expectedToken := "token"
customQueryParameterName := "Custom-Auth"
request := &http.Request{
Form: map[string][]string{
customQueryParameterName: []string{expectedToken},
},
}
tokenLocation := helper.BearerTokenLocation{QueryParameter: &customQueryParameterName}
token := helper.BearerTokenFromRequest(request, &tokenLocation)
assert.Equal(t, expectedToken, token)
})
t.Run("case=token should be received from default header if custom token location is set, but neither Header nor Query Param is configured", func(t *testing.T) {
expectedToken := "token"
request := &http.Request{Header: http.Header{defaultHeaderName: {"bearer " + expectedToken}}}
tokenLocation := helper.BearerTokenLocation{}
token := helper.BearerTokenFromRequest(request, &tokenLocation)
assert.Equal(t, expectedToken, token)
})
}
23 changes: 12 additions & 11 deletions pipeline/authn/authenticator_jwt.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,13 @@ type AuthenticatorJWTRegistry interface {
}

type AuthenticatorOAuth2JWTConfiguration struct {
Scope []string `json:"required_scope"`
Audience []string `json:"target_audience"`
Issuers []string `json:"trusted_issuers"`
AllowedAlgorithms []string `json:"allowed_algorithms"`
JWKSURLs []string `json:"jwks_urls"`
ScopeStrategy string `json:"scope_strategy"`
Scope []string `json:"required_scope"`
Audience []string `json:"target_audience"`
Issuers []string `json:"trusted_issuers"`
AllowedAlgorithms []string `json:"allowed_algorithms"`
JWKSURLs []string `json:"jwks_urls"`
ScopeStrategy string `json:"scope_strategy"`
BearerTokenLocation *helper.BearerTokenLocation `json:"token_from"`
}

type AuthenticatorJWT struct {
Expand Down Expand Up @@ -67,16 +68,16 @@ func (a *AuthenticatorJWT) Config(config json.RawMessage) (*AuthenticatorOAuth2J
}

func (a *AuthenticatorJWT) Authenticate(r *http.Request, config json.RawMessage, _ pipeline.Rule) (*AuthenticationSession, error) {
token := helper.BearerTokenFromRequest(r)
if token == "" {
return nil, errors.WithStack(ErrAuthenticatorNotResponsible)
}

cf, err := a.Config(config)
if err != nil {
return nil, err
}

token := helper.BearerTokenFromRequest(r, cf.BearerTokenLocation)
if token == "" {
return nil, errors.WithStack(ErrAuthenticatorNotResponsible)
}

if len(cf.AllowedAlgorithms) == 0 {
cf.AllowedAlgorithms = []string{"RS256"}
}
Expand Down
89 changes: 78 additions & 11 deletions pipeline/authn/authenticator_jwt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,13 +67,14 @@ func TestAuthenticatorJWT(t *testing.T) {

t.Run("method=authenticate", func(t *testing.T) {
for k, tc := range []struct {
setup func()
d string
r *http.Request
config string
expectErr bool
expectCode int
expectSess *AuthenticationSession
setup func()
d string
r *http.Request
config string
expectErr bool
expectExactErr error
expectCode int
expectSess *AuthenticationSession
}{
{
d: "should fail because no payloads",
Expand All @@ -85,6 +86,69 @@ func TestAuthenticatorJWT(t *testing.T) {
r: &http.Request{Header: http.Header{"Authorization": []string{"bearer invalid.token.sign"}}},
expectErr: true,
},
{
d: "should return error saying that authenticator is not responsible for validating the request, as the token was not provided in a proper location (default)",
r: &http.Request{Header: http.Header{"Foobar": []string{"bearer " + gen(keys[1], jwt.MapClaims{
"sub": "sub",
"exp": now.Add(time.Hour).Unix(),
})}}},
expectErr: true,
expectExactErr: ErrAuthenticatorNotResponsible,
},
{
d: "should return error saying that authenticator is not responsible for validating the request, as the token was not provided in a proper location (custom header)",
r: &http.Request{Header: http.Header{"Authorization": []string{"bearer " + gen(keys[1], jwt.MapClaims{
"sub": "sub",
"exp": now.Add(time.Hour).Unix(),
})}}},
config: `{"token_from": {"header": "X-Custom-Header"}}`,
expectErr: true,
expectExactErr: ErrAuthenticatorNotResponsible,
},
{
d: "should return error saying that authenticator is not responsible for validating the request, as the token was not provided in a proper location (custom query parameter)",
r: &http.Request{
Form: map[string][]string{
"someOtherQueryParam": []string{
gen(keys[1], jwt.MapClaims{
"sub": "sub",
"exp": now.Add(time.Hour).Unix(),
}),
},
},
Header: http.Header{"Authorization": []string{"bearer " + gen(keys[1], jwt.MapClaims{
"sub": "sub",
"exp": now.Add(time.Hour).Unix(),
})}},
},
config: `{"token_from": {"query_parameter": "token"}}`,
expectErr: true,
expectExactErr: ErrAuthenticatorNotResponsible,
},
{
d: "should pass because the valid JWT token was provided in a proper location (custom header)",
r: &http.Request{Header: http.Header{"X-Custom-Header": []string{gen(keys[1], jwt.MapClaims{
"sub": "sub",
"exp": now.Add(time.Hour).Unix(),
})}}},
config: `{"token_from": {"header": "X-Custom-Header"}}`,
expectErr: false,
},
{
d: "should pass because the valid JWT token was provided in a proper location (custom query parameter)",
r: &http.Request{
Form: map[string][]string{
"token": []string{
gen(keys[1], jwt.MapClaims{
"sub": "sub",
"exp": now.Add(time.Hour).Unix(),
}),
},
},
},
config: `{"token_from": {"query_parameter": "token"}}`,
expectErr: false,
},
{
d: "should pass because JWT is valid",
r: &http.Request{Header: http.Header{"Authorization": []string{"bearer " + gen(keys[1], jwt.MapClaims{
Expand Down Expand Up @@ -187,7 +251,7 @@ func TestAuthenticatorJWT(t *testing.T) {
expectCode: 401,
},
{
d: "should pass because JWT is missing scope",
d: "should fail because JWT is missing scope",
r: &http.Request{Header: http.Header{"Authorization": []string{"bearer " + gen(keys[2], jwt.MapClaims{
"sub": "sub",
"exp": now.Add(time.Hour).Unix(),
Expand All @@ -197,7 +261,7 @@ func TestAuthenticatorJWT(t *testing.T) {
expectErr: true,
},
{
d: "should pass because JWT issuer is untrusted",
d: "should fail because JWT issuer is untrusted",
r: &http.Request{Header: http.Header{"Authorization": []string{"bearer " + gen(keys[1], jwt.MapClaims{
"sub": "sub",
"exp": now.Add(time.Hour).Unix(),
Expand All @@ -207,7 +271,7 @@ func TestAuthenticatorJWT(t *testing.T) {
expectErr: true,
},
{
d: "should pass because JWT is missing audience",
d: "should fail because JWT is missing audience",
r: &http.Request{Header: http.Header{"Authorization": []string{"bearer " + gen(keys[1], jwt.MapClaims{
"sub": "sub",
"exp": now.Add(time.Hour).Unix(),
Expand Down Expand Up @@ -235,10 +299,13 @@ func TestAuthenticatorJWT(t *testing.T) {
tc.config, _ = sjson.Set(tc.config, "jwks_urls", keys)
session, err := a.Authenticate(tc.r, json.RawMessage([]byte(tc.config)), nil)
if tc.expectErr {
require.Error(t, err)
if tc.expectCode != 0 {
assert.Equal(t, tc.expectCode, herodot.ToDefaultError(err, "").StatusCode(), "Status code mismatch")
}
require.Error(t, err)
if tc.expectExactErr != nil {
assert.EqualError(t, err, tc.expectExactErr.Error())
}
} else {
require.NoError(t, err, "%#v", errors.Cause(err))
}
Expand Down
15 changes: 8 additions & 7 deletions pipeline/authn/authenticator_oauth2_introspection.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,13 @@ import (
)

type AuthenticatorOAuth2IntrospectionConfiguration struct {
Scopes []string `json:"required_scope"`
Audience []string `json:"target_audience"`
Issuers []string `json:"trusted_issuers"`
PreAuth *AuthenticatorOAuth2IntrospectionPreAuthConfiguration `json:"pre_authorization"`
ScopeStrategy string `json:"scope_strategy"`
IntrospectionURL string `json:"introspection_url"`
Scopes []string `json:"required_scope"`
Audience []string `json:"target_audience"`
Issuers []string `json:"trusted_issuers"`
PreAuth *AuthenticatorOAuth2IntrospectionPreAuthConfiguration `json:"pre_authorization"`
ScopeStrategy string `json:"scope_strategy"`
IntrospectionURL string `json:"introspection_url"`
BearerTokenLocation *helper.BearerTokenLocation `json:"token_from"`
}

type AuthenticatorOAuth2IntrospectionPreAuthConfiguration struct {
Expand Down Expand Up @@ -70,7 +71,7 @@ func (a *AuthenticatorOAuth2Introspection) Authenticate(r *http.Request, config
return nil, err
}

token := helper.BearerTokenFromRequest(r)
token := helper.BearerTokenFromRequest(r, cf.BearerTokenLocation)
if token == "" {
return nil, errors.WithStack(ErrAuthenticatorNotResponsible)
}
Expand Down
Loading