AXIAM C++ SDK 1.0.0-alpha8
Authentication, authorization, JWKS & route guards (REST + mTLS)
Loading...
Searching...
No Matches
AXIAM C++ SDK

CI Coverage Status Docs C++17 [License](LICENSE)

Idiomatic C++17 client for AXIAM (Access eXtended Identity and Authorization Management) — authentication, authorization checks, JWKS verification, and framework-agnostic route guards.

This SDK conforms to CONTRACT.md §1–§7, §9–§11, §13 and §16–§19 (including §6.1 mTLS).

‍Scope note: this v1 covers the REST surface. gRPC — including the gRPC-only get_user_info operation (CONTRACT §1.1, contract 1.3) — and §8 AMQP HMAC are intentionally out of scope for v1 (the cross-language contract does not require AMQP of C++); see Deferred / follow-ups. Per §1.1 the REST /oauth2/userinfo endpoint is not substituted for the gRPC operation.

  • Namespace: axiam — library target axiam_cpp (CMake axiam::axiam_cpp).
  • Public headers under include/axiam/; umbrella header #include <axiam/axiam.hpp>.
  • Dependencies: libcurl (HTTP + strict TLS + mTLS), OpenSSL (Ed25519 JWKS verification), vendored single-header nlohmann/json (third_party/nlohmann/json.hpp).
  • Version: 1.0.0-alpha24.

Install

CMake (FetchContent)

include(FetchContent)
FetchContent_Declare(axiam_cpp_sdk
GIT_REPOSITORY https://github.com/ilpanich/axiam-cplusplus-sdk.git
GIT_TAG v1.0.0-alpha24)
FetchContent_MakeAvailable(axiam_cpp_sdk)
target_link_libraries(my_app PRIVATE axiam::axiam_cpp)

Or, against an installed copy:

find_package(axiam-cpp-sdk CONFIG REQUIRED)
target_link_libraries(my_app PRIVATE axiam::axiam_cpp)

vcpkg

An in-repo port lives at ports/axiam-cpp-sdk. Point vcpkg at it with an overlay:

vcpkg install axiam-cpp-sdk --overlay-ports=./ports

Conan

conan create . --version=1.0.0-alpha24

The conanfile.py requires libcurl, openssl, and nlohmann_json.


Quickstart

#include <axiam/axiam.hpp>
#include <iostream>
int main() {
// §5: a tenant (slug or id) is mandatory — there is no default tenant.
.base_url("https://api.axiam.example")
.tenant_slug("acme")
.org_slug("acme") // §5.1: login/refresh need org context (a tenant slug is unique only within an org)
.build();
auto login = client.login("alice@acme.example", "correct horse battery staple");
if (login.mfa_required) {
login = client.verify_mfa(login.challenge_token, "123456");
}
// §1: check_access / can / batch_check take (action, resource[, scope]).
axiam::AccessDecision d = client.check_access("read", "resource-uuid");
std::cout << "allowed=" << std::boolalpha << d.allowed
<< " (" << d.reason_code.value_or("no reason code") << ")\n";
auto results = client.batch_check({
{"read", "res-1", std::nullopt, std::nullopt},
{"write", "res-2", std::nullopt, std::nullopt},
});
client.logout();
}
Client build()
Validates required fields and constructs the client.
Builder & base_url(std::string url)
Server base URL.
Builder & tenant_slug(std::string slug)
Builder & org_slug(std::string slug)
Definition client.hpp:24
LoginResult login(const std::string &username_or_email, const std::string &password)
LoginResult verify_mfa(const Sensitive< std::string > &challenge_token, const std::string &totp_code)
Complete an MFA challenge.
AccessDecision check_access(const std::string &action, const std::string &resource_id, std::optional< std::string > scope=std::nullopt, std::optional< std::string > subject_id=std::nullopt)
static Builder builder()
std::vector< AccessDecision > batch_check(const std::vector< AccessCheck > &checks)
Result of an access check (CheckAccessResponse).
Definition types.hpp:86
std::optional< std::string > reason_code
§11 rule 9 machine-readable decision reason.
Definition types.hpp:95
bool allowed
Definition types.hpp:87

Errors (§2)

All failures are exceptions rooted at axiam::AxiamError:

Type HTTP Meaning
axiam::AuthError 401 Authentication failure / expired session / failed refresh
axiam::AuthzError 403, 409 Authenticated but not authorized (carries action/resource_id when available)
axiam::NetworkError 400, 408, 429, 5xx, transport Transport/protocol failure (carries the underlying cause())

Token strings never appear in what(), logs, or serialized output (§7).

Authenticating a request (§10)

axiam::TokenAuthenticator is the entry point for turning an inbound credential into an AxiamUser. It verifies the Ed25519 signature and the claims that make a signature meaningful — exp (with a small named clock skew), nbf when present, and that the token's tenant_id matches the tenant this server serves — and fails closed on anything missing or malformed.

axiam::TokenAuthenticator auth(client.jwks(), "11111111-2222-3333-4444-555555555555");
// Wire it into the framework-agnostic guard: you supply the credential
// extraction, the authenticator supplies the verification.
auth.guard_authenticator<MyRequest>([](const MyRequest& r) {
return axiam::TokenAuthenticator::bearer_from_authorization(r.header("Authorization"));
}));
axiam::AxiamUser user = guard(request); // throws axiam::AuthError (401) otherwise
§10 guard functor: a callable that turns a request into an AxiamUser using a caller-supplied authenti...
Definition guard.hpp:105
JwksVerifier & jwks()
Shared JWKS verifier bound to this client's transport + base URL.
Safe-by-default local verification of an AXIAM access token.
Definition authenticator.hpp:81
Authenticated identity injected by the §10 guard into the request context.
Definition guard.hpp:27

This is CONTRACT §10.1's minimum local-verification set: the alg pin runs before any key lookup, exp is required (absent and non-numeric both hard-fail), nbf is honoured when present, and tenant_id is asserted — an empty tenant expectation is refused at construction rather than silently disabling the check.

Optional iss / aud pinning and the clock skew live on axiam::AuthenticatorOptions; AuthenticatorOptions::now is the injection seam for tests. try_authenticate() is the non-throwing twin. The issuer and audience checks are conditional — leave them unset (the default) and the claims are not checked; set one and it becomes required, so a token missing that claim is refused. The skew is a named, bounded constant: it defaults to axiam::kDefaultClockSkew (30 s) and may not exceed axiam::kMaxClockSkew (60 s), because an unbounded leeway would keep expired tokens usable.

Do not build an AxiamUser from JwksVerifier::verify_signature_only_unchecked(). That is a deliberately named expert primitive: it validates the signature and nothing else, so a guard fed from it accepts expired tokens and tokens minted for another tenant.

Declarative helpers (§11)

#include <axiam/guard.hpp>
void handler(axiam::Client& client, const std::optional<axiam::AxiamUser>& user) {
axiam::require_auth(user); // 401 if unauthenticated
axiam::require_role(user, {"editor", "admin"}); // local role check, 403
AXIAM_REQUIRE_ACCESS(client, user, "read", "resource-uuid"); // 403 if denied
// ... proceed ...
}
#define AXIAM_REQUIRE_ACCESS(client, user, action, resource)
§11 C++ analog of the per-language require_access macro.
Definition guard.hpp:125
void require_role(const std::optional< AxiamUser > &user, std::initializer_list< std::string > any_of)
§11 require_role — local check against the verified token's roles.
Definition guard.hpp:52
const AxiamUser & require_auth(const std::optional< AxiamUser > &user)
§11 require_auth — endpoint requires an authenticated identity.
Definition guard.hpp:42

require_access propagates subject_id = user.user_id (§11.2), fails closed on transport errors (§11.5), and never caches decisions (§11.6).

Decision reason codes (§11 rule 9)

Every AccessDecision — from check_access, can, and each element of batch_check — carries a reason_code alongside allowed:

axiam::ReasonCode:: value meaning
kAllowed allowed an allow grant matched and no deny did
kNoGrant no_grant nothing matched — default deny
kDeniedByRule denied_by_rule an explicit deny rule matched and overrode any allow

The two refusals are both allowed == false, but they mean opposite things to the person on the other end: no_grant says ask an admin for access, denied_by_rule says an admin has already decided. Branch on the code when you are telling a user what to do next:

axiam::AccessDecision d = client.check_access("docs:edit", doc_id);
if (!d.allowed) {
show_request_access_button();
else
show_plain_denied_message(); // a rule, or a code we don't know
}
static constexpr const char * kNoGrant
Nothing matched — default deny. Tells the user to ask an admin for access.
Definition types.hpp:80

Three things this field deliberately is not:

  • Not an enum class. An unrecognised code is surfaced verbatim, so the server can add a fourth code without turning every deployed client into a decode failure. Compare against the constants and let anything unknown fall through to a default branch.
  • Not the decision. The outcome is carried by allowed alone. Never re-derive allow/deny from the code.
  • Not guaranteed present. A server older than this clause omits the field and reason_code is std::nullopt — that is absence, not an error.

Enforcement is unchanged: require_access throws AuthzError (403) for both refusals. The clause is about reporting, and the guard must not vary its behaviour on the code.

Webhook signature verification (§13)

// `raw_body` MUST be the exact bytes received off the wire — never a
// re-serialization of parsed JSON, which changes key order and whitespace and
// breaks the MAC.
opts.event_type = request.header("X-Axiam-Event");
opts.delivery_id = request.header("X-Axiam-Delivery");
// opts.tolerance defaults to 300s, applied in BOTH directions.
auto result = axiam::webhook::verify(
request.header("X-Axiam-Signature"),
raw_body,
opts);
if (!result) {
return respond(400, result.error_message()); // typed, never leaks the MAC
}
// result.event.delivery_id is the at-least-once dedup key: a retry replays a
// valid signature inside the freshness window, so keep a short-lived seen-set
// if your handler is not idempotent.
handle(result.event);
Wraps secret material (access tokens, mTLS private keys).
Definition sensitive.hpp:26
Result verify(const Sensitive< std::string > &secret, const std::string &signature_header, const std::string &body, const Options &options=Options{})
Verify a webhook delivery.
Optional inputs to verify().
Definition webhook.hpp:65
std::string event_type
X-Axiam-Event, copied into the returned Event. Not covered by the MAC.
Definition webhook.hpp:81
std::string delivery_id
X-Axiam-Delivery, copied into the returned Event. Not covered by the MAC.
Definition webhook.hpp:84

verify_or_throw(...) is the exception-based twin (axiam::webhook::VerifyException).


Retry, memo, shutdown and telemetry (§16–§19)

Retry is on by default and applies only to operations that change no server state — check_access, can, batch_check and the JWKS fetch. That is not the same as "HTTP GET": the authorization check is a POST with a body and is the operation this policy exists for. login, verify_mfa, logout, refresh and authenticate_device are never retried automatically, both because they change state and because their credentials are single-use.

The policy is 3 attempts, 200 ms base, 5 s cap, full jitter over [0, backoff], and Retry-After honored as a floor — it can lengthen a wait, never shorten one, so a Retry-After: 0 cannot defeat the backoff. Only the switch is public; the attempt cap, base and cap are deliberately not settable, because §16.1 permits lowering the budget and never raising it.

auto client = axiam::Client::builder()
.base_url("https://iam.example.com")
.tenant_slug("acme")
.retry_enabled(false) // §16: one attempt
.decision_memo_ttl(std::chrono::seconds(5)) // §17: opt-in, off by default
.telemetry_hook([](const axiam::TelemetryEvent& ev) { // §19
if (const auto* r = std::get_if<axiam::RetryEvent>(&ev)) {
std::cerr << "retry " << r->operation << " attempt=" << r->attempt << "\n";
}
})
.build();
Builder & telemetry_hook(TelemetryHook hook)
§19: install a telemetry sink.
Builder & retry_enabled(bool enabled)
§16: enable or disable the bounded read-only retry policy.
Builder & decision_memo_ttl(std::chrono::milliseconds ttl)
§17: enable the client-side decision memo with a TTL.
std::variant< RequestStartEvent, RequestEndEvent, RetryEvent, RefreshEvent, ConfigClampedEvent > TelemetryEvent
One §19.1 event.
Definition telemetry.hpp:124

Read-your-own-writes is not guaranteed with the memo enabled. The staleness bound is the TTL in both directions: a grant revoked on the server can still read as allowed for up to the TTL, and a grant just added can still read as denied for up to the TTL. An admin UI that grants a role and immediately re-checks is the case that breaks, and it breaks silently. A TTL above 5 s is clamped to 5 s, and the clamp is announced through the ConfigClampedEvent rather than applied in silence.

TelemetryEvent is a closed std::variant over five structs with fixed member lists and no maps, which is what makes "no event carries a token" checkable by reading one declaration. Events carry the path template (/api/v1/authz/check), never a URL with ids substituted in — a metric label with a UUID in it is a cardinality bomb — and a retried call emits one RequestStartEvent/RequestEndEvent pair per attempt, so a caller can count real wire calls. The hook runs on the calling thread and must not block; buffering is yours to choose. A hook that throws cannot fail the operation that fired it.

close() releases the transport and its connection pool and clears the cookie jar, the CSRF token and the memo. It issues no request — it does not log out, because the server-side session deliberately outlives the client object. It is idempotent, and any operation attempted afterwards throws NetworkError naming the cause rather than silently reconnecting. The destructor releases whatever close() has not, so a Client that simply goes out of scope still frees its transport.


TLS & mTLS (§6 / §6.1)

Strict server verification is always on (CURLOPT_SSL_VERIFYPEER=1, CURLOPT_SSL_VERIFYHOST=2). There is no API to disable it — the only trust escape hatch is adding a custom CA.

The base URL must be https://: Client::Builder::build() throws std::invalid_argument for a plaintext http:// base, so a misconfiguration cannot silently send credentials, cookies, the CSRF token and the tenant header in cleartext. http:// is accepted only for the loopback development hosts localhost, 127.0.0.1 and ::1.

auto client = axiam::Client::builder()
.base_url("https://dev.axiam.local")
.tenant_slug("acme")
.org_slug("acme") // §5.1: org context alongside tenant
.with_custom_ca(dev_ca_pem) // §6: PEM only
.with_client_cert(device_cert_pem, device_key_pem) // §6.1: mTLS identity
.build();
auto device = client.authenticate_device(); // POST /api/v1/auth/device
Builder & with_custom_ca(std::string ca_pem)
§6: add a custom CA (PEM) to the trust chain.
Builder & with_client_cert(std::string cert_pem, std::string key_pem)
§6.1: present a client identity certificate (PEM chain + PEM key) for mutual TLS.
DeviceAuth authenticate_device()
§6.1 device / service-account authentication via the configured mTLS client certificate (POST /api/v1...

The custom CA and the client identity are passed to libcurl as in-memory blobs (CURLOPT_CAINFO_BLOB, CURLOPT_SSLCERT_BLOB, CURLOPT_SSLKEY_BLOB) — no temporary files touch disk. The mTLS private key is held behind axiam::Sensitive<T> and never logged.

with_custom_ca / with_client_cert accept PEM only; a non-PEM value throws std::invalid_argument at construction.


Build from source

cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug -DAXIAM_BUILD_TESTS=ON
cmake --build build -j
ctest --test-dir build --output-on-failure

Coverage (clang / llvm-cov or gcc / gcov): configure with -DAXIAM_ENABLE_COVERAGE=ON.


Deferred / follow-ups

  • gRPC transport (Tonic-parity authz checks). The §6.1 "both transports" rule applies once gRPC lands; the REST client already isolates TLS material for reuse.
  • §8 AMQP HMAC consumer (not required of C++ by the contract).
  • §12 OIDC relying-party surface, and with it the three sections built on top of it: §12.7 RP-initiated and back-channel logout, §14 the device authorization grant (RFC 8628), and §15 token exchange (RFC 8693).

    This SDK ships no OIDC layer — no discovery-document cache, no token endpoint, no ID-token validation, no PKCE. Each of those sections needs it directly: §12.7's logout_url must read end_session_endpoint from discovery (the clause exists precisely to forbid concatenating onto the issuer), §14 must read device_authorization_endpoint from discovery and then poll the token endpoint, and §15 is a token-endpoint grant requiring confidential-client authentication. Adding them means designing an OIDC stack for C++, not extending an existing one, so they are tracked here rather than half-shipped.

    What is implemented from the same area is local JWT/JWKS verification (§10.1), which the route guards need and which does not depend on discovery. Note also that DeviceAuth / authenticate_device() is §6.1 mTLS device authentication, not the §14 device authorization grant — different mechanisms that share a word.

  • Framework adapter samples for Crow / Pistache (the guard interface is already framework-agnostic).