From 6034084edda1419acdf4d156a5eb4b1d92205c8e Mon Sep 17 00:00:00 2001 From: Johannes Koch Date: Wed, 4 Jan 2023 11:17:53 +0100 Subject: [PATCH] use client authenticator in token introspector --- accesscontrol/introspection.go | 33 +++- config/introspection.go | 12 +- config/runtime/server.go | 2 +- .../2.configuration/4.block/introspection.md | 22 +++ server/http_oauth2_test.go | 42 ++++ server/testdata/oauth2/24_couper.hcl | 4 +- server/testdata/oauth2/25_couper.hcl | 180 ++++++++++++++++++ 7 files changed, 280 insertions(+), 15 deletions(-) create mode 100644 server/testdata/oauth2/25_couper.hcl diff --git a/accesscontrol/introspection.go b/accesscontrol/introspection.go index 65f64de4a..51157e97c 100644 --- a/accesscontrol/introspection.go +++ b/accesscontrol/introspection.go @@ -10,25 +10,34 @@ import ( "sync" "time" + "github.com/hashicorp/hcl/v2" + "github.com/avenga/couper/cache" "github.com/avenga/couper/config" "github.com/avenga/couper/config/request" "github.com/avenga/couper/eval" + "github.com/avenga/couper/oauth2" ) type Introspector struct { - conf *config.Introspection - memStore *cache.MemoryStore - mu sync.Mutex - transport http.RoundTripper + authenticator *oauth2.ClientAuthenticator + conf *config.Introspection + memStore *cache.MemoryStore + mu sync.Mutex + transport http.RoundTripper } -func NewIntrospector(conf *config.Introspection, transport http.RoundTripper, memStore *cache.MemoryStore) *Introspector { - return &Introspector{ - conf: conf, - memStore: memStore, - transport: transport, +func NewIntrospector(evalCtx *hcl.EvalContext, conf *config.Introspection, transport http.RoundTripper, memStore *cache.MemoryStore) (*Introspector, error) { + authenticator, err := oauth2.NewClientAuthenticator(evalCtx, conf.EndpointAuthMethod, "endpoint_auth_method", conf.ClientID, conf.ClientSecret, "", conf.JWTSigningProfile) + if err != nil { + return nil, err } + return &Introspector{ + authenticator: authenticator, + conf: conf, + memStore: memStore, + transport: transport, + }, nil } type IntrospectionResponse map[string]interface{} @@ -70,6 +79,12 @@ func (i *Introspector) Introspect(ctx context.Context, token string, exp, nbf in formParams := &url.Values{} formParams.Add("token", token) + + err := i.authenticator.Authenticate(formParams, req) + if err != nil { + return nil, err + } + eval.SetBody(req, []byte(formParams.Encode())) req = req.WithContext(outCtx) diff --git a/config/introspection.go b/config/introspection.go index ec461af85..d599a3eef 100644 --- a/config/introspection.go +++ b/config/introspection.go @@ -13,10 +13,14 @@ var ( ) type Introspection struct { - BackendName string `hcl:"backend,optional" docs:"References a [backend](/configuration/block/backend) in [definitions](/configuration/block/definitions) for introspection requests. Mutually exclusive with {backend} block."` - Endpoint string `hcl:"endpoint" docs:"The authorization server's {introspection_endpoint}."` - Remain hcl.Body `hcl:",remain"` - TTL string `hcl:"ttl" docs:"The time-to-live of a cached introspection response. With a non-positive value the introspection endpoint is called each time a token is validated." type:"duration"` + BackendName string `hcl:"backend,optional" docs:"References a [backend](/configuration/block/backend) in [definitions](/configuration/block/definitions) for introspection requests. Mutually exclusive with {backend} block."` + ClientID string `hcl:"client_id" docs:"The client identifier."` + ClientSecret string `hcl:"client_secret,optional" docs:"The client password. Required unless the {endpoint_auth_method} is {\"private_key_jwt\"}."` + Endpoint string `hcl:"endpoint" docs:"The authorization server's {introspection_endpoint}."` + EndpointAuthMethod *string `hcl:"endpoint_auth_method,optional" docs:"Defines the method to authenticate the client at the introspection endpoint. If set to {\"client_secret_post\"}, the client credentials are transported in the request body. If set to {\"client_secret_basic\"}, the client credentials are transported via Basic Authentication. If set to {\"client_secret_jwt\"}, the client is authenticated via a JWT signed with the {client_secret}. If set to {\"private_key_jwt\"}, the client is authenticated via a JWT signed with its private key (see {jwt_signing_profile} block)." default:"client_secret_basic"` + JWTSigningProfile *JWTSigningProfile `hcl:"jwt_signing_profile,block" docs:"Configures a [JWT signing profile](/configuration/block/jwt_signing_profile) to create a client assertion if {endpoint_auth_method} is either {\"client_secret_jwt\"} or {\"private_key_jwt\"}."` + Remain hcl.Body `hcl:",remain"` + TTL string `hcl:"ttl" docs:"The time-to-live of a cached introspection response. With a non-positive value the introspection endpoint is called each time a token is validated." type:"duration"` // Internally used Backend *hclsyntax.Body diff --git a/config/runtime/server.go b/config/runtime/server.go index dc6bc2808..bdb154bdc 100644 --- a/config/runtime/server.go +++ b/config/runtime/server.go @@ -654,7 +654,7 @@ func configureIntrospector(jwtConf *config.JWT, confContext *hcl.EvalContext, lo return nil, err } - return ac.NewIntrospector(jwtConf.Introspection, backend, memStore), nil + return ac.NewIntrospector(confContext, jwtConf.Introspection, backend, memStore) } type protectedOptions struct { diff --git a/docs/website/content/2.configuration/4.block/introspection.md b/docs/website/content/2.configuration/4.block/introspection.md index 874040af0..cc548708c 100644 --- a/docs/website/content/2.configuration/4.block/introspection.md +++ b/docs/website/content/2.configuration/4.block/introspection.md @@ -15,12 +15,30 @@ values: [ "name": "backend", "type": "string" }, + { + "default": "", + "description": "The client identifier.", + "name": "client_id", + "type": "string" + }, + { + "default": "", + "description": "The client password. Required unless the `endpoint_auth_method` is `\"private_key_jwt\"`.", + "name": "client_secret", + "type": "string" + }, { "default": "", "description": "The authorization server's `introspection_endpoint`.", "name": "endpoint", "type": "string" }, + { + "default": "\"client_secret_basic\"", + "description": "Defines the method to authenticate the client at the introspection endpoint. If set to `\"client_secret_post\"`, the client credentials are transported in the request body. If set to `\"client_secret_basic\"`, the client credentials are transported via Basic Authentication. If set to `\"client_secret_jwt\"`, the client is authenticated via a JWT signed with the `client_secret`. If set to `\"private_key_jwt\"`, the client is authenticated via a JWT signed with its private key (see `jwt_signing_profile` block).", + "name": "endpoint_auth_method", + "type": "string" + }, { "default": "", "description": "The time-to-live of a cached introspection response. With a non-positive value the introspection endpoint is called each time a token is validated.", @@ -38,6 +56,10 @@ values: [ { "description": "Configures a [backend](/configuration/block/backend) for introspection requests (zero or one). Mutually exclusive with `backend` attribute.", "name": "backend" + }, + { + "description": "Configures a [JWT signing profile](/configuration/block/jwt_signing_profile) to create a client assertion if `endpoint_auth_method` is either `\"client_secret_jwt\"` or `\"private_key_jwt\"`.", + "name": "jwt_signing_profile" } ] diff --git a/server/http_oauth2_test.go b/server/http_oauth2_test.go index ebf3e8b62..a7fcd5f44 100644 --- a/server/http_oauth2_test.go +++ b/server/http_oauth2_test.go @@ -2394,3 +2394,45 @@ func Test_OAuth2_Introspection_NonCaching(t *testing.T) { t.Errorf("Expected error type\nWant:\t%s\nGot:\t%s", expErrorType, et) } } + +func Test_OAuth2_Introspection_AuthnMethods(t *testing.T) { + helper := test.New(t) + + shutdown, _ := newCouper("testdata/oauth2/25_couper.hcl", test.New(t)) + defer shutdown() + + client := newClient() + + // get token + req, err := http.NewRequest(http.MethodGet, "http://1.1.1.1:9999/token", nil) + helper.Must(err) + res, err := client.Do(req) + helper.Must(err) + token := res.Header.Get("access-token") + + type testCase struct { + name string + path string + } + + for _, tc := range []testCase{ + {"client_secret_basic", "/csb"}, + {"client_secret_post", "/csp"}, + {"client_secret_jwt", "/csj"}, + {"private_key_jwt", "/pkj"}, + } { + t.Run(tc.name, func(subT *testing.T) { + h := test.New(subT) + + // get resource + req, err = http.NewRequest(http.MethodGet, "http://anyserver:8080"+tc.path, nil) + h.Must(err) + req.Header.Set("Authorization", "Bearer "+token) + res, err = client.Do(req) + h.Must(err) + if res.StatusCode != http.StatusNoContent { + subT.Errorf("Expected status code 204, got %d", res.StatusCode) + } + }) + } +} diff --git a/server/testdata/oauth2/24_couper.hcl b/server/testdata/oauth2/24_couper.hcl index 4ee20ccb1..a186a08b8 100644 --- a/server/testdata/oauth2/24_couper.hcl +++ b/server/testdata/oauth2/24_couper.hcl @@ -2,7 +2,7 @@ server { api "as" { base_path = "/as" - endpoint "/token" { + endpoint "/token" { # not proper OAuth2 token endpoint, but token is easier to extract from header response { headers = { access-token = jwt_sign("at", {}) @@ -31,6 +31,8 @@ definitions { introspection { endpoint = "{{.asOrigin}}/introspect" ttl = "{{.ttl}}" + client_id = "the_rs" + client_secret = "the_rs_asdf" } } } diff --git a/server/testdata/oauth2/25_couper.hcl b/server/testdata/oauth2/25_couper.hcl new file mode 100644 index 000000000..db8e5b09e --- /dev/null +++ b/server/testdata/oauth2/25_couper.hcl @@ -0,0 +1,180 @@ +server "rs" { + hosts = ["*:8080"] + + api { + endpoint "/csb" { + access_control = ["at_in_csb"] + + response { + status = 204 + } + } + + endpoint "/csp" { + access_control = ["at_in_csp"] + + response { + status = 204 + } + } + + endpoint "/csj" { + access_control = ["at_in_csj"] + + response { + status = 204 + } + } + + endpoint "/pkj" { + access_control = ["at_in_pkj"] + + response { + status = 204 + } + } + } +} + +definitions { + # for rs + jwt "at_in_csb" { + signature_algorithm = "HS256" + key = "asdf" + + introspection { + endpoint = "http://1.1.1.1:9999/introspect/csb" + ttl = "0s" + client_id = "the_rs" + client_secret = "the_rs_asdf" + } + } + + jwt "at_in_csp" { + signature_algorithm = "HS256" + key = "asdf" + + introspection { + endpoint = "http://1.1.1.1:9999/introspect/csp" + endpoint_auth_method = "client_secret_post" + ttl = "0s" + client_id = "the_rs" + client_secret = "the_rs_asdf" + } + } + + jwt "at_in_csj" { + signature_algorithm = "HS256" + key = "asdf" + + introspection { + endpoint = "http://1.1.1.1:9999/introspect/csj" + endpoint_auth_method = "client_secret_jwt" + ttl = "0s" + client_id = "the_rs" + client_secret = "the_rs_asdf" + + jwt_signing_profile { + signature_algorithm = "HS256" + ttl = "10s" + } + } + } + + jwt "at_in_pkj" { + signature_algorithm = "HS256" + key = "asdf" + + introspection { + endpoint = "http://1.1.1.1:9999/introspect/pkj" + endpoint_auth_method = "private_key_jwt" + ttl = "0s" + client_id = "the_rs" + + jwt_signing_profile { + signature_algorithm = "RS256" + key_file = "./pkcs8.key" + ttl = "10s" + } + } + } + + # for as + jwt_signing_profile "at" { + signature_algorithm = "HS256" + key = "asdf" + ttl = "60s" + } + + basic_auth "ba_csb" { + user = "the_rs" + password = "the_rs_asdf" + } + + jwt "jwt_csj" { + signature_algorithm = "HS256" + key = "the_rs_asdf" + token_value = request.form_body.client_assertion[0] + claims = { + iss = "the_rs" + sub = "the_rs" + } + required_claims = ["exp", "iat", "jti"] + } + + jwt "jwt_pkj" { + signature_algorithm = "RS256" + key_file = "./certificate.pem" + token_value = request.form_body.client_assertion[0] + claims = { + iss = "the_rs" + sub = "the_rs" + } + required_claims = ["exp", "iat", "jti"] + } +} + +server "as" { + hosts = ["*:9999"] + + api { + endpoint "/token" { # not proper OAuth2 token endpoint, but token is easier to extract from header + response { + headers = { + access-token = jwt_sign("at", {}) + } + } + } + + endpoint "/introspect/csb" { + access_control = ["ba_csb"] + + response { + json_body = {active: true} + } + } + + endpoint "/introspect/csp" { + response { + status = request.form_body.client_id[0] == "the_rs" && request.form_body.client_secret[0] == "the_rs_asdf" ? 200 : 401 + json_body = {active: true} + } + } + + endpoint "/introspect/csj" { + access_control = ["jwt_csj"] + + response { + json_body = {active: true} + } + } + + endpoint "/introspect/pkj" { + access_control = ["jwt_pkj"] + + response { + json_body = {active: true} + } + } + } +}