Skip to content
25 changes: 20 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ ifeq ($(UNAME_S),Darwin)
endif
endif

CXXFLAGS = -std=c++17 -g -Wall -Iinclude -Ithread_pool/include -Iutil -Itest -Ithird_party -Ithird_party/nghttp2 $(OPENSSL_CFLAGS)
CXXFLAGS = -std=c++17 -g -Wall -Iinclude -Ithread_pool/include -Iutil -Itest -Ithird_party -Ithird_party/nghttp2 -Ithird_party/jwt-cpp/include -DJWT_DISABLE_PICOJSON $(OPENSSL_CFLAGS)
CFLAGS = -g -Wall -Ithird_party/llhttp
NGHTTP2_CFLAGS = -std=c99 -g -Wall -DHAVE_CONFIG_H -Ithird_party/nghttp2
LDFLAGS = $(OPENSSL_LDFLAGS) -lpthread -lssl -lcrypto
Expand Down Expand Up @@ -79,6 +79,12 @@ RATE_LIMIT_SRCS = $(SERVER_DIR)/token_bucket.cc $(SERVER_DIR)/rate_limit_zone.cc
# Circuit breaker layer sources
CIRCUIT_BREAKER_SRCS = $(SERVER_DIR)/circuit_breaker_window.cc $(SERVER_DIR)/circuit_breaker_slice.cc $(SERVER_DIR)/retry_budget.cc $(SERVER_DIR)/circuit_breaker_host.cc $(SERVER_DIR)/circuit_breaker_manager.cc

# Auth layer sources (OAuth 2.0 token validation — Layer 7 middleware)
# Note: JWT decode + signature verification is delegated to vendored jwt-cpp
# (third_party/jwt-cpp/, header-only). See design spec §12.1 and r4/r5 revision
# history for the library-adoption rationale.
AUTH_SRCS = $(SERVER_DIR)/token_hasher.cc $(SERVER_DIR)/auth_policy_matcher.cc $(SERVER_DIR)/auth_claims.cc

# CLI layer sources
CLI_SRCS = $(SERVER_DIR)/cli_parser.cc $(SERVER_DIR)/signal_handler.cc $(SERVER_DIR)/pid_file.cc $(SERVER_DIR)/daemonizer.cc

Expand Down Expand Up @@ -125,7 +131,7 @@ NGHTTP2_SRC = $(THIRD_PARTY_DIR)/nghttp2/nghttp2_alpn.c \
NGHTTP2_OBJ = $(NGHTTP2_SRC:.c=.o)

# Server library sources (shared between test and production binaries)
LIB_SRCS = $(REACTOR_SRCS) $(NETWORK_SRCS) $(SERVER_SRCS) $(THREAD_POOL_SRCS) $(FOUNDATION_SRCS) $(HTTP_SRCS) $(HTTP2_SRCS) $(WS_SRCS) $(TLS_SRCS) $(UPSTREAM_SRCS) $(RATE_LIMIT_SRCS) $(CIRCUIT_BREAKER_SRCS) $(CLI_SRCS) $(UTIL_SRCS)
LIB_SRCS = $(REACTOR_SRCS) $(NETWORK_SRCS) $(SERVER_SRCS) $(THREAD_POOL_SRCS) $(FOUNDATION_SRCS) $(HTTP_SRCS) $(HTTP2_SRCS) $(WS_SRCS) $(TLS_SRCS) $(UPSTREAM_SRCS) $(RATE_LIMIT_SRCS) $(CIRCUIT_BREAKER_SRCS) $(AUTH_SRCS) $(CLI_SRCS) $(UTIL_SRCS)

# Test binary sources
TEST_SRCS = $(LIB_SRCS) $(TEST_DIR)/test_framework.cc $(TEST_DIR)/run_test.cc
Expand All @@ -146,11 +152,15 @@ TLS_HEADERS = $(LIB_DIR)/tls/tls_context.h $(LIB_DIR)/tls/tls_connection.h $(LIB
UPSTREAM_HEADERS = $(LIB_DIR)/upstream/upstream_manager.h $(LIB_DIR)/upstream/upstream_host_pool.h $(LIB_DIR)/upstream/pool_partition.h $(LIB_DIR)/upstream/upstream_connection.h $(LIB_DIR)/upstream/upstream_lease.h $(LIB_DIR)/upstream/upstream_http_codec.h $(LIB_DIR)/upstream/http_request_serializer.h $(LIB_DIR)/upstream/header_rewriter.h $(LIB_DIR)/upstream/retry_policy.h $(LIB_DIR)/upstream/proxy_transaction.h $(LIB_DIR)/upstream/proxy_handler.h $(LIB_DIR)/upstream/upstream_response.h $(LIB_DIR)/upstream/upstream_callbacks.h
RATE_LIMIT_HEADERS = $(LIB_DIR)/rate_limit/token_bucket.h $(LIB_DIR)/rate_limit/rate_limit_zone.h $(LIB_DIR)/rate_limit/rate_limiter.h
CIRCUIT_BREAKER_HEADERS = $(LIB_DIR)/circuit_breaker/circuit_breaker_state.h $(LIB_DIR)/circuit_breaker/circuit_breaker_window.h $(LIB_DIR)/circuit_breaker/circuit_breaker_slice.h $(LIB_DIR)/circuit_breaker/retry_budget.h $(LIB_DIR)/circuit_breaker/circuit_breaker_host.h $(LIB_DIR)/circuit_breaker/circuit_breaker_manager.h
# Auth headers. The vendored jwt-cpp headers are pulled into the dependency
# graph so a bump-jwt-cpp PR correctly invalidates the whole build.
JWT_CPP_DIR = $(THIRD_PARTY_DIR)/jwt-cpp/include/jwt-cpp
AUTH_HEADERS = $(LIB_DIR)/auth/auth_context.h $(LIB_DIR)/auth/auth_config.h $(LIB_DIR)/auth/token_hasher.h $(LIB_DIR)/auth/auth_policy_matcher.h $(LIB_DIR)/auth/auth_claims.h $(JWT_CPP_DIR)/jwt.h $(JWT_CPP_DIR)/base.h $(JWT_CPP_DIR)/traits/nlohmann-json/defaults.h $(JWT_CPP_DIR)/traits/nlohmann-json/traits.h
Comment thread
mwfj marked this conversation as resolved.
CLI_HEADERS = $(LIB_DIR)/cli/cli_parser.h $(LIB_DIR)/cli/signal_handler.h $(LIB_DIR)/cli/pid_file.h $(LIB_DIR)/cli/version.h $(LIB_DIR)/cli/daemonizer.h
TEST_HEADERS = $(TEST_DIR)/test_framework.h $(TEST_DIR)/http_test_client.h $(TEST_DIR)/basic_test.h $(TEST_DIR)/stress_test.h $(TEST_DIR)/race_condition_test.h $(TEST_DIR)/timeout_test.h $(TEST_DIR)/config_test.h $(TEST_DIR)/http_test.h $(TEST_DIR)/websocket_test.h $(TEST_DIR)/tls_test.h $(TEST_DIR)/cli_test.h $(TEST_DIR)/http2_test.h $(TEST_DIR)/route_test.h $(TEST_DIR)/upstream_pool_test.h $(TEST_DIR)/proxy_test.h $(TEST_DIR)/rate_limit_test.h $(TEST_DIR)/kqueue_test.h $(TEST_DIR)/circuit_breaker_test.h $(TEST_DIR)/circuit_breaker_components_test.h $(TEST_DIR)/circuit_breaker_integration_test.h $(TEST_DIR)/circuit_breaker_retry_budget_test.h $(TEST_DIR)/circuit_breaker_wait_queue_drain_test.h $(TEST_DIR)/circuit_breaker_observability_test.h $(TEST_DIR)/circuit_breaker_reload_test.h
TEST_HEADERS = $(TEST_DIR)/test_framework.h $(TEST_DIR)/http_test_client.h $(TEST_DIR)/basic_test.h $(TEST_DIR)/stress_test.h $(TEST_DIR)/race_condition_test.h $(TEST_DIR)/timeout_test.h $(TEST_DIR)/config_test.h $(TEST_DIR)/http_test.h $(TEST_DIR)/websocket_test.h $(TEST_DIR)/tls_test.h $(TEST_DIR)/cli_test.h $(TEST_DIR)/http2_test.h $(TEST_DIR)/route_test.h $(TEST_DIR)/upstream_pool_test.h $(TEST_DIR)/proxy_test.h $(TEST_DIR)/rate_limit_test.h $(TEST_DIR)/kqueue_test.h $(TEST_DIR)/circuit_breaker_test.h $(TEST_DIR)/circuit_breaker_components_test.h $(TEST_DIR)/circuit_breaker_integration_test.h $(TEST_DIR)/circuit_breaker_retry_budget_test.h $(TEST_DIR)/circuit_breaker_wait_queue_drain_test.h $(TEST_DIR)/circuit_breaker_observability_test.h $(TEST_DIR)/circuit_breaker_reload_test.h $(TEST_DIR)/auth_foundation_test.h

# All headers combined
HEADERS = $(CORE_HEADERS) $(CALLBACK_HEADERS) $(REACTOR_HEADERS) $(NETWORK_HEADERS) $(SERVER_HEADERS) $(THREAD_POOL_HEADERS) $(UTIL_HEADERS) $(FOUNDATION_HEADERS) $(HTTP_HEADERS) $(HTTP2_HEADERS) $(WS_HEADERS) $(TLS_HEADERS) $(UPSTREAM_HEADERS) $(RATE_LIMIT_HEADERS) $(CIRCUIT_BREAKER_HEADERS) $(CLI_HEADERS) $(TEST_HEADERS)
HEADERS = $(CORE_HEADERS) $(CALLBACK_HEADERS) $(REACTOR_HEADERS) $(NETWORK_HEADERS) $(SERVER_HEADERS) $(THREAD_POOL_HEADERS) $(UTIL_HEADERS) $(FOUNDATION_HEADERS) $(HTTP_HEADERS) $(HTTP2_HEADERS) $(WS_HEADERS) $(TLS_HEADERS) $(UPSTREAM_HEADERS) $(RATE_LIMIT_HEADERS) $(CIRCUIT_BREAKER_HEADERS) $(AUTH_HEADERS) $(CLI_HEADERS) $(TEST_HEADERS)

# Default target
.DEFAULT_GOAL := all
Expand Down Expand Up @@ -247,6 +257,11 @@ test_circuit_breaker: $(TARGET)
@echo "Running circuit breaker tests only..."
./$(TARGET) circuit_breaker

# Run only auth foundation tests
test_auth: $(TARGET)
@echo "Running auth foundation tests only..."
./$(TARGET) auth

# Display help information
help:
@echo "Reactor Server C++ - Makefile Help"
Expand Down Expand Up @@ -327,4 +342,4 @@ help:
# Build only the production server binary
server: $(SERVER_TARGET)

.PHONY: all clean test server test_basic test_stress test_race test_config test_http test_ws test_tls test_cli test_http2 test_upstream test_proxy test_rate_limit test_circuit_breaker help
.PHONY: all clean test server test_basic test_stress test_race test_config test_http test_ws test_tls test_cli test_http2 test_upstream test_proxy test_rate_limit test_circuit_breaker test_auth help
49 changes: 49 additions & 0 deletions include/auth/auth_claims.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
#pragma once

#include "common.h"
#include "auth/auth_context.h"
#include <nlohmann/json.hpp>
// <string>, <vector> via common.h

namespace auth {

// Helpers that translate a decoded-JWT payload OR an introspection-response
// JSON into the AuthContext we attach to HttpRequest.
//
// Kept separate from JwtVerifier / IntrospectionClient so the same
// claim-extraction logic is used by both paths and tested once.

// Build a scope list from the payload. OAuth 2.0 tokens conventionally use
// either `scope` (space-separated string) or `scp` (JSON array of strings).
// Some IdPs (e.g. Azure AD) also emit `scopes`. This helper accepts all three.
std::vector<std::string> ExtractScopes(const nlohmann::json& payload);

// Populate AuthContext from a decoded JWT payload plus operator-configured
// `claims_to_headers` keys (we only copy claims that the operator asks for
// into AuthContext::claims, to keep the context small).
//
// Also sets:
// ctx.issuer = payload["iss"] (if string)
// ctx.subject = payload["sub"] (if string)
// ctx.scopes = ExtractScopes(payload)
//
// Returns true when `sub` and `iss` are both present and string; false
// otherwise (caller returns 401 invalid_token).
bool PopulateFromPayload(const nlohmann::json& payload,
const std::vector<std::string>& claims_keys,
AuthContext& ctx);

// Check whether all required scopes are present in the token's scope list.
// Returns true iff every entry in `required` appears in `have`. Empty
// `required` is always accepted.
bool HasRequiredScopes(const std::vector<std::string>& have,
const std::vector<std::string>& required);

// Check whether the token's `aud` claim matches a required audience string.
// `aud` may be a string or array in JWT. Returns true iff `required` matches
// one of the audiences. Empty `required` is always accepted (no audience
// requirement).
bool MatchesAudience(const nlohmann::json& payload,
const std::string& required);

} // namespace auth
144 changes: 144 additions & 0 deletions include/auth/auth_config.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
#pragma once

#include "common.h"
// <string>, <vector>, <map>, <unordered_map> via common.h

namespace auth {

// ---------------------------------------------------------------------------
// Introspection-mode config (RFC 7662 token introspection).
// ---------------------------------------------------------------------------
struct IntrospectionConfig {
std::string endpoint; // Full URL; required when mode=introspection
std::string client_id; // OAuth client id for the introspection request
std::string client_secret_env; // Env-var name holding the client secret (inline secret is rejected)
std::string auth_style = "basic"; // "basic" (Authorization header) or "body" (urlencoded body)
int timeout_sec = 3; // Per-request timeout for introspection POST
int cache_sec = 60; // Positive-result cache TTL (capped by token exp)
int negative_cache_sec = 10; // Negative-result cache TTL
int stale_grace_sec = 30; // Serve stale positive when IdP unreachable
int max_entries = 100000; // Per-issuer cache cap (LRU eviction on insert)
int shards = 16; // Sharded LRU shard count

bool operator==(const IntrospectionConfig& o) const {
return endpoint == o.endpoint &&
client_id == o.client_id &&
client_secret_env == o.client_secret_env &&
auth_style == o.auth_style &&
timeout_sec == o.timeout_sec &&
cache_sec == o.cache_sec &&
negative_cache_sec == o.negative_cache_sec &&
stale_grace_sec == o.stale_grace_sec &&
max_entries == o.max_entries &&
shards == o.shards;
}
bool operator!=(const IntrospectionConfig& o) const { return !(*this == o); }
};

// ---------------------------------------------------------------------------
// Per-issuer config. One entry per trusted IdP.
// ---------------------------------------------------------------------------
struct IssuerConfig {
std::string name; // Config key (e.g. "google", "openai", "ours")
std::string issuer_url; // MUST be https://
bool discovery = true; // Use OIDC .well-known/openid-configuration
std::string jwks_uri; // Optional static override; only used when discovery=false
std::string upstream; // Name of the UpstreamHostPool used for outbound IdP calls
std::string mode = "jwt"; // "jwt" or "introspection"
std::vector<std::string> audiences; // Accepted `aud` values
std::vector<std::string> algorithms = { // Per-issuer allowlist (asymmetric only in v1)
"RS256"};
int leeway_sec = 30; // Clock-skew tolerance for exp/nbf/iat
int jwks_cache_sec = 300; // JWKS TTL
int jwks_refresh_timeout_sec = 5; // Per-refresh upstream timeout
int discovery_retry_sec = 30; // Retry interval if startup discovery fails
std::vector<std::string> required_claims; // Claims that MUST be present (beyond iss/exp/aud)
IntrospectionConfig introspection; // Only meaningful when mode=introspection

bool operator==(const IssuerConfig& o) const {
return name == o.name && issuer_url == o.issuer_url &&
discovery == o.discovery && jwks_uri == o.jwks_uri &&
upstream == o.upstream && mode == o.mode &&
audiences == o.audiences && algorithms == o.algorithms &&
leeway_sec == o.leeway_sec &&
jwks_cache_sec == o.jwks_cache_sec &&
jwks_refresh_timeout_sec == o.jwks_refresh_timeout_sec &&
discovery_retry_sec == o.discovery_retry_sec &&
required_claims == o.required_claims &&
introspection == o.introspection;
}
bool operator!=(const IssuerConfig& o) const { return !(*this == o); }
};

// ---------------------------------------------------------------------------
// Per-policy config. Attached either:
// (a) inline on a proxy via ProxyConfig::auth (applies_to derived from route_prefix)
// (b) top-level via AuthConfig::policies (applies_to declared explicitly)
// ---------------------------------------------------------------------------
struct AuthPolicy {
std::string name; // Optional for inline policies; required for top-level
bool enabled = false; // Opt-in per policy; default off (prevents empty policy from gating)
std::vector<std::string> applies_to; // Path prefixes (used only for top-level policies)
std::vector<std::string> issuers; // Accepted issuer names (must match AuthConfig::issuers keys)
std::vector<std::string> required_scopes; // All must be present in token scope/scp
std::string required_audience; // Overrides issuer-level `audiences` when set
std::string on_undetermined = "deny"; // "deny" (default) or "allow"
std::string realm = "api"; // For WWW-Authenticate: Bearer realm="..."

bool operator==(const AuthPolicy& o) const {
return name == o.name && enabled == o.enabled &&
applies_to == o.applies_to && issuers == o.issuers &&
required_scopes == o.required_scopes &&
required_audience == o.required_audience &&
on_undetermined == o.on_undetermined &&
realm == o.realm;
}
bool operator!=(const AuthPolicy& o) const { return !(*this == o); }
};

// ---------------------------------------------------------------------------
// Forward-overlay config. How validated identity is injected into the
// outbound (upstream) request header set by HeaderRewriter. Reload-mutable;
// held inside AuthManager as std::shared_ptr<const AuthForwardConfig> and
// snapshotted per-request at the start of the outbound hop.
// ---------------------------------------------------------------------------
struct AuthForwardConfig {
std::string subject_header = "X-Auth-Subject";
std::string issuer_header = "X-Auth-Issuer";
std::string scopes_header = "X-Auth-Scopes";
std::string raw_jwt_header; // Empty (default) = disabled
std::map<std::string, std::string> claims_to_headers; // claim -> outbound header name
bool strip_inbound_identity_headers = true; // Drop inbound X-Auth-* to prevent spoofing
bool preserve_authorization = true; // Forward original Authorization header

bool operator==(const AuthForwardConfig& o) const {
return subject_header == o.subject_header &&
issuer_header == o.issuer_header &&
scopes_header == o.scopes_header &&
raw_jwt_header == o.raw_jwt_header &&
claims_to_headers == o.claims_to_headers &&
strip_inbound_identity_headers == o.strip_inbound_identity_headers &&
preserve_authorization == o.preserve_authorization;
}
bool operator!=(const AuthForwardConfig& o) const { return !(*this == o); }
};

// ---------------------------------------------------------------------------
// Top-level auth config block.
// ---------------------------------------------------------------------------
struct AuthConfig {
bool enabled = false; // Master switch
std::unordered_map<std::string, IssuerConfig> issuers; // Keyed by IssuerConfig::name (redundant but stable)
std::vector<AuthPolicy> policies; // Top-level policies with applies_to
AuthForwardConfig forward; // Outbound header overlay config
std::string hmac_cache_key_env; // Env-var name for process-local HMAC key; empty = generated

bool operator==(const AuthConfig& o) const {
return enabled == o.enabled && issuers == o.issuers &&
policies == o.policies && forward == o.forward &&
hmac_cache_key_env == o.hmac_cache_key_env;
}
bool operator!=(const AuthConfig& o) const { return !(*this == o); }
};

} // namespace auth
42 changes: 42 additions & 0 deletions include/auth/auth_context.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
#pragma once

#include "common.h"
#include <optional>
// <string>, <vector>, <map>, <optional> via common.h

namespace auth {

// AuthContext is the output of the auth middleware on successful validation.
// Attached to HttpRequest via a mutable field (see include/http/http_request.h).
// Read by downstream middleware, route handlers, and by HeaderRewriter when
// constructing the outbound (upstream) request header set.
struct AuthContext {
std::string issuer; // Validated `iss` claim
std::string subject; // Validated `sub` claim
std::vector<std::string> scopes; // From `scope` (space-sep) or `scp` (array)
std::map<std::string, std::string> claims; // Operator-selected claims (claims_to_headers source)
std::string policy_name; // Matched policy's name (observability)

// SENSITIVE — raw bearer token. NEVER log this field. Never include it
// in error messages, debug dumps, or diagnostic responses. Per
// LOGGING_STANDARDS.md, logs must reference `subject` (post-validation)
// only; the raw token must never appear. The middleware should populate
// this field ONLY when `AuthForwardConfig::raw_jwt_header` is non-empty
// (operator explicitly opted in to re-forwarding under a separate
// header name) — otherwise leave it empty to minimize the blast radius
// if a future request-dump helper accidentally serializes AuthContext.
std::string raw_token;
bool undetermined = false; // True when on_undetermined="allow" path forwarded

void Clear() {
issuer.clear();
subject.clear();
scopes.clear();
claims.clear();
policy_name.clear();
raw_token.clear();
undetermined = false;
}
};

} // namespace auth
Loading
Loading