Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ set(SOURCES
src/codec/Codec.cpp
src/codec/HeaderCodec.cpp
src/auth/SCRAM.cpp
src/auth/SaslAuthenticator.cpp
src/topology/TopologyInfo.cpp
src/hash/MurmurHash3.cpp
src/hash/ConsistentHash.cpp
Expand Down Expand Up @@ -91,6 +92,8 @@ if(GTest_FOUND)
tests/unit/HashAwareRoutingTest.cpp
tests/unit/ErrorHandlingTest.cpp
tests/unit/ServerSelectionTest.cpp
tests/unit/AuthCodecTest.cpp
tests/unit/SaslAuthenticatorTest.cpp
)

target_link_libraries(unit_tests
Expand Down Expand Up @@ -310,6 +313,18 @@ if(GTest_FOUND)
# Note: NOT gtest_main - we provide our own main()
)

# SASL/SCRAM authentication integration tests (auth-enabled single server)
add_executable(auth_integration_tests
tests/integration/AuthIntegrationTest.cpp
)

target_link_libraries(auth_integration_tests
PRIVATE
hotrod-client
GTest::gtest
# Note: NOT gtest_main - we provide our own main()
)

add_test(NAME PingIntegrationTests COMMAND ping_integration_tests)
add_test(NAME GetIntegrationTests COMMAND get_integration_tests)
add_test(NAME GetWithMetadataIntegrationTests COMMAND getwithmetadata_integration_tests)
Expand All @@ -327,6 +342,7 @@ if(GTest_FOUND)
add_test(NAME ConcurrentMultiServerTests COMMAND concurrent_multiserver_tests)
add_test(NAME RetryViewIntegrationTests COMMAND retryview_integration_tests)
add_test(NAME PingRetryIntegrationTests COMMAND pingretry_integration_tests)
add_test(NAME AuthIntegrationTests COMMAND auth_integration_tests)

# Set working directory so scripts can be found
set_tests_properties(PingIntegrationTests PROPERTIES
Expand Down Expand Up @@ -397,6 +413,10 @@ if(GTest_FOUND)
WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
LABELS "integration;docker;topology;multi-server;retry"
)
set_tests_properties(AuthIntegrationTests PROPERTIES
WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
LABELS "integration;docker;auth"
)
endif() # NOT WIN32 (integration tests)

message(STATUS "Google Test found - unit tests enabled (integration tests: non-Windows only)")
Expand Down
21 changes: 13 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
A cross-platform C++17 implementation of the Infinispan Hot Rod protocol client.

🎉 **Smart client** - hash-aware routing, automatic failover, full CRUD +
metadata ops, typed error handling and user-decided retry (keyed & keyless).
metadata ops, typed error handling and user-decided retry (keyed & keyless),
and SASL/SCRAM authentication (SCRAM-SHA-1/256/512).

## Quick Start

Expand Down Expand Up @@ -114,7 +115,7 @@ ctest -C Release --output-on-failure

## Testing

### Unit Tests (204 tests)
### Unit Tests (223 tests)
```bash
cd build
./unit_tests
Expand All @@ -123,7 +124,7 @@ cd build
ctest -R UnitTests --output-on-failure
```

### Integration Tests (84 tests, 17 suites)
### Integration Tests (89 tests, 18 suites)
Requires Docker to run Infinispan server:
```bash
cd build
Expand Down Expand Up @@ -162,8 +163,8 @@ _(Step numbers follow [`../hotrod-foundry/ROADMAP.md`](../hotrod-foundry/ROADMAP
any headline count.)_

**Test Results** (verified 2026-09-25):
- Unit Tests: **204/204 passing** ✅
- Integration Tests: **84/84 passing** ✅ across 17 suites (against live Infinispan via Docker)
- Unit Tests: **223/223 passing** ✅
- Integration Tests: **89/89 passing** ✅ across 18 suites (against live Infinispan via Docker)

## Features

Expand All @@ -189,6 +190,9 @@ any headline count.)_
tried nodes) + `isTransient` / `outcomeUncertain` classification (Step 11a)
- ✅ **User-decided retry** (`cache.excluding(e)` bound view) for keyed ops and
keyless `ping`, with automatic before-send failover (Step 11b/11c)
- ✅ **SASL/SCRAM authentication** (`setAuthentication(...)`) — end-to-end for the
SCRAM family (`SCRAM-SHA-1`/`SCRAM-SHA-256`/`SCRAM-SHA-512`), applied to every
connection; see [`documentation/topics/security.adoc`](documentation/topics/security.adoc)
- ✅ **Integration test framework** (GoogleTest + Docker + multi-node clusters)

### Full CRUD with Smart Routing and Async Operations
Expand Down Expand Up @@ -262,10 +266,11 @@ cpp17-client/
│ ├── hash/ # MurmurHash3 + consistent hashing
│ └── operations/ # Hot Rod operations (with failover)
├── tests/ # Test suite
│ ├── unit/ # 204 unit tests
│ └── integration/ # 84 integration tests, 17 suites (multi-node clusters)
│ ├── unit/ # 223 unit tests
│ └── integration/ # 89 integration tests, 18 suites (multi-node clusters)
├── documentation/ # AsciiDoc user guide (index.adoc + topics/)
├── examples/ # Usage examples
│ └── quickstart/ # Simple GET/PUT/REMOVE example
│ └── quickstart/ # Simple GET/PUT/REMOVE + retry + auth examples
├── scripts/ # Test infrastructure
│ ├── start_cluster.sh # Start multi-node cluster
│ ├── add_cluster_node.sh # Add node dynamically
Expand Down
90 changes: 90 additions & 0 deletions docs/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -614,3 +614,93 @@ With slices 1–3 done, **Step 11c is complete**: keyless ops mirror the keyed
two-tier retry model (auto before-send failover, user-decided after-send), the
implementation (`selectAnyServer`) is generic for future non-key ops, and the
behaviour is unit-, integration-, and example-covered.

## 2026-09-25 — SASL/SCRAM authentication, end-to-end (whole SCRAM family)

**What shipped.** The client can now authenticate against a secured Infinispan
server. `RemoteCache::setAuthentication(username, password, realm="default",
serverName="infinispan", mechanism="SCRAM-SHA-256")` enables a SASL handshake that
runs on **every** connection the client opens — the seed connection *and* every
topology-discovered cluster member — inside `MultiplexedConnection::connect()`,
after the TCP connect and **before** the read-loop thread is spawned (the only
synchronous window; nothing consumes bytes before it). New public headers:
`Authentication.h` (pure-data config), `AuthCodec.h` (header-only body
encode/decode, following the `ServerSelection.h` inline precedent), and
`SaslAuthenticator.h`; implementation in `src/auth/SaslAuthenticator.cpp`. The
existing `SCRAM` crypto — previously **dead code** (unit-tested but never on any
code path) — is now generalized across the digest family and actually driven.

**Why flat setters, not a builder.** Locked with the user: `setAuthentication(...)`
matches the existing `setClientIntelligence` / `setProtocolVersion` idiom rather
than introducing a Java-style `ConfigurationBuilder`. A builder / multi-seed model
stays a possible future thread.

**Why the whole SCRAM family.** The API is mechanism-aware (defaults to
`SCRAM-SHA-256`). C++ has no JDK-style SASL provider framework, so each mechanism
is hand-rolled over OpenSSL; the three SCRAM variants share one generalized
implementation. `SCRAM` was parameterized by a `Digest { SHA1, SHA256, SHA512 }`
enum → `EVP_MD*`, with PBKDF2/HMAC/H output lengths derived from `EVP_MD_size`
(SHA-1=20, SHA-256=32, SHA-512=64). The prior SHA-256 behavior stays the default
and the existing `SCRAMTest.cpp` still passes. Any non-SCRAM mechanism (PLAIN,
DIGEST-MD5, EXTERNAL, GSSAPI, OAUTHBEARER, …) throws a clear
`HotRodClientException` ("unsupported SASL mechanism") at connect() time.

**Load-bearing protocol finding — SCRAM is exactly two client turns and the
client completes on its own side.** Confirmed against the two authoritative
sources (`AGENTS.md` order: Java client behavior, then Kaitai byte layout):

- **Java** (`AuthHandler` / `AuthOperation`): `AuthOperation.createResponse`
returns `complete ? null : challenge`; the loop is driven by the *client* SASL
state, not the server's `completed` flag. client-first→server-first
(`completed=false`); client-final→server-final. Infinispan/Elytron sends the
server-final with **`completed=false`** carrying the `v=` server signature, and
expects **no** confirmation round — the client finishes once it has verified
that signature (SASL client returns null / nothing more to send).
- **Kaitai** (`hotrod40.ksy`): mech-list request `0x21` = empty body; mech-list
response `0x22` = `vint count` + N×`lp_string`; auth request `0x23` =
`lp_string mech` (re-sent every round) + `lp_bytes response`; auth response
`0x24` = `u1 completed` + `lp_bytes challenge`.

Two earlier wrong attempts, now recorded so they aren't repeated: (1) requiring
`completed=true` on the server-final hung the handshake ("SASL exchange did not
complete") — the server sends `completed=false`; (2) "fixing" that by adding an
empty confirmation round in a completed-flag loop triggered Elytron `ELY05037`
("mechanism client refuses to initiate authentication"). The correct behavior is:
send nothing after verifying the server signature; ignore the server `completed`
flag. This mirrors Java exactly.

**Testable transport seam.** Hot Rod responses have **no total-length prefix**, so
a response body can only be consumed by reading its fields in order — the SASL
driver therefore reads *typed* fields, not a length-delimited blob. To keep the
state machine unit-testable without a socket, `SaslAuthenticator::authenticate`
runs against an abstract `SaslTransport` (send + typed reads +
`readResponseHeader()`); `ConnectionSaslTransport` is the production adapter over a
live blocking `Connection`, and its `readResponseHeader()` mirrors the read loop's
header parse (magic / messageId / opcode / status / topology-change marker,
consuming any topology update so the stream stays aligned) and surfaces server
ERROR/failed-status as a typed `HotRodClientException`. Unit tests drive a
`FakeScramServer` that computes the server side with OpenSSL, parameterized across
SCRAM-SHA-1/256/512, plus mech-not-offered / unsupported-mech /
bad-server-signature failure paths.

**Server config gotchas (Infinispan 16), discovered while standing up the
integration fixture.** The SCRAM family derives keys from the *cleartext*
password, so the realm must store plain-text passwords: a `properties-realm` with
`plain-text="true"`. A single-port endpoint requires **both** a `hotrod-connector`
and a `rest-connector` ("There must be a REST route!"), and the connectors nest
inside `<endpoint>` inside `<endpoints>`. The image entrypoint auto-generates
`conf/users.properties`, so the test mounts non-default filenames
(`test-users.properties` / `test-groups.properties`) to avoid a read-only clash.
Auth succeeding does not create a cache, so the config pre-defines a
`distributed-cache name="authcache"` the test targets (no admin round-trip on a
secured server). See `test-configs/infinispan-auth.xml` and
`scripts/start_infinispan_auth.sh` (starts with `-p 0:11222` for an ephemeral host
port, avoiding the fixed-port cluster clash noted elsewhere).

**Tests.** +19 unit (`AuthCodecTest` + parameterized `SaslAuthenticatorTest`) and
+5 integration (`AuthIntegrationTest`: SCRAM-SHA-1/256/512 put/get round-trips,
wrong-password throws at connect, no-credentials-against-secured-server fails),
all green live against a Dockerized auth-enabled Infinispan 16.0.7. Documented in
`documentation/topics/security.adoc` (new AsciiDoc user guide,
`documentation/index.adoc`) with a runnable snippet in
`examples/quickstart/README.md`.
56 changes: 49 additions & 7 deletions docs/STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,24 @@ _(Full per-session workflow: [`WORKFLOW.md`](WORKFLOW.md).)_

_The 1–3 concrete things to do next. Keep this short and current._

1. **Step 11c COMPLETE — keyless-op retry (ping) shipped (slices 1–3).** Keyless
1. **SASL/SCRAM authentication COMPLETE — end-to-end for the whole SCRAM
family.** `RemoteCache::setAuthentication(username, password, realm="default",
serverName="infinispan", mechanism="SCRAM-SHA-256")` (flat setter, matching
`setClientIntelligence`) enables a SASL handshake on **every** connection the
client opens — the seed connection and each topology-discovered member — run
inside `MultiplexedConnection::connect()` before the read loop starts. The
`SaslAuthenticator` drives the two-turn SCRAM exchange over a testable
`SaslTransport` seam (`ConnectionSaslTransport` in production); `SCRAM` is
generalized across `SCRAM-SHA-1/256/512` (OpenSSL EVP, digest-derived
lengths). It completes on the client side after verifying the server signature
(no confirmation round; ignores the server `completed` flag — mirrors Java
`AuthHandler`). Any non-SCRAM mechanism throws a clear `HotRodClientException`
at connect(). New public headers: `Authentication.h`, `AuthCodec.h`,
`SaslAuthenticator.h`. Documented in `documentation/topics/security.adoc`.
Rationale in [`DECISIONS.md`](DECISIONS.md) (2026-09-25 SASL/SCRAM entry).
**Next: pick a new roadmap step — Step 12 (bulk ops) or benchmark the
multiplexing path.**
2. **Step 11c COMPLETE — keyless-op retry (ping) shipped (slices 1–3).** Keyless
operations now mirror the keyed retry strategy: automatic before-send failover
across all servers, user-decided after-send retry (D2). `ping()` routes through
`selectAnyServer(ctx, triedOut)`, which orders candidates via
Expand Down Expand Up @@ -145,6 +162,17 @@ pull from here next. Step numbers follow
`PingRetryIntegrationTest` (3 tests) + a keyless `pingWithRetry` in the retry
example.
Java ref: `org.infinispan.client.hotrod.exceptions.*`.
- [x] **SASL/SCRAM authentication — COMPLETE.** End-to-end auth for the whole
SCRAM family (`SCRAM-SHA-1/256/512`). `setAuthentication(...)` (flat setter)
runs the AUTH_MECH_LIST + two-turn SCRAM handshake on every connection (seed +
topology-discovered), inside `MultiplexedConnection::connect()` before the read
loop; `SaslAuthenticator` over a testable `SaslTransport` seam; `SCRAM`
generalized across the digest family (OpenSSL EVP). Completes on the client
side after verifying the server signature (no confirmation round). Non-SCRAM
mechanisms throw at connect(). New headers `Authentication.h`, `AuthCodec.h`,
`SaslAuthenticator.h`; docs `documentation/topics/security.adoc`; 19 unit +
5 integration tests. This supersedes the earlier "SCRAM crypto exists but is
dead code" state. Java ref: `AuthHandler` / `AuthOperation`; Kaitai `hotrod40.ksy`.
- [ ] **Step 12 — Bulk operations.** `GET_ALL` (0x2F), `PUT_ALL` (0x2D),
`BULK_GET` (0x1F, iterator-style).

Expand All @@ -171,7 +199,11 @@ multiplexing. See "Working and shipped" below._
**Working and shipped:**
- Wire primitives (vInt, vLong, strings, byte arrays)
- Protocol 4.0 headers (all conditional fields)
- SCRAM-SHA-256 authentication (RFC 5802, OpenSSL)
- **SASL/SCRAM authentication (end-to-end)** — `setAuthentication(...)` runs the
AUTH_MECH_LIST + two-turn SCRAM handshake on every connection (seed +
topology-discovered), for the whole family (`SCRAM-SHA-1/256/512`, RFC 5802,
OpenSSL); non-SCRAM mechanisms throw at connect(). See
`documentation/topics/security.adoc`
- Cluster topology awareness (failover, load balancing)
- Consistent hashing (MurmurHash3 x64_32, seed 9001)
- Hash-aware routing → primary owner, with automatic failover
Expand Down Expand Up @@ -212,10 +244,14 @@ multiplexing. See "Working and shipped" below._
portability and `-Werror` build parity also landed (Sept 2026).

**Test status (verified 2026-09-25):**
- Unit: **204/204** passing (`./build/unit_tests`, <1s) — +17 for
`ServerSelectionTest` (`orderKeyCandidates` + `unionNodes`, Step 11b slices 1–3;
+3 keyless-ordering tests for `selectAnyServer`, Step 11c slice 1)
- Integration: **84/84** passing across 17 suites (`ctest`, spins up Docker
- Unit: **223/223** passing (`./build/unit_tests`, <1s) — +19 for the auth work
(`AuthCodecTest` body round-trips + `SaslAuthenticatorTest`, a parameterized
SCRAM-SHA-1/256/512 handshake driven by a fake OpenSSL server, plus
mech-not-offered / unsupported-mech / bad-server-signature failure paths); the
earlier +17 for `ServerSelectionTest` (`orderKeyCandidates` + `unionNodes`,
Step 11b slices 1–3; +3 keyless-ordering tests for `selectAnyServer`, Step 11c
slice 1) still stands
- Integration: **89/89** passing across 18 suites (`ctest`, spins up Docker
Infinispan single-server + multi-node clusters), now also green on Linux CI.
+3 for `RetryViewIntegrationTest` (Step 11b: `excluding()` routes around an
owner; proxy-disabled owners-exhausted throw; the catch→`excluding(e)` retry
Expand All @@ -224,11 +260,15 @@ multiplexing. See "Working and shipped" below._
to another server; excluding all servers throws BeforeSend with
`ownersExhausted=false`; the catch→`excluding(e).ping()` loop recovers after the
first-candidate node is killed) — verified 3/3 this session.
+5 for `AuthIntegrationTest` (SASL/SCRAM against an auth-enabled Infinispan 16
server started by `scripts/start_infinispan_auth.sh`): SCRAM-SHA-1/256/512
put/get round-trips (3, parameterized), wrong-password throws at connect(), and
no-credentials-against-a-secured-server fails — verified 5/5 live this session.
The interlaced distributed tests (`ConcurrentMultiServerTest`) were fixed to
tolerate a GET racing ahead of its PUT — a `nullopt` is expected, only a
present-but-wrong value is an error.
- Full run: `ctest --test-dir build --output-on-failure` → 100% pass
(18 ctest tests: 1 unit + 17 integration suites)
(19 ctest tests: 1 unit + 18 integration suites)
- **Concurrency validated (2026-09-25):** the concurrent suites pass 8/8
(incl. `ConcurrentWithFailover`), and a ThreadSanitizer run (`build-tsan/`)
reports **no races in production code** — slice-4's `stateMutex_`/`shared_ptr`
Expand All @@ -249,6 +289,8 @@ full list (Steps 10–12, benchmarks, TLS, code TODOs).
| What | Where |
|------|-------|
| Public API | `include/hotrod/RemoteCache.h` |
| Authentication (SASL/SCRAM) | `include/hotrod/Authentication.h`, `AuthCodec.h`, `SaslAuthenticator.h`; `src/auth/SaslAuthenticator.cpp`, `SCRAM.cpp` |
| User guide (AsciiDoc) | `documentation/index.adoc` + `documentation/topics/` |
| Retry API (bound view) | `include/hotrod/RetryView.h`, `RetryContext.h`; routing helpers in `ServerSelection.h` |
| Async transport core | `src/transport/MultiplexedConnection.cpp` |
| Operations (PING/GET/PUT/REMOVE) | `src/operations/RemoteCache.cpp` |
Expand Down
26 changes: 26 additions & 0 deletions documentation/index.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
= Infinispan Hot Rod C++17 Client Guide
:toc: left
:toclevels: 3

This guide covers the Infinispan Hot Rod C++17 client for the Hot Rod binary
protocol. The client speaks protocol 4.0/4.1, routes keys to their primary owner
(hash-aware), fails over automatically, and exposes every operation as a
`std::future`.

NOTE: The single source of truth for *what is implemented today* is
`docs/STATUS.md` in the repository root. If a chapter here describes a feature as
a stub, STATUS.md is authoritative for whether it has shipped.

include::topics/getting-started.adoc[]

include::topics/connecting.adoc[]

include::topics/security.adoc[]

include::topics/cache-operations.adoc[]

include::topics/client-intelligence.adoc[]

include::topics/error-handling-and-retry.adoc[]

include::topics/building-and-testing.adoc[]
Loading
Loading