From dd6e02cb555090259d04501d54cc9bfd4602c511 Mon Sep 17 00:00:00 2001 From: Bernardo Magri Date: Sat, 18 Jul 2026 11:11:28 +0100 Subject: [PATCH] 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. --- README.md | 26 +- docs/ARCHITECTURE.md | 32 +- docs/PRODUCT.md | 24 +- docs/ROADMAP.md | 25 +- include/nocal/sync/http.hpp | 43 +++ include/nocal/sync/oauth.hpp | 82 +++++ meson.build | 12 +- shell.nix | 1 + src/sync/oauth.cpp | 372 +++++++++++++++++++++ tests/oauth_security_tests.cpp | 575 +++++++++++++++++++++++++++++++++ tests/oauth_tests.cpp | 405 +++++++++++++++++++++++ 11 files changed, 1572 insertions(+), 25 deletions(-) create mode 100644 include/nocal/sync/http.hpp create mode 100644 include/nocal/sync/oauth.hpp create mode 100644 src/sync/oauth.cpp create mode 100644 tests/oauth_security_tests.cpp create mode 100644 tests/oauth_tests.cpp diff --git a/README.md b/README.md index 26c37ba..5d960d2 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 128fcc4..a7eb0ad 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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. diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index d4d9b04..4be31ec 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -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 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 92ae337..b3771ec 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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 diff --git a/include/nocal/sync/http.hpp b/include/nocal/sync/http.hpp new file mode 100644 index 0000000..cf958df --- /dev/null +++ b/include/nocal/sync/http.hpp @@ -0,0 +1,43 @@ +#pragma once + +#include +#include + +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 headers; + std::string body; + + bool operator==(const HttpRequest&) const = default; +}; + +struct HttpResponse { + int status{0}; + std::vector 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 diff --git a/include/nocal/sync/oauth.hpp b/include/nocal/sync/oauth.hpp new file mode 100644 index 0000000..a8f7a90 --- /dev/null +++ b/include/nocal/sync/oauth.hpp @@ -0,0 +1,82 @@ +#pragma once + +#include "nocal/sync/http.hpp" + +#include +#include +#include +#include +#include + +namespace nocal::sync { + +struct OAuthClientConfig { + std::string client_id; + std::string authorization_endpoint; + std::string token_endpoint; + std::vector 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 output) = 0; +}; + +class SystemEntropySource final : public EntropySource { +public: + void fill(std::span 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 diff --git a/meson.build b/meson.build index 71100b3..2259c9f 100644 --- a/meson.build +++ b/meson.build @@ -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) diff --git a/shell.nix b/shell.nix index 5cb8441..ae02d3f 100644 --- a/shell.nix +++ b/shell.nix @@ -9,6 +9,7 @@ pkgs.mkShell { buildInputs = with pkgs; [ gcc + openssl sqlite ]; } diff --git a/src/sync/oauth.cpp b/src/sync/oauth.cpp new file mode 100644 index 0000000..0ecd394 --- /dev/null +++ b/src/sync/oauth.cpp @@ -0,0 +1,372 @@ +#include "nocal/sync/oauth.hpp" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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(character + ('a' - 'A')) + : character; + }; + if (lower(static_cast(left[index])) + != lower(static_cast(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(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 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(input[index]); + const auto second = std::to_integer(input[index + 1]); + const auto third = std::to_integer(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(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(input[index]); + const auto second = std::to_integer(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(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& 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(left[index]) : 0U; + const unsigned char rhs = index < right.size() ? static_cast(right[index]) : 0U; + difference |= static_cast(lhs ^ rhs); + } + return difference == 0; +} + +[[nodiscard]] std::array entropy_bytes(EntropySource& entropy) { + std::array 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 sha256(std::string_view input) { + std::array digest{}; + unsigned int size = 0; + if (EVP_Digest(input.data(), input.size(), + reinterpret_cast(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 output) { + std::size_t offset = 0; + while (offset < output.size()) { + const std::size_t remaining = output.size() - offset; + const int count = static_cast( + std::min(remaining, static_cast(std::numeric_limits::max()))); + if (RAND_bytes(reinterpret_cast(output.data() + offset), count) != 1) { + throw OAuthError( + OAuthError::Kind::cryptographic_failure, "unable to generate OAuth entropy"); + } + offset += static_cast(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 diff --git a/tests/oauth_security_tests.cpp b/tests/oauth_security_tests.cpp new file mode 100644 index 0000000..28cdc5f --- /dev/null +++ b/tests/oauth_security_tests.cpp @@ -0,0 +1,575 @@ +#include "nocal/sync/oauth.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using nocal::sync::AuthorizationCallback; +using nocal::sync::AuthorizationCallbackReceiver; +using nocal::sync::BrowserLauncher; +using nocal::sync::EntropySource; +using nocal::sync::HttpMethod; +using nocal::sync::HttpRequest; +using nocal::sync::HttpResponse; +using nocal::sync::HttpTransport; +using nocal::sync::OAuthClientConfig; +using nocal::sync::OAuthError; +using nocal::sync::authorize_with_pkce; + +void require(bool condition, std::string_view message) { + if (!condition) { + throw std::runtime_error(std::string{message}); + } +} + +template +void require_throws(Function&& function, std::string_view message) { + try { + function(); + } catch (const std::exception&) { + return; + } + throw std::runtime_error(std::string{message}); +} + +template +void require_oauth_error( + Function&& function, OAuthError::Kind kind, std::string_view message) { + try { + function(); + } catch (const OAuthError& error) { + require(error.kind() == kind, std::string{message}.append(": wrong OAuth error kind")); + return; + } catch (const std::exception&) { + throw std::runtime_error(std::string{message}.append(": wrong exception type")); + } + throw std::runtime_error(std::string{message}.append(": no exception")); +} + +[[nodiscard]] int hexadecimal_value(char value) { + if (value >= '0' && value <= '9') { + return value - '0'; + } + if (value >= 'a' && value <= 'f') { + return value - 'a' + 10; + } + if (value >= 'A' && value <= 'F') { + return value - 'A' + 10; + } + return -1; +} + +[[nodiscard]] std::string percent_decode(std::string_view input, bool plus_is_space) { + std::string output; + output.reserve(input.size()); + for (std::size_t index = 0; index < input.size(); ++index) { + if (input[index] == '%' && index + 2 < input.size()) { + const int high = hexadecimal_value(input[index + 1]); + const int low = hexadecimal_value(input[index + 2]); + require(high >= 0 && low >= 0, "request contains malformed percent encoding"); + output.push_back(static_cast((high << 4) | low)); + index += 2; + } else if (input[index] == '+' && plus_is_space) { + output.push_back(' '); + } else { + output.push_back(input[index]); + } + } + return output; +} + +using Parameters = std::multimap; + +[[nodiscard]] Parameters parse_parameters(std::string_view encoded, bool plus_is_space) { + Parameters result; + std::size_t offset = 0; + while (offset <= encoded.size()) { + const std::size_t separator = encoded.find('&', offset); + const std::string_view field = encoded.substr( + offset, separator == std::string_view::npos ? encoded.size() - offset + : separator - offset); + const std::size_t equals = field.find('='); + const std::string_view name = field.substr(0, equals); + const std::string_view value = equals == std::string_view::npos + ? std::string_view{} + : field.substr(equals + 1); + result.emplace( + percent_decode(name, plus_is_space), percent_decode(value, plus_is_space)); + if (separator == std::string_view::npos) { + break; + } + offset = separator + 1; + } + return result; +} + +[[nodiscard]] Parameters query_parameters(const std::string& url) { + const std::size_t question = url.find('?'); + require(question != std::string::npos, "authorization URL has no query"); + const std::size_t fragment = url.find('#', question + 1); + return parse_parameters(std::string_view{url}.substr(question + 1, + fragment == std::string::npos ? std::string::npos + : fragment - question - 1), + false); +} + +[[nodiscard]] const std::string& only_value( + const Parameters& parameters, std::string_view name) { + const std::string owned_name{name}; + const auto [begin, end] = parameters.equal_range(owned_name); + require(begin != end, std::string{"missing parameter: "}.append(name)); + const auto next = std::next(begin); + require(next == end, std::string{"duplicate parameter: "}.append(name)); + return begin->second; +} + +[[nodiscard]] std::string base64url(std::span input) { + const std::size_t encoded_size = 4 * ((input.size() + 2) / 3); + std::string encoded(encoded_size, '\0'); + const int result = EVP_EncodeBlock(reinterpret_cast(encoded.data()), + input.data(), static_cast(input.size())); + require(result >= 0, "OpenSSL base64 encoding failed in test"); + encoded.resize(static_cast(result)); + std::replace(encoded.begin(), encoded.end(), '+', '-'); + std::replace(encoded.begin(), encoded.end(), '/', '_'); + while (!encoded.empty() && encoded.back() == '=') { + encoded.pop_back(); + } + return encoded; +} + +[[nodiscard]] std::string s256_challenge(std::string_view verifier) { + std::array digest{}; + unsigned int size = 0; + EVP_MD_CTX* context = EVP_MD_CTX_new(); + require(context != nullptr, "OpenSSL digest allocation failed in test"); + struct FreeContext { + EVP_MD_CTX* context; + ~FreeContext() { EVP_MD_CTX_free(context); } + } free_context{context}; + require(EVP_DigestInit_ex(context, EVP_sha256(), nullptr) == 1 + && EVP_DigestUpdate(context, verifier.data(), verifier.size()) == 1 + && EVP_DigestFinal_ex(context, digest.data(), &size) == 1 + && size == digest.size(), + "OpenSSL SHA-256 failed in test"); + return base64url(digest); +} + +class FakeEntropy final : public EntropySource { +public: + void fill(std::span output) override { + sizes.push_back(output.size()); + const std::size_t call = sizes.size() - 1; + for (std::size_t index = 0; index < output.size(); ++index) { + const auto value = static_cast( + call == 0 || identical + ? (0x11U + index * 3U) & 0xffU + : (0xd7U - index * 5U) & 0xffU); + output[index] = static_cast(value); + } + } + + bool identical{false}; + std::vector sizes; +}; + +class FakeBrowser final : public BrowserLauncher { +public: + void open(const std::string& url) override { + urls.push_back(url); + if (failure) { + throw std::runtime_error("browser launch failed"); + } + } + + bool failure{false}; + std::vector urls; +}; + +class FakeReceiver final : public AuthorizationCallbackReceiver { +public: + [[nodiscard]] std::string redirect_uri() const override { + if (redirect_failure) { + throw std::runtime_error("redirect receiver failed"); + } + return redirect; + } + + AuthorizationCallback receive() override { + ++receive_calls; + if (receive_failure) { + throw std::runtime_error("callback receiver failed"); + } + AuthorizationCallback result = callback; + if (copy_browser_state) { + require(browser != nullptr && browser->urls.size() == 1, + "test receiver could not observe authorization URL"); + result.state = only_value(query_parameters(browser->urls[0]), "state"); + } + return result; + } + + std::string redirect{"http://127.0.0.1:43127/callback"}; + AuthorizationCallback callback{"authorization-code", "", "", ""}; + FakeBrowser* browser{nullptr}; + bool copy_browser_state{true}; + bool redirect_failure{false}; + bool receive_failure{false}; + int receive_calls{0}; +}; + +class FakeTransport final : public HttpTransport { +public: + HttpResponse send(const HttpRequest& request) override { + requests.push_back(request); + if (failure) { + throw std::runtime_error("transport failed"); + } + return response; + } + + bool failure{false}; + HttpResponse response{200, {{"Content-Type", "application/json"}}, R"({"ok":true})"}; + std::vector requests; +}; + +struct Rig { + Rig() { receiver.browser = &browser; } + + OAuthClientConfig config{"client id+/%?", "https://login.example.test/oauth2/authorize", + "https://login.example.test/oauth2/token", + {"openid", "offline_access", "Calendars.ReadWrite"}}; + FakeEntropy entropy; + FakeBrowser browser; + FakeReceiver receiver; + FakeTransport transport; +}; + +[[nodiscard]] HttpResponse authorize(Rig& rig) { + return authorize_with_pkce( + rig.config, rig.browser, rig.receiver, rig.transport, rig.entropy); +} + +void require_no_token_request(const Rig& rig, std::string_view message) { + require(rig.transport.requests.empty(), message); +} + +void test_success_request_and_pkce() { + Rig rig; + rig.config.client_id = "client café +/雪"; + rig.receiver.callback.code = "code café +/雪?=&"; + rig.transport.response = + {204, {{"X-Result", "accepted"}}, R"({"access_token":"opaque"})"}; + + const HttpResponse response = authorize(rig); + require(response == rig.transport.response, "successful token response was not returned intact"); + require(rig.entropy.sizes == std::vector{32, 32}, + "PKCE attempt did not request exactly two independent 32-byte entropy fills"); + require(rig.browser.urls.size() == 1, "authorization attempt did not open exactly one URL"); + require(rig.receiver.receive_calls == 1, "authorization attempt did not receive once"); + require(rig.transport.requests.size() == 1, + "successful authorization did not send exactly one token request"); + + const Parameters authorization = query_parameters(rig.browser.urls[0]); + require(only_value(authorization, "response_type") == "code", + "authorization query did not request an authorization code"); + require(only_value(authorization, "client_id") == rig.config.client_id, + "authorization query did not RFC3986-encode the client ID"); + require(only_value(authorization, "redirect_uri") == rig.receiver.redirect, + "authorization query did not encode the loopback redirect"); + require(only_value(authorization, "code_challenge_method") == "S256", + "authorization query did not require PKCE S256"); + require(only_value(authorization, "scope") == "openid offline_access Calendars.ReadWrite", + "authorization query did not preserve scope order"); + require(rig.browser.urls[0].find("café") == std::string::npos + && rig.browser.urls[0].find("雪") == std::string::npos + && rig.browser.urls[0].find("%C3%A9") != std::string::npos + && rig.browser.urls[0].find("%E9%9B%AA") != std::string::npos, + "authorization query did not RFC3986-encode non-ASCII UTF-8 bytes"); + + const HttpRequest& token = rig.transport.requests[0]; + require(token.method == HttpMethod::post, "token exchange was not a POST"); + require(token.url == rig.config.token_endpoint, "token exchange used the wrong endpoint"); + const Parameters form = parse_parameters(token.body, false); + require(only_value(form, "grant_type") == "authorization_code", + "token request has the wrong grant type"); + require(only_value(form, "code") == rig.receiver.callback.code, + "token request did not RFC3986-encode the authorization code"); + require(only_value(form, "redirect_uri") == rig.receiver.redirect, + "token request did not encode the redirect URI"); + require(only_value(form, "client_id") == rig.config.client_id, + "token request did not RFC3986-encode the client ID"); + require(token.body.find("café") == std::string::npos + && token.body.find("雪") == std::string::npos + && token.body.find("%C3%A9") != std::string::npos + && token.body.find("%E9%9B%AA") != std::string::npos, + "token form did not RFC3986-encode non-ASCII UTF-8 bytes"); + const std::string& state = only_value(authorization, "state"); + const std::string& verifier = only_value(form, "code_verifier"); + require(!state.empty() && !verifier.empty() && state != verifier, + "distinct deterministic entropy was not used independently for state and verifier"); + require(only_value(authorization, "code_challenge") == s256_challenge(verifier), + "authorization challenge is not SHA-256 of the token verifier"); + require(rig.browser.urls[0].find(verifier) == std::string::npos, + "raw PKCE verifier was exposed to the authorization endpoint"); + require(form.count("state") == 0 && token.body.find("state=") == std::string::npos, + "callback state was improperly sent to the token endpoint"); + require(token.url.find(state) == std::string::npos + && std::ranges::none_of(token.headers, [&](const auto& header) { + return header.name.find(state) != std::string::npos + || header.value.find(state) != std::string::npos; + }) + && token.body.find(state) == std::string::npos, + "callback state leaked elsewhere in the token request"); + require(form.count("client_secret") == 0 + && token.body.find("client_secret") == std::string::npos, + "public-client token request contains a client secret"); +} + +void test_independent_identical_entropy_is_not_rejected() { + Rig rig; + rig.entropy.identical = true; + require(authorize(rig).status == 200, "identical random outputs were treated as invalid"); + require(rig.entropy.sizes == std::vector{32, 32}, + "identical entropy fixture did not receive two independent fills"); + const auto authorization = query_parameters(rig.browser.urls[0]); + const auto form = parse_parameters(rig.transport.requests[0].body, false); + require(only_value(authorization, "state") == only_value(form, "code_verifier"), + "identical entropy fixture did not produce the expected equal encodings"); +} + +void test_all_2xx_are_success_and_never_retry() { + for (const int status : {200, 201, 226, 299}) { + Rig rig; + rig.transport.response = {status, {}, "success-body"}; + require(authorize(rig).status == status, "2xx token response was rejected"); + require(rig.transport.requests.size() == 1, "2xx token response was retried"); + } +} + +void test_token_rejection_and_transport_failure_never_retry_or_leak() { + for (const int status : {0, 101, 199, 300, 400, 429, 500}) { + Rig rig; + const std::string sensitive = "SENSITIVE_TOKEN_BODY_" + std::to_string(status); + rig.transport.response = {status, {}, sensitive}; + try { + static_cast(authorize(rig)); + throw std::runtime_error("non-2xx token response was accepted"); + } catch (const OAuthError& error) { + require(error.kind() == OAuthError::Kind::token_endpoint_rejected, + "token rejection has the wrong OAuth error kind"); + require(std::string_view{error.what()}.find(sensitive) == std::string_view::npos, + "token response body leaked through OAuth exception"); + } + require(rig.transport.requests.size() == 1, "token rejection was retried"); + } + + Rig rig; + rig.transport.failure = true; + require_throws([&] { static_cast(authorize(rig)); }, + "transport failure did not propagate"); + require(rig.transport.requests.size() == 1, "transport exception triggered a retry"); +} + +void test_callback_failures_never_send_token_request() { + { + Rig rig; + rig.receiver.copy_browser_state = false; + rig.receiver.callback.state = "attacker-state"; + require_oauth_error([&] { static_cast(authorize(rig)); }, + OAuthError::Kind::state_mismatch, "callback state mismatch was accepted"); + require_no_token_request(rig, "state mismatch sent a token request"); + } + { + Rig rig; + const std::string sensitive = "SENSITIVE_AUTHORIZATION_DESCRIPTION"; + rig.receiver.callback = {"", "", "access_denied", sensitive}; + try { + static_cast(authorize(rig)); + throw std::runtime_error("authorization denial was accepted"); + } catch (const OAuthError& error) { + require(error.kind() == OAuthError::Kind::authorization_rejected, + "authorization denial has the wrong OAuth error kind"); + require(std::string_view{error.what()}.find(sensitive) == std::string_view::npos, + "authorization error description leaked through exception"); + } + require_no_token_request(rig, "authorization denial sent a token request"); + } + { + Rig rig; + rig.receiver.copy_browser_state = false; + rig.receiver.callback = {"code-without-state", "", "", ""}; + require_oauth_error([&] { static_cast(authorize(rig)); }, + OAuthError::Kind::invalid_callback, "callback with empty state was accepted"); + require_no_token_request(rig, "callback with empty state sent a token request"); + } + + const std::vector malformed{ + {"", "", "", ""}, + {"code", "", "error", "description"}, + {"", "", "", "description-without-error"}, + }; + for (const auto& callback : malformed) { + Rig rig; + rig.receiver.callback = callback; + require_oauth_error([&] { static_cast(authorize(rig)); }, + OAuthError::Kind::invalid_callback, "malformed callback was accepted"); + require_no_token_request(rig, "malformed callback sent a token request"); + } +} + +void test_browser_and_receiver_failures_never_send_token_request() { + { + Rig rig; + rig.browser.failure = true; + require_throws([&] { static_cast(authorize(rig)); }, + "browser failure did not propagate"); + require(rig.browser.urls.size() == 1, "browser was unexpectedly retried"); + require(rig.receiver.receive_calls == 0, "callback was awaited after browser failure"); + require_no_token_request(rig, "browser failure sent a token request"); + } + { + Rig rig; + rig.receiver.redirect_failure = true; + require_throws([&] { static_cast(authorize(rig)); }, + "redirect receiver failure did not propagate"); + require(rig.browser.urls.empty(), "browser opened after redirect receiver failure"); + require_no_token_request(rig, "redirect receiver failure sent a token request"); + } + { + Rig rig; + rig.receiver.receive_failure = true; + require_throws([&] { static_cast(authorize(rig)); }, + "callback receiver failure did not propagate"); + require(rig.receiver.receive_calls == 1, "callback receive was retried"); + require_no_token_request(rig, "callback receiver failure sent a token request"); + } +} + +void test_endpoint_validation() { + const std::vector invalid_endpoints{ + "http://login.example.test/oauth2/authorize", + "login.example.test/oauth2/authorize", + "https:///missing-authority", + "https://user@login.example.test/authorize", + "https://login.example.test/authorize?query=forbidden", + "https://login.example.test/authorize#fragment", + "https://login.example.test/path with space", + "https://login.example.test/path\ncontrol", + }; + for (const auto& endpoint : invalid_endpoints) { + for (const bool authorization_endpoint : {true, false}) { + Rig rig; + (authorization_endpoint ? rig.config.authorization_endpoint + : rig.config.token_endpoint) = endpoint; + require_oauth_error([&] { static_cast(authorize(rig)); }, + OAuthError::Kind::invalid_configuration, + "invalid authorization or token endpoint was accepted"); + require(rig.browser.urls.empty(), "invalid endpoint opened the browser"); + require_no_token_request(rig, "invalid endpoint sent a token request"); + } + } + + Rig uppercase; + uppercase.config.authorization_endpoint = "HTTPS://login.example.test/authorize"; + uppercase.config.token_endpoint = "HTTPS://login.example.test/token"; + require(authorize(uppercase).status == 200, "uppercase HTTPS scheme was rejected"); +} + +void test_invalid_scope_has_zero_side_effects() { + const std::vector invalid_scopes{ + "", "two scopes", "tab\tscope", "line\nscope", "return\rscope", "control\x1fscope"}; + for (std::size_t index = 0; index < invalid_scopes.size(); ++index) { + const auto& invalid = invalid_scopes[index]; + Rig rig; + rig.config.scopes = {"openid", invalid}; + require_oauth_error([&] { static_cast(authorize(rig)); }, + OAuthError::Kind::invalid_configuration, + "unsafe OAuth scope fixture " + std::to_string(index) + " was accepted"); + require(rig.entropy.sizes.empty(), "unsafe scope consumed entropy"); + require(rig.browser.urls.empty(), "unsafe scope opened the browser"); + require(rig.receiver.receive_calls == 0, "unsafe scope awaited a callback"); + require_no_token_request(rig, "unsafe scope sent a token request"); + } +} + +void test_loopback_redirect_validation() { + const std::vector accepted{ + "http://127.0.0.1:1/callback", + "http://127.0.0.1:65535", + "http://localhost/callback/path", + "HTTP://LOCALHOST:43127/callback", + "http://[::1]:43127/callback", + }; + for (const auto& redirect : accepted) { + Rig rig; + rig.receiver.redirect = redirect; + require(authorize(rig).status == 200, "valid loopback redirect was rejected"); + require(rig.transport.requests.size() == 1, + "valid loopback redirect did not complete one token request"); + } + + const std::vector rejected{ + "https://127.0.0.1:43127/callback", + "http://example.test:43127/callback", + "http://127.0.0.1.example.test:43127/callback", + "http://localhost.evil.test:43127/callback", + "http://localhost.:43127/callback", + "http://%31%32%37.0.0.1:43127/callback", + "http://user@127.0.0.1:43127/callback", + "http://127.0.0.1:/callback", + "http://127.0.0.1:0/callback", + "http://127.0.0.1:65536/callback", + "http://127.0.0.1:not-a-port/callback", + "http://::1:43127/callback", + "http://[::1]:43127/callback?query=forbidden", + "http://[::1]:43127/callback#fragment", + "http://localhost:43127/path with space", + "http://localhost:43127/path\rcontrol", + }; + for (const auto& redirect : rejected) { + Rig rig; + rig.receiver.redirect = redirect; + require_oauth_error([&] { static_cast(authorize(rig)); }, + OAuthError::Kind::invalid_redirect, "unsafe loopback redirect was accepted"); + require(rig.browser.urls.empty(), "unsafe redirect opened the browser"); + require_no_token_request(rig, "unsafe redirect sent a token request"); + } +} + +} // namespace + +int main() { + try { + test_success_request_and_pkce(); + test_independent_identical_entropy_is_not_rejected(); + test_all_2xx_are_success_and_never_retry(); + test_token_rejection_and_transport_failure_never_retry_or_leak(); + test_callback_failures_never_send_token_request(); + test_browser_and_receiver_failures_never_send_token_request(); + test_endpoint_validation(); + test_invalid_scope_has_zero_side_effects(); + test_loopback_redirect_validation(); + } catch (const std::exception& error) { + std::cerr << "OAuth security test failure: " << error.what() << '\n'; + return 1; + } + std::cout << "OAuth security tests passed\n"; + return 0; +} diff --git a/tests/oauth_tests.cpp b/tests/oauth_tests.cpp new file mode 100644 index 0000000..ff3a8d1 --- /dev/null +++ b/tests/oauth_tests.cpp @@ -0,0 +1,405 @@ +#include "nocal/sync/oauth.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using nocal::sync::AuthorizationCallback; +using nocal::sync::AuthorizationCallbackReceiver; +using nocal::sync::BrowserLauncher; +using nocal::sync::EntropySource; +using nocal::sync::HttpMethod; +using nocal::sync::HttpRequest; +using nocal::sync::HttpResponse; +using nocal::sync::HttpTransport; +using nocal::sync::OAuthClientConfig; +using nocal::sync::OAuthError; +using nocal::sync::authorize_with_pkce; + +void require(bool condition, std::string_view message) { + if (!condition) { + throw std::runtime_error(std::string(message)); + } +} + +class ScriptedEntropy final : public EntropySource { +public: + explicit ScriptedEntropy(std::vector> scripted_values) + : values(std::move(scripted_values)) {} + + std::vector> values; + std::size_t calls{0}; + std::size_t fail_at{static_cast(-1)}; + + void fill(std::span output) override { + require(output.size() == 32, "entropy request was not exactly 32 bytes"); + const std::size_t call = calls++; + if (call == fail_at) { + throw std::runtime_error("entropy failure detail"); + } + require(call < values.size(), "unexpected entropy request"); + std::copy(values[call].begin(), values[call].end(), output.begin()); + } +}; + +class RecordingBrowser final : public BrowserLauncher { +public: + std::vector urls; + + void open(const std::string& url) override { urls.push_back(url); } +}; + +class FixedReceiver final : public AuthorizationCallbackReceiver { +public: + std::string redirect{"http://127.0.0.1:43123/callback"}; + AuthorizationCallback callback; + std::size_t receives{0}; + + [[nodiscard]] std::string redirect_uri() const override { return redirect; } + + AuthorizationCallback receive() override { + ++receives; + return callback; + } +}; + +class RecordingTransport final : public HttpTransport { +public: + std::vector requests; + HttpResponse response{200, {}, "{\"access_token\":\"opaque\"}"}; + + HttpResponse send(const HttpRequest& request) override { + requests.push_back(request); + return response; + } +}; + +[[nodiscard]] OAuthClientConfig config() { + return {"client id/+", "https://login.example/authorize", "https://login.example/token", + {"openid", "Calendars.ReadWrite/test"}}; +} + +[[nodiscard]] std::array zeros() { return {}; } + +[[nodiscard]] int base64url_digit(char character) { + if (character >= 'A' && character <= 'Z') { + return character - 'A'; + } + if (character >= 'a' && character <= 'z') { + return character - 'a' + 26; + } + if (character >= '0' && character <= '9') { + return character - '0' + 52; + } + if (character == '-') { + return 62; + } + if (character == '_') { + return 63; + } + throw std::runtime_error("invalid base64url test fixture"); +} + +[[nodiscard]] std::array decode_verifier(std::string_view value) { + std::vector decoded; + unsigned int accumulator = 0; + unsigned int bits = 0; + for (const char character : value) { + accumulator = (accumulator << 6U) | static_cast(base64url_digit(character)); + bits += 6U; + if (bits >= 8U) { + bits -= 8U; + decoded.push_back(static_cast((accumulator >> bits) & 0xffU)); + } + } + require(decoded.size() == 32, "RFC verifier fixture did not decode to 32 bytes"); + std::array result{}; + std::copy(decoded.begin(), decoded.end(), result.begin()); + return result; +} + +[[nodiscard]] std::string deterministic_state() { + return "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; +} + +template +OAuthError expect_oauth_error(Action&& action, OAuthError::Kind kind) { + try { + action(); + } catch (const OAuthError& error) { + require(error.kind() == kind, "OAuth error had the wrong kind"); + return error; + } + throw std::runtime_error("expected OAuthError was not thrown"); +} + +void test_rfc7636_vector_and_exact_exchange() { + static constexpr std::string_view verifier = + "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + ScriptedEntropy entropy{{zeros(), decode_verifier(verifier)}}; + RecordingBrowser browser; + FixedReceiver receiver; + receiver.callback = {"code +/&", deterministic_state(), "", ""}; + RecordingTransport transport; + transport.response = {201, {{"Content-Type", "application/json"}}, "opaque token body"}; + + const HttpResponse response = + authorize_with_pkce(config(), browser, receiver, transport, entropy); + + require(response == transport.response, "successful token response was not returned unchanged"); + require(entropy.calls == 2, "OAuth attempt did not make exactly two entropy requests"); + require(browser.urls.size() == 1, "browser was not opened exactly once"); + require(receiver.receives == 1, "callback was not received exactly once"); + require(transport.requests.size() == 1, "token endpoint was not called exactly once"); + const std::string expected_url = + "https://login.example/authorize?response_type=code&client_id=client%20id%2F%2B" + "&redirect_uri=http%3A%2F%2F127.0.0.1%3A43123%2Fcallback" + "&scope=openid%20Calendars.ReadWrite%2Ftest&state=" + + deterministic_state() + + "&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + "&code_challenge_method=S256"; + require(browser.urls.front() == expected_url, "authorization URL was not exact RFC3986/PKCE"); + + const HttpRequest& request = transport.requests.front(); + require(request.method == HttpMethod::post, "token exchange did not use POST"); + require(request.url == "https://login.example/token", "token endpoint changed"); + require(request.headers + == std::vector{{"Content-Type", + "application/x-www-form-urlencoded"}, + {"Accept", "application/json"}}, + "token request headers were not exact"); + const std::string expected_body = + "grant_type=authorization_code&client_id=client%20id%2F%2B&code=code%20%2B%2F%26" + "&redirect_uri=http%3A%2F%2F127.0.0.1%3A43123%2Fcallback&code_verifier=" + + std::string(verifier); + require(request.body == expected_body, "token request form was not exact"); + require(request.body.find("client_secret") == std::string::npos, + "token request contained a client secret"); + require(request.body.find("state=") == std::string::npos, + "token request contained callback state"); +} + +void test_invalid_configurations_do_nothing() { + std::vector invalid; + OAuthClientConfig value = config(); + value.client_id.clear(); + invalid.push_back(value); + value = config(); + value.scopes.clear(); + invalid.push_back(value); + value = config(); + value.scopes.push_back(""); + invalid.push_back(value); + const std::vector invalid_scopes = {"two scopes", "line\nbreak", + "tab\tbreak", "quote\"", "backslash\\", std::string("nonascii-") + "\xC3\xAF"}; + for (const std::string& scope : invalid_scopes) { + value = config(); + value.scopes = {scope}; + invalid.push_back(value); + } + for (const std::string endpoint : {"", "http://login.example/auth", "/authorize", + "https://user@login.example/auth", "https://login.example/auth?prompt=login", + "https://login.example/auth#fragment", "https://login.example/a path", + "https:///authorize"}) { + value = config(); + value.authorization_endpoint = endpoint; + invalid.push_back(value); + value = config(); + value.token_endpoint = endpoint; + invalid.push_back(value); + } + + for (const OAuthClientConfig& invalid_config : invalid) { + ScriptedEntropy entropy{{zeros(), zeros()}}; + RecordingBrowser browser; + FixedReceiver receiver; + RecordingTransport transport; + expect_oauth_error( + [&] { + (void)authorize_with_pkce( + invalid_config, browser, receiver, transport, entropy); + }, + OAuthError::Kind::invalid_configuration); + require(entropy.calls == 0 && browser.urls.empty() && receiver.receives == 0 + && transport.requests.empty(), + "invalid configuration caused OAuth side effects"); + } +} + +void test_redirect_validation() { + const std::vector invalid = {"", "https://localhost/callback", + "http://example.com/callback", "http://localhost.example/callback", + "http://127.0.0.2/callback", "http://user@localhost/callback", + "http://localhost:0/callback", "http://localhost:65536/callback", + "http://localhost:port/callback", "http://::1/callback", + "http://[::1]evil/callback", "http://localhost/callback?code=x", + "http://localhost/callback#fragment", "http://localhost/a path"}; + for (const std::string& redirect : invalid) { + ScriptedEntropy entropy{{zeros(), zeros()}}; + RecordingBrowser browser; + FixedReceiver receiver; + receiver.redirect = redirect; + RecordingTransport transport; + expect_oauth_error( + [&] { + (void)authorize_with_pkce(config(), browser, receiver, transport, entropy); + }, + OAuthError::Kind::invalid_redirect); + require(entropy.calls == 0 && browser.urls.empty() && receiver.receives == 0 + && transport.requests.empty(), + "invalid redirect caused OAuth side effects"); + } +} + +void run_valid_redirect(std::string redirect) { + ScriptedEntropy entropy{{zeros(), zeros()}}; + RecordingBrowser browser; + FixedReceiver receiver; + receiver.redirect = std::move(redirect); + receiver.callback = {"code", deterministic_state(), "", ""}; + RecordingTransport transport; + (void)authorize_with_pkce(config(), browser, receiver, transport, entropy); + require(transport.requests.size() == 1, "valid loopback redirect was rejected"); +} + +void test_valid_loopback_redirects() { + run_valid_redirect("http://localhost"); + run_valid_redirect("HTTP://LOCALHOST:65535/callback/path"); + run_valid_redirect("http://127.0.0.1:1/"); + run_valid_redirect("http://[::1]:43123/callback"); +} + +void test_non_ascii_encoding_and_upper_success_boundary() { + ScriptedEntropy entropy{{zeros(), zeros()}}; + RecordingBrowser browser; + FixedReceiver receiver; + receiver.callback = {"code", deterministic_state(), "", ""}; + RecordingTransport transport; + transport.response.status = 299; + OAuthClientConfig utf8_config = config(); + utf8_config.client_id = std::string("client-") + "\xC3\xAF"; + + const HttpResponse response = + authorize_with_pkce(utf8_config, browser, receiver, transport, entropy); + + require(response.status == 299, "HTTP 299 token response was not accepted"); + require(browser.urls.front().find("client_id=client-%C3%AF") != std::string::npos, + "non-ASCII authorization parameter was not byte-percent-encoded"); + require(transport.requests.front().body.find("client_id=client-%C3%AF") + != std::string::npos, + "non-ASCII form parameter was not byte-percent-encoded"); +} + +void test_callback_shapes_and_state() { + const std::vector malformed = {{}, {"code", "", "", ""}, + {"code", deterministic_state(), "error", ""}, + {"code", deterministic_state(), "", "unexpected"}, {"", "", "denied", ""}}; + for (const AuthorizationCallback& callback : malformed) { + ScriptedEntropy entropy{{zeros(), zeros()}}; + RecordingBrowser browser; + FixedReceiver receiver; + receiver.callback = callback; + RecordingTransport transport; + expect_oauth_error( + [&] { + (void)authorize_with_pkce(config(), browser, receiver, transport, entropy); + }, + OAuthError::Kind::invalid_callback); + require(browser.urls.size() == 1 && transport.requests.empty(), + "malformed callback had incorrect side effects"); + } + + const std::vector mismatches = { + {"code", "wrong", "", ""}, {"", "wrong", "denied", "description"}}; + for (const AuthorizationCallback& callback : mismatches) { + ScriptedEntropy entropy{{zeros(), zeros()}}; + RecordingBrowser browser; + FixedReceiver receiver; + receiver.callback = callback; + RecordingTransport transport; + expect_oauth_error( + [&] { + (void)authorize_with_pkce(config(), browser, receiver, transport, entropy); + }, + OAuthError::Kind::state_mismatch); + require(transport.requests.empty(), "state mismatch sent a token request"); + } + + ScriptedEntropy entropy{{zeros(), zeros()}}; + RecordingBrowser browser; + FixedReceiver receiver; + receiver.callback = {"", deterministic_state(), "access_denied", "private detail"}; + RecordingTransport transport; + const OAuthError rejection = expect_oauth_error( + [&] { (void)authorize_with_pkce(config(), browser, receiver, transport, entropy); }, + OAuthError::Kind::authorization_rejected); + require(std::string(rejection.what()).find("private detail") == std::string::npos, + "authorization error leaked error_description"); + require(transport.requests.empty(), "authorization denial sent a token request"); +} + +void test_token_rejection_is_single_and_opaque() { + for (const int status : {0, 199, 300, 400, 500}) { + ScriptedEntropy entropy{{zeros(), zeros()}}; + RecordingBrowser browser; + FixedReceiver receiver; + receiver.callback = {"code", deterministic_state(), "", ""}; + RecordingTransport transport; + transport.response = {status, {}, "private token failure body"}; + const OAuthError error = expect_oauth_error( + [&] { + (void)authorize_with_pkce(config(), browser, receiver, transport, entropy); + }, + OAuthError::Kind::token_endpoint_rejected); + require(transport.requests.size() == 1, "token rejection was retried or not sent"); + require(std::string(error.what()).find("private token failure body") == std::string::npos, + "token rejection leaked response body"); + } +} + +void test_entropy_failure_is_cryptographic_and_has_no_network_side_effect() { + for (const std::size_t failure : {std::size_t{0}, std::size_t{1}}) { + ScriptedEntropy entropy{{zeros(), zeros()}}; + entropy.fail_at = failure; + RecordingBrowser browser; + FixedReceiver receiver; + RecordingTransport transport; + expect_oauth_error( + [&] { + (void)authorize_with_pkce(config(), browser, receiver, transport, entropy); + }, + OAuthError::Kind::cryptographic_failure); + require(browser.urls.empty() && receiver.receives == 0 && transport.requests.empty(), + "cryptographic failure caused browser or network side effects"); + } +} + +} // namespace + +int main() { + try { + test_rfc7636_vector_and_exact_exchange(); + test_invalid_configurations_do_nothing(); + test_redirect_validation(); + test_valid_loopback_redirects(); + test_non_ascii_encoding_and_upper_success_boundary(); + test_callback_shapes_and_state(); + test_token_rejection_is_single_and_opaque(); + test_entropy_failure_is_cryptographic_and_has_no_network_side_effect(); + } catch (const std::exception& error) { + std::cerr << "oauth tests failed: " << error.what() << '\n'; + return 1; + } + std::cout << "oauth tests passed\n"; + return 0; +}