diff --git a/gateway/src/apicast/policy/token_introspection/apicast-policy.json b/gateway/src/apicast/policy/token_introspection/apicast-policy.json index b1e2b5e95..3b27794b4 100644 --- a/gateway/src/apicast/policy/token_introspection/apicast-policy.json +++ b/gateway/src/apicast/policy/token_introspection/apicast-policy.json @@ -11,7 +11,7 @@ "properties": { "auth_type": { "type": "string", - "enum": ["use_3scale_oidc_issuer_endpoint", "client_id+client_secret"], + "enum": ["use_3scale_oidc_issuer_endpoint", "client_id+client_secret", "client_secret_jwt"], "default": "client_id+client_secret" }, "max_ttl_tokens": { @@ -61,6 +61,37 @@ "required": [ "client_id", "client_secret", "introspection_url" ] + }, { + "properties": { + "auth_type": { + "describe": "Specify the Token Introspection Endpoint, Client ID, and Client Secret.", + "enum": ["client_secret_jwt"] + }, + "client_id": { + "description": "Client ID for the Token Introspection Endpoint", + "type": "string" + }, + "client_secret": { + "description": "Client Secret for the Token Introspection Endpoint", + "type": "string" + }, + "client_jwt_assertion_expires_in": { + "description": "Duration of the singed JWT in seconds", + "type": "integer", + "default": 60 + }, + "client_jwt_assertion_audience": { + "description": "Audience claim of the singed JWT", + "type": "string" + }, + "introspection_url": { + "description": "Introspection Endpoint URL", + "type": "string" + } + }, + "required": [ + "client_id", "client_secret", "introspection_url", "client_jwt_assertion_audience" + ] }] } } diff --git a/gateway/src/apicast/policy/token_introspection/token_introspection.lua b/gateway/src/apicast/policy/token_introspection/token_introspection.lua index 6d686589f..2c18ee499 100644 --- a/gateway/src/apicast/policy/token_introspection/token_introspection.lua +++ b/gateway/src/apicast/policy/token_introspection/token_introspection.lua @@ -7,6 +7,7 @@ local http_ng = require 'resty.http_ng' local user_agent = require 'apicast.user_agent' local resty_env = require('resty.env') local resty_url = require('resty.url') +local resty_jwt = require('resty.jwt') local tokens_cache = require('tokens_cache') @@ -27,11 +28,12 @@ function _M.new(config) self.auth_type = config.auth_type or "client_id+client_secret" --- authorization for the token introspection endpoint. -- https://tools.ietf.org/html/rfc7662#section-2.2 - if self.auth_type == "client_id+client_secret" then - self.client_id = self.config.client_id or '' - self.client_secret = self.config.client_secret or '' - self.introspection_url = config.introspection_url - end + -- TODO: check what if multiple values if provided + self.client_id = self.config.client_id or '' + self.client_secret = self.config.client_secret or '' + self.introspection_url = config.introspection_url + self.client_jwt_assertion_expires_in = self.config.client_jwt_assertion_expires_in or 60 + self.client_aud = config.client_jwt_assertion_audience or '' self.http_client = http_ng.new{ backend = config.client, options = { @@ -62,14 +64,43 @@ local function introspect_token(self, token) if cached_token_info then return cached_token_info end local headers = {} - if self.client_id and self.client_secret then - headers['Authorization'] = create_credential(self.client_id or '', self.client_secret or '') + + local body = { + token = token, + token_type_hint = 'access_token' + } + + if self.auth_type == "client_id+client_secret" or self.auth_type == "use_3scale_oidc_issuer_endpoint" then + if self.client_id and self.client_secret then + headers['Authorization'] = create_credential(self.client_id or '', self.client_secret or '') + end + elseif self.auth_type == "client_secret_jwt" then + local key = self.client_secret + body.client_id = self.client_id + body.client_assertion_type = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" + local now = ngx.time() + + local assertion = { + header = { + typ = "JWT", + alg = "HS256", + }, + payload = { + iss = self.client_id, + sub = self.client_id, + aud = self.client_aud, + jti = ngx.var.request_id, + exp = now + (self.client_jwt_assertion_expires_in and self.client_jwt_assertion_expires_in or 60), + iat = now + } + } + + body.client_assertion = resty_jwt:sign(key, assertion) end --- Parameters for the token introspection endpoint. -- https://tools.ietf.org/html/rfc7662#section-2.1 - local res, err = self.http_client.post{self.introspection_url , { token = token, token_type_hint = 'access_token'}, - headers = headers} + local res, err = self.http_client.post{self.introspection_url , body, headers = headers} if err then ngx.log(ngx.WARN, 'token introspection error: ', err, ' url: ', self.introspection_url) return { active = false } diff --git a/spec/policy/token_introspection/token_introspection_spec.lua b/spec/policy/token_introspection/token_introspection_spec.lua index 31833b5bd..519334eb6 100644 --- a/spec/policy/token_introspection/token_introspection_spec.lua +++ b/spec/policy/token_introspection/token_introspection_spec.lua @@ -3,6 +3,7 @@ local TokensCache = require('apicast.policy.token_introspection.tokens_cache') local format = string.format local test_backend_client = require('resty.http_ng.backend.test') local cjson = require('cjson') +local resty_jwt = require "resty.jwt" describe("token introspection policy", function() describe("execute introspection", function() local context @@ -22,6 +23,7 @@ describe("token introspection policy", function() test_backend = test_backend_client.new() ngx.var = {} ngx.var.http_authorization = "Bearer "..test_access_token + ngx.var.request_id = "1234" context = { service = { auth_failed_status = 403, @@ -330,6 +332,93 @@ describe("token introspection policy", function() end) + describe('client_secret_jwt introspection auth type', function() + local auth_type = "client_secret_jwt" + local introspection_url = "http://example/token/introspection" + local audience = "http://example/auth/realm/basic" + local policy_config = { + auth_type = auth_type, + introspection_url = introspection_url, + client_id = test_client_id, + client_secret = test_client_secret, + client_jwt_assertion_audience = audience, + } + + describe('success with valid token', function() + local token_policy = TokenIntrospection.new(policy_config) + before_each(function() + test_backend + .expect{ + url = introspection_url, + method = 'POST', + } + .respond_with{ + status = 200, + body = cjson.encode({ + active = true + }) + } + token_policy.http_client.backend = test_backend + token_policy:access(context) + end) + + it('the request does not contains basic auth header', function() + assert.is_nil(test_backend.get_requests()[1].headers['Authorization']) + end) + + it('the request does not contains client_secret in body', function() + local body = ngx.decode_args(test_backend.get_requests()[1].body) + assert.is_nil(body.client_secret) + end) + + it('the request contains correct fields in body', function() + local body = ngx.decode_args(test_backend.get_requests()[1].body) + assert.same(body.client_id, test_client_id) + assert.same(body.client_assertion_type, "urn:ietf:params:oauth:client-assertion-type:jwt-bearer") + assert.is_not_nil(body.client_assertion) + end) + + it("has correct JWT headers", function() + local body = ngx.decode_args(test_backend.get_requests()[1].body) + local jwt_obj = resty_jwt:load_jwt(body.client_assertion) + assert.same(jwt_obj.header.typ, "JWT") + assert.same(jwt_obj.header.alg, "HS256") + end) + + it("has correct JWT body", function() + local body = ngx.decode_args(test_backend.get_requests()[1].body) + local jwt_obj = resty_jwt:load_jwt(body.client_assertion) + assert.same(jwt_obj.payload.sub, test_client_id) + assert.same(jwt_obj.payload.iss, test_client_id) + assert.truthy(jwt_obj.signature) + assert.truthy(jwt_obj.payload.jti) + assert.truthy(jwt_obj.payload.exp) + assert.is_true(jwt_obj.payload.exp > os.time()) + end) + end) + + it('failed with invalid token', function() + test_backend + .expect{ + url = introspection_url, + method = 'POST', + } + .respond_with{ + status = 200, + body = cjson.encode({ + active = false + }) + } + stub(ngx, 'say') + stub(ngx, 'exit') + + local token_policy = TokenIntrospection.new(policy_config) + token_policy.http_client.backend = test_backend + token_policy:access(context) + assert_authentication_failed() + end) + end) + describe('when caching is enabled', function() local introspection_url = "http://example/token/introspection" local policy_config = { diff --git a/t/apicast-policy-token-introspection.t b/t/apicast-policy-token-introspection.t index 6fc95726a..cdabb4a74 100644 --- a/t/apicast-policy-token-introspection.t +++ b/t/apicast-policy-token-introspection.t @@ -666,3 +666,184 @@ my $jwt = encode_jwt(payload => { --- error_code: 403 --- no_error_log [error] + + + +=== TEST 11: Token introspection request success with client_secret_jwt method +--- backend + location /token/introspection { + content_by_lua_block { + ngx.req.read_body() + local args, err = ngx.req.get_post_args() + local assert = require('luassert') + assert.same(args.client_id, "app") + assert.same(args.client_assertion_type, "urn:ietf:params:oauth:client-assertion-type:jwt-bearer") + assert.is_not_nil(args.client_assertion) + + local resty_jwt = require "resty.jwt" + local jwt_obj = resty_jwt:load_jwt(args.client_assertion) + assert.same(jwt_obj.header.typ, "JWT") + assert.same(jwt_obj.header.alg, "HS256") + assert.same(jwt_obj.payload.sub, "app") + assert.same(jwt_obj.payload.iss, "app") + assert.same(jwt_obj.payload.aud, "http://test_backend/auth/realms/basic") + assert.truthy(jwt_obj.signature) + assert.truthy(jwt_obj.payload.jti) + assert.truthy(jwt_obj.payload.exp) + assert.is_true(jwt_obj.payload.exp > os.time()) + ngx.say('{"active": true}') + } + } + + location /transactions/oauth_authrep.xml { + content_by_lua_block { + ngx.exit(200) + } + } + +--- configuration +{ + "oidc": [ + { + "issuer": "https://example.com/auth/realms/apicast", + "config": { + "id_token_signing_alg_values_supported": [ "RS256" ], + "introspection_endpoint": "http://test_backend:$TEST_NGINX_SERVER_PORT/token/introspection" + }, + "keys": { "somekid": { "pem": "-----BEGIN PUBLIC KEY-----\nMFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBALClz96cDQ965ENYMfZzG+Acu25lpx2K\nNpAALBQ+catCA59us7+uLY5rjQR6SOgZpCz5PJiKNAdRPDJMXSmXqM0CAwEAAQ==\n-----END PUBLIC KEY-----", "alg": "RS256" } } + } + ], + "services": [ + { + "id": 42, + "backend_version": "oauth", + "backend_authentication_type": "service_token", + "backend_authentication_value": "token-value", + "proxy": { + "authentication_method": "oidc", + "oidc_issuer_endpoint": "http://app:appsec@test_backend:$TEST_NGINX_SERVER_PORT/issuer/endpoint", + "api_backend": "http://test:$TEST_NGINX_SERVER_PORT/", + "proxy_rules": [ + { "pattern": "/", "http_method": "GET", "metric_system_name": "hits", "delta": 1 } + ], + "policy_chain": [ + { + "name": "apicast.policy.token_introspection", + "configuration": { + "auth_type": "client_secret_jwt", + "client_id": "app", + "client_secret": "appsec", + "introspection_url": "http://test_backend:$TEST_NGINX_SERVER_PORT/token/introspection", + "client_jwt_assertion_audience": "http://test_backend/auth/realms/basic" + } + }, + { "name": "apicast.policy.apicast" } + ] + } + } + ] +} +--- upstream + location /echo { + content_by_lua_block { + ngx.say('yay, api backend'); + } + } +--- request +GET /echo +--- more_headers eval +use Crypt::JWT qw(encode_jwt); +my $jwt = encode_jwt(payload => { + aud => 'the_token_audience', + sub => 'someone', + iss => 'https://example.com/auth/realms/apicast', + exp => time + 3600 }, key => \$::rsa, alg => 'RS256', extra_headers => { kid => 'somekid' }); +"Authorization: Bearer $jwt" +--- error_code: 200 +--- response_body +yay, api backend +--- no_error_log +[error] +oauth failed with + + + +=== TEST 12: Token introspection request success with client_secret_jwt method + and invalid token +--- backend + location /token/introspection { + content_by_lua_block { + ngx.say('{"active": false}') + } + } + + location /transactions/oauth_authrep.xml { + content_by_lua_block { + ngx.exit(200) + } + } + +--- configuration +{ + "oidc": [ + { + "issuer": "https://example.com/auth/realms/apicast", + "config": { + "id_token_signing_alg_values_supported": [ "RS256" ], + "introspection_endpoint": "http://test_backend:$TEST_NGINX_SERVER_PORT/token/introspection" + }, + "keys": { "somekid": { "pem": "-----BEGIN PUBLIC KEY-----\nMFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBALClz96cDQ965ENYMfZzG+Acu25lpx2K\nNpAALBQ+catCA59us7+uLY5rjQR6SOgZpCz5PJiKNAdRPDJMXSmXqM0CAwEAAQ==\n-----END PUBLIC KEY-----", "alg": "RS256" } } + } + ], + "services": [ + { + "id": 42, + "backend_version": "oauth", + "backend_authentication_type": "service_token", + "backend_authentication_value": "token-value", + "proxy": { + "authentication_method": "oidc", + "oidc_issuer_endpoint": "http://app:appsec@test_backend:$TEST_NGINX_SERVER_PORT/issuer/endpoint", + "api_backend": "http://test:$TEST_NGINX_SERVER_PORT/", + "proxy_rules": [ + { "pattern": "/", "http_method": "GET", "metric_system_name": "hits", "delta": 1 } + ], + "policy_chain": [ + { + "name": "apicast.policy.token_introspection", + "configuration": { + "auth_type": "client_secret_jwt", + "client_id": "app", + "client_secret": "appsec", + "introspection_url": "http://test_backend:$TEST_NGINX_SERVER_PORT/token/introspection", + "client_jwt_assertion_audience": "http://test_backend/auth/realms/basic" + } + }, + { "name": "apicast.policy.apicast" } + ] + } + } + ] +} +--- upstream + location /echo { + content_by_lua_block { + ngx.say('yay, api backend'); + } + } +--- request +GET /echo +--- more_headers eval +use Crypt::JWT qw(encode_jwt); +my $jwt = encode_jwt(payload => { + aud => 'the_token_audience', + sub => 'someone', + iss => 'https://example.com/auth/realms/apicast', + exp => time + 3600 }, key => \$::rsa, alg => 'RS256', extra_headers => { kid => 'somekid' }); +"Authorization: Bearer $jwt" +--- error_code: 403 +--- response_body +Authentication failed +--- no_error_log +[error] +oauth failed with