From cb81ca840e6a0ebad261bcb11dd5d186e9f63f93 Mon Sep 17 00:00:00 2001 From: An Tran Date: Mon, 17 Feb 2025 11:47:28 +1000 Subject: [PATCH] [jwt_parser] Ensure the policy only activate if the authentication mode is not OIDC --- .../src/apicast/policy/jwt_parser/README.md | 65 ++++++++++++++++ .../apicast/policy/jwt_parser/jwt_parser.lua | 25 +++++- spec/policy/jwt_parser/jwt_parser_spec.lua | 72 +++++++++++++++++- t/apicast-policy-jwt_parser.t | 76 +++++++++++++++++++ 4 files changed, 233 insertions(+), 5 deletions(-) create mode 100644 gateway/src/apicast/policy/jwt_parser/README.md diff --git a/gateway/src/apicast/policy/jwt_parser/README.md b/gateway/src/apicast/policy/jwt_parser/README.md new file mode 100644 index 000000000..69ffb0446 --- /dev/null +++ b/gateway/src/apicast/policy/jwt_parser/README.md @@ -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" + } + ] + } + }, +] +``` diff --git a/gateway/src/apicast/policy/jwt_parser/jwt_parser.lua b/gateway/src/apicast/policy/jwt_parser/jwt_parser.lua index 9ff100018..7ec549c80 100644 --- a/gateway/src/apicast/policy/jwt_parser/jwt_parser.lua +++ b/gateway/src/apicast/policy/jwt_parser/jwt_parser.lua @@ -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 @@ -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 diff --git a/spec/policy/jwt_parser/jwt_parser_spec.lua b/spec/policy/jwt_parser/jwt_parser_spec.lua index ee9f113c6..46bd44bc8 100644 --- a/spec/policy/jwt_parser/jwt_parser_spec.lua +++ b/spec/policy/jwt_parser/jwt_parser_spec.lua @@ -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) @@ -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() @@ -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() diff --git a/t/apicast-policy-jwt_parser.t b/t/apicast-policy-jwt_parser.t index 8ed071cd7..1389e4921 100644 --- a/t/apicast-policy-jwt_parser.t +++ b/t/apicast-policy-jwt_parser.t @@ -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 +