Skip to content

Commit

Permalink
Merge pull request #1466 from tkan145/THREESCALE-11019-fapi-advance-p…
Browse files Browse the repository at this point in the history
…rofile

[THREESCALE-11019] - FAPI advance profile
  • Loading branch information
tkan145 authored Jul 23, 2024
2 parents 4e7c87b + 97401f1 commit 34a4b0a
Show file tree
Hide file tree
Showing 6 changed files with 509 additions and 19 deletions.
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/).

- Bump openresty to 1.21.4.3 [PR #1461](https://github.com/3scale/APIcast/pull/1461) [THREESCALE-10601](https://issues.redhat.com/browse/THREESCALE-10601)

- Support Financial-grade API (FAPI) - Baseline profile [PR #1465](https://github.com/3scale/APIcast/pull/1465) [THREESCALE-10973](https://issues.redhat.com/browse/THREESCALE-10973)
- Support Financial-grade API (FAPI) 1.0 - Baseline profile [PR #1465](https://github.com/3scale/APIcast/pull/1465) [THREESCALE-10973](https://issues.redhat.com/browse/THREESCALE-10973)

- Support Financial-grade API (FAPI) 1.0 - Advance profile [PR #1465](https://github.com/3scale/APIcast/pull/1466) [THREESCALE-11019](https://issues.redhat.com/browse/THREESCALE-11019)

- Token Introspection Policy - Support `private_key_jwt` and `client_secret_jwt` authentication mode [PR #1464](https://github.com/3scale/APIcast/pull/1464) [THREESCALE-11015](https://issues.redhat.com/browse/THREESCALE-11015)

Expand Down
19 changes: 19 additions & 0 deletions gateway/src/apicast/policy/fapi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

The FAPI policy supports various features of the Financial-grade API (FAPI) standard.

* FAPI 1.0 Baseline Profile
* FAPI 1.0 Advance Profile

## Example configuration

```
Expand All @@ -30,3 +33,19 @@ The FAPI policy supports various features of the Financial-grade API (FAPI) stan
}
]
```

### Validate certificate-bound access tokens

```
"policy_chain": [
{
"name": "apicast.policy.fapi",
"configuration": {
"validate_oauth2_certificate_bound_access_token": true
}
},
{
"name": "apicast.policy.apicast"
}
]
```
9 changes: 8 additions & 1 deletion gateway/src/apicast/policy/fapi/apicast-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,14 @@
"type": "object",
"properties": {
"validate_x_fapi_customer_ip_address": {
"description": "Validate x-fapi-customer-ip-address header",
"description": "Validate x-fapi-customer-ip-address header. If the verification fails, the request will be rejected with 403",
"title": "Validate x-fapi-customer-ip-address header",
"type": "boolean",
"default": "false"
},
"validate_oauth2_certificate_bound_access_token ": {
"description": "Validate OAuth 2.0 Mutual TLS Certificate Bound access token. If enable, all tokens are verified and must contain the certificate hash claim (cnf). If the verification fails, the request will be rejected with 401.",
"title": "Validate OAuth 2.0 Mutual TLS Certificate Bound access token",
"type": "boolean",
"default": "false"
}
Expand Down
40 changes: 39 additions & 1 deletion gateway/src/apicast/policy/fapi/fapi.lua
Original file line number Diff line number Diff line change
@@ -1,16 +1,27 @@
--- Financial-grade API (FAPI) policy
-- Current support the following profile:
-- FAPI 1.0 - Baseline profile
-- FAPI 1.0 - Advance profile

local policy = require('apicast.policy')
local _M = policy.new('Financial-grade API (FAPI) Policy', 'builtin')

local uuid = require 'resty.jit-uuid'
local ipmatcher = require "resty.ipmatcher"
local X509 = require('resty.openssl.x509')
local b64 = require('ngx.base64')
local fmt = string.format

local new = _M.new
local X_FAPI_TRANSACTION_ID_HEADER = "x-fapi-transaction-id"
local X_FAPI_CUSTOMER_IP_ADDRESS = "x-fapi-customer-ip-address"

-- The "x5t#S256" (X.509 Certificate SHA-256 Thumbprint) Header
-- Parameter is a base64url encoded SHA-256 thumbprint (a.k.a. digest)
-- of the DER encoding of the X.509 certificate.
-- https://tools.ietf.org/html/rfc7515#section-4.1.8
local header_parameter = 'x5t#S256'

local function is_valid_ip(ip)
if type(ip) ~= "string" then
return false
Expand All @@ -29,16 +40,43 @@ local function error(status_code, msg)
ngx.exit(ngx.status)
end

-- https://datatracker.ietf.org/doc/html/rfc8705#name-jwt-certificate-thumbprint-
local function check_certificate(cert, cnf)
if not cert then
ngx.log(ngx.DEBUG, "client certificate is not available")
return false
end

if not cnf then
ngx.log(ngx.DEBUG, "cnf claim is not available")
return false
end
return b64.encode_base64url(cert:digest('SHA256')) == cnf[header_parameter]
end

--- Initialize FAPI policy
-- @tparam[config] table config
-- @field[config] validate_x_fapi_customer_ip_address Boolean
-- @field[config] validate_oauth2_certificate_bound_access_token Boolean
function _M.new(config)
local self = new(config)
self.validate_customer_ip_address = config and config.validate_x_fapi_customer_ip_address
self.validate_oauth2_certificate_bound_access_token = config and config.validate_oauth2_certificate_bound_access_token
return self
end

function _M:access()
function _M:access(context)
if self.validate_oauth2_certificate_bound_access_token then
if not context.jwt then return error(context.service or {}) end

local cert = X509.parse_pem_cert(ngx.var.ssl_client_raw_cert)

if not check_certificate(cert, context.jwt.cnf) then
ngx.log(ngx.WARN, 'fapi oauth_mtls failed for service ', context.service and context.service.id)
return error(ngx.HTTP_UNAUTHORIZED, "invalid_token")
end
end

--- 6.2.1.13
-- shall not reject requests with a x-fapi-customer-ip-address header containing a valid IPv4 or IPv6 address.
if self.validate_customer_ip_address then
Expand Down
113 changes: 99 additions & 14 deletions spec/policy/fapi/fapi_spec.lua
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
local FAPIPolicy = require('apicast.policy.fapi')
local uuid = require('resty.jit-uuid')
local X509 = require('resty.openssl.x509')
local b64 = require('ngx.base64')
local clientCert = assert(fixture('CA', 'client.crt'))

local header_parameter = 'x5t#S256'

local function jwt_cnf()
local cnf = b64.encode_base64url(X509.parse_pem_cert(clientCert):digest('SHA256'))
return { [header_parameter] = b64.encode_base64url(X509.parse_pem_cert(clientCert):digest('SHA256')) }
end

describe('fapi_1_baseline_profile policy', function()
local ngx_req_headers = {}
Expand Down Expand Up @@ -27,55 +37,130 @@ describe('fapi_1_baseline_profile policy', function()
describe('.header_filter', function()
it('Use value from request', function()
ngx_req_headers['x-fapi-transaction-id'] = 'abc'
local transaction_id_policy = FAPIPolicy.new({})
transaction_id_policy:header_filter()
local fapi_policy = FAPIPolicy.new({})
fapi_policy:header_filter()
assert.same('abc', ngx.header['x-fapi-transaction-id'])
end)

it('Only use x-fapi-transaction-id from request if the header also exist in response from upstream', function()
ngx_req_headers['x-fapi-transaction-id'] = 'abc'
ngx_resp_headers['x-fapi-transaction-id'] = 'bdf'
local transaction_id_policy = FAPIPolicy.new({})
transaction_id_policy:header_filter()
local fapi_policy = FAPIPolicy.new({})
fapi_policy:header_filter()
assert.same('abc', ngx.header['x-fapi-transaction-id'])
end)

it('Use x-fapi-transaction-id from upstream response', function()
ngx_resp_headers['x-fapi-transaction-id'] = 'abc'
local transaction_id_policy = FAPIPolicy.new({})
transaction_id_policy:header_filter()
local fapi_policy = FAPIPolicy.new({})
fapi_policy:header_filter()
assert.same('abc', ngx.header['x-fapi-transaction-id'])
end)

it('generate uuid if header does not exist in both request and response', function()
local transaction_id_policy = FAPIPolicy.new({})
transaction_id_policy:header_filter()
local fapi_policy = FAPIPolicy.new({})
fapi_policy:header_filter()
assert.is_true(uuid.is_valid(ngx.header['x-fapi-transaction-id']))
end)
end)

describe('x-fapi-customer-ip-address', function()
it('Allow request with valid IPv4', function()
ngx_req_headers['x-fapi-customer-ip-address'] = '127.0.0.1'
local transaction_id_policy = FAPIPolicy.new({validate_x_fapi_customer_ip_address=true})
transaction_id_policy:access()
local fapi_policy = FAPIPolicy.new({validate_x_fapi_customer_ip_address=true})
fapi_policy:access()
assert.stub(ngx.exit).was_not.called_with(403)
end)

it('Allow request with valid IPv6', function()
ngx_req_headers['x-fapi-customer-ip-address'] = '2001:db8::123:12:1'
local transaction_id_policy = FAPIPolicy.new({validate_x_fapi_customer_ip_address=true})
transaction_id_policy:access()
local fapi_policy = FAPIPolicy.new({validate_x_fapi_customer_ip_address=true})
fapi_policy:access()
assert.stub(ngx.exit).was_not.called_with(403)
end)

it('Reject request if header contains more than 1 IP', function()
ngx_req_headers['x-fapi-customer-ip-address'] = {"2001:db8::123:12:1", "127.0.0.1"}
local transaction_id_policy = FAPIPolicy.new({validate_x_fapi_customer_ip_address=true})
transaction_id_policy:access()
local fapi_policy = FAPIPolicy.new({validate_x_fapi_customer_ip_address=true})
fapi_policy:access()
assert.same(ngx.status, 403)
assert.stub(ngx.print).was.called_with('{"error": "invalid_request"}')
assert.stub(ngx.exit).was.called_with(403)
end)
end)
end)

describe('fapi_1 advance profile', function()
local context = {}
before_each(function()
context = {
jwt = {},
service = {
id = 4,
auth_failed_status = 403,
error_auth_failed = 'auth failed',
auth_failed_headers = 'text/plain; charset=utf-8'
}
}

ngx.header = {}
stub(ngx, 'print')
stub(ngx, 'exit')
context.jwt = {}
end)

it('accepts when the digest equals cnf claim', function()
ngx.var = { ssl_client_raw_cert = clientCert }
context.jwt.cnf = jwt_cnf()

local fapi_policy = FAPIPolicy.new({validate_oauth2_certificate_bound_access_token=true})
fapi_policy:access(context)
assert.stub(ngx.exit).was_not.called_with(403)
end)

it('rejects when the client certificate not found', function()
ngx.var = { ssl_client_raw_cert = nil }
context.jwt.cnf = jwt_cnf()

local fapi_policy = FAPIPolicy.new({validate_oauth2_certificate_bound_access_token=true})
fapi_policy:access(context)

assert.stub(ngx.exit).was_called_with(ngx.HTTP_UNAUTHORIZED)
assert.stub(ngx.print).was_called_with('{"error": "invalid_token"}')
end)

it('rejects when the cnf claim not defined', function()
ngx.var = { ssl_client_raw_cert = clientCert }

local fapi_policy = FAPIPolicy.new({validate_oauth2_certificate_bound_access_token=true})
fapi_policy:access(context)

assert.stub(ngx.exit).was_called_with(ngx.HTTP_UNAUTHORIZED)
assert.stub(ngx.print).was_called_with('{"error": "invalid_token"}')
end)

it('rejects when context.service is nil', function()
ngx.var = { ssl_client_raw_cert = clientCert }

local fapi_policy = FAPIPolicy.new({validate_oauth2_certificate_bound_access_token=true})
context.service = nil
fapi_policy:access(context)

assert.stub(ngx.exit).was_called_with(ngx.HTTP_UNAUTHORIZED)
assert.stub(ngx.print).was_called_with('{"error": "invalid_token"}')
end)

it('rejects when the digest not equals cnf claim', function()
ngx.var = { ssl_client_raw_cert = clientCert }

context.jwt.cnf = {}
context.jwt.cnf[header_parameter] = 'invalid_digest'

local fapi_policy = FAPIPolicy.new({validate_oauth2_certificate_bound_access_token=true})
context.service = nil
fapi_policy:access(context)

assert.stub(ngx.exit).was_called_with(ngx.HTTP_UNAUTHORIZED)
assert.stub(ngx.print).was_called_with('{"error": "invalid_token"}')
end)
end)
Loading

0 comments on commit 34a4b0a

Please sign in to comment.