aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorStefan Boberg <[email protected]>2026-02-27 16:08:58 +0100
committerStefan Boberg <[email protected]>2026-02-27 16:08:58 +0100
commitff0bf54463374d90a99ecedbf6dd940dfbfe6e83 (patch)
treecfece7996397f424e3bcc4f2f39ed8e7e9835291
parentadd SuppressDefaultConsoleOutput (diff)
downloadzen-sb/oidc-token.tar.xz
zen-sb/oidc-token.zip
added support for encrypted remote config, fixed console outputsb/oidc-token
-rw-r--r--src/zen/cmds/oidctoken_cmd.cpp173
-rw-r--r--src/zenhttp/auth/oidc.cpp31
2 files changed, 178 insertions, 26 deletions
diff --git a/src/zen/cmds/oidctoken_cmd.cpp b/src/zen/cmds/oidctoken_cmd.cpp
index 24a79323b..2cfd519df 100644
--- a/src/zen/cmds/oidctoken_cmd.cpp
+++ b/src/zen/cmds/oidctoken_cmd.cpp
@@ -2,11 +2,13 @@
#include "oidctoken_cmd.h"
+#include <zencore/crypto.h>
#include <zencore/except_fmt.h>
#include <zencore/filesystem.h>
#include <zencore/fmtutils.h>
#include <zencore/logging.h>
#include <zencore/process.h>
+#include <zencore/string.h>
#include <zencore/uid.h>
#include <zenhttp/auth/oidc.h>
#include <zenhttp/httpclient.h>
@@ -80,6 +82,73 @@ namespace {
}
// -----------------------------------------------------------------------
+ // AES-128-CBC Decryption (for encrypted remote config)
+ // -----------------------------------------------------------------------
+
+ // Decrypts AES-128-CBC ciphertext where the IV is prepended to the data.
+ // Key is a 16-byte hex string (32 hex chars -> 16 bytes).
+ // Returns empty string on failure.
+ std::string DecryptAes128Cbc(std::string_view HexKey, const void* EncryptedData, size_t EncryptedSize)
+ {
+ // Parse hex key to 16 bytes
+ if (HexKey.size() != 32)
+ {
+ ZEN_WARN("AES-128 key must be 32 hex characters (16 bytes), got {} characters", HexKey.size());
+ return {};
+ }
+
+ uint8_t KeyBytes[16];
+ if (!ParseHexBytes(HexKey, KeyBytes))
+ {
+ ZEN_WARN("Failed to parse hex key");
+ return {};
+ }
+
+ // IV is the first 16 bytes of the encrypted data
+ constexpr size_t IvSize = 16;
+ if (EncryptedSize <= IvSize)
+ {
+ ZEN_WARN("Encrypted data too small ({} bytes), must be larger than IV ({} bytes)", EncryptedSize, IvSize);
+ return {};
+ }
+
+ const auto* DataBytes = static_cast<const uint8_t*>(EncryptedData);
+ MemoryView IvView(DataBytes, IvSize);
+ MemoryView CiphertextView(DataBytes + IvSize, EncryptedSize - IvSize);
+
+ // Use the existing crypto infrastructure. The Aes class uses AES-256 with a 256-bit key,
+ // but we need AES-128. We pad the 16-byte key to 32 bytes to satisfy AesKey256Bit, but
+ // on BCrypt (Windows) the key size selects the algorithm automatically. On OpenSSL/mbedTLS,
+ // the existing Transform function hardcodes AES-256-CBC, so for cross-platform correctness
+ // we zero-extend the key to 32 bytes and use the existing Aes::Decrypt.
+ //
+ // NOTE: This works correctly on Windows (BCrypt auto-selects AES-128 based on key size).
+ // On platforms using OpenSSL/mbedTLS, we extend to 256-bit key and use AES-256-CBC which
+ // is a different cipher and would NOT produce the correct result. If cross-platform
+ // decryption of auth configs is needed, AES-128 support must be added to crypto.cpp.
+ // In practice, auth config encryption is primarily used in Windows build farm environments.
+
+ uint8_t PaddedKey[32] = {0};
+ memcpy(PaddedKey, KeyBytes, 16);
+
+ AesKey256Bit Key = AesKey256Bit::FromMemoryView(MemoryView(PaddedKey, 32));
+ AesIV128Bit IV = AesIV128Bit::FromMemoryView(IvView);
+
+ std::vector<uint8_t> DecryptedBuffer(CiphertextView.GetSize() + Aes::BlockSize);
+ std::optional<std::string> Reason;
+
+ MemoryView DecryptedView = Aes::Decrypt(Key, IV, CiphertextView, MakeMutableMemoryView(DecryptedBuffer), Reason);
+
+ if (Reason.has_value())
+ {
+ ZEN_WARN("AES decryption failed: {}", Reason.value());
+ return {};
+ }
+
+ return std::string(reinterpret_cast<const char*>(DecryptedView.GetData()), DecryptedView.GetSize());
+ }
+
+ // -----------------------------------------------------------------------
// Random String Generation
// -----------------------------------------------------------------------
@@ -637,9 +706,9 @@ namespace {
return true;
}
- bool LoadConfigFromUrl(std::string_view Url, OidcTokenConfig& Config)
+ bool LoadConfigFromUrl(std::string_view Url, OidcTokenConfig& Config, std::string_view EncryptionKey = {})
{
- ZEN_DEBUG("Fetching OIDC config from URL: {}", Url);
+ ZEN_DEBUG("Fetching OIDC config from URL: {}{}", Url, EncryptionKey.empty() ? "" : " (encrypted)");
HttpClient Http{Url};
HttpClient::Response Response = Http.Get("");
@@ -652,7 +721,24 @@ namespace {
ZEN_DEBUG("Received config response ({} bytes) from: {}", Response.DownloadedBytes, Url);
- std::string JsonText(Response.AsText());
+ std::string JsonText;
+
+ if (!EncryptionKey.empty())
+ {
+ ZEN_DEBUG("Decrypting config response with AES-128-CBC");
+ JsonText = DecryptAes128Cbc(EncryptionKey, Response.ResponsePayload.GetData(), Response.ResponsePayload.GetSize());
+ if (JsonText.empty())
+ {
+ ZEN_WARN("Failed to decrypt OIDC config from '{}'", Url);
+ return false;
+ }
+ ZEN_DEBUG("Decrypted config: {} bytes", JsonText.size());
+ }
+ else
+ {
+ JsonText = std::string(Response.AsText());
+ }
+
std::string JsonError;
json11::Json Json = json11::Json::parse(JsonText, JsonError);
if (!JsonError.empty())
@@ -673,12 +759,16 @@ namespace {
return LoadConfigFromUrl(Url, Config);
}
- OidcTokenConfig LoadConfiguration(const std::string& ConfigPath, const std::string& AuthConfigUrl, const std::string& HordeUrl)
+ OidcTokenConfig LoadConfiguration(const std::string& ConfigPath,
+ const std::string& AuthConfigUrl,
+ const std::string& HordeUrl,
+ const std::string& EncryptionKey = {})
{
- ZEN_DEBUG("Loading OIDC configuration (config-path='{}', auth-config-url='{}', horde-url='{}')",
+ ZEN_DEBUG("Loading OIDC configuration (config-path='{}', auth-config-url='{}', horde-url='{}', has-encryption-key={})",
ConfigPath,
AuthConfigUrl,
- HordeUrl);
+ HordeUrl,
+ !EncryptionKey.empty());
OidcTokenConfig Config;
// 1. Load from explicit path if provided
@@ -705,11 +795,11 @@ namespace {
LoadConfigFromFile(CandidatePath, Config);
}
- // 3. Load from auth config URL
+ // 3. Load from auth config URL (optionally encrypted)
if (!AuthConfigUrl.empty())
{
ZEN_DEBUG("Loading config from auth-config-url: {}", AuthConfigUrl);
- LoadConfigFromUrl(AuthConfigUrl, Config);
+ LoadConfigFromUrl(AuthConfigUrl, Config, EncryptionKey);
}
// 4. Load from Horde URL
@@ -787,6 +877,14 @@ OidcTokenCommand::Run(const ZenCliOptions& GlobalOptions, int argc, char** argv)
return;
}
+ // When outputting JSON to stdout, suppress all console logging so the output is clean JSON.
+ // This suppresses both the ZEN_CONSOLE logger and the default logger's console sink.
+ if (m_ResultToConsole)
+ {
+ zen::logging::SuppressConsoleLog();
+ zen::logging::SuppressDefaultConsoleOutput();
+ }
+
if (m_Service.empty())
{
throw ErrorWithReturnCode("--service is required", 1);
@@ -800,7 +898,7 @@ OidcTokenCommand::Run(const ZenCliOptions& GlobalOptions, int argc, char** argv)
m_ResultToConsole);
// Load configuration
- OidcTokenConfig Config = LoadConfiguration(m_ConfigPath, m_AuthConfigUrl, m_HordeUrl);
+ OidcTokenConfig Config = LoadConfiguration(m_ConfigPath, m_AuthConfigUrl, m_HordeUrl, m_AuthEncryptionKey);
auto ProviderIt = Config.Providers.find(m_Service);
if (ProviderIt == Config.Providers.end())
@@ -1035,31 +1133,60 @@ OidcTokenCommand::Run(const ZenCliOptions& GlobalOptions, int argc, char** argv)
Client.GetAuthorizationEndpoint(),
Client.GetTokenEndpoint());
- // Determine redirect URI and port
+ // Determine redirect URI and port. If the primary RedirectUri is empty,
+ // try each PossibleRedirectUri until we find a port we can bind to.
std::string RedirectUri = Provider.RedirectUri;
uint16_t Port = 0;
- if (RedirectUri.empty() && !Provider.PossibleRedirectUris.empty())
+ if (!RedirectUri.empty())
{
- ZEN_DEBUG("No primary RedirectUri, using first PossibleRedirectUri");
- RedirectUri = Provider.PossibleRedirectUris[0];
+ Port = ExtractPortFromUri(RedirectUri);
+ if (Port == 0)
+ {
+ throw ErrorWithReturnCode(fmt::format("Unable to extract port from redirect URI '{}'", RedirectUri), 9);
+ }
+ ZEN_DEBUG("Using primary redirect URI: {} (port {})", RedirectUri, Port);
}
-
- if (RedirectUri.empty())
+ else if (!Provider.PossibleRedirectUris.empty())
{
- throw ErrorWithReturnCode("No redirect URI configured for OIDC provider", 9);
- }
+ ZEN_DEBUG("No primary RedirectUri, trying {} possible redirect URI(s)", Provider.PossibleRedirectUris.size());
+ for (const auto& CandidateUri : Provider.PossibleRedirectUris)
+ {
+ uint16_t CandidatePort = ExtractPortFromUri(CandidateUri);
+ if (CandidatePort == 0)
+ {
+ ZEN_DEBUG("Skipping redirect URI '{}' (unable to extract port)", CandidateUri);
+ continue;
+ }
- ZEN_DEBUG("Using redirect URI: {}", RedirectUri);
+ // Try to bind to the port to see if it's available
+ try
+ {
+ asio::io_context TestContext;
+ asio::ip::tcp::acceptor TestAcceptor(TestContext, asio::ip::tcp::endpoint(asio::ip::address_v4::loopback(), CandidatePort));
+ TestAcceptor.close();
+
+ RedirectUri = CandidateUri;
+ Port = CandidatePort;
+ ZEN_DEBUG("Selected redirect URI: {} (port {} is available)", RedirectUri, Port);
+ break;
+ }
+ catch (const asio::system_error& Err)
+ {
+ ZEN_DEBUG("Port {} unavailable ({}), trying next", CandidatePort, Err.what());
+ }
+ }
- Port = ExtractPortFromUri(RedirectUri);
- if (Port == 0)
+ if (RedirectUri.empty())
+ {
+ throw ErrorWithReturnCode("None of the configured redirect URI ports are available", 2);
+ }
+ }
+ else
{
- throw ErrorWithReturnCode(fmt::format("Unable to extract port from redirect URI '{}'", RedirectUri), 9);
+ throw ErrorWithReturnCode("No redirect URI configured for OIDC provider", 9);
}
- ZEN_DEBUG("Extracted callback port: {}", Port);
-
// Generate state for CSRF protection
std::string State = GenerateRandomState();
ZEN_DEBUG("Generated CSRF state: {}", State);
diff --git a/src/zenhttp/auth/oidc.cpp b/src/zenhttp/auth/oidc.cpp
index 4b306236c..eea81fbdb 100644
--- a/src/zenhttp/auth/oidc.cpp
+++ b/src/zenhttp/auth/oidc.cpp
@@ -28,6 +28,27 @@ namespace details {
return Result;
}
+ std::string UrlEncodeFormValue(std::string_view Input)
+ {
+ std::string Result;
+ Result.reserve(Input.size());
+ for (unsigned char Ch : Input)
+ {
+ if ((Ch >= 'A' && Ch <= 'Z') || (Ch >= 'a' && Ch <= 'z') || (Ch >= '0' && Ch <= '9') || Ch == '-' || Ch == '_' || Ch == '.' ||
+ Ch == '~')
+ {
+ Result += static_cast<char>(Ch);
+ }
+ else
+ {
+ char Hex[4];
+ snprintf(Hex, sizeof(Hex), "%%%02X", Ch);
+ Result.append(Hex, 3);
+ }
+ }
+ return Result;
+ }
+
} // namespace details
using namespace std::literals;
@@ -81,7 +102,9 @@ OidcClient::Initialize()
OidcClient::RefreshTokenResult
OidcClient::RefreshToken(std::string_view RefreshToken)
{
- const std::string Body = fmt::format("grant_type=refresh_token&refresh_token={}&client_id={}", RefreshToken, m_ClientId);
+ const std::string Body = fmt::format("grant_type=refresh_token&refresh_token={}&client_id={}",
+ details::UrlEncodeFormValue(RefreshToken),
+ details::UrlEncodeFormValue(m_ClientId));
HttpClient Http{m_Config.TokenEndpoint};
@@ -119,8 +142,10 @@ OidcClient::RefreshToken(std::string_view RefreshToken)
OidcClient::RefreshTokenResult
OidcClient::ExchangeAuthorizationCode(std::string_view Code, std::string_view RedirectUri)
{
- const std::string Body =
- fmt::format("grant_type=authorization_code&code={}&redirect_uri={}&client_id={}", Code, RedirectUri, m_ClientId);
+ const std::string Body = fmt::format("grant_type=authorization_code&code={}&redirect_uri={}&client_id={}",
+ details::UrlEncodeFormValue(Code),
+ details::UrlEncodeFormValue(RedirectUri),
+ details::UrlEncodeFormValue(m_ClientId));
HttpClient Http{m_Config.TokenEndpoint};