diff --git a/apisix/plugins/openid-connect.lua b/apisix/plugins/openid-connect.lua index de0a9430e353..957aba1b13db 100644 --- a/apisix/plugins/openid-connect.lua +++ b/apisix/plugins/openid-connect.lua @@ -275,7 +275,16 @@ local schema = { items = { type = "string" } - } + }, + valid_issuers = { + description = [[Whitelist the vetted issuers of the jwt. + When not passed by the user, the issuer returned by discovery endpoint will be used. + In case both are missing, the issuer will not be validated.]], + type = "array", + items = { + type = "string" + } + }, }, encrypt_fields = {"client_secret", "client_rsa_private_key"}, required = {"client_id", "client_secret", "discovery"} @@ -375,17 +384,31 @@ local function introspect(ctx, conf) end end - -- If we get here, token was found in request. - if conf.public_key or conf.use_jwks then + local opts = {} -- Validate token against public key or jwks document of the oidc provider. -- TODO: In the called method, the openidc module will try to extract -- the token by itself again -- from a request header or session cookie. -- It is inefficient that we also need to extract it (just from headers) -- so we can add it in the configured header. Find a way to use openidc -- module's internal methods to extract the token. - local res, err = openidc.bearer_jwt_verify(conf) - + local valid_issuers + if conf.valid_issuers then + valid_issuers = conf.valid_issuers + else + local discovery, discovery_err = openidc.get_discovery_doc(conf) + if discovery_err then + core.log.warn("OIDC access discovery url failed : ", discovery_err) + else + core.log.info("valid_issuers not provided, using issuer from discovery doc: ", + discovery.issuer) + valid_issuers = {discovery.issuer} + end + end + if valid_issuers then + opts.valid_issuers = valid_issuers + end + local res, err = openidc.bearer_jwt_verify(conf, opts) if err then -- Error while validating or token invalid. ngx.header["WWW-Authenticate"] = 'Bearer realm="' .. conf.realm .. diff --git a/docs/en/latest/plugins/openid-connect.md b/docs/en/latest/plugins/openid-connect.md index 0ee7d7eb86b4..07598230a885 100644 --- a/docs/en/latest/plugins/openid-connect.md +++ b/docs/en/latest/plugins/openid-connect.md @@ -90,6 +90,7 @@ description: OpenID Connect allows the client to obtain user information from th | introspection_interval | integer | False | 0 | | TTL of the cached and introspected access token in seconds. | | introspection_expiry_claim | string | False | | | Name of the expiry claim, which controls the TTL of the cached and introspected access token. The default value is 0, which means this option is not used and the plugin defaults to use the TTL passed by expiry claim defined in `introspection_expiry_claim`. If `introspection_interval` is larger than 0 and less than the TTL passed by expiry claim defined in `introspection_expiry_claim`, use `introspection_interval`. | | introspection_addon_headers | string[] | False | | | Array of strings. Used to append additional header values to the introspection HTTP request. If the specified header does not exist in origin request, value will not be appended. | +| valid_issuers | string[] | False | | | Whitelist the vetted issuers of the jwt. When not passed by the user, the issuer returned by discovery endpoint will be used. In case both are missing, the issuer will not be validated. | NOTE: `encrypt_fields = {"client_secret"}` is also defined in the schema, which means that the field will be stored encrypted in etcd. See [encrypted storage fields](../plugin-develop.md#encrypted-storage-fields). diff --git a/docs/zh/latest/plugins/openid-connect.md b/docs/zh/latest/plugins/openid-connect.md index 5f99f0e1be26..fe2f62127578 100644 --- a/docs/zh/latest/plugins/openid-connect.md +++ b/docs/zh/latest/plugins/openid-connect.md @@ -90,6 +90,7 @@ description: OpenID Connect(OIDC)是基于 OAuth 2.0 的身份认证协议 | introspection_interval | integer | 否 | 0 | | 以秒为单位的缓存和内省访问令牌的 TTL。 | | introspection_expiry_claim | string | 否 | | | 过期声明的名称,用于控制缓存和内省访问令牌的 TTL。 | | introspection_addon_headers | string[] | 否 | | | `introspection_addon_headers` 是字符串列表,用于配置额外添加到内省 HTTP 请求中的请求头,如果配置的请求头不存在于源请求中,它将被忽略。| +| valid_issuers | string[] | 否 | | | 将经过审查的 jwt 发行者列入白名单。当用户未传递时,将使用发现端点返回的颁发者。如果两者均缺失,发行人将无法得到验证| 注意:schema 中还定义了 `encrypt_fields = {"client_secret"}`,这意味着该字段将会被加密存储在 etcd 中。具体参考 [加密存储字段](../plugin-develop.md#加密存储字段)。 diff --git a/t/plugin/jwt-auth2.t b/t/plugin/jwt-auth2.t index 965771197271..c5b770c368c6 100644 --- a/t/plugin/jwt-auth2.t +++ b/t/plugin/jwt-auth2.t @@ -27,6 +27,7 @@ add_block_preprocessor(sub { if (!defined $block->request) { $block->set_value("request", "GET /t"); } + }); run_tests; @@ -174,7 +175,8 @@ hello world [[G70MOLYvGCZxl1o8S3q4X67MxcPlfJaXnbog2AOOGRaFar88XiLFWTbXMCLuz7xD\n]] .. [[zQIDAQAB\n]] .. [[-----END PUBLIC KEY-----", - "token_signing_alg_values_expected": "RS256" + "token_signing_alg_values_expected": "RS256", + "valid_issuers": ["Mysoft corp"] } }, "upstream": { @@ -327,7 +329,8 @@ qr/ailed to verify jwt: 'exp' claim expired at/ [[G70MOLYvGCZxl1o8S3q4X67MxcPlfJaXnbog2AOOGRaFar88XiLFWTbXMCLuz7xD\n]] .. [[zQIDAQAB\n]] .. [[-----END PUBLIC KEY-----", - "token_signing_alg_values_expected": "RS256" + "token_signing_alg_values_expected": "RS256", + "valid_issuers": ["Mysoft corp"] } }, "upstream": { diff --git a/t/plugin/openid-connect.t b/t/plugin/openid-connect.t index 427e439ad628..c0d81b7468cd 100644 --- a/t/plugin/openid-connect.t +++ b/t/plugin/openid-connect.t @@ -479,7 +479,8 @@ OIDC introspection failed: Invalid Authorization header format. [[G70MOLYvGCZxl1o8S3q4X67MxcPlfJaXnbog2AOOGRaFar88XiLFWTbXMCLuz7xD\n]] .. [[zQIDAQAB\n]] .. [[-----END PUBLIC KEY-----", - "token_signing_alg_values_expected": "RS256" + "token_signing_alg_values_expected": "RS256", + "valid_issuers": ["Mysoft corp"] } }, "upstream": { @@ -553,7 +554,8 @@ true [[G70MOLYvGCZxl1o8S3q4X67MxcPlfJaXnbog2AOOGRaFar88XiLFWTbXMCLuz7xD\n]] .. [[zQIDAQAB\n]] .. [[-----END PUBLIC KEY-----", - "token_signing_alg_values_expected": "RS256" + "token_signing_alg_values_expected": "RS256", + "valid_issuers": ["Mysoft corp"] } }, "upstream": { @@ -623,7 +625,8 @@ x-userinfo: ey.* "set_access_token_header": true, "access_token_in_authorization_header": true, "set_id_token_header": false, - "set_userinfo_header": false + "set_userinfo_header": false, + "valid_issuers": ["Mysoft corp"] } }, "upstream": { @@ -687,7 +690,8 @@ x-real-ip: 127.0.0.1 [[G70MOLYvGCZxl1o8S3q4X67MxcPlfJaXnbog2AOOGRaFar88XiLFWTbXMCLuz7xD\n]] .. [[zQIDAQAB\n]] .. [[-----END PUBLIC KEY-----", - "token_signing_alg_values_expected": "RS256" + "token_signing_alg_values_expected": "RS256", + "valid_issuers": ["Mysoft corp"] } }, "upstream": { diff --git a/t/plugin/openid-connect7.t b/t/plugin/openid-connect7.t new file mode 100644 index 000000000000..e9c796097c7b --- /dev/null +++ b/t/plugin/openid-connect7.t @@ -0,0 +1,428 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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. +# +use t::APISIX 'no_plan'; + +log_level('debug'); +repeat_each(1); +no_long_string(); +no_root_location(); +# no_shuffle(); + +add_block_preprocessor(sub { + my ($block) = @_; + + if ((!defined $block->error_log) && (!defined $block->no_error_log)) { + $block->set_value("no_error_log", "[error]"); + } + + if (!defined $block->request) { + $block->set_value("request", "GET /t"); + } +}); + +run_tests(); + +__DATA__ + +=== TEST 1: Set up new route with wrong valid_issuers +--- 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": "dummy", + "client_secret": "dummy", + "discovery": "http://127.0.0.1:8080/realms/University/.well-known/openid-configuration", + "ssl_verify": true, + "timeout": 10, + "bearer_only": true, + "use_jwks": true, + "valid_issuers": 123 + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/*" + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- error_code: 400 +--- response_body eval +qr/\{"error_msg":"failed to check the configuration of plugin openid-connect err: property \\"valid_issuers\\" validation failed.*"\}/ + + + +=== TEST 2: Set up new route with valid valid_issuers +--- 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": "dummy", + "client_secret": "dummy", + "discovery": "http://127.0.0.1:8080/realms/University/.well-known/openid-configuration", + "ssl_verify": true, + "timeout": 10, + "bearer_only": true, + "use_jwks": true, + "valid_issuers": ["https://securetoken.google.com/test-firebase-project"] + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/*" + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 3: Update plugin with ID provider jwks endpoint for token verification with invalid issuer. +--- 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": "not required", + "client_secret": "not required", + "discovery": "http://127.0.0.1:8080/realms/University/.well-known/openid-configuration", + "redirect_uri": "http://localhost:3000", + "ssl_verify": false, + "timeout": 10, + "bearer_only": true, + "use_jwks": true, + "realm": "University", + "valid_issuers": ["https://securetoken.google.com/test-firebase-project"] + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/hello" + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 4: verification fails because issuer not in valid_issuer +--- config + location /t { + content_by_lua_block { + -- Obtain valid access token from Keycloak using known username and password. + local json_decode = require("toolkit.json").decode + local http = require "resty.http" + local httpc = http.new() + local uri = "http://127.0.0.1:8080/realms/University/protocol/openid-connect/token" + local res, err = httpc:request_uri(uri, { + method = "POST", + body = "grant_type=password&client_id=course_management&client_secret=d1ec69e9-55d2-4109-a3ea-befa071579d5&username=teacher@gmail.com&password=123456", + headers = { + ["Content-Type"] = "application/x-www-form-urlencoded" + } + }) + + -- Check response from keycloak and fail quickly if there's no response. + if not res then + ngx.say(err) + return + end + + -- Check if response code was ok. + if res.status == 200 then + -- Get access token from JSON response body. + local body = json_decode(res.body) + local accessToken = body["access_token"] + + -- Access route using access token. Should work. + uri = "http://127.0.0.1:" .. ngx.var.server_port .. "/hello" + local res, err = httpc:request_uri(uri, { + method = "GET", + headers = { + ["Authorization"] = "Bearer " .. body["access_token"] + } + }) + + if res.status == 200 then + -- Route accessed successfully. + ngx.say(true) + else + -- Couldn't access route. + ngx.say(false) + end + else + -- Response from Keycloak not ok. + ngx.say(false) + end + } + } +--- response_body +false +--- error_log +OIDC introspection failed: jwt signature verification failed: Claim 'iss' ('http://127.0.0.1:8080/realms/University') returned failure + + + +=== TEST 5: Update plugin with ID provider jwks endpoint for token verification with valid issuer. +--- 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": "dummy", + "client_secret": "dummy", + "discovery": "http://127.0.0.1:8080/realms/University/.well-known/openid-configuration", + "redirect_uri": "http://localhost:3000", + "ssl_verify": false, + "timeout": 10, + "bearer_only": true, + "use_jwks": true, + "realm": "University", + "valid_issuers": ["http://127.0.0.1:8080/realms/University"] + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/hello" + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 6: Obtain valid token and access route with it. +--- config + location /t { + content_by_lua_block { + -- Obtain valid access token from Keycloak using known username and password. + local json_decode = require("toolkit.json").decode + local http = require "resty.http" + local httpc = http.new() + local uri = "http://127.0.0.1:8080/realms/University/protocol/openid-connect/token" + local res, err = httpc:request_uri(uri, { + method = "POST", + body = "grant_type=password&client_id=course_management&client_secret=d1ec69e9-55d2-4109-a3ea-befa071579d5&username=teacher@gmail.com&password=123456", + headers = { + ["Content-Type"] = "application/x-www-form-urlencoded" + } + }) + + -- Check response from keycloak and fail quickly if there's no response. + if not res then + ngx.say(err) + return + end + + -- Check if response code was ok. + if res.status == 200 then + -- Get access token from JSON response body. + local body = json_decode(res.body) + local accessToken = body["access_token"] + + -- Access route using access token. Should work. + uri = "http://127.0.0.1:" .. ngx.var.server_port .. "/hello" + local res, err = httpc:request_uri(uri, { + method = "GET", + headers = { + ["Authorization"] = "Bearer " .. body["access_token"] + } + }) + + if res.status == 200 then + -- Route accessed successfully. + ngx.say(true) + else + -- Couldn't access route. + ngx.say(false) + end + else + -- Response from Keycloak not ok. + ngx.say(false) + end + } + } +--- response_body +true +--- grep_error_log eval +qr/token validate successfully by \w+/ +--- grep_error_log_out +token validate successfully by jwks + + + +=== TEST 7: Update plugin with ID provider jwks endpoint for token verification with valid issuer in discovery endpoint. +--- 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": "dummy", + "client_secret": "dummy", + "discovery": "http://127.0.0.1:8080/realms/University/.well-known/openid-configuration", + "redirect_uri": "http://localhost:3000", + "ssl_verify": false, + "timeout": 10, + "bearer_only": true, + "use_jwks": true, + "realm": "University" + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/hello" + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 8: Obtain valid token and access route with it. Use valid_issuer from discovery endpoint. +--- config + location /t { + content_by_lua_block { + -- Obtain valid access token from Keycloak using known username and password. + local json_decode = require("toolkit.json").decode + local http = require "resty.http" + local httpc = http.new() + local uri = "http://127.0.0.1:8080/realms/University/protocol/openid-connect/token" + local res, err = httpc:request_uri(uri, { + method = "POST", + body = "grant_type=password&client_id=course_management&client_secret=d1ec69e9-55d2-4109-a3ea-befa071579d5&username=teacher@gmail.com&password=123456", + headers = { + ["Content-Type"] = "application/x-www-form-urlencoded" + } + }) + + -- Check response from keycloak and fail quickly if there's no response. + if not res then + ngx.say(err) + return + end + + -- Check if response code was ok. + if res.status == 200 then + -- Get access token from JSON response body. + local body = json_decode(res.body) + local accessToken = body["access_token"] + + -- Access route using access token. Should work. + uri = "http://127.0.0.1:" .. ngx.var.server_port .. "/hello" + local res, err = httpc:request_uri(uri, { + method = "GET", + headers = { + ["Authorization"] = "Bearer " .. body["access_token"] + } + }) + + if res.status == 200 then + -- Route accessed successfully. + ngx.say(true) + else + -- Couldn't access route. + ngx.say(false) + end + else + -- Response from Keycloak not ok. + ngx.say(false) + end + } + } +--- response_body +true +--- grep_error_log eval +qr/token validate successfully by \w+/ +--- grep_error_log_out +token validate successfully by jwks +--- error_log +valid_issuers not provided, using issuer from discovery doc: http://127.0.0.1:8080/realms/University