new cie implementation #2
@@ -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++
|
||||
|
||||
+2
-18
@@ -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
|
||||
|
||||
+25
-187
@@ -1,13 +1,10 @@
|
||||
#include "common/Crypto.h"
|
||||
|
||||
#include <drogon/utils/Utilities.h>
|
||||
#include <openssl/bn.h>
|
||||
#include <openssl/evp.h>
|
||||
#include <openssl/pem.h>
|
||||
#include <openssl/rsa.h>
|
||||
#include <openssl/crypto.h>
|
||||
#include <openssl/hmac.h>
|
||||
|
||||
#include <iomanip>
|
||||
#include <memory>
|
||||
#include <random>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
@@ -16,12 +13,6 @@ namespace visione::crypto
|
||||
{
|
||||
namespace
|
||||
{
|
||||
using BnPtr = std::unique_ptr<BIGNUM, decltype(&BN_free)>;
|
||||
using RsaPtr = std::unique_ptr<RSA, decltype(&RSA_free)>;
|
||||
using EvpKeyPtr = std::unique_ptr<EVP_PKEY, decltype(&EVP_PKEY_free)>;
|
||||
using BioPtr = std::unique_ptr<BIO, decltype(&BIO_free)>;
|
||||
using BnCtxPtr = std::unique_ptr<BN_CTX, decltype(&BN_CTX_free)>;
|
||||
|
||||
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<const unsigned char *>(decoded.data()),
|
||||
static_cast<int>(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<int>(key.size()),
|
||||
reinterpret_cast<const unsigned char *>(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
|
||||
|
||||
+2
-6
@@ -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
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
#include "common/Http.h"
|
||||
|
||||
#include <drogon/HttpRequest.h>
|
||||
|
||||
#include <sstream>
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,350 +0,0 @@
|
||||
#include "common/OidcCrypto.h"
|
||||
|
||||
#include <openssl/bn.h>
|
||||
#include <openssl/crypto.h>
|
||||
#include <openssl/evp.h>
|
||||
#include <openssl/hmac.h>
|
||||
#include <openssl/rsa.h>
|
||||
|
||||
#include <array>
|
||||
#include <memory>
|
||||
#include <stdexcept>
|
||||
#include <vector>
|
||||
|
||||
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<Json::CharReader> 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<EVP_PKEY, decltype(&EVP_PKEY_free)>;
|
||||
using BnPtr = std::unique_ptr<BIGNUM, decltype(&BN_free)>;
|
||||
using RsaPtr = std::unique_ptr<RSA, decltype(&RSA_free)>;
|
||||
using PkeyCtxPtr = std::unique_ptr<EVP_PKEY_CTX, decltype(&EVP_PKEY_CTX_free)>;
|
||||
using CipherCtxPtr =
|
||||
std::unique_ptr<EVP_CIPHER_CTX, decltype(&EVP_CIPHER_CTX_free)>;
|
||||
|
||||
BnPtr bnFromBase64Url(const std::string &value)
|
||||
{
|
||||
const auto decoded = base64UrlDecode(value);
|
||||
BIGNUM *raw = BN_bin2bn(reinterpret_cast<const unsigned char *>(decoded.data()),
|
||||
static_cast<int>(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<std::string> split(const std::string &text, char separator)
|
||||
{
|
||||
std::vector<std::string> 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<const unsigned char *>(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<unsigned char *>(plaintext.data()),
|
||||
&outLen,
|
||||
reinterpret_cast<const unsigned char *>(ciphertext.data()),
|
||||
ciphertext.size()) != 1)
|
||||
{
|
||||
throw std::runtime_error("jwe_cek_decrypt_failed");
|
||||
}
|
||||
plaintext.resize(outLen);
|
||||
return plaintext;
|
||||
}
|
||||
|
||||
std::array<unsigned char, 8> bigEndianBitLength(uint64_t lenBytes)
|
||||
{
|
||||
const auto lenBits = lenBytes * 8;
|
||||
return {
|
||||
static_cast<unsigned char>((lenBits >> 56) & 0xff),
|
||||
static_cast<unsigned char>((lenBits >> 48) & 0xff),
|
||||
static_cast<unsigned char>((lenBits >> 40) & 0xff),
|
||||
static_cast<unsigned char>((lenBits >> 32) & 0xff),
|
||||
static_cast<unsigned char>((lenBits >> 24) & 0xff),
|
||||
static_cast<unsigned char>((lenBits >> 16) & 0xff),
|
||||
static_cast<unsigned char>((lenBits >> 8) & 0xff),
|
||||
static_cast<unsigned char>(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<const char *>(al.data()), al.size());
|
||||
|
||||
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("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<const unsigned char *>(encKey.data()),
|
||||
reinterpret_cast<const unsigned char *>(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<unsigned char *>(plaintext.data()),
|
||||
&outLen1,
|
||||
reinterpret_cast<const unsigned char *>(ciphertext.data()),
|
||||
ciphertext.size()) == 1,
|
||||
"jwe_decrypt_update_failed");
|
||||
|
||||
int outLen2 = 0;
|
||||
requireExact(EVP_DecryptFinal_ex(
|
||||
ctx.get(),
|
||||
reinterpret_cast<unsigned char *>(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
|
||||
@@ -1,23 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "common/OidcJwt.h"
|
||||
|
||||
#include <json/json.h>
|
||||
|
||||
#include <string>
|
||||
|
||||
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
|
||||
@@ -1,88 +0,0 @@
|
||||
#include "common/OidcHttp.h"
|
||||
|
||||
#include <drogon/HttpClient.h>
|
||||
|
||||
#include <stdexcept>
|
||||
|
||||
namespace visione::oidc
|
||||
{
|
||||
namespace
|
||||
{
|
||||
HttpResponseData sendGet(const std::string &url,
|
||||
const std::map<std::string, std::string> &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<int>(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<std::string, std::string> &headers,
|
||||
double timeoutSeconds)
|
||||
{
|
||||
return sendGet(url, headers, timeoutSeconds);
|
||||
}
|
||||
|
||||
HttpResponseData httpPostForm(const std::string &url,
|
||||
const std::map<std::string, std::string> ¶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<int>(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<Json::CharReader> 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
|
||||
@@ -1,26 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <json/json.h>
|
||||
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
namespace visione::oidc
|
||||
{
|
||||
struct HttpResponseData
|
||||
{
|
||||
int statusCode{0};
|
||||
std::string body;
|
||||
std::map<std::string, std::string> headers;
|
||||
};
|
||||
|
||||
HttpResponseData httpGet(const std::string &url, double timeoutSeconds = 5.0);
|
||||
HttpResponseData httpGet(const std::string &url,
|
||||
const std::map<std::string, std::string> &headers,
|
||||
double timeoutSeconds = 5.0);
|
||||
HttpResponseData httpPostForm(const std::string &url,
|
||||
const std::map<std::string, std::string> ¶ms,
|
||||
double timeoutSeconds = 5.0);
|
||||
Json::Value parseJsonResponse(const HttpResponseData &response);
|
||||
} // namespace visione::oidc
|
||||
@@ -1,360 +0,0 @@
|
||||
#include "common/OidcJwt.h"
|
||||
|
||||
#include <drogon/utils/Utilities.h>
|
||||
|
||||
#include <jwt-cpp/traits/open-source-parsers-jsoncpp/defaults.h>
|
||||
#include <openssl/bn.h>
|
||||
#include <openssl/evp.h>
|
||||
#include <openssl/pem.h>
|
||||
#include <openssl/rsa.h>
|
||||
#include <openssl/sha.h>
|
||||
|
||||
#include <memory>
|
||||
#include <stdexcept>
|
||||
#include <vector>
|
||||
|
||||
namespace visione::oidc
|
||||
{
|
||||
namespace
|
||||
{
|
||||
using BnPtr = std::unique_ptr<BIGNUM, decltype(&BN_free)>;
|
||||
using RsaPtr = std::unique_ptr<RSA, decltype(&RSA_free)>;
|
||||
using EvpKeyPtr = std::unique_ptr<EVP_PKEY, decltype(&EVP_PKEY_free)>;
|
||||
using BioPtr = std::unique_ptr<BIO, decltype(&BIO_free)>;
|
||||
using BnCtxPtr = std::unique_ptr<BN_CTX, decltype(&BN_CTX_free)>;
|
||||
|
||||
BnPtr bnFromBase64Url(const std::string &value)
|
||||
{
|
||||
const auto decoded = base64UrlDecode(value);
|
||||
BIGNUM *raw = BN_bin2bn(reinterpret_cast<const unsigned char *>(decoded.data()),
|
||||
static_cast<int>(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<Json::CharReader> reader(builder.newCharReader());
|
||||
if (!reader->parse(text.data(), text.data() + text.size(), &value, &errors))
|
||||
{
|
||||
throw std::runtime_error("invalid_json");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
template <typename Builder>
|
||||
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<Jwk> findJwkByKid(const std::vector<Jwk> &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<const unsigned char *>(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
|
||||
@@ -1,52 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <json/json.h>
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
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<Jwk> findJwkByKid(const std::vector<Jwk> &jwks,
|
||||
const std::string &kid);
|
||||
|
||||
std::string sha256Base64Url(const std::string &value);
|
||||
std::string leftHashBase64Url(const std::string &value);
|
||||
} // namespace visione::oidc
|
||||
@@ -1,221 +0,0 @@
|
||||
#include "common/OidcRpConfig.h"
|
||||
|
||||
#include <drogon/drogon.h>
|
||||
|
||||
#include <fstream>
|
||||
#include <stdexcept>
|
||||
#include <ctime>
|
||||
#include <filesystem>
|
||||
|
||||
namespace visione::oidc
|
||||
{
|
||||
namespace
|
||||
{
|
||||
std::vector<std::string> readStringArray(const Json::Value &value)
|
||||
{
|
||||
std::vector<std::string> 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<Json::Int64>(std::time(nullptr) + 2880 * 60);
|
||||
body["iat"] = static_cast<Json::Int64>(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<RpProviderConfig> 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
|
||||
@@ -1,44 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <json/json.h>
|
||||
|
||||
#include "common/OidcJwt.h"
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
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<std::string> contacts;
|
||||
std::string jwksPath;
|
||||
std::string federationJwksPath;
|
||||
std::vector<std::string> trustAnchors;
|
||||
std::vector<RpProviderConfig> providers;
|
||||
std::vector<Jwk> federationJwks;
|
||||
std::vector<Jwk> 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<RpProviderConfig> findProviderConfig(const RpConfig &config,
|
||||
const std::string &provider);
|
||||
} // namespace visione::oidc
|
||||
@@ -1,110 +0,0 @@
|
||||
#include "common/OidcUserClaims.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
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<char>(std::toupper(ch)); });
|
||||
return value;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
std::optional<NormalizedUserClaims> 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<NormalizedUserClaims> 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
|
||||
@@ -1,29 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <json/json.h>
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
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<NormalizedUserClaims> normalizeIdTokenClaims(
|
||||
const std::string &issuer,
|
||||
const Json::Value &claims);
|
||||
|
||||
std::optional<NormalizedUserClaims> mergeUserClaims(
|
||||
const std::string &issuer,
|
||||
const Json::Value &idTokenClaims,
|
||||
const Json::Value &userinfoClaims);
|
||||
} // namespace visione::oidc
|
||||
+6
-25
@@ -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": [
|
||||
"[email protected]"
|
||||
],
|
||||
"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",
|
||||
|
||||
@@ -66,6 +66,60 @@ void AuthController::verifyOtp(
|
||||
serviceResponse(service.verifyOtp(body), std::move(callback));
|
||||
}
|
||||
|
||||
void AuthController::startCieLogin(
|
||||
const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&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<void(const HttpResponsePtr &)> &&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<void(const HttpResponsePtr &)> &&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<void(const HttpResponsePtr &)> &&callback)
|
||||
|
||||
@@ -15,6 +15,15 @@ class AuthController : public drogon::HttpController<AuthController>
|
||||
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<AuthController>
|
||||
void verifyOtp(
|
||||
const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
|
||||
void startCieLogin(
|
||||
const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
|
||||
void completeCieLogin(
|
||||
const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
|
||||
void cieSessionStatus(
|
||||
const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const drogon::HttpResponsePtr &)> &&callback,
|
||||
std::string state);
|
||||
void me(const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
|
||||
void editUser(const drogon::HttpRequestPtr &req,
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
#include "controllers/OidcController.h"
|
||||
|
||||
#include "common/Http.h"
|
||||
#include "services/OidcRpService.h"
|
||||
|
||||
#include <drogon/HttpResponse.h>
|
||||
|
||||
using drogon::HttpResponsePtr;
|
||||
|
||||
namespace
|
||||
{
|
||||
void serviceResponse(Json::Value body,
|
||||
std::function<void(const HttpResponsePtr &)> &&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<void(const HttpResponsePtr &)> &&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<void(const HttpResponsePtr &)> &&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<std::string>
|
||||
{
|
||||
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<void(const HttpResponsePtr &)> &&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<void(const HttpResponsePtr &)> &&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<void(const HttpResponsePtr &)> &&callback)
|
||||
{
|
||||
visione::services::OidcRpService service;
|
||||
|
||||
const auto subParam = req->getParameter("sub");
|
||||
const auto anchorParam = req->getParameter("anchor");
|
||||
const std::optional<std::string> sub =
|
||||
subParam.empty() ? std::nullopt : std::make_optional(subParam);
|
||||
const std::optional<std::string> 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<void(const HttpResponsePtr &)> &&callback)
|
||||
{
|
||||
visione::services::OidcRpService service;
|
||||
callback(visione::http::json(service.jwks()));
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <drogon/HttpController.h>
|
||||
|
||||
class OidcController : public drogon::HttpController<OidcController>
|
||||
{
|
||||
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<void(const drogon::HttpResponsePtr &)> &&callback);
|
||||
void cieCallback(
|
||||
const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
|
||||
void cieSessionStatus(
|
||||
const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const drogon::HttpResponsePtr &)> &&callback,
|
||||
std::string state);
|
||||
void entityConfiguration(
|
||||
const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
|
||||
void resolveEntityStatement(
|
||||
const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
|
||||
void jwks(const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
|
||||
};
|
||||
+2
-8
@@ -36,15 +36,9 @@ Authorization: Bearer <token>
|
||||
- `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`
|
||||
|
||||
@@ -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<T>`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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=<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": "[email protected]"
|
||||
}
|
||||
```
|
||||
|
||||
Headers:
|
||||
- `Content-Type: application/json`
|
||||
- `X-Visione-Signature: <UPPERCASE_HEX_HMAC_SHA256>`
|
||||
|
||||
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
|
||||
+6
-12
@@ -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
|
||||
|
||||
+10
-5
@@ -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`
|
||||
|
||||
@@ -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/<state>
|
||||
```
|
||||
|
||||
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`
|
||||
@@ -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": {
|
||||
@@ -1611,6 +1704,109 @@
|
||||
},
|
||||
"openapi": "3.0.3",
|
||||
"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.",
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
@@ -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
|
||||
);
|
||||
@@ -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);
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
+333
-10
@@ -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 <drogon/drogon.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <json/writer.h>
|
||||
#include <optional>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
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<std::pair<std::string, std::string>> ¶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<std::string> 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<char>(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>();
|
||||
}
|
||||
|
||||
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<long long>();
|
||||
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<long long>();
|
||||
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<long long>());
|
||||
}
|
||||
|
||||
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<long long>();
|
||||
const auto status = session[0]["status"].as<std::string>();
|
||||
if (status == "AUTHENTICATED")
|
||||
{
|
||||
auto response = success();
|
||||
response["redirect_url"] = frontendRedirectWithState(
|
||||
session[0]["frontend_redirect_uri"].as<std::string>(), *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<std::string>(), *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<std::string>();
|
||||
if (status == "AUTHENTICATED")
|
||||
{
|
||||
response["status"] = "authenticated";
|
||||
response["auth"]["token"] = session[0]["result_token"].as<std::string>();
|
||||
response["auth"]["token_type"] = "Bearer";
|
||||
response["auth"]["expires_in_hours"] = tokenTtlHours();
|
||||
response["user_id"] =
|
||||
Json::Int64(session[0]["authenticated_user_id"].as<long long>());
|
||||
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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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 <drogon/HttpClient.h>
|
||||
#include <drogon/drogon.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <map>
|
||||
#include <sstream>
|
||||
|
||||
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<oidc::Jwk> parseJwks(const Json::Value &jwks)
|
||||
{
|
||||
std::vector<oidc::Jwk> 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<oidc::Jwk> &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<long long>();
|
||||
}
|
||||
|
||||
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<long long>();
|
||||
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<long long>();
|
||||
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<Json::Int64>(nowEpoch());
|
||||
requestPayload["exp"] = static_cast<Json::Int64>(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<std::string> &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<long long>();
|
||||
const auto frontendRedirect =
|
||||
sessions[0]["frontend_redirect_uri"].as<std::string>();
|
||||
|
||||
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<std::string>())
|
||||
{
|
||||
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<Json::CharReader> reader(builder.newCharReader());
|
||||
const auto providerMetadataText =
|
||||
sessions[0]["provider_metadata"].as<std::string>();
|
||||
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<std::string>());
|
||||
assertionPayload["iat"] = static_cast<Json::Int64>(nowEpoch());
|
||||
assertionPayload["exp"] = static_cast<Json::Int64>(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<std::string>(),
|
||||
{
|
||||
{"grant_type", "authorization_code"},
|
||||
{"redirect_uri", config.redirectUri},
|
||||
{"client_id", config.clientId},
|
||||
{"state", state},
|
||||
{"code", code},
|
||||
{"code_verifier", sessions[0]["pkce_verifier"].as<std::string>()},
|
||||
{"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<std::string>())
|
||||
{
|
||||
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<std::string>())
|
||||
{
|
||||
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<std::string>(),
|
||||
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<std::string>();
|
||||
response["status"] =
|
||||
sessionPublicStatus(result[0]["status"].as<std::string>());
|
||||
response["phase"] = result[0]["status"].as<std::string>();
|
||||
response["provider"] = result[0]["provider"].as<std::string>();
|
||||
response["frontend_redirect_uri"] =
|
||||
result[0]["frontend_redirect_uri"].as<std::string>();
|
||||
if (!result[0]["authenticated_user_id"].isNull())
|
||||
{
|
||||
response["authenticated_user_id"] =
|
||||
Json::Int64(result[0]["authenticated_user_id"].as<long long>());
|
||||
}
|
||||
response["error_code"] = result[0]["error_code"].as<std::string>();
|
||||
response["error_message"] = result[0]["error_message"].as<std::string>();
|
||||
response["created_at"] = result[0]["created_at"].as<std::string>();
|
||||
response["completed_at"] = result[0]["completed_at"].isNull()
|
||||
? ""
|
||||
: result[0]["completed_at"].as<std::string>();
|
||||
if (!result[0]["session_token"].isNull() &&
|
||||
!result[0]["session_token"].as<std::string>().empty())
|
||||
{
|
||||
response["auth"] = tokenPayloadForResponse(
|
||||
result[0]["session_token"].as<std::string>());
|
||||
}
|
||||
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<std::string> &sub,
|
||||
const std::optional<std::string> &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<Json::Int64>(nowEpoch());
|
||||
body["exp"] = static_cast<Json::Int64>(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<std::string> &sub,
|
||||
const std::optional<std::string> &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
|
||||
@@ -1,28 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <json/json.h>
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
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<std::string> &issuer);
|
||||
Json::Value sessionStatus(const std::string &state);
|
||||
Json::Value entityConfigurationDocument() const;
|
||||
std::string entityConfigurationJwt() const;
|
||||
Json::Value resolveDocument(const std::optional<std::string> &sub,
|
||||
const std::optional<std::string> &anchor) const;
|
||||
std::string resolveJwt(const std::optional<std::string> &sub,
|
||||
const std::optional<std::string> &anchor) const;
|
||||
Json::Value jwks() const;
|
||||
};
|
||||
} // namespace visione::services
|
||||
+1
-2
@@ -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)
|
||||
|
||||
@@ -1,359 +0,0 @@
|
||||
#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");
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user