diff --git a/.gitignore b/.gitignore index c67c7dd..53448b1 100644 --- a/.gitignore +++ b/.gitignore @@ -566,4 +566,5 @@ 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 6ee1a8b..8a15036 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -8,6 +8,7 @@ set(CMAKE_CXX_EXTENSIONS OFF) set(CMAKE_EXPORT_COMPILE_COMMANDS ON) find_package(Python3 COMPONENTS Interpreter REQUIRED) +find_package(OpenSSL REQUIRED) option(USE_SYSTEM_DROGON "Use an installed Drogon package instead of CPM" OFF) @@ -33,32 +34,16 @@ 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 @@ -70,7 +55,6 @@ 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 ) @@ -88,7 +72,7 @@ target_include_directories(${PROJECT_NAME} target_link_libraries(${PROJECT_NAME} PRIVATE Drogon::Drogon - jwt-cpp::jwt-cpp + OpenSSL::Crypto ) file(GLOB CONTROLLER_HEADERS CONFIGURE_DEPENDS diff --git a/common/Crypto.cc b/common/Crypto.cc index 47d5645..5e2aa15 100644 --- a/common/Crypto.cc +++ b/common/Crypto.cc @@ -1,13 +1,10 @@ #include "common/Crypto.h" #include -#include -#include -#include -#include +#include +#include #include -#include #include #include #include @@ -16,12 +13,6 @@ 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; @@ -32,39 +23,6 @@ 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) @@ -72,6 +30,29 @@ std::string sha256(const std::string &value) return drogon::utils::getSha256(value); } +std::string hmacSha256Hex(const std::string &key, const std::string &value) +{ + unsigned char digest[EVP_MAX_MD_SIZE]; + unsigned int digestSize = 0; + HMAC(EVP_sha256(), + key.data(), + static_cast(key.size()), + reinterpret_cast(value.data()), + value.size(), + digest, + &digestSize); + return toHex(digest, digestSize); +} + +bool constantTimeEquals(const std::string &left, const std::string &right) +{ + if (left.size() != right.size()) + { + return false; + } + return CRYPTO_memcmp(left.data(), right.data(), left.size()) == 0; +} + std::string randomNumericCode(int digits) { std::random_device device; @@ -92,151 +73,8 @@ 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 b0852c0..c9e8975 100644 --- a/common/Crypto.h +++ b/common/Crypto.h @@ -5,13 +5,9 @@ namespace visione::crypto { std::string sha256(const std::string &value); +std::string hmacSha256Hex(const std::string &key, const std::string &value); +bool constantTimeEquals(const std::string &left, const std::string &right); 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 85d58e5..1f55870 100644 --- a/common/Http.cc +++ b/common/Http.cc @@ -1,5 +1,9 @@ #include "common/Http.h" +#include + +#include + namespace visione::http { drogon::HttpResponsePtr json(Json::Value body, drogon::HttpStatusCode status) @@ -39,4 +43,23 @@ Json::Value requireJson(const drogon::HttpRequestPtr &request) } return *body; } + +Json::Value parseJsonBody(const std::string &body) +{ + if (body.empty()) + { + return Json::Value(Json::nullValue); + } + + Json::CharReaderBuilder builder; + builder["collectComments"] = false; + std::string errors; + Json::Value parsed; + std::istringstream stream(body); + if (!Json::parseFromStream(builder, stream, &parsed, &errors)) + { + return Json::Value(Json::nullValue); + } + return parsed; +} } // namespace visione::http diff --git a/common/Http.h b/common/Http.h index 1da2f51..8f7d341 100644 --- a/common/Http.h +++ b/common/Http.h @@ -15,4 +15,5 @@ drogon::HttpResponsePtr error(const std::string &code, drogon::HttpResponsePtr redirect(const std::string &url, drogon::HttpStatusCode status = drogon::k302Found); Json::Value requireJson(const drogon::HttpRequestPtr &request); +Json::Value parseJsonBody(const std::string &body); } // namespace visione::http diff --git a/common/OidcCrypto.cc b/common/OidcCrypto.cc deleted file mode 100644 index 5058733..0000000 --- a/common/OidcCrypto.cc +++ /dev/null @@ -1,350 +0,0 @@ -#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 deleted file mode 100644 index fa9a1d1..0000000 --- a/common/OidcCrypto.h +++ /dev/null @@ -1,23 +0,0 @@ -#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 deleted file mode 100644 index 8bbd6b4..0000000 --- a/common/OidcHttp.cc +++ /dev/null @@ -1,88 +0,0 @@ -#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 deleted file mode 100644 index d05fc54..0000000 --- a/common/OidcHttp.h +++ /dev/null @@ -1,26 +0,0 @@ -#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 deleted file mode 100644 index f63bd22..0000000 --- a/common/OidcJwt.cc +++ /dev/null @@ -1,360 +0,0 @@ -#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 deleted file mode 100644 index 8851fc1..0000000 --- a/common/OidcJwt.h +++ /dev/null @@ -1,52 +0,0 @@ -#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 deleted file mode 100644 index 7690c6d..0000000 --- a/common/OidcRpConfig.cc +++ /dev/null @@ -1,221 +0,0 @@ -#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 deleted file mode 100644 index a17c96c..0000000 --- a/common/OidcRpConfig.h +++ /dev/null @@ -1,44 +0,0 @@ -#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 deleted file mode 100644 index 947b06a..0000000 --- a/common/OidcUserClaims.cc +++ /dev/null @@ -1,110 +0,0 @@ -#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 deleted file mode 100644 index a75b618..0000000 --- a/common/OidcUserClaims.h +++ /dev/null @@ -1,29 +0,0 @@ -#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 81d5f2c..a328ef9 100644 --- a/config.json +++ b/config.json @@ -56,31 +56,12 @@ "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" + "cie_auth": { + "service_base_url": "http://127.0.0.1:8001", + "start_path": "/visione/start", + "shared_secret": "change-me-for-local-dev", + "state_ttl_minutes": 10, + "default_frontend_redirect_uri": "http://localhost:3000/auth/cie/callback" }, "mail": { "smtp_host": "127.0.0.1", diff --git a/controllers/AuthController.cc b/controllers/AuthController.cc index 762548c..fbfa70a 100644 --- a/controllers/AuthController.cc +++ b/controllers/AuthController.cc @@ -66,6 +66,60 @@ void AuthController::verifyOtp( serviceResponse(service.verifyOtp(body), std::move(callback)); } +void AuthController::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::AuthService service; + serviceResponse(service.startCieLogin(body), std::move(callback)); +} + +void AuthController::completeCieLogin( + const drogon::HttpRequestPtr &req, + std::function &&callback) +{ + const std::string rawBody(req->body()); + const auto body = visione::http::parseJsonBody(rawBody); + if (body.isNull()) + { + callback(visione::http::error("invalid_json", drogon::k400BadRequest)); + return; + } + + visione::services::AuthService service; + auto responseBody = + service.completeCieLogin(rawBody, body, req->getHeader("X-Visione-Signature")); + if (responseBody.get("ok", false).asBool() && + responseBody.isMember("redirect_url")) + { + callback(visione::http::redirect(responseBody["redirect_url"].asString())); + return; + } + serviceResponse(std::move(responseBody), std::move(callback)); +} + +void AuthController::cieSessionStatus( + const drogon::HttpRequestPtr & /*req*/, + std::function &&callback, + std::string state) +{ + visione::services::AuthService service; + auto body = service.cieSessionStatus(state); + if (!body.get("ok", false).asBool()) + { + callback(visione::http::json(std::move(body), drogon::k404NotFound)); + return; + } + callback(visione::http::json(std::move(body))); +} + void AuthController::me( const drogon::HttpRequestPtr &req, std::function &&callback) diff --git a/controllers/AuthController.h b/controllers/AuthController.h index e4d2ca8..e58e0dc 100644 --- a/controllers/AuthController.h +++ b/controllers/AuthController.h @@ -15,6 +15,15 @@ class AuthController : public drogon::HttpController ADD_METHOD_TO(AuthController::verifyOtp, "/api/v1/auth/otp/verify", drogon::Post); + ADD_METHOD_TO(AuthController::startCieLogin, + "/api/v1/auth/cie/start", + drogon::Post); + ADD_METHOD_TO(AuthController::completeCieLogin, + "/api/v1/auth/cie/exchange", + drogon::Post); + ADD_METHOD_TO(AuthController::cieSessionStatus, + "/api/v1/auth/cie/session/{1}", + drogon::Get); ADD_METHOD_TO(AuthController::me, "/api/v1/me", drogon::Get); ADD_METHOD_TO(AuthController::editUser, "/api/v1/me", drogon::Patch); METHOD_LIST_END @@ -28,6 +37,16 @@ class AuthController : public drogon::HttpController void verifyOtp( const drogon::HttpRequestPtr &req, std::function &&callback); + void startCieLogin( + const drogon::HttpRequestPtr &req, + std::function &&callback); + void completeCieLogin( + const drogon::HttpRequestPtr &req, + std::function &&callback); + void cieSessionStatus( + const drogon::HttpRequestPtr &req, + std::function &&callback, + std::string state); void me(const drogon::HttpRequestPtr &req, std::function &&callback); void editUser(const drogon::HttpRequestPtr &req, diff --git a/controllers/OidcController.cc b/controllers/OidcController.cc deleted file mode 100644 index 1fb700f..0000000 --- a/controllers/OidcController.cc +++ /dev/null @@ -1,136 +0,0 @@ -#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 deleted file mode 100644 index d20a37e..0000000 --- a/controllers/OidcController.h +++ /dev/null @@ -1,47 +0,0 @@ -#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 258bdf8..d19c355 100644 --- a/docs/API_SPEC.md +++ b/docs/API_SPEC.md @@ -36,15 +36,9 @@ Authorization: Bearer - `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 + creates local CIE login state and returns Django RP authorization URL - `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 + frontend polling endpoint; returns final Visione bearer token on success - `GET /me` current user profile, roles, permissions, points - `PATCH /me` diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3bc5166..7ea67c6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -25,9 +25,10 @@ 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}`. +- CIE login uses external Django RP service: + - Visione creates local `state` + - Django RP completes CIE/OIDC and sends signed identity payload to Visione + - Visione creates or links local user, issues `api_tokens`, frontend polls session status - DB access uses raw parameterized SQL through `drogon::orm::DbClient`. - No Drogon generated ORM models. No `Mapper`. @@ -64,9 +65,7 @@ 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` +- `api_tokens`, `otp_challenges`, `cie_auth_sessions`, `user_external_identities` - `report_categories`, `areas`, `team_categories`, `teams` - `reports`, `report_status_history`, `assignments`, `report_photos` - `messages`, `feedback`, `reward_events`, `notifications` @@ -102,6 +101,5 @@ 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. +- CIE bridge trusts HMAC-signed callback from Django RP service; keep shared + secret rotated and never expose it to frontend. diff --git a/docs/CIE_AUTH_INTEGRATION.md b/docs/CIE_AUTH_INTEGRATION.md new file mode 100644 index 0000000..57266a1 --- /dev/null +++ b/docs/CIE_AUTH_INTEGRATION.md @@ -0,0 +1,182 @@ +# Visione CIE Authentication Integration + +## Goal +Keep Visione backend simple. + +Visione does not implement OIDC Federation or CIE protocol details directly. +`spid-cie-oidc-django` handles CIE login. Visione only manages: +- local login `state` +- user provisioning +- external identity linking +- local bearer token issuance + +## Components +- Visione backend: `POST /api/v1/auth/cie/start`, `POST /api/v1/auth/cie/exchange`, + `GET /api/v1/auth/cie/session/{state}` +- Django RP service: `examples/relying_party` +- Frontend: browser redirect + polling + +## End-to-End Flow +1. Frontend calls `POST /api/v1/auth/cie/start` with `frontend_redirect_uri`. +2. Visione stores row in `cie_auth_sessions` and returns `authorization_url`. +3. Frontend redirects browser to returned Django URL. +4. Django RP performs CIE login with provider. +5. On success Django extracts user attributes and sends signed JSON payload to + Visione `POST /api/v1/auth/cie/exchange`. +6. Visione verifies HMAC signature, validates `state`, finds or creates user, + stores `user_external_identities`, issues `api_tokens`, marks session authenticated. +7. Visione responds to Django with HTTP redirect to + `frontend_redirect_uri?state=`. +8. Frontend reads `state` from query string and polls + `GET /api/v1/auth/cie/session/{state}` until authenticated. +9. Frontend stores returned Visione bearer token and calls `GET /api/v1/me`. + +## Why This Architecture +- No OIDC/Federation crypto in C++ backend. +- No CIE protocol tables spread across Visione domain code. +- Django demo repo stays reusable and isolated. +- Visione keeps final auth authority for application tokens and user model. + +## Visione Backend Config +`config.json`: + +```json +"cie_auth": { + "service_base_url": "http://127.0.0.1:8001", + "start_path": "/visione/start", + "shared_secret": "change-me-for-local-dev", + "state_ttl_minutes": 10, + "default_frontend_redirect_uri": "http://localhost:3000/auth/cie/callback" +} +``` + +Meaning: +- `service_base_url`: Django RP base URL +- `start_path`: Django entrypoint used by frontend redirect +- `shared_secret`: HMAC secret shared only between Visione and Django +- `state_ttl_minutes`: local login state lifetime +- `default_frontend_redirect_uri`: fallback if frontend omits value + +## Visione DB Additions +- `cie_auth_sessions` + stores local state, frontend redirect, final user id, final bearer token +- `user_external_identities` + stores `provider`, `issuer`, `subject`, `fiscal_code`, raw claims + +User matching strategy: +1. exact external identity on `provider + issuer + subject` +2. fallback reunification on `fiscal_code` +3. otherwise create new `citizen` + +## Signed Exchange Contract +Django sends `POST /api/v1/auth/cie/exchange` with JSON: + +```json +{ + "state": "visione-login-state", + "issuer": "http://127.0.0.1:8002/oidc/op", + "subject": "provider-subject", + "fiscal_code": "RSSMRA80A01H501U", + "first_name": "Mario", + "last_name": "Rossi", + "email": "mario.rossi@example.com" +} +``` + +Headers: +- `Content-Type: application/json` +- `X-Visione-Signature: ` + +Signature algorithm: +- payload bytes exactly as sent +- key = `cie_auth.shared_secret` +- HMAC-SHA256 +- uppercase hex output + +## Django Example Customization +Modified files in `spid-cie-oidc-django/examples/relying_party`: +- `relying_party/settingslocal.py.example` +- `relying_party/urls.py` +- `relying_party/visione_views.py` + +Added endpoints: +- `GET /visione/start` + stores Visione `state` in Django session, then redirects to `/oidc/rp/authorization` +- `GET /visione/complete` + reads `oidc_rp_user_attrs`, signs payload, calls Visione exchange endpoint, + then redirects browser to frontend callback URL returned by Visione + +Important: +- default OIDC callback remains managed by `spid_cie_oidc.relying_party` +- `LOGIN_REDIRECT_URL` now points to `/visione/complete` +- no need to modify RP metadata dump callback URI for this integration + +## Frontend Responsibilities +1. Call: + +```http +POST /api/v1/auth/cie/start +Content-Type: application/json +``` + +```json +{ + "frontend_redirect_uri": "http://localhost:3000/auth/cie/callback" +} +``` + +2. Redirect browser to `authorization_url`. +3. On frontend callback page, read `state` from query string. +4. Poll: + +```http +GET /api/v1/auth/cie/session/{state} +``` + +5. Handle states: +- `pending`: keep polling +- `authenticated`: save `auth.token` +- `failed`: show error and restart flow + +6. After token, call `GET /api/v1/me`. + +Frontend does not need: +- CIE secrets +- Django shared secret +- OIDC token parsing + +## Local Dev Setup +1. Start Visione dependencies: + +```bash +docker compose up -d +``` + +2. Run Visione: + +```bash +cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug +cmake --build build +./build/visione-backend +``` + +3. In separate repo checkout for `spid-cie-oidc-django`, configure: +- federation authority example +- provider example +- relying party example with `VISIONE_BACKEND_EXCHANGE_URL` +- same `VISIONE_SHARED_SECRET` as Visione `cie_auth.shared_secret` + +4. Start Django RP on `http://127.0.0.1:8001`. + +## Security Notes +- Shared secret must never reach frontend. +- `cie_auth.shared_secret` must change outside local dev. +- Keep `state_ttl_minutes` short. +- Visione bearer token stays out of query string; only `state` goes to frontend. +- Django service is trusted bridge. Protect it as internal auth component. + +## Future Improvements +- Mark failed sessions explicitly when Django login fails +- Rotate HMAC secret through environment variables +- Add replay protection with timestamp/nonce in signed payload +- Add integration tests between Django RP and Visione diff --git a/docs/DATABASE_SCHEMA.md b/docs/DATABASE_SCHEMA.md index ffd3877..e832129 100644 --- a/docs/DATABASE_SCHEMA.md +++ b/docs/DATABASE_SCHEMA.md @@ -11,6 +11,8 @@ Schema truth is `migrations/*.sql`. This file is compact human map of current DB notifications, moderation flags, reward uniqueness - `007_reference_data_and_intervention_links.sql` category-to-team-category mapping, public intervention links +- `011_cie_auth_bridge.sql` + lightweight CIE bridge sessions and external identity links ## Core Auth Tables - `users` @@ -23,6 +25,10 @@ Schema truth is `migrations/*.sql`. This file is compact human map of current DB OTP flow, hashed codes, expiry, consumption - `api_tokens` bearer token hashes, expiry, revocation +- `cie_auth_sessions` + frontend-visible CIE login state, final issued token, expiry +- `user_external_identities` + links local users to external providers using `provider + issuer + subject` ## Reference Data - `report_categories` @@ -107,15 +113,3 @@ Schema truth is `migrations/*.sql`. This file is compact human map of current DB - No route-optimization/routing tables - No audit table for intervention link access beyond `last_accessed_at` - Containment check for admin area polygons is simple vertex-inside check - -## OIDC Tables -- `oidc_authentications` - login transaction state, provider snapshot, callback/result lifecycle -- `oidc_authentication_tokens` - authorization code and tokens tied to `oidc_authentications` -- `oidc_federation_cache` - cached federation documents with expiry; uniqueness enforced by generated - SHA-256 `cache_key` over `(document_type, issuer, subject, fetch_url)` to stay - within MySQL index limits -- `user_external_identities` - external provider subject linkage and merged claims snapshot diff --git a/docs/LLM_CONTEXT.md b/docs/LLM_CONTEXT.md index 6b38ac7..b31f2a7 100644 --- a/docs/LLM_CONTEXT.md +++ b/docs/LLM_CONTEXT.md @@ -31,14 +31,18 @@ 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.*`. + - CIE via external `spid-cie-oidc-django` relying party service - Auth token: bearer token from `api_tokens` - Banned users cannot authenticate. +### CIE Bridge Flow +1. Frontend calls `POST /api/v1/auth/cie/start`. +2. Visione creates `cie_auth_sessions.state` and returns Django start URL. +3. Browser completes CIE login on Django RP service. +4. Django sends signed payload to `POST /api/v1/auth/cie/exchange`. +5. Visione finds or creates user, stores `user_external_identities`, issues token. +6. Frontend polls `GET /api/v1/auth/cie/session/{state}` until `authenticated`. + ## Current Report Workflow ### Citizen flow 1. Register/login. @@ -121,6 +125,7 @@ public revocable link generated by operator/admin. ## Files To Read First - `docs/API_SPEC.md` - `docs/ARCHITECTURE.md` +- `docs/CIE_AUTH_INTEGRATION.md` - `docs/openapi/visione.openapi.yaml` - `controllers/*.h` - `services/*.cc` diff --git a/docs/OIDC_CIE_IMPLEMENTATION_PLAN.md b/docs/OIDC_CIE_IMPLEMENTATION_PLAN.md deleted file mode 100644 index 2919492..0000000 --- a/docs/OIDC_CIE_IMPLEMENTATION_PLAN.md +++ /dev/null @@ -1,209 +0,0 @@ -# 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/docs/openapi/visione.openapi.yaml b/docs/openapi/visione.openapi.yaml index 4cfd28a..533f487 100644 --- a/docs/openapi/visione.openapi.yaml +++ b/docs/openapi/visione.openapi.yaml @@ -1496,6 +1496,99 @@ ], "type": "object" }, + "CieStartRequest": { + "additionalProperties": false, + "properties": { + "frontend_redirect_uri": { + "type": "string" + } + }, + "required": [ + "frontend_redirect_uri" + ], + "type": "object" + }, + "CieStartResponse": { + "additionalProperties": false, + "properties": { + "authorization_url": { + "type": "string" + }, + "ok": { + "type": "boolean" + }, + "state": { + "type": "string" + }, + "status": { + "type": "string" + } + }, + "required": [ + "ok", + "authorization_url", + "state", + "status" + ], + "type": "object" + }, + "CieExchangeRequest": { + "additionalProperties": false, + "properties": { + "email": { + "type": "string" + }, + "fiscal_code": { + "type": "string" + }, + "first_name": { + "type": "string" + }, + "issuer": { + "type": "string" + }, + "last_name": { + "type": "string" + }, + "state": { + "type": "string" + }, + "subject": { + "type": "string" + } + }, + "required": [ + "state", + "issuer", + "subject", + "fiscal_code" + ], + "type": "object" + }, + "CieSessionResponse": { + "additionalProperties": false, + "properties": { + "auth": { + "additionalProperties": true, + "type": "object" + }, + "ok": { + "type": "boolean" + }, + "status": { + "type": "string" + }, + "user_id": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "ok", + "status" + ], + "type": "object" + }, "UpdateModerationRequest": { "additionalProperties": false, "properties": { @@ -1610,7 +1703,110 @@ "version": "0.1.0" }, "openapi": "3.0.3", - "paths": { + "paths": { + "/api/v1/auth/cie/exchange": { + "post": { + "description": "Completes CIE bridge exchange after Django RP verifies the identity and posts signed user claims.", + "operationId": "AuthController.completeCieLogin", + "parameters": [ ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CieExchangeRequest" + } + } + }, + "required": true + }, + "responses": { + "302": { + "description": "Redirect to frontend callback URL with state query parameter." + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + }, + "summary": "Complete CIE login", + "tags": [ + "Auth" + ] + } + }, + "/api/v1/auth/cie/session/{id}": { + "get": { + "description": "Returns status for CIE login session started by Visione.", + "operationId": "AuthController.cieSessionStatus", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CieSessionResponse" + } + } + }, + "description": "Current login session status." + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + }, + "summary": "CIE session status", + "tags": [ + "Auth" + ] + } + }, + "/api/v1/auth/cie/start": { + "post": { + "description": "Creates a local CIE login session and returns Django RP authorization URL.", + "operationId": "AuthController.startCieLogin", + "parameters": [ ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CieStartRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CieStartResponse" + } + } + }, + "description": "Authorization URL and local state." + }, + "400": { + "$ref": "#/components/responses/BadRequest" + } + }, + "summary": "Start CIE login", + "tags": [ + "Auth" + ] + } + }, "/api/v1/admin/areas": { "post": { "description": "Creates a municipal area with GeoJSON boundary.", diff --git a/migrations/011_cie_auth_bridge.sql b/migrations/011_cie_auth_bridge.sql new file mode 100644 index 0000000..ff5d6b6 --- /dev/null +++ b/migrations/011_cie_auth_bridge.sql @@ -0,0 +1,36 @@ +CREATE TABLE IF NOT EXISTS cie_auth_sessions ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + state VARCHAR(128) NOT NULL, + frontend_redirect_uri VARCHAR(500) NOT NULL, + status ENUM('PENDING', 'AUTHENTICATED', 'FAILED') NOT NULL DEFAULT 'PENDING', + issuer VARCHAR(255) NULL, + subject VARCHAR(255) NULL, + fiscal_code VARCHAR(16) NULL, + authenticated_user_id BIGINT UNSIGNED NULL, + result_token VARCHAR(255) NULL, + raw_claims JSON NULL, + expires_at DATETIME NOT NULL, + consumed_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY cie_auth_sessions_state_unique (state), + KEY cie_auth_sessions_status_idx (status), + KEY cie_auth_sessions_expires_idx (expires_at), + CONSTRAINT cie_auth_sessions_user_fk FOREIGN KEY (authenticated_user_id) REFERENCES users(id) +); + +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) NOT 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) +); diff --git a/migrations/011_oidc_rp_foundation.sql b/migrations/011_oidc_rp_foundation.sql deleted file mode 100644 index 6f384d0..0000000 --- a/migrations/011_oidc_rp_foundation.sql +++ /dev/null @@ -1,106 +0,0 @@ -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, - cache_key BINARY(32) GENERATED ALWAYS AS ( - UNHEX( - SHA2( - JSON_ARRAY(document_type, issuer, subject, fetch_url), - 256 - ) - ) - ) STORED, - 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 (cache_key), - 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 deleted file mode 100644 index bf1e02d..0000000 --- a/migrations/012_oidc_session_result_token.sql +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 4427e38..0000000 --- a/secrets/oidc/demo-rp-jwks.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "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/AuthService.cc b/services/AuthService.cc index f01262b..b5ec2e7 100644 --- a/services/AuthService.cc +++ b/services/AuthService.cc @@ -1,21 +1,33 @@ #include "services/AuthService.h" #include "common/Email.h" +#include "common/Crypto.h" #include "common/Validation.h" -#include "common/Crypto.h" - #include +#include +#include +#include +#include +#include +#include + namespace visione::services { namespace { -Json::Value failure(const std::string &code) +constexpr std::string_view kCieProvider = "cie"; + +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; } @@ -42,6 +54,74 @@ int tokenTtlHours() .asInt(); } +const Json::Value &cieConfig() +{ + return drogon::app().getCustomConfig()["cie_auth"]; +} + +std::string cieAuthBaseUrl() +{ + return cieConfig().get("service_base_url", "").asString(); +} + +std::string cieStartPath() +{ + return cieConfig().get("start_path", "/visione/start").asString(); +} + +std::string cieSharedSecret() +{ + return cieConfig().get("shared_secret", "").asString(); +} + +int cieStateTtlMinutes() +{ + return cieConfig().get("state_ttl_minutes", 10).asInt(); +} + +std::string cieDefaultFrontendRedirect() +{ + return cieConfig().get("default_frontend_redirect_uri", "").asString(); +} + +std::string ensureCitizenEmail(const std::string &email, + const std::string &fiscalCode) +{ + if (!email.empty()) + { + return email; + } + return fiscalCode + "@cie.local"; +} + +std::string buildUrlWithQuery(const std::string &base, + const std::vector> ¶ms) +{ + std::string url = base; + url += (base.find('?') == std::string::npos) ? "?" : "&"; + bool first = true; + for (const auto &[key, value] : params) + { + if (!first) + { + url += "&"; + } + first = false; + url += drogon::utils::urlEncode(key) + "=" + drogon::utils::urlEncode(value); + } + return url; +} + +std::string frontendRedirectWithState(const std::string &baseUrl, + const std::string &state) +{ + if (baseUrl.empty()) + { + return {}; + } + return buildUrlWithQuery(baseUrl, {{"state", state}}); +} + Json::Value issueToken(long long userId) { auto token = crypto::randomToken(); @@ -58,6 +138,107 @@ Json::Value issueToken(long long userId) body["expires_in_hours"] = tokenTtlHours(); return body; } + +std::optional readString(const Json::Value &body, const char *key) +{ + if (!body.isMember(key) || !body[key].isString()) + { + return std::nullopt; + } + return body[key].asString(); +} + +std::string normalizeFiscalCode(std::string fiscalCode) +{ + std::ranges::transform(fiscalCode, fiscalCode.begin(), [](unsigned char ch) { + return static_cast(std::toupper(ch)); + }); + return fiscalCode; +} + +long long findOrCreateCieCitizen(const Json::Value &claims) +{ + auto db = drogon::app().getDbClient(); + + const auto issuer = claims["issuer"].asString(); + const auto subject = claims["subject"].asString(); + const auto fiscalCode = normalizeFiscalCode(claims["fiscal_code"].asString()); + const auto firstName = claims.get("first_name", "").asString(); + const auto lastName = claims.get("last_name", "").asString(); + const auto email = claims.get("email", "").asString(); + + const auto existingIdentity = db->execSqlSync( + "SELECT user_id FROM user_external_identities " + "WHERE provider = ? AND issuer = ? AND subject = ? LIMIT 1", + std::string(kCieProvider), + issuer, + subject); + if (!existingIdentity.empty()) + { + return existingIdentity[0]["user_id"].as(); + } + + long long userId = 0; + const auto byFiscal = db->execSqlSync( + "SELECT id FROM users WHERE fiscal_code = ? LIMIT 1", fiscalCode); + if (!byFiscal.empty()) + { + userId = byFiscal[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 = ?", + firstName, + lastName, + email, + userId); + } + else + { + db->execSqlSync( + "INSERT INTO users " + "(first_name, last_name, email, fiscal_code, birth_date, birth_place, phone) " + "VALUES (?, ?, ?, ?, NULL, NULL, NULL)", + firstName.empty() ? fiscalCode : firstName, + lastName.empty() ? fiscalCode : lastName, + ensureCitizenEmail(email, fiscalCode), + 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(kCieProvider), + issuer, + subject, + fiscalCode, + Json::writeString(writer, claims)); + return userId; +} + +bool validateCieExchangeSignature(const std::string &rawBody, + const std::string &signatureHeader) +{ + const auto secret = cieSharedSecret(); + if (secret.empty() || signatureHeader.empty()) + { + return false; + } + const auto expected = crypto::hmacSha256Hex(secret, rawBody); + return crypto::constantTimeEquals(expected, signatureHeader); +} } // namespace Json::Value AuthService::registerCitizen(const Json::Value &body) @@ -183,6 +364,143 @@ Json::Value AuthService::verifyOtp(const Json::Value &body) return issueToken(challenge[0]["user_id"].as()); } +Json::Value AuthService::startCieLogin(const Json::Value &body) +{ + const auto frontendRedirect = + body.get("frontend_redirect_uri", cieDefaultFrontendRedirect()).asString(); + if (frontendRedirect.empty()) + { + return failure("missing_frontend_redirect_uri"); + } + + const auto serviceBaseUrl = cieAuthBaseUrl(); + if (serviceBaseUrl.empty()) + { + return failure("cie_auth_not_configured"); + } + + const auto state = crypto::randomToken(); + auto db = drogon::app().getDbClient(); + db->execSqlSync( + "INSERT INTO cie_auth_sessions " + "(state, frontend_redirect_uri, status, expires_at) " + "VALUES (?, ?, 'PENDING', DATE_ADD(NOW(), INTERVAL ? MINUTE))", + state, + frontendRedirect, + cieStateTtlMinutes()); + + auto response = success(); + response["state"] = state; + response["authorization_url"] = buildUrlWithQuery( + serviceBaseUrl + cieStartPath(), + {{"state", state}, {"frontend_redirect_uri", frontendRedirect}}); + response["status"] = "pending"; + return response; +} + +Json::Value AuthService::completeCieLogin(const std::string &rawBody, + const Json::Value &body, + const std::string &signatureHeader) +{ + if (!validateCieExchangeSignature(rawBody, signatureHeader)) + { + return failure("invalid_signature"); + } + + const auto state = readString(body, "state"); + const auto issuer = readString(body, "issuer"); + const auto subject = readString(body, "subject"); + const auto fiscalCode = readString(body, "fiscal_code"); + if (!state || !issuer || !subject || !fiscalCode) + { + return failure("missing_fields"); + } + if (!validation::isValidFiscalCode(*fiscalCode)) + { + return failure("invalid_fiscal_code"); + } + + auto db = drogon::app().getDbClient(); + const auto session = db->execSqlSync( + "SELECT id, frontend_redirect_uri, status FROM cie_auth_sessions " + "WHERE state = ? AND consumed_at IS NULL AND expires_at > NOW() LIMIT 1", + *state); + if (session.empty()) + { + return failure("invalid_or_expired_state"); + } + + const auto sessionId = session[0]["id"].as(); + const auto status = session[0]["status"].as(); + if (status == "AUTHENTICATED") + { + auto response = success(); + response["redirect_url"] = frontendRedirectWithState( + session[0]["frontend_redirect_uri"].as(), *state); + return response; + } + + Json::Value claims(Json::objectValue); + claims["issuer"] = *issuer; + claims["subject"] = *subject; + claims["fiscal_code"] = normalizeFiscalCode(*fiscalCode); + claims["first_name"] = body.get("first_name", "").asString(); + claims["last_name"] = body.get("last_name", "").asString(); + claims["email"] = body.get("email", "").asString(); + + const auto userId = findOrCreateCieCitizen(claims); + const auto token = issueToken(userId); + + Json::StreamWriterBuilder writer; + writer["indentation"] = ""; + db->execSqlSync( + "UPDATE cie_auth_sessions " + "SET status = 'AUTHENTICATED', issuer = ?, subject = ?, fiscal_code = ?, " + "authenticated_user_id = ?, result_token = ?, raw_claims = CAST(? AS JSON), " + "consumed_at = NOW() WHERE id = ?", + *issuer, + *subject, + normalizeFiscalCode(*fiscalCode), + userId, + token["token"].asString(), + Json::writeString(writer, claims), + sessionId); + + auto response = success(); + response["redirect_url"] = frontendRedirectWithState( + session[0]["frontend_redirect_uri"].as(), *state); + return response; +} + +Json::Value AuthService::cieSessionStatus(const std::string &state) +{ + auto db = drogon::app().getDbClient(); + const auto session = db->execSqlSync( + "SELECT status, authenticated_user_id, result_token FROM cie_auth_sessions " + "WHERE state = ? LIMIT 1", + state); + if (session.empty()) + { + return failure("session_not_found"); + } + + auto response = success(); + const auto status = session[0]["status"].as(); + if (status == "AUTHENTICATED") + { + response["status"] = "authenticated"; + response["auth"]["token"] = session[0]["result_token"].as(); + response["auth"]["token_type"] = "Bearer"; + response["auth"]["expires_in_hours"] = tokenTtlHours(); + response["user_id"] = + Json::Int64(session[0]["authenticated_user_id"].as()); + return response; + } + + response["status"] = status == "FAILED" ? "failed" : "pending"; + return response; +} + Json::Value AuthService::currentUser(const auth::UserContext &user) { auto db = drogon::app().getDbClient(); @@ -214,27 +532,32 @@ Json::Value AuthService::currentUser(const auth::UserContext &user) return response; } -Json::Value AuthService::editUser(const auth::UserContext &user, Json::Value::const_iterator::reference body) { +Json::Value AuthService::editUser(const auth::UserContext &user, + Json::Value::const_iterator::reference body) +{ const auto allowedFields = {"first_name", "last_name", "phone"}; auto db = drogon::app().getDbClient(); bool hasUpdates = false; - for (const auto& field : allowedFields) { - if (body.isMember(field)) { - if (!body[field].isString()) { + for (const auto &field : allowedFields) + { + if (body.isMember(field)) + { + if (!body[field].isString()) + { return failure("invalid_field_type"); } db->execSqlSync( "UPDATE users SET " + std::string(field) + " = ? WHERE id = ?", body[field].asString(), - user.id - ); + user.id); hasUpdates = true; } } - if (!hasUpdates) { + if (!hasUpdates) + { return failure("missing_fields"); } diff --git a/services/AuthService.h b/services/AuthService.h index 695f3c2..03c3e97 100644 --- a/services/AuthService.h +++ b/services/AuthService.h @@ -12,6 +12,11 @@ class AuthService Json::Value registerCitizen(const Json::Value &body); Json::Value requestOtp(const Json::Value &body); Json::Value verifyOtp(const Json::Value &body); + Json::Value startCieLogin(const Json::Value &body); + Json::Value completeCieLogin(const std::string &rawBody, + const Json::Value &body, + const std::string &signatureHeader); + Json::Value cieSessionStatus(const std::string &state); Json::Value currentUser(const auth::UserContext &user); Json::Value editUser(const auth::UserContext & user, Json::Value::const_iterator::reference body); }; diff --git a/services/OidcRpService.cc b/services/OidcRpService.cc deleted file mode 100644 index ce51e86..0000000 --- a/services/OidcRpService.cc +++ /dev/null @@ -1,709 +0,0 @@ -#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 deleted file mode 100644 index 70d0af9..0000000 --- a/services/OidcRpService.h +++ /dev/null @@ -1,28 +0,0 @@ -#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/test/CMakeLists.txt b/test/CMakeLists.txt index bb8c8ff..82a7611 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -1,6 +1,5 @@ add_executable(${PROJECT_NAME}_test test_main.cc - OidcTests.cc ${APP_SOURCES} ) @@ -12,7 +11,7 @@ target_include_directories(${PROJECT_NAME}_test target_link_libraries(${PROJECT_NAME}_test PRIVATE Drogon::Drogon - jwt-cpp::jwt-cpp + OpenSSL::Crypto ) ParseAndAddDrogonTests(${PROJECT_NAME}_test) diff --git a/test/OidcTests.cc b/test/OidcTests.cc deleted file mode 100644 index b83b0d4..0000000 --- a/test/OidcTests.cc +++ /dev/null @@ -1,359 +0,0 @@ -#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"); -} diff --git a/test/test_main.cc b/test/test_main.cc index 8b56bfb..ccd4871 100644 --- a/test/test_main.cc +++ b/test/test_main.cc @@ -5,6 +5,7 @@ #include "common/GeoJson.h" #include "common/Crypto.h" +#include "common/Http.h" #include "common/Validation.h" #include "repositories/Sql.h" @@ -76,6 +77,24 @@ DROGON_TEST(SqlScalarSerializationKeepsTypes) CHECK(json["phone"].isString()); } +DROGON_TEST(CryptoHmacIsDeterministic) +{ + const auto digest = visione::crypto::hmacSha256Hex("secret", "{\"a\":1}"); + CHECK(digest == "AA9E2E3575F5D7098B6CACCD790888C36D5FDB63342A73BADA2D6A51747A8494"); + CHECK(visione::crypto::constantTimeEquals(digest, digest)); + CHECK(!visione::crypto::constantTimeEquals(digest, "different")); +} + +DROGON_TEST(ParseJsonBodyRejectsInvalidPayload) +{ + const auto parsed = visione::http::parseJsonBody("{\"ok\":true}"); + CHECK(parsed.isObject()); + CHECK(parsed["ok"].asBool()); + + const auto invalid = visione::http::parseJsonBody("{oops"); + CHECK(invalid.isNull()); +} + DROGON_TEST(GeoJsonMatchesPointInsidePolygon) { Json::Value polygon;