Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[THREESCALE-11019] - FAPI advance profile #1466

Merged
merged 2 commits into from
Jul 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,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)

## [3.15.0] 2024-04-04

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
Loading