From c3cad1c0ba1fd2016b9f87a47e7fb8b1a8c912af Mon Sep 17 00:00:00 2001 From: thefosk Date: Tue, 9 Jun 2015 16:06:34 -0700 Subject: [PATCH] OAuth 2.0 implementation --- .travis.yml | 2 +- .../cassandra/2015-06-09-170921_0.3.3.lua | 62 +++ ...-0.3.2-1.rockspec => kong-0.3.3-1.rockspec | 10 +- kong.yml | 1 + kong/constants.lua | 2 +- kong/dao/schemas_validation.lua | 9 +- kong/kong.lua | 2 +- kong/plugins/base_plugin.lua | 2 +- kong/plugins/httplog/schema.lua | 2 +- kong/plugins/oauth2/access.lua | 326 +++++++++++ kong/plugins/oauth2/api.lua | 51 ++ kong/plugins/oauth2/daos.lua | 176 ++++++ kong/plugins/oauth2/handler.lua | 17 + kong/plugins/oauth2/schema.lua | 25 + kong/plugins/request_transformer/access.lua | 3 - kong/tools/database_cache.lua | 10 + kong/tools/faker.lua | 2 +- kong/tools/responses.lua | 12 +- kong/tools/utils.lua | 38 ++ spec/integration/cli/start_disabled.lua | 8 +- spec/integration/proxy/realip_spec.lua | 7 +- spec/plugins/keyauth/api_spec.lua | 2 +- spec/plugins/logging_spec.lua | 14 +- spec/plugins/oauth2/access_spec.lua | 506 ++++++++++++++++++ spec/plugins/oauth2/api_spec.lua | 122 +++++ spec/unit/dao/oauth2/oauth2_entities_spec.lua | 48 ++ spec/unit/schemas_spec.lua | 37 ++ spec/unit/statics_spec.lua | 1 + spec/unit/tools/migrations_spec.lua | 2 +- spec/unit/tools/utils_spec.lua | 9 + 30 files changed, 1476 insertions(+), 32 deletions(-) create mode 100644 database/migrations/cassandra/2015-06-09-170921_0.3.3.lua rename kong-0.3.2-1.rockspec => kong-0.3.3-1.rockspec (95%) create mode 100644 kong/plugins/oauth2/access.lua create mode 100644 kong/plugins/oauth2/api.lua create mode 100644 kong/plugins/oauth2/daos.lua create mode 100644 kong/plugins/oauth2/handler.lua create mode 100644 kong/plugins/oauth2/schema.lua create mode 100644 spec/plugins/oauth2/access_spec.lua create mode 100644 spec/plugins/oauth2/api_spec.lua create mode 100644 spec/unit/dao/oauth2/oauth2_entities_spec.lua diff --git a/.travis.yml b/.travis.yml index 4e3b9dcff3f..f10de1acf10 100644 --- a/.travis.yml +++ b/.travis.yml @@ -22,4 +22,4 @@ script: - "busted -o spec/busted-print.lua --coverage spec/" - "make lint" -after_success: "luacov-coveralls -i kong" +after_success: "luacov-coveralls -i kong" \ No newline at end of file diff --git a/database/migrations/cassandra/2015-06-09-170921_0.3.3.lua b/database/migrations/cassandra/2015-06-09-170921_0.3.3.lua new file mode 100644 index 00000000000..fbfc872e1ed --- /dev/null +++ b/database/migrations/cassandra/2015-06-09-170921_0.3.3.lua @@ -0,0 +1,62 @@ +local Migration = { + name = "2015-06-09-170921_0.3.3", + + up = function(options) + return [[ + CREATE TABLE IF NOT EXISTS oauth2_credentials( + id uuid, + name text, + consumer_id uuid, + client_id text, + client_secret text, + redirect_uri text, + created_at timestamp, + PRIMARY KEY (id) + ); + + CREATE INDEX IF NOT EXISTS ON oauth2_credentials(consumer_id); + CREATE INDEX IF NOT EXISTS ON oauth2_credentials(client_id); + CREATE INDEX IF NOT EXISTS ON oauth2_credentials(client_secret); + + CREATE TABLE IF NOT EXISTS oauth2_authorization_codes( + id uuid, + code text, + authenticated_username text, + authenticated_userid text, + scope text, + created_at timestamp, + PRIMARY KEY (id) + ) WITH default_time_to_live = 300; + + CREATE INDEX IF NOT EXISTS ON oauth2_authorization_codes(code); + + CREATE TABLE IF NOT EXISTS oauth2_tokens( + id uuid, + credential_id uuid, + access_token text, + token_type text, + refresh_token text, + expires_in int, + authenticated_username text, + authenticated_userid text, + scope text, + created_at timestamp, + PRIMARY KEY (id) + ); + + CREATE INDEX IF NOT EXISTS ON oauth2_tokens(access_token); + CREATE INDEX IF NOT EXISTS ON oauth2_tokens(refresh_token); + + ]] + end, + + down = function(options) + return [[ + DROP TABLE oauth2_credentials; + DROP TABLE oauth2_authorization_codes; + DROP TABLE oauth2_tokens; + ]] + end +} + +return Migration \ No newline at end of file diff --git a/kong-0.3.2-1.rockspec b/kong-0.3.3-1.rockspec similarity index 95% rename from kong-0.3.2-1.rockspec rename to kong-0.3.3-1.rockspec index b6bfb68b708..185e2720b2f 100644 --- a/kong-0.3.2-1.rockspec +++ b/kong-0.3.3-1.rockspec @@ -1,9 +1,9 @@ package = "kong" -version = "0.3.2-1" +version = "0.3.3-1" supported_platforms = {"linux", "macosx"} source = { url = "git://github.com/Mashape/kong", - tag = "0.3.2" + tag = "0.3.3" } description = { summary = "Kong is a scalable and customizable API Management Layer built on top of Nginx.", @@ -101,6 +101,12 @@ build = { ["kong.plugins.keyauth.api"] = "kong/plugins/keyauth/api.lua", ["kong.plugins.keyauth.daos"] = "kong/plugins/keyauth/daos.lua", + ["kong.plugins.oauth2.handler"] = "kong/plugins/oauth2/handler.lua", + ["kong.plugins.oauth2.access"] = "kong/plugins/oauth2/access.lua", + ["kong.plugins.oauth2.schema"] = "kong/plugins/oauth2/schema.lua", + ["kong.plugins.oauth2.daos"] = "kong/plugins/oauth2/daos.lua", + ["kong.plugins.oauth2.api"] = "kong/plugins/oauth2/api.lua", + ["kong.plugins.tcplog.handler"] = "kong/plugins/tcplog/handler.lua", ["kong.plugins.tcplog.log"] = "kong/plugins/tcplog/log.lua", ["kong.plugins.tcplog.schema"] = "kong/plugins/tcplog/schema.lua", diff --git a/kong.yml b/kong.yml index e5520a16caa..d3fe481f4f6 100644 --- a/kong.yml +++ b/kong.yml @@ -3,6 +3,7 @@ plugins_available: - ssl - keyauth - basicauth + - oauth2 - ratelimiting - tcplog - udplog diff --git a/kong/constants.lua b/kong/constants.lua index edda67b766f..a26b9c619ba 100644 --- a/kong/constants.lua +++ b/kong/constants.lua @@ -1,4 +1,4 @@ -local VERSION = "0.3.2" +local VERSION = "0.3.3" return { NAME = "kong", diff --git a/kong/dao/schemas_validation.lua b/kong/dao/schemas_validation.lua index 43bb1ed2ad6..7f5b4ac1cdd 100644 --- a/kong/dao/schemas_validation.lua +++ b/kong/dao/schemas_validation.lua @@ -9,12 +9,19 @@ local POSSIBLE_TYPES = { string = true, number = true, boolean = true, + url = true, timestamp = true } local types_validation = { [constants.DATABASE_TYPES.ID] = function(v) return type(v) == "string" end, [constants.DATABASE_TYPES.TIMESTAMP] = function(v) return type(v) == "number" end, + ["url"] = function(v) + if v and type(v) == "string" then + local parsed_url = require("socket.url").parse(v) + return parsed_url and parsed_url.path and parsed_url.host and parsed_url.scheme + end + end, ["array"] = function(v) return utils.is_array(v) end } @@ -151,7 +158,7 @@ function _M.validate(t, schema, is_update) -- [FUNC] Check field against a custom function only if there is no error on that field already if v.func and type(v.func) == "function" and (errors == nil or errors[column] == nil) then - local ok, err, new_fields = v.func(t[column], t) + local ok, err, new_fields = v.func(t[column], t, column) if not ok and err then errors = utils.add_error(errors, column, err) elseif new_fields then diff --git a/kong/kong.lua b/kong/kong.lua index e0dcbec0c2b..61bb2c433f1 100644 --- a/kong/kong.lua +++ b/kong/kong.lua @@ -86,7 +86,7 @@ local function init_plugins() for _, v in ipairs(configuration.plugins_available) do local loaded, plugin_handler_mod = utils.load_module_if_exists("kong.plugins."..v..".handler") if not loaded then - error("The following plugin has been enabled in the configuration but is not installed on the system: "..v) + error("The following plugin has been enabled in the configuration but it is not installed on the system: "..v) else print("Loading plugin: "..v) table.insert(loaded_plugins, { diff --git a/kong/plugins/base_plugin.lua b/kong/plugins/base_plugin.lua index 4ff995d61e4..2f3b5113bd9 100644 --- a/kong/plugins/base_plugin.lua +++ b/kong/plugins/base_plugin.lua @@ -6,7 +6,7 @@ function BasePlugin:new(name) end function BasePlugin:init_worker() - --ngx.log(ngx.DEBUG, " executing plugin \""..self._name.."\": init_worker") + ngx.log(ngx.DEBUG, " executing plugin \""..self._name.."\": init_worker") end function BasePlugin:certificate() diff --git a/kong/plugins/httplog/schema.lua b/kong/plugins/httplog/schema.lua index aaba8d57335..83820b33ec0 100644 --- a/kong/plugins/httplog/schema.lua +++ b/kong/plugins/httplog/schema.lua @@ -1,5 +1,5 @@ return { - http_endpoint = { required = true, type = "string" }, + http_endpoint = { required = true, type = "url" }, method = { default = "POST", enum = { "POST", "PUT", "PATCH" } }, timeout = { default = 10000, type = "number" }, keepalive = { default = 60000, type = "number" } diff --git a/kong/plugins/oauth2/access.lua b/kong/plugins/oauth2/access.lua new file mode 100644 index 00000000000..f5d09b2b91a --- /dev/null +++ b/kong/plugins/oauth2/access.lua @@ -0,0 +1,326 @@ +local stringy = require "stringy" +local utils = require "kong.tools.utils" +local cache = require "kong.tools.database_cache" +local responses = require "kong.tools.responses" +local constants = require "kong.constants" +local timestamp = require "kong.tools.timestamp" + +local _M = {} + +local CONTENT_LENGTH = "content-length" +local RESPONSE_TYPE = "response_type" +local STATE = "state" +local CODE = "code" +local TOKEN = "token" +local REFRESH_TOKEN = "refresh_token" +local SCOPE = "scope" +local CLIENT_ID = "client_id" +local CLIENT_SECRET = "client_secret" +local REDIRECT_URI = "redirect_uri" +local ACCESS_TOKEN = "access_token" +local GRANT_TYPE = "grant_type" +local GRANT_AUTHORIZATION_CODE = "authorization_code" +local GRANT_REFRESH_TOKEN = "refresh_token" +local ERROR = "error" +local AUTHENTICATED_USERNAME = "authenticated_username" +local AUTHENTICATED_USERID = "authenticated_userid" + +local AUTHORIZE_URL = "^%s/oauth2/authorize/?$" +local TOKEN_URL = "^%s/oauth2/token/?$" + +-- TODO: Expire token (using TTL ?) +local function generate_token(conf, credential, authenticated_username, authenticated_userid, scope, state) + local token, err = dao.oauth2_tokens:insert({ + credential_id = credential.id, + authenticated_username = authenticated_username, + authenticated_userid = authenticated_userid, + expires_in = conf.token_expiration, + scope = scope + }) + + if err then + return responses.send_HTTP_INTERNAL_SERVER_ERROR(err) + end + + return { + access_token = token.access_token, + token_type = "bearer", + expires_in = conf.token_expiration > 0 and token.expires_in or nil, + refresh_token = conf.token_expiration > 0 and token.refresh_token or nil, + state = state -- If state is nil, this value won't be added + } +end + +local function get_redirect_uri(client_id) + local client + if client_id then + client = cache.get_or_set(cache.oauth2_credential_key(client_id), function() + local credentials, err = dao.oauth2_credentials:find_by_keys { client_id = client_id } + local result + if err then + return responses.send_HTTP_INTERNAL_SERVER_ERROR(err) + elseif #credentials > 0 then + result = credentials[1] + end + return result + end) + end + return client and client.redirect_uri or nil, client +end + +local function retrieve_parameters() + ngx.req.read_body() + -- OAuth2 parameters could be in both the querystring or body + return utils.table_merge(ngx.req.get_uri_args(), ngx.req.get_post_args()) +end + +local function authorize(conf) + local response_params = {} + + local parameters = retrieve_parameters() + + local redirect_uri, client + local state = parameters[STATE] + + if conf.provision_key ~= parameters.provision_key then + response_params = {[ERROR] = "invalid_provision_key", error_description = "Invalid Kong provision_key"} + else + local response_type = parameters[RESPONSE_TYPE] + -- Check response_type + if not (response_type == CODE or (conf.enable_implicit_grant and response_type == TOKEN)) then -- Authorization Code Grant (http://tools.ietf.org/html/rfc6749#section-4.1.1) + response_params = {[ERROR] = "unsupported_response_type", error_description = "Invalid "..RESPONSE_TYPE} + end + + -- Check scopes + local scope = parameters[SCOPE] + local scopes = {} + if conf.scopes and scope then + for v in scope:gmatch("%w+") do + if not utils.table_contains(conf.scopes, v) then + response_params = {[ERROR] = "invalid_scope", error_description = "\""..v.."\" is an invalid "..SCOPE} + break + else + table.insert(scopes, v) + end + end + elseif not scope and conf.mandatory_scope then + response_params = {[ERROR] = "invalid_scope", error_description = "You must specify a "..SCOPE} + end + + -- Check client_id and redirect_uri + redirect_uri, client = get_redirect_uri(parameters[CLIENT_ID]) + if not redirect_uri then + response_params = {[ERROR] = "invalid_request", error_description = "Invalid "..CLIENT_ID} + elseif parameters[REDIRECT_URI] and parameters[REDIRECT_URI] ~= redirect_uri then + response_params = {[ERROR] = "invalid_request", error_description = "Invalid "..REDIRECT_URI.." that does not match with the one created with the application"} + end + + -- If there are no errors, keep processing the request + if not response_params[ERROR] then + if response_type == CODE then + local authorization_code, err = dao.oauth2_authorization_codes:insert({ + authenticated_username = parameters[AUTHENTICATED_USERNAME], + authenticated_userid = parameters[AUTHENTICATED_USERID], + scope = table.concat(scopes, " ") + }) + + if err then + return responses.send_HTTP_INTERNAL_SERVER_ERROR(err) + end + + response_params = { + code = authorization_code.code, + } + else + response_params = generate_token(conf, client, parameters[AUTHENTICATED_USERNAME], parameters[AUTHENTICATED_USERID], table.concat(scopes, " "), state) + end + end + end + + -- Adding the state if it exists. If the state == nil then it won't be added + response_params.state = state + + -- Stopping other phases + ngx.ctx.stop_phases = true + + -- Sending response in JSON format + responses.send(response_params[ERROR] and 400 or 200, redirect_uri and { + redirect_uri = redirect_uri.."?"..ngx.encode_args(response_params) + } or response_params, false, { + ["cache-control"] = "no-store", + ["pragma"] = "no-cache" + }) +end + +local function issue_token(conf) + local response_params = {} + + local parameters = retrieve_parameters() --TODO: Also from authorization header + local state = parameters[STATE] + + local grant_type = parameters[GRANT_TYPE] + if not (grant_type == GRANT_AUTHORIZATION_CODE or grant_type == GRANT_REFRESH_TOKEN) then + response_params = {[ERROR] = "invalid_request", error_description = "Invalid "..GRANT_TYPE} + end + + -- Check client_id and redirect_uri + local redirect_uri, client = get_redirect_uri(parameters[CLIENT_ID]) + if not redirect_uri then + response_params = {[ERROR] = "invalid_request", error_description = "Invalid "..CLIENT_ID} + elseif parameters[REDIRECT_URI] and parameters[REDIRECT_URI] ~= redirect_uri then + response_params = {[ERROR] = "invalid_request", error_description = "Invalid "..REDIRECT_URI.." that does not match with the one created with the application"} + end + + local client_secret = parameters[CLIENT_SECRET] + if not client_secret or (client and client_secret ~= client.client_secret) then + response_params = {[ERROR] = "invalid_request", error_description = "Invalid "..CLIENT_SECRET} + end + + if not response_params[ERROR] then + if grant_type == GRANT_AUTHORIZATION_CODE then + local code = parameters[CODE] + local authorization_code = code and dao.oauth2_authorization_codes:find_by_keys({code = code})[1] or nil + if not authorization_code then + response_params = {[ERROR] = "invalid_request", error_description = "Invalid "..CODE} + else + response_params = generate_token(conf, client, authorization_code.authenticated_username, authorization_code.authenticated_userid, authorization_code.scope, state) + end + elseif grant_type == GRANT_REFRESH_TOKEN then + local refresh_token = parameters[REFRESH_TOKEN] + local token = refresh_token and dao.oauth2_tokens:find_by_keys({refresh_token = refresh_token})[1] or nil + if not token then + response_params = {[ERROR] = "invalid_request", error_description = "Invalid "..REFRESH_TOKEN} + else + response_params = generate_token(conf, client, token.authenticated_username, token.authenticated_userid, token.scope, state) + dao.oauth2_tokens:delete(token.id) -- Delete old token + end + end + end + + -- Adding the state if it exists. If the state == nil then it won't be added + response_params.state = state + + -- Stopping other phases + ngx.ctx.stop_phases = true + + -- Sending response in JSON format + responses.send(response_params[ERROR] and 400 or 200, response_params, false, { + ["cache-control"] = "no-store", + ["pragma"] = "no-cache" + }) +end + +local function retrieve_token(access_token) + local token + if access_token then + token = cache.get_or_set(cache.oauth2_token_key(access_token), function() + local credentials, err = dao.oauth2_tokens:find_by_keys { access_token = access_token } + local result + if err then + return responses.send_HTTP_INTERNAL_SERVER_ERROR(err) + elseif #credentials > 0 then + result = credentials[1] + end + return result + end) + end + return token +end + +local function parse_access_token(conf) + local found_in = {} + local result = retrieve_parameters()["access_token"] + if not result then + local authorization = ngx.req.get_headers()["authorization"] + if authorization then + local parts = {} + for v in authorization:gmatch("%w+") do -- Split by space + table.insert(parts, v) + end + if #parts == 2 and (parts[1]:lower() == "token" or parts[1]:lower() == "bearer") then + result = parts[2] + found_in.authorization_header = true + end + end + end + + if conf.hide_credentials then + if found_in.authorization_header then + ngx.req.clear_header("authorization") + else + -- Remove from querystring + local parameters = ngx.req.get_uri_args() + parameters[ACCESS_TOKEN] = nil + ngx.req.set_uri_args(parameters) + + if ngx.req.get_method() ~= "GET" then -- Remove from body + ngx.req.read_body() + parameters = ngx.req.get_post_args() + parameters[ACCESS_TOKEN] = nil + local encoded_args = ngx.encode_args(parameters) + ngx.req.set_header(CONTENT_LENGTH, string.len(encoded_args)) + ngx.req.set_body_data(encoded_args) + end + end + end + + return result +end + +function _M.execute(conf) + local path_prefix = ngx.ctx.api.path or "" + if stringy.endswith(path_prefix, "/") then + path_prefix = path_prefix:sub(1, path_prefix:len() - 1) + end + + if ngx.req.get_method() == "POST" then + if ngx.re.match(ngx.var.request_uri, string.format(AUTHORIZE_URL, path_prefix)) then + authorize(conf) + elseif ngx.re.match(ngx.var.request_uri, string.format(TOKEN_URL, path_prefix)) then + issue_token(conf) + end + end + + local token = retrieve_token(parse_access_token(conf)) + if not token then + ngx.ctx.stop_phases = true -- interrupt other phases of this request + return responses.send_HTTP_FORBIDDEN("Invalid authentication credentials") + end + + -- Check expiration date + if token.expires_in > 0 then -- zero means the token never expires + local now = timestamp.get_utc() + if now - token.created_at > (token.expires_in * 1000) then + ngx.ctx.stop_phases = true -- interrupt other phases of this request + return responses.send_HTTP_BAD_REQUEST({[ERROR] = "invalid_request", error_description = "access_token expired"}) + end + end + + -- Retrive the credential from the token + local credential = cache.get_or_set(cache.oauth2_credential_key(token.credential_id), function() + local result, err = dao.oauth2_credentials:find_one(token.credential_id) + if err then + return responses.send_HTTP_INTERNAL_SERVER_ERROR(err) + end + return result + end) + + -- Retrive the consumer from the credential + local consumer = cache.get_or_set(cache.consumer_key(credential.consumer_id), function() + local result, err = dao.consumers:find_one(credential.consumer_id) + if err then + return responses.send_HTTP_INTERNAL_SERVER_ERROR(err) + end + return result + end) + + ngx.req.set_header(constants.HEADERS.CONSUMER_ID, consumer.id) + ngx.req.set_header(constants.HEADERS.CONSUMER_CUSTOM_ID, consumer.custom_id) + ngx.req.set_header(constants.HEADERS.CONSUMER_USERNAME, consumer.username) + ngx.req.set_header("x-authenticated-scope", token.scope) + ngx.req.set_header("x-authenticated-username", token.authenticated_username) + ngx.req.set_header("x-authenticated-userid", token.authenticated_userid) + ngx.ctx.authenticated_entity = credential +end + +return _M \ No newline at end of file diff --git a/kong/plugins/oauth2/api.lua b/kong/plugins/oauth2/api.lua new file mode 100644 index 00000000000..792bacc40ef --- /dev/null +++ b/kong/plugins/oauth2/api.lua @@ -0,0 +1,51 @@ +local crud = require "kong.api.crud_helpers" + +return { + ["/consumers/:username_or_id/oauth2/"] = { + before = function(self, dao_factory, helpers) + crud.find_consumer_by_username_or_id(self, dao_factory, helpers) + self.params.consumer_id = self.consumer.id + end, + + GET = function(self, dao_factory, helpers) + crud.paginated_set(self, dao_factory.oauth2_credentials) + end, + + PUT = function(self, dao_factory) + crud.put(self.params, dao_factory.oauth2_credentials) + end, + + POST = function(self, dao_factory) + crud.post(self.params, dao_factory.oauth2_credentials) + end + }, + + ["/consumers/:username_or_id/oauth2/:id"] = { + before = function(self, dao_factory, helpers) + crud.find_consumer_by_username_or_id(self, dao_factory, helpers) + self.params.consumer_id = self.consumer.id + + local data, err = dao_factory.oauth2_credentials:find_by_keys({ id = self.params.id }) + if err then + return helpers.yield_error(err) + end + + self.plugin = data[1] + if not self.plugin then + return helpers.responses.send_HTTP_NOT_FOUND() + end + end, + + GET = function(self, dao_factory, helpers) + return helpers.responses.send_HTTP_OK(self.plugin) + end, + + PATCH = function(self, dao_factory) + crud.patch(self.params, dao_factory.oauth2_credentials) + end, + + DELETE = function(self, dao_factory) + crud.delete(self.plugin.id, dao_factory.oauth2_credentials) + end + } +} diff --git a/kong/plugins/oauth2/daos.lua b/kong/plugins/oauth2/daos.lua new file mode 100644 index 00000000000..60fd326e1d2 --- /dev/null +++ b/kong/plugins/oauth2/daos.lua @@ -0,0 +1,176 @@ +local utils = require "kong.tools.utils" +local stringy = require "stringy" +local BaseDao = require "kong.dao.cassandra.base_dao" + +local function generate_if_missing(v, t, column) + if not v or stringy.strip(v) == "" then + return true, nil, { [column] = utils.random_string()} + end + return true +end + +local OAuth2Credentials = BaseDao:extend() +function OAuth2Credentials:new(properties) + self._schema = { + id = { type = "id" }, + consumer_id = { type = "id", required = true, foreign = true, queryable = true }, + name = { type = "string", required = true }, + client_id = { type = "string", required = false, unique = true, queryable = true, func = generate_if_missing }, + client_secret = { type = "string", required = false, unique = true, func = generate_if_missing }, + redirect_uri = { type = "url", required = true }, + created_at = { type = "timestamp" } + } + + self._queries = { + insert = { + args_keys = { "id", "consumer_id", "name", "client_id", "client_secret", "redirect_uri", "created_at" }, + query = [[ + INSERT INTO oauth2_credentials(id, consumer_id, name, client_id, client_secret, redirect_uri, created_at) + VALUES(?, ?, ?, ?, ?, ?, ?); + ]] + }, + update = { + args_keys = { "name", "created_at", "id" }, + query = [[ UPDATE oauth2_credentials SET name = ?, created_at = ? WHERE id = ?; ]] + }, + select = { + query = [[ SELECT * FROM oauth2_credentials %s; ]] + }, + select_one = { + args_keys = { "id" }, + query = [[ SELECT * FROM oauth2_credentials WHERE id = ?; ]] + }, + delete = { + args_keys = { "id" }, + query = [[ DELETE FROM oauth2_credentials WHERE id = ?; ]] + }, + __foreign = { + consumer_id = { + args_keys = { "consumer_id" }, + query = [[ SELECT id FROM consumers WHERE id = ?; ]] + } + }, + __unique = { + client_id = { + args_keys = { "client_id" }, + query = [[ SELECT id FROM oauth2_credentials WHERE client_id = ?; ]] + }, + client_secret = { + args_keys = { "client_id" }, + query = [[ SELECT id FROM oauth2_credentials WHERE client_secret = ?; ]] + } + }, + drop = "TRUNCATE oauth2_credentials;" + } + + OAuth2Credentials.super.new(self, properties) +end + +local OAuth2AuthorizationCodes = BaseDao:extend() +function OAuth2AuthorizationCodes:new(properties) + self._schema = { + id = { type = "id" }, + code = { type = "string", required = false, unique = true, queryable = true, immutable = true, func = generate_if_missing }, + authenticated_username = { type = "string", required = false }, + authenticated_userid = { type = "string", required = false }, + scope = { type = "string" }, + created_at = { type = "timestamp" } + } + + self._queries = { + insert = { + args_keys = { "id", "code", "authenticated_username", "authenticated_userid", "scope", "created_at" }, + query = [[ + INSERT INTO oauth2_authorization_codes(id, code, authenticated_username, authenticated_userid, scope, created_at) + VALUES(?, ?, ?, ?, ?, ?); + ]] + }, + update = { + -- Disable update + }, + select = { + query = [[ SELECT * FROM oauth2_authorization_codes %s; ]] + }, + select_one = { + args_keys = { "id" }, + query = [[ SELECT * FROM oauth2_authorization_codes WHERE id = ?; ]] + }, + delete = { + args_keys = { "id" }, + query = [[ DELETE FROM oauth2_authorization_codes WHERE id = ?; ]] + }, + __foreign = {}, + __unique = { + code = { + args_keys = { "code" }, + query = [[ SELECT id FROM oauth2_authorization_codes WHERE code = ?; ]] + } + }, + drop = "TRUNCATE oauth2_authorization_codes;" + } + + OAuth2AuthorizationCodes.super.new(self, properties) +end + +local BEARER = "bearer" + +local OAuth2Tokens = BaseDao:extend() +function OAuth2Tokens:new(properties) + self._schema = { + id = { type = "id" }, + credential_id = { type = "id", required = true, foreign = true, queryable = true }, + token_type = { type = "string", required = true, enum = { BEARER }, default = BEARER }, + access_token = { type = "string", required = false, unique = true, queryable = true, immutable = true, func = generate_if_missing }, + refresh_token = { type = "string", required = false, unique = true, queryable = true, immutable = true, func = generate_if_missing }, + expires_in = { type = "number", required = true }, + authenticated_username = { type = "string", required = false }, + authenticated_userid = { type = "string", required = false }, + scope = { type = "string" }, + created_at = { type = "timestamp" } + } + + self._queries = { + insert = { + args_keys = { "id", "credential_id", "token_type", "access_token", "refresh_token", "expires_in", "authenticated_username", "authenticated_userid", "scope", "created_at" }, + query = [[ + INSERT INTO oauth2_tokens(id, credential_id, token_type, access_token, refresh_token, expires_in, authenticated_username, authenticated_userid, scope, created_at) + VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?); + ]] + }, + update = { + -- Disable update + }, + select = { + query = [[ SELECT * FROM oauth2_tokens %s; ]] + }, + select_one = { + args_keys = { "id" }, + query = [[ SELECT * FROM oauth2_tokens WHERE id = ?; ]] + }, + delete = { + args_keys = { "id" }, + query = [[ DELETE FROM oauth2_tokens WHERE id = ?; ]] + }, + __foreign = { + credential_id = { + args_keys = { "credential_id" }, + query = [[ SELECT id FROM oauth2_credentials WHERE id = ?; ]] + } + }, + __unique = { + access_token = { + args_keys = { "access_token" }, + query = [[ SELECT id FROM oauth2_tokens WHERE access_token = ?; ]] + }, + refresh_token = { + args_keys = { "access_token" }, + query = [[ SELECT id FROM oauth2_tokens WHERE refresh_token = ?; ]] + } + }, + drop = "TRUNCATE oauth2_tokens;" + } + + OAuth2Tokens.super.new(self, properties) +end + +return { oauth2_credentials = OAuth2Credentials, oauth2_authorization_codes = OAuth2AuthorizationCodes, oauth2_tokens = OAuth2Tokens } diff --git a/kong/plugins/oauth2/handler.lua b/kong/plugins/oauth2/handler.lua new file mode 100644 index 00000000000..1ae9931d463 --- /dev/null +++ b/kong/plugins/oauth2/handler.lua @@ -0,0 +1,17 @@ +local BasePlugin = require "kong.plugins.base_plugin" +local access = require "kong.plugins.oauth2.access" + +local OAuthHandler = BasePlugin:extend() + +function OAuthHandler:new() + OAuthHandler.super.new(self, "keyauth") +end + +function OAuthHandler:access(conf) + OAuthHandler.super.access(self) + access.execute(conf) +end + +OAuthHandler.PRIORITY = 1000 + +return OAuthHandler diff --git a/kong/plugins/oauth2/schema.lua b/kong/plugins/oauth2/schema.lua new file mode 100644 index 00000000000..21a0a914fbb --- /dev/null +++ b/kong/plugins/oauth2/schema.lua @@ -0,0 +1,25 @@ +local utils = require "kong.tools.utils" +local stringy = require "stringy" + +local function generate_if_missing(v, t, column) + if not v or stringy.strip(v) == "" then + return true, nil, { [column] = utils.random_string()} + end + return true +end + +local function check_mandatory_scope(v, t) + if v and not t.scopes then + return false, "To set a mandatory scope you also need to create available scopes" + end + return true +end + +return { + scopes = { required = false, type = "array" }, + mandatory_scope = { required = true, type = "boolean", default = false, func = check_mandatory_scope }, + provision_key = { required = false, unique = true, type = "string", func = generate_if_missing }, + token_expiration = { required = true, type = "number", default = 7200 }, + enable_implicit_grant = { required = true, type = "boolean", default = false }, + hide_credentials = { type = "boolean", default = false } +} diff --git a/kong/plugins/request_transformer/access.lua b/kong/plugins/request_transformer/access.lua index b29b589b20f..7ab545cecde 100644 --- a/kong/plugins/request_transformer/access.lua +++ b/kong/plugins/request_transformer/access.lua @@ -50,7 +50,6 @@ function _M.execute(conf) local content_type = get_content_type() if content_type and stringy.startswith(content_type, FORM_URLENCODED) then -- Call ngx.req.read_body to read the request body first - -- or turn on the lua_need_request_body directive to avoid errors. ngx.req.read_body() local parameters = ngx.req.get_post_args() @@ -62,7 +61,6 @@ function _M.execute(conf) ngx.req.set_body_data(encoded_args) elseif content_type and stringy.startswith(content_type, MULTIPART_DATA) then -- Call ngx.req.read_body to read the request body first - -- or turn on the lua_need_request_body directive to avoid errors. ngx.req.read_body() local body = ngx.req.get_body_data() @@ -109,7 +107,6 @@ function _M.execute(conf) ngx.req.set_body_data(encoded_args) elseif content_type and stringy.startswith(content_type, MULTIPART_DATA) then -- Call ngx.req.read_body to read the request body first - -- or turn on the lua_need_request_body directive to avoid errors. ngx.req.read_body() local body = ngx.req.get_body_data() diff --git a/kong/tools/database_cache.lua b/kong/tools/database_cache.lua index 0a7d98d145d..8f497ed6698 100644 --- a/kong/tools/database_cache.lua +++ b/kong/tools/database_cache.lua @@ -6,6 +6,8 @@ local CACHE_KEYS = { PLUGINS_CONFIGURATIONS = "plugins_configurations", BASICAUTH_CREDENTIAL = "basicauth_credentials", KEYAUTH_CREDENTIAL = "keyauth_credentials", + OAUTH2_CREDENTIAL = "oauth2_credentials", + OAUTH2_TOKEN = "oauth2_token", SSL = "ssl", REQUESTS = "requests" } @@ -76,6 +78,14 @@ function _M.basicauth_credential_key(username) return CACHE_KEYS.BASICAUTH_CREDENTIAL.."/"..username end +function _M.oauth2_credential_key(client_id) + return CACHE_KEYS.OAUTH2_CREDENTIAL.."/"..client_id +end + +function _M.oauth2_token_key(access_token) + return CACHE_KEYS.OAUTH2_TOKEN.."/"..access_token +end + function _M.keyauth_credential_key(key) return CACHE_KEYS.KEYAUTH_CREDENTIAL.."/"..key end diff --git a/kong/tools/faker.lua b/kong/tools/faker.lua index 6ff30653c1b..02f900b15ec 100644 --- a/kong/tools/faker.lua +++ b/kong/tools/faker.lua @@ -57,7 +57,7 @@ function Faker:insert_from_table(entities_to_insert) -- Insert in order (for foreign relashionships) -- 1. consumers and APIs -- 2. credentials, which need references to inserted apis and consumers - for _, type in ipairs({ "api", "consumer", "basicauth_credential", "keyauth_credential", "plugin_configuration" }) do + for _, type in ipairs({ "api", "consumer", "plugin_configuration", "oauth2_credential", "basicauth_credential", "keyauth_credential" }) do if entities_to_insert[type] then for i, entity in ipairs(entities_to_insert[type]) do diff --git a/kong/tools/responses.lua b/kong/tools/responses.lua index 19314da8d9c..885ee981bca 100644 --- a/kong/tools/responses.lua +++ b/kong/tools/responses.lua @@ -47,7 +47,7 @@ local function send_response(status_code) local constants = require "kong.constants" local cjson = require "cjson" - return function(content, raw) + return function(content, raw, headers) if status_code >= _M.status_codes.HTTP_INTERNAL_SERVER_ERROR then ngx.log(ngx.ERR, tostring(content)) ngx.ctx.stop_phases = true -- interrupt other phases of this request @@ -57,6 +57,12 @@ local function send_response(status_code) ngx.header["Content-Type"] = "application/json; charset=utf-8" ngx.header["Server"] = constants.NAME.."/"..constants.VERSION + if headers then + for k, v in pairs(headers) do + ngx.header[k] = v + end + end + if type(response_default_content[status_code]) == "function" then content = response_default_content[status_code](content) end @@ -85,13 +91,13 @@ local closure_cache = {} -- Sends any status code as a response. This is useful for plugins which want to -- send a response when the status code is not defined in `_M.status_codes` and thus -- has no sugar method on `_M`. -function _M.send(status_code, content, raw) +function _M.send(status_code, content, raw, headers) local res = closure_cache[status_code] if not res then res = send_response(status_code) closure_cache[status_code] = res end - return res(content, raw) + return res(content, raw, headers) end return _M diff --git a/kong/tools/utils.lua b/kong/tools/utils.lua index 3d51dcd5e63..acd3ef90466 100644 --- a/kong/tools/utils.lua +++ b/kong/tools/utils.lua @@ -1,5 +1,21 @@ +local uuid = require "uuid" + +-- This is important to seed the UUID generator +uuid.seed() + local _M = {} +-- Generates a random unique string +-- @param `no_hypens` (Optional) optionally remove hypens from the output +-- @return `string` The random string +function _M.random_string() + local res = uuid() + return res:gsub("-", "") +end + +-- Calculates a table size +-- @param `t` The table to use +-- @return `number` The size function _M.table_size(t) local res = 0 for _ in pairs(t) do @@ -8,6 +24,21 @@ function _M.table_size(t) return res end +-- Merges two table together +-- @param `t1` The first table +-- @param `t2` The second table +-- @return `table` The final table +function _M.table_merge(t1, t2) + local res = {} + for k,v in pairs(t1) do res[k] = v end + for k,v in pairs(t2) do res[k] = v end + return res +end + +-- Checks if a value exists in a table +-- @param `arr` The table to use +-- @param `val` The value to check +-- @return `boolean` Returns true if the table contains the value function _M.table_contains(arr, val) for _, v in pairs(arr) do if v == val then @@ -17,6 +48,9 @@ function _M.table_contains(arr, val) return false end +-- Checks if a table is an array and not an associative array +-- @param `t` The table to use +-- @return `boolean` Returns true if the table is an array function _M.is_array(t) local i = 0 for _ in pairs(t) do @@ -26,6 +60,9 @@ function _M.is_array(t) return true end +-- Deep copies a table into another table +-- @param `orig` The table to copy +-- @return `table` Returns a copy of the input table function _M.deep_copy(orig) local copy if type(orig) == "table" then @@ -40,6 +77,7 @@ function _M.deep_copy(orig) return copy end + -- Add an error message to a key/value table. -- Can accept a nil argument, and if is nil, will initialize the table. -- @param `errors` (Optional) Can be nil. Table to attach the error to. If nil, the table will be created. diff --git a/spec/integration/cli/start_disabled.lua b/spec/integration/cli/start_disabled.lua index 6fb0be3704a..1e5d39e5349 100644 --- a/spec/integration/cli/start_disabled.lua +++ b/spec/integration/cli/start_disabled.lua @@ -57,7 +57,7 @@ describe("CLI", function() assert.has_error(function() spec_helper.start_kong(SERVER_CONF, true) - end, "The following plugin has been enabled in the configuration but is not installed on the system: wot-wat") + end, "The following plugin has been enabled in the configuration but it is not installed on the system: wot-wat") end) it("should not fail when an existing plugin is being enabled", function() @@ -72,7 +72,7 @@ describe("CLI", function() assert.has_error(function() spec_helper.start_kong(SERVER_CONF, true) - end, "The following plugin has been enabled in the configuration but is not installed on the system: wot-wat") + end, "The following plugin has been enabled in the configuration but it is not installed on the system: wot-wat") end) it("should not work when a plugin is being used in the DB but it's not in the configuration", function() @@ -85,7 +85,7 @@ describe("CLI", function() } } - replace_conf_property("plugins_available", {"ssl", "keyauth", "basicauth", "tcplog", "udplog", "filelog", "httplog", "request_transformer"}) + replace_conf_property("plugins_available", {"ssl", "keyauth", "basicauth", "oauth2", "tcplog", "udplog", "filelog", "httplog", "request_transformer", "cors"}) assert.has_error(function() spec_helper.start_kong(SERVER_CONF, true) @@ -93,7 +93,7 @@ describe("CLI", function() end) it("should work the used plugins are enabled", function() - replace_conf_property("plugins_available", {"ssl", "keyauth", "basicauth", "tcplog", "udplog", "filelog", "httplog", "request_transformer", "ratelimiting", "cors"}) + replace_conf_property("plugins_available", {"ssl", "keyauth", "basicauth", "oauth2", "tcplog", "udplog", "filelog", "httplog", "request_transformer", "ratelimiting", "cors"}) local _, exit_code = spec_helper.start_kong(SERVER_CONF, true) assert.are.same(0, exit_code) diff --git a/spec/integration/proxy/realip_spec.lua b/spec/integration/proxy/realip_spec.lua index deb4cef74b0..d3627746470 100644 --- a/spec/integration/proxy/realip_spec.lua +++ b/spec/integration/proxy/realip_spec.lua @@ -2,12 +2,9 @@ local spec_helper = require "spec.spec_helpers" local http_client = require "kong.tools.http_client" local stringy = require "stringy" local cjson = require "cjson" -local uuid = require "uuid" +local utils = require "kong.tools.utils" local IO = require "kong.tools.io" --- This is important to seed the UUID generator -uuid.seed() - local FILE_LOG_PATH = spec_helper.get_env().configuration.nginx_working_dir.."/file_log_spec_output.log" describe("Real IP", function() @@ -33,7 +30,7 @@ describe("Real IP", function() it("should parse the correct IP", function() os.remove(FILE_LOG_PATH) - local uuid = string.gsub(uuid(), "-", "") + local uuid = utils.random_string() -- Making the request local _, status = http_client.get(spec_helper.STUB_GET_URL, nil, diff --git a/spec/plugins/keyauth/api_spec.lua b/spec/plugins/keyauth/api_spec.lua index 64a3097a2de..e26e516c02c 100644 --- a/spec/plugins/keyauth/api_spec.lua +++ b/spec/plugins/keyauth/api_spec.lua @@ -2,7 +2,7 @@ local json = require "cjson" local http_client = require "kong.tools.http_client" local spec_helper = require "spec.spec_helpers" -describe("Basic Auth Credentials API", function() +describe("Key Auth Credentials API", function() local BASE_URL, credential, consumer setup(function() diff --git a/spec/plugins/logging_spec.lua b/spec/plugins/logging_spec.lua index 28af94ccc74..e041b40ea27 100644 --- a/spec/plugins/logging_spec.lua +++ b/spec/plugins/logging_spec.lua @@ -1,12 +1,10 @@ local IO = require "kong.tools.io" -local uuid = require "uuid" +local utils = require "kong.tools.utils" local cjson = require "cjson" local stringy = require "stringy" local spec_helper = require "spec.spec_helpers" local http_client = require "kong.tools.http_client" --- This is important to seed the UUID generator -uuid.seed() local STUB_GET_URL = spec_helper.STUB_GET_URL @@ -39,7 +37,7 @@ describe("Logging Plugins", function() plugin_configuration = { { name = "tcplog", value = { host = "127.0.0.1", port = TCP_PORT }, __api = 1 }, { name = "udplog", value = { host = "127.0.0.1", port = UDP_PORT }, __api = 2 }, - { name = "httplog", value = { http_endpoint = "http://localhost:"..HTTP_PORT }, __api = 3 }, + { name = "httplog", value = { http_endpoint = "http://localhost:"..HTTP_PORT.."/" }, __api = 3 }, { name = "httplog", value = { http_endpoint = "https://mockbin.org/bin/"..mock_bin }, __api = 4 }, { name = "filelog", value = { path = FILE_LOG_PATH }, __api = 5 } } @@ -109,12 +107,16 @@ describe("Logging Plugins", function() local _, status = http_client.get(STUB_GET_URL, nil, { host = "https_logging.com" }) assert.are.equal(200, status) + local total_time = 0 local res, status, body repeat + assert.truthy(total_time <= 10) -- Fail after 10 seconds res, status = http_client.get("http://mockbin.org/bin/"..mock_bin.."/log", nil, { accept = "application/json" }) assert.are.equal(200, status) body = cjson.decode(res) - os.execute("sleep 0.2") + local wait = 1 + os.execute("sleep "..tostring(wait)) + total_time = total_time + wait until(#body.log.entries > 0) assert.are.equal(1, #body.log.entries) @@ -127,7 +129,7 @@ describe("Logging Plugins", function() it("should log to file", function() os.remove(FILE_LOG_PATH) - local uuid = string.gsub(uuid(), "-", "") + local uuid = utils.random_string() -- Making the request local _, status = http_client.get(STUB_GET_URL, nil, diff --git a/spec/plugins/oauth2/access_spec.lua b/spec/plugins/oauth2/access_spec.lua new file mode 100644 index 00000000000..e7c3534275e --- /dev/null +++ b/spec/plugins/oauth2/access_spec.lua @@ -0,0 +1,506 @@ +local spec_helper = require "spec.spec_helpers" +local utils = require "kong.tools.utils" +local http_client = require "kong.tools.http_client" +local cjson = require "cjson" +local rex = require "rex_pcre" + +-- Load everything we need from the spec_helper +local env = spec_helper.get_env() -- test environment +local dao_factory = env.dao_factory +local configuration = env.configuration +configuration.cassandra = configuration.databases_available[configuration.database].properties + +local PROXY_URL = spec_helper.PROXY_URL +local STUB_GET_URL = spec_helper.STUB_GET_URL +local STUB_POST_URL = spec_helper.STUB_POST_URL + +local function provision_code() + local response = http_client.post(PROXY_URL.."/oauth2/authorize", { provision_key = "provision123", client_id = "clientid123", scope = "email", response_type = "code", state = "hello", authenticated_username = "user123", authenticated_userid = "userid123" }, {host = "oauth2.com"}) + local body = cjson.decode(response) + local matches = rex.gmatch(body.redirect_uri, "^http://google\\.com/kong\\?code=([\\w]{32,32})&state=hello$") + local code + for line in matches do + code = line + end + local data = dao_factory.oauth2_authorization_codes:find_by_keys({code = code}) + return data[1].code +end + +local function provision_token() + local code = provision_code() + + local response = http_client.post(PROXY_URL.."/oauth2/token", { code = code, client_id = "clientid123", client_secret = "secret123", grant_type = "authorization_code" }, {host = "oauth2.com"}) + return cjson.decode(response) +end + +describe("Authentication Plugin", function() + + local function prepare() + spec_helper.drop_db() + spec_helper.insert_fixtures { + api = { + { name = "tests oauth2", public_dns = "oauth2.com", target_url = "http://mockbin.com" }, + { name = "tests oauth2 with path", public_dns = "mockbin-path.com", target_url = "http://mockbin.com", path = "/somepath/" }, + { name = "tests oauth2 with hide credentials", public_dns = "oauth2_3.com", target_url = "http://mockbin.com" } + }, + consumer = { + { username = "auth_tests_consumer" } + }, + plugin_configuration = { + { name = "oauth2", value = { scopes = { "email", "profile" }, mandatory_scope = true, provision_key = "provision123", token_expiration = 5, enable_implicit_grant = true }, __api = 1 }, + { name = "oauth2", value = { scopes = { "email", "profile" }, mandatory_scope = true, provision_key = "provision123", token_expiration = 5, enable_implicit_grant = true }, __api = 2 }, + { name = "oauth2", value = { scopes = { "email", "profile" }, mandatory_scope = true, provision_key = "provision123", token_expiration = 5, enable_implicit_grant = true, hide_credentials = true }, __api = 3 } + }, + oauth2_credential = { + { client_id = "clientid123", client_secret = "secret123", redirect_uri = "http://google.com/kong", name="testapp", __consumer = 1 } + } + } + end + + setup(function() + spec_helper.prepare_db() + end) + + teardown(function() + spec_helper.stop_kong() + end) + + before_each(function() + spec_helper.restart_kong() -- Required because the uuid function doesn't seed itself every millisecond, but every second + prepare() + end) + + describe("OAuth2 Authorization", function() + + describe("Code Grant", function() + + it("should return an error when no provision_key is being sent", function() + local response, status, headers = http_client.post(PROXY_URL.."/oauth2/authorize", { }, {host = "oauth2.com"}) + local body = cjson.decode(response) + assert.are.equal(400, status) + assert.are.equal(2, utils.table_size(body)) + assert.are.equal("invalid_provision_key", body.error) + assert.are.equal("Invalid Kong provision_key", body.error_description) + + -- Checking headers + assert.are.equal("no-store", headers["cache-control"]) + assert.are.equal("no-cache", headers["pragma"]) + end) + + it("should return an error when no parameter is being sent", function() + local response, status = http_client.post(PROXY_URL.."/oauth2/authorize", { provision_key = "provision123" }, {host = "oauth2.com"}) + local body = cjson.decode(response) + assert.are.equal(400, status) + assert.are.equal(2, utils.table_size(body)) + assert.are.equal("invalid_request", body.error) + assert.are.equal("Invalid client_id", body.error_description) + end) + + it("should return an error when only the client_is being sent", function() + local response, status = http_client.post(PROXY_URL.."/oauth2/authorize", { provision_key = "provision123", client_id = "clientid123" }, {host = "oauth2.com"}) + local body = cjson.decode(response) + assert.are.equal(400, status) + assert.are.equal(1, utils.table_size(body)) + assert.are.equal("http://google.com/kong?error=invalid_scope&error_description=You%20must%20specify%20a%20scope", body.redirect_uri) + end) + + it("should return an error when an invalid scope is being sent", function() + local response, status = http_client.post(PROXY_URL.."/oauth2/authorize", { provision_key = "provision123", client_id = "clientid123", scope = "wot" }, {host = "oauth2.com"}) + local body = cjson.decode(response) + assert.are.equal(400, status) + assert.are.equal(1, utils.table_size(body)) + assert.are.equal("http://google.com/kong?error=invalid_scope&error_description=%22wot%22%20is%20an%20invalid%20scope", body.redirect_uri) + end) + + it("should return an error when no response_type is being sent", function() + local response, status = http_client.post(PROXY_URL.."/oauth2/authorize", { provision_key = "provision123", client_id = "clientid123", scope = "email" }, {host = "oauth2.com"}) + local body = cjson.decode(response) + assert.are.equal(400, status) + assert.are.equal(1, utils.table_size(body)) + assert.are.equal("http://google.com/kong?error=unsupported_response_type&error_description=Invalid%20response_type", body.redirect_uri) + end) + + it("should return an error with a state when no response_type is being sent", function() + local response, status = http_client.post(PROXY_URL.."/oauth2/authorize", { provision_key = "provision123", client_id = "clientid123", scope = "email", state = "somestate" }, {host = "oauth2.com"}) + local body = cjson.decode(response) + assert.are.equal(400, status) + assert.are.equal(1, utils.table_size(body)) + assert.are.equal("http://google.com/kong?error=unsupported_response_type&state=somestate&error_description=Invalid%20response_type", body.redirect_uri) + end) + + it("should return error when the redirect_uri does not match", function() + local response, status = http_client.post(PROXY_URL.."/oauth2/authorize", { provision_key = "provision123", client_id = "clientid123", scope = "email", response_type = "code", redirect_uri = "http://hello.com/" }, {host = "oauth2.com"}) + local body = cjson.decode(response) + assert.are.equal(400, status) + assert.are.equal(1, utils.table_size(body)) + assert.are.equal("http://google.com/kong?error=invalid_request&error_description=Invalid%20redirect_uri%20that%20does%20not%20match%20with%20the%20one%20created%20with%20the%20application", body.redirect_uri) + end) + + it("should return success", function() + local response, status = http_client.post(PROXY_URL.."/oauth2/authorize", { provision_key = "provision123", client_id = "clientid123", scope = "email", response_type = "code" }, {host = "oauth2.com"}) + local body = cjson.decode(response) + assert.are.equal(200, status) + assert.are.equal(1, utils.table_size(body)) + assert.truthy(rex.match(body.redirect_uri, "^http://google\\.com/kong\\?code=[\\w]{32,32}$")) + end) + + it("should return success with a path", function() + local response, status = http_client.post(PROXY_URL.."/somepath/oauth2/authorize", { provision_key = "provision123", client_id = "clientid123", scope = "email", response_type = "code" }, {host = "mockbin-path.com"}) + local body = cjson.decode(response) + assert.are.equal(200, status) + assert.are.equal(1, utils.table_size(body)) + assert.truthy(rex.match(body.redirect_uri, "^http://google\\.com/kong\\?code=[\\w]{32,32}$")) + end) + + it("should return success when requesting the url with final slash", function() + local response, status = http_client.post(PROXY_URL.."/oauth2/authorize/", { provision_key = "provision123", client_id = "clientid123", scope = "email", response_type = "code" }, {host = "oauth2.com"}) + local body = cjson.decode(response) + assert.are.equal(200, status) + assert.are.equal(1, utils.table_size(body)) + assert.truthy(rex.match(body.redirect_uri, "^http://google\\.com/kong\\?code=[\\w]{32,32}$")) + end) + + it("should return success with a state", function() + local response, status, headers = http_client.post(PROXY_URL.."/oauth2/authorize", { provision_key = "provision123", client_id = "clientid123", scope = "email", response_type = "code", state = "hello" }, {host = "oauth2.com"}) + local body = cjson.decode(response) + assert.are.equal(200, status) + assert.are.equal(1, utils.table_size(body)) + assert.truthy(rex.match(body.redirect_uri, "^http://google\\.com/kong\\?code=[\\w]{32,32}&state=hello$")) + + -- Checking headers + assert.are.equal("no-store", headers["cache-control"]) + assert.are.equal("no-cache", headers["pragma"]) + end) + + it("should return success and store authenticated user properties", function() + local response, status = http_client.post(PROXY_URL.."/oauth2/authorize", { provision_key = "provision123", client_id = "clientid123", scope = "email", response_type = "code", state = "hello", authenticated_username = "user123", authenticated_userid = "userid123" }, {host = "oauth2.com"}) + local body = cjson.decode(response) + assert.are.equal(200, status) + assert.are.equal(1, utils.table_size(body)) + assert.truthy(rex.match(body.redirect_uri, "^http://google\\.com/kong\\?code=[\\w]{32,32}&state=hello$")) + + local matches = rex.gmatch(body.redirect_uri, "^http://google\\.com/kong\\?code=([\\w]{32,32})&state=hello$") + local code + for line in matches do + code = line + end + local data = dao_factory.oauth2_authorization_codes:find_by_keys({code = code}) + assert.are.equal(1, #data) + assert.are.equal(code, data[1].code) + + assert.are.equal("user123", data[1].authenticated_username) + assert.are.equal("userid123", data[1].authenticated_userid) + assert.are.equal("email", data[1].scope) + end) + + end) + + describe("Implicit Grant", function() + it("should return success", function() + local response, status, headers = http_client.post(PROXY_URL.."/oauth2/authorize", { provision_key = "provision123", client_id = "clientid123", scope = "email", response_type = "token" }, {host = "oauth2.com"}) + local body = cjson.decode(response) + assert.are.equal(200, status) + assert.are.equal(1, utils.table_size(body)) + assert.truthy(rex.match(body.redirect_uri, "^http://google\\.com/kong\\?refresh_token=[\\w]{32,32}&token_type=bearer&access_token=[\\w]{32,32}&expires_in=5$")) + + -- Checking headers + assert.are.equal("no-store", headers["cache-control"]) + assert.are.equal("no-cache", headers["pragma"]) + end) + it("should return success and the state", function() + local response, status = http_client.post(PROXY_URL.."/oauth2/authorize", { provision_key = "provision123", client_id = "clientid123", scope = "email", response_type = "token", state = "wot" }, {host = "oauth2.com"}) + local body = cjson.decode(response) + assert.are.equal(200, status) + assert.are.equal(1, utils.table_size(body)) + assert.truthy(rex.match(body.redirect_uri, "^http://google\\.com/kong\\?refresh_token=[\\w]{32,32}&token_type=bearer&state=wot&access_token=[\\w]{32,32}&expires_in=5$")) + end) + + it("should return success and store authenticated user properties", function() + local response, status = http_client.post(PROXY_URL.."/oauth2/authorize", { provision_key = "provision123", client_id = "clientid123", scope = "email profile", response_type = "token", authenticated_username = "user123", authenticated_userid = "userid123" }, {host = "oauth2.com"}) + local body = cjson.decode(response) + assert.are.equal(200, status) + assert.are.equal(1, utils.table_size(body)) + assert.truthy(rex.match(body.redirect_uri, "^http://google\\.com/kong\\?refresh_token=[\\w]{32,32}&token_type=bearer&access_token=[\\w]{32,32}&expires_in=5$")) + + local matches = rex.gmatch(body.redirect_uri, "^http://google\\.com/kong\\?refresh_token=[\\w]{32,32}&token_type=bearer&access_token=([\\w]{32,32})&expires_in=5$") + local access_token + for line in matches do + access_token = line + end + local data = dao_factory.oauth2_tokens:find_by_keys({access_token = access_token}) + assert.are.equal(1, #data) + assert.are.equal(access_token, data[1].access_token) + + assert.are.equal("user123", data[1].authenticated_username) + assert.are.equal("userid123", data[1].authenticated_userid) + assert.are.equal("email profile", data[1].scope) + end) + + end) + end) + + describe("OAuth2 Access Token", function() + + it("should return an error when nothing is being sent", function() + local response, status, headers = http_client.post(PROXY_URL.."/oauth2/token", { }, {host = "oauth2.com"}) + local body = cjson.decode(response) + assert.are.equal(400, status) + assert.are.equal(2, utils.table_size(body)) + assert.are.equal("invalid_request", body.error) + assert.are.equal("Invalid client_secret", body.error_description) + + -- Checking headers + assert.are.equal("no-store", headers["cache-control"]) + assert.are.equal("no-cache", headers["pragma"]) + end) + + it("should return an error when only the code is being sent", function() + local code = provision_code() + + local response, status, headers = http_client.post(PROXY_URL.."/oauth2/token", { code = code }, {host = "oauth2.com"}) + local body = cjson.decode(response) + assert.are.equal(400, status) + assert.are.equal(2, utils.table_size(body)) + assert.are.equal("invalid_request", body.error) + assert.are.equal("Invalid client_secret", body.error_description) + + -- Checking headers + assert.are.equal("no-store", headers["cache-control"]) + assert.are.equal("no-cache", headers["pragma"]) + end) + + it("should return an error when only the code and client_secret are being sent", function() + local code = provision_code() + + local response, status, headers = http_client.post(PROXY_URL.."/oauth2/token", { code = code, client_secret = "secret123" }, {host = "oauth2.com"}) + local body = cjson.decode(response) + assert.are.equal(400, status) + assert.are.equal(2, utils.table_size(body)) + assert.are.equal("invalid_request", body.error) + assert.are.equal("Invalid client_id", body.error_description) + + -- Checking headers + assert.are.equal("no-store", headers["cache-control"]) + assert.are.equal("no-cache", headers["pragma"]) + end) + + it("should return an error when only the code and client_secret and client_id are being sent", function() + local code = provision_code() + + local response, status = http_client.post(PROXY_URL.."/oauth2/token", { code = code, client_id = "clientid123", client_secret = "secret123" }, {host = "oauth2.com"}) + local body = cjson.decode(response) + assert.are.equal(400, status) + assert.are.equal(2, utils.table_size(body)) + assert.are.equal("invalid_request", body.error) + assert.are.equal("Invalid grant_type", body.error_description) + end) + + it("should return an error with a wrong code", function() + local code = provision_code() + + local response, status = http_client.post(PROXY_URL.."/oauth2/token", { code = code.."hello", client_id = "clientid123", client_secret = "secret123", grant_type = "authorization_code" }, {host = "oauth2.com"}) + local body = cjson.decode(response) + assert.are.equal(400, status) + assert.are.equal(2, utils.table_size(body)) + assert.are.equal("invalid_request", body.error) + assert.are.equal("Invalid code", body.error_description) + end) + + it("should return success without state", function() + local code = provision_code() + + local response, status = http_client.post(PROXY_URL.."/oauth2/token", { code = code, client_id = "clientid123", client_secret = "secret123", grant_type = "authorization_code" }, {host = "oauth2.com"}) + local body = cjson.decode(response) + assert.are.equal(200, status) + assert.are.equals(4, utils.table_size(body)) + assert.truthy(body.refresh_token) + assert.truthy(body.access_token) + assert.are.equal("bearer", body.token_type) + assert.are.equal(5, body.expires_in) + end) + + it("should return success with state", function() + local code = provision_code() + + local response, status = http_client.post(PROXY_URL.."/oauth2/token", { code = code, client_id = "clientid123", client_secret = "secret123", grant_type = "authorization_code", state = "wot" }, {host = "oauth2.com"}) + local body = cjson.decode(response) + assert.are.equal(200, status) + assert.are.equals(5, utils.table_size(body)) + assert.truthy(body.refresh_token) + assert.truthy(body.access_token) + assert.are.equal("bearer", body.token_type) + assert.are.equal(5, body.expires_in) + assert.are.equal("wot", body.state) + end) + end) + + describe("Making a request", function() + + it("should return an error when nothing is being sent", function() + local response, status = http_client.post(STUB_GET_URL, { }, {host = "oauth2.com"}) + local body = cjson.decode(response) + assert.are.equal(403, status) + assert.are.equal("Invalid authentication credentials", body.message) + end) + + it("should return an error when a wrong access token is being sent", function() + local response, status = http_client.get(STUB_GET_URL, { access_token = "hello" }, {host = "oauth2.com"}) + local body = cjson.decode(response) + assert.are.equal(403, status) + assert.are.equal("Invalid authentication credentials", body.message) + end) + + it("should work when a correct access_token is being sent in the querystring", function() + local token = provision_token() + local _, status = http_client.post(STUB_GET_URL, { access_token = token.access_token }, {host = "oauth2.com"}) + assert.are.equal(200, status) + end) + + it("should work when a correct access_token is being sent in a form body", function() + local token = provision_token() + local _, status = http_client.post(STUB_POST_URL, { access_token = token.access_token }, {host = "oauth2.com"}) + assert.are.equal(200, status) + end) + + it("should work when a correct access_token is being sent in an authorization header (bearer)", function() + local token = provision_token() + local _, status = http_client.post(STUB_POST_URL, { }, {host = "oauth2.com", authorization = "bearer "..token.access_token}) + assert.are.equal(200, status) + end) + + it("should work when a correct access_token is being sent in an authorization header (token)", function() + local token = provision_token() + local response, status = http_client.post(STUB_POST_URL, { }, {host = "oauth2.com", authorization = "token "..token.access_token}) + local body = cjson.decode(response) + assert.are.equal(200, status) + + local consumer = dao_factory.consumers:find_by_keys({username = "auth_tests_consumer"})[1] + + assert.are.equal(consumer.id, body.headers["x-consumer-id"]) + assert.are.equal(consumer.username, body.headers["x-consumer-username"]) + assert.are.equal("user123", body.headers["x-authenticated-username"]) + assert.are.equal("userid123", body.headers["x-authenticated-userid"]) + assert.are.equal("email", body.headers["x-authenticated-scope"]) + end) + + it("should not work when a correct access_token is being sent in an authorization header (bearer)", function() + local token = provision_token() + local response, status = http_client.post(STUB_POST_URL, { }, {host = "oauth2.com", authorization = "bearer "..token.access_token.."hello"}) + local body = cjson.decode(response) + assert.are.equal(403, status) + assert.are.equal("Invalid authentication credentials", body.message) + end) + + end) + + describe("Refresh Token", function() + + it("should not refresh an invalid access token", function() + local response, status = http_client.post(PROXY_URL.."/oauth2/token", { refresh_token = "hello", client_id = "clientid123", client_secret = "secret123", grant_type = "refresh_token" }, {host = "oauth2.com"}) + local body = cjson.decode(response) + assert.are.equal(400, status) + assert.are.equal(2, utils.table_size(body)) + assert.are.equal("invalid_request", body.error) + assert.are.equal("Invalid refresh_token", body.error_description) + end) + + it("should refresh an valid access token", function() + local token = provision_token() + local response, status = http_client.post(PROXY_URL.."/oauth2/token", { refresh_token = token.refresh_token, client_id = "clientid123", client_secret = "secret123", grant_type = "refresh_token" }, {host = "oauth2.com"}) + local body = cjson.decode(response) + assert.are.equal(200, status) + assert.are.equals(4, utils.table_size(body)) + assert.truthy(body.refresh_token) + assert.truthy(body.access_token) + assert.are.equal("bearer", body.token_type) + assert.are.equal(5, body.expires_in) + end) + + it("should expire after 5 seconds", function() + local token = provision_token() + local _, status = http_client.post(STUB_POST_URL, { }, {host = "oauth2.com", authorization = "bearer "..token.access_token}) + assert.are.equal(200, status) + + local id = dao_factory.oauth2_tokens:find_by_keys({access_token = token.access_token })[1].id + assert.truthy(dao_factory.oauth2_tokens:find_one(id)) + + -- But waiting after the cache expiration (5 seconds) should block the request + os.execute("sleep "..tonumber(6)) + + local response, status = http_client.post(STUB_POST_URL, { }, {host = "oauth2.com", authorization = "bearer "..token.access_token}) + local body = cjson.decode(response) + assert.are.equal(400, status) + assert.are.equal(2, utils.table_size(body)) + assert.are.equal("invalid_request", body.error) + assert.are.equal("access_token expired", body.error_description) + + -- Refreshing the token + local response, status = http_client.post(PROXY_URL.."/oauth2/token", { refresh_token = token.refresh_token, client_id = "clientid123", client_secret = "secret123", grant_type = "refresh_token" }, {host = "oauth2.com"}) + local body = cjson.decode(response) + assert.are.equal(200, status) + assert.are.equal(4, utils.table_size(body)) + assert.truthy(body.refresh_token) + assert.truthy(body.access_token) + assert.are.equal("bearer", body.token_type) + assert.are.equal(5, body.expires_in) + + assert.falsy(token.access_token == body.access_token) + assert.falsy(token.refresh_token == body.refresh_token) + + assert.falsy(dao_factory.oauth2_tokens:find_one(id)) + end) + + end) + + describe("Hide Credentials", function() + + it("should not hide credentials in the body", function() + local token = provision_token() + local response, status = http_client.post(STUB_POST_URL, { access_token = token.access_token }, {host = "oauth2.com"}) + local body = cjson.decode(response) + assert.are.equal(200, status) + assert.are.equal(token.access_token, body.postData.params.access_token) + end) + + it("should hide credentials in the body", function() + local token = provision_token() + local response, status = http_client.post(STUB_POST_URL, { access_token = token.access_token }, {host = "oauth2_3.com"}) + local body = cjson.decode(response) + assert.are.equal(200, status) + assert.falsy(body.postData.params.access_token) + end) + + it("should not hide credentials in the querystring", function() + local token = provision_token() + local response, status = http_client.get(STUB_GET_URL, { access_token = token.access_token }, {host = "oauth2.com"}) + local body = cjson.decode(response) + assert.are.equal(200, status) + assert.are.equal(token.access_token, body.queryString.access_token) + end) + + it("should hide credentials in the querystring", function() + local token = provision_token() + local response, status = http_client.get(STUB_GET_URL, { access_token = token.access_token }, {host = "oauth2_3.com"}) + local body = cjson.decode(response) + assert.are.equal(200, status) + assert.falsy(body.queryString.access_token) + end) + + it("should not hide credentials in the header", function() + local token = provision_token() + local response, status = http_client.get(STUB_GET_URL, {}, {host = "oauth2.com", authorization = "bearer "..token.access_token}) + local body = cjson.decode(response) + assert.are.equal(200, status) + assert.are.equal("bearer "..token.access_token, body.headers.authorization) + end) + + it("should hide credentials in the header", function() + local token = provision_token() + local response, status = http_client.get(STUB_GET_URL, {}, {host = "oauth2_3.com", authorization = "bearer "..token.access_token}) + local body = cjson.decode(response) + assert.are.equal(200, status) + assert.falsy(body.headers.authorization) + end) + end) + +end) diff --git a/spec/plugins/oauth2/api_spec.lua b/spec/plugins/oauth2/api_spec.lua new file mode 100644 index 00000000000..a555104686f --- /dev/null +++ b/spec/plugins/oauth2/api_spec.lua @@ -0,0 +1,122 @@ +local json = require "cjson" +local http_client = require "kong.tools.http_client" +local spec_helper = require "spec.spec_helpers" + +describe("OAuth 2 Credentials API", function() + local BASE_URL, credential, consumer + + setup(function() + spec_helper.prepare_db() + spec_helper.start_kong() + end) + + teardown(function() + spec_helper.stop_kong() + end) + + describe("/consumers/:consumer/oauth2/", function() + + setup(function() + local fixtures = spec_helper.insert_fixtures { + consumer = {{ username = "bob" }} + } + consumer = fixtures.consumer[1] + BASE_URL = spec_helper.API_URL.."/consumers/bob/oauth2/" + end) + + describe("POST", function() + + it("[SUCCESS] should create a oauth2 credential", function() + local response, status = http_client.post(BASE_URL, { name = "Test APP", redirect_uri = "http://google.com/" }) + assert.equal(201, status) + credential = json.decode(response) + assert.equal(consumer.id, credential.consumer_id) + end) + + it("[FAILURE] should return proper errors", function() + local response, status = http_client.post(BASE_URL, {}) + assert.equal(400, status) + assert.equal('{"redirect_uri":"redirect_uri is required","name":"name is required"}\n', response) + end) + + end) + + describe("PUT", function() + setup(function() + spec_helper.get_env().dao_factory.keyauth_credentials:delete(credential.id) + end) + + it("[SUCCESS] should create and update", function() + local response, status = http_client.put(BASE_URL, { redirect_uri = "http://google.com/", name = "Test APP" }) + assert.equal(201, status) + credential = json.decode(response) + assert.equal(consumer.id, credential.consumer_id) + end) + + it("[FAILURE] should return proper errors", function() + local response, status = http_client.put(BASE_URL, {}) + assert.equal(400, status) + assert.equal('{"redirect_uri":"redirect_uri is required","name":"name is required"}\n', response) + end) + + end) + + describe("GET", function() + + it("should retrieve all", function() + local response, status = http_client.get(BASE_URL) + assert.equal(200, status) + local body = json.decode(response) + assert.equal(2, #(body.data)) + end) + + end) + end) + + describe("/consumers/:consumer/oauth2/:id", function() + + describe("GET", function() + + it("should retrieve by id", function() + local _, status = http_client.get(BASE_URL..credential.id) + assert.equal(200, status) + end) + + end) + + describe("PATCH", function() + + it("[SUCCESS] should update a credential", function() + local response, status = http_client.patch(BASE_URL..credential.id, { redirect_uri = "http://getkong.org/" }) + assert.equal(200, status) + credential = json.decode(response) + assert.equal("http://getkong.org/", credential.redirect_uri) + end) + + it("[FAILURE] should return proper errors", function() + local response, status = http_client.patch(BASE_URL..credential.id, { redirect_uri = "" }) + assert.equal(400, status) + assert.equal('{"redirect_uri":"redirect_uri is not a url"}\n', response) + end) + + end) + + describe("DELETE", function() + + it("[FAILURE] should return proper errors", function() + local _, status = http_client.delete(BASE_URL.."blah") + assert.equal(400, status) + + _, status = http_client.delete(BASE_URL.."00000000-0000-0000-0000-000000000000") + assert.equal(404, status) + end) + + it("[SUCCESS] should delete a credential", function() + local _, status = http_client.delete(BASE_URL..credential.id) + assert.equal(204, status) + end) + + end) + + end) +end) diff --git a/spec/unit/dao/oauth2/oauth2_entities_spec.lua b/spec/unit/dao/oauth2/oauth2_entities_spec.lua new file mode 100644 index 00000000000..238a4074342 --- /dev/null +++ b/spec/unit/dao/oauth2/oauth2_entities_spec.lua @@ -0,0 +1,48 @@ +local validate = require("kong.dao.schemas_validation").validate +local oauth2_schema = require "kong.plugins.oauth2.schema" + +require "kong.tools.ngx_stub" + +describe("OAuth2 Entities Schemas", function() + + describe("OAuth2 Configuration", function() + + it("should not require a `scopes` when `mandatory_scope` is false", function() + local valid, errors = validate({ mandatory_scope = false }, oauth2_schema) + assert.truthy(valid) + assert.falsy(errors) + end) + + it("should require a `scopes` when `mandatory_scope` is true", function() + local valid, errors = validate({ mandatory_scope = true }, oauth2_schema) + assert.falsy(valid) + assert.equal("To set a mandatory scope you also need to create available scopes", errors.mandatory_scope) + end) + + it("should pass when both `scopes` when `mandatory_scope` are passed", function() + local valid, errors = validate({ mandatory_scope = true, scopes = { "email", "info" } }, oauth2_schema) + assert.truthy(valid) + assert.falsy(errors) + end) + + it("should autogenerate a `provision_key` when it is not being passed", function() + local t = { mandatory_scope = true, scopes = { "email", "info" } } + local valid, errors = validate(t, oauth2_schema) + assert.truthy(valid) + assert.falsy(errors) + assert.truthy(t.provision_key) + assert.are.equal(32, t.provision_key:len()) + end) + + it("should not autogenerate a `provision_key` when it is being passed", function() + local t = { mandatory_scope = true, scopes = { "email", "info" }, provision_key = "hello" } + local valid, errors = validate(t, oauth2_schema) + assert.truthy(valid) + assert.falsy(errors) + assert.truthy(t.provision_key) + assert.are.equal("hello", t.provision_key) + end) + + end) + +end) diff --git a/spec/unit/schemas_spec.lua b/spec/unit/schemas_spec.lua index b3e1364a87a..2cda2dec351 100644 --- a/spec/unit/schemas_spec.lua +++ b/spec/unit/schemas_spec.lua @@ -16,6 +16,7 @@ describe("Schemas", function() date = { default = 123456, immutable = true }, allowed = { enum = { "hello", "world" }}, boolean_val = { type = "boolean" }, + endpoint = { type = "url" }, default = { default = function(t) assert.truthy(t) return "default" @@ -106,6 +107,42 @@ describe("Schemas", function() local valid, err = validate(values, schema) assert.falsy(err) assert.truthy(valid) + + -- Failure + local values = { string = "foo", endpoint = "" } + + local valid, err = validate(values, schema) + assert.falsy(valid) + assert.truthy(err) + assert.are.equal("endpoint is not a url", err.endpoint) + + -- Failure + local values = { string = "foo", endpoint = "asdasd" } + + local valid, err = validate(values, schema) + assert.falsy(valid) + assert.truthy(err) + + -- Failure + local values = { string = "foo", endpoint = "http://google.com" } + + local valid, err = validate(values, schema) + assert.falsy(valid) + assert.truthy(err) + + -- Success + local values = { string = "foo", endpoint = "http://google.com/" } + + local valid, err = validate(values, schema) + assert.truthy(valid) + assert.falsy(err) + + -- Success + local values = { string = "foo", endpoint = "http://google.com/hello/?world=asd" } + + local valid, err = validate(values, schema) + assert.truthy(valid) + assert.falsy(err) end) it("should return error when an invalid boolean value is passed", function() diff --git a/spec/unit/statics_spec.lua b/spec/unit/statics_spec.lua index 48c4703e3bf..dee96c45059 100644 --- a/spec/unit/statics_spec.lua +++ b/spec/unit/statics_spec.lua @@ -43,6 +43,7 @@ plugins_available: - ssl - keyauth - basicauth + - oauth2 - ratelimiting - tcplog - udplog diff --git a/spec/unit/tools/migrations_spec.lua b/spec/unit/tools/migrations_spec.lua index 541d5f10bf4..c33ee2594aa 100644 --- a/spec/unit/tools/migrations_spec.lua +++ b/spec/unit/tools/migrations_spec.lua @@ -109,7 +109,7 @@ describe("Migrations", function() assert.are.same(migrations_names[i], migration.name..".lua") end) - assert.are.same(4, i) + assert.are.same(5, i) assert.spy(env.dao_factory.migrations.get_migrations).was.called(1) assert.spy(env.dao_factory.execute_queries).was.called(#migrations_names-1) assert.spy(env.dao_factory.migrations.add_migration).was.called(#migrations_names-1) diff --git a/spec/unit/tools/utils_spec.lua b/spec/unit/tools/utils_spec.lua index e614945f686..1884feb3d86 100644 --- a/spec/unit/tools/utils_spec.lua +++ b/spec/unit/tools/utils_spec.lua @@ -1,6 +1,15 @@ local utils = require "kong.tools.utils" describe("Utils", function() + + describe("strings", function() + local first = utils.random_string() + assert.truthy(first) + assert.falsy(first:find("-")) + local second = utils.random_string() + assert.falsy(first == second) + end) + describe("tables", function() describe("#table_size()", function()