feat: add PKCE OAuth boundary

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.
This commit is contained in:
2026-07-18 11:11:28 +01:00
parent 787daf00dd
commit dd6e02cb55
11 changed files with 1572 additions and 25 deletions

View File

@@ -120,9 +120,23 @@ committed atomically with the pulled page it represents. Normalized event
instances provide immutable snapshot input, while transactional outbox and
conflict records are reserved for later write synchronization.
This is a persistence prerequisite, not working Office 365 integration. Nocal
does not yet perform OAuth, make Graph requests, or synchronize a remote
calendar. OAuth tokens and other secrets will never be stored in SQLite; they
belong in the Freedesktop Secret Service. The next implementation step is an
injectable HTTP boundary with PKCE authentication and deterministic fake
transports, followed by read-only Microsoft Graph delta synchronization.
The provider boundary models Microsoft sign-in as a public desktop client
using the system browser, the OAuth authorization-code flow, PKCE with `S256`,
and a loopback redirect. Nocal's application registration must be multitenant,
with the intended organizational/personal Microsoft account audience, and the
first read-only synchronization will request only delegated `Calendars.Read`.
This permission normally supports user consent, but an organization's tenant
policy can still require administrator consent or block user consent. An
open-source desktop executable cannot keep a client secret, so none is embedded
or expected.
The generic HTTP boundary uses injected transports and deterministic fakes. It
does not retry requests: provider code must later make retry decisions with the
operation's idempotency and server response in view. This remains foundation,
not working Office 365 integration. There is no concrete HTTP transport,
browser/loopback adapter, token JSON parser, Secret Service backend, Graph call,
or usable account setup yet. OAuth tokens and other secrets will never be
stored in SQLite; they belong in the Freedesktop Secret Service. The next
implementation sequence is concrete desktop, HTTP, and Secret Service adapters,
followed by read-only Microsoft Graph calendar discovery and delta
synchronization.

View File

@@ -164,9 +164,29 @@ Conflict resolution is deterministic: unchanged side wins; otherwise retain
both versions and mark a conflict for the user. Network work happens outside the
render loop and publishes immutable snapshots to it.
The first provider-facing step after the cache is verified is an injectable HTTP
boundary, browser-based authorization-code flow with PKCE, and fake transports
for deterministic authentication and failure tests. Read-only Microsoft Graph
calendar discovery and delta synchronization follows. Graph delta links remain
opaque and are persisted only through the calendar/window transaction described
above; remote writes and conflict resolution come later.
### HTTP and OAuth boundary
Provider code depends on generic request/response and injected transport
interfaces rather than a concrete networking library. Deterministic fakes make
success, protocol failure, transport failure, cancellation, and malformed input
testable without network access. The generic layer performs no automatic
retries: only a provider knows whether an operation is idempotent and how to
interpret service-specific throttling or retry guidance.
Microsoft authentication is designed for a public desktop client. It uses the
system browser and authorization-code flow, a fresh PKCE verifier and `S256`
challenge, and a loopback redirect. The Entra application registration is
multitenant and selects the intended organizational and personal Microsoft
account audience. The initial read-only Graph scope is the delegated
`Calendars.Read` permission. It normally permits user consent, while tenant
consent policy remains authoritative and may require administrator approval or
block consent. A desktop binary distributed as open source is not a confidential
client and must not embed or depend on a client secret.
This boundary does not yet contain a concrete HTTP transport, system-browser or
loopback-listener adapter, token JSON parsing, a Secret Service backend, Graph
requests, or usable account setup. Those concrete desktop, HTTP, and secret
storage adapters are the next slice. Read-only Microsoft Graph calendar
discovery and delta synchronization follows; delta links remain opaque and are
persisted only through the calendar/window transaction described above. Remote
writes and conflict resolution come later.

View File

@@ -129,11 +129,25 @@ offline writes, but remote mutation behavior is not part of this phase. OAuth
tokens and other credentials never belong in the calendar database; account
secrets will be held by the Freedesktop Secret Service.
This foundation does not make Office 365 usable yet: Nocal currently performs no
OAuth flow, HTTP request, or Microsoft Graph synchronization. The next product
slice introduces injectable HTTP and PKCE authentication with fake transports,
then adds read-only Graph delta synchronization. Remote writes remain gated on
the durability and conflict paths being exercised end to end.
The Microsoft sign-in design treats Nocal as a public desktop client: sign-in
will open the system browser, use the authorization-code flow with PKCE `S256`,
and return through a loopback redirect. Its application registration must be
multitenant for the intended organizational and personal Microsoft account
audience. Initial synchronization requests only delegated `Calendars.Read`, not
write access. Users can normally consent to that delegated permission, but
their organization's tenant policy may still require administrator consent or
deny the application. Nocal never embeds a client secret in its open-source
desktop binary.
The provider-neutral HTTP seam accepts an injected transport and can therefore
be exercised with deterministic fakes. It intentionally owns no generic retry
policy; provider operations must decide whether a retry is safe. This phase does
not make Office 365 usable yet. It includes no concrete HTTP transport, real
browser/loopback adapter, token JSON parser, Secret Service backend, Graph call,
or usable account setup. The next product slice supplies concrete desktop,
network, and secret-storage adapters, followed by read-only Graph calendar
discovery and delta synchronization. Remote writes remain gated on the
durability and conflict paths being exercised end to end.
## Local file interchange

View File

@@ -35,10 +35,21 @@ Completed cache foundation:
- Normalized event instances for immutable snapshots
- Reserved transactional outbox and conflict records for later remote writes
Completed HTTP/OAuth boundary:
- Generic injected HTTP transport and deterministic fakes, with retries left to
provider operations
- Public-desktop OAuth authorization code design with PKCE `S256`, system
browser, and loopback redirect
- Multitenant Microsoft account audience and delegated least-privilege
`Calendars.Read`; tenant policy may still require administrator consent or
block user consent
- No client secret in the open-source desktop binary
Next provider-boundary slices:
- Injectable HTTP, PKCE authentication, and deterministic fake transports
- Secret Service credentials; OAuth secrets are never stored in SQLite
- Concrete HTTP, browser/loopback, token JSON, and Secret Service adapters
- OAuth secrets in Secret Service, never SQLite
- Generic `SyncProvider` contract and observable sync status
## 0.3 — CalDAV
@@ -54,10 +65,12 @@ Next provider-boundary slices:
ETag-aware remote writes after the conflict boundary is proven
- Account/calendar management UI and per-calendar ANSI identity
The current cache work is only the prerequisite durable boundary. No network,
OAuth, or Graph synchronization is implemented in that phase. After the cache
passes its verification gates, the immediate sequence is injectable HTTP plus
PKCE and fake transports, then read-only Microsoft Graph delta synchronization.
The cache and generic HTTP/OAuth boundaries are prerequisites, not usable
Microsoft 365 integration. The boundary phase has no concrete HTTP transport,
real browser/loopback adapter, token parser, Secret Service backend, Graph call,
or account setup. The immediate sequence is concrete desktop, HTTP, and Secret
Service adapters, then read-only Microsoft Graph discovery and delta
synchronization.
## 1.0 — distribution quality

View File

@@ -0,0 +1,43 @@
#pragma once
#include <string>
#include <vector>
namespace nocal::sync {
enum class HttpMethod { get, post, put, patch, delete_ };
struct HttpHeader {
std::string name;
std::string value;
bool operator==(const HttpHeader&) const = default;
};
struct HttpRequest {
HttpMethod method{HttpMethod::get};
std::string url;
std::vector<HttpHeader> headers;
std::string body;
bool operator==(const HttpRequest&) const = default;
};
struct HttpResponse {
int status{0};
std::vector<HttpHeader> headers;
std::string body;
bool operator==(const HttpResponse&) const = default;
};
class HttpTransport {
public:
virtual ~HttpTransport() = default;
// Performs exactly one request. Retry policy belongs to the caller because
// only the caller knows whether replay is safe.
virtual HttpResponse send(const HttpRequest& request) = 0;
};
} // namespace nocal::sync

View File

@@ -0,0 +1,82 @@
#pragma once
#include "nocal/sync/http.hpp"
#include <cstddef>
#include <span>
#include <stdexcept>
#include <string>
#include <vector>
namespace nocal::sync {
struct OAuthClientConfig {
std::string client_id;
std::string authorization_endpoint;
std::string token_endpoint;
std::vector<std::string> scopes;
};
struct AuthorizationCallback {
std::string code;
std::string state;
std::string error;
std::string error_description;
};
class OAuthError : public std::runtime_error {
public:
enum class Kind {
invalid_configuration,
invalid_redirect,
invalid_callback,
state_mismatch,
authorization_rejected,
token_endpoint_rejected,
cryptographic_failure,
};
OAuthError(Kind kind, std::string message);
[[nodiscard]] Kind kind() const noexcept;
private:
Kind kind_;
};
class EntropySource {
public:
virtual ~EntropySource() = default;
virtual void fill(std::span<std::byte> output) = 0;
};
class SystemEntropySource final : public EntropySource {
public:
void fill(std::span<std::byte> output) override;
};
class BrowserLauncher {
public:
virtual ~BrowserLauncher() = default;
virtual void open(const std::string& url) = 0;
};
class AuthorizationCallbackReceiver {
public:
virtual ~AuthorizationCallbackReceiver() = default;
// The receiver chooses and starts its loopback endpoint before returning
// this URI. receive() owns timeout and socket lifecycle behavior.
[[nodiscard]] virtual std::string redirect_uri() const = 0;
virtual AuthorizationCallback receive() = 0;
};
// Runs one public-client authorization-code attempt with PKCE S256. It opens
// one browser URL, accepts one callback, and performs at most one token request.
// The opaque response body is intentionally left for a provider adapter to
// parse. This function does not persist credentials or retry requests.
[[nodiscard]] HttpResponse authorize_with_pkce(const OAuthClientConfig& config,
BrowserLauncher& browser, AuthorizationCallbackReceiver& receiver,
HttpTransport& transport, EntropySource& entropy);
} // namespace nocal::sync

View File

@@ -13,6 +13,7 @@ project(
inc = include_directories('include')
sqlite = dependency('sqlite3')
libcrypto = dependency('libcrypto')
threads = dependency('threads')
nocal_sources = files(
'src/domain/calendar_transfer.cpp',
@@ -21,15 +22,16 @@ nocal_sources = files(
'src/domain/month_layout.cpp',
'src/storage/ics_store.cpp',
'src/storage/sync_cache.cpp',
'src/sync/oauth.cpp',
'src/tui/Screen.cpp',
'src/tui/EventEditor.cpp',
'src/tui/Terminal.cpp',
'src/tui/TuiApp.cpp',
)
nocal_lib = static_library('nocal-core', nocal_sources, include_directories: inc,
dependencies: sqlite)
dependencies: [sqlite, libcrypto])
nocal_dep = declare_dependency(include_directories: inc, link_with: nocal_lib,
dependencies: sqlite)
dependencies: [sqlite, libcrypto])
nocal_executable = executable('nocal', 'src/main.cpp',
dependencies: nocal_dep, install: true)
@@ -46,6 +48,12 @@ test('ics-storage', ics_tests)
sync_cache_tests = executable('sync-cache-tests', 'tests/sync_cache_tests.cpp',
dependencies: [nocal_dep, threads])
test('sync-cache', sync_cache_tests)
oauth_tests = executable('oauth-tests', 'tests/oauth_tests.cpp',
dependencies: nocal_dep)
test('oauth', oauth_tests)
oauth_security_tests = executable('oauth-security-tests',
'tests/oauth_security_tests.cpp', dependencies: nocal_dep)
test('oauth-security', oauth_security_tests)
tui_tests = executable('tui-tests', 'tests/tui_tests.cpp',
dependencies: nocal_dep)
test('tui', tui_tests)

View File

@@ -9,6 +9,7 @@ pkgs.mkShell {
buildInputs = with pkgs; [
gcc
openssl
sqlite
];
}

372
src/sync/oauth.cpp Normal file
View File

@@ -0,0 +1,372 @@
#include "nocal/sync/oauth.hpp"
#include <openssl/evp.h>
#include <openssl/rand.h>
#include <algorithm>
#include <array>
#include <cstddef>
#include <cstdint>
#include <limits>
#include <stdexcept>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
namespace nocal::sync {
namespace {
[[nodiscard]] bool ascii_iequals(std::string_view left, std::string_view right) {
if (left.size() != right.size()) {
return false;
}
for (std::size_t index = 0; index < left.size(); ++index) {
const auto lower = [](unsigned char character) {
return character >= 'A' && character <= 'Z'
? static_cast<unsigned char>(character + ('a' - 'A'))
: character;
};
if (lower(static_cast<unsigned char>(left[index]))
!= lower(static_cast<unsigned char>(right[index]))) {
return false;
}
}
return true;
}
[[nodiscard]] bool contains_unsafe_url_character(std::string_view value) {
return std::any_of(value.begin(), value.end(), [](unsigned char character) {
return character <= 0x20U || character == 0x7fU || character == '\\';
});
}
struct Authority {
std::string_view host;
std::string_view port;
bool has_port{false};
};
[[nodiscard]] bool valid_port(std::string_view value) {
if (value.empty() || value.size() > 5) {
return false;
}
unsigned int port = 0;
for (const unsigned char character : value) {
if (character < '0' || character > '9') {
return false;
}
port = port * 10U + static_cast<unsigned int>(character - '0');
}
return port > 0U && port <= 65'535U;
}
[[nodiscard]] bool parse_authority(std::string_view value, Authority& result) {
if (value.empty() || value.find('@') != std::string_view::npos) {
return false;
}
if (value.front() == '[') {
const std::size_t close = value.find(']');
if (close == std::string_view::npos) {
return false;
}
result.host = value.substr(0, close + 1);
const std::string_view remainder = value.substr(close + 1);
if (remainder.empty()) {
return true;
}
if (remainder.front() != ':') {
return false;
}
result.has_port = true;
result.port = remainder.substr(1);
return valid_port(result.port);
}
const std::size_t colon = value.rfind(':');
if (colon == std::string_view::npos) {
result.host = value;
return !result.host.empty();
}
if (value.find(':') != colon) {
return false;
}
result.host = value.substr(0, colon);
result.port = value.substr(colon + 1);
result.has_port = true;
return !result.host.empty() && valid_port(result.port);
}
[[nodiscard]] bool valid_https_endpoint(std::string_view value) {
if (value.empty() || contains_unsafe_url_character(value)
|| value.find_first_of("?#") != std::string_view::npos) {
return false;
}
const std::size_t scheme_end = value.find("://");
if (scheme_end == std::string_view::npos
|| !ascii_iequals(value.substr(0, scheme_end), "https")) {
return false;
}
const std::size_t authority_start = scheme_end + 3;
const std::size_t authority_end = value.find('/', authority_start);
const std::string_view authority = value.substr(authority_start,
authority_end == std::string_view::npos ? std::string_view::npos
: authority_end - authority_start);
Authority parsed;
return parse_authority(authority, parsed) && !parsed.host.empty();
}
[[nodiscard]] bool valid_loopback_redirect(std::string_view value) {
if (value.empty() || contains_unsafe_url_character(value)
|| value.find_first_of("?#") != std::string_view::npos) {
return false;
}
const std::size_t scheme_end = value.find("://");
if (scheme_end == std::string_view::npos
|| !ascii_iequals(value.substr(0, scheme_end), "http")) {
return false;
}
const std::size_t authority_start = scheme_end + 3;
const std::size_t authority_end = value.find('/', authority_start);
const std::string_view authority = value.substr(authority_start,
authority_end == std::string_view::npos ? std::string_view::npos
: authority_end - authority_start);
Authority parsed;
if (!parse_authority(authority, parsed)) {
return false;
}
return ascii_iequals(parsed.host, "localhost") || parsed.host == "127.0.0.1"
|| parsed.host == "[::1]";
}
[[nodiscard]] std::string base64url(std::span<const std::byte> input) {
static constexpr std::string_view alphabet =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
std::string result;
result.reserve((input.size() * 4U + 2U) / 3U);
std::size_t index = 0;
while (index + 3 <= input.size()) {
const auto first = std::to_integer<unsigned int>(input[index]);
const auto second = std::to_integer<unsigned int>(input[index + 1]);
const auto third = std::to_integer<unsigned int>(input[index + 2]);
result.push_back(alphabet[(first >> 2U) & 0x3fU]);
result.push_back(alphabet[((first << 4U) | (second >> 4U)) & 0x3fU]);
result.push_back(alphabet[((second << 2U) | (third >> 6U)) & 0x3fU]);
result.push_back(alphabet[third & 0x3fU]);
index += 3;
}
const std::size_t remaining = input.size() - index;
if (remaining == 1) {
const auto first = std::to_integer<unsigned int>(input[index]);
result.push_back(alphabet[(first >> 2U) & 0x3fU]);
result.push_back(alphabet[(first << 4U) & 0x3fU]);
} else if (remaining == 2) {
const auto first = std::to_integer<unsigned int>(input[index]);
const auto second = std::to_integer<unsigned int>(input[index + 1]);
result.push_back(alphabet[(first >> 2U) & 0x3fU]);
result.push_back(alphabet[((first << 4U) | (second >> 4U)) & 0x3fU]);
result.push_back(alphabet[(second << 2U) & 0x3fU]);
}
return result;
}
[[nodiscard]] std::string percent_encode(std::string_view value) {
static constexpr char hex[] = "0123456789ABCDEF";
std::string result;
result.reserve(value.size());
for (const unsigned char character : value) {
const bool ascii_alphanumeric = (character >= 'A' && character <= 'Z')
|| (character >= 'a' && character <= 'z')
|| (character >= '0' && character <= '9');
if (ascii_alphanumeric || character == '-' || character == '.' || character == '_'
|| character == '~') {
result.push_back(static_cast<char>(character));
} else {
result.push_back('%');
result.push_back(hex[character >> 4U]);
result.push_back(hex[character & 0x0fU]);
}
}
return result;
}
[[nodiscard]] std::string join_scopes(const std::vector<std::string>& scopes) {
std::string result;
for (const std::string& scope : scopes) {
if (!result.empty()) {
result.push_back(' ');
}
result += scope;
}
return result;
}
void append_parameter(
std::string& output, bool& first, std::string_view name, std::string_view value) {
if (!first) {
output.push_back('&');
}
first = false;
output += name;
output.push_back('=');
output += percent_encode(value);
}
[[nodiscard]] std::string authorization_url(const OAuthClientConfig& config,
std::string_view redirect_uri, std::string_view state, std::string_view challenge) {
std::string result = config.authorization_endpoint;
bool first = result.find('?') == std::string::npos;
if (first) {
result.push_back('?');
} else if (!result.ends_with('?') && !result.ends_with('&')) {
result.push_back('&');
}
append_parameter(result, first, "response_type", "code");
append_parameter(result, first, "client_id", config.client_id);
append_parameter(result, first, "redirect_uri", redirect_uri);
append_parameter(result, first, "scope", join_scopes(config.scopes));
append_parameter(result, first, "state", state);
append_parameter(result, first, "code_challenge", challenge);
append_parameter(result, first, "code_challenge_method", "S256");
return result;
}
[[nodiscard]] std::string token_body(const OAuthClientConfig& config,
std::string_view redirect_uri, std::string_view code, std::string_view verifier) {
std::string result;
bool first = true;
append_parameter(result, first, "grant_type", "authorization_code");
append_parameter(result, first, "client_id", config.client_id);
append_parameter(result, first, "code", code);
append_parameter(result, first, "redirect_uri", redirect_uri);
append_parameter(result, first, "code_verifier", verifier);
return result;
}
[[nodiscard]] bool constant_time_equal(std::string_view left, std::string_view right) {
const std::size_t count = std::max(left.size(), right.size());
std::size_t difference = left.size() ^ right.size();
for (std::size_t index = 0; index < count; ++index) {
const unsigned char lhs = index < left.size() ? static_cast<unsigned char>(left[index]) : 0U;
const unsigned char rhs = index < right.size() ? static_cast<unsigned char>(right[index]) : 0U;
difference |= static_cast<std::size_t>(lhs ^ rhs);
}
return difference == 0;
}
[[nodiscard]] std::array<std::byte, 32> entropy_bytes(EntropySource& entropy) {
std::array<std::byte, 32> output{};
try {
entropy.fill(output);
} catch (const OAuthError& error) {
if (error.kind() == OAuthError::Kind::cryptographic_failure) {
throw;
}
throw OAuthError(
OAuthError::Kind::cryptographic_failure, "unable to generate OAuth entropy");
} catch (const std::exception&) {
throw OAuthError(
OAuthError::Kind::cryptographic_failure, "unable to generate OAuth entropy");
} catch (...) {
throw OAuthError(
OAuthError::Kind::cryptographic_failure, "unable to generate OAuth entropy");
}
return output;
}
[[nodiscard]] std::array<std::byte, 32> sha256(std::string_view input) {
std::array<std::byte, 32> digest{};
unsigned int size = 0;
if (EVP_Digest(input.data(), input.size(),
reinterpret_cast<unsigned char*>(digest.data()), &size, EVP_sha256(), nullptr)
!= 1
|| size != digest.size()) {
throw OAuthError(
OAuthError::Kind::cryptographic_failure, "unable to compute PKCE challenge");
}
return digest;
}
void validate_config(const OAuthClientConfig& config) {
if (config.client_id.empty() || config.scopes.empty()
|| std::any_of(config.scopes.begin(), config.scopes.end(),
[](const std::string& scope) {
return scope.empty()
|| std::any_of(scope.begin(), scope.end(), [](unsigned char character) {
return character != 0x21U
&& !(character >= 0x23U && character <= 0x5bU)
&& !(character >= 0x5dU && character <= 0x7eU);
});
})
|| !valid_https_endpoint(config.authorization_endpoint)
|| !valid_https_endpoint(config.token_endpoint)) {
throw OAuthError(
OAuthError::Kind::invalid_configuration, "invalid OAuth client configuration");
}
}
} // namespace
OAuthError::OAuthError(Kind kind, std::string message)
: std::runtime_error(std::move(message)), kind_(kind) {}
OAuthError::Kind OAuthError::kind() const noexcept { return kind_; }
void SystemEntropySource::fill(std::span<std::byte> output) {
std::size_t offset = 0;
while (offset < output.size()) {
const std::size_t remaining = output.size() - offset;
const int count = static_cast<int>(
std::min(remaining, static_cast<std::size_t>(std::numeric_limits<int>::max())));
if (RAND_bytes(reinterpret_cast<unsigned char*>(output.data() + offset), count) != 1) {
throw OAuthError(
OAuthError::Kind::cryptographic_failure, "unable to generate OAuth entropy");
}
offset += static_cast<std::size_t>(count);
}
}
HttpResponse authorize_with_pkce(const OAuthClientConfig& config, BrowserLauncher& browser,
AuthorizationCallbackReceiver& receiver, HttpTransport& transport, EntropySource& entropy) {
validate_config(config);
const std::string redirect_uri = receiver.redirect_uri();
if (!valid_loopback_redirect(redirect_uri)) {
throw OAuthError(OAuthError::Kind::invalid_redirect, "invalid OAuth loopback redirect");
}
const std::string state = base64url(entropy_bytes(entropy));
const std::string verifier = base64url(entropy_bytes(entropy));
const std::string challenge = base64url(sha256(verifier));
browser.open(authorization_url(config, redirect_uri, state, challenge));
const AuthorizationCallback callback = receiver.receive();
const bool success = !callback.code.empty() && callback.error.empty()
&& callback.error_description.empty() && !callback.state.empty();
const bool rejected = callback.code.empty() && !callback.error.empty()
&& !callback.state.empty();
if (!success && !rejected) {
throw OAuthError(OAuthError::Kind::invalid_callback, "invalid OAuth callback");
}
if (!constant_time_equal(callback.state, state)) {
throw OAuthError(OAuthError::Kind::state_mismatch, "OAuth callback state mismatch");
}
if (rejected) {
throw OAuthError(
OAuthError::Kind::authorization_rejected, "authorization server rejected request");
}
HttpRequest request;
request.method = HttpMethod::post;
request.url = config.token_endpoint;
request.headers = {{"Content-Type", "application/x-www-form-urlencoded"},
{"Accept", "application/json"}};
request.body = token_body(config, redirect_uri, callback.code, verifier);
HttpResponse response = transport.send(request);
if (response.status < 200 || response.status >= 300) {
throw OAuthError(
OAuthError::Kind::token_endpoint_rejected, "OAuth token endpoint rejected request");
}
return response;
}
} // namespace nocal::sync

View File

@@ -0,0 +1,575 @@
#include "nocal/sync/oauth.hpp"
#include <openssl/evp.h>
#include <algorithm>
#include <array>
#include <cctype>
#include <cstddef>
#include <cstdint>
#include <exception>
#include <functional>
#include <iostream>
#include <map>
#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});
}
}
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});
}
template <typename Function>
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<char>((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<std::string, std::string>;
[[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<const unsigned char> 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<unsigned char*>(encoded.data()),
input.data(), static_cast<int>(input.size()));
require(result >= 0, "OpenSSL base64 encoding failed in test");
encoded.resize(static_cast<std::size_t>(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<unsigned char, 32> 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<std::byte> 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<unsigned char>(
call == 0 || identical
? (0x11U + index * 3U) & 0xffU
: (0xd7U - index * 5U) & 0xffU);
output[index] = static_cast<std::byte>(value);
}
}
bool identical{false};
std::vector<std::size_t> 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<std::string> 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<HttpRequest> 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<std::size_t>{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<std::size_t>{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<void>(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<void>(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<void>(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<void>(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<void>(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<AuthorizationCallback> malformed{
{"", "", "", ""},
{"code", "", "error", "description"},
{"", "", "", "description-without-error"},
};
for (const auto& callback : malformed) {
Rig rig;
rig.receiver.callback = callback;
require_oauth_error([&] { static_cast<void>(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<void>(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<void>(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<void>(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<std::string> 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<void>(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<std::string> 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<void>(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<std::string> 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<std::string> 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<void>(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;
}

405
tests/oauth_tests.cpp Normal file
View File

@@ -0,0 +1,405 @@
#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;
}