feat(CIE): Implement OIDC RP Service with login, callback, and session management
This commit is contained in:
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user