Skip to content

Commit 4ae0eef

Browse files
committed
[fapi] Support OAuth 2.0 MTLS and Certificate-Bound Access Tokens
1 parent d6f753c commit 4ae0eef

File tree

6 files changed

+490
-5
lines changed

6 files changed

+490
-5
lines changed

CHANGELOG.md

+3-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
1515

1616
- 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)
1717

18-
- 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)
18+
- 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)
19+
20+
- 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)
1921

2022
## [3.15.0] 2024-04-04
2123

gateway/src/apicast/policy/fapi/README.md

+18
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
## Description
44

55
The FAPI policy supports various features of the Financial-grade API (FAPI) standard.
6+
* FAPI 1.0 Baseline Profile
7+
* FAPI 1.0 Advance Profile
68

79
## Example configuration
810

@@ -30,3 +32,19 @@ The FAPI policy supports various features of the Financial-grade API (FAPI) stan
3032
}
3133
]
3234
```
35+
36+
### Validate certificate-bound access tokens
37+
38+
```
39+
"policy_chain": [
40+
{
41+
"name": "apicast.policy.fapi",
42+
"configuration": {
43+
"enable_oauth2_mtls": true
44+
}
45+
},
46+
{
47+
"name": "apicast.policy.apicast"
48+
}
49+
]
50+
```

gateway/src/apicast/policy/fapi/apicast-config.json

+6-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,12 @@
99
"type": "object",
1010
"properties": {
1111
"validate_x_fapi_customer_ip_address": {
12-
"description": "Validate x-fapi-customer-ip-address header",
12+
"description": "Validate x-fapi-customer-ip-address header. If the verification fails, the request will be rejected with 403",
13+
"type": "boolean",
14+
"default": "false"
15+
},
16+
"enable_oauth2_mtls ": {
17+
"description": "Configure OAuth 2.0 Mutual TLS Client Authentication.If enable, all tokens are verified and must contain the certificate hash claim. If the verification fails, the request will be rejected with 401.",
1318
"type": "boolean",
1419
"default": "false"
1520
}

gateway/src/apicast/policy/fapi/fapi.lua

+37-1
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,27 @@
11
--- Financial-grade API (FAPI) policy
2+
-- Current support the following profile:
3+
-- FAPI 1.0 - Baseline profile
4+
-- FAPI 1.0 - Advance profile
25

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

69
local uuid = require 'resty.jit-uuid'
710
local ipmatcher = require "resty.ipmatcher"
11+
local X509 = require('resty.openssl.x509')
12+
local b64 = require('ngx.base64')
813
local fmt = string.format
914

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

19+
-- The "x5t#S256" (X.509 Certificate SHA-256 Thumbprint) Header
20+
-- Parameter is a base64url encoded SHA-256 thumbprint (a.k.a. digest)
21+
-- of the DER encoding of the X.509 certificate.
22+
-- https://tools.ietf.org/html/rfc7515#section-4.1.8
23+
local header_parameter = 'x5t#S256'
24+
1425
local function is_valid_ip(ip)
1526
if type(ip) ~= "string" then
1627
return false
@@ -29,16 +40,30 @@ local function error(status_code, msg)
2940
ngx.exit(ngx.status)
3041
end
3142

43+
local function check_certificate(cert, cnf)
44+
if not cert then
45+
ngx.log(ngx.DEBUG, "client certificate is not available")
46+
return false
47+
end
48+
49+
if not cnf then
50+
ngx.log(ngx.DEBUG, "cnf claim is not available")
51+
return false
52+
end
53+
return b64.encode_base64url(cert:digest('SHA256')) == cnf[header_parameter]
54+
end
55+
3256
--- Initialize FAPI policy
3357
-- @tparam[config] table config
3458
-- @field[config] validate_x_fapi_customer_ip_address Boolean
3559
function _M.new(config)
3660
local self = new(config)
3761
self.validate_customer_ip_address = config and config.validate_x_fapi_customer_ip_address
62+
self.enable_oauth2_mtls = config and config.enable_oauth2_mtls
3863
return self
3964
end
4065

41-
function _M:access()
66+
function _M:access(context)
4267
--- 6.2.1.13
4368
-- shall not reject requests with a x-fapi-customer-ip-address header containing a valid IPv4 or IPv6 address.
4469
if self.validate_customer_ip_address then
@@ -55,6 +80,17 @@ function _M:access()
5580
end
5681
end
5782
end
83+
84+
if self.enable_oauth2_mtls then
85+
if not context.jwt then return error(context.service or {}) end
86+
87+
local cert = X509.parse_pem_cert(ngx.var.ssl_client_raw_cert)
88+
89+
if not check_certificate(cert, context.jwt.cnf) then
90+
ngx.log(ngx.WARN, 'fapi oauth_mtls failed for service ', context.service and context.service.id)
91+
return error(ngx.HTTP_UNAUTHORIZED, "invalid_token")
92+
end
93+
end
5894
end
5995

6096
function _M:header_filter()

spec/policy/fapi/fapi_spec.lua

+85
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
local FAPIPolicy = require('apicast.policy.fapi')
22
local uuid = require('resty.jit-uuid')
3+
local X509 = require('resty.openssl.x509')
4+
local b64 = require('ngx.base64')
5+
local clientCert = assert(fixture('CA', 'client.crt'))
6+
7+
local header_parameter = 'x5t#S256'
8+
9+
local function jwt_cnf()
10+
local cnf = b64.encode_base64url(X509.parse_pem_cert(clientCert):digest('SHA256'))
11+
return { [header_parameter] = b64.encode_base64url(X509.parse_pem_cert(clientCert):digest('SHA256')) }
12+
end
313

414
describe('fapi_1_baseline_profile policy', function()
515
local ngx_req_headers = {}
@@ -77,3 +87,78 @@ describe('fapi_1_baseline_profile policy', function()
7787
end)
7888
end)
7989
end)
90+
91+
describe('fapi_1 advance profile', function()
92+
local context = {}
93+
before_each(function()
94+
context = {
95+
jwt = {},
96+
service = {
97+
id = 4,
98+
auth_failed_status = 403,
99+
error_auth_failed = 'auth failed',
100+
auth_failed_headers = 'text/plain; charset=utf-8'
101+
}
102+
}
103+
104+
ngx.header = {}
105+
stub(ngx, 'print')
106+
stub(ngx, 'exit')
107+
context.jwt = {}
108+
end)
109+
110+
it('accepts when the digest equals cnf claim', function()
111+
ngx.var = { ssl_client_raw_cert = clientCert }
112+
context.jwt.cnf = jwt_cnf()
113+
114+
local fapi_policy = FAPIPolicy.new({enable_oauth2_mtls=true})
115+
fapi_policy:access(context)
116+
assert.stub(ngx.exit).was_not.called_with(403)
117+
end)
118+
119+
it('rejects when the client certificate not found', function()
120+
ngx.var = { ssl_client_raw_cert = nil }
121+
context.jwt.cnf = jwt_cnf()
122+
123+
local fapi_policy = FAPIPolicy.new({enable_oauth2_mtls=true})
124+
fapi_policy:access(context)
125+
126+
assert.stub(ngx.exit).was_called_with(ngx.HTTP_UNAUTHORIZED)
127+
assert.stub(ngx.print).was_called_with('{"error": "invalid_token"}')
128+
end)
129+
130+
it('rejects when the cnf claim not defined', function()
131+
ngx.var = { ssl_client_raw_cert = clientCert }
132+
133+
local fapi_policy = FAPIPolicy.new({enable_oauth2_mtls=true})
134+
fapi_policy:access(context)
135+
136+
assert.stub(ngx.exit).was_called_with(ngx.HTTP_UNAUTHORIZED)
137+
assert.stub(ngx.print).was_called_with('{"error": "invalid_token"}')
138+
end)
139+
140+
it('rejects when context.service is nil', function()
141+
ngx.var = { ssl_client_raw_cert = clientCert }
142+
143+
local fapi_policy = FAPIPolicy.new({enable_oauth2_mtls=true})
144+
context.service = nil
145+
fapi_policy:access(context)
146+
147+
assert.stub(ngx.exit).was_called_with(ngx.HTTP_UNAUTHORIZED)
148+
assert.stub(ngx.print).was_called_with('{"error": "invalid_token"}')
149+
end)
150+
151+
it('rejects when the digest not equals cnf claim', function()
152+
ngx.var = { ssl_client_raw_cert = clientCert }
153+
154+
context.jwt.cnf = {}
155+
context.jwt.cnf[header_parameter] = 'invalid_digest'
156+
157+
local fapi_policy = FAPIPolicy.new({enable_oauth2_mtls=true})
158+
context.service = nil
159+
fapi_policy:access(context)
160+
161+
assert.stub(ngx.exit).was_called_with(ngx.HTTP_UNAUTHORIZED)
162+
assert.stub(ngx.print).was_called_with('{"error": "invalid_token"}')
163+
end)
164+
end)

0 commit comments

Comments
 (0)