├── .env ├── .github └── ISSUE_TEMPLATE.md ├── .gitignore ├── .luacov ├── .travis.yml ├── AUTHORS ├── LICENSE ├── README.md ├── bin ├── build-env.sh ├── my-ip.sh ├── run-unit-tests.sh └── teardown-env.sh ├── ci ├── root.sh ├── run.sh └── setup.sh ├── docs └── kong_oidc_flow.png ├── kong-plugin-oidc-1.4.0-1.rockspec ├── kong └── plugins │ └── oidc │ ├── filter.lua │ ├── handler.lua │ ├── schema.lua │ ├── session.lua │ └── utils.lua ├── spec └── plugins │ └── oidc │ └── access_spec.lua └── test ├── docker ├── integration │ ├── Dockerfile │ ├── _network_functions │ ├── docker-compose.yml │ ├── keycloak_client.py │ ├── kong_client.py │ ├── nginx-redis.kong.conf │ └── setup.py └── unit │ └── Dockerfile └── unit ├── base_case.lua ├── mockable_case.lua ├── run.sh ├── test_already_auth.lua ├── test_bearer_jwt_auth.lua ├── test_filter.lua ├── test_filters_advanced.lua ├── test_handler_mocking_openidc.lua ├── test_header_claims.lua ├── test_introspect.lua ├── test_utils.lua └── test_utils_bearer_access_token.lua /.env: -------------------------------------------------------------------------------- 1 | BUILD_IMG_NAME=nokia/kong-oidc 2 | INTEGRATION_PATH=test/docker/integration 3 | UNIT_PATH=test/docker/unit 4 | 5 | KONG_BASE_TAG=:2.8.0-ubuntu 6 | KONG_TAG= 7 | KONG_DB_TAG=:14 8 | KONG_DB_PORT=5432 9 | KONG_DB_USER=kong 10 | KONG_DB_PW=kong 11 | KONG_DB_NAME=kong 12 | KONG_SESSION_STORE_PORT=6379 13 | KONG_HTTP_PROXY_PORT=8000 14 | KONG_HTTP_ADMIN_PORT=8001 15 | 16 | KEYCLOAK_TAG=:16.1.1 17 | KEYCLOAK_PORT=8081 18 | KEYCLOAK_USER=admin 19 | KEYCLOAK_PW=password 20 | -------------------------------------------------------------------------------- /.github/ ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 1. Used Kong OIDC plugin version: 2 | 3 | 2. Used Kong version: 4 | 5 | 3. Kong OIDC plugin configuration: 6 | 7 | 4. Used OIDC provider: 8 | 9 | 5. Issue description: 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # emacs 2 | *~ 3 | \#*\# 4 | .\#* 5 | lua_install 6 | luacov.stats.out 7 | venv/ 8 | bin/venv/ 9 | **/__pycache__/ 10 | # Visual Studio Code 11 | .vscode/ 12 | 13 | # IntelliJ IDEA 14 | *.iml 15 | .idea 16 | .dccache -------------------------------------------------------------------------------- /.luacov: -------------------------------------------------------------------------------- 1 | return { 2 | ["include"] = { 3 | 'kong/plugins/oidc' 4 | }, 5 | } 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # .travis.yaml 2 | 3 | language: python 4 | 5 | sudo: true 6 | 7 | env: 8 | - LUA_VERSION="5.1" KONG_VERSION="1.5.0-0" LUA_RESTY_OPENIDC_VERSION="1.7.2-1" 9 | 10 | script: 11 | - sudo -E bash ci/root.sh 12 | - . ci/setup.sh 13 | - . ci/run.sh 14 | 15 | after_success: 16 | - luarocks install luacov-coveralls 17 | - luacov-coveralls 18 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | List of authors based on the git log: 2 | 3 | Adam Płaczek 4 | Damian Czaja 5 | Geoff Kassel 6 | Gergely Csatari 7 | Hannu Laurila 8 | Joshua Erney 9 | Lars Wilhelmsen 10 | Luka Lodrant 11 | Micah Silverman 12 | Michal Kulik 13 | Nazarii Makarenko 14 | Pavel Mikhalchuk 15 | pekka.hirvonen 16 | The Gitter Badger 17 | Tom Milligan 18 | Trojan295 19 | Tuomo Syrjanen 20 | Yoriyasu Yano 21 | Yuan Cheung 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # What is Kong OIDC plugin 2 | 3 | [![Join the chat at https://gitter.im/nokia/kong-oidc](https://badges.gitter.im/nokia/kong-oidc.svg)](https://gitter.im/nokia/kong-oidc?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 4 | 5 | **Continuous Integration:** [![Build Status](https://travis-ci.org/nokia/kong-oidc.svg?branch=master)](https://travis-ci.org/nokia/kong-oidc) 6 | [![Coverage Status](https://coveralls.io/repos/github/nokia/kong-oidc/badge.svg?branch=master)](https://coveralls.io/github/nokia/kong-oidc?branch=master)
7 | 8 | **kong-oidc** is a plugin for [Kong](https://github.com/Mashape/kong) implementing the 9 | [OpenID Connect](http://openid.net/specs/openid-connect-core-1_0.html) Relying Party (RP) functionality. 10 | 11 | It authenticates users against an OpenID Connect Provider using 12 | [OpenID Connect Discovery](http://openid.net/specs/openid-connect-discovery-1_0.html) 13 | and the Basic Client Profile (i.e. the Authorization Code flow). 14 | 15 | It maintains sessions for authenticated users by leveraging `lua-resty-openidc` thus offering 16 | a configurable choice between storing the session state in a client-side browser cookie or use 17 | in of the server-side storage mechanisms `shared-memory|memcache|redis`. 18 | 19 | > **Note:** at the moment, there is an issue using memcached/redis, probably due to session locking: the sessions freeze. Help to debug this is appreciated. I am currently using shared memory to store sessions. 20 | 21 | It supports server-wide caching of resolved Discovery documents and validated Access Tokens. 22 | 23 | It can be used as a reverse proxy terminating OAuth/OpenID Connect in front of an origin server so that 24 | the origin server/services can be protected with the relevant standards without implementing those on 25 | the server itself. 26 | 27 | The introspection functionality adds capability for already authenticated users and/or applications that 28 | already possess access token to go through kong. The actual token verification is then done by Resource Server. 29 | 30 | ## How does it work 31 | 32 | The diagram below shows the message exchange between the involved parties. 33 | 34 | ![alt Kong OIDC flow](docs/kong_oidc_flow.png) 35 | 36 | The `X-Userinfo` header contains the payload from the Userinfo Endpoint 37 | 38 | ```json 39 | X-Userinfo: {"preferred_username":"alice","id":"60f65308-3510-40ca-83f0-e9c0151cc680","sub":"60f65308-3510-40ca-83f0-e9c0151cc680"} 40 | ``` 41 | 42 | The plugin also sets the `ngx.ctx.authenticated_credential` variable, which can be using in other Kong plugins: 43 | 44 | ```lua 45 | ngx.ctx.authenticated_credential = { 46 | id = "60f65308-3510-40ca-83f0-e9c0151cc680", -- sub field from Userinfo 47 | username = "alice" -- preferred_username from Userinfo 48 | } 49 | ``` 50 | 51 | For successfully authenticated request, possible (anonymous) consumer identity set by higher priority plugin is cleared as part of setting the credentials. 52 | 53 | The plugin will try to retrieve the user's groups from a field in the token (default `groups`) and set `kong.ctx.shared.authenticated_groups` so that Kong authorization plugins can make decisions based on the user's group membership. 54 | 55 | ## Dependencies 56 | 57 | **kong-oidc** depends on the following package: 58 | 59 | - [`lua-resty-openidc`](https://github.com/zmartzone/lua-resty-openidc/) 60 | 61 | ## Installation 62 | 63 | If you're using `luarocks` execute the following: 64 | 65 | luarocks install kong-oidc 66 | 67 | [Kong >= 0.14] Since `KONG_CUSTOM_PLUGINS` has been removed, you also need to set the `KONG_PLUGINS` environment variable to include besides the bundled ones, oidc 68 | 69 | export KONG_PLUGINS=bundled,oidc 70 | 71 | ## Usage 72 | 73 | ### Parameters 74 | 75 | | Parameter | Default | Required | description | 76 | | ------------------------------------------- | ------------------------------------------ | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 77 | | `name` | | true | plugin name, has to be `oidc` | 78 | | `config.client_id` | | true | OIDC Client ID | 79 | | `config.client_secret` | | true | OIDC Client secret | 80 | | `config.discovery` | | false | OIDC Discovery Endpoint (`/.well-known/openid-configuration`) | 81 | | `config.scope` | openid | false | OAuth2 Token scope. To use OIDC it has to contains the `openid` scope | 82 | | `config.ssl_verify` | false | false | Enable SSL verification to OIDC Provider | 83 | | `config.session_secret` | | false | Additional parameter, which is used to encrypt the session cookie. Needs to be random | 84 | | `config.introspection_endpoint` | | false | Token introspection endpoint | 85 | | `config.timeout` | | false | OIDC endpoint calls timeout | 86 | | `config.introspection_endpoint_auth_method` | client_secret_basic | false | Token introspection authentication method. `resty-openidc` supports `client_secret_(basic\|post)` | 87 | | `config.bearer_only` | no | false | Only introspect tokens without redirecting | 88 | | `config.realm` | kong | false | Realm used in WWW-Authenticate response header | 89 | | `config.logout_path` | /logout | false | Absolute path used to logout from the OIDC RP | 90 | | `config.unauth_action` | auth | false | What action to take when unauthenticated
- `auth` to redirect to the login page and attempt (re)authenticatation,
- `deny` to stop with 401 | 91 | | `config.recovery_page_path` | | false | Path of a recovery page to redirect the user when error occurs (except 401). To not show any error, you can use '/' to redirect immediately home. The error will be logged server side. | 92 | | `config.ignore_auth_filters` | | false | A comma-separated list of endpoints to bypass authentication for | 93 | | `config.redirect_uri` | | false | A relative or absolute URI the OP will redirect to after successful authentication | 94 | | `config.userinfo_header_name` | `X-Userinfo` | false | The name of the HTTP header to use when passing the UserInfo to the upstream server | 95 | | `config.id_token_header_name` | `X-ID-Token` | false | The name of the HTTP header to use when passing the ID Token to the upstream server | 96 | | `config.access_token_header_name` | `X-Access-Token` | false | The name of the HTTP header to use when passing the Access Token to the upstream server | 97 | | `config.access_token_as_bearer` | no | false | Whether or not the access token should be passed as a Bearer token | 98 | | `config.disable_userinfo_header` | no | false | Disable passing the Userinfo to the upstream server | 99 | | `config.disable_id_token_header` | no | false | Disable passing the ID Token to the upstream server | 100 | | `config.disable_access_token_header` | no | false | Disable passing the Access Token to the upstream server | 101 | | `config.groups_claim` | groups | false | Name of the claim in the token to get groups from | 102 | | `config.skip_already_auth_requests` | no | false | Ignore requests where credentials have already been set by a higher priority plugin such as basic-auth | 103 | | `config.bearer_jwt_auth_enable` | no | false | Authenticate based on JWT (ID) token provided in Authorization (Bearer) header. Checks iss, sub, aud, exp, iat (as in ID token). `config.discovery` must be defined to discover JWKS | 104 | | `config.bearer_jwt_auth_allowed_auds` | | false | List of JWT token `aud` values allowed when validating JWT token in Authorization header. If not provided, uses value from `config.client_id` | 105 | | `config.bearer_jwt_auth_signing_algs` | [ 'RS256' ] | false | List of allowed signing algorithms for Authorization header JWT token validation. Must match to OIDC provider and `resty-openidc` supported algorithms | 106 | | `config.header_names` | | false | List of custom upstream HTTP headers to be added based on claims. Must have same number of elements as `config.header_claims`. Example: `[ 'x-oidc-email', 'x-oidc-email-verified' ]` | 107 | | `config.header_claims` | | false | List of claims to be used as source for custom upstream headers. Claims are sourced from Userinfo, ID Token, Bearer JWT, Introspection, depending on auth method. Use only claims containing simple string values. Example: `[ 'email', 'email_verified'` | 108 | | `config.http_proxy` || false | http proxy url | 109 | | `config.https_proxy` || false | https proxy url (only supports url format __http__://proxy and not __https__://proxy) | 110 | 111 | ### Enabling kong-oidc 112 | 113 | To enable the plugin only for one API: 114 | 115 | ```http 116 | POST /apis//plugins/ HTTP/1.1 117 | Host: localhost:8001 118 | Content-Type: application/x-www-form-urlencoded 119 | Cache-Control: no-cache 120 | 121 | name=oidc&config.client_id=kong-oidc&config.client_secret=29d98bf7-168c-4874-b8e9-9ba5e7382fa0&config.discovery=https%3A%2F%2F%2F.well-known%2Fopenid-configuration 122 | ``` 123 | 124 | To enable the plugin globally: 125 | 126 | ```http 127 | POST /plugins HTTP/1.1 128 | Host: localhost:8001 129 | Content-Type: application/x-www-form-urlencoded 130 | Cache-Control: no-cache 131 | 132 | name=oidc&config.client_id=kong-oidc&config.client_secret=29d98bf7-168c-4874-b8e9-9ba5e7382fa0&config.discovery=https%3A%2F%2F%2F.well-known%2Fopenid-configuration 133 | ``` 134 | 135 | A successful response: 136 | 137 | ```http 138 | HTTP/1.1 201 Created 139 | Date: Tue, 24 Oct 2017 19:37:38 GMT 140 | Content-Type: application/json; charset=utf-8 141 | Transfer-Encoding: chunked 142 | Connection: keep-alive 143 | Access-Control-Allow-Origin: * 144 | Server: kong/0.11.0 145 | 146 | { 147 | "created_at": 1508871239797, 148 | "config": { 149 | "response_type": "code", 150 | "client_id": "kong-oidc", 151 | "discovery": "https:///.well-known/openid-configuration", 152 | "scope": "openid", 153 | "ssl_verify": "no", 154 | "client_secret": "29d98bf7-168c-4874-b8e9-9ba5e7382fa0", 155 | "token_endpoint_auth_method": "client_secret_post" 156 | }, 157 | "id": "58cc119b-e5d0-4908-8929-7d6ed73cb7de", 158 | "enabled": true, 159 | "name": "oidc", 160 | "api_id": "32625081-c712-4c46-b16a-5d6d9081f85f" 161 | } 162 | ``` 163 | 164 | ### Upstream API request 165 | 166 | For successfully authenticated request, the plugin will set upstream header `X-Credential-Identifier` to contain `sub` claim from user info, ID token or introspection result. Header `X-Anonymous-Consumer` is cleared. 167 | 168 | The plugin adds a 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: 169 | 170 | ```http 171 | GET / HTTP/1.1 172 | Host: netcat:9000 173 | Connection: keep-alive 174 | X-Forwarded-For: 172.19.0.1 175 | X-Forwarded-Proto: http 176 | X-Forwarded-Host: localhost 177 | X-Forwarded-Port: 8000 178 | X-Real-IP: 172.19.0.1 179 | Cache-Control: max-age=0 180 | User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.100 Safari/537.36 181 | Upgrade-Insecure-Requests: 1 182 | Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8 183 | Accept-Encoding: gzip, deflate 184 | Accept-Language: pl-PL,pl;q=0.8,en-US;q=0.6,en;q=0.4 185 | Cookie: session=KOn1am4mhQLKazlCA..... 186 | X-Userinfo: eyJnaXZlbl9uYW1lIjoixITEmMWaw5PFgcW7xbnEhiIsInN1YiI6ImM4NThiYzAxLTBiM2ItNDQzNy1hMGVlLWE1ZTY0ODkwMDE5ZCIsInByZWZlcnJlZF91c2VybmFtZSI6ImFkbWluIiwibmFtZSI6IsSExJjFmsOTxYHFu8W5xIYiLCJ1c2VybmFtZSI6ImFkbWluIiwiaWQiOiJjODU4YmMwMS0wYjNiLTQ0MzctYTBlZS1hNWU2NDg5MDAxOWQifQ== 187 | X-Access-Token: eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJGenFSY0N1Ry13dzlrQUJBVng1ZG9sT2ZwTFhBNWZiRGFlVDRiemtnSzZRIn0.eyJqdGkiOiIxYjhmYzlkMC1jMjlmLTQwY2ItYWM4OC1kNzMyY2FkODcxY2IiLCJleHAiOjE1NDg1MTA4MjksIm5iZiI6MCwiaWF0IjoxNTQ4NTEwNzY5LCJpc3MiOiJodHRwOi8vMTkyLjE2OC4wLjk6ODA4MC9hdXRoL3JlYWxtcy9tYXN0ZXIiLCJhdWQiOlsibWFzdGVyLXJlYWxtIiwiYWNjb3VudCJdLCJzdWIiOiJhNmE3OGQ5MS01NDk0LTRjZTMtOTU1NS04NzhhMTg1Y2E0YjkiLCJ0eXAiOiJCZWFyZXIiLCJhenAiOiJrb25nIiwibm9uY2UiOiJmNGRkNDU2YzBjZTY4ZmFmYWJmNGY4ZDA3YjQ0YWE4NiIsImF1dGhfdGltZSI6…IiwibWFuYWdlLWFjY291bnQtbGlua3MiLCJ2aWV3LXByb2ZpbGUiXX19LCJzY29wZSI6Im9wZW5pZCBwcm9maWxlIGVtYWlsIiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJhZG1pbiJ9.GWuguFjSEDGxw_vbD04UMKxtai15BE2lwBO0YkSzp-NKZ2SxAzl0nyhZxpP0VTzk712nQ8f_If5-mQBf_rqEVnOraDmX5NOXP0B8AoaS1jsdq4EomrhZGqlWmuaV71Cnqrw66iaouBR_6Q0s8bgc1FpCPyACM4VWs57CBdTrAZ2iv8dau5ODkbEvSgIgoLgBbUvjRKz1H0KyeBcXlVSgHJ_2zB9q2HvidBsQEIwTP8sWc6er-5AltLbV8ceBg5OaZ4xHoramMoz2xW-ttjIujS382QQn3iekNByb62O2cssTP3UYC747ehXReCrNZmDA6ecdnv8vOfIem3xNEnEmQw 188 | X-Id-Token: eyJuYmYiOjAsImF6cCI6ImtvbmciLCJpYXQiOjE1NDg1MTA3NjksImlzcyI6Imh0dHA6XC9cLzE5Mi4xNjguMC45OjgwODBcL2F1dGhcL3JlYWxtc1wvbWFzdGVyIiwiYXVkIjoia29uZyIsIm5vbmNlIjoiZjRkZDQ1NmMwY2U2OGZhZmFiZjRmOGQwN2I0NGFhODYiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJhZG1pbiIsImF1dGhfdGltZSI6MTU0ODUxMDY5NywiYWNyIjoiMSIsInNlc3Npb25fc3RhdGUiOiJiNDZmODU2Ny0zODA3LTQ0YmMtYmU1Mi1iMTNiNWQzODI5MTQiLCJleHAiOjE1NDg1MTA4MjksImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwianRpIjoiMjI1ZDRhNDItM2Y3ZC00Y2I2LTkxMmMtOGNkYzM0Y2JiNTk2Iiwic3ViIjoiYTZhNzhkOTEtNTQ5NC00Y2UzLTk1NTUtODc4YTE4NWNhNGI5IiwidHlwIjoiSUQifQ== 189 | ``` 190 | 191 | ### Standard OpenID Connect Scopes and Claims 192 | 193 | The OpenID Connect Core 1.0 profile specifies the following standard scopes and claims: 194 | 195 | | Scope | Claim(s) | 196 | | --------- | -------------------------------------------------------------------------------------------------------------------------------------- | 197 | | `openid` | `sub`. In an ID Token, `iss`, `aud`, `exp`, `iat` will also be provided. | 198 | | `profile` | Typically claims like `name`, `family_name`, `given_name`, `middle_name`, `preferred_username`, `nickname`, `picture` and `updated_at` | 199 | | `email` | `email` and `email_verified` (_boolean_) indicating if the email address has been verified by the user | 200 | 201 | _Note that the `openid` scope is a mandatory designator scope._ 202 | 203 | #### Description of the standard claims 204 | 205 | | Claim | Type | Description | 206 | | -------------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | 207 | | `iss` | URI | The Uniform Resource Identifier uniquely identifying the OpenID Connect Provider (_OP_) | 208 | | `aud` | string / array | The intended audiences. For ID tokens, the identity token is one or more clients. For Access tokens, the audience is typically one or more Resource Servers | 209 | | `nbf` | integer | _Not before_ timestamp in Unix Epoch time\*. May be omitted or set to 0 to indicate that the audience can disregard the claim | 210 | | `exp` | integer | _Expires_ timestamp in Unix Epoch time\* | 211 | | `name` | string | Preferred display name. Ex. `John Doe` | 212 | | `family_name` | string | Last name. Ex. `Doe` | 213 | | `given_name` | string | First name. Ex. `John` | 214 | | `middle_name` | string | Middle name. Ex. `Donald` | 215 | | `nickname` | string | Nick name. Ex. `Johnny` | 216 | | `preferred_username` | string | Preferred user name. Ex. `johdoe` | 217 | | `picture` | base64 | A Base-64 encoded picture (typically PNG or JPEG) of the subject | 218 | | `updated_at` | integer | A timestamp in Unix Epoch time\* | 219 | 220 | `*` (Seconds since January 1st 1970). 221 | 222 | ### Passing the Access token as a normal Bearer token 223 | 224 | To pass the access token to the upstream server as a normal Bearer token, configure the plugin as follows: 225 | 226 | | Key | Value | 227 | | -------------------------------------- | --------------- | 228 | | `config.access_token_header_name` | `Authorization` | 229 | | `config.access_token_as_bearer` | `yes` | 230 | 231 | ## Development 232 | 233 | ### Running Unit Tests 234 | 235 | To run unit tests, run the following command: 236 | 237 | ```shell 238 | ./bin/run-unit-tests.sh 239 | ``` 240 | 241 | This may take a while for the first run, as the docker image will need to be built, but subsequent runs will be quick. 242 | 243 | ### Building the Integration Test Environment 244 | 245 | To build the integration environment (Kong with the oidc plugin enabled, and Keycloak as the OIDC Provider), you will first need to find your computer's IP, and assign that to the environment variable `IP`. Finally, you will run the `./bin/build-env.sh` command. Here's an example: 246 | 247 | ```shell 248 | export IP=192.168.0.1 249 | ./bin/build-env.sh 250 | ``` 251 | 252 | To tear the environment down: 253 | 254 | ```shell 255 | ./bin/teardown-env.sh 256 | ``` 257 | -------------------------------------------------------------------------------- /bin/build-env.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | . .env 3 | . ${INTEGRATION_PATH}/_network_functions 4 | 5 | (set -e 6 | if [[ -z "$IP" ]]; then 7 | echo "Please set the IP var to your local IP address. Example: export IP=192.168.0.1" 8 | exit 1 9 | fi 10 | 11 | (set -x 12 | # Tear down environment if it is running 13 | docker-compose -f ${INTEGRATION_PATH}/docker-compose.yml down 14 | docker build --build-arg KONG_BASE_TAG=${KONG_BASE_TAG} -t nokia/kong-oidc -f ${INTEGRATION_PATH}/Dockerfile . 15 | docker-compose -f ${INTEGRATION_PATH}/docker-compose.yml up -d kong-db kong-session-store 16 | ) 17 | 18 | _wait_for_listener localhost:${KONG_DB_PORT} 19 | _wait_for_listener localhost:${KONG_SESSION_STORE_PORT} 20 | 21 | (set -x 22 | docker-compose -f ${INTEGRATION_PATH}/docker-compose.yml run --rm kong kong migrations bootstrap 23 | docker-compose -f ${INTEGRATION_PATH}/docker-compose.yml up -d 24 | ) 25 | 26 | _wait_for_endpoint http://localhost:${KONG_HTTP_ADMIN_PORT} 27 | _wait_for_endpoint http://localhost:${KEYCLOAK_PORT} 28 | 29 | (set -x 30 | python3 ${INTEGRATION_PATH}/setup.py 31 | ) 32 | ) -------------------------------------------------------------------------------- /bin/my-ip.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | ADAPTER="" 3 | if [ -z "$*" ]; then 4 | ADAPTER="wifi0" 5 | else 6 | ADAPTER=$1 7 | fi 8 | 9 | ip addr show $ADAPTER | grep "inet\b" | awk '{print $2}' | cut -d/ -f1 10 | -------------------------------------------------------------------------------- /bin/run-unit-tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | . .env 3 | 4 | (set -ex 5 | docker build \ 6 | --build-arg KONG_BASE_TAG=${KONG_BASE_TAG} \ 7 | -t ${BUILD_IMG_NAME} \ 8 | -f ${UNIT_PATH}/Dockerfile . 9 | docker run -it --rm ${BUILD_IMG_NAME} /bin/bash test/unit/run.sh 10 | ) 11 | 12 | echo "Done" -------------------------------------------------------------------------------- /bin/teardown-env.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | . .env 3 | 4 | docker-compose -f ${INTEGRATION_PATH}/docker-compose.yml down -------------------------------------------------------------------------------- /ci/root.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | apt-get update 5 | apt-get install -y curl unzip libssl-dev python python-pip git 6 | 7 | -------------------------------------------------------------------------------- /ci/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | lua -lluacov test/unit/test_filter.lua -o TAP --failure 5 | lua -lluacov test/unit/test_filters_advanced.lua -o TAP --failure 6 | lua -lluacov test/unit/test_utils.lua -o TAP --failure 7 | lua -lluacov test/unit/test_handler_mocking_openidc.lua -o TAP --failure 8 | lua -lluacov test/unit/test_introspect.lua -o TAP --failure 9 | lua -lluacov test/unit/test_utils_bearer_access_token.lua -o TAP --failure 10 | 11 | -------------------------------------------------------------------------------- /ci/setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | export LUA_VERSION=${LUA_VERSION:-5.1} 5 | export KONG_VERSION=${KONG_VERSION:-0.13.1-0} 6 | export LUA_RESTY_OPENIDC_VERSION=${LUA_RESTY_OPENIDC_VERSION:-1.7.1-1} 7 | 8 | pip install hererocks 9 | hererocks lua_install -r^ --lua=${LUA_VERSION} 10 | export PATH=${PATH}:${PWD}/lua_install/bin 11 | 12 | luarocks install kong ${KONG_VERSION} 13 | luarocks install lua-resty-openidc ${LUA_RESTY_OPENIDC_VERSION} 14 | luarocks install lua-cjson 15 | luarocks install luaunit 16 | luarocks install luacov 17 | -------------------------------------------------------------------------------- /docs/kong_oidc_flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/revomatico/kong-oidc/1b67d1ac1860a486e1cb0fd272a1977a128419a6/docs/kong_oidc_flow.png -------------------------------------------------------------------------------- /kong-plugin-oidc-1.4.0-1.rockspec: -------------------------------------------------------------------------------- 1 | package = "kong-plugin-oidc" 2 | version = "1.4.0-1" 3 | source = { 4 | url = "git://github.com/revomatico/kong-oidc", 5 | tag = "master", 6 | dir = "kong-oidc" 7 | } 8 | description = { 9 | summary = "A Kong plugin for implementing the OpenID Connect Relying Party (RP) functionality", 10 | detailed = [[ 11 | kong-oidc is a Kong plugin for implementing the OpenID Connect Relying Party. 12 | 13 | When used as an OpenID Connect Relying Party it authenticates users against an OpenID Connect Provider using OpenID Connect Discovery and the Basic Client Profile (i.e. the Authorization Code flow). 14 | 15 | It maintains sessions for authenticated users by leveraging lua-resty-session thus offering a configurable choice between storing the session state in a client-side browser cookie or use in of the server-side storage mechanisms shared-memory|memcache|redis. 16 | 17 | It supports server-wide caching of resolved Discovery documents and validated Access Tokens. 18 | 19 | It can be used as a reverse proxy terminating OAuth/OpenID Connect in front of an origin server so that the origin server/services can be protected with the relevant standards without implementing those on the server itself. 20 | ]], 21 | homepage = "git://github.com/revomatico/kong-oidc", 22 | license = "Apache 2.0" 23 | } 24 | dependencies = { 25 | "lua-resty-openidc ~> 1.7.6-3" 26 | } 27 | build = { 28 | type = "builtin", 29 | modules = { 30 | ["kong.plugins.oidc.filter"] = "kong/plugins/oidc/filter.lua", 31 | ["kong.plugins.oidc.handler"] = "kong/plugins/oidc/handler.lua", 32 | ["kong.plugins.oidc.schema"] = "kong/plugins/oidc/schema.lua", 33 | ["kong.plugins.oidc.session"] = "kong/plugins/oidc/session.lua", 34 | ["kong.plugins.oidc.utils"] = "kong/plugins/oidc/utils.lua" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /kong/plugins/oidc/filter.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local function shouldIgnoreRequest(patterns) 4 | if (patterns) then 5 | for _, pattern in ipairs(patterns) do 6 | local isMatching = not (string.find(ngx.var.uri, pattern) == nil) 7 | if (isMatching) then return true end 8 | end 9 | end 10 | return false 11 | end 12 | 13 | function M.shouldProcessRequest(config) 14 | return not shouldIgnoreRequest(config.filters) 15 | end 16 | 17 | return M 18 | -------------------------------------------------------------------------------- /kong/plugins/oidc/handler.lua: -------------------------------------------------------------------------------- 1 | local OidcHandler = { 2 | VERSION = "1.3.0", 3 | PRIORITY = 1000, 4 | } 5 | local utils = require("kong.plugins.oidc.utils") 6 | local filter = require("kong.plugins.oidc.filter") 7 | local session = require("kong.plugins.oidc.session") 8 | 9 | 10 | function OidcHandler:access(config) 11 | local oidcConfig = utils.get_options(config, ngx) 12 | 13 | -- partial support for plugin chaining: allow skipping requests, where higher priority 14 | -- plugin has already set the credentials. The 'config.anomyous' approach to define 15 | -- "and/or" relationship between auth plugins is not utilized 16 | if oidcConfig.skip_already_auth_requests and kong.client.get_credential() then 17 | ngx.log(ngx.DEBUG, "OidcHandler ignoring already auth request: " .. ngx.var.request_uri) 18 | return 19 | end 20 | 21 | if filter.shouldProcessRequest(oidcConfig) then 22 | session.configure(config) 23 | handle(oidcConfig) 24 | else 25 | ngx.log(ngx.DEBUG, "OidcHandler ignoring request, path: " .. ngx.var.request_uri) 26 | end 27 | 28 | ngx.log(ngx.DEBUG, "OidcHandler done") 29 | end 30 | 31 | function handle(oidcConfig) 32 | local response 33 | 34 | if oidcConfig.bearer_jwt_auth_enable then 35 | response = verify_bearer_jwt(oidcConfig) 36 | if response then 37 | utils.setCredentials(response) 38 | utils.injectGroups(response, oidcConfig.groups_claim) 39 | utils.injectHeaders(oidcConfig.header_names, oidcConfig.header_claims, { response }) 40 | if not oidcConfig.disable_userinfo_header then 41 | utils.injectUser(response, oidcConfig.userinfo_header_name) 42 | end 43 | return 44 | end 45 | end 46 | 47 | if oidcConfig.introspection_endpoint then 48 | response = introspect(oidcConfig) 49 | if response then 50 | utils.setCredentials(response) 51 | utils.injectGroups(response, oidcConfig.groups_claim) 52 | utils.injectHeaders(oidcConfig.header_names, oidcConfig.header_claims, { response }) 53 | if not oidcConfig.disable_userinfo_header then 54 | utils.injectUser(response, oidcConfig.userinfo_header_name) 55 | end 56 | end 57 | end 58 | 59 | if response == nil then 60 | response = make_oidc(oidcConfig) 61 | if response then 62 | if response.user or response.id_token then 63 | -- is there any scenario where lua-resty-openidc would not provide id_token? 64 | utils.setCredentials(response.user or response.id_token) 65 | end 66 | if response.user and response.user[oidcConfig.groups_claim] ~= nil then 67 | utils.injectGroups(response.user, oidcConfig.groups_claim) 68 | elseif response.id_token then 69 | utils.injectGroups(response.id_token, oidcConfig.groups_claim) 70 | end 71 | utils.injectHeaders(oidcConfig.header_names, oidcConfig.header_claims, { response.user, response.id_token }) 72 | if (not oidcConfig.disable_userinfo_header 73 | and response.user) then 74 | utils.injectUser(response.user, oidcConfig.userinfo_header_name) 75 | end 76 | if (not oidcConfig.disable_access_token_header 77 | and response.access_token) then 78 | utils.injectAccessToken(response.access_token, oidcConfig.access_token_header_name, oidcConfig.access_token_as_bearer) 79 | end 80 | if (not oidcConfig.disable_id_token_header 81 | and response.id_token) then 82 | utils.injectIDToken(response.id_token, oidcConfig.id_token_header_name) 83 | end 84 | end 85 | end 86 | end 87 | 88 | function make_oidc(oidcConfig) 89 | ngx.log(ngx.DEBUG, "OidcHandler calling authenticate, requested path: " .. ngx.var.request_uri) 90 | local unauth_action = oidcConfig.unauth_action 91 | if unauth_action ~= "auth" then 92 | -- constant for resty.oidc library 93 | unauth_action = "deny" 94 | end 95 | local res, err = require("resty.openidc").authenticate(oidcConfig, ngx.var.request_uri, unauth_action) 96 | 97 | if err then 98 | if err == 'unauthorized request' then 99 | return kong.response.error(ngx.HTTP_UNAUTHORIZED) 100 | else 101 | if oidcConfig.recovery_page_path then 102 | ngx.log(ngx.DEBUG, "Redirecting to recovery page: " .. oidcConfig.recovery_page_path) 103 | ngx.redirect(oidcConfig.recovery_page_path) 104 | end 105 | return kong.response.error(ngx.HTTP_INTERNAL_SERVER_ERROR) 106 | end 107 | end 108 | return res 109 | end 110 | 111 | function introspect(oidcConfig) 112 | if utils.has_bearer_access_token() or oidcConfig.bearer_only == "yes" then 113 | local res, err 114 | if oidcConfig.use_jwks == "yes" then 115 | res, err = require("resty.openidc").bearer_jwt_verify(oidcConfig) 116 | else 117 | res, err = require("resty.openidc").introspect(oidcConfig) 118 | end 119 | if err then 120 | if oidcConfig.bearer_only == "yes" then 121 | ngx.header["WWW-Authenticate"] = 'Bearer realm="' .. oidcConfig.realm .. '",error="' .. err .. '"' 122 | return kong.response.error(ngx.HTTP_UNAUTHORIZED) 123 | end 124 | return nil 125 | end 126 | if oidcConfig.validate_scope == "yes" then 127 | local validScope = false 128 | if res.scope then 129 | for scope in res.scope:gmatch("([^ ]+)") do 130 | if scope == oidcConfig.scope then 131 | validScope = true 132 | break 133 | end 134 | end 135 | end 136 | if not validScope then 137 | kong.log.err("Scope validation failed") 138 | return kong.response.error(ngx.HTTP_FORBIDDEN) 139 | end 140 | end 141 | ngx.log(ngx.DEBUG, "OidcHandler introspect succeeded, requested path: " .. ngx.var.request_uri) 142 | return res 143 | end 144 | return nil 145 | end 146 | 147 | function verify_bearer_jwt(oidcConfig) 148 | if not utils.has_bearer_access_token() then 149 | return nil 150 | end 151 | -- setup controlled configuration for bearer_jwt_verify 152 | local opts = { 153 | accept_none_alg = false, 154 | accept_unsupported_alg = false, 155 | token_signing_alg_values_expected = oidcConfig.bearer_jwt_auth_signing_algs, 156 | discovery = oidcConfig.discovery, 157 | timeout = oidcConfig.timeout, 158 | ssl_verify = oidcConfig.ssl_verify 159 | } 160 | 161 | local discovery_doc, err = require("resty.openidc").get_discovery_doc(opts) 162 | if err then 163 | kong.log.err('Discovery document retrieval for Bearer JWT verify failed') 164 | return nil 165 | end 166 | 167 | local allowed_auds = oidcConfig.bearer_jwt_auth_allowed_auds or oidcConfig.client_id 168 | 169 | local jwt_validators = require "resty.jwt-validators" 170 | jwt_validators.set_system_leeway(120) 171 | local claim_spec = { 172 | -- mandatory for id token: iss, sub, aud, exp, iat 173 | iss = jwt_validators.equals(discovery_doc.issuer), 174 | sub = jwt_validators.required(), 175 | aud = function(val) return utils.has_common_item(val, allowed_auds) end, 176 | exp = jwt_validators.is_not_expired(), 177 | iat = jwt_validators.required(), 178 | -- optional validations 179 | nbf = jwt_validators.opt_is_not_before(), 180 | } 181 | 182 | local json, err, token = require("resty.openidc").bearer_jwt_verify(opts, claim_spec) 183 | if err then 184 | kong.log.err('Bearer JWT verify failed: ' .. err) 185 | return nil 186 | end 187 | 188 | return json 189 | end 190 | 191 | return OidcHandler 192 | -------------------------------------------------------------------------------- /kong/plugins/oidc/schema.lua: -------------------------------------------------------------------------------- 1 | local typedefs = require "kong.db.schema.typedefs" 2 | 3 | return { 4 | name = "kong-oidc", 5 | fields = { 6 | { 7 | -- this plugin will only be applied to Services or Routes 8 | consumer = typedefs.no_consumer 9 | }, 10 | { 11 | -- this plugin will only run within Nginx HTTP module 12 | protocols = typedefs.protocols_http 13 | }, 14 | { 15 | config = { 16 | type = "record", 17 | fields = { 18 | { 19 | client_id = { 20 | type = "string", 21 | required = true 22 | } 23 | }, 24 | { 25 | client_secret = { 26 | type = "string", 27 | required = true 28 | } 29 | }, 30 | { 31 | discovery = { 32 | type = "string", 33 | required = true, 34 | default = "https://.well-known/openid-configuration" 35 | } 36 | }, 37 | { 38 | introspection_endpoint = { 39 | type = "string", 40 | required = false 41 | } 42 | }, 43 | { 44 | introspection_endpoint_auth_method = { 45 | type = "string", 46 | required = false 47 | } 48 | }, 49 | { 50 | introspection_cache_ignore = { 51 | type = "string", 52 | required = true, 53 | default = "no" 54 | } 55 | }, 56 | { 57 | timeout = { 58 | type = "number", 59 | required = false 60 | } 61 | }, 62 | { 63 | bearer_only = { 64 | type = "string", 65 | required = true, 66 | default = "no" 67 | } 68 | }, 69 | { 70 | realm = { 71 | type = "string", 72 | required = true, 73 | default = "kong" 74 | } 75 | }, 76 | { 77 | redirect_uri = { 78 | type = "string" 79 | } 80 | }, 81 | { 82 | scope = { 83 | type = "string", 84 | required = true, 85 | default = "openid" 86 | } 87 | }, 88 | { 89 | validate_scope = { 90 | type = "string", 91 | required = true, 92 | default = "no" 93 | } 94 | }, 95 | { 96 | response_type = { 97 | type = "string", 98 | required = true, 99 | default = "code" 100 | } 101 | }, 102 | { 103 | ssl_verify = { 104 | type = "string", 105 | required = true, 106 | default = "no" 107 | } 108 | }, 109 | { 110 | use_jwks = { 111 | type = "string", 112 | required = true, 113 | default = "no" 114 | } 115 | }, 116 | { 117 | token_endpoint_auth_method = { 118 | type = "string", 119 | required = true, 120 | default = "client_secret_post" 121 | } 122 | }, 123 | { 124 | session_secret = { 125 | type = "string", 126 | required = false 127 | } 128 | }, 129 | { 130 | recovery_page_path = { 131 | type = "string" 132 | } 133 | }, 134 | { 135 | logout_path = { 136 | type = "string", 137 | required = false, 138 | default = "/logout" 139 | } 140 | }, 141 | { 142 | redirect_after_logout_uri = { 143 | type = "string", 144 | required = false, 145 | default = "/" 146 | } 147 | }, 148 | { 149 | redirect_after_logout_with_id_token_hint = { 150 | type = "string", 151 | required = false, 152 | default = "no" 153 | } 154 | }, 155 | { 156 | post_logout_redirect_uri = { 157 | type = "string", 158 | required = false 159 | } 160 | }, 161 | { 162 | unauth_action = { 163 | type = "string", 164 | required = false, 165 | default = "auth" 166 | } 167 | }, 168 | { 169 | filters = { 170 | type = "string" 171 | } 172 | }, 173 | { 174 | ignore_auth_filters = { 175 | type = "string", 176 | required = false 177 | } 178 | }, 179 | { 180 | userinfo_header_name = { 181 | type = "string", 182 | required = false, 183 | default = "X-USERINFO" 184 | } 185 | }, 186 | { 187 | id_token_header_name = { 188 | type = "string", 189 | required = false, 190 | default = "X-ID-Token" 191 | } 192 | }, 193 | { 194 | access_token_header_name = { 195 | type = "string", 196 | required = false, 197 | default = "X-Access-Token" 198 | } 199 | }, 200 | { 201 | access_token_as_bearer = { 202 | type = "string", 203 | required = false, 204 | default = "no" 205 | } 206 | }, 207 | { 208 | disable_userinfo_header = { 209 | type = "string", 210 | required = false, 211 | default = "no" 212 | } 213 | }, 214 | { 215 | disable_id_token_header = { 216 | type = "string", 217 | required = false, 218 | default = "no" 219 | } 220 | }, 221 | { 222 | disable_access_token_header = { 223 | type = "string", 224 | required = false, 225 | default = "no" 226 | } 227 | }, 228 | { 229 | revoke_tokens_on_logout = { 230 | type = "string", 231 | required = false, 232 | default = "no" 233 | } 234 | }, 235 | { 236 | groups_claim = { 237 | type = "string", 238 | required = false, 239 | default = "groups" 240 | } 241 | }, 242 | { 243 | skip_already_auth_requests = { 244 | type = "string", 245 | required = false, 246 | default = "no" 247 | } 248 | }, 249 | { 250 | bearer_jwt_auth_enable = { 251 | type = "string", 252 | required = false, 253 | default = "no" 254 | } 255 | }, 256 | { 257 | bearer_jwt_auth_allowed_auds = { 258 | type = "array", 259 | required = false, 260 | elements = { 261 | type = "string" 262 | }, 263 | } 264 | }, 265 | { 266 | bearer_jwt_auth_signing_algs = { 267 | type = "array", 268 | required = true, 269 | elements = { 270 | type = "string" 271 | }, 272 | default = { 273 | "RS256" 274 | } 275 | } 276 | }, 277 | { 278 | header_names = { 279 | type = "array", 280 | required = true, 281 | elements = { 282 | type = "string" 283 | }, 284 | default = {} 285 | } 286 | }, 287 | { 288 | header_claims = { 289 | type = "array", 290 | required = true, 291 | elements = { 292 | type = "string" 293 | }, 294 | default = {} 295 | } 296 | }, 297 | { 298 | http_proxy = { 299 | type = "string", 300 | required = false 301 | } 302 | }, 303 | { 304 | https_proxy = { 305 | type = "string", 306 | required = false 307 | } 308 | } 309 | } 310 | } 311 | } 312 | } 313 | } 314 | -------------------------------------------------------------------------------- /kong/plugins/oidc/session.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | function M.configure(config) 4 | if config.session_secret then 5 | local decoded_session_secret = ngx.decode_base64(config.session_secret) 6 | if not decoded_session_secret then 7 | kong.log.err("Invalid plugin configuration, session secret could not be decoded") 8 | return kong.response.error(ngx.HTTP_INTERNAL_SERVER_ERROR) 9 | end 10 | ngx.var.session_secret = decoded_session_secret 11 | end 12 | end 13 | 14 | return M 15 | -------------------------------------------------------------------------------- /kong/plugins/oidc/utils.lua: -------------------------------------------------------------------------------- 1 | local cjson = require("cjson") 2 | local constants = require "kong.constants" 3 | 4 | local M = {} 5 | 6 | local function parseFilters(csvFilters) 7 | local filters = {} 8 | if (not (csvFilters == nil)) and (not (csvFilters == ",")) then 9 | for pattern in string.gmatch(csvFilters, "[^,]+") do 10 | table.insert(filters, pattern) 11 | end 12 | end 13 | return filters 14 | end 15 | 16 | local function formatAsBearerToken(token) 17 | return "Bearer " .. token 18 | end 19 | 20 | function M.get_redirect_uri(ngx) 21 | local function drop_query() 22 | local uri = ngx.var.request_uri 23 | local x = uri:find("?") 24 | if x then 25 | return uri:sub(1, x - 1) 26 | else 27 | return uri 28 | end 29 | end 30 | 31 | local function tackle_slash(path) 32 | local args = ngx.req.get_uri_args() 33 | if args and args.code then 34 | return path 35 | elseif path == "/" then 36 | return "/cb" 37 | elseif path:sub(-1) == "/" then 38 | return path:sub(1, -2) 39 | else 40 | return path .. "/" 41 | end 42 | end 43 | 44 | return tackle_slash(drop_query()) 45 | end 46 | 47 | function M.get_options(config, ngx) 48 | return { 49 | client_id = config.client_id, 50 | client_secret = config.client_secret, 51 | discovery = config.discovery, 52 | introspection_endpoint = config.introspection_endpoint, 53 | introspection_endpoint_auth_method = config.introspection_endpoint_auth_method, 54 | introspection_cache_ignore = config.introspection_cache_ignore, 55 | timeout = config.timeout, 56 | bearer_only = config.bearer_only, 57 | realm = config.realm, 58 | redirect_uri = config.redirect_uri or M.get_redirect_uri(ngx), 59 | scope = config.scope, 60 | validate_scope = config.validate_scope, 61 | response_type = config.response_type, 62 | ssl_verify = config.ssl_verify, 63 | use_jwks = config.use_jwks, 64 | token_endpoint_auth_method = config.token_endpoint_auth_method, 65 | recovery_page_path = config.recovery_page_path, 66 | filters = parseFilters((config.filters or "") .. "," .. (config.ignore_auth_filters or "")), 67 | logout_path = config.logout_path, 68 | revoke_tokens_on_logout = config.revoke_tokens_on_logout == "yes", 69 | redirect_after_logout_uri = config.redirect_after_logout_uri, 70 | redirect_after_logout_with_id_token_hint = config.redirect_after_logout_with_id_token_hint == "yes", 71 | post_logout_redirect_uri = config.post_logout_redirect_uri, 72 | unauth_action = config.unauth_action, 73 | userinfo_header_name = config.userinfo_header_name, 74 | id_token_header_name = config.id_token_header_name, 75 | access_token_header_name = config.access_token_header_name, 76 | access_token_as_bearer = config.access_token_as_bearer == "yes", 77 | disable_userinfo_header = config.disable_userinfo_header == "yes", 78 | disable_id_token_header = config.disable_id_token_header == "yes", 79 | disable_access_token_header = config.disable_access_token_header == "yes", 80 | groups_claim = config.groups_claim, 81 | skip_already_auth_requests = config.skip_already_auth_requests == "yes", 82 | bearer_jwt_auth_enable = config.bearer_jwt_auth_enable == "yes", 83 | bearer_jwt_auth_allowed_auds = config.bearer_jwt_auth_allowed_auds, 84 | bearer_jwt_auth_signing_algs = config.bearer_jwt_auth_signing_algs, 85 | header_names = config.header_names or {}, 86 | header_claims = config.header_claims or {}, 87 | proxy_opts = { 88 | http_proxy = config.http_proxy, 89 | https_proxy = config.https_proxy 90 | } 91 | } 92 | end 93 | 94 | -- Function set_consumer is derived from the following kong auth plugins: 95 | -- https://github.com/Kong/kong/blob/3.0.0/kong/plugins/ldap-auth/access.lua 96 | -- https://github.com/Kong/kong/blob/3.0.0/kong/plugins/oauth2/access.lua 97 | -- Copyright 2016-2022 Kong Inc. Licensed under the Apache License, Version 2.0 98 | -- https://github.com/Kong/kong/blob/3.0.0/LICENSE 99 | local function set_consumer(consumer, credential) 100 | kong.client.authenticate(consumer, credential) 101 | 102 | local set_header = kong.service.request.set_header 103 | local clear_header = kong.service.request.clear_header 104 | 105 | if consumer and consumer.id then 106 | set_header(constants.HEADERS.CONSUMER_ID, consumer.id) 107 | else 108 | clear_header(constants.HEADERS.CONSUMER_ID) 109 | end 110 | 111 | if consumer and consumer.custom_id then 112 | set_header(constants.HEADERS.CONSUMER_CUSTOM_ID, consumer.custom_id) 113 | else 114 | clear_header(constants.HEADERS.CONSUMER_CUSTOM_ID) 115 | end 116 | 117 | if consumer and consumer.username then 118 | set_header(constants.HEADERS.CONSUMER_USERNAME, consumer.username) 119 | else 120 | clear_header(constants.HEADERS.CONSUMER_USERNAME) 121 | end 122 | 123 | if credential and credential.username then 124 | set_header(constants.HEADERS.CREDENTIAL_IDENTIFIER, credential.username) 125 | else 126 | clear_header(constants.HEADERS.CREDENTIAL_IDENTIFIER) 127 | end 128 | 129 | if credential then 130 | clear_header(constants.HEADERS.ANONYMOUS) 131 | else 132 | set_header(constants.HEADERS.ANONYMOUS, true) 133 | end 134 | end 135 | 136 | function M.injectAccessToken(accessToken, headerName, bearerToken) 137 | ngx.log(ngx.DEBUG, "Injecting " .. headerName) 138 | local token = accessToken 139 | if (bearerToken) then 140 | token = formatAsBearerToken(token) 141 | end 142 | kong.service.request.set_header(headerName, token) 143 | end 144 | 145 | function M.injectIDToken(idToken, headerName) 146 | ngx.log(ngx.DEBUG, "Injecting " .. headerName) 147 | local tokenStr = cjson.encode(idToken) 148 | kong.service.request.set_header(headerName, ngx.encode_base64(tokenStr)) 149 | end 150 | 151 | function M.setCredentials(user) 152 | local tmp_user = user 153 | tmp_user.id = user.sub 154 | tmp_user.username = user.preferred_username 155 | set_consumer(nil, tmp_user) 156 | end 157 | 158 | function M.injectUser(user, headerName) 159 | ngx.log(ngx.DEBUG, "Injecting " .. headerName) 160 | local userinfo = cjson.encode(user) 161 | kong.service.request.set_header(headerName, ngx.encode_base64(userinfo)) 162 | end 163 | 164 | function M.injectGroups(user, claim) 165 | if user[claim] ~= nil then 166 | kong.ctx.shared.authenticated_groups = user[claim] 167 | end 168 | end 169 | 170 | function M.injectHeaders(header_names, header_claims, sources) 171 | if #header_names ~= #header_claims then 172 | kong.log.err('Different number of elements provided in header_names and header_claims. Headers will not be added.') 173 | return 174 | end 175 | for i = 1, #header_names do 176 | local header, claim 177 | header = header_names[i] 178 | claim = header_claims[i] 179 | kong.service.request.clear_header(header) 180 | for j = 1, #sources do 181 | local source, claim_value 182 | source = sources[j] 183 | claim_value = source[claim] 184 | -- Convert table to string if claim is a table 185 | if type(claim_value) == "table" then 186 | claim_value = table.concat(claim_value, ", ") 187 | end 188 | if (source and source[claim]) then 189 | kong.service.request.set_header(header, claim_value) 190 | break 191 | end 192 | end 193 | end 194 | end 195 | 196 | function M.has_bearer_access_token() 197 | local header = ngx.req.get_headers()['Authorization'] 198 | if header and header:find(" ") then 199 | local divider = header:find(' ') 200 | if string.lower(header:sub(0, divider-1)) == string.lower("Bearer") then 201 | return true 202 | end 203 | end 204 | return false 205 | end 206 | 207 | -- verify if tables t1 and t2 have at least one common string item 208 | -- instead of table, also string can be provided as t1 or t2 209 | function M.has_common_item(t1, t2) 210 | if t1 == nil or t2 == nil then 211 | return false 212 | end 213 | if type(t1) == "string" then 214 | t1 = { t1 } 215 | end 216 | if type(t2) == "string" then 217 | t2 = { t2 } 218 | end 219 | local i1, i2 220 | for _, i1 in pairs(t1) do 221 | for _, i2 in pairs(t2) do 222 | if type(i1) == "string" and type(i2) == "string" and i1 == i2 then 223 | return true 224 | end 225 | end 226 | end 227 | return false 228 | end 229 | 230 | return M 231 | -------------------------------------------------------------------------------- /spec/plugins/oidc/access_spec.lua: -------------------------------------------------------------------------------- 1 | local helpers = require "spec.helpers" 2 | 3 | 4 | describe("oidc plugin", function() 5 | local proxy_client 6 | local admin_client 7 | local timeout = 6000 8 | 9 | setup(function() 10 | local api1 = assert(helpers.dao.apis:insert { 11 | name = "mock", 12 | upstream_url = "http://mockbin.com", 13 | uris = { "/mock" } 14 | }) 15 | print("Api created:") 16 | for k, v in pairs(api1) do 17 | print(k, ": ", v) 18 | end 19 | assert(helpers.dao.plugins:insert { 20 | name = "oidc", 21 | config = { 22 | client_id = "afcc3a0a-aaa4-4bac-b86a-a7bd77259dd3", 23 | client_secret = "81de73f0-3a0e-451a-88ee-e540811a049c", 24 | discovery = "http://mockbin.org/bin/bd08be64-1820-4e1a-aca2-b4a38cd07961/" 25 | } 26 | }) 27 | 28 | -- start Kong with your testing Kong configuration (defined in "spec.helpers") 29 | assert(helpers.start_kong()) 30 | print("Kong started") 31 | 32 | admin_client = helpers.admin_client(timeout) 33 | end) 34 | 35 | teardown(function() 36 | if admin_client then 37 | admin_client:close() 38 | end 39 | 40 | helpers.stop_kong() 41 | print("Kong stopped") 42 | end) 43 | 44 | before_each(function() 45 | proxy_client = helpers.proxy_client(timeout) 46 | end) 47 | 48 | after_each(function() 49 | if proxy_client then 50 | proxy_client:close() 51 | end 52 | end) 53 | 54 | describe("being an OpenID Connect Relaying Party component", function() 55 | it("should redirect the authentication request (which is an OAuth 2.0 authorization request) to OP", function() 56 | local res = assert(proxy_client:send { 57 | method = "GET", 58 | path = "/mock", 59 | }) 60 | local body = assert.res_status(302, res) 61 | local redirect_uri = res.headers["Location"] 62 | assert.is_truthy(string.find(redirect_uri, "response_type=code")) 63 | assert.is_truthy(string.find(redirect_uri, "scope=openid")) 64 | assert.is_truthy(string.find(redirect_uri, "client_id=")) 65 | assert.is_truthy(string.find(redirect_uri, "state=")) 66 | assert.is_truthy(string.find(redirect_uri, "redirect_uri=")) 67 | end) 68 | 69 | it("should after successful login contact token and userinfo endpoints", function() 70 | -- Mimic authentication response 71 | local res = assert(proxy_client:send { 72 | method = "GET", 73 | path = "/mock/?state=123456&code=123456", 74 | }) 75 | -- This will fail in openidc.lua because session created in first phase is loat 76 | -- and several things could mismatch (state, nonce, original_url, ...) 77 | local body = assert.res_status(500, res) 78 | end) 79 | end) 80 | end) 81 | 82 | -------------------------------------------------------------------------------- /test/docker/integration/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG KONG_BASE_TAG 2 | FROM kong${KONG_BASE_TAG} 3 | USER root 4 | 5 | ENV LUA_PATH /usr/local/share/lua/5.1/?.lua;/usr/local/kong-oidc/?.lua;; 6 | # For lua-cjson 7 | ENV LUA_CPATH /usr/local/lib/lua/5.1/?.so;; 8 | 9 | # Install unzip for luarocks, gcc for lua-cjson 10 | RUN apt update && apt install -y unzip gcc curl 11 | RUN luarocks install luacov 12 | RUN luarocks install luaunit 13 | RUN luarocks install lua-cjson 14 | RUN luarocks install luaossl OPENSSL_DIR=/usr/local/kong CRYPTO_DIR=/usr/local/kong 15 | 16 | # Change openidc version when version in rockspec changes 17 | RUN luarocks install lua-resty-openidc 1.7.5-1 18 | 19 | COPY . /usr/local/kong-oidc 20 | -------------------------------------------------------------------------------- /test/docker/integration/_network_functions: -------------------------------------------------------------------------------- 1 | # Determine if a listener is active (useful for non-http endpoints) 2 | function _listener_ready() { 3 | local endpoint=$1 4 | if ! shift 1; then echo "ERROR: ${FUNCNAME[0]}() Missing argument"; return 1; fi 5 | curl -sf --max-time 1 "gopher://${endpoint}" 6 | 7 | # '28 Operation timeout' means that curl WAS able to connect 8 | [[ $? == 28 ]] 9 | } 10 | 11 | # Sleep until listener becomes active 12 | function _wait_for_listener() { 13 | local endpoint=$1 14 | if ! shift 1; then echo "ERROR: ${FUNCNAME[0]}() Missing argument"; return 1; fi 15 | 16 | echo -n "Waiting for ${endpoint} " 17 | local -i sec=0 18 | while ! _listener_ready ${endpoint}; do 19 | echo -n "." 20 | sleep 1 21 | sec+=1 22 | done 23 | echo "(${sec}s)" 24 | } 25 | 26 | function _endpoint_ready() { 27 | local url=$1 28 | if ! shift 1; then echo "ERROR: ${FUNCNAME[0]}() Missing argument"; return 1; fi 29 | 30 | curl -sf --max-time 1 "${url}" > /dev/null 2>&1 31 | } 32 | 33 | # Sleep until listener becomes active 34 | # Usage: _wait_for_endpoint [] 35 | function _wait_for_endpoint() { 36 | local url=$1 37 | if ! shift 1; then echo "ERROR: ${FUNCNAME[0]}() Missing argument"; return 1; fi 38 | local -i timeout=$2 39 | 40 | local -i sec=0 41 | echo -n "Waiting for ${url} " 42 | while ! _endpoint_ready ${url}; do 43 | echo -n "." 44 | sleep 1 45 | sec+=1 46 | done 47 | echo "(${sec}s)" 48 | } -------------------------------------------------------------------------------- /test/docker/integration/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.4' 2 | 3 | services: 4 | kong-db: 5 | image: postgres${KONG_DB_TAG} 6 | ports: 7 | - ${KONG_DB_PORT}:5432 8 | environment: 9 | POSTGRES_USER: ${KONG_DB_USER} 10 | POSTGRES_PASSWORD: ${KONG_DB_PW} 11 | POSTGRES_DB: ${KONG_DB_NAME} 12 | 13 | keycloak: 14 | image: jboss/keycloak${KEYCLOAK_TAG} 15 | ports: 16 | - ${KEYCLOAK_PORT}:8080 17 | environment: 18 | KEYCLOAK_USER: ${KEYCLOAK_USER} 19 | KEYCLOAK_PASSWORD: ${KEYCLOAK_PW} 20 | 21 | kong-session-store: 22 | image: redis 23 | ports: 24 | - 6379:6379 25 | 26 | kong: 27 | image: nokia/kong-oidc${KONG_TAG} 28 | ports: 29 | - 8000:8000 30 | - 8443:8443 31 | - 8001:8001 32 | - 8444:8444 33 | environment: 34 | KONG_NGINX_PROXY_INCLUDE: /usr/local/kong-oidc/test/docker/integration/nginx-redis.kong.conf 35 | KONG_DATABASE: postgres 36 | KONG_PG_HOST: kong-db 37 | KONG_PG_DATABASE: ${KONG_DB_NAME} 38 | KONG_PG_USER: ${KONG_DB_USER} 39 | KONG_PG_PASSWORD: ${KONG_DB_PW} 40 | KONG_ADMIN_LISTEN: 0.0.0.0:${KONG_HTTP_ADMIN_PORT} 41 | KONG_PROXY_LISTEN: 0.0.0.0:${KONG_HTTP_PROXY_PORT} 42 | KONG_PROXY_ACCESS_LOG: /dev/stdout 43 | KONG_ADMIN_ACCESS_LOG: /dev/stdout 44 | KONG_PROXY_ERROR_LOG: /dev/stderr 45 | KONG_ADMIN_ERROR_LOG: /dev/stderr 46 | KONG_PLUGINS: oidc 47 | depends_on: 48 | - kong-db 49 | - kong-session-store 50 | -------------------------------------------------------------------------------- /test/docker/integration/keycloak_client.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | class KeycloakClient: 4 | def __init__(self, url, realm, username, password): 5 | self._endpoint = url 6 | self._realm = realm 7 | self._session = requests.session() 8 | self._username = username 9 | self._password = password 10 | 11 | def discover(self, config_type = "openid-configuration"): 12 | res = self._session.get("{}/auth/realms/{}/.well-known/{}".format(self._endpoint, self._realm, config_type)) 13 | res.raise_for_status() 14 | return res.json() 15 | 16 | def create_client(self, name, secret): 17 | url = "{}/auth/admin/realms/master/clients".format(self._endpoint) 18 | payload = { 19 | "clientId": name, 20 | "secret": secret, 21 | "redirectUris": ["*"], 22 | } 23 | 24 | headers = self.get_auth_header() 25 | res = self._session.post(url, json=payload, headers=headers) 26 | 27 | if res.status_code not in [201, 409]: 28 | raise Exception("Cannot Keycloak create client") 29 | 30 | def get_auth_header(self): 31 | return { 32 | "Authorization": f'Bearer {self.get_token("admin-cli")}' 33 | } 34 | 35 | def get_token(self, client_id): 36 | url = "{}/auth/realms/{}/protocol/openid-connect/token".format(self._endpoint, self._realm) 37 | 38 | payload = f'client_id={client_id}&grant_type=password' + \ 39 | f'&username={self._username}&password={self._password}' 40 | 41 | headers = { 42 | "Content-Type": "application/x-www-form-urlencoded" 43 | } 44 | 45 | res = self._session.post(url, data=payload, headers=headers) 46 | res.raise_for_status() 47 | 48 | return res.json()["access_token"] 49 | -------------------------------------------------------------------------------- /test/docker/integration/kong_client.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | class KongClient: 4 | def __init__(self, url): 5 | self._endpoint = url 6 | self._session = requests.session() 7 | 8 | def create_service(self, name, upstream_url): 9 | url = "{}/services".format(self._endpoint) 10 | payload = { 11 | "name": name, 12 | "url": upstream_url, 13 | } 14 | res = self._session.post(url, json=payload) 15 | res.raise_for_status() 16 | return res.json() 17 | 18 | def create_route(self, service_name, paths): 19 | url = "{}/services/{}/routes".format(self._endpoint, service_name) 20 | payload = { 21 | "paths": paths, 22 | } 23 | res = self._session.post(url, json=payload) 24 | res.raise_for_status() 25 | return res.json() 26 | 27 | def create_plugin(self, plugin_name, service_name, config): 28 | url = "{}/services/{}/plugins".format(self._endpoint, service_name) 29 | payload = { 30 | "name": plugin_name, 31 | "config": config, 32 | } 33 | res = self._session.post(url, json=payload) 34 | try: 35 | res.raise_for_status() 36 | except Exception as e: 37 | print(res.text) 38 | raise e 39 | return res.json() 40 | 41 | def delete_service(self, name): 42 | try: 43 | routes = self.get_routes(name) 44 | for route in routes: 45 | self.delete_route(route) 46 | except requests.exceptions.HTTPError: 47 | pass 48 | url = "{}/services/{}".format(self._endpoint, name) 49 | self._session.delete(url).raise_for_status() 50 | 51 | def delete_route(self, route_id): 52 | url = "{}/routes/{}".format(self._endpoint, route_id) 53 | self._session.delete(url).raise_for_status() 54 | 55 | def get_routes(self, service_name): 56 | url = "{}/services/{}/routes".format(self._endpoint, service_name) 57 | res = self._session.get(url) 58 | res.raise_for_status() 59 | return map(lambda x: x['id'], res.json()['data']) 60 | -------------------------------------------------------------------------------- /test/docker/integration/nginx-redis.kong.conf: -------------------------------------------------------------------------------- 1 | set $session_storage redis; 2 | set $session_redis_prefix sessions; 3 | set $session_redis_host kong-session-store; 4 | set $session_redis_port 6379; 5 | -------------------------------------------------------------------------------- /test/docker/integration/setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | import requests 5 | from collections import namedtuple 6 | 7 | from keycloak_client import KeycloakClient 8 | from kong_client import KongClient 9 | 10 | local_ip = os.getenv("IP", default="") 11 | host = "localhost" 12 | env_file_path = ".env" 13 | 14 | Config = namedtuple("Config", [ 15 | "keycloak_endpoint", 16 | "keycloak_realm", 17 | "keycloak_admin", 18 | "keycloak_password", 19 | "client_id", 20 | "client_secret", 21 | "discovery", 22 | "kong_endpoint" 23 | ]) 24 | 25 | def validate_ip_set(): 26 | if local_ip == "": 27 | raise Exception("IP environment variable not set. See README.md for further instructions.") 28 | 29 | """ 30 | Attempt to pull in environment variables from .env file 31 | Returns {"KONG_TAG": "", "KONG_DB_TAG": ":10.1" ...} 32 | """ 33 | def get_env_vars(): 34 | with open(env_file_path) as f: 35 | lines = [l.rstrip().split("=", maxsplit=1) 36 | for l in f 37 | # Skip blank lines and comments 38 | if l.strip() != "" and not l.startswith("#")] 39 | 40 | return {l[0]: l[1] for l in lines} 41 | 42 | def get_config(env): 43 | keycloak_url = "http://{}:{}".format(host, env["KEYCLOAK_PORT"]) 44 | discovery_path = "/auth/realms/master/.well-known/openid-configuration" 45 | 46 | return Config( 47 | keycloak_endpoint = keycloak_url, 48 | keycloak_realm = "master", 49 | keycloak_admin = env["KEYCLOAK_USER"], 50 | keycloak_password = env["KEYCLOAK_PW"], 51 | client_id = "kong", 52 | client_secret = "secret", 53 | # Set host to local IP address so requests from Kong to Keycloak can make it 54 | # out of the container 55 | discovery = "http://{}:{}{}".format(local_ip, env["KEYCLOAK_PORT"], discovery_path), 56 | kong_endpoint = "http://{}:{}".format(host, env["KONG_HTTP_ADMIN_PORT"]) 57 | ) 58 | 59 | if __name__ == '__main__': 60 | validate_ip_set() 61 | 62 | print("Reading environment vars from {}".format(env_file_path)) 63 | env = get_env_vars() 64 | config = get_config(env) 65 | 66 | print("Creating Keycloak HTTP Client at {}".format(config.keycloak_endpoint)) 67 | kc_client = KeycloakClient(config.keycloak_endpoint, 68 | config.keycloak_realm, 69 | config.keycloak_admin, 70 | config.keycloak_password) 71 | 72 | print("Creating Keycloak client: {}".format(config.client_id)) 73 | kc_client.create_client(config.client_id, config.client_secret) 74 | 75 | print("Creating Kong HTTP Admin Client at {}".format(config.kong_endpoint)) 76 | kong_client = KongClient(config.kong_endpoint) 77 | 78 | print("Configuring Kong services, routes, and plugins for testing") 79 | kong_client.delete_service("httpbin") 80 | kong_client.create_service("httpbin", "http://httpbin.org") 81 | kong_client.create_route("httpbin", ["/httpbin"]) 82 | kong_client.create_plugin("oidc", "httpbin", { 83 | "client_id": config.client_id, 84 | "client_secret": config.client_secret, 85 | "discovery": config.discovery, 86 | "logout_path": "/httpbin/logout", 87 | }) 88 | 89 | print("Environment setup complete") -------------------------------------------------------------------------------- /test/docker/unit/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG KONG_BASE_TAG 2 | FROM kong${KONG_BASE_TAG} 3 | USER root 4 | 5 | ENV LUA_PATH /usr/local/share/lua/5.1/?.lua;/usr/local/kong-oidc/?.lua 6 | # For lua-cjson 7 | ENV LUA_CPATH /usr/local/lib/lua/5.1/?.so 8 | 9 | # Install unzip for luarocks, gcc for lua-cjson 10 | RUN apt update && apt install -y unzip gcc curl 11 | RUN luarocks install luacov 12 | RUN luarocks install luaunit 13 | RUN luarocks install lua-cjson 14 | RUN luarocks install luaossl OPENSSL_DIR=/usr/local/kong CRYPTO_DIR=/usr/local/kong 15 | 16 | # Change openidc version when version in rockspec changes 17 | RUN luarocks install lua-resty-openidc 1.7.5-1 18 | 19 | WORKDIR /usr/local/kong-oidc 20 | 21 | COPY . . -------------------------------------------------------------------------------- /test/unit/base_case.lua: -------------------------------------------------------------------------------- 1 | package.path = package.path .. ";test/lib/?.lua;;" -- kong & co 2 | 3 | local Object = require "kong.vendor.classic" 4 | local BaseCase = Object:extend() 5 | 6 | 7 | function BaseCase:setUp() 8 | end 9 | 10 | function BaseCase:tearDown() 11 | end 12 | 13 | 14 | return BaseCase 15 | -------------------------------------------------------------------------------- /test/unit/mockable_case.lua: -------------------------------------------------------------------------------- 1 | local BaseCase = require("test.unit.base_case") 2 | local MockableCase = BaseCase:extend() 3 | 4 | 5 | function MockableCase:setUp() 6 | MockableCase.super:setUp() 7 | self.logs = {} 8 | self.mocked_ngx = { 9 | DEBUG = "debug", 10 | ERR = "error", 11 | HTTP_UNAUTHORIZED = 401, 12 | HTTP_FORBIDDEN = 403, 13 | HTTP_INTERNAL_SERVER_ERROR = 500, 14 | ctx = {}, 15 | header = {}, 16 | var = {request_uri = "/"}, 17 | req = { 18 | get_uri_args = function(...) end, 19 | set_header = function(...) end, 20 | get_headers = function(...) end 21 | }, 22 | log = function(...) 23 | self.logs[#self.logs+1] = table.concat({...}, " ") 24 | print("ngx.log: ", self.logs[#self.logs]) 25 | end, 26 | say = function(...) end, 27 | exit = function(...) end, 28 | redirect = function(...) end, 29 | config = { 30 | subsystem = "http" 31 | } 32 | } 33 | self.ngx = _G.ngx 34 | _G.ngx = self.mocked_ngx 35 | 36 | self.mocked_kong = { 37 | client = { 38 | authenticate = function(consumer, credential) 39 | ngx.ctx.authenticated_consumer = consumer 40 | ngx.ctx.authenticated_credential = credential 41 | end 42 | }, 43 | service = { 44 | request = { 45 | clear_header = function(...) end, 46 | set_header = function(...) end 47 | } 48 | }, 49 | response = { 50 | error = function(status) 51 | ngx.status = status 52 | end 53 | }, 54 | log = { 55 | err = function(...) end 56 | }, 57 | ctx = { 58 | shared = {} 59 | } 60 | } 61 | self.kong = _G.kong 62 | _G.kong = self.mocked_kong 63 | 64 | self.resty = package.loaded.resty 65 | package.loaded["resty.http"] = nil 66 | package.preload["resty.http"] = function() 67 | return {encode = function(...) return "encoded" end} 68 | end 69 | 70 | self.cjson = package.loaded.cjson 71 | package.loaded.cjson = nil 72 | package.preload["cjson"] = function() 73 | return { 74 | encode = function(...) return "encoded" end, 75 | decode = function(...) return {sub = "sub"} end 76 | } 77 | end 78 | end 79 | 80 | function MockableCase:tearDown() 81 | MockableCase.super:tearDown() 82 | _G.ngx = self.ngx 83 | _G.kong = self.kong 84 | package.loaded.resty = self.resty 85 | package.loaded.cjson = self.cjson 86 | end 87 | 88 | function MockableCase:log_contains(str) 89 | return table.concat(self.logs, "//"):find(str) and true or false 90 | end 91 | 92 | function MockableCase:__tostring() 93 | return "MockableCase" 94 | end 95 | 96 | 97 | return MockableCase 98 | -------------------------------------------------------------------------------- /test/unit/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | rm -f luacov.stats.out 5 | 6 | # Run all test_*.lua files in test/unit 7 | for f in test/unit/test_*.lua; do 8 | (set -x 9 | lua -lluacov ${f} -o TAP --failure 10 | ) 11 | done 12 | luacov 13 | cat luacov.report.out 14 | -------------------------------------------------------------------------------- /test/unit/test_already_auth.lua: -------------------------------------------------------------------------------- 1 | local lu = require("luaunit") 2 | TestHandler = require("test.unit.mockable_case"):extend() 3 | 4 | 5 | function TestHandler:setUp() 6 | TestHandler.super:setUp() 7 | 8 | package.loaded["resty.openidc"] = nil 9 | self.module_resty = { openidc = {} } 10 | package.preload["resty.openidc"] = function() 11 | return self.module_resty.openidc 12 | end 13 | 14 | self.handler = require("kong.plugins.oidc.handler")() 15 | end 16 | 17 | function TestHandler:tearDown() 18 | TestHandler.super:tearDown() 19 | end 20 | 21 | function TestHandler:test_skip_already_auth_has_cred() 22 | kong.client.get_credential = function() return { consumer_id = "user" } end 23 | local called_authenticate 24 | self.module_resty.openidc.authenticate = function(opts) 25 | called_authenticate = true 26 | return nil, "error" 27 | end 28 | self.handler:access({ skip_already_auth_requests = "yes" }) 29 | lu.assertNil(called_authenticate) 30 | end 31 | 32 | function TestHandler:test_skip_already_auth_has_no_cred() 33 | kong.client.get_credential = function() return nil end 34 | local called_authenticate 35 | self.module_resty.openidc.authenticate = function(opts) 36 | called_authenticate = true 37 | return nil, "error" 38 | end 39 | self.handler:access({ skip_already_auth_requests = "yes" }) 40 | lu.assertTrue(called_authenticate) 41 | end 42 | 43 | 44 | lu.run() 45 | -------------------------------------------------------------------------------- /test/unit/test_bearer_jwt_auth.lua: -------------------------------------------------------------------------------- 1 | local lu = require("luaunit") 2 | TestHandler = require("test.unit.mockable_case"):extend() 3 | 4 | 5 | function TestHandler:setUp() 6 | TestHandler.super:setUp() 7 | 8 | package.loaded["resty.openidc"] = nil 9 | self.module_resty = { openidc = {} } 10 | package.preload["resty.openidc"] = function() 11 | return self.module_resty.openidc 12 | end 13 | 14 | self.handler = require("kong.plugins.oidc.handler")() 15 | end 16 | 17 | function TestHandler:tearDown() 18 | TestHandler.super:tearDown() 19 | end 20 | 21 | function TestHandler:test_bearer_jwt_auth_success() 22 | ngx.req.get_headers = function() return {Authorization = "Bearer xxx"} end 23 | ngx.encode_base64 = function(x) return "eyJzdWIiOiJzdWIifQ==" end 24 | 25 | self.module_resty.openidc.get_discovery_doc = function(opts) 26 | return { issuer = "https://oidc" } 27 | end 28 | 29 | self.module_resty.openidc.bearer_jwt_verify = function(opts) 30 | token = { 31 | iss = "https://oidc", 32 | sub = "sub111", 33 | aud = "aud222", 34 | groups = { "users" } 35 | } 36 | return token, nil, "xxx" 37 | end 38 | 39 | self.handler:access({ 40 | bearer_jwt_auth_enable = "yes", 41 | client_id = "aud222", 42 | groups_claim = "groups", 43 | userinfo_header_name = "x-userinfo" 44 | }) 45 | lu.assertEquals(ngx.ctx.authenticated_credential.id, "sub111") 46 | lu.assertEquals(kong.ctx.shared.authenticated_groups, { "users" }) 47 | end 48 | 49 | function TestHandler:test_bearer_jwt_auth_fail() 50 | ngx.req.get_headers = function() return {Authorization = "Bearer xxx"} end 51 | local called_authenticate 52 | self.module_resty.openidc.get_discovery_doc = function(opts) 53 | return { issuer = "https://oidc" } 54 | end 55 | 56 | self.module_resty.openidc.bearer_jwt_verify = function(opts) 57 | return nil, "JWT expired" 58 | end 59 | 60 | self.module_resty.openidc.authenticate = function(opts) 61 | called_authenticate = true 62 | return nil, "error" 63 | end 64 | self.handler:access({bearer_jwt_auth_enable = "yes", client_id = "aud222"}) 65 | lu.assertTrue(called_authenticate) 66 | end 67 | 68 | lu.run() 69 | -------------------------------------------------------------------------------- /test/unit/test_filter.lua: -------------------------------------------------------------------------------- 1 | local filter = require("kong.plugins.oidc.filter") 2 | local lu = require("luaunit") 3 | 4 | TestFilter = require("test.unit.base_case"):extend() 5 | 6 | function TestFilter:setUp() 7 | TestFilter.super:setUp() 8 | _G.ngx = { 9 | var = { 10 | uri = "" 11 | } 12 | } 13 | end 14 | 15 | function TestFilter:tearDown() 16 | TestFilter.super:tearDown() 17 | end 18 | 19 | local config = { 20 | filters = { "^/pattern1$","^/pattern2$"} 21 | } 22 | 23 | function TestFilter:testIgnoreRequestWhenMatchingPattern1() 24 | ngx.var.uri = "/pattern1" 25 | lu.assertFalse(filter.shouldProcessRequest(config)) 26 | end 27 | 28 | function TestFilter:testIgnoreRequestWhenMatchingPattern2() 29 | ngx.var.uri = "/pattern2" 30 | lu.assertFalse(filter.shouldProcessRequest(config)) 31 | end 32 | 33 | function TestFilter:testProcesseRequestWhenNoMatch() 34 | ngx.var.uri = "/not_matching" 35 | lu.assertTrue(filter.shouldProcessRequest(config)) 36 | end 37 | 38 | function TestFilter:testProcessRequestWhenTheyAreNoFiltersNil() 39 | ngx.var.uri = "/pattern1" 40 | config.filters= nil 41 | lu.assertTrue(filter.shouldProcessRequest(config)) 42 | end 43 | 44 | function TestFilter:testProcessRequestWhenTheyAreNoFiltersEmpty() 45 | ngx.var.uri = "/pattern1" 46 | config.filters= {} 47 | lu.assertTrue(filter.shouldProcessRequest(config)) 48 | end 49 | 50 | 51 | lu.run() 52 | -------------------------------------------------------------------------------- /test/unit/test_filters_advanced.lua: -------------------------------------------------------------------------------- 1 | local filter = require("kong.plugins.oidc.filter") 2 | local lu = require("luaunit") 3 | 4 | TestFilter = require("test.unit.mockable_case"):extend() 5 | 6 | function TestFilter:setUp() 7 | TestFilter.super:setUp() 8 | _G.ngx = { 9 | var = { 10 | uri = "" 11 | } 12 | } 13 | end 14 | 15 | function TestFilter:tearDown() 16 | TestFilter.super:tearDown() 17 | end 18 | 19 | 20 | local config = { 21 | filters = { 22 | "^/auth$", 23 | "^/auth[^%w_%-%.~]", 24 | "^/arc$","^/arc[^%w_%-%.~]", 25 | "^/projects/%d+/zeppelin[^%w_%-%.~]", 26 | "^/projects/%d+/zeppelin$" 27 | } 28 | } 29 | 30 | function TestFilter:testIgnoreRequestWhenUriIsAuth() 31 | ngx.var.uri = "/auth" 32 | lu.assertFalse(filter.shouldProcessRequest(config)) 33 | 34 | ngx.var.uri = "/auth/" 35 | lu.assertFalse(filter.shouldProcessRequest(config)) 36 | end 37 | 38 | function TestFilter:testIgnoreRequestWhenUriIsArc() 39 | ngx.var.uri = "/arc" 40 | lu.assertFalse(filter.shouldProcessRequest(config)) 41 | 42 | ngx.var.uri = "/arc/" 43 | lu.assertFalse(filter.shouldProcessRequest(config)) 44 | end 45 | 46 | function TestFilter:testProcessRequestWhichAreAllowed() 47 | ngx.var.uri = "/not_auth" 48 | assert(filter.shouldProcessRequest(config) == true) 49 | end 50 | 51 | function TestFilter:testIgnoreRequestBeingIdenticalToFilter() 52 | ngx.var.uri = "/arc" 53 | lu.assertFalse(filter.shouldProcessRequest(config) ) 54 | end 55 | 56 | function TestFilter:testIgnoreRequestStartingWithFilterFollowedBySlash() 57 | ngx.var.uri = "/arc/" 58 | lu.assertFalse(filter.shouldProcessRequest(config) ) 59 | end 60 | 61 | function TestFilter:testIgnoreRequestStartingWithFilterFollowedByPaths() 62 | ngx.var.uri = "/arc/de/triomphe" 63 | lu.assertFalse(filter.shouldProcessRequest(config) ) 64 | 65 | end 66 | 67 | function TestFilter:testIgnoreRequestStartingWithFilterFollowedByQuestionmark() 68 | ngx.var.uri = "/arc?" 69 | lu.assertFalse(filter.shouldProcessRequest(config) ) 70 | 71 | ngx.var.uri = "/arc?de=triomphe" 72 | lu.assertFalse(filter.shouldProcessRequest(config) ) 73 | 74 | end 75 | 76 | function TestFilter:testIgnoreRequestStartingWithFilterFollowedByQuestionmark() 77 | ngx.var.uri = "/arc?" 78 | lu.assertFalse(filter.shouldProcessRequest(config) ) 79 | 80 | ngx.var.uri = "/arc?de=triomphe" 81 | lu.assertFalse(filter.shouldProcessRequest(config) ) 82 | 83 | end 84 | 85 | function TestFilter:testPrefixNotAtTheStart() 86 | ngx.var.uri = "/process_this/arc" 87 | lu.assertTrue(filter.shouldProcessRequest(config) ) 88 | 89 | ngx.var.uri = "/process_this/arc/de/triomphe" 90 | lu.assertTrue(filter.shouldProcessRequest(config) ) 91 | 92 | ngx.var.uri = "/process_this/architecture" 93 | lu.assertTrue(filter.shouldProcessRequest(config) ) 94 | 95 | end 96 | 97 | function TestFilter:testLowercaseLetterAfterPrefix() 98 | ngx.var.uri = "/architecture" 99 | lu.assertTrue(filter.shouldProcessRequest(config) ) 100 | end 101 | 102 | function TestFilter:testUppercaseLetterLetterAfterPrefix() 103 | ngx.var.uri = "/archITACTURE" 104 | lu.assertTrue(filter.shouldProcessRequest(config) ) 105 | end 106 | 107 | function TestFilter:testDigitAfterPrefix() 108 | ngx.var.uri = "/arc123" 109 | lu.assertTrue(filter.shouldProcessRequest(config) ) 110 | end 111 | 112 | function TestFilter:testHyphenAfterPrefix() 113 | ngx.var.uri = "/arc-123" 114 | lu.assertTrue(filter.shouldProcessRequest(config) ) 115 | end 116 | 117 | function TestFilter:testPeriodAfterPrefix() 118 | ngx.var.uri = "/arc.123" 119 | lu.assertTrue(filter.shouldProcessRequest(config) ) 120 | end 121 | 122 | function TestFilter:testUnderscoreAfterPrefix() 123 | ngx.var.uri = "/arc_123" 124 | lu.assertTrue(filter.shouldProcessRequest(config) ) 125 | end 126 | 127 | function TestFilter:testTildeAfterPrefix() 128 | ngx.var.uri = "/arc~123" 129 | lu.assertTrue(filter.shouldProcessRequest(config) ) 130 | end 131 | 132 | lu.run() 133 | -------------------------------------------------------------------------------- /test/unit/test_handler_mocking_openidc.lua: -------------------------------------------------------------------------------- 1 | local lu = require("luaunit") 2 | TestHandler = require("test.unit.mockable_case"):extend() 3 | 4 | 5 | function TestHandler:setUp() 6 | TestHandler.super:setUp() 7 | 8 | package.loaded["resty.openidc"] = nil 9 | self.module_resty = {openidc = { 10 | authenticate = function(...) return {}, nil end } 11 | } 12 | package.preload["resty.openidc"] = function() 13 | return self.module_resty.openidc 14 | end 15 | 16 | self.handler = require("kong.plugins.oidc.handler")() 17 | end 18 | 19 | function TestHandler:tearDown() 20 | TestHandler.super:tearDown() 21 | end 22 | 23 | function TestHandler:test_authenticate_ok_no_userinfo() 24 | self.module_resty.openidc.authenticate = function(opts) 25 | return { id_token = { sub = "sub"}}, false 26 | end 27 | 28 | self.handler:access({disable_id_token_header = "yes"}) 29 | lu.assertTrue(self:log_contains("calling authenticate")) 30 | end 31 | 32 | function TestHandler:test_authenticate_ok_with_userinfo() 33 | self.module_resty.openidc.authenticate = function(opts) 34 | return {user = {sub = "sub"}}, false 35 | end 36 | ngx.encode_base64 = function(x) 37 | return "eyJzdWIiOiJzdWIifQ==" 38 | end 39 | 40 | local headers = {} 41 | kong.service.request.set_header = function(name, value) headers[name] = value end 42 | 43 | self.handler:access({userinfo_header_name = 'X-Userinfo'}) 44 | lu.assertTrue(self:log_contains("calling authenticate")) 45 | lu.assertEquals(ngx.ctx.authenticated_credential.id, "sub") 46 | lu.assertEquals(headers['X-Userinfo'], "eyJzdWIiOiJzdWIifQ==") 47 | end 48 | 49 | function TestHandler:test_authenticate_ok_with_no_accesstoken() 50 | self.module_resty.openidc.authenticate = function(opts) 51 | return {id_token = {sub = "sub"}}, true 52 | end 53 | 54 | local headers = {} 55 | kong.service.request.set_header = function(name, value) headers[name] = value end 56 | 57 | self.handler:access({disable_id_token_header = "yes"}) 58 | lu.assertTrue(self:log_contains("calling authenticate")) 59 | lu.assertNil(headers['X-Access-Token']) 60 | end 61 | 62 | function TestHandler:test_authenticate_ok_with_accesstoken() 63 | self.module_resty.openidc.authenticate = function(opts) 64 | return {id_token = { sub = "sub" } , access_token = "ACCESS_TOKEN"}, false 65 | end 66 | 67 | local headers = {} 68 | kong.service.request.set_header = function(name, value) headers[name] = value end 69 | 70 | self.handler:access({access_token_header_name = 'X-Access-Token', disable_id_token_header = "yes"}) 71 | lu.assertTrue(self:log_contains("calling authenticate")) 72 | lu.assertEquals(headers['X-Access-Token'], "ACCESS_TOKEN") 73 | end 74 | 75 | function TestHandler:test_authenticate_ok_with_no_idtoken() 76 | self.module_resty.openidc.authenticate = function(opts) 77 | return {}, false 78 | end 79 | 80 | local headers = {} 81 | kong.service.request.set_header = function(name, value) headers[name] = value end 82 | 83 | self.handler:access({}) 84 | lu.assertTrue(self:log_contains("calling authenticate")) 85 | lu.assertNil(headers['X-ID-Token']) 86 | end 87 | 88 | function TestHandler:test_authenticate_ok_with_idtoken() 89 | self.module_resty.openidc.authenticate = function(opts) 90 | return {id_token = {sub = "sub"}}, false 91 | end 92 | 93 | ngx.encode_base64 = function(x) 94 | return "eyJzdWIiOiJzdWIifQ==" 95 | end 96 | 97 | local headers = {} 98 | kong.service.request.set_header = function(name, value) headers[name] = value end 99 | 100 | self.handler:access({id_token_header_name = 'X-ID-Token'}) 101 | lu.assertTrue(self:log_contains("calling authenticate")) 102 | lu.assertEquals(headers['X-ID-Token'], "eyJzdWIiOiJzdWIifQ==") 103 | end 104 | 105 | function TestHandler:test_authenticate_nok_no_recovery() 106 | self.module_resty.openidc.authenticate = function(opts) 107 | return nil, true 108 | end 109 | 110 | self.handler:access({}) 111 | lu.assertTrue(self:log_contains("calling authenticate")) 112 | end 113 | 114 | function TestHandler:test_authenticate_nok_deny() 115 | self.module_resty.openidc.authenticate = function(opts) 116 | if opts.unauth_action == "deny" then 117 | return nil, "unauthorized request" 118 | end 119 | return {}, true 120 | end 121 | 122 | self.handler:access({unauth_action = "deny"}) 123 | lu.assertEquals(ngx.status, ngx.HTTP_UNAUTHORIZED) 124 | end 125 | 126 | function TestHandler:test_authenticate_nok_with_recovery() 127 | self.module_resty.openidc.authenticate = function(opts) 128 | return nil, true 129 | end 130 | 131 | self.handler:access({recovery_page_path = "x"}) 132 | lu.assertTrue(self:log_contains("recovery page")) 133 | end 134 | 135 | function TestHandler:test_introspect_ok_no_userinfo() 136 | self.module_resty.openidc.introspect = function(opts) 137 | return false, false 138 | end 139 | ngx.req.get_headers = function() return {Authorization = "Bearer xxx"} end 140 | 141 | self.handler:access({introspection_endpoint = "x"}) 142 | lu.assertTrue(self:log_contains("introspect succeeded")) 143 | end 144 | 145 | function TestHandler:test_introspect_ok_with_userinfo() 146 | self.module_resty.openidc.introspect = function(opts) 147 | return {}, false 148 | end 149 | ngx.req.get_headers = function() return {Authorization = "Bearer xxx"} end 150 | 151 | ngx.encode_base64 = function(x) 152 | return "eyJzdWIiOiJzdWIifQ==" 153 | end 154 | 155 | local headers = {} 156 | kong.service.request.set_header = function(name, value) headers[name] = value end 157 | 158 | self.handler:access({introspection_endpoint = "x", userinfo_header_name = "X-Userinfo"}) 159 | lu.assertTrue(self:log_contains("introspect succeeded")) 160 | lu.assertEquals(headers['X-Userinfo'], "eyJzdWIiOiJzdWIifQ==") 161 | end 162 | 163 | function TestHandler:test_bearer_only_with_good_token() 164 | self.module_resty.openidc.introspect = function(opts) 165 | return {sub = "sub"}, false 166 | end 167 | ngx.req.get_headers = function() return {Authorization = "Bearer xxx"} end 168 | 169 | ngx.encode_base64 = function(x) 170 | return "eyJzdWIiOiJzdWIifQ==" 171 | end 172 | 173 | local headers = {} 174 | kong.service.request.set_header = function(name, value) headers[name] = value end 175 | 176 | self.handler:access({introspection_endpoint = "x", bearer_only = "yes", realm = "kong", userinfo_header_name = "X-Userinfo"}) 177 | lu.assertTrue(self:log_contains("introspect succeeded")) 178 | lu.assertEquals(headers['X-Userinfo'], "eyJzdWIiOiJzdWIifQ==") 179 | end 180 | 181 | function TestHandler:test_bearer_only_with_bad_token() 182 | self.module_resty.openidc.introspect = function(opts) 183 | return {}, "validation failed" 184 | end 185 | ngx.req.get_headers = function() return {Authorization = "Bearer xxx"} end 186 | 187 | self.handler:access({introspection_endpoint = "x", bearer_only = "yes", realm = "kong", userinfo_header_name = 'X-Userinfo'}) 188 | 189 | lu.assertEquals(ngx.header["WWW-Authenticate"], 'Bearer realm="kong",error="validation failed"') 190 | lu.assertEquals(ngx.status, ngx.HTTP_UNAUTHORIZED) 191 | lu.assertFalse(self:log_contains("introspect succeeded")) 192 | end 193 | 194 | function TestHandler:test_introspect_bearer_token_and_property_mapping() 195 | self.module_resty.openidc.bearer_jwt_verify = function(opts) 196 | return {foo = "bar"}, false 197 | end 198 | ngx.req.get_headers = function() return {Authorization = "Bearer xxx"} end 199 | 200 | ngx.encode_base64 = function(x) return "x" end 201 | 202 | local headers = {} 203 | kong.service.request.set_header = function(name, value) headers[name] = value end 204 | 205 | self.handler:access({introspection_endpoint = "x", bearer_only = "yes", use_jwks = "yes", disable_userinfo_header = "yes", header_names = {'X-Foo', 'present'}, header_claims = {'foo', 'not'}}) 206 | lu.assertEquals(headers["X-Foo"], 'bar') 207 | lu.assertNil(headers["present"]) 208 | end 209 | 210 | function TestHandler:test_introspect_bearer_token_and_incorrect_property_mapping() 211 | self.module_resty.openidc.bearer_jwt_verify = function(opts) 212 | return {foo = "bar"}, false 213 | end 214 | ngx.req.get_headers = function() return {Authorization = "Bearer xxx"} end 215 | 216 | ngx.encode_base64 = function(x) return "x" end 217 | 218 | local headers = {} 219 | kong.service.request.set_header = function(name, value) headers[name] = value end 220 | 221 | self.handler:access({introspection_endpoint = "x", bearer_only = "yes", use_jwks = "yes", disable_userinfo_header = "yes", header_names = {'X-Foo'}, header_claims = {'foo', 'incorrect'}}) 222 | lu.assertNil(headers["X-Foo"]) 223 | end 224 | 225 | function TestHandler:test_introspect_bearer_token_and_scope_nok() 226 | self.module_resty.openidc.bearer_jwt_verify = function(opts) 227 | return {scope = "foo"}, false 228 | end 229 | ngx.req.get_headers = function() return {Authorization = "Bearer xxx"} end 230 | 231 | ngx.encode_base64 = function(x) return "x" end 232 | 233 | self.handler:access({introspection_endpoint = "x", bearer_only = "yes", use_jwks = "yes", userinfo_header_name = "X-Userinfo", validate_scope = "yes", scope = "bar"}) 234 | lu.assertEquals(ngx.status, ngx.HTTP_FORBIDDEN) 235 | end 236 | 237 | function TestHandler:test_introspect_bearer_token_and_empty_scope_nok() 238 | self.module_resty.openidc.bearer_jwt_verify = function(opts) 239 | return {foo = "bar"}, false 240 | end 241 | ngx.req.get_headers = function() return {Authorization = "Bearer xxx"} end 242 | 243 | ngx.encode_base64 = function(x) return "x" end 244 | 245 | self.handler:access({introspection_endpoint = "x", bearer_only = "yes", use_jwks = "yes", userinfo_header_name = "X-Userinfo", validate_scope = "yes", scope = "bar"}) 246 | lu.assertEquals(ngx.status, ngx.HTTP_FORBIDDEN) 247 | end 248 | 249 | function TestHandler:test_introspect_bearer_token_and_scope_ok() 250 | self.module_resty.openidc.bearer_jwt_verify = function(opts) 251 | return {scope = "foo bar"}, false 252 | end 253 | ngx.req.get_headers = function() return {Authorization = "Bearer xxx"} end 254 | 255 | ngx.encode_base64 = function(x) return "x" end 256 | 257 | self.handler:access({introspection_endpoint = "x", bearer_only = "yes", use_jwks = "yes", userinfo_header_name = "X-Userinfo", validate_scope = "yes", scope = "bar"}) 258 | lu.assertNotEquals(ngx.status, ngx.HTTP_FORBIDDEN) 259 | lu.assertNotEquals(ngx.status, ngx.HTTP_INTERNAL_SERVER_ERROR) 260 | end 261 | 262 | lu.run() 263 | -------------------------------------------------------------------------------- /test/unit/test_header_claims.lua: -------------------------------------------------------------------------------- 1 | local lu = require("luaunit") 2 | TestHandler = require("test.unit.mockable_case"):extend() 3 | 4 | function TestHandler:setUp() 5 | TestHandler.super:setUp() 6 | 7 | package.loaded["resty.openidc"] = nil 8 | self.module_resty = { openidc = {} } 9 | package.preload["resty.openidc"] = function() 10 | return self.module_resty.openidc 11 | end 12 | 13 | self.handler = require("kong.plugins.oidc.handler")() 14 | end 15 | 16 | function TestHandler:tearDown() 17 | TestHandler.super:tearDown() 18 | end 19 | 20 | function TestHandler:test_header_add() 21 | self.module_resty.openidc.authenticate = function(opts) 22 | return { user = {sub = "sub", email = "ghost@localhost"}, id_token = { sub = "sub", aud = "aud123"} }, false 23 | end 24 | local headers = {} 25 | kong.service.request.set_header = function(name, value) headers[name] = value end 26 | 27 | self.handler:access({ disable_id_token_header = "yes", disable_userinfo_header = "yes", 28 | header_names = { "X-Email", "X-Aud"}, header_claims = { "email", "aud" } }) 29 | lu.assertEquals(headers["X-Email"], "ghost@localhost") 30 | lu.assertEquals(headers["X-Aud"], "aud123") 31 | end 32 | 33 | lu.run() 34 | -------------------------------------------------------------------------------- /test/unit/test_introspect.lua: -------------------------------------------------------------------------------- 1 | local lu = require("luaunit") 2 | 3 | TestIntrospect = require("test.unit.mockable_case"):extend() 4 | 5 | 6 | function TestIntrospect:setUp() 7 | TestIntrospect.super:setUp() 8 | self.handler = require("kong.plugins.oidc.handler")() 9 | end 10 | 11 | function TestIntrospect:tearDown() 12 | TestIntrospect.super:tearDown() 13 | end 14 | 15 | function TestIntrospect:test_access_token_exists() 16 | package.loaded["resty.openidc"] = nil 17 | self.module_resty = { 18 | openidc = { 19 | introspect = function(...) return { sub = "sub" }, nil end, 20 | } 21 | } 22 | package.preload["resty.openidc"] = function() return self.module_resty.openidc end 23 | 24 | ngx.req.get_headers = function() return {Authorization = "Bearer xxx"} end 25 | 26 | ngx.encode_base64 = function(x) 27 | return "eyJzdWIiOiJzdWIifQ==" 28 | end 29 | 30 | local headers = {} 31 | kong.service.request.set_header = function(name, value) headers[name] = value end 32 | 33 | self.handler:access({introspection_endpoint = "x", userinfo_header_name = "X-Userinfo"}) 34 | lu.assertTrue(self:log_contains("introspect succeeded")) 35 | lu.assertEquals(headers['X-Userinfo'], "eyJzdWIiOiJzdWIifQ==") 36 | end 37 | 38 | function TestIntrospect:test_no_authorization_header() 39 | package.loaded["resty.openidc"] = nil 40 | self.module_resty = { 41 | openidc = { 42 | authenticate = function(...) return {}, nil end 43 | } 44 | } 45 | package.preload["resty.openidc"] = function() return self.module_resty.openidc end 46 | 47 | ngx.req.get_headers = function() return {} end 48 | 49 | local headers = {} 50 | kong.service.request.set_header = function(name, value) headers[name] = value end 51 | 52 | self.handler:access({introspection_endpoint = "x", userinfo_header_name = "X-Userinfo"}) 53 | lu.assertFalse(self:log_contains(self.mocked_ngx.ERR)) 54 | lu.assertEquals(headers['X-Userinfo'], nil) 55 | end 56 | 57 | 58 | lu.run() 59 | -------------------------------------------------------------------------------- /test/unit/test_utils.lua: -------------------------------------------------------------------------------- 1 | local utils = require("kong.plugins.oidc.utils") 2 | local lu = require("luaunit") 3 | 4 | TestUtils = require("test.unit.base_case"):extend() 5 | 6 | 7 | function TestUtils:testRedirectUriPath() 8 | local ngx = { 9 | var = { 10 | scheme = "http", 11 | host = "1.2.3.4", 12 | request_uri = "" 13 | }, 14 | req = { 15 | get_uri_args = function() return nil end 16 | } 17 | } 18 | ngx.var.request_uri = "/path?some=stuff" 19 | lu.assertEquals(utils.get_redirect_uri(ngx), "/path/") 20 | 21 | ngx.var.request_uri = "/long/path/" 22 | lu.assertEquals(utils.get_redirect_uri(ngx), "/long/path") 23 | 24 | ngx.req.get_uri_args = function() return {code = 1}end 25 | lu.assertEquals(utils.get_redirect_uri(ngx), "/long/path/") 26 | end 27 | 28 | function TestUtils:testOptions() 29 | local opts = utils.get_options({ 30 | client_id = 1, 31 | client_secret = 2, 32 | discovery = "d", 33 | scope = "openid", 34 | response_type = "code", 35 | ssl_verify = "no", 36 | token_endpoint_auth_method = "client_secret_post", 37 | introspection_endpoint_auth_method = "client_secret_basic", 38 | filters = "pattern1,pattern2,pattern3", 39 | logout_path = "/logout", 40 | redirect_after_logout_uri = "/login", 41 | userinfo_header_name = "X-UI", 42 | id_token_header_name = "X-ID", 43 | access_token_header_name = "Authorization", 44 | access_token_as_bearer = "yes", 45 | disable_userinfo_header = "yes", 46 | disable_id_token_header = "yes", 47 | disable_access_token_header = "yes" 48 | }, {var = {request_uri = "/path"}, 49 | req = {get_uri_args = function() return nil end}}) 50 | 51 | lu.assertEquals(opts.client_id, 1) 52 | lu.assertEquals(opts.client_secret, 2) 53 | lu.assertEquals(opts.discovery, "d") 54 | lu.assertEquals(opts.scope, "openid") 55 | lu.assertEquals(opts.response_type, "code") 56 | lu.assertEquals(opts.ssl_verify, "no") 57 | lu.assertEquals(opts.token_endpoint_auth_method, "client_secret_post") 58 | lu.assertEquals(opts.introspection_endpoint_auth_method, "client_secret_basic") 59 | lu.assertEquals(opts.redirect_uri, "/path/") 60 | lu.assertEquals(opts.logout_path, "/logout") 61 | lu.assertEquals(opts.redirect_after_logout_uri, "/login") 62 | lu.assertEquals(opts.userinfo_header_name, "X-UI") 63 | lu.assertEquals(opts.id_token_header_name, "X-ID") 64 | lu.assertEquals(opts.access_token_header_name, "Authorization") 65 | lu.assertEquals(opts.access_token_as_bearer, true) 66 | lu.assertEquals(opts.disable_userinfo_header, true) 67 | lu.assertEquals(opts.disable_id_token_header, true) 68 | lu.assertEquals(opts.disable_access_token_header, true) 69 | 70 | local expectedFilters = { 71 | "pattern1", 72 | "pattern2", 73 | "pattern3" 74 | } 75 | 76 | lu.assertItemsEquals(expectedFilters, opts.filters) 77 | 78 | end 79 | 80 | function TestUtils:testCommonItem() 81 | lu.assertFalse(utils.has_common_item(nil, "aud1")) 82 | lu.assertTrue(utils.has_common_item("aud1", "aud1")) 83 | lu.assertFalse(utils.has_common_item("aud1", "aud2")) 84 | lu.assertFalse(utils.has_common_item({"aud1", "aud2"}, "aud3")) 85 | lu.assertTrue(utils.has_common_item({"aud1", "aud2"}, "aud2")) 86 | lu.assertFalse(utils.has_common_item("aud1", {"aud2", "aud3"})) 87 | lu.assertTrue(utils.has_common_item("aud2", {"aud2", "aud3"})) 88 | lu.assertTrue(utils.has_common_item({"aud2","aud3","aud4"}, {"aud4", "aud5"})) 89 | lu.assertFalse(utils.has_common_item({"aud2","aud3","aud4"}, {"aud5", "aud6"})) 90 | end 91 | 92 | lu.run() 93 | -------------------------------------------------------------------------------- /test/unit/test_utils_bearer_access_token.lua: -------------------------------------------------------------------------------- 1 | local utils = require("kong.plugins.oidc.utils") 2 | local lu = require("luaunit") 3 | 4 | TestToken = require("test.unit.mockable_case"):extend() 5 | 6 | function TestToken:setUp() 7 | TestToken.super:setUp() 8 | end 9 | 10 | function TestToken:tearDown() 11 | TestToken.super:tearDown() 12 | end 13 | 14 | function TestToken:test_access_token_authorization_missing() 15 | _G.ngx = {req = { 16 | get_headers = function() return {} end } 17 | } 18 | lu.assertFalse(utils.has_bearer_access_token()) 19 | end 20 | 21 | function TestToken:test_access_token_bearer_missing() 22 | _G.ngx = {req = { 23 | get_headers = function() return {"Authorization"} end } 24 | } 25 | lu.assertFalse(utils.has_bearer_access_token()) 26 | end 27 | 28 | function TestToken:test_access_token_bearer_exists() 29 | _G.ngx = {req = { 30 | get_headers = function() return {Authorization = "Bearer xxx"} end } 31 | } 32 | lu.assertTrue(utils.has_bearer_access_token()) 33 | end 34 | 35 | 36 | lu.run() 37 | --------------------------------------------------------------------------------