diff --git a/.gitmodules b/.gitmodules index a7d3f69fe5a1..11fdae734da0 100644 --- a/.gitmodules +++ b/.gitmodules @@ -7,3 +7,6 @@ [submodule "extra/libkmip"] path = extra/libkmip url = https://github.com/Percona-Lab/libkmip.git +[submodule "extra/jwt-cpp"] + path = extra/jwt-cpp + url = https://github.com/Thalhammer/jwt-cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index b8b261c54114..5bbc6f6e1f50 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -156,6 +156,8 @@ ENDIF() # PAM build Handling OPTION(WITH_PAM "Build with Percona PAM plugin" OFF) +OPTION(WITH_AUTH_OPENID_CONNECT "Build with Percona OpenID Connect authentication plugin" ON) + # We choose to provide WITH_DEBUG as alias to standard CMAKE_BUILD_TYPE=Debug # which turns out to be not trivial, as this involves synchronization # between CMAKE_BUILD_TYPE and WITH_DEBUG. Besides, we have to deal with cases diff --git a/client/mysql.cc b/client/mysql.cc index 661d56b4010a..252ce0d57e87 100644 --- a/client/mysql.cc +++ b/client/mysql.cc @@ -248,6 +248,7 @@ static const CHARSET_INFO *charset_info = &my_charset_latin1; static char *opt_oci_config_file = nullptr; static char *opt_authentication_oci_client_config_profile = nullptr; +static char *opt_authentication_openid_connect_client_id_token_file = nullptr; static char *opt_register_factor = nullptr; static bool opt_tel_plugin = false; @@ -2072,6 +2073,11 @@ static struct my_option my_long_options[] = { "is ~/.oci/config and %HOME/.oci/config on Windows.", &opt_oci_config_file, &opt_oci_config_file, nullptr, GET_STR, REQUIRED_ARG, 0, 0, 0, nullptr, 0, nullptr}, + {"authentication-openid-connect-client-id-token-file", 0, + "Specifies the location of the ID token file.", + &opt_authentication_openid_connect_client_id_token_file, + &opt_authentication_openid_connect_client_id_token_file, nullptr, GET_STR, + REQUIRED_ARG, 0, 0, 0, nullptr, 0, nullptr}, {"telemetry-client", 0, "Load the telemetry_client plugin.", &opt_tel_plugin, &opt_tel_plugin, nullptr, GET_BOOL, NO_ARG, 0, 0, 0, nullptr, 0, nullptr}, @@ -5182,6 +5188,29 @@ static bool init_connection_options(MYSQL *mysql) { } } + /* set authentication_openid_connect_client ID token file option if required + */ + if (opt_authentication_openid_connect_client_id_token_file != nullptr) { + struct st_mysql_client_plugin *openid_connect_plugin = + mysql_client_find_plugin(mysql, "authentication_openid_connect_client", + MYSQL_CLIENT_AUTHENTICATION_PLUGIN); + if (!openid_connect_plugin) { + put_info("Cannot load the authentication_openid_connect_client plugin.", + INFO_ERROR); + return true; + } + if (mysql_plugin_options( + openid_connect_plugin, "id-token-file", + opt_authentication_openid_connect_client_id_token_file)) { + put_info( + "Failed to set id token file for " + "authentication_openid_connect_client " + "plugin.", + INFO_ERROR); + return true; + } + } + char error[256]{0}; #if defined(_WIN32) if (set_authentication_kerberos_client_mode(mysql, error, 255)) { diff --git a/components/keyrings/keyring_kmip/config/config.cc b/components/keyrings/keyring_kmip/config/config.cc index 82d42574db3b..ec6578a7c6f3 100644 --- a/components/keyrings/keyring_kmip/config/config.cc +++ b/components/keyrings/keyring_kmip/config/config.cc @@ -142,9 +142,15 @@ bool find_and_read_config_file(std::unique_ptr &config_pod) { // optional attribute } - if (config_reader->get_element(config_options[7], - config_pod_tmp.get()->max_objects)) { - // optional attribute + // rapidjson's TypeHelper has specializations for the fixed-width integer + // typedefs (uint64_t etc.) but not for size_t. On macOS / Apple clang + + // libc++, size_t is 'unsigned long' which has no TypeHelper specialization, + // so get_element() fails to compile. Read into a uint64_t and + // assign back on success; uint64_t is recognized on all platforms. + if (uint64_t max_objects_tmp = config_pod_tmp.get()->max_objects; + !config_reader->get_element(config_options[7], + max_objects_tmp)) { + config_pod_tmp.get()->max_objects = static_cast(max_objects_tmp); } if (config_reader->get_element(config_options[8], diff --git a/extra/jwt-cpp b/extra/jwt-cpp new file mode 160000 index 000000000000..3e037df3e669 --- /dev/null +++ b/extra/jwt-cpp @@ -0,0 +1 @@ +Subproject commit 3e037df3e669633a3044618e30550ea2f212e915 diff --git a/sql-common/oci/ssl.h b/include/base64_encode.h similarity index 100% rename from sql-common/oci/ssl.h rename to include/base64_encode.h diff --git a/sql-common/oci/ssl_ptr.h b/include/encode_ptr.h similarity index 100% rename from sql-common/oci/ssl_ptr.h rename to include/encode_ptr.h diff --git a/include/mysql.h.pp b/include/mysql.h.pp index 685c50fe501a..af478a7ae972 100644 --- a/include/mysql.h.pp +++ b/include/mysql.h.pp @@ -311,6 +311,7 @@ MYSQL_VIO_MEMORY } protocol; int socket; + bool is_tls_established; }; enum net_async_status { NET_ASYNC_COMPLETE = 0, diff --git a/include/mysql/client_plugin.h.pp b/include/mysql/client_plugin.h.pp index 197ee525574c..2fd69b3b23f0 100644 --- a/include/mysql/client_plugin.h.pp +++ b/include/mysql/client_plugin.h.pp @@ -12,6 +12,7 @@ MYSQL_VIO_MEMORY } protocol; int socket; + bool is_tls_established; }; enum net_async_status { NET_ASYNC_COMPLETE = 0, diff --git a/include/mysql/plugin_auth.h.pp b/include/mysql/plugin_auth.h.pp index 1b99be21226e..52cac52596c7 100644 --- a/include/mysql/plugin_auth.h.pp +++ b/include/mysql/plugin_auth.h.pp @@ -150,6 +150,7 @@ MYSQL_VIO_MEMORY } protocol; int socket; + bool is_tls_established; }; enum net_async_status { NET_ASYNC_COMPLETE = 0, diff --git a/include/mysql/plugin_auth_common.h b/include/mysql/plugin_auth_common.h index cf40dfa6d86a..5b0364bd553d 100644 --- a/include/mysql/plugin_auth_common.h +++ b/include/mysql/plugin_auth_common.h @@ -38,6 +38,10 @@ /** the max allowed length for a user name */ #define MYSQL_USERNAME_LENGTH 96 +#ifndef MYSQL_ABI_CHECK +#include +#endif + /** return values of the plugin authenticate_user() method. */ @@ -127,6 +131,7 @@ struct MYSQL_PLUGIN_VIO_INFO { MYSQL_VIO_MEMORY } protocol; int socket; /**< it's set, if the protocol is SOCKET or TCP */ + bool is_tls_established; #if defined(_WIN32) && !defined(MYSQL_ABI_CHECK) HANDLE handle; /**< it's set, if the protocol is PIPE or MEMORY */ #endif diff --git a/libmysql/CMakeLists.txt b/libmysql/CMakeLists.txt index 57e34b70992b..223857a52f01 100644 --- a/libmysql/CMakeLists.txt +++ b/libmysql/CMakeLists.txt @@ -304,6 +304,9 @@ ADD_SUBDIRECTORY(authentication_kerberos) # authentication IAM client plug-in ADD_SUBDIRECTORY(authentication_oci_client) +# authentication OpenID Connect client plug-in +ADD_SUBDIRECTORY(authentication_openid_connect_client) + # Fido and Webauthn clients ADD_SUBDIRECTORY(fido_client) diff --git a/libmysql/authentication_oci_client/authentication_oci_client_plugin.cc b/libmysql/authentication_oci_client/authentication_oci_client_plugin.cc index c4708afa6d7e..31ffdbf30885 100644 --- a/libmysql/authentication_oci_client/authentication_oci_client_plugin.cc +++ b/libmysql/authentication_oci_client/authentication_oci_client_plugin.cc @@ -41,8 +41,8 @@ #include #include +#include "include/base64_encode.h" #include "sql-common/oci/signing_key.h" -#include "sql-common/oci/ssl.h" #include "sql-common/oci/utilities.h" static char *s_oci_config_location = nullptr; diff --git a/libmysql/authentication_openid_connect_client/CMakeLists.txt b/libmysql/authentication_openid_connect_client/CMakeLists.txt new file mode 100644 index 000000000000..f2bc1899ac2a --- /dev/null +++ b/libmysql/authentication_openid_connect_client/CMakeLists.txt @@ -0,0 +1,64 @@ +# Copyright (c) 2024, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, +# as published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an additional +# permission to link the program and your derivative works with the +# separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +# +# Configuration for building OpenID Connect authentication client Plug-in (client-side) +# + +# The client authentication plug-in is part of the community build. + +# Skip it if disabled. +IF(NOT WITH_AUTHENTICATION_CLIENT_PLUGINS) + MESSAGE(STATUS "Skipping the OpenID Connect authentication client plugin.") + RETURN() +ENDIF() + +DISABLE_MISSING_PROFILE_WARNING() + +MYSQL_ADD_PLUGIN( + authentication_openid_connect_client + + # Authentication plugin main + authentication_openid_connect_client_plugin.cc + + LINK_LIBRARIES mysys OpenSSL::SSL OpenSSL::Crypto + + CLIENT_ONLY + MODULE_ONLY MODULE_OUTPUT_NAME "authentication_openid_connect_client" +) + +IF(LINUX OR SOLARIS) + SET(PLUGIN_VERSION_FILE + ${CMAKE_CURRENT_SOURCE_DIR}/authentication_openid_connect_client.ver) + IF(SOLARIS) + TARGET_LINK_OPTIONS(authentication_openid_connect_client PRIVATE + LINKER:-z,gnu-version-script-compat) + ENDIF() + # hide all symbols in mysys, to avoid ODR violations. + # There is *one* visible symbol: _mysql_client_plugin_declaration_ + TARGET_LINK_OPTIONS(authentication_openid_connect_client PRIVATE + LINKER:--version-script=${PLUGIN_VERSION_FILE} + ) + SET_TARGET_PROPERTIES(authentication_openid_connect_client + PROPERTIES LINK_DEPENDS ${PLUGIN_VERSION_FILE}) +ENDIF() diff --git a/libmysql/authentication_openid_connect_client/authentication_openid_connect_client.ver b/libmysql/authentication_openid_connect_client/authentication_openid_connect_client.ver new file mode 100644 index 000000000000..58baae7062fd --- /dev/null +++ b/libmysql/authentication_openid_connect_client/authentication_openid_connect_client.ver @@ -0,0 +1,28 @@ +# Copyright (c) 2024, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, +# as published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an additional +# permission to link the program and your derivative works with the +# separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +authentication_openid_connect_client +{ + global: _mysql_client_plugin_declaration_; + local: *; +}; diff --git a/libmysql/authentication_openid_connect_client/authentication_openid_connect_client_plugin.cc b/libmysql/authentication_openid_connect_client/authentication_openid_connect_client_plugin.cc new file mode 100644 index 000000000000..1a185a07019f --- /dev/null +++ b/libmysql/authentication_openid_connect_client/authentication_openid_connect_client_plugin.cc @@ -0,0 +1,255 @@ +/* Copyright (c) 2024, Oracle and/or its affiliates. + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License, version 2.0, + as published by the Free Software Foundation. + + This program is designed to work with certain software (including + but not limited to OpenSSL) that is licensed under separate terms, + as designated in a particular file or component or in included license + documentation. The authors of MySQL hereby grant you an additional + permission to link the program and your derivative works with the + separately licensed software that they have either included with + the program or referenced in the documentation. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License, version 2.0, for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA */ + +/* + This is a CLIENT_ONLY plugin, so allocation functions are my_malloc, + my_free etc. +*/ +#include +#include +#include +#include +#include +#include +#include "mysql_com.h" + +#define MAX_MESSAGE_SIZE 20000 + +static char *s_id_token_location = nullptr; +static const int s_max_token_size = 10000; +static constexpr const char *base64url_chars{ + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890+/-_="}; + +// Helper functions. +/** + Log error message to client + + @param [in] message Message to be displayed +*/ +void log_error(const std::string &message) { std::cerr << message << "\n"; } + +/** + Free plugin option + + @param [in] option Plugin option to be freed +*/ +inline void free_plugin_option(char *&option) { + if (option == nullptr) return; + my_free(option); + option = nullptr; +} + +/** + Extract a part from JWT + + @param [in] jwt JSON Web Token + @param [out] part Part extracted + + @returns Success status + @retval false Success + @retval true Failure +*/ +bool get_part(std::string &jwt, std::string &part) { + const size_t pos = jwt.find_first_of('.'); + if (pos == std::string::npos) return true; + part = jwt.substr(0, pos); + if (part.empty() || + part.find_first_not_of(base64url_chars) != std::string::npos) + return true; + jwt = jwt.substr(pos + 1); + return false; +} + +/** + Extract head, body and signature from JWT + + @param [in] jwt JSON Web Token + @param [out] head Token's head + @param [out] body Token's body + @param [out] sig Token's signature + + @returns Success status + @retval false Success + @retval true Failure +*/ +bool get_jwt_parts(std::string jwt, std::string &head, std::string &body, + std::string &sig) { + /* + JWT consists of base64URL-encoded header, body and signature, separated by + '.', e.g. "..". + */ + if (get_part(jwt, head)) return true; + + if (get_part(jwt, body)) return true; + + sig = jwt; + if (sig.empty() || + sig.find_first_not_of(base64url_chars) != std::string::npos) + return true; + return false; +} + +/** + client auth function + + * read stuff via the VIO. try to *read first* + * get (login) data from the MYSQL handle: mysql->user, mysql->passwd + * return CR_OK on success, CR_ERROR on failure +*/ +static int openid_connect_authentication_client_plugin(MYSQL_PLUGIN_VIO *vio, + MYSQL * /*mysql*/) { + /** + * Step 1: Read the id token. + */ + if (s_id_token_location == nullptr) { + log_error("The path to ID token file is not set."); + return CR_AUTH_USER_CREDENTIALS; + } + const char *filename = s_id_token_location; + std::string token, id_token_file(s_id_token_location); + // Check if the file exists + const int fd = open(filename, O_RDONLY); + if (fd == -1) { + log_error("Unable to open ID token file: " + id_token_file); + return CR_AUTH_USER_CREDENTIALS; + } + // Get the file size + struct stat fileStat; + if (fstat(fd, &fileStat) == -1) { + log_error("Unable to get ID token file size."); + close(fd); + return CR_AUTH_USER_CREDENTIALS; + } + const off_t fileSize = fileStat.st_size; + + if (fileSize > s_max_token_size) { + log_error("The id token file: " + id_token_file + + " is not acceptable, file size should be less than 10k."); + return CR_AUTH_USER_CREDENTIALS; + } + // Allocate buffer to read file contents + char *buffer = new char[fileSize + 1]; + buffer[fileSize] = '\0'; // Null terminate the buffer + + // Read file contents + const ssize_t bytesRead = read(fd, buffer, fileSize); + if (bytesRead == -1) { + log_error("Unable to read ID token file: " + id_token_file); + delete[] buffer; + close(fd); + return CR_AUTH_USER_CREDENTIALS; + } + token = buffer; + + // Clean up + delete[] buffer; + close(fd); + + if (token.empty()) { + log_error("The id token file: " + id_token_file + " is empty."); + return CR_AUTH_USER_CREDENTIALS; + } + + // Sometimes a '\n' is read on linux platforms which makes it an invalid JWT + // Check if the string ends with '\n' + if (token.back() == '\n') { + // Remove the last character + token.pop_back(); + } + + // Check if token is a valid JWT + std::string head, body, sig; + if (get_jwt_parts(token, head, body, sig)) { + log_error("The id token file: " + id_token_file + + " does not contain a valid JWT."); + return CR_AUTH_USER_CREDENTIALS; + } + + /** + * Step 2: Check if connection is secure. + */ + MYSQL_PLUGIN_VIO_INFO vio_info; + vio->info(vio, &vio_info); + if (vio_info.is_tls_established || + vio_info.protocol == MYSQL_PLUGIN_VIO_INFO::MYSQL_VIO_SOCKET || + vio_info.protocol == MYSQL_PLUGIN_VIO_INFO::MYSQL_VIO_MEMORY) { + /** + * Step 3: Send the id token to the server for verification. + */ + unsigned char message[MAX_MESSAGE_SIZE]; + unsigned char *pos = message; + unsigned short capability = 1; + *pos = *reinterpret_cast(&capability); + pos++; + auto length = token.length(); + pos = net_store_length(pos, length); + memcpy(pos, token.c_str(), length); + pos += length; + if (vio->write_packet(vio, message, (int)(pos - message))) { + log_error("An error occurred during the client server handshake."); + return CR_AUTH_HANDSHAKE; + } + } else { + log_error( + "The client-server connection is insecure. Please make sure either a " + "TLS, socket or shared memory connection is established between the " + "client and the server."); + return CR_ERROR; + } + return CR_OK; +} + +static int initialize_plugin(char *, size_t, int, va_list) { return 0; } + +static int deinitialize_plugin() { + free_plugin_option(s_id_token_location); + return 0; +} + +/** + authentication_openid_connect_client_option plugin API to allow server to pass + optional data for plugin to process +*/ +static int authentication_openid_connect_client_option(const char *option, + const void *val) { + const char *value = static_cast(val); + if (strcmp(option, "id-token-file") == 0) { + free_plugin_option(s_id_token_location); + if (value == nullptr) return 1; + const std::ifstream file(value); + if (file.good()) { + s_id_token_location = my_strdup(PSI_NOT_INSTRUMENTED, value, MYF(MY_WME)); + return 0; + } + } + return 1; +} + +mysql_declare_client_plugin( + AUTHENTICATION) "authentication_openid_connect_client", + MYSQL_CLIENT_PLUGIN_AUTHOR_ORACLE, + "OpenID Connect Client Authentication Plugin", {0, 1, 0}, "COMMUNITY", + nullptr, initialize_plugin, deinitialize_plugin, + authentication_openid_connect_client_option, + nullptr, openid_connect_authentication_client_plugin, + nullptr mysql_end_client_plugin; diff --git a/mysql-test/include/plugin.defs b/mysql-test/include/plugin.defs index bfca9e4b5c1f..5c47dd29babc 100644 --- a/mysql-test/include/plugin.defs +++ b/mysql-test/include/plugin.defs @@ -212,3 +212,4 @@ component_encryption_udf plugin_output_directory no ENCRYPTION_UDF_ component_masking_functions plugin_output_directory no MASKING_FUNCTIONS_COMPONENT component_percona_udf plugin_output_directory no PERCONA_UDF_COMPONENT component_js_lang plugin_output_directory no JS_LANG_COMPONENT +auth_openid_connect plugin_output_directory no AUTH_OIDC auth_openid_connect diff --git a/mysql-test/mysql-test-run.pl b/mysql-test/mysql-test-run.pl index 8b036695ccbf..c2c62596947a 100755 --- a/mysql-test/mysql-test-run.pl +++ b/mysql-test/mysql-test-run.pl @@ -382,6 +382,7 @@ sub set_term_args { our $exe_mysql; our $exe_mysql_migrate_keyring; our $exe_mysql_keyring_encryption_test; +our $exe_create_id_token; our $exe_mysqladmin; our $exe_mysqltest; our $exe_mysql_test_event_tracking; @@ -2929,7 +2930,7 @@ () mtr_exe_exists("$path_client_bindir/mysql_migrate_keyring"); $exe_mysql_keyring_encryption_test = mtr_exe_exists("$path_client_bindir/mysql_keyring_encryption_test"); - + $exe_create_id_token = mtr_exe_exists("$path_client_bindir/create_id_token"); # Look for mysql_test_event_tracking binary $exe_mysql_test_event_tracking = my_find_bin($bindir, [ "runtime_output_directory", "bin" ], @@ -3491,7 +3492,7 @@ sub environment_setup { $ENV{'MYSQL_SECURE_INSTALLATION'} = "$path_client_bindir/mysql_secure_installation"; $ENV{'OPENSSL_EXECUTABLE'} = $exe_openssl; - + $ENV{'CREATE_ID_TOKEN'} = $exe_create_id_token; my $exe_mysqld = find_mysqld($basedir); $ENV{'MYSQLD'} = $exe_mysqld; diff --git a/mysql-test/std_data/oidc/dummy_oidc_conf.json b/mysql-test/std_data/oidc/dummy_oidc_conf.json new file mode 100644 index 000000000000..642e79f29587 --- /dev/null +++ b/mysql-test/std_data/oidc/dummy_oidc_conf.json @@ -0,0 +1,55 @@ +{ + "oidc-idp": { + "issuer-name": "https://idp-test.com/realms/dummy", + "keys": [ + { + "kid": "rsa-key-1", + "kty": "RSA", + "n": "ptR4YxjdrF2RrYiY9XYH3KcXKzlS6b2foGAeHN9dViAs5yKStKAucUf81kszq6LkzkOOPX5SbToLBRv5wwOtrBLKVkqhw4i5kS0b-7jpR9g7lER3qfDVYfe0nneaJzc-1Iz6id0oVj4ay8lGmWg5JX5Z8nBs52_7rfDIBtyAsyIQ2dF2tW4e72cypvskw_TwdbsqelY5ZezzTmKx8Ra4-o9Pu5_Vxe1Hhlkx1K_tfbGH9ZUsbFy90b0Vcw5w6EKPEkM9mZTUoj8TMeHTHaiOREK4llIJ6hokXbEG8BsKJuMuYrzx98e23JkaWaWQY27LUGK5sx-lsX_n8DO2kEASRw", + "e": "AQAB", + "use": "sig", + "alg": "RS256" + }, + { + "kty": "EC", + "kid": "ec-key-1", + "use": "sig", + "alg": "ES256", + "crv": "P-256", + "x": "bjQTXrTcw_1HKiiZm2Hqv41w7Vd44M9koyY_-VsP-SA", + "y": "XqAzBfS0uQQwoemIKhNw4x8FsJxChCN1qT3_IsxMda0" + } + ], + "audiences": [ + "ee2811b9-10b8", + "https://api.example.com" + ], + "group-claim": "groups", + "group-role": [ + { + "acc": "accounting" + }, + { + "eng": "engineering" + } + ] + }, + + "oidc-idp2": { + "issuer-name": "https://idp-test2.com/realms/dummy", + "keys": [ + { + "kid": "key-1", + "kty": "RSA", + "n": "ptR4YxjdrF2RrYiY9XYH3KcXKzlS6b2foGAeHN9dViAs5yKStKAucUf81kszq6LkzkOOPX5SbToLBRv5wwOtrBLKVkqhw4i5kS0b-7jpR9g7lER3qfDVYfe0nneaJzc-1Iz6id0oVj4ay8lGmWg5JX5Z8nBs52_7rfDIBtyAsyIQ2dF2tW4e72cypvskw_TwdbsqelY5ZezzTmKx8Ra4-o9Pu5_Vxe1Hhlkx1K_tfbGH9ZUsbFy90b0Vcw5w6EKPEkM9mZTUoj8TMeHTHaiOREK4llIJ6hokXbEG8BsKJuMuYrzx98e23JkaWaWQY27LUGK5sx-lsX_n8DO2kEASRw", + "e": "AQAB", + "use": "sig", + "alg": "RS256" + } + ], + "audiences": [ + "ee2811b9-10b8" + ] + } +} + diff --git a/mysql-test/std_data/oidc/idp_private.pem b/mysql-test/std_data/oidc/idp_private.pem new file mode 100644 index 000000000000..e7773a8f44c9 --- /dev/null +++ b/mysql-test/std_data/oidc/idp_private.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCm1HhjGN2sXZGt +iJj1dgfcpxcrOVLpvZ+gYB4c311WICznIpK0oC5xR/zWSzOrouTOQ449flJtOgsF +G/nDA62sEspWSqHDiLmRLRv7uOlH2DuURHep8NVh97Sed5onNz7UjPqJ3ShWPhrL +yUaZaDklflnycGznb/ut8MgG3ICzIhDZ0Xa1bh7vZzKm+yTD9PB1uyp6Vjll7PNO +YrHxFrj6j0+7n9XF7UeGWTHUr+19sYf1lSxsXL3RvRVzDnDoQo8SQz2ZlNSiPxMx +4dMdqI5EQriWUgnqGiRdsQbwGwom4y5ivPH3x7bcmRpZpZBjbstQYrmzH6Wxf+fw +M7aQQBJHAgMBAAECggEATOmLjvQ5zmtc7Aobqp59xWZrMgw9g3FelEt71ofLuhcf +XHf99rQadTNhB1KoQarZnZZbj1IboiuuRO6+2P9rI/eNvPavWTxBgQKw8f4v3mV8 +IkDmgjx7w6y1YpF1SjsYBlnwb3q8S/ZZ2DW1DKiWIAj+Yt0d+B0ShQCK1071Lp/5 +TGcVr+G2Opve2kVFLLWh2vXEeoQMreelk1thoACA629VsHjvFMuc8P0BXqDlTI7F +00t+RMrkREuXhbmku29svcODa3z+eSyylXaOwaUN4ncPUogVN/XqkmtKa1/touxd +uC3Y7U6w5xdgEMlf2s1001esrKPJF1jgSwctpK2v8QKBgQDVF1w1x9xCdU0X4QO4 +NJs+9I59jMMbrsIlZsM+K0KMUISeI9ZFI7bAozJE3KoQUEGPWZPpaFAuwsuB4qvP +bzsEbDg+EzFQdnLOgjBH+MugF4q51ouYFe5sTJbI1pZaBovDu1pjCnBBkFx0MmbG +BqfSvlqwId6e1YAqHd4z8KtzOwKBgQDIbGMamvspUslye8bctRTwhV0Cd8wG33p8 +4OMkcBUAUU+8/8LtDuoR2sgCX91L3ztilngsj2sXI9Jy1J23gpXm0thwV22vIJHk +Yc0PxuZ1OEA1B6oLreFw7PD9SxlG62/L0P2WACvwi92xJlW/IDgE/CRE11IpSJrV +FshURyEUZQKBgQCFpRwJAutKpyUN1+ssSZogduMzLOhlYUqUiInlYN5hAFLcl99Y +B5kj4naxp6/lgWBM1sKkve6kFTnroU1eUQWztWfkzsa8Dz3b9NzxFsInCvzPpxZv +8TlSpQpgte0gU0CvJr7+pNpY1ICXw9CfXCc/TnG0S9nCxmaWg5sL+mKdZwKBgFOj +T5Qpusha4PAikTFHbA6XSOIfxgfUONRmMMPi9hCk3ga8IMc2ox2CVFcRVFM2PBz/ +N/U4gHMuosMC0TJkj1O9B0+SXJZpnBhXa/C6iy+9oqW+pgqrrFmot0Ssk0bSN1wx +wbFYLv36EDC+E6hntJj389a6mHHb96kXEdCBwl81AoGBAIwt2Ani0rMl3KhS/JGq +UhFZfneLbzeb5Oz8O/ZphJyG9sPtdXq56Z1xezMaZUWjjZRJuLMq86d4jGdpKTmX +LOQDHYtLJxrTLGpED42zHEW/51P+q0F7EeS0ge09lvMjqoU+FCsOTqKlZH/H7DAI +Xk/3ehQ3WrVVM7QkLw4jk8P3 +-----END PRIVATE KEY----- diff --git a/mysql-test/std_data/oidc/idp_public.pem b/mysql-test/std_data/oidc/idp_public.pem new file mode 100644 index 000000000000..f54628fb68b5 --- /dev/null +++ b/mysql-test/std_data/oidc/idp_public.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAptR4YxjdrF2RrYiY9XYH +3KcXKzlS6b2foGAeHN9dViAs5yKStKAucUf81kszq6LkzkOOPX5SbToLBRv5wwOt +rBLKVkqhw4i5kS0b+7jpR9g7lER3qfDVYfe0nneaJzc+1Iz6id0oVj4ay8lGmWg5 +JX5Z8nBs52/7rfDIBtyAsyIQ2dF2tW4e72cypvskw/TwdbsqelY5ZezzTmKx8Ra4 ++o9Pu5/Vxe1Hhlkx1K/tfbGH9ZUsbFy90b0Vcw5w6EKPEkM9mZTUoj8TMeHTHaiO +REK4llIJ6hokXbEG8BsKJuMuYrzx98e23JkaWaWQY27LUGK5sx+lsX/n8DO2kEAS +RwIDAQAB +-----END PUBLIC KEY----- diff --git a/mysql-test/std_data/oidc/keycloak_oidc_conf.json b/mysql-test/std_data/oidc/keycloak_oidc_conf.json new file mode 100644 index 000000000000..9938412b4147 --- /dev/null +++ b/mysql-test/std_data/oidc/keycloak_oidc_conf.json @@ -0,0 +1,61 @@ +{ + "oidc-idp": { + "issuer-name": "https://idp-test.com/realms/dummy", + "keys": [ + { + "kid": "rsa-key-1", + "kty": "RSA", + "n": "ptR4YxjdrF2RrYiY9XYH3KcXKzlS6b2foGAeHN9dViAs5yKStKAucUf81kszq6LkzkOOPX5SbToLBRv5wwOtrBLKVkqhw4i5kS0b-7jpR9g7lER3qfDVYfe0nneaJzc-1Iz6id0oVj4ay8lGmWg5JX5Z8nBs52_7rfDIBtyAsyIQ2dF2tW4e72cypvskw_TwdbsqelY5ZezzTmKx8Ra4-o9Pu5_Vxe1Hhlkx1K_tfbGH9ZUsbFy90b0Vcw5w6EKPEkM9mZTUoj8TMeHTHaiOREK4llIJ6hokXbEG8BsKJuMuYrzx98e23JkaWaWQY27LUGK5sx-lsX_n8DO2kEASRw", + "e": "AQAB", + "use": "sig", + "alg": "RS256" + }, + { + "kty": "EC", + "kid": "ec-key-1", + "use": "sig", + "alg": "ES256", + "crv": "P-256", + "x": "bjQTXrTcw_1HKiiZm2Hqv41w7Vd44M9koyY_-VsP-SA", + "y": "XqAzBfS0uQQwoemIKhNw4x8FsJxChCN1qT3_IsxMda0" + } + ], + "audiences": [ + "ee2811b9-10b8", + "https://api.example.com" + ], + "group-claim": "groups", + "group-role": [ + { + "acc": "accounting" + }, + { + "eng": "engineering" + } + ] + }, + "my-keycloak": { + "issuer-name": "https://keycloak.int.percona.com/realms/master", + "jwks-url": "https://keycloak.int.percona.com/realms/master/protocol/openid-connect/certs", + "audiences": [ + "account" + ], + "group-claim": "groups", + "group-role": [ + { + "/accounting": "accounting" + }, + { + "/marketing": "marketing" + } + ] + }, + "unaccessible-idp": { + "issuer-name": "https://dummy-host/realms/master", + "jwks-url": "https://dummy-host/realms/master/protocol/openid-connect/certs", + "audiences": [ + "account" + ] + } +} + diff --git a/mysql-test/suite/auth_openid_connect/r/auth.result b/mysql-test/suite/auth_openid_connect/r/auth.result new file mode 100644 index 000000000000..6b7212e91909 --- /dev/null +++ b/mysql-test/suite/auth_openid_connect/r/auth.result @@ -0,0 +1,49 @@ +### INITIALIZE TESTS +CALL mtr.add_suppression("Plugin auth_openid_connect reported: 'auth_openid_connect_configuration not set"); +INSTALL PLUGIN auth_openid_connect SONAME 'auth_openid_connect.so';; +CREATE USER mysql_oidc_user IDENTIFIED WITH 'auth_openid_connect' AS '{"identity_provider" : "oidc-idp", "user" : "oidc-user"}'; + +### Validate system variable checks for auth_openid_connect_configuration + +## VALID CASES +SET GLOBAL auth_openid_connect_configuration = 'JSON://{"oidc-idp": {"issuer-name": "https://idp-test.com/realms/dummy", "keys": [{"kid": "rsa-key-1", "kty": "RSA", "n": "ptR4YxjdrF2RrYiY9XYH3KcXKzlS6b2foGAeHN9dViAs5yKStKAucUf81kszq6LkzkOOPX5SbToLBRv5wwOtrBLKVkqhw4i5kS0b-7jpR9g7lER3qfDVYfe0nneaJzc-1Iz6id0oVj4ay8lGmWg5JX5Z8nBs52_7rfDIBtyAsyIQ2dF2tW4e72cypvskw_TwdbsqelY5ZezzTmKx8Ra4-o9Pu5_Vxe1Hhlkx1K_tfbGH9ZUsbFy90b0Vcw5w6EKPEkM9mZTUoj8TMeHTHaiOREK4llIJ6hokXbEG8BsKJuMuYrzx98e23JkaWaWQY27LUGK5sx-lsX_n8DO2kEASRw", "e": "AQAB", "use": "sig", "alg": "RS256"}], "audiences": ["ee2811b9-10b8","https://api.example.com"]}}'; +user() current_user() +mysql_oidc_user@localhost mysql_oidc_user@% +SET GLOBAL auth_openid_connect_configuration = 'FILE:///Users/michal.jankowski/dev/jankowsk-percona-server_8.4/percona-server/mysql-test/std_data/oidc/dummy_oidc_conf.json'; +user() current_user() +mysql_oidc_user@localhost mysql_oidc_user@% + +## INVALID CASES +CALL mtr.add_suppression("Plugin auth_openid_connect reported: 'invalid sysvar prefix"); +CALL mtr.add_suppression("Plugin auth_openid_connect reported: 'invalid value for system variable'"); +CALL mtr.add_suppression("Plugin auth_openid_connect reported: 'cannot open config file:"); +SET GLOBAL auth_openid_connect_configuration = 'INVD://'; +ERROR 42000: Variable 'auth_openid_connect_configuration' can't be set to the value of 'INVD://' +SET GLOBAL auth_openid_connect_configuration = 'JSON://{"invalid"[}'; +ERROR 42000: Variable 'auth_openid_connect_configuration' can't be set to the value of 'JSON://{"invalid"[}' +SET GLOBAL auth_openid_connect_configuration = 'FILE://nonexistent_path/nonexistent_file.json'; +ERROR 42000: Variable 'auth_openid_connect_configuration' can't be set to the value of 'FILE://nonexistent_path/nonexistent_file.json' +SET GLOBAL auth_openid_connect_configuration = 'JSON://{}'; + +### Client side validations (FR.5) +SET GLOBAL auth_openid_connect_configuration = 'FILE:///Users/michal.jankowski/dev/jankowsk-percona-server_8.4/percona-server/mysql-test/std_data/oidc/dummy_oidc_conf.json'; + +### CREATE USER with incorrect AS clause +CREATE USER invalid_user IDENTIFIED WITH 'auth_openid_connect' AS '{"identity_provider" : "oidc-idp"}'; +DROP USER invalid_user; +CREATE USER invalid_user IDENTIFIED WITH 'auth_openid_connect' AS '{"user" : "oidc-user"}'; +DROP USER invalid_user; +CREATE USER invalid_user IDENTIFIED WITH 'auth_openid_connect'; +DROP USER invalid_user; + +### Verifying token (FR.7, FR.8, FR.9) +CREATE USER invalid_user IDENTIFIED WITH 'auth_openid_connect' AS '{"user" : "oidc-user", "identity_provider" : "nonexistent-idp"}'; +DROP USER invalid_user; +SET GLOBAL auth_openid_connect_configuration = 'JSON://{"oidc-idp": {"issuer-name": "https://idp-test.com/realms/dummy", "keys": [{"kid": "rsa-key-1", "kty": "RSA", "n": "ptR4YxjdrF2RrYiY9XYH3KcXKzlS6b2foGAeHN9dViAs5yKStKAucUf81kszq6LkzkOOPX5SbToLBRv5wwOtrBLKVkqhw4i5kS0b-7jpR9g7lER3qfDVYfe0nneaJzc-1Iz6id0oVj4ay8lGmWg5JX5Z8nBs52_7rfDIBtyAsyIQ2dF2tW4e72cypvskw_TwdbsqelY5ZezzTmKx8Ra4-o9Pu5_Vxe1Hhlkx1K_tfbGH9ZUsbFy90b0Vcw5w6EKPEkM9mZTUoj8TMeHTHaiOREK4llIJ6hokXbEG8BsKJuMuYrzx98e23JkaWaWQY27LUGK5sx-lsX_n8DO2kEASRw", "e": "AQAB", "use": "sig", "alg": "RS256"}], "audiences": ["ee2811b9-10b8","https://api.example.com"]}}'; +user() current_user() +mysql_oidc_user@localhost mysql_oidc_user@% +SET GLOBAL auth_openid_connect_configuration = 'FILE:///Users/michal.jankowski/dev/jankowsk-percona-server_8.4/percona-server/mysql-test/std_data/oidc/dummy_oidc_conf.json'; + +### CLEANUP +DROP USER 'mysql_oidc_user'@'%'; +UNINSTALL PLUGIN auth_openid_connect; diff --git a/mysql-test/suite/auth_openid_connect/r/group_role.result b/mysql-test/suite/auth_openid_connect/r/group_role.result new file mode 100644 index 000000000000..0d235ce70ae7 --- /dev/null +++ b/mysql-test/suite/auth_openid_connect/r/group_role.result @@ -0,0 +1,22 @@ +### INITIALIZE TESTS +INSTALL PLUGIN auth_openid_connect SONAME 'auth_openid_connect.so';; +SET GLOBAL auth_openid_connect_configuration = 'FILE:///Users/michal.jankowski/dev/jankowsk-percona-server_8.4/percona-server/mysql-test/std_data/oidc/dummy_oidc_conf.json'; +CREATE USER mysql_oidc_user IDENTIFIED WITH 'auth_openid_connect' AS '{"identity_provider" : "oidc-idp", "user" : "oidc-user"}'; +CREATE ROLE accounting; +CREATE ROLE sales; + +### Connect user without dynamically added role, fails to set the role +### Connect user with dynamically added role +# - acc from token maps to accounting role according to the config +# - hr is unmapped + +### CLEANUP +DROP USER mysql_oidc_user; +Warnings: +Warning 1366 Incorrect string value: '\x8F' for column 'FROM_HOST' at row 1 +Warning 1366 Incorrect string value: '\x8F\x8F\x8F\x8F\x8F\x8F...' for column 'FROM_USER' at row 1 +Warning 1366 Incorrect string value: '\x8F' for column 'TO_HOST' at row 1 +Warning 1366 Incorrect string value: '\x8F\x8F\x8F\x8F\x8F\x8F...' for column 'TO_USER' at row 1 +DROP USER accounting; +DROP USER sales; +UNINSTALL PLUGIN auth_openid_connect; diff --git a/mysql-test/suite/auth_openid_connect/r/idp.result b/mysql-test/suite/auth_openid_connect/r/idp.result new file mode 100644 index 000000000000..18138cbb6ee3 --- /dev/null +++ b/mysql-test/suite/auth_openid_connect/r/idp.result @@ -0,0 +1,49 @@ +### INITIALIZE TESTS +CALL mtr.add_suppression("Plugin auth_openid_connect reported: 'JWKS configuration is insecure"); +CALL mtr.add_suppression("Plugin auth_openid_connect reported: 'IDP not found"); +CALL mtr.add_suppression("Plugin auth_openid_connect reported: 'JWKS: HTTP GET from https://dummy-host"); +CALL mtr.add_suppression("Plugin auth_openid_connect reported: 'configuration of unaccessible-idp"); +INSTALL PLUGIN auth_openid_connect SONAME 'auth_openid_connect.so'; +SET GLOBAL auth_openid_connect_configuration = 'FILE:///Users/michal.jankowski/dev/jankowsk-percona-server_8.4/percona-server/mysql-test/std_data/oidc/keycloak_oidc_conf.json'; +CREATE USER mysql_oidc_user IDENTIFIED WITH 'auth_openid_connect' AS '{"identity_provider" : "my-keycloak", "user" : "e3039939-719c-4ba7-99d9-d9efecf5caeb"}';; +CREATE USER mysql_other_user IDENTIFIED WITH 'auth_openid_connect' AS '{"identity_provider" : "my-keycloak", "user" : "other-user-id"}'; + +## Login tests +user() current_user() +mysql_oidc_user@localhost mysql_oidc_user@% + +## role mapping tests +CREATE ROLE accounting; +CREATE ROLE marketing; + +## Tests of update_jwks() UDF +CREATE FUNCTION update_jwks RETURNS INTEGER SONAME 'auth_openid_connect.so'; +SELECT update_jwks(); +update_jwks() +1 +SELECT update_jwks('my-keycloak'); +update_jwks('my-keycloak') +1 +SELECT update_jwks('unaccessible-idp'); +update_jwks('unaccessible-idp') +0 +SELECT update_jwks('dummy'); +update_jwks('dummy') +NULL +SELECT update_jwks('arg1', 'arg2'); +ERROR HY000: Can't initialize function 'update_jwks'; function requires 0 or 1 argument +SELECT update_jwks(123); +ERROR HY000: Can't initialize function 'update_jwks'; first argument of the function must be string + +### CLEANUP +DROP USER mysql_oidc_user; +Warnings: +Warning 1366 Incorrect string value: '\x8F' for column 'FROM_HOST' at row 1 +Warning 1366 Incorrect string value: '\x8F\x8F\x8F\x8F\x8F\x8F...' for column 'FROM_USER' at row 1 +Warning 1366 Incorrect string value: '\x8F' for column 'TO_HOST' at row 1 +Warning 1366 Incorrect string value: '\x8F\x8F\x8F\x8F\x8F\x8F...' for column 'TO_USER' at row 1 +DROP USER mysql_other_user; +DROP ROLE accounting; +DROP ROLE marketing; +DROP FUNCTION update_jwks; +UNINSTALL PLUGIN auth_openid_connect; diff --git a/mysql-test/suite/auth_openid_connect/r/proxy.result b/mysql-test/suite/auth_openid_connect/r/proxy.result new file mode 100644 index 000000000000..bbd3dc3f1140 --- /dev/null +++ b/mysql-test/suite/auth_openid_connect/r/proxy.result @@ -0,0 +1,62 @@ +### INITIALIZE TESTS +CALL mtr.add_suppression("Plugin auth_openid_connect reported: 'JWKS configuration is insecure"); +CALL mtr.add_suppression("Plugin auth_openid_connect reported: 'JWKS: HTTP GET from https://dummy-host"); +CALL mtr.add_suppression("Plugin auth_openid_connect reported: 'configuration of unaccessible-idp"); +INSTALL PLUGIN auth_openid_connect SONAME 'auth_openid_connect.so'; +SET GLOBAL auth_openid_connect_configuration = 'FILE:///Users/michal.jankowski/dev/jankowsk-percona-server_8.4/percona-server/mysql-test/std_data/oidc/keycloak_oidc_conf.json'; +INSTALL PLUGIN mysql_no_login SONAME 'mysql_no_login.so'; +CREATE USER '/accounting' IDENTIFIED WITH 'mysql_no_login'; +CREATE USER '/marketing' IDENTIFIED WITH 'mysql_no_login'; + +## First group proxying +CREATE USER ''@'' IDENTIFIED WITH 'auth_openid_connect' AS '{\"identity_provider\" : \"my-keycloak\"}'; +GRANT PROXY ON '/accounting' TO ''@''; +GRANT PROXY ON '/marketing' TO ''@''; + +IDP kk_proxy_user user is member of /marketing group: +expecting USER is mysql_oidc_user, CURRENT_USER is /marketing +user() current_user() +mysql_oidc_user@localhost /marketing@% + +IDP kkuser user is member of /accounting group: +expecting USER is mysql_oidc_user, CURRENT_USER is /accounting +user() current_user() +mysql_oidc_user@localhost /accounting@% + +IDP kkuser-no-groups user is not member of any group: +expecting access denied +DROP USER ''@''; + +## Named group proxying +CREATE USER accounting IDENTIFIED WITH 'auth_openid_connect' AS '{\"identity_provider\" : \"my-keycloak\", \"group"\ : \"/accounting\" }'; +GRANT PROXY ON '/accounting' TO accounting; +CREATE USER marketing IDENTIFIED WITH 'auth_openid_connect' AS '{\"identity_provider\" : \"my-keycloak\", \"group"\ : \"/marketing\" }'; +GRANT PROXY ON '/marketing' TO marketing; +GRANT PROXY ON '/accounting' TO accounting; +GRANT PROXY ON '/marketing' TO marketing; + +IDP kk_proxy_user user is member of /marketing group: +expecting USER is marketing, CURRENT_USER is /marketing +user() current_user() +marketing@localhost /marketing@% + +IDP kkuser user is member of /accounting group: +expecting USER is accounting, CURRENT_USER is /accounting +user() current_user() +accounting@localhost /accounting@% + +IDP kkuser user is not member of /marketing group: +expecting access denied +DROP USER accounting; +Warnings: +Warning 1366 Incorrect string value: '\x8F' for column 'TO_HOST' at row 1 +Warning 1366 Incorrect string value: '\x8F\x8F\x8F\x8F\x8F\x8F...' for column 'TO_USER' at row 1 +Warning 1366 Incorrect string value: '\x8F' for column 'FROM_HOST' at row 1 +Warning 1366 Incorrect string value: '\x8F\x8F\x8F\x8F\x8F\x8F...' for column 'FROM_USER' at row 1 +DROP USER marketing; + +### CLEANUP +DROP USER '/accounting'; +DROP USER '/marketing'; +UNINSTALL PLUGIN auth_openid_connect; +UNINSTALL PLUGIN mysql_no_login; diff --git a/mysql-test/suite/auth_openid_connect/t/auth-master.opt b/mysql-test/suite/auth_openid_connect/t/auth-master.opt new file mode 100755 index 000000000000..3c7dc63a1570 --- /dev/null +++ b/mysql-test/suite/auth_openid_connect/t/auth-master.opt @@ -0,0 +1 @@ +$AUTH_OIDC_OPT diff --git a/mysql-test/suite/auth_openid_connect/t/auth.test b/mysql-test/suite/auth_openid_connect/t/auth.test new file mode 100644 index 000000000000..5a3f51a06941 --- /dev/null +++ b/mysql-test/suite/auth_openid_connect/t/auth.test @@ -0,0 +1,174 @@ + +###################### INIT ####################### +--echo ### INITIALIZE TESTS +--let $CONFIG_FILE = FILE://$MYSQL_TEST_DIR/std_data/oidc/dummy_oidc_conf.json +--let $CONFIG_JSON = JSON://{\"oidc-idp\": {\"issuer-name\": \"https://idp-test.com/realms/dummy\", \"keys\": [{\"kid\": \"rsa-key-1\", \"kty\": \"RSA\", \"n\": \"ptR4YxjdrF2RrYiY9XYH3KcXKzlS6b2foGAeHN9dViAs5yKStKAucUf81kszq6LkzkOOPX5SbToLBRv5wwOtrBLKVkqhw4i5kS0b-7jpR9g7lER3qfDVYfe0nneaJzc-1Iz6id0oVj4ay8lGmWg5JX5Z8nBs52_7rfDIBtyAsyIQ2dF2tW4e72cypvskw_TwdbsqelY5ZezzTmKx8Ra4-o9Pu5_Vxe1Hhlkx1K_tfbGH9ZUsbFy90b0Vcw5w6EKPEkM9mZTUoj8TMeHTHaiOREK4llIJ6hokXbEG8BsKJuMuYrzx98e23JkaWaWQY27LUGK5sx-lsX_n8DO2kEASRw\", \"e\": \"AQAB\", \"use\": \"sig\", \"alg\": \"RS256\"}], \"audiences\": [\"ee2811b9-10b8\",\"https://api.example.com\"]}} +--let $TOKEN_FILE = $MYSQLTEST_VARDIR/tmp/id_token.txt +--let $COMMON_TOKEN_ARGS = --key $MYSQL_TEST_DIR/std_data/oidc/idp_private.pem --sub oidc-user --iss https://idp-test.com/realms/dummy --aud ee2811b9-10b8 --out $TOKEN_FILE +--let $MYSQL_OIDC_USER = --authentication-openid-connect-client-id-token-file=$TOKEN_FILE --plugin-dir=$AUTH_OIDC_DIR --user=mysql_oidc_user -e "SELECT user(), current_user()" +--let $MYSQL_INVALID_USER = --authentication-openid-connect-client-id-token-file=$TOKEN_FILE --plugin-dir=$AUTH_OIDC_DIR --user=invalid_user -e "SELECT user(), current_user()" + +CALL mtr.add_suppression("Plugin auth_openid_connect reported: 'auth_openid_connect_configuration not set"); +--replace_regex /\.dll/.so/ +--eval INSTALL PLUGIN auth_openid_connect SONAME '$AUTH_OIDC'; +CREATE USER mysql_oidc_user IDENTIFIED WITH 'auth_openid_connect' AS '{"identity_provider" : "oidc-idp", "user" : "oidc-user"}'; + + +###################### TESTS ####################### + +--echo +--echo ### Validate system variable checks for auth_openid_connect_configuration + +--exec $CREATE_ID_TOKEN $COMMON_TOKEN_ARGS + +--echo +--echo ## VALID CASES + +# valid JSON config (FR.1, FR.2) +# note, that the above JSON config contains only one key, so omitting "kid" is allowed -we test this feature by the way +--eval SET GLOBAL auth_openid_connect_configuration = '$CONFIG_JSON' +--exec $MYSQL $MYSQL_OIDC_USER + +# valid FILE config (FR.1, FR.3) +--exec $CREATE_ID_TOKEN $COMMON_TOKEN_ARGS --kid rsa-key-1 +--eval SET GLOBAL auth_openid_connect_configuration = '$CONFIG_FILE' +--exec $MYSQL $MYSQL_OIDC_USER + +--echo +--echo ## INVALID CASES + +CALL mtr.add_suppression("Plugin auth_openid_connect reported: 'invalid sysvar prefix"); +CALL mtr.add_suppression("Plugin auth_openid_connect reported: 'invalid value for system variable'"); +CALL mtr.add_suppression("Plugin auth_openid_connect reported: 'cannot open config file:"); + +# invalid prefix, expect error +--error 1231 +SET GLOBAL auth_openid_connect_configuration = 'INVD://'; + +# invalid (non-parsable) JSON, expect error +--error 1231 +SET GLOBAL auth_openid_connect_configuration = 'JSON://{"invalid"[}'; + +# invalid (incorrect path) FILE, expect error +--error 1231 +SET GLOBAL auth_openid_connect_configuration = 'FILE://nonexistent_path/nonexistent_file.json'; + +# valid JSON, but missing the configuration inside, expect error on connection +SET GLOBAL auth_openid_connect_configuration = 'JSON://{}'; +--error 1 +--exec $MYSQL $MYSQL_OIDC_USER + +##################################### +--echo +--echo ### Client side validations (FR.5) +--eval SET GLOBAL auth_openid_connect_configuration = '$CONFIG_FILE' + +# mising --authentication-openid-connect-client-id-token-file parameter +--error 1 +--exec $MYSQL --plugin-dir=$AUTH_OIDC_DIR --user=mysql_oidc_user -e "SELECT user(), current_user()" + +# missing token file +--remove_file $TOKEN_FILE +--error 1 +--exec $MYSQL $MYSQL_OIDC_USER + +##################################### +--echo +--echo ### CREATE USER with incorrect AS clause + +# missing user name +# expect the user is created, but cannot authenticate (FR.4.a) +CREATE USER invalid_user IDENTIFIED WITH 'auth_openid_connect' AS '{"identity_provider" : "oidc-idp"}'; +--exec $CREATE_ID_TOKEN $COMMON_TOKEN_ARGS --kid rsa-key-1 +--error 1 +--exec $MYSQL $MYSQL_INVALID_USER +DROP USER invalid_user; + +# missing IDP +# expect the user is created, but cannot authenticate (FR.4.a) +CREATE USER invalid_user IDENTIFIED WITH 'auth_openid_connect' AS '{"user" : "oidc-user"}'; +--exec $CREATE_ID_TOKEN $COMMON_TOKEN_ARGS --kid rsa-key-1 +--error 1 +--exec $MYSQL $MYSQL_INVALID_USER +DROP USER invalid_user; + +# missing whole clause +# expect the user is created, but cannot authenticate (FR.4.a) +CREATE USER invalid_user IDENTIFIED WITH 'auth_openid_connect'; +--exec $CREATE_ID_TOKEN $COMMON_TOKEN_ARGS --kid rsa-key-1 +--error 1 +--exec $MYSQL $MYSQL_INVALID_USER +DROP USER invalid_user; + + +##################################### +--echo +--echo ### Verifying token (FR.7, FR.8, FR.9) + +# invalid content of token file -failure (FR.8.b) +--remove_file $TOKEN_FILE +write_file $TOKEN_FILE; +some dummy content +EOF +--error 1 +--exec $MYSQL $MYSQL_OIDC_USER + +# token expired: TTL < 0 -failure (FR.8.c) +--exec $CREATE_ID_TOKEN $COMMON_TOKEN_ARGS --kid rsa-key-1 --ttl -10 +--error 1 +--exec $MYSQL $MYSQL_OIDC_USER + +# sub doesn't match -failure (FR.8.d) +--exec $CREATE_ID_TOKEN --key $MYSQL_TEST_DIR/std_data/oidc/idp_private.pem --sub oidc-user-invalid --iss https://idp-test.com/realms/dummy --aud ee2811b9-10b8 --kid rsa-key-1 --out $TOKEN_FILE +--error 1 +--exec $MYSQL $MYSQL_OIDC_USER + +# iss doesn't match -failure (FR.8.e) +--exec $CREATE_ID_TOKEN --key $MYSQL_TEST_DIR/std_data/oidc/idp_private.pem --sub oidc-user --iss https://invalid.com/realms/dummy --aud ee2811b9-10b8 --kid rsa-key-1 --out $TOKEN_FILE +--error 1 +--exec $MYSQL $MYSQL_OIDC_USER + +# missing IDP configuration -failure (FR.8.f) +CREATE USER invalid_user IDENTIFIED WITH 'auth_openid_connect' AS '{"user" : "oidc-user", "identity_provider" : "nonexistent-idp"}'; +--exec $CREATE_ID_TOKEN $COMMON_TOKEN_ARGS --kid rsa-key-1 +--error 1 +--exec $MYSQL $MYSQL_INVALID_USER +DROP USER invalid_user; + +# non-existing kid claim, multiple key in IDP -failure (FR.8.g) +--exec $CREATE_ID_TOKEN --key $MYSQL_TEST_DIR/std_data/oidc/idp_private.pem --sub oidc-user --iss https://idp-test.com/realms/dummy --aud ee2811b9-10b8 --kid nonexistent --out $TOKEN_FILE +--error 1 +--exec $MYSQL $MYSQL_OIDC_USER + +# no kid claim, multiple key in IDP -failure (FR.8.h) +--exec $CREATE_ID_TOKEN $COMMON_TOKEN_ARGS +--error 1 +--exec $MYSQL $MYSQL_OIDC_USER + +# no kid claim, single key in IDP -success (FR.8.h, FR.9) +--eval SET GLOBAL auth_openid_connect_configuration = '$CONFIG_JSON' +--exec $CREATE_ID_TOKEN $COMMON_TOKEN_ARGS +--exec $MYSQL $MYSQL_OIDC_USER + +# single key in IDP, alg not match -failure (FR.8.i) +--exec $CREATE_ID_TOKEN --key $MYSQL_TEST_DIR/std_data/oidc/idp_private.pem --sub oidc-user --iss https://idp-test.com/realms/dummy --aud ee2811b9-10b8 --alg HS256 --out $TOKEN_FILE +--error 1 +--exec $MYSQL $MYSQL_OIDC_USER + +# multiple keys in IDP, alg not match -failure (FR.8.i) +--eval SET GLOBAL auth_openid_connect_configuration = '$CONFIG_FILE' +--exec $CREATE_ID_TOKEN --key $MYSQL_TEST_DIR/std_data/oidc/idp_private.pem --sub oidc-user --iss https://idp-test.com/realms/dummy --aud ee2811b9-10b8 --alg HS512 --out $TOKEN_FILE +--error 1 +--exec $MYSQL $MYSQL_OIDC_USER + +# aud doesn't match -failure (FR.8.j) +--exec $CREATE_ID_TOKEN --key $MYSQL_TEST_DIR/std_data/oidc/idp_private.pem --sub oidc-user --iss https://idp-test.com/realms/dummy --aud invalid-aud --kid rsa-key-1 --out $TOKEN_FILE +--error 1 +--exec $MYSQL $MYSQL_OIDC_USER + +###################### CLEANUP ####################### +--echo +--echo ### CLEANUP +DROP USER 'mysql_oidc_user'@'%'; +UNINSTALL PLUGIN auth_openid_connect; +--remove_file $TOKEN_FILE diff --git a/mysql-test/suite/auth_openid_connect/t/group_role-master.opt b/mysql-test/suite/auth_openid_connect/t/group_role-master.opt new file mode 100755 index 000000000000..3c7dc63a1570 --- /dev/null +++ b/mysql-test/suite/auth_openid_connect/t/group_role-master.opt @@ -0,0 +1 @@ +$AUTH_OIDC_OPT diff --git a/mysql-test/suite/auth_openid_connect/t/group_role.test b/mysql-test/suite/auth_openid_connect/t/group_role.test new file mode 100644 index 000000000000..d06affa68e2f --- /dev/null +++ b/mysql-test/suite/auth_openid_connect/t/group_role.test @@ -0,0 +1,47 @@ + +###################### INIT ####################### +--echo ### INITIALIZE TESTS +--let $CONFIG_FILE = FILE://$MYSQL_TEST_DIR/std_data/oidc/dummy_oidc_conf.json +--let $CONFIG_JSON = JSON://{\"oidc-idp\": {\"issuer-name\": \"https://idp-test.com/realms/dummy\", \"keys\": [{\"kid\": \"rsa-key-1\", \"kty\": \"RSA\", \"n\": \"ptR4YxjdrF2RrYiY9XYH3KcXKzlS6b2foGAeHN9dViAs5yKStKAucUf81kszq6LkzkOOPX5SbToLBRv5wwOtrBLKVkqhw4i5kS0b-7jpR9g7lER3qfDVYfe0nneaJzc-1Iz6id0oVj4ay8lGmWg5JX5Z8nBs52_7rfDIBtyAsyIQ2dF2tW4e72cypvskw_TwdbsqelY5ZezzTmKx8Ra4-o9Pu5_Vxe1Hhlkx1K_tfbGH9ZUsbFy90b0Vcw5w6EKPEkM9mZTUoj8TMeHTHaiOREK4llIJ6hokXbEG8BsKJuMuYrzx98e23JkaWaWQY27LUGK5sx-lsX_n8DO2kEASRw\", \"e\": \"AQAB\", \"use\": \"sig\", \"alg\": \"RS256\"}], \"audiences\": [\"ee2811b9-10b8\",\"https://api.example.com\"]}} +--let $TOKEN_FILE = $MYSQLTEST_VARDIR/tmp/id_token.txt +--let $COMMON_TOKEN_ARGS = --key $MYSQL_TEST_DIR/std_data/oidc/idp_private.pem --sub oidc-user --iss https://idp-test.com/realms/dummy --aud ee2811b9-10b8 --out $TOKEN_FILE --kid rsa-key-1 +--let $MYSQL_OIDC_USER = --authentication-openid-connect-client-id-token-file=$TOKEN_FILE --plugin-dir=$AUTH_OIDC_DIR --user=mysql_oidc_user + +--replace_regex /\.dll/.so/ +--eval INSTALL PLUGIN auth_openid_connect SONAME '$AUTH_OIDC'; + +--eval SET GLOBAL auth_openid_connect_configuration = '$CONFIG_FILE' +CREATE USER mysql_oidc_user IDENTIFIED WITH 'auth_openid_connect' AS '{"identity_provider" : "oidc-idp", "user" : "oidc-user"}'; +CREATE ROLE accounting; +CREATE ROLE sales; + +###################### TESTS ####################### + +--echo +--echo ### Connect user without dynamically added role, fails to set the role +--exec $CREATE_ID_TOKEN $COMMON_TOKEN_ARGS +--error 1 +--exec $MYSQL $MYSQL_OIDC_USER -e "SET ROLE accounting" + +--echo ### Connect user with dynamically added role +--echo # - acc from token maps to accounting role according to the config +--echo # - hr is unmapped +--exec $CREATE_ID_TOKEN $COMMON_TOKEN_ARGS --groups "[\"acc\", \"hr\"]" +# success +--exec $MYSQL $MYSQL_OIDC_USER -e "SET ROLE accounting" +# failure +--error 1 +--exec $MYSQL $MYSQL_OIDC_USER -e "SET ROLE hr" +# failure +--error 1 +--exec $MYSQL $MYSQL_OIDC_USER -e "SET ROLE sales" + + +###################### CLEANUP ####################### +--echo +--echo ### CLEANUP +DROP USER mysql_oidc_user; +DROP USER accounting; +DROP USER sales; +UNINSTALL PLUGIN auth_openid_connect; +--remove_file $TOKEN_FILE diff --git a/mysql-test/suite/auth_openid_connect/t/idp-master.opt b/mysql-test/suite/auth_openid_connect/t/idp-master.opt new file mode 100755 index 000000000000..3c7dc63a1570 --- /dev/null +++ b/mysql-test/suite/auth_openid_connect/t/idp-master.opt @@ -0,0 +1 @@ +$AUTH_OIDC_OPT diff --git a/mysql-test/suite/auth_openid_connect/t/idp.test b/mysql-test/suite/auth_openid_connect/t/idp.test new file mode 100644 index 000000000000..77e80988c3e3 --- /dev/null +++ b/mysql-test/suite/auth_openid_connect/t/idp.test @@ -0,0 +1,103 @@ + +###################### INIT ####################### +--echo ### INITIALIZE TESTS +--let $IDP_URL = https://keycloak.int.percona.com/realms/master/protocol/openid-connect +# Note, that the token's subject is the IDP user ID +--let $IDP_USER_ID = e3039939-719c-4ba7-99d9-d9efecf5caeb + +### Check if IdP is available +--exec sh -c "curl -fsS $IDP_URL/certs >/dev/null 2>&1 && echo ok > $MYSQLTEST_VARDIR/tmp/idp_flag || true"; +if (`SELECT IF(LOAD_FILE('$MYSQLTEST_VARDIR/tmp/idp_flag') IS NULL, 1, 0)`) +{ + --skip IdP not available, skipping test +} +--remove_file $MYSQLTEST_VARDIR/tmp/idp_flag + +--let $CONFIG_FILE = FILE://$MYSQL_TEST_DIR/std_data/oidc/keycloak_oidc_conf.json +--let $TOKEN_FILE = $MYSQLTEST_VARDIR/tmp/id_token.txt +--let $MYSQL_OIDC_USER = --authentication-openid-connect-client-id-token-file=$TOKEN_FILE --plugin-dir=$AUTH_OIDC_DIR --user=mysql_oidc_user +--let $MYSQL_OTHER_USER = --authentication-openid-connect-client-id-token-file=$TOKEN_FILE --plugin-dir=$AUTH_OIDC_DIR --user=mysql_other_user + +let $CREATE_ID_TOKEN = curl --fail -sS -X POST "$IDP_URL/token" + -H "Content-Type: application/x-www-form-urlencoded" + -d "grant_type=password" + -d "client_id=myclient" + -d "username=kkuser" + --data-urlencode "password=alamakota1" + -d "scope=openid" +| jq -er '.access_token'; + +## suppress errors and warnings caused by tests +CALL mtr.add_suppression("Plugin auth_openid_connect reported: 'JWKS configuration is insecure"); +CALL mtr.add_suppression("Plugin auth_openid_connect reported: 'IDP not found"); +CALL mtr.add_suppression("Plugin auth_openid_connect reported: 'JWKS: HTTP GET from https://dummy-host"); +CALL mtr.add_suppression("Plugin auth_openid_connect reported: 'configuration of unaccessible-idp"); + +--replace_regex /\.dll/.so/ +--eval INSTALL PLUGIN auth_openid_connect SONAME '$AUTH_OIDC' +--eval SET GLOBAL auth_openid_connect_configuration = '$CONFIG_FILE' + +--EVAL CREATE USER mysql_oidc_user IDENTIFIED WITH 'auth_openid_connect' AS '{"identity_provider" : "my-keycloak", "user" : "$IDP_USER_ID"}'; +CREATE USER mysql_other_user IDENTIFIED WITH 'auth_openid_connect' AS '{"identity_provider" : "my-keycloak", "user" : "other-user-id"}'; + +###################### TESTS ####################### +--echo +--echo ## Login tests + +exec $CREATE_ID_TOKEN > $TOKEN_FILE; + +## login successful +--exec $MYSQL $MYSQL_OIDC_USER -e "SELECT user(), current_user()" + +# cannot authenticate as someone else +--error 1 +--exec $MYSQL $MYSQL_OTHER_USER -e "SELECT user(), current_user()" + +--echo +--echo ## role mapping tests +CREATE ROLE accounting; +CREATE ROLE marketing; +## success +--exec $MYSQL $MYSQL_OIDC_USER -e "SET ROLE accounting" + +## failure, the user is not member of group mapped to the role +--error 1 +--exec $MYSQL $MYSQL_OIDC_USER -e "SET ROLE marketing" + +--echo +--echo ## Tests of update_jwks() UDF + +--replace_regex /\.dll/.so/ +--eval CREATE FUNCTION update_jwks RETURNS INTEGER SONAME '$AUTH_OIDC' + +## no argument, update all, return 1 as there is 1 accessible IDP +SELECT update_jwks(); + +## one argument -configured and accessible IDP, return 1 +SELECT update_jwks('my-keycloak'); + +## one argument -configured but accessible IDP, return 0 +SELECT update_jwks('unaccessible-idp'); + +## one argument -non configured IDP, return NULL +SELECT update_jwks('dummy'); + +## two arguments not allowed, expect error +--error 1123 +SELECT update_jwks('arg1', 'arg2'); + +## invalid type of argument, expect error +--error 1123 +SELECT update_jwks(123); + +###################### CLEANUP ####################### +--echo +--echo ### CLEANUP +DROP USER mysql_oidc_user; +DROP USER mysql_other_user; +DROP ROLE accounting; +DROP ROLE marketing; +--remove_file $TOKEN_FILE +DROP FUNCTION update_jwks; +UNINSTALL PLUGIN auth_openid_connect; + diff --git a/mysql-test/suite/auth_openid_connect/t/proxy-master.opt b/mysql-test/suite/auth_openid_connect/t/proxy-master.opt new file mode 100755 index 000000000000..3c7dc63a1570 --- /dev/null +++ b/mysql-test/suite/auth_openid_connect/t/proxy-master.opt @@ -0,0 +1 @@ +$AUTH_OIDC_OPT diff --git a/mysql-test/suite/auth_openid_connect/t/proxy.test b/mysql-test/suite/auth_openid_connect/t/proxy.test new file mode 100644 index 000000000000..785b93dcac47 --- /dev/null +++ b/mysql-test/suite/auth_openid_connect/t/proxy.test @@ -0,0 +1,144 @@ + +###################### INIT ####################### +--source include/have_mysql_no_login_plugin.inc +--echo ### INITIALIZE TESTS +--let $IDP_URL = https://keycloak.int.percona.com/realms/master/protocol/openid-connect + +### Check if IdP is available +--exec sh -c "curl -fsS $IDP_URL/certs >/dev/null 2>&1 && echo ok > $MYSQLTEST_VARDIR/tmp/idp_flag || true"; +if (`SELECT IF(LOAD_FILE('$MYSQLTEST_VARDIR/tmp/idp_flag') IS NULL, 1, 0)`) +{ + --skip IdP not available, skipping test +} +--remove_file $MYSQLTEST_VARDIR/tmp/idp_flag + +--let $CONFIG_FILE = FILE://$MYSQL_TEST_DIR/std_data/oidc/keycloak_oidc_conf.json +--let $TOKEN_FILE = $MYSQLTEST_VARDIR/tmp/id_token.txt +--let $MYSQL_OIDC_USER = --authentication-openid-connect-client-id-token-file=$TOKEN_FILE --plugin-dir=$AUTH_OIDC_DIR --user=mysql_oidc_user +--let $MYSQL_MARKETING_USER = --authentication-openid-connect-client-id-token-file=$TOKEN_FILE --plugin-dir=$AUTH_OIDC_DIR --user=marketing +--let $MYSQL_ACCOUNTING_USER = --authentication-openid-connect-client-id-token-file=$TOKEN_FILE --plugin-dir=$AUTH_OIDC_DIR --user=accounting + +let $CREATE_ID_TOKEN_1 = curl --fail -sS -X POST "$IDP_URL/token" + -H "Content-Type: application/x-www-form-urlencoded" + -d "grant_type=password" + -d "client_id=myclient" + -d "username=kk_proxy_user" + --data-urlencode "password=kotmaale" + -d "scope=openid" +| jq -er '.access_token'; + +let $CREATE_ID_TOKEN_2 = curl --fail -sS -X POST "$IDP_URL/token" + -H "Content-Type: application/x-www-form-urlencoded" + -d "grant_type=password" + -d "client_id=myclient" + -d "username=kkuser" + --data-urlencode "password=alamakota1" + -d "scope=openid" +| jq -er '.access_token'; + +let $CREATE_ID_TOKEN_3 = curl --fail -sS -X POST "$IDP_URL/token" + -H "Content-Type: application/x-www-form-urlencoded" + -d "grant_type=password" + -d "client_id=myclient" + -d "username=kk_no_group_user" + --data-urlencode "password=QWSdm0qtuDctHMHvZXq235xApO5EiSr" + -d "scope=openid" +| jq -er '.access_token'; + +## suppress errors and warnings caused by tests +CALL mtr.add_suppression("Plugin auth_openid_connect reported: 'JWKS configuration is insecure"); +CALL mtr.add_suppression("Plugin auth_openid_connect reported: 'JWKS: HTTP GET from https://dummy-host"); +CALL mtr.add_suppression("Plugin auth_openid_connect reported: 'configuration of unaccessible-idp"); + +--replace_regex /\.dll/.so/ +--eval INSTALL PLUGIN auth_openid_connect SONAME '$AUTH_OIDC' +--eval SET GLOBAL auth_openid_connect_configuration = '$CONFIG_FILE' + +--replace_regex /\.dll/.so/ +eval INSTALL PLUGIN mysql_no_login SONAME '$MYSQL_NO_LOGIN'; + +### Create proxied accounts +CREATE USER '/accounting' IDENTIFIED WITH 'mysql_no_login'; +CREATE USER '/marketing' IDENTIFIED WITH 'mysql_no_login'; + +###################### TESTS ####################### +--echo +--echo ## First group proxying +## The user is authenticated using anonymous account, +## the proxied account is selected automatically based on the first group name + +CREATE USER ''@'' IDENTIFIED WITH 'auth_openid_connect' AS '{\"identity_provider\" : \"my-keycloak\"}'; +GRANT PROXY ON '/accounting' TO ''@''; +GRANT PROXY ON '/marketing' TO ''@''; + +exec $CREATE_ID_TOKEN_1 > $TOKEN_FILE; + +--echo +--echo IDP kk_proxy_user user is member of /marketing group: +--echo expecting USER is mysql_oidc_user, CURRENT_USER is /marketing +--exec $MYSQL $MYSQL_OIDC_USER -e "SELECT user(), current_user()" + +exec $CREATE_ID_TOKEN_2 > $TOKEN_FILE; + +--echo +--echo IDP kkuser user is member of /accounting group: +--echo expecting USER is mysql_oidc_user, CURRENT_USER is /accounting +--exec $MYSQL $MYSQL_OIDC_USER -e "SELECT user(), current_user()" + +exec $CREATE_ID_TOKEN_3 > $TOKEN_FILE; + +--echo +--echo IDP kkuser-no-groups user is not member of any group: +--echo expecting access denied +--error 1 +--exec $MYSQL $MYSQL_OIDC_USER -e "SELECT user(), current_user()" + +DROP USER ''@''; + +--echo +--echo ## Named group proxying +## The user is authenticated using shared account connected to a group, +## the proxied account is selected automatically based on the group name + +CREATE USER accounting IDENTIFIED WITH 'auth_openid_connect' AS '{\"identity_provider\" : \"my-keycloak\", \"group"\ : \"/accounting\" }'; +GRANT PROXY ON '/accounting' TO accounting; + +CREATE USER marketing IDENTIFIED WITH 'auth_openid_connect' AS '{\"identity_provider\" : \"my-keycloak\", \"group"\ : \"/marketing\" }'; +GRANT PROXY ON '/marketing' TO marketing; + +GRANT PROXY ON '/accounting' TO accounting; +GRANT PROXY ON '/marketing' TO marketing; + +exec $CREATE_ID_TOKEN_1 > $TOKEN_FILE; + +--echo +--echo IDP kk_proxy_user user is member of /marketing group: +--echo expecting USER is marketing, CURRENT_USER is /marketing +--exec $MYSQL $MYSQL_MARKETING_USER -e "SELECT user(), current_user()" + +exec $CREATE_ID_TOKEN_2 > $TOKEN_FILE; + +--echo +--echo IDP kkuser user is member of /accounting group: +--echo expecting USER is accounting, CURRENT_USER is /accounting +--exec $MYSQL $MYSQL_ACCOUNTING_USER -e "SELECT user(), current_user()" + +--echo +--echo IDP kkuser user is not member of /marketing group: +--echo expecting access denied +--error 1 +--exec $MYSQL $MYSQL_MARKETING_USER -e "SELECT user(), current_user()" + +DROP USER accounting; +DROP USER marketing; + +###################### CLEANUP ####################### +--echo +--echo ### CLEANUP +DROP USER '/accounting'; +DROP USER '/marketing'; + +--remove_file $TOKEN_FILE +UNINSTALL PLUGIN auth_openid_connect; +UNINSTALL PLUGIN mysql_no_login; + diff --git a/mysys/CMakeLists.txt b/mysys/CMakeLists.txt index af6e16bd5dd1..0e945e7c2fe3 100644 --- a/mysys/CMakeLists.txt +++ b/mysys/CMakeLists.txt @@ -30,6 +30,7 @@ ADD_CONVENIENCE_LIBRARY(mytime ${MY_TIME_SOURCES}) INCLUDE_DIRECTORIES(SYSTEM ${BOOST_PATCHES_DIR} ${BOOST_INCLUDE_DIR}) SET(MYSYS_SOURCES + base64_encode.cc array.cc buffered_error_log.cc charset.cc diff --git a/sql-common/oci/ssl.cc b/mysys/base64_encode.cc similarity index 99% rename from sql-common/oci/ssl.cc rename to mysys/base64_encode.cc index ba816d4f3800..831808e64c58 100644 --- a/sql-common/oci/ssl.cc +++ b/mysys/base64_encode.cc @@ -26,11 +26,11 @@ along with this program; if not, write to the Free Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA */ -#include "ssl.h" +#include "base64_encode.h" #include #include #include -#include "ssl_ptr.h" +#include "encode_ptr.h" #include #include diff --git a/plugin/auth_openid_connect/.clang-tidy b/plugin/auth_openid_connect/.clang-tidy new file mode 100644 index 000000000000..b481c288f37f --- /dev/null +++ b/plugin/auth_openid_connect/.clang-tidy @@ -0,0 +1,17 @@ +# (C) 2026 Percona LLC and/or its affiliates +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; version 2 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA + +Checks: '-misc-include-cleaner' +InheritParentConfig: true diff --git a/plugin/auth_openid_connect/CMakeLists.txt b/plugin/auth_openid_connect/CMakeLists.txt new file mode 100644 index 000000000000..c7a2d8a80fc4 --- /dev/null +++ b/plugin/auth_openid_connect/CMakeLists.txt @@ -0,0 +1,44 @@ +#Copyright (c) 2026 Percona LLC and/or its affiliates. All rights reserved. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, +# as published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +IF(WITH_AUTH_OPENID_CONNECT) + + SET(AUTH_OPENID_CONNECT_SOURCES + src/plugin_openid_connect.cc + src/config.cc + src/jwk.cc + src/jwks.cc + src/udf.cc + src/id_token.cc + ) + ### Configuration ### + ADD_DEFINITIONS(-DLOG_COMPONENT_TAG="auth_openid_connect") + SET(JWT_CPP_INCLUDE_DIR ${CMAKE_SOURCE_DIR}/extra/jwt-cpp/include) + MESSAGE(STATUS "JWT CPP include dir is " ${JWT_CPP_INCLUDE_DIR}) + + MYSQL_ADD_PLUGIN(auth_openid_connect + ${AUTH_OPENID_CONNECT_SOURCES} + LINK_LIBRARIES OpenSSL::SSL OpenSSL::Crypto curl + SYSTEM_INCLUDE_DIRECTORIES "${JWT_CPP_INCLUDE_DIR}" + MODULE_ONLY MODULE_OUTPUT_NAME "auth_openid_connect" + ) + + MYSQL_ADD_EXECUTABLE(create_id_token + tools/create_id_token.cc + SYSTEM_INCLUDE_DIRECTORIES "${JWT_CPP_INCLUDE_DIR}" + LINK_LIBRARIES OpenSSL::SSL OpenSSL::Crypto + COMPONENT Test) + +ENDIF(WITH_AUTH_OPENID_CONNECT) \ No newline at end of file diff --git a/plugin/auth_openid_connect/src/config.cc b/plugin/auth_openid_connect/src/config.cc new file mode 100644 index 000000000000..84d2ec919dfd --- /dev/null +++ b/plugin/auth_openid_connect/src/config.cc @@ -0,0 +1,461 @@ +/* +(C) 2026 Percona LLC and/or its affiliates + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; version 2 of the License. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA +*/ + +#include "config.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "id_token.h" +#include "jwk.h" +#include "jwks.h" +#include "mysql/components/services/bits/thd.h" + +char *Idp_configs::sysvar(nullptr); + +// Declaration to access the name of the SYS_VAR +struct SYS_VAR { + MYSQL_PLUGIN_VAR_HEADER; +}; + +/** + * @brief Retrieves a value from a picojson object by key. + * + * This template function attempts to extract a value of type T from the given + * picojson object using the specified key. If the key is not found or the + * value is not of the expected type, it handles the error based on the + * is_mandatory flag. + * + * @tparam T The expected type of the value to retrieve (e.g., std::string, + * int). + * @param obj The picojson object to search in. + * @param key The key to look for in the object. + * @param from A descriptive string indicating where the object comes from + * (used in error messages). + * @param is_mandatory If true, throws an exception if the key is missing or + * the type doesn't match; if false, returns a default + * value. + * @return A const reference to the retrieved value if found and of correct + * type, or a default value if is_mandatory is false and the key/type is + * invalid. + * @throws std::runtime_error If is_mandatory is true and the key is not found + * or the value is not of type T. + * + * @note The default value returned when is_mandatory is false is a static + * default-constructed instance of type T. + */ +template +static const T &json_get(const picojson::object &obj, const std::string &key, + const std::string &from, + const bool is_mandatory = true) { + const auto it = obj.find(key); + if (it == obj.end() || !it->second.is()) { + static const T def; + if (is_mandatory) + throw std::runtime_error("missing " + key + " in " + from); + return def; + } + return it->second.get(); +} + +int Idp_configs::check(MYSQL_THD thd [[maybe_unused]], + SYS_VAR *var [[maybe_unused]], void *save, + st_mysql_value *value) { + int value_len{0}; + const char *value_str{value->val_str(value, nullptr, &value_len)}; + if (value_str == nullptr || check(value_str) || value_len == 0) { + LogPluginErr(ERROR_LEVEL, ER_LOG_PRINTF_MSG, + "invalid value for system variable"); + return 1; + } + *static_cast(save) = value_str; + return 0; +} + +void Idp_configs::update(MYSQL_THD thd [[maybe_unused]], + SYS_VAR *var [[maybe_unused]], void *var_ptr, + const void *save) { + update(*static_cast(save), + static_cast(var_ptr)); +} + +long long Idp_configs::update_keys() noexcept { + long long no_updated_keys{0}; + try { + std::vector> configs; + create_tmp_configs(configs); + for (auto &val : configs | std::views::values) { + if (!val.load_keys()) ++no_updated_keys; + } + swap_idp_keys(configs); + } catch (std::exception &e) { + LogPluginErr(ERROR_LEVEL, ER_LOG_PRINTF_MSG, e.what()); + return -2; + } + return no_updated_keys; +} + +long long Idp_configs::update_keys(const char *idp_name) noexcept { + try { + // This is done in 3 steps to gain max performance with thread safety + // 1) get JWKS URL with shared lock + // 2) load new keys (takes longest time) without a lock + // 3) swaps the keys with unique lock + // Note 1: if the load fails, the keys container in tmp is empty and + // effectively the keys will be removed from IDP. Note 2: during 2 the IDP + // config may be removed, then 3 fails and the function returns an error. + // That is not effective, but it is an edge case. + const std::string jwks_url{get_safe_jwks_url(idp_name)}; + if (jwks_url.empty()) return 0; + Idp_config config("", jwks_url, "", {}, {}); + const long long result = config.load_keys() ? 0 : 1; + swap_idp_keys(idp_name, config); + return result; + } catch (std::exception &e) { + LogPluginErr(ERROR_LEVEL, ER_LOG_PRINTF_MSG, e.what()); + return -2; + } +} + +std::unique_ptr &Idp_configs::current(const char *sysvar_str) { + static std::unique_ptr current; + // create an empty configuration if + if (current == nullptr) current = std::make_unique(sysvar_str); + return current; +} + +std::shared_timed_mutex &Idp_configs::mutex() { + static std::shared_timed_mutex mutex; + return mutex; +} + +void Idp_configs::load(const std::string &config_json) { + picojson::value json_obj; + if (const std::string err = picojson::parse(json_obj, config_json); + !err.empty()) + throw std::runtime_error(err); + + if (!json_obj.is()) + throw std::runtime_error("incorrect configuration structure"); + + for (const picojson::object &obj = json_obj.get(); + const auto &[idp_name, idp_value] : obj) + load_idp(idp_value, idp_name); +} + +/* Ignore -Wdangling-reference for functions accessing picojson objects + * encapsulated in other picojson. This is safe because parent picojson objects + * are valid for all life of the functions and this way we avoid copying. + */ +#ifndef __clang__ +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdangling-reference" +#endif +void Idp_configs::load_idp(const picojson::value &idp_value, + const std::string &idp_name) noexcept { + // we catch all exceptions here, so if one IDP is misconfigured, + // configuration of other IDPs can be loaded + try { + if (!idp_value.is()) + throw std::runtime_error("incorrect IdP definition of " + idp_name); + const picojson::object &idp_object = idp_value.get(); + + const std::string &issuer_name{ + json_get(idp_object, "issuer-name", idp_name)}; + + std::unordered_set audiences{}; + const picojson::array &audience_array{ + json_get(idp_object, "audiences", idp_name, false)}; + for (const auto &audience : audience_array) { + audiences.insert(audience.get()); + } + + const std::string &group_claim{ + json_get(idp_object, "group-claim", idp_name, false)}; + + std::map roles; + + const picojson::array &roles_array{ + json_get(idp_object, "group-role", idp_name, false)}; + for (const auto &group_role : roles_array) { + if (!group_role.is()) + throw std::runtime_error("incorrect group role mapping in " + idp_name); + const auto &group_role_object{group_role.get()}; + const auto &group_role_pair{group_role_object.begin()}; + + if (group_role_pair == group_role_object.end() || + !group_role_pair->second.is()) + throw std::runtime_error("incorrect group role mapping in " + idp_name); + + roles.emplace(group_role_pair->first, + group_role_pair->second.get()); + } + + const std::string &jwks_url{ + json_get(idp_object, "jwks-url", idp_name, false)}; + + Idp_config &config{ + idp_configs + .emplace(idp_name, + Idp_config(issuer_name, jwks_url, group_claim, + std::move(audiences), std::move(roles))) + .first->second}; + + if (jwks_url.empty()) { + const picojson::array &key_array{ + json_get(idp_object, "keys", idp_name)}; + config.load_keys(key_array, idp_name); + } else if (config.load_keys()) { + const std::string message{ + "configuration of " + idp_name + + " successfully parsed, but failed to load keys"}; + LogPluginErr(WARNING_LEVEL, ER_LOG_PRINTF_MSG, message.c_str()); + return; + } + const std::string message{"configuration of " + idp_name + + " successfully parsed"}; + LogPluginErr(INFORMATION_LEVEL, ER_LOG_PRINTF_MSG, message.c_str()); + } catch (const std::exception &e) { + LogPluginErr(ERROR_LEVEL, ER_LOG_PRINTF_MSG, e.what()); + } catch (...) { + LogPluginErr(ERROR_LEVEL, ER_LOG_PRINTF_MSG, + "unknown error while parsing IDP configuration"); + } +} + +bool Idp_config::load_keys() noexcept { + try { + if (jwks.get_url().empty()) return true; + const std::string body = jwks.http_get(); + picojson::value root; + const std::string err = picojson::parse(root, body); + if (!err.empty()) { + throw std::runtime_error("JWKS: invalid JSON: " + err); + } + + if (!root.is()) { + throw std::runtime_error("JWKS: JSON root is not an object"); + } + const picojson::object &root_object = root.get(); + + const picojson::array &key_array{ + json_get(root_object, "keys", jwks.get_url())}; + + load_keys(key_array, jwks.get_url()); + } + // SECURITY: Remove the keys on any error to prevent accepting compromised + // keys. If loading fails partway through, it's better to have no keys than to + // risk accepting tokens signed with potentially compromised keys. + // This follows the principle of "fail secure" - better to deny access + // than to allow potentially unauthorized access. + catch (const std::exception &e) { + keys.clear(); + LogPluginErr(ERROR_LEVEL, ER_LOG_PRINTF_MSG, e.what()); + return true; + } catch (...) { + keys.clear(); + LogPluginErr(ERROR_LEVEL, ER_LOG_PRINTF_MSG, + "unknown error while loading keys from JWKS"); + return true; + } + return false; +} + +void Idp_config::load_keys(const picojson::array &key_array, + const std::string &from) { + // for reload case + keys.clear(); + + for (const auto &key_value : key_array) { + if (!key_value.is()) + throw std::runtime_error("incorrect keys definition of " + from); + + const picojson::object &key_object{key_value.get()}; + const std::string &kty{json_get(key_object, "kty", from)}; + const std::string &kid{json_get(key_object, "kid", from)}; + + std::string pem_key; + if (kty == "RSA") { + Rsa_jwk rsa_jwk(json_get(key_object, "n", from), + json_get(key_object, "e", from)); + pem_key = rsa_jwk.to_pem(); + } else if (kty == "EC") { + Ec_jwk ec_jwk(json_get(key_object, "crv", from), + json_get(key_object, "x", from), + json_get(key_object, "y", from)); + pem_key = ec_jwk.to_pem(); + } else + throw std::runtime_error(std::string("invalid kty in ") + from); + + keys[kid] = std::move(pem_key); + } + const std::string message{"public keys from " + from + " loaded"}; + LogPluginErr(INFORMATION_LEVEL, ER_LOG_PRINTF_MSG, message.c_str()); +} +#ifndef __clang__ +#pragma GCC diagnostic pop +#endif + +char Idp_configs::parse_prefix(const std::string &prefix) { + static constexpr std::string_view file_prefix{"FILE://"}; + static constexpr std::string_view json_prefix{"JSON://"}; + static_assert(prefix_len > 0); + + if (prefix.size() != prefix_len) return 0; + + // Case-insensitive prefix check + if (std::toupper(prefix[0]) == file_prefix[0]) { + for (size_t i = 0; i < prefix_len; ++i) { + if (std::toupper(prefix[i]) != file_prefix[i]) return 0; + } + return 'F'; + } + if (std::toupper(prefix[0]) == json_prefix[0]) { + for (size_t i = 0; i < prefix_len; ++i) { + if (std::toupper(prefix[i]) != json_prefix[i]) return 0; + } + return 'J'; + } + + return 0; +} + +std::string Idp_configs::read_from_file(const std::string &path) { + std::ifstream file(path, std::ios::binary | std::ios::ate); + if (!file) { + throw std::runtime_error("cannot open config file: " + path); + } + + const auto size = file.tellg(); + if (size < 0) { + throw std::runtime_error("cannot determine size of config file: " + path); + } + + std::string content(size, '\0'); + file.seekg(0, std::ios::beg); + if (!file.read(content.data(), size)) { + throw std::runtime_error("cannot read config file: " + path); + } + + return content; +} + +void Idp_configs::parse_var(const char *variable, + std::string &config_json) noexcept { + try { + const std::string config_var{variable}; + if (config_var.size() < prefix_len) + throw std::runtime_error("sysvar too short, expected FILE:// or JSON://"); + const std::string prefix{config_var.substr(0, prefix_len)}; + switch (parse_prefix(prefix)) { + case 'F': + config_json = read_from_file(config_var.substr(prefix_len - 1)); + break; + case 'J': + config_json = config_var.substr(prefix_len); + break; + default: + throw std::runtime_error( + "invalid sysvar prefix, expected FILE:// or JSON://"); + } + } catch (const std::exception &e) { + LogPluginErr(ERROR_LEVEL, ER_LOG_PRINTF_MSG, e.what()); + } catch (...) { + LogPluginErr(ERROR_LEVEL, ER_LOG_PRINTF_MSG, + "unknown error while parsing sysvar"); + } +} + +bool Idp_configs::check(const char *variable) noexcept { + try { + std::string config_json; + parse_var(variable, config_json); + picojson::value json_obj; + const std::string err{picojson::parse(json_obj, config_json)}; + return !err.empty(); + } catch (const std::exception &e) { + LogPluginErr(ERROR_LEVEL, ER_LOG_PRINTF_MSG, e.what()); + } catch (...) { + LogPluginErr(ERROR_LEVEL, ER_LOG_PRINTF_MSG, + "unknown error while checking sysvar"); + } + return true; +} + +void Idp_configs::update(const char *variable, + const char **sysvar_ptr) noexcept { + // This function updates the configuration which must be done in read-only + // mode. In order to minimize the locking time, a new configuration is created + // and loaded out of the locks. If success, the lock is set for a short time + // of swaping old and new configuration. + try { + auto new_configs = std::make_unique(variable); + std::string config_json; + parse_var(variable, config_json); + new_configs->load(config_json); + std::unique_lock lock(mutex(), lock_timeout); + if (!lock.owns_lock()) + throw std::runtime_error("failed to acquire unique lock"); + current().swap(new_configs); + *sysvar_ptr = current()->sysvar_str.c_str(); + } catch (const std::exception &e) { + LogPluginErr(ERROR_LEVEL, ER_LOG_PRINTF_MSG, e.what()); + } catch (...) { + LogPluginErr(ERROR_LEVEL, ER_LOG_PRINTF_MSG, + "failed to update configuration"); + } +} + +void Idp_configs::set(const char *variable) noexcept { + // This function sets the configuration on plugin init, so it is safe to set + // the config directly + try { + std::string config_json; + parse_var(variable, config_json); + current(variable)->load(config_json); + } catch (const std::exception &e) { + LogPluginErr(ERROR_LEVEL, ER_LOG_PRINTF_MSG, e.what()); + } catch (...) { + LogPluginErr(ERROR_LEVEL, ER_LOG_PRINTF_MSG, + "failed to update configuration"); + } +} + +std::string Idp_configs::verify_token(const Id_token &token, + const std::string &idp_name, + const std::string &ext_user, + const std::string &ext_group, + std::string &roles) { + // No change to the configuration is allowed while verifying the token, + // use lock + const std::shared_lock lock(mutex(), lock_timeout); + if (!lock.owns_lock()) + throw std::runtime_error("failed to acquire shared lock"); + return token.verify(ext_user, ext_group, current()->get_idp(idp_name), roles); +} diff --git a/plugin/auth_openid_connect/src/config.h b/plugin/auth_openid_connect/src/config.h new file mode 100644 index 000000000000..47f2aaad582b --- /dev/null +++ b/plugin/auth_openid_connect/src/config.h @@ -0,0 +1,399 @@ +/* +(C) 2026 Percona LLC and/or its affiliates + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; version 2 of the License. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA +*/ + +#ifndef AUTH_OIDC_CONFIG_H +#define AUTH_OIDC_CONFIG_H + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include +#include + +#include "jwks.h" + +class Id_token; + +/** + * @class Idp_config + * @brief Configuration for a single Identity Provider (IDP). + */ +class Idp_config { + private: + std::string issuer_name; ///< The token's issuer name. + Jwks jwks; ///< URL for the JSON Web Key Set. + std::string group_claim; ///< Name of the claim in the JWT that contains + ///< group information. + std::unordered_set + audiences; ///< Set of allowed audiences for the token. + std::map + roles; ///< Map of IDP groups to database roles. + std::map + keys; ///< Map of Key ID (kid) to public key in PEM format. + + public: + /** + * @brief Constructs an Idp_config object. + * @param issuer_name The token's issuer name. + * @param jwks_url The URL for the JSON Web Key Set. + * @param group_claim The group claim name. + * @param audiences A set of allowed audiences. + * @param roles A map of group-to-role mappings. + */ + Idp_config(const std::string &issuer_name, const std::string &jwks_url, + const std::string &group_claim, + std::unordered_set &&audiences, + std::map &&roles) + : issuer_name(issuer_name), + jwks(jwks_url), + group_claim(group_claim), + audiences(std::move(audiences)), + roles(std::move(roles)) {} + + /** + * @brief Loads the public keys from JWKS. + * + * @return false if keys were successfully loaded, + * true if keys were not successfully loaded. + */ + bool load_keys() noexcept; + + /** + * @brief Loads the public keys from a JSON array. + * + * @param key_array The array containing key definitions. + * @param from A descriptive string indicating the source (for error + * messages). + * @throws std::runtime_error if any key object is malformed or invalid. + */ + void load_keys(const picojson::array &key_array, const std::string &from); + + /** + * @brief Gets the issuer name. + * @return The issuer name string. + */ + const std::string &get_issuer_name() const noexcept { return issuer_name; } + + /** + * @brief Gets the JWKS URL. + * @return The JWKS URL. + */ + const std::string &get_jwks_url() const noexcept { return jwks.get_url(); } + + /** + * @brief Gets the name of the group claim. + * @return The group claim name. + */ + const std::string &get_group_claim() const noexcept { return group_claim; } + + /** + * @brief Gets the only public key. + * The "kid" element may be omitted in JOSE header iff only one key is + * available. + * @return The only public key in PEM format. + * @throws std::runtime_error if no keys are available. + */ + const std::string &get_the_only_pub_key() const { + if (keys.size() != 1) throw std::runtime_error("incorrect number of keys"); + const auto key{keys.cbegin()}; + return key->second; + } + + /** + * @brief Gets a public key by its Key ID (kid). + * @param kid The Key ID. + * @return The public key in PEM format. + * @throws std::out_of_range if the Key ID is not found. + */ + const std::string &get_pub_key(const std::string &kid) const { + return keys.at(kid); + } + + /** + * @brief Checks if a given audience is allowed. + * @param audience The audience string from the token. + * @return true if the audience is allowed or if no audiences are configured + * (allowing all). + */ + bool is_audience_allowed(const std::string &audience) const noexcept { + return audiences.empty() || audiences.contains(audience); + } + + /** + * @brief Gets the mapped database role for an IDP group. + * @param group The IDP group name. + * @return The mapped role name, or an empty string if no mapping exists. + */ + const std::string &get_role(const std::string &group) const noexcept { + static std::string no_role; + const auto it = roles.find(group); + return it == roles.end() ? no_role : it->second; + } + + /** + * @brief Swaps the current keys with new keys. + * @param other An Idp_config object containing the keys to swap in. + */ + void swap_keys(Idp_config &other) { keys.swap(other.keys); } +}; + +/** + * @class Idp_configs + * @brief Manages a collection of Identity Provider configurations. + */ +class Idp_configs { + private: + std::string sysvar_str{}; ///< Value of the configuration system variable. + std::map + idp_configs{}; ///< Map of IDP names to Idp_config objects. + Idp_configs() = delete; + + /** + * @brief Length of the + */ + static constexpr size_t prefix_len{sizeof("FILE://") - 1}; + + /** + * @brief Timeout duration for acquiring locks when updating configurations. + * This is used to prevent deadlocks in case of long-running operations while. + */ + static constexpr std::chrono::seconds lock_timeout{5}; + + /** + * @brief Gets the current Idp_configs instance. The instance is a function + * local static in order to avoid static initialization order issues. + * @return A reference to the unique pointer holding the current Idp_configs. + */ + static std::unique_ptr ¤t(const char *sysvar_str = ""); + + /** + * @brief A mutex used for synchronizing access to the + * configuration. The mutex is a function local static to ensure it is + * initialized before use and to avoid static initialization order issues. + * @return A reference to the mutex used + * @note This mutex should be used to synchronize access to the current + * configuration. + */ + static std::shared_timed_mutex &mutex(); + + /** + * @brief Parses the JSON configuration string and loads configurations cache. + * @param config_json The JSON string containing IDP configurations. + */ + void load(const std::string &config_json); + + /** + * @brief Parses the configuration for a single IDP and adds it to the + * idp_configs map. + * @param idp_value The JSON value representing the IDP configuration. + * @param idp_name The name of the IDP (used as the key in the map). + */ + void load_idp(const picojson::value &idp_value, + const std::string &idp_name) noexcept; + + /** + * @brief Parses the prefix of the configuration system variable. + * @param prefix The prefix. + * @return F: if the prefix is FILE, J: if the prefix is JSON, else throws an + * exception. + */ + static char parse_prefix(const std::string &prefix); + + /** + * @brief Reads the configuration from a file. + * @param path The path to the configuration file. + * @return The content of the file as a string. + */ + static std::string read_from_file(const std::string &path); + + /** + * @brief Parses the configuration system variable and optionally loads the + * configuration. + * @param variable The value of the configuration variable. + * configuration, else only the basic checks are done. + * @param config_json The JSON string containing IDP configurations, + * or an empty string if parsing fails. + */ + static void parse_var(const char *variable, + std::string &config_json) noexcept; + + /** + * @brief Gets the configuration for a specific IDP. + * @param idp_name The name of the IDP. + * @return reference to the Idp_config object, or nullptr if not found. + * @throws std::runtime_error if the IDP is not found in the configuration. + */ + Idp_config &get_idp(const std::string &idp_name) { + const auto it = idp_configs.find(idp_name); + if (it == idp_configs.end()) + throw std::runtime_error("IDP not found: " + idp_name); + return it->second; + } + + /** + * @brief Swaps the keys of the specified IDP with the keys from another IDP + * in a thread-safe manner. + * @param idp_name The name of the first IDP. + * @param other_idp The second IDP config. + * @throws std::runtime_error if the IDP is not found in the configuration. + */ + static void swap_idp_keys(const std::string &idp_name, + Idp_config &other_idp) { + std::unique_lock lock(mutex()); + current()->get_idp(idp_name).swap_keys(other_idp); + } + + /** + * @brief Gets the JWKS URL for a specific IDP in a thread-safe manner. + * @param idp_name The name of the IDP. + * @return The JWKS URL for the specified IDP. + */ + static const std::string &get_safe_jwks_url(const std::string &idp_name) { + std::shared_lock lock(mutex(), lock_timeout); + if (!lock.owns_lock()) + throw std::runtime_error("failed to acquire shared lock"); + return current()->get_idp(idp_name).get_jwks_url(); + } + + /** + * @brief Gets the JWKS URL for a specific IDP in a thread-safe manner. + */ + static void swap_idp_keys( + std::vector> &configs) { + std::unique_lock lock(mutex(), lock_timeout); + if (!lock.owns_lock()) + throw std::runtime_error("failed to acquire unique lock"); + for (auto &config : configs) { + current()->get_idp(config.first).swap_keys(config.second); + } + } + + /** + * @brief Creates temporary IDP configs for further key loading in a + * thread-safe manner. + * @param configs A vector to be populated with pairs of IDP names and their + * corresponding temporary Idp_config objects + */ + static void create_tmp_configs( + std::vector> &configs) { + std::shared_lock lock(mutex(), lock_timeout); + if (!lock.owns_lock()) + throw std::runtime_error("failed to acquire shared lock"); + configs.reserve(current()->idp_configs.size()); + for (auto &config : current()->idp_configs) { + configs.emplace_back( + config.first, + Idp_config("", config.second.get_jwks_url(), "", {}, {})); + } + } + + public: + static char *sysvar; ///< Pointer to the system variable storage. + + /** + * @brief Constructor for Idp_configs. + * @param sysvar_str The value of the system variable string. + */ + explicit Idp_configs(const char *sysvar_str) : sysvar_str(sysvar_str) {} + + /** + * @brief Verifies the ID token and extracts user roles based on the IDP + * configuration. + * @param token The ID token to verify. + * @param idp_name The name of the IDP to use for verification. + * @param ext_user The expected external username (subject) in the token. + * @param ext_group The expected external group in the token. + * @param roles A string to be populated with the mapped database roles + * @return The proxy user name + * (comma-separated) if verification is successful. + */ + static std::string verify_token(const Id_token &token, + const std::string &idp_name, + const std::string &ext_user, + const std::string &ext_group, + std::string &roles); + + /** + * @brief Validates the variable syntax, checks if the variable + * or content of the file is a valid JSON. + * @param variable The configuration variable value to validate. + * @return true if parsing fails, false if valid. + */ + static bool check(const char *variable) noexcept; + + /** + * @brief Parses and loads the configuration according to the new value of + * the variable. Updates the system variable pointer to the new value stored + * internally. + * @param variable New value of the variable. + * @param sysvar_ptr Pointer to the system variable. + * @note If parsing or loading fails, the system variable will not be updated + * and an error will be logged. + */ + static void update(const char *variable, const char **sysvar_ptr) noexcept; + + /** + * @brief Parses and loads the configuration according to the value of + * the variable. To be used on plugin initialization only. + * @param variable Value of the variable. + * @note If parsing or loading fails, the system variable will not be set + * and an error will be logged. + */ + static void set(const char *variable) noexcept; + + /** + * @brief Check function for the MySQL system variable. + * @return 0 for success, non-zero for error. + */ + static int check(MYSQL_THD thd [[maybe_unused]], + SYS_VAR *var [[maybe_unused]], void *save, + st_mysql_value *value); + + /** + * @brief Update function for the MySQL system variable. + */ + static void update(MYSQL_THD thd [[maybe_unused]], + SYS_VAR *var [[maybe_unused]], void *var_ptr, + const void *save); + + /** + * @brief Updates the keys for all IDPs by calling JWKS. + * @return on success: number of updated IDPs, on failure: negative value + */ + static long long update_keys() noexcept; + + /** + * @brief Updates the keys for a specific IDP by calling JWKS. + * @param idp_name name od IDP which keys are to be updated. + * @return on success: number of updated IDPs (0 or 1), + * on failure: negative value + */ + static long long update_keys(const char *idp_name) noexcept; +}; + +#endif // AUTH_OIDC_CONFIG_H diff --git a/plugin/auth_openid_connect/src/id_token.cc b/plugin/auth_openid_connect/src/id_token.cc new file mode 100644 index 000000000000..ec4289ba6739 --- /dev/null +++ b/plugin/auth_openid_connect/src/id_token.cc @@ -0,0 +1,193 @@ +/* +(C) 2026 Percona LLC and/or its affiliates + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; version 2 of the License. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA +*/ + +#include "id_token.h" + +#include +#include +#include + +/* Clang‑tidy wants to reorder those headers, but jwt‑cpp requires + * traits header included before jwt.h */ +// clang-format off +#include +#include +#include +// clang-format on +#include +#include +#include + +#include "config.h" + +auto Id_token::get_verifier(const std::string &name, const std::string &key) { + if (name == "RS256") + return jwt::verify().allow_algorithm(jwt::algorithm::rs256(key)); + if (name == "RS384") + return jwt::verify().allow_algorithm(jwt::algorithm::rs384(key)); + if (name == "RS512") + return jwt::verify().allow_algorithm(jwt::algorithm::rs512(key)); + if (name == "ES256") + return jwt::verify().allow_algorithm(jwt::algorithm::es256(key)); + if (name == "HS256") + return jwt::verify().allow_algorithm(jwt::algorithm::hs256(key)); + + throw std::runtime_error("Unsupported algorithm: " + name); +} + +void Id_token::verify_group_member( + const jwt::basic_claim &groups_claim, + const std::string &group) { + if (groups_claim.get_type() == jwt::json::type::array) { + const auto groups{groups_claim.as_array()}; + if (!std::ranges::any_of(groups, [&](const picojson::value &claim_group) { + return claim_group.to_str() == group; + })) + throw std::runtime_error("user is not a member of the required group"); + + } else if (groups_claim.get_type() == jwt::json::type::string) { + if (groups_claim.as_string() != group) + throw std::runtime_error("user is not a member of the required group"); + } else { + throw std::runtime_error( + "cannot parse groups claim in the token, it must be a string or an " + "array of strings"); + } +} + +std::string Id_token::get_first_group( + const jwt::basic_claim &groups_claim) { + if (groups_claim.get_type() == jwt::json::type::array) { + const auto claims{groups_claim.as_array()}; + if (claims.empty()) throw std::runtime_error("empty groups claim"); + return groups_claim.as_array().front().to_str(); + } + + if (groups_claim.get_type() == jwt::json::type::string) + return groups_claim.as_string(); + + throw std::runtime_error( + "cannot parse groups claim in the token, it must be a string or an " + "array of strings"); +} + +void Id_token::map_groups_to_roles( + const Idp_config &idp, + const jwt::basic_claim &groups_claim, + std::string &roles) { + // Group-role mapping + if (groups_claim.get_type() == jwt::json::type::array) { + bool first{true}; + for (const auto &group : groups_claim.as_array()) { + const std::string &role{idp.get_role(group.to_str())}; + if (role.empty()) continue; + if (first) + first = false; + else + roles += ","; + roles += role; + } + } else if (groups_claim.get_type() == jwt::json::type::string) { + roles = groups_claim.as_string(); + } else + throw std::runtime_error( + "cannot parse groups claim in the token, it must be a string or an " + "array of strings"); +} + +const char *Id_token::get_error() const { return error.c_str(); } + +bool Id_token::read(MYSQL_PLUGIN_VIO *vio) { + unsigned char *pos(nullptr); + int len_to_parse = vio->read_packet(vio, &pos); + + // 1. field: capability + // ensure the packet is long enough to hold the field + if (len_to_parse <= 1 || pos == nullptr) { + error = "malformed packet"; + return true; + } + // skip the field + pos++; + len_to_parse--; + + // 2. field: token length + // ensure the packet is long enough to hold the field + len_to_parse -= net_field_length_size(pos); + if (len_to_parse < 1) { + error = "malformed packet"; + return true; + } + // get token length and move pos to the 3. field: the token + const uint64_t token_len = net_field_length_ll(&pos); + // check if the token length is correct + if (token_len > static_cast(len_to_parse) || token_len < 1) { + error = "malformed packet"; + return true; + } + token = std::string(reinterpret_cast(pos), token_len); + return false; +} + +std::string Id_token::verify(const std::string &ext_user, + const std::string &ext_group, + const Idp_config &idp, std::string &roles) const { + const auto decoded_token = jwt::decode(token); + + const std::string &pub_key{decoded_token.has_key_id() + ? idp.get_pub_key(decoded_token.get_key_id()) + : idp.get_the_only_pub_key()}; + // We verify "sub" only if "user" is specified in the AUTHENTICATED AS + // clause, so proxying is possible + const auto verifier = + ext_user.empty() + ? get_verifier(decoded_token.get_header_claim("alg").as_string(), + pub_key) + .with_claim("iss", jwt::claim(idp.get_issuer_name())) + : get_verifier(decoded_token.get_header_claim("alg").as_string(), + pub_key) + .with_claim("iss", jwt::claim(idp.get_issuer_name())) + .with_claim("sub", jwt::claim(ext_user)); + // Not explicit here, but verifier verifies both claims and expiration + verifier.verify(decoded_token); + + // audience check -optional + if (!idp.is_audience_allowed( + decoded_token.get_payload_claim("aud").as_string())) + throw std::runtime_error("invalid audience"); + + // groups and roles mapping -optional + const std::string &group_claim_name{idp.get_group_claim()}; + if (!group_claim_name.empty() && + decoded_token.has_payload_claim(group_claim_name)) + map_groups_to_roles(idp, decoded_token.get_payload_claim(group_claim_name), + roles); + + if (ext_user.empty()) { + // Proxying occurs + if (ext_group.empty()) + // group not specified, get the first group + return get_first_group(decoded_token.get_payload_claim(group_claim_name)); + + // group specified, verify that the user is member of the group + verify_group_member(decoded_token.get_payload_claim(group_claim_name), + ext_group); + return ext_group; + } + + return ""; +} diff --git a/plugin/auth_openid_connect/src/id_token.h b/plugin/auth_openid_connect/src/id_token.h new file mode 100644 index 000000000000..f036002c7087 --- /dev/null +++ b/plugin/auth_openid_connect/src/id_token.h @@ -0,0 +1,126 @@ +/* +(C) 2026 Percona LLC and/or its affiliates + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; version 2 of the License. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA +*/ + +/** + * @file id_token.h + * @brief Header for the Id_token class, which handles OpenID Connect ID token + * verification. + */ + +#ifndef ID_TOKEN_H +#define ID_TOKEN_H + +#include + +/* Clang‑tidy wants to reorder those headers, but jwt‑cpp requires + * traits header included before jwt.h */ +// clang-format off +#include +#include +// clang-format on +#include + +class Idp_config; + +/** + * @class Id_token + * @brief Represents an OpenID Connect ID token and provides methods to read and + * verify it. + */ +class Id_token { + private: + std::string token; ///< The raw JWT token string. + std::string + error; ///< Stores error messages if verification or reading fails. + + /** + * @brief Returns a JWT verifier for a given algorithm and public key. + * @param name The name of the algorithm (e.g., "RS256"). + * @param key The public key in PEM format. + * @return A jwt::verifier object configured with the specified algorithm and + * key. + * @throws std::runtime_error if the algorithm is not supported. + */ + static auto get_verifier(const std::string &name, const std::string &key); + + /** + * @brief Checks if a user is a member of a specified group based on JWT + * groups claim. + * @param groups_claim The claim containing group information from the JWT. + * @param group_name The name of the group to check membership for. + * @throws std::runtime_error if the user is not member of the group. + */ + static void verify_group_member( + const jwt::basic_claim &groups_claim, + const std::string &group_name); + + /** + * @brief Gets the first group from the JWT groups claim. + * @param groups_claim The claim containing group information from the JWT. + * @return The first group name found in the claim, or an empty string if no + * groups are present. + * @throws std::runtime_error if the groups claim is invalid or there is no + * group. + */ + static std::string get_first_group( + const jwt::basic_claim &groups_claim); + + /** + * @brief Maps OpenID Connect groups to database roles based on IDP + * configuration. + * @param idp The identity provider configuration. + * @param groups_claim The claim containing group information from the JWT. + * @param roles A string to which the mapped roles will be appended + * (comma-separated). + * @throws std::runtime_error if the groups claim format is invalid. + */ + static void map_groups_to_roles( + const Idp_config &idp, + const jwt::basic_claim &groups_claim, + std::string &roles); + + public: + /** + * @brief Gets the last error message. + * @return A pointer to the error message string. + */ + const char *get_error() const; + + /** + * @brief Reads the ID token from the MySQL client-server communication + * channel. + * @param vio The VIO (Virtual I/O) object for communication. + * @return true if an error occurred while reading, false otherwise. + */ + bool read(MYSQL_PLUGIN_VIO *vio); + + /** + * @brief Verifies the ID token against IDP configuration and user + * information. + * @param ext_user The expected external username (subject). + * @param ext_group The expected external group. + * @param idp Idp_config object containing verification parameters. + * @param roles String to be populated with roles mapped from the token's + * groups. + * @return The proxy user name + * @throws std::runtime_error if verification fails or token is invalid. + */ + std::string verify(const std::string &ext_user, const std::string &ext_group, + const Idp_config &idp, std::string &roles) const; +}; + +#endif // ID_TOKEN_H diff --git a/plugin/auth_openid_connect/src/jwk.cc b/plugin/auth_openid_connect/src/jwk.cc new file mode 100644 index 000000000000..b971474fae3b --- /dev/null +++ b/plugin/auth_openid_connect/src/jwk.cc @@ -0,0 +1,131 @@ +/* +(C) 2026 Percona LLC and/or its affiliates + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; version 2 of the License. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA +*/ + +#include "jwk.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +std::vector Jwk::base64url_decode( + const std::string_view &input) { + std::string prepared_input{input.data(), input.size()}; + const size_t padding = prepared_input.size() % 4; + if (padding != 0) prepared_input.append(4 - padding, '='); + std::ranges::replace(prepared_input, '-', '+'); + std::ranges::replace(prepared_input, '_', '/'); + + std::vector output(prepared_input.size()); + const int len = EVP_DecodeBlock( + output.data(), + reinterpret_cast(prepared_input.data()), + output.size()); + if (len < 0) throw std::runtime_error("Base64 decode failed"); + output.resize(len - (padding == 0 ? 0 : 4 - padding)); + return output; +} + +OSSL_PARAM *Rsa_jwk::construct_param() { + if (n.empty() || e.empty()) throw std::runtime_error("RSA requires n and e"); + + const auto n_bytes = base64url_decode(n); + const auto e_bytes = base64url_decode(e); + const std::unique_ptr bn_n( + BN_bin2bn(n_bytes.data(), n_bytes.size(), nullptr), BN_free); + const std::unique_ptr bn_e( + BN_bin2bn(e_bytes.data(), e_bytes.size(), nullptr), BN_free); + + // BN for RSA + const std::unique_ptr + param_bld(OSSL_PARAM_BLD_new(), OSSL_PARAM_BLD_free); + if (OSSL_PARAM_BLD_push_BN(param_bld.get(), OSSL_PKEY_PARAM_RSA_N, + bn_n.get()) == 0 || + OSSL_PARAM_BLD_push_BN(param_bld.get(), OSSL_PKEY_PARAM_RSA_E, + bn_e.get()) == 0) + throw std::runtime_error("Failed to push BN params for RSA"); + + return OSSL_PARAM_BLD_to_param(param_bld.get()); +} + +OSSL_PARAM *Ec_jwk::construct_param() { + if (crv.empty() || x.empty() || y.empty()) + throw std::runtime_error("EC requires crv, x, y"); + + if (crv != "P-256" && crv != "P-384" && crv != "P-521") + throw std::runtime_error("Unsupported EC curve: " + crv); + + auto x_bytes = base64url_decode(x); + auto y_bytes = base64url_decode(y); + + // Uncompressed public key point: 0x04 + X + Y + std::vector pub_key_octet = {0x04}; + pub_key_octet.insert(pub_key_octet.end(), x_bytes.begin(), x_bytes.end()); + pub_key_octet.insert(pub_key_octet.end(), y_bytes.begin(), y_bytes.end()); + + const std::unique_ptr + param_bld(OSSL_PARAM_BLD_new(), OSSL_PARAM_BLD_free); + if (OSSL_PARAM_BLD_push_utf8_string( + param_bld.get(), OSSL_PKEY_PARAM_GROUP_NAME, crv.c_str(), 0) == 0 || + OSSL_PARAM_BLD_push_octet_string(param_bld.get(), OSSL_PKEY_PARAM_PUB_KEY, + pub_key_octet.data(), + pub_key_octet.size()) == 0) + throw std::runtime_error("Failed to build EC params"); + + return OSSL_PARAM_BLD_to_param(param_bld.get()); +} + +EVP_PKEY *Jwk::pkey_from_ctx(EVP_PKEY_CTX *ctx, OSSL_PARAM *params) { + EVP_PKEY *pkey(nullptr); + if (ctx == nullptr || EVP_PKEY_fromdata_init(ctx) == 0 || + EVP_PKEY_fromdata(ctx, &pkey, EVP_PKEY_PUBLIC_KEY, params) == 0) { + throw std::runtime_error("RSA EVP_PKEY_fromdata failed"); + } + return pkey; +} + +std::string Jwk::to_pem() { + const std::unique_ptr param( + construct_param(), OSSL_PARAM_free); + + const std::unique_ptr ctx( + EVP_PKEY_CTX_new_from_name(nullptr, kty.c_str(), nullptr), + EVP_PKEY_CTX_free); + const std::unique_ptr pkey( + pkey_from_ctx(ctx.get(), param.get()), EVP_PKEY_free); + const std::unique_ptr bio(BIO_new(BIO_s_mem()), + BIO_free); + std::string pem; + + if (PEM_write_bio_PUBKEY(bio.get(), pkey.get()) == 0) + throw std::runtime_error("PEM_write_bio_PUBKEY failed"); + + BUF_MEM *mem(nullptr); + BIO_get_mem_ptr(bio.get(), &mem); + pem.assign(mem->data, mem->length); + return pem; +} diff --git a/plugin/auth_openid_connect/src/jwk.h b/plugin/auth_openid_connect/src/jwk.h new file mode 100644 index 000000000000..50f91d852892 --- /dev/null +++ b/plugin/auth_openid_connect/src/jwk.h @@ -0,0 +1,145 @@ +/* +(C) 2026 Percona LLC and/or its affiliates + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; version 2 of the License. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA +*/ + +#ifndef MYSQL_JWK_H +#define MYSQL_JWK_H + +#include +#include +#include +#include +#include + +/** + * @class Jwk + * @brief Base class for representing a JSON Web Key. + */ +class Jwk { + private: + /** + * @brief Creates an EVP_PKEY object from the given OSSL_PARAM array. + * @param ctx The EVP_PKEY_CTX to use for key creation. + * @param params The OSSL_PARAM array containing key parameters. + * @return A pointer to the created EVP_PKEY object. + * @throws std::runtime_error if key creation fails. + */ + static EVP_PKEY *pkey_from_ctx(EVP_PKEY_CTX *ctx, OSSL_PARAM *params); + + protected: + std::string alg{}; ///< Algorithm used for the key. + std::string use{}; ///< Intended use of the key. + std::string kid{}; ///< Key ID. + std::string kty{}; ///< Key Type (e.g., "RSA", "EC"). + + /** + * @brief Constructs OpenSSL OSSL_PARAM for the key. + * @return A pointer to an array of OSSL_PARAM objects. + */ + virtual OSSL_PARAM *construct_param() = 0; + + public: + /** + * @brief Constructs a Jwk object with a given key type. + * @param kty The key type string. + */ + explicit Jwk(const char *kty) : kty(kty) {} + Jwk() = delete; + Jwk(const Jwk &) = delete; + Jwk &operator=(const Jwk &) = delete; + Jwk(Jwk &&) = delete; + Jwk &operator=(Jwk &&) = delete; + virtual ~Jwk() = default; + + /** + * @brief Converts the JWK to PEM format. + * @return The PEM-encoded public key. + */ + std::string to_pem(); + + /** + * @brief Decodes a base64url-encoded string. + * @param input The base64url string to decode. + * @return A vector containing the decoded bytes. + */ + static std::vector base64url_decode( + const std::string_view &input); +}; + +/** + * @class Rsa_jwk + * @brief Represents an RSA public key in JWK format. + */ +class Rsa_jwk : public Jwk { + private: + std::string n{}; ///< Modulus. + std::string e{}; ///< Public exponent. + + protected: + /** + * @brief Constructs OpenSSL OSSL_PARAM for the RSA key. + * @return A pointer to an array of OSSL_PARAM objects. + */ + OSSL_PARAM *construct_param() override; + + public: + Rsa_jwk() = delete; + /** + * @brief Constructs an Rsa_jwk object. + * @param n The RSA modulus in base64url format. + * @param e The RSA public exponent in base64url format. + */ + Rsa_jwk(std::string n, std::string e) + : Jwk("RSA"), n(std::move(n)), e(std::move(e)) {} +}; + +/** + * @class Ec_jwk + * @brief Represents an Elliptic Curve (EC) public key in JWK format. + */ +class Ec_jwk : public Jwk { + private: + std::string crv{}; ///< Curve type (e.g., "P-256"). + std::string x{}; ///< X coordinate. + std::string y{}; ///< Y coordinate. + + protected: + /** + * @brief Constructs OpenSSL OSSL_PARAM for the EC key. + * + * Converts the base64url-encoded EC coordinates to OpenSSL OSSL_PARAM format + * suitable for key creation. Supports curves P-256, P-384, and P-521. + * + * @return A pointer to an array of OSSL_PARAM objects. + * @throws std::runtime_error if crv, x, or y is empty, if the curve is + * unsupported, or if parameter construction fails. + */ + OSSL_PARAM *construct_param() override; + + public: + Ec_jwk() = delete; + + /** + * @brief Constructs an Ec_jwk object. + * @param crv The curve name. + * @param x The X coordinate in base64url format. + * @param y The Y coordinate in base64url format. + */ + Ec_jwk(std::string crv, std::string x, std::string y) + : Jwk("EC"), crv(std::move(crv)), x(std::move(x)), y(std::move(y)) {} +}; + +#endif // MYSQL_JWK_H diff --git a/plugin/auth_openid_connect/src/jwks.cc b/plugin/auth_openid_connect/src/jwks.cc new file mode 100644 index 000000000000..fd7957aba00c --- /dev/null +++ b/plugin/auth_openid_connect/src/jwks.cc @@ -0,0 +1,93 @@ +/* +(C) 2026 Percona LLC and/or its affiliates + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; version 2 of the License. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA +*/ +#include "jwks.h" + +#include +#include + +#include +#include +#include +#include + +#include "jwk.h" +#include "mysql/components/services/log_builtins.h" +#include "mysql/my_loglevel.h" +#include "mysqld_error.h" + +std::size_t Jwks::write_callback(const char *received, + const std::size_t element_size, + const std::size_t no_elements, + void *user_data) { + const std::size_t total = element_size * no_elements; + std::string *out = static_cast(user_data); + out->append(received, total); + return total; +} + +std::string Jwks::http_get() const { + if (url.empty()) return ""; + const std::unique_ptr curl( + curl_easy_init(), curl_easy_cleanup); + if (curl == nullptr) throw std::runtime_error("JWKS: curl_easy_init failed"); + + std::string response; + + curl_easy_setopt(curl.get(), CURLOPT_URL, url.c_str()); + curl_easy_setopt(curl.get(), CURLOPT_FOLLOWLOCATION, 1L); + curl_easy_setopt(curl.get(), CURLOPT_WRITEFUNCTION, &Jwks::write_callback); + curl_easy_setopt(curl.get(), CURLOPT_WRITEDATA, &response); + curl_easy_setopt(curl.get(), CURLOPT_USERAGENT, "Jwst/1.0"); + curl_easy_setopt(curl.get(), CURLOPT_NOPROGRESS, 1L); + curl_easy_setopt(curl.get(), CURLOPT_TIMEOUT, 30L); + curl_easy_setopt(curl.get(), CURLOPT_MAXREDIRS, 5L); + curl_easy_setopt(curl.get(), CURLOPT_BUFFERSIZE, 102400L); // Max 100K + + // SECURITY: the constructor ensures the URL starts with HTTP or HTTPS. + // HTTP case: no security verification is done, assume + // the administrator deliberately uses unsafe config (e.g. for testing). + // HTTPS case: the JWKS endpoint must use a valid certificate. + if (url.find("https://") == 0) { + curl_easy_setopt(curl.get(), CURLOPT_SSL_VERIFYPEER, 1L); + curl_easy_setopt(curl.get(), CURLOPT_SSL_VERIFYHOST, 2L); + } else { + const std::string message{"JWKS configuration is insecure, use HTTPS: " + + url}; + LogPluginErr(WARNING_LEVEL, ER_LOG_PRINTF_MSG, message.c_str()); + } + CURLcode curl_code = curl_easy_perform(curl.get()); + if (curl_code != CURLE_OK) { + const std::string msg = std::string("JWKS: HTTP GET from ") + url + + " failed: " + curl_easy_strerror(curl_code); + throw std::runtime_error(msg); + } + + long http_code = 0; + curl_code = curl_easy_getinfo(curl.get(), CURLINFO_RESPONSE_CODE, &http_code); + if (curl_code != CURLE_OK) { + const std::string msg = std::string("JWKS: CURL get info from ") + url + + "failed: " + curl_easy_strerror(curl_code); + throw std::runtime_error(msg); + } + + if (http_code < 200 || http_code >= 300) { + throw std::runtime_error("JWKS: unexpected HTTP status from " + url + ": " + + std::to_string(http_code)); + } + + return response; +} diff --git a/plugin/auth_openid_connect/src/jwks.h b/plugin/auth_openid_connect/src/jwks.h new file mode 100644 index 000000000000..ce4926149bf1 --- /dev/null +++ b/plugin/auth_openid_connect/src/jwks.h @@ -0,0 +1,84 @@ +/* +(C) 2026 Percona LLC and/or its affiliates + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; version 2 of the License. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA +*/ + +#ifndef MYSQL_JWKS_H +#define MYSQL_JWKS_H + +#include +#include +#include +#include + +/** + * @class Jwks + * @brief Manages JSON Web Key Set (JWKS) retrieval via HTTPS. + * + * This class handles fetching JWKS from a remote HTTPS endpoint + * using libcurl. It provides methods for HTTP GET requests and handles + * response buffering. + */ +class Jwks { + private: + std::string url; ///< The JWKS endpoint URL. + + public: + /** + * @brief Gets the JWKS URL. + * @return The URL string. + */ + const std::string &get_url() const { return url; } + + Jwks() = delete; + + /** + * @brief Constructs a Jwks object with a given URL. + * @param url The URL of the JWKS endpoint. + */ + explicit Jwks(const std::string_view &url) { + if (!url.empty() && url.find("http://") != 0 && url.find("https://") != 0) { + throw std::runtime_error("JWKS URL is not valid"); + } + this->url = url; + } + + /** + * @brief Performs an HTTP GET request to the given URL. + * + * @return The response body as a string. + * @throws std::runtime_error if curl initialization fails, HTTP request + * fails, or HTTP status code indicates an error. + * + * @note This method does NOT enforce HTTPS, but logs a warning if HTTP is + * used. + */ + std::string http_get() const; + + private: + /** + * @brief Callback function for writing HTTP response data. + * + * @param received Pointer to the received data buffer. + * @param element_size Size of each element. + * @param no_elements Number of elements received. + * @param user_data Pointer to the output std::string* buffer. + * @return The number of bytes processed (element_size * no_elements). + */ + static std::size_t write_callback(const char *received, + std::size_t element_size, + std::size_t no_elements, void *user_data); +}; +#endif // MYSQL_JWKS_H diff --git a/plugin/auth_openid_connect/src/plugin_openid_connect.cc b/plugin/auth_openid_connect/src/plugin_openid_connect.cc new file mode 100644 index 000000000000..e550b4566088 --- /dev/null +++ b/plugin/auth_openid_connect/src/plugin_openid_connect.cc @@ -0,0 +1,284 @@ +/* +(C) 2026 Percona LLC and/or its affiliates + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; version 2 of the License. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA +*/ + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "config.h" +#include "id_token.h" + +static SERVICE_TYPE(registry) * + reg_srv(nullptr); // NOLINT(misc-use-anonymous-namespace) +SERVICE_TYPE(log_builtins) * log_bi(nullptr); +SERVICE_TYPE(log_builtins_string) * log_bs(nullptr); + +/** + * @brief Initializes the OpenID Connect authentication plugin. + * @param plugin_info Pointer to the plugin information. + * @return 0 for success, 1 for error. + */ +static int auth_oidc_init( // NOLINT(misc-use-anonymous-namespace) + MYSQL_PLUGIN plugin_info [[maybe_unused]]) { + if (init_logging_service_for_plugin(®_srv, &log_bi, &log_bs)) return 1; + if (curl_global_init(CURL_GLOBAL_DEFAULT) != CURLE_OK) { + LogPluginErr(ERROR_LEVEL, ER_LOG_PRINTF_MSG, "curl_global_init failed"); + deinit_logging_service_for_plugin(®_srv, &log_bi, &log_bs); + return 1; + } + + if (Idp_configs::sysvar != nullptr && Idp_configs::sysvar[0] != '\0') { + if (Idp_configs::check(Idp_configs::sysvar)) { + LogPluginErr(WARNING_LEVEL, ER_LOG_PRINTF_MSG, + "Invalid value of auth_openid_connect_configuration"); + } else { + Idp_configs::set(Idp_configs::sysvar); + } + } else { + LogPluginErr(INFORMATION_LEVEL, ER_LOG_PRINTF_MSG, + "auth_openid_connect_configuration not set."); + } + return 0; +} + +/** + * @brief Deinitializes the OpenID Connect authentication plugin. + * @param plugin_info Pointer to the plugin information. + * @return 0 for success. + */ +static int auth_oidc_deinit( // NOLINT(misc-use-anonymous-namespace) + MYSQL_PLUGIN plugin_info [[maybe_unused]]) { + deinit_logging_service_for_plugin(®_srv, &log_bi, &log_bs); + curl_global_cleanup(); + return 0; +} + +/** + * @class User_auth_data + * @brief Holds user-specific authentication data extracted from the 'IDENTIFIED + * AS' clause. + */ +class User_auth_data { + private: + std::string idp; ///< Name of the identity provider. + std::string ext_user; ///< External username (subject) in the IDP. + std::string ext_group; ///< External group in the IDP. + std::string error; ///< Error message if initialization fails. + + public: + /** @return The IDP name. */ + const std::string &get_idp() const { return idp; } + /** @return The external user name. */ + const std::string &get_ext_user() const { return ext_user; } + /** @return The external group name. */ + const std::string &get_ext_group() const { return ext_group; } + /** @return The error message. */ + const char *get_error() const { return error.c_str(); } + + /** + * @brief Initializes the User_auth_data from the MySQL auth info. + * @param info Pointer to the MYSQL_SERVER_AUTH_INFO structure. + * @return true if an error occurred, false otherwise. + */ + // clang-tidy is absolutely wrong here, + // the method cannot be static as uses non-static members! + // NOLINTNEXTLINE(readability-convert-member-functions-to-static) + bool init(const MYSQL_SERVER_AUTH_INFO *info) { + picojson::value auth_json; + const std::string auth(info->auth_string_length > 0 ? info->auth_string + : ""); + + if (const std::string parse_error = picojson::parse(auth_json, auth); + !parse_error.empty()) { + error = "invalid IDENTIFIED AS : " + parse_error; + return true; + } + + if (!auth_json.is()) { + error = "invalid IDENTIFIED AS : not a JSON object"; + return true; + } + + const auto &obj = auth_json.get(); + idp = obj.at("identity_provider").get(); + if (const auto it = obj.find("user"); it != obj.end()) + ext_user = it->second.get(); + if (const auto it = obj.find("group"); it != obj.end()) + ext_group = it->second.get(); + + return false; + } +}; + +/** + * @brief The main authentication function for the OpenID Connect plugin. + * @param vio The VIO (Virtual I/O) object for communication with the client. + * @param info The server authentication information. + * @return CR_OK, CR_ERROR, or other MySQL authentication status codes. + */ +static int auth_oidc_authenticate( // NOLINT(misc-use-anonymous-namespace) + MYSQL_PLUGIN_VIO *vio, MYSQL_SERVER_AUTH_INFO *info) noexcept { + assert(vio); + assert(info); + + try { + // Check if the connection is secured, else we cannot trust the token + MYSQL_PLUGIN_VIO_INFO vio_info{}; + vio->info(vio, &vio_info); + if (!vio_info.is_tls_established && + vio_info.protocol != MYSQL_PLUGIN_VIO_INFO::MYSQL_VIO_SOCKET && + vio_info.protocol != MYSQL_PLUGIN_VIO_INFO::MYSQL_VIO_MEMORY) { + LogPluginErr(INFORMATION_LEVEL, ER_LOG_PRINTF_MSG, + "unsecure connection, use TLS, socket or memory"); + return CR_ERROR; + } + + User_auth_data auth_data; + if (auth_data.init(info)) { + LogPluginErr(INFORMATION_LEVEL, ER_LOG_PRINTF_MSG, auth_data.get_error()); + return CR_ERROR; + } + + Id_token token; + if (token.read(vio)) { + LogPluginErr(INFORMATION_LEVEL, ER_LOG_PRINTF_MSG, token.get_error()); + return CR_ERROR; + } + + std::string roles; + auto authenticated_as = Idp_configs::verify_token( + token, auth_data.get_idp(), auth_data.get_ext_user(), + auth_data.get_ext_group(), roles); + + if (!authenticated_as.empty()) { + if (size_t buf_size{std::size(info->authenticated_as)}; + authenticated_as.size() >= buf_size) + LogPluginErr(INFORMATION_LEVEL, ER_LOG_PRINTF_MSG, + "authenticated as name is too long, ignoring user name"); + else + std::strncpy(info->authenticated_as, authenticated_as.c_str(), + buf_size); + } + + if (size_t role_buf_size{std::size(info->external_roles)}; + roles.size() + 1 >= role_buf_size) + LogPluginErr(INFORMATION_LEVEL, ER_LOG_PRINTF_MSG, + "too many roles, ignoring roles"); + else if (!roles.empty()) + std::strncpy(info->external_roles, roles.c_str(), role_buf_size); + + LogPluginErr(INFORMATION_LEVEL, ER_LOG_PRINTF_MSG, + "authentication successful"); + return CR_OK; + } catch (const std::exception &e) { + LogPluginErr(INFORMATION_LEVEL, ER_LOG_PRINTF_MSG, e.what()); + } catch (...) { + LogPluginErr(INFORMATION_LEVEL, ER_LOG_PRINTF_MSG, "authentication failed"); + } + + return CR_ERROR; +} + +static int auth_oidc_generate_hash( // NOLINT(misc-use-anonymous-namespace) + char *outbuf, unsigned int *buflen, const char *inbuf, + unsigned int inbuflen) noexcept { + /* + fail if the buffer specified by the server cannot be copied to the output + buffer + */ + if (*buflen < inbuflen) return 1; /* error */ + strncpy(outbuf, inbuf, inbuflen); + *buflen = strnlen(inbuf, inbuflen); + return 0; /* success */ +} + +static int auth_oidc_validate_hash( // NOLINT(misc-use-anonymous-namespace) + char *const, unsigned int) noexcept { + return 0; /* success */ +} + +static int auth_oidc_set_salt( // NOLINT(misc-use-anonymous-namespace) + const char *password [[maybe_unused]], + unsigned int password_len [[maybe_unused]], + unsigned char *salt [[maybe_unused]], unsigned char *salt_len) noexcept { + *salt_len = 0; + return 0; /* success */ +} + +static MYSQL_SYSVAR_STR( // NOLINT(misc-use-anonymous-namespace) + configuration, Idp_configs::sysvar, PLUGIN_VAR_OPCMDARG | PLUGIN_VAR_STR, + "Configuration of OpenId Connect authentication", + Idp_configs::check, // check + Idp_configs::update, // update + "" // default +); + +static SYS_VAR + *auth_openid_connect_sysvars[] // NOLINT(misc-use-anonymous-namespace) + = {MYSQL_SYSVAR( + configuration), // NOLINT(cppcoreguidelines-pro-type-cstyle-cast) + nullptr}; + +/** + * @brief MySQL authentication plugin interface for OpenID Connect. + * + * Defines the plugin interface including authentication, hashing, and + * validation function pointers. + */ +// NOLINTNEXTLINE(misc-use-internal-linkage) +st_mysql_auth auth_oidc_info = { + MYSQL_AUTHENTICATION_INTERFACE_VERSION, // int interface_version + "authentication_openid_connect_client", // const char *client_auth_plugin + auth_oidc_authenticate, // authentication function + auth_oidc_generate_hash, // generate_authentication_string, + auth_oidc_validate_hash, // validate_authentication_string, + auth_oidc_set_salt, // set_salt, + 0UL, // const unsigned long authentication_flags + nullptr}; + +// NOLINTNEXTLINE(misc-use-internal-linkage) +mysql_declare_plugin(auth_openid_connect){ + MYSQL_AUTHENTICATION_PLUGIN, // type + &auth_oidc_info, // info + "auth_openid_connect", // name + "Percona LLC and/or its affiliates.", // author + "OpenID Connect authentication plugin", // description + PLUGIN_LICENSE_GPL, // license + auth_oidc_init, // init function (when loaded) + nullptr, // check uninstall function + auth_oidc_deinit, // deinit function (when unloaded) + 0x0001, // version + nullptr, // status variables + auth_openid_connect_sysvars, // system variables + nullptr, // reserved + 0, // flags +} mysql_declare_plugin_end; diff --git a/plugin/auth_openid_connect/src/udf.cc b/plugin/auth_openid_connect/src/udf.cc new file mode 100644 index 000000000000..7dd6888266aa --- /dev/null +++ b/plugin/auth_openid_connect/src/udf.cc @@ -0,0 +1,101 @@ +/* +(C) 2026 Percona LLC and/or its affiliates + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; version 2 of the License. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA +*/ + +#include + +#include +#include + +#include "config.h" + +extern "C" { + +/** + * @brief Initialization function for the update_jwks UDF. + * + * This function is called by MySQL when the UDF is loaded. It validates + * the number and types of arguments and sets up the return type. + * + * @param udf_init Pointer to UDF_INIT structure to be filled with metadata. + * @param args Pointer to UDF_ARGS containing argument information. + * @param message Output buffer for error messages (MYSQL_ERRMSG_SIZE bytes). + * @return true if initialization fails, false on success. + * + * @note Arguments: + * - 0 arguments: Updates keys for all IDPs + * - 1 argument (STRING): Updates keys for specific IDP by name + * - More than 1 argument: Error + */ +bool update_jwks_init(UDF_INIT *udf_init, UDF_ARGS *args, char *message) { + if (args->arg_count > 1) { + std::snprintf(message, MYSQL_ERRMSG_SIZE, + "function requires 0 or 1 argument"); + return true; + } + if (args->arg_count == 1 && args->arg_type[0] != STRING_RESULT) { + std::snprintf(message, MYSQL_ERRMSG_SIZE, + "first argument of the function must be string"); + return true; + } + + udf_init->maybe_null = false; + udf_init->decimals = 0; + udf_init->max_length = 20; + + return false; +} + +/** + * @brief Deinitialization function for the update_jwks UDF. + * + * This function is called by MySQL when the UDF is unloaded. + * Currently, does nothing as no resources are allocated during init. + * + * @param udf_init Pointer to UDF_INIT structure. + */ +void update_jwks_deinit(UDF_INIT *udf_init [[maybe_unused]]) {} + +/** + * @brief Updates JWKS keys for one or all IDPs. + * + * This UDF function refreshes the JSON Web Key Set from the JWKS endpoint(s). + * Can either update all IDPs or a specific IDP by name. + * + * @param udf_init Pointer to UDF_INIT structure. + * @param args Pointer to UDF_ARGS containing arguments. + * - args->arg_count == 0: Update all IDPs + * - args->arg_count == 1: Update specific IDP (args->args[0] contains name) + * @param is_null Output parameter to mark result as NULL (set to 0 for valid + * result). + * @param error Output parameter for error flag (set to 1 on error). + * @return Number of updated IDPs on success, negative on error. + * - >= 0: Number of successfully updated IDPs + * - -1: Configuration not found + * - -2: Unexpected error during update + * + * @throws May throw exceptions which are caught and reported via error flag. + */ +long long update_jwks(UDF_INIT *udf_init [[maybe_unused]], UDF_ARGS *args, + char *is_null, char *error) { + *is_null = 0; + const long long ret = (args->arg_count == 0) + ? Idp_configs::update_keys() + : Idp_configs::update_keys(args->args[0]); + *error = (ret >= 0) ? 0 : 1; + return ret; +} +} diff --git a/plugin/auth_openid_connect/tools/create_id_token.cc b/plugin/auth_openid_connect/tools/create_id_token.cc new file mode 100644 index 000000000000..133b0337e7d3 --- /dev/null +++ b/plugin/auth_openid_connect/tools/create_id_token.cc @@ -0,0 +1,275 @@ +/* +(C) 2026 Percona LLC and/or its affiliates + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; version 2 of the License. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA +*/ + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +struct Args { + std::string key_path; + std::string iss = "https://idp-test.com/realms/dummy"; + std::string sub = "idp_user"; + std::string kid; + std::string name = "IDP User"; + std::string aud = "your-client-id"; + int ttl = 60; + std::string groups_json; + std::string email = "idp_user@percona.com"; + std::string algorithm = "RS256"; + std::string out = "./id_token.json"; +}; + +// NOLINTNEXTLINE(misc-use-anonymous-namespace) +static std::string read_file(const std::string &path) { + std::ifstream in(path, std::ios::binary); + if (!in) { + throw std::runtime_error("Cannot open file: " + path); + } + + std::ostringstream ss; + ss << in.rdbuf(); + return ss.str(); +} + +// NOLINTNEXTLINE(misc-use-anonymous-namespace) +static std::vector parse_groups_json( + const std::string &groups_json) { + if (groups_json.empty()) { + return {}; + } + + picojson::value groups_array; + if (const std::string err = picojson::parse(groups_array, groups_json); + !err.empty()) { + throw std::runtime_error("Invalid groups JSON: " + err); + } + + if (!groups_array.is()) { + throw std::runtime_error("groups must be a JSON array"); + } + + std::vector result; + const auto &arr = groups_array.get(); + result.reserve(arr.size()); + + for (const auto &item : arr) { + if (!item.is()) { + throw std::runtime_error("groups array must contain only strings"); + } + result.push_back(item.get()); + } + + return result; +} + +// NOLINTNEXTLINE(misc-use-anonymous-namespace) +static std::string create_signed_id_token( + const std::string &private_key_pem, const std::string &issuer, + const std::string &subject, const std::string &kid, + const std::string &audience, const std::string &name, + const std::vector &groups, const std::string &email, + const int ttl, const std::string &algorithm) { + using namespace std::chrono; + const auto now = system_clock::now(); + const auto exp = now + static_cast(ttl); + + picojson::array groups_array(groups.size()); + for (const auto &group : groups) { + groups_array.emplace_back(group); + } + + auto builder = jwt::create() + .set_issuer(issuer) + .set_subject(subject) + .set_audience(audience) + .set_issued_at(now) + .set_expires_at(exp) + .set_payload_claim("name", jwt::claim(name)) + .set_payload_claim("email", jwt::claim(email)) + .set_payload_claim("email_verified", + jwt::claim(picojson::value(true))) + .set_payload_claim( + "groups", jwt::claim(picojson::value(groups_array))); + + if (!kid.empty()) { + builder.set_key_id(kid); + } + + if (algorithm == "HS256") { + return builder.sign( + jwt::algorithm::hs256(private_key_pem // HMAC signing key + )); + } + if (algorithm == "HS384") { + return builder.sign( + jwt::algorithm::hs384(private_key_pem // HMAC signing key + )); + } + if (algorithm == "HS512") { + return builder.sign( + jwt::algorithm::hs256(private_key_pem // HMAC signing key + )); + } + if (algorithm == "RS256") { + return builder.sign(jwt::algorithm::rs256( + "", // public key -not needed for signing + private_key_pem // private key in PEM format + )); + } + if (algorithm == "RS384") { + return builder.sign(jwt::algorithm::rs384( + "", // public key - not needed for signing + private_key_pem // private key in PEM format + )); + } + if (algorithm == "RS512") { + return builder.sign(jwt::algorithm::rs512( + "", // public key - not needed for signing + private_key_pem // private key in PEM format + )); + } + if (algorithm == "ES256") { + return builder.sign(jwt::algorithm::es256( + "", // public key - not needed for signing + private_key_pem // private key in PEM format + )); + } + if (algorithm == "ES384") { + return builder.sign(jwt::algorithm::es384( + "", // public key - not needed for signing + private_key_pem // private key in PEM format + )); + } + if (algorithm == "ES512") { + return builder.sign(jwt::algorithm::es512( + "", // public key - not needed for signing + private_key_pem // private key in PEM format + )); + } + + throw std::runtime_error( + "Unsupported algorithm or key length, " + "supported algorithms are: RS, ES, HS, " + "supported lengths are: 256, 384, 512."); +} + +// NOLINTNEXTLINE(misc-use-anonymous-namespace) +static void print_usage(const char *progname) { + std::cerr + << "Usage: " << progname << " --key [options]\n" + << "Options:\n" + << " --alg, -a Algorithm (e.g. RS256)\n" + << " --aud, -d Audience\n" + << " --email, -e Email\n" + << " --groups, -g JSON array of groups, e.g. " + "[\"group1\",\"group2\"]\n" + "signing key (required)\n" + << " --iss, -i Issuer\n" + << " --key, -k Path to private key in PEM format or HMAC key\n" + << " --kid, -z Key id\n" + << " --name, -n Name\n" + << " --uot, -o Output file, default: \"./id_token.json\"\n" + << " --sub, -s Subject\n" + << " --ttl, -t Time to live in seconds\n"; +} + +// NOLINTNEXTLINE(misc-use-anonymous-namespace) +static Args parse_args(int argc, char **argv) { + Args args; + + for (int i = 1; i < argc; ++i) { + std::string arg = argv[i]; + + auto require_value = [&](const std::string &opt) -> std::string { + if (i + 1 >= argc) { + throw std::runtime_error("Missing value for option: " + opt); + } + return argv[++i]; + }; + + if (arg == "--key" || arg == "-k") { + args.key_path = require_value(arg); + } else if (arg == "--iss" || arg == "-i") { + args.iss = require_value(arg); + } else if (arg == "--sub" || arg == "-s") { + args.sub = require_value(arg); + } else if (arg == "--kid" || arg == "-z") { + args.kid = require_value(arg); + } else if (arg == "--name" || arg == "-n") { + args.name = require_value(arg); + } else if (arg == "--aud" || arg == "-d") { + args.aud = require_value(arg); + } else if (arg == "--ttl" || arg == "-t") { + args.ttl = std::stoi(require_value(arg)); + } else if (arg == "--groups" || arg == "-g") { + args.groups_json = require_value(arg); + } else if (arg == "--email" || arg == "-e") { + args.email = require_value(arg); + } else if (arg == "--alg" || arg == "-a") { + args.algorithm = require_value(arg); + } else if (arg == "--out" || arg == "-o") { + args.out = require_value(arg); + } else if (arg == "--help" || arg == "-h") { + print_usage(argv[0]); + std::exit(0); + } else { + throw std::runtime_error("Unknown argument: " + arg); + } + } + + if (args.key_path.empty()) { + throw std::runtime_error("Missing required argument: --key"); + } + + return args; +} + +int main(int argc, char **argv) { + try { + const Args args = parse_args(argc, argv); + const std::string private_key_pem = read_file(args.key_path); + const std::vector groups = parse_groups_json(args.groups_json); + + const std::string token = create_signed_id_token( + private_key_pem, args.iss, args.sub, args.kid, args.aud, args.name, + groups, args.email, args.ttl, args.algorithm); + + std::ofstream out(args.out, std::ios::binary); + if (!out) { + throw std::runtime_error("Cannot open output file: " + args.out); + } + out << token; + if (!out) { + throw std::runtime_error("Failed to write token to file: " + args.out); + } + return 0; + } catch (const std::exception &e) { + std::cout << "ERROR: " << e.what() << '\n'; + return 1; + } +} \ No newline at end of file diff --git a/sql-common/client.cc b/sql-common/client.cc index 726818cd7878..7b603e60acb5 100644 --- a/sql-common/client.cc +++ b/sql-common/client.cc @@ -5715,6 +5715,7 @@ void mpvio_info(Vio *vio, MYSQL_PLUGIN_VIO_INFO *info) { info->socket = (int)vio_fd(vio); return; case VIO_TYPE_SSL: { + info->is_tls_established = true; struct sockaddr addr; socklen_t addrlen = sizeof(addr); if (getsockname(vio_fd(vio), &addr, &addrlen)) return; diff --git a/sql-common/oci/CMakeLists.txt b/sql-common/oci/CMakeLists.txt index da312d4f4123..4aa1c6f68cbf 100644 --- a/sql-common/oci/CMakeLists.txt +++ b/sql-common/oci/CMakeLists.txt @@ -25,7 +25,6 @@ ADD_WSHADOW_WARNING() SET(OCI_SOURCES signing_key.cc - ssl.cc utilities.cc ) diff --git a/sql-common/oci/signing_key.cc b/sql-common/oci/signing_key.cc index edf95017b371..edd952e8f144 100644 --- a/sql-common/oci/signing_key.cc +++ b/sql-common/oci/signing_key.cc @@ -30,8 +30,8 @@ #include #include #include +#include "include/base64_encode.h" #include "scope_guard.h" -#include "sql-common/oci/ssl.h" namespace oci { // custom unique_ptr deleter since OPENSSL_free is a macro diff --git a/sql-common/oci/signing_key.h b/sql-common/oci/signing_key.h index 572320a3ff41..91225cc8f12d 100644 --- a/sql-common/oci/signing_key.h +++ b/sql-common/oci/signing_key.h @@ -31,8 +31,8 @@ #include -#include "sql-common/oci/ssl.h" -#include "sql-common/oci/ssl_ptr.h" +#include "include/base64_encode.h" +#include "include/encode_ptr.h" namespace oci { class Signing_Key { diff --git a/sql/threadpool_unix.cc b/sql/threadpool_unix.cc index 7532fdf283e0..c4a9419d8fce 100644 --- a/sql/threadpool_unix.cc +++ b/sql/threadpool_unix.cc @@ -152,8 +152,16 @@ struct alignas(128) thread_group_t { char padding[248]; }; -static_assert(sizeof(thread_group_t) == 512, - "sizeof(thread_group_t) must be 512 to avoid false sharing"); +// thread_group_t is alignas(128); to avoid false sharing the struct must +// occupy a whole number of 128-byte cache lines. Linux/glibc lays this +// out at exactly 512 bytes (the original size when 'padding[248]' was +// hand-tuned), but on macOS / libc++ pthread_mutex_t is larger so the +// struct grows past 512 (e.g. to 640). Both sizes are still multiples of +// 128 so the false-sharing guarantee holds; assert that invariant +// rather than the absolute size. +static_assert(sizeof(thread_group_t) % 128 == 0, + "sizeof(thread_group_t) must be a multiple of 128 to avoid " + "false sharing"); static thread_group_t all_groups[MAX_THREAD_GROUPS]; static uint group_count;