Introduce provider-neutral HTTP contracts and public-client authorization-code orchestration with PKCE S256, strict HTTPS and loopback validation, constant-time state checks, secure OpenSSL entropy, and single-send token exchange semantics. Add independent protocol and adversarial suites covering RFC 7636, RFC3986 encoding, callback failures, response redaction, and no-retry behavior. Concrete networking, browser/listener, token parsing, Secret Service, and Graph integration remain separate follow-up slices.
406 lines
16 KiB
C++
406 lines
16 KiB
C++
#include "nocal/sync/oauth.hpp"
|
|
|
|
#include <algorithm>
|
|
#include <array>
|
|
#include <cstddef>
|
|
#include <exception>
|
|
#include <iostream>
|
|
#include <span>
|
|
#include <stdexcept>
|
|
#include <string>
|
|
#include <string_view>
|
|
#include <utility>
|
|
#include <vector>
|
|
|
|
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<std::array<std::byte, 32>> scripted_values)
|
|
: values(std::move(scripted_values)) {}
|
|
|
|
std::vector<std::array<std::byte, 32>> values;
|
|
std::size_t calls{0};
|
|
std::size_t fail_at{static_cast<std::size_t>(-1)};
|
|
|
|
void fill(std::span<std::byte> 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<std::string> 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<HttpRequest> 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<std::byte, 32> 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<std::byte, 32> decode_verifier(std::string_view value) {
|
|
std::vector<std::byte> decoded;
|
|
unsigned int accumulator = 0;
|
|
unsigned int bits = 0;
|
|
for (const char character : value) {
|
|
accumulator = (accumulator << 6U) | static_cast<unsigned int>(base64url_digit(character));
|
|
bits += 6U;
|
|
if (bits >= 8U) {
|
|
bits -= 8U;
|
|
decoded.push_back(static_cast<std::byte>((accumulator >> bits) & 0xffU));
|
|
}
|
|
}
|
|
require(decoded.size() == 32, "RFC verifier fixture did not decode to 32 bytes");
|
|
std::array<std::byte, 32> result{};
|
|
std::copy(decoded.begin(), decoded.end(), result.begin());
|
|
return result;
|
|
}
|
|
|
|
[[nodiscard]] std::string deterministic_state() {
|
|
return "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
|
}
|
|
|
|
template <typename Action>
|
|
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<nocal::sync::HttpHeader>{{"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<OAuthClientConfig> 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<std::string> 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<std::string> 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<AuthorizationCallback> 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<AuthorizationCallback> 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;
|
|
}
|