diff --git a/Gopkg.lock b/Gopkg.lock index 920e9b0175..5167eaaea8 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -239,18 +239,20 @@ [[projects]] name = "github.com/ory/fosite" packages = ["."] - revision = "99029e0e1bc4b1d6dfa1ca8b85a46d79cffad6e8" - version = "v0.17.2" + revision = "a07ce27c814538c7d0e6228ae814482be2e96e7e" + version = "v0.21.3" [[projects]] name = "github.com/ory/go-convenience" packages = [ "corsx", + "jwtx", + "mapx", "stringslice", "stringsx" ] - revision = "42cb17c3e4dc0d7d7672cfaffbdfe8f5deb494db" - version = "v0.0.2" + revision = "c47f601243faea2022b8bc0b90d7e1cd1f722807" + version = "v0.0.6" [[projects]] name = "github.com/ory/graceful" @@ -540,6 +542,6 @@ [solve-meta] analyzer-name = "dep" analyzer-version = 1 - inputs-digest = "1213e51765e6ba29f566f40c6f5ab3f0e03ab2bbaa548e98d398994b37607618" + inputs-digest = "1a243d722f9e92c1f1366af096682f0e0c73df036d6bffb18856e82eea3950b7" solver-name = "gps-cdcl" solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml index f9630ade3c..15f44a1d71 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -67,7 +67,7 @@ [[constraint]] name = "github.com/ory/fosite" - version = "0.17.2" + version = "0.21.3" [[constraint]] name = "github.com/ory/graceful" @@ -103,7 +103,7 @@ [[constraint]] name = "github.com/ory/go-convenience" - version = "0.0.2" + version = "0.0.6" [[constraint]] name = "github.com/pborman/uuid" diff --git a/cmd/helper_server.go b/cmd/helper_server.go index 2bc68b2865..4f9b80a312 100644 --- a/cmd/helper_server.go +++ b/cmd/helper_server.go @@ -192,6 +192,10 @@ func handlerFactories(keyManager rsakey.Manager) ([]proxy.Authenticator, []proxy proxy.NewAuthenticatorOAuth2ClientCredentials( viper.GetString("AUTHENTICATOR_OAUTH2_CLIENT_CREDENTIALS_TOKEN_URL"), ), + proxy.NewAuthenticatorJWT( + viper.GetString("AUTHENTICATOR_JWT_JWKS_URL"), + fosite.WildcardScopeStrategy, + ), }, authorizers, []proxy.CredentialsIssuer{ diff --git a/cmd/serve_proxy.go b/cmd/serve_proxy.go index 40f7208b17..85fc36958d 100644 --- a/cmd/serve_proxy.go +++ b/cmd/serve_proxy.go @@ -85,6 +85,11 @@ HTTP(S) CONTROLS AUTHENTICATORS ============== +- JSON Web Token Authenticator: + - AUTHENTICATOR_JWT_JWKS_URL: The URL where ORY Oathkeeper can retrieve JSON Web Keys from for validating + the JSON Web Token. Usually something like "https://my-keys.com/.well-known/jwks.json". The response + of that endpoint must return a JSON Web Key Set (JWKS). + - OAuth 2.0 Client Credentials Authenticator: - AUTHENTICATOR_OAUTH2_CLIENT_CREDENTIALS_TOKEN_URL: Sets the OAuth 2.0 Token URL that should be used to check if the provided credentials are valid or not. diff --git a/helper/errors.go b/helper/errors.go index 1031668ddb..b0a5b1ba24 100644 --- a/helper/errors.go +++ b/helper/errors.go @@ -33,7 +33,7 @@ var ( StatusField: http.StatusText(http.StatusForbidden), } ErrUnauthorized = &herodot.DefaultError{ - ErrorField: "Access credentials are either expired or missing a scope", + ErrorField: "Access credentials are invalid", CodeField: http.StatusUnauthorized, StatusField: http.StatusText(http.StatusUnauthorized), } diff --git a/proxy/authenticator_jwt.go b/proxy/authenticator_jwt.go index d078aa8a59..975ecaa99e 100644 --- a/proxy/authenticator_jwt.go +++ b/proxy/authenticator_jwt.go @@ -4,17 +4,20 @@ import ( "encoding/json" "net/http" - "github.com/ory/oathkeeper/rule" - "github.com/pkg/errors" - "github.com/ory/fosite" - "gopkg.in/square/go-jose.v2" "bytes" - "github.com/ory/oathkeeper/helper" - "github.com/dgrijalva/jwt-go" + "crypto/ecdsa" + "crypto/rsa" "fmt" + + "github.com/dgrijalva/jwt-go" + "github.com/ory/fosite" + "github.com/ory/go-convenience/jwtx" + "github.com/ory/go-convenience/mapx" "github.com/ory/go-convenience/stringslice" - "crypto/rsa" - "crypto/ecdsa" + "github.com/ory/oathkeeper/helper" + "github.com/ory/oathkeeper/rule" + "github.com/pkg/errors" + "gopkg.in/square/go-jose.v2" ) type AuthenticatorOAuth2JWTConfiguration struct { @@ -76,22 +79,22 @@ func (a *AuthenticatorJWT) Authenticate(r *http.Request, config json.RawMessage, } // Parse the token. - parsedToken, err := jwt.Parse(token, func(t *jwt.Token) (interface{}, error) { - if !stringslice.Has(cf.AllowedAlgorithms, fmt.Sprintf("%s", t.Header["alg"])) { - return nil, errors.WithStack(helper.ErrUnauthorized.WithReason(fmt.Sprintf(`JSON Web Token used signing method "%s" which is not allowed.`, t.Header["alg"]))) + parsedToken, err := jwt.ParseWithClaims(token, jwt.MapClaims{}, func(token *jwt.Token) (interface{}, error) { + if !stringslice.Has(cf.AllowedAlgorithms, fmt.Sprintf("%s", token.Header["alg"])) { + return nil, errors.WithStack(helper.ErrUnauthorized.WithReason(fmt.Sprintf(`JSON Web Token used signing method "%s" which is not allowed.`, token.Header["alg"]))) } - switch t.Method.(type) { + switch token.Method.(type) { case *jwt.SigningMethodRSA: - return a.findRSAPublicKey(t) + return a.findRSAPublicKey(token) case *jwt.SigningMethodECDSA: - return a.findECDSAPublicKey(t) + return a.findECDSAPublicKey(token) case *jwt.SigningMethodRSAPSS: - return a.findRSAPublicKey(t) + return a.findRSAPublicKey(token) case *jwt.SigningMethodHMAC: - return a.findSharedKey(t) + return a.findSharedKey(token) default: - return nil, errors.WithStack(helper.ErrUnauthorized.WithReason(fmt.Sprintf(`This request object uses unsupported signing algorithm "%s"."`, t.Header["alg"]))) + return nil, errors.WithStack(helper.ErrUnauthorized.WithReason(fmt.Sprintf(`This request object uses unsupported signing algorithm "%s"."`, token.Header["alg"]))) } }) @@ -101,30 +104,36 @@ func (a *AuthenticatorJWT) Authenticate(r *http.Request, config json.RawMessage, return nil, errors.WithStack(fosite.ErrInactiveToken) } - if len(cf.Scopes) > 0 { - + claims, ok := parsedToken.Claims.(jwt.MapClaims) + if !ok { + return nil, errors.Errorf("unable to type assert jwt claims to jwt.MapClaims") } - if !stringslice.Has(cf.Audience, parsedToken.Claims["aud"]) { - return nil, errors.WithStack(helper.ErrForbidden.WithReason(fmt.Sprintf("Token audience is not intended for target audience %s", audience))) + parsedClaims := jwtx.ParseMapStringInterfaceClaims(claims) + + for _, audience := range cf.Audience { + if !stringslice.Has(parsedClaims.Audience, audience) { + return nil, errors.WithStack(helper.ErrForbidden.WithReason(fmt.Sprintf("Token audience %v is not intended for target audience %s", parsedClaims.Audience, audience))) } + } - if !stringslice.Has(cf.Issuers, parsedToken.Claims["iss"]) { - return nil, errors.WithStack(helper.ErrForbidden.WithReason(fmt.Sprintf("Token issuer does not match any trusted issuer"))) + if len(cf.Issuers) > 0 { + if !stringslice.Has(cf.Issuers, parsedClaims.Issuer) { + return nil, errors.WithStack(helper.ErrForbidden.WithReason(fmt.Sprintf("Token issuer does not match any trusted issuer"))) + } } + tokenScope := mapx.GetStringSliceDefault(map[interface{}]interface{}{"scope": claims["scope"]}, "scope", []string{}) for _, scope := range cf.Scopes { - if !a.scopeStrategy(parsedToken.Claims["scope"], scope) { - - // TO BE DONE - // TO BE DONE - // TO BE DONE - // TO BE DONE - // TO BE DONE - // TO BE DONE - return nil, errors.WithStack(helper.ErrForbidden.WithReason(fmt.Sprintf("Token claims TO BE DONE"))) + if !a.scopeStrategy(tokenScope, scope) { + return nil, errors.WithStack(helper.ErrForbidden.WithReason(fmt.Sprintf("Token is missing required scope %s", scope))) } } + + return &AuthenticationSession{ + Subject: parsedClaims.Subject, + Extra: claims, + }, nil } func (a *AuthenticatorJWT) findRSAPublicKey(t *jwt.Token) (*rsa.PublicKey, error) { @@ -151,7 +160,7 @@ func (a *AuthenticatorJWT) findECDSAPublicKey(t *jwt.Token) (*ecdsa.PublicKey, e return nil, err } - if key, err := findRSAPublicKey(t, keys); err == nil { + if key, err := findECDSAPublicKey(t, keys); err == nil { return key, nil } @@ -160,16 +169,16 @@ func (a *AuthenticatorJWT) findECDSAPublicKey(t *jwt.Token) (*ecdsa.PublicKey, e return nil, err } - return findRSAPublicKey(t, keys) + return findECDSAPublicKey(t, keys) } -func (a *AuthenticatorJWT) findSharedKey(t *jwt.Token) (*rsa.PublicKey, error) { +func (a *AuthenticatorJWT) findSharedKey(t *jwt.Token) ([]byte, error) { keys, err := a.fetcher.Resolve(a.jwksURL, false) if err != nil { return nil, err } - if key, err := findRSAPublicKey(t, keys); err == nil { + if key, err := findSharedKey(t, keys); err == nil { return key, nil } @@ -185,12 +194,12 @@ func (a *AuthenticatorJWT) findSharedKey(t *jwt.Token) (*rsa.PublicKey, error) { func findRSAPublicKey(t *jwt.Token, set *jose.JSONWebKeySet) (*rsa.PublicKey, error) { kid, ok := t.Header["kid"].(string) if !ok { - return nil, errors.WithStack(helper.ErrForbidden.WithReason("The JSON Web Token must contain a kid header value but did not.")) + return nil, errors.WithStack(helper.ErrUnauthorized.WithReason("The JSON Web Token must contain a kid header value but did not.")) } keys := set.Key(kid) if len(keys) == 0 { - return nil, errors.WithStack(helper.ErrForbidden.WithReason(fmt.Sprintf("The JSON Web Token uses signing key with kid \"%s\", which could not be found.", kid))) + return nil, errors.WithStack(helper.ErrUnauthorized.WithReason(fmt.Sprintf("The JSON Web Token uses signing key with kid \"%s\", which could not be found.", kid))) } for _, key := range keys { @@ -202,18 +211,18 @@ func findRSAPublicKey(t *jwt.Token, set *jose.JSONWebKeySet) (*rsa.PublicKey, er } } - return nil, errors.WithStack(helper.ErrForbidden.WithReason(fmt.Sprintf("Unable to find RSA public key with use=\"sig\" for kid \"%s\" in JSON Web Key Set.", kid))) + return nil, errors.WithStack(helper.ErrUnauthorized.WithReason(fmt.Sprintf("Unable to find RSA public key with use=\"sig\" for kid \"%s\" in JSON Web Key Set.", kid))) } func findECDSAPublicKey(t *jwt.Token, set *jose.JSONWebKeySet) (*ecdsa.PublicKey, error) { kid, ok := t.Header["kid"].(string) if !ok { - return nil, errors.WithStack(helper.ErrForbidden.WithReason("The JSON Web Token must contain a kid header value but did not.")) + return nil, errors.WithStack(helper.ErrUnauthorized.WithReason("The JSON Web Token must contain a kid header value but did not.")) } keys := set.Key(kid) if len(keys) == 0 { - return nil, errors.WithStack(helper.ErrForbidden.WithReason(fmt.Sprintf("The JSON Web Token uses signing key with kid \"%s\", which could not be found.", kid))) + return nil, errors.WithStack(helper.ErrUnauthorized.WithReason(fmt.Sprintf("The JSON Web Token uses signing key with kid \"%s\", which could not be found.", kid))) } for _, key := range keys { @@ -225,18 +234,18 @@ func findECDSAPublicKey(t *jwt.Token, set *jose.JSONWebKeySet) (*ecdsa.PublicKey } } - return nil, errors.WithStack(helper.ErrForbidden.WithReason(fmt.Sprintf("Unable to find RSA public key with use=\"sig\" for kid \"%s\" in JSON Web Key Set.", kid))) + return nil, errors.WithStack(helper.ErrUnauthorized.WithReason(fmt.Sprintf("Unable to find RSA public key with use=\"sig\" for kid \"%s\" in JSON Web Key Set.", kid))) } func findSharedKey(t *jwt.Token, set *jose.JSONWebKeySet) ([]byte, error) { kid, ok := t.Header["kid"].(string) if !ok { - return nil, errors.WithStack(helper.ErrForbidden.WithReason("The JSON Web Token must contain a kid header value but did not.")) + return nil, errors.WithStack(helper.ErrUnauthorized.WithReason("The JSON Web Token must contain a kid header value but did not.")) } keys := set.Key(kid) if len(keys) == 0 { - return nil, errors.WithStack(helper.ErrForbidden.WithReason(fmt.Sprintf("The JSON Web Token uses signing key with kid \"%s\", which could not be found.", kid))) + return nil, errors.WithStack(helper.ErrUnauthorized.WithReason(fmt.Sprintf("The JSON Web Token uses signing key with kid \"%s\", which could not be found.", kid))) } for _, key := range keys { @@ -248,5 +257,5 @@ func findSharedKey(t *jwt.Token, set *jose.JSONWebKeySet) ([]byte, error) { } } - return nil, errors.WithStack(helper.ErrForbidden.WithReason(fmt.Sprintf("Unable to find shared key with use=\"sig\" for kid \"%s\" in JSON Web Key Set.", kid))) + return nil, errors.WithStack(helper.ErrUnauthorized.WithReason(fmt.Sprintf("Unable to find shared key with use=\"sig\" for kid \"%s\" in JSON Web Key Set.", kid))) } diff --git a/proxy/authenticator_jwt_test.go b/proxy/authenticator_jwt_test.go new file mode 100644 index 0000000000..359d3e18a1 --- /dev/null +++ b/proxy/authenticator_jwt_test.go @@ -0,0 +1,251 @@ +/* + * Copyright © 2017-2018 Aeneas Rekkas + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * @author Aeneas Rekkas + * @copyright 2017-2018 Aeneas Rekkas + * @license Apache-2.0 + */ + +package proxy + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/dgrijalva/jwt-go" + "github.com/ory/fosite" + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/square/go-jose.v2" +) + +var keys = map[string]interface{}{"HS256": []byte("some-secret")} + +func generateKeys(t *testing.T) { + rs, err := rsa.GenerateKey(rand.Reader, 1024) + require.NoError(t, err) + keys["RS256"] = rs + keys["RS256:public"] = rs.Public() + + es, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + keys["ES256"] = es + keys["ES256:public"] = es.Public() +} + +func generateJWT(t *testing.T, claims jwt.Claims, method string) string { + var sm jwt.SigningMethod + var key interface{} + var kid string + + switch method { + case "RS256": + key = keys[method] + sm = jwt.SigningMethodRS256 + kid = method + ":public" + case "ES256": + key = keys[method] + sm = jwt.SigningMethodES256 + kid = method + ":public" + case "HS256": + key = keys[method] + sm = jwt.SigningMethodHS256 + kid = method + } + + token := jwt.NewWithClaims(sm, claims) + token.Header["kid"] = kid + sign, err := token.SigningString() + require.NoError(t, err) + j, err := token.Method.Sign(sign, key) + require.NoError(t, err) + return fmt.Sprintf("%s.%s", sign, j) +} + +func TestAuthenticatorJWT(t *testing.T) { + generateKeys(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.NoError(t, json.NewEncoder(w).Encode(jose.JSONWebKeySet{ + Keys: []jose.JSONWebKey{ + {KeyID: "RS256:public", Use: "sig", Key: keys["RS256:public"]}, + {KeyID: "ES256:public", Use: "sig", Key: keys["ES256:public"]}, + {KeyID: "HS256", Use: "sig", Key: keys["HS256"]}, + }, + })) + })) + defer ts.Close() + + authenticator := NewAuthenticatorJWT(ts.URL, fosite.ExactScopeStrategy) + assert.NotEmpty(t, authenticator.GetID()) + now := time.Now().Round(time.Second) + + for k, tc := range []struct { + d string + r *http.Request + config string + expectErr bool + expectSess *AuthenticationSession + }{ + { + d: "should fail because no payloads", + r: &http.Request{Header: http.Header{}}, + expectErr: true, + }, + { + d: "should fail because not a jwt", + r: &http.Request{Header: http.Header{"Authorization": []string{"bearer invalid.token.sign"}}}, + expectErr: true, + }, + { + d: "should pass because JWT is valid", + r: &http.Request{Header: http.Header{"Authorization": []string{"bearer " + generateJWT(t, jwt.MapClaims{ + "sub": "sub", + "exp": now.Add(time.Hour).Unix(), + "aud": []string{"aud-1", "aud-2"}, + "iss": "iss-2", + "scope": []string{"scope-3", "scope-2", "scope-1"}, + }, "RS256")}}}, + config: `{"target_audience": ["aud-1", "aud-2"], "trusted_issuers": ["iss-1", "iss-2"], "required_scope": ["scope-1", "scope-2"]}`, + expectErr: false, + expectSess: &AuthenticationSession{ + Subject: "sub", + Extra: map[string]interface{}{ + "sub": "sub", + "exp": float64(now.Add(time.Hour).Unix()), + "aud": []interface{}{"aud-1", "aud-2"}, + "iss": "iss-2", + "scope": []interface{}{"scope-3", "scope-2", "scope-1"}, + }, + }, + }, + { + d: "should pass because JWT is valid and HS256 is allowed", + r: &http.Request{Header: http.Header{"Authorization": []string{"bearer " + generateJWT(t, jwt.MapClaims{ + "sub": "sub", + "exp": now.Add(time.Hour).Unix(), + }, "HS256")}}}, + expectErr: false, + config: `{ "allowed_algorithms": ["HS256"] }`, + expectSess: &AuthenticationSession{ + Subject: "sub", + Extra: map[string]interface{}{"sub": "sub", "exp": float64(now.Add(time.Hour).Unix())}, + }, + }, + { + d: "should pass because JWT is valid and ES256 is allowed", + r: &http.Request{Header: http.Header{"Authorization": []string{"bearer " + generateJWT(t, jwt.MapClaims{ + "sub": "sub", + "exp": now.Add(time.Hour).Unix(), + }, "ES256")}}}, + expectErr: false, + config: `{ "allowed_algorithms": ["ES256"] }`, + expectSess: &AuthenticationSession{ + Subject: "sub", + Extra: map[string]interface{}{"sub": "sub", "exp": float64(now.Add(time.Hour).Unix())}, + }, + }, + { + d: "should pass because JWT is valid", + r: &http.Request{Header: http.Header{"Authorization": []string{"bearer " + generateJWT(t, jwt.MapClaims{ + "sub": "sub", + "exp": now.Add(time.Hour).Unix(), + }, "RS256")}}}, + config: `{}`, + expectErr: false, + }, + { + d: "should fail because JWT nbf is in future", + r: &http.Request{Header: http.Header{"Authorization": []string{"bearer " + generateJWT(t, jwt.MapClaims{ + "sub": "sub", + "exp": now.Add(time.Hour).Unix(), + "nbf": now.Add(time.Hour).Unix(), + }, "RS256")}}}, + config: `{}`, + expectErr: true, + }, + { + d: "should fail because JWT iat is in future", + r: &http.Request{Header: http.Header{"Authorization": []string{"bearer " + generateJWT(t, jwt.MapClaims{ + "sub": "sub", + "exp": now.Add(time.Hour).Unix(), + "iat": now.Add(time.Hour).Unix(), + }, "RS256")}}}, + config: `{}`, + expectErr: true, + }, + { + d: "should pass because JWT is missing scope", + r: &http.Request{Header: http.Header{"Authorization": []string{"bearer " + generateJWT(t, jwt.MapClaims{ + "sub": "sub", + "exp": now.Add(time.Hour).Unix(), + "scope": []string{"scope-1", "scope-2"}, + }, "RS256")}}}, + config: `{"required_scope": ["scope-1", "scope-2", "scope-3"]}`, + expectErr: true, + }, + { + d: "should pass because JWT issuer is untrusted", + r: &http.Request{Header: http.Header{"Authorization": []string{"bearer " + generateJWT(t, jwt.MapClaims{ + "sub": "sub", + "exp": now.Add(time.Hour).Unix(), + "iss": "iss-4", + }, "RS256")}}}, + config: `{"trusted_issuers": ["iss-1", "iss-2", "iss-3"]}`, + expectErr: true, + }, + { + d: "should pass because JWT is missing audience", + r: &http.Request{Header: http.Header{"Authorization": []string{"bearer " + generateJWT(t, jwt.MapClaims{ + "sub": "sub", + "exp": now.Add(time.Hour).Unix(), + "aud": []string{"aud-1", "aud-2"}, + }, "RS256")}}}, + config: `{"required_audience": ["aud-1", "aud-2", "aud-3"]}`, + expectErr: true, + }, + { + d: "should fail because JWT is expired", + r: &http.Request{Header: http.Header{"Authorization": []string{"bearer " + generateJWT(t, jwt.MapClaims{ + "sub": "sub", + "exp": now.Add(-time.Hour).Unix(), + }, "RS256")}}}, + config: `{}`, + expectErr: true, + }, + } { + t.Run(fmt.Sprintf("case=%d/description=%s", k, tc.d), func(t *testing.T) { + session, err := authenticator.Authenticate(tc.r, json.RawMessage([]byte(tc.config)), nil) + if tc.expectErr { + require.Error(t, err) + } else { + require.NoError(t, err, "%#v", errors.Cause(err)) + } + + if tc.expectSess != nil { + assert.Equal(t, tc.expectSess, session) + } + }) + } +}