From e980259038372684522e1cf8f441797eb0e629e4 Mon Sep 17 00:00:00 2001 From: Michal Jankowski Date: Sat, 30 May 2026 13:26:14 +0200 Subject: [PATCH 1/3] PS-11248 [8.4]: External roles never revoked PS-11253 [8.4]: Fix heap-use-after-free when granting external roles PROBLEM 1 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 1 Lookup the durable ACL cache entry with find_acl_user() and pass that to grant_role() instead of the mem_root copy. PROBLEM 2 An external role once granted was not revoked until the server restarts. FIX 2 Compare roles returned by authentication plugin with roles already granted. Revoke external roles that are no longer granted by the plugin. Simplified external roles container (the external roles always have empty hostname). --- sql/auth/role_tables.cc | 4 +- sql/auth/sql_authentication.cc | 104 +++++++++++++++++++++++++-------- sql/auth/sql_authentication.h | 3 +- sql/auth/sql_authorization.cc | 3 +- 4 files changed, 83 insertions(+), 31 deletions(-) diff --git a/sql/auth/role_tables.cc b/sql/auth/role_tables.cc index 2654fda88ff4..cd85db9dcc4b 100644 --- a/sql/auth/role_tables.cc +++ b/sql/auth/role_tables.cc @@ -343,9 +343,7 @@ bool populate_roles_caches(THD *thd, Table_ref *tablelst) { } for (const auto &r : p.second) { - const char *host2 = r.second.c_str(); - const char *name = r.first.c_str(); - ACL_USER *acl_role = find_acl_user(host2, name, false); + ACL_USER *acl_role = find_acl_user("", r.c_str(), false); if (acl_role == nullptr) { continue; } diff --git a/sql/auth/sql_authentication.cc b/sql/auth/sql_authentication.cc index 87c780136935..59bd48ce2a34 100644 --- a/sql/auth/sql_authentication.cc +++ b/sql/auth/sql_authentication.cc @@ -34,6 +34,10 @@ #include #include +#include +#include +#include +#include #include /* std::string */ #include #include /* std::vector */ @@ -3997,6 +4001,80 @@ static void check_and_update_password_lock_state(MPVIO_EXT &mpvio, THD *thd, } } +static void apply_external_roles(THD *thd, const char *plugin_roles_list, + const ACL_USER *acl_user) { + // shall be always correct, we are passing a table + assert(plugin_roles_list != nullptr); + if (acl_user->user == nullptr) return; + std::vector plugin_roles; + if (plugin_roles_list[0] != '\0') + boost::algorithm::split(plugin_roles, plugin_roles_list, + boost::is_any_of(",")); + + // acl user is a copy, the below is safe + const name_and_host_t user(std::string(acl_user->user), + std::string(acl_user->host.get_host())); + + // Adding or removing external roles requires locking + Acl_cache_lock_guard acl_cache_lock_guard(thd, + Acl_cache_lock_mode::WRITE_MODE); + acl_cache_lock_guard.lock(); + const auto user_roles_it = g_external_roles.find(user); + // we proceed only if some roles for the user either were added + // in the past or are being added + if (user_roles_it == g_external_roles.end() && plugin_roles.empty()) return; + + // we need a pointer to the really cached user + ACL_USER *cached_acl_user = + find_acl_user(acl_user->host.get_host(), acl_user->user, true); + if (cached_acl_user == nullptr) return; + + // if no roles added so far, add all roles returned by the plugin that exist + if (user_roles_it == g_external_roles.end()) { + std::vector new_user_roles; + for (auto const &role : plugin_roles) { + ACL_USER *acl_role = find_acl_user("", role.c_str(), false); + if (acl_role != nullptr && acl_role->user != nullptr) { + grant_role(acl_role, cached_acl_user, false); + new_user_roles.push_back(role); + } + } + // set the new user roles to the external roles container + if (!new_user_roles.empty()) + g_external_roles.emplace(user, std::move(new_user_roles)); + } else { + std::vector new_user_roles; + // added roles + for (auto const &role : plugin_roles) + if (std::ranges::find(user_roles_it->second, role) == + user_roles_it->second.end()) { + // role not yet granted + ACL_USER *acl_role = find_acl_user("", role.c_str(), false); + if (acl_role != nullptr && acl_role->user != nullptr) { + grant_role(acl_role, cached_acl_user, false); + new_user_roles.push_back(role); + } + } else + // role already granted + new_user_roles.push_back(role); + + // removed roles + for (auto const &role : user_roles_it->second) + if (std::ranges::find(plugin_roles, role) == plugin_roles.end()) { + // role to be revoken + ACL_USER *acl_role = find_acl_user("", role.c_str(), false); + if (acl_role != nullptr && acl_role->user != nullptr) + revoke_role(thd, acl_role, cached_acl_user); + } + + // no external user roles from now + if (new_user_roles.empty()) + g_external_roles.erase(user_roles_it); + else + std::swap(user_roles_it->second, new_user_roles); + } +} + /** Generate ER_SERVER_OFFLINE_MODE with several possible variants of the error text, depending on whether OFFLINE_MODE system variable has a "reason" @@ -4309,30 +4387,8 @@ int acl_authenticate(THD *thd, enum_server_command command) { sctx->set_master_access(acl_user->access, *(mpvio.restrictions)); assign_priv_user_host(sctx, const_cast(acl_user)); - std::vector external_roles; - if (strlen(mpvio.auth_info.external_roles) > 0) { - boost::algorithm::split(external_roles, mpvio.auth_info.external_roles, - boost::is_any_of(",")); - } + apply_external_roles(thd, mpvio.auth_info.external_roles, acl_user); - if (acl_user->user != nullptr && !external_roles.empty()) { - // Adding external roles - Acl_cache_lock_guard acl_cache_lock2(thd, - Acl_cache_lock_mode::WRITE_MODE); - acl_cache_lock2.lock(); - const name_and_host_t u(std::string(acl_user->user), - std::string(acl_user->host.get_host())); - if (g_external_roles.find(u) != g_external_roles.end()) - g_external_roles[u].clear(); - 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); - const name_and_host_t r(std::string(acl_role->user), ""); - g_external_roles[u].push_back(r); - } - } - } /* Assign default role */ { List_of_auth_id_refs default_roles; @@ -4537,7 +4593,7 @@ int acl_authenticate(THD *thd, enum_server_command command) { ret = 0; end: if (mpvio.restrictions) mpvio.restrictions->~Restrictions(); - /* Ready to handle queries */ + /* Ready to handle queries */ #ifdef HAVE_PSI_THREAD_INTERFACE LEX_CSTRING main_sctx_user = thd->m_main_security_ctx.user(); LEX_CSTRING main_sctx_host_or_ip = thd->m_main_security_ctx.host_or_ip(); diff --git a/sql/auth/sql_authentication.h b/sql/auth/sql_authentication.h index 39e885b47f55..b34f3206d721 100644 --- a/sql/auth/sql_authentication.h +++ b/sql/auth/sql_authentication.h @@ -245,8 +245,7 @@ class Cached_authentication_plugins { }; using name_and_host_t = std::pair; -using external_roles_t = - std::map>; +using external_roles_t = std::map>; extern external_roles_t g_external_roles; extern Cached_authentication_plugins *g_cached_authentication_plugins; diff --git a/sql/auth/sql_authorization.cc b/sql/auth/sql_authorization.cc index 49c759dd7431..d182c1bd3b7b 100644 --- a/sql/auth/sql_authorization.cc +++ b/sql/auth/sql_authorization.cc @@ -3254,9 +3254,8 @@ bool mysql_revoke_role(THD *thd, const List *users, roles_it.rewind(); while (LEX_USER *role = roles_it++) { for (const auto &r : p.second) { - const char *name = r.first.c_str(); Role_id id(role->user.str, ""); - Role_id id2(name, ""); + Role_id id2(r.c_str(), ""); if (id == id2) { my_error(ER_DYNAMIC_ROLE, MYF(0), role->user.str); commit_and_close_mysql_tables(thd); From 0ed42d7501b218b0a9a5d4a2fdc94fe918d300c1 Mon Sep 17 00:00:00 2001 From: Michal Jankowski Date: Fri, 5 Jun 2026 09:05:18 +0200 Subject: [PATCH 2/3] PS-10999 [9.7]: 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 PS-11065: Telemetry support for OpenID Connect authentication PS-11220: Add MySQL instrumentation to memory --- .gitmodules | 3 + CMakeLists.txt | 2 + .../telemetry_status_allowlist.h | 7 + extra/jwt-cpp | 1 + mysql-test/include/plugin.defs | 1 + mysql-test/mysql-test-run.pl | 3 + 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 | 67 +++ .../auth_openid_connect/r/group_role.result | 27 + .../suite/auth_openid_connect/r/idp.result | 53 ++ .../suite/auth_openid_connect/r/proxy.result | 66 +++ .../auth_openid_connect/r/telemetry.result | 29 + .../auth_openid_connect/t/auth-master.opt | 1 + .../suite/auth_openid_connect/t/auth.test | 204 +++++++ .../t/group_role-master.opt | 1 + .../auth_openid_connect/t/group_role.test | 59 ++ .../auth_openid_connect/t/idp-master.opt | 1 + .../suite/auth_openid_connect/t/idp.test | 105 ++++ .../auth_openid_connect/t/proxy-master.opt | 1 + .../suite/auth_openid_connect/t/proxy.test | 148 ++++++ .../t/telemetry-master.opt | 1 + .../auth_openid_connect/t/telemetry.test | 40 ++ plugin/auth_openid_connect/.clang-tidy | 20 + plugin/auth_openid_connect/CMakeLists.txt | 45 ++ plugin/auth_openid_connect/src/config.cc | 482 +++++++++++++++++ plugin/auth_openid_connect/src/config.h | 502 ++++++++++++++++++ plugin/auth_openid_connect/src/id_token.cc | 254 +++++++++ plugin/auth_openid_connect/src/id_token.h | 150 ++++++ plugin/auth_openid_connect/src/jwk.cc | 211 ++++++++ plugin/auth_openid_connect/src/jwk.h | 163 ++++++ plugin/auth_openid_connect/src/jwks.cc | 95 ++++ plugin/auth_openid_connect/src/jwks.h | 84 +++ .../src/plugin_openid_connect.cc | 372 +++++++++++++ .../src/psi_openid_connect.cc | 40 ++ .../src/psi_openid_connect.h | 83 +++ plugin/auth_openid_connect/src/udf.cc | 101 ++++ .../tools/create_id_token.cc | 334 ++++++++++++ 41 files changed, 3930 insertions(+) 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 100644 mysql-test/suite/auth_openid_connect/r/telemetry.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 100755 mysql-test/suite/auth_openid_connect/t/telemetry-master.opt create mode 100644 mysql-test/suite/auth_openid_connect/t/telemetry.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/psi_openid_connect.cc create mode 100644 plugin/auth_openid_connect/src/psi_openid_connect.h 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 faa929a3e172..c53583ffb853 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -164,6 +164,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/components/percona_telemetry/telemetry_status_allowlist.h b/components/percona_telemetry/telemetry_status_allowlist.h index c1343fe9112f..cd7cf3d643f0 100644 --- a/components/percona_telemetry/telemetry_status_allowlist.h +++ b/components/percona_telemetry/telemetry_status_allowlist.h @@ -39,6 +39,13 @@ inline constexpr std::string_view kStatusAllowlistCsv = "'Aborted_clients'," "'Aborted_connects'," + "'auth_openid_connect_users_authenticated'," + "'auth_openid_connect_users_denied'," + "'auth_openid_connect_users_proxied'," + "'auth_openid_connect_users_with_roles'," + "'auth_openid_connect_configured_idps'," + "'auth_openid_connect_configured_idps_using_jwks'," + "'auth_openid_connect_configured_group_role_maps'," "'Acl_cache_items_count'," "'Binlog_cache_disk_use'," "'Binlog_cache_use'," 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 dd6dfce62a34..544e2473deb8 100644 --- a/mysql-test/include/plugin.defs +++ b/mysql-test/include/plugin.defs @@ -244,3 +244,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 dd97c314d9b1..39ef88a78967 100755 --- a/mysql-test/mysql-test-run.pl +++ b/mysql-test/mysql-test-run.pl @@ -399,6 +399,7 @@ sub set_term_args { our $exe_mysql_migrate_keyring; our $exe_mysql_keyring_encryption_test; our $exe_mysql_test_jwt_generator; +our $exe_create_id_token; our $exe_mysqladmin; our $exe_mysqltest; our $exe_mysql_test_event_tracking; @@ -3002,6 +3003,7 @@ () $exe_mysql_keyring_encryption_test = mtr_exe_exists("$path_client_bindir/mysql_keyring_encryption_test"); $exe_mysql_test_jwt_generator = mtr_exe_maybe_exists("$path_client_bindir/mysql_test_jwt_generator"); + $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" ], @@ -3629,6 +3631,7 @@ sub environment_setup { "$path_client_bindir/mysql_secure_installation"; $ENV{'OPENSSL_EXECUTABLE'} = $exe_openssl; $ENV{'MYSQL_TEST_JWT_GENERATOR'} = $exe_mysql_test_jwt_generator; + $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..8f501f145c59 --- /dev/null +++ b/mysql-test/suite/auth_openid_connect/r/auth.result @@ -0,0 +1,67 @@ +### 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@% +SHOW GLOBAL STATUS LIKE 'auth_openid_connect%'; +Variable_name Value +auth_openid_connect_configured_group_role_maps 0 +auth_openid_connect_configured_idps 1 +auth_openid_connect_configured_idps_using_jwks 0 +auth_openid_connect_users_authenticated 5 +auth_openid_connect_users_denied 16 +auth_openid_connect_users_proxied 0 +auth_openid_connect_users_with_roles 0 +SELECT COUNT_ALLOC>0, SUM_NUMBER_OF_BYTES_ALLOC>0, CURRENT_NUMBER_OF_BYTES_USED>0 FROM performance_schema.memory_summary_global_by_event_name WHERE EVENT_NAME LIKE 'memory/auth_openid_connect/%'; +COUNT_ALLOC>0 SUM_NUMBER_OF_BYTES_ALLOC>0 CURRENT_NUMBER_OF_BYTES_USED>0 +1 1 1 + +### 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..2165eedbc1c5 --- /dev/null +++ b/mysql-test/suite/auth_openid_connect/r/group_role.result @@ -0,0 +1,27 @@ +### 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 +### Connect user without dynamically added role, verify the roles were revoked +SHOW GLOBAL STATUS LIKE 'auth_openid_connect%'; +Variable_name Value +auth_openid_connect_configured_group_role_maps 2 +auth_openid_connect_configured_idps 2 +auth_openid_connect_configured_idps_using_jwks 0 +auth_openid_connect_users_authenticated 5 +auth_openid_connect_users_denied 0 +auth_openid_connect_users_proxied 0 +auth_openid_connect_users_with_roles 3 + +### 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..4ffeae32a5f2 --- /dev/null +++ b/mysql-test/suite/auth_openid_connect/r/idp.result @@ -0,0 +1,53 @@ +### 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 +SHOW GLOBAL STATUS LIKE 'auth_openid_connect%'; +Variable_name Value +auth_openid_connect_configured_group_role_maps 4 +auth_openid_connect_configured_idps 3 +auth_openid_connect_configured_idps_using_jwks 2 +auth_openid_connect_users_authenticated 3 +auth_openid_connect_users_denied 1 +auth_openid_connect_users_proxied 0 +auth_openid_connect_users_with_roles 3 + +### 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..1d1123e7edc5 --- /dev/null +++ b/mysql-test/suite/auth_openid_connect/r/proxy.result @@ -0,0 +1,66 @@ +### 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; +SHOW GLOBAL STATUS LIKE 'auth_openid_connect%'; +Variable_name Value +auth_openid_connect_configured_group_role_maps 4 +auth_openid_connect_configured_idps 3 +auth_openid_connect_configured_idps_using_jwks 2 +auth_openid_connect_users_authenticated 4 +auth_openid_connect_users_denied 2 +auth_openid_connect_users_proxied 4 +auth_openid_connect_users_with_roles 0 + +### 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/r/telemetry.result b/mysql-test/suite/auth_openid_connect/r/telemetry.result new file mode 100644 index 000000000000..61a270e36c25 --- /dev/null +++ b/mysql-test/suite/auth_openid_connect/r/telemetry.result @@ -0,0 +1,29 @@ +# restart +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"}'; +SET GLOBAL auth_openid_connect_configuration = 'FILE://'; +user() current_user() +mysql_oidc_user@localhost mysql_oidc_user@% + "auth_openid_connect" + "name": "auth_openid_connect_configured_group_role_maps", + "variable_value": "2" +-- + "name": "auth_openid_connect_configured_idps", + "variable_value": "2" +-- + "name": "auth_openid_connect_configured_idps_using_jwks", + "variable_value": "0" +-- + "name": "auth_openid_connect_users_authenticated", + "variable_value": "1" +-- + "name": "auth_openid_connect_users_denied", + "variable_value": "0" +-- + "name": "auth_openid_connect_users_proxied", + "variable_value": "0" +-- + "name": "auth_openid_connect_users_with_roles", + "variable_value": "0" +DROP USER 'mysql_oidc_user'@'%'; +UNINSTALL PLUGIN auth_openid_connect; 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..0507e994357d --- /dev/null +++ b/mysql-test/suite/auth_openid_connect/t/auth.test @@ -0,0 +1,204 @@ + +###################### 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 + +SHOW GLOBAL STATUS LIKE 'auth_openid_connect%'; + +# ensure memory instrumentation works +SELECT COUNT_ALLOC>0, SUM_NUMBER_OF_BYTES_ALLOC>0, CURRENT_NUMBER_OF_BYTES_USED>0 FROM performance_schema.memory_summary_global_by_event_name WHERE EVENT_NAME LIKE 'memory/auth_openid_connect/%'; +###################### 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..2a253709a8ad --- /dev/null +++ b/mysql-test/suite/auth_openid_connect/t/group_role.test @@ -0,0 +1,59 @@ + +###################### 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" + +--echo ### Connect user without dynamically added role, verify the roles were revoked +--exec $CREATE_ID_TOKEN $COMMON_TOKEN_ARGS +--error 1 +--exec $MYSQL $MYSQL_OIDC_USER -e "SET ROLE accounting" + +SHOW GLOBAL STATUS LIKE 'auth_openid_connect%'; + +###################### 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..9427dc10e023 --- /dev/null +++ b/mysql-test/suite/auth_openid_connect/t/idp.test @@ -0,0 +1,105 @@ + +###################### 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); + +SHOW GLOBAL STATUS LIKE 'auth_openid_connect%'; + +###################### 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..eda1b1698f16 --- /dev/null +++ b/mysql-test/suite/auth_openid_connect/t/proxy.test @@ -0,0 +1,148 @@ + +###################### 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; + +SHOW GLOBAL STATUS LIKE 'auth_openid_connect%'; + +###################### 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/mysql-test/suite/auth_openid_connect/t/telemetry-master.opt b/mysql-test/suite/auth_openid_connect/t/telemetry-master.opt new file mode 100755 index 000000000000..3c7dc63a1570 --- /dev/null +++ b/mysql-test/suite/auth_openid_connect/t/telemetry-master.opt @@ -0,0 +1 @@ +$AUTH_OIDC_OPT diff --git a/mysql-test/suite/auth_openid_connect/t/telemetry.test b/mysql-test/suite/auth_openid_connect/t/telemetry.test new file mode 100644 index 000000000000..790c8d6b43fa --- /dev/null +++ b/mysql-test/suite/auth_openid_connect/t/telemetry.test @@ -0,0 +1,40 @@ +###################### INIT ####################### +--source include/big_test.inc +--source include/have_percona_telemetry.inc +if ($CREATE_ID_TOKEN == "") +{ + --skip create_id_token tool not available, skipping test +} +--let $CONFIG_FILE = FILE://$MYSQL_TEST_DIR/std_data/oidc/dummy_oidc_conf.json +--let $TOKEN_FILE = $MYSQLTEST_VARDIR/tmp/id_token.txt +--let $TELEMETRY_DIR = $MYSQLTEST_VARDIR/tmp/telemetry +--let $GRACE_INTERVAL = 20 +--let $SLEEP_TIME = 21 + +--mkdir $TELEMETRY_DIR + +###################### TESTS ####################### +# restart the server with custom telemetry file path +--let $restart_parameters = "restart:--percona_telemetry.grace_interval=$GRACE_INTERVAL --percona_telemetry.telemetry_root_dir=$TELEMETRY_DIR" +--let $do_not_echo_parameters = 1 +--source include/restart_mysqld.inc + +--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"}'; + +--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 --out $TOKEN_FILE --kid rsa-key-1 +--replace_result $CONFIG_FILE FILE:// +--eval SET GLOBAL auth_openid_connect_configuration = '$CONFIG_FILE' +--exec $MYSQL --authentication-openid-connect-client-id-token-file=$TOKEN_FILE --plugin-dir=$AUTH_OIDC_DIR --user=mysql_oidc_user -e "SELECT user(), current_user()" + +--sleep $SLEEP_TIME + +--exec grep -rh '"auth_openid_connect"' $TELEMETRY_DIR +--exec grep -A 1 -rh '"name": "auth_openid_connect_' $TELEMETRY_DIR + +###################### CLEANUP ####################### +DROP USER 'mysql_oidc_user'@'%'; +UNINSTALL PLUGIN auth_openid_connect; +--remove_file $TOKEN_FILE +--force-rmdir $TELEMETRY_DIR 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..a3fbc80fe80e --- /dev/null +++ b/plugin/auth_openid_connect/CMakeLists.txt @@ -0,0 +1,45 @@ +# 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/psi_openid_connect.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..e329a6d0a269 --- /dev/null +++ b/plugin/auth_openid_connect/src/config.cc @@ -0,0 +1,482 @@ +/* +(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); +Idp_config_statistics Idp_configs::statistics; + +static constexpr std::string_view key_keys{"keys"}; + +/** + * @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 Config_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 + +Config_string_map Idp_configs::load_group_roles( + const picojson::object &idp_object, const std::string &idp_name) { + static constexpr std::string_view key_group_role{"group-role"}; + + Config_string_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()); + } + return roles; +} + +Config_string_set Idp_configs::load_audiences( + const picojson::object &idp_object, const std::string &idp_name) { + static constexpr std::string_view key_audiences{"audiences"}; + Config_string_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(Config_string(audience.get())); + } + return audiences; +} + +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_group_claim{"group-claim"}; + 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 &jwks_url{json_get( + idp_object, std::string(key_jwks_url), idp_name, false)}; + + Idp_config &config{ + idp_configs + .emplace(idp_name, + Idp_config(json_get( + idp_object, std::string(key_issuer_name), + idp_name), + jwks_url, + json_get( + idp_object, std::string(key_group_claim), + idp_name, false), + load_audiences(idp_object, idp_name), + load_group_roles(idp_object, idp_name))) + .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 Config_string kid{json_get(key_object, key_kid, from)}; + + Config_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) { + 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://"); + } +} + +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); + Idp_config_statistics new_statistics{new_configs->collect_statistics()}; + 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(); + statistics = new_statistics; + } catch (const std::exception &e) { + LogPluginErr(ERROR_LEVEL, ER_LOG_PRINTF_MSG, e.what()); + } catch (...) { + LogPluginErr(ERROR_LEVEL, ER_LOG_PRINTF_MSG, + "failed to update configuration"); + } +} + +void Idp_configs::set(const char *variable) noexcept { + // This function sets the configuration on plugin init, so it is safe to set + // the config directly + try { + std::string config_json; + parse_var(variable, config_json); + current(variable)->load(config_json); + statistics = current()->collect_statistics(); + } 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..bfba0e91530a --- /dev/null +++ b/plugin/auth_openid_connect/src/config.h @@ -0,0 +1,502 @@ +/* +(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" +#include "psi_openid_connect.h" + +class Id_token; + +template +using Config_allocator = + Psi_openid_connect::Allocator; + +using Config_string = + std::basic_string, + Psi_openid_connect::Config_allocator>; + +class Idp_config; +struct Config_string_hash { + using is_transparent = void; + std::size_t operator()(std::string_view s) const noexcept { + return std::hash{}(s); + } + std::size_t operator()(const Config_string &s) const noexcept { + return (*this)(std::string_view{s.data(), s.size()}); + } +}; + +using Config_string_set = + std::unordered_set, + Psi_openid_connect::Config_allocator>; +using Config_string_map = + std::map, + Psi_openid_connect::Config_allocator< + std::pair>>; +using Config_idp_map = + std::map, + Psi_openid_connect::Config_allocator< + std::pair>>; + +/** + * @brief Statistics for Identity Provider (IDP) configurations. + */ +struct Idp_config_statistics { + long long configured_idps{0}; ///< Total number of configured IDPs. + long long configured_idps_using_jwks{ + 0}; ///< Number of configured IDPs that use JWKS for key management. + long long configured_role_maps{ + 0}; ///< Total number of group-to-role mappings across all IDPs. +}; + +/** + * @class Idp_config + * @brief Configuration for a single Identity Provider (IDP). + */ +class Idp_config { + private: + Config_string issuer_name; ///< The token's issuer name. + Jwks jwks; ///< Object managing the JSON Web Key Set. + Config_string group_claim; ///< Name of the claim in the JWT that contains + ///< group information. + Config_string_set audiences; ///< Set of allowed audiences for the token. + Config_string_map roles; ///< Map of IDP groups to database roles. + Config_string_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_view issuer_name, + const std::string_view jwks_url, + const std::string_view group_claim, Config_string_set &&audiences, + Config_string_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. + */ + std::string get_issuer_name() const noexcept { + return std::string(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. + */ + std::string_view 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. + */ + std::string_view 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. + */ + std::string_view get_pub_key(const std::string_view kid) const { + const auto it = keys.find(kid); + + if (it == keys.end()) throw std::out_of_range("KID not found"); + return it->second; + } + + /** + * @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_view 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. + */ + std::string_view get_role(const std::string_view group) const noexcept { + static Config_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); } + + void add_statistics(Idp_config_statistics &statistics) const noexcept { + statistics.configured_idps++; + statistics.configured_role_maps += roles.size(); + if (!jwks.get_url().empty()) statistics.configured_idps_using_jwks++; + } +}; + +/** + * @class Idp_configs + * @brief Manages a collection of Identity Provider configurations. + */ +class Idp_configs { + private: + Config_string sysvar_str{}; ///< Value of the configuration system variable. + Config_idp_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{15}; + + /** + * @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 Load mapping of IDP groups to database roles from an IDP JSON + * object. + * + * @param idp_object The picojson::object that contains the IDP + * configuration. + * @param idp_name The name of the IDP + * @return map of IDP group names to database role names. If no mapping is + * present the returned map will be empty. + */ + static Config_string_map load_group_roles(const picojson::object &idp_object, + const std::string &idp_name); + + /** + * @brief Load the set of allowed audiences from an IDP JSON object. + * + * @param idp_object The picojson::object that contains the IDP + * configuration. + * @param idp_name The name of the IDP + * + * @return An unordered set containing the allowed audience + * strings. If no audiences are configured the set will be empty. + */ + static Config_string_set load_audiences(const picojson::object &idp_object, + const std::string &idp_name); + + /** + * @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. + * @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); + + /** + * @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(std::string_view idp_name) { + const auto it = idp_configs.find(idp_name); + if (it == idp_configs.end()) + throw std::runtime_error(std::string("IDP not found: ") + + std::string(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 Config_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 Config_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. + static Idp_config_statistics + statistics; ///< Statistics about the current configuration. + + /** + * @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; + + /** + * @brief Collects statistics about the current configuration. + * @return statistics structure. + */ + Idp_config_statistics collect_statistics() const { + Idp_config_statistics new_statistics; + for (const auto &idp : idp_configs) { + idp.second.add_statistics(new_statistics); + } + return new_statistics; + } +}; + +#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..9f4f5bdd5fc9 --- /dev/null +++ b/plugin/auth_openid_connect/src/id_token.cc @@ -0,0 +1,254 @@ +/* +(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 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 == 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 groups{groups_claim.as_array()}; + if (groups.empty()) throw std::runtime_error("empty groups claim"); + return groups.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_view 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 auto issuer{decoded_token.get_issuer()}; + if (issuer != idp.get_issuer_name()) + throw std::runtime_error("Token not issued by " + idp.get_issuer_name()); + 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(issuer)) + : get_verifier(decoded_token.get_header_claim("alg").as_string(), + pub_key) + .with_claim("iss", jwt::claim(issuer)) + .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()) verify_audiences(idp, decoded_token); + + // Dealing with groups + const std::string group_claim_name{idp.get_group_claim()}; + + // If the user is empty: proxying + if (ext_user.empty()) + return handle_proxying(decoded_token, ext_group, group_claim_name); + + // 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 ""; +} + +void Id_token::verify_audiences( + const Idp_config &idp, + const jwt::decoded_jwt &decoded_token) { + 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 + else 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"); +} + +std::string Id_token::handle_proxying( + const jwt::decoded_jwt &decoded_token, + const std::string &ext_group, const std::string &group_claim_name) { + 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; +} \ 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..10f41a0493a7 --- /dev/null +++ b/plugin/auth_openid_connect/src/id_token.h @@ -0,0 +1,150 @@ +/* +(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); + + /** + * @brief Verifies audiences claim in the token with audiences configured to + * IDP. + * @param idp The identity provider configuration. + * @param decoded_token The decoded token. + * @throws std::runtime_error if verification failed. + */ + static void verify_audiences( + const Idp_config &idp, + const jwt::decoded_jwt &decoded_token); + + /** + * @brief Handles proxying. + * @param decoded_token The decoded token. + * @param ext_group The expected external group. + * @param group_claim_name name of the group claim in the token + * @throws std::runtime_error if verification failed. + * @return The proxy user name (group name) if proxying is successful, or an + * empty. + */ + static std::string handle_proxying( + const jwt::decoded_jwt &decoded_token, + const std::string &ext_group, const std::string &group_claim_name); + + 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..3e6eea5140a4 --- /dev/null +++ b/plugin/auth_openid_connect/src/jwk.cc @@ -0,0 +1,211 @@ +/* +(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 +#ifdef JWK_OPENSSL_3_0 +#include +#include +#include +#else +#include +#include +#endif +#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; +} + +#ifdef JWK_OPENSSL_3_0 +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()); +} +#else +EVP_PKEY *Rsa_jwk::construct_pkey() { + 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); + std::unique_ptr bn_n( + BN_bin2bn(n_bytes.data(), n_bytes.size(), nullptr), BN_free); + std::unique_ptr bn_e( + BN_bin2bn(e_bytes.data(), e_bytes.size(), nullptr), BN_free); + if (!bn_n || !bn_e) throw std::runtime_error("BN_bin2bn failed for RSA"); + + std::unique_ptr rsa(RSA_new(), RSA_free); + if (!rsa) throw std::runtime_error("RSA_new failed"); + + // RSA_set0_key takes ownership of the BIGNUMs on success + BIGNUM *raw_n = bn_n.release(); + BIGNUM *raw_e = bn_e.release(); + if (RSA_set0_key(rsa.get(), raw_n, raw_e, nullptr) == 0) { + BN_free(raw_n); + BN_free(raw_e); + throw std::runtime_error("RSA_set0_key failed"); + } + + std::unique_ptr pkey(EVP_PKEY_new(), + EVP_PKEY_free); + if (!pkey) throw std::runtime_error("EVP_PKEY_new failed"); + if (EVP_PKEY_assign_RSA(pkey.get(), rsa.release()) == 0) + throw std::runtime_error("EVP_PKEY_assign_RSA failed"); + return pkey.release(); +} +#endif + +#ifdef JWK_OPENSSL_3_0 +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()); +} +#else +EVP_PKEY *Ec_jwk::construct_pkey() { + if (crv.empty() || x.empty() || y.empty()) + throw std::runtime_error("EC requires crv, x, y"); + + int nid; + if (crv == "P-256") + nid = NID_X9_62_prime256v1; + else if (crv == "P-384") + nid = NID_secp384r1; + else if (crv == "P-521") + nid = NID_secp521r1; + else + throw std::runtime_error("Unsupported EC curve: " + crv); + + std::unique_ptr ec_key( + EC_KEY_new_by_curve_name(nid), EC_KEY_free); + if (!ec_key) throw std::runtime_error("EC_KEY_new_by_curve_name failed"); + + const auto x_bytes = base64url_decode(x); + const auto y_bytes = base64url_decode(y); + const std::unique_ptr bn_x( + BN_bin2bn(x_bytes.data(), x_bytes.size(), nullptr), BN_free); + const std::unique_ptr bn_y( + BN_bin2bn(y_bytes.data(), y_bytes.size(), nullptr), BN_free); + if (!bn_x || !bn_y) throw std::runtime_error("BN_bin2bn failed for EC"); + + if (EC_KEY_set_public_key_affine_coordinates(ec_key.get(), bn_x.get(), + bn_y.get()) == 0) + throw std::runtime_error("EC_KEY_set_public_key_affine_coordinates failed"); + + std::unique_ptr pkey(EVP_PKEY_new(), + EVP_PKEY_free); + if (!pkey) throw std::runtime_error("EVP_PKEY_new failed"); + if (EVP_PKEY_assign_EC_KEY(pkey.get(), ec_key.release()) == 0) + throw std::runtime_error("EVP_PKEY_assign_EC_KEY failed"); + return pkey.release(); +} +#endif + +#ifdef JWK_OPENSSL_3_0 +EVP_PKEY *Jwk::construct_pkey() { + EVP_PKEY *pkey(nullptr); + const std::unique_ptr param( + construct_param(), OSSL_PARAM_free); + + const std::unique_ptr ctx_ptr( + EVP_PKEY_CTX_new_from_name(nullptr, kty.c_str(), nullptr), + EVP_PKEY_CTX_free); + auto ctx = ctx_ptr.get(); + + if (ctx == nullptr || EVP_PKEY_fromdata_init(ctx) == 0 || + EVP_PKEY_fromdata(ctx, &pkey, EVP_PKEY_PUBLIC_KEY, param.get()) == 0) { + throw std::runtime_error("RSA EVP_PKEY_fromdata failed"); + } + return pkey; +} +#endif + +std::string Jwk::to_pem() { + const std::unique_ptr pkey( + construct_pkey(), EVP_PKEY_free); + const std::unique_ptr bio(BIO_new(BIO_s_mem()), + BIO_free); + 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); + std::string pem; + 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..c9cfcf74f626 --- /dev/null +++ b/plugin/auth_openid_connect/src/jwk.h @@ -0,0 +1,163 @@ +/* +(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 +#if OPENSSL_VERSION_NUMBER >= 0x30000000L // 3.0.0 +#define JWK_OPENSSL_3_0 +#endif + +#ifdef JWK_OPENSSL_3_0 +#include +#else +#include +#endif +#include +#include +#include +#include + +/** + * @class Jwk + * @brief Base class for representing a JSON Web Key. + */ +class Jwk { + 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"). + +#ifdef JWK_OPENSSL_3_0 + /** + * @brief Constructs OpenSSL OSSL_PARAM for the key (OpenSSL 3.0). + * @return A pointer to an array of OSSL_PARAM objects. + */ + virtual OSSL_PARAM *construct_param() = 0; +#endif + /** + * @brief Constructs an EVP_PKEY for the key + * @return A newly allocated EVP_PKEY; caller takes ownership. + */ +#ifdef JWK_OPENSSL_3_0 + EVP_PKEY *construct_pkey(); +#else + virtual EVP_PKEY *construct_pkey() = 0; +#endif + + 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: +#ifdef JWK_OPENSSL_3_0 + /** + * @brief Constructs OpenSSL OSSL_PARAM for the RSA key (OpenSSL 3.0). + * @return A pointer to an array of OSSL_PARAM objects. + */ + OSSL_PARAM *construct_param() override; +#else + EVP_PKEY *construct_pkey() override; +#endif + + 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: +#ifdef JWK_OPENSSL_3_0 + /** + * @brief Constructs OpenSSL OSSL_PARAM for the EC key (OpenSSL 3.0). + * + * 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; +#else + EVP_PKEY *construct_pkey() override; +#endif + + 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..652fdbe97155 --- /dev/null +++ b/plugin/auth_openid_connect/src/jwks.cc @@ -0,0 +1,95 @@ +/* +(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 "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{512000L}; + 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..5d3f827e6766 --- /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..b55d42a18341 --- /dev/null +++ b/plugin/auth_openid_connect/src/plugin_openid_connect.cc @@ -0,0 +1,372 @@ +/* +(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 + +#include "config.h" +#include "id_token.h" +#include "psi_openid_connect.h" + +static SERVICE_TYPE(registry) * reg_srv(nullptr); +SERVICE_TYPE(log_builtins) * log_bi(nullptr); +SERVICE_TYPE(log_builtins_string) * log_bs(nullptr); + +class Connection_status { + private: + bool authenticated{false}; + bool proxied{false}; + bool using_roles{false}; + + void add_status() const { + static std::mutex lock; + std::lock_guard guard{lock}; + if (authenticated) + ++users_authenticated; + else + ++users_denied; + + if (proxied) ++users_proxied; + + if (using_roles) ++users_with_roles; + } + + public: + ~Connection_status() { add_status(); } + void set_authenticated() { authenticated = true; } + void set_proxied() { proxied = true; } + void set_using_roles() { using_roles = true; } + static long long users_authenticated; + static long long users_denied; + static long long users_proxied; + static long long users_with_roles; + static void reset() { + users_authenticated = 0; + users_denied = 0; + users_proxied = 0; + users_with_roles = 0; + } +}; + +long long Connection_status::users_authenticated{0}; +long long Connection_status::users_denied{0}; +long long Connection_status::users_proxied{0}; +long long Connection_status::users_with_roles{0}; + +/** + * @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]]) { + Psi_openid_connect::init(); + 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."); + } + Connection_status::reset(); + 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 { + // Ensure that if auth_oidc_authenticate() returns other than CR_OK, + // the users_denied counter is incremented + auto status = std::make_unique(); + + // 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()); + status->set_proxied(); + } + } + + 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()); + status->set_using_roles(); + } + + LogPluginErr(INFORMATION_LEVEL, ER_LOG_PRINTF_MSG, + "authentication successful"); + status->set_authenticated(); + 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}; + +static SHOW_VAR auth_openid_connect_statvars[] = { + {"auth_openid_connect_users_authenticated", + reinterpret_cast(&Connection_status::users_authenticated), + SHOW_LONGLONG, SHOW_SCOPE_GLOBAL}, + {"auth_openid_connect_users_denied", + reinterpret_cast(&Connection_status::users_denied), SHOW_LONGLONG, + SHOW_SCOPE_GLOBAL}, + {"auth_openid_connect_users_proxied", + reinterpret_cast(&Connection_status::users_proxied), SHOW_LONGLONG, + SHOW_SCOPE_GLOBAL}, + {"auth_openid_connect_users_with_roles", + reinterpret_cast(&Connection_status::users_with_roles), + SHOW_LONGLONG, SHOW_SCOPE_GLOBAL}, + {"auth_openid_connect_configured_idps", + reinterpret_cast(&Idp_configs::statistics.configured_idps), + SHOW_LONGLONG, SHOW_SCOPE_GLOBAL}, + {"auth_openid_connect_configured_idps_using_jwks", + reinterpret_cast( + &Idp_configs::statistics.configured_idps_using_jwks), + SHOW_LONGLONG, SHOW_SCOPE_GLOBAL}, + {"auth_openid_connect_configured_group_role_maps", + reinterpret_cast(&Idp_configs::statistics.configured_role_maps), + SHOW_LONGLONG, SHOW_SCOPE_GLOBAL}, + {nullptr, nullptr, SHOW_UNDEF, SHOW_SCOPE_GLOBAL}}; + +/** + * @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 + auth_openid_connect_statvars, // status variables + auth_openid_connect_sysvars, // system variables + nullptr, // reserved + 0, // flags +} mysql_declare_plugin_end; diff --git a/plugin/auth_openid_connect/src/psi_openid_connect.cc b/plugin/auth_openid_connect/src/psi_openid_connect.cc new file mode 100644 index 000000000000..ff82a5fd9420 --- /dev/null +++ b/plugin/auth_openid_connect/src/psi_openid_connect.cc @@ -0,0 +1,40 @@ +/* +(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 "psi_openid_connect.h" + +#include +#include + +namespace Psi_openid_connect { + +PSI_memory_key config_memory_key; + +static constexpr auto config_memory{"config_memory"}; + +static PSI_memory_info all_memory[] = {{&config_memory_key, config_memory, 0, + PSI_VOLATILITY_UNKNOWN, + PSI_DOCUMENT_ME}}; + +void init() { + static constexpr auto category{"auth_openid_connect"}; + + int count = array_elements(all_memory); + mysql_memory_register(category, all_memory, count); +} + +} // namespace Psi_openid_connect diff --git a/plugin/auth_openid_connect/src/psi_openid_connect.h b/plugin/auth_openid_connect/src/psi_openid_connect.h new file mode 100644 index 000000000000..adbdf4f17bfd --- /dev/null +++ b/plugin/auth_openid_connect/src/psi_openid_connect.h @@ -0,0 +1,83 @@ +/* +(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 PSI_OIDC_H +#define PSI_OIDC_H + +#include +#include +#include +#include + +namespace Psi_openid_connect { + +void init(); + +extern PSI_memory_key config_memory_key; + +/** + * @brief STL-compatible allocator that routes allocations through my_malloc / + * my_free for MySQL PSI memory instrumentation. + * + * @tparam T Value type of the container element. + * @tparam Key Reference to the PSI_memory_key to use for accounting. + */ +template +class Allocator { + public: + using value_type = T; + using size_type = std::size_t; + using difference_type = std::ptrdiff_t; + using pointer = T *; + using const_pointer = const T *; + using reference = T &; + using const_reference = const T &; + + Allocator() noexcept = default; + ~Allocator() = default; + Allocator(const Allocator &) noexcept = default; + Allocator(Allocator &&) noexcept = default; + Allocator &operator=(const Allocator &) = default; + Allocator &operator=(Allocator &&) = default; + bool operator==(const Allocator &) const noexcept = default; + + template + explicit Allocator(const Allocator &) noexcept {} + + T *allocate(std::size_t size) { + (void)this; // fake use to silence clang-tidy "could be static" + void *ptr = my_malloc(Key, size * sizeof(T), MYF(MY_WME)); + if (ptr == nullptr) throw std::bad_alloc(); + return static_cast(ptr); + } + + void deallocate(T *ptr, std::size_t size [[maybe_unused]]) noexcept { + (void)this; // fake use to silence clang-tidy "could be static" + my_free(ptr); + } + + template + struct rebind { + using other = Allocator; + }; +}; + +template +using Config_allocator = Allocator; + +} // namespace Psi_openid_connect +#endif // PSI_OIDC_H 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..f0a6a65bcb59 --- /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 = 360; + 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 From 82de4a5a5060e059ee92435e9c1a2e5dca7a67a1 Mon Sep 17 00:00:00 2001 From: Michal Jankowski Date: Tue, 14 Jul 2026 12:34:35 +0200 Subject: [PATCH 3/3] PS-11413 [8.4] Improve the json error handling and logging of OIDC configuration Problem: The configuration parser parsed until the end of a valid JSON document. Any strings after that in the config file or config string were ignored. E.g. IDP configuration added at the end of the config (not before the closing brace) was silently ignored. Fix: Added check if anything other than whitespaces were left after the document. If so, issue an error on setting auth_openid_connect_configuration and log an appropriate message. Added check in a MTR test to verify the above. --- .../suite/auth_openid_connect/r/auth.result | 3 +++ .../suite/auth_openid_connect/t/auth.test | 6 +++++ .../auth_openid_connect/t/group_role.test | 1 - plugin/auth_openid_connect/src/config.cc | 23 +++++++++++++++---- 4 files changed, 28 insertions(+), 5 deletions(-) diff --git a/mysql-test/suite/auth_openid_connect/r/auth.result b/mysql-test/suite/auth_openid_connect/r/auth.result index 8f501f145c59..3b212bde9ecc 100644 --- a/mysql-test/suite/auth_openid_connect/r/auth.result +++ b/mysql-test/suite/auth_openid_connect/r/auth.result @@ -17,6 +17,7 @@ mysql_oidc_user@localhost mysql_oidc_user@% 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: 'extra content after correct JSON document'"); 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://' @@ -24,6 +25,8 @@ 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://{"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"]}} , {"oidc-idp": {"issuer-name":"name"}}'; +ERROR 42000: Variable 'auth_openid_connect_configuration' can't be set to the value of 'JSON://{"oidc-idp": {"issuer-name": "https://idp-test.com/realms/dummy", "keys": [{"kid": "rsa-key-1", "kty": "RSA", "n": "ptR4YxjdrF2RrYiY9XYH3KcXKzlS6b2foGAeHN9dViAs5yKStKAucUf81kszq6LkzkOOPX5SbToLB' SET GLOBAL auth_openid_connect_configuration = 'JSON://{}'; ### Client side validations (FR.5) diff --git a/mysql-test/suite/auth_openid_connect/t/auth.test b/mysql-test/suite/auth_openid_connect/t/auth.test index 0507e994357d..0351e3c6b282 100644 --- a/mysql-test/suite/auth_openid_connect/t/auth.test +++ b/mysql-test/suite/auth_openid_connect/t/auth.test @@ -49,6 +49,7 @@ CREATE USER mysql_oidc_user IDENTIFIED WITH 'auth_openid_connect' AS '{"identity 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: 'extra content after correct JSON document'"); CALL mtr.add_suppression("Plugin auth_openid_connect reported: 'syntax error"); # invalid prefix, expect error @@ -63,11 +64,16 @@ SET GLOBAL auth_openid_connect_configuration = 'JSON://{"invalid"[}'; --error ER_WRONG_VALUE_FOR_VAR SET GLOBAL auth_openid_connect_configuration = 'FILE://nonexistent_path/nonexistent_file.json'; +# valid JSON + extra non-parsed element at the end +--error ER_WRONG_VALUE_FOR_VAR +--eval SET GLOBAL auth_openid_connect_configuration = '$CONFIG_JSON , {\"oidc-idp\": {\"issuer-name\":\"name\"}}' + # 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) diff --git a/mysql-test/suite/auth_openid_connect/t/group_role.test b/mysql-test/suite/auth_openid_connect/t/group_role.test index 2a253709a8ad..e1bca50a15a7 100644 --- a/mysql-test/suite/auth_openid_connect/t/group_role.test +++ b/mysql-test/suite/auth_openid_connect/t/group_role.test @@ -7,7 +7,6 @@ if ($CREATE_ID_TOKEN == "") --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 diff --git a/plugin/auth_openid_connect/src/config.cc b/plugin/auth_openid_connect/src/config.cc index e329a6d0a269..9d3e40f85d87 100644 --- a/plugin/auth_openid_connect/src/config.cc +++ b/plugin/auth_openid_connect/src/config.cc @@ -82,6 +82,22 @@ static const T &json_get(const picojson::object &obj, const std::string &key, return it->second.get(); } +static inline std::string json_parse(picojson::value &json_document, + std::string_view json_txt) { + std::string err; + auto end = + picojson::parse(json_document, json_txt.begin(), json_txt.end(), &err); + // Syntax error in the JSON document itself + if (!err.empty()) return err; + // Skip any trailing whitespace + while (end != json_txt.end() && + std::isspace(static_cast(*end)) != 0) + ++end; + // There is extra non‑whitespace content after the parsed JSON + if (end != json_txt.end()) return "extra content after correct JSON document"; + return {}; +} + int Idp_configs::check(MYSQL_THD thd [[maybe_unused]], SYS_VAR *var [[maybe_unused]], void *save, st_mysql_value *value) { @@ -155,8 +171,7 @@ std::shared_timed_mutex &Idp_configs::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()) + if (const std::string err = json_parse(json_obj, config_json); !err.empty()) throw std::runtime_error(err); if (!json_obj.is()) @@ -269,7 +284,7 @@ bool Idp_config::load_keys() noexcept { 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); + const std::string err = json_parse(root, body); if (!err.empty()) { throw std::runtime_error("JWKS: invalid JSON: " + err); } @@ -413,7 +428,7 @@ bool Idp_configs::check(const char *variable) noexcept { std::string config_json; parse_var(variable, config_json); picojson::value json_obj; - const std::string err{picojson::parse(json_obj, config_json)}; + const std::string err{json_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) {