Skip to content

Commit

Permalink
[jwt_parser] Ensure the policy only activate if the authentication mo…
Browse files Browse the repository at this point in the history
…de is not OIDC
  • Loading branch information
tkan145 committed Feb 17, 2025
1 parent cc1a05a commit cb81ca8
Show file tree
Hide file tree
Showing 4 changed files with 233 additions and 5 deletions.
65 changes: 65 additions & 0 deletions gateway/src/apicast/policy/jwt_parser/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# JWT Parser

JWT Parser is used to parse the JSON Web Token (JWT) in the `Authorization` header and stores it in the request context that can be shared with other policies.

If `required` flag is set to true and no JWT token is sent, APIcast will reject the request and send HTTP ``WWW-Authenticate`` response header.

NOTE: Not compatible with OIDC authentication mode. When this policy is added to a service configured with OIDC authentication mode, APIcast will print a warning about the incompatibility and ignore the policy.

## Example usage

With `JWT Claim Check` policy

```
"policy_chain": [
{
"name": "apicast.policy.jwt_parser",
"configuration": {
"issuer_endpoint": "http://red_hat_single_sign-on/auth/realms/foo"
"required": true
}
},
{
"name": "apicast.policy.jwt_claim_check",
"configuration": {
"error_message": "Invalid JWT check",
"rules": [
{
"operations": [
{"op": "==", "jwt_claim": "role", "jwt_claim_type": "plain", "value": "admin"}
],
"combine_op":"and",
"methods": ["GET"],
"resource": "/resource",
"resource_type": "plain"
}
]
}
}
]
```

With `Keycloak Role Check` policy

```
"policy_chain": [
{
"name": "apicast.policy.jwt_parser",
"configuration": {
"u": true
"issuer_endpoint": "http://red_hat_single_sign-on/auth/realms/foo"
}
},
{
"name": "apicast.policy.keycloak_role_check",
"configuration": {
"scopes": [
{
"realm_roles": [ { "name": "foo" } ],
"resource": "/confidential"
}
]
}
},
]
```
25 changes: 22 additions & 3 deletions gateway/src/apicast/policy/jwt_parser/jwt_parser.lua
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,21 @@ local function bearer_token()
return http_authorization.new(ngx.var.http_authorization).token
end

local function check_compatible(context)
local service = context.service or {}
local authentication = service.authentication_method or service.backend_version
if authentication == "oidc" or authentication == "oauth" then
ngx.log(ngx.WARN, 'jwt_parser is incompatible with OIDC authentication mode')
return false
end
return true
end

function _M:rewrite(context)
if not check_compatible(context) then
return
end

local access_token = bearer_token()

if access_token or self.required then
Expand All @@ -63,18 +77,23 @@ local function exit_status(status)
return ngx.exit(status)
end

local function challenge_response()
local function challenge_response(status)
ngx.header.www_authenticate = 'Bearer'

return exit_status(ngx.HTTP_UNAUTHORIZED)
return exit_status(status)
end

function _M:access(context)
if not check_compatible(context) then
ngx.log(ngx.WARN, 'jwt_parser is incompatible with OIDC authentication mode')
return
end

local jwt = context[self]

if not jwt or not jwt.token then
if self.required then
return challenge_response()
return challenge_response(context.service.auth_failed_status)
else
return
end
Expand Down
72 changes: 70 additions & 2 deletions spec/policy/jwt_parser/jwt_parser_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -111,10 +111,41 @@ describe('jwt_parser policy', function()
assert.is_table(context.jwt)
assert.contains({reason = 'invalid jwt string', valid = false }, context.jwt)
end)

it('ignore when authenticate mode is oidc', function()
local policy = _M.new()
local context = {
service = {
authentication_method = "oidc"
}
}

ngx.var.http_authorization = 'Bearer ' .. tostring(access_token)

policy:rewrite(context)

assert.falsy(context.jwt)
end)

it('ignore when backend_version is oauth', function()
local policy = _M.new()
local context = {
service = {
backend_version = "oauth"
}
}

ngx.var.http_authorization = 'Bearer ' .. 'invalid token value'

policy:rewrite(context)

assert.falsy(context.jwt)
end)
end)

describe(':access', function()


it('works without config', function()
_M.new():access({})
end)
Expand All @@ -123,7 +154,38 @@ describe('jwt_parser policy', function()
_M.new({}):access({})
end)

it('ignore when authenticate mode is oidc', function()
spy.on(ngx, 'exit')
local policy = _M.new()
local context = {
service = {
authentication_method = "oidc"
}
}

policy:access(context)
assert.spy(ngx.exit).was_not_called()
end)

it('ignore when backend_version is oauth', function()
spy.on(ngx, 'exit')
local policy = _M.new()
local context = {
service = {
backend_version = "oauth"
}
}

policy:access(context)
assert.spy(ngx.exit).was_not_called()
end)

context('when OIDC is required', function ()
local function assert_authentication_failed()
assert.same(ngx.status, 403)
assert.stub(ngx.say).was.called_with("auth failed")
assert.stub(ngx.exit).was.called_with(403)
end

local policy
before_each(function()
Expand All @@ -134,10 +196,16 @@ describe('jwt_parser policy', function()
spy.on(ngx, 'exit')

ngx.header = { }
policy:access({})
policy:access({
service = {
auth_failed_status = 403,
error_auth_failed = "auth failed"
}
})

assert.spy(ngx.exit).was_called_with(ngx.HTTP_UNAUTHORIZED)
assert.same('Bearer', ngx.header.www_authenticate)
assert.same(ngx.status, 403)
assert.stub(ngx.exit).was.called_with(403)
end)

it('returns forbidden on invalid token', function()
Expand Down
76 changes: 76 additions & 0 deletions t/apicast-policy-jwt_parser.t
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,79 @@ my $jwt = encode_jwt(payload => {
GET /echo HTTP/1.1
--- no_error_log
[error]
=== TEST 3: Uses OIDC Discovery to load OIDC configuration but ignore due to
incompatible authentication method
--- env eval
( 'APICAST_CONFIGURATION_LOADER' => 'lazy' )
--- backend
location = /realm/.well-known/openid-configuration {
content_by_lua_block {
local base = "http://" .. ngx.var.host .. ':' .. ngx.var.server_port
ngx.header.content_type = 'application/json;charset=utf-8'
ngx.say(require('cjson').encode {
issuer = 'https://example.com/auth/realms/apicast',
id_token_signing_alg_values_supported = { 'RS256' },
jwks_uri = base .. '/jwks',
})
}
}
location = /jwks {
content_by_lua_block {
ngx.header.content_type = 'application/json;charset=utf-8'
ngx.say([[
{ "keys": [
{ "kty":"RSA","kid":"somekid",
"n":"sKXP3pwND3rkQ1gx9nMb4By7bmWnHYo2kAAsFD5xq0IDn26zv64tjmuNBHpI6BmkLPk8mIo0B1E8MkxdKZeozQ","e":"AQAB",
"alg":"RS256" }
] }
]])
}
}
--- configuration
{
"oidc": [
{
"issuer": "https://example.com/auth/realms/apicast",
"config": { "id_token_signing_alg_values_supported": [ "RS256" ] },
"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": "https://example.com/auth/realms/apicast",
"policy_chain": [
{ "name": "apicast.policy.jwt_parser",
"configuration": {
"issuer_endpoint": "http://test_backend:$TEST_NGINX_SERVER_PORT/realm" } },
{ "name": "apicast.policy.echo" }
]
}
}
]
}
--- 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
GET /echo HTTP/1.1
--- error_log
jwt_parser is incompatible with OIDC authentication mode

0 comments on commit cb81ca8

Please sign in to comment.