#include "nocal/sync/oauth.hpp" #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)); } } class ScriptedEntropy final : public EntropySource { public: explicit ScriptedEntropy(std::vector> scripted_values) : values(std::move(scripted_values)) {} std::vector> values; std::size_t calls{0}; std::size_t fail_at{static_cast(-1)}; void fill(std::span output) override { require(output.size() == 32, "entropy request was not exactly 32 bytes"); const std::size_t call = calls++; if (call == fail_at) { throw std::runtime_error("entropy failure detail"); } require(call < values.size(), "unexpected entropy request"); std::copy(values[call].begin(), values[call].end(), output.begin()); } }; class RecordingBrowser final : public BrowserLauncher { public: std::vector urls; void open(const std::string& url) override { urls.push_back(url); } }; class FixedReceiver final : public AuthorizationCallbackReceiver { public: std::string redirect{"http://127.0.0.1:43123/callback"}; AuthorizationCallback callback; std::size_t receives{0}; [[nodiscard]] std::string redirect_uri() const override { return redirect; } AuthorizationCallback receive() override { ++receives; return callback; } }; class RecordingTransport final : public HttpTransport { public: std::vector requests; HttpResponse response{200, {}, "{\"access_token\":\"opaque\"}"}; HttpResponse send(const HttpRequest& request) override { requests.push_back(request); return response; } }; [[nodiscard]] OAuthClientConfig config() { return {"client id/+", "https://login.example/authorize", "https://login.example/token", {"openid", "Calendars.ReadWrite/test"}}; } [[nodiscard]] std::array zeros() { return {}; } [[nodiscard]] int base64url_digit(char character) { if (character >= 'A' && character <= 'Z') { return character - 'A'; } if (character >= 'a' && character <= 'z') { return character - 'a' + 26; } if (character >= '0' && character <= '9') { return character - '0' + 52; } if (character == '-') { return 62; } if (character == '_') { return 63; } throw std::runtime_error("invalid base64url test fixture"); } [[nodiscard]] std::array decode_verifier(std::string_view value) { std::vector decoded; unsigned int accumulator = 0; unsigned int bits = 0; for (const char character : value) { accumulator = (accumulator << 6U) | static_cast(base64url_digit(character)); bits += 6U; if (bits >= 8U) { bits -= 8U; decoded.push_back(static_cast((accumulator >> bits) & 0xffU)); } } require(decoded.size() == 32, "RFC verifier fixture did not decode to 32 bytes"); std::array result{}; std::copy(decoded.begin(), decoded.end(), result.begin()); return result; } [[nodiscard]] std::string deterministic_state() { return "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; } template OAuthError expect_oauth_error(Action&& action, OAuthError::Kind kind) { try { action(); } catch (const OAuthError& error) { require(error.kind() == kind, "OAuth error had the wrong kind"); return error; } throw std::runtime_error("expected OAuthError was not thrown"); } void test_rfc7636_vector_and_exact_exchange() { static constexpr std::string_view verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; ScriptedEntropy entropy{{zeros(), decode_verifier(verifier)}}; RecordingBrowser browser; FixedReceiver receiver; receiver.callback = {"code +/&", deterministic_state(), "", ""}; RecordingTransport transport; transport.response = {201, {{"Content-Type", "application/json"}}, "opaque token body"}; const HttpResponse response = authorize_with_pkce(config(), browser, receiver, transport, entropy); require(response == transport.response, "successful token response was not returned unchanged"); require(entropy.calls == 2, "OAuth attempt did not make exactly two entropy requests"); require(browser.urls.size() == 1, "browser was not opened exactly once"); require(receiver.receives == 1, "callback was not received exactly once"); require(transport.requests.size() == 1, "token endpoint was not called exactly once"); const std::string expected_url = "https://login.example/authorize?response_type=code&client_id=client%20id%2F%2B" "&redirect_uri=http%3A%2F%2F127.0.0.1%3A43123%2Fcallback" "&scope=openid%20Calendars.ReadWrite%2Ftest&state=" + deterministic_state() + "&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" "&code_challenge_method=S256"; require(browser.urls.front() == expected_url, "authorization URL was not exact RFC3986/PKCE"); const HttpRequest& request = transport.requests.front(); require(request.method == HttpMethod::post, "token exchange did not use POST"); require(request.url == "https://login.example/token", "token endpoint changed"); require(request.headers == std::vector{{"Content-Type", "application/x-www-form-urlencoded"}, {"Accept", "application/json"}}, "token request headers were not exact"); const std::string expected_body = "grant_type=authorization_code&client_id=client%20id%2F%2B&code=code%20%2B%2F%26" "&redirect_uri=http%3A%2F%2F127.0.0.1%3A43123%2Fcallback&code_verifier=" + std::string(verifier); require(request.body == expected_body, "token request form was not exact"); require(request.body.find("client_secret") == std::string::npos, "token request contained a client secret"); require(request.body.find("state=") == std::string::npos, "token request contained callback state"); } void test_invalid_configurations_do_nothing() { std::vector invalid; OAuthClientConfig value = config(); value.client_id.clear(); invalid.push_back(value); value = config(); value.scopes.clear(); invalid.push_back(value); value = config(); value.scopes.push_back(""); invalid.push_back(value); const std::vector invalid_scopes = {"two scopes", "line\nbreak", "tab\tbreak", "quote\"", "backslash\\", std::string("nonascii-") + "\xC3\xAF"}; for (const std::string& scope : invalid_scopes) { value = config(); value.scopes = {scope}; invalid.push_back(value); } for (const std::string endpoint : {"", "http://login.example/auth", "/authorize", "https://user@login.example/auth", "https://login.example/auth?prompt=login", "https://login.example/auth#fragment", "https://login.example/a path", "https:///authorize"}) { value = config(); value.authorization_endpoint = endpoint; invalid.push_back(value); value = config(); value.token_endpoint = endpoint; invalid.push_back(value); } for (const OAuthClientConfig& invalid_config : invalid) { ScriptedEntropy entropy{{zeros(), zeros()}}; RecordingBrowser browser; FixedReceiver receiver; RecordingTransport transport; expect_oauth_error( [&] { (void)authorize_with_pkce( invalid_config, browser, receiver, transport, entropy); }, OAuthError::Kind::invalid_configuration); require(entropy.calls == 0 && browser.urls.empty() && receiver.receives == 0 && transport.requests.empty(), "invalid configuration caused OAuth side effects"); } } void test_redirect_validation() { const std::vector invalid = {"", "https://localhost/callback", "http://example.com/callback", "http://localhost.example/callback", "http://127.0.0.2/callback", "http://user@localhost/callback", "http://localhost:0/callback", "http://localhost:65536/callback", "http://localhost:port/callback", "http://::1/callback", "http://[::1]evil/callback", "http://localhost/callback?code=x", "http://localhost/callback#fragment", "http://localhost/a path"}; for (const std::string& redirect : invalid) { ScriptedEntropy entropy{{zeros(), zeros()}}; RecordingBrowser browser; FixedReceiver receiver; receiver.redirect = redirect; RecordingTransport transport; expect_oauth_error( [&] { (void)authorize_with_pkce(config(), browser, receiver, transport, entropy); }, OAuthError::Kind::invalid_redirect); require(entropy.calls == 0 && browser.urls.empty() && receiver.receives == 0 && transport.requests.empty(), "invalid redirect caused OAuth side effects"); } } void run_valid_redirect(std::string redirect) { ScriptedEntropy entropy{{zeros(), zeros()}}; RecordingBrowser browser; FixedReceiver receiver; receiver.redirect = std::move(redirect); receiver.callback = {"code", deterministic_state(), "", ""}; RecordingTransport transport; (void)authorize_with_pkce(config(), browser, receiver, transport, entropy); require(transport.requests.size() == 1, "valid loopback redirect was rejected"); } void test_valid_loopback_redirects() { run_valid_redirect("http://localhost"); run_valid_redirect("HTTP://LOCALHOST:65535/callback/path"); run_valid_redirect("http://127.0.0.1:1/"); run_valid_redirect("http://[::1]:43123/callback"); } void test_non_ascii_encoding_and_upper_success_boundary() { ScriptedEntropy entropy{{zeros(), zeros()}}; RecordingBrowser browser; FixedReceiver receiver; receiver.callback = {"code", deterministic_state(), "", ""}; RecordingTransport transport; transport.response.status = 299; OAuthClientConfig utf8_config = config(); utf8_config.client_id = std::string("client-") + "\xC3\xAF"; const HttpResponse response = authorize_with_pkce(utf8_config, browser, receiver, transport, entropy); require(response.status == 299, "HTTP 299 token response was not accepted"); require(browser.urls.front().find("client_id=client-%C3%AF") != std::string::npos, "non-ASCII authorization parameter was not byte-percent-encoded"); require(transport.requests.front().body.find("client_id=client-%C3%AF") != std::string::npos, "non-ASCII form parameter was not byte-percent-encoded"); } void test_callback_shapes_and_state() { const std::vector malformed = {{}, {"code", "", "", ""}, {"code", deterministic_state(), "error", ""}, {"code", deterministic_state(), "", "unexpected"}, {"", "", "denied", ""}}; for (const AuthorizationCallback& callback : malformed) { ScriptedEntropy entropy{{zeros(), zeros()}}; RecordingBrowser browser; FixedReceiver receiver; receiver.callback = callback; RecordingTransport transport; expect_oauth_error( [&] { (void)authorize_with_pkce(config(), browser, receiver, transport, entropy); }, OAuthError::Kind::invalid_callback); require(browser.urls.size() == 1 && transport.requests.empty(), "malformed callback had incorrect side effects"); } const std::vector mismatches = { {"code", "wrong", "", ""}, {"", "wrong", "denied", "description"}}; for (const AuthorizationCallback& callback : mismatches) { ScriptedEntropy entropy{{zeros(), zeros()}}; RecordingBrowser browser; FixedReceiver receiver; receiver.callback = callback; RecordingTransport transport; expect_oauth_error( [&] { (void)authorize_with_pkce(config(), browser, receiver, transport, entropy); }, OAuthError::Kind::state_mismatch); require(transport.requests.empty(), "state mismatch sent a token request"); } ScriptedEntropy entropy{{zeros(), zeros()}}; RecordingBrowser browser; FixedReceiver receiver; receiver.callback = {"", deterministic_state(), "access_denied", "private detail"}; RecordingTransport transport; const OAuthError rejection = expect_oauth_error( [&] { (void)authorize_with_pkce(config(), browser, receiver, transport, entropy); }, OAuthError::Kind::authorization_rejected); require(std::string(rejection.what()).find("private detail") == std::string::npos, "authorization error leaked error_description"); require(transport.requests.empty(), "authorization denial sent a token request"); } void test_token_rejection_is_single_and_opaque() { for (const int status : {0, 199, 300, 400, 500}) { ScriptedEntropy entropy{{zeros(), zeros()}}; RecordingBrowser browser; FixedReceiver receiver; receiver.callback = {"code", deterministic_state(), "", ""}; RecordingTransport transport; transport.response = {status, {}, "private token failure body"}; const OAuthError error = expect_oauth_error( [&] { (void)authorize_with_pkce(config(), browser, receiver, transport, entropy); }, OAuthError::Kind::token_endpoint_rejected); require(transport.requests.size() == 1, "token rejection was retried or not sent"); require(std::string(error.what()).find("private token failure body") == std::string::npos, "token rejection leaked response body"); } } void test_entropy_failure_is_cryptographic_and_has_no_network_side_effect() { for (const std::size_t failure : {std::size_t{0}, std::size_t{1}}) { ScriptedEntropy entropy{{zeros(), zeros()}}; entropy.fail_at = failure; RecordingBrowser browser; FixedReceiver receiver; RecordingTransport transport; expect_oauth_error( [&] { (void)authorize_with_pkce(config(), browser, receiver, transport, entropy); }, OAuthError::Kind::cryptographic_failure); require(browser.urls.empty() && receiver.receives == 0 && transport.requests.empty(), "cryptographic failure caused browser or network side effects"); } } } // namespace int main() { try { test_rfc7636_vector_and_exact_exchange(); test_invalid_configurations_do_nothing(); test_redirect_validation(); test_valid_loopback_redirects(); test_non_ascii_encoding_and_upper_success_boundary(); test_callback_shapes_and_state(); test_token_rejection_is_single_and_opaque(); test_entropy_failure_is_cryptographic_and_has_no_network_side_effect(); } catch (const std::exception& error) { std::cerr << "oauth tests failed: " << error.what() << '\n'; return 1; } std::cout << "oauth tests passed\n"; return 0; }