|
AXIAM C++ SDK 1.0.0-alpha8
Authentication, authorization, JWKS & route guards (REST + mTLS)
|
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_infooperation (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/userinfoendpoint is not substituted for the gRPC operation.
axiam — library target axiam_cpp (CMake axiam::axiam_cpp).include/axiam/; umbrella header #include <axiam/axiam.hpp>.third_party/nlohmann/json.hpp).1.0.0-alpha24.Or, against an installed copy:
An in-repo port lives at ports/axiam-cpp-sdk. Point vcpkg at it with an overlay:
The conanfile.py requires libcurl, openssl, and nlohmann_json.
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).
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.
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
AxiamUserfromJwksVerifier::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.
require_access propagates subject_id = user.user_id (§11.2), fails closed on transport errors (§11.5), and never caches decisions (§11.6).
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:
Three things this field deliberately is not:
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.allowed alone. Never re-derive allow/deny from the code.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.
verify_or_throw(...) is the exception-based twin (axiam::webhook::VerifyException).
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.
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
ConfigClampedEventrather 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.
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.
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.
Coverage (clang / llvm-cov or gcc / gcov): configure with -DAXIAM_ENABLE_COVERAGE=ON.
§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.