#include "nocal/sync/oauth.hpp" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace { using nocal::sync::AuthorizationCallback; using nocal::sync::AuthorizationCallbackReceiver; using nocal::sync::BrowserLauncher; using nocal::sync::EntropySource; using nocal::sync::HttpMethod; using nocal::sync::HttpRequest; using nocal::sync::HttpResponse; using nocal::sync::HttpTransport; using nocal::sync::OAuthClientConfig; using nocal::sync::OAuthError; using nocal::sync::authorize_with_pkce; void require(bool condition, std::string_view message) { if (!condition) { throw std::runtime_error(std::string{message}); } } template void require_throws(Function&& function, std::string_view message) { try { function(); } catch (const std::exception&) { return; } throw std::runtime_error(std::string{message}); } template void require_oauth_error( Function&& function, OAuthError::Kind kind, std::string_view message) { try { function(); } catch (const OAuthError& error) { require(error.kind() == kind, std::string{message}.append(": wrong OAuth error kind")); return; } catch (const std::exception&) { throw std::runtime_error(std::string{message}.append(": wrong exception type")); } throw std::runtime_error(std::string{message}.append(": no exception")); } [[nodiscard]] int hexadecimal_value(char value) { if (value >= '0' && value <= '9') { return value - '0'; } if (value >= 'a' && value <= 'f') { return value - 'a' + 10; } if (value >= 'A' && value <= 'F') { return value - 'A' + 10; } return -1; } [[nodiscard]] std::string percent_decode(std::string_view input, bool plus_is_space) { std::string output; output.reserve(input.size()); for (std::size_t index = 0; index < input.size(); ++index) { if (input[index] == '%' && index + 2 < input.size()) { const int high = hexadecimal_value(input[index + 1]); const int low = hexadecimal_value(input[index + 2]); require(high >= 0 && low >= 0, "request contains malformed percent encoding"); output.push_back(static_cast((high << 4) | low)); index += 2; } else if (input[index] == '+' && plus_is_space) { output.push_back(' '); } else { output.push_back(input[index]); } } return output; } using Parameters = std::multimap; [[nodiscard]] Parameters parse_parameters(std::string_view encoded, bool plus_is_space) { Parameters result; std::size_t offset = 0; while (offset <= encoded.size()) { const std::size_t separator = encoded.find('&', offset); const std::string_view field = encoded.substr( offset, separator == std::string_view::npos ? encoded.size() - offset : separator - offset); const std::size_t equals = field.find('='); const std::string_view name = field.substr(0, equals); const std::string_view value = equals == std::string_view::npos ? std::string_view{} : field.substr(equals + 1); result.emplace( percent_decode(name, plus_is_space), percent_decode(value, plus_is_space)); if (separator == std::string_view::npos) { break; } offset = separator + 1; } return result; } [[nodiscard]] Parameters query_parameters(const std::string& url) { const std::size_t question = url.find('?'); require(question != std::string::npos, "authorization URL has no query"); const std::size_t fragment = url.find('#', question + 1); return parse_parameters(std::string_view{url}.substr(question + 1, fragment == std::string::npos ? std::string::npos : fragment - question - 1), false); } [[nodiscard]] const std::string& only_value( const Parameters& parameters, std::string_view name) { const std::string owned_name{name}; const auto [begin, end] = parameters.equal_range(owned_name); require(begin != end, std::string{"missing parameter: "}.append(name)); const auto next = std::next(begin); require(next == end, std::string{"duplicate parameter: "}.append(name)); return begin->second; } [[nodiscard]] std::string base64url(std::span input) { const std::size_t encoded_size = 4 * ((input.size() + 2) / 3); std::string encoded(encoded_size, '\0'); const int result = EVP_EncodeBlock(reinterpret_cast(encoded.data()), input.data(), static_cast(input.size())); require(result >= 0, "OpenSSL base64 encoding failed in test"); encoded.resize(static_cast(result)); std::replace(encoded.begin(), encoded.end(), '+', '-'); std::replace(encoded.begin(), encoded.end(), '/', '_'); while (!encoded.empty() && encoded.back() == '=') { encoded.pop_back(); } return encoded; } [[nodiscard]] std::string s256_challenge(std::string_view verifier) { std::array digest{}; unsigned int size = 0; EVP_MD_CTX* context = EVP_MD_CTX_new(); require(context != nullptr, "OpenSSL digest allocation failed in test"); struct FreeContext { EVP_MD_CTX* context; ~FreeContext() { EVP_MD_CTX_free(context); } } free_context{context}; require(EVP_DigestInit_ex(context, EVP_sha256(), nullptr) == 1 && EVP_DigestUpdate(context, verifier.data(), verifier.size()) == 1 && EVP_DigestFinal_ex(context, digest.data(), &size) == 1 && size == digest.size(), "OpenSSL SHA-256 failed in test"); return base64url(digest); } class FakeEntropy final : public EntropySource { public: void fill(std::span output) override { sizes.push_back(output.size()); const std::size_t call = sizes.size() - 1; for (std::size_t index = 0; index < output.size(); ++index) { const auto value = static_cast( call == 0 || identical ? (0x11U + index * 3U) & 0xffU : (0xd7U - index * 5U) & 0xffU); output[index] = static_cast(value); } } bool identical{false}; std::vector sizes; }; class FakeBrowser final : public BrowserLauncher { public: void open(const std::string& url) override { urls.push_back(url); if (failure) { throw std::runtime_error("browser launch failed"); } } bool failure{false}; std::vector urls; }; class FakeReceiver final : public AuthorizationCallbackReceiver { public: [[nodiscard]] std::string redirect_uri() const override { if (redirect_failure) { throw std::runtime_error("redirect receiver failed"); } return redirect; } AuthorizationCallback receive() override { ++receive_calls; if (receive_failure) { throw std::runtime_error("callback receiver failed"); } AuthorizationCallback result = callback; if (copy_browser_state) { require(browser != nullptr && browser->urls.size() == 1, "test receiver could not observe authorization URL"); result.state = only_value(query_parameters(browser->urls[0]), "state"); } return result; } std::string redirect{"http://127.0.0.1:43127/callback"}; AuthorizationCallback callback{"authorization-code", "", "", ""}; FakeBrowser* browser{nullptr}; bool copy_browser_state{true}; bool redirect_failure{false}; bool receive_failure{false}; int receive_calls{0}; }; class FakeTransport final : public HttpTransport { public: HttpResponse send(const HttpRequest& request) override { requests.push_back(request); if (failure) { throw std::runtime_error("transport failed"); } return response; } bool failure{false}; HttpResponse response{200, {{"Content-Type", "application/json"}}, R"({"ok":true})"}; std::vector requests; }; struct Rig { Rig() { receiver.browser = &browser; } OAuthClientConfig config{"client id+/%?", "https://login.example.test/oauth2/authorize", "https://login.example.test/oauth2/token", {"openid", "offline_access", "Calendars.ReadWrite"}}; FakeEntropy entropy; FakeBrowser browser; FakeReceiver receiver; FakeTransport transport; }; [[nodiscard]] HttpResponse authorize(Rig& rig) { return authorize_with_pkce( rig.config, rig.browser, rig.receiver, rig.transport, rig.entropy); } void require_no_token_request(const Rig& rig, std::string_view message) { require(rig.transport.requests.empty(), message); } void test_success_request_and_pkce() { Rig rig; rig.config.client_id = "client café +/雪"; rig.receiver.callback.code = "code café +/雪?=&"; rig.transport.response = {204, {{"X-Result", "accepted"}}, R"({"access_token":"opaque"})"}; const HttpResponse response = authorize(rig); require(response == rig.transport.response, "successful token response was not returned intact"); require(rig.entropy.sizes == std::vector{32, 32}, "PKCE attempt did not request exactly two independent 32-byte entropy fills"); require(rig.browser.urls.size() == 1, "authorization attempt did not open exactly one URL"); require(rig.receiver.receive_calls == 1, "authorization attempt did not receive once"); require(rig.transport.requests.size() == 1, "successful authorization did not send exactly one token request"); const Parameters authorization = query_parameters(rig.browser.urls[0]); require(only_value(authorization, "response_type") == "code", "authorization query did not request an authorization code"); require(only_value(authorization, "client_id") == rig.config.client_id, "authorization query did not RFC3986-encode the client ID"); require(only_value(authorization, "redirect_uri") == rig.receiver.redirect, "authorization query did not encode the loopback redirect"); require(only_value(authorization, "code_challenge_method") == "S256", "authorization query did not require PKCE S256"); require(only_value(authorization, "scope") == "openid offline_access Calendars.ReadWrite", "authorization query did not preserve scope order"); require(rig.browser.urls[0].find("café") == std::string::npos && rig.browser.urls[0].find("雪") == std::string::npos && rig.browser.urls[0].find("%C3%A9") != std::string::npos && rig.browser.urls[0].find("%E9%9B%AA") != std::string::npos, "authorization query did not RFC3986-encode non-ASCII UTF-8 bytes"); const HttpRequest& token = rig.transport.requests[0]; require(token.method == HttpMethod::post, "token exchange was not a POST"); require(token.url == rig.config.token_endpoint, "token exchange used the wrong endpoint"); const Parameters form = parse_parameters(token.body, false); require(only_value(form, "grant_type") == "authorization_code", "token request has the wrong grant type"); require(only_value(form, "code") == rig.receiver.callback.code, "token request did not RFC3986-encode the authorization code"); require(only_value(form, "redirect_uri") == rig.receiver.redirect, "token request did not encode the redirect URI"); require(only_value(form, "client_id") == rig.config.client_id, "token request did not RFC3986-encode the client ID"); require(token.body.find("café") == std::string::npos && token.body.find("雪") == std::string::npos && token.body.find("%C3%A9") != std::string::npos && token.body.find("%E9%9B%AA") != std::string::npos, "token form did not RFC3986-encode non-ASCII UTF-8 bytes"); const std::string& state = only_value(authorization, "state"); const std::string& verifier = only_value(form, "code_verifier"); require(!state.empty() && !verifier.empty() && state != verifier, "distinct deterministic entropy was not used independently for state and verifier"); require(only_value(authorization, "code_challenge") == s256_challenge(verifier), "authorization challenge is not SHA-256 of the token verifier"); require(rig.browser.urls[0].find(verifier) == std::string::npos, "raw PKCE verifier was exposed to the authorization endpoint"); require(form.count("state") == 0 && token.body.find("state=") == std::string::npos, "callback state was improperly sent to the token endpoint"); require(token.url.find(state) == std::string::npos && std::ranges::none_of(token.headers, [&](const auto& header) { return header.name.find(state) != std::string::npos || header.value.find(state) != std::string::npos; }) && token.body.find(state) == std::string::npos, "callback state leaked elsewhere in the token request"); require(form.count("client_secret") == 0 && token.body.find("client_secret") == std::string::npos, "public-client token request contains a client secret"); } void test_independent_identical_entropy_is_not_rejected() { Rig rig; rig.entropy.identical = true; require(authorize(rig).status == 200, "identical random outputs were treated as invalid"); require(rig.entropy.sizes == std::vector{32, 32}, "identical entropy fixture did not receive two independent fills"); const auto authorization = query_parameters(rig.browser.urls[0]); const auto form = parse_parameters(rig.transport.requests[0].body, false); require(only_value(authorization, "state") == only_value(form, "code_verifier"), "identical entropy fixture did not produce the expected equal encodings"); } void test_all_2xx_are_success_and_never_retry() { for (const int status : {200, 201, 226, 299}) { Rig rig; rig.transport.response = {status, {}, "success-body"}; require(authorize(rig).status == status, "2xx token response was rejected"); require(rig.transport.requests.size() == 1, "2xx token response was retried"); } } void test_token_rejection_and_transport_failure_never_retry_or_leak() { for (const int status : {0, 101, 199, 300, 400, 429, 500}) { Rig rig; const std::string sensitive = "SENSITIVE_TOKEN_BODY_" + std::to_string(status); rig.transport.response = {status, {}, sensitive}; try { static_cast(authorize(rig)); throw std::runtime_error("non-2xx token response was accepted"); } catch (const OAuthError& error) { require(error.kind() == OAuthError::Kind::token_endpoint_rejected, "token rejection has the wrong OAuth error kind"); require(std::string_view{error.what()}.find(sensitive) == std::string_view::npos, "token response body leaked through OAuth exception"); } require(rig.transport.requests.size() == 1, "token rejection was retried"); } Rig rig; rig.transport.failure = true; require_throws([&] { static_cast(authorize(rig)); }, "transport failure did not propagate"); require(rig.transport.requests.size() == 1, "transport exception triggered a retry"); } void test_callback_failures_never_send_token_request() { { Rig rig; rig.receiver.copy_browser_state = false; rig.receiver.callback.state = "attacker-state"; require_oauth_error([&] { static_cast(authorize(rig)); }, OAuthError::Kind::state_mismatch, "callback state mismatch was accepted"); require_no_token_request(rig, "state mismatch sent a token request"); } { Rig rig; const std::string sensitive = "SENSITIVE_AUTHORIZATION_DESCRIPTION"; rig.receiver.callback = {"", "", "access_denied", sensitive}; try { static_cast(authorize(rig)); throw std::runtime_error("authorization denial was accepted"); } catch (const OAuthError& error) { require(error.kind() == OAuthError::Kind::authorization_rejected, "authorization denial has the wrong OAuth error kind"); require(std::string_view{error.what()}.find(sensitive) == std::string_view::npos, "authorization error description leaked through exception"); } require_no_token_request(rig, "authorization denial sent a token request"); } { Rig rig; rig.receiver.copy_browser_state = false; rig.receiver.callback = {"code-without-state", "", "", ""}; require_oauth_error([&] { static_cast(authorize(rig)); }, OAuthError::Kind::invalid_callback, "callback with empty state was accepted"); require_no_token_request(rig, "callback with empty state sent a token request"); } const std::vector malformed{ {"", "", "", ""}, {"code", "", "error", "description"}, {"", "", "", "description-without-error"}, }; for (const auto& callback : malformed) { Rig rig; rig.receiver.callback = callback; require_oauth_error([&] { static_cast(authorize(rig)); }, OAuthError::Kind::invalid_callback, "malformed callback was accepted"); require_no_token_request(rig, "malformed callback sent a token request"); } } void test_browser_and_receiver_failures_never_send_token_request() { { Rig rig; rig.browser.failure = true; require_throws([&] { static_cast(authorize(rig)); }, "browser failure did not propagate"); require(rig.browser.urls.size() == 1, "browser was unexpectedly retried"); require(rig.receiver.receive_calls == 0, "callback was awaited after browser failure"); require_no_token_request(rig, "browser failure sent a token request"); } { Rig rig; rig.receiver.redirect_failure = true; require_throws([&] { static_cast(authorize(rig)); }, "redirect receiver failure did not propagate"); require(rig.browser.urls.empty(), "browser opened after redirect receiver failure"); require_no_token_request(rig, "redirect receiver failure sent a token request"); } { Rig rig; rig.receiver.receive_failure = true; require_throws([&] { static_cast(authorize(rig)); }, "callback receiver failure did not propagate"); require(rig.receiver.receive_calls == 1, "callback receive was retried"); require_no_token_request(rig, "callback receiver failure sent a token request"); } } void test_endpoint_validation() { const std::vector invalid_endpoints{ "http://login.example.test/oauth2/authorize", "login.example.test/oauth2/authorize", "https:///missing-authority", "https://user@login.example.test/authorize", "https://login.example.test/authorize?query=forbidden", "https://login.example.test/authorize#fragment", "https://login.example.test/path with space", "https://login.example.test/path\ncontrol", }; for (const auto& endpoint : invalid_endpoints) { for (const bool authorization_endpoint : {true, false}) { Rig rig; (authorization_endpoint ? rig.config.authorization_endpoint : rig.config.token_endpoint) = endpoint; require_oauth_error([&] { static_cast(authorize(rig)); }, OAuthError::Kind::invalid_configuration, "invalid authorization or token endpoint was accepted"); require(rig.browser.urls.empty(), "invalid endpoint opened the browser"); require_no_token_request(rig, "invalid endpoint sent a token request"); } } Rig uppercase; uppercase.config.authorization_endpoint = "HTTPS://login.example.test/authorize"; uppercase.config.token_endpoint = "HTTPS://login.example.test/token"; require(authorize(uppercase).status == 200, "uppercase HTTPS scheme was rejected"); } void test_invalid_scope_has_zero_side_effects() { const std::vector invalid_scopes{ "", "two scopes", "tab\tscope", "line\nscope", "return\rscope", "control\x1fscope"}; for (std::size_t index = 0; index < invalid_scopes.size(); ++index) { const auto& invalid = invalid_scopes[index]; Rig rig; rig.config.scopes = {"openid", invalid}; require_oauth_error([&] { static_cast(authorize(rig)); }, OAuthError::Kind::invalid_configuration, "unsafe OAuth scope fixture " + std::to_string(index) + " was accepted"); require(rig.entropy.sizes.empty(), "unsafe scope consumed entropy"); require(rig.browser.urls.empty(), "unsafe scope opened the browser"); require(rig.receiver.receive_calls == 0, "unsafe scope awaited a callback"); require_no_token_request(rig, "unsafe scope sent a token request"); } } void test_loopback_redirect_validation() { const std::vector accepted{ "http://127.0.0.1:1/callback", "http://127.0.0.1:65535", "http://localhost/callback/path", "HTTP://LOCALHOST:43127/callback", "http://[::1]:43127/callback", }; for (const auto& redirect : accepted) { Rig rig; rig.receiver.redirect = redirect; require(authorize(rig).status == 200, "valid loopback redirect was rejected"); require(rig.transport.requests.size() == 1, "valid loopback redirect did not complete one token request"); } const std::vector rejected{ "https://127.0.0.1:43127/callback", "http://example.test:43127/callback", "http://127.0.0.1.example.test:43127/callback", "http://localhost.evil.test:43127/callback", "http://localhost.:43127/callback", "http://%31%32%37.0.0.1:43127/callback", "http://user@127.0.0.1:43127/callback", "http://127.0.0.1:/callback", "http://127.0.0.1:0/callback", "http://127.0.0.1:65536/callback", "http://127.0.0.1:not-a-port/callback", "http://::1:43127/callback", "http://[::1]:43127/callback?query=forbidden", "http://[::1]:43127/callback#fragment", "http://localhost:43127/path with space", "http://localhost:43127/path\rcontrol", }; for (const auto& redirect : rejected) { Rig rig; rig.receiver.redirect = redirect; require_oauth_error([&] { static_cast(authorize(rig)); }, OAuthError::Kind::invalid_redirect, "unsafe loopback redirect was accepted"); require(rig.browser.urls.empty(), "unsafe redirect opened the browser"); require_no_token_request(rig, "unsafe redirect sent a token request"); } } } // namespace int main() { try { test_success_request_and_pkce(); test_independent_identical_entropy_is_not_rejected(); test_all_2xx_are_success_and_never_retry(); test_token_rejection_and_transport_failure_never_retry_or_leak(); test_callback_failures_never_send_token_request(); test_browser_and_receiver_failures_never_send_token_request(); test_endpoint_validation(); test_invalid_scope_has_zero_side_effects(); test_loopback_redirect_validation(); } catch (const std::exception& error) { std::cerr << "OAuth security test failure: " << error.what() << '\n'; return 1; } std::cout << "OAuth security tests passed\n"; return 0; }