Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
cmake_minimum_required(VERSION 3.21)

# Preserve legacy FetchContent_Populate behavior to avoid CMake 3.30+ deprecation warnings
# for fallback dependencies that populate manually (imported targets, interface libraries).
if(POLICY CMP0169)
cmake_policy(SET CMP0169 OLD)
endif()

project(kurlyk VERSION 1.0.2 LANGUAGES CXX)
# options

Expand Down
11 changes: 8 additions & 3 deletions examples/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,16 @@ set(KURLYK_EXAMPLE_SOURCES
websocket_client_lifecycle_test.cpp
websocket_echo_example.cpp
websocket_independent_clients_example.cpp
bearer_token_auth_provider_example.cpp
api_key_auth_provider_example.cpp
simple_bearer_auth_example.cpp
)

if(KURLYK_AUTH_SUPPORT)
list(APPEND KURLYK_EXAMPLE_SOURCES
bearer_token_auth_provider_example.cpp
api_key_auth_provider_example.cpp
simple_bearer_auth_example.cpp
)
endif()

if(KURLYK_OAUTH_SUPPORT)
list(APPEND KURLYK_EXAMPLE_SOURCES
openrouter_oauth_pkce_example.cpp
Expand Down
6 changes: 6 additions & 0 deletions guides/auth-providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

Injects an `Authorization: Bearer <token>` header, replacing any previous `Authorization` value.

When attached to an `HttpClient` via `set_auth_provider()`, the provider runs **after** per-request headers are merged. This means a provider can overwrite a header that was set manually on an individual request.

```cpp
kurlyk::http::auth::BearerTokenAuthProvider auth("my_api_key");

Expand Down Expand Up @@ -44,6 +46,10 @@ query_auth.authorize(request);

Query parameters are percent-encoded automatically via `kurlyk::utils::percent_encode`.

## Thread safety

`HttpClient::set_auth_provider()` is not internally synchronized. Set the provider before starting concurrent requests, or guard the call and subsequent submissions with external synchronization.

## Implementing a custom provider

Derive from `IAuthProvider` and implement `authorize(HttpRequest&)` and `authorize(Headers&)`.
Expand Down
25 changes: 25 additions & 0 deletions include/kurlyk/http/HttpClient.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
/// \file HttpClient.hpp
/// \brief Contains the definition of the concrete HttpClient class for making HTTP requests to a specific host.

#if KURLYK_AUTH_SUPPORT
# include "auth/IAuthProvider.hpp"
#endif

namespace kurlyk {

/// \class HttpClient
Expand Down Expand Up @@ -185,6 +189,19 @@ namespace kurlyk {
m_request.headers = headers;
}

#if KURLYK_AUTH_SUPPORT
/// \brief Assigns an authentication provider to all requests created by this client.
/// \param provider Shared pointer to an IAuthProvider implementation.
/// \note The provider's `authorize()` is called on every request created by this client
/// after per-request headers are merged, so it can overwrite headers such as
/// `Authorization` set manually on the request. Set to `nullptr` to disable.
/// \note Thread-safety: this setter is not synchronized; call it only before concurrent
/// requests begin, or externally synchronize with request submission.
void set_auth_provider(std::shared_ptr<http::auth::IAuthProvider> provider) {
m_auth_provider = provider;
}
#endif

/// \brief Assigns an existing rate limit to future requests by ID.
/// \param limit_id The unique identifier of the rate limit to assign.
/// \param type Rate limit type to configure.
Expand Down Expand Up @@ -896,6 +913,9 @@ namespace kurlyk {
bool m_owns_specific_rate_limit = false; ///< Flag indicating if the client owns the specific rate limit.
mutable std::mutex m_submit_mutex; ///< Protects client-side submission settings and admission checks.
std::size_t m_max_in_flight = 0; ///< Maximum number of in-flight requests for this client group, or 0 for disabled.
# if KURLYK_AUTH_SUPPORT
std::shared_ptr<http::auth::IAuthProvider> m_auth_provider; ///< Optional authentication provider applied to every request.
# endif

/// \brief Adds the request to the request manager and notifies the worker to process it.
/// \param request_ptr The HTTP request to be sent.
Expand Down Expand Up @@ -929,6 +949,11 @@ namespace kurlyk {
request_ptr->set_url(m_host, path, query);
request_ptr->headers.insert(headers.begin(), headers.end());
request_ptr->content = content;
# if KURLYK_AUTH_SUPPORT
if (m_auth_provider) {
m_auth_provider->authorize(*request_ptr);
}
# endif
return request_ptr;
}

Expand Down
27 changes: 22 additions & 5 deletions include/kurlyk/http/HttpRequestManager.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -431,11 +431,28 @@ namespace kurlyk {

// If there are ready requests, create a new HttpBatchRequestHandler to manage them.
if (pending_request.empty()) return;
# if __cplusplus >= 201402L
m_active_request_batches.push_back(std::make_unique<HttpBatchRequestHandler>(pending_request));
# else
m_active_request_batches.push_back(std::unique_ptr<HttpBatchRequestHandler>(new HttpBatchRequestHandler(pending_request)));
# endif
try {
# if __cplusplus >= 201402L
m_active_request_batches.push_back(std::make_unique<HttpBatchRequestHandler>(pending_request));
# else
m_active_request_batches.push_back(std::unique_ptr<HttpBatchRequestHandler>(new HttpBatchRequestHandler(pending_request)));
# endif
} catch (...) {
for (auto& context : pending_request) {
if (!context || !context->callback) continue;
# if __cplusplus >= 201402L
auto response = std::make_unique<HttpResponse>();
# else
auto response = std::unique_ptr<HttpResponse>(new HttpResponse());
# endif
response->error_code = utils::make_error_code(utils::ClientError::AbortedDuringDestruction);
response->status_code = 499; // Client closed request
response->ready = true;
context->callback(std::move(response));
context->complete();
}
return;
}
}

/// \brief Processes active requests, moving failed ones to the failed requests list for retrying.
Expand Down
65 changes: 63 additions & 2 deletions include/kurlyk/http/HttpRequestManager/HttpBatchRequestHandler.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,57 @@ namespace kurlyk {
/// \param context_list List of unique pointers to HttpRequestContext objects.
explicit HttpBatchRequestHandler(std::vector<std::unique_ptr<HttpRequestContext>>& context_list)
: m_multi_handle(curl_multi_init()) {
if (!m_multi_handle) {
// libcurl multi handle creation failed: fail all requests immediately.
for (auto& context : context_list) {
if (!context || !context->callback) continue;
# if __cplusplus >= 201402L
auto response = std::make_unique<HttpResponse>();
# else
auto response = std::unique_ptr<HttpResponse>(new HttpResponse());
# endif
response->error_code = utils::make_error_code(utils::ClientError::AbortedDuringDestruction);
response->status_code = 499; // Client closed request
response->ready = true;
context->callback(std::move(response));
context->complete();
context.reset();
}
return;
}
for (auto& context : context_list) {
if (!context) continue;
# if __cplusplus >= 201402L
auto handler = std::make_unique<HttpRequestHandler>(std::move(context));
# else
auto handler = std::unique_ptr<HttpRequestHandler>(new HttpRequestHandler(std::move(context)));
# endif
CURL* curl = handler->get_curl();
if (!curl) continue;
if (!curl) {
// curl_easy_init failed: deliver error immediately.
auto ctx = handler->get_request_context();
if (ctx && ctx->callback) {
# if __cplusplus >= 201402L
auto response = std::make_unique<HttpResponse>();
# else
auto response = std::unique_ptr<HttpResponse>(new HttpResponse());
# endif
response->error_code = utils::make_error_code(utils::ClientError::AbortedDuringDestruction);
response->status_code = 499; // Client closed request
response->ready = true;
ctx->callback(std::move(response));
ctx->complete();
}
continue;
}

curl_multi_add_handle(m_multi_handle, curl);
m_handlers.push_back(std::move(handler));
}
// Ensure the source vector is empty after moving contexts into handlers or failing them.
for (auto& context : context_list) {
context.reset();
}
}

/// \brief Cleans up the multi handle and removes all request handles.
Expand All @@ -37,12 +76,34 @@ namespace kurlyk {
if (!curl) continue;
curl_multi_remove_handle(m_multi_handle, curl);
}
curl_multi_cleanup(m_multi_handle);
if (m_multi_handle) {
curl_multi_cleanup(m_multi_handle);
}
}

/// \brief Processes the requests within the handler.
/// \return True if all requests are completed, false otherwise.
bool process() {
if (!m_multi_handle) {
// Multi handle was never created: fail all handlers immediately.
for (auto& handler : m_handlers) {
auto ctx = handler->get_request_context();
if (ctx && ctx->callback) {
# if __cplusplus >= 201402L
auto response = std::make_unique<HttpResponse>();
# else
auto response = std::unique_ptr<HttpResponse>(new HttpResponse());
# endif
response->error_code = utils::make_error_code(utils::ClientError::AbortedDuringDestruction);
response->status_code = 499; // Client closed request
response->ready = true;
ctx->callback(std::move(response));
ctx->complete();
}
}
m_handlers.clear();
return true;
}
int still_running = 0;
CURLMcode res = curl_multi_perform(m_multi_handle, &still_running);
if (res != CURLM_OK) return false;
Expand Down
48 changes: 48 additions & 0 deletions tests/auth/test_auth_providers.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -53,5 +53,53 @@ int main() {
if (req.url != "https://example.com/api?foo=bar&key=val") return 1;
}

// Edge cases: empty token returns false and does not inject header
{
kurlyk::Headers headers;
kurlyk::http::auth::BearerTokenAuthProvider bearer("");
if (bearer.authorize(headers)) return 1;
if (headers.find("Authorization") != headers.end()) return 1;
}

// Edge cases: empty ApiKey name or value returns false
{
kurlyk::Headers headers;
kurlyk::http::auth::ApiKeyAuthProvider api_key(
"", "val", kurlyk::http::auth::ApiKeyPlacement::HEADER);
if (api_key.authorize(headers)) return 1;
}
{
kurlyk::Headers headers;
kurlyk::http::auth::ApiKeyAuthProvider api_key(
"key", "", kurlyk::http::auth::ApiKeyPlacement::HEADER);
if (api_key.authorize(headers)) return 1;
}

// Edge cases: empty ApiKey name or value in QUERY mode leaves URL unchanged
{
kurlyk::HttpRequest req;
req.url = "https://example.com/api";
kurlyk::http::auth::ApiKeyAuthProvider api_key(
"", "val", kurlyk::http::auth::ApiKeyPlacement::QUERY);
if (api_key.authorize(req)) return 1;
if (req.url != "https://example.com/api") return 1;
}
{
kurlyk::HttpRequest req;
req.url = "https://example.com/api";
kurlyk::http::auth::ApiKeyAuthProvider api_key(
"key", "", kurlyk::http::auth::ApiKeyPlacement::QUERY);
if (api_key.authorize(req)) return 1;
if (req.url != "https://example.com/api") return 1;
}

// Edge case: ApiKeyAuthProvider QUERY placement with Headers interface returns false
{
kurlyk::Headers headers;
kurlyk::http::auth::ApiKeyAuthProvider api_key(
"key", "val", kurlyk::http::auth::ApiKeyPlacement::QUERY);
if (api_key.authorize(headers)) return 1;
}

return 0;
}
51 changes: 51 additions & 0 deletions tests/auth/test_oauth_token_parse.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -52,5 +52,56 @@ int main() {
if (result.error != kurlyk::AuthError::UnsupportedFlow) return 1;
}

// Custom parser: simulates error response with invalid_grant
{
TestClient client3(config);
client3.set_token_parser([](const std::string& raw,
kurlyk::OAuthToken& token,
std::string& err) -> bool {
if (raw.find("invalid_grant") != std::string::npos) {
err = "invalid_grant";
return false;
}
token.access_token = "ok";
token.token_type = "Bearer";
return true;
});
kurlyk::AuthResult result;
if (client3.test_parse("invalid_grant", result)) return 1;
if (result.success) return 1;
if (result.error != kurlyk::AuthError::InvalidResponse) return 1;
if (result.error_message != "invalid_grant") return 1;
}

// validate_state: empty strings return false
{
kurlyk::http::auth::OAuthPkceClient empty_state(config);
if (empty_state.validate_state("")) return 1;
}

// validate_state: matching state returns true
{
kurlyk::OAuthConfig good_config;
good_config.client_id = "client";
good_config.authorization_endpoint = "https://example.com/auth";
good_config.redirect_uri = "https://example.com/cb";
kurlyk::http::auth::OAuthPkceClient match_client(good_config);
std::string url = match_client.build_authorization_url();
(void)url;
if (!match_client.validate_state(match_client.state())) return 1;
}

// validate_state: mismatching state returns false
{
kurlyk::OAuthConfig good_config;
good_config.client_id = "client";
good_config.authorization_endpoint = "https://example.com/auth";
good_config.redirect_uri = "https://example.com/cb";
kurlyk::http::auth::OAuthPkceClient mismatch_client(good_config);
std::string url = mismatch_client.build_authorization_url();
(void)url;
if (mismatch_client.validate_state("wrong_state")) return 1;
}

return 0;
}
9 changes: 9 additions & 0 deletions tests/integration/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ add_executable(http_client_wait_requests_test
http_client_wait_requests_main.cpp
)

add_executable(http_client_auth_provider_test
http_client_auth_provider_main.cpp
)

add_executable(sequential_rate_limit_test
sequential_rate_limit_main.cpp
)
Expand Down Expand Up @@ -98,6 +102,8 @@ target_link_libraries(request_group_cancel_test PRIVATE kurlyk)
target_link_libraries(http_client_rate_limit_api_test PRIVATE kurlyk)
target_link_libraries(http_client_wait_requests_test PRIVATE kurlyk)
target_include_directories(http_client_wait_requests_test PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/../../external/Simple-Web-Server")
target_link_libraries(http_client_auth_provider_test PRIVATE kurlyk)
target_include_directories(http_client_auth_provider_test PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/../../external/Simple-Web-Server")
target_link_libraries(sequential_rate_limit_test PRIVATE kurlyk)
target_link_libraries(rate_limit_partition_test PRIVATE kurlyk)
target_link_libraries(http_local_test PRIVATE kurlyk)
Expand All @@ -116,6 +122,7 @@ target_compile_features(rate_limit_lifetime_test PRIVATE cxx_std_17)
target_compile_features(request_group_cancel_test PRIVATE cxx_std_17)
target_compile_features(http_client_rate_limit_api_test PRIVATE cxx_std_17)
target_compile_features(http_client_wait_requests_test PRIVATE cxx_std_17)
target_compile_features(http_client_auth_provider_test PRIVATE cxx_std_17)
target_compile_features(sequential_rate_limit_test PRIVATE cxx_std_17)
target_compile_features(rate_limit_partition_test PRIVATE cxx_std_17)
target_compile_features(http_local_test PRIVATE cxx_std_17)
Expand All @@ -137,6 +144,7 @@ copy_runtime_dlls(rate_limit_lifetime_test)
copy_runtime_dlls(request_group_cancel_test)
copy_runtime_dlls(http_client_rate_limit_api_test)
copy_runtime_dlls(http_client_wait_requests_test)
copy_runtime_dlls(http_client_auth_provider_test)
copy_runtime_dlls(sequential_rate_limit_test)
copy_runtime_dlls(rate_limit_partition_test)
copy_runtime_dlls(http_local_test)
Expand All @@ -150,6 +158,7 @@ add_test(NAME rate_limit_lifetime_test COMMAND rate_limit_lifetime_test)
add_test(NAME request_group_cancel_test COMMAND request_group_cancel_test)
add_test(NAME http_client_rate_limit_api_test COMMAND http_client_rate_limit_api_test)
add_test(NAME http_client_wait_requests_test COMMAND http_client_wait_requests_test)
add_test(NAME http_client_auth_provider_test COMMAND http_client_auth_provider_test)
add_test(NAME sequential_rate_limit_test COMMAND sequential_rate_limit_test)
add_test(NAME rate_limit_partition_test COMMAND rate_limit_partition_test)
add_test(NAME http_local_test COMMAND http_local_test)
Expand Down
Loading
Loading