diff --git a/gateway/src/apicast/policy/token_introspection/apicast-policy.json b/gateway/src/apicast/policy/token_introspection/apicast-policy.json index e238e4493..4e177e134 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", "client_secret_jwt"], + "enum": ["use_3scale_oidc_issuer_endpoint", "client_id+client_secret", "client_secret_jwt", "private_key_jwt"], "default": "client_id+client_secret" }, "max_ttl_tokens": { @@ -84,6 +84,11 @@ "description": "Audience. The aud claim of the singed JWT. The audience SHOULD be the URL of the Authorization Server’s Token Endpoint.", "type": "string" }, + "client_jwt_assertion_algorithm": { + "type": "string", + "enum": ["HS256"], + "default": "HS256" + }, "introspection_url": { "description": "Introspection Endpoint URL", "type": "string" @@ -92,6 +97,78 @@ "required": [ "client_id", "client_secret", "introspection_url", "client_jwt_assertion_audience" ] + }, { + "properties": { + "auth_type": { + "describe": "Authenticate with client_secret_jwt method defined in https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication", + "enum": ["private_key_jwt"] + }, + "client_id": { + "description": "Client ID for the Token Introspection Endpoint", + "type": "string" + }, + "introspection_url": { + "description": "Introspection Endpoint URL", + "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. The aud claim of the singed JWT. The audience SHOULD be the URL of the Authorization Server’s Token Endpoint.", + "type": "string" + }, + "client_jwt_assertion_algorithm": { + "type": "string", + "enum": ["RS256"], + "default": "RS256" + }, + "certificate_type": { + "title": "Certificate type", + "type": "string", + "enum": [ + "path", + "embedded" + ], + "default": "path" + } + }, + "dependencies": { + "certificate_type": { + "oneOf": [ + { + "properties": { + "certificate_type": { + "const": "embedded" + }, + "certificate": { + "title": "Certificate", + "description": "Client RSA private key used to sign JWT.", + "format": "data-url", + "type": "string" + } + } + }, + { + "properties": { + "certificate_type": { + "const": "path" + }, + "certificate": { + "title": "Certificate", + "description": "Client RSA private key used to sign JWT.", + "type": "string" + } + } + } + ] + } + }, + "required": [ + "client_id", "introspection_url", "client_jwt_assertion_audience", "certificate_type" + ] }] } } diff --git a/gateway/src/apicast/policy/token_introspection/token_introspection.lua b/gateway/src/apicast/policy/token_introspection/token_introspection.lua index c71edd2c2..36b188a1e 100644 --- a/gateway/src/apicast/policy/token_introspection/token_introspection.lua +++ b/gateway/src/apicast/policy/token_introspection/token_introspection.lua @@ -8,6 +8,9 @@ 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 data_url = require('resty.data_url') +local util = require 'apicast.util' +local pcall = pcall local tokens_cache = require('tokens_cache') @@ -17,6 +20,35 @@ local new = _M.new local noop = function() end local noop_cache = { get = noop, set = noop } +local path_type = "path" +local embedded_type = "embedded" + +local function get_cert(value, value_type) + if value_type == path_type then + ngx.log(ngx.DEBUG, "reading path:", value) + return util.read_file(value) + end + + if value_type == embedded_type then + local parsed_data, err = data_url.parse(value) + if err then + ngx.log(ngx.ERR, "Cannot parse certificate content: ", err) + return nil + end + return parsed_data.data + end +end + +-- lua-resty-jwt expects certificate as a string, thereforce we will return the certificate key +-- as is +local function read_certificate_key(value, value_type) + local data, err = get_cert(value, value_type) + if data == nil then + ngx.log(ngx.ERR, "Certificate value is invalid, err: ", err) + return + end + return data +end local function create_credential(client_id, client_secret) return 'Basic ' .. ngx.encode_base64(table.concat({ client_id, client_secret }, ':')) @@ -33,9 +65,16 @@ function _M.new(config) self.client_secret = self.config.client_secret or '' self.introspection_url = config.introspection_url - if self.auth_type == "client_secret_jwt" then + if self.auth_type == "client_secret_jwt" or self.auth_type == "private_key_jwt" then 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.client_algorithm = config.client_jwt_assertion_algorithm + end + + if self.auth_type == "private_key_jwt" then + self.client_rsa_private_key = read_certificate_key( + config.certificate, + config.certificate_type or path_type) end end self.http_client = http_ng.new{ @@ -76,8 +115,13 @@ local function introspect_token(self, token) if self.auth_type == "client_id+client_secret" or self.auth_type == "use_3scale_oidc_issuer_endpoint" then headers['Authorization'] = create_credential(self.client_id or '', self.client_secret or '') - elseif self.auth_type == "client_secret_jwt" then - local key = self.client_secret + elseif self.auth_type == "client_secret_jwt" or self.auth_type == "private_key_jwt" then + local key = self.auth_type == "client_secret_jwt" and self.client_secret or self.client_rsa_private_key + if not key then + ngx.log(ngx.WARN, "token introspection can't use " .. self.auth_type .. " without a key.") + return {active = false} + end + body.client_id = self.client_id body.client_assertion_type = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" local now = ngx.time() @@ -85,7 +129,7 @@ local function introspect_token(self, token) local assertion = { header = { typ = "JWT", - alg = "HS256", + alg = self.client_algorithm, }, payload = { iss = self.client_id, @@ -97,7 +141,15 @@ local function introspect_token(self, token) } } - body.client_assertion = resty_jwt:sign(key, assertion) + -- use pcall here to catch error throw by lua-rest-jwt sign method. + local ok, client_assertion = pcall(resty_jwt.sign, _M, key, assertion) + + if not ok then + ngx.log(ngx.WARN, "token introspection failed to sign JWT token ,err: ", client_assertion.reason) + return {active = false} + end + + body.client_assertion = client_assertion end --- Parameters for the token introspection endpoint. diff --git a/spec/policy/token_introspection/token_introspection_spec.lua b/spec/policy/token_introspection/token_introspection_spec.lua index 519334eb6..09577e22b 100644 --- a/spec/policy/token_introspection/token_introspection_spec.lua +++ b/spec/policy/token_introspection/token_introspection_spec.lua @@ -4,6 +4,8 @@ local format = string.format local test_backend_client = require('resty.http_ng.backend.test') local cjson = require('cjson') local resty_jwt = require "resty.jwt" +local util = require 'apicast.util' + describe("token introspection policy", function() describe("execute introspection", function() local context @@ -419,6 +421,134 @@ describe("token introspection policy", function() end) end) + describe('private_key_jwt introspection auth type', function() + local auth_type = "private_key_jwt" + local introspection_url = "http://example/token/introspection" + local audience = "http://example/auth/realm/basic" + local certificate_path = 't/fixtures/rsa.pem' + local path_type = "path" + local embedded_type = "embedded" + + describe("certificate validation", function() + it("Reads correctly the file path", function() + local policy_config = { + auth_type = auth_type, + introspection_url = introspection_url, + client_id = test_client_id, + client_jwt_assertion_audience = audience, + certificate = certificate_path, + certificate_type = path_type, + } + local token_policy = TokenIntrospection.new(policy_config) + assert.truthy(token_policy.client_rsa_private_key) + end) + + it("Read correctly the embedded path", function() + local policy_config = { + auth_type = auth_type, + introspection_url = introspection_url, + client_id = test_client_id, + client_jwt_assertion_audience = audience, + certificate_type = embedded_type, + certificate = "data:application/x-x509-ca-cert;name=rsa.pem;base64,LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlCUEFJQkFBSkJBTENsejk2Y0RROTY1RU5ZTWZaekcrQWN1MjVscHgyS05wQUFMQlErY2F0Q0E1OXVzNyt1CkxZNXJqUVI2U09nWnBDejVQSmlLTkFkUlBESk1YU21YcU0wQ0F3RUFBUUpCQUpud1phNEJJQUNWZjhhUVhUb0EKSmhLdjkwYkZuMVRHMWJXMzhMSFRtUXM4RU05WENtZ2hMV0NqZTdkL05iVXJVY2VvdElPbmp0di94SFR5d0d0MgpOd0VDSVFEaHZNWkRRK1pSUmJid09OY3ZPOUc3aDZoRmd5MG9raXY2SmNpWmNjdnR4UUloQU1oVVRBV2dWMWhRCk8yeVdUUllSUVpvc0VJc0ZCM2taZnNMTWVUS2prOGRwQWlFQXNsc1o5Mm05bjNkS3JKRHNqRmhpUlI1Uk9PTUYKR2lvcjd4Qk5aOWUrdmRVQ0lEc2pmNG5OcXR0Y1hCNlRSRkIyYWFwc3hibDBrNTh4WXBWNUxYSkFqZmk1QWlFQQp2UmFTYXVCZlJDUDNKZ1hITmdjRFNXMDE3L0J0YndHaXo4YUlUdjZCMEZ3PQotLS0tLUVORCBSU0EgUFJJVkFURSBLRVktLS0tLQo=" + } + local token_policy = TokenIntrospection.new(policy_config) + assert.truthy(token_policy.client_rsa_private_key) + end) + end) + + describe('success with valid token', function() + local policy_config = { + auth_type = auth_type, + introspection_url = introspection_url, + client_id = test_client_id, + client_jwt_assertion_audience = audience, + certificate = certificate_path, + certificate_type = path_type, + } + before_each(function() + test_backend + .expect{ + url = introspection_url, + method = 'POST', + } + .respond_with{ + status = 200, + body = cjson.encode({ + active = true + }) + } + local token_policy = TokenIntrospection.new(policy_config) + 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, "RS256") + 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() + 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, + certificate_type= "path", + certificate = certificate_path + } + 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 bef0889c2..a646f3a19 100644 --- a/t/apicast-policy-token-introspection.t +++ b/t/apicast-policy-token-introspection.t @@ -847,3 +847,187 @@ Authentication failed --- no_error_log [error] oauth failed with + + + +=== TEST 13: Token introspection request success with private_key_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, "RS256") + 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": "private_key_jwt", + "client_id": "app", + "introspection_url": "http://test_backend:$TEST_NGINX_SERVER_PORT/token/introspection", + "client_jwt_assertion_audience": "http://test_backend/auth/realms/basic", + "certificate_type": "embedded", + "certificate": "data:application/x-x509-ca-cert;name=rsa.pem;base64,LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlCUEFJQkFBSkJBTENsejk2Y0RROTY1RU5ZTWZaekcrQWN1MjVscHgyS05wQUFMQlErY2F0Q0E1OXVzNyt1CkxZNXJqUVI2U09nWnBDejVQSmlLTkFkUlBESk1YU21YcU0wQ0F3RUFBUUpCQUpud1phNEJJQUNWZjhhUVhUb0EKSmhLdjkwYkZuMVRHMWJXMzhMSFRtUXM4RU05WENtZ2hMV0NqZTdkL05iVXJVY2VvdElPbmp0di94SFR5d0d0MgpOd0VDSVFEaHZNWkRRK1pSUmJid09OY3ZPOUc3aDZoRmd5MG9raXY2SmNpWmNjdnR4UUloQU1oVVRBV2dWMWhRCk8yeVdUUllSUVpvc0VJc0ZCM2taZnNMTWVUS2prOGRwQWlFQXNsc1o5Mm05bjNkS3JKRHNqRmhpUlI1Uk9PTUYKR2lvcjd4Qk5aOWUrdmRVQ0lEc2pmNG5OcXR0Y1hCNlRSRkIyYWFwc3hibDBrNTh4WXBWNUxYSkFqZmk1QWlFQQp2UmFTYXVCZlJDUDNKZ1hITmdjRFNXMDE3L0J0YndHaXo4YUlUdjZCMEZ3PQotLS0tLUVORCBSU0EgUFJJVkFURSBLRVktLS0tLQo=" + } + }, + { "name": "apicast.policy.apicast" } + ] + } + } + ] +} +--- upstream + location /echo { + content_by_lua_block { + ngx.say('yay, api backend'); + } + } +--- user_files +--- 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 14: Token introspection request failed with private_key_jwt method +--- 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": "private_key_jwt", + "client_id": "app", + "introspection_url": "http://test_backend:$TEST_NGINX_SERVER_PORT/token/introspection", + "client_jwt_assertion_audience": "http://test_backend/auth/realms/basic", + "certificate_type": "embedded", + "certificate": "data:application/x-x509-ca-cert;name=rsa.pem;base64,LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlCUEFJQkFBSkJBTENsejk2Y0RROTY1RU5ZTWZaekcrQWN1MjVscHgyS05wQUFMQlErY2F0Q0E1OXVzNyt1CkxZNXJqUVI2U09nWnBDejVQSmlLTkFkUlBESk1YU21YcU0wQ0F3RUFBUUpCQUpud1phNEJJQUNWZjhhUVhUb0EKSmhLdjkwYkZuMVRHMWJXMzhMSFRtUXM4RU05WENtZ2hMV0NqZTdkL05iVXJVY2VvdElPbmp0di94SFR5d0d0MgpOd0VDSVFEaHZNWkRRK1pSUmJid09OY3ZPOUc3aDZoRmd5MG9raXY2SmNpWmNjdnR4UUloQU1oVVRBV2dWMWhRCk8yeVdUUllSUVpvc0VJc0ZCM2taZnNMTWVUS2prOGRwQWlFQXNsc1o5Mm05bjNkS3JKRHNqRmhpUlI1Uk9PTUYKR2lvcjd4Qk5aOWUrdmRVQ0lEc2pmNG5OcXR0Y1hCNlRSRkIyYWFwc3hibDBrNTh4WXBWNUxYSkFqZmk1QWlFQQp2UmFTYXVCZlJDUDNKZ1hITmdjRFNXMDE3L0J0YndHaXo4YUlUdjZCMEZ3PQotLS0tLUVORCBSU0EgUFJJVkFURSBLRVktLS0tLQo=" + } + }, + { "name": "apicast.policy.apicast" } + ] + } + } + ] +} +--- upstream + location /echo { + content_by_lua_block { + ngx.say('yay, api backend'); + } + } +--- user_files +--- 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