From 6422c921a703873ce3eba87ec337cec934707a3d Mon Sep 17 00:00:00 2001 From: mwfj Date: Wed, 15 Apr 2026 20:52:32 +0800 Subject: [PATCH 01/17] Support Oauth 2.0 --- Makefile | 8 +- include/auth/auth_claims.h | 49 +++++++++ include/auth/auth_config.h | 144 ++++++++++++++++++++++++ include/auth/auth_context.h | 33 ++++++ include/auth/auth_policy_matcher.h | 46 ++++++++ include/auth/jwt_decode.h | 62 +++++++++++ include/auth/token_hasher.h | 52 +++++++++ include/config/server_config.h | 20 ++++ include/http/http_request.h | 12 ++ server/auth_claims.cc | 109 ++++++++++++++++++ server/auth_policy_matcher.cc | 52 +++++++++ server/jwt_decode.cc | 171 +++++++++++++++++++++++++++++ server/token_hasher.cc | 82 ++++++++++++++ 13 files changed, 838 insertions(+), 2 deletions(-) create mode 100644 include/auth/auth_claims.h create mode 100644 include/auth/auth_config.h create mode 100644 include/auth/auth_context.h create mode 100644 include/auth/auth_policy_matcher.h create mode 100644 include/auth/jwt_decode.h create mode 100644 include/auth/token_hasher.h create mode 100644 server/auth_claims.cc create mode 100644 server/auth_policy_matcher.cc create mode 100644 server/jwt_decode.cc create mode 100644 server/token_hasher.cc diff --git a/Makefile b/Makefile index 2b9ae194..19ac7186 100644 --- a/Makefile +++ b/Makefile @@ -79,6 +79,9 @@ 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) +AUTH_SRCS = $(SERVER_DIR)/token_hasher.cc $(SERVER_DIR)/jwt_decode.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 @@ -125,7 +128,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 @@ -146,11 +149,12 @@ 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 = $(LIB_DIR)/auth/auth_context.h $(LIB_DIR)/auth/auth_config.h $(LIB_DIR)/auth/token_hasher.h $(LIB_DIR)/auth/jwt_decode.h $(LIB_DIR)/auth/auth_policy_matcher.h $(LIB_DIR)/auth/auth_claims.h 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 # 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 diff --git a/include/auth/auth_claims.h b/include/auth/auth_claims.h new file mode 100644 index 00000000..19938ba4 --- /dev/null +++ b/include/auth/auth_claims.h @@ -0,0 +1,49 @@ +#pragma once + +#include "common.h" +#include "auth/auth_context.h" +#include +// , 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 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& 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& have, + const std::vector& 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 diff --git a/include/auth/auth_config.h b/include/auth/auth_config.h new file mode 100644 index 00000000..318a9753 --- /dev/null +++ b/include/auth/auth_config.h @@ -0,0 +1,144 @@ +#pragma once + +#include "common.h" +// , , , 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 audiences; // Accepted `aud` values + std::vector 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 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 applies_to; // Path prefixes (used only for top-level policies) + std::vector issuers; // Accepted issuer names (must match AuthConfig::issuers keys) + std::vector 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 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 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 issuers; // Keyed by IssuerConfig::name (redundant but stable) + std::vector 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 diff --git a/include/auth/auth_context.h b/include/auth/auth_context.h new file mode 100644 index 00000000..9f9eb99d --- /dev/null +++ b/include/auth/auth_context.h @@ -0,0 +1,33 @@ +#pragma once + +#include "common.h" +#include +// , , , 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 scopes; // From `scope` (space-sep) or `scp` (array) + std::map claims; // Operator-selected claims (claims_to_headers source) + std::string policy_name; // Matched policy's name (observability) + std::string raw_token; // Raw bearer token (for raw_jwt_header injection, if enabled) + 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 diff --git a/include/auth/auth_policy_matcher.h b/include/auth/auth_policy_matcher.h new file mode 100644 index 00000000..51c45237 --- /dev/null +++ b/include/auth/auth_policy_matcher.h @@ -0,0 +1,46 @@ +#pragma once + +#include "common.h" +#include "auth/auth_config.h" +// , via common.h + +namespace auth { + +// AppliedPolicy is a (path_prefix, policy) pair. One policy may have many +// applied entries (one per prefix in its applies_to list). AppliedPolicyList +// is an immutable snapshot — atomically swapped inside AuthManager on Reload. +struct AppliedPolicy { + std::string prefix; + AuthPolicy policy; +}; + +using AppliedPolicyList = std::vector; + +// Longest-prefix match over an AppliedPolicyList. +// +// Semantics: +// - Returns a pointer to the entry whose `prefix` is the LONGEST string that +// is a prefix of `path`. Returns nullptr if nothing matches. +// - Ties between equal-length prefixes are NOT resolved here — the config +// loader rejects exact-prefix collisions at load time (see §3.2 of the +// design spec). If a tie does somehow reach runtime (e.g. programmatic +// RegisterPolicy bypassing validation) the first in the vector wins. +// - An empty prefix ("") is valid and matches every path; operators may use +// it as a catch-all "require auth on everything" policy. It will lose to +// any longer matching prefix (longest-prefix wins). +// - Matching is case-sensitive. HTTP path components are case-sensitive per +// RFC 3986 §6.2.2.1 (schemes and hosts are the case-insensitive parts). +const AppliedPolicy* FindPolicyForPath(const AppliedPolicyList& policies, + const std::string& path); + +// Validate that an AppliedPolicyList has no exact-prefix collisions. Returns +// true when the list is clean. On collision, returns false and stores an +// offender description in `err_out` (format: "prefix `/api/v1` declared by +// both policy A and policy B"). +// +// Called by ConfigLoader::Validate at load time and by AuthManager::Reload +// before swapping in a new snapshot. +bool ValidatePolicyList(const AppliedPolicyList& policies, + std::string& err_out); + +} // namespace auth diff --git a/include/auth/jwt_decode.h b/include/auth/jwt_decode.h new file mode 100644 index 00000000..14cc258f --- /dev/null +++ b/include/auth/jwt_decode.h @@ -0,0 +1,62 @@ +#pragma once + +#include "common.h" +#include +// , , via common.h + +namespace auth { + +// Decoded JWT components. Fields are populated to the extent they were +// present in the token; callers should assume nothing is guaranteed beyond +// what Decode() actually parsed. +// +// JwtDecoded does NOT verify signatures — it parses structure only. The +// raw header/payload bytes (without base64url decoding) are preserved in +// `header_raw_b64` and `payload_raw_b64` so that the signature verifier can +// operate on the signing input (`header_raw_b64 + "." + payload_raw_b64`). +struct JwtDecoded { + std::string header_raw_b64; // "eyJ..." + std::string payload_raw_b64; // "eyJ..." + std::string signature_raw_b64; // "abc..." (may be empty for alg=none — which we reject) + std::string signing_input; // header_raw_b64 + "." + payload_raw_b64 + + // Parsed header fields (must be present for a well-formed signed JWT). + std::string alg; // e.g. "RS256" + std::string kid; // Key ID from JWKS (may be empty for single-key JWKS) + std::string typ; // Usually "JWT" + + // Decoded payload as JSON (for claim lookups). Ownership kept here. + nlohmann::json payload; +}; + +// JWT size limit (design §9 item 5). A bearer token exceeding this is +// rejected at decode time. +constexpr size_t MAX_JWT_BYTES = 8192; + +// Decode a compact-serialized JWT (three `.`-separated base64url segments). +// Returns true on success. On failure, returns false and stores a short +// diagnostic in err_out. +// +// This function: +// - Validates overall size (<= MAX_JWT_BYTES) +// - Splits into header/payload/signature base64url segments +// - base64url-decodes header and payload +// - parses both as JSON +// - extracts alg / kid / typ from header +// +// It does NOT: +// - Verify the signature (that's JwtVerifier) +// - Validate claims (exp, aud, iss — handler-layer concerns) +// - Reject alg=none (that's the verifier's alg-allowlist, with per-issuer +// policy — but Decode does reject the obviously-invalid 2-segment shape +// which `alg: none` uses). +bool Decode(const std::string& token, JwtDecoded& out, std::string& err_out); + +// Base64url decode (RFC 7515 §2). Input may omit padding. Returns empty on +// invalid input. +std::string Base64UrlDecode(const std::string& input); + +// Base64url encode (RFC 7515 §2). No padding in output. +std::string Base64UrlEncode(const std::string& input); + +} // namespace auth diff --git a/include/auth/token_hasher.h b/include/auth/token_hasher.h new file mode 100644 index 00000000..d43e639d --- /dev/null +++ b/include/auth/token_hasher.h @@ -0,0 +1,52 @@ +#pragma once + +#include "common.h" +// , via common.h + +namespace auth { + +// Keyed HMAC-SHA256 hasher used to derive cache keys for the introspection +// cache. Takes raw bearer tokens and returns a 128-bit (16-byte) truncated +// HMAC, hex-encoded for convenient map keying. +// +// Security rationale: the introspection cache stores validated claim bundles +// keyed by an opaque function of the bearer token. Using a raw SHA-256 would +// let an attacker with process-memory access enumerate tokens by hashing +// candidates. HMAC with a per-process random key prevents enumeration even +// on memory capture — the attacker would need the key too. +// +// The key is process-local, generated at AuthManager::Start() time from the +// env var named by `auth.hmac_cache_key_env` (if set) or from +// RAND_bytes() otherwise. On restart, a new key is generated and the cache +// is empty — that is the correct behavior; cache-key secrecy does not need +// to persist across restarts, and the cache rebuilds quickly. +class TokenHasher { +public: + // Initialize with a 32-byte key. Smaller keys are accepted (zero-padded + // internally by HMAC) but a warn is logged; larger keys are hashed down + // to 32 bytes (standard HMAC behavior). Throws std::invalid_argument + // when key is empty. + explicit TokenHasher(const std::string& key); + + // Compute the cache-key hex string for a token. Returns 32 hex chars + // (128-bit truncation of HMAC-SHA256). Thread-safe — OpenSSL's HMAC + // via EVP is reentrant given an independent per-call context. + std::string Hash(const std::string& token) const; + + // Return true when the hasher is initialized with a non-empty key. + bool ready() const { return !key_.empty(); } + +private: + std::string key_; // Raw 32-byte (or larger) HMAC key material +}; + +// Generate a fresh 32-byte random key via OpenSSL's RAND_bytes. +// Returns a binary string of length 32. Throws if RAND_bytes fails. +std::string GenerateHmacKey(); + +// Load key material from an environment variable by name. The env value is +// interpreted as raw bytes (NOT base64). Returns an empty string when the +// env var is unset or empty. +std::string LoadHmacKeyFromEnv(const std::string& env_var_name); + +} // namespace auth diff --git a/include/config/server_config.h b/include/config/server_config.h index ee879f28..6b92f85b 100644 --- a/include/config/server_config.h +++ b/include/config/server_config.h @@ -5,6 +5,8 @@ #include #include +#include "auth/auth_config.h" + struct TlsConfig { bool enabled = false; std::string cert_file; @@ -127,6 +129,23 @@ struct ProxyConfig { // Retry policy configuration ProxyRetryConfig retry; + // Inline auth policy for this proxy (applies_to derived from route_prefix). + // Reload-propagated via AuthManager::Reload — EXCLUDED from operator== + // below so that proxy.auth edits do not trip the outer "restart required" + // warning in HttpServer::Reload(). See `DEVELOPMENT_RULES.md` under + // *"Live-reloadable config fields in restart-required equality operators + // — ordering matters"* for the rationale. + auth::AuthPolicy auth; + + // Excludes `auth` — auth policy edits are live-reloadable via + // `AuthManager::Reload`, which `HttpServer::Reload` invokes on every + // reload. Topology fields (response_timeout_ms, route_prefix, + // strip_prefix, methods, header_rewrite, retry) remain restart-only. + // + // Contract: a config pair that differs ONLY in auth fields must compare + // EQUAL so the outer reload doesn't fire a spurious warn. This is the + // same discipline used by `UpstreamConfig::operator==` for the + // `circuit_breaker` field. bool operator==(const ProxyConfig& o) const { return response_timeout_ms == o.response_timeout_ms && route_prefix == o.route_prefix && @@ -260,4 +279,5 @@ struct ServerConfig { Http2Config http2; std::vector upstreams; RateLimitConfig rate_limit; + auth::AuthConfig auth; }; diff --git a/include/http/http_request.h b/include/http/http_request.h index 02aa795b..f057c839 100644 --- a/include/http/http_request.h +++ b/include/http/http_request.h @@ -1,6 +1,8 @@ #pragma once #include "common.h" +#include "auth/auth_context.h" +#include // provided by common.h struct HttpRequest { @@ -87,6 +89,15 @@ struct HttpRequest { // Dispatcher-thread only. mutable int async_cap_sec_override = -1; + // Authenticated identity populated by the auth middleware on successful + // validation. Read by downstream middleware / handlers and by + // HeaderRewriter when constructing the outbound upstream request. + // + // Mutable because, like params / client_ip / async_cancel_slot, it is + // populated during dispatch through a const HttpRequest&. + // Dispatcher-thread only. Left empty when no auth policy matches. + mutable std::optional auth; + // Case-insensitive header lookup std::string GetHeader(const std::string& name) const { std::string lower = name; @@ -123,5 +134,6 @@ struct HttpRequest { client_fd = -1; async_cancel_slot.reset(); async_cap_sec_override = -1; + auth.reset(); } }; diff --git a/server/auth_claims.cc b/server/auth_claims.cc new file mode 100644 index 00000000..f6309468 --- /dev/null +++ b/server/auth_claims.cc @@ -0,0 +1,109 @@ +#include "auth/auth_claims.h" + +#include + +namespace auth { + +namespace { + +std::vector SplitWhitespace(const std::string& s) { + std::vector out; + std::istringstream iss(s); + std::string tok; + while (iss >> tok) { + if (!tok.empty()) out.push_back(std::move(tok)); + } + return out; +} + +} // namespace + +std::vector ExtractScopes(const nlohmann::json& payload) { + // Try `scope` first (OAuth 2.0 convention — space-separated string). + if (payload.contains("scope") && payload["scope"].is_string()) { + return SplitWhitespace(payload["scope"].get()); + } + // Then `scp` (RFC 8693 / common IdP convention — array of strings). + if (payload.contains("scp") && payload["scp"].is_array()) { + std::vector out; + out.reserve(payload["scp"].size()); + for (const auto& v : payload["scp"]) { + if (v.is_string()) out.push_back(v.get()); + } + return out; + } + // Azure AD and friends. + if (payload.contains("scopes") && payload["scopes"].is_array()) { + std::vector out; + out.reserve(payload["scopes"].size()); + for (const auto& v : payload["scopes"]) { + if (v.is_string()) out.push_back(v.get()); + } + return out; + } + return {}; +} + +bool PopulateFromPayload(const nlohmann::json& payload, + const std::vector& claims_keys, + AuthContext& ctx) { + if (!payload.is_object()) return false; + + // `iss` and `sub` are mandatory (RFC 7519 §4.1.1 / §4.1.2) — a token + // without them is not considered valid for our purposes. + if (!payload.contains("iss") || !payload["iss"].is_string()) return false; + if (!payload.contains("sub") || !payload["sub"].is_string()) return false; + + ctx.issuer = payload["iss"].get(); + ctx.subject = payload["sub"].get(); + ctx.scopes = ExtractScopes(payload); + + // Copy only operator-requested claims into ctx.claims, to keep the + // context object small and to limit the data that flows into logs. + for (const auto& key : claims_keys) { + if (!payload.contains(key)) continue; + const auto& v = payload[key]; + if (v.is_string()) { + ctx.claims[key] = v.get(); + } else if (v.is_number_integer()) { + ctx.claims[key] = std::to_string(v.get()); + } else if (v.is_number_float()) { + ctx.claims[key] = std::to_string(v.get()); + } else if (v.is_boolean()) { + ctx.claims[key] = v.get() ? "true" : "false"; + } + // Arrays/objects: skip (operator should pick a more specific key). + } + return true; +} + +bool HasRequiredScopes(const std::vector& have, + const std::vector& required) { + if (required.empty()) return true; + for (const auto& r : required) { + bool found = false; + for (const auto& h : have) { + if (h == r) { found = true; break; } + } + if (!found) return false; + } + return true; +} + +bool MatchesAudience(const nlohmann::json& payload, + const std::string& required) { + if (required.empty()) return true; + if (!payload.contains("aud")) return false; + const auto& aud = payload["aud"]; + if (aud.is_string()) { + return aud.get() == required; + } + if (aud.is_array()) { + for (const auto& v : aud) { + if (v.is_string() && v.get() == required) return true; + } + } + return false; +} + +} // namespace auth diff --git a/server/auth_policy_matcher.cc b/server/auth_policy_matcher.cc new file mode 100644 index 00000000..4848cd83 --- /dev/null +++ b/server/auth_policy_matcher.cc @@ -0,0 +1,52 @@ +#include "auth/auth_policy_matcher.h" + +#include + +namespace auth { + +const AppliedPolicy* FindPolicyForPath(const AppliedPolicyList& policies, + const std::string& path) { + const AppliedPolicy* best = nullptr; + size_t best_len = 0; + + for (const auto& entry : policies) { + const auto& pref = entry.prefix; + // An empty prefix matches anything (catch-all). + if (pref.empty()) { + if (!best) { + best = &entry; + best_len = 0; + } + continue; + } + if (path.size() < pref.size()) continue; + if (path.compare(0, pref.size(), pref) != 0) continue; + if (pref.size() > best_len) { + best = &entry; + best_len = pref.size(); + } + } + return best; +} + +bool ValidatePolicyList(const AppliedPolicyList& policies, + std::string& err_out) { + std::unordered_map seen; // prefix -> owner name + for (const auto& entry : policies) { + const std::string& pref = entry.prefix; + const std::string& owner = + entry.policy.name.empty() ? std::string("") + : entry.policy.name; + auto [it, inserted] = seen.try_emplace(pref, owner); + if (!inserted) { + err_out = "auth policy prefix `" + pref + + "` declared by both `" + it->second + "` and `" + + owner + "` — exact-prefix collisions must be resolved " + "at config time (see design spec §3.2)"; + return false; + } + } + return true; +} + +} // namespace auth diff --git a/server/jwt_decode.cc b/server/jwt_decode.cc new file mode 100644 index 00000000..08aeddf7 --- /dev/null +++ b/server/jwt_decode.cc @@ -0,0 +1,171 @@ +#include "auth/jwt_decode.h" + +namespace auth { + +namespace { + +// Base64url alphabet (RFC 4648 §5): A-Z, a-z, 0-9, '-', '_'. +// std lookup table for decode: -1 means invalid. +int8_t DecodeChar(unsigned char c) { + if (c >= 'A' && c <= 'Z') return c - 'A'; + if (c >= 'a' && c <= 'z') return c - 'a' + 26; + if (c >= '0' && c <= '9') return c - '0' + 52; + if (c == '-') return 62; + if (c == '_') return 63; + return -1; +} + +const char kEncodeTable[] = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + +} // namespace + +std::string Base64UrlDecode(const std::string& input) { + std::string out; + if (input.empty()) return out; + + // 4 input chars -> 3 output bytes. Pad length to a multiple of 4 logically, + // but we don't need literal '=' chars — we just track how many bytes the + // final group produces. + size_t n = input.size(); + size_t remainder = n % 4; + if (remainder == 1) return {}; // Invalid length + + out.reserve((n * 3) / 4 + 2); + + uint32_t buf = 0; + int bits = 0; + for (size_t i = 0; i < n; ++i) { + int v = DecodeChar(static_cast(input[i])); + if (v < 0) return {}; // Invalid char + buf = (buf << 6) | static_cast(v); + bits += 6; + if (bits >= 8) { + bits -= 8; + out.push_back(static_cast((buf >> bits) & 0xFF)); + } + } + // Any remaining `bits` < 8 are the padding (discarded). + return out; +} + +std::string Base64UrlEncode(const std::string& input) { + std::string out; + if (input.empty()) return out; + out.reserve(((input.size() + 2) / 3) * 4); + + size_t i = 0; + while (i + 3 <= input.size()) { + uint32_t v = (static_cast(input[i]) << 16) | + (static_cast(input[i + 1]) << 8) | + static_cast(input[i + 2]); + out.push_back(kEncodeTable[(v >> 18) & 0x3F]); + out.push_back(kEncodeTable[(v >> 12) & 0x3F]); + out.push_back(kEncodeTable[(v >> 6) & 0x3F]); + out.push_back(kEncodeTable[v & 0x3F]); + i += 3; + } + size_t rem = input.size() - i; + if (rem == 1) { + uint32_t v = static_cast(input[i]); + out.push_back(kEncodeTable[(v >> 2) & 0x3F]); + out.push_back(kEncodeTable[(v << 4) & 0x3F]); + } else if (rem == 2) { + uint32_t v = (static_cast(input[i]) << 8) | + static_cast(input[i + 1]); + out.push_back(kEncodeTable[(v >> 10) & 0x3F]); + out.push_back(kEncodeTable[(v >> 4) & 0x3F]); + out.push_back(kEncodeTable[(v << 2) & 0x3F]); + } + return out; +} + +bool Decode(const std::string& token, JwtDecoded& out, std::string& err_out) { + out = {}; + if (token.empty()) { + err_out = "empty token"; + return false; + } + if (token.size() > MAX_JWT_BYTES) { + err_out = "token exceeds MAX_JWT_BYTES"; + return false; + } + + // Split on '.' — must produce exactly 3 segments for a signed JWT. + size_t dot1 = token.find('.'); + if (dot1 == std::string::npos) { + err_out = "token has no '.' separator (not a JWT)"; + return false; + } + size_t dot2 = token.find('.', dot1 + 1); + if (dot2 == std::string::npos) { + err_out = "token has fewer than 3 segments (may be alg=none — rejected)"; + return false; + } + // Reject a 4th segment. + if (token.find('.', dot2 + 1) != std::string::npos) { + err_out = "token has more than 3 '.' separators"; + return false; + } + + out.header_raw_b64 = token.substr(0, dot1); + out.payload_raw_b64 = token.substr(dot1 + 1, dot2 - dot1 - 1); + out.signature_raw_b64 = token.substr(dot2 + 1); + out.signing_input = token.substr(0, dot2); + + if (out.header_raw_b64.empty() || out.payload_raw_b64.empty() || + out.signature_raw_b64.empty()) { + err_out = "token has empty segment(s)"; + return false; + } + + std::string header_json = Base64UrlDecode(out.header_raw_b64); + if (header_json.empty()) { + err_out = "header base64url decode failed"; + return false; + } + std::string payload_json = Base64UrlDecode(out.payload_raw_b64); + if (payload_json.empty()) { + err_out = "payload base64url decode failed"; + return false; + } + + nlohmann::json header_parsed; + try { + header_parsed = nlohmann::json::parse(header_json); + } catch (const std::exception& e) { + err_out = "header JSON parse failed"; + return false; + } + try { + out.payload = nlohmann::json::parse(payload_json); + } catch (const std::exception& e) { + err_out = "payload JSON parse failed"; + return false; + } + + // Header field extraction (all optional from this layer's perspective). + if (header_parsed.is_object()) { + if (header_parsed.contains("alg") && header_parsed["alg"].is_string()) { + out.alg = header_parsed["alg"].get(); + } + if (header_parsed.contains("kid") && header_parsed["kid"].is_string()) { + out.kid = header_parsed["kid"].get(); + } + if (header_parsed.contains("typ") && header_parsed["typ"].is_string()) { + out.typ = header_parsed["typ"].get(); + } + } else { + err_out = "header is not a JSON object"; + return false; + } + + if (out.alg.empty()) { + err_out = "header `alg` is missing or not a string"; + return false; + } + + return true; +} + +} // namespace auth diff --git a/server/token_hasher.cc b/server/token_hasher.cc new file mode 100644 index 00000000..d7d358ee --- /dev/null +++ b/server/token_hasher.cc @@ -0,0 +1,82 @@ +#include "auth/token_hasher.h" + +#include "log/logger.h" +#include +#include +#include + +#include +#include + +namespace auth { + +namespace { + +constexpr size_t TRUNCATED_BYTES = 16; // 128-bit truncation of HMAC-SHA256 +constexpr size_t SHORT_KEY_BYTES = 32; + +std::string HexEncode(const unsigned char* bytes, size_t len) { + static const char* kHex = "0123456789abcdef"; + std::string out; + out.resize(len * 2); + for (size_t i = 0; i < len; ++i) { + out[2 * i] = kHex[bytes[i] >> 4]; + out[2 * i + 1] = kHex[bytes[i] & 0x0F]; + } + return out; +} + +} // namespace + +TokenHasher::TokenHasher(const std::string& key) : key_(key) { + if (key_.empty()) { + throw std::invalid_argument("TokenHasher: key must not be empty"); + } + if (key_.size() < SHORT_KEY_BYTES) { + logging::Get()->warn("TokenHasher: HMAC key is shorter than {} bytes " + "({}); zero-padded by HMAC. Prefer 32-byte keys.", + SHORT_KEY_BYTES, key_.size()); + } +} + +std::string TokenHasher::Hash(const std::string& token) const { + unsigned char out[EVP_MAX_MD_SIZE]; + unsigned int out_len = 0; + auto* md = EVP_sha256(); + + const unsigned char* key_bytes = + reinterpret_cast(key_.data()); + const unsigned char* msg_bytes = + reinterpret_cast(token.data()); + + if (!HMAC(md, key_bytes, static_cast(key_.size()), + msg_bytes, token.size(), out, &out_len)) { + logging::Get()->error("TokenHasher::Hash: HMAC failed"); + return {}; + } + if (out_len < TRUNCATED_BYTES) { + logging::Get()->error("TokenHasher::Hash: HMAC produced only {} bytes, " + "expected >= {}", out_len, TRUNCATED_BYTES); + return {}; + } + return HexEncode(out, TRUNCATED_BYTES); +} + +std::string GenerateHmacKey() { + unsigned char buf[SHORT_KEY_BYTES]; + if (RAND_bytes(buf, static_cast(sizeof(buf))) != 1) { + throw std::runtime_error( + "GenerateHmacKey: RAND_bytes failed — refusing to proceed with a " + "predictable cache key"); + } + return std::string(reinterpret_cast(buf), sizeof(buf)); +} + +std::string LoadHmacKeyFromEnv(const std::string& env_var_name) { + if (env_var_name.empty()) return {}; + const char* val = std::getenv(env_var_name.c_str()); + if (!val) return {}; + return std::string(val); +} + +} // namespace auth From 915d33afbb4a3ad2af94805377824373927c4ea9 Mon Sep 17 00:00:00 2001 From: mwfj Date: Wed, 15 Apr 2026 23:33:52 +0800 Subject: [PATCH 02/17] Apply jwt-cpp library --- Makefile | 14 +- include/auth/auth_context.h | 11 +- include/auth/auth_policy_matcher.h | 13 ++ include/auth/jwt_decode.h | 62 --------- include/auth/token_hasher.h | 26 +++- server/auth_claims.cc | 25 +++- server/token_hasher.cc | 41 +++++- test/auth_foundation_test.h | 211 +++++++++++++++++++++++++++++ test/run_test.cc | 4 + 9 files changed, 326 insertions(+), 81 deletions(-) delete mode 100644 include/auth/jwt_decode.h create mode 100644 test/auth_foundation_test.h diff --git a/Makefile b/Makefile index 19ac7186..d31bc9aa 100644 --- a/Makefile +++ b/Makefile @@ -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 @@ -80,7 +80,10 @@ RATE_LIMIT_SRCS = $(SERVER_DIR)/token_bucket.cc $(SERVER_DIR)/rate_limit_zone.cc 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) -AUTH_SRCS = $(SERVER_DIR)/token_hasher.cc $(SERVER_DIR)/jwt_decode.cc $(SERVER_DIR)/auth_policy_matcher.cc $(SERVER_DIR)/auth_claims.cc +# 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 @@ -149,9 +152,12 @@ 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 = $(LIB_DIR)/auth/auth_context.h $(LIB_DIR)/auth/auth_config.h $(LIB_DIR)/auth/token_hasher.h $(LIB_DIR)/auth/jwt_decode.h $(LIB_DIR)/auth/auth_policy_matcher.h $(LIB_DIR)/auth/auth_claims.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 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) $(AUTH_HEADERS) $(CLI_HEADERS) $(TEST_HEADERS) diff --git a/include/auth/auth_context.h b/include/auth/auth_context.h index 9f9eb99d..81a9dab3 100644 --- a/include/auth/auth_context.h +++ b/include/auth/auth_context.h @@ -16,7 +16,16 @@ struct AuthContext { std::vector scopes; // From `scope` (space-sep) or `scp` (array) std::map claims; // Operator-selected claims (claims_to_headers source) std::string policy_name; // Matched policy's name (observability) - std::string raw_token; // Raw bearer token (for raw_jwt_header injection, if enabled) + + // 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() { diff --git a/include/auth/auth_policy_matcher.h b/include/auth/auth_policy_matcher.h index 51c45237..f62369cf 100644 --- a/include/auth/auth_policy_matcher.h +++ b/include/auth/auth_policy_matcher.h @@ -30,6 +30,19 @@ using AppliedPolicyList = std::vector; // any longer matching prefix (longest-prefix wins). // - Matching is case-sensitive. HTTP path components are case-sensitive per // RFC 3986 §6.2.2.1 (schemes and hosts are the case-insensitive parts). +// +// IMPORTANT — PLAIN (byte-level) prefix, NOT segment-safe: +// - A policy `/admin` matches `/administrator/foo` because `/admin` is a +// byte prefix of `/administrator/foo`. This is deliberate — it mirrors +// `RateLimitZone::ZonePolicy::applies_to` semantics and matches the design +// spec §3.2. Operators who want segment-safe matching ("only the /admin +// subtree") must express that with a trailing slash: `/admin/` only +// matches `/admin/...` and NOT `/administrator`. +// - Not a security hole: plain-prefix over-protects (additional paths get +// auth enforcement), it does NOT under-protect. Still, operators should +// prefer trailing-slash prefixes for narrow subtree protection. +// - A future `match_mode: "segment"` knob is tracked in the design spec's +// §15 (Future work) and is out of scope for v1. const AppliedPolicy* FindPolicyForPath(const AppliedPolicyList& policies, const std::string& path); diff --git a/include/auth/jwt_decode.h b/include/auth/jwt_decode.h deleted file mode 100644 index 14cc258f..00000000 --- a/include/auth/jwt_decode.h +++ /dev/null @@ -1,62 +0,0 @@ -#pragma once - -#include "common.h" -#include -// , , via common.h - -namespace auth { - -// Decoded JWT components. Fields are populated to the extent they were -// present in the token; callers should assume nothing is guaranteed beyond -// what Decode() actually parsed. -// -// JwtDecoded does NOT verify signatures — it parses structure only. The -// raw header/payload bytes (without base64url decoding) are preserved in -// `header_raw_b64` and `payload_raw_b64` so that the signature verifier can -// operate on the signing input (`header_raw_b64 + "." + payload_raw_b64`). -struct JwtDecoded { - std::string header_raw_b64; // "eyJ..." - std::string payload_raw_b64; // "eyJ..." - std::string signature_raw_b64; // "abc..." (may be empty for alg=none — which we reject) - std::string signing_input; // header_raw_b64 + "." + payload_raw_b64 - - // Parsed header fields (must be present for a well-formed signed JWT). - std::string alg; // e.g. "RS256" - std::string kid; // Key ID from JWKS (may be empty for single-key JWKS) - std::string typ; // Usually "JWT" - - // Decoded payload as JSON (for claim lookups). Ownership kept here. - nlohmann::json payload; -}; - -// JWT size limit (design §9 item 5). A bearer token exceeding this is -// rejected at decode time. -constexpr size_t MAX_JWT_BYTES = 8192; - -// Decode a compact-serialized JWT (three `.`-separated base64url segments). -// Returns true on success. On failure, returns false and stores a short -// diagnostic in err_out. -// -// This function: -// - Validates overall size (<= MAX_JWT_BYTES) -// - Splits into header/payload/signature base64url segments -// - base64url-decodes header and payload -// - parses both as JSON -// - extracts alg / kid / typ from header -// -// It does NOT: -// - Verify the signature (that's JwtVerifier) -// - Validate claims (exp, aud, iss — handler-layer concerns) -// - Reject alg=none (that's the verifier's alg-allowlist, with per-issuer -// policy — but Decode does reject the obviously-invalid 2-segment shape -// which `alg: none` uses). -bool Decode(const std::string& token, JwtDecoded& out, std::string& err_out); - -// Base64url decode (RFC 7515 §2). Input may omit padding. Returns empty on -// invalid input. -std::string Base64UrlDecode(const std::string& input); - -// Base64url encode (RFC 7515 §2). No padding in output. -std::string Base64UrlEncode(const std::string& input); - -} // namespace auth diff --git a/include/auth/token_hasher.h b/include/auth/token_hasher.h index d43e639d..0ba07362 100644 --- a/include/auth/token_hasher.h +++ b/include/auth/token_hasher.h @@ -1,6 +1,7 @@ #pragma once #include "common.h" +#include // , via common.h namespace auth { @@ -28,10 +29,17 @@ class TokenHasher { // when key is empty. explicit TokenHasher(const std::string& key); - // Compute the cache-key hex string for a token. Returns 32 hex chars - // (128-bit truncation of HMAC-SHA256). Thread-safe — OpenSSL's HMAC - // via EVP is reentrant given an independent per-call context. - std::string Hash(const std::string& token) const; + // Compute the cache-key hex string for a token. On success returns + // 32 hex chars (128-bit truncation of HMAC-SHA256). Returns std::nullopt + // on HMAC failure — callers MUST treat nullopt as "uncacheable" and + // fall through to live introspection. Never fall back to a fixed + // sentinel value: two distinct tokens that both hit an HMAC failure + // would collide on the same cache key, which is a confidentiality bug + // (a leaked-claims cache hit for one token served to another). + // + // Thread-safe: uses OpenSSL's one-shot HMAC() API, which allocates its + // own EVP context per call and is reentrant. + std::optional Hash(const std::string& token) const; // Return true when the hasher is initialized with a non-empty key. bool ready() const { return !key_.empty(); } @@ -45,8 +53,14 @@ class TokenHasher { std::string GenerateHmacKey(); // Load key material from an environment variable by name. The env value is -// interpreted as raw bytes (NOT base64). Returns an empty string when the -// env var is unset or empty. +// interpreted using auto-detect: +// 1. If the value is valid base64url (no padding) AND decodes to exactly +// 32 bytes, the decoded bytes are used. This is the safer shell-transport +// form recommended by the design spec (§5.1) because raw 32-byte keys +// often contain non-printable bytes that mangle through `.env` files. +// 2. Otherwise, the value is used as raw bytes. +// +// Returns an empty string when the env var is unset or empty. std::string LoadHmacKeyFromEnv(const std::string& env_var_name); } // namespace auth diff --git a/server/auth_claims.cc b/server/auth_claims.cc index f6309468..20c0b19f 100644 --- a/server/auth_claims.cc +++ b/server/auth_claims.cc @@ -7,12 +7,13 @@ namespace auth { namespace { std::vector SplitWhitespace(const std::string& s) { + // operator>>(istream&, string&) skips leading whitespace and reads a + // non-empty run of non-whitespace characters — it cannot produce an + // empty `tok` on success, so no inner empty-check is needed here. std::vector out; std::istringstream iss(s); std::string tok; - while (iss >> tok) { - if (!tok.empty()) out.push_back(std::move(tok)); - } + while (iss >> tok) out.push_back(std::move(tok)); return out; } @@ -60,6 +61,21 @@ bool PopulateFromPayload(const nlohmann::json& payload, // Copy only operator-requested claims into ctx.claims, to keep the // context object small and to limit the data that flows into logs. + // + // Only scalar claims are flattened into the string-valued map. Array / + // object claims are SILENTLY SKIPPED — a common operator ask like + // "forward the `groups` array to X-Auth-Groups" will produce no header + // with the current Phase 1-2 model. That is intentional for this layer: + // array-to-header flattening (typically comma-separated, or multi-valued + // headers) is a HeaderRewriter / middleware concern because the + // serialization choice depends on what the upstream expects. Phase 3 + // wiring should add that flattening at the overlay layer, not here. + // + // Numeric truncation caveat: uint64_t claims that exceed INT64_MAX are + // currently read via is_number_integer() + get(), which nlohmann + // clamps to the signed range. Tolerable for human-readable claim types + // (email, sub, username) but callers that need big-unsigned claim + // fidelity should extract them directly from the payload JSON. for (const auto& key : claims_keys) { if (!payload.contains(key)) continue; const auto& v = payload[key]; @@ -72,7 +88,8 @@ bool PopulateFromPayload(const nlohmann::json& payload, } else if (v.is_boolean()) { ctx.claims[key] = v.get() ? "true" : "false"; } - // Arrays/objects: skip (operator should pick a more specific key). + // Arrays/objects: skip (see comment above — flattening is a Phase 3 + // HeaderRewriter concern, not a claim-extraction concern). } return true; } diff --git a/server/token_hasher.cc b/server/token_hasher.cc index d7d358ee..8fdac952 100644 --- a/server/token_hasher.cc +++ b/server/token_hasher.cc @@ -1,6 +1,7 @@ #include "auth/token_hasher.h" #include "log/logger.h" +#include "jwt-cpp/base.h" #include #include #include @@ -39,7 +40,7 @@ TokenHasher::TokenHasher(const std::string& key) : key_(key) { } } -std::string TokenHasher::Hash(const std::string& token) const { +std::optional TokenHasher::Hash(const std::string& token) const { unsigned char out[EVP_MAX_MD_SIZE]; unsigned int out_len = 0; auto* md = EVP_sha256(); @@ -52,12 +53,12 @@ std::string TokenHasher::Hash(const std::string& token) const { if (!HMAC(md, key_bytes, static_cast(key_.size()), msg_bytes, token.size(), out, &out_len)) { logging::Get()->error("TokenHasher::Hash: HMAC failed"); - return {}; + return std::nullopt; } if (out_len < TRUNCATED_BYTES) { logging::Get()->error("TokenHasher::Hash: HMAC produced only {} bytes, " "expected >= {}", out_len, TRUNCATED_BYTES); - return {}; + return std::nullopt; } return HexEncode(out, TRUNCATED_BYTES); } @@ -76,7 +77,39 @@ std::string LoadHmacKeyFromEnv(const std::string& env_var_name) { if (env_var_name.empty()) return {}; const char* val = std::getenv(env_var_name.c_str()); if (!val) return {}; - return std::string(val); + std::string raw(val); + if (raw.empty()) return {}; + + // Auto-detect base64url: operators typically store base64url-encoded keys + // because raw 32-byte binaries mangle through `.env` transport. A valid + // base64url-encoded 32-byte key is exactly 43 chars (no-padding form) or + // 44 with '=' padding. Accept either by trying the base64url decoder + // first and using the result only when it yields EXACTLY 32 bytes — any + // other length means the env value wasn't a base64url 32-byte key and we + // fall back to treating it as raw. + // + // Strip a trailing '=' padding char so 44-char padded forms also work. + // + // Exception containment (design spec §9 item 16): jwt::base::decode + // throws std::runtime_error on invalid input (illegal chars, bad length). + // Catch at this boundary and fall through to the raw-bytes interpretation + // — a malformed env value must not propagate as an exception into + // AuthManager::Start(), which would abort startup. + std::string candidate = raw; + while (!candidate.empty() && candidate.back() == '=') candidate.pop_back(); + try { + std::string decoded = + jwt::base::decode(candidate); + if (decoded.size() == 32) { + return decoded; + } + } catch (const std::exception& e) { + logging::Get()->debug("LoadHmacKeyFromEnv: base64url decode failed " + "for env var '{}' ({}); falling back to raw " + "bytes interpretation", + env_var_name, e.what()); + } + return raw; } } // namespace auth diff --git a/test/auth_foundation_test.h b/test/auth_foundation_test.h new file mode 100644 index 00000000..56ebe3d9 --- /dev/null +++ b/test/auth_foundation_test.h @@ -0,0 +1,211 @@ +#pragma once + +// Minimal test coverage for the auth-foundation pieces landed in this PR. +// Full Phase 1 / Phase 2 test suites (jwt_verifier_test, jwks_cache_test, +// auth_policy_matcher_test, etc.) are tracked in §13.1 of the design spec and +// land in later PRs. The coverage here is deliberately narrow — it pins the +// security-critical invariants introduced by the r3/r5 revisions so that a +// regression is caught in CI: +// +// - TokenHasher::Hash returns std::optional — never "" on failure +// (r3 finding #2: cross-token cache-key collision risk). +// - LoadHmacKeyFromEnv never propagates an exception from jwt::base::decode +// (r5 finding #2 / §9 item 16: exception containment at library boundary). +// - Base64url-encoded 32-byte env value is preferred over raw interpretation +// (§12.1 spec contract). + +#include "test_framework.h" +#include "auth/token_hasher.h" +#include "jwt-cpp/base.h" + +#include +#include + +namespace AuthFoundationTests { + +// ----------------------------------------------------------------------------- +// TokenHasher::Hash — returns std::optional, never empty-string sentinel. +// ----------------------------------------------------------------------------- +void TestHasherBasicDeterminism() { + std::cout << "\n[TEST] TokenHasher::Hash determinism + optional contract..." << std::endl; + try { + auth::TokenHasher hasher(std::string(32, 'k')); + + auto a1 = hasher.Hash("token-A"); + auto a2 = hasher.Hash("token-A"); + auto b = hasher.Hash("token-B"); + + bool has_values = a1.has_value() && a2.has_value() && b.has_value(); + bool deterministic = has_values && *a1 == *a2; + bool distinct = has_values && *a1 != *b; + bool hex128 = has_values && a1->size() == 32; // 128 bits = 32 hex chars + + bool pass = deterministic && distinct && hex128; + std::string err; + if (!has_values) err = "Hash() returned nullopt on valid input"; + else if (!deterministic) err = "Hash() non-deterministic: " + *a1 + " vs " + *a2; + else if (!distinct) err = "Hash() collision between distinct tokens"; + else if (!hex128) err = "Hash() output not 32 hex chars"; + + TestFramework::RecordTest("AuthFoundation: TokenHasher basic", + pass, err, + TestFramework::TestCategory::OTHER); + } catch (const std::exception& e) { + TestFramework::RecordTest("AuthFoundation: TokenHasher basic", + false, e.what(), + TestFramework::TestCategory::OTHER); + } +} + +// ----------------------------------------------------------------------------- +// LoadHmacKeyFromEnv — exception containment (§9 item 16). +// jwt::base::decode throws on invalid base64url input. The env-var loader +// MUST catch this and fall through to the raw-bytes interpretation. Without +// the try/catch, an invalid base64url env value would propagate as an +// uncaught exception into AuthManager::Start() and abort server startup. +// ----------------------------------------------------------------------------- +void TestLoadHmacKeyFromEnvDoesNotThrow() { + std::cout << "\n[TEST] LoadHmacKeyFromEnv exception containment..." << std::endl; + + // Preserve/restore the env var across the test. No bleedthrough to + // other tests (most of which run servers and don't touch this var). + const char* kVarName = "REACTOR_TEST_AUTH_BAD_KEY"; + auto restore_env = [](const char* name, const char* prev) { + if (prev) setenv(name, prev, 1); + else unsetenv(name); + }; + + try { + // Case 1: illegal-char base64url (@ is not a base64url alphabet char). + // This is a syntactically invalid input that jwt::base::decode throws on. + const char* prev = std::getenv(kVarName); + std::string saved = prev ? prev : ""; + setenv(kVarName, "@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@", 1); // 43 chars, all invalid + std::string bad_key; + bool threw = false; + try { + bad_key = auth::LoadHmacKeyFromEnv(kVarName); + } catch (const std::exception& e) { + threw = true; + } + // Contract: must not throw. Must return the raw env string as a + // fallback (auto-detect declined base64url → raw interpretation). + bool case1_pass = !threw && bad_key.size() == 43; + std::string case1_err = threw + ? "LoadHmacKeyFromEnv PROPAGATED exception — §9 item 16 violated" + : (case1_pass ? "" : "Expected raw-bytes fallback of length 43, got " + + std::to_string(bad_key.size())); + + // Case 2: length-1-remainder base64url (4n+1 chars — impossible shape). + // jwt::base::decode typically throws on this length pattern. + setenv(kVarName, "A", 1); + std::string short_key; + bool threw2 = false; + try { + short_key = auth::LoadHmacKeyFromEnv(kVarName); + } catch (const std::exception& e) { + threw2 = true; + } + bool case2_pass = !threw2 && short_key == "A"; + std::string case2_err = threw2 + ? "LoadHmacKeyFromEnv PROPAGATED exception on length-1 input" + : (case2_pass ? "" : "Expected raw-bytes fallback 'A', got '" + short_key + "'"); + + restore_env(kVarName, saved.empty() ? nullptr : saved.c_str()); + + bool pass = case1_pass && case2_pass; + std::string err; + if (!case1_pass) err = case1_err; + else if (!case2_pass) err = case2_err; + + TestFramework::RecordTest( + "AuthFoundation: LoadHmacKeyFromEnv contains jwt-cpp exceptions", + pass, err, TestFramework::TestCategory::OTHER); + } catch (const std::exception& e) { + restore_env(kVarName, std::getenv(kVarName)); + TestFramework::RecordTest( + "AuthFoundation: LoadHmacKeyFromEnv contains jwt-cpp exceptions", + false, std::string("unexpected test harness failure: ") + e.what(), + TestFramework::TestCategory::OTHER); + } +} + +// ----------------------------------------------------------------------------- +// LoadHmacKeyFromEnv — base64url auto-detect: 32-byte decode preferred. +// Per §12.1 / §5.1: a valid base64url-encoded 32-byte key is used decoded. +// Anything else (wrong length after decode, non-base64url chars) falls to raw. +// ----------------------------------------------------------------------------- +void TestLoadHmacKeyFromEnvAutoDetect() { + std::cout << "\n[TEST] LoadHmacKeyFromEnv base64url auto-detect..." << std::endl; + + const char* kVarName = "REACTOR_TEST_AUTH_GOOD_KEY"; + auto restore_env = [](const char* name, const char* prev) { + if (prev) setenv(name, prev, 1); + else unsetenv(name); + }; + + try { + // Derive the correct base64url encoding of 32 bytes of 0x41 via + // jwt-cpp's public helper — avoids a hand-computed constant that + // would drift if the encoder ever changed or get mis-hand-counted. + const std::string raw32(32, 'A'); // 32 bytes of 0x41 + std::string base64url_of_32_As = + jwt::base::encode(raw32); + // Strip any trailing '=' padding chars — LoadHmacKeyFromEnv's + // auto-detect supports both padded and no-padding forms but the + // contract is "exactly 32 bytes after decode" regardless. + while (!base64url_of_32_As.empty() && + base64url_of_32_As.back() == '=') { + base64url_of_32_As.pop_back(); + } + + const char* prev = std::getenv(kVarName); + std::string saved = prev ? prev : ""; + setenv(kVarName, base64url_of_32_As.c_str(), 1); + std::string decoded_key = auth::LoadHmacKeyFromEnv(kVarName); + + // Contract: must be interpreted as base64url → 32 bytes, NOT raw 43. + bool decoded_to_32 = decoded_key.size() == 32; + bool all_As = decoded_to_32 && + decoded_key.find_first_not_of('A') == std::string::npos; + + // Also check raw fallback: a 16-char string that decodes to 12 bytes + // (not 32) should fall back to raw. + setenv(kVarName, "AAAAAAAAAAAAAAAA", 1); // 16 chars base64url -> 12 bytes + std::string raw_fallback = auth::LoadHmacKeyFromEnv(kVarName); + bool raw_ok = raw_fallback == "AAAAAAAAAAAAAAAA"; + + restore_env(kVarName, saved.empty() ? nullptr : saved.c_str()); + + bool pass = all_As && raw_ok; + std::string err; + if (!decoded_to_32) { + err = "base64url 32-byte input not auto-detected; got size=" + + std::to_string(decoded_key.size()); + } else if (!all_As) { + err = "base64url decode produced wrong bytes"; + } else if (!raw_ok) { + err = "16-char input (decodes to 12 bytes) should fall to raw, got '" + + raw_fallback + "'"; + } + + TestFramework::RecordTest( + "AuthFoundation: LoadHmacKeyFromEnv base64url auto-detect", + pass, err, TestFramework::TestCategory::OTHER); + } catch (const std::exception& e) { + restore_env(kVarName, std::getenv(kVarName)); + TestFramework::RecordTest( + "AuthFoundation: LoadHmacKeyFromEnv base64url auto-detect", + false, std::string("unexpected exception: ") + e.what(), + TestFramework::TestCategory::OTHER); + } +} + +inline void RunAllTests() { + std::cout << "\n===== Auth Foundation Tests =====" << std::endl; + TestHasherBasicDeterminism(); + TestLoadHmacKeyFromEnvDoesNotThrow(); + TestLoadHmacKeyFromEnvAutoDetect(); +} + +} // namespace AuthFoundationTests diff --git a/test/run_test.cc b/test/run_test.cc index 0419c6ee..40463d6e 100644 --- a/test/run_test.cc +++ b/test/run_test.cc @@ -20,6 +20,7 @@ #include "circuit_breaker_wait_queue_drain_test.h" #include "circuit_breaker_observability_test.h" #include "circuit_breaker_reload_test.h" +#include "auth_foundation_test.h" #include "test_framework.h" #include #include @@ -106,6 +107,9 @@ void RunAllTest(){ // Run circuit-breaker hot-reload tests CircuitBreakerReloadTests::RunAllTests(); + // Run auth foundation tests (minimal — pins r3/r5 security invariants) + AuthFoundationTests::RunAllTests(); + std::cout << "====================================\n" << std::endl; } From 8d8068f3d604a2a4d7f9fe7d30833caf890d7d20 Mon Sep 17 00:00:00 2001 From: mwfj Date: Wed, 15 Apr 2026 23:34:16 +0800 Subject: [PATCH 03/17] Apply jwt-cpp lib --- server/jwt_decode.cc | 171 ------------------------------------------- 1 file changed, 171 deletions(-) delete mode 100644 server/jwt_decode.cc diff --git a/server/jwt_decode.cc b/server/jwt_decode.cc deleted file mode 100644 index 08aeddf7..00000000 --- a/server/jwt_decode.cc +++ /dev/null @@ -1,171 +0,0 @@ -#include "auth/jwt_decode.h" - -namespace auth { - -namespace { - -// Base64url alphabet (RFC 4648 §5): A-Z, a-z, 0-9, '-', '_'. -// std lookup table for decode: -1 means invalid. -int8_t DecodeChar(unsigned char c) { - if (c >= 'A' && c <= 'Z') return c - 'A'; - if (c >= 'a' && c <= 'z') return c - 'a' + 26; - if (c >= '0' && c <= '9') return c - '0' + 52; - if (c == '-') return 62; - if (c == '_') return 63; - return -1; -} - -const char kEncodeTable[] = - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; - -} // namespace - -std::string Base64UrlDecode(const std::string& input) { - std::string out; - if (input.empty()) return out; - - // 4 input chars -> 3 output bytes. Pad length to a multiple of 4 logically, - // but we don't need literal '=' chars — we just track how many bytes the - // final group produces. - size_t n = input.size(); - size_t remainder = n % 4; - if (remainder == 1) return {}; // Invalid length - - out.reserve((n * 3) / 4 + 2); - - uint32_t buf = 0; - int bits = 0; - for (size_t i = 0; i < n; ++i) { - int v = DecodeChar(static_cast(input[i])); - if (v < 0) return {}; // Invalid char - buf = (buf << 6) | static_cast(v); - bits += 6; - if (bits >= 8) { - bits -= 8; - out.push_back(static_cast((buf >> bits) & 0xFF)); - } - } - // Any remaining `bits` < 8 are the padding (discarded). - return out; -} - -std::string Base64UrlEncode(const std::string& input) { - std::string out; - if (input.empty()) return out; - out.reserve(((input.size() + 2) / 3) * 4); - - size_t i = 0; - while (i + 3 <= input.size()) { - uint32_t v = (static_cast(input[i]) << 16) | - (static_cast(input[i + 1]) << 8) | - static_cast(input[i + 2]); - out.push_back(kEncodeTable[(v >> 18) & 0x3F]); - out.push_back(kEncodeTable[(v >> 12) & 0x3F]); - out.push_back(kEncodeTable[(v >> 6) & 0x3F]); - out.push_back(kEncodeTable[v & 0x3F]); - i += 3; - } - size_t rem = input.size() - i; - if (rem == 1) { - uint32_t v = static_cast(input[i]); - out.push_back(kEncodeTable[(v >> 2) & 0x3F]); - out.push_back(kEncodeTable[(v << 4) & 0x3F]); - } else if (rem == 2) { - uint32_t v = (static_cast(input[i]) << 8) | - static_cast(input[i + 1]); - out.push_back(kEncodeTable[(v >> 10) & 0x3F]); - out.push_back(kEncodeTable[(v >> 4) & 0x3F]); - out.push_back(kEncodeTable[(v << 2) & 0x3F]); - } - return out; -} - -bool Decode(const std::string& token, JwtDecoded& out, std::string& err_out) { - out = {}; - if (token.empty()) { - err_out = "empty token"; - return false; - } - if (token.size() > MAX_JWT_BYTES) { - err_out = "token exceeds MAX_JWT_BYTES"; - return false; - } - - // Split on '.' — must produce exactly 3 segments for a signed JWT. - size_t dot1 = token.find('.'); - if (dot1 == std::string::npos) { - err_out = "token has no '.' separator (not a JWT)"; - return false; - } - size_t dot2 = token.find('.', dot1 + 1); - if (dot2 == std::string::npos) { - err_out = "token has fewer than 3 segments (may be alg=none — rejected)"; - return false; - } - // Reject a 4th segment. - if (token.find('.', dot2 + 1) != std::string::npos) { - err_out = "token has more than 3 '.' separators"; - return false; - } - - out.header_raw_b64 = token.substr(0, dot1); - out.payload_raw_b64 = token.substr(dot1 + 1, dot2 - dot1 - 1); - out.signature_raw_b64 = token.substr(dot2 + 1); - out.signing_input = token.substr(0, dot2); - - if (out.header_raw_b64.empty() || out.payload_raw_b64.empty() || - out.signature_raw_b64.empty()) { - err_out = "token has empty segment(s)"; - return false; - } - - std::string header_json = Base64UrlDecode(out.header_raw_b64); - if (header_json.empty()) { - err_out = "header base64url decode failed"; - return false; - } - std::string payload_json = Base64UrlDecode(out.payload_raw_b64); - if (payload_json.empty()) { - err_out = "payload base64url decode failed"; - return false; - } - - nlohmann::json header_parsed; - try { - header_parsed = nlohmann::json::parse(header_json); - } catch (const std::exception& e) { - err_out = "header JSON parse failed"; - return false; - } - try { - out.payload = nlohmann::json::parse(payload_json); - } catch (const std::exception& e) { - err_out = "payload JSON parse failed"; - return false; - } - - // Header field extraction (all optional from this layer's perspective). - if (header_parsed.is_object()) { - if (header_parsed.contains("alg") && header_parsed["alg"].is_string()) { - out.alg = header_parsed["alg"].get(); - } - if (header_parsed.contains("kid") && header_parsed["kid"].is_string()) { - out.kid = header_parsed["kid"].get(); - } - if (header_parsed.contains("typ") && header_parsed["typ"].is_string()) { - out.typ = header_parsed["typ"].get(); - } - } else { - err_out = "header is not a JSON object"; - return false; - } - - if (out.alg.empty()) { - err_out = "header `alg` is missing or not a string"; - return false; - } - - return true; -} - -} // namespace auth From 90776b734357416b0ed7488e8f2d478b9518dd52 Mon Sep 17 00:00:00 2001 From: mwfj Date: Wed, 15 Apr 2026 23:36:33 +0800 Subject: [PATCH 04/17] Apply jwt-cpp lib --- third_party/jwt-cpp/LICENSE | 21 + third_party/jwt-cpp/include/jwt-cpp/base.h | 356 ++ third_party/jwt-cpp/include/jwt-cpp/jwt.h | 4228 +++++++++++++++++ .../jwt-cpp/traits/nlohmann-json/defaults.h | 91 + .../jwt-cpp/traits/nlohmann-json/traits.h | 81 + 5 files changed, 4777 insertions(+) create mode 100644 third_party/jwt-cpp/LICENSE create mode 100644 third_party/jwt-cpp/include/jwt-cpp/base.h create mode 100644 third_party/jwt-cpp/include/jwt-cpp/jwt.h create mode 100644 third_party/jwt-cpp/include/jwt-cpp/traits/nlohmann-json/defaults.h create mode 100644 third_party/jwt-cpp/include/jwt-cpp/traits/nlohmann-json/traits.h diff --git a/third_party/jwt-cpp/LICENSE b/third_party/jwt-cpp/LICENSE new file mode 100644 index 00000000..7d583cec --- /dev/null +++ b/third_party/jwt-cpp/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Dominik Thalhammer + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third_party/jwt-cpp/include/jwt-cpp/base.h b/third_party/jwt-cpp/include/jwt-cpp/base.h new file mode 100644 index 00000000..7258b2e7 --- /dev/null +++ b/third_party/jwt-cpp/include/jwt-cpp/base.h @@ -0,0 +1,356 @@ +#ifndef JWT_CPP_BASE_H +#define JWT_CPP_BASE_H + +#include +#include +#include +#include +#include +#include + +#ifdef __has_cpp_attribute +#if __has_cpp_attribute(fallthrough) +#define JWT_FALLTHROUGH [[fallthrough]] +#endif +#endif + +#ifndef JWT_FALLTHROUGH +#define JWT_FALLTHROUGH +#endif + +namespace jwt { + /** + * \brief character maps when encoding and decoding + */ + namespace alphabet { + /** + * \brief valid list of character when working with [Base64](https://datatracker.ietf.org/doc/html/rfc4648#section-4) + * + * As directed in [X.509 Parameter](https://datatracker.ietf.org/doc/html/rfc7517#section-4.7) certificate chains are + * base64-encoded as per [Section 4 of RFC4648](https://datatracker.ietf.org/doc/html/rfc4648#section-4) + */ + struct base64 { + static const std::array& data() { + static constexpr std::array data{ + {'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', + 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', + 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', + 'w', 'x', 'y', 'z', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '+', '/'}}; + return data; + } + static const std::array& rdata() { + static constexpr std::array rdata{{ + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 62, -1, -1, -1, 63, + 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, -1, -1, -1, -1, -1, -1, -1, 0, 1, 2, 3, 4, 5, 6, + 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, -1, -1, -1, -1, -1, + -1, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, + 49, 50, 51, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + }}; + return rdata; + } + static const std::string& fill() { + static const std::string fill{"="}; + return fill; + } + }; + /** + * \brief valid list of character when working with [Base64URL](https://tools.ietf.org/html/rfc4648#section-5) + * + * As directed by [RFC 7519 Terminology](https://datatracker.ietf.org/doc/html/rfc7519#section-2) set the definition of Base64URL + * encoding as that in [RFC 7515](https://datatracker.ietf.org/doc/html/rfc7515#section-2) that states: + * + * > Base64 encoding using the URL- and filename-safe character set defined in + * > [Section 5 of RFC 4648 RFC4648](https://tools.ietf.org/html/rfc4648#section-5), with all trailing '=' characters omitted + */ + struct base64url { + static const std::array& data() { + static constexpr std::array data{ + {'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', + 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', + 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', + 'w', 'x', 'y', 'z', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '-', '_'}}; + return data; + } + static const std::array& rdata() { + static constexpr std::array rdata{{ + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 62, -1, -1, + 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, -1, -1, -1, -1, -1, -1, -1, 0, 1, 2, 3, 4, 5, 6, + 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, -1, -1, -1, -1, 63, + -1, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, + 49, 50, 51, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + }}; + return rdata; + } + static const std::string& fill() { + static const std::string fill{"%3d"}; + return fill; + } + }; + namespace helper { + /** + * \brief A General purpose base64url alphabet respecting the + * [URI Case Normalization](https://datatracker.ietf.org/doc/html/rfc3986#section-6.2.2.1) + * + * This is useful in situations outside of JWT encoding/decoding and is provided as a helper + */ + struct base64url_percent_encoding { + static const std::array& data() { + static constexpr std::array data{ + {'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', + 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', + 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', + 'w', 'x', 'y', 'z', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '-', '_'}}; + return data; + } + static const std::array& rdata() { + static constexpr std::array rdata{{ + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 62, -1, -1, + 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, -1, -1, -1, -1, -1, -1, -1, 0, 1, 2, 3, 4, 5, 6, + 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, -1, -1, -1, -1, 63, + -1, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, + 49, 50, 51, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + }}; + return rdata; + } + static const std::vector& fill() { + static const std::vector fill{"%3D", "%3d"}; + return fill; + } + }; + } // namespace helper + + inline uint32_t index(const std::array& rdata, char symbol) { + auto index = rdata[static_cast(symbol)]; + if (index <= -1) { throw std::runtime_error("Invalid input: not within alphabet"); } + return static_cast(index); + } + } // namespace alphabet + + /** + * \brief A collection of fellable functions for working with base64 and base64url + */ + namespace base { + namespace details { + struct padding { + size_t count = 0; + size_t length = 0; + + padding() = default; + padding(size_t count, size_t length) : count(count), length(length) {} + + padding operator+(const padding& p) { return padding(count + p.count, length + p.length); } + + friend bool operator==(const padding& lhs, const padding& rhs) { + return lhs.count == rhs.count && lhs.length == rhs.length; + } + }; + + inline padding count_padding(const std::string& base, const std::vector& fills) { + for (const auto& fill : fills) { + if (base.size() < fill.size()) continue; + // Does the end of the input exactly match the fill pattern? + if (base.substr(base.size() - fill.size()) == fill) { + return padding{1, fill.length()} + + count_padding(base.substr(0, base.size() - fill.size()), fills); + } + } + + return {}; + } + + inline std::string encode(const std::string& bin, const std::array& alphabet, + const std::string& fill) { + size_t size = bin.size(); + std::string res; + + // clear incomplete bytes + size_t fast_size = size - size % 3; + for (size_t i = 0; i < fast_size;) { + uint32_t octet_a = static_cast(bin[i++]); + uint32_t octet_b = static_cast(bin[i++]); + uint32_t octet_c = static_cast(bin[i++]); + + uint32_t triple = (octet_a << 0x10) + (octet_b << 0x08) + octet_c; + + res += alphabet[(triple >> 3 * 6) & 0x3F]; + res += alphabet[(triple >> 2 * 6) & 0x3F]; + res += alphabet[(triple >> 1 * 6) & 0x3F]; + res += alphabet[(triple >> 0 * 6) & 0x3F]; + } + + if (fast_size == size) return res; + + size_t mod = size % 3; + + uint32_t octet_a = fast_size < size ? static_cast(bin[fast_size++]) : 0; + uint32_t octet_b = fast_size < size ? static_cast(bin[fast_size++]) : 0; + uint32_t octet_c = fast_size < size ? static_cast(bin[fast_size++]) : 0; + + uint32_t triple = (octet_a << 0x10) + (octet_b << 0x08) + octet_c; + + switch (mod) { + case 1: + res += alphabet[(triple >> 3 * 6) & 0x3F]; + res += alphabet[(triple >> 2 * 6) & 0x3F]; + res += fill; + res += fill; + break; + case 2: + res += alphabet[(triple >> 3 * 6) & 0x3F]; + res += alphabet[(triple >> 2 * 6) & 0x3F]; + res += alphabet[(triple >> 1 * 6) & 0x3F]; + res += fill; + break; + default: break; + } + + return res; + } + + inline std::string decode(const std::string& base, const std::array& rdata, + const std::vector& fill) { + const auto pad = count_padding(base, fill); + if (pad.count > 2) throw std::runtime_error("Invalid input: too much fill"); + + const size_t size = base.size() - pad.length; + if ((size + pad.count) % 4 != 0) throw std::runtime_error("Invalid input: incorrect total size"); + + size_t out_size = size / 4 * 3; + std::string res; + res.reserve(out_size); + + auto get_sextet = [&](size_t offset) { return alphabet::index(rdata, base[offset]); }; + + size_t fast_size = size - size % 4; + for (size_t i = 0; i < fast_size;) { + uint32_t sextet_a = get_sextet(i++); + uint32_t sextet_b = get_sextet(i++); + uint32_t sextet_c = get_sextet(i++); + uint32_t sextet_d = get_sextet(i++); + + uint32_t triple = + (sextet_a << 3 * 6) + (sextet_b << 2 * 6) + (sextet_c << 1 * 6) + (sextet_d << 0 * 6); + + res += static_cast((triple >> 2 * 8) & 0xFFU); + res += static_cast((triple >> 1 * 8) & 0xFFU); + res += static_cast((triple >> 0 * 8) & 0xFFU); + } + + if (pad.count == 0) return res; + + uint32_t triple = (get_sextet(fast_size) << 3 * 6) + (get_sextet(fast_size + 1) << 2 * 6); + + switch (pad.count) { + case 1: + triple |= (get_sextet(fast_size + 2) << 1 * 6); + res += static_cast((triple >> 2 * 8) & 0xFFU); + res += static_cast((triple >> 1 * 8) & 0xFFU); + break; + case 2: res += static_cast((triple >> 2 * 8) & 0xFFU); break; + default: break; + } + + return res; + } + + inline std::string decode(const std::string& base, const std::array& rdata, + const std::string& fill) { + return decode(base, rdata, std::vector{fill}); + } + + inline std::string pad(const std::string& base, const std::string& fill) { + std::string padding; + switch (base.size() % 4) { + case 1: padding += fill; JWT_FALLTHROUGH; + case 2: padding += fill; JWT_FALLTHROUGH; + case 3: padding += fill; JWT_FALLTHROUGH; + default: break; + } + + return base + padding; + } + + inline std::string trim(const std::string& base, const std::string& fill) { + auto pos = base.find(fill); + return base.substr(0, pos); + } + } // namespace details + + /** + * \brief Generic base64 encoding + * + * A Generic base64 encode function that supports any "alphabet" + * such as jwt::alphabet::base64 + * + * \code + * const auto b64 = jwt::base::encode("example_data") + * \endcode + */ + template + std::string encode(const std::string& bin) { + return details::encode(bin, T::data(), T::fill()); + } + /** + * \brief Generic base64 decoding + * + * A Generic base64 decoding function that supports any "alphabet" + * such as jwt::alphabet::base64 + * + * \code + * const auto b64 = jwt::base::decode("ZXhhbXBsZV9kYXRh") + * \endcode + */ + template + std::string decode(const std::string& base) { + return details::decode(base, T::rdata(), T::fill()); + } + /** + * \brief Generic base64 padding + * + * A Generic base64 pad function that supports any "alphabet" + * such as jwt::alphabet::base64 + * + * \code + * const auto b64 = jwt::base::pad("ZXhhbXBsZV9kYQ") + * \endcode + */ + template + std::string pad(const std::string& base) { + return details::pad(base, T::fill()); + } + /** + * \brief Generic base64 trimming + * + * A Generic base64 trim function that supports any "alphabet" + * such as jwt::alphabet::base64 + * + * \code + * const auto b64 = jwt::base::trim("ZXhhbXBsZV9kYQ==") + * \endcode + */ + template + std::string trim(const std::string& base) { + return details::trim(base, T::fill()); + } + } // namespace base +} // namespace jwt + +#endif diff --git a/third_party/jwt-cpp/include/jwt-cpp/jwt.h b/third_party/jwt-cpp/include/jwt-cpp/jwt.h new file mode 100644 index 00000000..41ef6579 --- /dev/null +++ b/third_party/jwt-cpp/include/jwt-cpp/jwt.h @@ -0,0 +1,4228 @@ +#ifndef JWT_CPP_JWT_H +#define JWT_CPP_JWT_H + +#ifndef JWT_DISABLE_PICOJSON +#ifndef PICOJSON_USE_INT64 +#define PICOJSON_USE_INT64 +#endif +#include "picojson/picojson.h" +#endif + +#ifndef JWT_DISABLE_BASE64 +#include "base.h" +#endif + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if __cplusplus >= 201402L +#ifdef __has_include +#if __has_include() +#include +#endif +#endif +#endif + +#if OPENSSL_VERSION_NUMBER >= 0x30000000L // 3.0.0 +#define JWT_OPENSSL_3_0 +#include +#elif OPENSSL_VERSION_NUMBER >= 0x10101000L // 1.1.1 +#define JWT_OPENSSL_1_1_1 +#elif OPENSSL_VERSION_NUMBER >= 0x10100000L // 1.1.0 +#define JWT_OPENSSL_1_1_0 +#elif OPENSSL_VERSION_NUMBER >= 0x10000000L // 1.0.0 +#define JWT_OPENSSL_1_0_0 +#endif + +#if defined(LIBRESSL_VERSION_NUMBER) +#if LIBRESSL_VERSION_NUMBER >= 0x3050300fL +#define JWT_OPENSSL_1_1_0 +#else +#define JWT_OPENSSL_1_0_0 +#endif +#endif + +#if defined(LIBWOLFSSL_VERSION_HEX) +#define JWT_OPENSSL_1_1_1 +#endif + +#ifndef JWT_CLAIM_EXPLICIT +#define JWT_CLAIM_EXPLICIT explicit +#endif + +/** + * \brief JSON Web Token. + * + * A namespace to contain everything related to handling JSON Web Tokens, JWT for short, + * as a part of [RFC7519](https://tools.ietf.org/html/rfc7519), or alternatively for + * JWS (JSON Web Signature) from [RFC7515](https://tools.ietf.org/html/rfc7515) + */ +namespace jwt { + /** + * Default system time point in UTC + */ + using date = std::chrono::system_clock::time_point; + + /** + * \brief Everything related to error codes issued by the library + */ + namespace error { + struct signature_verification_exception : public std::system_error { + using system_error::system_error; + }; + struct signature_generation_exception : public std::system_error { + using system_error::system_error; + }; + struct rsa_exception : public std::system_error { + using system_error::system_error; + }; + struct ecdsa_exception : public std::system_error { + using system_error::system_error; + }; + struct token_verification_exception : public std::system_error { + using system_error::system_error; + }; + /** + * \brief Errors related to processing of RSA signatures + */ + enum class rsa_error { + ok = 0, + cert_load_failed = 10, + get_key_failed, + write_key_failed, + write_cert_failed, + convert_to_pem_failed, + load_key_bio_write, + load_key_bio_read, + create_mem_bio_failed, + no_key_provided, + set_rsa_failed, + create_context_failed + }; + /** + * \brief Error category for RSA errors + */ + inline std::error_category& rsa_error_category() { + class rsa_error_cat : public std::error_category { + public: + const char* name() const noexcept override { return "rsa_error"; }; + std::string message(int ev) const override { + switch (static_cast(ev)) { + case rsa_error::ok: return "no error"; + case rsa_error::cert_load_failed: return "error loading cert into memory"; + case rsa_error::get_key_failed: return "error getting key from certificate"; + case rsa_error::write_key_failed: return "error writing key data in PEM format"; + case rsa_error::write_cert_failed: return "error writing cert data in PEM format"; + case rsa_error::convert_to_pem_failed: return "failed to convert key to pem"; + case rsa_error::load_key_bio_write: return "failed to load key: bio write failed"; + case rsa_error::load_key_bio_read: return "failed to load key: bio read failed"; + case rsa_error::create_mem_bio_failed: return "failed to create memory bio"; + case rsa_error::no_key_provided: return "at least one of public or private key need to be present"; + case rsa_error::set_rsa_failed: return "set modulus and exponent to RSA failed"; + case rsa_error::create_context_failed: return "failed to create context"; + default: return "unknown RSA error"; + } + } + }; + static rsa_error_cat cat; + return cat; + } + /** + * \brief Converts JWT-CPP errors into generic STL error_codes + */ + inline std::error_code make_error_code(rsa_error e) { return {static_cast(e), rsa_error_category()}; } + /** + * \brief Errors related to processing of RSA signatures + */ + enum class ecdsa_error { + ok = 0, + load_key_bio_write = 10, + load_key_bio_read, + create_mem_bio_failed, + no_key_provided, + invalid_key_size, + invalid_key, + create_context_failed, + cert_load_failed, + get_key_failed, + write_key_failed, + write_cert_failed, + convert_to_pem_failed, + unknown_curve, + set_ecdsa_failed + }; + /** + * \brief Error category for ECDSA errors + */ + inline std::error_category& ecdsa_error_category() { + class ecdsa_error_cat : public std::error_category { + public: + const char* name() const noexcept override { return "ecdsa_error"; }; + std::string message(int ev) const override { + switch (static_cast(ev)) { + case ecdsa_error::ok: return "no error"; + case ecdsa_error::load_key_bio_write: return "failed to load key: bio write failed"; + case ecdsa_error::load_key_bio_read: return "failed to load key: bio read failed"; + case ecdsa_error::create_mem_bio_failed: return "failed to create memory bio"; + case ecdsa_error::no_key_provided: + return "at least one of public or private key need to be present"; + case ecdsa_error::invalid_key_size: return "invalid key size"; + case ecdsa_error::invalid_key: return "invalid key"; + case ecdsa_error::create_context_failed: return "failed to create context"; + case ecdsa_error::cert_load_failed: return "error loading cert into memory"; + case ecdsa_error::get_key_failed: return "error getting key from certificate"; + case ecdsa_error::write_key_failed: return "error writing key data in PEM format"; + case ecdsa_error::write_cert_failed: return "error writing cert data in PEM format"; + case ecdsa_error::convert_to_pem_failed: return "failed to convert key to pem"; + case ecdsa_error::unknown_curve: return "unknown curve"; + case ecdsa_error::set_ecdsa_failed: return "set parameters to ECDSA failed"; + default: return "unknown ECDSA error"; + } + } + }; + static ecdsa_error_cat cat; + return cat; + } + /** + * \brief Converts JWT-CPP errors into generic STL error_codes + */ + inline std::error_code make_error_code(ecdsa_error e) { return {static_cast(e), ecdsa_error_category()}; } + + /** + * \brief Errors related to verification of signatures + */ + enum class signature_verification_error { + ok = 0, + invalid_signature = 10, + create_context_failed, + verifyinit_failed, + verifyupdate_failed, + verifyfinal_failed, + get_key_failed, + set_rsa_pss_saltlen_failed, + signature_encoding_failed + }; + /** + * \brief Error category for verification errors + */ + inline std::error_category& signature_verification_error_category() { + class verification_error_cat : public std::error_category { + public: + const char* name() const noexcept override { return "signature_verification_error"; }; + std::string message(int ev) const override { + switch (static_cast(ev)) { + case signature_verification_error::ok: return "no error"; + case signature_verification_error::invalid_signature: return "invalid signature"; + case signature_verification_error::create_context_failed: + return "failed to verify signature: could not create context"; + case signature_verification_error::verifyinit_failed: + return "failed to verify signature: VerifyInit failed"; + case signature_verification_error::verifyupdate_failed: + return "failed to verify signature: VerifyUpdate failed"; + case signature_verification_error::verifyfinal_failed: + return "failed to verify signature: VerifyFinal failed"; + case signature_verification_error::get_key_failed: + return "failed to verify signature: Could not get key"; + case signature_verification_error::set_rsa_pss_saltlen_failed: + return "failed to verify signature: EVP_PKEY_CTX_set_rsa_pss_saltlen failed"; + case signature_verification_error::signature_encoding_failed: + return "failed to verify signature: i2d_ECDSA_SIG failed"; + default: return "unknown signature verification error"; + } + } + }; + static verification_error_cat cat; + return cat; + } + /** + * \brief Converts JWT-CPP errors into generic STL error_codes + */ + inline std::error_code make_error_code(signature_verification_error e) { + return {static_cast(e), signature_verification_error_category()}; + } + + /** + * \brief Errors related to signature generation errors + */ + enum class signature_generation_error { + ok = 0, + hmac_failed = 10, + create_context_failed, + signinit_failed, + signupdate_failed, + signfinal_failed, + ecdsa_do_sign_failed, + digestinit_failed, + digestupdate_failed, + digestfinal_failed, + rsa_padding_failed, + rsa_private_encrypt_failed, + get_key_failed, + set_rsa_pss_saltlen_failed, + signature_decoding_failed + }; + /** + * \brief Error category for signature generation errors + */ + inline std::error_category& signature_generation_error_category() { + class signature_generation_error_cat : public std::error_category { + public: + const char* name() const noexcept override { return "signature_generation_error"; }; + std::string message(int ev) const override { + switch (static_cast(ev)) { + case signature_generation_error::ok: return "no error"; + case signature_generation_error::hmac_failed: return "hmac failed"; + case signature_generation_error::create_context_failed: + return "failed to create signature: could not create context"; + case signature_generation_error::signinit_failed: + return "failed to create signature: SignInit failed"; + case signature_generation_error::signupdate_failed: + return "failed to create signature: SignUpdate failed"; + case signature_generation_error::signfinal_failed: + return "failed to create signature: SignFinal failed"; + case signature_generation_error::ecdsa_do_sign_failed: return "failed to generate ecdsa signature"; + case signature_generation_error::digestinit_failed: + return "failed to create signature: DigestInit failed"; + case signature_generation_error::digestupdate_failed: + return "failed to create signature: DigestUpdate failed"; + case signature_generation_error::digestfinal_failed: + return "failed to create signature: DigestFinal failed"; + case signature_generation_error::rsa_padding_failed: + return "failed to create signature: EVP_PKEY_CTX_set_rsa_padding failed"; + case signature_generation_error::rsa_private_encrypt_failed: + return "failed to create signature: RSA_private_encrypt failed"; + case signature_generation_error::get_key_failed: + return "failed to generate signature: Could not get key"; + case signature_generation_error::set_rsa_pss_saltlen_failed: + return "failed to create signature: EVP_PKEY_CTX_set_rsa_pss_saltlen failed"; + case signature_generation_error::signature_decoding_failed: + return "failed to create signature: d2i_ECDSA_SIG failed"; + default: return "unknown signature generation error"; + } + } + }; + static signature_generation_error_cat cat = {}; + return cat; + } + /** + * \brief Converts JWT-CPP errors into generic STL error_codes + */ + inline std::error_code make_error_code(signature_generation_error e) { + return {static_cast(e), signature_generation_error_category()}; + } + + /** + * \brief Errors related to token verification errors + */ + enum class token_verification_error { + ok = 0, + wrong_algorithm = 10, + missing_claim, + claim_type_missmatch, + claim_value_missmatch, + token_expired, + audience_missmatch + }; + /** + * \brief Error category for token verification errors + */ + inline std::error_category& token_verification_error_category() { + class token_verification_error_cat : public std::error_category { + public: + const char* name() const noexcept override { return "token_verification_error"; }; + std::string message(int ev) const override { + switch (static_cast(ev)) { + case token_verification_error::ok: return "no error"; + case token_verification_error::wrong_algorithm: return "wrong algorithm"; + case token_verification_error::missing_claim: return "decoded JWT is missing required claim(s)"; + case token_verification_error::claim_type_missmatch: + return "claim type does not match expected type"; + case token_verification_error::claim_value_missmatch: + return "claim value does not match expected value"; + case token_verification_error::token_expired: return "token expired"; + case token_verification_error::audience_missmatch: + return "token doesn't contain the required audience"; + default: return "unknown token verification error"; + } + } + }; + static token_verification_error_cat cat = {}; + return cat; + } + /** + * \brief Converts JWT-CPP errors into generic STL error_codes + */ + inline std::error_code make_error_code(token_verification_error e) { + return {static_cast(e), token_verification_error_category()}; + } + /** + * \brief Raises an exception if any JWT-CPP error codes are active + */ + inline void throw_if_error(std::error_code ec) { + if (ec) { + if (ec.category() == rsa_error_category()) throw rsa_exception(ec); + if (ec.category() == ecdsa_error_category()) throw ecdsa_exception(ec); + if (ec.category() == signature_verification_error_category()) + throw signature_verification_exception(ec); + if (ec.category() == signature_generation_error_category()) throw signature_generation_exception(ec); + if (ec.category() == token_verification_error_category()) throw token_verification_exception(ec); + } + } + } // namespace error +} // namespace jwt + +namespace std { + template<> + struct is_error_code_enum : true_type {}; + template<> + struct is_error_code_enum : true_type {}; + template<> + struct is_error_code_enum : true_type {}; + template<> + struct is_error_code_enum : true_type {}; + template<> + struct is_error_code_enum : true_type {}; +} // namespace std + +namespace jwt { + /** + * \brief A collection for working with certificates + * + * These _helpers_ are usefully when working with certificates OpenSSL APIs. + * For example, when dealing with JWKS (JSON Web Key Set)[https://tools.ietf.org/html/rfc7517] + * you maybe need to extract the modulus and exponent of an RSA Public Key. + */ + namespace helper { + /** + * \brief Handle class for EVP_PKEY structures + * + * Starting from OpenSSL 1.1.0, EVP_PKEY has internal reference counting. This handle class allows + * jwt-cpp to leverage that and thus safe an allocation for the control block in std::shared_ptr. + * The handle uses shared_ptr as a fallback on older versions. The behaviour should be identical between both. + */ + class evp_pkey_handle { + public: + /** + * \brief Creates a null key pointer. + */ + constexpr evp_pkey_handle() noexcept = default; +#ifdef JWT_OPENSSL_1_0_0 + /** + * \brief Construct a new handle. The handle takes ownership of the key. + * \param key The key to store + */ + explicit evp_pkey_handle(EVP_PKEY* key) { m_key = std::shared_ptr(key, EVP_PKEY_free); } + + EVP_PKEY* get() const noexcept { return m_key.get(); } + bool operator!() const noexcept { return m_key == nullptr; } + explicit operator bool() const noexcept { return m_key != nullptr; } + + private: + std::shared_ptr m_key{nullptr}; +#else + /** + * \brief Construct a new handle. The handle takes ownership of the key. + * \param key The key to store + */ + explicit constexpr evp_pkey_handle(EVP_PKEY* key) noexcept : m_key{key} {} + evp_pkey_handle(const evp_pkey_handle& other) : m_key{other.m_key} { + if (m_key != nullptr && EVP_PKEY_up_ref(m_key) != 1) throw std::runtime_error("EVP_PKEY_up_ref failed"); + } +// C++11 requires the body of a constexpr constructor to be empty +#if __cplusplus >= 201402L + constexpr +#endif + evp_pkey_handle(evp_pkey_handle&& other) noexcept + : m_key{other.m_key} { + other.m_key = nullptr; + } + evp_pkey_handle& operator=(const evp_pkey_handle& other) { + if (&other == this) return *this; + decrement_ref_count(m_key); + m_key = other.m_key; + increment_ref_count(m_key); + return *this; + } + evp_pkey_handle& operator=(evp_pkey_handle&& other) noexcept { + if (&other == this) return *this; + decrement_ref_count(m_key); + m_key = other.m_key; + other.m_key = nullptr; + return *this; + } + evp_pkey_handle& operator=(EVP_PKEY* key) { + decrement_ref_count(m_key); + m_key = key; + increment_ref_count(m_key); + return *this; + } + ~evp_pkey_handle() noexcept { decrement_ref_count(m_key); } + + EVP_PKEY* get() const noexcept { return m_key; } + bool operator!() const noexcept { return m_key == nullptr; } + explicit operator bool() const noexcept { return m_key != nullptr; } + + private: + EVP_PKEY* m_key{nullptr}; + + static void increment_ref_count(EVP_PKEY* key) { + if (key != nullptr && EVP_PKEY_up_ref(key) != 1) throw std::runtime_error("EVP_PKEY_up_ref failed"); + } + static void decrement_ref_count(EVP_PKEY* key) noexcept { + if (key != nullptr) EVP_PKEY_free(key); + } +#endif + }; + + inline std::unique_ptr make_mem_buf_bio() { + return std::unique_ptr(BIO_new(BIO_s_mem()), BIO_free_all); + } + + inline std::unique_ptr make_mem_buf_bio(const std::string& data) { + return std::unique_ptr( +#if OPENSSL_VERSION_NUMBER <= 0x10100003L + BIO_new_mem_buf(const_cast(data.data()), static_cast(data.size())), BIO_free_all +#else + BIO_new_mem_buf(data.data(), static_cast(data.size())), BIO_free_all +#endif + ); + } + + template + std::string write_bio_to_string(std::unique_ptr& bio_out, std::error_code& ec) { + char* ptr = nullptr; + auto len = BIO_get_mem_data(bio_out.get(), &ptr); + if (len <= 0 || ptr == nullptr) { + ec = error_category::convert_to_pem_failed; + return {}; + } + return {ptr, static_cast(len)}; + } + + inline std::unique_ptr make_evp_md_ctx() { + return +#ifdef JWT_OPENSSL_1_0_0 + std::unique_ptr(EVP_MD_CTX_create(), &EVP_MD_CTX_destroy); +#else + std::unique_ptr(EVP_MD_CTX_new(), &EVP_MD_CTX_free); +#endif + } + + /** + * \brief Extract the public key of a pem certificate + * + * \tparam error_category jwt::error enum category to match with the keys being used + * \param certstr String containing the certificate encoded as pem + * \param pw Password used to decrypt certificate (leave empty if not encrypted) + * \param ec error_code for error_detection (gets cleared if no error occurred) + */ + template + std::string extract_pubkey_from_cert(const std::string& certstr, const std::string& pw, std::error_code& ec) { + ec.clear(); + auto certbio = make_mem_buf_bio(certstr); + auto keybio = make_mem_buf_bio(); + if (!certbio || !keybio) { + ec = error_category::create_mem_bio_failed; + return {}; + } + + std::unique_ptr cert( + PEM_read_bio_X509(certbio.get(), nullptr, nullptr, const_cast(pw.c_str())), X509_free); + if (!cert) { + ec = error_category::cert_load_failed; + return {}; + } + std::unique_ptr key(X509_get_pubkey(cert.get()), EVP_PKEY_free); + if (!key) { + ec = error_category::get_key_failed; + return {}; + } + if (PEM_write_bio_PUBKEY(keybio.get(), key.get()) == 0) { + ec = error_category::write_key_failed; + return {}; + } + + return write_bio_to_string(keybio, ec); + } + + /** + * \brief Extract the public key of a pem certificate + * + * \tparam error_category jwt::error enum category to match with the keys being used + * \param certstr String containing the certificate encoded as pem + * \param pw Password used to decrypt certificate (leave empty if not encrypted) + * \throw templated error_category's type exception if an error occurred + */ + template + std::string extract_pubkey_from_cert(const std::string& certstr, const std::string& pw = "") { + std::error_code ec; + auto res = extract_pubkey_from_cert(certstr, pw, ec); + error::throw_if_error(ec); + return res; + } + + /** + * \brief Convert the certificate provided as DER to PEM. + * + * \param cert_der_str String containing the certificate encoded as base64 DER + * \param ec error_code for error_detection (gets cleared if no error occurs) + */ + inline std::string convert_der_to_pem(const std::string& cert_der_str, std::error_code& ec) { + ec.clear(); + + auto c_str = reinterpret_cast(cert_der_str.c_str()); + + std::unique_ptr cert( + d2i_X509(NULL, &c_str, static_cast(cert_der_str.size())), X509_free); + auto certbio = make_mem_buf_bio(); + if (!cert || !certbio) { + ec = error::rsa_error::create_mem_bio_failed; + return {}; + } + + if (!PEM_write_bio_X509(certbio.get(), cert.get())) { + ec = error::rsa_error::write_cert_failed; + return {}; + } + + return write_bio_to_string(certbio, ec); + } + + /** + * \brief Convert the certificate provided as base64 DER to PEM. + * + * This is useful when using with JWKs as x5c claim is encoded as base64 DER. More info + * [here](https://tools.ietf.org/html/rfc7517#section-4.7). + * + * \tparam Decode is callable, taking a string_type and returns a string_type. + * It should ensure the padding of the input and then base64 decode and return + * the results. + * + * \param cert_base64_der_str String containing the certificate encoded as base64 DER + * \param decode The function to decode the cert + * \param ec error_code for error_detection (gets cleared if no error occurs) + */ + template + std::string convert_base64_der_to_pem(const std::string& cert_base64_der_str, Decode decode, + std::error_code& ec) { + ec.clear(); + const auto decoded_str = decode(cert_base64_der_str); + return convert_der_to_pem(decoded_str, ec); + } + + /** + * \brief Convert the certificate provided as base64 DER to PEM. + * + * This is useful when using with JWKs as x5c claim is encoded as base64 DER. More info + * [here](https://tools.ietf.org/html/rfc7517#section-4.7) + * + * \tparam Decode is callable, taking a string_type and returns a string_type. + * It should ensure the padding of the input and then base64 decode and return + * the results. + * + * \param cert_base64_der_str String containing the certificate encoded as base64 DER + * \param decode The function to decode the cert + * \throw rsa_exception if an error occurred + */ + template + std::string convert_base64_der_to_pem(const std::string& cert_base64_der_str, Decode decode) { + std::error_code ec; + auto res = convert_base64_der_to_pem(cert_base64_der_str, std::move(decode), ec); + error::throw_if_error(ec); + return res; + } + + /** + * \brief Convert the certificate provided as DER to PEM. + * + * \param cert_der_str String containing the DER certificate + * \throw rsa_exception if an error occurred + */ + inline std::string convert_der_to_pem(const std::string& cert_der_str) { + std::error_code ec; + auto res = convert_der_to_pem(cert_der_str, ec); + error::throw_if_error(ec); + return res; + } + +#ifndef JWT_DISABLE_BASE64 + /** + * \brief Convert the certificate provided as base64 DER to PEM. + * + * This is useful when using with JWKs as x5c claim is encoded as base64 DER. More info + * [here](https://tools.ietf.org/html/rfc7517#section-4.7) + * + * \param cert_base64_der_str String containing the certificate encoded as base64 DER + * \param ec error_code for error_detection (gets cleared if no error occurs) + */ + inline std::string convert_base64_der_to_pem(const std::string& cert_base64_der_str, std::error_code& ec) { + auto decode = [](const std::string& token) { + return base::decode(base::pad(token)); + }; + return convert_base64_der_to_pem(cert_base64_der_str, std::move(decode), ec); + } + + /** + * \brief Convert the certificate provided as base64 DER to PEM. + * + * This is useful when using with JWKs as x5c claim is encoded as base64 DER. More info + * [here](https://tools.ietf.org/html/rfc7517#section-4.7) + * + * \param cert_base64_der_str String containing the certificate encoded as base64 DER + * \throw rsa_exception if an error occurred + */ + inline std::string convert_base64_der_to_pem(const std::string& cert_base64_der_str) { + std::error_code ec; + auto res = convert_base64_der_to_pem(cert_base64_der_str, ec); + error::throw_if_error(ec); + return res; + } +#endif + /** + * \brief Load a public key from a string. + * + * The string should contain a pem encoded certificate or public key + * + * \tparam error_category jwt::error enum category to match with the keys being used + * \param key String containing the certificate encoded as pem + * \param password Password used to decrypt certificate (leave empty if not encrypted) + * \param ec error_code for error_detection (gets cleared if no error occurs) + */ + template + evp_pkey_handle load_public_key_from_string(const std::string& key, const std::string& password, + std::error_code& ec) { + ec.clear(); + auto pubkey_bio = make_mem_buf_bio(); + if (!pubkey_bio) { + ec = error_category::create_mem_bio_failed; + return {}; + } + if (key.substr(0, 27) == "-----BEGIN CERTIFICATE-----") { + auto epkey = helper::extract_pubkey_from_cert(key, password, ec); + if (ec) return {}; + const int len = static_cast(epkey.size()); + if (BIO_write(pubkey_bio.get(), epkey.data(), len) != len) { + ec = error_category::load_key_bio_write; + return {}; + } + } else { + const int len = static_cast(key.size()); + if (BIO_write(pubkey_bio.get(), key.data(), len) != len) { + ec = error_category::load_key_bio_write; + return {}; + } + } + + evp_pkey_handle pkey(PEM_read_bio_PUBKEY( + pubkey_bio.get(), nullptr, nullptr, + (void*)password.data())); // NOLINT(google-readability-casting) requires `const_cast` + if (!pkey) ec = error_category::load_key_bio_read; + return pkey; + } + + /** + * \brief Load a public key from a string. + * + * The string should contain a pem encoded certificate or public key + * + * \tparam error_category jwt::error enum category to match with the keys being used + * \param key String containing the certificate encoded as pem + * \param password Password used to decrypt certificate (leave empty if not encrypted) + * \throw Templated error_category's type exception if an error occurred + */ + template + inline evp_pkey_handle load_public_key_from_string(const std::string& key, const std::string& password = "") { + std::error_code ec; + auto res = load_public_key_from_string(key, password, ec); + error::throw_if_error(ec); + return res; + } + + /** + * \brief Load a private key from a string. + * + * \tparam error_category jwt::error enum category to match with the keys being used + * \param key String containing a private key as pem + * \param password Password used to decrypt key (leave empty if not encrypted) + * \param ec error_code for error_detection (gets cleared if no error occurs) + */ + template + inline evp_pkey_handle load_private_key_from_string(const std::string& key, const std::string& password, + std::error_code& ec) { + ec.clear(); + auto private_key_bio = make_mem_buf_bio(); + if (!private_key_bio) { + ec = error_category::create_mem_bio_failed; + return {}; + } + const int len = static_cast(key.size()); + if (BIO_write(private_key_bio.get(), key.data(), len) != len) { + ec = error_category::load_key_bio_write; + return {}; + } + evp_pkey_handle pkey( + PEM_read_bio_PrivateKey(private_key_bio.get(), nullptr, nullptr, const_cast(password.c_str()))); + if (!pkey) ec = error_category::load_key_bio_read; + return pkey; + } + + /** + * \brief Load a private key from a string. + * + * \tparam error_category jwt::error enum category to match with the keys being used + * \param key String containing a private key as pem + * \param password Password used to decrypt key (leave empty if not encrypted) + * \throw Templated error_category's type exception if an error occurred + */ + template + inline evp_pkey_handle load_private_key_from_string(const std::string& key, const std::string& password = "") { + std::error_code ec; + auto res = load_private_key_from_string(key, password, ec); + error::throw_if_error(ec); + return res; + } + + /** + * \brief Load a public key from a string. + * + * The string should contain a pem encoded certificate or public key + * + * \deprecated Use the templated version helper::load_private_key_from_string with error::ecdsa_error + * + * \param key String containing the certificate encoded as pem + * \param password Password used to decrypt certificate (leave empty if not encrypted) + * \param ec error_code for error_detection (gets cleared if no error occurs) + */ + inline evp_pkey_handle load_public_ec_key_from_string(const std::string& key, const std::string& password, + std::error_code& ec) { + return load_public_key_from_string(key, password, ec); + } + + /** + * Convert a OpenSSL BIGNUM to a std::string + * \param bn BIGNUM to convert + * \return bignum as string + */ + inline +#ifdef JWT_OPENSSL_1_0_0 + std::string + bn2raw(BIGNUM* bn) +#else + std::string + bn2raw(const BIGNUM* bn) +#endif + { + std::string res(BN_num_bytes(bn), '\0'); + BN_bn2bin(bn, (unsigned char*)res.data()); // NOLINT(google-readability-casting) requires `const_cast` + return res; + } + /** + * Convert an std::string to a OpenSSL BIGNUM + * \param raw String to convert + * \param ec error_code for error_detection (gets cleared if no error occurs) + * \return BIGNUM representation + */ + inline std::unique_ptr raw2bn(const std::string& raw, std::error_code& ec) { + auto bn = + BN_bin2bn(reinterpret_cast(raw.data()), static_cast(raw.size()), nullptr); + // https://www.openssl.org/docs/man1.1.1/man3/BN_bin2bn.html#RETURN-VALUES + if (!bn) { + ec = error::rsa_error::set_rsa_failed; + return {nullptr, BN_free}; + } + return {bn, BN_free}; + } + /** + * Convert an std::string to a OpenSSL BIGNUM + * \param raw String to convert + * \return BIGNUM representation + */ + inline std::unique_ptr raw2bn(const std::string& raw) { + std::error_code ec; + auto res = raw2bn(raw, ec); + error::throw_if_error(ec); + return res; + } + + /** + * \brief Load a public key from a string. + * + * The string should contain a pem encoded certificate or public key + * + * \deprecated Use the templated version helper::load_private_key_from_string with error::ecdsa_error + * + * \param key String containing the certificate or key encoded as pem + * \param password Password used to decrypt certificate or key (leave empty if not encrypted) + * \throw ecdsa_exception if an error occurred + */ + inline evp_pkey_handle load_public_ec_key_from_string(const std::string& key, + const std::string& password = "") { + std::error_code ec; + auto res = load_public_key_from_string(key, password, ec); + error::throw_if_error(ec); + return res; + } + + /** + * \brief Load a private key from a string. + * + * \deprecated Use the templated version helper::load_private_key_from_string with error::ecdsa_error + * + * \param key String containing a private key as pem + * \param password Password used to decrypt key (leave empty if not encrypted) + * \param ec error_code for error_detection (gets cleared if no error occurs) + */ + inline evp_pkey_handle load_private_ec_key_from_string(const std::string& key, const std::string& password, + std::error_code& ec) { + return load_private_key_from_string(key, password, ec); + } + + /** + * \brief create public key from modulus and exponent. This is defined in + * [RFC 7518 Section 6.3](https://www.rfc-editor.org/rfc/rfc7518#section-6.3) + * Using the required "n" (Modulus) Parameter and "e" (Exponent) Parameter. + * + * \tparam Decode is callable, taking a string_type and returns a string_type. + * It should ensure the padding of the input and then base64url decode and + * return the results. + * \param modulus string containing base64url encoded modulus + * \param exponent string containing base64url encoded exponent + * \param decode The function to decode the RSA parameters + * \param ec error_code for error_detection (gets cleared if no error occur + * \return public key in PEM format + */ + template + std::string create_public_key_from_rsa_components(const std::string& modulus, const std::string& exponent, + Decode decode, std::error_code& ec) { + ec.clear(); + auto decoded_modulus = decode(modulus); + auto decoded_exponent = decode(exponent); + + auto n = helper::raw2bn(decoded_modulus, ec); + if (ec) return {}; + auto e = helper::raw2bn(decoded_exponent, ec); + if (ec) return {}; + +#if defined(JWT_OPENSSL_3_0) + // OpenSSL deprecated mutable keys and there is a new way for making them + // https://mta.openssl.org/pipermail/openssl-users/2021-July/013994.html + // https://www.openssl.org/docs/man3.1/man3/OSSL_PARAM_BLD_new.html#Example-2 + std::unique_ptr param_bld(OSSL_PARAM_BLD_new(), + OSSL_PARAM_BLD_free); + if (!param_bld) { + ec = error::rsa_error::create_context_failed; + return {}; + } + + if (OSSL_PARAM_BLD_push_BN(param_bld.get(), "n", n.get()) != 1 || + OSSL_PARAM_BLD_push_BN(param_bld.get(), "e", e.get()) != 1) { + ec = error::rsa_error::set_rsa_failed; + return {}; + } + + std::unique_ptr params(OSSL_PARAM_BLD_to_param(param_bld.get()), + OSSL_PARAM_free); + if (!params) { + ec = error::rsa_error::set_rsa_failed; + return {}; + } + + std::unique_ptr ctx( + EVP_PKEY_CTX_new_from_name(nullptr, "RSA", nullptr), EVP_PKEY_CTX_free); + if (!ctx) { + ec = error::rsa_error::create_context_failed; + return {}; + } + + // https://www.openssl.org/docs/man3.0/man3/EVP_PKEY_fromdata.html#EXAMPLES + // Error codes based on https://www.openssl.org/docs/manmaster/man3/EVP_PKEY_fromdata_init.html#RETURN-VALUES + EVP_PKEY* pkey = NULL; + if (EVP_PKEY_fromdata_init(ctx.get()) <= 0 || + EVP_PKEY_fromdata(ctx.get(), &pkey, EVP_PKEY_KEYPAIR, params.get()) <= 0) { + // It's unclear if this can fail after allocating but free it anyways + // https://www.openssl.org/docs/man3.0/man3/EVP_PKEY_fromdata.html + EVP_PKEY_free(pkey); + + ec = error::rsa_error::cert_load_failed; + return {}; + } + + // Transfer ownership so we get ref counter and cleanup + evp_pkey_handle rsa(pkey); + +#else + std::unique_ptr rsa(RSA_new(), RSA_free); + +#if defined(JWT_OPENSSL_1_1_1) || defined(JWT_OPENSSL_1_1_0) + // After this RSA_free will also free the n and e big numbers + // See https://github.com/Thalhammer/jwt-cpp/pull/298#discussion_r1282619186 + if (RSA_set0_key(rsa.get(), n.get(), e.get(), nullptr) == 1) { + // This can only fail we passed in NULL for `n` or `e` + // https://github.com/openssl/openssl/blob/d6e4056805f54bb1a0ef41fa3a6a35b70c94edba/crypto/rsa/rsa_lib.c#L396 + // So to make sure there is no memory leak, we hold the references + n.release(); + e.release(); + } else { + ec = error::rsa_error::set_rsa_failed; + return {}; + } +#elif defined(JWT_OPENSSL_1_0_0) + rsa->e = e.release(); + rsa->n = n.release(); + rsa->d = nullptr; +#endif +#endif + + auto pub_key_bio = make_mem_buf_bio(); + if (!pub_key_bio) { + ec = error::rsa_error::create_mem_bio_failed; + return {}; + } + + auto write_pem_to_bio = +#if defined(JWT_OPENSSL_3_0) + // https://www.openssl.org/docs/man3.1/man3/PEM_write_bio_RSA_PUBKEY.html + &PEM_write_bio_PUBKEY; +#else + &PEM_write_bio_RSA_PUBKEY; +#endif + if (write_pem_to_bio(pub_key_bio.get(), rsa.get()) != 1) { + ec = error::rsa_error::load_key_bio_write; + return {}; + } + + return write_bio_to_string(pub_key_bio, ec); + } + + /** + * Create public key from modulus and exponent. This is defined in + * [RFC 7518 Section 6.3](https://www.rfc-editor.org/rfc/rfc7518#section-6.3) + * Using the required "n" (Modulus) Parameter and "e" (Exponent) Parameter. + * + * \tparam Decode is callable, taking a string_type and returns a string_type. + * It should ensure the padding of the input and then base64url decode and + * return the results. + * \param modulus string containing base64url encoded modulus + * \param exponent string containing base64url encoded exponent + * \param decode The function to decode the RSA parameters + * \return public key in PEM format + */ + template + std::string create_public_key_from_rsa_components(const std::string& modulus, const std::string& exponent, + Decode decode) { + std::error_code ec; + auto res = create_public_key_from_rsa_components(modulus, exponent, decode, ec); + error::throw_if_error(ec); + return res; + } + +#ifndef JWT_DISABLE_BASE64 + /** + * Create public key from modulus and exponent. This is defined in + * [RFC 7518 Section 6.3](https://www.rfc-editor.org/rfc/rfc7518#section-6.3) + * Using the required "n" (Modulus) Parameter and "e" (Exponent) Parameter. + * + * \param modulus string containing base64 encoded modulus + * \param exponent string containing base64 encoded exponent + * \param ec error_code for error_detection (gets cleared if no error occur + * \return public key in PEM format + */ + inline std::string create_public_key_from_rsa_components(const std::string& modulus, + const std::string& exponent, std::error_code& ec) { + auto decode = [](const std::string& token) { + return base::decode(base::pad(token)); + }; + return create_public_key_from_rsa_components(modulus, exponent, std::move(decode), ec); + } + /** + * Create public key from modulus and exponent. This is defined in + * [RFC 7518 Section 6.3](https://www.rfc-editor.org/rfc/rfc7518#section-6.3) + * Using the required "n" (Modulus) Parameter and "e" (Exponent) Parameter. + * + * \param modulus string containing base64url encoded modulus + * \param exponent string containing base64url encoded exponent + * \return public key in PEM format + */ + inline std::string create_public_key_from_rsa_components(const std::string& modulus, + const std::string& exponent) { + std::error_code ec; + auto res = create_public_key_from_rsa_components(modulus, exponent, ec); + error::throw_if_error(ec); + return res; + } +#endif + /** + * \brief Load a private key from a string. + * + * \deprecated Use the templated version helper::load_private_key_from_string with error::ecdsa_error + * + * \param key String containing a private key as pem + * \param password Password used to decrypt key (leave empty if not encrypted) + * \throw ecdsa_exception if an error occurred + */ + inline evp_pkey_handle load_private_ec_key_from_string(const std::string& key, + const std::string& password = "") { + std::error_code ec; + auto res = load_private_key_from_string(key, password, ec); + error::throw_if_error(ec); + return res; + } + +#if defined(JWT_OPENSSL_3_0) + + /** + * \brief Convert a curve name to a group name. + * + * \param curve string containing curve name + * \param ec error_code for error_detection + * \return group name + */ + inline std::string curve2group(const std::string curve, std::error_code& ec) { + if (curve == "P-256") { + return "prime256v1"; + } else if (curve == "P-384") { + return "secp384r1"; + } else if (curve == "P-521") { + return "secp521r1"; + } else { + ec = jwt::error::ecdsa_error::unknown_curve; + return {}; + } + } + +#else + + /** + * \brief Convert a curve name to an ID. + * + * \param curve string containing curve name + * \param ec error_code for error_detection + * \return ID + */ + inline int curve2nid(const std::string curve, std::error_code& ec) { + if (curve == "P-256") { + return NID_X9_62_prime256v1; + } else if (curve == "P-384") { + return NID_secp384r1; + } else if (curve == "P-521") { + return NID_secp521r1; + } else { + ec = jwt::error::ecdsa_error::unknown_curve; + return {}; + } + } + +#endif + + /** + * Create public key from curve name and coordinates. This is defined in + * [RFC 7518 Section 6.2](https://www.rfc-editor.org/rfc/rfc7518#section-6.2) + * Using the required "crv" (Curve), "x" (X Coordinate) and "y" (Y Coordinate) Parameters. + * + * \tparam Decode is callable, taking a string_type and returns a string_type. + * It should ensure the padding of the input and then base64url decode and + * return the results. + * \param curve string containing curve name + * \param x string containing base64url encoded x coordinate + * \param y string containing base64url encoded y coordinate + * \param decode The function to decode the RSA parameters + * \param ec error_code for error_detection (gets cleared if no error occur + * \return public key in PEM format + */ + template + std::string create_public_key_from_ec_components(const std::string& curve, const std::string& x, + const std::string& y, Decode decode, std::error_code& ec) { + ec.clear(); + auto decoded_x = decode(x); + auto decoded_y = decode(y); + +#if defined(JWT_OPENSSL_3_0) + // OpenSSL deprecated mutable keys and there is a new way for making them + // https://mta.openssl.org/pipermail/openssl-users/2021-July/013994.html + // https://www.openssl.org/docs/man3.1/man3/OSSL_PARAM_BLD_new.html#Example-2 + std::unique_ptr param_bld(OSSL_PARAM_BLD_new(), + OSSL_PARAM_BLD_free); + if (!param_bld) { + ec = error::ecdsa_error::create_context_failed; + return {}; + } + + std::string group = helper::curve2group(curve, ec); + if (ec) return {}; + + // https://github.com/openssl/openssl/issues/16270#issuecomment-895734092 + std::string pub = std::string("\x04").append(decoded_x).append(decoded_y); + + if (OSSL_PARAM_BLD_push_utf8_string(param_bld.get(), "group", group.data(), group.size()) != 1 || + OSSL_PARAM_BLD_push_octet_string(param_bld.get(), "pub", pub.data(), pub.size()) != 1) { + ec = error::ecdsa_error::set_ecdsa_failed; + return {}; + } + + std::unique_ptr params(OSSL_PARAM_BLD_to_param(param_bld.get()), + OSSL_PARAM_free); + if (!params) { + ec = error::ecdsa_error::set_ecdsa_failed; + return {}; + } + + std::unique_ptr ctx( + EVP_PKEY_CTX_new_from_name(nullptr, "EC", nullptr), EVP_PKEY_CTX_free); + if (!ctx) { + ec = error::ecdsa_error::create_context_failed; + return {}; + } + + // https://www.openssl.org/docs/man3.0/man3/EVP_PKEY_fromdata.html#EXAMPLES + // Error codes based on https://www.openssl.org/docs/manmaster/man3/EVP_PKEY_fromdata_init.html#RETURN-VALUES + EVP_PKEY* pkey = NULL; + if (EVP_PKEY_fromdata_init(ctx.get()) <= 0 || + EVP_PKEY_fromdata(ctx.get(), &pkey, EVP_PKEY_KEYPAIR, params.get()) <= 0) { + // It's unclear if this can fail after allocating but free it anyways + // https://www.openssl.org/docs/man3.0/man3/EVP_PKEY_fromdata.html + EVP_PKEY_free(pkey); + + ec = error::ecdsa_error::cert_load_failed; + return {}; + } + + // Transfer ownership so we get ref counter and cleanup + evp_pkey_handle ecdsa(pkey); + +#else + int nid = helper::curve2nid(curve, ec); + if (ec) return {}; + + auto qx = helper::raw2bn(decoded_x, ec); + if (ec) return {}; + auto qy = helper::raw2bn(decoded_y, ec); + if (ec) return {}; + + std::unique_ptr ecgroup(EC_GROUP_new_by_curve_name(nid), EC_GROUP_free); + if (!ecgroup) { + ec = error::ecdsa_error::set_ecdsa_failed; + return {}; + } + + EC_GROUP_set_asn1_flag(ecgroup.get(), OPENSSL_EC_NAMED_CURVE); + + std::unique_ptr ecpoint(EC_POINT_new(ecgroup.get()), EC_POINT_free); + if (!ecpoint || + EC_POINT_set_affine_coordinates_GFp(ecgroup.get(), ecpoint.get(), qx.get(), qy.get(), nullptr) != 1) { + ec = error::ecdsa_error::set_ecdsa_failed; + return {}; + } + + std::unique_ptr ecdsa(EC_KEY_new(), EC_KEY_free); + if (!ecdsa || EC_KEY_set_group(ecdsa.get(), ecgroup.get()) != 1 || + EC_KEY_set_public_key(ecdsa.get(), ecpoint.get()) != 1) { + ec = error::ecdsa_error::set_ecdsa_failed; + return {}; + } + +#endif + + auto pub_key_bio = make_mem_buf_bio(); + if (!pub_key_bio) { + ec = error::ecdsa_error::create_mem_bio_failed; + return {}; + } + + auto write_pem_to_bio = +#if defined(JWT_OPENSSL_3_0) + // https://www.openssl.org/docs/man3.1/man3/PEM_write_bio_EC_PUBKEY.html + &PEM_write_bio_PUBKEY; +#else + &PEM_write_bio_EC_PUBKEY; +#endif + if (write_pem_to_bio(pub_key_bio.get(), ecdsa.get()) != 1) { + ec = error::ecdsa_error::load_key_bio_write; + return {}; + } + + return write_bio_to_string(pub_key_bio, ec); + } + + /** + * Create public key from curve name and coordinates. This is defined in + * [RFC 7518 Section 6.2](https://www.rfc-editor.org/rfc/rfc7518#section-6.2) + * Using the required "crv" (Curve), "x" (X Coordinate) and "y" (Y Coordinate) Parameters. + * + * \tparam Decode is callable, taking a string_type and returns a string_type. + * It should ensure the padding of the input and then base64url decode and + * return the results. + * \param curve string containing curve name + * \param x string containing base64url encoded x coordinate + * \param y string containing base64url encoded y coordinate + * \param decode The function to decode the RSA parameters + * \return public key in PEM format + */ + template + std::string create_public_key_from_ec_components(const std::string& curve, const std::string& x, + const std::string& y, Decode decode) { + std::error_code ec; + auto res = create_public_key_from_ec_components(curve, x, y, decode, ec); + error::throw_if_error(ec); + return res; + } + +#ifndef JWT_DISABLE_BASE64 + /** + * Create public key from curve name and coordinates. This is defined in + * [RFC 7518 Section 6.2](https://www.rfc-editor.org/rfc/rfc7518#section-6.2) + * Using the required "crv" (Curve), "x" (X Coordinate) and "y" (Y Coordinate) Parameters. + * + * \param curve string containing curve name + * \param x string containing base64url encoded x coordinate + * \param y string containing base64url encoded y coordinate + * \param ec error_code for error_detection (gets cleared if no error occur + * \return public key in PEM format + */ + inline std::string create_public_key_from_ec_components(const std::string& curve, const std::string& x, + const std::string& y, std::error_code& ec) { + auto decode = [](const std::string& token) { + return base::decode(base::pad(token)); + }; + return create_public_key_from_ec_components(curve, x, y, std::move(decode), ec); + } + /** + * Create public key from curve name and coordinates. This is defined in + * [RFC 7518 Section 6.2](https://www.rfc-editor.org/rfc/rfc7518#section-6.2) + * Using the required "crv" (Curve), "x" (X Coordinate) and "y" (Y Coordinate) Parameters. + * + * \param curve string containing curve name + * \param x string containing base64url encoded x coordinate + * \param y string containing base64url encoded y coordinate + * \return public key in PEM format + */ + inline std::string create_public_key_from_ec_components(const std::string& curve, const std::string& x, + const std::string& y) { + std::error_code ec; + auto res = create_public_key_from_ec_components(curve, x, y, ec); + error::throw_if_error(ec); + return res; + } +#endif + } // namespace helper + + /** + * \brief Various cryptographic algorithms when working with JWT + * + * JWT (JSON Web Tokens) signatures are typically used as the payload for a JWS (JSON Web Signature) or + * JWE (JSON Web Encryption). Both of these use various cryptographic as specified by + * [RFC7518](https://tools.ietf.org/html/rfc7518) and are exposed through the a [JOSE + * Header](https://tools.ietf.org/html/rfc7515#section-4) which points to one of the JWA [JSON Web + * Algorithms](https://tools.ietf.org/html/rfc7518#section-3.1) + */ + namespace algorithm { + /** + * \brief "none" algorithm. + * + * Returns and empty signature and checks if the given signature is empty. + * See [RFC 7518 Section 3.6](https://datatracker.ietf.org/doc/html/rfc7518#section-3.6) + * for more information. + */ + struct none { + /** + * \brief Return an empty string + */ + std::string sign(const std::string& /*unused*/, std::error_code& ec) const { + ec.clear(); + return {}; + } + /** + * \brief Check if the given signature is empty. + * + * JWT's with "none" algorithm should not contain a signature. + * \param signature Signature data to verify + * \param ec error_code filled with details about the error + */ + void verify(const std::string& /*unused*/, const std::string& signature, std::error_code& ec) const { + ec.clear(); + if (!signature.empty()) { ec = error::signature_verification_error::invalid_signature; } + } + /// Get algorithm name + std::string name() const { return "none"; } + }; + /** + * \brief Base class for HMAC family of algorithms + */ + struct hmacsha { + /** + * Construct new hmac algorithm + * + * \param key Key to use for HMAC + * \param md Pointer to hash function + * \param name Name of the algorithm + */ + hmacsha(std::string key, const EVP_MD* (*md)(), std::string name) + : secret(std::move(key)), md(md), alg_name(std::move(name)) {} + /** + * Sign jwt data + * + * \param data The data to sign + * \param ec error_code filled with details on error + * \return HMAC signature for the given data + */ + std::string sign(const std::string& data, std::error_code& ec) const { + ec.clear(); + std::string res(static_cast(EVP_MAX_MD_SIZE), '\0'); + auto len = static_cast(res.size()); + if (HMAC(md(), secret.data(), static_cast(secret.size()), + reinterpret_cast(data.data()), static_cast(data.size()), + (unsigned char*)res.data(), // NOLINT(google-readability-casting) requires `const_cast` + &len) == nullptr) { + ec = error::signature_generation_error::hmac_failed; + return {}; + } + res.resize(len); + return res; + } + /** + * Check if signature is valid + * + * \param data The data to check signature against + * \param signature Signature provided by the jwt + * \param ec Filled with details about failure. + */ + void verify(const std::string& data, const std::string& signature, std::error_code& ec) const { + ec.clear(); + auto res = sign(data, ec); + if (ec) return; + + bool matched = true; + for (size_t i = 0; i < std::min(res.size(), signature.size()); i++) + if (res[i] != signature[i]) matched = false; + if (res.size() != signature.size()) matched = false; + if (!matched) { + ec = error::signature_verification_error::invalid_signature; + return; + } + } + /** + * Returns the algorithm name provided to the constructor + * + * \return algorithm's name + */ + std::string name() const { return alg_name; } + + private: + /// HMAC secret + const std::string secret; + /// HMAC hash generator + const EVP_MD* (*md)(); + /// algorithm's name + const std::string alg_name; + }; + /** + * \brief Base class for RSA family of algorithms + */ + struct rsa { + /** + * Construct new rsa algorithm + * + * \param public_key RSA public key in PEM format + * \param private_key RSA private key or empty string if not available. If empty, signing will always fail. + * \param public_key_password Password to decrypt public key pem. + * \param private_key_password Password to decrypt private key pem. + * \param md Pointer to hash function + * \param name Name of the algorithm + */ + rsa(const std::string& public_key, const std::string& private_key, const std::string& public_key_password, + const std::string& private_key_password, const EVP_MD* (*md)(), std::string name) + : md(md), alg_name(std::move(name)) { + if (!private_key.empty()) { + pkey = helper::load_private_key_from_string(private_key, private_key_password); + } else if (!public_key.empty()) { + pkey = helper::load_public_key_from_string(public_key, public_key_password); + } else + throw error::rsa_exception(error::rsa_error::no_key_provided); + } + /** + * Sign jwt data + * \param data The data to sign + * \param ec error_code filled with details on error + * \return RSA signature for the given data + */ + std::string sign(const std::string& data, std::error_code& ec) const { + ec.clear(); + auto ctx = helper::make_evp_md_ctx(); + if (!ctx) { + ec = error::signature_generation_error::create_context_failed; + return {}; + } + if (!EVP_SignInit(ctx.get(), md())) { + ec = error::signature_generation_error::signinit_failed; + return {}; + } + + std::string res(EVP_PKEY_size(pkey.get()), '\0'); + unsigned int len = 0; + + if (!EVP_SignUpdate(ctx.get(), data.data(), data.size())) { + ec = error::signature_generation_error::signupdate_failed; + return {}; + } + if (EVP_SignFinal(ctx.get(), (unsigned char*)res.data(), &len, pkey.get()) == 0) { + ec = error::signature_generation_error::signfinal_failed; + return {}; + } + + res.resize(len); + return res; + } + /** + * Check if signature is valid + * + * \param data The data to check signature against + * \param signature Signature provided by the jwt + * \param ec Filled with details on failure + */ + void verify(const std::string& data, const std::string& signature, std::error_code& ec) const { + ec.clear(); + auto ctx = helper::make_evp_md_ctx(); + if (!ctx) { + ec = error::signature_verification_error::create_context_failed; + return; + } + if (!EVP_VerifyInit(ctx.get(), md())) { + ec = error::signature_verification_error::verifyinit_failed; + return; + } + if (!EVP_VerifyUpdate(ctx.get(), data.data(), data.size())) { + ec = error::signature_verification_error::verifyupdate_failed; + return; + } + auto res = EVP_VerifyFinal(ctx.get(), reinterpret_cast(signature.data()), + static_cast(signature.size()), pkey.get()); + if (res != 1) { + ec = error::signature_verification_error::verifyfinal_failed; + return; + } + } + /** + * Returns the algorithm name provided to the constructor + * \return algorithm's name + */ + std::string name() const { return alg_name; } + + private: + /// OpenSSL structure containing converted keys + helper::evp_pkey_handle pkey; + /// Hash generator + const EVP_MD* (*md)(); + /// algorithm's name + const std::string alg_name; + }; + /** + * \brief Base class for ECDSA family of algorithms + */ + struct ecdsa { + /** + * Construct new ecdsa algorithm + * + * \param public_key ECDSA public key in PEM format + * \param private_key ECDSA private key or empty string if not available. If empty, signing will always fail + * \param public_key_password Password to decrypt public key pem + * \param private_key_password Password to decrypt private key pem + * \param md Pointer to hash function + * \param name Name of the algorithm + * \param siglen The bit length of the signature + */ + ecdsa(const std::string& public_key, const std::string& private_key, const std::string& public_key_password, + const std::string& private_key_password, const EVP_MD* (*md)(), std::string name, size_t siglen) + : md(md), alg_name(std::move(name)), signature_length(siglen) { + if (!private_key.empty()) { + pkey = helper::load_private_ec_key_from_string(private_key, private_key_password); + check_private_key(pkey.get()); + } else if (!public_key.empty()) { + pkey = helper::load_public_ec_key_from_string(public_key, public_key_password); + check_public_key(pkey.get()); + } else { + throw error::ecdsa_exception(error::ecdsa_error::no_key_provided); + } + if (!pkey) throw error::ecdsa_exception(error::ecdsa_error::invalid_key); + + size_t keysize = EVP_PKEY_bits(pkey.get()); + if (keysize != signature_length * 4 && (signature_length != 132 || keysize != 521)) + throw error::ecdsa_exception(error::ecdsa_error::invalid_key_size); + } + + /** + * Sign jwt data + * \param data The data to sign + * \param ec error_code filled with details on error + * \return ECDSA signature for the given data + */ + std::string sign(const std::string& data, std::error_code& ec) const { + ec.clear(); + auto ctx = helper::make_evp_md_ctx(); + if (!ctx) { + ec = error::signature_generation_error::create_context_failed; + return {}; + } + if (!EVP_DigestSignInit(ctx.get(), nullptr, md(), nullptr, pkey.get())) { + ec = error::signature_generation_error::signinit_failed; + return {}; + } + if (!EVP_DigestUpdate(ctx.get(), data.data(), data.size())) { + ec = error::signature_generation_error::digestupdate_failed; + return {}; + } + + size_t len = 0; + if (!EVP_DigestSignFinal(ctx.get(), nullptr, &len)) { + ec = error::signature_generation_error::signfinal_failed; + return {}; + } + std::string res(len, '\0'); + if (!EVP_DigestSignFinal(ctx.get(), (unsigned char*)res.data(), &len)) { + ec = error::signature_generation_error::signfinal_failed; + return {}; + } + + res.resize(len); + return der_to_p1363_signature(res, ec); + } + + /** + * Check if signature is valid + * \param data The data to check signature against + * \param signature Signature provided by the jwt + * \param ec Filled with details on error + */ + void verify(const std::string& data, const std::string& signature, std::error_code& ec) const { + ec.clear(); + std::string der_signature = p1363_to_der_signature(signature, ec); + if (ec) { return; } + + auto ctx = helper::make_evp_md_ctx(); + if (!ctx) { + ec = error::signature_verification_error::create_context_failed; + return; + } + if (!EVP_DigestVerifyInit(ctx.get(), nullptr, md(), nullptr, pkey.get())) { + ec = error::signature_verification_error::verifyinit_failed; + return; + } + if (!EVP_DigestUpdate(ctx.get(), data.data(), data.size())) { + ec = error::signature_verification_error::verifyupdate_failed; + return; + } + +#if OPENSSL_VERSION_NUMBER < 0x10002000L + unsigned char* der_sig_data = reinterpret_cast(const_cast(der_signature.data())); +#else + const unsigned char* der_sig_data = reinterpret_cast(der_signature.data()); +#endif + auto res = + EVP_DigestVerifyFinal(ctx.get(), der_sig_data, static_cast(der_signature.length())); + if (res == 0) { + ec = error::signature_verification_error::invalid_signature; + return; + } + if (res == -1) { + ec = error::signature_verification_error::verifyfinal_failed; + return; + } + } + /** + * Returns the algorithm name provided to the constructor + * \return algorithm's name + */ + std::string name() const { return alg_name; } + + private: + static void check_public_key(EVP_PKEY* pkey) { +#ifdef JWT_OPENSSL_3_0 + std::unique_ptr ctx( + EVP_PKEY_CTX_new_from_pkey(nullptr, pkey, nullptr), EVP_PKEY_CTX_free); + if (!ctx) { throw error::ecdsa_exception(error::ecdsa_error::create_context_failed); } + if (EVP_PKEY_public_check(ctx.get()) != 1) { + throw error::ecdsa_exception(error::ecdsa_error::invalid_key); + } +#else + std::unique_ptr eckey(EVP_PKEY_get1_EC_KEY(pkey), EC_KEY_free); + if (!eckey) { throw error::ecdsa_exception(error::ecdsa_error::invalid_key); } + if (EC_KEY_check_key(eckey.get()) == 0) throw error::ecdsa_exception(error::ecdsa_error::invalid_key); +#endif + } + + static void check_private_key(EVP_PKEY* pkey) { +#ifdef JWT_OPENSSL_3_0 + std::unique_ptr ctx( + EVP_PKEY_CTX_new_from_pkey(nullptr, pkey, nullptr), EVP_PKEY_CTX_free); + if (!ctx) { throw error::ecdsa_exception(error::ecdsa_error::create_context_failed); } + if (EVP_PKEY_private_check(ctx.get()) != 1) { + throw error::ecdsa_exception(error::ecdsa_error::invalid_key); + } +#else + std::unique_ptr eckey(EVP_PKEY_get1_EC_KEY(pkey), EC_KEY_free); + if (!eckey) { throw error::ecdsa_exception(error::ecdsa_error::invalid_key); } + if (EC_KEY_check_key(eckey.get()) == 0) throw error::ecdsa_exception(error::ecdsa_error::invalid_key); +#endif + } + + std::string der_to_p1363_signature(const std::string& der_signature, std::error_code& ec) const { + const unsigned char* possl_signature = reinterpret_cast(der_signature.data()); + std::unique_ptr sig( + d2i_ECDSA_SIG(nullptr, &possl_signature, static_cast(der_signature.length())), + ECDSA_SIG_free); + if (!sig) { + ec = error::signature_generation_error::signature_decoding_failed; + return {}; + } + +#ifdef JWT_OPENSSL_1_0_0 + auto rr = helper::bn2raw(sig->r); + auto rs = helper::bn2raw(sig->s); +#else + const BIGNUM* r; + const BIGNUM* s; + ECDSA_SIG_get0(sig.get(), &r, &s); + auto rr = helper::bn2raw(r); + auto rs = helper::bn2raw(s); +#endif + if (rr.size() > signature_length / 2 || rs.size() > signature_length / 2) + throw std::logic_error("bignum size exceeded expected length"); + rr.insert(0, signature_length / 2 - rr.size(), '\0'); + rs.insert(0, signature_length / 2 - rs.size(), '\0'); + return rr + rs; + } + + std::string p1363_to_der_signature(const std::string& signature, std::error_code& ec) const { + ec.clear(); + auto r = helper::raw2bn(signature.substr(0, signature.size() / 2), ec); + if (ec) return {}; + auto s = helper::raw2bn(signature.substr(signature.size() / 2), ec); + if (ec) return {}; + + ECDSA_SIG* psig; +#ifdef JWT_OPENSSL_1_0_0 + ECDSA_SIG sig; + sig.r = r.get(); + sig.s = s.get(); + psig = &sig; +#else + std::unique_ptr sig(ECDSA_SIG_new(), ECDSA_SIG_free); + if (!sig) { + ec = error::signature_verification_error::create_context_failed; + return {}; + } + ECDSA_SIG_set0(sig.get(), r.release(), s.release()); + psig = sig.get(); +#endif + + int length = i2d_ECDSA_SIG(psig, nullptr); + if (length < 0) { + ec = error::signature_verification_error::signature_encoding_failed; + return {}; + } + std::string der_signature(length, '\0'); + unsigned char* psbuffer = (unsigned char*)der_signature.data(); + length = i2d_ECDSA_SIG(psig, &psbuffer); + if (length < 0) { + ec = error::signature_verification_error::signature_encoding_failed; + return {}; + } + der_signature.resize(length); + return der_signature; + } + + /// OpenSSL struct containing keys + helper::evp_pkey_handle pkey; + /// Hash generator function + const EVP_MD* (*md)(); + /// algorithm's name + const std::string alg_name; + /// Length of the resulting signature + const size_t signature_length; + }; + +#if !defined(JWT_OPENSSL_1_0_0) && !defined(JWT_OPENSSL_1_1_0) + /** + * \brief Base class for EdDSA family of algorithms + * + * https://tools.ietf.org/html/rfc8032 + * + * The EdDSA algorithms were introduced in [OpenSSL v1.1.1](https://www.openssl.org/news/openssl-1.1.1-notes.html), + * so these algorithms are only available when building against this version or higher. + */ + struct eddsa { + /** + * Construct new eddsa algorithm + * \param public_key EdDSA public key in PEM format + * \param private_key EdDSA private key or empty string if not available. If empty, signing will always + * fail. + * \param public_key_password Password to decrypt public key pem. + * \param private_key_password Password + * to decrypt private key pem. + * \param name Name of the algorithm + */ + eddsa(const std::string& public_key, const std::string& private_key, const std::string& public_key_password, + const std::string& private_key_password, std::string name) + : alg_name(std::move(name)) { + if (!private_key.empty()) { + pkey = helper::load_private_key_from_string(private_key, private_key_password); + } else if (!public_key.empty()) { + pkey = helper::load_public_key_from_string(public_key, public_key_password); + } else + throw error::ecdsa_exception(error::ecdsa_error::load_key_bio_read); + } + /** + * Sign jwt data + * \param data The data to sign + * \param ec error_code filled with details on error + * \return EdDSA signature for the given data + */ + std::string sign(const std::string& data, std::error_code& ec) const { + ec.clear(); + auto ctx = helper::make_evp_md_ctx(); + if (!ctx) { + ec = error::signature_generation_error::create_context_failed; + return {}; + } + if (!EVP_DigestSignInit(ctx.get(), nullptr, nullptr, nullptr, pkey.get())) { + ec = error::signature_generation_error::signinit_failed; + return {}; + } + + size_t len = EVP_PKEY_size(pkey.get()); + std::string res(len, '\0'); + +// LibreSSL is the special kid in the block, as it does not support EVP_DigestSign. +// OpenSSL on the otherhand does not support using EVP_DigestSignUpdate for eddsa, which is why we end up with this +// mess. +#if defined(LIBRESSL_VERSION_NUMBER) || defined(LIBWOLFSSL_VERSION_HEX) + ERR_clear_error(); + if (EVP_DigestSignUpdate(ctx.get(), reinterpret_cast(data.data()), data.size()) != + 1) { + std::cout << ERR_error_string(ERR_get_error(), NULL) << std::endl; + ec = error::signature_generation_error::signupdate_failed; + return {}; + } + if (EVP_DigestSignFinal(ctx.get(), reinterpret_cast(&res[0]), &len) != 1) { + ec = error::signature_generation_error::signfinal_failed; + return {}; + } +#else + if (EVP_DigestSign(ctx.get(), reinterpret_cast(&res[0]), &len, + reinterpret_cast(data.data()), data.size()) != 1) { + ec = error::signature_generation_error::signfinal_failed; + return {}; + } +#endif + + res.resize(len); + return res; + } + + /** + * Check if signature is valid + * \param data The data to check signature against + * \param signature Signature provided by the jwt + * \param ec Filled with details on error + */ + void verify(const std::string& data, const std::string& signature, std::error_code& ec) const { + ec.clear(); + auto ctx = helper::make_evp_md_ctx(); + if (!ctx) { + ec = error::signature_verification_error::create_context_failed; + return; + } + if (!EVP_DigestVerifyInit(ctx.get(), nullptr, nullptr, nullptr, pkey.get())) { + ec = error::signature_verification_error::verifyinit_failed; + return; + } +// LibreSSL is the special kid in the block, as it does not support EVP_DigestVerify. +// OpenSSL on the otherhand does not support using EVP_DigestVerifyUpdate for eddsa, which is why we end up with this +// mess. +#if defined(LIBRESSL_VERSION_NUMBER) || defined(LIBWOLFSSL_VERSION_HEX) + if (EVP_DigestVerifyUpdate(ctx.get(), reinterpret_cast(data.data()), + data.size()) != 1) { + ec = error::signature_verification_error::verifyupdate_failed; + return; + } + if (EVP_DigestVerifyFinal(ctx.get(), reinterpret_cast(signature.data()), + signature.size()) != 1) { + ec = error::signature_verification_error::verifyfinal_failed; + return; + } +#else + auto res = EVP_DigestVerify(ctx.get(), reinterpret_cast(signature.data()), + signature.size(), reinterpret_cast(data.data()), + data.size()); + if (res != 1) { + ec = error::signature_verification_error::verifyfinal_failed; + return; + } +#endif + } + /** + * Returns the algorithm name provided to the constructor + * \return algorithm's name + */ + std::string name() const { return alg_name; } + + private: + /// OpenSSL struct containing keys + helper::evp_pkey_handle pkey; + /// algorithm's name + const std::string alg_name; + }; +#endif + /** + * \brief Base class for PSS-RSA family of algorithms + */ + struct pss { + /** + * Construct new pss algorithm + * \param public_key RSA public key in PEM format + * \param private_key RSA private key or empty string if not available. If empty, signing will always fail. + * \param public_key_password Password to decrypt public key pem. + * \param private_key_password Password to decrypt private key pem. + * \param md Pointer to hash function + * \param name Name of the algorithm + */ + pss(const std::string& public_key, const std::string& private_key, const std::string& public_key_password, + const std::string& private_key_password, const EVP_MD* (*md)(), std::string name) + : md(md), alg_name(std::move(name)) { + if (!private_key.empty()) { + pkey = helper::load_private_key_from_string(private_key, private_key_password); + } else if (!public_key.empty()) { + pkey = helper::load_public_key_from_string(public_key, public_key_password); + } else + throw error::rsa_exception(error::rsa_error::no_key_provided); + } + + /** + * Sign jwt data + * \param data The data to sign + * \param ec error_code filled with details on error + * \return ECDSA signature for the given data + */ + std::string sign(const std::string& data, std::error_code& ec) const { + ec.clear(); + auto md_ctx = helper::make_evp_md_ctx(); + if (!md_ctx) { + ec = error::signature_generation_error::create_context_failed; + return {}; + } + EVP_PKEY_CTX* ctx = nullptr; + if (EVP_DigestSignInit(md_ctx.get(), &ctx, md(), nullptr, pkey.get()) != 1) { + ec = error::signature_generation_error::signinit_failed; + return {}; + } + if (EVP_PKEY_CTX_set_rsa_padding(ctx, RSA_PKCS1_PSS_PADDING) <= 0) { + ec = error::signature_generation_error::rsa_padding_failed; + return {}; + } +// wolfSSL does not require EVP_PKEY_CTX_set_rsa_pss_saltlen. The default behavior +// sets the salt length to the hash length. Unlike OpenSSL which exposes this functionality. +#ifndef LIBWOLFSSL_VERSION_HEX + if (EVP_PKEY_CTX_set_rsa_pss_saltlen(ctx, -1) <= 0) { + ec = error::signature_generation_error::set_rsa_pss_saltlen_failed; + return {}; + } +#endif + if (EVP_DigestUpdate(md_ctx.get(), data.data(), data.size()) != 1) { + ec = error::signature_generation_error::digestupdate_failed; + return {}; + } + + size_t size = EVP_PKEY_size(pkey.get()); + std::string res(size, 0x00); + if (EVP_DigestSignFinal( + md_ctx.get(), + (unsigned char*)res.data(), // NOLINT(google-readability-casting) requires `const_cast` + &size) <= 0) { + ec = error::signature_generation_error::signfinal_failed; + return {}; + } + + return res; + } + + /** + * Check if signature is valid + * \param data The data to check signature against + * \param signature Signature provided by the jwt + * \param ec Filled with error details + */ + void verify(const std::string& data, const std::string& signature, std::error_code& ec) const { + ec.clear(); + + auto md_ctx = helper::make_evp_md_ctx(); + if (!md_ctx) { + ec = error::signature_verification_error::create_context_failed; + return; + } + EVP_PKEY_CTX* ctx = nullptr; + if (EVP_DigestVerifyInit(md_ctx.get(), &ctx, md(), nullptr, pkey.get()) != 1) { + ec = error::signature_verification_error::verifyinit_failed; + return; + } + if (EVP_PKEY_CTX_set_rsa_padding(ctx, RSA_PKCS1_PSS_PADDING) <= 0) { + ec = error::signature_generation_error::rsa_padding_failed; + return; + } +// wolfSSL does not require EVP_PKEY_CTX_set_rsa_pss_saltlen. The default behavior +// sets the salt length to the hash length. Unlike OpenSSL which exposes this functionality. +#ifndef LIBWOLFSSL_VERSION_HEX + if (EVP_PKEY_CTX_set_rsa_pss_saltlen(ctx, -1) <= 0) { + ec = error::signature_verification_error::set_rsa_pss_saltlen_failed; + return; + } +#endif + if (EVP_DigestUpdate(md_ctx.get(), data.data(), data.size()) != 1) { + ec = error::signature_verification_error::verifyupdate_failed; + return; + } + + if (EVP_DigestVerifyFinal(md_ctx.get(), (unsigned char*)signature.data(), signature.size()) <= 0) { + ec = error::signature_verification_error::verifyfinal_failed; + return; + } + } + /** + * Returns the algorithm name provided to the constructor + * \return algorithm's name + */ + std::string name() const { return alg_name; } + + private: + /// OpenSSL structure containing keys + helper::evp_pkey_handle pkey; + /// Hash generator function + const EVP_MD* (*md)(); + /// algorithm's name + const std::string alg_name; + }; + + /** + * HS256 algorithm + */ + struct hs256 : public hmacsha { + /** + * Construct new instance of algorithm + * \param key HMAC signing key + */ + explicit hs256(std::string key) : hmacsha(std::move(key), EVP_sha256, "HS256") {} + }; + /** + * HS384 algorithm + */ + struct hs384 : public hmacsha { + /** + * Construct new instance of algorithm + * \param key HMAC signing key + */ + explicit hs384(std::string key) : hmacsha(std::move(key), EVP_sha384, "HS384") {} + }; + /** + * HS512 algorithm + */ + struct hs512 : public hmacsha { + /** + * Construct new instance of algorithm + * \param key HMAC signing key + */ + explicit hs512(std::string key) : hmacsha(std::move(key), EVP_sha512, "HS512") {} + }; + /** + * RS256 algorithm. + * + * This data structure is used to describe the RSA256 and can be used to verify JWTs + */ + struct rs256 : public rsa { + /** + * \brief Construct new instance of algorithm + * + * \param public_key RSA public key in PEM format + * \param private_key RSA private key or empty string if not available. If empty, signing will always fail. + * \param public_key_password Password to decrypt public key pem. + * \param private_key_password Password to decrypt private key pem. + */ + explicit rs256(const std::string& public_key, const std::string& private_key = "", + const std::string& public_key_password = "", const std::string& private_key_password = "") + : rsa(public_key, private_key, public_key_password, private_key_password, EVP_sha256, "RS256") {} + }; + /** + * RS384 algorithm + */ + struct rs384 : public rsa { + /** + * Construct new instance of algorithm + * \param public_key RSA public key in PEM format + * \param private_key RSA private key or empty string if not available. If empty, signing will always fail. + * \param public_key_password Password to decrypt public key pem. + * \param private_key_password Password to decrypt private key pem. + */ + explicit rs384(const std::string& public_key, const std::string& private_key = "", + const std::string& public_key_password = "", const std::string& private_key_password = "") + : rsa(public_key, private_key, public_key_password, private_key_password, EVP_sha384, "RS384") {} + }; + /** + * RS512 algorithm + */ + struct rs512 : public rsa { + /** + * Construct new instance of algorithm + * \param public_key RSA public key in PEM format + * \param private_key RSA private key or empty string if not available. If empty, signing will always fail. + * \param public_key_password Password to decrypt public key pem. + * \param private_key_password Password to decrypt private key pem. + */ + explicit rs512(const std::string& public_key, const std::string& private_key = "", + const std::string& public_key_password = "", const std::string& private_key_password = "") + : rsa(public_key, private_key, public_key_password, private_key_password, EVP_sha512, "RS512") {} + }; + /** + * ES256 algorithm + */ + struct es256 : public ecdsa { + /** + * Construct new instance of algorithm + * \param public_key ECDSA public key in PEM format + * \param private_key ECDSA private key or empty string if not available. If empty, signing will always + * fail. + * \param public_key_password Password to decrypt public key pem. + * \param private_key_password Password + * to decrypt private key pem. + */ + explicit es256(const std::string& public_key, const std::string& private_key = "", + const std::string& public_key_password = "", const std::string& private_key_password = "") + : ecdsa(public_key, private_key, public_key_password, private_key_password, EVP_sha256, "ES256", 64) {} + }; + /** + * ES384 algorithm + */ + struct es384 : public ecdsa { + /** + * Construct new instance of algorithm + * \param public_key ECDSA public key in PEM format + * \param private_key ECDSA private key or empty string if not available. If empty, signing will always + * fail. + * \param public_key_password Password to decrypt public key pem. + * \param private_key_password Password + * to decrypt private key pem. + */ + explicit es384(const std::string& public_key, const std::string& private_key = "", + const std::string& public_key_password = "", const std::string& private_key_password = "") + : ecdsa(public_key, private_key, public_key_password, private_key_password, EVP_sha384, "ES384", 96) {} + }; + /** + * ES512 algorithm + */ + struct es512 : public ecdsa { + /** + * Construct new instance of algorithm + * \param public_key ECDSA public key in PEM format + * \param private_key ECDSA private key or empty string if not available. If empty, signing will always + * fail. + * \param public_key_password Password to decrypt public key pem. + * \param private_key_password Password + * to decrypt private key pem. + */ + explicit es512(const std::string& public_key, const std::string& private_key = "", + const std::string& public_key_password = "", const std::string& private_key_password = "") + : ecdsa(public_key, private_key, public_key_password, private_key_password, EVP_sha512, "ES512", 132) {} + }; + /** + * ES256K algorithm + */ + struct es256k : public ecdsa { + /** + * Construct new instance of algorithm + * \param public_key ECDSA public key in PEM format + * \param private_key ECDSA private key or empty string if not available. If empty, signing will always + * fail. + * \param public_key_password Password to decrypt public key pem. + * \param private_key_password Password to decrypt private key pem. + */ + explicit es256k(const std::string& public_key, const std::string& private_key = "", + const std::string& public_key_password = "", const std::string& private_key_password = "") + : ecdsa(public_key, private_key, public_key_password, private_key_password, EVP_sha256, "ES256K", 64) {} + }; + +#if !defined(JWT_OPENSSL_1_0_0) && !defined(JWT_OPENSSL_1_1_0) + /** + * Ed25519 algorithm + * + * https://en.wikipedia.org/wiki/EdDSA#Ed25519 + * + * Requires at least OpenSSL 1.1.1. + */ + struct ed25519 : public eddsa { + /** + * Construct new instance of algorithm + * \param public_key Ed25519 public key in PEM format + * \param private_key Ed25519 private key or empty string if not available. If empty, signing will always + * fail. + * \param public_key_password Password to decrypt public key pem. + * \param private_key_password Password + * to decrypt private key pem. + */ + explicit ed25519(const std::string& public_key, const std::string& private_key = "", + const std::string& public_key_password = "", const std::string& private_key_password = "") + : eddsa(public_key, private_key, public_key_password, private_key_password, "EdDSA") {} + }; + + /** + * Ed448 algorithm + * + * https://en.wikipedia.org/wiki/EdDSA#Ed448 + * + * Requires at least OpenSSL 1.1.1. + */ + struct ed448 : public eddsa { + /** + * Construct new instance of algorithm + * \param public_key Ed448 public key in PEM format + * \param private_key Ed448 private key or empty string if not available. If empty, signing will always + * fail. + * \param public_key_password Password to decrypt public key pem. + * \param private_key_password Password + * to decrypt private key pem. + */ + explicit ed448(const std::string& public_key, const std::string& private_key = "", + const std::string& public_key_password = "", const std::string& private_key_password = "") + : eddsa(public_key, private_key, public_key_password, private_key_password, "EdDSA") {} + }; +#endif + + /** + * PS256 algorithm + */ + struct ps256 : public pss { + /** + * Construct new instance of algorithm + * \param public_key RSA public key in PEM format + * \param private_key RSA private key or empty string if not available. If empty, signing will always fail. + * \param public_key_password Password to decrypt public key pem. + * \param private_key_password Password to decrypt private key pem. + */ + explicit ps256(const std::string& public_key, const std::string& private_key = "", + const std::string& public_key_password = "", const std::string& private_key_password = "") + : pss(public_key, private_key, public_key_password, private_key_password, EVP_sha256, "PS256") {} + }; + /** + * PS384 algorithm + */ + struct ps384 : public pss { + /** + * Construct new instance of algorithm + * \param public_key RSA public key in PEM format + * \param private_key RSA private key or empty string if not available. If empty, signing will always fail. + * \param public_key_password Password to decrypt public key pem. + * \param private_key_password Password to decrypt private key pem. + */ + explicit ps384(const std::string& public_key, const std::string& private_key = "", + const std::string& public_key_password = "", const std::string& private_key_password = "") + : pss(public_key, private_key, public_key_password, private_key_password, EVP_sha384, "PS384") {} + }; + /** + * PS512 algorithm + */ + struct ps512 : public pss { + /** + * Construct new instance of algorithm + * \param public_key RSA public key in PEM format + * \param private_key RSA private key or empty string if not available. If empty, signing will always fail. + * \param public_key_password Password to decrypt public key pem. + * \param private_key_password Password to decrypt private key pem. + */ + explicit ps512(const std::string& public_key, const std::string& private_key = "", + const std::string& public_key_password = "", const std::string& private_key_password = "") + : pss(public_key, private_key, public_key_password, private_key_password, EVP_sha512, "PS512") {} + }; + } // namespace algorithm + + /** + * \brief JSON Abstractions for working with any library + */ + namespace json { + /** + * \brief Categories for the various JSON types used in JWTs + * + * This enum is to abstract the third party underlying types and allows the library + * to identify the different structures and reason about them without needing a "concept" + * to capture that defintion to compare against a concrete type. + */ + enum class type { boolean, integer, number, string, array, object }; + } // namespace json + + namespace details { +#ifdef __cpp_lib_void_t + template + using void_t = std::void_t; +#else + // https://en.cppreference.com/w/cpp/types/void_t + template + struct make_void { + using type = void; + }; + + template + using void_t = typename make_void::type; +#endif + +#ifdef __cpp_lib_experimental_detect + template class _Op, typename... _Args> + using is_detected = std::experimental::is_detected<_Op, _Args...>; +#else + struct nonesuch { + nonesuch() = delete; + ~nonesuch() = delete; + nonesuch(nonesuch const&) = delete; + nonesuch(nonesuch const&&) = delete; + void operator=(nonesuch const&) = delete; + void operator=(nonesuch&&) = delete; + }; + + // https://en.cppreference.com/w/cpp/experimental/is_detected + template class Op, class... Args> + struct detector { + using value = std::false_type; + using type = Default; + }; + + template class Op, class... Args> + struct detector>, Op, Args...> { + using value = std::true_type; + using type = Op; + }; + + template class Op, class... Args> + using is_detected = typename detector::value; +#endif + + template + using is_signature = typename std::is_same; + + template class Op, typename Signature> + struct is_function_signature_detected { + using type = Op; + static constexpr auto value = is_detected::value && std::is_function::value && + is_signature::value; + }; + + template + struct supports_get_type { + template + using get_type_t = decltype(T::get_type); + + static constexpr auto value = + is_function_signature_detected::value; + + // Internal assertions for better feedback + static_assert(value, "traits implementation must provide `jwt::json::type get_type(const value_type&)`"); + }; + +#define JWT_CPP_JSON_TYPE_TYPE(TYPE) json_##TYPE_type +#define JWT_CPP_AS_TYPE_T(TYPE) as_##TYPE_t +#define JWT_CPP_SUPPORTS_AS(TYPE) \ + template \ + struct supports_as_##TYPE { \ + template \ + using JWT_CPP_AS_TYPE_T(TYPE) = decltype(T::as_##TYPE); \ + \ + static constexpr auto value = \ + is_function_signature_detected::value; \ + \ + static_assert(value, "traits implementation must provide `" #TYPE "_type as_" #TYPE "(const value_type&)`"); \ + } + + JWT_CPP_SUPPORTS_AS(object); + JWT_CPP_SUPPORTS_AS(array); + JWT_CPP_SUPPORTS_AS(string); + JWT_CPP_SUPPORTS_AS(number); + JWT_CPP_SUPPORTS_AS(integer); + JWT_CPP_SUPPORTS_AS(boolean); + +#undef JWT_CPP_JSON_TYPE_TYPE +#undef JWT_CPP_AS_TYPE_T +#undef JWT_CPP_SUPPORTS_AS + + template + struct is_valid_traits { + static constexpr auto value = + supports_get_type::value && + supports_as_object::value && + supports_as_array::value && + supports_as_string::value && + supports_as_number::value && + supports_as_integer::value && + supports_as_boolean::value; + }; + + template + struct is_valid_json_value { + static constexpr auto value = + std::is_default_constructible::value && + std::is_constructible::value && // a more generic is_copy_constructible + std::is_move_constructible::value && std::is_assignable::value && + std::is_copy_assignable::value && std::is_move_assignable::value; + // TODO(prince-chrismc): Stream operators + }; + + // https://stackoverflow.com/a/53967057/8480874 + template + struct is_iterable : std::false_type {}; + + template + struct is_iterable())), decltype(std::end(std::declval())), +#if __cplusplus > 201402L + decltype(std::cbegin(std::declval())), decltype(std::cend(std::declval())) +#else + decltype(std::begin(std::declval())), + decltype(std::end(std::declval())) +#endif + >> : std::true_type { + }; + +#if __cplusplus > 201703L + template + inline constexpr bool is_iterable_v = is_iterable::value; +#endif + + template + using is_count_signature = typename std::is_integral().count( + std::declval()))>; + + template + struct is_subcription_operator_signature : std::false_type {}; + + template + struct is_subcription_operator_signature< + object_type, string_type, + void_t().operator[](std::declval()))>> : std::true_type { + // TODO(prince-chrismc): I am not convienced this is meaningful anymore + static_assert( + value, + "object_type must implementate the subscription operator '[]' taking string_type as an argument"); + }; + + template + using is_at_const_signature = + typename std::is_same().at(std::declval())), + const value_type&>; + + template + struct is_valid_json_object { + template + using mapped_type_t = typename T::mapped_type; + template + using key_type_t = typename T::key_type; + template + using iterator_t = typename T::iterator; + template + using const_iterator_t = typename T::const_iterator; + + static constexpr auto value = + std::is_constructible::value && + is_detected::value && + std::is_same::value && + is_detected::value && + (std::is_same::value || + std::is_constructible::value) && + is_detected::value && is_detected::value && + is_iterable::value && is_count_signature::value && + is_subcription_operator_signature::value && + is_at_const_signature::value; + }; + + template + struct is_valid_json_array { + template + using value_type_t = typename T::value_type; + using front_base_type = typename std::decay().front())>::type; + + static constexpr auto value = std::is_constructible::value && + is_iterable::value && + is_detected::value && + std::is_same::value && + std::is_same::value; + }; + + template + using is_substr_start_end_index_signature = + typename std::is_same().substr(std::declval(), + std::declval())), + string_type>; + + template + using is_substr_start_index_signature = + typename std::is_same().substr(std::declval())), + string_type>; + + template + using is_std_operate_plus_signature = + typename std::is_same(), std::declval())), + string_type>; + + template + struct is_valid_json_string { + static constexpr auto substr = is_substr_start_end_index_signature::value && + is_substr_start_index_signature::value; + static_assert(substr, "string_type must have a substr method taking only a start index and an overload " + "taking a start and end index, both must return a string_type"); + + static constexpr auto operator_plus = is_std_operate_plus_signature::value; + static_assert(operator_plus, + "string_type must have a '+' operator implemented which returns the concatenated string"); + + static constexpr auto value = + std::is_constructible::value && substr && operator_plus; + }; + + template + struct is_valid_json_number { + static constexpr auto value = + std::is_floating_point::value && std::is_constructible::value; + }; + + template + struct is_valid_json_integer { + static constexpr auto value = std::is_signed::value && + !std::is_floating_point::value && + std::is_constructible::value; + }; + template + struct is_valid_json_boolean { + static constexpr auto value = std::is_convertible::value && + std::is_constructible::value; + }; + + template + struct is_valid_json_types { + // Internal assertions for better feedback + static_assert(is_valid_json_value::value, + "value_type must meet basic requirements, default constructor, copyable, moveable"); + static_assert(is_valid_json_object::value, + "object_type must be a string_type to value_type container"); + static_assert(is_valid_json_array::value, + "array_type must be a container of value_type"); + + static constexpr auto value = is_valid_json_value::value && + is_valid_json_object::value && + is_valid_json_array::value && + is_valid_json_string::value && + is_valid_json_number::value && + is_valid_json_integer::value && + is_valid_json_boolean::value; + }; + } // namespace details + + /** + * \brief a class to store a generic JSON value as claim + * + * \tparam json_traits : JSON implementation traits + * + * \see [RFC 7519: JSON Web Token (JWT)](https://tools.ietf.org/html/rfc7519) + */ + template + class basic_claim { + /** + * The reason behind this is to provide an expressive abstraction without + * over complicating the API. For more information take the time to read + * https://github.com/nlohmann/json/issues/774. It maybe be expanded to + * support custom string types. + */ + static_assert(std::is_same::value || + std::is_convertible::value || + std::is_constructible::value, + "string_type must be a std::string, convertible to a std::string, or construct a std::string."); + + static_assert( + details::is_valid_json_types::value, + "must satisfy json container requirements"); + static_assert(details::is_valid_traits::value, "traits must satisfy requirements"); + + typename json_traits::value_type val; + + public: + /** + * Order list of strings + */ + using set_t = std::set; + + basic_claim() = default; + basic_claim(const basic_claim&) = default; + basic_claim(basic_claim&&) = default; + basic_claim& operator=(const basic_claim&) = default; + basic_claim& operator=(basic_claim&&) = default; + ~basic_claim() = default; + + JWT_CLAIM_EXPLICIT basic_claim(typename json_traits::string_type s) : val(std::move(s)) {} + JWT_CLAIM_EXPLICIT basic_claim(const date& d) + : val(typename json_traits::integer_type(std::chrono::system_clock::to_time_t(d))) {} + JWT_CLAIM_EXPLICIT basic_claim(typename json_traits::array_type a) : val(std::move(a)) {} + JWT_CLAIM_EXPLICIT basic_claim(typename json_traits::value_type v) : val(std::move(v)) {} + JWT_CLAIM_EXPLICIT basic_claim(const set_t& s) : val(typename json_traits::array_type(s.begin(), s.end())) {} + template + basic_claim(Iterator begin, Iterator end) : val(typename json_traits::array_type(begin, end)) {} + + /** + * Get wrapped JSON value + * \return Wrapped JSON value + */ + typename json_traits::value_type to_json() const { return val; } + + /** + * Parse input stream into underlying JSON value + * \return input stream + */ + std::istream& operator>>(std::istream& is) { return is >> val; } + + /** + * Serialize claim to output stream from wrapped JSON value + * \return output stream + */ + std::ostream& operator<<(std::ostream& os) { return os << val; } + + /** + * Get type of contained JSON value + * \return Type + * \throw std::logic_error An internal error occurred + */ + json::type get_type() const { return json_traits::get_type(val); } + + /** + * Get the contained JSON value as a string + * \return content as string + * \throw std::bad_cast Content was not a string + */ + typename json_traits::string_type as_string() const { return json_traits::as_string(val); } + + /** + * \brief Get the contained JSON value as a date + * + * If the value is a decimal, it is rounded up to the closest integer + * + * \return content as date + * \throw std::bad_cast Content was not a date + */ + date as_date() const { + using std::chrono::system_clock; + if (get_type() == json::type::number) return system_clock::from_time_t(std::round(as_number())); + return system_clock::from_time_t(as_integer()); + } + + /** + * Get the contained JSON value as an array + * \return content as array + * \throw std::bad_cast Content was not an array + */ + typename json_traits::array_type as_array() const { return json_traits::as_array(val); } + + /** + * Get the contained JSON value as a set of strings + * \return content as set of strings + * \throw std::bad_cast Content was not an array of string + */ + set_t as_set() const { + set_t res; + for (const auto& e : json_traits::as_array(val)) { + res.insert(json_traits::as_string(e)); + } + return res; + } + + /** + * Get the contained JSON value as an integer + * \return content as int + * \throw std::bad_cast Content was not an int + */ + typename json_traits::integer_type as_integer() const { return json_traits::as_integer(val); } + + /** + * Get the contained JSON value as a bool + * \return content as bool + * \throw std::bad_cast Content was not a bool + */ + typename json_traits::boolean_type as_boolean() const { return json_traits::as_boolean(val); } + + /** + * Get the contained JSON value as a number + * \return content as double + * \throw std::bad_cast Content was not a number + */ + typename json_traits::number_type as_number() const { return json_traits::as_number(val); } + }; + + namespace error { + /** + * Attempt to parse JSON was unsuccessful + */ + struct invalid_json_exception : public std::runtime_error { + invalid_json_exception() : runtime_error("invalid json") {} + }; + /** + * Attempt to access claim was unsuccessful + */ + struct claim_not_present_exception : public std::out_of_range { + claim_not_present_exception() : out_of_range("claim not found") {} + }; + } // namespace error + + namespace details { + template + struct map_of_claims { + typename json_traits::object_type claims; + using basic_claim_t = basic_claim; + using iterator = typename json_traits::object_type::iterator; + using const_iterator = typename json_traits::object_type::const_iterator; + + map_of_claims() = default; + map_of_claims(const map_of_claims&) = default; + map_of_claims(map_of_claims&&) = default; + map_of_claims& operator=(const map_of_claims&) = default; + map_of_claims& operator=(map_of_claims&&) = default; + + map_of_claims(typename json_traits::object_type json) : claims(std::move(json)) {} + + iterator begin() { return claims.begin(); } + iterator end() { return claims.end(); } + const_iterator cbegin() const { return claims.begin(); } + const_iterator cend() const { return claims.end(); } + const_iterator begin() const { return claims.begin(); } + const_iterator end() const { return claims.end(); } + + /** + * \brief Parse a JSON string into a map of claims + * + * The implication is that a "map of claims" is identic to a JSON object + * + * \param str JSON data to be parse as an object + * \return content as JSON object + */ + static typename json_traits::object_type parse_claims(const typename json_traits::string_type& str) { + typename json_traits::value_type val; + if (!json_traits::parse(val, str)) throw error::invalid_json_exception(); + + return json_traits::as_object(val); + }; + + /** + * Check if a claim is present in the map + * \return true if claim was present, false otherwise + */ + bool has_claim(const typename json_traits::string_type& name) const noexcept { + return claims.count(name) != 0; + } + + /** + * Get a claim by name + * + * \param name the name of the desired claim + * \return Requested claim + * \throw jwt::error::claim_not_present_exception if the claim was not present + */ + basic_claim_t get_claim(const typename json_traits::string_type& name) const { + if (!has_claim(name)) throw error::claim_not_present_exception(); + return basic_claim_t{claims.at(name)}; + } + }; + } // namespace details + + /** + * Base class that represents a token payload. + * Contains Convenience accessors for common claims. + */ + template + class payload { + protected: + details::map_of_claims payload_claims; + + public: + using basic_claim_t = basic_claim; + + /** + * Check if issuer is present ("iss") + * \return true if present, false otherwise + */ + bool has_issuer() const noexcept { return has_payload_claim("iss"); } + /** + * Check if subject is present ("sub") + * \return true if present, false otherwise + */ + bool has_subject() const noexcept { return has_payload_claim("sub"); } + /** + * Check if audience is present ("aud") + * \return true if present, false otherwise + */ + bool has_audience() const noexcept { return has_payload_claim("aud"); } + /** + * Check if expires is present ("exp") + * \return true if present, false otherwise + */ + bool has_expires_at() const noexcept { return has_payload_claim("exp"); } + /** + * Check if not before is present ("nbf") + * \return true if present, false otherwise + */ + bool has_not_before() const noexcept { return has_payload_claim("nbf"); } + /** + * Check if issued at is present ("iat") + * \return true if present, false otherwise + */ + bool has_issued_at() const noexcept { return has_payload_claim("iat"); } + /** + * Check if token id is present ("jti") + * \return true if present, false otherwise + */ + bool has_id() const noexcept { return has_payload_claim("jti"); } + /** + * Get issuer claim + * \return issuer as string + * \throw std::runtime_error If claim was not present + * \throw std::bad_cast Claim was present but not a string (Should not happen in a valid token) + */ + typename json_traits::string_type get_issuer() const { return get_payload_claim("iss").as_string(); } + /** + * Get subject claim + * \return subject as string + * \throw std::runtime_error If claim was not present + * \throw std::bad_cast Claim was present but not a string (Should not happen in a valid token) + */ + typename json_traits::string_type get_subject() const { return get_payload_claim("sub").as_string(); } + /** + * Get audience claim + * \return audience as a set of strings + * \throw std::runtime_error If claim was not present + * \throw std::bad_cast Claim was present but not a set (Should not happen in a valid token) + */ + typename basic_claim_t::set_t get_audience() const { + auto aud = get_payload_claim("aud"); + if (aud.get_type() == json::type::string) return {aud.as_string()}; + + return aud.as_set(); + } + /** + * Get expires claim + * \return expires as a date in utc + * \throw std::runtime_error If claim was not present + * \throw std::bad_cast Claim was present but not a date (Should not happen in a valid token) + */ + date get_expires_at() const { return get_payload_claim("exp").as_date(); } + /** + * Get not valid before claim + * \return nbf date in utc + * \throw std::runtime_error If claim was not present + * \throw std::bad_cast Claim was present but not a date (Should not happen in a valid token) + */ + date get_not_before() const { return get_payload_claim("nbf").as_date(); } + /** + * Get issued at claim + * \return issued at as date in utc + * \throw std::runtime_error If claim was not present + * \throw std::bad_cast Claim was present but not a date (Should not happen in a valid token) + */ + date get_issued_at() const { return get_payload_claim("iat").as_date(); } + /** + * Get id claim + * \return id as string + * \throw std::runtime_error If claim was not present + * \throw std::bad_cast Claim was present but not a string (Should not happen in a valid token) + */ + typename json_traits::string_type get_id() const { return get_payload_claim("jti").as_string(); } + /** + * Check if a payload claim is present + * \return true if claim was present, false otherwise + */ + bool has_payload_claim(const typename json_traits::string_type& name) const noexcept { + return payload_claims.has_claim(name); + } + /** + * Get payload claim + * \return Requested claim + * \throw std::runtime_error If claim was not present + */ + basic_claim_t get_payload_claim(const typename json_traits::string_type& name) const { + return payload_claims.get_claim(name); + } + }; + + /** + * Base class that represents a token header. + * Contains Convenience accessors for common claims. + */ + template + class header { + protected: + details::map_of_claims header_claims; + + public: + using basic_claim_t = basic_claim; + /** + * Check if algorithm is present ("alg") + * \return true if present, false otherwise + */ + bool has_algorithm() const noexcept { return has_header_claim("alg"); } + /** + * Check if type is present ("typ") + * \return true if present, false otherwise + */ + bool has_type() const noexcept { return has_header_claim("typ"); } + /** + * Check if content type is present ("cty") + * \return true if present, false otherwise + */ + bool has_content_type() const noexcept { return has_header_claim("cty"); } + /** + * Check if key id is present ("kid") + * \return true if present, false otherwise + */ + bool has_key_id() const noexcept { return has_header_claim("kid"); } + /** + * Get algorithm claim + * \return algorithm as string + * \throw std::runtime_error If claim was not present + * \throw std::bad_cast Claim was present but not a string (Should not happen in a valid token) + */ + typename json_traits::string_type get_algorithm() const { return get_header_claim("alg").as_string(); } + /** + * Get type claim + * \return type as a string + * \throw std::runtime_error If claim was not present + * \throw std::bad_cast Claim was present but not a string (Should not happen in a valid token) + */ + typename json_traits::string_type get_type() const { return get_header_claim("typ").as_string(); } + /** + * Get content type claim + * \return content type as string + * \throw std::runtime_error If claim was not present + * \throw std::bad_cast Claim was present but not a string (Should not happen in a valid token) + */ + typename json_traits::string_type get_content_type() const { return get_header_claim("cty").as_string(); } + /** + * Get key id claim + * \return key id as string + * \throw std::runtime_error If claim was not present + * \throw std::bad_cast Claim was present but not a string (Should not happen in a valid token) + */ + typename json_traits::string_type get_key_id() const { return get_header_claim("kid").as_string(); } + /** + * Check if a header claim is present + * \return true if claim was present, false otherwise + */ + bool has_header_claim(const typename json_traits::string_type& name) const noexcept { + return header_claims.has_claim(name); + } + /** + * Get header claim + * \return Requested claim + * \throw std::runtime_error If claim was not present + */ + basic_claim_t get_header_claim(const typename json_traits::string_type& name) const { + return header_claims.get_claim(name); + } + }; + + /** + * Class containing all information about a decoded token + */ + template + class decoded_jwt : public header, public payload { + protected: + /// Unmodified token, as passed to constructor + typename json_traits::string_type token; + /// Header part decoded from base64 + typename json_traits::string_type header; + /// Unmodified header part in base64 + typename json_traits::string_type header_base64; + /// Payload part decoded from base64 + typename json_traits::string_type payload; + /// Unmodified payload part in base64 + typename json_traits::string_type payload_base64; + /// Signature part decoded from base64 + typename json_traits::string_type signature; + /// Unmodified signature part in base64 + typename json_traits::string_type signature_base64; + + public: + using basic_claim_t = basic_claim; +#ifndef JWT_DISABLE_BASE64 + /** + * \brief Parses a given token + * + * \note Decodes using the jwt::base64url which supports an std::string + * + * \param token The token to parse + * \throw std::invalid_argument Token is not in correct format + * \throw std::runtime_error Base64 decoding failed or invalid json + */ + JWT_CLAIM_EXPLICIT decoded_jwt(const typename json_traits::string_type& token) + : decoded_jwt(token, [](const typename json_traits::string_type& str) { + return base::decode(base::pad(str)); + }) {} +#endif + /** + * \brief Parses a given token + * + * \tparam Decode is callable, taking a string_type and returns a string_type. + * It should ensure the padding of the input and then base64url decode and + * return the results. + * \param token The token to parse + * \param decode The function to decode the token + * \throw std::invalid_argument Token is not in correct format + * \throw std::runtime_error Base64 decoding failed or invalid json + */ + template + decoded_jwt(const typename json_traits::string_type& token, Decode decode) : token(token) { + auto hdr_end = token.find('.'); + if (hdr_end == json_traits::string_type::npos) throw std::invalid_argument("invalid token supplied"); + auto payload_end = token.find('.', hdr_end + 1); + if (payload_end == json_traits::string_type::npos) throw std::invalid_argument("invalid token supplied"); + header_base64 = token.substr(0, hdr_end); + payload_base64 = token.substr(hdr_end + 1, payload_end - hdr_end - 1); + signature_base64 = token.substr(payload_end + 1); + + header = decode(header_base64); + payload = decode(payload_base64); + signature = decode(signature_base64); + + this->header_claims = details::map_of_claims::parse_claims(header); + this->payload_claims = details::map_of_claims::parse_claims(payload); + } + + /** + * Get token string, as passed to constructor + * \return token as passed to constructor + */ + const typename json_traits::string_type& get_token() const noexcept { return token; } + /** + * Get header part as json string + * \return header part after base64 decoding + */ + const typename json_traits::string_type& get_header() const noexcept { return header; } + /** + * Get payload part as json string + * \return payload part after base64 decoding + */ + const typename json_traits::string_type& get_payload() const noexcept { return payload; } + /** + * Get signature part as json string + * \return signature part after base64 decoding + */ + const typename json_traits::string_type& get_signature() const noexcept { return signature; } + /** + * Get header part as base64 string + * \return header part before base64 decoding + */ + const typename json_traits::string_type& get_header_base64() const noexcept { return header_base64; } + /** + * Get payload part as base64 string + * \return payload part before base64 decoding + */ + const typename json_traits::string_type& get_payload_base64() const noexcept { return payload_base64; } + /** + * Get signature part as base64 string + * \return signature part before base64 decoding + */ + const typename json_traits::string_type& get_signature_base64() const noexcept { return signature_base64; } + /** + * Get all payload as JSON object + * \return map of claims + */ + typename json_traits::object_type get_payload_json() const { return this->payload_claims.claims; } + /** + * Get all header as JSON object + * \return map of claims + */ + typename json_traits::object_type get_header_json() const { return this->header_claims.claims; } + /** + * Get a payload claim by name + * + * \param name the name of the desired claim + * \return Requested claim + * \throw jwt::error::claim_not_present_exception if the claim was not present + */ + basic_claim_t get_payload_claim(const typename json_traits::string_type& name) const { + return this->payload_claims.get_claim(name); + } + /** + * Get a header claim by name + * + * \param name the name of the desired claim + * \return Requested claim + * \throw jwt::error::claim_not_present_exception if the claim was not present + */ + basic_claim_t get_header_claim(const typename json_traits::string_type& name) const { + return this->header_claims.get_claim(name); + } + }; + + /** + * Builder class to build and sign a new token + * Use jwt::create() to get an instance of this class. + */ + template + class builder { + typename json_traits::object_type header_claims; + typename json_traits::object_type payload_claims; + + /// Instance of clock type + Clock clock; + + public: + /** + * Constructor for building a new builder instance + * \param c Clock instance + */ + JWT_CLAIM_EXPLICIT builder(Clock c) : clock(c) {} + /** + * Set a header claim. + * \param id Name of the claim + * \param c Claim to add + * \return *this to allow for method chaining + */ + builder& set_header_claim(const typename json_traits::string_type& id, typename json_traits::value_type c) { + header_claims[id] = std::move(c); + return *this; + } + + /** + * Set a header claim. + * \param id Name of the claim + * \param c Claim to add + * \return *this to allow for method chaining + */ + builder& set_header_claim(const typename json_traits::string_type& id, basic_claim c) { + header_claims[id] = c.to_json(); + return *this; + } + /** + * Set a payload claim. + * \param id Name of the claim + * \param c Claim to add + * \return *this to allow for method chaining + */ + builder& set_payload_claim(const typename json_traits::string_type& id, typename json_traits::value_type c) { + payload_claims[id] = std::move(c); + return *this; + } + /** + * Set a payload claim. + * \param id Name of the claim + * \param c Claim to add + * \return *this to allow for method chaining + */ + builder& set_payload_claim(const typename json_traits::string_type& id, basic_claim c) { + payload_claims[id] = c.to_json(); + return *this; + } + /** + * \brief Set algorithm claim + * You normally don't need to do this, as the algorithm is automatically set if you don't change it. + * + * \param str Name of algorithm + * \return *this to allow for method chaining + */ + builder& set_algorithm(typename json_traits::string_type str) { + return set_header_claim("alg", typename json_traits::value_type(str)); + } + /** + * Set type claim + * \param str Type to set + * \return *this to allow for method chaining + */ + builder& set_type(typename json_traits::string_type str) { + return set_header_claim("typ", typename json_traits::value_type(str)); + } + /** + * Set content type claim + * \param str Type to set + * \return *this to allow for method chaining + */ + builder& set_content_type(typename json_traits::string_type str) { + return set_header_claim("cty", typename json_traits::value_type(str)); + } + /** + * \brief Set key id claim + * + * \param str Key id to set + * \return *this to allow for method chaining + */ + builder& set_key_id(typename json_traits::string_type str) { + return set_header_claim("kid", typename json_traits::value_type(str)); + } + /** + * Set issuer claim + * \param str Issuer to set + * \return *this to allow for method chaining + */ + builder& set_issuer(typename json_traits::string_type str) { + return set_payload_claim("iss", typename json_traits::value_type(str)); + } + /** + * Set subject claim + * \param str Subject to set + * \return *this to allow for method chaining + */ + builder& set_subject(typename json_traits::string_type str) { + return set_payload_claim("sub", typename json_traits::value_type(str)); + } + /** + * Set audience claim + * \param a Audience set + * \return *this to allow for method chaining + */ + builder& set_audience(typename json_traits::array_type a) { + return set_payload_claim("aud", typename json_traits::value_type(a)); + } + /** + * Set audience claim + * \param aud Single audience + * \return *this to allow for method chaining + */ + builder& set_audience(typename json_traits::string_type aud) { + return set_payload_claim("aud", typename json_traits::value_type(aud)); + } + /** + * Set expires at claim + * \param d Expires time + * \return *this to allow for method chaining + */ + builder& set_expires_at(const date& d) { return set_payload_claim("exp", basic_claim(d)); } + /** + * Set expires at claim to @p d from the current moment + * \param d token expiration timeout + * \return *this to allow for method chaining + */ + template + builder& set_expires_in(const std::chrono::duration& d) { + return set_payload_claim("exp", basic_claim(clock.now() + d)); + } + /** + * Set not before claim + * \param d First valid time + * \return *this to allow for method chaining + */ + builder& set_not_before(const date& d) { return set_payload_claim("nbf", basic_claim(d)); } + /** + * Set issued at claim + * \param d Issued at time, should be current time + * \return *this to allow for method chaining + */ + builder& set_issued_at(const date& d) { return set_payload_claim("iat", basic_claim(d)); } + /** + * Set issued at claim to the current moment + * \return *this to allow for method chaining + */ + builder& set_issued_now() { return set_issued_at(clock.now()); } + /** + * Set id claim + * \param str ID to set + * \return *this to allow for method chaining + */ + builder& set_id(const typename json_traits::string_type& str) { + return set_payload_claim("jti", typename json_traits::value_type(str)); + } + + /** + * Sign token and return result + * \tparam Algo Callable method which takes a string_type and return the signed input as a string_type + * \tparam Encode Callable method which takes a string_type and base64url safe encodes it, + * MUST return the result with no padding; trim the result. + * \param algo Instance of an algorithm to sign the token with + * \param encode Callable to transform the serialized json to base64 with no padding + * \return Final token as a string + * + * \note If the 'alg' header in not set in the token it will be set to `algo.name()` + */ + template + typename json_traits::string_type sign(const Algo& algo, Encode encode) const { + std::error_code ec; + auto res = sign(algo, encode, ec); + error::throw_if_error(ec); + return res; + } +#ifndef JWT_DISABLE_BASE64 + /** + * Sign token and return result + * + * using the `jwt::base` functions provided + * + * \param algo Instance of an algorithm to sign the token with + * \return Final token as a string + */ + template + typename json_traits::string_type sign(const Algo& algo) const { + std::error_code ec; + auto res = sign(algo, ec); + error::throw_if_error(ec); + return res; + } +#endif + + /** + * Sign token and return result + * \tparam Algo Callable method which takes a string_type and return the signed input as a string_type + * \tparam Encode Callable method which takes a string_type and base64url safe encodes it, + * MUST return the result with no padding; trim the result. + * \param algo Instance of an algorithm to sign the token with + * \param encode Callable to transform the serialized json to base64 with no padding + * \param ec error_code filled with details on error + * \return Final token as a string + * + * \note If the 'alg' header in not set in the token it will be set to `algo.name()` + */ + template + typename json_traits::string_type sign(const Algo& algo, Encode encode, std::error_code& ec) const { + // make a copy such that a builder can be re-used + typename json_traits::object_type obj_header = header_claims; + if (header_claims.count("alg") == 0) obj_header["alg"] = typename json_traits::value_type(algo.name()); + + const auto header = encode(json_traits::serialize(typename json_traits::value_type(obj_header))); + const auto payload = encode(json_traits::serialize(typename json_traits::value_type(payload_claims))); + const auto token = header + "." + payload; + + auto signature = algo.sign(token, ec); + if (ec) return {}; + + return token + "." + encode(signature); + } +#ifndef JWT_DISABLE_BASE64 + /** + * Sign token and return result + * + * using the `jwt::base` functions provided + * + * \param algo Instance of an algorithm to sign the token with + * \param ec error_code filled with details on error + * \return Final token as a string + */ + template + typename json_traits::string_type sign(const Algo& algo, std::error_code& ec) const { + return sign( + algo, + [](const typename json_traits::string_type& data) { + return base::trim(base::encode(data)); + }, + ec); + } +#endif + }; + + namespace verify_ops { + /** + * This is the base container which holds the token that need to be verified + */ + template + struct verify_context { + verify_context(date ctime, const decoded_jwt& j, size_t l) + : current_time(ctime), jwt(j), default_leeway(l) {} + /// Current time, retrieved from the verifiers clock and cached for performance and consistency + date current_time; + /// The jwt passed to the verifier + const decoded_jwt& jwt; + /// The configured default leeway for this verification + size_t default_leeway{0}; + + /// The claim key to apply this comparison on + typename json_traits::string_type claim_key{}; + + /** + * \brief Helper method to get a claim from the jwt in this context + * \param in_header check JWT header or payload sections + * \param ec std::error_code which will indicate if any error occure + * \return basic_claim if it was present otherwise empty + */ + basic_claim get_claim(bool in_header, std::error_code& ec) const { + if (in_header) { + if (!jwt.has_header_claim(claim_key)) { + ec = error::token_verification_error::missing_claim; + return {}; + } + return jwt.get_header_claim(claim_key); + } else { + if (!jwt.has_payload_claim(claim_key)) { + ec = error::token_verification_error::missing_claim; + return {}; + } + return jwt.get_payload_claim(claim_key); + } + } + /** + * Helper method to get a claim of a specific type from the jwt in this context + * \param in_header check JWT header or payload sections + * \param t the expected type of the claim + * \param ec std::error_code which will indicate if any error occure + * \return basic_claim if it was present otherwise empty + */ + basic_claim get_claim(bool in_header, json::type t, std::error_code& ec) const { + auto c = get_claim(in_header, ec); + if (ec) return {}; + if (c.get_type() != t) { + ec = error::token_verification_error::claim_type_missmatch; + return {}; + } + return c; + } + /** + * \brief Helper method to get a payload claim from the jwt + * \param ec std::error_code which will indicate if any error occure + * \return basic_claim if it was present otherwise empty + */ + basic_claim get_claim(std::error_code& ec) const { return get_claim(false, ec); } + /** + * \brief Helper method to get a payload claim of a specific type from the jwt + * \param t the expected type of the claim + * \param ec std::error_code which will indicate if any error occure + * \return basic_claim if it was present otherwise empty + */ + basic_claim get_claim(json::type t, std::error_code& ec) const { + return get_claim(false, t, ec); + } + }; + + /** + * This is the default operation and does case sensitive matching + */ + template + struct equals_claim { + const basic_claim expected; + void operator()(const verify_context& ctx, std::error_code& ec) const { + auto jc = ctx.get_claim(in_header, expected.get_type(), ec); + if (ec) return; + const bool matches = [&]() { + switch (expected.get_type()) { + case json::type::boolean: return expected.as_boolean() == jc.as_boolean(); + case json::type::integer: return expected.as_integer() == jc.as_integer(); + case json::type::number: return expected.as_number() == jc.as_number(); + case json::type::string: return expected.as_string() == jc.as_string(); + case json::type::array: + case json::type::object: + return json_traits::serialize(expected.to_json()) == json_traits::serialize(jc.to_json()); + default: throw std::logic_error("internal error, should be unreachable"); + } + }(); + if (!matches) { + ec = error::token_verification_error::claim_value_missmatch; + return; + } + } + }; + + /** + * Checks that the current time is before the time specified in the given + * claim. This is identical to how the "exp" check works. + */ + template + struct date_before_claim { + const size_t leeway; + void operator()(const verify_context& ctx, std::error_code& ec) const { + auto jc = ctx.get_claim(in_header, json::type::integer, ec); + if (ec) return; + auto c = jc.as_date(); + if (ctx.current_time > c + std::chrono::seconds(leeway)) { + ec = error::token_verification_error::token_expired; + } + } + }; + + /** + * Checks that the current time is after the time specified in the given + * claim. This is identical to how the "nbf" and "iat" check works. + */ + template + struct date_after_claim { + const size_t leeway; + void operator()(const verify_context& ctx, std::error_code& ec) const { + auto jc = ctx.get_claim(in_header, json::type::integer, ec); + if (ec) return; + auto c = jc.as_date(); + if (ctx.current_time < c - std::chrono::seconds(leeway)) { + ec = error::token_verification_error::token_expired; + } + } + }; + + /** + * Checks if the given set is a subset of the set inside the token. + * If the token value is a string it is treated as a set with a single element. + * The comparison is case sensitive. + */ + template + struct is_subset_claim { + const typename basic_claim::set_t expected; + void operator()(const verify_context& ctx, std::error_code& ec) const { + auto c = ctx.get_claim(in_header, ec); + if (ec) return; + if (c.get_type() == json::type::string) { + if (expected.size() != 1 || *expected.begin() != c.as_string()) { + ec = error::token_verification_error::audience_missmatch; + return; + } + } else if (c.get_type() == json::type::array) { + auto jc = c.as_set(); + for (auto& e : expected) { + if (jc.find(e) == jc.end()) { + ec = error::token_verification_error::audience_missmatch; + return; + } + } + } else { + ec = error::token_verification_error::claim_type_missmatch; + return; + } + } + }; + + /** + * Checks if the claim is a string and does an case insensitive comparison. + */ + template + struct insensitive_string_claim { + const typename json_traits::string_type expected; + std::locale locale; + insensitive_string_claim(const typename json_traits::string_type& e, std::locale loc) + : expected(to_lower_unicode(e, loc)), locale(loc) {} + + void operator()(const verify_context& ctx, std::error_code& ec) const { + const auto c = ctx.get_claim(in_header, json::type::string, ec); + if (ec) return; + if (to_lower_unicode(c.as_string(), locale) != expected) { + ec = error::token_verification_error::claim_value_missmatch; + } + } + + static std::string to_lower_unicode(const std::string& str, const std::locale& loc) { + std::mbstate_t state = std::mbstate_t(); + const char* in_next = str.data(); + const char* in_end = str.data() + str.size(); + std::wstring wide; + wide.reserve(str.size()); + + while (in_next != in_end) { + wchar_t wc; + std::size_t result = std::mbrtowc(&wc, in_next, in_end - in_next, &state); + if (result == static_cast(-1)) { + throw std::runtime_error("encoding error: " + std::string(std::strerror(errno))); + } else if (result == static_cast(-2)) { + throw std::runtime_error("conversion error: next bytes constitute an incomplete, but so far " + "valid, multibyte character."); + } + in_next += result; + wide.push_back(wc); + } + + auto& f = std::use_facet>(loc); + f.tolower(&wide[0], &wide[0] + wide.size()); + + std::string out; + out.reserve(wide.size()); + for (wchar_t wc : wide) { + char mb[MB_LEN_MAX]; + std::size_t n = std::wcrtomb(mb, wc, &state); + if (n != static_cast(-1)) out.append(mb, n); + } + + return out; + } + }; + } // namespace verify_ops + + /** + * Verifier class used to check if a decoded token contains all claims required by your application and has a valid + * signature. + */ + template + class verifier { + public: + using basic_claim_t = basic_claim; + /** + * \brief Verification function data structure. + * + * This gets passed the current verifier, a reference to the decoded jwt, a reference to the key of this claim, + * as well as a reference to an error_code. + * The function checks if the actual value matches certain rules (e.g. equality to value x) and sets the error_code if + * it does not. Once a non zero error_code is encountered the verification stops and this error_code becomes the result + * returned from verify + */ + using verify_check_fn_t = + std::function&, std::error_code& ec)>; + + private: + struct algo_base { + virtual ~algo_base() = default; + virtual void verify(const std::string& data, const std::string& sig, std::error_code& ec) = 0; + }; + template + struct algo : public algo_base { + T alg; + explicit algo(T a) : alg(a) {} + void verify(const std::string& data, const std::string& sig, std::error_code& ec) override { + alg.verify(data, sig, ec); + } + }; + /// Required claims + std::unordered_map claims; + /// Leeway time for exp, nbf and iat + size_t default_leeway = 0; + /// Instance of clock type + Clock clock; + /// Supported algorithms + std::unordered_map> algs; + + public: + /** + * Constructor for building a new verifier instance + * \param c Clock instance + */ + explicit verifier(Clock c) : clock(c) { + claims["exp"] = [](const verify_ops::verify_context& ctx, std::error_code& ec) { + if (!ctx.jwt.has_expires_at()) return; + auto exp = ctx.jwt.get_expires_at(); + if (ctx.current_time > exp + std::chrono::seconds(ctx.default_leeway)) { + ec = error::token_verification_error::token_expired; + } + }; + claims["iat"] = [](const verify_ops::verify_context& ctx, std::error_code& ec) { + if (!ctx.jwt.has_issued_at()) return; + auto iat = ctx.jwt.get_issued_at(); + if (ctx.current_time < iat - std::chrono::seconds(ctx.default_leeway)) { + ec = error::token_verification_error::token_expired; + } + }; + claims["nbf"] = [](const verify_ops::verify_context& ctx, std::error_code& ec) { + if (!ctx.jwt.has_not_before()) return; + auto nbf = ctx.jwt.get_not_before(); + if (ctx.current_time < nbf - std::chrono::seconds(ctx.default_leeway)) { + ec = error::token_verification_error::token_expired; + } + }; + } + + /** + * Set default leeway to use. + * \param leeway Default leeway to use if not specified otherwise + * \return *this to allow chaining + */ + verifier& leeway(size_t leeway) { + default_leeway = leeway; + return *this; + } + /** + * Set leeway for expires at. + * If not specified the default leeway will be used. + * \param leeway Set leeway to use for expires at. + * \return *this to allow chaining + */ + verifier& expires_at_leeway(size_t leeway) { + claims["exp"] = verify_ops::date_before_claim{leeway}; + return *this; + } + /** + * Set leeway for not before. + * If not specified the default leeway will be used. + * \param leeway Set leeway to use for not before. + * \return *this to allow chaining + */ + verifier& not_before_leeway(size_t leeway) { + claims["nbf"] = verify_ops::date_after_claim{leeway}; + return *this; + } + /** + * Set leeway for issued at. + * If not specified the default leeway will be used. + * \param leeway Set leeway to use for issued at. + * \return *this to allow chaining + */ + verifier& issued_at_leeway(size_t leeway) { + claims["iat"] = verify_ops::date_after_claim{leeway}; + return *this; + } + + /** + * Set an type to check for. + * + * According to [RFC 7519 Section 5.1](https://datatracker.ietf.org/doc/html/rfc7519#section-5.1), + * This parameter is ignored by JWT implementations; any processing of this parameter is performed by the JWT application. + * Check is case sensitive. + * + * \param type Type Header Parameter to check for. + * \param locale Localization functionality to use when comparing + * \return *this to allow chaining + */ + verifier& with_type(const typename json_traits::string_type& type, std::locale locale = std::locale{}) { + return with_claim("typ", verify_ops::insensitive_string_claim{type, std::move(locale)}); + } + + /** + * Set an issuer to check for. + * Check is case sensitive. + * \param iss Issuer to check for. + * \return *this to allow chaining + */ + verifier& with_issuer(const typename json_traits::string_type& iss) { + return with_claim("iss", basic_claim_t(iss)); + } + + /** + * Set a subject to check for. + * Check is case sensitive. + * \param sub Subject to check for. + * \return *this to allow chaining + */ + verifier& with_subject(const typename json_traits::string_type& sub) { + return with_claim("sub", basic_claim_t(sub)); + } + /** + * Set an audience to check for. + * If any of the specified audiences is not present in the token the check fails. + * \param aud Audience to check for. + * \return *this to allow chaining + */ + verifier& with_audience(const typename basic_claim_t::set_t& aud) { + claims["aud"] = verify_ops::is_subset_claim{aud}; + return *this; + } + /** + * Set an audience to check for. + * If the specified audiences is not present in the token the check fails. + * \param aud Audience to check for. + * \return *this to allow chaining + */ + verifier& with_audience(const typename json_traits::string_type& aud) { + typename basic_claim_t::set_t s; + s.insert(aud); + return with_audience(s); + } + /** + * Set an id to check for. + * Check is case sensitive. + * \param id ID to check for. + * \return *this to allow chaining + */ + verifier& with_id(const typename json_traits::string_type& id) { return with_claim("jti", basic_claim_t(id)); } + + /** + * Specify a claim to check for using the specified operation. + * This is helpful for implementating application specific authentication checks + * such as the one seen in partial-claim-verifier.cpp + * + * \snippet{trimleft} partial-claim-verifier.cpp verifier check custom claim + * + * \param name Name of the claim to check for + * \param fn Function to use for verifying the claim + * \return *this to allow chaining + */ + verifier& with_claim(const typename json_traits::string_type& name, verify_check_fn_t fn) { + claims[name] = fn; + return *this; + } + + /** + * Specify a claim to check for equality (both type & value). + * See the private-claims.cpp example. + * + * \snippet{trimleft} private-claims.cpp verify exact claim + * + * \param name Name of the claim to check for + * \param c Claim to check for + * \return *this to allow chaining + */ + verifier& with_claim(const typename json_traits::string_type& name, basic_claim_t c) { + return with_claim(name, verify_ops::equals_claim{c}); + } + + /** + * \brief Add an algorithm available for checking. + * + * This is used to handle incomming tokens for predefined algorithms + * which the authorization server is provided. For example a small system + * where only a single RSA key-pair is used to sign tokens + * + * \snippet{trimleft} example/rsa-verify.cpp allow rsa algorithm + * + * \tparam Algorithm any algorithm such as those provided by jwt::algorithm + * \param alg Algorithm to allow + * \return *this to allow chaining + */ + template + verifier& allow_algorithm(Algorithm alg) { + algs[alg.name()] = std::make_shared>(alg); + return *this; + } + + /** + * Verify the given token. + * \param jwt Token to check + * \throw token_verification_exception Verification failed + */ + void verify(const decoded_jwt& jwt) const { + std::error_code ec; + verify(jwt, ec); + error::throw_if_error(ec); + } + /** + * Verify the given token. + * \param jwt Token to check + * \param ec error_code filled with details on error + */ + void verify(const decoded_jwt& jwt, std::error_code& ec) const { + ec.clear(); + const typename json_traits::string_type data = jwt.get_header_base64() + "." + jwt.get_payload_base64(); + const typename json_traits::string_type sig = jwt.get_signature(); + const std::string algo = jwt.get_algorithm(); + if (algs.count(algo) == 0) { + ec = error::token_verification_error::wrong_algorithm; + return; + } + algs.at(algo)->verify(data, sig, ec); + if (ec) return; + + verify_ops::verify_context ctx{clock.now(), jwt, default_leeway}; + for (auto& c : claims) { + ctx.claim_key = c.first; + c.second(ctx, ec); + if (ec) return; + } + } + }; + + /** + * \brief JSON Web Key + * + * https://tools.ietf.org/html/rfc7517 + * + * A JSON object that represents a cryptographic key. The members of + * the object represent properties of the key, including its value. + */ + template + class jwk { + using basic_claim_t = basic_claim; + const details::map_of_claims jwk_claims; + + public: + JWT_CLAIM_EXPLICIT jwk(const typename json_traits::string_type& str) + : jwk_claims(details::map_of_claims::parse_claims(str)) {} + + JWT_CLAIM_EXPLICIT jwk(const typename json_traits::value_type& json) + : jwk_claims(json_traits::as_object(json)) {} + + /** + * Get key type claim + * + * This returns the general type (e.g. RSA or EC), not a specific algorithm value. + * \return key type as string + * \throw std::runtime_error If claim was not present + * \throw std::bad_cast Claim was present but not a string (Should not happen in a valid token) + */ + typename json_traits::string_type get_key_type() const { return get_jwk_claim("kty").as_string(); } + + /** + * Get public key usage claim + * \return usage parameter as string + * \throw std::runtime_error If claim was not present + * \throw std::bad_cast Claim was present but not a string (Should not happen in a valid token) + */ + typename json_traits::string_type get_use() const { return get_jwk_claim("use").as_string(); } + + /** + * Get key operation types claim + * \return key operation types as a set of strings + * \throw std::runtime_error If claim was not present + * \throw std::bad_cast Claim was present but not a string (Should not happen in a valid token) + */ + typename basic_claim_t::set_t get_key_operations() const { return get_jwk_claim("key_ops").as_set(); } + + /** + * Get algorithm claim + * \return algorithm as string + * \throw std::runtime_error If claim was not present + * \throw std::bad_cast Claim was present but not a string (Should not happen in a valid token) + */ + typename json_traits::string_type get_algorithm() const { return get_jwk_claim("alg").as_string(); } + + /** + * Get key id claim + * \return key id as string + * \throw std::runtime_error If claim was not present + * \throw std::bad_cast Claim was present but not a string (Should not happen in a valid token) + */ + typename json_traits::string_type get_key_id() const { return get_jwk_claim("kid").as_string(); } + + /** + * \brief Get curve claim + * + * https://www.rfc-editor.org/rfc/rfc7518.html#section-6.2.1.1 + * https://www.iana.org/assignments/jose/jose.xhtml#table-web-key-elliptic-curve + * + * \return curve as string + * \throw std::runtime_error If claim was not present + * \throw std::bad_cast Claim was present but not a string (Should not happen in a valid token) + */ + typename json_traits::string_type get_curve() const { return get_jwk_claim("crv").as_string(); } + + /** + * Get x5c claim + * \return x5c as an array + * \throw std::runtime_error If claim was not present + * \throw std::bad_cast Claim was present but not a array (Should not happen in a valid token) + */ + typename json_traits::array_type get_x5c() const { return get_jwk_claim("x5c").as_array(); }; + + /** + * Get X509 URL claim + * \return x5u as string + * \throw std::runtime_error If claim was not present + * \throw std::bad_cast Claim was present but not a string (Should not happen in a valid token) + */ + typename json_traits::string_type get_x5u() const { return get_jwk_claim("x5u").as_string(); }; + + /** + * Get X509 thumbprint claim + * \return x5t as string + * \throw std::runtime_error If claim was not present + * \throw std::bad_cast Claim was present but not a string (Should not happen in a valid token) + */ + typename json_traits::string_type get_x5t() const { return get_jwk_claim("x5t").as_string(); }; + + /** + * Get X509 SHA256 thumbprint claim + * \return x5t#S256 as string + * \throw std::runtime_error If claim was not present + * \throw std::bad_cast Claim was present but not a string (Should not happen in a valid token) + */ + typename json_traits::string_type get_x5t_sha256() const { return get_jwk_claim("x5t#S256").as_string(); }; + + /** + * Get x5c claim as a string + * \return x5c as an string + * \throw std::runtime_error If claim was not present + * \throw std::bad_cast Claim was present but not a string (Should not happen in a valid token) + */ + typename json_traits::string_type get_x5c_key_value() const { + auto x5c_array = get_jwk_claim("x5c").as_array(); + if (x5c_array.size() == 0) throw error::claim_not_present_exception(); + + return json_traits::as_string(x5c_array.front()); + }; + + /** + * Check if a key type is present ("kty") + * \return true if present, false otherwise + */ + bool has_key_type() const noexcept { return has_jwk_claim("kty"); } + + /** + * Check if a public key usage indication is present ("use") + * \return true if present, false otherwise + */ + bool has_use() const noexcept { return has_jwk_claim("use"); } + + /** + * Check if a key operations parameter is present ("key_ops") + * \return true if present, false otherwise + */ + bool has_key_operations() const noexcept { return has_jwk_claim("key_ops"); } + + /** + * Check if algorithm is present ("alg") + * \return true if present, false otherwise + */ + bool has_algorithm() const noexcept { return has_jwk_claim("alg"); } + + /** + * Check if curve is present ("crv") + * \return true if present, false otherwise + */ + bool has_curve() const noexcept { return has_jwk_claim("crv"); } + + /** + * Check if key id is present ("kid") + * \return true if present, false otherwise + */ + bool has_key_id() const noexcept { return has_jwk_claim("kid"); } + + /** + * Check if X509 URL is present ("x5u") + * \return true if present, false otherwise + */ + bool has_x5u() const noexcept { return has_jwk_claim("x5u"); } + + /** + * Check if X509 Chain is present ("x5c") + * \return true if present, false otherwise + */ + bool has_x5c() const noexcept { return has_jwk_claim("x5c"); } + + /** + * Check if a X509 thumbprint is present ("x5t") + * \return true if present, false otherwise + */ + bool has_x5t() const noexcept { return has_jwk_claim("x5t"); } + + /** + * Check if a X509 SHA256 thumbprint is present ("x5t#S256") + * \return true if present, false otherwise + */ + bool has_x5t_sha256() const noexcept { return has_jwk_claim("x5t#S256"); } + + /** + * Check if a jwk claim is present + * \return true if claim was present, false otherwise + */ + bool has_jwk_claim(const typename json_traits::string_type& name) const noexcept { + return jwk_claims.has_claim(name); + } + + /** + * Get jwk claim by name + * \return Requested claim + * \throw std::runtime_error If claim was not present + */ + basic_claim_t get_jwk_claim(const typename json_traits::string_type& name) const { + return jwk_claims.get_claim(name); + } + + /** + * Check if the jwk has any claims + * \return true is any claim is present + */ + bool empty() const noexcept { return jwk_claims.empty(); } + + /** + * Get all jwk claims + * \return Map of claims + */ + typename json_traits::object_type get_claims() const { return this->jwk_claims.claims; } + }; + + /** + * \brief JWK Set + * + * https://tools.ietf.org/html/rfc7517 + * + * A JSON object that represents a set of JWKs. The JSON object MUST + * have a "keys" member, which is an array of JWKs. + * + * This container takes a JWKs and simplifies it to a vector of JWKs + */ + template + class jwks { + public: + /// JWK instance template specialization + using jwks_t = jwk; + /// Type specialization for the vector of JWK + using jwks_vector_t = std::vector; + using iterator = typename jwks_vector_t::iterator; + using const_iterator = typename jwks_vector_t::const_iterator; + + /** + * Default constructor producing an empty object without any keys + */ + jwks() = default; + + /** + * Parses a string buffer to extract the JWKS. + * \param str buffer containing JSON object representing a JWKS + * \throw error::invalid_json_exception or underlying JSON implation error if the JSON is + * invalid with regards to the JWKS specification + */ + JWT_CLAIM_EXPLICIT jwks(const typename json_traits::string_type& str) { + typename json_traits::value_type parsed_val; + if (!json_traits::parse(parsed_val, str)) throw error::invalid_json_exception(); + + const details::map_of_claims jwks_json = json_traits::as_object(parsed_val); + if (!jwks_json.has_claim("keys")) throw error::invalid_json_exception(); + + auto jwk_list = jwks_json.get_claim("keys").as_array(); + std::transform(jwk_list.begin(), jwk_list.end(), std::back_inserter(jwk_claims), + [](const typename json_traits::value_type& val) { return jwks_t{val}; }); + } + + iterator begin() { return jwk_claims.begin(); } + iterator end() { return jwk_claims.end(); } + const_iterator cbegin() const { return jwk_claims.begin(); } + const_iterator cend() const { return jwk_claims.end(); } + const_iterator begin() const { return jwk_claims.begin(); } + const_iterator end() const { return jwk_claims.end(); } + + /** + * Check if a jwk with the kid is present + * \return true if jwk was present, false otherwise + */ + bool has_jwk(const typename json_traits::string_type& key_id) const noexcept { + return find_by_kid(key_id) != end(); + } + + /** + * Get jwk + * \return Requested jwk by key_id + * \throw std::runtime_error If jwk was not present + */ + jwks_t get_jwk(const typename json_traits::string_type& key_id) const { + const auto maybe = find_by_kid(key_id); + if (maybe == end()) throw error::claim_not_present_exception(); + return *maybe; + } + + private: + jwks_vector_t jwk_claims; + + const_iterator find_by_kid(const typename json_traits::string_type& key_id) const noexcept { + return std::find_if(cbegin(), cend(), [key_id](const jwks_t& jwk) { + if (!jwk.has_key_id()) { return false; } + return jwk.get_key_id() == key_id; + }); + } + }; + + /** + * Create a verifier using the given clock + * \param c Clock instance to use + * \return verifier instance + */ + template + verifier verify(Clock c) { + return verifier(c); + } + + /** + * Create a builder using the given clock + * \param c Clock instance to use + * \return builder instance + */ + template + builder create(Clock c) { + return builder(c); + } + + /** + * Default clock class using std::chrono::system_clock as a backend. + */ + struct default_clock { + /** + * Gets the current system time + * \return time_point of the host system + */ + date now() const { return date::clock::now(); } + }; + + /** + * Create a verifier using the default_clock. + * + * + * + * \param c Clock instance to use + * \return verifier instance + */ + template + verifier verify(default_clock c = {}) { + return verifier(c); + } + + /** + * Return a builder instance to create a new token + */ + template + builder create(default_clock c = {}) { + return builder(c); + } + + /** + * \brief Decode a token. This can be used to to help access important feild like 'x5c' + * for verifying tokens. See associated example rsa-verify.cpp for more details. + * + * \tparam json_traits JSON implementation traits + * \tparam Decode is callable, taking a string_type and returns a string_type. + * It should ensure the padding of the input and then base64url decode and + * return the results. + * \param token Token to decode + * \param decode function that will pad and base64url decode the token + * \return Decoded token + * \throw std::invalid_argument Token is not in correct format + * \throw std::runtime_error Base64 decoding failed or invalid json + */ + template + decoded_jwt decode(const typename json_traits::string_type& token, Decode decode) { + return decoded_jwt(token, decode); + } + + /** + * Decode a token. This can be used to to help access important feild like 'x5c' + * for verifying tokens. See associated example rsa-verify.cpp for more details. + * + * \tparam json_traits JSON implementation traits + * \param token Token to decode + * \return Decoded token + * \throw std::invalid_argument Token is not in correct format + * \throw std::runtime_error Base64 decoding failed or invalid json + */ + template + decoded_jwt decode(const typename json_traits::string_type& token) { + return decoded_jwt(token); + } + /** + * Parse a single JSON Web Key + * \tparam json_traits JSON implementation traits + * \param jwk_ string buffer containing the JSON object + * \return Decoded jwk + */ + template + jwk parse_jwk(const typename json_traits::string_type& jwk_) { + return jwk(jwk_); + } + /** + * Parse a JSON Web Key Set. This can be used to to help access + * important feild like 'x5c' for verifying tokens. See example + * jwks-verify.cpp for more information. + * + * \tparam json_traits JSON implementation traits + * \param jwks_ string buffer containing the JSON object + * \return Parsed JSON object containing the data of the JWK SET string + * \throw std::runtime_error Token is not in correct format + */ + template + jwks parse_jwks(const typename json_traits::string_type& jwks_) { + return jwks(jwks_); + } +} // namespace jwt + +template +std::istream& operator>>(std::istream& is, jwt::basic_claim& c) { + return c.operator>>(is); +} + +template +std::ostream& operator<<(std::ostream& os, const jwt::basic_claim& c) { + return os << c.to_json(); +} + +#ifndef JWT_DISABLE_PICOJSON +#include "traits/kazuho-picojson/defaults.h" +#endif + +#endif diff --git a/third_party/jwt-cpp/include/jwt-cpp/traits/nlohmann-json/defaults.h b/third_party/jwt-cpp/include/jwt-cpp/traits/nlohmann-json/defaults.h new file mode 100644 index 00000000..a5ca8dd1 --- /dev/null +++ b/third_party/jwt-cpp/include/jwt-cpp/traits/nlohmann-json/defaults.h @@ -0,0 +1,91 @@ +#ifndef JWT_CPP_NLOHMANN_JSON_DEFAULTS_H +#define JWT_CPP_NLOHMANN_JSON_DEFAULTS_H + +#ifndef JWT_DISABLE_PICOJSON +#define JWT_DISABLE_PICOJSON +#endif + +#include "traits.h" + +namespace jwt { + /** + * \brief a class to store a generic [JSON for Modern C++](https://github.com/nlohmann/json) value as claim + * + * This type is the specialization of the \ref basic_claim class which + * uses the standard template types. + */ + using claim = basic_claim; + + /** + * Create a verifier using the default clock + * \return verifier instance + */ + inline verifier verify() { + return verify(default_clock{}); + } + + /** + * Create a builder using the default clock + * \return builder instance to create a new token + */ + inline builder create() { + return builder(default_clock{}); + } + +#ifndef JWT_DISABLE_BASE64 + /** + * Decode a token + * \param token Token to decode + * \return Decoded token + * \throw std::invalid_argument Token is not in correct format + * \throw std::runtime_error Base64 decoding failed or invalid json + */ + inline decoded_jwt decode(const std::string& token) { + return decoded_jwt(token); + } +#endif + + /** + * Decode a token + * \tparam Decode is callable, taking a string_type and returns a string_type. + * It should ensure the padding of the input and then base64url decode and + * return the results. + * \param token Token to decode + * \param decode The token to parse + * \return Decoded token + * \throw std::invalid_argument Token is not in correct format + * \throw std::runtime_error Base64 decoding failed or invalid json + */ + template + decoded_jwt decode(const std::string& token, Decode decode) { + return decoded_jwt(token, decode); + } + + /** + * Parse a jwk + * \param token JWK Token to parse + * \return Parsed JWK + * \throw std::runtime_error Token is not in correct format + */ + inline jwk parse_jwk(const traits::nlohmann_json::string_type& token) { + return jwk(token); + } + + /** + * Parse a jwks + * \param token JWKs Token to parse + * \return Parsed JWKs + * \throw std::runtime_error Token is not in correct format + */ + inline jwks parse_jwks(const traits::nlohmann_json::string_type& token) { + return jwks(token); + } + + /** + * This type is the specialization of the \ref verify_ops::verify_context class which + * uses the standard template types. + */ + using verify_context = verify_ops::verify_context; +} // namespace jwt + +#endif // JWT_CPP_NLOHMANN_JSON_DEFAULTS_H diff --git a/third_party/jwt-cpp/include/jwt-cpp/traits/nlohmann-json/traits.h b/third_party/jwt-cpp/include/jwt-cpp/traits/nlohmann-json/traits.h new file mode 100644 index 00000000..272f3d33 --- /dev/null +++ b/third_party/jwt-cpp/include/jwt-cpp/traits/nlohmann-json/traits.h @@ -0,0 +1,81 @@ +#ifndef JWT_CPP_NLOHMANN_JSON_TRAITS_H +#define JWT_CPP_NLOHMANN_JSON_TRAITS_H + +#include "jwt-cpp/jwt.h" +#include "nlohmann/json.hpp" + +namespace jwt { + /** + * \brief Namespace containing all the json_trait implementations for a jwt::basic_claim. + */ + namespace traits { + /// basic_claim's JSON trait implementation for Modern C++ JSON + struct nlohmann_json { + using json = nlohmann::json; + using value_type = json; + using object_type = json::object_t; + using array_type = json::array_t; + using string_type = std::string; // current limitation of traits implementation + using number_type = json::number_float_t; + using integer_type = json::number_integer_t; + using boolean_type = json::boolean_t; + + static jwt::json::type get_type(const json& val) { + using jwt::json::type; + + if (val.type() == json::value_t::boolean) return type::boolean; + // nlohmann internally tracks two types of integers + if (val.type() == json::value_t::number_integer) return type::integer; + if (val.type() == json::value_t::number_unsigned) return type::integer; + if (val.type() == json::value_t::number_float) return type::number; + if (val.type() == json::value_t::string) return type::string; + if (val.type() == json::value_t::array) return type::array; + if (val.type() == json::value_t::object) return type::object; + + throw std::logic_error("invalid type"); + } + + static json::object_t as_object(const json& val) { + if (val.type() != json::value_t::object) throw std::bad_cast(); + return val.get(); + } + + static std::string as_string(const json& val) { + if (val.type() != json::value_t::string) throw std::bad_cast(); + return val.get(); + } + + static json::array_t as_array(const json& val) { + if (val.type() != json::value_t::array) throw std::bad_cast(); + return val.get(); + } + + static int64_t as_integer(const json& val) { + switch (val.type()) { + case json::value_t::number_integer: + case json::value_t::number_unsigned: return val.get(); + default: throw std::bad_cast(); + } + } + + static bool as_boolean(const json& val) { + if (val.type() != json::value_t::boolean) throw std::bad_cast(); + return val.get(); + } + + static double as_number(const json& val) { + if (val.type() != json::value_t::number_float) throw std::bad_cast(); + return val.get(); + } + + static bool parse(json& val, std::string str) { + val = json::parse(str.begin(), str.end()); + return true; + } + + static std::string serialize(const json& val) { return val.dump(); } + }; + } // namespace traits +} // namespace jwt + +#endif From fc52cc470aa7940af9ea7dc61e9ff885004ba6ce Mon Sep 17 00:00:00 2001 From: mwfj Date: Thu, 16 Apr 2026 12:27:01 +0800 Subject: [PATCH 05/17] Apply jwt-cpp library --- Makefile | 7 +- include/auth/token_hasher.h | 21 +- server/auth_claims.cc | 23 +- server/config_loader.cc | 506 ++++++++++++++++++++++++++++++++++++ server/token_hasher.cc | 49 +++- test/auth_foundation_test.h | 381 ++++++++++++++++++++++++--- test/run_test.cc | 7 +- 7 files changed, 939 insertions(+), 55 deletions(-) diff --git a/Makefile b/Makefile index d31bc9aa..d5d389f0 100644 --- a/Makefile +++ b/Makefile @@ -257,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" @@ -337,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 diff --git a/include/auth/token_hasher.h b/include/auth/token_hasher.h index 0ba07362..b480eb1f 100644 --- a/include/auth/token_hasher.h +++ b/include/auth/token_hasher.h @@ -54,13 +54,26 @@ std::string GenerateHmacKey(); // Load key material from an environment variable by name. The env value is // interpreted using auto-detect: -// 1. If the value is valid base64url (no padding) AND decodes to exactly -// 32 bytes, the decoded bytes are used. This is the safer shell-transport -// form recommended by the design spec (§5.1) because raw 32-byte keys -// often contain non-printable bytes that mangle through `.env` files. +// 1. If the value is a valid base64url encoding AND decodes to exactly +// 32 bytes, the decoded bytes are used. The three accepted forms are: +// - RFC 7515 §2 standard unpadded (43 chars for a 32-byte key) +// - jwt-cpp "%3d"-padded (46 chars) +// - Legacy base64 "="-padded (44 chars) +// These are the safer shell-transport forms recommended by the +// design spec (§5.1) because raw 32-byte keys often contain +// non-printable bytes that mangle through `.env` files. // 2. Otherwise, the value is used as raw bytes. // // Returns an empty string when the env var is unset or empty. +// +// CORNER CASE: a raw 43-char key composed entirely of base64url-alphabet +// characters ([A-Za-z0-9_-], which includes UUID-like strings since `-` is +// in the alphabet) is silently interpreted as base64url rather than raw. +// HMAC security is preserved either way (both give 32 bytes of key material), +// but the derived HMAC key differs between interpretations. An `info` log +// line fires when this branch is taken so operators can disambiguate. To +// force raw-bytes interpretation for such keys, either (a) base64url-encode +// the raw bytes explicitly, or (b) use a key length other than 43/44 chars. std::string LoadHmacKeyFromEnv(const std::string& env_var_name); } // namespace auth diff --git a/server/auth_claims.cc b/server/auth_claims.cc index 20c0b19f..01633eb6 100644 --- a/server/auth_claims.cc +++ b/server/auth_claims.cc @@ -24,14 +24,23 @@ std::vector ExtractScopes(const nlohmann::json& payload) { if (payload.contains("scope") && payload["scope"].is_string()) { return SplitWhitespace(payload["scope"].get()); } - // Then `scp` (RFC 8693 / common IdP convention — array of strings). - if (payload.contains("scp") && payload["scp"].is_array()) { - std::vector out; - out.reserve(payload["scp"].size()); - for (const auto& v : payload["scp"]) { - if (v.is_string()) out.push_back(v.get()); + // Then `scp`. Two common forms in the wild: + // - RFC 8693 / Keycloak / Ory: array of strings + // - Azure AD / Entra delegated flows: space-delimited string + // Accept both; fall back to empty list for any other shape (e.g. object). + if (payload.contains("scp")) { + const auto& v = payload["scp"]; + if (v.is_array()) { + std::vector out; + out.reserve(v.size()); + for (const auto& s : v) { + if (s.is_string()) out.push_back(s.get()); + } + return out; + } + if (v.is_string()) { + return SplitWhitespace(v.get()); } - return out; } // Azure AD and friends. if (payload.contains("scopes") && payload["scopes"].is_array()) { diff --git a/server/config_loader.cc b/server/config_loader.cc index 38fb2fb4..ea511489 100644 --- a/server/config_loader.cc +++ b/server/config_loader.cc @@ -1,4 +1,5 @@ #include "config/config_loader.h" +#include "auth/auth_config.h" #include "http2/http2_constants.h" #include "http/route_trie.h" // ParsePattern, ValidatePattern for proxy route_prefix #include "log/logger.h" @@ -15,6 +16,234 @@ using json = nlohmann::json; +// Serialize a single AuthPolicy to JSON (mirror of ParseAuthPolicy for +// ToJson round-trip). Omits defaulted fields only when they would collapse +// noisily; defaulted simple fields are always emitted to keep the output +// shape stable across round-trips. +static nlohmann::json SerializeAuthPolicy(const auth::AuthPolicy& p) { + nlohmann::json out; + if (!p.name.empty()) out["name"] = p.name; + out["enabled"] = p.enabled; + if (!p.applies_to.empty()) out["applies_to"] = p.applies_to; + if (!p.issuers.empty()) out["issuers"] = p.issuers; + if (!p.required_scopes.empty()) out["required_scopes"] = p.required_scopes; + if (!p.required_audience.empty()) out["required_audience"] = p.required_audience; + out["on_undetermined"] = p.on_undetermined; + out["realm"] = p.realm; + return out; +} + +// Parse a single AuthPolicy JSON object. Used both for inline +// `upstreams[i].proxy.auth` and for top-level `auth.policies[]` entries. +// `context` is embedded in error messages so operators can locate the +// offending block. +static void ParseAuthPolicy(const nlohmann::json& j, auth::AuthPolicy& out, + const std::string& context) { + if (!j.is_object()) { + throw std::invalid_argument(context + " must be a JSON object"); + } + out.name = j.value("name", std::string{}); + out.enabled = j.value("enabled", false); + if (j.contains("applies_to")) { + if (!j["applies_to"].is_array()) { + throw std::invalid_argument( + context + ".applies_to must be an array of strings"); + } + for (const auto& p : j["applies_to"]) { + if (!p.is_string()) { + throw std::invalid_argument( + context + ".applies_to entries must be strings"); + } + out.applies_to.push_back(p.get()); + } + } + if (j.contains("issuers")) { + if (!j["issuers"].is_array()) { + throw std::invalid_argument( + context + ".issuers must be an array of strings"); + } + for (const auto& p : j["issuers"]) { + if (!p.is_string()) { + throw std::invalid_argument( + context + ".issuers entries must be strings"); + } + out.issuers.push_back(p.get()); + } + } + if (j.contains("required_scopes")) { + if (!j["required_scopes"].is_array()) { + throw std::invalid_argument( + context + ".required_scopes must be an array of strings"); + } + for (const auto& p : j["required_scopes"]) { + if (!p.is_string()) { + throw std::invalid_argument( + context + ".required_scopes entries must be strings"); + } + out.required_scopes.push_back(p.get()); + } + } + out.required_audience = j.value("required_audience", std::string{}); + out.on_undetermined = j.value("on_undetermined", std::string("deny")); + out.realm = j.value("realm", std::string("api")); +} + +// Parse a single IssuerConfig JSON object for the top-level +// `auth.issuers[name]` map. +static void ParseIssuerConfig(const std::string& name, const nlohmann::json& j, + auth::IssuerConfig& out) { + const std::string ctx = "auth.issuers." + name; + if (!j.is_object()) { + throw std::invalid_argument(ctx + " must be a JSON object"); + } + out.name = name; + out.issuer_url = j.value("issuer_url", std::string{}); + out.discovery = j.value("discovery", true); + out.jwks_uri = j.value("jwks_uri", std::string{}); + out.upstream = j.value("upstream", std::string{}); + out.mode = j.value("mode", std::string("jwt")); + out.leeway_sec = j.value("leeway_sec", 30); + out.jwks_cache_sec = j.value("jwks_cache_sec", 300); + out.jwks_refresh_timeout_sec = j.value("jwks_refresh_timeout_sec", 5); + out.discovery_retry_sec = j.value("discovery_retry_sec", 30); + + if (j.contains("audiences")) { + if (!j["audiences"].is_array()) { + throw std::invalid_argument(ctx + ".audiences must be an array"); + } + out.audiences.clear(); + for (const auto& v : j["audiences"]) { + if (!v.is_string()) { + throw std::invalid_argument( + ctx + ".audiences entries must be strings"); + } + out.audiences.push_back(v.get()); + } + } + if (j.contains("algorithms")) { + if (!j["algorithms"].is_array()) { + throw std::invalid_argument(ctx + ".algorithms must be an array"); + } + out.algorithms.clear(); + for (const auto& v : j["algorithms"]) { + if (!v.is_string()) { + throw std::invalid_argument( + ctx + ".algorithms entries must be strings"); + } + out.algorithms.push_back(v.get()); + } + } + if (j.contains("required_claims")) { + if (!j["required_claims"].is_array()) { + throw std::invalid_argument( + ctx + ".required_claims must be an array"); + } + for (const auto& v : j["required_claims"]) { + if (!v.is_string()) { + throw std::invalid_argument( + ctx + ".required_claims entries must be strings"); + } + out.required_claims.push_back(v.get()); + } + } + if (j.contains("introspection")) { + if (!j["introspection"].is_object()) { + throw std::invalid_argument( + ctx + ".introspection must be an object"); + } + const auto& i = j["introspection"]; + // Reject inline client_secret — only env-var sourcing is allowed + // (design spec §9 item 8, §5.3). + if (i.contains("client_secret")) { + throw std::invalid_argument( + ctx + ".introspection.client_secret must NOT be set inline; " + "use client_secret_env instead"); + } + out.introspection.endpoint = i.value("endpoint", std::string{}); + out.introspection.client_id = i.value("client_id", std::string{}); + out.introspection.client_secret_env = + i.value("client_secret_env", std::string{}); + out.introspection.auth_style = + i.value("auth_style", std::string("basic")); + out.introspection.timeout_sec = i.value("timeout_sec", 3); + out.introspection.cache_sec = i.value("cache_sec", 60); + out.introspection.negative_cache_sec = + i.value("negative_cache_sec", 10); + out.introspection.stale_grace_sec = i.value("stale_grace_sec", 30); + out.introspection.max_entries = i.value("max_entries", 100000); + out.introspection.shards = i.value("shards", 16); + } +} + +// Parse the top-level `auth` block into ServerConfig::auth. +static void ParseAuthConfig(const nlohmann::json& j, auth::AuthConfig& out) { + if (!j.is_object()) { + throw std::invalid_argument("auth must be a JSON object"); + } + out.enabled = j.value("enabled", false); + out.hmac_cache_key_env = j.value("hmac_cache_key_env", std::string{}); + + if (j.contains("issuers")) { + if (!j["issuers"].is_object()) { + throw std::invalid_argument( + "auth.issuers must be an object mapping name -> IssuerConfig"); + } + for (auto it = j["issuers"].begin(); it != j["issuers"].end(); ++it) { + auth::IssuerConfig ic; + ParseIssuerConfig(it.key(), it.value(), ic); + out.issuers.emplace(it.key(), std::move(ic)); + } + } + + if (j.contains("policies")) { + if (!j["policies"].is_array()) { + throw std::invalid_argument( + "auth.policies must be an array of AuthPolicy objects"); + } + for (size_t i = 0; i < j["policies"].size(); ++i) { + auth::AuthPolicy p; + ParseAuthPolicy(j["policies"][i], p, + "auth.policies[" + std::to_string(i) + "]"); + out.policies.push_back(std::move(p)); + } + } + + if (j.contains("forward")) { + if (!j["forward"].is_object()) { + throw std::invalid_argument("auth.forward must be an object"); + } + const auto& f = j["forward"]; + out.forward.subject_header = + f.value("subject_header", std::string("X-Auth-Subject")); + out.forward.issuer_header = + f.value("issuer_header", std::string("X-Auth-Issuer")); + out.forward.scopes_header = + f.value("scopes_header", std::string("X-Auth-Scopes")); + out.forward.raw_jwt_header = + f.value("raw_jwt_header", std::string{}); + out.forward.strip_inbound_identity_headers = + f.value("strip_inbound_identity_headers", true); + out.forward.preserve_authorization = + f.value("preserve_authorization", true); + if (f.contains("claims_to_headers")) { + if (!f["claims_to_headers"].is_object()) { + throw std::invalid_argument( + "auth.forward.claims_to_headers must be an object " + "mapping claim-name -> header-name"); + } + for (auto it = f["claims_to_headers"].begin(); + it != f["claims_to_headers"].end(); ++it) { + if (!it.value().is_string()) { + throw std::invalid_argument( + "auth.forward.claims_to_headers values must be strings"); + } + out.forward.claims_to_headers.emplace( + it.key(), it.value().get()); + } + } + } +} + ServerConfig ConfigLoader::LoadFromFile(const std::string& path) { std::ifstream file(path); if (!file.is_open()) { @@ -265,6 +494,17 @@ ServerConfig ConfigLoader::LoadFromString(const std::string& json_str) { upstream.proxy.retry.retry_on_disconnect = r.value("retry_on_disconnect", true); upstream.proxy.retry.retry_non_idempotent = r.value("retry_non_idempotent", false); } + + // Inline per-proxy auth policy. `applies_to` is derived from + // `route_prefix` at AuthManager::RegisterPolicy time — the + // inline stanza never declares its own `applies_to`. See + // design spec §3.2 / §5.2. + if (proxy.contains("auth")) { + ParseAuthPolicy( + proxy["auth"], + upstream.proxy.auth, + "upstreams[" + upstream.name + "].proxy.auth"); + } } if (item.contains("circuit_breaker")) { @@ -391,6 +631,17 @@ ServerConfig ConfigLoader::LoadFromString(const std::string& json_str) { } } + // Top-level auth config section (OAuth 2.0 token validation — §5.1). + // Parsed into config.auth; actually consumed by AuthManager at startup + // and by HttpServer::Reload() via AuthManager::Reload(). Per-proxy + // auth stanzas are handled inline in the upstreams loop above; the + // top-level section here owns the named issuers registry, the + // top-level `auth.policies[]` with explicit applies_to, the forward + // overlay config, and the HMAC cache-key env-var name. + if (j.contains("auth")) { + ParseAuthConfig(j["auth"], config.auth); + } + return config; } @@ -1219,6 +1470,180 @@ void ConfigLoader::Validate(const ServerConfig& config) { } } } + + // ------------------------------------------------------------------- + // Auth validation (design spec §5.3). + // + // Scope: defensive input validation on the parsed auth config. Hard- + // reject conditions that cannot safely be live-applied later by + // AuthManager — e.g. HS256 which has no symmetric-secret provisioning + // surface in v1, or alg `none` which would constitute an + // authentication bypass if silently accepted. Validator runs once at + // startup (via ConfigLoader::Validate) and whenever reload wiring + // gets added — the runtime code downstream can then trust the parsed + // shape. + // ------------------------------------------------------------------- + { + // Supported asymmetric algorithm allowlist — v1. + const std::unordered_set kAllowedAlgs = { + "RS256", "RS384", "RS512", "ES256", "ES384" + }; + // Collect upstream names to validate `issuer.upstream` references. + std::unordered_set upstream_names; + for (const auto& u : config.upstreams) upstream_names.insert(u.name); + + // Issuer validation. + for (const auto& [name, ic] : config.auth.issuers) { + const std::string ctx = "auth.issuers." + name; + if (name.empty()) { + throw std::invalid_argument( + "auth.issuers key must be a non-empty string"); + } + if (ic.issuer_url.empty()) { + throw std::invalid_argument(ctx + ".issuer_url is required"); + } + // TLS-mandatory to IdP (design spec §9 item 4). Plaintext rejected. + if (ic.issuer_url.rfind("https://", 0) != 0) { + throw std::invalid_argument( + ctx + ".issuer_url must start with https:// (plaintext " + "IdP traffic is rejected for security)"); + } + // Mode whitelist; `auto` is deferred per spec §15. + if (ic.mode != "jwt" && ic.mode != "introspection") { + throw std::invalid_argument( + ctx + ".mode must be one of: \"jwt\", \"introspection\""); + } + // Algorithm allowlist — reject HS*/none/PS*/unknown. Phase 1 is + // asymmetric-only; HS* needs symmetric-secret provisioning + // (deferred, spec §15). + for (const auto& a : ic.algorithms) { + if (kAllowedAlgs.count(a) == 0) { + throw std::invalid_argument( + ctx + ".algorithms contains unsupported value '" + a + + "' (v1 supports only RS256/RS384/RS512/ES256/ES384; " + "HS*/none/PS*/auto are deferred per design spec §15)"); + } + } + // Referenced upstream must exist (for outbound IdP calls). + if (!ic.upstream.empty() && upstream_names.count(ic.upstream) == 0) { + throw std::invalid_argument( + ctx + ".upstream references unknown upstream '" + + ic.upstream + "' — define it under `upstreams[]` first"); + } + // Basic range checks. + if (ic.leeway_sec < 0) { + throw std::invalid_argument(ctx + ".leeway_sec must be >= 0"); + } + if (ic.jwks_cache_sec <= 0) { + throw std::invalid_argument(ctx + ".jwks_cache_sec must be > 0"); + } + } + + // Top-level policy validation. + for (size_t i = 0; i < config.auth.policies.size(); ++i) { + const auto& p = config.auth.policies[i]; + const std::string ctx = + "auth.policies[" + std::to_string(i) + "]"; + if (p.on_undetermined != "deny" && p.on_undetermined != "allow") { + throw std::invalid_argument( + ctx + ".on_undetermined must be \"deny\" or \"allow\""); + } + for (const auto& issuer_name : p.issuers) { + if (config.auth.issuers.count(issuer_name) == 0) { + throw std::invalid_argument( + ctx + ".issuers references unknown issuer '" + + issuer_name + "'"); + } + } + } + + // Inline proxy.auth validation + exact-prefix collision detection. + // Per spec §3.2 / §5.2: a prefix that appears in both an inline + // proxy.auth and a top-level auth.policies[].applies_to is a + // hard-reject config error (ambiguity, not resolved at runtime). + // Also catch duplicate `route_prefix` across proxies when both + // have inline auth. + std::unordered_map inline_prefixes; // prefix -> owner + for (const auto& u : config.upstreams) { + if (u.proxy.auth == auth::AuthPolicy{}) continue; + const auto& p = u.proxy.auth; + const std::string ctx = "upstreams['" + u.name + "'].proxy.auth"; + if (p.on_undetermined != "deny" && p.on_undetermined != "allow") { + throw std::invalid_argument( + ctx + ".on_undetermined must be \"deny\" or \"allow\""); + } + for (const auto& issuer_name : p.issuers) { + if (config.auth.issuers.count(issuer_name) == 0) { + throw std::invalid_argument( + ctx + ".issuers references unknown issuer '" + + issuer_name + "'"); + } + } + // Inline proxy.auth derives its applies_to from the proxy's + // route_prefix. An empty route_prefix is a config bug — the + // policy would apply to everything, shadowing any top-level + // catch-all. + if (u.proxy.route_prefix.empty()) { + throw std::invalid_argument( + ctx + " has no route_prefix — inline auth requires a " + "non-empty proxy.route_prefix to derive applies_to"); + } + auto ins = inline_prefixes.emplace(u.proxy.route_prefix, u.name); + if (!ins.second) { + throw std::invalid_argument( + "prefix '" + u.proxy.route_prefix + "' appears in two " + "proxies with inline auth (upstreams '" + ins.first->second + + "' and '" + u.name + "') — exact-prefix collisions must be " + "resolved at config time (design spec §3.2)"); + } + } + // Top-level policies vs inline: a prefix that appears inline MUST + // NOT also appear in any top-level `auth.policies[].applies_to`. + for (size_t i = 0; i < config.auth.policies.size(); ++i) { + const auto& p = config.auth.policies[i]; + for (const auto& pref : p.applies_to) { + auto it = inline_prefixes.find(pref); + if (it != inline_prefixes.end()) { + throw std::invalid_argument( + "auth.policies[" + std::to_string(i) + "] applies_to " + "contains prefix '" + pref + "' which is already " + "declared by inline proxy.auth on upstream '" + + it->second + "' — resolve the collision at config " + "time (design spec §3.2)"); + } + } + } + + // Forward config: reject header-name collisions among the fixed + // output slots and the claims_to_headers map. Design §5.3. + if (config.auth.forward != auth::AuthForwardConfig{}) { + std::unordered_set output_headers; + auto add_header = [&output_headers](const std::string& name, + const std::string& which) { + if (name.empty()) return; + std::string lower; + lower.reserve(name.size()); + for (char c : name) { + lower.push_back(static_cast( + std::tolower(static_cast(c)))); + } + if (!output_headers.insert(lower).second) { + throw std::invalid_argument( + "auth.forward." + which + " '" + name + + "' collides with another output header name " + "(case-insensitive)"); + } + }; + add_header(config.auth.forward.subject_header, "subject_header"); + add_header(config.auth.forward.issuer_header, "issuer_header"); + add_header(config.auth.forward.scopes_header, "scopes_header"); + add_header(config.auth.forward.raw_jwt_header, "raw_jwt_header"); + for (const auto& [claim, header] : + config.auth.forward.claims_to_headers) { + add_header(header, "claims_to_headers[" + claim + "]"); + } + } + } } ServerConfig ConfigLoader::Default() { @@ -1297,6 +1722,14 @@ std::string ConfigLoader::ToJson(const ServerConfig& config) { rj["retry_non_idempotent"] = u.proxy.retry.retry_non_idempotent; pj["retry"] = rj; + // Inline per-proxy auth policy. Only emitted when differs from + // default — same shape as the circuit_breaker block below — + // because an empty/disabled stanza is the common case and + // serializing it adds noise to every config dump. + if (u.proxy.auth != auth::AuthPolicy{}) { + pj["auth"] = SerializeAuthPolicy(u.proxy.auth); + } + uj["proxy"] = pj; } // Always serialize circuit_breaker — same rationale as proxy block. @@ -1348,5 +1781,78 @@ std::string ConfigLoader::ToJson(const ServerConfig& config) { j["rate_limit"] = rlj; } + // Auth top-level block serialization. Emitted whenever it differs + // from defaults so a round-trip preserves operator intent. + if (config.auth != auth::AuthConfig{}) { + nlohmann::json aj; + aj["enabled"] = config.auth.enabled; + if (!config.auth.hmac_cache_key_env.empty()) { + aj["hmac_cache_key_env"] = config.auth.hmac_cache_key_env; + } + if (!config.auth.issuers.empty()) { + nlohmann::json ij = nlohmann::json::object(); + for (const auto& [name, ic] : config.auth.issuers) { + nlohmann::json ijv; + ijv["issuer_url"] = ic.issuer_url; + ijv["discovery"] = ic.discovery; + if (!ic.jwks_uri.empty()) ijv["jwks_uri"] = ic.jwks_uri; + ijv["upstream"] = ic.upstream; + ijv["mode"] = ic.mode; + if (!ic.audiences.empty()) ijv["audiences"] = ic.audiences; + ijv["algorithms"] = ic.algorithms; + ijv["leeway_sec"] = ic.leeway_sec; + ijv["jwks_cache_sec"] = ic.jwks_cache_sec; + ijv["jwks_refresh_timeout_sec"] = ic.jwks_refresh_timeout_sec; + ijv["discovery_retry_sec"] = ic.discovery_retry_sec; + if (!ic.required_claims.empty()) { + ijv["required_claims"] = ic.required_claims; + } + if (ic.introspection != auth::IntrospectionConfig{}) { + nlohmann::json inj; + inj["endpoint"] = ic.introspection.endpoint; + inj["client_id"] = ic.introspection.client_id; + inj["client_secret_env"] = + ic.introspection.client_secret_env; + inj["auth_style"] = ic.introspection.auth_style; + inj["timeout_sec"] = ic.introspection.timeout_sec; + inj["cache_sec"] = ic.introspection.cache_sec; + inj["negative_cache_sec"] = + ic.introspection.negative_cache_sec; + inj["stale_grace_sec"] = + ic.introspection.stale_grace_sec; + inj["max_entries"] = ic.introspection.max_entries; + inj["shards"] = ic.introspection.shards; + ijv["introspection"] = inj; + } + ij[name] = ijv; + } + aj["issuers"] = ij; + } + if (!config.auth.policies.empty()) { + nlohmann::json pj = nlohmann::json::array(); + for (const auto& p : config.auth.policies) { + pj.push_back(SerializeAuthPolicy(p)); + } + aj["policies"] = pj; + } + if (config.auth.forward != auth::AuthForwardConfig{}) { + nlohmann::json fj; + fj["subject_header"] = config.auth.forward.subject_header; + fj["issuer_header"] = config.auth.forward.issuer_header; + fj["scopes_header"] = config.auth.forward.scopes_header; + fj["raw_jwt_header"] = config.auth.forward.raw_jwt_header; + fj["strip_inbound_identity_headers"] = + config.auth.forward.strip_inbound_identity_headers; + fj["preserve_authorization"] = + config.auth.forward.preserve_authorization; + if (!config.auth.forward.claims_to_headers.empty()) { + fj["claims_to_headers"] = + config.auth.forward.claims_to_headers; + } + aj["forward"] = fj; + } + j["auth"] = aj; + } + return j.dump(4); } diff --git a/server/token_hasher.cc b/server/token_hasher.cc index 8fdac952..96bd5b7e 100644 --- a/server/token_hasher.cc +++ b/server/token_hasher.cc @@ -81,14 +81,25 @@ std::string LoadHmacKeyFromEnv(const std::string& env_var_name) { if (raw.empty()) return {}; // Auto-detect base64url: operators typically store base64url-encoded keys - // because raw 32-byte binaries mangle through `.env` transport. A valid - // base64url-encoded 32-byte key is exactly 43 chars (no-padding form) or - // 44 with '=' padding. Accept either by trying the base64url decoder - // first and using the result only when it yields EXACTLY 32 bytes — any - // other length means the env value wasn't a base64url 32-byte key and we - // fall back to treating it as raw. + // because raw 32-byte binaries mangle through `.env` transport. // - // Strip a trailing '=' padding char so 44-char padded forms also work. + // Accept THREE equivalent base64url forms for a 32-byte key: + // 1. RFC 7515 §2 standard: 43 chars, NO padding (e.g. "QUFB...QUE") + // 2. jwt-cpp native: 46 chars, "%3d" padding (percent-encoded '=') + // 3. Legacy base64: 44 chars, "=" padding + // All three decode to the same 32 bytes. + // + // The trick: jwt-cpp's `decode` expects form (2) — the + // alphabet's fill() returns "%3d", not "=". Form (1) throws because + // `(size + 0) % 4 != 0`. Form (3) throws because "=" is not in the + // base64url alphabet. We normalize by stripping BOTH padding forms, + // then re-padding with the form jwt-cpp wants. This matches how + // jwt-cpp itself handles JWT segments (see jwt.h:682, 1055, 2985 — + // `decode(pad(token))`). + // + // Result is accepted ONLY when it yields EXACTLY 32 bytes, per §5.1 + // contract. Any other decoded length means the env value wasn't a + // base64url 32-byte key and we fall back to raw. // // Exception containment (design spec §9 item 16): jwt::base::decode // throws std::runtime_error on invalid input (illegal chars, bad length). @@ -96,11 +107,31 @@ std::string LoadHmacKeyFromEnv(const std::string& env_var_name) { // — a malformed env value must not propagate as an exception into // AuthManager::Start(), which would abort startup. std::string candidate = raw; + // Step 1: strip standard '=' padding (if operator encoded with the + // legacy base64 form). while (!candidate.empty() && candidate.back() == '=') candidate.pop_back(); + // Step 2: strip jwt-cpp "%3d" padding too — trim() returns the substring + // before the first occurrence of the fill string. + candidate = jwt::base::trim(candidate); try { - std::string decoded = - jwt::base::decode(candidate); + std::string decoded = jwt::base::decode( + jwt::base::pad(candidate)); if (decoded.size() == 32) { + // Silent-swap corner case (review round N+1, finding #4): an + // operator's raw 43-char key composed entirely of base64url + // alphabet chars [A-Za-z0-9_-] will be interpreted as encoded + // rather than raw. HMAC security is preserved either way (both + // forms give 32 bytes of key material), but the derived key + // differs between interpretations. Log at info so operators + // see the decision in their startup logs and can disambiguate + // if they intended a 43-char raw key (recommend: change length + // or base64url-encode explicitly). + logging::Get()->info( + "LoadHmacKeyFromEnv: env var '{}' interpreted as " + "base64url-encoded 32-byte key (decoded). If you intended " + "a raw 43-char key, either base64url-encode it explicitly " + "or pick a length other than 43/44 chars.", + env_var_name); return decoded; } } catch (const std::exception& e) { diff --git a/test/auth_foundation_test.h b/test/auth_foundation_test.h index 56ebe3d9..c797ce94 100644 --- a/test/auth_foundation_test.h +++ b/test/auth_foundation_test.h @@ -16,7 +16,11 @@ #include "test_framework.h" #include "auth/token_hasher.h" +#include "auth/auth_claims.h" +#include "auth/auth_context.h" +#include "config/config_loader.h" #include "jwt-cpp/base.h" +#include #include #include @@ -69,17 +73,22 @@ void TestLoadHmacKeyFromEnvDoesNotThrow() { // Preserve/restore the env var across the test. No bleedthrough to // other tests (most of which run servers and don't touch this var). + // + // Hoisted out of the try/catch so the outer catch can reference the + // saved original value — evaluating std::getenv() in the catch would + // return the test-fixture value instead of the pre-test original. const char* kVarName = "REACTOR_TEST_AUTH_BAD_KEY"; auto restore_env = [](const char* name, const char* prev) { if (prev) setenv(name, prev, 1); else unsetenv(name); }; + const char* pre_test_prev = std::getenv(kVarName); + std::string saved = pre_test_prev ? pre_test_prev : ""; + bool had_original = pre_test_prev != nullptr; try { // Case 1: illegal-char base64url (@ is not a base64url alphabet char). // This is a syntactically invalid input that jwt::base::decode throws on. - const char* prev = std::getenv(kVarName); - std::string saved = prev ? prev : ""; setenv(kVarName, "@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@", 1); // 43 chars, all invalid std::string bad_key; bool threw = false; @@ -111,7 +120,7 @@ void TestLoadHmacKeyFromEnvDoesNotThrow() { ? "LoadHmacKeyFromEnv PROPAGATED exception on length-1 input" : (case2_pass ? "" : "Expected raw-bytes fallback 'A', got '" + short_key + "'"); - restore_env(kVarName, saved.empty() ? nullptr : saved.c_str()); + restore_env(kVarName, had_original ? saved.c_str() : nullptr); bool pass = case1_pass && case2_pass; std::string err; @@ -122,7 +131,9 @@ void TestLoadHmacKeyFromEnvDoesNotThrow() { "AuthFoundation: LoadHmacKeyFromEnv contains jwt-cpp exceptions", pass, err, TestFramework::TestCategory::OTHER); } catch (const std::exception& e) { - restore_env(kVarName, std::getenv(kVarName)); + // Restore using the HOISTED saved value — not std::getenv(), which + // would return the test-fixture value at this point. + restore_env(kVarName, had_original ? saved.c_str() : nullptr); TestFramework::RecordTest( "AuthFoundation: LoadHmacKeyFromEnv contains jwt-cpp exceptions", false, std::string("unexpected test harness failure: ") + e.what(), @@ -143,47 +154,57 @@ void TestLoadHmacKeyFromEnvAutoDetect() { if (prev) setenv(name, prev, 1); else unsetenv(name); }; + // Hoisted out of the try/catch so the outer catch restores the + // pre-test value, not the test-fixture value. + const char* pre_test_prev = std::getenv(kVarName); + std::string saved = pre_test_prev ? pre_test_prev : ""; + bool had_original = pre_test_prev != nullptr; try { // Derive the correct base64url encoding of 32 bytes of 0x41 via // jwt-cpp's public helper — avoids a hand-computed constant that // would drift if the encoder ever changed or get mis-hand-counted. const std::string raw32(32, 'A'); // 32 bytes of 0x41 - std::string base64url_of_32_As = - jwt::base::encode(raw32); - // Strip any trailing '=' padding chars — LoadHmacKeyFromEnv's - // auto-detect supports both padded and no-padding forms but the - // contract is "exactly 32 bytes after decode" regardless. - while (!base64url_of_32_As.empty() && - base64url_of_32_As.back() == '=') { - base64url_of_32_As.pop_back(); - } + // RFC 7515 standard form: trim any padding. LoadHmacKeyFromEnv's + // normalizer accepts either padded or unpadded input — we test the + // unpadded form because that's the form JWT tooling produces. + std::string base64url_unpadded = + jwt::base::trim( + jwt::base::encode(raw32)); - const char* prev = std::getenv(kVarName); - std::string saved = prev ? prev : ""; - setenv(kVarName, base64url_of_32_As.c_str(), 1); - std::string decoded_key = auth::LoadHmacKeyFromEnv(kVarName); + // Case A: unpadded base64url (RFC 7515 standard — the review's + // finding that this branch must work). + setenv(kVarName, base64url_unpadded.c_str(), 1); + std::string decoded_unpadded = auth::LoadHmacKeyFromEnv(kVarName); - // Contract: must be interpreted as base64url → 32 bytes, NOT raw 43. - bool decoded_to_32 = decoded_key.size() == 32; - bool all_As = decoded_to_32 && - decoded_key.find_first_not_of('A') == std::string::npos; + // Case B: same value but with jwt-cpp "%3d" padding (what + // jwt::base::encode produces natively) — must also work. + std::string base64url_padded = + jwt::base::encode(raw32); + setenv(kVarName, base64url_padded.c_str(), 1); + std::string decoded_padded = auth::LoadHmacKeyFromEnv(kVarName); - // Also check raw fallback: a 16-char string that decodes to 12 bytes - // (not 32) should fall back to raw. - setenv(kVarName, "AAAAAAAAAAAAAAAA", 1); // 16 chars base64url -> 12 bytes + // Case C: raw fallback — 16-char string decodes to 12 bytes (not 32) + // so auto-detect should decline and return the raw bytes. + setenv(kVarName, "AAAAAAAAAAAAAAAA", 1); std::string raw_fallback = auth::LoadHmacKeyFromEnv(kVarName); - bool raw_ok = raw_fallback == "AAAAAAAAAAAAAAAA"; - restore_env(kVarName, saved.empty() ? nullptr : saved.c_str()); + restore_env(kVarName, had_original ? saved.c_str() : nullptr); - bool pass = all_As && raw_ok; + bool unpadded_ok = decoded_unpadded.size() == 32 && + decoded_unpadded.find_first_not_of('A') == std::string::npos; + bool padded_ok = decoded_padded.size() == 32 && + decoded_padded.find_first_not_of('A') == std::string::npos; + bool raw_ok = raw_fallback == "AAAAAAAAAAAAAAAA"; + + bool pass = unpadded_ok && padded_ok && raw_ok; std::string err; - if (!decoded_to_32) { - err = "base64url 32-byte input not auto-detected; got size=" + - std::to_string(decoded_key.size()); - } else if (!all_As) { - err = "base64url decode produced wrong bytes"; + if (!unpadded_ok) { + err = "RFC 7515 unpadded base64url not accepted; got size=" + + std::to_string(decoded_unpadded.size()); + } else if (!padded_ok) { + err = "jwt-cpp '%3d'-padded base64url not accepted; got size=" + + std::to_string(decoded_padded.size()); } else if (!raw_ok) { err = "16-char input (decodes to 12 bytes) should fall to raw, got '" + raw_fallback + "'"; @@ -193,7 +214,9 @@ void TestLoadHmacKeyFromEnvAutoDetect() { "AuthFoundation: LoadHmacKeyFromEnv base64url auto-detect", pass, err, TestFramework::TestCategory::OTHER); } catch (const std::exception& e) { - restore_env(kVarName, std::getenv(kVarName)); + // Restore using the HOISTED saved value, NOT std::getenv(), which + // at this point returns the test-fixture value. + restore_env(kVarName, had_original ? saved.c_str() : nullptr); TestFramework::RecordTest( "AuthFoundation: LoadHmacKeyFromEnv base64url auto-detect", false, std::string("unexpected exception: ") + e.what(), @@ -201,11 +224,303 @@ void TestLoadHmacKeyFromEnvAutoDetect() { } } +// ----------------------------------------------------------------------------- +// ExtractScopes — accept `scp` as a string (Azure AD / Entra) not just array. +// Pins the review-round-N+1 P2 fix for auth_claims.cc. +// ----------------------------------------------------------------------------- +void TestExtractScopesScpAsString() { + std::cout << "\n[TEST] ExtractScopes scp-as-string (Azure AD)..." << std::endl; + try { + // Azure AD delegated flow: scp is a space-separated string. + auto payload_str = nlohmann::json::parse( + R"({"scp":"read:data read:profile write:data"})"); + auto scopes_str = auth::ExtractScopes(payload_str); + + // Traditional array form must still work. + auto payload_arr = nlohmann::json::parse( + R"({"scp":["read:data","read:profile"]})"); + auto scopes_arr = auth::ExtractScopes(payload_arr); + + // `scope` (space-sep string, OAuth2 classic) must still work. + auto payload_scope = nlohmann::json::parse( + R"({"scope":"alpha beta gamma"})"); + auto scopes_scope = auth::ExtractScopes(payload_scope); + + bool str_ok = scopes_str.size() == 3 && + scopes_str[0] == "read:data" && + scopes_str[1] == "read:profile" && + scopes_str[2] == "write:data"; + bool arr_ok = scopes_arr.size() == 2 && + scopes_arr[0] == "read:data" && + scopes_arr[1] == "read:profile"; + bool scope_ok = scopes_scope.size() == 3 && + scopes_scope[0] == "alpha"; + + bool pass = str_ok && arr_ok && scope_ok; + std::string err; + if (!str_ok) err = "scp string-valued form not split into scope list"; + else if (!arr_ok) err = "scp array-valued form broke"; + else if (!scope_ok) err = "scope (space-sep) form broke"; + + TestFramework::RecordTest( + "AuthFoundation: ExtractScopes scp-as-string", + pass, err, TestFramework::TestCategory::OTHER); + } catch (const std::exception& e) { + TestFramework::RecordTest( + "AuthFoundation: ExtractScopes scp-as-string", + false, std::string("unexpected exception: ") + e.what(), + TestFramework::TestCategory::OTHER); + } +} + +// ----------------------------------------------------------------------------- +// ConfigLoader round-trip for the auth block — pins review round N+1 P1 fix. +// A config-driven deployment's top-level `auth` block and per-proxy +// `proxy.auth` policy must survive LoadFromString → ToJson → LoadFromString. +// Before the fix these were silently dropped; this test ensures the loader +// actually reads and writes the fields. +// ----------------------------------------------------------------------------- +void TestConfigLoaderAuthRoundTrip() { + std::cout << "\n[TEST] ConfigLoader auth round-trip..." << std::endl; + try { + const std::string kInput = R"({ + "bind_host": "127.0.0.1", + "bind_port": 8080, + "upstreams": [ + { + "name": "internal-api", + "host": "127.0.0.1", + "port": 8080, + "proxy": { + "route_prefix": "/api/v1", + "auth": { + "enabled": true, + "issuers": ["google"], + "required_scopes": ["read:data"], + "on_undetermined": "deny", + "realm": "api" + } + } + }, + { + "name": "idp_google", + "host": "127.0.0.2", + "port": 443 + } + ], + "auth": { + "enabled": true, + "hmac_cache_key_env": "MY_HMAC_KEY", + "issuers": { + "google": { + "issuer_url": "https://accounts.google.com", + "discovery": true, + "upstream": "idp_google", + "mode": "jwt", + "audiences": ["my-backend"], + "algorithms": ["RS256"], + "leeway_sec": 30 + } + }, + "policies": [ + { + "name": "public-health", + "enabled": false, + "applies_to": ["/public/"] + } + ], + "forward": { + "subject_header": "X-Auth-Subject", + "claims_to_headers": {"email": "X-Auth-Email"}, + "preserve_authorization": true, + "raw_jwt_header": "" + } + } + })"; + + ServerConfig c1 = ConfigLoader::LoadFromString(kInput); + + // Post-parse assertions — prove the fields aren't silently dropped. + bool parsed_top = + c1.auth.enabled && + c1.auth.hmac_cache_key_env == "MY_HMAC_KEY" && + c1.auth.issuers.count("google") == 1 && + c1.auth.issuers.at("google").issuer_url == + "https://accounts.google.com" && + c1.auth.issuers.at("google").upstream == "idp_google" && + c1.auth.issuers.at("google").mode == "jwt" && + c1.auth.issuers.at("google").audiences.size() == 1 && + c1.auth.issuers.at("google").audiences[0] == "my-backend" && + c1.auth.issuers.at("google").algorithms.size() == 1 && + c1.auth.issuers.at("google").algorithms[0] == "RS256" && + c1.auth.issuers.at("google").leeway_sec == 30 && + c1.auth.policies.size() == 1 && + c1.auth.policies[0].name == "public-health" && + !c1.auth.policies[0].enabled && + c1.auth.policies[0].applies_to.size() == 1 && + c1.auth.policies[0].applies_to[0] == "/public/" && + c1.auth.forward.subject_header == "X-Auth-Subject" && + c1.auth.forward.claims_to_headers.count("email") == 1 && + c1.auth.forward.claims_to_headers.at("email") == "X-Auth-Email" && + c1.auth.forward.preserve_authorization && + c1.auth.forward.raw_jwt_header.empty(); + + // Inline proxy.auth must also survive parse. + bool parsed_inline = false; + for (const auto& u : c1.upstreams) { + if (u.name == "internal-api") { + const auto& a = u.proxy.auth; + parsed_inline = + a.enabled && + a.issuers.size() == 1 && a.issuers[0] == "google" && + a.required_scopes.size() == 1 && + a.required_scopes[0] == "read:data" && + a.on_undetermined == "deny" && + a.realm == "api"; + } + } + + // Validation must accept this config (algorithms OK, upstream exists, + // no collisions, https issuer). + bool validation_ok = true; + std::string validation_err; + try { + ConfigLoader::Validate(c1); + } catch (const std::exception& e) { + validation_ok = false; + validation_err = e.what(); + } + + // Round-trip through ToJson → LoadFromString must preserve both + // top-level auth and inline proxy.auth. + std::string reserialized = ConfigLoader::ToJson(c1); + ServerConfig c2 = ConfigLoader::LoadFromString(reserialized); + + bool round_trip_ok = + c2.auth == c1.auth && + c2.upstreams.size() == c1.upstreams.size(); + if (round_trip_ok) { + for (size_t i = 0; i < c1.upstreams.size(); ++i) { + if (!(c1.upstreams[i].proxy.auth == + c2.upstreams[i].proxy.auth)) { + round_trip_ok = false; + break; + } + } + } + + bool pass = parsed_top && parsed_inline && validation_ok && round_trip_ok; + std::string err; + if (!parsed_top) err = "top-level auth block not parsed — fields silently dropped"; + else if (!parsed_inline) err = "upstreams[].proxy.auth not parsed — inline policy silently dropped"; + else if (!validation_ok) err = "validation rejected a valid config: " + validation_err; + else if (!round_trip_ok) err = "ToJson -> LoadFromString did not preserve auth config"; + + TestFramework::RecordTest( + "AuthFoundation: ConfigLoader auth round-trip", + pass, err, TestFramework::TestCategory::OTHER); + } catch (const std::exception& e) { + TestFramework::RecordTest( + "AuthFoundation: ConfigLoader auth round-trip", + false, std::string("unexpected exception: ") + e.what(), + TestFramework::TestCategory::OTHER); + } +} + +// ----------------------------------------------------------------------------- +// ConfigLoader::Validate — reject HS256 / none / non-https / unknown issuer +// referenced by policy. Pins the validation rules from §5.3. +// ----------------------------------------------------------------------------- +void TestConfigLoaderAuthValidation() { + std::cout << "\n[TEST] ConfigLoader auth validation rejects bad inputs..." << std::endl; + try { + auto validate_expect_failure = [](const std::string& json, + const std::string& what_to_contain) -> std::string { + try { + auto c = ConfigLoader::LoadFromString(json); + ConfigLoader::Validate(c); + } catch (const std::exception& e) { + std::string msg = e.what(); + if (msg.find(what_to_contain) != std::string::npos) return {}; + return "expected error containing '" + what_to_contain + + "', got: " + msg; + } + return "expected exception not thrown (should contain '" + + what_to_contain + "')"; + }; + + std::string err; + + // Case 1: HS256 algorithm. + err = validate_expect_failure(R"({ + "upstreams": [{"name":"x","host":"127.0.0.1","port":80}], + "auth": {"issuers": {"ours": { + "issuer_url":"https://auth.internal", + "upstream":"x", "mode":"jwt", + "algorithms":["HS256"] + }}} + })", "HS256"); + if (!err.empty()) throw std::runtime_error("HS256 case: " + err); + + // Case 2: alg `none`. + err = validate_expect_failure(R"({ + "upstreams": [{"name":"x","host":"127.0.0.1","port":80}], + "auth": {"issuers": {"ours": { + "issuer_url":"https://auth.internal", + "upstream":"x", "mode":"jwt", + "algorithms":["none"] + }}} + })", "none"); + if (!err.empty()) throw std::runtime_error("alg=none case: " + err); + + // Case 3: non-https issuer URL. + err = validate_expect_failure(R"({ + "upstreams": [{"name":"x","host":"127.0.0.1","port":80}], + "auth": {"issuers": {"ours": { + "issuer_url":"http://insecure.example.com", + "upstream":"x" + }}} + })", "https://"); + if (!err.empty()) throw std::runtime_error("non-https case: " + err); + + // Case 4: policy references unknown issuer. + err = validate_expect_failure(R"({ + "upstreams": [{"name":"x","host":"127.0.0.1","port":80}], + "auth": { + "issuers": {"ours": {"issuer_url":"https://a","upstream":"x"}}, + "policies": [{"name":"p","enabled":true,"applies_to":["/a"],"issuers":["unknown"]}] + } + })", "unknown issuer"); + if (!err.empty()) throw std::runtime_error("unknown issuer case: " + err); + + // Case 5: inline client_secret (forbidden — must use env var). + err = validate_expect_failure(R"({ + "upstreams": [{"name":"x","host":"127.0.0.1","port":80}], + "auth": {"issuers": {"ours": { + "issuer_url":"https://a","upstream":"x","mode":"introspection", + "introspection":{"client_id":"c","client_secret":"s"} + }}} + })", "client_secret"); + if (!err.empty()) throw std::runtime_error("inline client_secret case: " + err); + + TestFramework::RecordTest( + "AuthFoundation: ConfigLoader auth validation", + true, "", TestFramework::TestCategory::OTHER); + } catch (const std::exception& e) { + TestFramework::RecordTest( + "AuthFoundation: ConfigLoader auth validation", + false, e.what(), TestFramework::TestCategory::OTHER); + } +} + inline void RunAllTests() { std::cout << "\n===== Auth Foundation Tests =====" << std::endl; TestHasherBasicDeterminism(); TestLoadHmacKeyFromEnvDoesNotThrow(); TestLoadHmacKeyFromEnvAutoDetect(); + TestExtractScopesScpAsString(); + TestConfigLoaderAuthRoundTrip(); + TestConfigLoaderAuthValidation(); } } // namespace AuthFoundationTests diff --git a/test/run_test.cc b/test/run_test.cc index 40463d6e..1b1bf1bf 100644 --- a/test/run_test.cc +++ b/test/run_test.cc @@ -131,8 +131,10 @@ void PrintUsage(const char* program_name) { std::cout << " upstream, -U Run upstream connection pool tests only" << std::endl; std::cout << " proxy, -P Run proxy engine tests only" << std::endl; std::cout << " rate_limit, -L Run rate limit tests only" << std::endl; + std::cout << " circuit_breaker, -B Run circuit-breaker tests only" << std::endl; + std::cout << " auth, -A Run auth foundation tests only" << std::endl; std::cout << " help, -h Show this help message" << std::endl; - std::cout << "\nNo arguments: Run all tests (basic + stress + race + timeout + config + http + ws + tls + cli + http2 + route + kqueue + upstream + proxy + rate_limit)" << std::endl; + std::cout << "\nNo arguments: Run all tests (basic + stress + race + timeout + config + http + ws + tls + cli + http2 + route + kqueue + upstream + proxy + rate_limit + circuit_breaker + auth)" << std::endl; } int main(int argc, char* argv[]) { @@ -191,6 +193,9 @@ int main(int argc, char* argv[]) { // Run circuit-breaker tests (unit + components + integration + retry-budget + drain + observability + reload) }else if(mode == "circuit_breaker" || mode == "-B"){ CircuitBreakerTests::RunAllTests(); + // Run auth foundation tests (token_hasher + base64url env auto-detect + scope extractors) + }else if(mode == "auth" || mode == "-A"){ + AuthFoundationTests::RunAllTests(); CircuitBreakerComponentsTests::RunAllTests(); CircuitBreakerIntegrationTests::RunAllTests(); CircuitBreakerRetryBudgetTests::RunAllTests(); From ef67dc227fb6863a3db9742fefffa74d13746dc2 Mon Sep 17 00:00:00 2001 From: mwfj Date: Thu, 16 Apr 2026 13:20:39 +0800 Subject: [PATCH 06/17] Fix review comment --- server/auth_claims.cc | 16 ++-- server/config_loader.cc | 167 ++++++++++++++++++++++++++++++++-------- server/token_hasher.cc | 53 +++++++++---- test/run_test.cc | 6 +- 4 files changed, 189 insertions(+), 53 deletions(-) diff --git a/server/auth_claims.cc b/server/auth_claims.cc index 01633eb6..ec029c2e 100644 --- a/server/auth_claims.cc +++ b/server/auth_claims.cc @@ -80,16 +80,22 @@ bool PopulateFromPayload(const nlohmann::json& payload, // serialization choice depends on what the upstream expects. Phase 3 // wiring should add that flattening at the overlay layer, not here. // - // Numeric truncation caveat: uint64_t claims that exceed INT64_MAX are - // currently read via is_number_integer() + get(), which nlohmann - // clamps to the signed range. Tolerable for human-readable claim types - // (email, sub, username) but callers that need big-unsigned claim - // fidelity should extract them directly from the payload JSON. + // Numeric claims: check unsigned FIRST, then signed. This ordering + // preserves uint64 values > INT64_MAX — e.g. OAuth numeric IDs for user + // or tenant claims that operators may map via claims_to_headers. + // Without the unsigned branch, `is_number_integer()` + `get()` + // would silently wrap values like 18446744073709551615 to -1, which + // would then flow into X-Auth-* headers and downstream services would + // see the wrong principal data. nlohmann's `is_number_integer()` + // returns true for both signed and unsigned; `is_number_unsigned()` + // narrows to the specific unsigned shape. for (const auto& key : claims_keys) { if (!payload.contains(key)) continue; const auto& v = payload[key]; if (v.is_string()) { ctx.claims[key] = v.get(); + } else if (v.is_number_unsigned()) { + ctx.claims[key] = std::to_string(v.get()); } else if (v.is_number_integer()) { ctx.claims[key] = std::to_string(v.get()); } else if (v.is_number_float()) { diff --git a/server/config_loader.cc b/server/config_loader.cc index ea511489..3098f8b1 100644 --- a/server/config_loader.cc +++ b/server/config_loader.cc @@ -16,6 +16,30 @@ using json = nlohmann::json; +// Strict integer parser for security-sensitive auth knobs. +// +// nlohmann/json's `j.value("key", default)` silently coerces non-integer +// JSON values — `true` becomes 1, `1.9` becomes 1 — which would let invalid +// configuration pass validation for fields like `leeway_sec`, `cache_sec`, +// `timeout_sec`, etc. An operator typo that stores `"leeway_sec": true` +// would result in a 1-second leeway instead of surfacing as a config error. +// +// This helper rejects non-integer JSON at parse time. `is_number_integer()` +// is the right gate: it returns true for both signed and unsigned integers +// and false for booleans, floats, null, strings, arrays, and objects. +static int ParseStrictInt(const nlohmann::json& j, const std::string& key, + int default_value, const std::string& context) { + if (!j.contains(key)) return default_value; + const auto& v = j[key]; + if (v.is_null()) return default_value; + if (!v.is_number_integer()) { + throw std::invalid_argument( + context + "." + key + " must be an integer " + "(got " + std::string(v.type_name()) + ")"); + } + return v.get(); +} + // Serialize a single AuthPolicy to JSON (mirror of ParseAuthPolicy for // ToJson round-trip). Omits defaulted fields only when they would collapse // noisily; defaulted simple fields are always emitted to keep the output @@ -42,6 +66,14 @@ static void ParseAuthPolicy(const nlohmann::json& j, auth::AuthPolicy& out, if (!j.is_object()) { throw std::invalid_argument(context + " must be a JSON object"); } + // Defensive reset: callers today pass fresh AuthPolicy locals, but the + // reload path (future Phase 3) can easily re-parse into an existing + // object, in which case the *_vec fields would otherwise accumulate + // entries across reloads. Clear up front — the only state preserved + // across ParseAuthPolicy is what this function explicitly rewrites. + out.applies_to.clear(); + out.issuers.clear(); + out.required_scopes.clear(); out.name = j.value("name", std::string{}); out.enabled = j.value("enabled", false); if (j.contains("applies_to")) { @@ -102,10 +134,12 @@ static void ParseIssuerConfig(const std::string& name, const nlohmann::json& j, out.jwks_uri = j.value("jwks_uri", std::string{}); out.upstream = j.value("upstream", std::string{}); out.mode = j.value("mode", std::string("jwt")); - out.leeway_sec = j.value("leeway_sec", 30); - out.jwks_cache_sec = j.value("jwks_cache_sec", 300); - out.jwks_refresh_timeout_sec = j.value("jwks_refresh_timeout_sec", 5); - out.discovery_retry_sec = j.value("discovery_retry_sec", 30); + out.leeway_sec = ParseStrictInt(j, "leeway_sec", 30, ctx); + out.jwks_cache_sec = ParseStrictInt(j, "jwks_cache_sec", 300, ctx); + out.jwks_refresh_timeout_sec = + ParseStrictInt(j, "jwks_refresh_timeout_sec", 5, ctx); + out.discovery_retry_sec = + ParseStrictInt(j, "discovery_retry_sec", 30, ctx); if (j.contains("audiences")) { if (!j["audiences"].is_array()) { @@ -138,6 +172,7 @@ static void ParseIssuerConfig(const std::string& name, const nlohmann::json& j, throw std::invalid_argument( ctx + ".required_claims must be an array"); } + out.required_claims.clear(); for (const auto& v : j["required_claims"]) { if (!v.is_string()) { throw std::invalid_argument( @@ -165,13 +200,18 @@ static void ParseIssuerConfig(const std::string& name, const nlohmann::json& j, i.value("client_secret_env", std::string{}); out.introspection.auth_style = i.value("auth_style", std::string("basic")); - out.introspection.timeout_sec = i.value("timeout_sec", 3); - out.introspection.cache_sec = i.value("cache_sec", 60); + const std::string ictx = ctx + ".introspection"; + out.introspection.timeout_sec = + ParseStrictInt(i, "timeout_sec", 3, ictx); + out.introspection.cache_sec = + ParseStrictInt(i, "cache_sec", 60, ictx); out.introspection.negative_cache_sec = - i.value("negative_cache_sec", 10); - out.introspection.stale_grace_sec = i.value("stale_grace_sec", 30); - out.introspection.max_entries = i.value("max_entries", 100000); - out.introspection.shards = i.value("shards", 16); + ParseStrictInt(i, "negative_cache_sec", 10, ictx); + out.introspection.stale_grace_sec = + ParseStrictInt(i, "stale_grace_sec", 30, ictx); + out.introspection.max_entries = + ParseStrictInt(i, "max_entries", 100000, ictx); + out.introspection.shards = ParseStrictInt(i, "shards", 16, ictx); } } @@ -1537,6 +1577,47 @@ void ConfigLoader::Validate(const ServerConfig& config) { if (ic.jwks_cache_sec <= 0) { throw std::invalid_argument(ctx + ".jwks_cache_sec must be > 0"); } + + // Mode-specific required fields (design spec §5.3). + // jwt mode requires at least one algorithm (the allowlist the + // Phase-2 verifier will build `allow_algorithm(key)` calls over) + // and a key source — either OIDC discovery OR a static jwks_uri. + // introspection mode requires the endpoint (the POST target). + if (ic.mode == "jwt") { + if (ic.algorithms.empty()) { + throw std::invalid_argument( + ctx + ".algorithms must contain at least one entry " + "for mode=\"jwt\" (supported: RS256/RS384/RS512/" + "ES256/ES384)"); + } + if (!ic.discovery && ic.jwks_uri.empty()) { + throw std::invalid_argument( + ctx + ": mode=\"jwt\" with discovery=false requires " + "a non-empty jwks_uri (static JWKS location)"); + } + } else if (ic.mode == "introspection") { + if (ic.introspection.endpoint.empty()) { + throw std::invalid_argument( + ctx + ".introspection.endpoint is required for " + "mode=\"introspection\""); + } + } + + // Mode/endpoint mismatch — warn per design spec §5.3. Not a + // hard-reject because operators sometimes template both blocks + // and select mode dynamically; emitting a warn ensures the + // unused field is noticed without blocking deployment. + if (ic.mode == "jwt" && + !ic.introspection.endpoint.empty()) { + logging::Get()->warn( + "{}: mode=\"jwt\" but introspection.endpoint is set — " + "introspection config will be ignored", ctx); + } + if (ic.mode == "introspection" && !ic.jwks_uri.empty()) { + logging::Get()->warn( + "{}: mode=\"introspection\" but jwks_uri is set — " + "JWKS config will be ignored", ctx); + } } // Top-level policy validation. @@ -1561,11 +1642,21 @@ void ConfigLoader::Validate(const ServerConfig& config) { // Per spec §3.2 / §5.2: a prefix that appears in both an inline // proxy.auth and a top-level auth.policies[].applies_to is a // hard-reject config error (ambiguity, not resolved at runtime). - // Also catch duplicate `route_prefix` across proxies when both - // have inline auth. - std::unordered_map inline_prefixes; // prefix -> owner + // Same rule applies across ALL prefix sources: two inline proxies + // with the same route_prefix, one top-level policy with the same + // prefix declared twice in its applies_to, or two top-level + // policies sharing any prefix. + // + // Unified `all_prefixes` map catches every collision shape. Keyed + // by prefix string; value is a human-readable owner description. + std::unordered_map all_prefixes; + for (const auto& u : config.upstreams) { - if (u.proxy.auth == auth::AuthPolicy{}) continue; + // Detect "proxy has inline auth" via the master switch + // (u.proxy.auth.enabled). Earlier versions used a default- + // struct comparison which broke the moment a new field with a + // non-trivial default landed in AuthPolicy. + if (!u.proxy.auth.enabled) continue; const auto& p = u.proxy.auth; const std::string ctx = "upstreams['" + u.name + "'].proxy.auth"; if (p.on_undetermined != "deny" && p.on_undetermined != "allow") { @@ -1588,35 +1679,49 @@ void ConfigLoader::Validate(const ServerConfig& config) { ctx + " has no route_prefix — inline auth requires a " "non-empty proxy.route_prefix to derive applies_to"); } - auto ins = inline_prefixes.emplace(u.proxy.route_prefix, u.name); + const std::string owner = + "inline proxy.auth on upstream '" + u.name + "'"; + auto ins = all_prefixes.emplace(u.proxy.route_prefix, owner); if (!ins.second) { throw std::invalid_argument( - "prefix '" + u.proxy.route_prefix + "' appears in two " - "proxies with inline auth (upstreams '" + ins.first->second + - "' and '" + u.name + "') — exact-prefix collisions must be " - "resolved at config time (design spec §3.2)"); - } - } - // Top-level policies vs inline: a prefix that appears inline MUST - // NOT also appear in any top-level `auth.policies[].applies_to`. + "auth policy prefix '" + u.proxy.route_prefix + + "' declared by both " + ins.first->second + " and " + + owner + " — exact-prefix collisions must be resolved at " + "config time (design spec §3.2)"); + } + } + // Top-level policies: catches (a) top-level vs inline, (b) two + // top-level policies sharing a prefix, (c) one top-level policy + // listing the same prefix twice in its applies_to. This is the + // guarantee the `auth_policy_matcher::ValidatePolicyList` helper + // was designed to enforce — ConfigLoader::Validate is the correct + // place to call it because collisions must be a load-time error, + // not a silent runtime first-wins. for (size_t i = 0; i < config.auth.policies.size(); ++i) { const auto& p = config.auth.policies[i]; + const std::string policy_owner = + p.name.empty() + ? ("auth.policies[" + std::to_string(i) + "]") + : ("auth.policies['" + p.name + "']"); for (const auto& pref : p.applies_to) { - auto it = inline_prefixes.find(pref); - if (it != inline_prefixes.end()) { + auto ins = all_prefixes.emplace(pref, policy_owner); + if (!ins.second) { throw std::invalid_argument( - "auth.policies[" + std::to_string(i) + "] applies_to " - "contains prefix '" + pref + "' which is already " - "declared by inline proxy.auth on upstream '" + - it->second + "' — resolve the collision at config " - "time (design spec §3.2)"); + "auth policy prefix '" + pref + "' declared by both " + + ins.first->second + " and " + policy_owner + + " — exact-prefix collisions must be resolved at " + "config time (design spec §3.2)"); } } } // Forward config: reject header-name collisions among the fixed // output slots and the claims_to_headers map. Design §5.3. - if (config.auth.forward != auth::AuthForwardConfig{}) { + // Run unconditionally — the default AuthForwardConfig has three + // distinct non-empty header names that trivially pass the set + // insertion, and an operator who writes the defaults back + // explicitly in JSON gets the same (correct) treatment. + { std::unordered_set output_headers; auto add_header = [&output_headers](const std::string& name, const std::string& which) { diff --git a/server/token_hasher.cc b/server/token_hasher.cc index 96bd5b7e..1cad4849 100644 --- a/server/token_hasher.cc +++ b/server/token_hasher.cc @@ -112,21 +112,23 @@ std::string LoadHmacKeyFromEnv(const std::string& env_var_name) { while (!candidate.empty() && candidate.back() == '=') candidate.pop_back(); // Step 2: strip jwt-cpp "%3d" padding too — trim() returns the substring // before the first occurrence of the fill string. - candidate = jwt::base::trim(candidate); + std::string candidate_b64url = + jwt::base::trim(candidate); try { std::string decoded = jwt::base::decode( - jwt::base::pad(candidate)); + jwt::base::pad(candidate_b64url)); if (decoded.size() == 32) { - // Silent-swap corner case (review round N+1, finding #4): an - // operator's raw 43-char key composed entirely of base64url - // alphabet chars [A-Za-z0-9_-] will be interpreted as encoded - // rather than raw. HMAC security is preserved either way (both - // forms give 32 bytes of key material), but the derived key - // differs between interpretations. Log at info so operators - // see the decision in their startup logs and can disambiguate - // if they intended a 43-char raw key (recommend: change length - // or base64url-encode explicitly). - logging::Get()->info( + // Silent-swap corner case: an operator's raw 43-char key + // composed entirely of base64url alphabet chars [A-Za-z0-9_-] + // will be interpreted as encoded rather than raw. HMAC security + // is preserved either way (both forms give 32 bytes of key + // material), but the derived key differs between + // interpretations. Log at debug — the base64url path is the + // COMMON case (operators running `openssl rand -base64 32` and + // stripping '=' or using the url-safe variant), so info-level + // would spam every startup. Operators who suspect a raw/decoded + // mismatch can enable debug logging to disambiguate. + logging::Get()->debug( "LoadHmacKeyFromEnv: env var '{}' interpreted as " "base64url-encoded 32-byte key (decoded). If you intended " "a raw 43-char key, either base64url-encode it explicitly " @@ -136,8 +138,31 @@ std::string LoadHmacKeyFromEnv(const std::string& env_var_name) { } } catch (const std::exception& e) { logging::Get()->debug("LoadHmacKeyFromEnv: base64url decode failed " - "for env var '{}' ({}); falling back to raw " - "bytes interpretation", + "for env var '{}' ({}); trying standard base64", + env_var_name, e.what()); + } + + // Step 3: standard base64 fallback (for operators who ran + // `openssl rand -base64 32`, which emits the '+' / '/' alphabet — NOT + // base64url). base64url's alphabet excludes '+' and '/', so the first + // attempt above throws on those characters and we fall through here. + // The decoded bytes are identical as long as the key is 32 bytes. + try { + std::string trimmed_b64 = + jwt::base::trim(candidate); + std::string decoded_b64 = jwt::base::decode( + jwt::base::pad(trimmed_b64)); + if (decoded_b64.size() == 32) { + logging::Get()->debug( + "LoadHmacKeyFromEnv: env var '{}' interpreted as " + "standard-base64-encoded 32-byte key (decoded).", + env_var_name); + return decoded_b64; + } + } catch (const std::exception& e) { + logging::Get()->debug("LoadHmacKeyFromEnv: standard base64 decode " + "also failed for env var '{}' ({}); falling " + "back to raw bytes interpretation", env_var_name, e.what()); } return raw; diff --git a/test/run_test.cc b/test/run_test.cc index 1b1bf1bf..d6bd1774 100644 --- a/test/run_test.cc +++ b/test/run_test.cc @@ -193,15 +193,15 @@ int main(int argc, char* argv[]) { // Run circuit-breaker tests (unit + components + integration + retry-budget + drain + observability + reload) }else if(mode == "circuit_breaker" || mode == "-B"){ CircuitBreakerTests::RunAllTests(); - // Run auth foundation tests (token_hasher + base64url env auto-detect + scope extractors) - }else if(mode == "auth" || mode == "-A"){ - AuthFoundationTests::RunAllTests(); CircuitBreakerComponentsTests::RunAllTests(); CircuitBreakerIntegrationTests::RunAllTests(); CircuitBreakerRetryBudgetTests::RunAllTests(); CircuitBreakerWaitQueueDrainTests::RunAllTests(); CircuitBreakerObservabilityTests::RunAllTests(); CircuitBreakerReloadTests::RunAllTests(); + // Run auth foundation tests (token_hasher + base64url env auto-detect + scope extractors) + }else if(mode == "auth" || mode == "-A"){ + AuthFoundationTests::RunAllTests(); // Show help }else if(mode == "help" || mode == "-h" || mode == "--help"){ PrintUsage(argv[0]); From f6f08ad2a215cbb9b95b8e441a0cd2b2d11abc1c Mon Sep 17 00:00:00 2001 From: mwfj Date: Thu, 16 Apr 2026 13:54:04 +0800 Subject: [PATCH 07/17] Fix review comment --- test/auth_foundation_test.h | 161 ++++++++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) diff --git a/test/auth_foundation_test.h b/test/auth_foundation_test.h index c797ce94..6ac388da 100644 --- a/test/auth_foundation_test.h +++ b/test/auth_foundation_test.h @@ -513,14 +513,175 @@ void TestConfigLoaderAuthValidation() { } } +// ----------------------------------------------------------------------------- +// LoadHmacKeyFromEnv — standard base64 fallback (base64 alphabet uses +/=, +// not base64url's -_). Operators running `openssl rand -base64 32` (the most +// common JWT-key-generation command in tutorials) get 44-char output with +// '+' or '/' characters that base64url rejects. This test pins the fallback +// path that decodes via `jwt::alphabet::base64` after base64url fails — +// added in the review round that introduced the Step-3 fallback in +// server/token_hasher.cc. +// ----------------------------------------------------------------------------- +void TestLoadHmacKeyFromEnvStandardBase64() { + std::cout << "\n[TEST] LoadHmacKeyFromEnv standard base64 fallback..." << std::endl; + + const char* kVarName = "REACTOR_TEST_AUTH_STD_B64_KEY"; + auto restore_env = [](const char* name, const char* prev, bool had) { + if (had) setenv(name, prev, 1); + else unsetenv(name); + }; + + // Hoist saved state above the try so the catch block sees the correct + // pre-test value (matches the pattern from the other tests — fixes the + // restore-corruption regression flagged in an earlier round). + const char* prev = std::getenv(kVarName); + std::string saved = prev ? prev : ""; + bool had_original = (prev != nullptr); + + try { + // Craft a 32-byte binary key whose STANDARD base64 encoding contains + // '+' or '/'. Byte pattern 0xFF 0xFB 0xFF 0xFB ... produces bit groups + // that base64-encode to chars including '/' (0x3F = 63) and '+' (0x3E + // = 62) — neither of which is in the base64url alphabet. + std::string raw_key; + raw_key.reserve(32); + for (int i = 0; i < 32; ++i) { + raw_key.push_back( + static_cast(i % 2 == 0 ? 0xFF : 0xFB)); + } + // Encode using STANDARD base64 (mirrors `openssl rand -base64 32` + // output, which uses '+' / '/' / '='). jwt-cpp's base64 alphabet + // emits '=' padding by default, producing the 44-char form. + std::string encoded = + jwt::base::encode(raw_key); + + // Fixture self-check: if the encoded string happens not to contain + // any base64-only character, this test wouldn't exercise the + // fallback — a base64url decode would succeed and we'd never hit + // Step 3. Fail loudly rather than silently pass a no-op test. + bool has_std_only_char = + encoded.find('+') != std::string::npos || + encoded.find('/') != std::string::npos; + if (!has_std_only_char) { + TestFramework::RecordTest( + "AuthFoundation: LoadHmacKeyFromEnv standard base64 fallback", + false, + "fixture: chosen byte pattern didn't produce '+' or '/' in " + "base64 encoding — test would not exercise the Step-3 " + "fallback path", + TestFramework::TestCategory::OTHER); + return; + } + + setenv(kVarName, encoded.c_str(), 1); + std::string decoded_key = auth::LoadHmacKeyFromEnv(kVarName); + + restore_env(kVarName, saved.c_str(), had_original); + + bool decoded_to_32 = decoded_key.size() == 32; + bool matches_raw = decoded_to_32 && decoded_key == raw_key; + + bool pass = decoded_to_32 && matches_raw; + std::string err; + if (!decoded_to_32) { + err = "standard-base64 input (" + std::to_string(encoded.size()) + + " chars containing '+' or '/') not decoded via Step-3 " + "fallback; returned size=" + + std::to_string(decoded_key.size()); + } else if (!matches_raw) { + err = "standard-base64 decoded bytes differ from original raw key " + "(HMAC key would silently change)"; + } + + TestFramework::RecordTest( + "AuthFoundation: LoadHmacKeyFromEnv standard base64 fallback", + pass, err, TestFramework::TestCategory::OTHER); + } catch (const std::exception& e) { + restore_env(kVarName, saved.c_str(), had_original); + TestFramework::RecordTest( + "AuthFoundation: LoadHmacKeyFromEnv standard base64 fallback", + false, std::string("unexpected exception: ") + e.what(), + TestFramework::TestCategory::OTHER); + } +} + +// ----------------------------------------------------------------------------- +// ConfigLoader::Validate — same-header collision in claims_to_headers. +// Two distinct claim keys mapping to the same header NAME must be rejected +// at config-load time. Without this rejection, the runtime HeaderRewriter +// would get last-write-wins behavior and operators would see silently +// wrong values in the selected claim header. The unified header-collision +// set in Validate() already catches this — the test pins that guarantee +// against a future refactor that might inadvertently narrow the check. +// ----------------------------------------------------------------------------- +void TestConfigLoaderClaimHeaderCollision() { + std::cout << "\n[TEST] ConfigLoader rejects claim->same-header collision..." << std::endl; + try { + const std::string bad_json = R"({ + "bind_host": "127.0.0.1", + "bind_port": 8080, + "auth": { + "enabled": true, + "issuers": { + "google": { + "issuer_url": "https://accounts.google.com", + "upstream": "idp_google", + "mode": "jwt", + "algorithms": ["RS256"] + } + }, + "forward": { + "claims_to_headers": { + "email": "X-Shared-Header", + "sub": "X-Shared-Header" + } + } + }, + "upstreams": [{"name": "idp_google", "host": "127.0.0.1", "port": 443}] + })"; + bool threw = false; + std::string err_msg; + try { + ServerConfig cfg = ConfigLoader::LoadFromString(bad_json); + ConfigLoader::Validate(cfg); + } catch (const std::invalid_argument& e) { + threw = true; + err_msg = e.what(); + } + bool mentions_collision = + threw && err_msg.find("collides") != std::string::npos; + + bool pass = threw && mentions_collision; + std::string err; + if (!threw) { + err = "expected Validate() to reject duplicate header names in " + "claims_to_headers but it accepted the config"; + } else if (!mentions_collision) { + err = "Validate() threw but error text didn't mention " + "'collides'; got: " + err_msg; + } + + TestFramework::RecordTest( + "AuthFoundation: ConfigLoader rejects claim->same-header collision", + pass, err, TestFramework::TestCategory::OTHER); + } catch (const std::exception& e) { + TestFramework::RecordTest( + "AuthFoundation: ConfigLoader rejects claim->same-header collision", + false, std::string("unexpected harness error: ") + e.what(), + TestFramework::TestCategory::OTHER); + } +} + inline void RunAllTests() { std::cout << "\n===== Auth Foundation Tests =====" << std::endl; TestHasherBasicDeterminism(); TestLoadHmacKeyFromEnvDoesNotThrow(); TestLoadHmacKeyFromEnvAutoDetect(); + TestLoadHmacKeyFromEnvStandardBase64(); TestExtractScopesScpAsString(); TestConfigLoaderAuthRoundTrip(); TestConfigLoaderAuthValidation(); + TestConfigLoaderClaimHeaderCollision(); } } // namespace AuthFoundationTests From fbac7cfd972db9f8383fe57b9e9bd6477e3f7b77 Mon Sep 17 00:00:00 2001 From: mwfj Date: Fri, 17 Apr 2026 10:43:20 +0800 Subject: [PATCH 08/17] Fix review comment --- server/auth_claims.cc | 36 +++- server/config_loader.cc | 80 +++++++++ test/auth_foundation_test.h | 331 +++++++++++++++++++++++++++++++++++- 3 files changed, 434 insertions(+), 13 deletions(-) diff --git a/server/auth_claims.cc b/server/auth_claims.cc index ec029c2e..9e28a308 100644 --- a/server/auth_claims.cc +++ b/server/auth_claims.cc @@ -59,13 +59,35 @@ bool PopulateFromPayload(const nlohmann::json& payload, AuthContext& ctx) { if (!payload.is_object()) return false; - // `iss` and `sub` are mandatory (RFC 7519 §4.1.1 / §4.1.2) — a token - // without them is not considered valid for our purposes. - if (!payload.contains("iss") || !payload["iss"].is_string()) return false; - if (!payload.contains("sub") || !payload["sub"].is_string()) return false; - - ctx.issuer = payload["iss"].get(); - ctx.subject = payload["sub"].get(); + // `iss` and `sub` are both OPTIONAL per RFC 7519 §4.1.1 / §4.1.2; the + // RFC 7662 introspection response also doesn't guarantee them (only + // `active` is required). Common cases where one or both are absent: + // + // - Client-credentials / service-account access tokens: no human + // `sub`. The OAuth client itself is identified by `client_id`, + // which the verifier can pass through via `claims_to_headers`. + // - Introspection responses from minimal IdPs: may return + // `{"active": true, "scope": "read:data"}` and nothing else. + // - Symmetric-key flows (deferred — HS256 is out of scope for v1): + // issuer is implicit in the shared key. + // + // The verifier (Phase 2 `JwtVerifier`) is the layer that enforces + // `iss` matches a configured issuer, via jwt-cpp's `with_issuer(...)` + // — by the time we get here that constraint has already been + // applied. Our job here is claim EXTRACTION, not policy enforcement, + // so we populate what's present and leave what isn't empty. False is + // returned ONLY for a structurally-invalid payload (not an object). + // + // Downstream readers (the future HeaderRewriter overlay) must treat + // both `ctx.issuer` and `ctx.subject` as possibly-empty and skip + // emitting their respective headers when empty rather than emitting + // empty values that would mislead upstream services. + if (payload.contains("iss") && payload["iss"].is_string()) { + ctx.issuer = payload["iss"].get(); + } + if (payload.contains("sub") && payload["sub"].is_string()) { + ctx.subject = payload["sub"].get(); + } ctx.scopes = ExtractScopes(payload); // Copy only operator-requested claims into ctx.claims, to keep the diff --git a/server/config_loader.cc b/server/config_loader.cc index 3098f8b1..e93f6e63 100644 --- a/server/config_loader.cc +++ b/server/config_loader.cc @@ -1603,6 +1603,40 @@ void ConfigLoader::Validate(const ServerConfig& config) { } } + // TLS-mandatory on actual outbound IdP endpoints, not just on + // issuer_url (design spec §9 item 4 hardening). The issuer_url + // check above protects discovery; these checks protect the two + // other URLs that carry security-sensitive data: + // + // - jwks_uri (static, used when discovery=false): a plaintext + // JWKS lets a network attacker substitute their own public + // keys, which would cause our verifier to accept tokens + // they signed → token forgery. + // - introspection.endpoint: a plaintext POST exposes both the + // bearer token (authentication credential) and our + // client_id / client_secret (the gateway's IdP credential) + // to anyone on the wire. + // + // Both checks are conditional — if the field is empty, mode + // validation above has already either required it (and would + // have thrown) or made it discovery-supplied (and the discovered + // URL gets validated at fetch time in Phase 2). We only need + // to validate the static value here. + if (!ic.jwks_uri.empty() && + ic.jwks_uri.rfind("https://", 0) != 0) { + throw std::invalid_argument( + ctx + ".jwks_uri must start with https:// — plaintext " + "JWKS allows MITM key substitution and would compromise " + "token verification (design spec §9 item 4)"); + } + if (!ic.introspection.endpoint.empty() && + ic.introspection.endpoint.rfind("https://", 0) != 0) { + throw std::invalid_argument( + ctx + ".introspection.endpoint must start with https:// " + "— plaintext introspection would leak bearer tokens and " + "client credentials over the wire (design spec §9 item 4)"); + } + // Mode/endpoint mismatch — warn per design spec §5.3. Not a // hard-reject because operators sometimes template both blocks // and select mode dynamically; emitting a warn ensures the @@ -1748,6 +1782,52 @@ void ConfigLoader::Validate(const ServerConfig& config) { add_header(header, "claims_to_headers[" + claim + "]"); } } + + // ----- Final gate: enforcement-not-yet-wired master rejection ----- + // + // This PR (Phase 1, Steps 1–2) lands the auth config schema, the + // pure utilities (token_hasher / jwt_decode-via-jwt-cpp / + // auth_policy_matcher / auth_claims), and the data-structure plumbing + // (HttpRequest::auth, ProxyConfig::auth, ServerConfig::auth) — but + // request-time enforcement (AuthManager + middleware + JwtVerifier + // wiring) is scheduled for follow-up PRs per design spec §14 + // (Phase 1 Steps 3–7 / Phase 2). Until that lands, a config that + // toggles auth ON would silently behave as unauthenticated — i.e. + // an operator who deploys `auth.enabled=true` thinking their proxy + // is now protected would be wrong. That's an authentication-bypass + // misconfiguration vector. + // + // To prevent silent unenforced-policy acceptance, the validator + // hard-rejects ANY config that flips an auth enable flag on. The + // schema (issuers, policies, forward) may stay populated for + // forward-compatibility — operators can prepare their config in + // advance of the enforcement PR — but the master switches must + // remain false until enforcement is wired. + // + // Same fail-closed discipline used for HS256 / alg:none / mode:auto + // throughout this design: features that are not safely usable yet + // must reject loudly at config load, not silently accept. + if (config.auth.enabled) { + throw std::invalid_argument( + "auth.enabled=true rejected: request-time enforcement " + "(AuthManager + middleware) is not yet wired in this build. " + "Schedule: design spec §14 Phase 2 / follow-up PR. To prevent " + "silent unenforced-policy acceptance, the validator hard-" + "rejects this flag until enforcement lands. Set " + "auth.enabled=false for now; auth.issuers / policies / " + "forward may remain populated for upgrade."); + } + for (const auto& u : config.upstreams) { + if (u.proxy.auth.enabled) { + throw std::invalid_argument( + "upstreams['" + u.name + + "'].proxy.auth.enabled=true rejected: request-time " + "enforcement is not yet wired in this build (design spec " + "§14 Phase 2). Set proxy.auth.enabled=false for now; the " + "auth block (issuers reference, required_scopes, etc.) " + "may remain populated for upgrade."); + } + } } } diff --git a/test/auth_foundation_test.h b/test/auth_foundation_test.h index 6ac388da..aeea28a9 100644 --- a/test/auth_foundation_test.h +++ b/test/auth_foundation_test.h @@ -380,15 +380,42 @@ void TestConfigLoaderAuthRoundTrip() { } } - // Validation must accept this config (algorithms OK, upstream exists, - // no collisions, https issuer). - bool validation_ok = true; + // Validation behavior on this fixture (which has auth.enabled=true): + // The fixture is structurally well-formed (algorithms OK, upstream + // exists, no collisions, https issuer, etc.) AND it has the master + // auth flag flipped on. Until request-time enforcement lands per + // design spec §14 Phase 2, the validator's enforcement-not-yet-wired + // gate fires for any enabled=true config. This block confirms the + // gate behaves correctly on a fully-formed but enabled fixture — + // it must throw with the gate's distinctive "not yet wired" message, + // proving that all the structural checks passed (otherwise an + // earlier throw with a different message would fire). + // + // When enforcement lands, this assertion flips back to "must + // succeed" and the gate logic in ConfigLoader::Validate is removed. + bool validation_ok = false; // success means gate fired with right msg std::string validation_err; try { ConfigLoader::Validate(c1); + validation_err = "Validate() unexpectedly accepted enabled=true; " + "the enforcement-not-yet-wired gate should have " + "rejected it"; + } catch (const std::invalid_argument& e) { + std::string msg = e.what(); + if (msg.find("not yet wired") != std::string::npos && + msg.find("auth.enabled") != std::string::npos) { + validation_ok = true; + } else { + validation_err = + "Validate() threw a DIFFERENT error than the expected " + "enforcement-not-yet-wired gate (this means an earlier " + "structural check failed when it shouldn't have); " + "got: " + msg; + } } catch (const std::exception& e) { - validation_ok = false; - validation_err = e.what(); + validation_err = + std::string("Validate() threw an unexpected exception type: ") + + e.what(); } // Round-trip through ToJson → LoadFromString must preserve both @@ -617,11 +644,17 @@ void TestLoadHmacKeyFromEnvStandardBase64() { void TestConfigLoaderClaimHeaderCollision() { std::cout << "\n[TEST] ConfigLoader rejects claim->same-header collision..." << std::endl; try { + // NOTE: enabled=false here is deliberate — the structural header- + // collision check runs unconditionally, and we want to exercise it + // in isolation. With enabled=true, the new "enforcement-not-yet- + // wired" gate would fire first and we'd never reach the collision + // check. The collision is purely a config-shape issue, not gated + // on the master switch. const std::string bad_json = R"({ "bind_host": "127.0.0.1", "bind_port": 8080, "auth": { - "enabled": true, + "enabled": false, "issuers": { "google": { "issuer_url": "https://accounts.google.com", @@ -672,6 +705,288 @@ void TestConfigLoaderClaimHeaderCollision() { } } +// ----------------------------------------------------------------------------- +// ConfigLoader::Validate — auth-enabled fail-closed gate (review P1 #1). +// +// Until AuthManager + middleware lands (design spec §14 Phase 2), a config +// that toggles auth ON would silently behave as unauthenticated — i.e. the +// gateway would accept the config but route requests to upstreams without +// any token validation. To prevent that auth-bypass-by-misconfig scenario, +// Validate() hard-rejects any config with auth.enabled=true OR +// upstreams[].proxy.auth.enabled=true. Schema fields (issuers, policies, +// forward) may still be populated for forward-compatibility — only the +// master switches are gated. +// +// These tests pin the gate. When enforcement actually lands, both +// throw-cases below are removed (the gate logic is deleted from +// ConfigLoader::Validate) AND these test cases are flipped to assert +// successful validation. Until then, the gate is the safety net. +// ----------------------------------------------------------------------------- +void TestConfigLoaderRejectsAuthEnabled() { + std::cout << "\n[TEST] ConfigLoader rejects auth.enabled=true (gateway-wide)..." << std::endl; + try { + const std::string json_with_auth_enabled = R"({ + "bind_host": "127.0.0.1", + "bind_port": 8080, + "auth": { + "enabled": true, + "issuers": { + "google": { + "issuer_url": "https://accounts.google.com", + "upstream": "idp_google", + "mode": "jwt", + "algorithms": ["RS256"] + } + } + }, + "upstreams": [{"name": "idp_google", "host": "127.0.0.1", "port": 443}] + })"; + bool threw = false; + std::string err_msg; + try { + ServerConfig cfg = ConfigLoader::LoadFromString(json_with_auth_enabled); + ConfigLoader::Validate(cfg); + } catch (const std::invalid_argument& e) { + threw = true; + err_msg = e.what(); + } + // Contract: must throw, and message must be informative — mention + // both that enforcement isn't wired and how to disable. We check + // for the canonical phrase "not yet wired" and "auth.enabled" so + // the test fails loudly if the wording silently regresses to a + // less-actionable message. + bool good_msg = threw && + err_msg.find("not yet wired") != std::string::npos && + err_msg.find("auth.enabled") != std::string::npos; + bool pass = threw && good_msg; + std::string err; + if (!threw) { + err = "expected Validate() to reject auth.enabled=true but it accepted"; + } else if (!good_msg) { + err = "Validate() threw but message lacked 'not yet wired' or " + "'auth.enabled'; got: " + err_msg; + } + TestFramework::RecordTest( + "AuthFoundation: ConfigLoader rejects gateway auth.enabled=true", + pass, err, TestFramework::TestCategory::OTHER); + } catch (const std::exception& e) { + TestFramework::RecordTest( + "AuthFoundation: ConfigLoader rejects gateway auth.enabled=true", + false, std::string("unexpected harness error: ") + e.what(), + TestFramework::TestCategory::OTHER); + } +} + +void TestConfigLoaderRejectsProxyAuthEnabled() { + std::cout << "\n[TEST] ConfigLoader rejects proxy.auth.enabled=true..." << std::endl; + try { + const std::string json_with_proxy_auth_enabled = R"({ + "bind_host": "127.0.0.1", + "bind_port": 8080, + "auth": { + "enabled": false, + "issuers": { + "google": { + "issuer_url": "https://accounts.google.com", + "upstream": "idp_google", + "mode": "jwt", + "algorithms": ["RS256"] + } + } + }, + "upstreams": [ + {"name": "idp_google", "host": "127.0.0.1", "port": 443}, + { + "name": "internal-api", + "host": "127.0.0.1", + "port": 8080, + "proxy": { + "route_prefix": "/api/v1", + "auth": { + "enabled": true, + "issuers": ["google"] + } + } + } + ] + })"; + bool threw = false; + std::string err_msg; + try { + ServerConfig cfg = ConfigLoader::LoadFromString(json_with_proxy_auth_enabled); + ConfigLoader::Validate(cfg); + } catch (const std::invalid_argument& e) { + threw = true; + err_msg = e.what(); + } + // Message must name the offending upstream so operators can find it + // quickly, and mention the gate phrasing. + bool good_msg = threw && + err_msg.find("not yet wired") != std::string::npos && + err_msg.find("internal-api") != std::string::npos && + err_msg.find("proxy.auth.enabled") != std::string::npos; + bool pass = threw && good_msg; + std::string err; + if (!threw) { + err = "expected Validate() to reject proxy.auth.enabled=true but it accepted"; + } else if (!good_msg) { + err = "Validate() threw but message lacked one of " + "{'not yet wired', 'internal-api', 'proxy.auth.enabled'}; " + "got: " + err_msg; + } + TestFramework::RecordTest( + "AuthFoundation: ConfigLoader rejects per-proxy auth.enabled=true", + pass, err, TestFramework::TestCategory::OTHER); + } catch (const std::exception& e) { + TestFramework::RecordTest( + "AuthFoundation: ConfigLoader rejects per-proxy auth.enabled=true", + false, std::string("unexpected harness error: ") + e.what(), + TestFramework::TestCategory::OTHER); + } +} + +// ----------------------------------------------------------------------------- +// ConfigLoader::Validate — TLS-mandatory on outbound IdP endpoints +// (review P1 #2). issuer_url already has the https check; this test pins +// the same protection for jwks_uri (when discovery=false) and +// introspection.endpoint. Plaintext on either is a critical security bug: +// - http://jwks → MITM key substitution → token forgery +// - http://introspect → bearer token + client credential exposure +// ----------------------------------------------------------------------------- +void TestConfigLoaderRejectsPlaintextIdpEndpoints() { + std::cout << "\n[TEST] ConfigLoader rejects plaintext jwks_uri / introspection.endpoint..." << std::endl; + auto validate_expect_failure = [](const std::string& json, + const std::string& expected_phrase) + -> std::string { + try { + ServerConfig cfg = ConfigLoader::LoadFromString(json); + ConfigLoader::Validate(cfg); + return "expected Validate() to throw containing '" + + expected_phrase + "' but it accepted"; + } catch (const std::invalid_argument& e) { + std::string msg = e.what(); + if (msg.find(expected_phrase) == std::string::npos) { + return "Validate() threw but message lacked '" + + expected_phrase + "'; got: " + msg; + } + return ""; + } catch (const std::exception& e) { + return std::string("unexpected exception type: ") + e.what(); + } + }; + + try { + // Case 1: plaintext jwks_uri (discovery=false case). + std::string err = validate_expect_failure(R"({ + "upstreams": [{"name":"x","host":"127.0.0.1","port":80}], + "auth": { + "issuers": { + "ours": { + "issuer_url": "https://issuer.example", + "discovery": false, + "jwks_uri": "http://issuer.example/jwks.json", + "upstream": "x", + "mode": "jwt", + "algorithms": ["RS256"] + } + } + } + })", "jwks_uri must start with https://"); + if (!err.empty()) throw std::runtime_error("plaintext jwks_uri case: " + err); + + // Case 2: plaintext introspection.endpoint. + err = validate_expect_failure(R"({ + "upstreams": [{"name":"x","host":"127.0.0.1","port":80}], + "auth": { + "issuers": { + "ours": { + "issuer_url": "https://issuer.example", + "upstream": "x", + "mode": "introspection", + "introspection": { + "endpoint": "http://issuer.example/introspect" + } + } + } + } + })", "introspection.endpoint must start with https://"); + if (!err.empty()) throw std::runtime_error("plaintext introspection case: " + err); + + TestFramework::RecordTest( + "AuthFoundation: ConfigLoader rejects plaintext IdP endpoints", + true, "", TestFramework::TestCategory::OTHER); + } catch (const std::exception& e) { + TestFramework::RecordTest( + "AuthFoundation: ConfigLoader rejects plaintext IdP endpoints", + false, e.what(), TestFramework::TestCategory::OTHER); + } +} + +// ----------------------------------------------------------------------------- +// PopulateFromPayload — iss/sub now optional (review P1 #3). +// RFC 7519 §4.1.1 / §4.1.2 mark both as OPTIONAL; RFC 7662 introspection +// only requires `active`. Common scenarios where one or both are absent: +// - Client-credentials access tokens (no human subject) +// - Minimal introspection responses ({"active": true, "scope": "..."}) +// Pre-fix behavior would 401 these tokens. This test pins that absent-but- +// well-formed payloads now succeed and leave ctx fields empty for downstream +// HeaderRewriter to skip emitting when populated values aren't present. +// ----------------------------------------------------------------------------- +void TestPopulateFromPayloadOptionalIssSub() { + std::cout << "\n[TEST] PopulateFromPayload accepts payloads without iss/sub..." << std::endl; + try { + // Case 1: client-credentials shape — no `sub`, scope present. + nlohmann::json client_cred = nlohmann::json::parse(R"({ + "iss": "https://issuer.example", + "client_id": "machine-a", + "scope": "read:data" + })"); + auth::AuthContext ctx1; + bool ok1 = auth::PopulateFromPayload(client_cred, {"client_id"}, ctx1); + bool case1_pass = ok1 && + ctx1.issuer == "https://issuer.example" && + ctx1.subject.empty() && + ctx1.scopes.size() == 1 && ctx1.scopes[0] == "read:data" && + ctx1.claims.count("client_id") == 1 && + ctx1.claims.at("client_id") == "machine-a"; + + // Case 2: minimal introspection response — only active + scope. + nlohmann::json minimal_introspect = nlohmann::json::parse(R"({ + "active": true, + "scope": "read:data write:data" + })"); + auth::AuthContext ctx2; + bool ok2 = auth::PopulateFromPayload(minimal_introspect, {}, ctx2); + bool case2_pass = ok2 && + ctx2.issuer.empty() && + ctx2.subject.empty() && + ctx2.scopes.size() == 2; + + // Case 3: structurally invalid payload (not an object) — STILL rejected. + // The relaxation is only about iss/sub; structural validity is + // unchanged. + nlohmann::json not_an_object = nlohmann::json::parse(R"("a string")"); + auth::AuthContext ctx3; + bool ok3 = auth::PopulateFromPayload(not_an_object, {}, ctx3); + bool case3_pass = !ok3; // must return false + + bool pass = case1_pass && case2_pass && case3_pass; + std::string err; + if (!case1_pass) err = "client-credentials case (no sub) failed"; + else if (!case2_pass) err = "minimal introspection case (no iss, no sub) failed"; + else if (!case3_pass) err = "non-object payload should still be rejected"; + + TestFramework::RecordTest( + "AuthFoundation: PopulateFromPayload accepts payloads without iss/sub", + pass, err, TestFramework::TestCategory::OTHER); + } catch (const std::exception& e) { + TestFramework::RecordTest( + "AuthFoundation: PopulateFromPayload accepts payloads without iss/sub", + false, std::string("unexpected exception: ") + e.what(), + TestFramework::TestCategory::OTHER); + } +} + inline void RunAllTests() { std::cout << "\n===== Auth Foundation Tests =====" << std::endl; TestHasherBasicDeterminism(); @@ -681,6 +996,10 @@ inline void RunAllTests() { TestExtractScopesScpAsString(); TestConfigLoaderAuthRoundTrip(); TestConfigLoaderAuthValidation(); + TestConfigLoaderRejectsAuthEnabled(); + TestConfigLoaderRejectsProxyAuthEnabled(); + TestConfigLoaderRejectsPlaintextIdpEndpoints(); + TestPopulateFromPayloadOptionalIssSub(); TestConfigLoaderClaimHeaderCollision(); } From f9acaa49881468e074e6afc660dba4700a314cc2 Mon Sep 17 00:00:00 2001 From: mwfj Date: Fri, 17 Apr 2026 11:58:41 +0800 Subject: [PATCH 09/17] Fix review comment --- server/config_loader.cc | 114 ++++++++++++++- test/auth_foundation_test.h | 272 ++++++++++++++++++++++++++++++++++++ 2 files changed, 384 insertions(+), 2 deletions(-) diff --git a/server/config_loader.cc b/server/config_loader.cc index e93f6e63..e246c776 100644 --- a/server/config_loader.cc +++ b/server/config_loader.cc @@ -13,6 +13,7 @@ #include #include #include +#include using json = nlohmann::json; @@ -27,6 +28,15 @@ using json = nlohmann::json; // This helper rejects non-integer JSON at parse time. `is_number_integer()` // is the right gate: it returns true for both signed and unsigned integers // and false for booleans, floats, null, strings, arrays, and objects. +// +// Range hardening (review round): `is_number_integer()` returns true for +// ANY integer that fits in nlohmann/json's internal int64/uint64 +// representation, including ones that DON'T fit in `int`. Without an +// explicit range check, `v.get()` would wrap or truncate values like +// 4294967297 — letting an operator's intended-large `leeway_sec` quietly +// validate as a small wrapped value. We read as int64/uint64 first, +// range-check against [INT_MIN, INT_MAX], and throw on overflow so +// out-of-range never reaches the caller. static int ParseStrictInt(const nlohmann::json& j, const std::string& key, int default_value, const std::string& context) { if (!j.contains(key)) return default_value; @@ -37,7 +47,71 @@ static int ParseStrictInt(const nlohmann::json& j, const std::string& key, context + "." + key + " must be an integer " "(got " + std::string(v.type_name()) + ")"); } - return v.get(); + // Unsigned values that overflow uint64-to-int64 must be caught with + // is_number_unsigned() FIRST — get() on a too-large unsigned + // value would itself wrap before our range check could catch it. + if (v.is_number_unsigned()) { + uint64_t u = v.get(); + if (u > static_cast(std::numeric_limits::max())) { + throw std::invalid_argument( + context + "." + key + " value " + std::to_string(u) + + " is out of int range (max " + + std::to_string(std::numeric_limits::max()) + ")"); + } + return static_cast(u); + } + int64_t s = v.get(); + if (s < std::numeric_limits::min() || + s > std::numeric_limits::max()) { + throw std::invalid_argument( + context + "." + key + " value " + std::to_string(s) + + " is out of int range [" + + std::to_string(std::numeric_limits::min()) + ", " + + std::to_string(std::numeric_limits::max()) + "]"); + } + return static_cast(s); +} + +// Header-name allow-list helper for auth.forward. +// +// auth.forward injects HTTP request headers into the forwarded upstream +// request via HeaderRewriter (Phase 2 wiring). If an operator misconfigures +// the output names to reserved categories, the resulting request would be +// either malformed, ambiguous, or spoofable: +// +// - HTTP/2 pseudo-headers (`:method`, `:path`, `:scheme`, `:authority`, +// `:status`): nghttp2 rejects these as regular headers; injecting them +// would either fail the request or be silently dropped depending on +// the encoder. +// - Hop-by-hop headers (RFC 7230 §6.1: Connection, Keep-Alive, +// Proxy-Authenticate, Proxy-Authorization, TE, Trailer, +// Transfer-Encoding, Upgrade): these are local to a single hop and +// MUST NOT be forwarded; injecting them via auth would fight the +// existing HeaderRewriter hop-by-hop strip. +// - Framing-critical headers (Host, Content-Length, Content-Type, +// Content-Encoding): a client-controlled claim could rewrite Host +// (request smuggling vector against backends that trust it for +// virtual-hosting), Content-Length (HTTP request smuggling), or +// content typing (JSON/XML parser confusion). +// - Authorization: would conflict with `preserve_authorization` — +// either both write and one wins unpredictably, or the upstream +// receives a forged identity. +// +// Match is case-insensitive. Caller passes already-lowercased name. +static bool IsReservedAuthForwardHeader(const std::string& lower) { + if (!lower.empty() && lower[0] == ':') return true; // HTTP/2 pseudo + static const std::unordered_set kReserved = { + // Hop-by-hop per RFC 7230 §6.1 + "connection", "keep-alive", "proxy-authenticate", + "proxy-authorization", "te", "trailer", "transfer-encoding", + "upgrade", + // Framing-critical (corrupting these is a smuggling/parser-confusion + // vector against the upstream) + "host", "content-length", "content-type", "content-encoding", + // Conflicts with preserve_authorization + "authorization", + }; + return kReserved.count(lower) > 0; } // Serialize a single AuthPolicy to JSON (mirror of ParseAuthPolicy for @@ -1565,7 +1639,30 @@ void ConfigLoader::Validate(const ServerConfig& config) { } } // Referenced upstream must exist (for outbound IdP calls). - if (!ic.upstream.empty() && upstream_names.count(ic.upstream) == 0) { + // + // Reload-safe: HttpServer::Reload calls ConfigLoader::Validate on + // a copy whose upstreams[] has been deliberately stripped (see + // server/http_server.cc:3601 — `validation_copy.upstreams.clear()`) + // because upstream topology is restart-only and the reload path + // intentionally re-validates only the live-reloadable bits. If we + // ran the cross-reference check unconditionally, ANY hot reload + // of a config that has populated auth.issuers — even an + // entirely auth-unrelated reload (e.g. a rate-limit edit) — + // would fail with "unknown issuer upstream" because the source + // map is empty. That would block the forward-compatible auth + // schema the final enforcement-not-yet-wired gate intentionally + // permits. + // + // Skip the cross-ref when upstream_names is empty (i.e. we're + // running in a stripped reload context). The startup path + // always passes the full upstreams list, so the typo-catching + // value of this check is preserved there. When upstreams is + // truly empty at startup (a no-proxies gateway), the issuer's + // upstream reference is unverifiable here anyway — Phase 2's + // AuthManager::Start will surface it at first IdP outbound + // attempt, which is acceptable for that uncommon case. + if (!ic.upstream.empty() && !upstream_names.empty() && + upstream_names.count(ic.upstream) == 0) { throw std::invalid_argument( ctx + ".upstream references unknown upstream '" + ic.upstream + "' — define it under `upstreams[]` first"); @@ -1766,6 +1863,19 @@ void ConfigLoader::Validate(const ServerConfig& config) { lower.push_back(static_cast( std::tolower(static_cast(c)))); } + // Reserved-name check FIRST (security): hop-by-hop / pseudo / + // framing-critical / Authorization names would corrupt or + // spoof the forwarded request. See IsReservedAuthForwardHeader + // for the full categorization. Reject before the duplicate + // check so the operator sees the more-actionable error. + if (IsReservedAuthForwardHeader(lower)) { + throw std::invalid_argument( + "auth.forward." + which + " '" + name + + "' is a reserved/hop-by-hop/pseudo/framing header " + "name and must not be used as an auth-forward output " + "(would corrupt or spoof the upstream request); pick " + "an X-prefixed name like 'X-Auth-Subject' instead"); + } if (!output_headers.insert(lower).second) { throw std::invalid_argument( "auth.forward." + which + " '" + name + diff --git a/test/auth_foundation_test.h b/test/auth_foundation_test.h index aeea28a9..b67ba73c 100644 --- a/test/auth_foundation_test.h +++ b/test/auth_foundation_test.h @@ -987,6 +987,275 @@ void TestPopulateFromPayloadOptionalIssSub() { } } +// ----------------------------------------------------------------------------- +// ConfigLoader::Validate — issuer.upstream cross-reference is reload-safe +// (review P2 #1). HttpServer::Reload() validates a copy with upstreams[] +// stripped (server/http_server.cc:3601) so the topology-restart-only path +// can be re-validated for live-reloadable bits without dragging restart +// constraints. Before this fix, the issuer.upstream check would fire on +// the empty upstream_names set and reject ANY reload of a config that has +// auth.issuers populated — even an entirely auth-unrelated reload like a +// rate-limit edit. This test pins the relaxation: with upstreams empty, +// the cross-reference check is skipped (typo-catching value preserved at +// startup where upstreams is always full). +// ----------------------------------------------------------------------------- +void TestConfigLoaderUpstreamCrossRefReloadSafe() { + std::cout << "\n[TEST] ConfigLoader issuer.upstream cross-ref is reload-safe..." << std::endl; + try { + // Simulate the reload-validation context: full auth schema, but + // upstreams[] cleared (mimics HttpServer::Reload's validation_copy). + const std::string reload_shape = R"({ + "bind_host": "127.0.0.1", + "bind_port": 8080, + "upstreams": [], + "auth": { + "enabled": false, + "issuers": { + "google": { + "issuer_url": "https://accounts.google.com", + "upstream": "idp_google", + "mode": "jwt", + "algorithms": ["RS256"] + } + } + } + })"; + + bool threw = false; + std::string err_msg; + try { + ServerConfig cfg = ConfigLoader::LoadFromString(reload_shape); + ConfigLoader::Validate(cfg); + } catch (const std::exception& e) { + threw = true; + err_msg = e.what(); + } + + // Regression check: at STARTUP (full upstreams, no idp_google), the + // check should still fire. This confirms we relaxed only the + // empty-upstreams branch, not the entire check. + const std::string startup_shape = R"({ + "bind_host": "127.0.0.1", + "bind_port": 8080, + "upstreams": [{"name":"some_other_upstream","host":"127.0.0.1","port":80}], + "auth": { + "enabled": false, + "issuers": { + "google": { + "issuer_url": "https://accounts.google.com", + "upstream": "idp_google", + "mode": "jwt", + "algorithms": ["RS256"] + } + } + } + })"; + bool startup_threw = false; + std::string startup_err; + try { + ServerConfig cfg2 = ConfigLoader::LoadFromString(startup_shape); + ConfigLoader::Validate(cfg2); + } catch (const std::exception& e) { + startup_threw = true; + startup_err = e.what(); + } + + bool reload_pass = !threw; + bool startup_pass = startup_threw && + startup_err.find("references unknown upstream") != std::string::npos; + + bool pass = reload_pass && startup_pass; + std::string err; + if (!reload_pass) { + err = "reload-shape (empty upstreams) should NOT throw on " + "issuer.upstream cross-ref but did: " + err_msg; + } else if (!startup_pass) { + err = startup_threw + ? "startup-shape threw but with wrong error (expected " + "'references unknown upstream'); got: " + startup_err + : "startup-shape (full upstreams, missing idp_google) " + "should still reject the cross-ref but accepted"; + } + + TestFramework::RecordTest( + "AuthFoundation: ConfigLoader issuer.upstream check is reload-safe", + pass, err, TestFramework::TestCategory::OTHER); + } catch (const std::exception& e) { + TestFramework::RecordTest( + "AuthFoundation: ConfigLoader issuer.upstream check is reload-safe", + false, std::string("unexpected harness error: ") + e.what(), + TestFramework::TestCategory::OTHER); + } +} + +// ----------------------------------------------------------------------------- +// ParseStrictInt — out-of-int-range JSON integers must throw, not wrap +// (review P2 #2). is_number_integer() returns true for any integer that +// fits in nlohmann's internal int64/uint64 representation. v.get() +// then wraps/truncates oversized values — a 4294967297 leeway_sec would +// silently become a small wrapped value. Pin both the unsigned-too-big +// path (UINT_MAX > INT_MAX) and the signed-out-of-range path (negative +// large or near-INT64 boundary). +// ----------------------------------------------------------------------------- +void TestConfigLoaderRejectsOutOfRangeIntegers() { + std::cout << "\n[TEST] ConfigLoader rejects out-of-range integers..." << std::endl; + auto validate_expect_failure = [](const std::string& json, + const std::string& expected_phrase) + -> std::string { + try { + ServerConfig cfg = ConfigLoader::LoadFromString(json); + ConfigLoader::Validate(cfg); + return "expected throw containing '" + expected_phrase + + "' but accepted"; + } catch (const std::invalid_argument& e) { + std::string msg = e.what(); + if (msg.find(expected_phrase) == std::string::npos) { + return "threw with wrong message; expected '" + + expected_phrase + "', got: " + msg; + } + return ""; + } catch (const std::exception& e) { + return std::string("unexpected exception type: ") + e.what(); + } + }; + + try { + // Case 1: leeway_sec exceeding INT_MAX (2^32 + 1 = 4294967297). + std::string err = validate_expect_failure(R"({ + "upstreams": [{"name":"x","host":"127.0.0.1","port":80}], + "auth": { + "enabled": false, + "issuers": { + "ours": { + "issuer_url": "https://issuer.example", + "upstream": "x", + "mode": "jwt", + "algorithms": ["RS256"], + "leeway_sec": 4294967297 + } + } + } + })", "out of int range"); + if (!err.empty()) throw std::runtime_error("oversized leeway_sec: " + err); + + // Case 2: introspection.timeout_sec exceeding INT_MAX. + err = validate_expect_failure(R"({ + "upstreams": [{"name":"x","host":"127.0.0.1","port":80}], + "auth": { + "enabled": false, + "issuers": { + "ours": { + "issuer_url": "https://issuer.example", + "upstream": "x", + "mode": "introspection", + "introspection": { + "endpoint": "https://issuer.example/introspect", + "timeout_sec": 9999999999 + } + } + } + } + })", "out of int range"); + if (!err.empty()) throw std::runtime_error("oversized timeout_sec: " + err); + + TestFramework::RecordTest( + "AuthFoundation: ConfigLoader rejects out-of-range integers", + true, "", TestFramework::TestCategory::OTHER); + } catch (const std::exception& e) { + TestFramework::RecordTest( + "AuthFoundation: ConfigLoader rejects out-of-range integers", + false, e.what(), TestFramework::TestCategory::OTHER); + } +} + +// ----------------------------------------------------------------------------- +// ConfigLoader::Validate — auth.forward header names must not be reserved +// (review P2 #3). Hop-by-hop, HTTP/2 pseudo, framing-critical, and +// Authorization names would corrupt or spoof the upstream request. Test +// all four categories on different config positions (subject_header, +// raw_jwt_header, claims_to_headers value). +// ----------------------------------------------------------------------------- +void TestConfigLoaderRejectsReservedForwardHeaders() { + std::cout << "\n[TEST] ConfigLoader rejects reserved auth.forward header names..." << std::endl; + auto validate_expect_reserved = [](const std::string& bad_header_field, + const std::string& bad_header_name) + -> std::string { + std::string json = R"({ + "upstreams": [{"name":"x","host":"127.0.0.1","port":80}], + "auth": { + "enabled": false, + "issuers": { + "ours": { + "issuer_url": "https://issuer.example", + "upstream": "x", + "mode": "jwt", + "algorithms": ["RS256"] + } + }, + "forward": {)" + bad_header_field + R"(} + } + })"; + try { + ServerConfig cfg = ConfigLoader::LoadFromString(json); + ConfigLoader::Validate(cfg); + return "expected reserved-name rejection for '" + bad_header_name + + "' but accepted"; + } catch (const std::invalid_argument& e) { + std::string msg = e.what(); + if (msg.find("reserved") == std::string::npos) { + return "threw but message lacked 'reserved'; got: " + msg; + } + if (msg.find(bad_header_name) == std::string::npos) { + return "threw but message lacked offending name '" + + bad_header_name + "'; got: " + msg; + } + return ""; + } catch (const std::exception& e) { + return std::string("unexpected exception type: ") + e.what(); + } + }; + + try { + // Case 1: hop-by-hop in subject_header. + std::string err = validate_expect_reserved( + R"("subject_header": "Connection")", "Connection"); + if (!err.empty()) throw std::runtime_error("Connection: " + err); + + // Case 2: HTTP/2 pseudo-header in raw_jwt_header. + err = validate_expect_reserved( + R"("raw_jwt_header": ":path")", ":path"); + if (!err.empty()) throw std::runtime_error(":path: " + err); + + // Case 3: framing-critical Host in claims_to_headers value. + err = validate_expect_reserved( + R"("claims_to_headers": {"sub": "Host"})", "Host"); + if (!err.empty()) throw std::runtime_error("Host: " + err); + + // Case 4: Content-Length (smuggling vector) in claims_to_headers. + err = validate_expect_reserved( + R"("claims_to_headers": {"sub": "Content-Length"})", "Content-Length"); + if (!err.empty()) throw std::runtime_error("Content-Length: " + err); + + // Case 5: Authorization (conflicts with preserve_authorization). + err = validate_expect_reserved( + R"("issuer_header": "Authorization")", "Authorization"); + if (!err.empty()) throw std::runtime_error("Authorization: " + err); + + // Case 6: case-insensitive — "connection" lowercase rejected too. + err = validate_expect_reserved( + R"("subject_header": "connection")", "connection"); + if (!err.empty()) throw std::runtime_error("connection (lowercase): " + err); + + TestFramework::RecordTest( + "AuthFoundation: ConfigLoader rejects reserved auth.forward headers", + true, "", TestFramework::TestCategory::OTHER); + } catch (const std::exception& e) { + TestFramework::RecordTest( + "AuthFoundation: ConfigLoader rejects reserved auth.forward headers", + false, e.what(), TestFramework::TestCategory::OTHER); + } +} + inline void RunAllTests() { std::cout << "\n===== Auth Foundation Tests =====" << std::endl; TestHasherBasicDeterminism(); @@ -1000,6 +1269,9 @@ inline void RunAllTests() { TestConfigLoaderRejectsProxyAuthEnabled(); TestConfigLoaderRejectsPlaintextIdpEndpoints(); TestPopulateFromPayloadOptionalIssSub(); + TestConfigLoaderUpstreamCrossRefReloadSafe(); + TestConfigLoaderRejectsOutOfRangeIntegers(); + TestConfigLoaderRejectsReservedForwardHeaders(); TestConfigLoaderClaimHeaderCollision(); } From a6ea5ccfa1a62db29dd0043dd118b1ac9aea1cb7 Mon Sep 17 00:00:00 2001 From: mwfj Date: Fri, 17 Apr 2026 12:46:05 +0800 Subject: [PATCH 10/17] Fix review comment --- server/auth_claims.cc | 24 +++ server/config_loader.cc | 75 ++++++-- test/auth_foundation_test.h | 347 ++++++++++++++++++++++++++++++++++++ 3 files changed, 433 insertions(+), 13 deletions(-) diff --git a/server/auth_claims.cc b/server/auth_claims.cc index 9e28a308..51f870ba 100644 --- a/server/auth_claims.cc +++ b/server/auth_claims.cc @@ -57,6 +57,30 @@ std::vector ExtractScopes(const nlohmann::json& payload) { bool PopulateFromPayload(const nlohmann::json& payload, const std::vector& claims_keys, AuthContext& ctx) { + // Defensive reset of the fields THIS function manages. Without this, + // a non-fresh AuthContext (e.g. a verifier that reuses a stack object + // across requests, or a retry path that re-invokes Populate) would + // INHERIT the previous call's iss / sub / scopes / claims when the + // current payload doesn't supply them — a token could end up with + // the prior caller's principal, scopes, or forwarded-claim values. + // Principal-confusion bug. The cheap fix is "this function owns these + // fields; clear up front, populate from payload." + // + // Fields NOT cleared here (policy_name, raw_token, undetermined) are + // caller-managed; they're set by the middleware around the verifier + // call, not derived from the JWT payload. Clearing them would force + // the caller to re-set on every payload populate, which is the wrong + // contract. + // + // Cleared even when we return false below (non-object payload) — a + // caller who accidentally uses ctx after a false return gets clean + // empty fields, not stale ones from a previous successful call. This + // is the safer side of the fail-closed coin. + ctx.issuer.clear(); + ctx.subject.clear(); + ctx.scopes.clear(); + ctx.claims.clear(); + if (!payload.is_object()) return false; // `iss` and `sub` are both OPTIONAL per RFC 7519 §4.1.1 / §4.1.2; the diff --git a/server/config_loader.cc b/server/config_loader.cc index e246c776..c3522461 100644 --- a/server/config_loader.cc +++ b/server/config_loader.cc @@ -1783,33 +1783,71 @@ void ConfigLoader::Validate(const ServerConfig& config) { std::unordered_map all_prefixes; for (const auto& u : config.upstreams) { - // Detect "proxy has inline auth" via the master switch - // (u.proxy.auth.enabled). Earlier versions used a default- - // struct comparison which broke the moment a new field with a - // non-trivial default landed in AuthPolicy. - if (!u.proxy.auth.enabled) continue; const auto& p = u.proxy.auth; const std::string ctx = "upstreams['" + u.name + "'].proxy.auth"; + + // ---- Structural validation: runs regardless of `enabled` ---- + // + // Per the rollout plan (design spec §14), operators are EXPECTED + // to pre-stage inline auth blocks with `enabled=false` while + // request-time enforcement is being wired in follow-up PRs. + // If we only validated when enabled=true, typos in the staged + // configs (unknown issuer names, invalid on_undetermined + // values) would silently slip through and only surface when + // the operator flips enabled to true at deployment. That's a + // bad operator experience and a hidden-correctness vector. + // + // The structural checks below have well-defined semantics for + // ANY populated AuthPolicy and so are safe to run on disabled + // blocks — they catch typos BEFORE deploy. if (p.on_undetermined != "deny" && p.on_undetermined != "allow") { throw std::invalid_argument( - ctx + ".on_undetermined must be \"deny\" or \"allow\""); + ctx + ".on_undetermined must be \"deny\" or \"allow\" " + "(checked regardless of `enabled` so staged disabled " + "policies still get typo-rejection)"); } for (const auto& issuer_name : p.issuers) { if (config.auth.issuers.count(issuer_name) == 0) { throw std::invalid_argument( ctx + ".issuers references unknown issuer '" + - issuer_name + "'"); + issuer_name + "' (checked regardless of `enabled` " + "so staged disabled policies still get typo-rejection)"); } } - // Inline proxy.auth derives its applies_to from the proxy's - // route_prefix. An empty route_prefix is a config bug — the - // policy would apply to everything, shadowing any top-level - // catch-all. - if (u.proxy.route_prefix.empty()) { + + // route_prefix non-empty is required ONLY when the operator + // has actually populated the inline auth block. A proxy with + // a fully-default auth block (the operator never wrote + // `proxy.auth: {...}`) shouldn't be required to have a + // route_prefix — it might be a programmatic-only proxy. So + // gate this specific check on whether ANY field of the block + // was touched by the operator. Detection: any field differs + // from the AuthPolicy default constructor. + // + // Includes p.enabled in the populated check because an + // operator who writes `"auth": {"enabled": true}` literally + // (even with no other fields) is signaling intent and should + // still be told their proxy lacks a route_prefix. Same for + // any other non-default field. + const bool inline_auth_populated = (p != auth::AuthPolicy{}); + if (inline_auth_populated && u.proxy.route_prefix.empty()) { throw std::invalid_argument( ctx + " has no route_prefix — inline auth requires a " "non-empty proxy.route_prefix to derive applies_to"); } + + // ---- Collision detection: ENABLED-only ---- + // + // Per spec §3.2, only enabled inline policies participate in + // the runtime longest-prefix matcher. Disabled policies are + // inert at request time, so they shouldn't collide with each + // other or with top-level policies. (This matches the prior + // reviewer round's guidance that the "enable a disabled + // policy and discover a collision later" flow is a deliberate + // UX trade-off, not a bug — and confirms the gate is the + // right place to draw the structural-vs-collision line.) + if (!p.enabled) continue; + const std::string owner = "inline proxy.auth on upstream '" + u.name + "'"; auto ins = all_prefixes.emplace(u.proxy.route_prefix, owner); @@ -1994,7 +2032,18 @@ std::string ConfigLoader::ToJson(const ServerConfig& config) { // route_prefix is empty (exposed via programmatic Proxy() API). // Skipping this block on empty route_prefix would silently reset // those settings on a ToJson() / LoadFromString() round-trip. - if (u.proxy != ProxyConfig{}) { + // + // The gate also explicitly checks for an auth-only difference: + // ProxyConfig::operator== INTENTIONALLY ignores the `auth` field + // (live-reloadable per same-PR `AuthManager::Reload` discipline, + // see DEVELOPMENT_RULES.md). Without the second clause, a proxy + // that only customizes inline auth — exactly the staged config + // shape operators are expected to write before request-time + // enforcement is wired — would compare equal to the default and + // get its entire proxy block (including `auth`) silently dropped + // by ToJson(). Round-trip would lose the staged policy. Treat + // any non-default auth as sufficient reason to serialize. + if (u.proxy != ProxyConfig{} || u.proxy.auth != auth::AuthPolicy{}) { nlohmann::json pj; pj["route_prefix"] = u.proxy.route_prefix; pj["strip_prefix"] = u.proxy.strip_prefix; diff --git a/test/auth_foundation_test.h b/test/auth_foundation_test.h index b67ba73c..7824f87b 100644 --- a/test/auth_foundation_test.h +++ b/test/auth_foundation_test.h @@ -1256,6 +1256,350 @@ void TestConfigLoaderRejectsReservedForwardHeaders() { } } +// ----------------------------------------------------------------------------- +// PopulateFromPayload — clears stale fields on reuse (review P2 #1). +// A second call with a payload missing iss/sub/claims must NOT inherit +// the previous call's values. Principal-confusion bug if not cleared. +// ----------------------------------------------------------------------------- +void TestPopulateFromPayloadClearsStaleFields() { + std::cout << "\n[TEST] PopulateFromPayload clears stale fields on reuse..." << std::endl; + try { + auth::AuthContext ctx; + + // First call: populate ctx with iss/sub/email/groups (groups + // ignored — it's an array — but email is a scalar that lands + // in claims). + nlohmann::json first = nlohmann::json::parse(R"({ + "iss": "https://issuer-A.example", + "sub": "alice", + "email": "alice@example.com", + "scope": "read:data" + })"); + bool ok1 = auth::PopulateFromPayload(first, {"email"}, ctx); + bool first_pass = ok1 && + ctx.issuer == "https://issuer-A.example" && + ctx.subject == "alice" && + ctx.claims.count("email") == 1 && + ctx.scopes.size() == 1; + if (!first_pass) { + TestFramework::RecordTest( + "AuthFoundation: PopulateFromPayload clears stale fields", + false, "first call failed to populate baseline state", + TestFramework::TestCategory::OTHER); + return; + } + + // Second call REUSING the same ctx: payload has NEITHER iss NOR + // sub, no email, and a different scope. After this call, ctx + // must reflect ONLY the second payload — no carryover. + nlohmann::json second = nlohmann::json::parse(R"({ + "scope": "write:data" + })"); + bool ok2 = auth::PopulateFromPayload(second, {"email"}, ctx); + + bool second_pass = ok2 && + ctx.issuer.empty() && // NOT "https://issuer-A.example" + ctx.subject.empty() && // NOT "alice" + ctx.claims.count("email") == 0 && // NOT alice@example.com + ctx.scopes.size() == 1 && + ctx.scopes[0] == "write:data"; + + // Third case: structurally invalid payload also clears (caller + // who accidentally trusts ctx after a false return gets clean + // state, not stale carryover from the first successful call). + ctx.issuer = "leftover"; + ctx.subject = "leftover"; + ctx.claims["leftover"] = "leftover"; + nlohmann::json bad = nlohmann::json::parse(R"("a string")"); + bool ok3 = auth::PopulateFromPayload(bad, {}, ctx); + bool third_pass = !ok3 && // returns false on non-object + ctx.issuer.empty() && + ctx.subject.empty() && + ctx.claims.empty(); + + bool pass = second_pass && third_pass; + std::string err; + if (!second_pass) { + err = "stale-field carryover on reuse: ctx still holds prior " + "values (iss='" + ctx.issuer + "', sub='" + ctx.subject + + "', claims.size=" + std::to_string(ctx.claims.size()) + ")"; + } else if (!third_pass) { + err = "non-object payload didn't clear ctx — fail-closed " + "contract violated"; + } + TestFramework::RecordTest( + "AuthFoundation: PopulateFromPayload clears stale fields", + pass, err, TestFramework::TestCategory::OTHER); + } catch (const std::exception& e) { + TestFramework::RecordTest( + "AuthFoundation: PopulateFromPayload clears stale fields", + false, std::string("unexpected exception: ") + e.what(), + TestFramework::TestCategory::OTHER); + } +} + +// ----------------------------------------------------------------------------- +// ConfigLoader::Validate — disabled inline proxy.auth still gets structural +// validation (review P2 #2). Operators are expected to pre-stage disabled +// auth blocks during the rollout; typos must surface NOW, not at the +// deploy where they flip enabled=true. +// ----------------------------------------------------------------------------- +void TestConfigLoaderValidatesDisabledInlineAuth() { + std::cout << "\n[TEST] ConfigLoader validates disabled inline proxy.auth..." << std::endl; + auto validate_expect_failure = [](const std::string& json, + const std::string& expected_phrase) + -> std::string { + try { + ServerConfig cfg = ConfigLoader::LoadFromString(json); + ConfigLoader::Validate(cfg); + return "expected throw containing '" + expected_phrase + + "' but accepted"; + } catch (const std::invalid_argument& e) { + std::string msg = e.what(); + if (msg.find(expected_phrase) == std::string::npos) { + return "threw with wrong message; expected '" + + expected_phrase + "', got: " + msg; + } + return ""; + } catch (const std::exception& e) { + return std::string("unexpected exception type: ") + e.what(); + } + }; + + try { + // Case 1: DISABLED inline auth references unknown issuer — must reject. + std::string err = validate_expect_failure(R"({ + "upstreams": [ + {"name":"x","host":"127.0.0.1","port":80}, + { + "name": "internal-api", + "host": "127.0.0.1", + "port": 8080, + "proxy": { + "route_prefix": "/api/v1", + "auth": { + "enabled": false, + "issuers": ["typo-not-a-real-issuer"] + } + } + } + ], + "auth": { + "enabled": false, + "issuers": { + "google": { + "issuer_url": "https://accounts.google.com", + "upstream": "x", + "mode": "jwt", + "algorithms": ["RS256"] + } + } + } + })", "references unknown issuer"); + if (!err.empty()) throw std::runtime_error("disabled unknown issuer: " + err); + + // Case 2: DISABLED inline auth with bad on_undetermined value. + err = validate_expect_failure(R"({ + "upstreams": [ + {"name":"x","host":"127.0.0.1","port":80}, + { + "name": "internal-api", + "host": "127.0.0.1", + "port": 8080, + "proxy": { + "route_prefix": "/api/v1", + "auth": { + "enabled": false, + "issuers": ["google"], + "on_undetermined": "maybe" + } + } + } + ], + "auth": { + "enabled": false, + "issuers": { + "google": { + "issuer_url": "https://accounts.google.com", + "upstream": "x", + "mode": "jwt", + "algorithms": ["RS256"] + } + } + } + })", "on_undetermined must be"); + if (!err.empty()) throw std::runtime_error("disabled bad on_undetermined: " + err); + + // Case 3: DISABLED inline auth populated but no route_prefix. + err = validate_expect_failure(R"({ + "upstreams": [ + {"name":"x","host":"127.0.0.1","port":80}, + { + "name": "internal-api", + "host": "127.0.0.1", + "port": 8080, + "proxy": { + "auth": { + "enabled": false, + "issuers": ["google"] + } + } + } + ], + "auth": { + "enabled": false, + "issuers": { + "google": { + "issuer_url": "https://accounts.google.com", + "upstream": "x", + "mode": "jwt", + "algorithms": ["RS256"] + } + } + } + })", "no route_prefix"); + if (!err.empty()) throw std::runtime_error("disabled no route_prefix: " + err); + + // Case 4: NEGATIVE — proxy with no auth block at all and no + // route_prefix (programmatic-only proxy). Must NOT trigger the + // route_prefix check (the populated-detection guards it). + try { + ServerConfig cfg = ConfigLoader::LoadFromString(R"({ + "upstreams": [ + { + "name": "programmatic-only", + "host": "127.0.0.1", + "port": 8080 + } + ] + })"); + ConfigLoader::Validate(cfg); + // Expected: no throw. If we got here, good. + } catch (const std::exception& e) { + throw std::runtime_error( + "no-auth-block proxy without route_prefix should pass " + "validation, but threw: " + std::string(e.what())); + } + + TestFramework::RecordTest( + "AuthFoundation: ConfigLoader validates disabled inline proxy.auth", + true, "", TestFramework::TestCategory::OTHER); + } catch (const std::exception& e) { + TestFramework::RecordTest( + "AuthFoundation: ConfigLoader validates disabled inline proxy.auth", + false, e.what(), TestFramework::TestCategory::OTHER); + } +} + +// ----------------------------------------------------------------------------- +// ConfigLoader::ToJson — serializes proxy block when only auth differs +// (review P2 #3). ProxyConfig::operator== ignores `auth` (correct, by +// design — auth is live-reloadable). Without an explicit auth-difference +// check in the serialization gate, a proxy customizing only the inline +// auth stanza gets its entire block dropped on round-trip. That's exactly +// the staged-config shape operators are expected to use during the +// pre-Phase-2 rollout, so the round-trip loss is a config-data-loss bug. +// ----------------------------------------------------------------------------- +void TestConfigLoaderRoundTripsAuthOnlyProxy() { + std::cout << "\n[TEST] ConfigLoader round-trips auth-only proxy block..." << std::endl; + try { + // Proxy that ONLY customizes inline auth — every other proxy + // field is at its default (empty route_prefix would fail the + // populated-route-prefix structural check, so we set + // route_prefix to something non-empty; everything ELSE is at + // default). Notable: enabled=false on the auth (so the master + // gate doesn't fire) but issuers/realm are populated to make + // the auth block clearly non-default. + const std::string original = R"({ + "bind_host": "127.0.0.1", + "bind_port": 8080, + "upstreams": [ + {"name":"x","host":"127.0.0.1","port":80}, + { + "name": "internal-api", + "host": "127.0.0.1", + "port": 8080, + "proxy": { + "route_prefix": "/api/v1", + "auth": { + "enabled": false, + "issuers": ["google"], + "realm": "internal-api" + } + } + } + ], + "auth": { + "enabled": false, + "issuers": { + "google": { + "issuer_url": "https://accounts.google.com", + "upstream": "x", + "mode": "jwt", + "algorithms": ["RS256"] + } + } + } + })"; + + ServerConfig c1 = ConfigLoader::LoadFromString(original); + // Confirm the original fixture parsed the inline auth block. + bool parsed_inline = false; + for (const auto& u : c1.upstreams) { + if (u.name == "internal-api") { + parsed_inline = + !u.proxy.auth.issuers.empty() && + u.proxy.auth.issuers[0] == "google" && + u.proxy.auth.realm == "internal-api"; + break; + } + } + if (!parsed_inline) { + TestFramework::RecordTest( + "AuthFoundation: ConfigLoader round-trips auth-only proxy", + false, "fixture: parser failed to capture the inline auth " + "block on the first parse — test cannot proceed", + TestFramework::TestCategory::OTHER); + return; + } + + // Serialize and re-parse. THIS is the path that previously dropped + // the proxy block because operator== ignores auth and the gate + // used `u.proxy != ProxyConfig{}`. + std::string reserialized = ConfigLoader::ToJson(c1); + ServerConfig c2 = ConfigLoader::LoadFromString(reserialized); + + // The round-trip must preserve the inline auth block. + bool round_trip_preserved = false; + for (const auto& u : c2.upstreams) { + if (u.name == "internal-api") { + round_trip_preserved = + !u.proxy.auth.issuers.empty() && + u.proxy.auth.issuers[0] == "google" && + u.proxy.auth.realm == "internal-api"; + break; + } + } + + bool pass = round_trip_preserved; + std::string err; + if (!pass) { + err = "round-trip dropped the auth-only proxy block — " + "ToJson() gate didn't recognize the auth-only " + "difference. Reserialized JSON: " + + reserialized.substr(0, 300) + "..."; + } + TestFramework::RecordTest( + "AuthFoundation: ConfigLoader round-trips auth-only proxy", + pass, err, TestFramework::TestCategory::OTHER); + } catch (const std::exception& e) { + TestFramework::RecordTest( + "AuthFoundation: ConfigLoader round-trips auth-only proxy", + false, std::string("unexpected exception: ") + e.what(), + TestFramework::TestCategory::OTHER); + } +} + inline void RunAllTests() { std::cout << "\n===== Auth Foundation Tests =====" << std::endl; TestHasherBasicDeterminism(); @@ -1272,6 +1616,9 @@ inline void RunAllTests() { TestConfigLoaderUpstreamCrossRefReloadSafe(); TestConfigLoaderRejectsOutOfRangeIntegers(); TestConfigLoaderRejectsReservedForwardHeaders(); + TestPopulateFromPayloadClearsStaleFields(); + TestConfigLoaderValidatesDisabledInlineAuth(); + TestConfigLoaderRoundTripsAuthOnlyProxy(); TestConfigLoaderClaimHeaderCollision(); } From 47504107e083934b7bd242af3147b68a0f5501ea Mon Sep 17 00:00:00 2001 From: mwfj Date: Fri, 17 Apr 2026 13:29:43 +0800 Subject: [PATCH 11/17] Fix review comment --- server/config_loader.cc | 54 ++++++- test/auth_foundation_test.h | 300 ++++++++++++++++++++++++++++++++++++ 2 files changed, 351 insertions(+), 3 deletions(-) diff --git a/server/config_loader.cc b/server/config_loader.cc index c3522461..c6705ef5 100644 --- a/server/config_loader.cc +++ b/server/config_loader.cc @@ -105,6 +105,12 @@ static bool IsReservedAuthForwardHeader(const std::string& lower) { "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade", + // Non-standard hop-by-hop legacy header that + // HeaderRewriter::IsHopByHopHeader() also strips. Forwarding + // identity through `proxy-connection` would be silently dropped + // at outbound time, so reject at config load with a clear error + // instead of a confusing empty-header symptom later. + "proxy-connection", // Framing-critical (corrupting these is a smuggling/parser-confusion // vector against the upstream) "host", "content-length", "content-type", "content-encoding", @@ -135,8 +141,18 @@ static nlohmann::json SerializeAuthPolicy(const auth::AuthPolicy& p) { // `upstreams[i].proxy.auth` and for top-level `auth.policies[]` entries. // `context` is embedded in error messages so operators can locate the // offending block. +// +// `allow_applies_to` controls whether the `applies_to` field is permitted +// in this JSON. Top-level policies set it to true (applies_to is the +// REQUIRED prefix declaration). Inline proxy.auth blocks set it to false: +// per design spec §3.2 / §5.2, the prefix for an inline policy is +// derived from `proxy.route_prefix` at AuthManager::RegisterPolicy time, +// and an inline `applies_to` would be silently ignored — the JSON would +// then describe a different protected path than what the runtime uses, +// which is a config-correctness bug. Reject loudly at parse time. static void ParseAuthPolicy(const nlohmann::json& j, auth::AuthPolicy& out, - const std::string& context) { + const std::string& context, + bool allow_applies_to = true) { if (!j.is_object()) { throw std::invalid_argument(context + " must be a JSON object"); } @@ -151,6 +167,20 @@ static void ParseAuthPolicy(const nlohmann::json& j, auth::AuthPolicy& out, out.name = j.value("name", std::string{}); out.enabled = j.value("enabled", false); if (j.contains("applies_to")) { + // Inline-policy guard: applies_to is meaningless inside an inline + // proxy.auth block (the prefix comes from proxy.route_prefix). + // Accepting it would let the JSON describe one protected path + // while the runtime applies a different one — a misleading + // round-trip and a likely operator-confusion vector. Reject and + // tell the operator where the prefix actually comes from. + if (!allow_applies_to) { + throw std::invalid_argument( + context + ".applies_to is not permitted on inline auth " + "(the prefix is derived from the surrounding proxy's " + "route_prefix, see design spec §3.2 / §5.2). Remove " + "applies_to here, or move this policy to top-level " + "auth.policies[] if it needs an explicit prefix list."); + } if (!j["applies_to"].is_array()) { throw std::invalid_argument( context + ".applies_to must be an array of strings"); @@ -612,12 +642,17 @@ ServerConfig ConfigLoader::LoadFromString(const std::string& json_str) { // Inline per-proxy auth policy. `applies_to` is derived from // `route_prefix` at AuthManager::RegisterPolicy time — the // inline stanza never declares its own `applies_to`. See - // design spec §3.2 / §5.2. + // design spec §3.2 / §5.2. Pass `allow_applies_to=false` + // so the parser rejects misleading inline applies_to + // declarations at parse time, before they can mislead an + // operator into thinking that field governs runtime + // matching. if (proxy.contains("auth")) { ParseAuthPolicy( proxy["auth"], upstream.proxy.auth, - "upstreams[" + upstream.name + "].proxy.auth"); + "upstreams[" + upstream.name + "].proxy.auth", + /*allow_applies_to=*/false); } } @@ -1866,8 +1901,21 @@ void ConfigLoader::Validate(const ServerConfig& config) { // was designed to enforce — ConfigLoader::Validate is the correct // place to call it because collisions must be a load-time error, // not a silent runtime first-wins. + // + // SYMMETRY with inline policies: only ENABLED top-level policies + // participate in the runtime longest-prefix matcher (per spec §3.2), + // so only they should drive collision detection. We already skip + // disabled inline proxy.auth above — applying the same rule to + // top-level keeps the two paths consistent and lets operators + // pre-stage disabled top-level policies during the rollout + // without spurious collision errors. (Without this, an operator + // who staged two top-level policies with identical applies_to + // — both disabled, intentionally inert — would see Validate + // reject the config even though the runtime would never match + // either of them.) for (size_t i = 0; i < config.auth.policies.size(); ++i) { const auto& p = config.auth.policies[i]; + if (!p.enabled) continue; // Symmetry with inline path const std::string policy_owner = p.name.empty() ? ("auth.policies[" + std::to_string(i) + "]") diff --git a/test/auth_foundation_test.h b/test/auth_foundation_test.h index 7824f87b..ebd1c82a 100644 --- a/test/auth_foundation_test.h +++ b/test/auth_foundation_test.h @@ -1600,6 +1600,303 @@ void TestConfigLoaderRoundTripsAuthOnlyProxy() { } } +// ----------------------------------------------------------------------------- +// ConfigLoader::Validate — disabled top-level policies skip collision +// detection, matching the inline-policy treatment (review P2 #1). +// Operators staging policies during the rollout get a usable config; the +// runtime matcher already ignores disabled entries so collision is moot. +// ----------------------------------------------------------------------------- +void TestConfigLoaderDisabledTopLevelPoliciesDoNotCollide() { + std::cout << "\n[TEST] ConfigLoader skips disabled top-level policies in collision check..." << std::endl; + try { + // Two top-level policies with IDENTICAL applies_to but BOTH + // disabled. Pre-fix: rejected with "exact-prefix collision". + // Post-fix: accepted (neither participates in runtime matching). + const std::string both_disabled = R"({ + "upstreams": [{"name":"x","host":"127.0.0.1","port":80}], + "auth": { + "enabled": false, + "issuers": { + "google": { + "issuer_url": "https://issuer.example", + "upstream": "x", + "mode": "jwt", + "algorithms": ["RS256"] + } + }, + "policies": [ + {"name":"a", "enabled":false, "applies_to":["/api/"], "issuers":["google"]}, + {"name":"b", "enabled":false, "applies_to":["/api/"], "issuers":["google"]} + ] + } + })"; + try { + ServerConfig cfg = ConfigLoader::LoadFromString(both_disabled); + ConfigLoader::Validate(cfg); + // Expected: no throw. + } catch (const std::exception& e) { + throw std::runtime_error( + "two disabled policies sharing a prefix should be ACCEPTED " + "(neither participates in runtime matching) but Validate " + "threw: " + std::string(e.what())); + } + + // Regression: ENABLED policies with same prefix must STILL reject. + // Confirms the relaxation is scoped to disabled entries only. + const std::string both_enabled = R"({ + "upstreams": [{"name":"x","host":"127.0.0.1","port":80}], + "auth": { + "enabled": false, + "issuers": { + "google": { + "issuer_url": "https://issuer.example", + "upstream": "x", + "mode": "jwt", + "algorithms": ["RS256"] + } + }, + "policies": [ + {"name":"a", "enabled":true, "applies_to":["/api/"], "issuers":["google"]}, + {"name":"b", "enabled":true, "applies_to":["/api/"], "issuers":["google"]} + ] + } + })"; + bool enabled_threw = false; + std::string enabled_err; + try { + ServerConfig cfg = ConfigLoader::LoadFromString(both_enabled); + ConfigLoader::Validate(cfg); + } catch (const std::invalid_argument& e) { + enabled_threw = true; + enabled_err = e.what(); + } + bool enabled_collision_msg = enabled_threw && + enabled_err.find("declared by both") != std::string::npos; + + // Mixed: one enabled, one disabled with same prefix. Should NOT + // collide because the disabled one doesn't enter the registry. + const std::string mixed = R"({ + "upstreams": [{"name":"x","host":"127.0.0.1","port":80}], + "auth": { + "enabled": false, + "issuers": { + "google": { + "issuer_url": "https://issuer.example", + "upstream": "x", + "mode": "jwt", + "algorithms": ["RS256"] + } + }, + "policies": [ + {"name":"a", "enabled":true, "applies_to":["/api/"], "issuers":["google"]}, + {"name":"b", "enabled":false, "applies_to":["/api/"], "issuers":["google"]} + ] + } + })"; + try { + ServerConfig cfg = ConfigLoader::LoadFromString(mixed); + ConfigLoader::Validate(cfg); + // Expected: no throw. + } catch (const std::exception& e) { + throw std::runtime_error( + "mixed enabled/disabled at same prefix should be ACCEPTED " + "but Validate threw: " + std::string(e.what())); + } + + bool pass = enabled_collision_msg; + std::string err; + if (!enabled_threw) { + err = "two ENABLED policies sharing a prefix should still " + "reject (regression check failed)"; + } else if (!enabled_collision_msg) { + err = "enabled-collision threw but with wrong message; expected " + "'declared by both', got: " + enabled_err; + } + TestFramework::RecordTest( + "AuthFoundation: ConfigLoader skips disabled top-level policies in collision", + pass, err, TestFramework::TestCategory::OTHER); + } catch (const std::exception& e) { + TestFramework::RecordTest( + "AuthFoundation: ConfigLoader skips disabled top-level policies in collision", + false, e.what(), TestFramework::TestCategory::OTHER); + } +} + +// ----------------------------------------------------------------------------- +// ConfigLoader::Validate — inline proxy.auth must reject applies_to +// (review P2 #2). The prefix is derived from proxy.route_prefix; an inline +// applies_to is silently ignored at runtime, so a JSON with both would +// describe a different protected path than what's actually enforced. +// ----------------------------------------------------------------------------- +void TestConfigLoaderRejectsInlineAuthAppliesTo() { + std::cout << "\n[TEST] ConfigLoader rejects applies_to inside inline proxy.auth..." << std::endl; + try { + const std::string bad = R"({ + "upstreams": [ + {"name":"x","host":"127.0.0.1","port":80}, + { + "name": "internal-api", + "host": "127.0.0.1", + "port": 8080, + "proxy": { + "route_prefix": "/api/", + "auth": { + "enabled": false, + "applies_to": ["/admin/"], + "issuers": ["google"] + } + } + } + ], + "auth": { + "enabled": false, + "issuers": { + "google": { + "issuer_url": "https://issuer.example", + "upstream": "x", + "mode": "jwt", + "algorithms": ["RS256"] + } + } + } + })"; + bool threw = false; + std::string err_msg; + try { + ServerConfig cfg = ConfigLoader::LoadFromString(bad); + ConfigLoader::Validate(cfg); + } catch (const std::invalid_argument& e) { + threw = true; + err_msg = e.what(); + } + // Message should mention applies_to and route_prefix so operators + // know the prefix actually comes from the surrounding proxy. + bool good_msg = threw && + err_msg.find("applies_to") != std::string::npos && + err_msg.find("route_prefix") != std::string::npos; + bool pass = threw && good_msg; + std::string err; + if (!threw) { + err = "expected inline applies_to to be rejected at parse time " + "but config was accepted"; + } else if (!good_msg) { + err = "threw but message lacked one of {'applies_to', " + "'route_prefix'}; got: " + err_msg; + } + + // Regression: TOP-LEVEL applies_to must STILL be accepted (only + // inline rejects it). Confirms the parser parameter is wired + // correctly per call site. + try { + const std::string ok = R"({ + "upstreams": [{"name":"x","host":"127.0.0.1","port":80}], + "auth": { + "enabled": false, + "issuers": { + "google": { + "issuer_url": "https://issuer.example", + "upstream": "x", + "mode": "jwt", + "algorithms": ["RS256"] + } + }, + "policies": [ + {"name":"p", "enabled":false, "applies_to":["/admin/"], "issuers":["google"]} + ] + } + })"; + ServerConfig c2 = ConfigLoader::LoadFromString(ok); + ConfigLoader::Validate(c2); + } catch (const std::exception& e) { + err = "regression: top-level applies_to should still be accepted " + "but threw: " + std::string(e.what()); + pass = false; + } + + TestFramework::RecordTest( + "AuthFoundation: ConfigLoader rejects applies_to in inline proxy.auth", + pass, err, TestFramework::TestCategory::OTHER); + } catch (const std::exception& e) { + TestFramework::RecordTest( + "AuthFoundation: ConfigLoader rejects applies_to in inline proxy.auth", + false, e.what(), TestFramework::TestCategory::OTHER); + } +} + +// ----------------------------------------------------------------------------- +// ConfigLoader::Validate — Proxy-Connection is reserved (review P3). +// HeaderRewriter::IsHopByHopHeader strips Proxy-Connection at outbound +// time (server/header_rewriter.cc:18); auth.forward must reject it at +// config load to avoid the silently-dropped-identity-header symptom. +// ----------------------------------------------------------------------------- +void TestConfigLoaderRejectsProxyConnectionInAuthForward() { + std::cout << "\n[TEST] ConfigLoader rejects Proxy-Connection in auth.forward..." << std::endl; + auto validate_expect_reserved = [](const std::string& bad_header_field, + const std::string& bad_name) + -> std::string { + std::string json = R"({ + "upstreams": [{"name":"x","host":"127.0.0.1","port":80}], + "auth": { + "enabled": false, + "issuers": { + "google": { + "issuer_url": "https://issuer.example", + "upstream": "x", + "mode": "jwt", + "algorithms": ["RS256"] + } + }, + "forward": {)" + bad_header_field + R"(} + } + })"; + try { + ServerConfig cfg = ConfigLoader::LoadFromString(json); + ConfigLoader::Validate(cfg); + return "expected reserved-name rejection for '" + bad_name + + "' but accepted"; + } catch (const std::invalid_argument& e) { + std::string msg = e.what(); + if (msg.find("reserved") == std::string::npos || + msg.find(bad_name) == std::string::npos) { + return "threw but message missing 'reserved' or offending " + "name '" + bad_name + "'; got: " + msg; + } + return ""; + } catch (const std::exception& e) { + return std::string("unexpected exception type: ") + e.what(); + } + }; + + try { + // Test all three fixed-slot positions. + std::string err = validate_expect_reserved( + R"("subject_header": "Proxy-Connection")", "Proxy-Connection"); + if (!err.empty()) throw std::runtime_error("subject_header: " + err); + + err = validate_expect_reserved( + R"("raw_jwt_header": "Proxy-Connection")", "Proxy-Connection"); + if (!err.empty()) throw std::runtime_error("raw_jwt_header: " + err); + + err = validate_expect_reserved( + R"("claims_to_headers": {"sub": "Proxy-Connection"})", + "Proxy-Connection"); + if (!err.empty()) throw std::runtime_error("claims_to_headers: " + err); + + // Case-insensitive: lowercase variant rejected too. + err = validate_expect_reserved( + R"("subject_header": "proxy-connection")", "proxy-connection"); + if (!err.empty()) throw std::runtime_error("lowercase: " + err); + + TestFramework::RecordTest( + "AuthFoundation: ConfigLoader rejects Proxy-Connection in auth.forward", + true, "", TestFramework::TestCategory::OTHER); + } catch (const std::exception& e) { + TestFramework::RecordTest( + "AuthFoundation: ConfigLoader rejects Proxy-Connection in auth.forward", + false, e.what(), TestFramework::TestCategory::OTHER); + } +} + inline void RunAllTests() { std::cout << "\n===== Auth Foundation Tests =====" << std::endl; TestHasherBasicDeterminism(); @@ -1619,6 +1916,9 @@ inline void RunAllTests() { TestPopulateFromPayloadClearsStaleFields(); TestConfigLoaderValidatesDisabledInlineAuth(); TestConfigLoaderRoundTripsAuthOnlyProxy(); + TestConfigLoaderDisabledTopLevelPoliciesDoNotCollide(); + TestConfigLoaderRejectsInlineAuthAppliesTo(); + TestConfigLoaderRejectsProxyConnectionInAuthForward(); TestConfigLoaderClaimHeaderCollision(); } From 3c783edb5b209227b2bcdc60f822720ab2cd0e5c Mon Sep 17 00:00:00 2001 From: mwfj Date: Fri, 17 Apr 2026 15:32:36 +0800 Subject: [PATCH 12/17] Fix review comment --- server/config_loader.cc | 63 ++++++++++ test/auth_foundation_test.h | 228 ++++++++++++++++++++++++++++++++++++ 2 files changed, 291 insertions(+) diff --git a/server/config_loader.cc b/server/config_loader.cc index c6705ef5..94b1ab8f 100644 --- a/server/config_loader.cc +++ b/server/config_loader.cc @@ -1733,6 +1733,55 @@ void ConfigLoader::Validate(const ServerConfig& config) { ctx + ".introspection.endpoint is required for " "mode=\"introspection\""); } + // auth_style — RFC 7662 doesn't standardize the credential + // delivery channel; we support two: "basic" (Authorization + // header) and "body" (urlencoded form). Anything else + // would silently choose one at request time, which is + // worse than a load-time reject. + const auto& is = ic.introspection; + if (is.auth_style != "basic" && is.auth_style != "body") { + throw std::invalid_argument( + ctx + ".introspection.auth_style must be \"basic\" " + "or \"body\" (got \"" + is.auth_style + "\")"); + } + // Numeric ranges. Strict-positive for fields where 0 makes + // no sense (a 0-second timeout cannot complete an HTTP + // request; a 0-entry cache is a contradiction; a 0-shard + // map cannot be indexed). Non-negative for fields where 0 + // means "feature off" — negative caching and stale-grace + // both have meaningful 0-disables-feature semantics. + if (is.timeout_sec <= 0) { + throw std::invalid_argument( + ctx + ".introspection.timeout_sec must be > 0 (got " + + std::to_string(is.timeout_sec) + ")"); + } + if (is.cache_sec <= 0) { + throw std::invalid_argument( + ctx + ".introspection.cache_sec must be > 0 (got " + + std::to_string(is.cache_sec) + ")"); + } + if (is.negative_cache_sec < 0) { + throw std::invalid_argument( + ctx + ".introspection.negative_cache_sec must be >= 0 " + "(0 = disable negative caching) (got " + + std::to_string(is.negative_cache_sec) + ")"); + } + if (is.stale_grace_sec < 0) { + throw std::invalid_argument( + ctx + ".introspection.stale_grace_sec must be >= 0 " + "(0 = disable stale serving) (got " + + std::to_string(is.stale_grace_sec) + ")"); + } + if (is.max_entries <= 0) { + throw std::invalid_argument( + ctx + ".introspection.max_entries must be > 0 (got " + + std::to_string(is.max_entries) + ")"); + } + if (is.shards <= 0) { + throw std::invalid_argument( + ctx + ".introspection.shards must be > 0 (got " + + std::to_string(is.shards) + ")"); + } } // TLS-mandatory on actual outbound IdP endpoints, not just on @@ -1802,6 +1851,20 @@ void ConfigLoader::Validate(const ServerConfig& config) { issuer_name + "'"); } } + // applies_to is the prefix list that drives runtime matching; + // an enabled policy without it never matches any path → silent + // dead policy → routes the operator INTENDED to protect are + // left wide open at runtime. Reject loudly. Disabled policies + // are allowed to have empty applies_to (mid-construction state + // during the rollout — operator may be filling fields in + // increments before flipping enabled). + if (p.enabled && p.applies_to.empty()) { + throw std::invalid_argument( + ctx + " is enabled but has no applies_to prefixes — " + "the policy would never match any path. Add at least " + "one prefix to applies_to, or set enabled=false until " + "the prefix list is ready."); + } } // Inline proxy.auth validation + exact-prefix collision detection. diff --git a/test/auth_foundation_test.h b/test/auth_foundation_test.h index ebd1c82a..e9978fa7 100644 --- a/test/auth_foundation_test.h +++ b/test/auth_foundation_test.h @@ -1897,6 +1897,232 @@ void TestConfigLoaderRejectsProxyConnectionInAuthForward() { } } +// ----------------------------------------------------------------------------- +// ConfigLoader::Validate — enabled top-level policy must declare applies_to +// (review P2 #1). A policy with enabled=true and no prefixes never matches +// any path; the operator's intended-protected routes silently stay open. +// Disabled policies allowed to be empty (mid-construction state during +// rollout — same logic that lets us skip them in collision detection). +// ----------------------------------------------------------------------------- +void TestConfigLoaderRejectsEnabledPolicyWithoutAppliesTo() { + std::cout << "\n[TEST] ConfigLoader rejects enabled policy without applies_to..." << std::endl; + try { + // Case 1: enabled + empty applies_to → reject. + const std::string bad = R"({ + "upstreams": [{"name":"x","host":"127.0.0.1","port":80}], + "auth": { + "enabled": false, + "issuers": { + "google": { + "issuer_url": "https://issuer.example", + "upstream": "x", + "mode": "jwt", + "algorithms": ["RS256"] + } + }, + "policies": [ + {"name":"dead", "enabled":true, "issuers":["google"]} + ] + } + })"; + bool threw = false; + std::string err_msg; + try { + ServerConfig cfg = ConfigLoader::LoadFromString(bad); + ConfigLoader::Validate(cfg); + } catch (const std::invalid_argument& e) { + threw = true; + err_msg = e.what(); + } + bool good_msg = threw && + err_msg.find("applies_to") != std::string::npos && + err_msg.find("never match") != std::string::npos; + + // Case 2: disabled + empty applies_to → ACCEPT (mid-construction). + const std::string ok_disabled = R"({ + "upstreams": [{"name":"x","host":"127.0.0.1","port":80}], + "auth": { + "enabled": false, + "issuers": { + "google": { + "issuer_url": "https://issuer.example", + "upstream": "x", + "mode": "jwt", + "algorithms": ["RS256"] + } + }, + "policies": [ + {"name":"staged", "enabled":false, "issuers":["google"]} + ] + } + })"; + bool disabled_passed = true; + try { + ServerConfig cfg2 = ConfigLoader::LoadFromString(ok_disabled); + ConfigLoader::Validate(cfg2); + } catch (const std::exception& e) { + disabled_passed = false; + err_msg = std::string("disabled+empty rejected when it shouldn't: ") + e.what(); + } + + bool pass = threw && good_msg && disabled_passed; + std::string err; + if (!threw) { + err = "expected enabled+empty applies_to to reject, but accepted"; + } else if (!good_msg) { + err = "rejected but message missing 'applies_to' / 'never match'; got: " + err_msg; + } else if (!disabled_passed) { + err = err_msg; + } + TestFramework::RecordTest( + "AuthFoundation: ConfigLoader rejects enabled policy without applies_to", + pass, err, TestFramework::TestCategory::OTHER); + } catch (const std::exception& e) { + TestFramework::RecordTest( + "AuthFoundation: ConfigLoader rejects enabled policy without applies_to", + false, e.what(), TestFramework::TestCategory::OTHER); + } +} + +// ----------------------------------------------------------------------------- +// ConfigLoader::Validate — introspection knobs (review P2 #2). auth_style +// must be "basic"|"body"; timeout/cache/max_entries/shards must be > 0; +// negative_cache_sec / stale_grace_sec must be >= 0 (0 disables feature). +// ----------------------------------------------------------------------------- +void TestConfigLoaderValidatesIntrospectionKnobs() { + std::cout << "\n[TEST] ConfigLoader validates introspection knobs..." << std::endl; + auto introspection_with = [](const std::string& fields) -> std::string { + return R"({ + "upstreams": [{"name":"x","host":"127.0.0.1","port":80}], + "auth": { + "enabled": false, + "issuers": { + "ours": { + "issuer_url": "https://issuer.example", + "upstream": "x", + "mode": "introspection", + "introspection": { + "endpoint": "https://issuer.example/introspect")" + + fields + R"( + } + } + } + } + })"; + }; + auto validate_expect_failure = [&](const std::string& json, + const std::string& expected_phrase) + -> std::string { + try { + ServerConfig cfg = ConfigLoader::LoadFromString(json); + ConfigLoader::Validate(cfg); + return "expected throw containing '" + expected_phrase + + "' but accepted"; + } catch (const std::invalid_argument& e) { + std::string msg = e.what(); + if (msg.find(expected_phrase) == std::string::npos) { + return "threw but message missing '" + expected_phrase + + "'; got: " + msg; + } + return ""; + } catch (const std::exception& e) { + return std::string("unexpected exception type: ") + e.what(); + } + }; + + try { + // 1. auth_style="weird" rejected. + std::string err = validate_expect_failure( + introspection_with(R"(, "auth_style": "weird")"), + "auth_style must be"); + if (!err.empty()) throw std::runtime_error("auth_style: " + err); + + // 2. timeout_sec=-1 rejected. + err = validate_expect_failure( + introspection_with(R"(, "timeout_sec": -1)"), + "timeout_sec must be > 0"); + if (!err.empty()) throw std::runtime_error("timeout_sec=-1: " + err); + + // 3. timeout_sec=0 also rejected (strict positive). + err = validate_expect_failure( + introspection_with(R"(, "timeout_sec": 0)"), + "timeout_sec must be > 0"); + if (!err.empty()) throw std::runtime_error("timeout_sec=0: " + err); + + // 4. cache_sec=0 rejected. + err = validate_expect_failure( + introspection_with(R"(, "cache_sec": 0)"), + "cache_sec must be > 0"); + if (!err.empty()) throw std::runtime_error("cache_sec=0: " + err); + + // 5. shards=0 rejected. + err = validate_expect_failure( + introspection_with(R"(, "shards": 0)"), + "shards must be > 0"); + if (!err.empty()) throw std::runtime_error("shards=0: " + err); + + // 6. max_entries=0 rejected. + err = validate_expect_failure( + introspection_with(R"(, "max_entries": 0)"), + "max_entries must be > 0"); + if (!err.empty()) throw std::runtime_error("max_entries=0: " + err); + + // 7. negative_cache_sec=-1 rejected. + err = validate_expect_failure( + introspection_with(R"(, "negative_cache_sec": -1)"), + "negative_cache_sec must be >= 0"); + if (!err.empty()) throw std::runtime_error("negative_cache_sec=-1: " + err); + + // 8. stale_grace_sec=-5 rejected. + err = validate_expect_failure( + introspection_with(R"(, "stale_grace_sec": -5)"), + "stale_grace_sec must be >= 0"); + if (!err.empty()) throw std::runtime_error("stale_grace_sec=-5: " + err); + + // 9. POSITIVE: negative_cache_sec=0 accepted (means "disable + // negative caching" — meaningful 0 semantics, not an error). + try { + ServerConfig cfg = ConfigLoader::LoadFromString( + introspection_with(R"(, "negative_cache_sec": 0)")); + ConfigLoader::Validate(cfg); + } catch (const std::exception& e) { + throw std::runtime_error( + std::string("negative_cache_sec=0 should be ACCEPTED ") + + "(disables feature) but rejected: " + e.what()); + } + + // 10. POSITIVE: stale_grace_sec=0 accepted (disable stale serving). + try { + ServerConfig cfg = ConfigLoader::LoadFromString( + introspection_with(R"(, "stale_grace_sec": 0)")); + ConfigLoader::Validate(cfg); + } catch (const std::exception& e) { + throw std::runtime_error( + std::string("stale_grace_sec=0 should be ACCEPTED ") + + "but rejected: " + e.what()); + } + + // 11. POSITIVE: auth_style="body" accepted. + try { + ServerConfig cfg = ConfigLoader::LoadFromString( + introspection_with(R"(, "auth_style": "body")")); + ConfigLoader::Validate(cfg); + } catch (const std::exception& e) { + throw std::runtime_error( + std::string("auth_style=body should be ACCEPTED but ") + + "rejected: " + e.what()); + } + + TestFramework::RecordTest( + "AuthFoundation: ConfigLoader validates introspection knobs", + true, "", TestFramework::TestCategory::OTHER); + } catch (const std::exception& e) { + TestFramework::RecordTest( + "AuthFoundation: ConfigLoader validates introspection knobs", + false, e.what(), TestFramework::TestCategory::OTHER); + } +} + inline void RunAllTests() { std::cout << "\n===== Auth Foundation Tests =====" << std::endl; TestHasherBasicDeterminism(); @@ -1919,6 +2145,8 @@ inline void RunAllTests() { TestConfigLoaderDisabledTopLevelPoliciesDoNotCollide(); TestConfigLoaderRejectsInlineAuthAppliesTo(); TestConfigLoaderRejectsProxyConnectionInAuthForward(); + TestConfigLoaderRejectsEnabledPolicyWithoutAppliesTo(); + TestConfigLoaderValidatesIntrospectionKnobs(); TestConfigLoaderClaimHeaderCollision(); } From cdeb91d77c6c03d81e31725a81ae74c39f531229 Mon Sep 17 00:00:00 2001 From: mwfj Date: Fri, 17 Apr 2026 17:06:55 +0800 Subject: [PATCH 13/17] Fix review comment --- server/config_loader.cc | 161 ++++++++++++++-- test/auth_foundation_test.h | 370 +++++++++++++++++++++++++++++++++++- 2 files changed, 510 insertions(+), 21 deletions(-) diff --git a/server/config_loader.cc b/server/config_loader.cc index 94b1ab8f..d3ecd589 100644 --- a/server/config_loader.cc +++ b/server/config_loader.cc @@ -98,8 +98,48 @@ static int ParseStrictInt(const nlohmann::json& j, const std::string& key, // receives a forged identity. // // Match is case-insensitive. Caller passes already-lowercased name. +// RFC 7230 §3.2.6 field-name validator (the `token` production). +// +// HTTP header names are a strict subset of printable ASCII — the "tchar" +// rule excludes whitespace, control chars, slashes, parens, angle +// brackets, colons, at-signs, commas, quote chars, curly braces, and more. +// HttpRequestSerializer writes the configured name verbatim into the +// upstream request, so if we accept a malformed name here (e.g. "X Bad" +// with a space, or "X/Bad" with a slash) every forwarded request becomes +// malformed HTTP as soon as auth enforcement lands — a deployment- +// stopping bug masquerading as a config typo. +// +// Valid tchar: A-Z, a-z, 0-9, and the punctuation set +// ! # $ % & ' * + - . ^ _ ` | ~ +// Everything else (including `:`, which is separately caught by the +// pseudo-header reserved-name check, and space / slash / paren / etc.) +// is rejected at config load. +static bool IsValidHttpFieldName(const std::string& name) { + if (name.empty()) return false; + for (char c : name) { + unsigned char uc = static_cast(c); + // DIGIT / ALPHA + if ((uc >= '0' && uc <= '9') || + (uc >= 'A' && uc <= 'Z') || + (uc >= 'a' && uc <= 'z')) continue; + // tchar punctuation (RFC 7230 §3.2.6 exact list) + switch (uc) { + case '!': case '#': case '$': case '%': case '&': + case '\'': case '*': case '+': case '-': case '.': + case '^': case '_': case '`': case '|': case '~': + continue; + default: + return false; + } + } + return true; +} + static bool IsReservedAuthForwardHeader(const std::string& lower) { if (!lower.empty() && lower[0] == ':') return true; // HTTP/2 pseudo + // Validates RFC 7230 §3.2.6 token rules — applied separately and earlier + // in the add_header lambda (on the ORIGINAL case-preserved name). See + // IsValidHttpFieldName for details. static const std::unordered_set kReserved = { // Hop-by-hop per RFC 7230 §6.1 "connection", "keep-alive", "proxy-authenticate", @@ -1673,30 +1713,49 @@ void ConfigLoader::Validate(const ServerConfig& config) { "HS*/none/PS*/auto are deferred per design spec §15)"); } } - // Referenced upstream must exist (for outbound IdP calls). + // Referenced upstream is mandatory. An issuer without a bound + // UpstreamHostPool has no way to talk to the IdP in Phase 2 — + // JWKS refresh, OIDC discovery, and RFC 7662 introspection all + // route through UpstreamManager. Reject at config load so the + // misconfig surfaces here instead of at first request. + // + // This is a STRUCTURAL check (is the field set at all?) and so + // fires unconditionally — independent of the cross-reference + // check below. + if (ic.upstream.empty()) { + throw std::invalid_argument( + ctx + ".upstream is required — each issuer must bind " + "to an existing UpstreamHostPool so JWKS / discovery / " + "introspection traffic has a configured outbound path " + "(declare the IdP as an entry in `upstreams[]`, then " + "set `auth.issuers." + name + ".upstream` to its name)"); + } + + // Cross-reference check: does the named upstream actually + // exist in this config? // - // Reload-safe: HttpServer::Reload calls ConfigLoader::Validate on - // a copy whose upstreams[] has been deliberately stripped (see - // server/http_server.cc:3601 — `validation_copy.upstreams.clear()`) - // because upstream topology is restart-only and the reload path - // intentionally re-validates only the live-reloadable bits. If we - // ran the cross-reference check unconditionally, ANY hot reload - // of a config that has populated auth.issuers — even an - // entirely auth-unrelated reload (e.g. a rate-limit edit) — - // would fail with "unknown issuer upstream" because the source - // map is empty. That would block the forward-compatible auth - // schema the final enforcement-not-yet-wired gate intentionally - // permits. + // Reload-safe: HttpServer::Reload calls ConfigLoader::Validate + // on a copy whose upstreams[] has been deliberately stripped + // (see server/http_server.cc:3601 — + // `validation_copy.upstreams.clear()`) because upstream topology + // is restart-only and the reload path intentionally re-validates + // only the live-reloadable bits. If we ran the cross-reference + // check unconditionally, ANY hot reload of a config that has + // populated auth.issuers — even an entirely auth-unrelated + // reload (e.g. a rate-limit edit) — would fail with "unknown + // issuer upstream" because the source map is empty. That would + // block the forward-compatible auth schema the final + // enforcement-not-yet-wired gate intentionally permits. // // Skip the cross-ref when upstream_names is empty (i.e. we're // running in a stripped reload context). The startup path // always passes the full upstreams list, so the typo-catching - // value of this check is preserved there. When upstreams is - // truly empty at startup (a no-proxies gateway), the issuer's - // upstream reference is unverifiable here anyway — Phase 2's - // AuthManager::Start will surface it at first IdP outbound - // attempt, which is acceptable for that uncommon case. - if (!ic.upstream.empty() && !upstream_names.empty() && + // value of this check is preserved there. (Note: the structural + // "upstream must be non-empty" check above still fires on + // reload paths — that catches configs that were outright + // missing the field; only the existence cross-check is + // context-sensitive.) + if (!upstream_names.empty() && upstream_names.count(ic.upstream) == 0) { throw std::invalid_argument( ctx + ".upstream references unknown upstream '" + @@ -1934,6 +1993,55 @@ void ConfigLoader::Validate(const ServerConfig& config) { "non-empty proxy.route_prefix to derive applies_to"); } + // route_prefix must be a LITERAL byte prefix when inline auth + // is populated. auth::FindPolicyForPath does literal-prefix + // matching (design spec §3.2) — it has no understanding of the + // route_trie's :param / *splat syntax. A proxy with + // `/api/:version/users/*path` routes real requests like + // `/api/v1/users/123` via the trie just fine, but the AUTH + // overlay would try to match the literal string + // `/api/:version/users/*path` as a prefix of `/api/v1/...` and + // never succeed. That would silently leave the proxy + // unprotected as soon as enforcement lands. + // + // Reject patterned route_prefixes here and point operators at + // the alternative: top-level auth.policies[] with applies_to + // listing the literal prefix(es) the pattern expands through. + // Reuse ParsePattern so the pattern-detection rules stay + // consistent with how the route_trie itself interprets them. + if (inline_auth_populated && !u.proxy.route_prefix.empty()) { + std::vector segs; + try { + segs = ROUTE_TRIE::ParsePattern(u.proxy.route_prefix); + } catch (const std::exception& e) { + // ParsePattern may throw on malformed input. The + // proxy-routes pass already validates pattern syntax, + // so by the time we get here the string should parse. + // If it doesn't, surface it with auth context so the + // operator knows the auth check was the one that tripped. + throw std::invalid_argument( + ctx + ": route_prefix '" + u.proxy.route_prefix + + "' failed to parse as a route pattern: " + e.what()); + } + for (const auto& s : segs) { + if (s.type != ROUTE_TRIE::NodeType::STATIC) { + throw std::invalid_argument( + ctx + ": inline auth requires a LITERAL prefix " + "in proxy.route_prefix (got '" + + u.proxy.route_prefix + "' which contains a " + + (s.type == ROUTE_TRIE::NodeType::PARAM + ? "':" + s.param_name + "' param" + : "'*" + s.param_name + "' catch-all") + + " segment). The auth matcher does byte-prefix " + "matching only (design spec §3.2). If you need " + "to protect a patterned route, use top-level " + "auth.policies[] with applies_to listing the " + "literal prefix(es) the pattern expands through " + "(e.g. ['/api/'])."); + } + } + } + // ---- Collision detection: ENABLED-only ---- // // Per spec §3.2, only enabled inline policies participate in @@ -2006,6 +2114,21 @@ void ConfigLoader::Validate(const ServerConfig& config) { auto add_header = [&output_headers](const std::string& name, const std::string& which) { if (name.empty()) return; + // Validate the ORIGINAL case-preserved name against RFC + // 7230 §3.2.6 tchar. An invalid name would pass through + // HttpRequestSerializer verbatim and produce malformed + // HTTP on every forwarded request. Check first — a + // malformed name CAN'T meaningfully be reserved or + // non-reserved, so field-name validity is more + // fundamental than the reserved/duplicate checks below. + if (!IsValidHttpFieldName(name)) { + throw std::invalid_argument( + "auth.forward." + which + " '" + name + + "' contains characters not valid in an HTTP field " + "name (RFC 7230 §3.2.6 `token`: A-Z a-z 0-9 and " + "!#$%&'*+-.^_`|~). Spaces, slashes, colons, and " + "other punctuation are forbidden."); + } std::string lower; lower.reserve(name.size()); for (char c : name) { diff --git a/test/auth_foundation_test.h b/test/auth_foundation_test.h index e9978fa7..8fa52e78 100644 --- a/test/auth_foundation_test.h +++ b/test/auth_foundation_test.h @@ -1202,8 +1202,21 @@ void TestConfigLoaderRejectsReservedForwardHeaders() { "' but accepted"; } catch (const std::invalid_argument& e) { std::string msg = e.what(); - if (msg.find("reserved") == std::string::npos) { - return "threw but message lacked 'reserved'; got: " + msg; + // Accept EITHER rejection path: + // - "reserved" — the name is syntactically valid tchar but + // in the reserved list (Connection, Host, etc.) + // - "not valid in an HTTP field name" — the name contains + // non-tchar characters (e.g. ':path' has a colon) + // Both are valid reasons to reject; pseudo-headers like :path + // are syntactically invalid AND reserved. The tchar check now + // fires first for pseudo-headers — either message is fine for + // this test as long as the offending name appears. + bool has_rejection_phrase = + msg.find("reserved") != std::string::npos || + msg.find("not valid in an HTTP field name") != std::string::npos; + if (!has_rejection_phrase) { + return "threw but message lacked both 'reserved' and " + "'not valid in an HTTP field name'; got: " + msg; } if (msg.find(bad_header_name) == std::string::npos) { return "threw but message lacked offending name '" + @@ -1222,6 +1235,10 @@ void TestConfigLoaderRejectsReservedForwardHeaders() { if (!err.empty()) throw std::runtime_error("Connection: " + err); // Case 2: HTTP/2 pseudo-header in raw_jwt_header. + // Note: `:path` fails the tchar check first (colon isn't a valid + // tchar char) — the reserved check is secondary. Either rejection + // path is accepted by the helper above; the test still pins the + // "pseudo-headers cannot appear in auth.forward outputs" intent. err = validate_expect_reserved( R"("raw_jwt_header": ":path")", ":path"); if (!err.empty()) throw std::runtime_error(":path: " + err); @@ -2123,6 +2140,352 @@ void TestConfigLoaderValidatesIntrospectionKnobs() { } } +// ----------------------------------------------------------------------------- +// ConfigLoader::Validate — inline auth rejects patterned route_prefix +// (review P1). auth::FindPolicyForPath does byte-prefix matching; a proxy +// with /api/:v/users/*path cannot be matched via the auth overlay because +// the literal string never appears in real request paths. Reject at load. +// ----------------------------------------------------------------------------- +void TestConfigLoaderRejectsPatternedInlineAuthPrefix() { + std::cout << "\n[TEST] ConfigLoader rejects patterned route_prefix in inline auth..." << std::endl; + auto validate_expect_failure = [](const std::string& json, + const std::string& expected_phrase) + -> std::string { + try { + ServerConfig cfg = ConfigLoader::LoadFromString(json); + ConfigLoader::Validate(cfg); + return "expected throw containing '" + expected_phrase + + "' but accepted"; + } catch (const std::invalid_argument& e) { + std::string msg = e.what(); + if (msg.find(expected_phrase) == std::string::npos) { + return "threw but message missing '" + expected_phrase + + "'; got: " + msg; + } + return ""; + } catch (const std::exception& e) { + return std::string("unexpected exception type: ") + e.what(); + } + }; + + auto make_json = [](const std::string& route_prefix) -> std::string { + return R"({ + "upstreams": [ + {"name":"x","host":"127.0.0.1","port":80}, + { + "name": "api", + "host": "127.0.0.1", + "port": 8080, + "proxy": { + "route_prefix": ")" + route_prefix + R"(", + "auth": { + "enabled": false, + "issuers": ["google"] + } + } + } + ], + "auth": { + "enabled": false, + "issuers": { + "google": { + "issuer_url": "https://issuer.example", + "upstream": "x", + "mode": "jwt", + "algorithms": ["RS256"] + } + } + } + })"; + }; + + try { + // Case 1: :param segment. + std::string err = validate_expect_failure( + make_json("/api/:version/users"), "LITERAL prefix"); + if (!err.empty()) throw std::runtime_error(":version case: " + err); + + // Case 2: *splat segment. + err = validate_expect_failure( + make_json("/api/*rest"), "LITERAL prefix"); + if (!err.empty()) throw std::runtime_error("*rest case: " + err); + + // Case 3: mixed — both :param and *splat. + err = validate_expect_failure( + make_json("/api/:version/users/*path"), "LITERAL prefix"); + if (!err.empty()) throw std::runtime_error("mixed case: " + err); + + // POSITIVE: pure literal prefix accepted. + try { + ServerConfig cfg = ConfigLoader::LoadFromString( + make_json("/api/v1/")); + ConfigLoader::Validate(cfg); + } catch (const std::exception& e) { + throw std::runtime_error( + std::string("pure literal prefix should be ACCEPTED but ") + + "rejected: " + e.what()); + } + + // POSITIVE: proxy with :param prefix but NO auth block at all — + // should still be accepted (route_trie handles the pattern; auth + // overlay isn't involved). + try { + ServerConfig cfg = ConfigLoader::LoadFromString(R"({ + "upstreams": [ + { + "name": "api", + "host": "127.0.0.1", + "port": 8080, + "proxy": {"route_prefix": "/api/:v/users"} + } + ] + })"); + ConfigLoader::Validate(cfg); + } catch (const std::exception& e) { + throw std::runtime_error( + std::string("patterned route WITHOUT inline auth should be ") + + "ACCEPTED (auth overlay not involved) but rejected: " + e.what()); + } + + TestFramework::RecordTest( + "AuthFoundation: ConfigLoader rejects patterned route_prefix in inline auth", + true, "", TestFramework::TestCategory::OTHER); + } catch (const std::exception& e) { + TestFramework::RecordTest( + "AuthFoundation: ConfigLoader rejects patterned route_prefix in inline auth", + false, e.what(), TestFramework::TestCategory::OTHER); + } +} + +// ----------------------------------------------------------------------------- +// ConfigLoader::Validate — issuer.upstream is structurally required +// (review P2 #1). Separate the "field present" check (fires always) from +// the "cross-ref to existing upstream" check (reload-safe-skipped when +// upstreams is empty). +// ----------------------------------------------------------------------------- +void TestConfigLoaderRequiresIssuerUpstream() { + std::cout << "\n[TEST] ConfigLoader requires issuer.upstream..." << std::endl; + auto validate_expect_failure = [](const std::string& json, + const std::string& expected_phrase) + -> std::string { + try { + ServerConfig cfg = ConfigLoader::LoadFromString(json); + ConfigLoader::Validate(cfg); + return "expected throw containing '" + expected_phrase + + "' but accepted"; + } catch (const std::invalid_argument& e) { + std::string msg = e.what(); + if (msg.find(expected_phrase) == std::string::npos) { + return "threw but message missing '" + expected_phrase + + "'; got: " + msg; + } + return ""; + } catch (const std::exception& e) { + return std::string("unexpected exception type: ") + e.what(); + } + }; + + try { + // Case 1: issuer omits `upstream` entirely — rejected as structural + // requirement (fires even on reload-path with empty upstreams). + std::string err = validate_expect_failure(R"({ + "upstreams": [{"name":"x","host":"127.0.0.1","port":80}], + "auth": { + "enabled": false, + "issuers": { + "ours": { + "issuer_url": "https://issuer.example", + "mode": "jwt", + "algorithms": ["RS256"] + } + } + } + })", "upstream is required"); + if (!err.empty()) throw std::runtime_error("missing upstream: " + err); + + // Case 2: structural check fires on reload-path shape (empty + // upstreams) too. Before the split, a config with upstream="" + // and empty upstreams[] would silently accept because both + // branches of the combined check were short-circuited. + err = validate_expect_failure(R"({ + "upstreams": [], + "auth": { + "enabled": false, + "issuers": { + "ours": { + "issuer_url": "https://issuer.example", + "mode": "jwt", + "algorithms": ["RS256"] + } + } + } + })", "upstream is required"); + if (!err.empty()) throw std::runtime_error("empty-upstreams reload shape: " + err); + + // Case 3: regression — upstream set but pointing at unknown name, + // with full startup upstreams list, STILL rejected by the + // cross-ref check. + err = validate_expect_failure(R"({ + "upstreams": [{"name":"x","host":"127.0.0.1","port":80}], + "auth": { + "enabled": false, + "issuers": { + "ours": { + "issuer_url": "https://issuer.example", + "upstream": "nonexistent", + "mode": "jwt", + "algorithms": ["RS256"] + } + } + } + })", "references unknown upstream"); + if (!err.empty()) throw std::runtime_error("unknown upstream at startup: " + err); + + // Case 4: regression — reload-safe path still skips cross-ref + // when upstreams is empty (avoids blocking unrelated hot reloads). + // Upstream IS set here; upstreams list is empty. Per the + // reload-safety contract, this is accepted (the structural check + // passed because upstream is non-empty; the cross-ref is skipped). + try { + ServerConfig cfg = ConfigLoader::LoadFromString(R"({ + "upstreams": [], + "auth": { + "enabled": false, + "issuers": { + "ours": { + "issuer_url": "https://issuer.example", + "upstream": "somewhere", + "mode": "jwt", + "algorithms": ["RS256"] + } + } + } + })"); + ConfigLoader::Validate(cfg); + } catch (const std::exception& e) { + throw std::runtime_error( + std::string("reload-shape (empty upstreams, upstream set) ") + + "should pass cross-ref check but threw: " + e.what()); + } + + TestFramework::RecordTest( + "AuthFoundation: ConfigLoader requires issuer.upstream", + true, "", TestFramework::TestCategory::OTHER); + } catch (const std::exception& e) { + TestFramework::RecordTest( + "AuthFoundation: ConfigLoader requires issuer.upstream", + false, e.what(), TestFramework::TestCategory::OTHER); + } +} + +// ----------------------------------------------------------------------------- +// ConfigLoader::Validate — header names must be RFC 7230 §3.2.6 tchar +// (review P2 #2). Space, slash, paren, and other non-tchar characters +// would produce malformed HTTP on the forwarded request. +// ----------------------------------------------------------------------------- +void TestConfigLoaderValidatesHeaderNameTchar() { + std::cout << "\n[TEST] ConfigLoader validates header-name tchar..." << std::endl; + auto validate_expect_tchar_reject = [](const std::string& field_fragment, + const std::string& offending_name) + -> std::string { + std::string json = R"({ + "upstreams": [{"name":"x","host":"127.0.0.1","port":80}], + "auth": { + "enabled": false, + "issuers": { + "ours": { + "issuer_url": "https://issuer.example", + "upstream": "x", + "mode": "jwt", + "algorithms": ["RS256"] + } + }, + "forward": {)" + field_fragment + R"(} + } + })"; + try { + ServerConfig cfg = ConfigLoader::LoadFromString(json); + ConfigLoader::Validate(cfg); + return "expected tchar rejection for '" + offending_name + + "' but accepted"; + } catch (const std::invalid_argument& e) { + std::string msg = e.what(); + if (msg.find("not valid in an HTTP field name") == std::string::npos) { + return "threw but message lacked 'not valid in an HTTP " + "field name'; got: " + msg; + } + if (msg.find(offending_name) == std::string::npos) { + return "threw but message lacked offending name '" + + offending_name + "'; got: " + msg; + } + return ""; + } catch (const std::exception& e) { + return std::string("unexpected exception type: ") + e.what(); + } + }; + + try { + // Case 1: space in subject_header — most common real-world typo. + std::string err = validate_expect_tchar_reject( + R"("subject_header": "X Bad")", "X Bad"); + if (!err.empty()) throw std::runtime_error("space: " + err); + + // Case 2: slash (common mistake from URL paths). + err = validate_expect_tchar_reject( + R"("raw_jwt_header": "X/Bad")", "X/Bad"); + if (!err.empty()) throw std::runtime_error("slash: " + err); + + // Case 3: paren (commonly copied from function names in config). + // Use a non-default raw-string delimiter `raw(...)raw` because the + // default `(...)` terminates at the first `)"` — which appears + // inside the literal header name `"X(Bad)"`. + err = validate_expect_tchar_reject( + R"raw("claims_to_headers": {"sub": "X(Bad)"})raw", "X(Bad)"); + if (!err.empty()) throw std::runtime_error("paren: " + err); + + // Case 4: at-sign. + err = validate_expect_tchar_reject( + R"("issuer_header": "X@Bad")", "X@Bad"); + if (!err.empty()) throw std::runtime_error("at-sign: " + err); + + // POSITIVE: valid tchar punctuation should be accepted. + // RFC 7230 allows !#$%&'*+-.^_`|~ in tchar. + try { + ServerConfig cfg = ConfigLoader::LoadFromString(R"({ + "upstreams": [{"name":"x","host":"127.0.0.1","port":80}], + "auth": { + "enabled": false, + "issuers": { + "ours": { + "issuer_url": "https://issuer.example", + "upstream": "x", + "mode": "jwt", + "algorithms": ["RS256"] + } + }, + "forward": { + "subject_header": "X-Auth-Subject", + "claims_to_headers": {"email": "X-User.Email_v1"} + } + } + })"); + ConfigLoader::Validate(cfg); + } catch (const std::exception& e) { + throw std::runtime_error( + std::string("valid tchar names should be ACCEPTED but ") + + "rejected: " + e.what()); + } + + TestFramework::RecordTest( + "AuthFoundation: ConfigLoader validates header-name tchar", + true, "", TestFramework::TestCategory::OTHER); + } catch (const std::exception& e) { + TestFramework::RecordTest( + "AuthFoundation: ConfigLoader validates header-name tchar", + false, e.what(), TestFramework::TestCategory::OTHER); + } +} + inline void RunAllTests() { std::cout << "\n===== Auth Foundation Tests =====" << std::endl; TestHasherBasicDeterminism(); @@ -2147,6 +2510,9 @@ inline void RunAllTests() { TestConfigLoaderRejectsProxyConnectionInAuthForward(); TestConfigLoaderRejectsEnabledPolicyWithoutAppliesTo(); TestConfigLoaderValidatesIntrospectionKnobs(); + TestConfigLoaderRejectsPatternedInlineAuthPrefix(); + TestConfigLoaderRequiresIssuerUpstream(); + TestConfigLoaderValidatesHeaderNameTchar(); TestConfigLoaderClaimHeaderCollision(); } From 94fd47db7832ff66be1ba6a860ff6a7bd6cee49d Mon Sep 17 00:00:00 2001 From: mwfj Date: Fri, 17 Apr 2026 20:10:55 +0800 Subject: [PATCH 14/17] Fix review comment --- include/config/config_loader.h | 18 +++- server/config_loader.cc | 60 ++++++++--- server/http_server.cc | 9 +- test/auth_foundation_test.h | 185 ++++++++++++++++++++++++++++----- 4 files changed, 226 insertions(+), 46 deletions(-) diff --git a/include/config/config_loader.h b/include/config/config_loader.h index 2a76c3b8..83cb2977 100644 --- a/include/config/config_loader.h +++ b/include/config/config_loader.h @@ -26,7 +26,23 @@ class ConfigLoader { // Validate the configuration. // Throws std::invalid_argument if validation fails. - static void Validate(const ServerConfig& config); + // + // `reload_copy` — set to `true` ONLY by the SIGHUP reload path + // (HttpServer::Reload / main.cc::ReloadConfig), which passes a + // ServerConfig with `upstreams` deliberately cleared so that + // topology-restart-only checks don't run against a stripped copy. + // When `true`, checks that cross-reference into `upstreams[]` are + // skipped (the reload path revalidates topology separately via the + // existing `proxy == o.proxy` equality mechanism). + // + // At startup, `reload_copy=false` so ALL cross-reference checks + // fire — including on programmatic-only deployments (empty + // `upstreams[]` is a legitimate startup shape, and typos in + // `auth.issuers.*.upstream` must still surface loudly in that + // context rather than silently accepting references to pools that + // don't exist). + static void Validate(const ServerConfig& config, + bool reload_copy = false); // Validate ONLY the fields that are live-reloadable without a // restart — today this is the per-upstream circuit_breaker block diff --git a/server/config_loader.cc b/server/config_loader.cc index d3ecd589..46d1ea52 100644 --- a/server/config_loader.cc +++ b/server/config_loader.cc @@ -1110,7 +1110,7 @@ void ConfigLoader::ValidateHotReloadable( } } -void ConfigLoader::Validate(const ServerConfig& config) { +void ConfigLoader::Validate(const ServerConfig& config, bool reload_copy) { // Validate bind_host is a strict dotted-quad IPv4 address. // Use inet_pton (not inet_addr) to reject legacy shorthand forms // like "1" (→ 0.0.0.1) or octal "0127.0.0.1" (→ 87.0.0.1). @@ -1739,23 +1739,26 @@ void ConfigLoader::Validate(const ServerConfig& config) { // (see server/http_server.cc:3601 — // `validation_copy.upstreams.clear()`) because upstream topology // is restart-only and the reload path intentionally re-validates - // only the live-reloadable bits. If we ran the cross-reference - // check unconditionally, ANY hot reload of a config that has - // populated auth.issuers — even an entirely auth-unrelated - // reload (e.g. a rate-limit edit) — would fail with "unknown - // issuer upstream" because the source map is empty. That would - // block the forward-compatible auth schema the final - // enforcement-not-yet-wired gate intentionally permits. + // only the live-reloadable bits. The caller signals that + // context explicitly via `reload_copy=true`; we skip the + // topology cross-ref in that context. // - // Skip the cross-ref when upstream_names is empty (i.e. we're - // running in a stripped reload context). The startup path - // always passes the full upstreams list, so the typo-catching - // value of this check is preserved there. (Note: the structural - // "upstream must be non-empty" check above still fires on - // reload paths — that catches configs that were outright - // missing the field; only the existence cross-check is - // context-sensitive.) - if (!upstream_names.empty() && + // Startup path (`reload_copy=false`, the default) runs the + // check ALWAYS — including when upstreams[] is genuinely + // empty. An empty upstreams[] is a legitimate startup shape + // (programmatic-only deployment), and in that case an + // issuer.upstream reference still needs to fail loudly + // because no pool exists to host the IdP traffic. Earlier + // iterations of this check used `upstream_names.empty()` as + // an implicit reload sentinel, but that overload let genuine + // startup typos slip through for programmatic-only configs; + // the explicit flag fixes that without re-breaking reload. + // + // (The structural "upstream must be non-empty" check above + // still fires regardless of `reload_copy` — it catches + // configs that were outright missing the field, which is a + // schema error independent of topology context.) + if (!reload_copy && upstream_names.count(ic.upstream) == 0) { throw std::invalid_argument( ctx + ".upstream references unknown upstream '" + @@ -1924,6 +1927,29 @@ void ConfigLoader::Validate(const ServerConfig& config) { "one prefix to applies_to, or set enabled=false until " "the prefix list is ready."); } + + // applies_to entries are consumed ONLY by auth::FindPolicyForPath + // which does literal byte-prefix matching (design spec §3.2). + // No route_trie is involved in this path — the string is taken + // verbatim as the prefix to compare against each request's URL + // path bytes. So applies_to values can legitimately contain + // any printable characters, including ':' and '*' that would + // look like route-trie pattern syntax (e.g. a docs system with + // `/docs/:faq` as a LITERAL URL, or `/assets/*latest` where + // `*latest` is a literal filename prefix). + // + // We deliberately do NOT run ROUTE_TRIE::ParsePattern here: + // there's no second matcher that interprets the pattern, so + // there's no mismatch risk (unlike inline `proxy.auth`, where + // the trie AND the auth matcher both consume the same + // route_prefix and disagree on semantics — that case still + // rejects patterned prefixes). + // + // If an operator writes `/api/:version/` here EXPECTING trie + // semantics, they'll get literal matching only. That's an + // operator-education problem, not a validator problem; the + // matcher's behavior is unambiguous and trying to guess + // intent would incorrectly reject legitimate literal URLs. } // Inline proxy.auth validation + exact-prefix collision detection. diff --git a/server/http_server.cc b/server/http_server.cc index c3590ba0..fbfdf1cb 100644 --- a/server/http_server.cc +++ b/server/http_server.cc @@ -3605,7 +3605,14 @@ bool HttpServer::Reload(const ServerConfig& new_config) { // (rate<=0, invalid key_type, duplicate zone names) must be caught. validation_copy.rate_limit = new_config.rate_limit; try { - ConfigLoader::Validate(validation_copy); + // reload_copy=true — signals the validator that upstreams[] + // has been deliberately stripped above, so topology cross- + // reference checks (e.g. `auth.issuers.*.upstream` pointing + // at a pool name) should be skipped in this context. + // Startup validation passes false (the default), so genuine + // startup configs with no upstreams still get their cross- + // refs checked. See ConfigLoader::Validate docstring. + ConfigLoader::Validate(validation_copy, /*reload_copy=*/true); } catch (const std::invalid_argument& e) { logging::Get()->error("Reload() rejected invalid config: {}", e.what()); return false; diff --git a/test/auth_foundation_test.h b/test/auth_foundation_test.h index 8fa52e78..e3972319 100644 --- a/test/auth_foundation_test.h +++ b/test/auth_foundation_test.h @@ -989,21 +989,24 @@ void TestPopulateFromPayloadOptionalIssSub() { // ----------------------------------------------------------------------------- // ConfigLoader::Validate — issuer.upstream cross-reference is reload-safe -// (review P2 #1). HttpServer::Reload() validates a copy with upstreams[] -// stripped (server/http_server.cc:3601) so the topology-restart-only path -// can be re-validated for live-reloadable bits without dragging restart -// constraints. Before this fix, the issuer.upstream check would fire on -// the empty upstream_names set and reject ANY reload of a config that has -// auth.issuers populated — even an entirely auth-unrelated reload like a -// rate-limit edit. This test pins the relaxation: with upstreams empty, -// the cross-reference check is skipped (typo-catching value preserved at -// startup where upstreams is always full). +// via the EXPLICIT `reload_copy=true` flag (review P2). HttpServer::Reload() +// validates a copy with upstreams[] stripped and now passes +// `reload_copy=true` so topology cross-refs are skipped. Earlier iterations +// used `upstream_names.empty()` as an implicit reload sentinel — that +// overloaded "no upstreams" to mean "reload context", which incorrectly +// skipped checks on legitimate startup configs with programmatic-only +// routes. The explicit flag fixes that. This test pins three behaviors: +// +// 1. reload_copy=true + empty upstreams → cross-ref skipped (reload safe) +// 2. reload_copy=false + full upstreams + missing target → reject +// 3. reload_copy=false + empty upstreams + populated issuer → reject +// (regression catch — programmatic-only startup must still validate) // ----------------------------------------------------------------------------- void TestConfigLoaderUpstreamCrossRefReloadSafe() { std::cout << "\n[TEST] ConfigLoader issuer.upstream cross-ref is reload-safe..." << std::endl; try { - // Simulate the reload-validation context: full auth schema, but - // upstreams[] cleared (mimics HttpServer::Reload's validation_copy). + // Reload-context shape: full auth schema, upstreams[] cleared. + // With reload_copy=true the cross-ref must be skipped. const std::string reload_shape = R"({ "bind_host": "127.0.0.1", "bind_port": 8080, @@ -1025,15 +1028,15 @@ void TestConfigLoaderUpstreamCrossRefReloadSafe() { std::string err_msg; try { ServerConfig cfg = ConfigLoader::LoadFromString(reload_shape); - ConfigLoader::Validate(cfg); + ConfigLoader::Validate(cfg, /*reload_copy=*/true); } catch (const std::exception& e) { threw = true; err_msg = e.what(); } - // Regression check: at STARTUP (full upstreams, no idp_google), the - // check should still fire. This confirms we relaxed only the - // empty-upstreams branch, not the entire check. + // Regression #1: at STARTUP (full upstreams, missing target), + // the cross-ref must still fire. This confirms the flag default + // (false) preserves the typo-catching value of the check. const std::string startup_shape = R"({ "bind_host": "127.0.0.1", "bind_port": 8080, @@ -1054,27 +1057,54 @@ void TestConfigLoaderUpstreamCrossRefReloadSafe() { std::string startup_err; try { ServerConfig cfg2 = ConfigLoader::LoadFromString(startup_shape); - ConfigLoader::Validate(cfg2); + ConfigLoader::Validate(cfg2); // default reload_copy=false } catch (const std::exception& e) { startup_threw = true; startup_err = e.what(); } + // Regression #2 (the new behavior the explicit flag fixes): + // genuine startup config with empty upstreams[] (a programmatic- + // only deployment that uses top-level auth.policies[] to protect + // its handlers) MUST still reject an issuer.upstream pointing + // at a nonexistent pool. Before the flag, this slipped through + // because empty upstreams was the reload sentinel. + bool prog_only_startup_threw = false; + std::string prog_only_err; + try { + ServerConfig cfg3 = ConfigLoader::LoadFromString(reload_shape); + ConfigLoader::Validate(cfg3); // default reload_copy=false + } catch (const std::exception& e) { + prog_only_startup_threw = true; + prog_only_err = e.what(); + } + bool reload_pass = !threw; bool startup_pass = startup_threw && startup_err.find("references unknown upstream") != std::string::npos; + bool prog_only_pass = prog_only_startup_threw && + prog_only_err.find("references unknown upstream") != std::string::npos; - bool pass = reload_pass && startup_pass; + bool pass = reload_pass && startup_pass && prog_only_pass; std::string err; if (!reload_pass) { - err = "reload-shape (empty upstreams) should NOT throw on " - "issuer.upstream cross-ref but did: " + err_msg; + err = "reload-shape (reload_copy=true, empty upstreams) should " + "NOT throw on issuer.upstream cross-ref but did: " + err_msg; } else if (!startup_pass) { err = startup_threw ? "startup-shape threw but with wrong error (expected " "'references unknown upstream'); got: " + startup_err - : "startup-shape (full upstreams, missing idp_google) " - "should still reject the cross-ref but accepted"; + : "startup-shape (reload_copy=false, full upstreams, missing " + "idp_google) should still reject the cross-ref but accepted"; + } else if (!prog_only_pass) { + err = prog_only_startup_threw + ? "programmatic-only startup threw but with wrong error " + "(expected 'references unknown upstream'); got: " + prog_only_err + : "programmatic-only startup (reload_copy=false, empty " + "upstreams, populated issuer.upstream) should reject the " + "cross-ref — this is the gap the explicit reload_copy " + "flag was added to fix. Test failure means the fix has " + "regressed."; } TestFramework::RecordTest( @@ -2342,10 +2372,9 @@ void TestConfigLoaderRequiresIssuerUpstream() { if (!err.empty()) throw std::runtime_error("unknown upstream at startup: " + err); // Case 4: regression — reload-safe path still skips cross-ref - // when upstreams is empty (avoids blocking unrelated hot reloads). - // Upstream IS set here; upstreams list is empty. Per the - // reload-safety contract, this is accepted (the structural check - // passed because upstream is non-empty; the cross-ref is skipped). + // when called with reload_copy=true (avoids blocking unrelated + // hot reloads). Upstream IS set here; upstreams list is empty; + // caller signals reload context explicitly via the flag. try { ServerConfig cfg = ConfigLoader::LoadFromString(R"({ "upstreams": [], @@ -2361,10 +2390,10 @@ void TestConfigLoaderRequiresIssuerUpstream() { } } })"); - ConfigLoader::Validate(cfg); + ConfigLoader::Validate(cfg, /*reload_copy=*/true); } catch (const std::exception& e) { throw std::runtime_error( - std::string("reload-shape (empty upstreams, upstream set) ") + std::string("reload-shape with reload_copy=true ") + "should pass cross-ref check but threw: " + e.what()); } @@ -2486,6 +2515,107 @@ void TestConfigLoaderValidatesHeaderNameTchar() { } } +// ----------------------------------------------------------------------------- +// ConfigLoader::Validate — top-level auth.policies[].applies_to entries +// are LITERAL byte prefixes and may contain any printable characters — +// INCLUDING ':' and '*' that look like route-trie pattern syntax but are +// actually literal path components. An earlier iteration parsed these +// with ROUTE_TRIE::ParsePattern and rejected ':' / '*' segments; that +// was over-strict and blocked legitimate literal URLs like "/docs/:faq" +// or "/assets/*latest". The auth matcher has NO concept of route-trie +// patterns — it just does byte-prefix comparison — so accepting these +// strings is correct. +// +// This test pins the acceptance behavior (regression protection against +// reintroducing the over-strict check). The inline `proxy.auth` +// route_prefix check is SEPARATE and DOES still reject patterned +// prefixes, because inline route_prefix is consumed by TWO matchers +// (route_trie + auth) with different semantics — see +// TestConfigLoaderRejectsPatternedInlineAuthPrefix. +// ----------------------------------------------------------------------------- +void TestConfigLoaderAcceptsLiteralPatternCharsInAppliesTo() { + std::cout << "\n[TEST] ConfigLoader accepts literal :/* chars in top-level applies_to..." << std::endl; + + auto make_json = [](const std::string& applies_to_array, + bool enabled) -> std::string { + return std::string(R"({ + "upstreams": [{"name":"x","host":"127.0.0.1","port":80}], + "auth": { + "enabled": false, + "issuers": { + "google": { + "issuer_url": "https://issuer.example", + "upstream": "x", + "mode": "jwt", + "algorithms": ["RS256"] + } + }, + "policies": [{"name":"p","enabled":)") + + (enabled ? "true" : "false") + R"(,"applies_to":)" + + applies_to_array + R"(,"issuers":["google"]}] + } + })"; + }; + + auto expect_accepted = [&make_json](const std::string& applies_to_array, + const std::string& label, + bool enabled) -> std::string { + try { + ServerConfig cfg = ConfigLoader::LoadFromString( + make_json(applies_to_array, enabled)); + ConfigLoader::Validate(cfg); + return ""; + } catch (const std::exception& e) { + return label + " rejected (should be accepted): " + e.what(); + } + }; + + try { + // Case 1: literal ":faq" segment (e.g. docs/wiki system with + // literal ':' in URL path) — accepted. + std::string err = expect_accepted( + R"(["/docs/:faq"])", "/docs/:faq", /*enabled=*/true); + if (!err.empty()) throw std::runtime_error(err); + + // Case 2: literal "*latest" segment (e.g. asset versioning URL) — + // accepted. + err = expect_accepted( + R"(["/assets/*latest"])", "/assets/*latest", /*enabled=*/true); + if (!err.empty()) throw std::runtime_error(err); + + // Case 3: mixed — pure-literal and pattern-looking entries in + // same policy — all accepted. + err = expect_accepted( + R"(["/api/", "/api/:version/"])", "mixed", /*enabled=*/true); + if (!err.empty()) throw std::runtime_error(err); + + // Case 4: disabled policy with pattern-looking entry also + // accepted (consistency — no reason to reject based on enable + // state when the entry itself is legal). + err = expect_accepted( + R"(["/api/:id"])", "disabled /api/:id", /*enabled=*/false); + if (!err.empty()) throw std::runtime_error(err); + + // Case 5: pure literal entry (the common case) — accepted. + err = expect_accepted( + R"(["/api/v1/"])", "literal /api/v1/", /*enabled=*/true); + if (!err.empty()) throw std::runtime_error(err); + + // Case 6: empty string (catch-all) — accepted per auth_policy_matcher.h. + err = expect_accepted( + R"([""])", "empty catch-all", /*enabled=*/true); + if (!err.empty()) throw std::runtime_error(err); + + TestFramework::RecordTest( + "AuthFoundation: ConfigLoader accepts literal :/* in top-level applies_to", + true, "", TestFramework::TestCategory::OTHER); + } catch (const std::exception& e) { + TestFramework::RecordTest( + "AuthFoundation: ConfigLoader accepts literal :/* in top-level applies_to", + false, e.what(), TestFramework::TestCategory::OTHER); + } +} + inline void RunAllTests() { std::cout << "\n===== Auth Foundation Tests =====" << std::endl; TestHasherBasicDeterminism(); @@ -2513,6 +2643,7 @@ inline void RunAllTests() { TestConfigLoaderRejectsPatternedInlineAuthPrefix(); TestConfigLoaderRequiresIssuerUpstream(); TestConfigLoaderValidatesHeaderNameTchar(); + TestConfigLoaderAcceptsLiteralPatternCharsInAppliesTo(); TestConfigLoaderClaimHeaderCollision(); } From d49eefc652fc077c82eba6cfc92829c6ede96e7e Mon Sep 17 00:00:00 2001 From: mwfj Date: Fri, 17 Apr 2026 21:24:09 +0800 Subject: [PATCH 15/17] Fix review comment --- include/config/config_loader.h | 38 +++++ server/config_loader.cc | 84 +++++++++++ server/http_server.cc | 20 +++ test/auth_foundation_test.h | 254 +++++++++++++++++++++++++++++++++ 4 files changed, 396 insertions(+) diff --git a/include/config/config_loader.h b/include/config/config_loader.h index 83cb2977..8293813e 100644 --- a/include/config/config_loader.h +++ b/include/config/config_loader.h @@ -80,6 +80,44 @@ class ConfigLoader { const ServerConfig& config, const std::unordered_set& live_upstream_names); + // Validate inline per-proxy auth blocks (structural checks + + // enforcement-not-yet-wired gate). Runs the SAME per-upstream auth + // checks that ConfigLoader::Validate() applies inline, but exposed + // as a separate entry point so callers can run them on the REAL + // (non-stripped) `upstreams[]` list even when the full Validate is + // called on a reload-stripped copy. + // + // Motivation: HttpServer::Reload() strips `upstreams[]` from its + // validation copy to skip topology-restart-only checks + // (UpstreamTlsConfig/Pool ranges/etc.). That stripping also skips + // the per-upstream inline-auth loop, so an operator reload that + // sets `proxy.auth.enabled=true` or a bad inline issuer reference + // would slip through the strict reload gate. Reload calls this + // helper explicitly against the original `new_config` to restore + // those checks without reintroducing the topology-restart noise. + // + // Runs (against `config.upstreams`): + // - on_undetermined value check + // - issuer references resolve to `config.auth.issuers` + // - populated-inline-auth requires non-empty `proxy.route_prefix` + // - populated-inline-auth requires LITERAL byte-prefix + // (rejects route_trie patterns because the auth matcher is + // literal-only) + // - enforcement-not-yet-wired gate: rejects `proxy.auth.enabled=true` + // until request-time enforcement is wired (design spec §14 Phase 2) + // + // Does NOT run collision detection — that requires the cross-source + // view (inline + top-level applies_to) which the full Validate owns. + // + // Idempotent with the inline-auth branch inside Validate(): at + // startup the full Validate runs the same logic once; this helper + // is safe to call a second time with the same config (all checks + // are pure / side-effect-free / deterministic). + // + // Throws std::invalid_argument with an `upstreams['name'].proxy.auth...` + // message on failure. + static void ValidateProxyAuth(const ServerConfig& config); + // Return a ServerConfig with all default values. static ServerConfig Default(); diff --git a/server/config_loader.cc b/server/config_loader.cc index 46d1ea52..b53461a2 100644 --- a/server/config_loader.cc +++ b/server/config_loader.cc @@ -2239,6 +2239,90 @@ void ConfigLoader::Validate(const ServerConfig& config, bool reload_copy) { } } +void ConfigLoader::ValidateProxyAuth(const ServerConfig& config) { + // Same per-upstream checks that Validate() runs inline, extracted so + // the reload path can invoke them against the REAL upstreams[] list + // even when Validate() is called on a stripped validation_copy. + // See config_loader.h docstring for the full motivation. + for (const auto& u : config.upstreams) { + const auto& p = u.proxy.auth; + const std::string ctx = "upstreams['" + u.name + "'].proxy.auth"; + + if (p.on_undetermined != "deny" && p.on_undetermined != "allow") { + throw std::invalid_argument( + ctx + ".on_undetermined must be \"deny\" or \"allow\" " + "(checked regardless of `enabled` so staged disabled " + "policies still get typo-rejection)"); + } + for (const auto& issuer_name : p.issuers) { + if (config.auth.issuers.count(issuer_name) == 0) { + throw std::invalid_argument( + ctx + ".issuers references unknown issuer '" + + issuer_name + "' (checked regardless of `enabled` " + "so staged disabled policies still get typo-rejection)"); + } + } + + // route_prefix non-empty is required ONLY when the operator has + // actually populated the inline auth block. See the parallel + // in-Validate comment for the rationale (programmatic-only + // proxies with no auth block skip this check). + const bool inline_auth_populated = (p != auth::AuthPolicy{}); + if (inline_auth_populated && u.proxy.route_prefix.empty()) { + throw std::invalid_argument( + ctx + " has no route_prefix — inline auth requires a " + "non-empty proxy.route_prefix to derive applies_to"); + } + + // route_prefix must be a LITERAL byte prefix — patterns never + // match because route_trie patterns + auth matcher (literal) + // disagree. See the parallel in-Validate comment for full + // rationale and the alternative operator guidance. + if (inline_auth_populated && !u.proxy.route_prefix.empty()) { + std::vector segs; + try { + segs = ROUTE_TRIE::ParsePattern(u.proxy.route_prefix); + } catch (const std::exception& e) { + throw std::invalid_argument( + ctx + ": route_prefix '" + u.proxy.route_prefix + + "' failed to parse as a route pattern: " + e.what()); + } + for (const auto& s : segs) { + if (s.type != ROUTE_TRIE::NodeType::STATIC) { + throw std::invalid_argument( + ctx + ": inline auth requires a LITERAL prefix " + "in proxy.route_prefix (got '" + + u.proxy.route_prefix + "' which contains a " + + (s.type == ROUTE_TRIE::NodeType::PARAM + ? "':" + s.param_name + "' param" + : "'*" + s.param_name + "' catch-all") + + " segment). The auth matcher does byte-prefix " + "matching only (design spec §3.2). If you need " + "to protect a patterned route, use top-level " + "auth.policies[] with applies_to listing the " + "literal prefix(es) the pattern expands through " + "(e.g. ['/api/'])."); + } + } + } + + // Enforcement-not-yet-wired gate (the security-critical case + // the reviewer specifically flagged as bypassed by the reload + // strip). Same message as the in-Validate gate — operators + // should see the same wording regardless of which entry point + // (startup Validate / reload ValidateProxyAuth) surfaces it. + if (p.enabled) { + throw std::invalid_argument( + "upstreams['" + u.name + + "'].proxy.auth.enabled=true rejected: request-time " + "enforcement is not yet wired in this build (design spec " + "§14 Phase 2). Set proxy.auth.enabled=false for now; the " + "auth block (issuers reference, required_scopes, etc.) " + "may remain populated for upgrade."); + } + } +} + ServerConfig ConfigLoader::Default() { return ServerConfig{}; } diff --git a/server/http_server.cc b/server/http_server.cc index fbfdf1cb..18bc7d54 100644 --- a/server/http_server.cc +++ b/server/http_server.cc @@ -3617,6 +3617,26 @@ bool HttpServer::Reload(const ServerConfig& new_config) { logging::Get()->error("Reload() rejected invalid config: {}", e.what()); return false; } + + // Inline proxy.auth validation runs AGAINST the original + // new_config (full upstreams), not validation_copy. The strip + // above skips the in-Validate per-upstream auth loop entirely + // — which would let a reload that toggled + // `upstreams[i].proxy.auth.enabled=true` slip past the + // enforcement-not-yet-wired gate AND let bad inline issuer + // references slide through structural validation until the + // next restart. ValidateProxyAuth re-runs those checks on the + // real upstream list so the strict reload gate is + // enforcement-complete for inline auth too. (Collision + // detection stays in the main Validate — it requires the + // cross-source view that the full Validate owns.) + try { + ConfigLoader::ValidateProxyAuth(new_config); + } catch (const std::invalid_argument& e) { + logging::Get()->error("Reload() rejected invalid inline auth: {}", + e.what()); + return false; + } // Strict gate for hot-reloadable CB fields + duplicate names. // Mirrors main.cc::ReloadConfig — both entry points must reject // invalid CB tuning before it reaches live slices. diff --git a/test/auth_foundation_test.h b/test/auth_foundation_test.h index e3972319..0dcf9e94 100644 --- a/test/auth_foundation_test.h +++ b/test/auth_foundation_test.h @@ -2616,6 +2616,259 @@ void TestConfigLoaderAcceptsLiteralPatternCharsInAppliesTo() { } } +// ----------------------------------------------------------------------------- +// ConfigLoader::ValidateProxyAuth — reload-path gate for inline per-proxy +// auth (review P1). HttpServer::Reload strips upstreams[] from its +// validation copy to avoid topology-restart-only noise — that stripping +// also skipped the in-Validate per-proxy auth loop entirely, leaving the +// enforcement-not-yet-wired gate bypassable via reload. +// +// This test calls ValidateProxyAuth directly with a full upstreams[] +// list (simulating what HttpServer::Reload now passes), covering: +// - proxy.auth.enabled=true must reject +// - bad inline issuer reference must reject (structural) +// - bad on_undetermined value must reject (structural) +// - patterned route_prefix with inline auth must reject +// - clean disabled inline auth must accept +// - proxy with no auth block at all must accept +// ----------------------------------------------------------------------------- +void TestValidateProxyAuthReloadGate() { + std::cout << "\n[TEST] ConfigLoader::ValidateProxyAuth reload gate..." << std::endl; + + auto validate_expect_failure = [](const std::string& json, + const std::string& expected_phrase) + -> std::string { + try { + ServerConfig cfg = ConfigLoader::LoadFromString(json); + ConfigLoader::ValidateProxyAuth(cfg); + return "expected throw containing '" + expected_phrase + + "' but accepted"; + } catch (const std::invalid_argument& e) { + std::string msg = e.what(); + if (msg.find(expected_phrase) == std::string::npos) { + return "threw but message missing '" + expected_phrase + + "'; got: " + msg; + } + return ""; + } catch (const std::exception& e) { + return std::string("unexpected exception type: ") + e.what(); + } + }; + + try { + // Case 1: enforcement gate — proxy.auth.enabled=true. This is + // the primary security concern the reviewer flagged: a reload + // toggling auth on MUST be rejected even when the full Validate + // runs on a stripped copy. + std::string err = validate_expect_failure(R"({ + "upstreams": [ + {"name":"x","host":"127.0.0.1","port":80}, + { + "name": "api", + "host": "127.0.0.1", + "port": 8080, + "proxy": { + "route_prefix": "/api/v1/", + "auth": { + "enabled": true, + "issuers": ["google"] + } + } + } + ], + "auth": { + "enabled": false, + "issuers": { + "google": { + "issuer_url": "https://issuer.example", + "upstream": "x", + "mode": "jwt", + "algorithms": ["RS256"] + } + } + } + })", "proxy.auth.enabled=true rejected"); + if (!err.empty()) throw std::runtime_error("enforcement gate: " + err); + + // Case 2: structural — unknown issuer reference. Staged + // disabled policy with a typo should fail the reload gate. + err = validate_expect_failure(R"({ + "upstreams": [ + {"name":"x","host":"127.0.0.1","port":80}, + { + "name": "api", + "host": "127.0.0.1", + "port": 8080, + "proxy": { + "route_prefix": "/api/v1/", + "auth": { + "enabled": false, + "issuers": ["typo"] + } + } + } + ], + "auth": { + "enabled": false, + "issuers": { + "google": { + "issuer_url": "https://issuer.example", + "upstream": "x", + "mode": "jwt", + "algorithms": ["RS256"] + } + } + } + })", "references unknown issuer"); + if (!err.empty()) throw std::runtime_error("unknown issuer: " + err); + + // Case 3: structural — bad on_undetermined. + err = validate_expect_failure(R"({ + "upstreams": [ + {"name":"x","host":"127.0.0.1","port":80}, + { + "name": "api", + "host": "127.0.0.1", + "port": 8080, + "proxy": { + "route_prefix": "/api/v1/", + "auth": { + "enabled": false, + "issuers": ["google"], + "on_undetermined": "maybe" + } + } + } + ], + "auth": { + "enabled": false, + "issuers": { + "google": { + "issuer_url": "https://issuer.example", + "upstream": "x", + "mode": "jwt", + "algorithms": ["RS256"] + } + } + } + })", "on_undetermined must be"); + if (!err.empty()) throw std::runtime_error("bad on_undetermined: " + err); + + // Case 4: structural — patterned route_prefix with inline auth. + err = validate_expect_failure(R"({ + "upstreams": [ + {"name":"x","host":"127.0.0.1","port":80}, + { + "name": "api", + "host": "127.0.0.1", + "port": 8080, + "proxy": { + "route_prefix": "/api/:v/users", + "auth": { + "enabled": false, + "issuers": ["google"] + } + } + } + ], + "auth": { + "enabled": false, + "issuers": { + "google": { + "issuer_url": "https://issuer.example", + "upstream": "x", + "mode": "jwt", + "algorithms": ["RS256"] + } + } + } + })", "LITERAL prefix"); + if (!err.empty()) throw std::runtime_error("patterned prefix: " + err); + + // Case 5: POSITIVE — clean disabled inline auth passes. Proves + // the helper doesn't over-reject. + try { + ServerConfig cfg = ConfigLoader::LoadFromString(R"({ + "upstreams": [ + {"name":"x","host":"127.0.0.1","port":80}, + { + "name": "api", + "host": "127.0.0.1", + "port": 8080, + "proxy": { + "route_prefix": "/api/v1/", + "auth": { + "enabled": false, + "issuers": ["google"] + } + } + } + ], + "auth": { + "enabled": false, + "issuers": { + "google": { + "issuer_url": "https://issuer.example", + "upstream": "x", + "mode": "jwt", + "algorithms": ["RS256"] + } + } + } + })"); + ConfigLoader::ValidateProxyAuth(cfg); + } catch (const std::exception& e) { + throw std::runtime_error( + std::string("clean disabled inline auth should be ACCEPTED ") + + "but ValidateProxyAuth threw: " + e.what()); + } + + // Case 6: POSITIVE — proxy with NO auth block at all must pass. + try { + ServerConfig cfg = ConfigLoader::LoadFromString(R"({ + "upstreams": [ + { + "name": "api", + "host": "127.0.0.1", + "port": 8080, + "proxy": {"route_prefix": "/api/v1/"} + } + ] + })"); + ConfigLoader::ValidateProxyAuth(cfg); + } catch (const std::exception& e) { + throw std::runtime_error( + std::string("proxy without auth block should be ACCEPTED ") + + "but ValidateProxyAuth threw: " + e.what()); + } + + // Case 7: POSITIVE — empty upstreams list passes (no-op). + // Mirrors the reload-copy scenario where ValidateProxyAuth is + // also called separately by HttpServer::Reload on the REAL + // upstreams, but in the hypothetical case that it gets called + // on a stripped copy it should be a harmless no-op. + try { + ServerConfig cfg = ConfigLoader::LoadFromString(R"({ + "upstreams": [], + "auth": {"enabled": false} + })"); + ConfigLoader::ValidateProxyAuth(cfg); + } catch (const std::exception& e) { + throw std::runtime_error( + std::string("empty upstreams should be a no-op for ") + + "ValidateProxyAuth but threw: " + e.what()); + } + + TestFramework::RecordTest( + "AuthFoundation: ValidateProxyAuth reload gate", + true, "", TestFramework::TestCategory::OTHER); + } catch (const std::exception& e) { + TestFramework::RecordTest( + "AuthFoundation: ValidateProxyAuth reload gate", + false, e.what(), TestFramework::TestCategory::OTHER); + } +} + inline void RunAllTests() { std::cout << "\n===== Auth Foundation Tests =====" << std::endl; TestHasherBasicDeterminism(); @@ -2644,6 +2897,7 @@ inline void RunAllTests() { TestConfigLoaderRequiresIssuerUpstream(); TestConfigLoaderValidatesHeaderNameTchar(); TestConfigLoaderAcceptsLiteralPatternCharsInAppliesTo(); + TestValidateProxyAuthReloadGate(); TestConfigLoaderClaimHeaderCollision(); } From 308414b4716d4ff53b02593b62576475c41e09e9 Mon Sep 17 00:00:00 2001 From: mwfj Date: Fri, 17 Apr 2026 22:19:57 +0800 Subject: [PATCH 16/17] Fix review comment --- include/config/config_loader.h | 5 + server/auth_claims.cc | 25 ++- server/config_loader.cc | 35 +++++ server/token_hasher.cc | 37 +++-- test/auth_foundation_test.h | 273 +++++++++++++++++++++++++++++++++ 5 files changed, 358 insertions(+), 17 deletions(-) diff --git a/include/config/config_loader.h b/include/config/config_loader.h index 8293813e..197890e5 100644 --- a/include/config/config_loader.h +++ b/include/config/config_loader.h @@ -97,6 +97,11 @@ class ConfigLoader { // those checks without reintroducing the topology-restart noise. // // Runs (against `config.upstreams`): + // - issuer.upstream cross-reference — each auth.issuers.*.upstream + // must name an existing entry in config.upstreams. This check is + // skipped by the stripped-copy Validate (reload_copy=true), so + // running it here on the real upstreams is how reload and + // startup enforce issuer refs consistently. // - on_undetermined value check // - issuer references resolve to `config.auth.issuers` // - populated-inline-auth requires non-empty `proxy.route_prefix` diff --git a/server/auth_claims.cc b/server/auth_claims.cc index 51f870ba..1c4470c6 100644 --- a/server/auth_claims.cc +++ b/server/auth_claims.cc @@ -42,14 +42,25 @@ std::vector ExtractScopes(const nlohmann::json& payload) { return SplitWhitespace(v.get()); } } - // Azure AD and friends. - if (payload.contains("scopes") && payload["scopes"].is_array()) { - std::vector out; - out.reserve(payload["scopes"].size()); - for (const auto& v : payload["scopes"]) { - if (v.is_string()) out.push_back(v.get()); + // Azure AD and friends. Accept both array-of-strings AND a single + // whitespace-delimited string — matches the dual-form handling of + // `scp` above. Some identity providers (and custom introspection + // endpoints) serialize scopes as a single string even though the + // field name is plural; rejecting that form would make + // required_scopes wrongly reject otherwise-valid tokens. + if (payload.contains("scopes")) { + const auto& v = payload["scopes"]; + if (v.is_array()) { + std::vector out; + out.reserve(v.size()); + for (const auto& s : v) { + if (s.is_string()) out.push_back(s.get()); + } + return out; + } + if (v.is_string()) { + return SplitWhitespace(v.get()); } - return out; } return {}; } diff --git a/server/config_loader.cc b/server/config_loader.cc index b53461a2..d0b10d9d 100644 --- a/server/config_loader.cc +++ b/server/config_loader.cc @@ -2244,6 +2244,41 @@ void ConfigLoader::ValidateProxyAuth(const ServerConfig& config) { // the reload path can invoke them against the REAL upstreams[] list // even when Validate() is called on a stripped validation_copy. // See config_loader.h docstring for the full motivation. + + // Issuer upstream cross-reference — Validate() normally handles this + // in its main issuer loop, but its reload-path call (reload_copy=true + // on a stripped validation_copy) SKIPS the cross-ref because + // `upstream_names` is empty. That leaves staged issuer typos + // slipping through reload and only surfacing at the next restart's + // full Validate(). Run the check here on the REAL upstreams so the + // reload path is enforcement-symmetric with startup. Startup still + // runs this check via the in-Validate path (no double-check — the + // full Validate doesn't call ValidateProxyAuth internally). + { + std::unordered_set upstream_names; + upstream_names.reserve(config.upstreams.size()); + for (const auto& u : config.upstreams) { + upstream_names.insert(u.name); + } + for (const auto& [name, ic] : config.auth.issuers) { + if (ic.upstream.empty()) { + // Structural "must be non-empty" check — identical to the + // one in Validate(). Duplicated because structural checks + // must fire in BOTH entry points. + throw std::invalid_argument( + "auth.issuers." + name + ".upstream is required — each " + "issuer must bind to an existing UpstreamHostPool so " + "JWKS / discovery / introspection traffic has a " + "configured outbound path"); + } + if (upstream_names.count(ic.upstream) == 0) { + throw std::invalid_argument( + "auth.issuers." + name + ".upstream references " + "unknown upstream '" + ic.upstream + "' — define it " + "under `upstreams[]` first"); + } + } + } for (const auto& u : config.upstreams) { const auto& p = u.proxy.auth; const std::string ctx = "upstreams['" + u.name + "'].proxy.auth"; diff --git a/server/token_hasher.cc b/server/token_hasher.cc index 1cad4849..a5e51e0e 100644 --- a/server/token_hasher.cc +++ b/server/token_hasher.cc @@ -106,17 +106,34 @@ std::string LoadHmacKeyFromEnv(const std::string& env_var_name) { // Catch at this boundary and fall through to the raw-bytes interpretation // — a malformed env value must not propagate as an exception into // AuthManager::Start(), which would abort startup. + // TRAILING-only padding strip (review round: avoid silent truncation + // of raw env keys that happen to contain padding sequences in the + // middle). jwt::base::trim uses find() to locate the fill + // string — which strips at the FIRST occurrence, not just the tail. + // So a raw key like "AAAA...%3dRAW" (perfectly valid as a binary + // HMAC secret) would get truncated at the middle "%3d" and then + // potentially decode to 32 bytes, silently changing the operator's + // configured HMAC key. We strip only real trailing padding below, + // then skip jwt-cpp's trim entirely and feed the candidate directly + // into pad() + decode() — pad() adds correct trailing padding if + // needed, and decode() rejects obviously-invalid input by throwing, + // which our catch handles via the raw-bytes fallback. std::string candidate = raw; - // Step 1: strip standard '=' padding (if operator encoded with the - // legacy base64 form). + // Strip trailing '=' (standard base64 padding). while (!candidate.empty() && candidate.back() == '=') candidate.pop_back(); - // Step 2: strip jwt-cpp "%3d" padding too — trim() returns the substring - // before the first occurrence of the fill string. - std::string candidate_b64url = - jwt::base::trim(candidate); + // Strip trailing '%3d' (jwt-cpp's URL-safe escaped padding). MUST + // happen after the '=' strip in case an operator's pad sequence + // mixes forms (unlikely but harmless). + while (candidate.size() >= 3 && + candidate.compare(candidate.size() - 3, 3, "%3d") == 0) { + candidate.resize(candidate.size() - 3); + } + + // Step 2: try base64url decoding (the common case — `openssl rand + // -base64 32 | tr '+/' '-_' | tr -d '='` style keys). try { std::string decoded = jwt::base::decode( - jwt::base::pad(candidate_b64url)); + jwt::base::pad(candidate)); if (decoded.size() == 32) { // Silent-swap corner case: an operator's raw 43-char key // composed entirely of base64url alphabet chars [A-Za-z0-9_-] @@ -147,11 +164,11 @@ std::string LoadHmacKeyFromEnv(const std::string& env_var_name) { // base64url). base64url's alphabet excludes '+' and '/', so the first // attempt above throws on those characters and we fall through here. // The decoded bytes are identical as long as the key is 32 bytes. + // Same rationale for skipping jwt-cpp's trim — we feed the candidate + // directly into pad()+decode(). try { - std::string trimmed_b64 = - jwt::base::trim(candidate); std::string decoded_b64 = jwt::base::decode( - jwt::base::pad(trimmed_b64)); + jwt::base::pad(candidate)); if (decoded_b64.size() == 32) { logging::Get()->debug( "LoadHmacKeyFromEnv: env var '{}' interpreted as " diff --git a/test/auth_foundation_test.h b/test/auth_foundation_test.h index 0dcf9e94..70f9e419 100644 --- a/test/auth_foundation_test.h +++ b/test/auth_foundation_test.h @@ -2869,6 +2869,276 @@ void TestValidateProxyAuthReloadGate() { } } +// ----------------------------------------------------------------------------- +// ExtractScopes — `scopes` claim as a whitespace-delimited STRING is also +// accepted (review P2). The helper already handled `scope` and `scp` in +// both array + string forms; `scopes` only accepted the array form which +// inconsistently rejected legitimate tokens from providers that serialize +// scopes as a single string under the plural field name. +// ----------------------------------------------------------------------------- +void TestExtractScopesScopesAsString() { + std::cout << "\n[TEST] ExtractScopes handles 'scopes' as string..." << std::endl; + try { + // Case 1: `scopes` as a whitespace-separated string. + nlohmann::json p1 = nlohmann::json::parse(R"({ + "scopes": "read:data write:data admin:all" + })"); + auto scopes1 = auth::ExtractScopes(p1); + bool case1 = scopes1.size() == 3 && + scopes1[0] == "read:data" && + scopes1[1] == "write:data" && + scopes1[2] == "admin:all"; + + // Case 2: `scopes` as an array still works (regression). + nlohmann::json p2 = nlohmann::json::parse(R"({ + "scopes": ["read:data", "write:data"] + })"); + auto scopes2 = auth::ExtractScopes(p2); + bool case2 = scopes2.size() == 2 && + scopes2[0] == "read:data" && + scopes2[1] == "write:data"; + + // Case 3: `scope` precedence over `scopes` (when both are + // present — `scope` wins, matches existing behavior). + nlohmann::json p3 = nlohmann::json::parse(R"({ + "scope": "a b", + "scopes": "x y z" + })"); + auto scopes3 = auth::ExtractScopes(p3); + bool case3 = scopes3.size() == 2 && scopes3[0] == "a" && scopes3[1] == "b"; + + // Case 4: `scopes` as a non-string/non-array (e.g. object) — + // returns empty (graceful degradation). + nlohmann::json p4 = nlohmann::json::parse(R"({ + "scopes": {"something": "weird"} + })"); + auto scopes4 = auth::ExtractScopes(p4); + bool case4 = scopes4.empty(); + + bool pass = case1 && case2 && case3 && case4; + std::string err; + if (!case1) err = "string form of scopes returned " + + std::to_string(scopes1.size()) + " tokens (expected 3)"; + else if (!case2) err = "array form of scopes broke"; + else if (!case3) err = "scope-precedence-over-scopes broke"; + else if (!case4) err = "non-string/non-array scopes should return empty"; + + TestFramework::RecordTest( + "AuthFoundation: ExtractScopes handles scopes-as-string", + pass, err, TestFramework::TestCategory::OTHER); + } catch (const std::exception& e) { + TestFramework::RecordTest( + "AuthFoundation: ExtractScopes handles scopes-as-string", + false, std::string("unexpected exception: ") + e.what(), + TestFramework::TestCategory::OTHER); + } +} + +// ----------------------------------------------------------------------------- +// ValidateProxyAuth — runs the issuer.upstream cross-reference check +// (review P3 #1). The reload path's stripped-copy Validate skips this +// because reload_copy=true disables cross-refs; ValidateProxyAuth now +// runs it explicitly on the real upstreams so reload and startup enforce +// this equally. +// ----------------------------------------------------------------------------- +void TestValidateProxyAuthIssuerUpstreamCrossRef() { + std::cout << "\n[TEST] ValidateProxyAuth rejects unknown issuer.upstream..." << std::endl; + try { + // Case 1: issuer.upstream points at a non-existent pool. + const std::string bad = R"({ + "upstreams": [{"name":"x","host":"127.0.0.1","port":80}], + "auth": { + "enabled": false, + "issuers": { + "google": { + "issuer_url": "https://issuer.example", + "upstream": "typo_not_a_real_upstream", + "mode": "jwt", + "algorithms": ["RS256"] + } + } + } + })"; + bool threw = false; + std::string err_msg; + try { + ServerConfig cfg = ConfigLoader::LoadFromString(bad); + ConfigLoader::ValidateProxyAuth(cfg); + } catch (const std::invalid_argument& e) { + threw = true; + err_msg = e.what(); + } + bool good_msg = threw && + err_msg.find("references unknown upstream") != std::string::npos && + err_msg.find("typo_not_a_real_upstream") != std::string::npos; + + // Case 2: missing upstream field entirely — also rejected as + // structural error. + const std::string missing = R"({ + "upstreams": [{"name":"x","host":"127.0.0.1","port":80}], + "auth": { + "enabled": false, + "issuers": { + "google": { + "issuer_url": "https://issuer.example", + "mode": "jwt", + "algorithms": ["RS256"] + } + } + } + })"; + bool missing_threw = false; + std::string missing_err; + try { + ServerConfig cfg = ConfigLoader::LoadFromString(missing); + ConfigLoader::ValidateProxyAuth(cfg); + } catch (const std::invalid_argument& e) { + missing_threw = true; + missing_err = e.what(); + } + bool missing_msg = missing_threw && + missing_err.find("upstream is required") != std::string::npos; + + // Case 3: POSITIVE — valid cross-ref passes. + try { + ServerConfig cfg = ConfigLoader::LoadFromString(R"({ + "upstreams": [{"name":"x","host":"127.0.0.1","port":80}], + "auth": { + "enabled": false, + "issuers": { + "google": { + "issuer_url": "https://issuer.example", + "upstream": "x", + "mode": "jwt", + "algorithms": ["RS256"] + } + } + } + })"); + ConfigLoader::ValidateProxyAuth(cfg); + } catch (const std::exception& e) { + throw std::runtime_error( + std::string("valid upstream ref should be ACCEPTED but ") + + "ValidateProxyAuth threw: " + e.what()); + } + + bool pass = threw && good_msg && missing_threw && missing_msg; + std::string err; + if (!threw) { + err = "unknown-upstream case should reject but accepted"; + } else if (!good_msg) { + err = "rejected but wrong message; got: " + err_msg; + } else if (!missing_threw) { + err = "missing-upstream case should reject but accepted"; + } else if (!missing_msg) { + err = "missing case rejected but wrong message; got: " + missing_err; + } + TestFramework::RecordTest( + "AuthFoundation: ValidateProxyAuth rejects unknown issuer.upstream", + pass, err, TestFramework::TestCategory::OTHER); + } catch (const std::exception& e) { + TestFramework::RecordTest( + "AuthFoundation: ValidateProxyAuth rejects unknown issuer.upstream", + false, e.what(), TestFramework::TestCategory::OTHER); + } +} + +// ----------------------------------------------------------------------------- +// LoadHmacKeyFromEnv — raw env keys containing middle '%3d' or '=' must +// be preserved verbatim (review P3 #2). jwt::base::trim uses find() so +// it truncates at the FIRST occurrence of the padding sequence; +// previously this mis-decoded raw keys with embedded padding as 32-byte +// base64 keys. The fix strips padding only at the tail. +// ----------------------------------------------------------------------------- +void TestLoadHmacKeyFromEnvPreservesMiddlePadding() { + std::cout << "\n[TEST] LoadHmacKeyFromEnv preserves middle %3d / = chars..." << std::endl; + + const char* kVarName = "REACTOR_TEST_AUTH_MID_PAD_KEY"; + auto restore_env = [](const char* name, const char* prev, bool had) { + if (had) setenv(name, prev, 1); + else unsetenv(name); + }; + + const char* prev = std::getenv(kVarName); + std::string saved = prev ? prev : ""; + bool had_original = (prev != nullptr); + + try { + // Case 1: raw env key with "%3d" in the MIDDLE. Under the + // previous jwt::base::trim behavior this would be truncated at + // the first '%3d', leaving a short prefix that might decode to + // 32 bytes → silent HMAC key change. Post-fix: the middle '%3d' + // is preserved and the raw string is used verbatim (falls + // through to raw-bytes after base64 decode fails). + const std::string tricky = "AAAA%3dBBBBCCCCDDDDEEEEFFFF"; + setenv(kVarName, tricky.c_str(), 1); + std::string loaded1 = auth::LoadHmacKeyFromEnv(kVarName); + // The string is not a valid 32-byte base64/base64url decode, so + // it falls through to raw-bytes and is returned verbatim. The + // CRITICAL assertion: the returned bytes are the full tricky + // string, NOT a truncated substring. + bool case1 = loaded1 == tricky; + + // Case 2: raw env key with literal '=' in the middle — similar + // concern (the loop at the top strips TRAILING '=' only; middle + // '=' should remain intact). + const std::string tricky_eq = "AAAA=BBBBCCCCDDDDEEEEFFFF"; + setenv(kVarName, tricky_eq.c_str(), 1); + std::string loaded2 = auth::LoadHmacKeyFromEnv(kVarName); + bool case2 = loaded2 == tricky_eq; + + // Case 3: REGRESSION — valid base64url still decodes to 32 bytes + // when the operator provides that form. Uses jwt::base::encode + // so the encoding is exactly what the decoder expects. + std::string raw_key(32, 'K'); + std::string encoded = + jwt::base::encode(raw_key); + // Strip standard '=' padding (operator-typical form). + while (!encoded.empty() && encoded.back() == '=') encoded.pop_back(); + setenv(kVarName, encoded.c_str(), 1); + std::string loaded3 = auth::LoadHmacKeyFromEnv(kVarName); + bool case3 = loaded3.size() == 32 && loaded3 == raw_key; + + // Case 4: raw key with TRAILING '%3d' — should still strip + // (that's the legitimate padding case). The remainder + // ("AAAABBBB") should decode to 6 bytes via base64url, but that + // doesn't equal 32 so falls to raw. Returned value must equal + // the original string (with padding stripped OR kept — test + // accepts either; the PRIMARY contract is "not mid-truncated"). + const std::string trailing = "AAAABBBB%3d"; + setenv(kVarName, trailing.c_str(), 1); + std::string loaded4 = auth::LoadHmacKeyFromEnv(kVarName); + // Must be either the full string OR the stripped-tail version. + // Must NOT be empty and must NOT be a middle-truncated variant + // (i.e. if we see just "AAAABBBB" that's fine; if we see + // anything shorter that's a bug). + bool case4 = (loaded4 == trailing) || (loaded4 == "AAAABBBB"); + + restore_env(kVarName, saved.c_str(), had_original); + + bool pass = case1 && case2 && case3 && case4; + std::string err; + if (!case1) err = "middle '%3d' caused truncation — got '" + + loaded1 + "' (expected '" + tricky + "')"; + else if (!case2) err = "middle '=' caused truncation — got '" + + loaded2 + "' (expected '" + tricky_eq + "')"; + else if (!case3) err = "valid base64url decode regression — got size=" + + std::to_string(loaded3.size()); + else if (!case4) err = "trailing '%3d' case produced unexpected " + "result '" + loaded4 + "'"; + + TestFramework::RecordTest( + "AuthFoundation: LoadHmacKeyFromEnv preserves middle padding", + pass, err, TestFramework::TestCategory::OTHER); + } catch (const std::exception& e) { + restore_env(kVarName, saved.c_str(), had_original); + TestFramework::RecordTest( + "AuthFoundation: LoadHmacKeyFromEnv preserves middle padding", + false, std::string("unexpected exception: ") + e.what(), + TestFramework::TestCategory::OTHER); + } +} + inline void RunAllTests() { std::cout << "\n===== Auth Foundation Tests =====" << std::endl; TestHasherBasicDeterminism(); @@ -2898,6 +3168,9 @@ inline void RunAllTests() { TestConfigLoaderValidatesHeaderNameTchar(); TestConfigLoaderAcceptsLiteralPatternCharsInAppliesTo(); TestValidateProxyAuthReloadGate(); + TestExtractScopesScopesAsString(); + TestValidateProxyAuthIssuerUpstreamCrossRef(); + TestLoadHmacKeyFromEnvPreservesMiddlePadding(); TestConfigLoaderClaimHeaderCollision(); } From 9092e100ea675a22355f5d9530dcdf8cf7ccca88 Mon Sep 17 00:00:00 2001 From: mwfj Date: Fri, 17 Apr 2026 23:25:00 +0800 Subject: [PATCH 17/17] Fix review comment --- server/config_loader.cc | 71 +++++++-- test/auth_foundation_test.h | 303 +++++++++++++++++++++++++++++++++++- 2 files changed, 361 insertions(+), 13 deletions(-) diff --git a/server/config_loader.cc b/server/config_loader.cc index d0b10d9d..041ee772 100644 --- a/server/config_loader.cc +++ b/server/config_loader.cc @@ -41,7 +41,11 @@ static int ParseStrictInt(const nlohmann::json& j, const std::string& key, int default_value, const std::string& context) { if (!j.contains(key)) return default_value; const auto& v = j[key]; - if (v.is_null()) return default_value; + // JSON null is NOT treated as "field absent" (earlier behavior). A + // templated config that renders `"key": null` — typically because a + // variable is missing or unrendered — should surface loudly, not + // silently fall back to the default. Strict-typing guarantee means + // null fails the same way `true` or `1.9` would. if (!v.is_number_integer()) { throw std::invalid_argument( context + "." + key + " must be an integer " @@ -614,7 +618,19 @@ ServerConfig ConfigLoader::LoadFromString(const std::string& json_str) { UpstreamConfig upstream; upstream.name = item.value("name", ""); upstream.host = item.value("host", ""); - upstream.port = item.value("port", 80); + // Integer fields use ParseStrictInt throughout the upstream + // block: nlohmann/json's json::value() silently coerces + // booleans (true → 1), floats (1.9 → 1), and oversized + // unsigned values (4294967297 → 1). For security-sensitive + // routing knobs (ports, timeouts, retry counts), that + // quiet coercion would mean a malformed config silently + // retargets traffic or rewrites retry semantics instead of + // surfacing as an error. ParseStrictInt rejects non-integer + // JSON AND out-of-int-range values (review P2 hardening). + const std::string up_ctx = "upstreams['" + + (upstream.name.empty() ? std::string("?") : upstream.name) + + "']"; + upstream.port = ParseStrictInt(item, "port", 80, up_ctx); if (item.contains("tls")) { if (!item["tls"].is_object()) @@ -631,12 +647,19 @@ ServerConfig ConfigLoader::LoadFromString(const std::string& json_str) { if (!item["pool"].is_object()) throw std::runtime_error("upstream pool must be an object"); auto& pool = item["pool"]; - upstream.pool.max_connections = pool.value("max_connections", 64); - upstream.pool.max_idle_connections = pool.value("max_idle_connections", 16); - upstream.pool.connect_timeout_ms = pool.value("connect_timeout_ms", 5000); - upstream.pool.idle_timeout_sec = pool.value("idle_timeout_sec", 90); - upstream.pool.max_lifetime_sec = pool.value("max_lifetime_sec", 3600); - upstream.pool.max_requests_per_conn = pool.value("max_requests_per_conn", 0); + const std::string pool_ctx = up_ctx + ".pool"; + upstream.pool.max_connections = + ParseStrictInt(pool, "max_connections", 64, pool_ctx); + upstream.pool.max_idle_connections = + ParseStrictInt(pool, "max_idle_connections", 16, pool_ctx); + upstream.pool.connect_timeout_ms = + ParseStrictInt(pool, "connect_timeout_ms", 5000, pool_ctx); + upstream.pool.idle_timeout_sec = + ParseStrictInt(pool, "idle_timeout_sec", 90, pool_ctx); + upstream.pool.max_lifetime_sec = + ParseStrictInt(pool, "max_lifetime_sec", 3600, pool_ctx); + upstream.pool.max_requests_per_conn = + ParseStrictInt(pool, "max_requests_per_conn", 0, pool_ctx); } if (item.contains("proxy")) { @@ -645,7 +668,8 @@ ServerConfig ConfigLoader::LoadFromString(const std::string& json_str) { auto& proxy = item["proxy"]; upstream.proxy.route_prefix = proxy.value("route_prefix", ""); upstream.proxy.strip_prefix = proxy.value("strip_prefix", false); - upstream.proxy.response_timeout_ms = proxy.value("response_timeout_ms", 30000); + upstream.proxy.response_timeout_ms = ParseStrictInt( + proxy, "response_timeout_ms", 30000, up_ctx + ".proxy"); if (proxy.contains("methods")) { if (!proxy["methods"].is_array()) @@ -671,7 +695,8 @@ ServerConfig ConfigLoader::LoadFromString(const std::string& json_str) { if (!proxy["retry"].is_object()) throw std::runtime_error("upstream proxy retry must be an object"); auto& r = proxy["retry"]; - upstream.proxy.retry.max_retries = r.value("max_retries", 0); + upstream.proxy.retry.max_retries = ParseStrictInt( + r, "max_retries", 0, up_ctx + ".proxy.retry"); upstream.proxy.retry.retry_on_connect_failure = r.value("retry_on_connect_failure", true); upstream.proxy.retry.retry_on_5xx = r.value("retry_on_5xx", false); upstream.proxy.retry.retry_on_timeout = r.value("retry_on_timeout", false); @@ -1795,12 +1820,36 @@ void ConfigLoader::Validate(const ServerConfig& config, bool reload_copy) { ctx + ".introspection.endpoint is required for " "mode=\"introspection\""); } + // Credentials: both supported auth_style values ("basic", + // "body") require client_id + client secret to be part of + // the RFC 7662 request. Inline client_secret is already + // rejected (env-var sourcing is mandatory — see + // ParseIssuerConfig). Without these checks, an issuer + // staged with `mode="introspection"` + endpoint but no + // credentials would load successfully and fail every + // introspection call at request time. Reject at load + // instead, so the misconfig surfaces before enforcement + // lands in Phase 2. + const auto& is = ic.introspection; + if (is.client_id.empty()) { + throw std::invalid_argument( + ctx + ".introspection.client_id is required for " + "mode=\"introspection\" (both 'basic' and 'body' " + "auth_style values need it per RFC 7662)"); + } + if (is.client_secret_env.empty()) { + throw std::invalid_argument( + ctx + ".introspection.client_secret_env is required " + "for mode=\"introspection\" — the secret must be " + "sourced from an environment variable (inline " + "client_secret is rejected separately as a secret-" + "in-config anti-pattern)"); + } // auth_style — RFC 7662 doesn't standardize the credential // delivery channel; we support two: "basic" (Authorization // header) and "body" (urlencoded form). Anything else // would silently choose one at request time, which is // worse than a load-time reject. - const auto& is = ic.introspection; if (is.auth_style != "basic" && is.auth_style != "body") { throw std::invalid_argument( ctx + ".introspection.auth_style must be \"basic\" " diff --git a/test/auth_foundation_test.h b/test/auth_foundation_test.h index 70f9e419..23941d5e 100644 --- a/test/auth_foundation_test.h +++ b/test/auth_foundation_test.h @@ -904,7 +904,9 @@ void TestConfigLoaderRejectsPlaintextIdpEndpoints() { "upstream": "x", "mode": "introspection", "introspection": { - "endpoint": "http://issuer.example/introspect" + "endpoint": "http://issuer.example/introspect", + "client_id": "c", + "client_secret_env": "E" } } } @@ -1180,6 +1182,8 @@ void TestConfigLoaderRejectsOutOfRangeIntegers() { "mode": "introspection", "introspection": { "endpoint": "https://issuer.example/introspect", + "client_id": "c", + "client_secret_env": "E", "timeout_sec": 9999999999 } } @@ -2049,7 +2053,9 @@ void TestConfigLoaderValidatesIntrospectionKnobs() { "upstream": "x", "mode": "introspection", "introspection": { - "endpoint": "https://issuer.example/introspect")" + "endpoint": "https://issuer.example/introspect", + "client_id": "c", + "client_secret_env": "E")" + fields + R"( } } @@ -3139,6 +3145,296 @@ void TestLoadHmacKeyFromEnvPreservesMiddlePadding() { } } +// ----------------------------------------------------------------------------- +// ParseStrictInt — JSON null is rejected (review P3). Previously null was +// treated as "field absent" and returned the default. A templated config +// that renders `"key": null` (missing variable, unrendered) would silently +// get default values for security-sensitive knobs. Strict typing means +// null fails the same way `true` or `1.9` would. +// ----------------------------------------------------------------------------- +void TestParseStrictIntRejectsNull() { + std::cout << "\n[TEST] ParseStrictInt rejects JSON null..." << std::endl; + try { + // Exercised indirectly via ConfigLoader::LoadFromString parsing + // an auth issuer with a null-valued integer field. + const std::string with_null = R"({ + "upstreams": [{"name":"x","host":"127.0.0.1","port":80}], + "auth": { + "enabled": false, + "issuers": { + "ours": { + "issuer_url": "https://issuer.example", + "upstream": "x", + "mode": "jwt", + "algorithms": ["RS256"], + "leeway_sec": null + } + } + } + })"; + bool threw = false; + std::string err_msg; + try { + ServerConfig cfg = ConfigLoader::LoadFromString(with_null); + } catch (const std::invalid_argument& e) { + threw = true; + err_msg = e.what(); + } + bool good = threw && + err_msg.find("leeway_sec") != std::string::npos && + err_msg.find("must be an integer") != std::string::npos; + + bool pass = threw && good; + std::string err; + if (!threw) { + err = "expected null value to reject but LoadFromString accepted"; + } else if (!good) { + err = "threw but message missing 'leeway_sec' or 'must be an " + "integer'; got: " + err_msg; + } + TestFramework::RecordTest( + "AuthFoundation: ParseStrictInt rejects JSON null", + pass, err, TestFramework::TestCategory::OTHER); + } catch (const std::exception& e) { + TestFramework::RecordTest( + "AuthFoundation: ParseStrictInt rejects JSON null", + false, std::string("unexpected exception: ") + e.what(), + TestFramework::TestCategory::OTHER); + } +} + +// ----------------------------------------------------------------------------- +// ConfigLoader::Validate — introspection issuers require client_id AND +// client_secret_env (review P2 #2). RFC 7662 basic/body auth both need +// these; without them introspection calls fail at runtime. Reject now. +// ----------------------------------------------------------------------------- +void TestConfigLoaderRequiresIntrospectionCredentials() { + std::cout << "\n[TEST] ConfigLoader requires introspection credentials..." << std::endl; + auto validate_expect_failure = [](const std::string& json, + const std::string& expected_phrase) + -> std::string { + try { + ServerConfig cfg = ConfigLoader::LoadFromString(json); + ConfigLoader::Validate(cfg); + return "expected throw containing '" + expected_phrase + + "' but accepted"; + } catch (const std::invalid_argument& e) { + std::string msg = e.what(); + if (msg.find(expected_phrase) == std::string::npos) { + return "threw but message missing '" + expected_phrase + + "'; got: " + msg; + } + return ""; + } catch (const std::exception& e) { + return std::string("unexpected exception type: ") + e.what(); + } + }; + + try { + // Case 1: missing client_id. + std::string err = validate_expect_failure(R"({ + "upstreams": [{"name":"x","host":"127.0.0.1","port":80}], + "auth": { + "enabled": false, + "issuers": { + "ours": { + "issuer_url": "https://issuer.example", + "upstream": "x", + "mode": "introspection", + "introspection": { + "endpoint": "https://issuer.example/introspect", + "client_secret_env": "E" + } + } + } + } + })", "client_id is required"); + if (!err.empty()) throw std::runtime_error("missing client_id: " + err); + + // Case 2: missing client_secret_env. + err = validate_expect_failure(R"({ + "upstreams": [{"name":"x","host":"127.0.0.1","port":80}], + "auth": { + "enabled": false, + "issuers": { + "ours": { + "issuer_url": "https://issuer.example", + "upstream": "x", + "mode": "introspection", + "introspection": { + "endpoint": "https://issuer.example/introspect", + "client_id": "c" + } + } + } + } + })", "client_secret_env is required"); + if (!err.empty()) throw std::runtime_error("missing client_secret_env: " + err); + + // POSITIVE: with both credentials present, validation passes. + try { + ServerConfig cfg = ConfigLoader::LoadFromString(R"({ + "upstreams": [{"name":"x","host":"127.0.0.1","port":80}], + "auth": { + "enabled": false, + "issuers": { + "ours": { + "issuer_url": "https://issuer.example", + "upstream": "x", + "mode": "introspection", + "introspection": { + "endpoint": "https://issuer.example/introspect", + "client_id": "c", + "client_secret_env": "E" + } + } + } + } + })"); + ConfigLoader::Validate(cfg); + } catch (const std::exception& e) { + throw std::runtime_error( + std::string("introspection with credentials should pass but ") + + "threw: " + e.what()); + } + + // POSITIVE: jwt-mode issuer without credentials still valid. + // (credentials are REQUIRED only for mode="introspection") + try { + ServerConfig cfg = ConfigLoader::LoadFromString(R"({ + "upstreams": [{"name":"x","host":"127.0.0.1","port":80}], + "auth": { + "enabled": false, + "issuers": { + "ours": { + "issuer_url": "https://issuer.example", + "upstream": "x", + "mode": "jwt", + "algorithms": ["RS256"] + } + } + } + })"); + ConfigLoader::Validate(cfg); + } catch (const std::exception& e) { + throw std::runtime_error( + std::string("jwt mode without introspection creds should ") + + "pass but threw: " + e.what()); + } + + TestFramework::RecordTest( + "AuthFoundation: ConfigLoader requires introspection credentials", + true, "", TestFramework::TestCategory::OTHER); + } catch (const std::exception& e) { + TestFramework::RecordTest( + "AuthFoundation: ConfigLoader requires introspection credentials", + false, e.what(), TestFramework::TestCategory::OTHER); + } +} + +// ----------------------------------------------------------------------------- +// ConfigLoader::LoadFromString — upstream/pool/proxy integer fields parsed +// strictly (review P2 #1). Pre-existing issue: nlohmann's json::value() +// silently coerces booleans, floats, and oversized unsigned values. For +// routing-critical fields (port, timeouts, retry counts), that would let +// a typo'd config silently retarget traffic or rewrite behavior. +// ----------------------------------------------------------------------------- +void TestConfigLoaderStrictUpstreamIntegers() { + std::cout << "\n[TEST] ConfigLoader strict upstream integer parsing..." << std::endl; + auto load_expect_failure = [](const std::string& json, + const std::string& expected_phrase) + -> std::string { + try { + ServerConfig cfg = ConfigLoader::LoadFromString(json); + return "expected throw containing '" + expected_phrase + + "' but accepted"; + } catch (const std::invalid_argument& e) { + std::string msg = e.what(); + if (msg.find(expected_phrase) == std::string::npos) { + return "threw but message missing '" + expected_phrase + + "'; got: " + msg; + } + return ""; + } catch (const std::exception& e) { + return std::string("unexpected exception type: ") + e.what(); + } + }; + + try { + // Case 1: upstream.port as boolean (the reviewer's true→1 example). + std::string err = load_expect_failure(R"({ + "upstreams": [{"name":"x","host":"127.0.0.1","port":true}] + })", "must be an integer"); + if (!err.empty()) throw std::runtime_error("port as bool: " + err); + + // Case 2: upstream.port as float. + err = load_expect_failure(R"({ + "upstreams": [{"name":"x","host":"127.0.0.1","port":80.5}] + })", "must be an integer"); + if (!err.empty()) throw std::runtime_error("port as float: " + err); + + // Case 3: pool.max_connections oversized (reviewer's 4294967297 → 1 example). + err = load_expect_failure(R"({ + "upstreams": [{"name":"x","host":"127.0.0.1","port":80, + "pool":{"max_connections":4294967297}}] + })", "out of int range"); + if (!err.empty()) throw std::runtime_error("max_connections oversized: " + err); + + // Case 4: proxy.response_timeout_ms as bool. + err = load_expect_failure(R"({ + "upstreams": [{"name":"x","host":"127.0.0.1","port":80, + "proxy":{"response_timeout_ms":true}}] + })", "must be an integer"); + if (!err.empty()) throw std::runtime_error("response_timeout_ms as bool: " + err); + + // Case 5: proxy.retry.max_retries as bool. + err = load_expect_failure(R"({ + "upstreams": [{"name":"x","host":"127.0.0.1","port":80, + "proxy":{"retry":{"max_retries":true}}}] + })", "must be an integer"); + if (!err.empty()) throw std::runtime_error("max_retries as bool: " + err); + + // POSITIVE: valid config with normal integers parses fine. + try { + ServerConfig cfg = ConfigLoader::LoadFromString(R"({ + "upstreams": [{ + "name":"x", + "host":"127.0.0.1", + "port":8080, + "pool":{"max_connections":128,"connect_timeout_ms":3000}, + "proxy":{"response_timeout_ms":10000, + "retry":{"max_retries":2}} + }] + })"); + bool all_set = + cfg.upstreams.size() == 1 && + cfg.upstreams[0].port == 8080 && + cfg.upstreams[0].pool.max_connections == 128 && + cfg.upstreams[0].pool.connect_timeout_ms == 3000 && + cfg.upstreams[0].proxy.response_timeout_ms == 10000 && + cfg.upstreams[0].proxy.retry.max_retries == 2; + if (!all_set) { + throw std::runtime_error( + "valid integers parsed but values didn't land in struct"); + } + } catch (const std::runtime_error& e) { + throw; + } catch (const std::exception& e) { + throw std::runtime_error( + std::string("valid integer config rejected by strict parser: ") + + e.what()); + } + + TestFramework::RecordTest( + "AuthFoundation: ConfigLoader strict upstream integer parsing", + true, "", TestFramework::TestCategory::OTHER); + } catch (const std::exception& e) { + TestFramework::RecordTest( + "AuthFoundation: ConfigLoader strict upstream integer parsing", + false, e.what(), TestFramework::TestCategory::OTHER); + } +} + inline void RunAllTests() { std::cout << "\n===== Auth Foundation Tests =====" << std::endl; TestHasherBasicDeterminism(); @@ -3171,6 +3467,9 @@ inline void RunAllTests() { TestExtractScopesScopesAsString(); TestValidateProxyAuthIssuerUpstreamCrossRef(); TestLoadHmacKeyFromEnvPreservesMiddlePadding(); + TestParseStrictIntRejectsNull(); + TestConfigLoaderRequiresIntrospectionCredentials(); + TestConfigLoaderStrictUpstreamIntegers(); TestConfigLoaderClaimHeaderCollision(); }