feat(CIE): Implement OIDC RP Service with login, callback, and session management

This commit is contained in:
gabdevele
2026-06-16 23:33:59 +02:00 Unverified
parent bb7ed18539
commit 5f81d2dcf7
31 changed files with 3218 additions and 4 deletions
+359
View File
@@ -0,0 +1,359 @@
#include <drogon/drogon_test.h>
#include "common/Crypto.h"
#include "common/OidcCrypto.h"
#include "common/OidcJwt.h"
#include "common/OidcUserClaims.h"
#include <fstream>
#include <filesystem>
#include <openssl/evp.h>
#include <openssl/hmac.h>
#include <openssl/pem.h>
#include <openssl/rsa.h>
#include <stdexcept>
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<int>(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<int>(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<const unsigned char *>(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<unsigned char *>(encryptedKey.data()),
&encryptedLen,
reinterpret_cast<const unsigned char *>(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<const unsigned char *>(encKey.data()),
reinterpret_cast<const unsigned char *>(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<unsigned char *>(ciphertext.data()),
&outLen1,
reinterpret_cast<const unsigned char *>(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<unsigned char *>(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<char>((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<int>(macKey.size()),
reinterpret_cast<const unsigned char *>(macInput.data()),
macInput.size(),
hmac,
&hmacLen))
{
throw std::runtime_error("hmac_failed");
}
outCiphertext = ciphertext;
outTag.assign(reinterpret_cast<const char *>(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<oidc::Jwk> 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<char>(i + 1);
}
for (size_t i = 0; i < iv.size(); ++i)
{
iv[i] = static_cast<char>(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<char>(0x30 + i);
}
for (size_t i = 0; i < iv.size(); ++i)
{
iv[i] = static_cast<char>(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"] = "[email protected]";
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 == "[email protected]");
CHECK(merged->fiscalCode == "RSSMRA80A01H501U");
}