diff --git a/README.md b/README.md index a8daa936..73b3cdae 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,13 @@ The diagram below shows the message exchange between the involved parties. ![alt Kong OIDC flow](docs/kong_oidc_flow.png) +For security purposes the following headers are stripped at the beginning of this plugins execution: +* `X-Access-Token` +* `X-ID-Token` +* `X-Userinfo` + +These headers will only be appended to the requests if the user is authenticated or has a valid session. + The `X-Userinfo` header contains the payload from the Userinfo Endpoint ``` @@ -45,6 +52,32 @@ ngx.ctx.authenticated_consumer = { } ``` +### XMLHttp/Ajax Requests + +XMLHttpRequests made by client-side code (i.e ajax) should include the `X-Requested-With: XMLHttpRequest` header. 302 Redirects are replaced with 401 Unauthorized HTTP responses when this header is present AND the user is unauthenticated. + +#### Why? + +302 redirects are followed transparently via XMLHttpRequests (xhr/ajax requests) thus there is nothing the client side can do to detect if a 302 happened. Returning a status code of 401 allows the client to respond to the request accordingly. + +The response body of this 401 is as follows: + +``` +{ + "status":401, + "request_path":"/api/path" +} +``` + +Currently we do NOT have access to the redirect url that **lua-resty-openidc** would normally generate thus we only respond with the above body. When **lua-resty-openidc** exposes the method generating the authorization code path uri then we change the http response body the following: + +``` +{ + "status":302, + "request_path":"/api/path", + "redirect_path":"https://idp.com/oauth/authorize?client_id=a17c21ed&response_type=code..." +} +``` ## Dependencies @@ -80,20 +113,20 @@ For full support and functionality you should have a `lua_shared_dict` with the ### Parameters -| Parameter | Default | Required | description | -| ------------------------------------------- | ---------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -| `name` | | true | plugin name, has to be `oidc` | -| `config.client_id` | | true | OIDC Client ID | -| `config.client_secret` | | true | OIDC Client secret | -| `config.discovery` | https://.well-known/openid-configuration | true | OIDC Discovery Endpoint (`/.well-known/openid-configuration`) | -| `config.discovery_override` | | false | This is a **map** type with multiple properties. See [Discovery Override](#discovery-override) below. | -| `config.scope` | openid | false | OAuth2 Token scope. To use OIDC it has to contains the `openid` scope. Note if using `refresh_token` grant then include `offline_access` as a scope. | -| `config.ssl_verify` | false | false | Enable SSL verification to OIDC Provider | -| `config.session_secret` | | false | Additional parameter, which is used to encrypt the session cookie. Needs to be random | -| `config.introspection_endpoint_auth_method` | client_secret_basic | false | Token introspection auth method. resty-openidc supports `client_secret_(basic|post)` | -| `config.introspection_expiry_claim` | | false | Claim name that will be checked to determine cache ttl | -| `config.introspection_cache_ignore` | false | false | Forces cache to NOT be used | -| `config.introspection_interval` | | false | TTL that can be used to overwrite token `expiry_claim` ttl (will only be used if shorter then `expiry_claim`) | +| Parameter | Default | Required | description | +| ------------------------------------------- | ---------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `name` | | true | plugin name, has to be `oidc` | +| `config.client_id` | | true | OIDC Client ID | +| `config.client_secret` | | true | OIDC Client secret | +| `config.discovery` | https://.well-known/openid-configuration | true | OIDC Discovery Endpoint (`/.well-known/openid-configuration`) | +| `config.discovery_override` | | false | This is a **map** type with multiple properties. See [Discovery Override](#discovery-override) below. | +| `config.scope` | openid | false | OAuth2 Token scope. To use OIDC it has to contains the `openid` scope. Note if using `refresh_token` grant then include `offline_access` as a scope. | +| `config.ssl_verify` | false | false | Enable SSL verification to OIDC Provider | +| `config.session_secret` | | false | Additional parameter, which is used to encrypt the session cookie. Needs to be random | +| `config.introspection_endpoint_auth_method` | client_secret_basic | false | Token introspection auth method. resty-openidc supports `client_secret_(basic|post)` | +| `config.introspection_expiry_claim` | | false | Claim name that will be checked to determine cache ttl | +| `config.introspection_cache_ignore` | false | false | Forces cache to NOT be used | +| `config.introspection_interval` | | false | TTL that can be used to overwrite token `expiry_claim` ttl (will only be used if shorter then `expiry_claim`) | | `config.timeout` | | false | OIDC endpoint calls timeout | | `config.bearer_only` | no | false | Only introspect tokens without redirecting | | `config.realm` | kong | false | Realm used in WWW-Authenticate response header | @@ -101,6 +134,7 @@ For full support and functionality you should have a `lua_shared_dict` with the | `config.redirect_uri` | | true | URI (absolute, e.g. http://website.com) to which authorization code is sent back from OIDC Provider | | `config.prompt` | | false | Valid values include `none`, `login`, `consent` and/or `select_account`. Note if using `refresh_token` grant then `consent` is required. See [https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest](https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest) | | `config.session` | `{ cookie = { samesite = 'Lax' }}` | false | See [OIDC Session Config](#oidc-session-config) | +| `config.force_authentication_path` | | false | See [force_authentication_path Parameter](#force_authentication_path-parameter) | #### Discovery Override @@ -153,6 +187,18 @@ These properties are provided to `session.start(opts)`, for more information on - nonce - Replay attack mitigation - last_authenticated - used for silent reauthentication +#### force_authentication_path Parameter + +By default, the **kong oidc** plugin prevents unauthenticated requests from reaching the upstream api. When the `force_authentication_path` parameter is set, the behavior is changed. + +Setting the `force_authentication_path` parameter changes the plugin behavior to allow unauthenticated request to reach the upstream API. Unauthenticated requests will be proxied without `x-userinfo` headers. Authenticated requests will be proxied with `x-userinfo`. + +The `force_authentication_path` variable should be a *string* relative url path value (e.g `/api/auth/login`). When a request is made to the defined path if the user is unauthenticated then the plugin will respond with a 302 HTTP status code to redirect the user to the IDP login page (authentication code flow). + +The following diagram illustrates how the behavior of **kong-oidc** plugin when the parameter `force_authentication_path` is set. + +![alt Kong OIDC force authentication path](docs/kong_oidc_force_auth_path.png) + ### Enabling To enable the plugin only for one API: @@ -206,7 +252,7 @@ Server: kong/0.11.0 ### Upstream API request -The plugin adds an additional `X-Userinfo`, `X-Access-Token` and `X-Id-Token` headers to the upstream request, which can be consumer by upstream server. All of them are base64 encoded: +The plugin adds an additional `X-Userinfo`, `X-Access-Token` and `X-Id-Token` headers to the upstream request, which can be consumer by upstream server. Note if these headers were present in the request prior to the execution of this plugin, then they will be removed/overwritten. All of them are base64 encoded: ``` GET / HTTP/1.1 @@ -232,6 +278,10 @@ X-Id-Token: eyJuYmYiOjAsImF6cCI6ImtvbmciLCJpYXQiOjE1NDg1MTA3NjksImlzcyI6Imh0dHA6 ## Development +The following references are useful to those that are new to kong plugin development: +* [https://docs.konghq.com/1.5.x/plugin-development/file-structure/](https://docs.konghq.com/1.5.x/plugin-development/file-structure/) +* [https://docs.konghq.com/1.5.x/plugin-development/custom-logic/](https://docs.konghq.com/1.5.x/plugin-development/custom-logic/) + ### Running Unit Tests To run unit tests, run the following command: diff --git a/docs/kong_oidc_force_auth_path.png b/docs/kong_oidc_force_auth_path.png new file mode 100644 index 00000000..96475bde Binary files /dev/null and b/docs/kong_oidc_force_auth_path.png differ diff --git a/docs/src/pass_flow.mmd b/docs/src/pass_flow.mmd new file mode 100644 index 00000000..4bbecaea --- /dev/null +++ b/docs/src/pass_flow.mmd @@ -0,0 +1,22 @@ +title Kong OIDC Plugin - "pass" functionality + +participant User +participant OIDC Provider +participant Kong +participant Upstream API + +Note over User,Upstream API: When user isn't authenticated +User->Kong: GET / +Kong->Upstream API: GET / without x-userinfo +Upstream API->User: HTTP response + +Note over User,Upstream API: When user isn't authenticated (force_authentication_path) +User->Kong: GET /force_authentication_path +Kong->User: Redirect to OIDC Provider for Authorization Grant +User->+OIDC Provider: Login +OIDC Provider->-User: Redirect to Kong with Authorization Grant +note right of User: See "How does Kong OIDC work?" diagram for rest of sequence. + +Note over User,Upstream API: When user is authenticated +User->Kong: GET / +Kong->Upstream API: GET / with x-userinfo diff --git a/kong/plugins/oidc/handler.lua b/kong/plugins/oidc/handler.lua index 945af28a..511e7afe 100644 --- a/kong/plugins/oidc/handler.lua +++ b/kong/plugins/oidc/handler.lua @@ -5,10 +5,10 @@ local filter = require("kong.plugins.oidc.filter") local session = require("kong.plugins.oidc.session") local cjson = require("cjson") local openidc = require("resty.openidc") +local constants = require("kong.plugins.oidc.util.constants") OidcHandler.PRIORITY = 1000 - function OidcHandler:new() OidcHandler.super.new(self, "oidc") end @@ -31,6 +31,9 @@ end function handle(oidcConfig, oidcSessionConfig) local response + -- clear oidc plugin headers to prevent spoofing of info to upstream api + utils.clear_request_headers() + -- get/cache discovery data, mutate oidcConfig.discovery if it is a string (discovery endpoint) openidc.get_discovery_doc(oidcConfig) @@ -41,7 +44,7 @@ function handle(oidcConfig, oidcSessionConfig) if response then local access_token = utils.get_bearer_access_token_from_header(oidcConfig) local userinfo, err = get_userinfo(oidcConfig, response) - + -- @todo: how can we distinguish between access_token and id_token? -- err can occur due to id_token being used for authorization header instead of access_token if err or not userinfo then @@ -49,7 +52,7 @@ function handle(oidcConfig, oidcSessionConfig) -- introspect passed but userinfo failed, set userinfo to decoded token instead of leaving blank userinfo = response end - + response = { access_token = access_token, user = userinfo @@ -93,22 +96,29 @@ function make_oidc(oidcConfig, oidcSessionConfig) end end - -- grab X-Requested-With Header to see if request was from browser/ajax - local unauth_action = nil local ngx_headers = ngx.req.get_headers() - if ngx_headers then - local xhr_value = ngx_headers["X-Requested-With"] - -- was the request ajax/async? - if xhr_value == "XMLHttpRequest" then - -- reference: https://github.com/zmartzone/lua-resty-openidc/blob/master/lib/resty/openidc.lua#L1436 - -- set to deny so resty.openidc returns instead of redirects (ends request) - ngx.log(ngx.DEBUG, "OidcHandler ajax/async request detected, setting unauth_action = deny") - unauth_action = "deny" - end + + -- default value for unauth_action is based on force_authentication_path being set. + -- If set, unauth_action is set to "pass", default action is to allow request through to the upstream service. + -- If not set, unauth_action is set to nil, default action is to redirect request to idp authentication. + local unauth_action = oidcConfig.force_authentication_path and constants.UNAUTH_ACTION.PASS or constants.UNAUTH_ACTION.NIL + + -- If the request is an ajax request, set unauth_action to deny (don't redirect user if authentication fails) + if ngx_headers and ngx_headers["X-Requested-With"] == "XMLHttpRequest" then + -- reference: https://github.com/zmartzone/lua-resty-openidc/blob/master/lib/resty/openidc.lua#L1436 + ngx.log(ngx.DEBUG, "OidcHandler ajax/async request detected, setting unauth_action = deny") + unauth_action = constants.UNAUTH_ACTION.DENY + + -- If the request is not ajax, and matches the configured authentication path (redirect user if authentication fails) + elseif ngx.var.request_uri == oidcConfig.force_authentication_path then + ngx.log(ngx.DEBUG, "OidcHandler force_authentication_path matched request, setting unauth_action = nil") + unauth_action = constants.UNAUTH_ACTION.NIL end + local res, err, original_url, session = openidc.authenticate(oidcConfig, nil, unauth_action, oidcSessionConfig) + -- @todo: add unit test to check for session:close() -- handle and close session, prevent locking session:close() @@ -117,7 +127,7 @@ function make_oidc(oidcConfig, oidcSessionConfig) -- code execution has gone this far, so return 401 status code to allow client to respond accordingly if err == "unauthorized request" then ngx.log(ngx.DEBUG, "OidcHandler unauthorized ajax/async request detected, responding with 401 status code") - local message = cjson.encode({ status = ngx.status, request_path = ngx.var.request_uri}) + local message = cjson.encode({ status = ngx.HTTP_UNAUTHORIZED, request_path = ngx.var.request_uri}) return utils.exit(ngx.HTTP_UNAUTHORIZED, message, ngx.HTTP_UNAUTHORIZED) end @@ -159,7 +169,7 @@ function get_userinfo(oidcConfig, introspect_response) -- cache hit if userinfo then userinfo = cjson.decode(userinfo) - + -- check if decoded value is blank if userinfo == cjson.null then ngx.log(ngx.DEBUG, "userinfo cached value is null returning nil value") @@ -168,22 +178,22 @@ function get_userinfo(oidcConfig, introspect_response) return userinfo end - + ngx.log(ngx.INFO, "userinfo cache miss, calling userinfo endpoint") userinfo, err = openidc.call_userinfo_endpoint(oidcConfig, access_token) - + if err then ngx.log(ngx.ERR, "call to userinfo endpoint failed, ", err) return nil, err end -- @see openidc.introspect https://github.com/zmartzone/lua-resty-openidc/blob/master/lib/resty/openidc.lua#L1575 - -- utilized openidc.introspect caching logic + -- utilized openidc.introspect caching logic -- todo: add tests to verify values are respected local introspection_cache_ignore = oidcConfig.introspection_cache_ignore or false local expiry_claim = oidcConfig.introspection_expiry_claim or "exp" local introspection_interval = oidcConfig.introspection_interval or 0 - + if not introspection_cache_ignore and introspect_response[expiry_claim] then local ttl = introspect_response[expiry_claim] ngx.log(ngx.INFO, ttl) diff --git a/kong/plugins/oidc/schema.lua b/kong/plugins/oidc/schema.lua index 85cb57ef..8f66ab5b 100644 --- a/kong/plugins/oidc/schema.lua +++ b/kong/plugins/oidc/schema.lua @@ -61,7 +61,8 @@ return { { end_session_endpoint = { type = "string", required = false } } } } - } + }, + { force_authentication_path = { type = "string" } } } } } diff --git a/kong/plugins/oidc/util/constants.lua b/kong/plugins/oidc/util/constants.lua new file mode 100644 index 00000000..e60567fc --- /dev/null +++ b/kong/plugins/oidc/util/constants.lua @@ -0,0 +1,22 @@ +-------------------------------------------------- +-- Declare Contants -- +-------------------------------------------------- +local constants = { + + -- Request Headers + REQUEST_HEADERS = { + X_ACCESS_TOKEN = "X-Access-Token", + X_ID_TOKEN = "X-ID-Token", + X_USERINFO = "X-Userinfo", + }, + + -- unauth_action values + UNAUTH_ACTION = { + PASS = "pass", + DENY = "deny", + NIL = nil, + } +} + + +return constants diff --git a/kong/plugins/oidc/utils.lua b/kong/plugins/oidc/utils.lua index a41786e5..b3fbd00c 100644 --- a/kong/plugins/oidc/utils.lua +++ b/kong/plugins/oidc/utils.lua @@ -1,4 +1,5 @@ local cjson = require("cjson") +local constants = require("kong.plugins.oidc.util.constants") local M = {} @@ -42,6 +43,7 @@ function M.get_options(config, ngx) filters = parseFilters(config.filters), logout_path = config.logout_path, redirect_after_logout_uri = config.redirect_after_logout_uri, + force_authentication_path = config.force_authentication_path }, config.session end @@ -52,12 +54,12 @@ function M.exit(httpStatusCode, message, ngxCode) end function M.injectAccessToken(accessToken) - ngx.req.set_header("X-Access-Token", accessToken) + ngx.req.set_header(constants.REQUEST_HEADERS.X_ACCESS_TOKEN, accessToken) end function M.injectIDToken(idToken) local tokenStr = cjson.encode(idToken) - ngx.req.set_header("X-ID-Token", ngx.encode_base64(tokenStr)) + ngx.req.set_header(constants.REQUEST_HEADERS.X_ID_TOKEN, ngx.encode_base64(tokenStr)) end function M.injectUser(user) @@ -66,7 +68,7 @@ function M.injectUser(user) tmp_user.username = user.preferred_username ngx.ctx.authenticated_credential = tmp_user local userinfo = cjson.encode(user) - ngx.req.set_header("X-Userinfo", ngx.encode_base64(userinfo)) + ngx.req.set_header(constants.REQUEST_HEADERS.X_USERINFO, ngx.encode_base64(userinfo)) end function M.has_bearer_access_token() @@ -132,4 +134,10 @@ function M.cache_get(type, key) return value end +function M.clear_request_headers() + ngx.req.clear_header(constants.REQUEST_HEADERS.X_ACCESS_TOKEN) + ngx.req.clear_header(constants.REQUEST_HEADERS.X_ID_TOKEN) + ngx.req.clear_header(constants.REQUEST_HEADERS.X_USERINFO) +end + return M diff --git a/test/unit/mockable_case.lua b/test/unit/mockable_case.lua index d8ca2758..1bba4837 100644 --- a/test/unit/mockable_case.lua +++ b/test/unit/mockable_case.lua @@ -5,6 +5,7 @@ local MockableCase = BaseCase:extend() function MockableCase:setUp() MockableCase.super:setUp() self.logs = {} + self.mocked_ngx = { DEBUG = "debug", ERR = "error", @@ -16,7 +17,8 @@ function MockableCase:setUp() req = { get_uri_args = function(...) end, set_header = function(...) end, - get_headers = function(...) end + get_headers = function(...) end, + clear_header = function(...) end }, log = function(...) self.logs[#self.logs+1] = table.concat({...}, " ") diff --git a/test/unit/test_handler_mocking_openidc.lua b/test/unit/test_handler_mocking_openidc.lua index aa2c9883..a7022a0e 100644 --- a/test/unit/test_handler_mocking_openidc.lua +++ b/test/unit/test_handler_mocking_openidc.lua @@ -1,7 +1,9 @@ local lu = require("luaunit") TestHandler = require("test.unit.mockable_case"):extend() local session = nil; - +local idpAuthPath = "/path/to/idp/authentication" +local publicRoute = "/this/route/is/publicly/accessible" +local constants = require("kong.plugins.oidc.util.constants") function TestHandler:setUp() TestHandler.super:setUp() @@ -10,6 +12,15 @@ function TestHandler:setUp() close = function(...) end } + package.loaded["cjson"] = nil + self.cjson = { + encode = function(...) end, + decode = function(...) end + } + package.preload["cjson"] = function() + return self.cjson + end + package.loaded["kong.plugins.oidc.utils"] = nil package.preload["kong.plugins.oidc.utils"] = require("kong.plugins.oidc.utils") @@ -75,11 +86,11 @@ function TestHandler:test_authenticate_ok_with_userinfo() -- act self.handler:access({}) - + -- assert lu.assertTrue(authenticate_called) lu.assertEquals(ngx.ctx.authenticated_credential.id, "sub") - lu.assertEquals(headers['X-Userinfo'], "eyJzdWIiOiJzdWIifQ==") + lu.assertEquals(headers[constants.REQUEST_HEADERS.X_USERINFO], "eyJzdWIiOiJzdWIifQ==") end function TestHandler:test_authenticate_ok_with_no_accesstoken() @@ -97,10 +108,10 @@ function TestHandler:test_authenticate_ok_with_no_accesstoken() -- act self.handler:access({}) - + -- assert lu.assertTrue(authenticate_called) - lu.assertNil(headers['X-Access-Token']) + lu.assertNil(headers[constants.REQUEST_HEADERS.X_ACCESS_TOKEN]) end function TestHandler:test_authenticate_ok_with_accesstoken() @@ -118,10 +129,10 @@ function TestHandler:test_authenticate_ok_with_accesstoken() -- act self.handler:access({}) - + -- assert lu.assertTrue(authenticate_called) - lu.assertEquals(headers['X-Access-Token'], "ACCESS_TOKEN") + lu.assertEquals(headers[constants.REQUEST_HEADERS.X_ACCESS_TOKEN], "ACCESS_TOKEN") end function TestHandler:test_authenticate_ok_with_no_idtoken() @@ -139,10 +150,10 @@ function TestHandler:test_authenticate_ok_with_no_idtoken() -- act self.handler:access({}) - + -- assert lu.assertTrue(authenticate_called) - lu.assertNil(headers['X-ID-Token']) + lu.assertNil(headers[constants.REQUEST_HEADERS.X_ID_TOKEN]) end function TestHandler:test_authenticate_ok_with_idtoken() @@ -164,10 +175,10 @@ function TestHandler:test_authenticate_ok_with_idtoken() -- act self.handler:access({}) - + -- assert lu.assertTrue(authenticate_called) - lu.assertEquals(headers['X-ID-Token'], "eyJzdWIiOiJzdWIifQ==") + lu.assertEquals(headers[constants.REQUEST_HEADERS.X_ID_TOKEN], "eyJzdWIiOiJzdWIifQ==") end function TestHandler:test_authenticate_error_no_recovery() @@ -181,10 +192,10 @@ function TestHandler:test_authenticate_error_no_recovery() authenticate_called = true return {}, true, "/", session end - + -- act self.handler:access({}) - + -- assert lu.assertTrue(authenticate_called) lu.assertEquals(statusCode, 500) @@ -218,7 +229,7 @@ function TestHandler:test_introspect_called_when_bearer_token() -- act self.handler:access({discovery = { introspection_endpoint = "x" }}) - + -- assert lu.assertTrue(instrospect_called) end @@ -230,7 +241,7 @@ function TestHandler:test_introspect_ok_with_userinfo() local instrospect_called = false ngx.req.get_headers = function() return {Authorization = "Bearer xxx"} end - + local headers = {} ngx.req.set_header = function(h, v) headers[h] = v @@ -246,7 +257,7 @@ function TestHandler:test_introspect_ok_with_userinfo() return { email = "test@gmail.com", email_verified = true } end - package.loaded.cjson.encode = function(x) + self.cjson.encode = function(x) userinfo_to_be_encoded = x end @@ -261,8 +272,8 @@ function TestHandler:test_introspect_ok_with_userinfo() lu.assertTrue(instrospect_called) lu.assertTrue(called_userinfo_endpoint) lu.assertEquals(userinfo_to_be_encoded.email, "test@gmail.com") - lu.assertEquals(headers['X-Userinfo'], "eyJzdWIiOiJzdWIifQ==") - lu.assertEquals(headers['X-Access-Token'], 'xxx') + lu.assertEquals(headers[constants.REQUEST_HEADERS.X_USERINFO], "eyJzdWIiOiJzdWIifQ==") + lu.assertEquals(headers[constants.REQUEST_HEADERS.X_ACCESS_TOKEN], 'xxx') end function TestHandler:test_bearer_only_with_good_token() @@ -285,10 +296,10 @@ function TestHandler:test_bearer_only_with_good_token() -- act self.handler:access({ discovery = { introspection_endpoint = "x" }, bearer_only = "yes", realm = "kong"}) - + -- assert lu.assertTrue(introspect_called) - lu.assertEquals(headers['X-Userinfo'], "eyJzdWIiOiJzdWIifQ==") + lu.assertEquals(headers[constants.REQUEST_HEADERS.X_USERINFO], "eyJzdWIiOiJzdWIifQ==") end function TestHandler:test_bearer_only_with_bad_token() @@ -350,15 +361,19 @@ function TestHandler:test_authenticate_ok_with_xmlhttprequest() end -- act - self.handler:access({}) + self.handler:access({ force_authentication_path = idpAuthPath}) -- assert lu.assertTrue(self:log_contains("ajax/async request detected")) - lu.assertEquals(actual_unauth_action, "deny") + lu.assertEquals(actual_unauth_action, constants.UNAUTH_ACTION.DENY) end function TestHandler:test_authenticate_nok_with_xmlhttprequest() -- arrange + ngx.var.request_uri = "/api/auth/unauthorized" + local statusCode + local message_status + local message_request_path -- add XMLHttpRequest to headers ngx.req.get_headers = function() @@ -372,12 +387,25 @@ function TestHandler:test_authenticate_nok_with_xmlhttprequest() return {}, "unauthorized request", "/", session end + -- mock encode to simply return parameter to check message used in utils.exit + self.cjson.encode = function(x) + return x + end + + package.loaded["kong.plugins.oidc.utils"].exit = function(httpStatusCode, message, ngxCode) + statusCode = httpStatusCode + message_status = message.status + message_request_path = message.request_path + end + -- act self.handler:access({}) -- assert + lu.assertEquals(message_status, ngx.HTTP_UNAUTHORIZED) + lu.assertEquals(message_request_path, ngx.var.request_uri) lu.assertTrue(self:log_contains("ajax/async request detected")) - lu.assertEquals(ngx.status, ngx.HTTP_UNAUTHORIZED) + lu.assertEquals(statusCode, ngx.HTTP_UNAUTHORIZED) end function TestHandler:test_authenticate_with_session_cookie_samesite_set_to_none() @@ -405,6 +433,67 @@ function TestHandler:test_authenticate_with_session_cookie_samesite_set_to_none( lu.assertItemsEquals(v, opts.session) end +function TestHandler:test_authenticate_ok_to_force_authentication_path() + -- arrange + local actual_unauth_action + ngx.var.request_uri = idpAuthPath + + -- mock authenticate to be able to check unauth_action + self.module_resty.openidc.authenticate = function(opts, target_url, unauth_action) + actual_unauth_action = unauth_action + return {}, false, "/", session + end + -- act + self.handler:access({ force_authentication_path = idpAuthPath }) + + -- assert + lu.assertTrue(self:log_contains("force_authentication_path matched request")) + lu.assertEquals(actual_unauth_action, nil) +end + +function TestHandler:test_authenticate_ok_to_non_force_authentication_path() + -- arrange + local actual_unauth_action + + -- mock authenticate to be able to check unauth_action + self.module_resty.openidc.authenticate = function(opts, target_url, unauth_action) + actual_unauth_action = unauth_action + return {}, false, "/", session + end + -- act + self.handler:access({ force_authentication_path = idpAuthPath }) + + -- assert + lu.assertEquals(actual_unauth_action, constants.UNAUTH_ACTION.PASS) +end + +function TestHandler:test_authenticate_nok_to_force_authentication_path_with_xmlhttprequest() + -- arrange + local actual_unauth_action + ngx.var.request_uri = idpAuthPath + + -- add XMLHttpRequest to headers + ngx.req.get_headers = function() + local headers = {} + headers["X-Requested-With"] = "XMLHttpRequest" + return headers + end + + -- mock authenticate to be able to check unauth_action + self.module_resty.openidc.authenticate = function(opts, target_url, unauth_action) + actual_unauth_action = unauth_action + return {}, "unauthorized request", "/", session + end + + -- act + self.handler:access({ force_authentication_path = idpAuthPath}) + + -- assert + lu.assertTrue(self:log_contains("ajax/async request detected")) + lu.assertEquals(actual_unauth_action, constants.UNAUTH_ACTION.DENY) + lu.assertEquals(ngx.status, ngx.HTTP_UNAUTHORIZED) +end + lu.run() diff --git a/test/unit/test_introspect.lua b/test/unit/test_introspect.lua index da7a96ee..0148318c 100644 --- a/test/unit/test_introspect.lua +++ b/test/unit/test_introspect.lua @@ -1,4 +1,5 @@ local lu = require("luaunit") +local constants = require("kong.plugins.oidc.util.constants") TestIntrospect = require("test.unit.mockable_case"):extend() @@ -6,7 +7,7 @@ TestIntrospect = require("test.unit.mockable_case"):extend() function TestIntrospect:setUp() TestIntrospect.super:setUp() package.loaded["resty.openidc"] = nil - package.preload["resty.openidc"] = function() + package.preload["resty.openidc"] = function() return { call_userinfo_endpoint = function(...) return { email = "test@gmail.net" } @@ -14,7 +15,7 @@ function TestIntrospect:setUp() get_discovery_doc = function(opts) opts.discovery = { introspection_endpoint = "x" } end - } + } end package.loaded["kong.plugins.oidc.handler"] = nil self.handler = require("kong.plugins.oidc.handler")() @@ -42,7 +43,7 @@ function TestIntrospect:test_access_token_exists() self.handler:access({}) lu.assertTrue(self:log_contains("introspect succeeded")) - lu.assertEquals(headers['X-Userinfo'], "eyJzdWIiOiJzdWIifQ==") + lu.assertEquals(headers[constants.REQUEST_HEADERS.X_USERINFO], "eyJzdWIiOiJzdWIifQ==") end function TestIntrospect:test_no_authorization_header() @@ -56,7 +57,7 @@ function TestIntrospect:test_no_authorization_header() self.handler:access({}) lu.assertFalse(self:log_contains(self.mocked_ngx.ERR)) - lu.assertEquals(headers['X-Userinfo'], nil) + lu.assertEquals(headers[constants.REQUEST_HEADERS.X_USERINFO], nil) end diff --git a/test/unit/test_utils.lua b/test/unit/test_utils.lua index 7540cd6d..27a9be99 100644 --- a/test/unit/test_utils.lua +++ b/test/unit/test_utils.lua @@ -1,37 +1,38 @@ local utils = require("kong.plugins.oidc.utils") local lu = require("luaunit") +local constants = require("kong.plugins.oidc.util.constants") -- opts_fixture, ngx are global to prevent mutation in consecutive tests local opts_fixture = nil -local ngx = nil TestUtils = require("test.unit.base_case"):extend() function TestUtils:setUp() -- reset opts_fixture opts_fixture = { - client_id = 1, - client_secret = 2, - discovery = "d", - scope = "openid", - response_type = "code", - ssl_verify = "no", - token_endpoint_auth_method = "client_secret_post", - introspection_endpoint_auth_method = "client_secret_basic", - introspection_expiry_claim = "expires", - introspection_cache_ignore = false, - introspection_interval = 600, - filters = "pattern1,pattern2,pattern3", - logout_path = "/logout", - redirect_uri = "http://domain.com/auth/callback", - redirect_after_logout_uri = "/login", - prompt = "login", - session = { cookie = { samesite = "None" } }, - } + client_id = 1, + client_secret = 2, + discovery = "d", + scope = "openid", + response_type = "code", + ssl_verify = "no", + token_endpoint_auth_method = "client_secret_post", + introspection_endpoint_auth_method = "client_secret_basic", + introspection_expiry_claim = "expires", + introspection_cache_ignore = false, + introspection_interval = 600, + filters = "pattern1,pattern2,pattern3", + logout_path = "/logout", + redirect_uri = "http://domain.com/auth/callback", + redirect_after_logout_uri = "/login", + prompt = "login", + session = { cookie = { samesite = "None" } }, + force_authentication_path = "/api/auth/login" + } - ngx = { - var = { request_uri = "/path"}, - req = { get_uri_args = function() return nil end } - } + _G.ngx = { + var = { request_uri = "/path"}, + req = { get_uri_args = function() return nil end } + } end function TestUtils:testOptions() @@ -60,6 +61,7 @@ function TestUtils:testOptions() lu.assertEquals(opts.redirect_after_logout_uri, "/login") lu.assertEquals(opts.prompt, "login") lu.assertEquals(session.cookie.samesite, "None") + lu.assertEquals(opts.force_authentication_path, "/api/auth/login") end @@ -77,4 +79,25 @@ function TestUtils:testDiscoveryOverride() lu.assertItemsEquals(opts.discovery, opts_fixture.discovery_override) end +function TestUtils:testClearRequestHeaders() + -- assign + local headers = {} + + _G.ngx = { + req = { + clear_header = function(header) + headers[header] = true + end + } + } + + -- act + utils.clear_request_headers() + + -- assert + lu.assertTrue(headers[constants.REQUEST_HEADERS.X_ACCESS_TOKEN]) + lu.assertTrue(headers[constants.REQUEST_HEADERS.X_ID_TOKEN]) + lu.assertTrue(headers[constants.REQUEST_HEADERS.X_USERINFO]) +end + lu.run()