Skip to content

Commit

Permalink
Implement OAuth Client (#357)
Browse files Browse the repository at this point in the history
 for details see: #350
  • Loading branch information
vimpostor authored Nov 12, 2024
1 parent 8da3e2f commit 8b472fd
Show file tree
Hide file tree
Showing 6 changed files with 467 additions and 1 deletion.
4 changes: 4 additions & 0 deletions .github/workflows/build_cmake.yml
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,10 @@ jobs:
shell: bash
run: build-wrapper-linux-x86-64 --out-dir ${{ env.BUILD_WRAPPER_OUT_DIR }} cmake --build ../build --config ${{ matrix.cmake-build-type }}

- name: Run Keycloak Docker
shell: bash
run: docker run -p 8090:8080 -d -e KEYCLOAK_ADMIN=admin -e KEYCLOAK_ADMIN_PASSWORD=admin quay.io/keycloak/keycloak:26.0.5 start-dev && src/client/test/setup-keycloak.sh

- name: Run tests
if: matrix.configurations.name != env.REFERENCE_CONFIG || matrix.cmake-build-type != 'Debug'
working-directory: ${{runner.workspace}}/build
Expand Down
31 changes: 31 additions & 0 deletions src/client/test/setup-keycloak.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
#!/usr/bin/env bash

# This script requires keycloak to be already running and configures it to be compatible with the unit tests by setting up a realm, a user etc...
# To start a keycloak instance in the first place, the following is sufficient:
# docker run -p 8090:8080 -e KEYCLOAK_ADMIN=admin -e KEYCLOAK_ADMIN_PASSWORD=admin quay.io/keycloak/keycloak:25.0.4 start-dev
#
# For more information see: https://www.keycloak.org/getting-started/getting-started-docker

URL="http://localhost:8090"
REDIRECT_URI="http://localhost:8091"
ADMIN_PASSWORD="admin"

while ! curl -s "$URL"; do
echo "Waiting for $URL to be reachable..."
sleep 1
done

echo 'Getting access token'
AUTH="Authorization: Bearer $(curl -s -X POST "$URL/realms/master/protocol/openid-connect/token" -H 'Accept: application/json' -H 'Content-Type: application/x-www-form-urlencoded' -d 'grant_type=password&username=admin&password='"$ADMIN_PASSWORD"'&client_id=admin-cli' | jq -r '.access_token')"

echo 'Creating realm with name "testrealm"'
curl -s -X POST "$URL/admin/realms" -H 'Content-Type: application/json' -H "$AUTH" -d '{"realm":"testrealm","enabled":true}'

echo 'Creating user with name "testuser" and password "testuser"'
curl -s -X POST "$URL/admin/realms/testrealm/users" -H 'Content-Type: application/json' -H "$AUTH" -d '{"username":"testuser","emailVerified":true,"enabled":true,"firstName":"Mr.","lastName":"Bar","email":"[email protected]","credentials":[{"type":"password","temporary":false,"value":"testuser"}]}'

echo 'Adding a client with client id "testclientid"'
curl -s -X POST "$URL/admin/realms/testrealm/clients" -H 'Content-Type: application/json' -H "$AUTH" -d '{"clientId":"testclientid","enabled":true,"redirectUris":["'"$REDIRECT_URI"'"],"publicClient":true}'

echo 'Adding a confidential client with client id "confidentialclientid" and secret "secret00000000000000000000000000"'
curl -s -X POST "$URL/admin/realms/testrealm/clients" -H 'Content-Type: application/json' -H "$AUTH" -d '{"clientId":"confidentialclientid","enabled":true,"redirectUris":["'"$REDIRECT_URI"'"],"publicClient":false,"clientAuthenticatorType":"client-secret","directAccessGrantsEnabled":true,"secret":"secret00000000000000000000000000"}'
3 changes: 2 additions & 1 deletion src/services/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ add_library(
include/services/dns.hpp
include/services/dns_client.hpp
include/services/dns_storage.hpp
include/services/dns_types.hpp)
include/services/dns_types.hpp
include/services/OAuthClient.hpp)

target_include_directories(services INTERFACE $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
$<INSTALL_INTERFACE:include/>)
Expand Down
276 changes: 276 additions & 0 deletions src/services/include/services/OAuthClient.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
#ifndef OPENCMW_CPP_OAUTHCLIENT_HPP
#define OPENCMW_CPP_OAUTHCLIENT_HPP

#include "IoBuffer.hpp"
#include "IoSerialiserJson.hpp"
#include "URI.hpp"
#include <httplib.h>
#include <majordomo/Worker.hpp>

namespace opencmw {

using StrictUri = opencmw::URI<opencmw::uri_check::STRICT>;

struct OAuthContext {
std::string secretToken;
};

struct OAuthInput {
std::string scope;
std::string clientId;
std::string clientSecret;
std::string secret;
};

struct OAuthOutput {
std::string authorizationUri;
std::string secret;
std::string accessToken;
std::string refreshToken;
};
} // namespace opencmw

ENABLE_REFLECTION_FOR(opencmw::OAuthContext, secretToken)
ENABLE_REFLECTION_FOR(opencmw::OAuthInput, scope, clientId, clientSecret, secret)
ENABLE_REFLECTION_FOR(opencmw::OAuthOutput, authorizationUri, secret, accessToken, refreshToken)

namespace opencmw {

class Token {
public:
std::string _token;
std::optional<std::chrono::time_point<std::chrono::steady_clock>> _expiry; // time when this token will expire if any

Token(const std::string &token = "")
: _token(token) {}
Token(const std::string &token, std::chrono::seconds expiresIn)
: Token(token) {
// convenience constructor for OAuth access token responses (expires_in is always in seconds)
_expiry = std::chrono::steady_clock::now() + expiresIn;
}

bool expired() const {
return _expiry && *_expiry < std::chrono::steady_clock::now();
}

bool valid() const {
return !_token.empty() && !expired();
}
};

struct OAuthAccess {
Token accessToken;
Token refreshToken;
};

struct OAuthResponse {
int status = -1;
std::string body;
};

// response as per https://datatracker.ietf.org/doc/html/rfc6749#section-5.1
struct AccessTokenResponse {
std::string access_token;
std::string token_type;
int expires_in = 3600;
std::string refresh_token;
std::string bullshit;
};
} // namespace opencmw

ENABLE_REFLECTION_FOR(opencmw::AccessTokenResponse, access_token, token_type, expires_in, refresh_token, bullshit)

namespace opencmw {

// This class implements an RFC 6749 compliant OAuth client
// https://datatracker.ietf.org/doc/html/rfc6749
class OAuthClient {
private:
StrictUri _redirectUri;
StrictUri _endpoint;
StrictUri _tokenEndpoint;
httplib::Server _srv;
std::unique_ptr<std::thread> _thread;
std::function<void(const std::string &code, const std::string &state)> _endpointCallback;

public:
explicit OAuthClient(StrictUri redirectUri, StrictUri endpoint, StrictUri tokenEndpoint)
: _redirectUri(redirectUri), _endpoint(endpoint), _tokenEndpoint(tokenEndpoint) {
_srv.Get("/", [&](const httplib::Request req, httplib::Response &res) {
std::string code, state;
// response as per https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2
for (const auto &[k, v] : req.params) {
if (k == "code") {
code = v;
} else if (k == "state") {
state = v;
}
}
if (code.empty()) {
res.set_content("Did not receive an RFC 6749-compliant response.", "text/plain");
res.status = 401; // Unauthorized
} else {
res.set_content("Authorization complete. You can close this browser window now.\n", "text/plain");
if (_endpointCallback) {
_endpointCallback(code, state);
}
}
});
_thread = std::make_unique<std::thread>([this]() {
_srv.listen(*_redirectUri.hostName(), *_redirectUri.port());
});
}

// convenience API function for clients who just want to open the URI in a web browser
static bool openWebBrowser(const StrictUri &uri) {
fmt::println("Opening {} in a web browser...", uri.str());
#ifdef __unix__
// An alternative would be to use the org.freedesktop.portal.OpenURI method, but that would require dbus
// and it is more likely that xdg-open is present, than the portal running
// flawfinder: ignore
return !std::system(fmt::format("xdg-open '{}'", uri.str()).c_str());
#else
std::cout << "Your platform is unsupported, please open the link manually." << std::endl;
return false;
#endif
}

void setEndpointCallback(decltype(_endpointCallback) callback) {
_endpointCallback = callback;
}

StrictUri authorizationUri(const std::string &scope, const std::string &clientId, const std::string &state = "", const std::string &clientSecret = "") const {
// parameters as per https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1
std::unordered_map<std::string, std::optional<std::string>> params{ { "scope", scope }, { "response_type", "code" }, { "client_id", clientId }, { "redirect_uri", _redirectUri.str() } };

if (!state.empty()) {
params.emplace("state", state);
}

if (!clientSecret.empty()) {
params.emplace("client_secret", clientSecret);
}

return StrictUri::UriFactory(_endpoint).setQuery(params).build();
}

static std::optional<httplib::Client> getClient(const StrictUri &endpoint) {
const auto scheme = endpoint.scheme();
const auto hostname = endpoint.hostName();
const auto port = endpoint.port();
const auto path = endpoint.path();
if (!scheme || !hostname || !port || !path || path->empty()) {
return std::nullopt;
}

return httplib::Client(*scheme + "://" + *hostname + ":" + std::to_string(*port));
}

OAuthAccess getAccessToken(const httplib::Params &params) {
auto client = getClient(_tokenEndpoint);
if (!client) {
return {};
}

auto res = client->Post(*_tokenEndpoint.path(), params);
if (!res || res->status != 200) {
return {};
}
opencmw::IoBuffer buf{ res->body.c_str() };
AccessTokenResponse resp;
opencmw::deserialise<opencmw::Json, opencmw::ProtocolCheck::LENIENT>(buf, resp);
if (resp.access_token.empty()) {
return {};
}

return { Token(resp.access_token, std::chrono::seconds(resp.expires_in)), Token(resp.refresh_token) };
}

OAuthAccess requestToken(const std::string &authCode, const std::string &clientId) {
if (authCode.empty() || clientId.empty()) {
return {};
}

// parameters as per https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3
return getAccessToken({ { "grant_type", "authorization_code" }, { "code", authCode }, { "redirect_uri", _redirectUri.str() }, { "client_id", clientId } });
}

OAuthAccess refreshAccessToken(const std::string &refreshToken, const std::string &clientId) {
if (refreshToken.empty()) {
return {};
}
// parameters as per https://datatracker.ietf.org/doc/html/rfc6749#section-6
return getAccessToken({ { "grant_type", "refresh_token" }, { "refresh_token", refreshToken }, { "client_id", clientId } });
}

void stop() {
_srv.stop();
_thread->join();
}
};

namespace {
using OAuthWorkerType = majordomo::Worker<"/oauth", OAuthContext, OAuthInput, OAuthOutput, majordomo::description<"Authorization with OAuth2">>;
};

template<typename T>
T makeRandom(uint32_t size) {
std::random_device rd;
std::mt19937 generator(rd());
std::uniform_int_distribution<T> distribution(0, static_cast<T>(size));

return distribution(generator);
}

template<>
inline std::string makeRandom(uint32_t size) {
std::string characters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";

std::string randomString(size, ' ');
std::generate(randomString.begin(), randomString.end(), [&]() {
return characters[makeRandom<uint32_t>(static_cast<uint32_t>(characters.length() - 1))];
});

return randomString;
}

class OAuthWorker : public OAuthWorkerType {
public:
explicit OAuthWorker(StrictUri redirectUri, StrictUri endpoint, StrictUri tokenEndpoint, StrictUri brokerAddress, const zmq::Context &context, majordomo::Settings settings = {})
: OAuthWorkerType(brokerAddress, {}, context, settings), _client(redirectUri, endpoint, tokenEndpoint) { init(); };
template<typename BrokerType>
explicit OAuthWorker(StrictUri redirecturi, StrictUri endpoint, StrictUri tokenEndpoint, const BrokerType &broker)
: OAuthWorkerType(broker, {}), _client(redirecturi, endpoint, tokenEndpoint) { init(); };

void stop() {
_client.stop();
}

private:
std::map<std::string, std::string> _secrets;
OAuthClient _client;
void init() {
_client.setEndpointCallback([this](const std::string &code, const std::string &state) {
_secrets[state] = code;
});
OAuthWorkerType::setCallback([this](const majordomo::RequestContext &rawCtx, const OAuthContext &context, const OAuthInput &in, OAuthContext &replyContext, OAuthOutput &out) {
if (in.secret.empty()) {
// first stage, client gets a secret in the response back plus an URI to authorize at
constexpr std::size_t secretLength = 64;
const auto secret = makeRandom<std::string>(secretLength);
out.authorizationUri = _client.authorizationUri(in.scope, in.clientId, secret).str();
out.secret = secret;
} else if (_secrets.contains(in.secret)) {
// second stage, client has authorized and can get an access token
const auto code = _secrets.at(in.secret);
const auto access = _client.requestToken(code, in.clientId);
out.accessToken = access.accessToken._token;
out.refreshToken = access.refreshToken._token;
}
});
}
};

} // namespace opencmw

#endif // OPENCMW_CPP_OAUTHCLIENT_HPP
9 changes: 9 additions & 0 deletions src/services/test/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,12 @@ target_link_libraries(dns_tests PUBLIC services Catch2::Catch2)
target_include_directories(dns_tests PRIVATE ${CMAKE_SOURCE_DIR})

catch_discover_tests(dns_tests)


add_executable(oauthClient_tests OAuthClient_tests.cpp)

target_link_libraries(oauthClient_tests PUBLIC services Catch2::Catch2)

target_include_directories(oauthClient_tests PRIVATE ${CMAKE_SOURCE_DIR})

catch_discover_tests(oauthClient_tests)
Loading

0 comments on commit 8b472fd

Please sign in to comment.