From 2ac57c4b5554432bbac4b91c9b18b173eb400b7c Mon Sep 17 00:00:00 2001 From: Michal Jankowski Date: Sat, 30 May 2026 13:26:14 +0200 Subject: [PATCH 1/3] PS-11253 [8.4]: Fix heap-use-after-free when granting external roles When OpenID Connect authentication maps external roles during login, acl_authenticate() called grant_role() with mpvio->acl_user. That ACL_USER is a copy allocated on the connection's mem_root and is freed when dispatch_command() ends. grant_role() stores ACL_USER by value in the role graph, including the raw user/host pointers. Later DROP USER walks that graph and reads those pointers after the mem_root was cleared, causing a heap-use-after-free (ASAN failure in auth_openid_connect.idp cleanup). Fix: lookup the durable ACL cache entry with find_acl_user() and pass that to grant_role() instead of the mem_root copy. The same problem probably would occur with any other plugin granting roles. --- sql/auth/sql_authentication.cc | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/sql/auth/sql_authentication.cc b/sql/auth/sql_authentication.cc index ffc82e552bb6..f8ce9e728cde 100644 --- a/sql/auth/sql_authentication.cc +++ b/sql/auth/sql_authentication.cc @@ -4237,10 +4237,13 @@ int acl_authenticate(THD *thd, enum_server_command command) { std::string(acl_user->host.get_host())); if (g_external_roles.find(u) != g_external_roles.end()) g_external_roles[u].clear(); + ACL_USER *cached_acl_user = + find_acl_user(acl_user->host.get_host(), acl_user->user, true); for (const auto &role : external_roles) { ACL_USER *acl_role = find_acl_user("", role.c_str(), false); - if (acl_role != nullptr && acl_role->user != nullptr) { - grant_role(acl_role, acl_user, false); + if (acl_role != nullptr && acl_role->user != nullptr && + cached_acl_user != nullptr) { + grant_role(acl_role, cached_acl_user, false); const name_and_host_t r(std::string(acl_role->user), ""); g_external_roles[u].push_back(r); } From 4c7f7839a0f28c01b944d6e8d997677efcac2944 Mon Sep 17 00:00:00 2001 From: Michal Jankowski Date: Fri, 27 Mar 2026 12:32:24 +0100 Subject: [PATCH 2/3] PS-11019 [8.4]: Client side OIDC authentication plugin Upstream added OIDC authentication in 9.x, by this commit the client side plugin is backported to 8.4. Follow up of WL#16269 OpenID Connect (Oauth2 - JWT) Authentication Support Change-Id in upstream: I11944643d4a6098312edd16550c0160e86905063 The upstream commit introduces client side OpenID Connect authentication plugin to MySQL 9.x. Here we port it to 8.4 as part of work on Percona OpenID Connect authentication. --- client/mysql.cc | 29 ++ .../oci/ssl.h => include/base64_encode.h | 0 .../oci/ssl_ptr.h => include/encode_ptr.h | 0 include/mysql.h.pp | 1 + include/mysql/client_plugin.h.pp | 1 + include/mysql/plugin_auth.h.pp | 1 + include/mysql/plugin_auth_common.h | 5 + libmysql/CMakeLists.txt | 3 + .../authentication_oci_client_plugin.cc | 2 +- .../CMakeLists.txt | 64 +++++ .../authentication_openid_connect_client.ver | 28 ++ ...entication_openid_connect_client_plugin.cc | 255 ++++++++++++++++++ mysql-test/mysql-test-run.pl | 5 +- mysys/CMakeLists.txt | 1 + .../oci/ssl.cc => mysys/base64_encode.cc | 4 +- sql-common/client.cc | 1 + sql-common/oci/CMakeLists.txt | 1 - sql-common/oci/signing_key.cc | 2 +- sql-common/oci/signing_key.h | 4 +- 19 files changed, 398 insertions(+), 9 deletions(-) rename sql-common/oci/ssl.h => include/base64_encode.h (100%) rename sql-common/oci/ssl_ptr.h => include/encode_ptr.h (100%) create mode 100644 libmysql/authentication_openid_connect_client/CMakeLists.txt create mode 100644 libmysql/authentication_openid_connect_client/authentication_openid_connect_client.ver create mode 100644 libmysql/authentication_openid_connect_client/authentication_openid_connect_client_plugin.cc rename sql-common/oci/ssl.cc => mysys/base64_encode.cc (99%) 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/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/mysql-test-run.pl b/mysql-test/mysql-test-run.pl index 8b036695ccbf..7f63f1afe78c 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_jwt_generator_test; 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_jwt_generator_test = mtr_exe_exists("$path_client_bindir/jwt_generator_test"); # 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{'JWT_GENERATOR_TEST'} = $exe_jwt_generator_test; my $exe_mysqld = find_mysqld($basedir); $ENV{'MYSQLD'} = $exe_mysqld; 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/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 { From 79b4af793d8f343b1051b311be467c0c3407cae8 Mon Sep 17 00:00:00 2001 From: Michal Jankowski Date: Fri, 5 Jun 2026 09:05:18 +0200 Subject: [PATCH 3/3] PS-10999 [8.4]: OIDC Authentication for Percona Server for MySQL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenID Connect pluggable authentication is a parity feature with MySQL Enterprise Edition. It allows users to authenticate to Percona MySQL Server using OpenID Connect. The user connecting to the server must identify itself with ID Token previously obtained from an Identity Provider. The server verifies if the token was signed by user’s Identity Provider (IDP) and if token’s subject matches user’s name in the Identity Provider domain. The plugin supports group-role mapping and proxy accounts. This commit gathers the following tasks: PS-10849: OpenID Connect Pluggable Authentication PS-11017: MTR tests PS-11018: Add proxy support to OIDC authentication --- .gitmodules | 3 + CMakeLists.txt | 2 + extra/jwt-cpp | 1 + mysql-test/include/plugin.defs | 1 + mysql-test/mysql-test-run.pl | 6 +- mysql-test/std_data/oidc/dummy_oidc_conf.json | 55 ++ mysql-test/std_data/oidc/idp_private.pem | 28 + mysql-test/std_data/oidc/idp_public.pem | 9 + .../inc/keycloak_oidc_conf.inc | 63 +++ .../auth_openid_connect/inc/set_idp_vars.inc | 19 + .../suite/auth_openid_connect/r/auth.result | 55 ++ .../auth_openid_connect/r/group_role.result | 17 + .../suite/auth_openid_connect/r/idp.result | 44 ++ .../suite/auth_openid_connect/r/proxy.result | 57 +++ .../auth_openid_connect/t/auth-master.opt | 1 + .../suite/auth_openid_connect/t/auth.test | 200 ++++++++ .../t/group_role-master.opt | 1 + .../auth_openid_connect/t/group_role.test | 53 ++ .../auth_openid_connect/t/idp-master.opt | 1 + .../suite/auth_openid_connect/t/idp.test | 103 ++++ .../auth_openid_connect/t/proxy-master.opt | 1 + .../suite/auth_openid_connect/t/proxy.test | 146 ++++++ plugin/auth_openid_connect/.clang-tidy | 20 + plugin/auth_openid_connect/CMakeLists.txt | 44 ++ plugin/auth_openid_connect/src/config.cc | 484 ++++++++++++++++++ plugin/auth_openid_connect/src/config.h | 410 +++++++++++++++ plugin/auth_openid_connect/src/id_token.cc | 249 +++++++++ plugin/auth_openid_connect/src/id_token.h | 126 +++++ plugin/auth_openid_connect/src/jwk.cc | 131 +++++ plugin/auth_openid_connect/src/jwk.h | 145 ++++++ plugin/auth_openid_connect/src/jwks.cc | 96 ++++ plugin/auth_openid_connect/src/jwks.h | 84 +++ .../src/plugin_openid_connect.cc | 293 +++++++++++ plugin/auth_openid_connect/src/udf.cc | 101 ++++ .../tools/create_id_token.cc | 334 ++++++++++++ 35 files changed, 3380 insertions(+), 3 deletions(-) create mode 160000 extra/jwt-cpp create mode 100644 mysql-test/std_data/oidc/dummy_oidc_conf.json create mode 100644 mysql-test/std_data/oidc/idp_private.pem create mode 100644 mysql-test/std_data/oidc/idp_public.pem create mode 100644 mysql-test/suite/auth_openid_connect/inc/keycloak_oidc_conf.inc create mode 100644 mysql-test/suite/auth_openid_connect/inc/set_idp_vars.inc create mode 100644 mysql-test/suite/auth_openid_connect/r/auth.result create mode 100644 mysql-test/suite/auth_openid_connect/r/group_role.result create mode 100644 mysql-test/suite/auth_openid_connect/r/idp.result create mode 100644 mysql-test/suite/auth_openid_connect/r/proxy.result create mode 100755 mysql-test/suite/auth_openid_connect/t/auth-master.opt create mode 100644 mysql-test/suite/auth_openid_connect/t/auth.test create mode 100755 mysql-test/suite/auth_openid_connect/t/group_role-master.opt create mode 100644 mysql-test/suite/auth_openid_connect/t/group_role.test create mode 100755 mysql-test/suite/auth_openid_connect/t/idp-master.opt create mode 100644 mysql-test/suite/auth_openid_connect/t/idp.test create mode 100755 mysql-test/suite/auth_openid_connect/t/proxy-master.opt create mode 100644 mysql-test/suite/auth_openid_connect/t/proxy.test create mode 100644 plugin/auth_openid_connect/.clang-tidy create mode 100644 plugin/auth_openid_connect/CMakeLists.txt create mode 100644 plugin/auth_openid_connect/src/config.cc create mode 100644 plugin/auth_openid_connect/src/config.h create mode 100644 plugin/auth_openid_connect/src/id_token.cc create mode 100644 plugin/auth_openid_connect/src/id_token.h create mode 100644 plugin/auth_openid_connect/src/jwk.cc create mode 100644 plugin/auth_openid_connect/src/jwk.h create mode 100644 plugin/auth_openid_connect/src/jwks.cc create mode 100644 plugin/auth_openid_connect/src/jwks.h create mode 100644 plugin/auth_openid_connect/src/plugin_openid_connect.cc create mode 100644 plugin/auth_openid_connect/src/udf.cc create mode 100644 plugin/auth_openid_connect/tools/create_id_token.cc 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/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/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 7f63f1afe78c..a3006c1291fa 100755 --- a/mysql-test/mysql-test-run.pl +++ b/mysql-test/mysql-test-run.pl @@ -382,7 +382,7 @@ sub set_term_args { our $exe_mysql; our $exe_mysql_migrate_keyring; our $exe_mysql_keyring_encryption_test; -our $exe_jwt_generator_test; +our $exe_create_id_token; our $exe_mysqladmin; our $exe_mysqltest; our $exe_mysql_test_event_tracking; @@ -2930,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_jwt_generator_test = mtr_exe_exists("$path_client_bindir/jwt_generator_test"); + $exe_create_id_token = mtr_exe_maybe_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" ], @@ -3492,7 +3492,7 @@ sub environment_setup { $ENV{'MYSQL_SECURE_INSTALLATION'} = "$path_client_bindir/mysql_secure_installation"; $ENV{'OPENSSL_EXECUTABLE'} = $exe_openssl; - $ENV{'JWT_GENERATOR_TEST'} = $exe_jwt_generator_test; + $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/suite/auth_openid_connect/inc/keycloak_oidc_conf.inc b/mysql-test/suite/auth_openid_connect/inc/keycloak_oidc_conf.inc new file mode 100644 index 000000000000..81f007e3614e --- /dev/null +++ b/mysql-test/suite/auth_openid_connect/inc/keycloak_oidc_conf.inc @@ -0,0 +1,63 @@ +let $KEYCLOAK_OIDC_CONF = +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\" + }, + { + \"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\": \"$IDP_HOST/realms/master\", + \"jwks-url\": \"$IDP_HOST/realms/master/protocol/openid-connect/certs\", + \"audiences\": [ + \"myclient\" + ], + \"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/inc/set_idp_vars.inc b/mysql-test/suite/auth_openid_connect/inc/set_idp_vars.inc new file mode 100644 index 000000000000..13b02b2702a8 --- /dev/null +++ b/mysql-test/suite/auth_openid_connect/inc/set_idp_vars.inc @@ -0,0 +1,19 @@ +if ($MTR_OIDC_IDP_HOST) +{ + --let $IDP_HOST = $MTR_OIDC_IDP_HOST +} + +if (!$IDP_HOST) +{ + --let $IDP_HOST = https://keycloak.int.percona.com +} + +if ($MTR_OIDC_USER_ID) +{ + --let $IDP_USER_ID = $MTR_USER_ID +} + +if (!$IDP_USER_ID) +{ + --let $IDP_USER_ID = e3039939-719c-4ba7-99d9-d9efecf5caeb +} \ No newline at end of file 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..42650f77815a --- /dev/null +++ b/mysql-test/suite/auth_openid_connect/r/auth.result @@ -0,0 +1,55 @@ +### 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:///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:"); +CALL mtr.add_suppression("Plugin auth_openid_connect reported: 'syntax error"); +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:///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:///std_data/oidc/dummy_oidc_conf.json'; +user() current_user() +mysql_oidc_user@localhost mysql_oidc_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"}]}}'; +user() current_user() +mysql_oidc_user@localhost mysql_oidc_user@% + +### 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..c4e779ee9386 --- /dev/null +++ b/mysql-test/suite/auth_openid_connect/r/group_role.result @@ -0,0 +1,17 @@ +### INITIALIZE TESTS +INSTALL PLUGIN auth_openid_connect SONAME 'auth_openid_connect.so';; +SET GLOBAL auth_openid_connect_configuration = 'FILE:///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; +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..e9ef66aa5f83 --- /dev/null +++ b/mysql-test/suite/auth_openid_connect/r/idp.result @@ -0,0 +1,44 @@ +### 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 = 'JSON://'; +CREATE USER mysql_oidc_user IDENTIFIED WITH 'auth_openid_connect' AS '{"identity_provider" : "my-keycloak", "user" : ""}';; +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; +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..06744e39dd7d --- /dev/null +++ b/mysql-test/suite/auth_openid_connect/r/proxy.result @@ -0,0 +1,57 @@ +### 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 = '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; +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..d9e408355acd --- /dev/null +++ b/mysql-test/suite/auth_openid_connect/t/auth.test @@ -0,0 +1,200 @@ + +###################### INIT ####################### +if ($CREATE_ID_TOKEN == "") +{ + --skip create_id_token tool not available, skipping test +} + +--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 $CONFIG_JSON_NO_AUD = 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\"}]}} +--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 $TOKEN_ARGS_NO_AUD = --key $MYSQL_TEST_DIR/std_data/oidc/idp_private.pem --sub oidc-user --iss https://idp-test.com/realms/dummy --kid rsa-key-1 --out $TOKEN_FILE +--let $TOKEN_ARGS_MANY_AUDS = --key $MYSQL_TEST_DIR/std_data/oidc/idp_private.pem --sub oidc-user --iss https://idp-test.com/realms/dummy --kid rsa-key-1 --aud first,ee2811b9-10b8,third --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 +--replace_result $CONFIG_FILE FILE:///std_data/oidc/dummy_oidc_conf.json +--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:"); +CALL mtr.add_suppression("Plugin auth_openid_connect reported: 'syntax error"); + +# invalid prefix, expect error +--error ER_WRONG_VALUE_FOR_VAR +SET GLOBAL auth_openid_connect_configuration = 'INVD://'; + +# invalid (non-parsable) JSON, expect error +--error ER_WRONG_VALUE_FOR_VAR +SET GLOBAL auth_openid_connect_configuration = 'JSON://{"invalid"[}'; + +# invalid (incorrect path) FILE, expect error +--error ER_WRONG_VALUE_FOR_VAR +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) +--replace_result $CONFIG_FILE FILE:///std_data/oidc/dummy_oidc_conf.json +--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) +--replace_result $CONFIG_FILE FILE:///std_data/oidc/dummy_oidc_conf.json +--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 + +# aud is missing -failure (FR.8.j) +--exec $CREATE_ID_TOKEN $TOKEN_ARGS_NO_AUD +--error 1 +--exec $MYSQL $MYSQL_OIDC_USER + +# many auds -success +--exec $CREATE_ID_TOKEN $TOKEN_ARGS_MANY_AUDS +--exec $MYSQL $MYSQL_OIDC_USER + +# aud is missing, but audiences were not configured -success +--eval SET GLOBAL auth_openid_connect_configuration = '$CONFIG_JSON_NO_AUD' +--exec $CREATE_ID_TOKEN $TOKEN_ARGS_NO_AUD +--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..fb594b0ec6d5 --- /dev/null +++ b/mysql-test/suite/auth_openid_connect/t/group_role.test @@ -0,0 +1,53 @@ + +###################### INIT ####################### +if ($CREATE_ID_TOKEN == "") +{ + --skip create_id_token tool not available, skipping test +} + +--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'; + +--replace_result $CONFIG_FILE FILE:///std_data/oidc/dummy_oidc_conf.json +--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..aad56be81a72 --- /dev/null +++ b/mysql-test/suite/auth_openid_connect/t/idp.test @@ -0,0 +1,103 @@ + +###################### INIT ####################### +--source suite/auth_openid_connect/inc/set_idp_vars.inc +--source suite/auth_openid_connect/inc/keycloak_oidc_conf.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 $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 $OBTAIN_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 '.id_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' +--replace_result $KEYCLOAK_OIDC_CONF JSON:// +--eval SET GLOBAL auth_openid_connect_configuration = '$KEYCLOAK_OIDC_CONF' +--replace_result $IDP_USER_ID +--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 $OBTAIN_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 ER_CANT_INITIALIZE_UDF +SELECT update_jwks('arg1', 'arg2'); + +## invalid type of argument, expect error +--error ER_CANT_INITIALIZE_UDF +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..ec5908485761 --- /dev/null +++ b/mysql-test/suite/auth_openid_connect/t/proxy.test @@ -0,0 +1,146 @@ + +###################### INIT ####################### +--source include/have_mysql_no_login_plugin.inc +--source suite/auth_openid_connect/inc/set_idp_vars.inc +--source suite/auth_openid_connect/inc/keycloak_oidc_conf.inc +--echo ### INITIALIZE TESTS +--let $IDP_URL = $IDP_HOST/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 $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 $OBTAIN_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 '.id_token'; + +let $OBTAIN_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 '.id_token'; + +let $OBTAIN_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 '.id_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' +--replace_result $KEYCLOAK_OIDC_CONF JSON:// +--eval SET GLOBAL auth_openid_connect_configuration = '$KEYCLOAK_OIDC_CONF' + +--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 $OBTAIN_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 $OBTAIN_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 $OBTAIN_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 $OBTAIN_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 $OBTAIN_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/plugin/auth_openid_connect/.clang-tidy b/plugin/auth_openid_connect/.clang-tidy new file mode 100644 index 000000000000..7c2cb05e1e08 --- /dev/null +++ b/plugin/auth_openid_connect/.clang-tidy @@ -0,0 +1,20 @@ +# (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, + -bugprone-empty-catch, + -misc-use-anonymous-namespace +InheritParentConfig: true diff --git a/plugin/auth_openid_connect/CMakeLists.txt b/plugin/auth_openid_connect/CMakeLists.txt new file mode 100644 index 000000000000..f77db125e6bd --- /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..b60b810e3f84 --- /dev/null +++ b/plugin/auth_openid_connect/src/config.cc @@ -0,0 +1,484 @@ +/* +(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); + +static constexpr std::string_view key_keys{"keys"}; + +// 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 &[key, val] : configs) { + 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 update_keys_error; + } + 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 update_keys_error; + } +} + +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. + * Use #if as this check is done only by gcc >= 13 + */ +#if defined(__GNUC__) && !defined(__clang__) && __GNUC__ >= 13 +#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 { + static constexpr std::string_view key_issuer_name{"issuer-name"}; + static constexpr std::string_view key_audiences{"audiences"}; + static constexpr std::string_view key_group_claim{"group-claim"}; + static constexpr std::string_view key_group_role{"group-role"}; + static constexpr std::string_view key_jwks_url{"jwks-url"}; + // 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, std::string(key_issuer_name), idp_name)}; + + std::unordered_set audiences{}; + const picojson::array &audience_array{json_get( + idp_object, std::string(key_audiences), idp_name, false)}; + for (const auto &audience : audience_array) { + audiences.insert(audience.get()); + } + + const std::string &group_claim{json_get( + idp_object, std::string(key_group_claim), idp_name, false)}; + + std::map roles; + + const picojson::array &roles_array{json_get( + idp_object, std::string(key_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, std::string(key_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, std::string(key_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, std::string(key_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) { + static const std::string key_kty{"kty"}; + static const std::string key_kid{"kid"}; + static constexpr std::string_view key_kty_rsa{"RSA"}; + static constexpr std::string_view key_kty_ec{"EC"}; + static const std::string key_rsa_n{"n"}; + static const std::string key_rsa_e{"e"}; + static const std::string key_ec_crv{"crv"}; + static const std::string key_ec_x{"x"}; + static const std::string key_ec_y{"y"}; + + // 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, key_kty, from)}; + const std::string &kid{json_get(key_object, key_kid, from)}; + + std::string pem_key; + if (kty == key_kty_rsa) { + Rsa_jwk rsa_jwk(json_get(key_object, key_rsa_n, from), + json_get(key_object, key_rsa_e, from)); + pem_key = rsa_jwk.to_pem(); + } else if (kty == key_kty_ec) { + Ec_jwk ec_jwk(json_get(key_object, key_ec_crv, from), + json_get(key_object, key_ec_x, from), + json_get(key_object, key_ec_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()); +} +#if defined(__GNUC__) && !defined(__clang__) && __GNUC__ >= 13 +#pragma GCC diagnostic pop +#endif + +char Idp_configs::parse_prefix(const std::string &prefix) { + 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 prefix 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)); + 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)}; + if (err.empty()) return false; + LogPluginErr(ERROR_LEVEL, ER_LOG_PRINTF_MSG, err.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 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); + const std::unique_lock lock(mutex(), lock_timeout); + if (!lock.owns_lock()) + throw std::runtime_error( + "failed to acquire unique lock on configuration"); + 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); + const std::unique_lock lock(mutex(), lock_timeout); + if (!lock.owns_lock()) + throw std::runtime_error( + "failed to acquire unique lock on configuration "); + 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 on configuration"); + 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..19e4b759bf18 --- /dev/null +++ b/plugin/auth_openid_connect/src/config.h @@ -0,0 +1,410 @@ +/* +(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 Shall be audiences claim shall be checked? + * @return true if audiences were configured, false otherwise. + */ + bool check_audiences() const noexcept { return !audiences.empty(); } + + /** + * @brief Checks if a given audience is allowed. + * @param audience The audience string from the token. + * @return true if the audience is allowed + */ + bool is_audience_allowed(const std::string &audience) const noexcept { + return 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 Prefixes and their length + */ + static constexpr std::string_view file_prefix{"FILE://"}; + static constexpr std::string_view json_prefix{"JSON://"}; + static_assert(file_prefix.length() == json_prefix.length(), + "prefixes must have the same length for correct parsing"); + static constexpr size_t prefix_len{file_prefix.length()}; + + /** + * @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); + + static constexpr long long update_keys_error{ + -1}; ///< Return value indicating that an error occurred + /** + * @brief Updates the keys for all IDPs by calling JWKS. + * @return on success: number of updated IDPs, on failure: update_keys_error + */ + 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: update_keys_error + */ + 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..63181bba8c34 --- /dev/null +++ b/plugin/auth_openid_connect/src/id_token.cc @@ -0,0 +1,249 @@ +/* +(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 +#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) { + constexpr std::string_view rs256("RS256"); + constexpr std::string_view rs384("RS384"); + constexpr std::string_view rs512("RS512"); + constexpr std::string_view es256("ES256"); + constexpr std::string_view es384("ES384"); + constexpr std::string_view es512("ES512"); + constexpr std::string_view hs256("HS256"); + constexpr std::string_view hs384("HS384"); + constexpr std::string_view hs512("HS512"); + constexpr std::string_view ps256("PS256"); + constexpr std::string_view ps384("PS384"); + constexpr std::string_view ps512("PS512"); + + 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 == es384) + return jwt::verify().allow_algorithm(jwt::algorithm::es384(key)); + if (name == es512) + return jwt::verify().allow_algorithm(jwt::algorithm::es512(key)); + if (name == hs256) + return jwt::verify().allow_algorithm(jwt::algorithm::hs256(key)); + if (name == hs384) + return jwt::verify().allow_algorithm(jwt::algorithm::hs384(key)); + if (name == hs512) + return jwt::verify().allow_algorithm(jwt::algorithm::hs512(key)); + if (name == ps256) + return jwt::verify().allow_algorithm(jwt::algorithm::ps256(key)); + if (name == ps384) + return jwt::verify().allow_algorithm(jwt::algorithm::ps384(key)); + if (name == ps512) + return jwt::verify().allow_algorithm(jwt::algorithm::ps512(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 = idp.get_role(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.check_audiences()) { + if (!decoded_token.has_payload_claim("aud")) + throw std::runtime_error("missing audience"); + const auto audiences{decoded_token.get_payload_claim("aud")}; + bool authorized{false}; + // there is a single audience given as string + if (audiences.get_type() == jwt::json::type::string) { + authorized = idp.is_audience_allowed( + decoded_token.get_payload_claim("aud").as_string()); + } + // there are multiple audiences given as array + if (audiences.get_type() == jwt::json::type::array) { + for (const auto &audience : audiences.as_array()) { + if (idp.is_audience_allowed(audience.to_str())) { + authorized = true; + break; + } + } + } + if (!authorized) throw std::runtime_error("audience not authorized"); + } + + // Dealing with groups + const std::string &group_claim_name{idp.get_group_claim()}; + + if (ext_user.empty()) { + // The user is empty, proxying + if (group_claim_name.empty()) + throw std::runtime_error( + "groups claim not configured, proxying not possible"); + + if (!decoded_token.has_payload_claim(group_claim_name)) + throw std::runtime_error("token does not contain groups claim"); + + // 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; + } + + // groups and roles mapping -optional and only if there is not proxying + 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); + + return ""; +} \ No newline at end of file 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..8f1ef996161b --- /dev/null +++ b/plugin/auth_openid_connect/src/jwks.cc @@ -0,0 +1,96 @@ +/* +(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); + // limit the max amount of data received from JWKS to 500KB + constexpr size_t max_jwks{5120000L}; + if (out->size() + total > max_jwks) return 0; + 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, 10L); + 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..3310d09084b0 --- /dev/null +++ b/plugin/auth_openid_connect/src/plugin_openid_connect.cc @@ -0,0 +1,293 @@ +/* +(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); +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(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(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. + * Sets error message if authentication string is incorrect. + * @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(); + const auto idp_it = obj.find("identity_provider"); + if (idp_it == obj.end() || !idp_it->second.is()) { + error = "missing or invalid identity_provider in IDENTIFIED AS"; + return true; + } + idp = idp_it->second.get(); + if (const auto it = obj.find("user"); it != obj.end()) { + if (!it->second.is()) { + error = "invalid user in IDENTIFIED AS"; + return true; + } + ext_user = it->second.get(); + } + if (const auto it = obj.find("group"); it != obj.end()) { + if (!it->second.is()) { + error = "invalid group in IDENTIFIED AS"; + return true; + } + 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(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() + 1 > buf_size) + LogPluginErr(INFORMATION_LEVEL, ER_LOG_PRINTF_MSG, + "authenticated as name is too long, ignoring user name"); + else + std::snprintf(info->authenticated_as, buf_size, "%s", + authenticated_as.c_str()); + } + + if (size_t role_buf_size{std::size(info->external_roles)}; + roles.size() + 1 >= role_buf_size) + LogPluginErr(WARNING_LEVEL, ER_LOG_PRINTF_MSG, + "too many roles, ignoring roles"); + else if (!roles.empty()) + std::snprintf(info->external_roles, role_buf_size, "%s", roles.c_str()); + + 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(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(char *const, unsigned int) noexcept { + return 0; /* success */ +} + +static int auth_oidc_set_salt(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(configuration, Idp_configs::sysvar, + PLUGIN_VAR_OPCMDARG | PLUGIN_VAR_STR, + "Configuration of OpenId Connect authentication", + Idp_configs::check, // check + Idp_configs::update, // update + "" // default +); + +// NOLINTNEXTLINE(cppcoreguidelines-pro-type-cstyle-cast) +static SYS_VAR *auth_openid_connect_sysvars[] = {MYSQL_SYSVAR(configuration), + 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..4d16e6a13bb2 --- /dev/null +++ b/plugin/auth_openid_connect/tools/create_id_token.cc @@ -0,0 +1,334 @@ +/* +(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; + 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_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 (!audience.empty()) { + auto trim = [](std::string st) { + st.erase(st.begin(), std::ranges::find_if(st, [](const unsigned char ch) { + return !std::isspace(ch); + })); + st.erase(std::find_if(st.rbegin(), st.rend(), + [](unsigned char ch) { return !std::isspace(ch); }) + .base(), + st.end()); + return st; + }; + + std::vector parts; + std::size_t start = 0; + + while (start <= audience.size()) { + std::size_t pos = audience.find(',', start); + std::string item = trim(audience.substr( + start, pos == std::string::npos ? std::string::npos : pos - start)); + + if (!item.empty()) { + parts.push_back(std::move(item)); + } + + if (pos == std::string::npos) { + break; + } + start = pos + 1; + } + + if (parts.size() == 1) { + builder.set_audience(parts.front()); + } else if (parts.size() > 1) { + typename jwt::traits::kazuho_picojson::array_type audiences; + for (const auto &part : parts) { + audiences.emplace_back(part); + } + + builder.set_audience(audiences); + } + } + + 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::hs512(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 == "PS256") { + return builder.sign(jwt::algorithm::ps256( + "", // public key -not needed for signing + private_key_pem // private key in PEM format + )); + } + if (algorithm == "PS384") { + return builder.sign(jwt::algorithm::ps384( + "", // public key - not needed for signing + private_key_pem // private key in PEM format + )); + } + if (algorithm == "PS512") { + return builder.sign(jwt::algorithm::ps512( + "", // 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" + << " --oot, -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