diff --git a/COPYRIGHT b/COPYRIGHT index 1f1293eff277..797417b98597 100644 --- a/COPYRIGHT +++ b/COPYRIGHT @@ -361,4 +361,68 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +%%%%%%%%% + +lua-resty-http +https://github.com/ledgetech/lua-resty-http + +BSD 2-Clause "Simplified" License + +Copyright (c) 2013, James Hurst +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above copyright notice, this + list of conditions and the following disclaimer in the documentation and/or + other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +%%%%%%%%% + +lua-resty-openidc +https://github.com/zmartzone/lua-resty-openidc + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + + +%%%%%%%%% +kong-oidc +https://github.com/nokia/kong-oidc + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/bin/apisix b/bin/apisix index 7e5319cefb85..ce836f8d03d7 100755 --- a/bin/apisix +++ b/bin/apisix @@ -93,6 +93,11 @@ http { lua_shared_dict upstream-healthcheck 10m; lua_shared_dict worker-events 10m; + # for openid-connect plugin + lua_shared_dict discovery 1m; # cache for discovery metadata documents + lua_shared_dict jwks 1m; # cache for JWKs + lua_shared_dict introspection 10m; # cache for JWT verification results + lua_ssl_verify_depth 5; ssl_session_timeout 86400; diff --git a/conf/config.yaml b/conf/config.yaml index aec58e38a30d..5c2614c29301 100644 --- a/conf/config.yaml +++ b/conf/config.yaml @@ -40,3 +40,4 @@ plugins: # plugin list - grpc-transcode - serverless-pre-function - serverless-post-function + - openid-connect diff --git a/conf/nginx.conf b/conf/nginx.conf index 40fe263db597..d9a861df0908 100644 --- a/conf/nginx.conf +++ b/conf/nginx.conf @@ -28,6 +28,11 @@ http { lua_shared_dict upstream-healthcheck 10m; lua_shared_dict worker-events 10m; + # for openid-connect plugin + lua_shared_dict discovery 1m; # cache for discovery metadata documents + lua_shared_dict jwks 1m; # cache for JWKs + lua_shared_dict introspection 10m; # cache for JWT verification results + lua_ssl_verify_depth 5; ssl_session_timeout 86400; diff --git a/lua/apisix/plugins/openid-connect.lua b/lua/apisix/plugins/openid-connect.lua new file mode 100644 index 000000000000..fc118bcfc1e6 --- /dev/null +++ b/lua/apisix/plugins/openid-connect.lua @@ -0,0 +1,152 @@ +local core = require("apisix.core") +local ngx_re = require("ngx.re") +local openidc = require("resty.openidc") +local ngx = ngx +local ngx_encode_base64 = ngx.encode_base64 + +local plugin_name = "openid-connect" + + +local schema = { + type = "object", + properties = { + client_id = {type = "string"}, + client_secret = {type = "string"}, + discovery = {type = "string"}, + scope = {type = "string"}, + ssl_verify = {type = "boolean"}, -- default is false + timeout = {type = "integer", minimum = 1}, --default is 3 seconds + introspection_endpoint = {type = "string"}, --default is nil + --default is client_secret_basic + introspection_endpoint_auth_method = {type = "string"}, + bearer_only = {type = "boolean"}, -- default is false + realm = {type = "string"}, -- default is apisix + logout_path = {type = "string"}, -- default is /logout + redirect_uri = {type = "string"}, -- default is ngx.var.request_uri + }, + required = {"client_id", "client_secret", "discovery"} +} + + +local _M = { + version = 0.1, + priority = 2599, + name = plugin_name, + schema = schema, +} + +function _M.check_schema(conf) + local ok, err = core.schema.check(schema, conf) + if not ok then + return false, err + end + + if not conf.scope then + conf.scope = "openid" + end + if not conf.ssl_verify then + conf.ssl_verify = "no" + end + if not conf.timeout then + conf.timeout = 3 + end + conf.timeout = conf.timeout * 1000 + if not conf.introspection_endpoint_auth_method then + conf.introspection_endpoint_auth_method = 'client_secret_basic' + end + if not conf.bearer_only then + conf.bearer_only = false + end + if not conf.realm then + conf.realm = 'apisix' + end + if not conf.logout_path then + conf.logout_path = '/logout' + end + + return true +end + + +local function has_bearer_access_token(ctx) + local auth_header = core.request.header(ctx, "Authorization") + if not auth_header then + return false + end + + local res, err = ngx_re.split(auth_header, " ", nil, nil, 2) + if not res then + return false, err + end + + if res[1] == "bearer" then + return true + end + + return false +end + + +local function introspect(ctx, conf) + if has_bearer_access_token(ctx) or conf.bearer_only then + local res, err = openidc.introspect(conf) + if res then + return res + end + if conf.bearer_only then + ngx.header["WWW-Authenticate"] = 'Bearer realm="' .. conf.realm .. '",error="' .. err .. '"' + return ngx.HTTP_UNAUTHORIZED, err + end + end + + return nil +end + + +local function add_user_header(user) + local userinfo = core.json.encode(user) + ngx.req.set_header("X-Userinfo", ngx_encode_base64(userinfo)) +end + + +function _M.access(conf, ctx) + if not conf.redirect_uri then + conf.redirect_uri = ctx.var.request_uri + end + + local response, err + if conf.introspection_endpoint then + response, err = introspect(ctx, conf) + if err then + core.log.error("failed to introspect in openidc: ", err) + return response + end + if response then + add_user_header(response) + end + end + + if not response then + local response, err = openidc.authenticate(conf) + if err then + core.log.error("failed to authenticate in openidc: ", err) + return 500 + end + + if response then + if response.user then + add_user_header(response.user) + end + if response.access_token then + ngx.req.set_header("X-Access-Token", response.access_token) + end + if response.id_token then + local token = core.json.encode(response.id_token) + ngx.req.set_header("X-ID-Token", ngx.encode_base64(token)) + end + end + end +end + + +return _M diff --git a/rockspec/apisix-dev-1.0-0.rockspec b/rockspec/apisix-dev-1.0-0.rockspec index aa460a386c2e..8ed9b6026a69 100644 --- a/rockspec/apisix-dev-1.0-0.rockspec +++ b/rockspec/apisix-dev-1.0-0.rockspec @@ -29,6 +29,7 @@ dependencies = { "lua-resty-radixtree = 0.5", "lua-resty-iputils = 0.3.0-1", "lua-protobuf = 0.3.1", + "lua-resty-openidc = 1.7.2-1", } build = { diff --git a/t/admin/plugins.t b/t/admin/plugins.t index ba9141031ca9..7009630605ed 100644 --- a/t/admin/plugins.t +++ b/t/admin/plugins.t @@ -14,6 +14,6 @@ __DATA__ --- request GET /apisix/admin/plugins/list --- response_body_like eval -qr/\["limit-req","limit-count","limit-conn","key-auth","prometheus","node-status","jwt-auth","zipkin","ip-restriction","grpc-transcode","serverless-pre-function","serverless-post-function"\]/ +qr/\["limit-req","limit-count","limit-conn","key-auth","prometheus","node-status","jwt-auth","zipkin","ip-restriction","grpc-transcode","serverless-pre-function","serverless-post-function","openid-connect"\]/ --- no_error_log [error] diff --git a/t/debug-mode.t b/t/debug-mode.t index 6279510746f0..1bf2a45c0ae5 100644 --- a/t/debug-mode.t +++ b/t/debug-mode.t @@ -41,6 +41,7 @@ qr/loaded plugin and sort by priority: [-\d]+ name: [\w-]+/ --- grep_error_log_out loaded plugin and sort by priority: 10000 name: serverless-pre-function loaded plugin and sort by priority: 3000 name: ip-restriction +loaded plugin and sort by priority: 2599 name: openid-connect loaded plugin and sort by priority: 2510 name: jwt-auth loaded plugin and sort by priority: 2500 name: key-auth loaded plugin and sort by priority: 1003 name: limit-conn diff --git a/t/plugin/openid-connect.t b/t/plugin/openid-connect.t new file mode 100644 index 000000000000..0aa06b7707cf --- /dev/null +++ b/t/plugin/openid-connect.t @@ -0,0 +1,259 @@ +use t::APISix 'no_plan'; + +repeat_each(1); +no_long_string(); +no_root_location(); +run_tests; + +__DATA__ + +=== TEST 1: sanity +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.openid-connect") + local ok, err = plugin.check_schema({client_id = "a", client_secret = "b", discovery = "c"}) + if not ok then + ngx.say(err) + end + + ngx.say("done") + } + } +--- request +GET /t +--- response_body +done +--- no_error_log +[error] + + + +=== TEST 2: missing client_id +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.openid-connect") + local ok, err = plugin.check_schema({client_secret = "b", discovery = "c"}) + if not ok then + ngx.say(err) + end + + ngx.say("done") + } + } +--- request +GET /t +--- response_body +invalid "required" in docuement at pointer "#" +done +--- no_error_log +[error] + + + +=== TEST 3: wrong type of string +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.openid-connect") + local ok, err = plugin.check_schema({client_id = 123, client_secret = "b", discovery = "c"}) + if not ok then + ngx.say(err) + end + + ngx.say("done") + } + } +--- request +GET /t +--- response_body +invalid "type" in docuement at pointer "#/client_id" +done +--- no_error_log +[error] + + + +=== TEST 4: add plugin +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "plugins": { + "openid-connect": { + "client_id": "kbyuFDidLLm280LIwVFiazOqjO3ty8KH", + "client_secret": "60Op4HFM0I8ajz0WdiStAbziZ-VFQttXuxixHHs2R7r7-CW8GR79l-mmLqMhc-Sa", + "discovery": "https://samples.auth0.com/.well-known/openid-configuration", + "redirect_uri": "https://iresty.com", + "ssl_verify": false, + "timeout": 10, + "scope": "apisix" + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/hello" + }]], + [[{ + "node": { + "value": { + "plugins": { + "openid-connect": { + "client_id": "kbyuFDidLLm280LIwVFiazOqjO3ty8KH", + "client_secret": "60Op4HFM0I8ajz0WdiStAbziZ-VFQttXuxixHHs2R7r7-CW8GR79l-mmLqMhc-Sa", + "discovery": "https://samples.auth0.com/.well-known/openid-configuration", + "redirect_uri": "https://iresty.com", + "ssl_verify": "no", + "timeout": 10000, + "scope": "apisix" + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/hello" + }, + "key": "/apisix/routes/1" + }, + "action": "set" + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed +--- no_error_log +[error] + + + +=== TEST 5: access +--- config + location /t { + content_by_lua_block { + local http = require "resty.http" + local httpc = http.new() + local uri = "http://127.0.0.1:" .. ngx.var.server_port .. "/hello" + local res, err = httpc:request_uri(uri, {method = "GET"}) + ngx.status = res.status + local location = res.headers['Location'] + if string.find(location, 'https://samples.auth0.com/authorize') ~= -1 and + string.find(location, 'scope=apisix') ~= -1 and + string.find(location, 'client_id=kbyuFDidLLm280LIwVFiazOqjO3ty8KH') ~= -1 and + string.find(location, 'response_type=code') ~= -1 and + string.find(location, 'redirect_uri=https://iresty.com') ~= -1 then + ngx.say(true) + end + } + } +--- request +GET /t +--- timeout: 10s +--- response_body +true +--- error_code: 302 +--- no_error_log +[error] + + + +=== TEST 6: update plugin with bearer_only=true +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "plugins": { + "openid-connect": { + "client_id": "kbyuFDidLLm280LIwVFiazOqjO3ty8KH", + "client_secret": "60Op4HFM0I8ajz0WdiStAbziZ-VFQttXuxixHHs2R7r7-CW8GR79l-mmLqMhc-Sa", + "discovery": "https://samples.auth0.com/.well-known/openid-configuration", + "redirect_uri": "https://iresty.com", + "ssl_verify": false, + "timeout": 10, + "bearer_only": true, + "scope": "apisix" + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/hello" + }]], + [[{ + "node": { + "value": { + "plugins": { + "openid-connect": { + "client_id": "kbyuFDidLLm280LIwVFiazOqjO3ty8KH", + "client_secret": "60Op4HFM0I8ajz0WdiStAbziZ-VFQttXuxixHHs2R7r7-CW8GR79l-mmLqMhc-Sa", + "discovery": "https://samples.auth0.com/.well-known/openid-configuration", + "redirect_uri": "https://iresty.com", + "ssl_verify": "no", + "timeout": 10000, + "bearer_only": true, + "scope": "apisix" + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/hello" + }, + "key": "/apisix/routes/1" + }, + "action": "set" + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed +--- no_error_log +[error] + + + +=== TEST 7: access +--- timeout: 10s +--- request +GET /hello +--- error_code: 401 +--- response_headers_like +WWW-Authenticate: Bearer realm=apisix +--- no_error_log +[error] +--- SKIP