From 5f81d2dcf7f07160a6cd3da0595cfd01274d3686 Mon Sep 17 00:00:00 2001 From: gabdevele Date: Tue, 16 Jun 2026 23:33:59 +0200 Subject: [PATCH] feat(CIE): Implement OIDC RP Service with login, callback, and session management --- .gitignore | 1 - CMakeLists.txt | 18 + common/Crypto.cc | 188 +++++ common/Crypto.h | 6 + common/Http.cc | 9 +- common/Http.h | 3 +- common/OidcCrypto.cc | 350 +++++++++ common/OidcCrypto.h | 23 + common/OidcHttp.cc | 88 +++ common/OidcHttp.h | 26 + common/OidcJwt.cc | 360 ++++++++++ common/OidcJwt.h | 52 ++ common/OidcRpConfig.cc | 221 ++++++ common/OidcRpConfig.h | 44 ++ common/OidcUserClaims.cc | 110 +++ common/OidcUserClaims.h | 29 + config.json | 28 +- controllers/OidcController.cc | 136 ++++ controllers/OidcController.h | 47 ++ docs/API_SPEC.md | 10 + docs/ARCHITECTURE.md | 8 + docs/LLM_CONTEXT.md | 5 + docs/OIDC_CIE_IMPLEMENTATION_PLAN.md | 209 ++++++ migrations/011_oidc_rp_foundation.sql | 103 +++ migrations/012_oidc_session_result_token.sql | 6 + secrets/oidc/demo-rp-jwks.json | 43 ++ services/OidcRpService.cc | 709 +++++++++++++++++++ services/OidcRpService.h | 28 + spid-cie-oidc-django | 1 + test/CMakeLists.txt | 2 + test/OidcTests.cc | 359 ++++++++++ 31 files changed, 3218 insertions(+), 4 deletions(-) create mode 100644 common/OidcCrypto.cc create mode 100644 common/OidcCrypto.h create mode 100644 common/OidcHttp.cc create mode 100644 common/OidcHttp.h create mode 100644 common/OidcJwt.cc create mode 100644 common/OidcJwt.h create mode 100644 common/OidcRpConfig.cc create mode 100644 common/OidcRpConfig.h create mode 100644 common/OidcUserClaims.cc create mode 100644 common/OidcUserClaims.h create mode 100644 controllers/OidcController.cc create mode 100644 controllers/OidcController.h create mode 100644 docs/OIDC_CIE_IMPLEMENTATION_PLAN.md create mode 100644 migrations/011_oidc_rp_foundation.sql create mode 100644 migrations/012_oidc_session_result_token.sql create mode 100644 secrets/oidc/demo-rp-jwks.json create mode 100644 services/OidcRpService.cc create mode 100644 services/OidcRpService.h create mode 160000 spid-cie-oidc-django create mode 100644 test/OidcTests.cc diff --git a/.gitignore b/.gitignore index 53448b1..c67c7dd 100644 --- a/.gitignore +++ b/.gitignore @@ -566,5 +566,4 @@ FodyWeavers.xsd # Additional files built by Visual Studio *.tlog -build-codex/ # End of https://www.toptal.com/developers/gitignore/api/intellij+all,visualstudio,visualstudiocode,cmake,c,c++ diff --git a/CMakeLists.txt b/CMakeLists.txt index 0c11926..6ee1a8b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -33,16 +33,32 @@ else () "BUILD_YAML_CONFIG OFF" "BUILD_TESTING OFF" ) + + CPMAddPackage( + NAME jwt-cpp + GITHUB_REPOSITORY Thalhammer/jwt-cpp + GIT_TAG v0.7.1 + OPTIONS + "JWT_BUILD_EXAMPLES OFF" + "JWT_BUILD_TESTS OFF" + "JWT_DISABLE_PICOJSON ON" + ) endif () set(APP_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/controllers/ApiController.cc ${CMAKE_CURRENT_SOURCE_DIR}/controllers/AdminController.cc ${CMAKE_CURRENT_SOURCE_DIR}/controllers/AuthController.cc + ${CMAKE_CURRENT_SOURCE_DIR}/controllers/OidcController.cc ${CMAKE_CURRENT_SOURCE_DIR}/controllers/PublicController.cc ${CMAKE_CURRENT_SOURCE_DIR}/controllers/ReportController.cc ${CMAKE_CURRENT_SOURCE_DIR}/controllers/UserController.cc ${CMAKE_CURRENT_SOURCE_DIR}/common/Auth.cc + ${CMAKE_CURRENT_SOURCE_DIR}/common/OidcRpConfig.cc + ${CMAKE_CURRENT_SOURCE_DIR}/common/OidcHttp.cc + ${CMAKE_CURRENT_SOURCE_DIR}/common/OidcCrypto.cc + ${CMAKE_CURRENT_SOURCE_DIR}/common/OidcJwt.cc + ${CMAKE_CURRENT_SOURCE_DIR}/common/OidcUserClaims.cc ${CMAKE_CURRENT_SOURCE_DIR}/common/Cors.cc ${CMAKE_CURRENT_SOURCE_DIR}/common/Crypto.cc ${CMAKE_CURRENT_SOURCE_DIR}/common/Email.cc @@ -54,6 +70,7 @@ set(APP_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/repositories/Sql.cc ${CMAKE_CURRENT_SOURCE_DIR}/services/AdminService.cc ${CMAKE_CURRENT_SOURCE_DIR}/services/AuthService.cc + ${CMAKE_CURRENT_SOURCE_DIR}/services/OidcRpService.cc ${CMAKE_CURRENT_SOURCE_DIR}/services/ReportService.cc ${CMAKE_CURRENT_SOURCE_DIR}/services/UserService.cc ) @@ -71,6 +88,7 @@ target_include_directories(${PROJECT_NAME} target_link_libraries(${PROJECT_NAME} PRIVATE Drogon::Drogon + jwt-cpp::jwt-cpp ) file(GLOB CONTROLLER_HEADERS CONFIGURE_DEPENDS diff --git a/common/Crypto.cc b/common/Crypto.cc index 3cf0e44..47d5645 100644 --- a/common/Crypto.cc +++ b/common/Crypto.cc @@ -1,7 +1,13 @@ #include "common/Crypto.h" #include +#include +#include +#include +#include + #include +#include #include #include #include @@ -10,6 +16,12 @@ namespace visione::crypto { namespace { +using BnPtr = std::unique_ptr; +using RsaPtr = std::unique_ptr; +using EvpKeyPtr = std::unique_ptr; +using BioPtr = std::unique_ptr; +using BnCtxPtr = std::unique_ptr; + std::string toHex(const unsigned char *data, size_t size) { std::ostringstream stream; @@ -20,6 +32,39 @@ std::string toHex(const unsigned char *data, size_t size) } return stream.str(); } + +std::string base64UrlDecode(std::string value) +{ + while (value.size() % 4 != 0) + { + value.push_back('='); + } + for (char &ch : value) + { + if (ch == '-') + { + ch = '+'; + } + else if (ch == '_') + { + ch = '/'; + } + } + return drogon::utils::base64Decode(value); +} + +BnPtr bnFromBase64Url(const std::string &value) +{ + const auto decoded = base64UrlDecode(value); + BIGNUM *raw = BN_bin2bn(reinterpret_cast(decoded.data()), + static_cast(decoded.size()), + nullptr); + if (!raw) + { + throw std::runtime_error("invalid_jwk_bn"); + } + return {raw, &BN_free}; +} } // namespace std::string sha256(const std::string &value) @@ -47,8 +92,151 @@ std::string randomToken() return drogon::utils::getUuid() + drogon::utils::getUuid(); } +std::string randomBytes(size_t size) +{ + return drogon::utils::genRandomString(size); +} + std::string hashSecret(const std::string &secret) { return sha256("visione:" + secret); } + +std::string pemFromRsaJwk(const std::string &n, + const std::string &e, + const std::string &d, + const std::string &p, + const std::string &q) +{ + RsaPtr rsa(RSA_new(), &RSA_free); + if (!rsa) + { + throw std::runtime_error("rsa_new_failed"); + } + + auto nBn = bnFromBase64Url(n); + auto eBn = bnFromBase64Url(e); + BIGNUM *nRaw = nBn.release(); + BIGNUM *eRaw = eBn.release(); + if (RSA_set0_key(rsa.get(), nRaw, eRaw, nullptr) != 1) + { + BN_free(nRaw); + BN_free(eRaw); + throw std::runtime_error("rsa_set_public_failed"); + } + + const bool includePrivate = !d.empty(); + if (includePrivate) + { + auto dBn = bnFromBase64Url(d); + BIGNUM *dRaw = dBn.release(); + if (RSA_set0_key(rsa.get(), nullptr, nullptr, dRaw) != 1) + { + BN_free(dRaw); + throw std::runtime_error("rsa_set_private_failed"); + } + + if (p.empty() || q.empty()) + { + throw std::runtime_error("missing_private_jwk_fields"); + } + + auto pBn = bnFromBase64Url(p); + auto qBn = bnFromBase64Url(q); + BIGNUM *pRaw = pBn.release(); + BIGNUM *qRaw = qBn.release(); + if (RSA_set0_factors(rsa.get(), pRaw, qRaw) != 1) + { + BN_free(pRaw); + BN_free(qRaw); + throw std::runtime_error("rsa_set_factors_failed"); + } + + const BIGNUM *rsaD = nullptr; + const BIGNUM *rsaP = nullptr; + const BIGNUM *rsaQ = nullptr; + const BIGNUM *rsaE = nullptr; + RSA_get0_key(rsa.get(), nullptr, &rsaE, &rsaD); + RSA_get0_factors(rsa.get(), &rsaP, &rsaQ); + + BnCtxPtr bnCtx(BN_CTX_new(), &BN_CTX_free); + if (!bnCtx) + { + throw std::runtime_error("bn_ctx_new_failed"); + } + + BIGNUM *dp = BN_new(); + BIGNUM *dq = BN_new(); + BIGNUM *qi = BN_new(); + BIGNUM *pMinus1 = BN_dup(rsaP); + BIGNUM *qMinus1 = BN_dup(rsaQ); + if (!dp || !dq || !qi || !pMinus1 || !qMinus1) + { + BN_free(dp); + BN_free(dq); + BN_free(qi); + BN_free(pMinus1); + BN_free(qMinus1); + throw std::runtime_error("bn_alloc_failed"); + } + + BN_sub_word(pMinus1, 1); + BN_sub_word(qMinus1, 1); + if (BN_mod(dp, rsaD, pMinus1, bnCtx.get()) != 1 || + BN_mod(dq, rsaD, qMinus1, bnCtx.get()) != 1 || + BN_mod_inverse(qi, rsaQ, rsaP, bnCtx.get()) == nullptr) + { + BN_free(dp); + BN_free(dq); + BN_free(qi); + BN_free(pMinus1); + BN_free(qMinus1); + throw std::runtime_error("rsa_crt_params_failed"); + } + BN_free(pMinus1); + BN_free(qMinus1); + + if (RSA_set0_crt_params(rsa.get(), dp, dq, qi) != 1) + { + BN_free(dp); + BN_free(dq); + BN_free(qi); + throw std::runtime_error("rsa_set_crt_failed"); + } + } + + EVP_PKEY *rawPkey = EVP_PKEY_new(); + if (!rawPkey) + { + throw std::runtime_error("evp_pkey_new_failed"); + } + EvpKeyPtr pkey(rawPkey, &EVP_PKEY_free); + if (EVP_PKEY_assign_RSA(pkey.get(), rsa.release()) != 1) + { + throw std::runtime_error("evp_assign_rsa_failed"); + } + + BioPtr bio(BIO_new(BIO_s_mem()), &BIO_free); + if (!bio) + { + throw std::runtime_error("bio_new_failed"); + } + + const int ok = includePrivate + ? PEM_write_bio_PrivateKey( + bio.get(), pkey.get(), nullptr, nullptr, 0, nullptr, nullptr) + : PEM_write_bio_PUBKEY(bio.get(), pkey.get()); + if (ok != 1) + { + throw std::runtime_error("pem_write_failed"); + } + + BUF_MEM *buffer = nullptr; + BIO_get_mem_ptr(bio.get(), &buffer); + if (!buffer || !buffer->data || buffer->length == 0) + { + throw std::runtime_error("pem_buffer_failed"); + } + return {buffer->data, buffer->length}; +} } // namespace visione::crypto diff --git a/common/Crypto.h b/common/Crypto.h index 3382009..b0852c0 100644 --- a/common/Crypto.h +++ b/common/Crypto.h @@ -7,5 +7,11 @@ namespace visione::crypto std::string sha256(const std::string &value); std::string randomNumericCode(int digits); std::string randomToken(); +std::string randomBytes(size_t size); std::string hashSecret(const std::string &secret); +std::string pemFromRsaJwk(const std::string &n, + const std::string &e, + const std::string &d = {}, + const std::string &p = {}, + const std::string &q = {}); } // namespace visione::crypto diff --git a/common/Http.cc b/common/Http.cc index dfd56cd..85d58e5 100644 --- a/common/Http.cc +++ b/common/Http.cc @@ -22,6 +22,14 @@ drogon::HttpResponsePtr error(const std::string &code, return json(std::move(body), status); } +drogon::HttpResponsePtr redirect(const std::string &url, + drogon::HttpStatusCode status) +{ + auto response = drogon::HttpResponse::newRedirectionResponse(url); + response->setStatusCode(status); + return response; +} + Json::Value requireJson(const drogon::HttpRequestPtr &request) { const auto &body = request->getJsonObject(); @@ -32,4 +40,3 @@ Json::Value requireJson(const drogon::HttpRequestPtr &request) return *body; } } // namespace visione::http - diff --git a/common/Http.h b/common/Http.h index 678c240..1da2f51 100644 --- a/common/Http.h +++ b/common/Http.h @@ -12,6 +12,7 @@ drogon::HttpResponsePtr json(Json::Value body, drogon::HttpResponsePtr error(const std::string &code, drogon::HttpStatusCode status, const std::string &message = {}); +drogon::HttpResponsePtr redirect(const std::string &url, + drogon::HttpStatusCode status = drogon::k302Found); Json::Value requireJson(const drogon::HttpRequestPtr &request); } // namespace visione::http - diff --git a/common/OidcCrypto.cc b/common/OidcCrypto.cc new file mode 100644 index 0000000..5058733 --- /dev/null +++ b/common/OidcCrypto.cc @@ -0,0 +1,350 @@ +#include "common/OidcCrypto.h" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace visione::oidc +{ +namespace +{ +Json::Value parseJsonText(const std::string &text) +{ + Json::CharReaderBuilder builder; + builder["collectComments"] = false; + std::string errors; + Json::Value value; + std::unique_ptr reader(builder.newCharReader()); + if (!reader->parse(text.data(), text.data() + text.size(), &value, &errors)) + { + throw std::runtime_error("invalid_json"); + } + return value; +} + +using EvpKeyPtr = std::unique_ptr; +using BnPtr = std::unique_ptr; +using RsaPtr = std::unique_ptr; +using PkeyCtxPtr = std::unique_ptr; +using CipherCtxPtr = + std::unique_ptr; + +BnPtr bnFromBase64Url(const std::string &value) +{ + const auto decoded = base64UrlDecode(value); + BIGNUM *raw = BN_bin2bn(reinterpret_cast(decoded.data()), + static_cast(decoded.size()), + nullptr); + if (!raw) + { + throw std::runtime_error("invalid_jwk_bn"); + } + return {raw, &BN_free}; +} + +EvpKeyPtr keyFromJwk(const Jwk &jwk, bool includePrivate) +{ + if (jwk.kty != "RSA") + { + throw std::runtime_error("unsupported_jwk_type"); + } + + RsaPtr rsa(RSA_new(), &RSA_free); + if (!rsa) + { + throw std::runtime_error("rsa_new_failed"); + } + + auto n = bnFromBase64Url(jwk.n); + auto e = bnFromBase64Url(jwk.e); + BIGNUM *nRaw = n.release(); + BIGNUM *eRaw = e.release(); + if (RSA_set0_key(rsa.get(), nRaw, eRaw, nullptr) != 1) + { + BN_free(nRaw); + BN_free(eRaw); + throw std::runtime_error("rsa_set_public_failed"); + } + + if (includePrivate) + { + if (jwk.d.empty() || jwk.p.empty() || jwk.q.empty()) + { + throw std::runtime_error("missing_private_jwk_fields"); + } + + auto d = bnFromBase64Url(jwk.d); + auto p = bnFromBase64Url(jwk.p); + auto q = bnFromBase64Url(jwk.q); + BIGNUM *dRaw = d.release(); + if (RSA_set0_key(rsa.get(), nullptr, nullptr, dRaw) != 1) + { + BN_free(dRaw); + throw std::runtime_error("rsa_set_private_failed"); + } + + BIGNUM *pRaw = p.release(); + BIGNUM *qRaw = q.release(); + if (RSA_set0_factors(rsa.get(), pRaw, qRaw) != 1) + { + BN_free(pRaw); + BN_free(qRaw); + throw std::runtime_error("rsa_set_factors_failed"); + } + } + + EVP_PKEY *rawPkey = EVP_PKEY_new(); + if (!rawPkey) + { + throw std::runtime_error("evp_pkey_new_failed"); + } + EvpKeyPtr pkey(rawPkey, &EVP_PKEY_free); + if (EVP_PKEY_assign_RSA(pkey.get(), rsa.release()) != 1) + { + throw std::runtime_error("evp_assign_rsa_failed"); + } + return pkey; +} + +std::vector split(const std::string &text, char separator) +{ + std::vector parts; + std::string current; + for (char ch : text) + { + if (ch == separator) + { + parts.push_back(current); + current.clear(); + continue; + } + current.push_back(ch); + } + parts.push_back(current); + return parts; +} + +std::string rsaOaepDecrypt(const std::string &ciphertext, + const Jwk &jwk, + const std::string &algorithm) +{ + auto pkey = keyFromJwk(jwk, true); + PkeyCtxPtr ctx(EVP_PKEY_CTX_new(pkey.get(), nullptr), &EVP_PKEY_CTX_free); + if (!ctx) + { + throw std::runtime_error("jwe_pkey_ctx_new_failed"); + } + if (EVP_PKEY_decrypt_init(ctx.get()) != 1) + { + throw std::runtime_error("jwe_pkey_decrypt_init_failed"); + } + if (EVP_PKEY_CTX_set_rsa_padding(ctx.get(), RSA_PKCS1_OAEP_PADDING) != 1) + { + throw std::runtime_error("jwe_rsa_padding_failed"); + } + + const EVP_MD *md = EVP_sha1(); + if (algorithm == "RSA-OAEP-256") + { + md = EVP_sha256(); + } + else if (algorithm != "RSA-OAEP") + { + throw std::runtime_error("unsupported_jwe_alg"); + } + if (EVP_PKEY_CTX_set_rsa_oaep_md(ctx.get(), md) != 1 || + EVP_PKEY_CTX_set_rsa_mgf1_md(ctx.get(), md) != 1) + { + throw std::runtime_error("jwe_rsa_oaep_md_failed"); + } + + size_t outLen = 0; + if (EVP_PKEY_decrypt(ctx.get(), + nullptr, + &outLen, + reinterpret_cast(ciphertext.data()), + ciphertext.size()) != 1) + { + throw std::runtime_error("jwe_cek_size_failed"); + } + + std::string plaintext(outLen, '\0'); + if (EVP_PKEY_decrypt( + ctx.get(), + reinterpret_cast(plaintext.data()), + &outLen, + reinterpret_cast(ciphertext.data()), + ciphertext.size()) != 1) + { + throw std::runtime_error("jwe_cek_decrypt_failed"); + } + plaintext.resize(outLen); + return plaintext; +} + +std::array bigEndianBitLength(uint64_t lenBytes) +{ + const auto lenBits = lenBytes * 8; + return { + static_cast((lenBits >> 56) & 0xff), + static_cast((lenBits >> 48) & 0xff), + static_cast((lenBits >> 40) & 0xff), + static_cast((lenBits >> 32) & 0xff), + static_cast((lenBits >> 24) & 0xff), + static_cast((lenBits >> 16) & 0xff), + static_cast((lenBits >> 8) & 0xff), + static_cast(lenBits & 0xff), + }; +} + +void requireExact(bool ok, const char *error) +{ + if (!ok) + { + throw std::runtime_error(error); + } +} + +std::string decryptCbcHmac(const JweParts &parts, + const std::string &cek, + size_t expectedCekSize, + size_t ivSize, + size_t tagSize, + const EVP_CIPHER *cipher, + const EVP_MD *md) +{ + if (cek.size() != expectedCekSize) + { + throw std::runtime_error("invalid_jwe_cek_size"); + } + + const auto iv = base64UrlDecode(parts.encodedIv); + const auto ciphertext = base64UrlDecode(parts.encodedCiphertext); + const auto tag = base64UrlDecode(parts.encodedTag); + if (iv.size() != ivSize || tag.size() != tagSize) + { + throw std::runtime_error("invalid_jwe_parts"); + } + + const auto macKeySize = expectedCekSize / 2; + const auto encKeySize = expectedCekSize / 2; + const std::string macKey = cek.substr(0, macKeySize); + const std::string encKey = cek.substr(macKeySize, encKeySize); + const auto al = bigEndianBitLength(parts.encodedHeader.size()); + + std::string macInput; + macInput.reserve(parts.encodedHeader.size() + iv.size() + ciphertext.size() + + al.size()); + macInput.append(parts.encodedHeader); + macInput.append(iv); + macInput.append(ciphertext); + macInput.append(reinterpret_cast(al.data()), al.size()); + + unsigned char hmac[EVP_MAX_MD_SIZE]; + unsigned int hmacLen = 0; + if (!HMAC(md, + macKey.data(), + static_cast(macKey.size()), + reinterpret_cast(macInput.data()), + macInput.size(), + hmac, + &hmacLen)) + { + throw std::runtime_error("jwe_hmac_failed"); + } + if (hmacLen < tag.size() || + CRYPTO_memcmp(hmac, tag.data(), tag.size()) != 0) + { + throw std::runtime_error("jwe_auth_tag_mismatch"); + } + + CipherCtxPtr ctx(EVP_CIPHER_CTX_new(), &EVP_CIPHER_CTX_free); + if (!ctx) + { + throw std::runtime_error("jwe_cipher_ctx_new_failed"); + } + requireExact(EVP_DecryptInit_ex( + ctx.get(), + cipher, + nullptr, + reinterpret_cast(encKey.data()), + reinterpret_cast(iv.data())) == 1, + "jwe_decrypt_init_failed"); + + std::string plaintext(ciphertext.size() + EVP_MAX_BLOCK_LENGTH, '\0'); + int outLen1 = 0; + requireExact(EVP_DecryptUpdate( + ctx.get(), + reinterpret_cast(plaintext.data()), + &outLen1, + reinterpret_cast(ciphertext.data()), + ciphertext.size()) == 1, + "jwe_decrypt_update_failed"); + + int outLen2 = 0; + requireExact(EVP_DecryptFinal_ex( + ctx.get(), + reinterpret_cast(plaintext.data()) + outLen1, + &outLen2) == 1, + "jwe_decrypt_final_failed"); + plaintext.resize(outLen1 + outLen2); + return plaintext; +} +} // namespace + +JweParts parseJwe(const std::string &jwe) +{ + const auto parts = split(jwe, '.'); + if (parts.size() != 5) + { + throw std::runtime_error("invalid_jwe"); + } + + JweParts parsed; + parsed.encodedHeader = parts[0]; + parsed.encodedEncryptedKey = parts[1]; + parsed.encodedIv = parts[2]; + parsed.encodedCiphertext = parts[3]; + parsed.encodedTag = parts[4]; + parsed.header = parseJsonText(base64UrlDecode(parts[0])); + return parsed; +} + +std::string decryptJwe(const std::string &jwe, const Jwk &jwk) +{ + const auto parsed = parseJwe(jwe); + const auto alg = parsed.header.get("alg", "").asString(); + const auto enc = parsed.header.get("enc", "").asString(); + + const auto cek = + rsaOaepDecrypt(base64UrlDecode(parsed.encodedEncryptedKey), jwk, alg); + if (enc == "A256CBC-HS512") + { + return decryptCbcHmac(parsed, + cek, + 64, + 16, + 32, + EVP_aes_256_cbc(), + EVP_sha512()); + } + if (enc == "A128CBC-HS256") + { + return decryptCbcHmac(parsed, + cek, + 32, + 16, + 16, + EVP_aes_128_cbc(), + EVP_sha256()); + } + throw std::runtime_error("unsupported_jwe_enc"); +} +} // namespace visione::oidc diff --git a/common/OidcCrypto.h b/common/OidcCrypto.h new file mode 100644 index 0000000..fa9a1d1 --- /dev/null +++ b/common/OidcCrypto.h @@ -0,0 +1,23 @@ +#pragma once + +#include "common/OidcJwt.h" + +#include + +#include + +namespace visione::oidc +{ +struct JweParts +{ + Json::Value header; + std::string encodedHeader; + std::string encodedEncryptedKey; + std::string encodedIv; + std::string encodedCiphertext; + std::string encodedTag; +}; + +JweParts parseJwe(const std::string &jwe); +std::string decryptJwe(const std::string &jwe, const Jwk &jwk); +} // namespace visione::oidc diff --git a/common/OidcHttp.cc b/common/OidcHttp.cc new file mode 100644 index 0000000..8bbd6b4 --- /dev/null +++ b/common/OidcHttp.cc @@ -0,0 +1,88 @@ +#include "common/OidcHttp.h" + +#include + +#include + +namespace visione::oidc +{ +namespace +{ +HttpResponseData sendGet(const std::string &url, + const std::map &headers, + double timeoutSeconds) +{ + auto client = drogon::HttpClient::newHttpClient(url); + auto request = drogon::HttpRequest::newHttpRequest(); + request->setMethod(drogon::Get); + for (const auto &[key, value] : headers) + { + request->addHeader(key, value); + } + + const auto [result, response] = client->sendRequest(request, timeoutSeconds); + if (result != drogon::ReqResult::Ok || !response) + { + throw std::runtime_error("http_get_failed"); + } + + HttpResponseData out; + out.statusCode = static_cast(response->statusCode()); + out.body = response->body(); + return out; +} +} // namespace + +HttpResponseData httpGet(const std::string &url, double timeoutSeconds) +{ + return sendGet(url, {}, timeoutSeconds); +} + +HttpResponseData httpGet(const std::string &url, + const std::map &headers, + double timeoutSeconds) +{ + return sendGet(url, headers, timeoutSeconds); +} + +HttpResponseData httpPostForm(const std::string &url, + const std::map ¶ms, + double timeoutSeconds) +{ + auto client = drogon::HttpClient::newHttpClient(url); + auto request = drogon::HttpRequest::newHttpFormPostRequest(); + request->setMethod(drogon::Post); + for (const auto &[key, value] : params) + { + request->setBodyParameter(key, value); + } + + const auto [result, response] = client->sendRequest(request, timeoutSeconds); + if (result != drogon::ReqResult::Ok || !response) + { + throw std::runtime_error("http_post_failed"); + } + + HttpResponseData out; + out.statusCode = static_cast(response->statusCode()); + out.body = response->body(); + return out; +} + +Json::Value parseJsonResponse(const HttpResponseData &response) +{ + Json::CharReaderBuilder builder; + builder["collectComments"] = false; + std::string errors; + Json::Value value; + std::unique_ptr reader(builder.newCharReader()); + if (!reader->parse(response.body.data(), + response.body.data() + response.body.size(), + &value, + &errors)) + { + throw std::runtime_error("invalid_http_json"); + } + return value; +} +} // namespace visione::oidc diff --git a/common/OidcHttp.h b/common/OidcHttp.h new file mode 100644 index 0000000..d05fc54 --- /dev/null +++ b/common/OidcHttp.h @@ -0,0 +1,26 @@ +#pragma once + +#include + +#include +#include +#include + +namespace visione::oidc +{ +struct HttpResponseData +{ + int statusCode{0}; + std::string body; + std::map headers; +}; + +HttpResponseData httpGet(const std::string &url, double timeoutSeconds = 5.0); +HttpResponseData httpGet(const std::string &url, + const std::map &headers, + double timeoutSeconds = 5.0); +HttpResponseData httpPostForm(const std::string &url, + const std::map ¶ms, + double timeoutSeconds = 5.0); +Json::Value parseJsonResponse(const HttpResponseData &response); +} // namespace visione::oidc diff --git a/common/OidcJwt.cc b/common/OidcJwt.cc new file mode 100644 index 0000000..f63bd22 --- /dev/null +++ b/common/OidcJwt.cc @@ -0,0 +1,360 @@ +#include "common/OidcJwt.h" + +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace visione::oidc +{ +namespace +{ +using BnPtr = std::unique_ptr; +using RsaPtr = std::unique_ptr; +using EvpKeyPtr = std::unique_ptr; +using BioPtr = std::unique_ptr; +using BnCtxPtr = std::unique_ptr; + +BnPtr bnFromBase64Url(const std::string &value) +{ + const auto decoded = base64UrlDecode(value); + BIGNUM *raw = BN_bin2bn(reinterpret_cast(decoded.data()), + static_cast(decoded.size()), + nullptr); + if (!raw) + { + throw std::runtime_error("invalid_jwk_bn"); + } + return {raw, &BN_free}; +} + +EvpKeyPtr keyFromJwk(const Jwk &jwk, bool includePrivate) +{ + if (jwk.kty != "RSA") + { + throw std::runtime_error("unsupported_jwk_type"); + } + + RsaPtr rsa(RSA_new(), &RSA_free); + if (!rsa) + { + throw std::runtime_error("rsa_new_failed"); + } + + auto n = bnFromBase64Url(jwk.n); + auto e = bnFromBase64Url(jwk.e); + BIGNUM *nRaw = n.release(); + BIGNUM *eRaw = e.release(); + if (RSA_set0_key(rsa.get(), nRaw, eRaw, nullptr) != 1) + { + BN_free(nRaw); + BN_free(eRaw); + throw std::runtime_error("rsa_set_public_failed"); + } + + if (includePrivate) + { + if (jwk.d.empty() || jwk.p.empty() || jwk.q.empty()) + { + throw std::runtime_error("missing_private_jwk_fields"); + } + + auto d = bnFromBase64Url(jwk.d); + auto p = bnFromBase64Url(jwk.p); + auto q = bnFromBase64Url(jwk.q); + BIGNUM *dRaw = d.release(); + if (RSA_set0_key(rsa.get(), nullptr, nullptr, dRaw) != 1) + { + BN_free(dRaw); + throw std::runtime_error("rsa_set_private_failed"); + } + + BIGNUM *pRaw = p.release(); + BIGNUM *qRaw = q.release(); + if (RSA_set0_factors(rsa.get(), pRaw, qRaw) != 1) + { + BN_free(pRaw); + BN_free(qRaw); + throw std::runtime_error("rsa_set_factors_failed"); + } + + const BIGNUM *rsaD = nullptr; + const BIGNUM *rsaP = nullptr; + const BIGNUM *rsaQ = nullptr; + const BIGNUM *rsaE = nullptr; + RSA_get0_key(rsa.get(), nullptr, &rsaE, &rsaD); + RSA_get0_factors(rsa.get(), &rsaP, &rsaQ); + + BnCtxPtr bnCtx(BN_CTX_new(), &BN_CTX_free); + if (!bnCtx) + { + throw std::runtime_error("bn_ctx_new_failed"); + } + + BIGNUM *dp = BN_new(); + BIGNUM *dq = BN_new(); + BIGNUM *qi = BN_new(); + BIGNUM *pMinus1 = BN_dup(rsaP); + BIGNUM *qMinus1 = BN_dup(rsaQ); + if (!dp || !dq || !qi || !pMinus1 || !qMinus1) + { + BN_free(dp); + BN_free(dq); + BN_free(qi); + BN_free(pMinus1); + BN_free(qMinus1); + throw std::runtime_error("bn_alloc_failed"); + } + + BN_sub_word(pMinus1, 1); + BN_sub_word(qMinus1, 1); + if (BN_mod(dp, rsaD, pMinus1, bnCtx.get()) != 1 || + BN_mod(dq, rsaD, qMinus1, bnCtx.get()) != 1 || + BN_mod_inverse(qi, rsaQ, rsaP, bnCtx.get()) == nullptr) + { + BN_free(dp); + BN_free(dq); + BN_free(qi); + BN_free(pMinus1); + BN_free(qMinus1); + throw std::runtime_error("rsa_crt_params_failed"); + } + BN_free(pMinus1); + BN_free(qMinus1); + + if (RSA_set0_crt_params(rsa.get(), dp, dq, qi) != 1) + { + BN_free(dp); + BN_free(dq); + BN_free(qi); + throw std::runtime_error("rsa_set_crt_failed"); + } + } + + EVP_PKEY *rawPkey = EVP_PKEY_new(); + if (!rawPkey) + { + throw std::runtime_error("evp_pkey_new_failed"); + } + EvpKeyPtr pkey(rawPkey, &EVP_PKEY_free); + if (EVP_PKEY_assign_RSA(pkey.get(), rsa.release()) != 1) + { + throw std::runtime_error("evp_assign_rsa_failed"); + } + return pkey; +} + +std::string pemFromJwk(const Jwk &jwk, bool includePrivate) +{ + auto pkey = keyFromJwk(jwk, includePrivate); + BioPtr bio(BIO_new(BIO_s_mem()), &BIO_free); + if (!bio) + { + throw std::runtime_error("bio_new_failed"); + } + + const int ok = includePrivate + ? PEM_write_bio_PrivateKey( + bio.get(), pkey.get(), nullptr, nullptr, 0, nullptr, nullptr) + : PEM_write_bio_PUBKEY(bio.get(), pkey.get()); + if (ok != 1) + { + throw std::runtime_error("pem_write_failed"); + } + + BUF_MEM *buffer = nullptr; + BIO_get_mem_ptr(bio.get(), &buffer); + if (!buffer || !buffer->data || buffer->length == 0) + { + throw std::runtime_error("pem_buffer_failed"); + } + return {buffer->data, buffer->length}; +} + +Json::Value parseJsonText(const std::string &text) +{ + Json::CharReaderBuilder builder; + builder["collectComments"] = false; + std::string errors; + Json::Value value; + std::unique_ptr reader(builder.newCharReader()); + if (!reader->parse(text.data(), text.data() + text.size(), &value, &errors)) + { + throw std::runtime_error("invalid_json"); + } + return value; +} + +template +void setClaims(Builder &builder, + const Json::Value &value, + bool header) +{ + if (!value.isObject()) + { + throw std::runtime_error("invalid_jwt_claims_json"); + } + + for (const auto &name : value.getMemberNames()) + { + if (header) + { + builder.set_header_claim(name, value[name]); + } + else + { + builder.set_payload_claim(name, value[name]); + } + } +} +} // namespace + +Jwk parseJwk(const Json::Value &value) +{ + Jwk jwk; + jwk.kty = value.get("kty", "").asString(); + jwk.use = value.get("use", "").asString(); + jwk.alg = value.get("alg", "").asString(); + jwk.kid = value.get("kid", "").asString(); + jwk.n = value.get("n", "").asString(); + jwk.e = value.get("e", "").asString(); + jwk.d = value.get("d", "").asString(); + jwk.p = value.get("p", "").asString(); + jwk.q = value.get("q", "").asString(); + return jwk; +} + +Json::Value jwkToPublicJson(const Jwk &jwk) +{ + Json::Value value; + value["kty"] = jwk.kty; + value["use"] = jwk.use; + if (!jwk.alg.empty()) + { + value["alg"] = jwk.alg; + } + value["kid"] = jwk.kid; + value["n"] = jwk.n; + value["e"] = jwk.e; + return value; +} + +std::string base64UrlEncode(const std::string &value) +{ + return drogon::utils::base64EncodeUnpadded(value, true); +} + +std::string base64UrlDecode(const std::string &value) +{ + std::string padded = value; + while (padded.size() % 4 != 0) + { + padded.push_back('='); + } + for (char &ch : padded) + { + if (ch == '-') + { + ch = '+'; + } + else if (ch == '_') + { + ch = '/'; + } + } + return drogon::utils::base64Decode(padded); +} + +std::string randomUrlSafeString(size_t sizeBytes) +{ + return base64UrlEncode(drogon::utils::genRandomString(sizeBytes)); +} + +std::string signJwt(const Json::Value &header, + const Json::Value &payload, + const Jwk &jwk) +{ + auto builder = jwt::create(); + setClaims(builder, header, true); + setClaims(builder, payload, false); + return builder.sign( + jwt::algorithm::rs256(pemFromJwk(jwk, false), pemFromJwk(jwk, true))); +} + +bool verifyJwtSignature(const std::string &jwtText, const Jwk &jwk) +{ + try + { + auto decoded = jwt::decode(jwtText); + auto verifier = jwt::verify().allow_algorithm( + jwt::algorithm::rs256(pemFromJwk(jwk, false))); + verifier.verify(decoded); + return true; + } + catch (const std::exception &) + { + return false; + } +} + +JwtParts parseJwt(const std::string &jwtText) +{ + const auto decoded = jwt::decode(jwtText); + + JwtParts parsed; + parsed.encodedHeader = decoded.get_header_base64(); + parsed.encodedPayload = decoded.get_payload_base64(); + parsed.encodedSignature = decoded.get_signature_base64(); + parsed.header = decoded.get_header_json(); + parsed.payload = decoded.get_payload_json(); + return parsed; +} + +Json::Value parseJsonObjectJwtPayload(const std::string &jwtText) +{ + const auto parsed = parseJwt(jwtText); + if (!parsed.payload.isObject()) + { + throw std::runtime_error("invalid_jwt_payload"); + } + return parsed.payload; +} + +std::optional findJwkByKid(const std::vector &jwks, + const std::string &kid) +{ + for (const auto &jwk : jwks) + { + if (jwk.kid == kid) + { + return jwk; + } + } + return std::nullopt; +} + +std::string sha256Base64Url(const std::string &value) +{ + unsigned char digest[SHA256_DIGEST_LENGTH]; + SHA256(reinterpret_cast(value.data()), + value.size(), + digest); + return drogon::utils::base64EncodeUnpadded(digest, + SHA256_DIGEST_LENGTH, + true); +} + +std::string leftHashBase64Url(const std::string &value) +{ + const auto digest = base64UrlDecode(sha256Base64Url(value)); + return base64UrlEncode(digest.substr(0, digest.size() / 2)); +} +} // namespace visione::oidc diff --git a/common/OidcJwt.h b/common/OidcJwt.h new file mode 100644 index 0000000..8851fc1 --- /dev/null +++ b/common/OidcJwt.h @@ -0,0 +1,52 @@ +#pragma once + +#include + +#include +#include +#include + +namespace visione::oidc +{ +struct Jwk +{ + std::string kty; + std::string use; + std::string alg; + std::string kid; + std::string n; + std::string e; + std::string d; + std::string p; + std::string q; +}; + +struct JwtParts +{ + Json::Value header; + Json::Value payload; + std::string encodedHeader; + std::string encodedPayload; + std::string encodedSignature; +}; + +Jwk parseJwk(const Json::Value &value); +Json::Value jwkToPublicJson(const Jwk &jwk); + +std::string base64UrlEncode(const std::string &value); +std::string base64UrlDecode(const std::string &value); +std::string randomUrlSafeString(size_t sizeBytes); + +std::string signJwt(const Json::Value &header, + const Json::Value &payload, + const Jwk &jwk); +bool verifyJwtSignature(const std::string &jwt, const Jwk &jwk); +JwtParts parseJwt(const std::string &jwt); +Json::Value parseJsonObjectJwtPayload(const std::string &jwt); + +std::optional findJwkByKid(const std::vector &jwks, + const std::string &kid); + +std::string sha256Base64Url(const std::string &value); +std::string leftHashBase64Url(const std::string &value); +} // namespace visione::oidc diff --git a/common/OidcRpConfig.cc b/common/OidcRpConfig.cc new file mode 100644 index 0000000..7690c6d --- /dev/null +++ b/common/OidcRpConfig.cc @@ -0,0 +1,221 @@ +#include "common/OidcRpConfig.h" + +#include + +#include +#include +#include +#include + +namespace visione::oidc +{ +namespace +{ +std::vector readStringArray(const Json::Value &value) +{ + std::vector out; + if (!value.isArray()) + { + return out; + } + + out.reserve(value.size()); + for (const auto &item : value) + { + if (item.isString()) + { + out.push_back(item.asString()); + } + } + return out; +} + +Json::Value readJsonFile(const std::string &path) +{ + std::ifstream input(path); + if (!input.is_open()) + { + throw std::runtime_error("oidc_jwks_file_not_found"); + } + Json::Value value; + input >> value; + return value; +} + +bool fileExists(const std::string &path) +{ + return !path.empty() && std::filesystem::exists(path); +} +} // namespace + +RpConfig loadRpConfig() +{ + const auto &root = drogon::app().getCustomConfig(); + const auto &oidcRp = root["oidc_rp"]; + const auto &federation = root["oidc_federation"]; + + RpConfig config; + config.enabled = oidcRp.get("enabled", false).asBool(); + config.baseUrl = oidcRp.get("base_url", "").asString(); + config.clientId = oidcRp.get("client_id", "").asString(); + config.redirectUri = oidcRp.get("redirect_uri", "").asString(); + config.postLoginRedirectUri = + oidcRp.get("post_login_redirect_uri", "").asString(); + config.organizationName = + oidcRp.get("organization_name", "").asString(); + config.clientName = oidcRp.get("client_name", "").asString(); + config.contacts = readStringArray(oidcRp["contacts"]); + config.jwksPath = oidcRp.get("jwks_path", "").asString(); + config.federationJwksPath = + oidcRp.get("federation_jwks_path", "").asString(); + config.trustAnchors = readStringArray(federation["trust_anchors"]); + + const auto &providers = federation["identity_providers"]; + if (providers.isObject()) + { + for (const auto &name : providers.getMemberNames()) + { + const auto &item = providers[name]; + RpProviderConfig provider; + provider.profile = item.get("profile", name).asString(); + provider.issuer = item.get("issuer", "").asString(); + provider.trustAnchor = item.get("trust_anchor", "").asString(); + config.providers.push_back(std::move(provider)); + } + } + + const auto jwksRoot = readJsonFile(config.jwksPath); + Json::Value federationRoot; + if (fileExists(config.federationJwksPath)) + { + federationRoot = readJsonFile(config.federationJwksPath); + } + else + { + federationRoot["keys"] = jwksRoot["federation"]; + } + + const auto &federationKeys = federationRoot.isObject() + ? federationRoot["keys"] + : federationRoot; + if (federationKeys.isArray()) + { + for (const auto &item : federationKeys) + { + config.federationJwks.push_back(parseJwk(item)); + } + } + if (jwksRoot["core"].isArray()) + { + for (const auto &item : jwksRoot["core"]) + { + config.coreJwks.push_back(parseJwk(item)); + } + } + config.trustMark = jwksRoot["trust_mark"]; + + return config; +} + +Json::Value rpMetadataDocument(const RpConfig &config) +{ + Json::Value metadata; + metadata["openid_relying_party"]["organization_name"] = + config.organizationName; + metadata["openid_relying_party"]["application_type"] = "web"; + metadata["openid_relying_party"]["client_id"] = config.clientId; + metadata["openid_relying_party"]["client_registration_types"].append( + "automatic"); + metadata["openid_relying_party"]["client_name"] = config.clientName; + metadata["openid_relying_party"]["subject_type"] = "pairwise"; + metadata["openid_relying_party"]["redirect_uris"].append(config.redirectUri); + metadata["openid_relying_party"]["response_types"].append("code"); + metadata["openid_relying_party"]["grant_types"].append("refresh_token"); + metadata["openid_relying_party"]["grant_types"].append("authorization_code"); + metadata["openid_relying_party"]["token_endpoint_auth_method"] = + "private_key_jwt"; + metadata["openid_relying_party"]["id_token_signed_response_alg"] = "RS256"; + metadata["openid_relying_party"]["userinfo_signed_response_alg"] = "RS256"; + metadata["openid_relying_party"]["userinfo_encrypted_response_alg"] = + "RSA-OAEP"; + metadata["openid_relying_party"]["userinfo_encrypted_response_enc"] = + "A256CBC-HS512"; + + Json::Value publicJwks; + publicJwks["keys"] = Json::arrayValue; + for (const auto &jwk : config.coreJwks) + { + publicJwks["keys"].append(jwkToPublicJson(jwk)); + } + metadata["openid_relying_party"]["jwks"] = publicJwks; + metadata["openid_relying_party"]["jwks_uri"] = + config.clientId + "/openid_relying_party/jwks.json"; + metadata["openid_relying_party"]["signed_jwks_uri"] = + config.clientId + "/openid_relying_party/jwks.jose"; + + for (const auto &contact : config.contacts) + { + metadata["openid_relying_party"]["contacts"].append(contact); + } + return metadata; +} + +Json::Value rpPublicJwksDocument(const RpConfig &config) +{ + Json::Value body; + body["keys"] = Json::arrayValue; + for (const auto &jwk : config.coreJwks) + { + body["keys"].append(jwkToPublicJson(jwk)); + } + return body; +} + +Json::Value entityConfigurationDocument(const RpConfig &config) +{ + Json::Value body; + body["exp"] = static_cast(std::time(nullptr) + 2880 * 60); + body["iat"] = static_cast(std::time(nullptr)); + body["iss"] = config.clientId; + body["sub"] = config.clientId; + body["jwks"]["keys"] = Json::arrayValue; + for (const auto &jwk : config.federationJwks) + { + body["jwks"]["keys"].append(jwkToPublicJson(jwk)); + } + body["metadata"] = rpMetadataDocument(config); + body["metadata"]["federation_entity"]["federation_resolve_endpoint"] = + config.clientId + "/resolve"; + body["metadata"]["federation_entity"]["organization_name"] = + config.organizationName; + body["metadata"]["federation_entity"]["homepage_uri"] = config.baseUrl; + body["metadata"]["federation_entity"]["policy_uri"] = config.baseUrl; + body["metadata"]["federation_entity"]["logo_uri"] = config.baseUrl; + for (const auto &contact : config.contacts) + { + body["metadata"]["federation_entity"]["contacts"].append(contact); + } + for (const auto &anchor : config.trustAnchors) + { + body["authority_hints"].append(anchor); + } + if (!config.trustMark.isNull()) + { + body["trust_marks"].append(config.trustMark); + } + return body; +} + +std::optional findProviderConfig(const RpConfig &config, + const std::string &provider) +{ + for (const auto &candidate : config.providers) + { + if (candidate.profile == provider || candidate.issuer == provider) + { + return candidate; + } + } + return std::nullopt; +} +} // namespace visione::oidc diff --git a/common/OidcRpConfig.h b/common/OidcRpConfig.h new file mode 100644 index 0000000..a17c96c --- /dev/null +++ b/common/OidcRpConfig.h @@ -0,0 +1,44 @@ +#pragma once + +#include + +#include "common/OidcJwt.h" + +#include +#include + +namespace visione::oidc +{ +struct RpProviderConfig +{ + std::string issuer; + std::string trustAnchor; + std::string profile; +}; + +struct RpConfig +{ + bool enabled{false}; + std::string baseUrl; + std::string clientId; + std::string redirectUri; + std::string postLoginRedirectUri; + std::string organizationName; + std::string clientName; + std::vector contacts; + std::string jwksPath; + std::string federationJwksPath; + std::vector trustAnchors; + std::vector providers; + std::vector federationJwks; + std::vector coreJwks; + Json::Value trustMark; +}; + +RpConfig loadRpConfig(); +Json::Value rpMetadataDocument(const RpConfig &config); +Json::Value rpPublicJwksDocument(const RpConfig &config); +Json::Value entityConfigurationDocument(const RpConfig &config); +std::optional findProviderConfig(const RpConfig &config, + const std::string &provider); +} // namespace visione::oidc diff --git a/common/OidcUserClaims.cc b/common/OidcUserClaims.cc new file mode 100644 index 0000000..947b06a --- /dev/null +++ b/common/OidcUserClaims.cc @@ -0,0 +1,110 @@ +#include "common/OidcUserClaims.h" + +#include + +namespace visione::oidc +{ +namespace +{ +std::string claimString(const Json::Value &value, + const char *primary, + const char *secondary = nullptr) +{ + const auto primaryValue = value.get(primary, ""); + if (primaryValue.isString() && !primaryValue.asString().empty()) + { + return primaryValue.asString(); + } + if (secondary) + { + const auto secondaryValue = value.get(secondary, ""); + if (secondaryValue.isString()) + { + return secondaryValue.asString(); + } + } + return {}; +} + +std::string trimFiscalPrefix(std::string value) +{ + const std::string prefix = "TINIT-"; + if (value.rfind(prefix, 0) == 0) + { + value.erase(0, prefix.size()); + } + std::transform(value.begin(), + value.end(), + value.begin(), + [](unsigned char ch) { return static_cast(std::toupper(ch)); }); + return value; +} +} // namespace + +std::optional normalizeIdTokenClaims( + const std::string &issuer, + const Json::Value &claims) +{ + if (!claims.isObject()) + { + return std::nullopt; + } + + const auto subject = claims.get("sub", "").asString(); + const auto fiscal = trimFiscalPrefix( + claims.get("fiscal_number", + claims.get("https://attributes.eid.gov.it/fiscal_number", "")) + .asString()); + if (subject.empty() || fiscal.empty()) + { + return std::nullopt; + } + + NormalizedUserClaims user; + user.issuer = issuer; + user.subject = subject; + user.firstName = claims.get("given_name", "").asString(); + user.lastName = claims.get("family_name", "").asString(); + user.email = claims.get("email", "").asString(); + user.fiscalCode = fiscal; + user.rawClaims = claims; + return user; +} + +std::optional mergeUserClaims( + const std::string &issuer, + const Json::Value &idTokenClaims, + const Json::Value &userinfoClaims) +{ + if (!idTokenClaims.isObject() || !userinfoClaims.isObject()) + { + return std::nullopt; + } + + Json::Value merged = idTokenClaims; + for (const auto &name : userinfoClaims.getMemberNames()) + { + merged[name] = userinfoClaims[name]; + } + + const auto subject = claimString(merged, "sub"); + const auto fiscal = trimFiscalPrefix(claimString( + merged, + "fiscal_number", + "https://attributes.eid.gov.it/fiscal_number")); + if (subject.empty() || fiscal.empty()) + { + return std::nullopt; + } + + NormalizedUserClaims user; + user.issuer = issuer; + user.subject = subject; + user.firstName = claimString(merged, "given_name"); + user.lastName = claimString(merged, "family_name"); + user.email = claimString(merged, "email"); + user.fiscalCode = fiscal; + user.rawClaims = merged; + return user; +} +} // namespace visione::oidc diff --git a/common/OidcUserClaims.h b/common/OidcUserClaims.h new file mode 100644 index 0000000..a75b618 --- /dev/null +++ b/common/OidcUserClaims.h @@ -0,0 +1,29 @@ +#pragma once + +#include + +#include +#include + +namespace visione::oidc +{ +struct NormalizedUserClaims +{ + std::string issuer; + std::string subject; + std::string firstName; + std::string lastName; + std::string email; + std::string fiscalCode; + Json::Value rawClaims; +}; + +std::optional normalizeIdTokenClaims( + const std::string &issuer, + const Json::Value &claims); + +std::optional mergeUserClaims( + const std::string &issuer, + const Json::Value &idTokenClaims, + const Json::Value &userinfoClaims); +} // namespace visione::oidc diff --git a/config.json b/config.json index 7a79e78..81d5f2c 100644 --- a/config.json +++ b/config.json @@ -56,6 +56,32 @@ "token_ttl_hours": 24, "otp_ttl_minutes": 10 }, + "oidc_federation": { + "trust_anchors": [ + "http://127.0.0.1:8000" + ], + "identity_providers": { + "cie_demo": { + "profile": "cie", + "issuer": "http://127.0.0.1:8002/oidc/op", + "trust_anchor": "http://127.0.0.1:8000" + } + } + }, + "oidc_rp": { + "enabled": true, + "base_url": "http://127.0.0.1:5555", + "client_id": "http://127.0.0.1:8000/oidc/rp", + "redirect_uri": "http://127.0.0.1:5555/api/v1/auth/cie/callback", + "post_login_redirect_uri": "http://localhost:3000/auth/cie/callback", + "organization_name": "that fancy RP", + "client_name": "Name of an example organization", + "contacts": [ + "ops@rp.example.it" + ], + "jwks_path": "secrets/oidc/demo-rp-jwks.json", + "federation_jwks_path": "secrets/oidc/rp-federation-jwks.json" + }, "mail": { "smtp_host": "127.0.0.1", "smtp_port": 1025, @@ -72,4 +98,4 @@ "allowed_origin": "http://localhost:3000" } } -} \ No newline at end of file +} diff --git a/controllers/OidcController.cc b/controllers/OidcController.cc new file mode 100644 index 0000000..1fb700f --- /dev/null +++ b/controllers/OidcController.cc @@ -0,0 +1,136 @@ +#include "controllers/OidcController.h" + +#include "common/Http.h" +#include "services/OidcRpService.h" + +#include + +using drogon::HttpResponsePtr; + +namespace +{ +void serviceResponse(Json::Value body, + std::function &&callback, + drogon::HttpStatusCode failureStatus = + drogon::k400BadRequest) +{ + if (body.get("ok", false).asBool()) + { + callback(visione::http::json(std::move(body))); + return; + } + callback(visione::http::json(std::move(body), failureStatus)); +} +} // namespace + +void OidcController::startCieLogin( + const drogon::HttpRequestPtr &req, + std::function &&callback) +{ + const auto body = visione::http::requireJson(req); + if (body.isNull()) + { + callback(visione::http::error("invalid_json", drogon::k400BadRequest)); + return; + } + + visione::services::OidcRpService service; + serviceResponse(service.startLogin(body), std::move(callback)); +} + +void OidcController::cieCallback( + const drogon::HttpRequestPtr &req, + std::function &&callback) +{ + visione::services::OidcRpService service; + auto body = service.callback(req->getParameter("state"), + req->getParameter("code"), + req->getParameter("error"), + req->getParameter("error_description"), + [&req]() -> std::optional + { + const auto issuer = req->getParameter("iss"); + if (issuer.empty()) + { + return std::nullopt; + } + return issuer; + }()); + if (body.isMember("redirect_url") && body["redirect_url"].isString()) + { + callback(visione::http::redirect(body["redirect_url"].asString())); + return; + } + serviceResponse(std::move(body), std::move(callback), drogon::k400BadRequest); +} + +void OidcController::cieSessionStatus( + const drogon::HttpRequestPtr & /*req*/, + std::function &&callback, + std::string state) +{ + visione::services::OidcRpService service; + serviceResponse(service.sessionStatus(state), + std::move(callback), + drogon::k404NotFound); +} + +void OidcController::entityConfiguration( + const drogon::HttpRequestPtr &req, + std::function &&callback) +{ + visione::services::OidcRpService service; + if (req->getParameter("format") == "json") + { + callback(visione::http::json(service.entityConfigurationDocument())); + return; + } + + auto response = drogon::HttpResponse::newHttpResponse(); + response->setStatusCode(drogon::k200OK); + response->setContentTypeString("application/entity-statement+jwt"); + response->setBody(service.entityConfigurationJwt()); + callback(response); +} + +void OidcController::resolveEntityStatement( + const drogon::HttpRequestPtr &req, + std::function &&callback) +{ + visione::services::OidcRpService service; + + const auto subParam = req->getParameter("sub"); + const auto anchorParam = req->getParameter("anchor"); + const std::optional sub = + subParam.empty() ? std::nullopt : std::make_optional(subParam); + const std::optional anchor = + anchorParam.empty() ? std::nullopt : std::make_optional(anchorParam); + + if (req->getParameter("format") == "json") + { + serviceResponse(service.resolveDocument(sub, anchor), + std::move(callback), + drogon::k404NotFound); + return; + } + + auto jwt = service.resolveJwt(sub, anchor); + if (jwt.empty()) + { + callback(visione::http::error("not_found", drogon::k404NotFound)); + return; + } + auto response = drogon::HttpResponse::newHttpResponse(); + response->setStatusCode(drogon::k200OK); + response->setContentTypeString("application/resolve-response+jwt"); + response->setBody(std::move(jwt)); + callback(response); +} + +void OidcController::jwks( + const drogon::HttpRequestPtr & /*req*/, + std::function &&callback) +{ + visione::services::OidcRpService service; + callback(visione::http::json(service.jwks())); +} diff --git a/controllers/OidcController.h b/controllers/OidcController.h new file mode 100644 index 0000000..d20a37e --- /dev/null +++ b/controllers/OidcController.h @@ -0,0 +1,47 @@ +#pragma once + +#include + +class OidcController : public drogon::HttpController +{ + public: + METHOD_LIST_BEGIN + ADD_METHOD_TO(OidcController::startCieLogin, + "/api/v1/auth/cie/start", + drogon::Post); + ADD_METHOD_TO(OidcController::cieCallback, + "/api/v1/auth/cie/callback", + drogon::Get); + ADD_METHOD_TO(OidcController::cieSessionStatus, + "/api/v1/auth/cie/session/{1}", + drogon::Get); + ADD_METHOD_TO(OidcController::entityConfiguration, + "/oidc/rp/.well-known/openid-federation", + drogon::Get); + ADD_METHOD_TO(OidcController::resolveEntityStatement, + "/oidc/rp/resolve", + drogon::Get); + ADD_METHOD_TO(OidcController::jwks, + "/oidc/rp/jwks.json", + drogon::Get); + METHOD_LIST_END + + void startCieLogin( + const drogon::HttpRequestPtr &req, + std::function &&callback); + void cieCallback( + const drogon::HttpRequestPtr &req, + std::function &&callback); + void cieSessionStatus( + const drogon::HttpRequestPtr &req, + std::function &&callback, + std::string state); + void entityConfiguration( + const drogon::HttpRequestPtr &req, + std::function &&callback); + void resolveEntityStatement( + const drogon::HttpRequestPtr &req, + std::function &&callback); + void jwks(const drogon::HttpRequestPtr &req, + std::function &&callback); +}; diff --git a/docs/API_SPEC.md b/docs/API_SPEC.md index aec34c5..258bdf8 100644 --- a/docs/API_SPEC.md +++ b/docs/API_SPEC.md @@ -35,6 +35,16 @@ Authorization: Bearer sends OTP through Mailpit - `POST /auth/otp/verify` consumes OTP, returns bearer token +- `POST /auth/cie/start` + starts local demo CIE OIDC RP login flow, returns provider `authorization_url` + current demo scope: `openid cie_demo` +- `GET /auth/cie/callback` + OIDC redirect URI for provider callback, backend completes token + userinfo flow then redirects frontend +- `GET /auth/cie/session/{state}` + frontend polling endpoint for OIDC login result + returns public `status` in `pending | authenticated | failed` + internal RP phase exposed separately as `phase` + `auth` payload is present only on success - `GET /me` current user profile, roles, permissions, points - `PATCH /me` diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4620b4d..3bc5166 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -25,6 +25,9 @@ generate public read-only intervention links. ## Runtime Model - Auth uses bearer tokens from `api_tokens`. - OTP login uses `otp_challenges` and Mailpit in local dev. +- CIE login uses OIDC RP state tables, public federation endpoints under + `/oidc/rp/*`, callback-side token exchange, encrypted/signed `userinfo`, + and frontend polling on `GET /api/v1/auth/cie/session/{state}`. - DB access uses raw parameterized SQL through `drogon::orm::DbClient`. - No Drogon generated ORM models. No `Mapper`. @@ -62,6 +65,8 @@ Effective permission helpers: ## Main Persistence Objects - `users`, `roles`, `permissions`, `user_roles`, `role_permissions` - `api_tokens`, `otp_challenges` +- `oidc_authentications`, `oidc_authentication_tokens`, + `oidc_federation_cache`, `user_external_identities` - `report_categories`, `areas`, `team_categories`, `teams` - `reports`, `report_status_history`, `assignments`, `report_photos` - `messages`, `feedback`, `reward_events`, `notifications` @@ -97,3 +102,6 @@ Output path: still exposes citizen phone by design. - Allowed-area containment for admin areas is vertex-based, simple not strict GIS-hard. - No frontend doc set here beyond API/OpenAPI docs. +- OIDC federation trust validation/cache is still pragmatic: metadata comes from + trust-anchor `resolve`, but full trust-chain cryptographic validation is not + implemented yet. diff --git a/docs/LLM_CONTEXT.md b/docs/LLM_CONTEXT.md index 57fbb84..6b38ac7 100644 --- a/docs/LLM_CONTEXT.md +++ b/docs/LLM_CONTEXT.md @@ -31,6 +31,11 @@ public revocable link generated by operator/admin. - Registration creates `citizen`. - Login path: - OTP email via Mailpit +- CIE OIDC RP demo flow implemented for local CIE provider: + start login, callback token exchange, encrypted/signed `userinfo`, + local user reunification, frontend polling result +- JWT/JWS/JWK operations use `jwt-cpp`. +- JWE decryption remains custom OpenSSL in `common/OidcCrypto.*`. - Auth token: bearer token from `api_tokens` - Banned users cannot authenticate. diff --git a/docs/OIDC_CIE_IMPLEMENTATION_PLAN.md b/docs/OIDC_CIE_IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..2919492 --- /dev/null +++ b/docs/OIDC_CIE_IMPLEMENTATION_PLAN.md @@ -0,0 +1,209 @@ +# Visione CIE OIDC RP Technical Plan + +## Goal +Implement Visione backend as SPID/CIE OIDC Federation Relying Party compatible +with local demo provider contained in `spid-cie-oidc-django/examples/provider`. + +Scope target for first real implementation: +- compatibility with demo provider only +- CIE profile only +- Visione backend remains source of truth for application auth +- frontend drives browser redirect flow + +## Local Reference Mapping +- Demo OP/federation behavior source: + - `spid-cie-oidc-django/examples/provider` + - `spid-cie-oidc-django/spid_cie_oidc/provider` + - `spid-cie-oidc-django/spid_cie_oidc/entity` +- Demo RP behavior to emulate in Drogon: + - `spid-cie-oidc-django/spid_cie_oidc/relying_party/views/rp_begin.py` + - `spid-cie-oidc-django/spid_cie_oidc/relying_party/views/rp_callback.py` + +## Current Status +- `common/OidcJwt.*` now owns JWT/JWS/JWK work through `jwt-cpp` +- `common/OidcCrypto.*` isolates OpenSSL-only JWE compact parsing and decrypt +- `POST /api/v1/auth/cie/start` signs the request object with RP signing key +- callback exchanges code with `private_key_jwt`, verifies token JWS and `at_hash` +- `userinfo` GET supports plain JSON and demo-compatible nested JWE -> JWS +- merged OIDC claims persist through `user_external_identities` +- frontend polling contract is active through `GET /api/v1/auth/cie/session/{state}` + +## Foundation Added In This Step +- config placeholders in `config.json` +- DB schema for OIDC auth sessions, tokens, federation cache, external identities +- Drogon controller skeleton in `controllers/OidcController.*` +- service skeleton in `services/OidcRpService.*` +- RP config helper in `common/OidcRpConfig.*` +- RP public endpoints reserved: + - `POST /api/v1/auth/cie/start` + - `GET /api/v1/auth/cie/callback` + - `GET /api/v1/auth/cie/session/{state}` + - `GET /oidc/rp/.well-known/openid-federation` + - `GET /oidc/rp/resolve` + - `GET /oidc/rp/jwks.json` + +## Residual Backlog +### Phase 1: Dependencies and crypto plumbing +- Done for demo scope: + - `jwt-cpp` handles JWT parsing, signing and signature verification + - custom OpenSSL code remains only for JWE decryption in `OidcCrypto` + - focused unit tests cover JWT helpers and both supported JWE CBC/HMAC modes + +### Phase 2: RP metadata and JWKS +- Done for demo flow. +- Residual gap: + - verify metadata fields against non-demo providers before reuse + +### Phase 3: Federation trust resolution +- Current cut: + - fetches trust-anchor `resolve` + - extracts OP metadata/JWKS/endpoints +- Not done: + - full trust-chain signature validation + - federation cache population/reuse + - multi-provider or multi-anchor logic + +### Phase 4: Authorization start +- Done. +- Demo scope now sent explicitly as `openid cie_demo`. + +### Phase 5: Callback and token exchange +- Done for demo provider. +- Residual gap: + - explicit `exp` / `iat` skew checks still thin + - no refresh-token lifecycle use yet + +### Phase 6: UserInfo and local account link +- Done for demo provider, including JWE decrypt + JWS verify + merged claims. + +### Phase 7: Frontend completion contract +- Done. +- Session payload now exposes: + - public `status` + - internal `phase` + - `auth` payload only on success + +### Phase 8: Tests and docs +- Unit tests added for: + - JWT parse/sign/verify + - `kid` lookup + - `at_hash` helper + - JWE decrypt for `A256CBC-HS512` + - JWE decrypt for `A128CBC-HS256` + - invalid JWE tag / unsupported enc + - merged claim precedence and fiscal code normalization +- Still missing: + - automated integration tests against live demo Django OP + +## Data Model Notes +### `oidc_authentications` +Stores single login transaction state and provider snapshot. + +### `oidc_authentication_tokens` +Stores authorization code and received tokens. + +### `oidc_federation_cache` +Stores fetched federation docs with expiry. + +### `user_external_identities` +Decouples local user from external subject identifiers. + +## Current Decisions +- Keep OTP auth alive in parallel +- Use local `api_tokens` after OIDC success +- Use `fiscal_code` as strongest user reunification key +- Do not expose Visione token in query string +- Prefer external validated libs over handwritten JWT/JWK code + +## Ready Criteria For Real Implementation +- foundation schema merged +- config keys finalized +- dependency choice finalized +- endpoint shapes stable enough for frontend + +When these are acceptable, next step is real implementation of Phase 1-4. + +## Demo CIE Runbook +### Prerequisites +- MySQL and Mailpit running locally through this repo `docker compose` +- RP keys available in `secrets/oidc/demo-rp-jwks.json` +- backend `config.json` aligned with: + - trust anchor `http://127.0.0.1:8000` + - provider issuer `http://127.0.0.1:8002/oidc/op` + - callback `http://127.0.0.1:5555/api/v1/auth/cie/callback` + +### Bootstrap local dependencies +```bash +docker compose up -d +cmake -S . -B build_local -G Ninja -DCMAKE_BUILD_TYPE=Debug +cmake --build build_local +``` + +### Bootstrap demo federation authority +```bash +cd spid-cie-oidc-django/examples/federation_authority +cp federation_authority/settingslocal.py.example federation_authority/settingslocal.py +python3 -m pip install --upgrade pip +python3 -m pip install -e ../.. +python3 -m pip install "design-django-theme==v1.4.8" +./manage.py migrate +./manage.py loaddata dumps/example.json +./manage.py runserver 0.0.0.0:8000 +``` + +### Bootstrap demo provider +```bash +cd spid-cie-oidc-django/examples/provider +cp provider/settingslocal.py.example provider/settingslocal.py +./manage.py migrate +./manage.py loaddata dumps/example.json +./manage.py runserver 0.0.0.0:8002 +``` + +### Start Visione backend +```bash +./build_local/visione-backend +``` + +### Start login +```bash +curl -sS -X POST http://127.0.0.1:5555/api/v1/auth/cie/start \ + -H 'Content-Type: application/json' \ + -d '{"provider":"cie_demo","frontend_redirect_uri":"http://localhost:3000/auth/cie/callback"}' +``` + +Expected result: +- `authorization_url` present +- `state` present +- one row in `oidc_authentications` with phase `AUTHORIZATION_REDIRECT_READY` + +### Complete browser flow +1. Open returned `authorization_url`. +2. Authenticate with the demo CIE user configured by the Django fixtures. +3. Accept consent if requested. +4. Provider redirects to backend callback. +5. Backend redirects frontend to `frontend_redirect_uri?state=...`. + +### Poll login result +```bash +curl -sS http://127.0.0.1:5555/api/v1/auth/cie/session/ +``` + +Expected success payload: +- `status = "authenticated"` +- `phase = "AUTHENTICATED"` +- `auth.token` present + +### Minimal DB verification +```sql +SELECT state,status,authenticated_user_id FROM oidc_authentications ORDER BY id DESC LIMIT 3; +SELECT authentication_id,token_type,scope FROM oidc_authentication_tokens ORDER BY id DESC LIMIT 3; +SELECT user_id,provider,issuer,subject,fiscal_code FROM user_external_identities ORDER BY id DESC LIMIT 3; +SELECT user_id,expires_at FROM api_tokens ORDER BY id DESC LIMIT 3; +``` + +### Common failures +- `provider_metadata_missing`: federation authority not started or wrong trust anchor URL +- `token_exchange_failed`: provider callback reached but token endpoint unavailable or RP assertion invalid +- `invalid_userinfo_response`: JWE decryption key mismatch or unexpected provider response format +- `at_hash_mismatch`: provider tokens not aligned with received `access_token` diff --git a/migrations/011_oidc_rp_foundation.sql b/migrations/011_oidc_rp_foundation.sql new file mode 100644 index 0000000..9ca161f --- /dev/null +++ b/migrations/011_oidc_rp_foundation.sql @@ -0,0 +1,103 @@ +CREATE TABLE IF NOT EXISTS oidc_authentications ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + provider VARCHAR(50) NOT NULL, + provider_issuer VARCHAR(255) NOT NULL, + trust_anchor VARCHAR(255) NOT NULL, + state VARCHAR(128) NOT NULL, + nonce VARCHAR(128) NOT NULL, + pkce_verifier VARCHAR(255) NOT NULL, + code_challenge VARCHAR(255) NOT NULL, + frontend_redirect_uri VARCHAR(500) NULL, + authorization_endpoint VARCHAR(500) NULL, + token_endpoint VARCHAR(500) NULL, + userinfo_endpoint VARCHAR(500) NULL, + federation_resolve_endpoint VARCHAR(500) NULL, + request_object_jwt TEXT NULL, + provider_metadata JSON NULL, + trust_chain_json JSON NULL, + status ENUM( + 'CREATED', + 'AUTHORIZATION_REDIRECT_READY', + 'CALLBACK_RECEIVED', + 'TOKENS_RECEIVED', + 'USERINFO_RECEIVED', + 'AUTHENTICATED', + 'FAILED' + ) NOT NULL DEFAULT 'CREATED', + error_code VARCHAR(100) NULL, + error_message TEXT NULL, + authenticated_user_id BIGINT UNSIGNED NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + completed_at DATETIME NULL, + UNIQUE KEY oidc_authentications_state_unique (state), + KEY oidc_authentications_status_idx (status), + KEY oidc_authentications_provider_idx (provider), + CONSTRAINT oidc_authentications_user_fk + FOREIGN KEY (authenticated_user_id) REFERENCES users(id) +); + +CREATE TABLE IF NOT EXISTS oidc_authentication_tokens ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + authentication_id BIGINT UNSIGNED NOT NULL, + authorization_code TEXT NULL, + access_token TEXT NULL, + id_token TEXT NULL, + refresh_token TEXT NULL, + token_type VARCHAR(50) NULL, + scope VARCHAR(255) NULL, + expires_in_seconds INT NULL, + access_token_expires_at DATETIME NULL, + refresh_token_expires_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT oidc_auth_tokens_auth_fk + FOREIGN KEY (authentication_id) REFERENCES oidc_authentications(id) + ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS oidc_federation_cache ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + document_type ENUM( + 'ENTITY_CONFIGURATION', + 'ENTITY_STATEMENT', + 'RESOLVE_RESPONSE', + 'JWKS' + ) NOT NULL, + issuer VARCHAR(255) NOT NULL, + subject VARCHAR(255) NULL, + fetch_url VARCHAR(500) NOT NULL, + payload_json JSON NOT NULL, + expires_at DATETIME NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY oidc_federation_cache_unique ( + document_type, + issuer, + subject, + fetch_url + ), + KEY oidc_federation_cache_expires_idx (expires_at) +); + +CREATE TABLE IF NOT EXISTS user_external_identities ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT UNSIGNED NOT NULL, + provider VARCHAR(50) NOT NULL, + issuer VARCHAR(255) NOT NULL, + subject VARCHAR(255) NOT NULL, + fiscal_code VARCHAR(16) NULL, + raw_claims JSON NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY user_external_identities_provider_subject_unique ( + provider, + issuer, + subject + ), + KEY user_external_identities_user_idx (user_id), + KEY user_external_identities_fiscal_code_idx (fiscal_code), + CONSTRAINT user_external_identities_user_fk + FOREIGN KEY (user_id) REFERENCES users(id) + ON DELETE CASCADE +); diff --git a/migrations/012_oidc_session_result_token.sql b/migrations/012_oidc_session_result_token.sql new file mode 100644 index 0000000..bf1e02d --- /dev/null +++ b/migrations/012_oidc_session_result_token.sql @@ -0,0 +1,6 @@ +ALTER TABLE oidc_authentications + ADD COLUMN session_token VARCHAR(255) NULL AFTER authenticated_user_id, + ADD COLUMN session_token_expires_at DATETIME NULL AFTER session_token; + +CREATE UNIQUE INDEX oidc_authentications_session_token_unique + ON oidc_authentications (session_token); diff --git a/secrets/oidc/demo-rp-jwks.json b/secrets/oidc/demo-rp-jwks.json new file mode 100644 index 0000000..4427e38 --- /dev/null +++ b/secrets/oidc/demo-rp-jwks.json @@ -0,0 +1,43 @@ +{ + "federation": [ + { + "use": "sig", + "kty": "RSA", + "n": "pn_IchC66SFQMhbTHLtbD58jKiYYvYo7S4wjPjzECMu27c6FJVFNXtlubtb7sC6-WTQ1Hv4rSQdPhafJbIxa32R51sRQpkTr3JFMYP7x22DRQD_ixtQJRaiHw-npnZ8qgVHJ_t4gRTc4HJkehBLGv4-rHVAKzFiQNU1u2AFw1fWMnMH4ob_pyisXVgcku3dy14l7sYSANlpXufY_qnkQFTv0wMH-C6Izl-akENS2UHpvTLhfCBVKPrFXJxul4XDbUwUbvNZUxTernEx8lV5gxCSgKSBEgoH9MgpD1YWFPbAnwi7p7e7MNGz5lH7eDFKkPQhXLWABU8UvEIIWyA95SQ", + "e": "AQAB", + "d": "jOvIjuqkGF-OzeZNpprY1t79V1ZJSFtwFmA1EB9EWKF7YcmTHeOzVZ04f-3LtvHJQB0U_PdpUXUzlE9QB2TAISWcje84x5G8A8Ki39HIiMJoOTYZTen3MxMSXfpkNXma8ztzeRkuPQcgoevjTSvLNiV2UegGS7CF1eeE8qQ9iYjeimPSnfVeLx70L9Rat9-2cs3tqxkPOPPechnKrLJneVbxSmimiAEOQ238gGyEwmlfEGPn3OiIdU5oT-qvyp2KasmLucNgQxGLCkc5ppC_RsstQyNjs8bj88bjq3ahVDCiCFs8n6iRHAEZC1CM7xcEyZjyJ9ozC5nB-Pw3hLBSdQ", + "p": "0LeDPVA0DOSV3Gy3M1t5JxPMmPsgsCbx8y1thLuP21FOiVwRixRsXW10CxH56mX1qS9WVodlcCsqy1U-7yGJK9r-De8FvQtS8hbKEiVGMW61qIcMCv8krWOk5qU7uhxHLTL6qkvCsCpLdHeR7x842Aq3AnMCsNuWpLl3G4c0Cps", + "q": "zDfdUHIR1PvvBGleEnJFqe0Kw8bW9dOEGusZJ4bDoz61QRyn149Y64b8VW1f4kp1aGBySDm40BgnvPJtTMY0Bm-dPmL7XupsTyt-WHLmD178HQmsVTVIL0VLEIm0yKX7SdpgBOV9FFpHAkn1Mq3H0-tm48DwhCyi-84_bkc2h-s", + "kid": "k54HQtDibyGcs9ZWVMfviHf-2qLcFUtpwY2rgxBk88M", + "alg": "RS256" + } + ], + "core": [ + { + "alg": "RSA-OAEP", + "use": "enc", + "kty": "RSA", + "e": "AQAB", + "n": "oP1EPjcPOtV7Zog2suguY-tCLUVWSe2DOAHqlEWeDJtuQ1sO99Ue_5-Zbdm7iUmA2JNoCKZhp3RpICxRy01PsmXVm5UhsiYvHwK4vYq8hJRgtNpci3Gwj4YzpsN1p2un1VbvtAgiSN5wXURWyaMPMDuOMhIPIleaXU0wHcmGeXEuJrVPluz2jbqYhUHkAlySsH8-3Tx9VIjYohkiaSGU43XIYgCDL3mDkt7u3Z5w009vsNu1XhVZ7SE_XhkOzcvnA82NSq04ogwZ_oAyBY8nYMIN0irxR9r8NssGR3OZxqUMwvElqpyWt2NthgvS0GkMrgirQig-rvJ5bldlBkgOWQ", + "d": "caSgY0rD4zH0oSM9eZ_ajUCFUgIN54-dyFlI0M_Bwf_2jQNM1sqbO9eSm9Rgsq2eIh-jLC7ZnBK1kLdvTxRELhiQQ7FwPHZuzQeMhkBpZb_qhFJ8JjyI1DXDZPUnquMK3_xaFODNnBCOZdqO1uxozFpivT7duTvUvAgupfzlp2XWDu_b2xDed16ZtroQk2gqjcByJSDt8U3lj82n34HjcpTZNGIIV4IbJ1jbUJ554A73bmQbjRFInKHMEDmTZGoa-GVcn9YgITUPL_vNvMJgzwcNeextFFLsX-Z8WD3ku4en-guehqFt7-6ZPVLJ0nlBn7oYOpLEML-U-tBZXsBx", + "p": "z8soMD0NaVkvMqIYN1OkKPGQUNSaopYiQEgS9ynQfo-GEo7lhHbcLnhpnqXVR0MYwpvdchJwehIr5-UZIWIV7BHkNLSWy5KPCKZ5G2P7CWsbDTDk0DjL7IJpOukhMsWRpumIKoOefs8RurTtbvGhwj09eLwy4sWO7uI7u11SHdU", + "q": "xlZonE4-C1acGa15uQDSes0DXrLShT337FLCRMy-6HQODSW__xxtV87wVywvDIf39nTQxoOnvUybuAfXww9xexuzC3Q2jXznpvHE7O7lglc6Uq-tEnviVVe-RhAwEQheVPEbCIJQHfvXhDsRzbTrzw7ennM0Gd5WtaICtb54vHU", + "kid": "m1-4Lr9DqAh5-UXYvQnacFiMSrPMaXfK0cfFmCxVvI8" + }, + { + "alg": "RS256", + "use": "sig", + "kty": "RSA", + "kid": "2HnoFS3YnC9tjiCaivhWLVUJ3AxwGGz_98uRFaqMEEs", + "n": "5s4qi1Ta-sEuKb5rJ8TzHmyGKaSu89pIXIi6w4Ekx6GL56mJDNE_MWJHsFjWXajfMdMQmZrSXAvLtXxmbhUui9Mq_IormhmEyyEJS0SyE9UKTxWzi0yd_n_C7OjFBhM-0ZyUlgl81E_sr-35P1A6b5WSYwMvRSR-P9yx_NI-XBQ48G_zdmk3CbuuzZsXZqqgj5U7OGWH-4Huosn9nH3FVkwX0OlWkgWM-J9DEWzGBjl9hfbbrMtM_obljHL2NfT6RJYER2IpdI8RCyQS3sMPt6ZHDskmuNlyMDNATCChXQJLnltwEjxcgvzjw_G9J25DwfdfVEhDF_0kCp44UMmS3Q", + "e": "AQAB", + "d": "kXg7xFmVMxpy2AiWTRiLCw_nd3O-eR-JIBllbTeGUPR202o9YQC5TYzeFj3HznxTQHoBKm80SqN8n0Rq4tMi5SoRG96SIKwY0FZgHzqK6okJ2FKbOR7vLaqk1uDW3T9gBokj9XTBYqeFTFU3FTqhuhaRjypArtmTYPjejbSNbUZ29r2UjlMY92y-w4-IVDD9cWlI5I75QA1iWrmPF2t80uk9qqZFde8ZwWsvqqJym-I-x7T34SfMVhJQPbts6VzsUFAUZbT6kVKuUzffSdnr-QeQgj2dR-ULjcN3Y_M-6oc_n25Cz_xFgv3_3hLveizP6inooBzyhgTD1nlR7cWNgQ", + "p": "_XLjTNHt1OfGbaHae50Sm_C4dWp_fNNt1__cUNJ62bxJgFG3KlnwbRWtztXGe-BThMyMXK1HHjjUCf66FQGmfoDYTe7qz9j0OBctKMdkoAjTTYZdOYXu3G9U3HddnB-6bnd8fNnKidGAiseWi1eCoViEqeZ7cVULeOlI-ZOB500", + "q": "6SDtrFt50EL37kgcaKVttxXjz7JbanH5q-mLgV_tzBcdjEeg1lSv9-7pRDPNedF7KD-FsaT-2YSrPrc2F8z6_aKE7M_TCUv-m2LdLbvvB0iqO_kOjkdd1v9I-3qOq3Yvvd_SYTb81uAFTEFeRXoE7sLINOCO8ClCWa95nEFOMdE" + } + ], + "trust_mark": { + "id": "https://www.spid.gov.it/openid-federation/agreement/sp-public", + "trust_mark": "eyJhbGciOiJSUzI1NiIsImtpZCI6IkZpZll4MDNibm9zRDhtNmdZUUlmTkhOUDljTV9TYW05VGM1bkxsb0lJcmMifQ.eyJpc3MiOiJodHRwOi8vMTI3LjAuMC4xOjgwMDAvIiwic3ViIjoiaHR0cDovLzEyNy4wLjAuMTo4MDAwL29pZGMvcnAvIiwiaWF0IjoxNjQ1NjEyNDAxLCJpZCI6Imh0dHBzOi8vd3d3LnNwaWQuZ292Lml0L2NlcnRpZmljYXRpb24vcnAiLCJtYXJrIjoiaHR0cHM6Ly93d3cuYWdpZC5nb3YuaXQvdGhlbWVzL2N1c3RvbS9hZ2lkL2xvZ28uc3ZnIiwicmVmIjoiaHR0cHM6Ly9kb2NzLml0YWxpYS5pdC9pdGFsaWEvc3BpZC9zcGlkLXJlZ29sZS10ZWNuaWNoZS1vaWRjL2l0L3N0YWJpbGUvaW5kZXguaHRtbCJ9.mSPNR0AOPBn3UNJAIbrWUMQ8vGTetQajpa3i59JDKDXYWqo2TUGh4AQBghCiG3qqV9cl-hleLtuwoeZ1InKHeslTLftVdcR3meeMLs3mLobHYr26Mi7pC7-jx1ZFVyk4GXl7mn9WVSQGEUOiuhL01tdlUfxf0TJSFSOMEZGpCA3hXroLOnEl3FjkAw7sPvjfImsbadbHVusb72HTTs1n5Xo7z3As3fDWHcxD-fvvq0beu9cx-L2sT4YaNC-ELd1M3m5r0NIjjEUAt4Gnot-l5Z3-C_bA41uvh2hX34U_fGZ6jpmuluJo1Lqi26N8LTB-Rbu0UMaZnkRg9E72_YRZig" + } +} diff --git a/services/OidcRpService.cc b/services/OidcRpService.cc new file mode 100644 index 0000000..ce51e86 --- /dev/null +++ b/services/OidcRpService.cc @@ -0,0 +1,709 @@ +#include "services/OidcRpService.h" + +#include "common/Crypto.h" +#include "common/OidcCrypto.h" +#include "common/OidcHttp.h" +#include "common/OidcJwt.h" +#include "common/OidcRpConfig.h" +#include "common/OidcUserClaims.h" +#include "common/Validation.h" + +#include +#include + +#include +#include +#include + +namespace visione::services +{ +namespace +{ +constexpr std::string_view kCieProfile = "cie"; +constexpr std::string_view kProviderAlias = "cie_demo"; +constexpr std::string_view kOidcScope = "openid cie_demo"; + +Json::Value failure(const std::string &code, const std::string &message = {}) +{ + Json::Value body; + body["ok"] = false; + body["error"] = code; + if (!message.empty()) + { + body["message"] = message; + } + return body; +} + +Json::Value success() +{ + Json::Value body; + body["ok"] = true; + return body; +} + +long long nowEpoch() +{ + return std::chrono::system_clock::to_time_t( + std::chrono::system_clock::now()); +} + +int oidcTimeoutSeconds() +{ + return drogon::app().getCustomConfig()["oidc_rp"] + .get("http_timeout_seconds", 8) + .asInt(); +} + +std::string redirectUrlWithState(const std::string &baseUrl, + const std::string &state) +{ + if (baseUrl.empty()) + { + return {}; + } + return baseUrl + (baseUrl.find('?') == std::string::npos ? "?" : "&") + + "state=" + drogon::utils::urlEncode(state); +} + +std::string issueVisioneToken(long long userId) +{ + const auto token = crypto::randomToken(); + const auto hours = drogon::app() + .getCustomConfig()["auth"] + .get("token_ttl_hours", 24) + .asInt(); + drogon::app().getDbClient()->execSqlSync( + "INSERT INTO api_tokens (user_id, token_hash, expires_at) " + "VALUES (?, ?, DATE_ADD(NOW(), INTERVAL ? HOUR))", + userId, + crypto::hashSecret(token), + hours); + return token; +} + +std::vector parseJwks(const Json::Value &jwks) +{ + std::vector out; + const auto &keys = jwks.isObject() ? jwks["keys"] : jwks; + if (!keys.isArray()) + { + return out; + } + for (const auto &item : keys) + { + out.push_back(oidc::parseJwk(item)); + } + return out; +} + +std::string sessionPublicStatus(const std::string &internalStatus) +{ + if (internalStatus == "AUTHENTICATED") + { + return "authenticated"; + } + if (internalStatus == "FAILED") + { + return "failed"; + } + return "pending"; +} + +Json::Value fetchUserinfoClaims(const oidc::RpConfig &config, + const Json::Value &providerMetadata, + const std::vector &providerJwks, + const std::string &accessToken) +{ + const auto userinfoEndpoint = + providerMetadata.get("userinfo_endpoint", "").asString(); + if (userinfoEndpoint.empty()) + { + throw std::runtime_error("userinfo_endpoint_missing"); + } + + const auto response = oidc::httpGet( + userinfoEndpoint, + {{"Authorization", "Bearer " + accessToken}}, + oidcTimeoutSeconds()); + if (response.statusCode != 200) + { + throw std::runtime_error("userinfo_request_failed"); + } + + try + { + return oidc::parseJsonResponse(response); + } + catch (const std::exception &) + { + } + + const auto jwe = oidc::parseJwe(response.body); + const auto rpKid = jwe.header.get("kid", "").asString(); + const auto rpJwk = oidc::findJwkByKid(config.coreJwks, rpKid); + if (!rpJwk) + { + throw std::runtime_error("userinfo_decryption_key_not_found"); + } + + auto jws = oidc::decryptJwe(response.body, *rpJwk); + const auto jwsParts = oidc::parseJwt(jws); + const auto providerJwk = oidc::findJwkByKid( + providerJwks, jwsParts.header.get("kid", "").asString()); + if (!providerJwk || !oidc::verifyJwtSignature(jws, *providerJwk)) + { + throw std::runtime_error("invalid_userinfo_signature"); + } + + return oidc::parseJsonObjectJwtPayload(jws); +} + +std::string defaultFrontendRedirect(const oidc::RpConfig &config) +{ + return config.postLoginRedirectUri; +} + +Json::Value tokenPayloadForResponse(const std::string &token) +{ + Json::Value body; + body["token"] = token; + body["token_type"] = "Bearer"; + body["expires_in_hours"] = drogon::app() + .getCustomConfig()["auth"] + .get("token_ttl_hours", 24) + .asInt(); + return body; +} + +long long findOrCreateCitizen(const oidc::NormalizedUserClaims &claims) +{ + auto db = drogon::app().getDbClient(); + + const auto ext = db->execSqlSync( + "SELECT user_id FROM user_external_identities " + "WHERE provider = ? AND issuer = ? AND subject = ? LIMIT 1", + std::string(kProviderAlias), + claims.issuer, + claims.subject); + if (!ext.empty()) + { + return ext[0]["user_id"].as(); + } + + const auto userByFiscal = db->execSqlSync( + "SELECT id FROM users WHERE fiscal_code = ? LIMIT 1", claims.fiscalCode); + long long userId = 0; + if (!userByFiscal.empty()) + { + userId = userByFiscal[0]["id"].as(); + db->execSqlSync( + "UPDATE users SET first_name = COALESCE(NULLIF(?, ''), first_name), " + "last_name = COALESCE(NULLIF(?, ''), last_name), " + "email = COALESCE(NULLIF(?, ''), email) WHERE id = ?", + claims.firstName, + claims.lastName, + claims.email, + userId); + } + else + { + const auto email = !claims.email.empty() + ? claims.email + : (claims.fiscalCode + "@cie.local"); + db->execSqlSync( + "INSERT INTO users " + "(first_name, last_name, email, fiscal_code, birth_date, birth_place, phone) " + "VALUES (?, ?, ?, ?, NULL, NULL, NULL)", + claims.firstName.empty() ? claims.fiscalCode : claims.firstName, + claims.lastName.empty() ? claims.fiscalCode : claims.lastName, + email, + claims.fiscalCode); + const auto inserted = db->execSqlSync("SELECT LAST_INSERT_ID() AS id"); + userId = inserted[0]["id"].as(); + db->execSqlSync( + "INSERT INTO user_roles (user_id, role_id) " + "SELECT ?, id FROM roles WHERE name = 'citizen'", + userId); + } + + Json::StreamWriterBuilder writer; + writer["indentation"] = ""; + db->execSqlSync( + "INSERT INTO user_external_identities " + "(user_id, provider, issuer, subject, fiscal_code, raw_claims) " + "VALUES (?, ?, ?, ?, ?, CAST(? AS JSON)) " + "ON DUPLICATE KEY UPDATE user_id = VALUES(user_id), " + "fiscal_code = VALUES(fiscal_code), raw_claims = VALUES(raw_claims)", + userId, + std::string(kProviderAlias), + claims.issuer, + claims.subject, + claims.fiscalCode, + Json::writeString(writer, claims.rawClaims)); + return userId; +} + +Json::Value providerResolveDocument(const oidc::RpProviderConfig &provider) +{ + const auto url = provider.trustAnchor + "/resolve?sub=" + + drogon::utils::urlEncode(provider.issuer) + + "&trust_anchor=" + drogon::utils::urlEncode(provider.trustAnchor) + + "&format=json"; + return oidc::parseJsonResponse(oidc::httpGet(url, oidcTimeoutSeconds())); +} +} // namespace + +Json::Value OidcRpService::startLogin(const Json::Value &body) +{ + const auto config = oidc::loadRpConfig(); + if (!config.enabled) + { + return failure("cie_login_disabled"); + } + + const auto providerName = body.get("provider", std::string(kProviderAlias)).asString(); + const auto provider = oidc::findProviderConfig(config, providerName); + if (!provider) + { + return failure("unknown_provider"); + } + + const auto resolveDoc = providerResolveDocument(*provider); + const auto metadata = resolveDoc["metadata"]["openid_provider"]; + if (!metadata.isObject()) + { + return failure("provider_metadata_missing"); + } + + const auto authorizationEndpoint = + metadata.get("authorization_endpoint", "").asString(); + const auto tokenEndpoint = metadata.get("token_endpoint", "").asString(); + if (authorizationEndpoint.empty() || tokenEndpoint.empty()) + { + return failure("provider_endpoints_missing"); + } + + const auto state = oidc::randomUrlSafeString(24); + const auto nonce = oidc::randomUrlSafeString(24); + const auto pkceVerifier = oidc::randomUrlSafeString(32); + const auto codeChallenge = oidc::sha256Base64Url(pkceVerifier); + + Json::Value claims; + claims["id_token"]["given_name"]["essential"] = true; + claims["id_token"]["family_name"]["essential"] = true; + claims["userinfo"]["given_name"] = Json::nullValue; + claims["userinfo"]["family_name"] = Json::nullValue; + claims["userinfo"]["email"] = Json::nullValue; + claims["userinfo"]["https://attributes.eid.gov.it/fiscal_number"] = + Json::nullValue; + + Json::Value requestHeader; + requestHeader["alg"] = "RS256"; + requestHeader["typ"] = "JWT"; + requestHeader["kid"] = config.coreJwks[1].kid; + + Json::Value requestPayload; + requestPayload["iss"] = config.clientId; + requestPayload["client_id"] = config.clientId; + requestPayload["response_type"] = "code"; + requestPayload["scope"] = std::string(kOidcScope); + requestPayload["redirect_uri"] = config.redirectUri; + requestPayload["nonce"] = nonce; + requestPayload["state"] = state; + requestPayload["code_challenge"] = codeChallenge; + requestPayload["code_challenge_method"] = "S256"; + requestPayload["acr_values"].append("https://www.spid.gov.it/SpidL2"); + requestPayload["claims"] = claims; + requestPayload["prompt"] = "consent login"; + requestPayload["iat"] = static_cast(nowEpoch()); + requestPayload["exp"] = static_cast(nowEpoch() + 60); + requestPayload["jti"] = drogon::utils::getUuid(); + requestPayload["aud"].append(provider->issuer); + requestPayload["aud"].append(authorizationEndpoint); + + const auto requestJwt = + oidc::signJwt(requestHeader, requestPayload, config.coreJwks[1]); + + Json::StreamWriterBuilder writer; + writer["indentation"] = ""; + const auto frontendRedirect = + body.get("frontend_redirect_uri", defaultFrontendRedirect(config)).asString(); + drogon::app().getDbClient()->execSqlSync( + "INSERT INTO oidc_authentications " + "(provider, provider_issuer, trust_anchor, state, nonce, pkce_verifier, " + "code_challenge, frontend_redirect_uri, authorization_endpoint, " + "token_endpoint, userinfo_endpoint, federation_resolve_endpoint, " + "request_object_jwt, provider_metadata, trust_chain_json, status) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS JSON), " + "CAST(? AS JSON), 'AUTHORIZATION_REDIRECT_READY')", + providerName, + provider->issuer, + provider->trustAnchor, + state, + nonce, + pkceVerifier, + codeChallenge, + frontendRedirect, + authorizationEndpoint, + tokenEndpoint, + metadata.get("userinfo_endpoint", "").asString(), + provider->trustAnchor + "/resolve", + requestJwt, + Json::writeString(writer, metadata), + Json::writeString(writer, resolveDoc)); + + std::ostringstream authorizationUrl; + authorizationUrl << authorizationEndpoint + << "?client_id=" << drogon::utils::urlEncode(config.clientId) + << "&scope=" << drogon::utils::urlEncode(std::string(kOidcScope)) + << "&response_type=code" + << "&code_challenge=" << drogon::utils::urlEncode(codeChallenge) + << "&code_challenge_method=S256" + << "&request=" << drogon::utils::urlEncode(requestJwt); + + auto response = success(); + response["status"] = "authorization_redirect_ready"; + response["provider"] = providerName; + response["state"] = state; + response["authorization_url"] = authorizationUrl.str(); + return response; +} + +Json::Value OidcRpService::callback(const std::string &state, + const std::string &code, + const std::string &error, + const std::string &errorDescription, + const std::optional &issuer) +{ + const auto config = oidc::loadRpConfig(); + auto db = drogon::app().getDbClient(); + + const auto sessions = db->execSqlSync( + "SELECT id, provider, provider_issuer, nonce, pkce_verifier, token_endpoint, " + "frontend_redirect_uri, provider_metadata FROM oidc_authentications " + "WHERE state = ? LIMIT 1", + state); + if (sessions.empty()) + { + return failure("session_not_found"); + } + + const auto authId = sessions[0]["id"].as(); + const auto frontendRedirect = + sessions[0]["frontend_redirect_uri"].as(); + + auto finishRedirect = [&](const std::string &statusCode, + const std::string &message) { + db->execSqlSync( + "UPDATE oidc_authentications SET status = 'FAILED', error_code = ?, " + "error_message = ?, completed_at = NOW() WHERE id = ?", + statusCode, + message, + authId); + auto response = failure(statusCode, message); + response["redirect_url"] = redirectUrlWithState(frontendRedirect, state); + return response; + }; + + if (!error.empty()) + { + return finishRedirect(error, errorDescription); + } + if (code.empty()) + { + return finishRedirect("missing_code", "Provider callback did not include code"); + } + if (issuer && *issuer != sessions[0]["provider_issuer"].as()) + { + return finishRedirect("issuer_mismatch", "Provider issuer mismatch"); + } + db->execSqlSync( + "UPDATE oidc_authentications SET status = 'CALLBACK_RECEIVED' WHERE id = ?", + authId); + + Json::Value providerMetadata; + { + Json::CharReaderBuilder builder; + std::string errors; + std::unique_ptr reader(builder.newCharReader()); + const auto providerMetadataText = + sessions[0]["provider_metadata"].as(); + reader->parse(providerMetadataText.data(), + providerMetadataText.data() + providerMetadataText.size(), + &providerMetadata, + &errors); + } + + Json::Value assertionHeader; + assertionHeader["alg"] = "RS256"; + assertionHeader["typ"] = "JWT"; + assertionHeader["kid"] = config.coreJwks[1].kid; + Json::Value assertionPayload; + assertionPayload["iss"] = config.clientId; + assertionPayload["sub"] = config.clientId; + assertionPayload["aud"].append(sessions[0]["token_endpoint"].as()); + assertionPayload["iat"] = static_cast(nowEpoch()); + assertionPayload["exp"] = static_cast(nowEpoch() + 60); + assertionPayload["jti"] = drogon::utils::getUuid(); + + const auto clientAssertion = + oidc::signJwt(assertionHeader, assertionPayload, config.coreJwks[1]); + + Json::Value tokenResponse; + try + { + tokenResponse = oidc::parseJsonResponse(oidc::httpPostForm( + sessions[0]["token_endpoint"].as(), + { + {"grant_type", "authorization_code"}, + {"redirect_uri", config.redirectUri}, + {"client_id", config.clientId}, + {"state", state}, + {"code", code}, + {"code_verifier", sessions[0]["pkce_verifier"].as()}, + {"client_assertion_type", + "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"}, + {"client_assertion", clientAssertion}, + }, + oidcTimeoutSeconds())); + } + catch (const std::exception &e) + { + return finishRedirect("token_exchange_failed", e.what()); + } + + if (!tokenResponse.isObject() || !tokenResponse.isMember("id_token") || + !tokenResponse.isMember("access_token")) + { + return finishRedirect("invalid_token_response", + "Provider token response missing tokens"); + } + db->execSqlSync( + "UPDATE oidc_authentications SET status = 'TOKENS_RECEIVED' WHERE id = ?", + authId); + + const auto providerJwks = parseJwks(providerMetadata["jwks"]); + const auto idToken = tokenResponse["id_token"].asString(); + const auto accessToken = tokenResponse["access_token"].asString(); + const auto parsedId = oidc::parseJwt(idToken); + const auto parsedAccess = oidc::parseJwt(accessToken); + const auto idJwk = oidc::findJwkByKid(providerJwks, + parsedId.header.get("kid", "").asString()); + const auto accessJwk = + oidc::findJwkByKid(providerJwks, + parsedAccess.header.get("kid", "").asString()); + if (!idJwk || !accessJwk || + !oidc::verifyJwtSignature(idToken, *idJwk) || + !oidc::verifyJwtSignature(accessToken, *accessJwk)) + { + return finishRedirect("invalid_provider_signature", + "Provider token signature verification failed"); + } + + const auto idPayload = parsedId.payload; + if (idPayload.get("iss", "").asString() != + sessions[0]["provider_issuer"].as()) + { + return finishRedirect("id_token_issuer_mismatch", "ID token issuer mismatch"); + } + const auto aud = idPayload["aud"]; + bool audienceOk = false; + if (aud.isArray()) + { + for (const auto &item : aud) + { + if (item.asString() == config.clientId) + { + audienceOk = true; + break; + } + } + } + else + { + audienceOk = aud.asString() == config.clientId; + } + if (!audienceOk || idPayload.get("nonce", "").asString() != + sessions[0]["nonce"].as()) + { + return finishRedirect("id_token_validation_failed", + "ID token audience or nonce mismatch"); + } + if (idPayload.get("at_hash", "").asString() != + oidc::leftHashBase64Url(accessToken)) + { + return finishRedirect("at_hash_mismatch", "ID token at_hash mismatch"); + } + + Json::Value userinfoClaims; + try + { + userinfoClaims = + fetchUserinfoClaims(config, providerMetadata, providerJwks, accessToken); + } + catch (const std::exception &e) + { + return finishRedirect("invalid_userinfo_response", e.what()); + } + db->execSqlSync( + "UPDATE oidc_authentications SET status = 'USERINFO_RECEIVED' WHERE id = ?", + authId); + + auto normalized = oidc::mergeUserClaims( + sessions[0]["provider_issuer"].as(), + idPayload, + userinfoClaims); + if (!normalized) + { + return finishRedirect("missing_identity_claims", + "Required identity claims missing from merged OIDC claims"); + } + + const auto userId = findOrCreateCitizen(*normalized); + const auto sessionToken = issueVisioneToken(userId); + + Json::StreamWriterBuilder writer; + writer["indentation"] = ""; + db->execSqlSync( + "INSERT INTO oidc_authentication_tokens " + "(authentication_id, authorization_code, access_token, id_token, refresh_token, " + "token_type, scope, expires_in_seconds) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + authId, + code, + accessToken, + idToken, + tokenResponse.get("refresh_token", "").asString(), + tokenResponse.get("token_type", "Bearer").asString(), + tokenResponse.get("scope", "openid").asString(), + tokenResponse.get("expires_in", 0).asInt()); + db->execSqlSync( + "UPDATE oidc_authentications SET status = 'AUTHENTICATED', " + "authenticated_user_id = ?, session_token = ?, " + "session_token_expires_at = DATE_ADD(NOW(), INTERVAL ? HOUR), " + "completed_at = NOW() WHERE id = ?", + userId, + sessionToken, + drogon::app().getCustomConfig()["auth"].get("token_ttl_hours", 24).asInt(), + authId); + + auto response = success(); + response["redirect_url"] = redirectUrlWithState(frontendRedirect, state); + return response; +} + +Json::Value OidcRpService::sessionStatus(const std::string &state) +{ + auto db = drogon::app().getDbClient(); + const auto result = db->execSqlSync( + "SELECT state, status, provider, frontend_redirect_uri, " + "authenticated_user_id, session_token, error_code, error_message, created_at, " + "completed_at FROM oidc_authentications WHERE state = ? LIMIT 1", + state); + if (result.empty()) + { + return failure("session_not_found"); + } + + auto response = success(); + response["state"] = result[0]["state"].as(); + response["status"] = + sessionPublicStatus(result[0]["status"].as()); + response["phase"] = result[0]["status"].as(); + response["provider"] = result[0]["provider"].as(); + response["frontend_redirect_uri"] = + result[0]["frontend_redirect_uri"].as(); + if (!result[0]["authenticated_user_id"].isNull()) + { + response["authenticated_user_id"] = + Json::Int64(result[0]["authenticated_user_id"].as()); + } + response["error_code"] = result[0]["error_code"].as(); + response["error_message"] = result[0]["error_message"].as(); + response["created_at"] = result[0]["created_at"].as(); + response["completed_at"] = result[0]["completed_at"].isNull() + ? "" + : result[0]["completed_at"].as(); + if (!result[0]["session_token"].isNull() && + !result[0]["session_token"].as().empty()) + { + response["auth"] = tokenPayloadForResponse( + result[0]["session_token"].as()); + } + return response; +} + +Json::Value OidcRpService::entityConfigurationDocument() const +{ + const auto config = oidc::loadRpConfig(); + return oidc::entityConfigurationDocument(config); +} + +std::string OidcRpService::entityConfigurationJwt() const +{ + const auto config = oidc::loadRpConfig(); + Json::Value header; + header["alg"] = "RS256"; + header["typ"] = "entity-statement+jwt"; + header["kid"] = config.federationJwks.front().kid; + return oidc::signJwt( + header, oidc::entityConfigurationDocument(config), config.federationJwks.front()); +} + +Json::Value OidcRpService::resolveDocument(const std::optional &sub, + const std::optional &anchor) const +{ + const auto config = oidc::loadRpConfig(); + if (!sub || !anchor || *sub != config.clientId) + { + return failure("not_found"); + } + bool anchorAllowed = false; + for (const auto &value : config.trustAnchors) + { + if (value == *anchor) + { + anchorAllowed = true; + break; + } + } + if (!anchorAllowed) + { + return failure("not_found"); + } + + Json::Value body; + body["iss"] = config.clientId; + body["sub"] = config.clientId; + body["iat"] = static_cast(nowEpoch()); + body["exp"] = static_cast(nowEpoch() + 2880 * 60); + if (!config.trustMark.isNull()) + { + body["trust_marks"].append(config.trustMark); + } + body["metadata"] = oidc::rpMetadataDocument(config); + body["trust_chain"] = Json::arrayValue; + return body; +} + +std::string OidcRpService::resolveJwt(const std::optional &sub, + const std::optional &anchor) const +{ + const auto document = resolveDocument(sub, anchor); + if (document.get("ok", false).asBool() == false && document.isMember("error")) + { + return {}; + } + const auto config = oidc::loadRpConfig(); + Json::Value header; + header["alg"] = "RS256"; + header["typ"] = "resolve-response+jwt"; + header["kid"] = config.federationJwks.front().kid; + return oidc::signJwt(header, document, config.federationJwks.front()); +} + +Json::Value OidcRpService::jwks() const +{ + return oidc::rpPublicJwksDocument(oidc::loadRpConfig()); +} +} // namespace visione::services diff --git a/services/OidcRpService.h b/services/OidcRpService.h new file mode 100644 index 0000000..70d0af9 --- /dev/null +++ b/services/OidcRpService.h @@ -0,0 +1,28 @@ +#pragma once + +#include + +#include +#include + +namespace visione::services +{ +class OidcRpService +{ + public: + Json::Value startLogin(const Json::Value &body); + Json::Value callback(const std::string &state, + const std::string &code, + const std::string &error, + const std::string &errorDescription, + const std::optional &issuer); + Json::Value sessionStatus(const std::string &state); + Json::Value entityConfigurationDocument() const; + std::string entityConfigurationJwt() const; + Json::Value resolveDocument(const std::optional &sub, + const std::optional &anchor) const; + std::string resolveJwt(const std::optional &sub, + const std::optional &anchor) const; + Json::Value jwks() const; +}; +} // namespace visione::services diff --git a/spid-cie-oidc-django b/spid-cie-oidc-django new file mode 160000 index 0000000..931c953 --- /dev/null +++ b/spid-cie-oidc-django @@ -0,0 +1 @@ +Subproject commit 931c95394b53c9f8b31a03525759ef99ba29753d diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 4912b43..bb8c8ff 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -1,5 +1,6 @@ add_executable(${PROJECT_NAME}_test test_main.cc + OidcTests.cc ${APP_SOURCES} ) @@ -11,6 +12,7 @@ target_include_directories(${PROJECT_NAME}_test target_link_libraries(${PROJECT_NAME}_test PRIVATE Drogon::Drogon + jwt-cpp::jwt-cpp ) ParseAndAddDrogonTests(${PROJECT_NAME}_test) diff --git a/test/OidcTests.cc b/test/OidcTests.cc new file mode 100644 index 0000000..b83b0d4 --- /dev/null +++ b/test/OidcTests.cc @@ -0,0 +1,359 @@ +#include + +#include "common/Crypto.h" +#include "common/OidcCrypto.h" +#include "common/OidcJwt.h" +#include "common/OidcUserClaims.h" + +#include +#include +#include +#include +#include +#include +#include + +using namespace visione; + +namespace +{ +const std::string kWorkspaceRoot = + std::filesystem::path(__FILE__).parent_path().parent_path().string(); + +Json::Value loadJson(const std::string &path) +{ + std::ifstream input(path); + if (!input.is_open()) + { + throw std::runtime_error("fixture_open_failed"); + } + Json::Value value; + input >> value; + return value; +} + +oidc::Jwk loadCoreJwk(size_t index) +{ + const auto root = loadJson(kWorkspaceRoot + "/secrets/oidc/demo-rp-jwks.json"); + if (!root["core"].isArray() || root["core"].size() <= index) + { + throw std::runtime_error("fixture_jwk_missing"); + } + return oidc::parseJwk(root["core"][static_cast(index)]); +} + +std::string compactJwe(const Json::Value &header, + const std::string &cek, + const std::string &iv, + const std::string &ciphertext, + const std::string &tag, + const oidc::Jwk &recipientKey) +{ + const auto publicPem = crypto::pemFromRsaJwk(recipientKey.n, recipientKey.e); + BIO *bio = BIO_new_mem_buf(publicPem.data(), static_cast(publicPem.size())); + if (!bio) + { + throw std::runtime_error("bio_new_failed"); + } + EVP_PKEY *pkey = PEM_read_bio_PUBKEY(bio, nullptr, nullptr, nullptr); + BIO_free(bio); + if (!pkey) + { + throw std::runtime_error("pem_read_failed"); + } + + EVP_PKEY_CTX *ctx = EVP_PKEY_CTX_new(pkey, nullptr); + if (!ctx) + { + EVP_PKEY_free(pkey); + throw std::runtime_error("ctx_new_failed"); + } + if (EVP_PKEY_encrypt_init(ctx) != 1 || + EVP_PKEY_CTX_set_rsa_padding(ctx, RSA_PKCS1_OAEP_PADDING) != 1 || + EVP_PKEY_CTX_set_rsa_oaep_md(ctx, EVP_sha1()) != 1 || + EVP_PKEY_CTX_set_rsa_mgf1_md(ctx, EVP_sha1()) != 1) + { + EVP_PKEY_CTX_free(ctx); + EVP_PKEY_free(pkey); + throw std::runtime_error("encrypt_init_failed"); + } + + size_t encryptedLen = 0; + if (EVP_PKEY_encrypt(ctx, + nullptr, + &encryptedLen, + reinterpret_cast(cek.data()), + cek.size()) != 1) + { + EVP_PKEY_CTX_free(ctx); + EVP_PKEY_free(pkey); + throw std::runtime_error("encrypt_size_failed"); + } + std::string encryptedKey(encryptedLen, '\0'); + if (EVP_PKEY_encrypt(ctx, + reinterpret_cast(encryptedKey.data()), + &encryptedLen, + reinterpret_cast(cek.data()), + cek.size()) != 1) + { + EVP_PKEY_CTX_free(ctx); + EVP_PKEY_free(pkey); + throw std::runtime_error("encrypt_failed"); + } + encryptedKey.resize(encryptedLen); + + EVP_PKEY_CTX_free(ctx); + EVP_PKEY_free(pkey); + + Json::StreamWriterBuilder writer; + writer["indentation"] = ""; + const auto encodedHeader = + oidc::base64UrlEncode(Json::writeString(writer, header)); + return encodedHeader + "." + oidc::base64UrlEncode(encryptedKey) + "." + + oidc::base64UrlEncode(iv) + "." + oidc::base64UrlEncode(ciphertext) + + "." + oidc::base64UrlEncode(tag); +} + +std::string encryptAesCbcHmac(const EVP_CIPHER *cipher, + const EVP_MD *md, + const std::string &macKey, + const std::string &encKey, + const std::string &aad, + const std::string &plaintext, + const std::string &iv, + size_t tagSize, + std::string &outCiphertext, + std::string &outTag) +{ + EVP_CIPHER_CTX *ctx = EVP_CIPHER_CTX_new(); + if (!ctx) + { + throw std::runtime_error("cipher_ctx_new_failed"); + } + if (EVP_EncryptInit_ex(ctx, + cipher, + nullptr, + reinterpret_cast(encKey.data()), + reinterpret_cast(iv.data())) != 1) + { + EVP_CIPHER_CTX_free(ctx); + throw std::runtime_error("encrypt_init_failed"); + } + + std::string ciphertext(plaintext.size() + EVP_MAX_BLOCK_LENGTH, '\0'); + int outLen1 = 0; + if (EVP_EncryptUpdate(ctx, + reinterpret_cast(ciphertext.data()), + &outLen1, + reinterpret_cast(plaintext.data()), + plaintext.size()) != 1) + { + EVP_CIPHER_CTX_free(ctx); + throw std::runtime_error("encrypt_update_failed"); + } + int outLen2 = 0; + if (EVP_EncryptFinal_ex( + ctx, + reinterpret_cast(ciphertext.data()) + outLen1, + &outLen2) != 1) + { + EVP_CIPHER_CTX_free(ctx); + throw std::runtime_error("encrypt_final_failed"); + } + EVP_CIPHER_CTX_free(ctx); + ciphertext.resize(outLen1 + outLen2); + + const uint64_t bitLength = aad.size() * 8; + std::string al(8, '\0'); + for (int i = 0; i < 8; ++i) + { + al[7 - i] = static_cast((bitLength >> (i * 8)) & 0xff); + } + + std::string macInput = aad + iv + ciphertext + al; + unsigned char hmac[EVP_MAX_MD_SIZE]; + unsigned int hmacLen = 0; + if (!HMAC(md, + macKey.data(), + static_cast(macKey.size()), + reinterpret_cast(macInput.data()), + macInput.size(), + hmac, + &hmacLen)) + { + throw std::runtime_error("hmac_failed"); + } + + outCiphertext = ciphertext; + outTag.assign(reinterpret_cast(hmac), tagSize); + return ciphertext; +} +} // namespace + +DROGON_TEST(OidcJwtParseSignVerifyAndKidLookup) +{ + const auto signingJwk = loadCoreJwk(1); + + Json::Value header; + header["alg"] = "RS256"; + header["typ"] = "JWT"; + header["kid"] = signingJwk.kid; + + Json::Value payload; + payload["iss"] = "visione-test"; + payload["sub"] = "user-123"; + payload["nonce"] = "nonce-456"; + payload["roles"].append("citizen"); + + const auto token = oidc::signJwt(header, payload, signingJwk); + const auto parsed = oidc::parseJwt(token); + + CHECK(parsed.header["kid"].asString() == signingJwk.kid); + CHECK(parsed.payload["sub"].asString() == "user-123"); + CHECK(oidc::verifyJwtSignature(token, signingJwk)); + + std::vector keys{loadCoreJwk(0), signingJwk}; + const auto found = oidc::findJwkByKid(keys, signingJwk.kid); + REQUIRE(found.has_value()); + CHECK(found->kid == signingJwk.kid); +} + +DROGON_TEST(OidcJwtLeftHashMatchesJwtCppComputation) +{ + const std::string accessToken = "access-token-fixture"; + const auto expected = oidc::leftHashBase64Url(accessToken); + CHECK(expected == "xb80u-_Gg2ezXboVjrZ35g"); +} + +DROGON_TEST(OidcCryptoDecryptsA256CbcHs512) +{ + const auto recipientJwk = loadCoreJwk(0); + + Json::Value header; + header["alg"] = "RSA-OAEP"; + header["enc"] = "A256CBC-HS512"; + header["kid"] = recipientJwk.kid; + + std::string cek(64, '\0'); + std::string iv(16, '\0'); + for (size_t i = 0; i < cek.size(); ++i) + { + cek[i] = static_cast(i + 1); + } + for (size_t i = 0; i < iv.size(); ++i) + { + iv[i] = static_cast(0x41 + i); + } + + const std::string plaintext = R"({"sub":"user-123","given_name":"Mario"})"; + Json::StreamWriterBuilder writer; + writer["indentation"] = ""; + const auto aad = oidc::base64UrlEncode(Json::writeString(writer, header)); + + std::string ciphertext; + std::string tag; + encryptAesCbcHmac(EVP_aes_256_cbc(), + EVP_sha512(), + cek.substr(0, 32), + cek.substr(32, 32), + aad, + plaintext, + iv, + 32, + ciphertext, + tag); + + const auto jwe = + compactJwe(header, cek, iv, ciphertext, tag, recipientJwk); + CHECK(oidc::decryptJwe(jwe, recipientJwk) == plaintext); +} + +DROGON_TEST(OidcCryptoDecryptsA128CbcHs256AndRejectsInvalidCases) +{ + const auto recipientJwk = loadCoreJwk(0); + + Json::Value header; + header["alg"] = "RSA-OAEP"; + header["enc"] = "A128CBC-HS256"; + header["kid"] = recipientJwk.kid; + + std::string cek(32, '\0'); + std::string iv(16, '\0'); + for (size_t i = 0; i < cek.size(); ++i) + { + cek[i] = static_cast(0x30 + i); + } + for (size_t i = 0; i < iv.size(); ++i) + { + iv[i] = static_cast(0x51 + i); + } + + const std::string plaintext = R"({"sub":"user-abc","email":"mario@example.com"})"; + Json::StreamWriterBuilder writer; + writer["indentation"] = ""; + const auto aad = oidc::base64UrlEncode(Json::writeString(writer, header)); + + std::string ciphertext; + std::string tag; + encryptAesCbcHmac(EVP_aes_128_cbc(), + EVP_sha256(), + cek.substr(0, 16), + cek.substr(16, 16), + aad, + plaintext, + iv, + 16, + ciphertext, + tag); + + const auto validJwe = + compactJwe(header, cek, iv, ciphertext, tag, recipientJwk); + CHECK(oidc::decryptJwe(validJwe, recipientJwk) == plaintext); + + auto missingKidHeader = header; + missingKidHeader.removeMember("kid"); + const auto missingKidJwe = + compactJwe(missingKidHeader, cek, iv, ciphertext, tag, recipientJwk); + const auto parsedMissingKid = oidc::parseJwe(missingKidJwe); + CHECK(parsedMissingKid.header.get("kid", "").asString().empty()); + + auto unsupportedHeader = header; + unsupportedHeader["enc"] = "A192GCM"; + const auto unsupportedJwe = + compactJwe(unsupportedHeader, cek, iv, ciphertext, tag, recipientJwk); + CHECK_THROWS_AS(oidc::decryptJwe(unsupportedJwe, recipientJwk), + std::runtime_error); + + std::string invalidTag = tag; + invalidTag[0] ^= 0x01; + const auto invalidTagJwe = + compactJwe(header, cek, iv, ciphertext, invalidTag, recipientJwk); + CHECK_THROWS_AS(oidc::decryptJwe(invalidTagJwe, recipientJwk), + std::runtime_error); +} + +DROGON_TEST(OidcMergeClaimsPrefersUserinfoAndNormalizesFiscalCode) +{ + Json::Value idTokenClaims; + idTokenClaims["sub"] = "provider-subject"; + idTokenClaims["given_name"] = "Old"; + idTokenClaims["family_name"] = "Value"; + + Json::Value userinfoClaims; + userinfoClaims["given_name"] = "Mario"; + userinfoClaims["family_name"] = "Rossi"; + userinfoClaims["email"] = "mario.rossi@example.com"; + userinfoClaims["https://attributes.eid.gov.it/fiscal_number"] = + "TINIT-RSSMRA80A01H501U"; + + const auto merged = + oidc::mergeUserClaims("http://127.0.0.1:8002/oidc/op", + idTokenClaims, + userinfoClaims); + REQUIRE(merged.has_value()); + CHECK(merged->subject == "provider-subject"); + CHECK(merged->firstName == "Mario"); + CHECK(merged->lastName == "Rossi"); + CHECK(merged->email == "mario.rossi@example.com"); + CHECK(merged->fiscalCode == "RSSMRA80A01H501U"); +}