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:
26
README.md
26
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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
43
include/nocal/sync/http.hpp
Normal file
43
include/nocal/sync/http.hpp
Normal 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
|
||||
82
include/nocal/sync/oauth.hpp
Normal file
82
include/nocal/sync/oauth.hpp
Normal 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
|
||||
12
meson.build
12
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)
|
||||
|
||||
@@ -9,6 +9,7 @@ pkgs.mkShell {
|
||||
|
||||
buildInputs = with pkgs; [
|
||||
gcc
|
||||
openssl
|
||||
sqlite
|
||||
];
|
||||
}
|
||||
|
||||
372
src/sync/oauth.cpp
Normal file
372
src/sync/oauth.cpp
Normal 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
|
||||
575
tests/oauth_security_tests.cpp
Normal file
575
tests/oauth_security_tests.cpp
Normal 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
405
tests/oauth_tests.cpp
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user