feat: add Linux OAuth adapters

Add a bounded libcurl transport with verified TLS, no redirects or retries, sanitized failures, binary-safe requests, and resolved-address enforcement for cleartext loopback traffic.

Add shell-free xdg-open launching and a one-shot IPv4 loopback callback receiver with strict HTTP/query parsing, deadlines, fixed callback path, and RAII cleanup. Token parsing, Secret Service, and Graph remain follow-up phases.
This commit is contained in:
2026-07-18 11:33:06 +01:00
parent dd6e02cb55
commit 295acbc125
12 changed files with 2095 additions and 28 deletions

418
tests/curl_http_tests.cpp Normal file
View File

@@ -0,0 +1,418 @@
#include "nocal/sync/curl_http.hpp"
#include <arpa/inet.h>
#include <netinet/in.h>
#include <poll.h>
#include <sys/socket.h>
#include <unistd.h>
#include <algorithm>
#include <array>
#include <chrono>
#include <cstddef>
#include <cstring>
#include <exception>
#include <functional>
#include <iostream>
#include <mutex>
#include <stdexcept>
#include <string>
#include <string_view>
#include <thread>
#include <utility>
#include <vector>
namespace {
using nocal::sync::CurlHttpError;
using nocal::sync::CurlHttpOptions;
using nocal::sync::CurlHttpTransport;
using nocal::sync::HttpHeader;
using nocal::sync::HttpMethod;
using nocal::sync::HttpRequest;
using nocal::sync::HttpResponse;
void require(bool condition, std::string_view message) {
if (!condition) {
throw std::runtime_error(std::string(message));
}
}
void close_socket(int descriptor) {
if (descriptor >= 0) {
::close(descriptor);
}
}
void send_all(int descriptor, std::string_view data) {
std::size_t sent = 0;
while (sent < data.size()) {
const ssize_t count = ::send(
descriptor, data.data() + sent, data.size() - sent, MSG_NOSIGNAL);
if (count <= 0) {
return;
}
sent += static_cast<std::size_t>(count);
}
}
[[nodiscard]] std::size_t content_length(std::string_view headers) {
std::string lower(headers);
std::transform(lower.begin(), lower.end(), lower.begin(), [](unsigned char character) {
return character >= 'A' && character <= 'Z'
? static_cast<char>(character + ('a' - 'A'))
: static_cast<char>(character);
});
const std::string_view name = "\r\ncontent-length:";
const std::size_t position = lower.find(name);
if (position == std::string::npos) {
return 0;
}
std::size_t offset = position + name.size();
while (offset < lower.size() && (lower[offset] == ' ' || lower[offset] == '\t')) {
++offset;
}
std::size_t length = 0;
while (offset < lower.size() && lower[offset] >= '0' && lower[offset] <= '9') {
length = length * 10U + static_cast<std::size_t>(lower[offset] - '0');
++offset;
}
return length;
}
[[nodiscard]] std::string receive_request(int descriptor) {
std::string request;
std::array<char, 4096> buffer{};
std::size_t expected = std::string::npos;
while (true) {
const ssize_t count = ::recv(descriptor, buffer.data(), buffer.size(), 0);
if (count <= 0) {
break;
}
request.append(buffer.data(), static_cast<std::size_t>(count));
const std::size_t end = request.find("\r\n\r\n");
if (end != std::string::npos && expected == std::string::npos) {
expected = end + 4 + content_length(std::string_view(request).substr(0, end + 2));
}
if (expected != std::string::npos && request.size() >= expected) {
break;
}
}
return request;
}
class OneShotServer {
public:
using Handler = std::function<void(int)>;
explicit OneShotServer(Handler handler) {
listener_ = ::socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0);
require(listener_ >= 0, "unable to create test server socket");
const int enabled = 1;
require(::setsockopt(listener_, SOL_SOCKET, SO_REUSEADDR, &enabled, sizeof(enabled)) == 0,
"unable to configure test server socket");
sockaddr_in address{};
address.sin_family = AF_INET;
address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
address.sin_port = 0;
require(::bind(listener_, reinterpret_cast<const sockaddr*>(&address), sizeof(address)) == 0,
"unable to bind test server socket");
socklen_t size = sizeof(address);
require(::getsockname(listener_, reinterpret_cast<sockaddr*>(&address), &size) == 0,
"unable to inspect test server socket");
port_ = ntohs(address.sin_port);
require(::listen(listener_, 4) == 0, "unable to listen on test server socket");
thread_ = std::thread([this, handler = std::move(handler)] {
try {
pollfd descriptor{listener_, POLLIN, 0};
const int ready = ::poll(&descriptor, 1, 2'000);
if (ready > 0) {
const int connection = ::accept4(listener_, nullptr, nullptr, SOCK_CLOEXEC);
if (connection >= 0) {
++connections_;
handler(connection);
close_socket(connection);
}
}
} catch (...) {
error_ = std::current_exception();
}
});
}
~OneShotServer() {
if (thread_.joinable()) {
thread_.join();
}
close_socket(listener_);
}
OneShotServer(const OneShotServer&) = delete;
OneShotServer& operator=(const OneShotServer&) = delete;
[[nodiscard]] std::string url(std::string_view path = "/") const {
return "http://127.0.0.1:" + std::to_string(port_) + std::string(path);
}
[[nodiscard]] std::string localhost_url(std::string_view path = "/") const {
return "http://localhost:" + std::to_string(port_) + std::string(path);
}
void finish() {
if (thread_.joinable()) {
thread_.join();
}
if (error_) {
std::rethrow_exception(error_);
}
}
[[nodiscard]] int connections() const noexcept { return connections_; }
private:
int listener_{-1};
unsigned short port_{0};
std::thread thread_;
std::exception_ptr error_;
int connections_{0};
};
template <typename Action>
CurlHttpError expect_curl_error(Action&& action, CurlHttpError::Kind kind) {
try {
action();
} catch (const CurlHttpError& error) {
if (error.kind() != kind) {
throw std::runtime_error("curl error had kind "
+ std::to_string(static_cast<int>(error.kind())) + " instead of "
+ std::to_string(static_cast<int>(kind)));
}
return error;
}
throw std::runtime_error("expected CurlHttpError was not thrown");
}
[[nodiscard]] std::string method_text(HttpMethod method) {
switch (method) {
case HttpMethod::get: return "GET";
case HttpMethod::post: return "POST";
case HttpMethod::put: return "PUT";
case HttpMethod::patch: return "PATCH";
case HttpMethod::delete_: return "DELETE";
}
throw std::runtime_error("unknown test method");
}
void test_methods_headers_binary_body_status_and_response_headers() {
for (const HttpMethod method : {HttpMethod::get, HttpMethod::post, HttpMethod::put,
HttpMethod::patch, HttpMethod::delete_}) {
std::string received;
OneShotServer server([&](int connection) {
received = receive_request(connection);
send_all(connection,
"HTTP/1.1 207 Multi-Status\r\nX-Order: first\r\n"
"X-Order: second\r\nContent-Length: 3\r\nConnection: close\r\n\r\nok!");
});
CurlHttpTransport transport;
const std::string body{"A\0B", 3};
const HttpResponse response = transport.send(
{method, server.url("/method"), {{"X-Test", "value"}}, body});
server.finish();
require(server.connections() == 1, "transport opened more than one connection");
require(received.starts_with(method_text(method) + " /method HTTP/1.1\r\n"),
"transport sent the wrong HTTP method");
require(received.find("\r\nX-Test: value\r\n") != std::string::npos,
"transport did not preserve request header");
require(received.size() >= body.size()
&& received.compare(received.size() - body.size(), body.size(), body) == 0,
"transport did not preserve binary request body");
require(response.status == 207 && response.body == "ok!",
"transport did not preserve response status/body");
require(std::count(response.headers.begin(), response.headers.end(),
HttpHeader{"X-Order", "first"})
== 1
&& std::count(response.headers.begin(), response.headers.end(),
HttpHeader{"X-Order", "second"})
== 1,
"transport did not preserve ordinary duplicate response headers");
}
}
void test_redirect_is_not_followed() {
OneShotServer server([](int connection) {
(void)receive_request(connection);
send_all(connection,
"HTTP/1.1 302 Found\r\nLocation: http://127.0.0.1:1/never\r\n"
"Content-Length: 0\r\nConnection: close\r\n\r\n");
});
CurlHttpTransport transport;
const HttpResponse response = transport.send({HttpMethod::get, server.url(), {}, {}});
server.finish();
require(response.status == 302 && server.connections() == 1,
"transport followed or retried a redirect");
}
void test_localhost_resolution_remains_loopback() {
OneShotServer server([](int connection) {
(void)receive_request(connection);
send_all(connection,
"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok");
});
CurlHttpTransport transport;
const HttpResponse response =
transport.send({HttpMethod::get, server.localhost_url(), {}, {}});
server.finish();
require(response.status == 200 && response.body == "ok" && server.connections() == 1,
"localhost did not resolve through the loopback-only socket policy");
}
void test_cumulative_response_limit() {
OneShotServer server([](int connection) {
(void)receive_request(connection);
send_all(connection,
"HTTP/1.1 200 OK\r\nContent-Length: 256\r\nConnection: close\r\n\r\n"
+ std::string(256, 'x'));
});
CurlHttpOptions options;
options.maximum_response_bytes = 100;
CurlHttpTransport transport(options);
expect_curl_error(
[&] { (void)transport.send({HttpMethod::get, server.url(), {}, {}}); },
CurlHttpError::Kind::response_too_large);
server.finish();
require(server.connections() == 1, "oversize response caused a retry");
}
void test_timeout_is_single_and_sanitized() {
OneShotServer server([](int connection) {
(void)receive_request(connection);
std::this_thread::sleep_for(std::chrono::milliseconds(150));
send_all(connection,
"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
});
CurlHttpOptions options;
options.connect_timeout_milliseconds = 40;
options.total_timeout_milliseconds = 40;
CurlHttpTransport transport(options);
const CurlHttpError error = expect_curl_error(
[&] {
(void)transport.send({HttpMethod::get, server.url("/?SECRET_QUERY"),
{{"X-Secret", "SECRET_HEADER"}}, "SECRET_BODY"});
},
CurlHttpError::Kind::transport_failed);
server.finish();
const std::string message = error.what();
require(message.find("SECRET") == std::string::npos
&& message.find("127.0.0.1") == std::string::npos,
"transport error leaked request details");
require(server.connections() == 1, "timed-out request was retried");
}
void test_invalid_options() {
std::vector<CurlHttpOptions> invalid;
CurlHttpOptions value;
value.connect_timeout_milliseconds = 0;
invalid.push_back(value);
value = {};
value.total_timeout_milliseconds = -1;
invalid.push_back(value);
value = {};
value.maximum_response_bytes = 0;
invalid.push_back(value);
const std::vector<std::string> invalid_agents = {std::string("bad\ragent"),
std::string("bad\nagent"), std::string("bad\0agent", 9), std::string("bad\x7f")};
for (const std::string& agent : invalid_agents) {
value = {};
value.user_agent = agent;
invalid.push_back(value);
}
for (const CurlHttpOptions& options : invalid) {
expect_curl_error(
[&] { CurlHttpTransport transport(options); }, CurlHttpError::Kind::invalid_request);
}
}
void test_invalid_headers_and_urls_are_rejected_before_network() {
CurlHttpTransport transport;
const std::vector<HttpHeader> invalid_headers = {{"", "value"}, {"Bad Name", "value"},
{"Bad:Name", "value"}, {"X-Test", "line\rbreak"}, {"X-Test", "line\nbreak"},
{"X-Test", std::string("nul\0break", 9)}};
for (std::size_t index = 0; index < invalid_headers.size(); ++index) {
try {
expect_curl_error(
[&] {
(void)transport.send({HttpMethod::get, "http://127.0.0.1:1/",
{invalid_headers[index]}, "SECRET_BODY"});
},
CurlHttpError::Kind::invalid_request);
} catch (const std::exception& error) {
throw std::runtime_error(
"invalid header case " + std::to_string(index) + ": " + error.what());
}
}
const std::vector<std::string> invalid_urls = {"", "/relative", "ftp://example.test/",
"http://example.test/", "http://localhost.evil/", "http://user@localhost/",
"http://::1/", "http://[::1]evil/", "https:///missing", "http://localhost:0/",
"http://localhost:65536/", "http://localhost/a path", "https://[not-ipv6]/",
"https://bad%host/",
std::string("http://localhost/a\nb")};
for (std::size_t index = 0; index < invalid_urls.size(); ++index) {
try {
const CurlHttpError error = expect_curl_error(
[&] {
(void)transport.send(
{HttpMethod::get, invalid_urls[index], {}, "SECRET_BODY"});
},
CurlHttpError::Kind::invalid_request);
require(std::string(error.what()).find("SECRET") == std::string::npos,
"invalid-request error leaked request data");
} catch (const std::exception& error) {
throw std::runtime_error(
"invalid URL case " + std::to_string(index) + ": " + error.what());
}
}
}
void test_parallel_construction() {
std::mutex mutex;
std::exception_ptr error;
std::vector<std::thread> threads;
for (int index = 0; index < 8; ++index) {
threads.emplace_back([&] {
try {
CurlHttpTransport transport;
} catch (...) {
std::scoped_lock lock(mutex);
error = std::current_exception();
}
});
}
for (std::thread& thread : threads) {
thread.join();
}
if (error) {
std::rethrow_exception(error);
}
}
} // namespace
int main() {
try {
test_methods_headers_binary_body_status_and_response_headers();
test_redirect_is_not_followed();
test_localhost_resolution_remains_loopback();
test_cumulative_response_limit();
test_timeout_is_single_and_sanitized();
test_invalid_options();
test_invalid_headers_and_urls_are_rejected_before_network();
test_parallel_construction();
} catch (const std::exception& error) {
std::cerr << "curl HTTP tests failed: " << error.what() << '\n';
return 1;
}
std::cout << "curl HTTP tests passed\n";
return 0;
}

View File

@@ -0,0 +1,517 @@
#include "nocal/sync/desktop_oauth.hpp"
#include <array>
#include <cerrno>
#include <chrono>
#include <cstdint>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <functional>
#include <iostream>
#include <iterator>
#include <stdexcept>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>
namespace {
using namespace std::chrono_literals;
using nocal::sync::AuthorizationCallback;
using nocal::sync::LoopbackCallbackReceiver;
using nocal::sync::XdgBrowserLauncher;
void require(bool condition, std::string_view message) {
if (!condition) {
throw std::runtime_error(std::string{message});
}
}
template <typename Function>
void require_throws(Function&& function, std::string_view message) {
try {
function();
} catch (const std::exception&) {
return;
}
throw std::runtime_error(std::string{message});
}
class TempDirectory {
public:
TempDirectory() {
std::string pattern =
(std::filesystem::temp_directory_path() / "nocal-desktop-oauth-tests.XXXXXX")
.string();
std::vector<char> writable(pattern.begin(), pattern.end());
writable.push_back('\0');
const char* created = ::mkdtemp(writable.data());
if (created == nullptr) {
throw std::runtime_error("mkdtemp failed");
}
path_ = created;
}
TempDirectory(const TempDirectory&) = delete;
TempDirectory& operator=(const TempDirectory&) = delete;
~TempDirectory() {
std::error_code ignored;
std::filesystem::remove_all(path_, ignored);
}
[[nodiscard]] const std::filesystem::path& path() const noexcept { return path_; }
private:
std::filesystem::path path_;
};
class Descriptor {
public:
explicit Descriptor(int value = -1) : value_(value) {}
~Descriptor() {
if (value_ >= 0) {
static_cast<void>(::close(value_));
}
}
Descriptor(const Descriptor&) = delete;
Descriptor& operator=(const Descriptor&) = delete;
Descriptor(Descriptor&& other) noexcept : value_(std::exchange(other.value_, -1)) {}
Descriptor& operator=(Descriptor&& other) noexcept {
if (this != &other) {
if (value_ >= 0) {
static_cast<void>(::close(value_));
}
value_ = std::exchange(other.value_, -1);
}
return *this;
}
[[nodiscard]] int get() const noexcept { return value_; }
private:
int value_;
};
[[nodiscard]] std::string read_file(const std::filesystem::path& path) {
std::ifstream input(path, std::ios::binary);
require(static_cast<bool>(input), "could not read browser helper output");
return {std::istreambuf_iterator<char>{input}, {}};
}
[[nodiscard]] std::string self_executable() {
return std::filesystem::read_symlink("/proc/self/exe").string();
}
void set_helper_output(const std::filesystem::path& path) {
require(::setenv("NOCAL_DESKTOP_OAUTH_HELPER_OUTPUT", path.c_str(), 1) == 0,
"could not configure browser helper output");
}
int run_browser_helper(int argc, char** argv) {
const char* output_path = std::getenv("NOCAL_DESKTOP_OAUTH_HELPER_OUTPUT");
if (output_path == nullptr) {
return 91;
}
std::ofstream output(output_path, std::ios::binary | std::ios::trunc);
output << argc << '\n';
for (int index = 0; index < argc; ++index) {
output << argv[index] << '\n';
}
if (!output) {
return 92;
}
return argc == 2 && std::string_view{argv[1]}.starts_with("browser-helper:exit=7") ? 7 : 0;
}
void test_browser_literal_argv_and_failures(const std::filesystem::path& root) {
const auto helper_output = root / "browser-argv.txt";
const auto injection_target = root / "must-not-exist";
set_helper_output(helper_output);
XdgBrowserLauncher launcher{self_executable()};
const std::string url = "browser-helper:exit=0; literal argument; touch "
+ injection_target.string() + "; $(touch " + injection_target.string() + ")";
launcher.open(url);
const std::string report = read_file(helper_output);
require(report.starts_with("2\n"), "browser helper received more than one URL argument");
require(report.ends_with(url + "\n"), "browser helper did not receive the URL literally");
require(!std::filesystem::exists(injection_target), "browser URL was interpreted by a shell");
const std::string sensitive = "SENSITIVE_BROWSER_URL";
try {
launcher.open("browser-helper:exit=7;" + sensitive);
throw std::runtime_error("nonzero browser exit was accepted");
} catch (const std::exception& error) {
require(std::string_view{error.what()}.find(sensitive) == std::string_view::npos,
"browser URL leaked through an exception");
}
require_throws([] { XdgBrowserLauncher invalid{""}; },
"empty browser executable was accepted");
require_throws(
[] { XdgBrowserLauncher invalid{std::string{"browser\0name", 12}}; },
"browser executable containing NUL was accepted");
require_throws(
[] {
XdgBrowserLauncher missing{"nocal-browser-executable-that-does-not-exist"};
missing.open("https://example.test");
},
"missing browser executable was accepted");
for (const std::string& invalid_url : {
std::string{"https://example.test/\0secret", 28},
std::string{"https://example.test/\nsecret"},
std::string{"https://example.test/\rsecret"},
std::string{"https://example.test/\x7fsecret"},
}) {
require_throws([&] { launcher.open(invalid_url); },
"browser URL containing a control character was accepted");
}
}
[[nodiscard]] unsigned int port_from_uri(std::string_view uri) {
constexpr std::string_view prefix = "http://localhost:";
constexpr std::string_view suffix = "/nocal/oauth/callback";
require(uri.starts_with(prefix) && uri.ends_with(suffix),
"loopback redirect URI has the wrong shape");
const std::string_view encoded = uri.substr(prefix.size(), uri.size() - prefix.size() - suffix.size());
require(!encoded.empty(), "loopback redirect URI has no port");
unsigned int port = 0;
for (const char character : encoded) {
require(character >= '0' && character <= '9', "loopback redirect port is not numeric");
port = port * 10U + static_cast<unsigned int>(character - '0');
}
require(port > 0 && port <= 65'535, "loopback redirect port is out of range");
return port;
}
[[nodiscard]] Descriptor connect_ipv4(unsigned int port) {
Descriptor client{::socket(AF_INET, SOCK_STREAM, 0)};
require(client.get() >= 0, "could not create loopback test socket");
sockaddr_in address{};
address.sin_family = AF_INET;
address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
address.sin_port = htons(static_cast<std::uint16_t>(port));
require(::connect(client.get(), reinterpret_cast<const sockaddr*>(&address), sizeof(address))
== 0,
"could not connect to loopback receiver");
return client;
}
[[nodiscard]] bool can_connect_ipv4(unsigned int port) {
Descriptor client{::socket(AF_INET, SOCK_STREAM, 0)};
if (client.get() < 0) {
return false;
}
sockaddr_in address{};
address.sin_family = AF_INET;
address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
address.sin_port = htons(static_cast<std::uint16_t>(port));
return ::connect(client.get(), reinterpret_cast<const sockaddr*>(&address), sizeof(address))
== 0;
}
[[nodiscard]] bool can_connect_ipv6(unsigned int port) {
Descriptor client{::socket(AF_INET6, SOCK_STREAM, 0)};
if (client.get() < 0) {
return false;
}
sockaddr_in6 address{};
address.sin6_family = AF_INET6;
address.sin6_addr = in6addr_loopback;
address.sin6_port = htons(static_cast<std::uint16_t>(port));
return ::connect(client.get(), reinterpret_cast<const sockaddr*>(&address), sizeof(address))
== 0;
}
void send_bytes(int descriptor, std::string_view bytes) {
std::size_t offset = 0;
while (offset < bytes.size()) {
const ssize_t sent =
::send(descriptor, bytes.data() + offset, bytes.size() - offset, MSG_NOSIGNAL);
if (sent < 0 && errno == EINTR) {
continue;
}
require(sent > 0, "failed to send loopback test request");
offset += static_cast<std::size_t>(sent);
}
}
void send_all(int descriptor, std::string_view bytes) {
send_bytes(descriptor, bytes);
require(::shutdown(descriptor, SHUT_WR) == 0, "failed to finish loopback test request");
}
[[nodiscard]] std::string read_response(int descriptor) {
std::string result;
std::array<char, 1024> buffer{};
while (true) {
const ssize_t received = ::recv(descriptor, buffer.data(), buffer.size(), 0);
if (received > 0) {
result.append(buffer.data(), static_cast<std::size_t>(received));
} else if (received == 0) {
return result;
} else if (errno == ECONNRESET && !result.empty()) {
return result;
} else if (errno != EINTR) {
throw std::runtime_error("failed to read loopback HTTP response");
}
}
}
[[nodiscard]] std::string request_for(unsigned int port, std::string_view target,
std::string_view method = "GET", std::string_view extra_headers = {}) {
return std::string{method} + " " + std::string{target} + " HTTP/1.1\r\nHost: localhost:"
+ std::to_string(port) + "\r\n" + std::string{extra_headers} + "\r\n";
}
struct ExchangeResult {
AuthorizationCallback callback;
std::string response;
};
[[nodiscard]] ExchangeResult valid_exchange(
LoopbackCallbackReceiver& receiver, std::string_view query) {
const unsigned int port = port_from_uri(receiver.redirect_uri());
Descriptor client = connect_ipv4(port);
send_all(client.get(), request_for(port, std::string{"/nocal/oauth/callback?"} + std::string{query}));
AuthorizationCallback callback = receiver.receive();
return {std::move(callback), read_response(client.get())};
}
[[nodiscard]] std::string invalid_exchange(
LoopbackCallbackReceiver& receiver, const std::string& request) {
const unsigned int port = port_from_uri(receiver.redirect_uri());
Descriptor client = connect_ipv4(port);
send_all(client.get(), request);
require_throws([&] { static_cast<void>(receiver.receive()); },
"invalid loopback request was accepted");
return read_response(client.get());
}
void require_success_response(std::string_view response) {
require(response.starts_with("HTTP/1.1 200 OK\r\n"),
"valid callback did not receive HTTP 200");
require(response.find("Cache-Control: no-store") != std::string_view::npos,
"valid callback response is cacheable");
}
void require_failure_response(std::string_view response) {
require(response.starts_with("HTTP/1.1 400 Bad Request\r\n"),
"invalid callback did not receive HTTP 400");
require(response.find("Cache-Control: no-store") != std::string_view::npos,
"invalid callback response is cacheable");
require(response.find("SENSITIVE_CALLBACK") == std::string_view::npos,
"invalid callback response reflected request data");
}
void test_redirect_and_parsed_callbacks() {
LoopbackCallbackReceiver success{1s};
const unsigned int port = port_from_uri(success.redirect_uri());
require(!can_connect_ipv6(port), "loopback receiver unexpectedly listens on IPv6");
const auto accepted =
valid_exchange(success, "code=abc%2Bdef&state=state+with+spaces&session_state=ignored");
require(accepted.callback.code == "abc+def" && accepted.callback.state == "state with spaces",
"successful callback query was not decoded correctly");
require(accepted.callback.error.empty() && accepted.callback.error_description.empty(),
"unknown callback field contaminated known fields");
require_success_response(accepted.response);
LoopbackCallbackReceiver denied{1s};
const auto rejected = valid_exchange(denied,
"error=access_denied&error_description=User+cancelled&state=state-2&unknown=value");
require(rejected.callback.code.empty() && rejected.callback.error == "access_denied"
&& rejected.callback.error_description == "User cancelled"
&& rejected.callback.state == "state-2",
"authorization error callback was not decoded correctly");
require_success_response(rejected.response);
LoopbackCallbackReceiver uppercase_host{1s};
const unsigned int uppercase_port = port_from_uri(uppercase_host.redirect_uri());
Descriptor uppercase_client = connect_ipv4(uppercase_port);
send_all(uppercase_client.get(),
"GET /nocal/oauth/callback?code=upper&state=case HTTP/1.1\r\nhOsT: LOCALHOST:"
+ std::to_string(uppercase_port) + "\r\n\r\n");
const auto uppercase_callback = uppercase_host.receive();
require(uppercase_callback.code == "upper" && uppercase_callback.state == "case",
"ASCII-case-insensitive localhost Host was rejected");
require_success_response(read_response(uppercase_client.get()));
}
void test_invalid_http_matrix() {
auto run = [](const std::function<std::string(unsigned int)>& build) {
LoopbackCallbackReceiver receiver{1s};
const unsigned int port = port_from_uri(receiver.redirect_uri());
const std::string response = invalid_exchange(receiver, build(port));
require_failure_response(response);
require(!can_connect_ipv4(port), "invalid callback left listener open");
};
run([](unsigned int port) {
return request_for(port, "/nocal/oauth/callback?code=x&state=y", "POST");
});
run([](unsigned int port) {
return request_for(port, "/wrong?code=x&state=y");
});
run([](unsigned int port) {
return "GET /nocal/oauth/callback?code=x&state=y HTTP/1.0\r\nHost: localhost:"
+ std::to_string(port) + "\r\n\r\n";
});
run([](unsigned int) {
return std::string{"GET /nocal/oauth/callback?code=x&state=y HTTP/1.1\r\n\r\n"};
});
run([](unsigned int port) {
return "GET /nocal/oauth/callback?code=x&state=y HTTP/1.1\r\nHost: 127.0.0.1:"
+ std::to_string(port) + "\r\n\r\n";
});
run([](unsigned int port) {
return "GET /nocal/oauth/callback?code=x&state=y HTTP/1.1\r\nHost: localhost.:"
+ std::to_string(port) + "\r\n\r\n";
});
run([](unsigned int port) {
return "GET /nocal/oauth/callback?code=x&state=y HTTP/1.1\r\nHost: localhost.evil:"
+ std::to_string(port) + "\r\n\r\n";
});
run([](unsigned int port) {
return request_for(port, "/nocal/oauth/callback?code=x&state=y",
"GET", "Host: localhost:" + std::to_string(port) + "\r\n");
});
run([](unsigned int port) {
return request_for(port, "/nocal/oauth/callback?code=x&state=y", "GET", "Broken\r\n");
});
run([](unsigned int port) {
return request_for(port, "/nocal/oauth/callback?code=x&state=y", "GET",
std::string{"X-"} + "\xC3\xA9" + ": value\r\n");
});
run([](unsigned int port) {
return request_for(port, "/nocal/oauth/callback?code=x&state=y", "GET",
"Transfer-Encoding: chunked\r\n");
});
run([](unsigned int port) {
return request_for(port, "/nocal/oauth/callback?code=x&state=y", "GET",
"Content-Length: 1\r\n")
+ "x";
});
run([](unsigned int port) {
return request_for(
port, "/nocal/oauth/callback?code=SENSITIVE_CALLBACK&state=y&state=z");
});
run([](unsigned int port) {
return request_for(port, "/nocal/oauth/callback?code=%GG&state=y");
});
run([](unsigned int port) {
return request_for(port, "/nocal/oauth/callback?code=%00&state=y");
});
run([](unsigned int port) {
return request_for(port, "/nocal/oauth/callback?code=%1F&state=y");
});
run([](unsigned int port) {
return request_for(port, "/nocal/oauth/callback?code&state=y");
});
run([](unsigned int port) {
std::string target = "/nocal/oauth/callback?code=x&state=y";
target.push_back('\0');
return request_for(port, target);
});
run([](unsigned int port) {
return request_for(port, "/nocal/oauth/callback?code=x&state=y", "GET",
"X-Large: " + std::string(17U * 1024U, 'a') + "\r\n");
});
run([](unsigned int port) {
return "GET /nocal/oauth/callback?code=x&state=y HTTP/1.1\nHost: localhost:"
+ std::to_string(port) + "\n\n";
});
}
void test_timeout_single_use_and_cleanup() {
require_throws([] { LoopbackCallbackReceiver invalid{0ms}; },
"zero loopback timeout was accepted");
require_throws([] { LoopbackCallbackReceiver invalid{-1ms}; },
"negative loopback timeout was accepted");
LoopbackCallbackReceiver timed{20ms};
const unsigned int timed_port = port_from_uri(timed.redirect_uri());
require_throws([&] { static_cast<void>(timed.receive()); },
"loopback receiver did not time out");
require(!can_connect_ipv4(timed_port), "timed-out receiver left listener open");
require_throws([&] { static_cast<void>(timed.receive()); },
"timed-out receiver was reusable");
LoopbackCallbackReceiver partial{20ms};
const unsigned int partial_port = port_from_uri(partial.redirect_uri());
Descriptor partial_client = connect_ipv4(partial_port);
send_bytes(partial_client.get(),
"GET /nocal/oauth/callback?code=x&state=y HTTP/1.1\r\nHost: localhost:");
require_throws([&] { static_cast<void>(partial.receive()); },
"partial callback did not obey the total deadline");
require_failure_response(read_response(partial_client.get()));
require(!can_connect_ipv4(partial_port), "partial timeout left listener open");
LoopbackCallbackReceiver used{1s};
const unsigned int used_port = port_from_uri(used.redirect_uri());
static_cast<void>(valid_exchange(used, "code=x&state=y"));
require_throws([&] { static_cast<void>(used.receive()); },
"completed receiver was reusable");
require(!can_connect_ipv4(used_port), "completed receiver left listener open");
unsigned int destroyed_port = 0;
{
LoopbackCallbackReceiver destroyed{1s};
destroyed_port = port_from_uri(destroyed.redirect_uri());
}
require(!can_connect_ipv4(destroyed_port), "destroyed receiver left listener open");
}
void test_move_safety_and_cleanup() {
LoopbackCallbackReceiver source{1s};
const std::string uri = source.redirect_uri();
LoopbackCallbackReceiver moved{std::move(source)};
require(moved.redirect_uri() == uri, "move construction changed redirect URI");
require_throws([&] { static_cast<void>(source.redirect_uri()); },
"moved-from receiver remained usable");
static_cast<void>(valid_exchange(moved, "code=moved&state=state"));
LoopbackCallbackReceiver assignment_source{1s};
const std::string assigned_uri = assignment_source.redirect_uri();
LoopbackCallbackReceiver assignment_target{1s};
const unsigned int replaced_port = port_from_uri(assignment_target.redirect_uri());
assignment_target = std::move(assignment_source);
require(assignment_target.redirect_uri() == assigned_uri,
"move assignment changed redirect URI");
require(!can_connect_ipv4(replaced_port),
"move assignment did not close the replaced listener");
require_throws([&] { static_cast<void>(assignment_source.redirect_uri()); },
"move-assigned source remained usable");
static_cast<void>(valid_exchange(assignment_target, "error=denied&state=state"));
}
} // namespace
int main(int argc, char** argv) {
if (argc >= 2 && std::string_view{argv[1]}.starts_with("browser-helper:")) {
return run_browser_helper(argc, argv);
}
try {
TempDirectory temporary;
test_browser_literal_argv_and_failures(temporary.path());
test_redirect_and_parsed_callbacks();
test_invalid_http_matrix();
test_timeout_single_use_and_cleanup();
test_move_safety_and_cleanup();
} catch (const std::exception& error) {
std::cerr << "desktop OAuth test failure: " << error.what() << '\n';
return 1;
}
std::cout << "desktop OAuth tests passed\n";
return 0;
}