axiam-sdk (Python)
Official Python client SDK for AXIAM — Access eXtended Identity and Authorization Management.
Platform documentation: https://ilpanich.github.io/axiam/ — getting started, the authorization model, the OAuth2/OIDC surface, and the operations guides. This README covers the SDK; the site covers the server it talks to.
Package identity
- Repository: github.com/ilpanich/axiam-python-sdk
- PyPI package:
axiam-sdk - Registry: pypi.org/project/axiam-sdk
- Version tags:
vX.Y.Z - Stability: stable from 1.0.0, versioned by Semantic Versioning: a contract record may add a member or widen a type in a minor release, and a removal (or any other breaking change to the public API) waits for the next major version, 2.0.0
- API docs: ilpanich.github.io/axiam-python-sdk
- License: Apache-2.0
- Python:
>=3.10(D-11) — see Supported Python versions
Contract conformance
This SDK (1.1.1) conforms to contract 1.63: CONTRACT.md §1–§13 and §12.7, §14, §15, §17, §19, §20, §21, §22, §23, §24, §25, §26, §27, §28, §28.12, §29, §30, §31, §32, §33, §34 and §36 (the P1 – P12 clarifications, as contract 1.60 amends them), with §32.7 and §33.2 signed (including §6.1 mTLS, the §10.1 minimum local-verification set, and §1.1.1/§10.3's gRPC token operations). §12 is implemented in full at its 1.38 shape: all thirteen operations, including the four public "Sign in with X" entry points, on both clients.
CONTRACT.md's Conformance Statement for §36 (network segments, reply profiles and the RADIUS decision, a §27 namespace, ported from the merge commit that carries the routes):
This SDK conforms to CONTRACT.md §1–§13, §14, §15, §17, §19, §27, §28, §28.12, §29, §30, §31, §32, §33 and §36.
Manifest pruning: not offered in any namespace (§27.10; pruning is a MAY, §27.6 rule 4).
§12.7, §14, §15, §20, §22, §24, §25, §26, §27, §28, §28.12, §29, §30, §31, §32, §32.7
and §33 are named rather than folded into the range because they landed after this
SDK already claimed §1–§13: widening the range silently would turn a statement that
was true when written into a different claim without anyone editing it. The §21.3.1
amendment of contract 1.58 (the seventh mtls_endpoint_aliases member,
backchannel_authentication_endpoint) is decoded and honoured on an mTLS CIBA call.
Contract 1.59 adds no section: its §34 clarifications (P1 – P12) bind §28.12 – §33's
behaviour, and this SDK's follow-up (F-59-03) implements the ones its review named —
poll returns what it judged and lists the SETs a
failed key fetch or replay store left unjudged (P1, second form), a 5xx on ciba_poll is
transient whatever its body (P8), an unknown union arm renders for a log line (P12.2), and
a None write-only secret is omitted, never sent as null (P12.3).
Contract 1.62 — the 1.1.0 release (§34.6)
Contract 1.62 adds one management namespace, network, and changes how a device client
authenticates; the rest is two request-member rules and a read-modify-write form. In this
SDK, which releases it as 1.1.0 (1.0.1 was never tagged, so the 1.61 fields below ship
in the same release):
- §36 —
client.network(andclient.management.network, on both clients): twelve generated operations,list_segments,create_segment,get_segment,replace_segment,delete_segment,list_profiles,create_profile,get_profile,replace_profile,delete_profile,get_policyandreplace_policy— 202 operations across 29 namespaces. Every write is conditional on theversionyou read and sent exactly (delete_segment(id, version)anddelete_profile(id, version)take it as anint; a profile write also carriessegment_version), is never retried, and a409is aConflictErrorwhosereason_code(stale_version,strands_profile,has_children,site_gone,duplicate_name,duplicate_vlan,duplicate_rank,profile_exists,other, or a value this SDK has not seen, verbatim) says which — branch on it, never on the message. A reply profile is read as its effective profile (session_timeout_secsismin(stored, cap),session_timeout_clampedsays so), and a clamped profile cannot be written back to restore its stored timeout: raise the cap first. The decision routePOST /api/v1/network/decideis FreeRADIUS's, not an SDK's, and has no method here. - §27.6.2 —
networkin the manifest.ManagementManifest(network=NetworkSpec(policy=, segments=, profiles=)). A segment is keyed by site + kind + name withkindthe wire spelling,"access"or"quarantine"(this SDK has no other spelling); a profile by its segment; the policy is a singleton.planreadsget_policy,list_segmentsandlist_profilesand judges every stated member on the value as read — a profile's effective timeout included, so a statedsession_timeout_secsabove the cap is anUpdatewhoseapplythe server refuses with403(reported as given;applystops, nothing is lowered or retried).filter_idsis compared as a set (a reorder isNoChange; a repeated Filter-Id in one segment is refused atplanwith no request) and sent in the order you state. An unstated member is carried from the read; a member statedNoneis left out of the replacement body (UNSETis the unstated value). Order: policy, segments, profiles. A profile'ssegment_versionis theversionof thisapply's own segment write. A409fails that action —StepOutcome.reason_code/ManifestFailure.reason_code— and re-runningapplyis the recovery. No pruning: this SDK prunes nothing in any namespace, so no segment or profile is ever deleted by a manifest. - §14 — the device grant authenticates its client. A client built with a
client_secretsends it as theclient_secretform member atdevice_authorizeand at every poll (neverAuthorization: Basic, whichever way it is registered); a client with a §6.1 certificate presents it on both; a public client sends no credential and is not refused; the poll sendsclient_id; a401 invalid_clientis anOAuthProtocolError, terminal and never refreshed. See Device authorization grant. - §32.2 —
management.ssf_stream_edit(stream, changes), the change-taking read-modify-write form: it sendsevents_requestedandexpected_updated_atas read (the server's own string;[]stays[]) and cutsevents_requestedto a loweredevents_allowed.ssf_stream_inputstays, and its documentation names the caller's duty;update_stream's documentation states the three meanings ofevents_requested— absent resets to every allowed event,[]is none, a list is stored as given and refused outsideevents_allowed. - §27.11 rule 5 — held already. An assigned
Noneonuser_id(certificates.generate,sign_csr) orexpected_updated_at(ssf.update_stream,scim_targets.update) is "none": the key is omitted,nullis never sent. The requestuser_idis a plainstr | None, so a malformed value reaches the server and its400is raised asValidationError.
Contract 1.61 — the 1.0.1 fields (§34.5)
Contract 1.61 is additive and clarifying: no route and no operation, one new optional member on each of three models, and a rule about what a read-modify-write sends. In this SDK:
- §27.11 rule 5 —
Certificate.user_id(andGeneratedCertificate.user_id): a UUID string,nulland an absent member all decode, the last two asNone("no account named"), never synthesised frommetadata. OnCreateCertificateRequestandSignCertificateCsrRequestit is sent exactly as given, and the key is absent when you name no account — an assignedNoneis dropped, never sent asnull. The SDK does not judge the value; the server answers400. - §32.2 / §32.3 rule 4 —
SsfStreamInput.expected_updated_at, sent exactly as the stream'supdated_atwas read (not re-formatted) and absent when unset. A stream written since — a receiver's pause or narrowing included — answers409(ConflictError), which is never retried.ssf_stream_inputfills it with theupdated_atit read and always carriesevents_requestedas read,[]as[](absent would reset the receiver's narrowing to every allowed event); if you lowerevents_allowedyourself, cutevents_requestedto it. - §33.3 rule 16 —
CibaPendingList.total_pendingandtruncatedare not modelled: the type belongs to the console'sGET /api/v1/ciba/requestsroute, which no registry operation returns and no SDK drives.
Contract 1.60 — what changed here
Contract 1.60 adds no section; it answers the questions the 1.59 ports raised (§34.4) and writes the 1.0.0 server changes into the sections they live in. In this SDK:
- §32.7 / §34.2 P1, P3, P4, P6 — a replay store that cannot answer makes
verify_setraiseNetworkErrorwith the store's exception as its__cause__; in apollbatch the store is then asked nothing more, a later SET that fails steps 1–8 is still refused, and one that passes them is unjudged. The SSF key cache expires after 300 s (the contract's bound is 10 minutes), and a failed fill or a failed refresh of an expired cache counts toward the once-a-minute fetch limit. - §19.1 — the
ssf_unjudgedevent (SsfUnjudged): apollthat returns leaving SETs unjudged says so to the telemetry hook — how many, and whether a key fetch or the replay store left them — with nojti. - §34.2 P12.1 — an unknown union arm keeps its discriminator and nothing else.
- §31 —
ScimTargetInput.expected_updated_at, sent exactly as given;scim_target_inputfills it with theupdated_atit read, so an update another administrator overtook is a409(ConflictError) rather than a silent overwrite. - §27.15 —
window_minuteson the notification rules, passed through and never clamped; the federation configuration'sallow_sha1_signatures(absent decodes asFalse) andidp_metadata_signing_cert_pem; and onfederation.update_configan explicitNoneon any of the ten nullable members sendsnulland clears it, while a member left unset is not sent and stays as stored. - §12.1 / §21.5 — an
oidc_refreshresult carries the response'sscope, which may be narrower than the grant's;OidcConfigurationdecodes the four revocation and introspection discovery members as optional. - §1.1.1 rule 7 —
introspect_tokenreturnsactive: Falsefor a user whose account may no longer sign in; it never raises for it. - §15.2 rule 9, §8 — the actor token comes from the same client's
client_credentialsgrant; a broker confirm is not evidence AXIAM saw a message.
§28 (MCP resource-server helpers) is SHOULD-level, additive and off by default: a
guard built without resource_metadata_url is byte-for-byte what it was before §28
existed. See MCP resource-server helpers (§28).
Contract 1.53 – 1.58 — what this SDK ships
Everything below is on both AxiamClient and AsyncAxiamClient, under the same
names. Nothing in these sections is carved out.
| Section | Here |
|---|---|
| §28.12 RFC 7592 client configuration | read_client_registration, update_client_registration, delete_client_registration; ClientRegistration — see RFC 7592 client configuration |
| §29 SAML service providers | client.saml — eleven generated operations with the call-site notes, ParseSamlSpMetadata.from_url / .from_xml, management.saml_service_provider_input |
| §30 directory | client.directory — six generated operations; bind_secret a SecretStr; an explicit None on update sends null for group_base_dn / group_filter, while a None secret is omitted (kept), never null; management.set_directory_config |
| §31 outbound SCIM targets | client.scim_targets — six generated operations; credential a SecretStr; management.scim_target_input |
| §32 SSF streams | client.ssf — five generated operations; authorization_header a SecretStr; management.ssf_stream_input and, from contract 1.62, the change-taking management.ssf_stream_edit |
| §36 network segments, reply profiles and the cap | client.network — twelve generated operations; ConflictError.reason_code; the decision route is not SDK work |
| §32.7 SSF receiver helper | axiam_sdk.ssf.SsfReceiver / AsyncSsfReceiver — verify_set, poll — see SSF receiver |
| §33 CIBA | ciba_initiate, ciba_poll, ciba_await, ciba_handle_ping — see CIBA |
| §33.2 signed request | CibaRequestSigner (PS256, ES256, EdDSA; the caller's key and algorithm, no defaults) |
Contract 1.58 also made PyJWT[crypto] (and cryptography) a declared runtime
dependency: EdDSA verification and every signing path need cryptography, which a
plain PyJWT install does not bring.
Contract 1.51 — the dogfooding remediation
The claude_dev/dogfooding-findings-fix-plan.md
plan's C-3 task. Shipped:
| Item | Shipped as |
|---|---|
| §5.2 rule 1 — the acting tenant | AxiamClient(acting_tenant=...) / AsyncAxiamClient(acting_tenant=...) at construction; client.acting_tenant(uuid) / client.clear_acting_tenant() on an existing client (Python's own handle idiom — see Acting on another tenant) |
| §6.1 rules 6–10 — the mTLS device login | AxiamClient.authenticate_device() / AsyncAxiamClient.authenticate_device() — see The mTLS device login |
§1.1.1 / §10.3 — gRPC validate_token / introspect_token |
AuthzGrpcClient.validate_token()/.introspect_token() and the AsyncAuthzGrpcClient twins, on the same channel check_access/get_user_info already use |
| §10.1 rule 9 at the default entry point | Fixed, breaking. JwksVerifier.verify_access_token now refuses any cnf-bearing token; the new verify_with_proofs is the accept-with-evidence path. See Local token verification (§10.1) |
§27.6.1 — resources[].metadata |
Shipped: ResourceSpec(metadata=...) |
| §27.6.1 — the resource-scoped, non-inheriting role binding | Shipped: ScopedRoleBinding(role=, resource=, inherit=False), anywhere a RoleBinding is accepted (GroupSpec.roles, UserSpec.roles, ServiceAccountSpec.roles) |
§27.6.1 — service_accounts in the manifest |
Shipped: ServiceAccountSpec, ManagementManifest(service_accounts=...), axiam_service_account |
§27.6.2 — network in the manifest |
Shipped (contract 1.62): NetworkSpec, NetworkSegmentSpec, NetworkProfileSpec, NetworkPolicySpec, ManagementManifest(network=...) |
§27.6 webhooks |
Declined. The contract names it and leaves it unspecified; no consumer has asked for it |
§27 is the management API — 202 administrative operations across 29 namespaces,
generated from the vendored management-registry.json
and re-checked against it in CI. See Management API (§27).
See CONTRACT.md for the full cross-language behavioral contract.
Status
Implemented (Phase 19). AxiamClient (sync) and the dedicated
AsyncAxiamClient (async, SDK-Q08) each expose the same canonical operation
names — login, verify_mfa, refresh, logout, check_access, can,
batch_check, authenticate_device (§6.1, contract 1.51), and the thirteen
§12 OIDC/SSO relying-party operations (see below) — as sync or async def
methods respectively (never an async_*-prefixed twin on the sync class).
Both also accept acting_tenant= at construction and offer
acting_tenant(uuid)/clear_acting_tenant() on an existing client (§5.2
rule 1, contract 1.51). Each client owns its own session, cookie jar, and
single-flight refresh guard. gRPC (sync grpcio + async grpc.aio) covers
check_access/batch_check, get_user_info, and — contract 1.51 —
validate_token/introspect_token, all on the same channel. AMQP
(async-only aio-pika), a FastAPI dependency plus an oidc_login_router,
and a Django middleware plus oidc_login_views, are all available. Both
framework surfaces also carry the §28 MCP resource-server helpers —
protected_resource_metadata/bearer_challenge (framework-agnostic) plus a
serve_protected_resource_metadata per framework — opt-in and off by
default. Seven runnable examples live under examples/.
Installation
pip install axiam-sdk
The FastAPI dependency and Django middleware are optional extras — install only what you need, since a pure REST/gRPC/AMQP consumer should not be forced to pull in FastAPI or Django:
pip install "axiam-sdk[fastapi]"
pip install "axiam-sdk[django]"
[speed] adds uvloop for async workloads — measured at −20% client CPU and
a materially tighter p95 on the check_access path. The SDK never installs a
loop policy for you; see PERFORMANCE.md, which also explains
why a single CPython process tops out around 310 checks/s and what to do about
it:
pip install "axiam-sdk[speed]"
from axiam_sdk import AxiamClient
Supported Python versions
| Version | Why this one | |
|---|---|---|
| Floor | 3.10 | The oldest interpreter still receiving upstream security fixes. requires-python = ">=3.10", so pip refuses to install below it. |
| Newest | 3.14 | The newest GA interpreter (released 2025-10-07). |
Everything between the two — 3.11, 3.12, 3.13 — is supported and carries a trove classifier.
The SDK is built against the floor, and runs on everything up to the newest.
Those are two different claims and CI proves both of them separately: the
gating matrix in sdk-ci-python.yml runs the full test suite on 3.10 and
on 3.14 (D-18). The floor leg is what stops a 3.12-only stdlib API or a
match statement from slipping in; the newest leg is what catches a removal
or a deprecation that has become an error. A version between two green legs is
interpolation, which is why the matrix is two legs rather than five — 3.12
additionally runs the whole suite under the Coverage and docs workflows.
If you are on a newer interpreter than the floor, you do not need to do anything: the published wheel is pure Python and version-agnostic, and the newest leg is the evidence that it works.
tests/test_language_version_policy.py fails the build if requires-python,
the trove classifiers and the CI matrix ever stop agreeing with each other —
they are three independent declarations of the same fact and nothing else
compares them.
Quickstart
Login + MFA (§1, §5) — sync AxiamClient or async AsyncAxiamClient
AxiamClient (sync) and AsyncAxiamClient (async, SDK-Q08) are separate
classes, each with their own session — pick the one that matches your call
site's paradigm.
from axiam_sdk import AxiamClient
# tenant_slug is required — AXIAM is multi-tenant and there is no default
# tenant (§5). login/refresh also require organization context (§5.1) — a
# tenant slug is only unique within an org — so pass org_slug too. TLS is
# always verify=True (§6); the only escape hatch is an explicit custom_ca
# parameter, never a boolean bypass.
with AxiamClient(base_url="https://localhost:8443", tenant_slug="acme", org_slug="acme") as client:
result = client.login(email, password)
if result.mfa_required:
result = client.verify_mfa(result.mfa_token, totp_code)
print(result.session_id, result.expires_in)
import asyncio
from axiam_sdk import AsyncAxiamClient
async def main() -> None:
async with AsyncAxiamClient(
base_url="https://localhost:8443", tenant_slug="acme", org_slug="acme"
) as client:
result = await client.login(email, password)
if result.mfa_required:
result = await client.verify_mfa(result.mfa_token, totp_code)
print(result.session_id, result.expires_in)
asyncio.run(main())
REST authorization checks — check_access / can / batch_check (§1)
result = client.check_access("resource:read", resource_id)
can_write = client.can("resource:write", resource_id)
from axiam_sdk import AccessCheck
results = client.batch_check(
[
AccessCheck(action="resource:read", resource_id=resource_id),
AccessCheck(action="resource:delete", resource_id=resource_id, scope="admin"),
]
)
AsyncAxiamClient exposes the same check_access/can/batch_check names
as async def methods, each backed by that client's own session and
single-flight refresh guard (§9). See
examples/rest_authz.py.
gRPC authorization checks (§1, §5, §9, §6)
AuthzGrpcClient (sync, grpcio) and AsyncAuthzGrpcClient (async,
grpc.aio) are both first-class transports — the async client is not a
thread-pool bridge over the sync one.
from axiam_sdk.grpc import AuthzGrpcClient
client = AuthzGrpcClient(
"localhost:9443",
token_fn=lambda: current_access_token, # non-blocking cache read
tenant_id=tenant_id,
refresh_fn=refresh_fn, # invoked exactly once on UNAUTHENTICATED, then one retry (§9.3)
)
decision = client.check_access(subject_id, "resource:read", resource_id)
See examples/grpc_checkaccess.py.
gRPC-only userinfo — get_user_info (§1.1)
get_user_info is the low-latency gRPC counterpart of the server's REST
GET /oauth2/userinfo endpoint (CONTRACT.md §1.1, contract 1.3). It has no
REST form in the SDK vocabulary. The request is empty — identity is derived
entirely server-side from the bearer token — and it returns a typed
UserInfo(sub, tenant_id, org_id, email, preferred_username). email is
populated only when the access token carries the email scope and
preferred_username only with the profile scope (both None otherwise);
sub/tenant_id/org_id are always present. Calling it with no token raises
AuthError client-side without a wire call, and a gRPC UNAUTHENTICATED
drives the same single-flight refresh-and-retry-once path as check_access
(§9). It is exposed as get_user_info() on both AuthzGrpcClient (sync) and
AsyncAuthzGrpcClient (async).
info = client.get_user_info()
print(info.sub, info.tenant_id, info.org_id, info.email, info.preferred_username)
gRPC token validation — validate_token / introspect_token (§1.1.1, §10.3, contract 1.51)
The gRPC-only wrappers around TokenService/ValidateToken and
/IntrospectToken, on the same channel and interceptor check_access/
get_user_info use. The caller's own token authenticates the call
(metadata, via the interceptor); the token being inspected is the
argument, and is never defaulted to the caller's own:
result = client.validate_token(some_other_access_token)
if result.cnf is not None:
# Sender-constrained: `valid: True` is NOT "usable as presented" (§10.3
# rule 2). Prove possession before trusting it.
result.verify_possession(certificate_thumbprint=peer_thumbprint)
print(result.valid, result.subject_id, result.tenant_id, result.token_type)
full = client.introspect_token(some_other_access_token) # RFC 7662 set + cnf + permissions
result.cnfdecides boundness — nevertoken_type, which reads"Bearer"for a certificate-bound token too (rule 5).verify_possession(certificate_thumbprint=, dpop_thumbprint=)applies CONTRACT.md §10.1 rule 9 with your evidence; called with neither, it raises for any bound token — the same rule the localJwksVerifierapplies, so a gRPC-validating guard and a JWKS-verifying one never disagree about whether a token is a bearer token.- A no-token call (this client's own) raises
AuthErrorclient-side, with no wire call, exactly likeget_user_info.
AMQP event consumer (§8)
from axiam_sdk.amqp import ErrDrop, consume
async def handler(event: dict) -> None:
if "action" not in event:
raise ErrDrop("poison message") # nack without requeue
... # None return -> ack; any other exception -> nack with requeue
await consume(channel, "axiam.authz.request", signing_key, handler, prefetch=10)
Every delivery's HMAC-SHA256 signature is verified BEFORE the handler is
ever invoked — an unverified message never reaches your code. See
examples/amqp_consumer.py.
A broker confirm is not evidence that AXIAM saw a message (§8, contract
1.60). A publisher confirm, or the broker's basic.ack of a publish, means only
that the broker accepted the message; it never means AXIAM decided the request
or recorded the event. A server running in the minimal profile
(AXIAM__AMQP__ENABLED=false) reads no AMQP queue at all — it does not consume
axiam.authz.request or axiam.audit.events, whatever a broker holds — so this
SDK does not treat a confirm as evidence of that, and neither should you.
Against a minimal-profile server use REST or gRPC; GET /health reports
profile: minimal and lists amqp_authz and amqp_audit_ingestion under
unavailable.
Reactors — AMQP extension actors (§22)
A reactor is an external process that subscribes to named hook events on the AMQP bus and answers back — allow, deny, or a field-allow-listed mutation — inside a timeout the server declared. It is AXIAM's answer to Zitadel Actions and Keycloak SPIs, and the difference is the whole design: those load third-party code into the authorization server, and this keeps it outside, reachable only through a signed reply schema the server validates before it believes a word of it.
from axiam_sdk.amqp import (
LOGIN_POST_AUTH,
TOKEN_PRE_ISSUE,
ReactorConfig,
ReactorDecision,
ReactorEvent,
aio_pika_dialer,
allow,
deny,
mutate,
reactor_serve,
)
async def decide(event: ReactorEvent) -> ReactorDecision:
# token.pre_issue is mutable — the `ext.` namespace, and nothing else.
if event.event == TOKEN_PRE_ISSUE:
return mutate({"ext.cost_center": "42"})
# login.post_auth is veto-only, plus step-up.
if event.event == LOGIN_POST_AUTH and event.payload.get("ip", "").startswith("198.51.100."):
return deny("embargoed region")
return allow()
await reactor_serve(
aio_pika_dialer("amqps://reactor:secret@broker.example.com:5671"),
ReactorConfig(
tenant_id="11111111-1111-1111-1111-111111111111",
reactor_id="99999999-9999-9999-9999-999999999999",
signing_key=subkey, # the tenant's HKDF-derived AMQP subkey, never the master key
),
decide,
)
Binding handlers per event (§22.14)
The if chain above is the shape every multi-event reactor grows, and its last line —
return allow() — answers on behalf of code that never ran. That is the defect §22.10
rule 2 forbids the runtime from committing, relocated into your file where the rule does
not reach it: an operator who set fail_closed on the registration has it defeated there.
ReactorRouter is §22.14's declarative form, in the spirit of the §11 declarative
authorization helpers:
from axiam_sdk.amqp import LOGIN_POST_AUTH, TOKEN_PRE_ISSUE, ReactorRouter, reactor_serve
router = ReactorRouter()
@router.on(TOKEN_PRE_ISSUE)
def enrich_token(event: ReactorEvent) -> ReactorDecision: # sync or async, both work
return mutate({"ext.cost_center": "42"})
@router.on(LOGIN_POST_AUTH)
async def screen_login(event: ReactorEvent) -> ReactorDecision:
return deny("embargoed region") if await embargoed(event) else allow()
await reactor_serve(dialer, config, router.handler())
- A misspelled event is refused when you bind it —
ReactorRouteraccepts only §22.5 registry names, which is also how it refuses the three hot-path operations §22.7 excludes: they are in no registry row. - An unbound event abstains — no reply, and the registration's
failure_policydecides (§22.8), exactly as it decides a timeout. Never a synthesizedallow. - A duplicate binding raises rather than silently overwriting, and
router.eventsfeedsdefault_failure_policy_forso you can see what an unreachable reactor costs before you go live.
For a class-based reactor, mark the methods and collect them — bound methods keep their instance:
from axiam_sdk.amqp import on_reactor_event, reactor_handlers
class Reactor:
@on_reactor_event(TOKEN_PRE_ISSUE)
def enrich(self, event: ReactorEvent) -> ReactorDecision: ...
handler = reactor_handlers(Reactor()) # or reactor_handlers({TOKEN_PRE_ISSUE: fn})
It is pure sugar: the value it produces is exactly the handler reactor_serve already
takes. It opens nothing, verifies nothing, signs nothing, does not filter a patch, and a
handler's own exception reaches the runtime unchanged so nothing is published.
See examples/reactor.py for a complete three-hook reactor with
graceful shutdown and a telemetry hook.
The five hookable events, and their allow-lists
| Event | Mutable | Complete allow-list | Default failure policy |
|---|---|---|---|
token.pre_issue |
yes | the ext. namespace only |
fail_open |
login.post_auth |
no | — (veto, or require_mfa) |
fail_closed |
user.pre_create |
yes | username, email, metadata. |
fail_closed |
user.pre_update |
yes | username, email, metadata. |
fail_closed |
grant.pre_assign |
no | — (veto only) | fail_closed |
An entry ending in . is a namespace prefix and needs at least one character after the
dot: ext. admits ext.department and ext.a.b.c, and refuses ext. itself, ext, extra,
external_id and evil.ext.department. So a reactor can never reach sub, aud, exp,
scope or any other standard claim — a correctly signed reply setting sub is refused
exactly as a forged one is.
Registrations that name no failure_policy get the strictest default among their events,
in either array order — default_failure_policy_for([...]) computes it, and "take the first
event's default" is specifically what §22.8 forbids, because it lets the order of a JSON array
decide whether an unreachable fraud check passes.
The authorization hot path is not hookable, and this SDK does not pretend otherwise
The single check, the batch check and token introspection are absent from EVENT_REGISTRY,
from REACTOR_EVENT_NAMES and from every example here (§22.7, a normative MUST NOT). A
reactor round-trip is milliseconds; the check path's budget is microseconds. An application
that needs external input on an authorization decision writes a deny grant, which the
engine evaluates in the hot path at hot-path cost — and there is deliberately no client-side
interceptor in this SDK offering itself as the reactor equivalent.
What the runtime guarantees
- Both directions are signed. The server signs the event with the tenant's HKDF-derived
AMQP subkey; the reactor signs its reply with the same key. An unsigned or stale reply is
not a weak reply — the server discards it as though the reactor had never answered. Every
event is verified (
key_version >= 2, MAC, ±300 s freshness in both directions, nonce seen-set) before your handler is called. - Three canonicalization traps, all of them silent failures if missed. A reactor body
signs
hmac_signatureasnull, where §8's own two message types omit it;json.dumpsmust run withensure_ascii=False, becauseserde_jsonescapes no non-ASCII and Python escapes all of it by default; andissued_atischrono's RFC 3339 (…T12:00:00Z, no fraction on a whole second), notdatetime.isoformat()'s+00:00with six digits. All three are pinned by server-generated vectors rather than by memory — seetestdata/reactor_v2_reference_vectors.jsonandtests/test_reactor_vectors.py. - It declares no topology. No queue declare, no exchange declare, no bind — the server
owns all three, and the transport protocol this runtime is written against does not even
offer them. A reactor that can bind is a reactor that can bind itself to
*.token.pre_issueand read another tenant's issuance events. - It fails closed on its own errors. A handler that raises, a body that will not parse, a
window that has already closed: each publishes nothing, so the registration's
failure_policydecides. Synthesizing anallowwould override the operator'sfail_closedsetting from inside the library.abstain()is the explicit form of the same thing. - It does not filter your patch. One forbidden key rejects the whole patch server-side;
pruning it here would leave you believing a field was set when it was dropped. Check
yourself with
patch_field_allowed(spec, field)if you want to know before you send. - It honours
timeout_ms. The handler runs inside the window the server declared, and a reply whose window has closed is abandoned rather than published late. - Shutdown drains (§18). Cancel the
reactor_servetask; it stops taking deliveries, lets every dispatch already running finish — handler, signature, publish — and only then closes the channel and connection. - TLS is not optional (§8b).
aio_pika_dialeracceptsamqps://only and refuses a plaintext URL rather than downgrading.ca_bundle=takes a path or inline PEM for a privately-issued broker certificate; there is no verification-skip switch under any name.
Registering a reactor (§22.9)
Registration is a REST admin call, not part of this runtime:
curl -X POST https://axiam.example.com/api/v1/reactors \
-H "Authorization: Bearer $ADMIN_TOKEN" -H 'Content-Type: application/json' \
-d '{"name":"fraud-check","events":["login.post_auth"],"mode":"intercept","timeout_ms":500}'
The response's id is what reactor_id takes, and the server declares the queue.
timeout_ms defaults to 500 and is refused outside 1…5000; the chain's wall-clock
ceiling is 5000 ms and the per-tenant in-flight cap is 64. This SDK exposes those as
constants (DEFAULT_REACTOR_TIMEOUT_MS, MAX_REACTOR_TIMEOUT_MS,
DEFAULT_REACTOR_MAX_IN_FLIGHT) but ships no typed client for the CRUD endpoints — call
them through the REST client and let the server validate; §22.9 explicitly warns against
re-deriving PUT merge semantics or the failure_policy re-derivation client-side.
Logging
The payload, patch, reason and decision are tenant business data — readable by design,
since a handler that cannot inspect the event cannot decide anything, but this runtime never
logs them at info level and yours should not either (§22.12). The signing key is never logged
at any level and never appears in an error payload; signing_key_fingerprint() gives eight
hex characters for an operational log instead. nonce, correlation_id and hmac_signature
are not secrets and may be logged for correlation.
Local token verification (§10.1)
Both framework guards below verify the access token locally and therefore
apply the complete CONTRACT.md §10.1 minimum local-verification set, through
the single entry point JwksVerifier.verify_access_token(...):
| # | Claim | What this SDK does |
|---|---|---|
| 1 | signature | alg pinned to EdDSA and checked before any JWKS lookup, so alg: none and HS-family confusion are rejected without ever consulting a key |
| 2 | exp |
Required and must be a JSON number — a token with no exp is a permanent credential and is rejected, and a numeric string exp (which PyJWT would coerce) is rejected too |
| 3 | nbf |
Honoured when present; absent is valid |
| 4 | tenant_id |
Required and asserted against the configured tenant; no configured tenant fails closed |
| 5 | iss |
Checked only when expected_issuer is configured (optional, unset by default — no issuer is ever assumed) |
| 6 | aud |
Checked only when expected_audience is configured; a user-facing resource server should pass RECOMMENDED_RESOURCE_SERVER_AUDIENCE ("axiam:user") |
| 7 | clock skew | DEFAULT_CLOCK_SKEW_SECONDS (60 s), bounded by MAX_CLOCK_SKEW_SECONDS — never settable to an unbounded value |
| 9 | cnf (sender-constrained tokens, contract 1.51) |
A token carrying cnf is refused: this entry point has no transport evidence to accept a certificate- or DPoP-bound token with. JwksVerifier.verify_with_proofs(token, expected_tenant_id=, certificate_thumbprint=, dpop_thumbprint=) is the entry point that CAN accept one, given the evidence; verify_sender_constrained(...) is its certificate-only shape. An unbound token is unaffected either way — rule 9 constrains tokens that claim a constraint, not every token |
from axiam_sdk._jwks import (
DEFAULT_CLOCK_SKEW_SECONDS,
RECOMMENDED_RESOURCE_SERVER_AUDIENCE,
JwksVerifier,
)
verifier = JwksVerifier(
base_url,
expected_issuer="https://axiam.example.com", # optional
expected_audience=RECOMMENDED_RESOURCE_SERVER_AUDIENCE, # optional
clock_skew_seconds=DEFAULT_CLOCK_SKEW_SECONDS, # bounded
)
JwksVerifier.verify_signature_only_unchecked(...) is the raw signature-only
primitive §10.1 permits for integrators implementing their own policy. Its
name states the omission: it checks no claims at all, and the SDK's own
guards never call it.
The session-revocation feed (contract 1.44, CONTRACT.md §10.4, opt-in)
§10.2 records the gap this narrows: local verification proves a token was issued and has not expired, never that the session behind it still exists. A logout or a role removal does not reach a token already in a caller's hands until it expires — up to fifteen minutes. The documented answer has been "route the decision through gRPC introspection instead", which is correct and costs a round trip per request.
A deployment can publish GET /oauth2/revocations: the hashed ids of the
sessions revoked within the last access-token lifetime. Attach a poller and a
guard rejects a revoked session within one poll interval instead, for one
cacheable fetch per interval:
from axiam_sdk import JwksVerifier, RevocationFeed
verifier = JwksVerifier(
"https://iam.example.com",
expected_issuer="https://iam.example.com",
revocation_feed=RevocationFeed("https://iam.example.com"),
)
It is not a control, and every property follows from that. It is off unless
you pass one. It is never fetched on the request path once warm —
verify_access_token reads a cached set. And it never fails closed: an
unreachable feed, a non-200, a body that does not parse, or an alg this
build does not know all behave exactly as no feed at all — and specifically not
as an empty list, which would assert that nothing has been revoked and is a
guard silently honouring no revocations while appearing to honour them. Every
§10.1 rule runs first and still decides; the feed can only ever turn an accept
into a reject.
A token with no sid — a client-credentials token, an RPT, a token exchange —
is never matched against it. There is no session behind one, and hashing jti
instead would match nothing while looking like it worked.
The poll interval defaults to 30 seconds and is clamped to a 15-second floor;
the cached set is bounded at MAX_ENTRIES, and a document larger than the bound
is treated as unusable rather than truncated — a truncated set is a guard that
admits some revoked sessions and reports none.
RevocationFeed is thread-safe; share one instance across the guards that
should poll once between them rather than once each.
FastAPI dependency (§10) — axiam-sdk[fastapi]
from fastapi import Depends, FastAPI
from axiam_sdk.fastapi import AxiamUser, JwksVerifier, require_authenticated_user
verifier = JwksVerifier(base_url)
authenticated_user = require_authenticated_user(verifier, "acme")
app = FastAPI()
@app.get("/protected")
async def protected(user: AxiamUser = Depends(authenticated_user)):
return {"user_id": user.user_id, "tenant_id": user.tenant_id, "roles": user.roles}
See examples/fastapi_dependency.py.
require_authenticated_user verifies through
JwksVerifier.verify_access_token (CONTRACT.md §10.1 rule 9) — the guard has
no way to obtain mTLS/DPoP transport evidence from a framework request in a
generic dependency, so it refuses every device- or DPoP-bound (cnf)
access token outright, not only unbound ones. A caller who needs to accept a
sender-constrained token in FastAPI verifies with
JwksVerifier.verify_with_proofs/verify_sender_constrained directly,
supplying the certificate/DPoP proof this dependency cannot.
Django middleware (§10) — axiam-sdk[django]
# settings.py
MIDDLEWARE = [..., "axiam_sdk.django.middleware.AxiamAuthMiddleware"]
AXIAM_JWKS_BASE_URL = "https://localhost:8443"
AXIAM_TENANT_SLUG = "acme"
# Optional §10.1 rule 5-7 settings; all default to unset / the recommended value.
AXIAM_EXPECTED_ISSUER = "https://localhost:8443" # unset -> iss not checked
AXIAM_EXPECTED_AUDIENCE = "axiam:user" # unset -> aud not checked
AXIAM_CLOCK_SKEW_SECONDS = 60 # bounded by MAX_CLOCK_SKEW_SECONDS
# views.py
def protected_view(request):
user = request.axiam_user
return JsonResponse({"user_id": user.user_id, "roles": user.roles})
See examples/django_middleware.py.
AxiamAuthMiddleware verifies the same way, through
JwksVerifier.verify_access_token (CONTRACT.md §10.1 rule 9): it too has no
transport evidence to offer, so it refuses every cnf-bound access token
(device or DPoP) rather than accepting it unchecked.
Declarative authorization helpers (§11)
Layered on top of the §10 authentication guards above, require_access /
require_role add a per-endpoint AXIAM authorization check without hand-
writing check_access(...) calls in every handler. They run strictly
after authentication (never a separate/duplicated token-verification
path) and check the request's authenticated caller (subject_id), never
the SDK client's own — typically service-account — identity. Error
mapping: unauthenticated -> 401; denied -> 403; an
unresolvable resource id -> 400; a transport failure while calling the
authz endpoint -> 503 (fail closed — never allow on a transport error). No
decision caching: every request is a fresh check_access round-trip.
require_role is a local, no-round-trip check against the verified
identity's roles — cheaper but coarser, and NOT a substitute for
require_access's authoritative, resource-level check.
FastAPI (axiam-sdk[fastapi]) — require_access takes the async
AsyncAxiamClient:
from fastapi import Depends, FastAPI
from axiam_sdk import AsyncAxiamClient
from axiam_sdk.fastapi import AxiamUser, JwksVerifier, require_access, require_role
verifier = JwksVerifier(base_url)
authz_client = AsyncAxiamClient(base_url=base_url, tenant_slug="acme")
app = FastAPI()
require_doc_read = require_access(
verifier, "acme", authz_client, "documents:read", resource_param="doc_id"
)
@app.get("/docs/{doc_id}")
async def get_doc(doc_id: str, user: AxiamUser = Depends(require_doc_read)):
return {"message": f"user {user.user_id} may read document {doc_id}"}
require_admin_role = require_role(verifier, "acme", "admin")
@app.delete("/admin/cache")
async def reset_cache(user: AxiamUser = Depends(require_admin_role)):
return {"message": f"cache reset by {user.user_id}"}
The resource id is resolved, in precedence order, from a literal
resource_id= (singleton resources), a resource_param= path parameter
name, or a resolver=lambda request: ... callback (body fields, headers,
composite lookups) — exactly one must be supplied.
Django (axiam-sdk[django]) — require_access/require_role are view
decorators reading request.axiam_user (set by AxiamAuthMiddleware) and
take the sync AxiamClient:
from axiam_sdk import AxiamClient
from axiam_sdk.django.decorators import require_access, require_role
authz_client = AxiamClient(base_url="https://localhost:8443", tenant_slug="acme")
@require_access(authz_client, "documents:read", resource_param="doc_id")
def get_document(request, doc_id):
user = request.axiam_user
return JsonResponse({"message": f"user {user.user_id} may read document {doc_id}"})
@require_role("admin")
def reset_cache_view(request):
return JsonResponse({"message": f"cache reset by {request.axiam_user.user_id}"})
Both async and sync Django views are supported (require_access/
require_role detect the wrapped view's dispatch mode automatically).
resource_param defaults to "pk", matching the view kwarg Django's own
URL path converters typically bind a captured resource identifier to.
See examples/fastapi_dependency.py and
examples/django_middleware.py.
OIDC / SSO relying-party helpers (§12)
AxiamClient/AsyncAxiamClient expose the thirteen canonical §12 operations
directly (this SDK has no browser-bundle constraint, so — unlike the
TypeScript SDK's dedicated OidcClient — the methods live on the same
client used for everything else). They let a backend application offer
"Login with AXIAM" (authorization-code + PKCE against AXIAM's own OIDC
provider), authenticate itself as a service account (client_credentials),
introspect/revoke tokens, drive the server's upstream-IdP federation
endpoints, and — as of contract 1.38 — render and drive the public
"Sign in with X" buttons:
| Operation | Purpose |
|---|---|
oidc_discover() |
GET /.well-known/openid-configuration — cached per origin, ≥5-minute TTL, single-flight |
oidc_begin(...) |
Build the authorization URL + PKCE verifier/state/nonce — pure local computation, no network I/O |
oidc_exchange(...) |
POST /oauth2/token (authorization_code) — validates the returned ID token in full (§12.4) before returning |
oidc_refresh(...) |
POST /oauth2/token (refresh_token) — a distinct operation from refresh(), under the same §9 single-flight guard |
login_client_credentials(...) |
POST /oauth2/token (client_credentials) — service-account M2M login, no id_token |
introspect(...) |
POST /oauth2/introspect (RFC 7662) — requires confidential-client credentials |
revoke(...) |
POST /oauth2/revoke (RFC 7009) — idempotent; any 200 (including for an unknown token) is success |
sso_start(...) |
POST /api/v1/auth/federation/oidc/start — step 1 of upstream-IdP SSO |
sso_complete(...) |
POST /api/v1/auth/federation/oidc/callback — step 2; the session arrives via Set-Cookie, no token in the body |
sso_providers(...) |
GET /api/v1/auth/federation/providers — which "Sign in with X" buttons to render. Identifiers go in the query string, not a body. An empty list is a success — see below |
sso_start_oauth2(...) |
POST /api/v1/auth/federation/oauth2/start — step 1 through a plain-OAuth2 upstream (GitHub, Facebook, generic_oauth2). PKCE is mandatory here and is generated and held server-side, so this SDK computes no verifier and sends no challenge |
sso_complete_oauth2(...) |
POST /api/v1/auth/federation/oauth2/callback — step 2 of the OAuth2 variant; same Set-Cookie session and same post-login sync as sso_complete |
sso_complete_handoff(...) |
POST /api/v1/auth/federation/handoff — redeems the single-use axiam_handoff code the SAML and Apple flows deliver. Valid 60 s, redeemable once; a 401 is terminal and is never retried |
Both AxiamClient (sync) and AsyncAxiamClient (async, async def twins
under the same names, SDK-Q08) expose all thirteen — including oidc_begin,
which performs no I/O but is still async def on the async client, per
CONTRACT.md §12.2's Python naming table.
The four public login-provider operations, and their rules
An empty provider list is a success (§12.1 note 9). An unknown
organization, a known one with nothing configured, and a request naming no
workspace at all all answer 200 with an empty array. sso_providers
returns every one of them as an ordinary result and never raises: the endpoint
is deliberately shaped so it cannot be used to enumerate organization or tenant
slugs, and telling the three apart client-side would rebuild that oracle. For
the same reason sso_providers is the one federation operation that does
not refuse client-side when no workspace resolves — it sends the request.
You learn you named the workspace wrongly at the start operations, where every
failure is a uniform 401.
protocol selects which start operation to call (§12.1 note 10) — never
provider_kind, which is branding:
provider.protocol |
call |
|---|---|
OidcConnect (PROTOCOL_OIDC_CONNECT) |
sso_start |
OAuth2 (PROTOCOL_OAUTH2) |
sso_start_oauth2 |
Saml (PROTOCOL_SAML) |
the SAML login endpoint — not a §12 vocabulary operation |
The server refuses a mismatch with 400 rather than accepting it silently, so
a client that assumes OIDC fails on every GitHub button. An OAuth2 provider
also issues no ID token: the server authenticates by calling a configured
userinfo endpoint, so there is no signature, no nonce and no aud. A UI
rendering these buttons should make that distinction visible rather than
presenting the two as equivalent.
FederationProvider is modelled faithfully — id, provider_kind,
display_name, protocol, has_bundled_mark, inherited, and the optional
button_icon (a data: URL, None for most providers). Inheritance from the
organization is resolved server-side (§12.1 note 13): pass back the
workspace and the id sso_providers gave you, and compute nothing locally.
inherited is reported so an admin surface can show that a provider is not the
tenant's to edit.
A 400 from a start call is a configuration refusal (§12.1 rule 12a). On
the SAML and Apple flows the identity provider never validates the SPA
redirect_uri, so the server confines it to its own issuer origin plus
AXIAM__AUTH__SSO_SPA_ORIGINS. That refusal surfaces as NetworkError —
§2's 400 row, the taxonomy's configuration/programming-error member, as
distinct from the AuthError a 401 gets — and is not retried, because the
same origin will be refused again. Never build a redirect_uri out of anything
the identity provider supplied.
from axiam_sdk import HANDOFF_QUERY_PARAM, PROTOCOL_OAUTH2, PROTOCOL_OIDC_CONNECT
providers = client.sso_providers(org_slug=org_slug).providers
# Empty is normal: render a password form, not an error.
for p in providers:
if p.protocol == PROTOCOL_OIDC_CONNECT:
start = client.sso_start(federation_config_id=p.id, redirect_uri=redirect_uri)
elif p.protocol == PROTOCOL_OAUTH2:
start = client.sso_start_oauth2(federation_config_id=p.id, redirect_uri=redirect_uri)
# SAML / Apple come back through a handoff code on your own callback route.
code = request.GET[HANDOFF_QUERY_PARAM]
session = client.sso_complete_handoff(code=code) # once, never retried
The caller owns the login state (§12.3 rule 1). oidc_begin returns
state, nonce, and code_verifier and stores none of them anywhere — no
process-global cache, no implicit session. Persist all three yourself
(typically in your own HTTP session) between the login redirect and the
callback, and pass nonce/code_verifier back into oidc_exchange
explicitly. MemoryOidcStateStore (single-use consume, 10-minute TTL) is
available for framework integrations that need somewhere to park that
triple across the two HTTP requests of a redirect flow — it is optional and
per-instance, never process-global.
from axiam_sdk import AxiamClient, OAuthProtocolError, AuthError
client = AxiamClient(
base_url="https://localhost:8443",
tenant_slug="acme",
client_id="my-backend-app",
client_secret="changeme", # omit for a public client
)
configuration = client.oidc_discover()
request = client.oidc_begin(
configuration=configuration,
redirect_uri="https://app.example.com/oidc/callback",
scope="openid profile email",
)
# ... persist request.state / request.nonce / request.code_verifier,
# redirect the browser to request.url, and receive the callback ...
try:
tokens = client.oidc_exchange(
code=callback_code,
code_verifier=request.code_verifier,
redirect_uri="https://app.example.com/oidc/callback",
nonce=request.nonce,
tenant_id="00000000-0000-0000-0000-000000000000",
)
except OAuthProtocolError as exc:
print(f"{exc.error}: {exc.error_description}")
except AuthError as exc:
print(f"login failed ({exc.reason}): {exc}")
else:
print(tokens.id_claims.sub if tokens.id_claims else "no id_token")
OAuthProtocolError is a language-idiomatic sub-type of AuthError
(CONTRACT.md §2/§12.3 rule 3) — existing except AuthError: code keeps
matching it unchanged. It carries error/error_description and
str(exc) == "<error>: <error_description>". Every §12.4 ID-token
validation failure raises the plain AuthError with a stable
reason — one of invalid_alg, unknown_kid, invalid_signature,
invalid_issuer, invalid_audience, token_expired, nonce_mismatch.
access_token, refresh_token, id_token, client_secret, and
code_verifier are all pydantic.SecretStr (§7/§12.5) — never printed or
serialized in the clear; read the raw value via .get_secret_value().
state/nonce are plain strings (§12.3 rule 2 — not secrets).
Framework glue. axiam_sdk.fastapi.oidc_login_router(client, redirect_uri=...)
builds a two-route APIRouter (login redirect + callback); axiam_sdk.django.oidc.oidc_login_views(client, redirect_uri=...)
builds a (login_view, callback_view) pair sharing one state store. Both
delegate entirely to the operations above and to the existing session/cookie
machinery — see examples/oidc_login.py.
OPAQUE (§23)
login_opaque authenticates the password without sending it. What crosses the
wire is a blinded group element and a MAC, neither useful without the account's
registration record and the tenant's OPRF seed.
# Same LoginResult as login(), including the mfa_required case.
result = client.login_opaque("alice", "correct horse battery staple")
Unlike the SRP-6a this replaces, it returns without verifying a server proof
separately, and nothing is missing: RFC 9807's AKE authenticates the server
during the handshake, so opening KE2 is the proof that it holds the
record. The old contract had to mandate an M2 check in capitals because
skipping it kept only half the protocol; there is now nothing to skip.
Fall back to login() when the tenant does not offer OPAQUE. That case is a
NetworkError, deliberately not an AuthError, so it cannot be mistaken
for a bad password:
try:
result = client.login_opaque(user, password)
except NetworkError as exc:
if "opaque_mode is disabled" not in str(exc):
raise # a KSF this build cannot perform — not a fallback case
result = client.login(user, password)
AuthError from login_opaque is the whole of the authentication check, and
covers both halves of the mutual authentication: a wrong password, an account
that does not exist, an account with no registration record, and a server that
does not hold the record are indistinguishable by design. KE3 is never sent
once the envelope fails to open (§23.4 rule 7).
mode, and the one time falling back to login() is right
What follows a failed exchange is decided by the mode the login/start
response carries — the tenant's opaque_mode — and by nothing else. The SDK
handles it for you, on both clients:
mode |
after a failed KE2 |
|---|---|
"optional" |
login_opaque retries over POST /auth/login with the same credentials and returns that call's outcome — its success, or its error |
"required" |
AuthError; nothing is retried |
| unrecognised, or absent (a server older than contract 1.29) | as "required" — fail closed |
optional is the mode a tenant lives in for as long as its migration takes.
Every account has no registration record the moment an operator enables OPAQUE,
and acquires one only when its password is next set, so under optional a
failed exchange is the ordinary case rather than an error. An SDK that treated
it as final would lock out every user of the tenant — which would make enabling
optional indistinguishable from enabling required with nobody enrolled.
Under required the retry would be refused anyway: /auth/login answers
403 opaque_required for every principal in the tenant, before examining any
credential, so trying would put a plaintext password on the wire for nothing.
mode is not downgrade protection, and nothing here should be read as
though it were: a hostile server that wanted the plaintext could answer 404
and get the fallback whatever it puts in this field. required is what closes
that, server-side.
Enrolment
The server cannot build a registration record — it never sees the plaintext — so one has to be sent with any request that sets a password:
enrollment = client.opaque_enrollment("new password")
# send enrollment["registration_record"] and enrollment["opaque_session"]
# as the request's `opaque` object
It is async/one round trip because OPAQUE's envelope is sealed under the
server's oblivious PRF: there is no offline computation that produces a valid
record. Note the absence of an identity argument. The SRP version required
the account's canonical username, and passing an email produced a verifier no
login could ever satisfy; a record binds to a credential identifier the server
chooses, so there is nothing here to get wrong — and a later rename cannot
invalidate a credential.
There is also no group or kdf argument. The key-stretching function comes
from the */start response, every time: a credential enrolled under one cost
keeps working after a tenant raises its policy, so a client that used local
defaults would derive a different randomized password and fail against a good
record.
Installing
The protocol itself is not in this SDK. CONTRACT.md §23.1 forbids an SDK
from implementing OPAQUE — it needs an oblivious PRF, hash_to_curve,
expand_message_xmd, an envelope construction and a three-message AKE, and
eleven independent implementations of that is eleven chances to be subtly and
silently wrong. What ships here is a ctypes binding to
libaxiam_opaque_ffi, the same implementation the AXIAM server links.
That library is a Rust cdylib published as a per-platform asset on the
axiam release page, not a PyPI
distribution — so there is no axiam-sdk[opaque] extra, and a name that
installed nothing while reading as though it installed the thing would be worse
than its absence. Put the file on the loader path, or point an environment
variable at it:
export AXIAM_OPAQUE_LIBRARY=/opt/axiam/libaxiam_opaque_ffi.so
Ask before you need it:
if client.opaque_available():
result = client.login_opaque(user, password)
else:
result = client.login(user, password)
It reports rather than raising, so an application can choose the password path
up front instead of discovering the gap mid-exchange. When it is absent,
login_opaque raises a NetworkError naming the artifact and the environment
variable — never something that looks like a wrong password.
Two things that will bite you
It blocks, and on AsyncAxiamClient it blocks the event loop. The KSF is
CPU-bound: Argon2id at 19 MiB by default, tens to hundreds of milliseconds. That
cost is what makes a stolen record expensive to attack even by someone holding
the OPRF seed. On a server handling other requests, wrap the call in
asyncio.to_thread.
What it protects, and what it does not. A TLS-terminating proxy, an accidentally verbose request log, or a heap dump on the server cannot capture a plaintext password, because the server never has one — and a stolen record database is not offline-crackable on its own without the tenant's OPRF seed, which is the pre-computation resistance SRP could not offer. It does not protect against a compromised AXIAM server.
WebAuthn and passkeys (§24)
A passkey ceremony is two exchanges stacked: one with an authenticator, which needs a platform API, and one with AXIAM, which is four ordinary JSON round trips. Python has no authenticator, so this SDK ships the second half.
That is not a consolation prize. A Python service completing a ceremony that ran on an Android or iOS handset is the relying party exactly as a browser is — and §24.6b rule 2 forbids the alternative outright: an SDK must not emulate an authenticator in software, because a "credential" held in process memory is not a second factor.
The three-step shape
from axiam_sdk import AxiamClient, webauthn_request_json
client = AxiamClient(base_url=..., tenant_slug="acme", org_slug="globex")
challenge = client.webauthn_discoverable_start()
# The JSON form every platform authenticator API takes (§24.6a) — the exact
# string Android's CreatePublicKeyCredentialRequest and a browser's
# parseCreationOptionsFromJSON() both want.
response_json = your_device_channel(webauthn_request_json(challenge))
session = client.webauthn_discoverable_finish(
state_token=challenge.state_token,
response=response_json, # the platform's string, verbatim
)
The client is authenticated when that returns — §24.3 rule 1 is not a "MAY
adopt". webauthn_register_start/_finish and
webauthn_authenticate_start/_finish follow the same shape, for enrolling a
credential and for a passkey used as a second factor after login() answered
mfa_required.
Both *_finish operations take either a parsed mapping or the platform's own
JSON string. Requiring a caller to destructure one into a dict this SDK
immediately re-serializes is three chances to corrupt a signed buffer in service
of nothing.
What the SDK will not do
It never adjusts an option. The server generates the challenge and chooses
residentKey, userVerification, the attestation conveyance, the exclusion list
and the timeout; this SDK carries all of it through unchanged and posts the
answer back unchanged. Not because those fields are hard — because they are not,
and relaxing userVerification to "preferred" because a test authenticator
kept prompting weakens a ceremony the server believes it configured. The server
cannot catch it: an assertion produced under weaker options is a valid assertion.
It never parses state_token. It is opaque, it is a SecretStr, and it goes
straight back to the matching *_finish.
Classifying a device's failure
Every platform reports a ceremony failure as one opaque type whose only machine-readable part is a name — so a handset can relay just that name, and a Python service can turn it into the same five outcomes a browser would see:
from axiam_sdk import WebauthnFailure, classify_webauthn_error, webauthn_error_message
failure = classify_webauthn_error(name_relayed_by_the_device)
if failure is WebauthnFailure.ALREADY_REGISTERED:
... # the only outcome whose remedy is "use a different device"
show(webauthn_error_message(failure))
CANCELLED covers both an explicit refusal and a silent timeout. The
WebAuthn spec deliberately refuses to distinguish them, because telling a website
which one happened leaks whether an authenticator was present — so the copy does
not accuse anyone of cancelling, and the distinction must not be recovered by
timing the call.
Two error rows that are not the generic mapping
- A
403onwebauthn_register_finishis the tenant's attestation policy refusing this authenticator — an AAGUID that is not allow-listed, a missing FIDO certification, a revoked status — not a permission problem with the user. The server's message is surfaced verbatim, because it is the only way the person holding the key learns a different one would work. - A
503onwebauthn_register_startmeans attestation is required and the FIDO metadata service has no usable snapshot. A server configuration state, not a transient failure, and deliberately not retried.
Worked example: examples/webauthn_relying_party.py.
Account lifecycle and MFA enrolment (§25)
§1 locks the middle of an account's life — login, verify_mfa, refresh,
logout all assume an account that already exists, is verified, and already has
its second factor. These nine operations are how it gets there.
enrolment = client.mfa_enroll()
render_qr(enrolment.totp_uri.get_secret_value())
client.mfa_confirm(totp_code=code_typed_by_user) # → True once it is live
secret_base32 and totp_uri are both SecretStr, and the URI is the one that
matters: it is otpauth://…?secret=…, so it contains the secret it sits beside.
Wrapping only the secret would have wrapped nothing — the URI is the field that
actually reaches a log, because it is the field you hand to a QR renderer.
login() has a third outcome
LoginResult gains mfa_setup_required and setup_token. The server has always
been able to answer 403 mfa_setup_required for an account in a tenant that
requires MFA; it used to reach you as an AuthzError, saying you lacked
permission to log in when what the server said was recoverable.
result = client.login(email, password)
if result.mfa_setup_required:
enrolment = client.mfa_setup_enroll(setup_token=result.setup_token)
render_qr(enrolment.totp_uri.get_secret_value())
client.mfa_setup_confirm(setup_token=result.setup_token, totp_code=code)
Additive here rather than a new variant, because this model has always been one
type with flags rather than a discriminated union — so nothing that reads
mfa_required today has to change. A genuine authorization refusal is still an
AuthzError: the SDK matches on the body's discriminant, not on the 403 alone.
Email verification and password reset
client.verify_email(token=token_from_link, tenant_id=tenant_id)
client.resend_verification(email=email, tenant_id=tenant_id) # anonymous caller
client.resend_own_verification() # signed-in caller
client.request_password_reset(email=email)
There are two resends, and picking the wrong one is silent. Use the second whenever you have a session.
resend_verification takes an address from an unauthenticated caller, so it
returns normally whatever happens — unknown address, already verified, over the
daily limit. That constancy is the point: anything else is an oracle for which
addresses have accounts.
resend_own_verification is for a caller signed in to the account it is asking
about. It takes no address at all (the server reads it off your own record,
and a parameter would let a session mail an arbitrary one) and it says what
happened:
try:
client.resend_own_verification() # minted and enqueued
except ConflictError:
... # already verified, or not eligible
except NetworkError:
... # 429 — daily limit
A profile page that called the first one reports success while doing nothing, which is the bug this pair exists to separate. This SDK does not fall back from the second to the first on either failure — that would turn both back into a normal return with an extra round-trip. And returning means enqueued: delivery is asynchronous and can still fail at the provider.
Organization-level principals (§5.2)
A completed login also reports whether the account is an organization-level principal — one whose record lives in its organization's reserved tenant, so its global grants apply in every tenant of that organization:
result = client.login(email, password)
if result.organization_level:
# Acts on any tenant of its organization via client.acting_tenant(...)
# (§5.2 rule 1, below) — no re-login: it already is a principal of
# every tenant there.
...
Check it before offering a tenant switch. An ordinary tenant principal is a
principal of exactly one tenant, and switching for one of those produces a
403 — so a UI that offers the switch to everyone has turned a distinction
the server made into a failure the user discovers. False against a server
older than contract 1.31, which is the safe reading of absent.
Acting on another tenant (§5.2 rule 1)
X-Axiam-Tenant is what actually switches the tenant an organization-level
principal acts on — not X-Tenant-ID (§5 rule 2), which every request
sends unconditionally and which the server does not read as a switch:
client = AxiamClient(base_url="https://iam.example.com", tenant_slug="organization")
client.login("root@example.com", password) # organization_level: True
acme = client.acting_tenant(acme_tenant_id) # a new handle; `client` is unchanged
acme.resources.list() # X-Axiam-Tenant: <acme_tenant_id>
client.resources.list() # no X-Axiam-Tenant -- acts on the org's own scope
own_scope = acme.clear_acting_tenant() # a new handle, again -- `acme` itself is unchanged
acting_tenant()/clear_acting_tenant() always return a new handle rather
than mutating the one they were called on (§5.2 rule 1) — the return value must
be kept, exactly as acme itself had to be above. acme.clear_acting_tenant()
on its own, with the result discarded, builds and immediately drops a handle
that acts on the org's own scope; acme keeps sending X-Axiam-Tenant for
acme_tenant_id regardless.
- Construction-time form,
AxiamClient(acting_tenant=...), is for a client built to always act on a known tenant — a provisioning script, say. Nothing is checked at construction (there is no login yet to check against), so a malformed value is still refused as a non-UUID, but the organization-level /reachable_tenant_idsgate below does not apply until a login result is held. client.acting_tenant(tenant_id)returns a new handle sharing the session (cookie jar, refresh guard, decision memo) withclient—clientitself is unchanged, so two handles can act on two tenants at once over one session. Once this client holds a login result that reported the principal's reach, it refuses client-side, with no wire call (AuthzError), unlessorganization_levelisTrue, and refuses a tenant outsidereachable_tenant_idswhen the login reported one (§5.2.3 rule 4). What "holds a login result" covers:login,verify_mfa,login_opaqueandmfa_setup_confirmall report the principal's reach — so does completing a forced WebAuthn setup — because their responses all carry the same user object, through the same internal handler. A client that has only completed a WebAuthn authentication, an SSO sign-in, or the mTLS device login — or that holds an injected token or a service account from client credentials — has nothing to gate on: the header is sent regardless, and the server's403is the answer. (This differs from the Rust reference, which treats OPAQUE and the setup flows the same as those: their responses carry no less of a user object here, so gating on it is tighter than gating on nothing.)- It is REST-only: the gRPC interceptor reads no acting-tenant metadata and acts on the token's tenant regardless of what this says.
- It never changes
X-Tenant-ID, and never changes a{tenant_id}path segment on a management call (§27.4 rule 3) — pass that id explicitly to address a tenant other than the client's own.
Signing one in (§5.2.1)
The reserved tenant has a fixed slug, organization, the same in every
deployment — so signing in as an organization-level principal needs no new
surface, only the ordinary constructor:
client = AxiamClient(
base_url="https://iam.example.com",
tenant_slug="organization",
org_slug="globex",
)
client.login("root@example.com", password)
Prefer that form. The server also reads a login body naming no tenant as "the
organization's own scope", but §5 rule 2 still requires a tenant on the
X-Tenant-ID header of every request after the login, so the client needs one
either way.
What §5.2.1 forbids is the third possibility: an empty-string slug. Nothing can
carry one, so tenant_slug: "" resolves nothing — and on
/auth/opaque/login/start it fails on the workspace before the tenant's
OPAQUE mode is read, so the 404 that means "OPAQUE is not offered here" never
arrives and this SDK has no fallback to take. Sign-in then fails even against a
tenant with OPAQUE disabled. Construction refuses a blank tenant_slug,
whitespace included, so one never reaches the wire.
request_password_reset returns normally whether or not the address exists,
and this SDK exposes no way to tell them apart. Any signal distinguishing them —
including one inferred from timing — turns the endpoint into the account
enumeration oracle its uniform response exists to prevent.
Setting the new password takes one extra call on any tenant that might have OPAQUE enabled, because the client has to build a registration record and cannot know the parameters before it has a token to ask with:
context = client.password_reset_context(token=token)
client.confirm_password_reset(
token=token,
new_password=new_password,
tenant_id=tenant_id,
opaque=client.opaque_enrollment(new_password) if context.opaque else None,
)
The context discloses no identity, and a 404 covers unknown, expired and
already-consumed without distinguishing them.
Worked example: examples/account_lifecycle.py.
Pushed authorization requests (§26)
PAR (RFC 9126) moves the authorization request off the browser: the client POSTs
scope, redirect_uri, state and the PKCE challenge straight to AXIAM over an
authenticated back channel and puts an opaque request_uri in the redirect, so
what travels through the user agent is a random string that cannot be edited into
meaning something else.
Required for a FAPI 2.0 client — profile: "fapi2" refuses a registration that
does not set require_par.
configuration = client.oidc_discover()
request = client.oidc_begin(configuration=configuration, redirect_uri=uri, scope="openid profile")
pushed = client.oidc_par(
request=request,
redirect_uri=uri,
scope="openid profile",
configuration=configuration,
tenant_id=tenant_id,
)
redirect(pushed.authorization_url)
# …on the callback, unchanged by PAR:
tokens = client.oidc_exchange(
code=code,
redirect_uri=uri,
nonce=pushed.nonce,
code_verifier=pushed.code_verifier,
tenant_id=tenant_id,
)
oidc_begin still does the computing — there is no second generator for state,
nonce and PKCE — and pushed.code_verifier is the one it produced, so there is
exactly one value to keep.
Three things that are easy to get wrong:
- The endpoint answers
201, not200. RFC 9126 §2.2 specifies Created, and a success predicate written== 200treats every successful push as a failure. - The authorization URL carries exactly
client_idandrequest_uri. The server refuses a request mixing arequest_uriwith inline authorization parameters rather than merging them, and re-adding them "for compatibility" restores the parameter-confusion attack the refusal prevents. request_uriis single-use and short-lived. There is nothing to retry with it; the safe recovery is a fresh push.oidc_paris correspondingly never retried on a5xxor a transport failure — it is a POST that creates state.
Binding the code to a DPoP key — dpop_jkt (contract 1.42, RFC 9449 §10.1)
oidc_par takes an optional dpop_jkt: the RFC 7638 SHA-256 thumbprint of the
public key the eventual token request will prove possession of. Pushing it binds
the authorization code to that key before the browser ever sees a redirect, so
whoever intercepts the code cannot exchange it.
from axiam_sdk._dpop import jwk_thumbprint_s256
pushed = client.oidc_par(
request=request,
redirect_uri=uri,
scope="openid profile",
dpop_jkt=jwk_thumbprint_s256(my_public_jwk),
configuration=configuration,
tenant_id=tenant_id,
)
It is caller-supplied rather than derived because this SDK holds no DPoP key of
its own: per CONTRACT.md §21.9 it verifies proofs (verify_dpop_proof,
InMemoryJtiStore) and does not mint them. Omit it and the parameter is left
out of the form entirely — an empty dpop_jkt is a thumbprint that matches no
key, which would bind the code to a key nobody holds.
There is deliberately no way to push a request_uri. RFC 9126 §2.1 makes it
the one authorization parameter a client MUST NOT push; AXIAM models it so it
can refuse it, and a client able to send one is a client able to chain one
pushed request into another.
Worked example: examples/par_login.py.
Device authorization grant (§14)
RFC 8628 — signing in a device that cannot show a browser: a TV, a CLI, a
headless commissioning tool. device_authorize, device_poll and the composed
device_login, on both AxiamClient and AsyncAxiamClient.
def show(auth: DeviceAuthorization) -> None:
# Called BEFORE the first poll. Display it however the device can —
# screen, QR code, e-ink panel. The SDK never prints it for you.
print(f"visit {auth.verification_uri} and enter {auth.user_code}")
tokens = client.device_login(show, scope="openid profile")
The polling rules are where implementations go wrong, so they are worth stating:
slow_downraises the interval permanently. An SDK that backs off for one round and returns to the original interval will be told to slow down again, forever.access_deniedandexpired_tokenstay distinct. A human said no, versus nobody answered — the only information the device can act on.- Polling stops at
expires_in, even if the server has not yet saidexpired_token. - A
5xxmid-poll is not terminal. A server restart must not lose a grant the user has already approved.
device_code is a SecretStr; user_code deliberately is not — it exists to
be read aloud, and wrapping it would defeat the one thing it is for.
Client authentication (contract 1.62, §14.1). Both endpoints authenticate the
client the way it is registered. A public client — the usual device, which cannot
keep a secret — is built without a secret: device_authorize and every poll send
client_id and no credential, and the SDK never refuses to call either for want of one.
A client built with a client_secret sends it at device_authorize and at every
poll, in the form body (client_secret) — also when it is registered
client_secret_basic: this SDK never sends Authorization: Basic to an /oauth2/*
route (§12.1 rule 3). A client built with a §6.1 client certificate presents it on both
requests. The poll sends client_id every time. A 401 {"error": "invalid_client"} —
an unknown client, a failed or missing credential, a credential sent by a public client,
a client that no longer holds the grant — is an OAuthProtocolError with
error == "invalid_client": terminal, sent once, never refreshed. A deployment upgraded
to 1.1.0 finds a confidential client that sends no credential refused at its devices'
next request.
This SDK has no private_key_jwt client assertion and generates no DPoP proofs
(§21.7.3: it verifies them), so it cannot drive a device client registered for either;
it surfaces the server's invalid_client as above.
The async device_login awaits an async callback before polling, so a
device that needs to await a paint still satisfies §14.3 rule 2.
Per §14.3 rule 4, device_login returns the token set rather than adopting
it, matching this SDK's login_client_credentials posture. See
examples/device_login.py.
Token exchange (§15)
RFC 8693 — a service holding a user's token exchanging it for a narrower one before calling the next service.
from axiam_sdk import ACCESS_TOKEN_TYPE
exchanged = client.token_exchange(
subject_token=user_token,
subject_token_type=ACCESS_TOKEN_TYPE, # required (§15.1), no default
scopes=["orders:read"],
audience="orders-service",
)
Most of what this method does is refuse to be helpful, and each refusal is deliberate:
-
No default
actor_token. Omitting it asks for impersonation; the SDK will not quietly substitute the client's own session token and turn that into a delegation. -
An
actor_tokenis the same client'sclient_credentialstoken (§15.2 rule 9). The server answers an actor token that was not issued to the exchanging client with400 invalid_request(actor_token was not issued to the exchanging client); the SDK surfaces that unchanged, sends one request and does not substitute a token of its own. Obtain it from the grant of the client that is exchanging:actor = client.login_client_credentials() exchanged = client.token_exchange( subject_token=user_token, subject_token_type=ACCESS_TOKEN_TYPE, actor_token=actor.access_token, # this client's own token, never another's )
-
No auto-narrowing after
invalid_scope. The server refuses rather than silently narrowing precisely so the caller finds out here. -
No refresh token, ever —
ExchangedTokenhas no such field, so there is nothing to synthesise. Re-run the exchange. -
No adoption. The issued token is handed onward in one call; adopting it would silently re-privilege every later call this client makes. A MUST NOT, where
login_client_credentialsadoption is a MAY.
See examples/token_exchange.py.
External-IdP subject tokens (§15.7)
The same method exchanges a token minted by a trusted external IdP — a partner's Entra, Okta or Keycloak — for an AXIAM token scoped to what the resolved AXIAM user may actually do. There is no separate operation:
from axiam_sdk._oidc import JWT_TOKEN_TYPE
exchanged = client.token_exchange(
subject_token=partner_token,
subject_token_type=JWT_TOKEN_TYPE, # named, never guessed
scopes=["read:orders"],
audience="https://orders.internal",
)
subject_token_typeis yours to state, and is required (§15.1). The SDK never decodes the subject token to pick it, and never overrides what you named. There is no default — omitting it raisesTypeErrorbefore any wire call, because a default would be the SDK choosing for you.- No actor token. Delegation across a trust boundary is unsupported in v1;
sending one is
invalid_request, which the SDK will not work around by dropping it and re-sending. - One refusal is distinguishable.
invalid_grantwhose description isthe subject token's issuer is not configured for token exchangemeans fix the AXIAM trust configuration. Every otherinvalid_grantmeans fix your token, and is deliberately generic. - Forward the result as-is. It carries an
ext_exchangeclaim naming the partner issuer; never strip it, and never read it as an authorization input. It also cannot be exchanged again — exchanges do not compose.
The operator guide is docs/api/federated-token-exchange.md.
UMA 2.0 — Protection API and ticket grant (§20)
For the resource-server side of a User-Managed Access deployment: a service
that guards resources on someone else's behalf registers them, asks AXIAM what
a caller would need, and exchanges the resulting ticket for a Requesting Party
Token. All seven §20.1 operations are on both AxiamClient and
AsyncAxiamClient, under the same names.
The Protection API
The five resource-set operations and uma_request_ticket carry a PAT — an
ordinary access token obtained through login_client_credentials with the
uma_protection scope, not a user token.
from axiam_sdk import RequestedPermission, ResourceSet
# §20.2 rule 1 — a client-credentials token, not a user token: a minted ticket
# binds to the client_id that minted it, so a user token cannot stand in.
session = client.login_client_credentials(scope="uma_protection")
pat = session.access_token
resource = client.uma_register_resource(
pat,
ResourceSet(
name="invoice-2026-04",
type="urn:acme:invoice",
resource_scopes=["read", "approve"],
),
)
client.uma_read_resource(pat, resource.id)
client.uma_update_resource(pat, resource.id, resource)
client.uma_list_resources(pat) # -> list[str] of ids
client.uma_delete_resource(pat, resource.id)
The ticket dance
ticket = client.uma_request_ticket(
pat,
[
RequestedPermission(resource_id=resource.id, resource_scopes=["read"]),
],
)
rpt = client.uma_exchange_ticket(ticket=ticket, claim_token=subject_token)
claim_token is a required keyword argument, though UMA 2.0 §3.3.1 marks
it optional. AXIAM implements neither incremental authorization nor
claims-gathering, so it is the only channel that names a requesting party —
and defaulting it to the resource server's own PAT would mint an RPT for the
resource server instead of for the user.
Three things worth knowing
uma_exchange_ticket never retries, and that is not an oversight. It is
outside the §16 retry policy entirely — not on 5xx, not on timeout, not on
any transport failure. A permission ticket is consumed before the request is
evaluated, so a failed exchange has already spent it; a retry cannot succeed,
and under concurrency it is exactly the double redemption to avoid. On failure,
request a new ticket. This is the one refusal in the contract that is not
re-sendable after fixing something.
An undeclared scope is a 400, not a denial. uma_request_ticket
validates scope names against each resource's declared set, and this SDK
surfaces the distinction the server draws rather than flattening it — "you
asked for something that does not exist" and "you may not have it" are
different answers to different questions.
Partial grants are refused whole. If a ticket names three pairs and the
requesting party may have two, the answer is access_denied for the ticket,
and this SDK does not auto-narrow and re-ask. Whether two of three is useful
is a decision for the calling application, which knows what it is for.
Emitting the challenge (§20.3)
UmaChallenger turns a denial from the §11 guards into a
WWW-Authenticate: UMA header carrying a fresh ticket for the pairs the caller
lacked, so a UMA-aware client learns where to obtain authority instead of only
being told no. Pass it as uma_challenge= to FastAPI's require_access or
Django's @require_access. Clients parse the header with uma_parse_challenge.
Worked examples: examples/uma_resource_server.py
and examples/uma_client.py — the emit and consume
halves.
Logout — RP-initiated and back-channel (§12.7)
logout_url builds the redirect; verify_logout_token validates a token the
OP pushed to your back-channel endpoint.
url = client.logout_url(id_token=stored_id_token)
# …and at your registered backchannel_logout_uri:
verified = client.verify_logout_token(logout_token)
if verified.sid is not None:
end_session(verified.sid) # that session ONLY
The verifier is where the security weight sits — the input arrives unsolicited
and instructs you to terminate a session. It checks the signature (same JWKS
path, same kid-required discipline as §12.4), iss, aud, that events
carries the back-channel-logout key (the only thing separating a logout token
from an ID token), that nonce is absent (its presence is how an ID token
gets replayed as one), that something is named, and freshness.
It returns sid/sub/jti rather than a bare bool: you have to know
which session to end. Dedup on jti yourself — delivery is at-least-once,
so a valid token legitimately arrives twice; the SDK has no durable store and
an in-memory guard would silently drop a real second logout after a restart.
See examples/logout.py.
Management API (§27)
202 administrative operations across 29 namespaces, reached as
client.<namespace>.<operation> on both clients. Acquiring a handle performs no
I/O, so there is nothing to cache and nothing to close:
from pydantic import SecretStr
from axiam_sdk import AxiamClient
from axiam_sdk.management import PageRequest, models
with AxiamClient(base_url="https://axiam.example", tenant_slug="acme") as client:
client.login("admin@example.test", password)
page = client.users.list(PageRequest(limit=50))
print(page.total) # the whole set, not this page
everyone = client.users.list_all() # walks to exhaustion
found = client.users.list(PageRequest(limit=50, search="ada"))
user = client.users.create(
models.CreateUserRequest(
username="alice", email="alice@example.test", password=SecretStr(pw)
)
)
client.users.update(user.id, models.UpdateUserRequest(email="new@example.test"))
The same surface exists on AsyncAxiamClient with await.
What the surface guarantees
| Rule | What it means here |
|---|---|
| §27.2 | Namespaced, not flat. Nineteen namespaces have a list and nineteen a get; flattening 202 operations onto the client would bury the eight §1 methods most callers want. |
| §27.4 rule 1 | No session, no wire call — login() first, or an AuthError before anything is sent. |
| §27.4 rule 3 | {org_id} and {tenant_id} default from the client. .in_org(...) / .for_tenant(...) override them and return a new handle. |
| §27.4 rule 4 | Page.total is the whole set. list_all() walks it, and stops on an empty page even if total disagrees. Bare-array reads such as scopes.list are lists, not pages. PageRequest.search filters server-side, before offset/limit, and list_all() carries the term across the whole walk. |
| §27.11 | Tenant.kind, MtlsTrustAnchorResponse.trusted_anchors and Certificate.bound_service_account_id are optional, and each None means something specific — see below. Generated enums are open: an unrecognised value validates rather than failing the response. |
| §27.4 rule 5 | A sparse update body sends only the fields you set. A replacement body (SetOrgSettings, SetMtlsTrustAnchor, ...) will not construct half-filled. |
| §27.4 rule 7 | 404 → NotFoundError (an AuthzError), 409 → ConflictError, 400/422 → ValidationError with per-field detail (a NetworkError). |
| §27.4 rule 8 | Only GET is retried. No write is replayed, including the ones that look idempotent. |
| §27.5 | One-time secrets come back as SecretStr — redacted from every repr, log line and JSON rendering. .get_secret_value() is the only way out. |
search is on the page request, and the server does the filtering.
page = client.users.list(PageRequest(limit=50, search="ada"))
found = client.users.list_all(PageRequest(limit=200, search="ada"))
It is matched case-insensitively against the identifying fields of whatever is being listed — a name or username, plus the record id, so a UUID out of a log line pastes in as-is. Three consequences worth knowing:
totalcounts matches, not rows, because the filter is applied beforeoffset/limit. That is what lets a pager built on it show a page count belonging to the result set it is paging. Filtering the page in Python after the fetch gives you neither.list_all()carries the term across the whole walk, so it returns the matches and not the matches followed by the unfiltered tail.- A blank term is no term.
search=""andsearch=" "send nosearchparameter at all, so a box that fires on every keystroke does not ask a different question once it has been cleared. The server also caps the term's length; this SDK does not copy that cap, because a client-side truncation the server would not have made is a silently different query.
Three model fields arrived with contract 1.31 (§27.11), and each None means
something specific. Tenant.kind is None on a row written before
organization scope existed — read it as standard.
MtlsTrustAnchorResponse.trusted_anchors is None when nothing was reloaded,
which is not zero: "the listener trusts no CAs" and "there was no listener to
ask" are different states, and only one is a problem.
Certificate.bound_service_account_id is resolved by certificates.list() and
None on get; the SDK does not issue a second request to fill it in.
Generated enums are open. Each is Literal[...] | str, so a value this
SDK's copy of the spec does not list validates instead of raising. A bare
Literal is checked strictly by pydantic, which would turn the next kind or
status the server adds into a validation error on the whole response —
taking down every record on the page over one field of one of them.
Every {..._id} on this surface is a UUID, and a non-UUID argument is refused
locally rather than sent to produce a 404 that reads as "no such object".
Declarative management (§27.6 / §27.7)
Describe the shape a tenant should have, then reconcile it. plan reads only;
apply stops at the first failure and reports every step, including the ones it
did not attempt — these are independent HTTP endpoints and nothing spans them,
so there is deliberately no rollback.
from axiam_sdk.management.manifest import (
GrantSpec,
PermissionSpec,
ResourceSpec,
RoleSpec,
ScopeSpec,
define_manifest,
)
shape = define_manifest(
resources=[
ResourceSpec(
key="docs",
name="documents",
resource_type="collection",
scopes=(ScopeSpec(key="draft", name="draft", description="Unpublished"),),
)
],
permissions=[PermissionSpec(key="read", action="document:read", description="Read")],
roles=[
RoleSpec(
key="editor",
name="Editor",
description="Edits",
grants=(GrantSpec(permission="read", scopes=("draft",)),),
)
],
)
plan = client.manifest.plan(shape)
if not plan.is_converged():
report = client.manifest.apply(shape)
define_manifest validates at the point of declaration, so a dangling key, a
duplicate, or a cycle in the resource parents fails where the manifest is
written rather than on the first plan against a live tenant. The decorator form
(@axiam_resource, @axiam_role, @axiam_grant, @axiam_service_account, ...
assembled by collect_manifest) lowers to exactly the same value.
Certificates, CA certificates, PGP keys and SCIM tokens are deliberately absent
from the manifest: they mint one-time secrets, and "ensure a certificate exists"
either re-mints one on every run or silently accepts drift. webhooks stays
named by CONTRACT.md §27.6 but unspecified in contract 1.51 — this SDK declines
it; no consumer has asked for it.
resources[].metadata, resource-scoped bindings, and service_accounts (§27.6.1, contract 1.51)
from axiam_sdk.management.manifest import (
GroupSpec,
ManagementManifest,
ResourceSpec,
RoleSpec,
ScopedRoleBinding,
ServiceAccountSpec,
)
shape = ManagementManifest(
resources=[
ResourceSpec(
key="docs",
name="documents",
resource_type="collection",
metadata={"owner": "platform-team"}, # sent on Create; on Update only when it drifts
),
],
roles=[
RoleSpec(key="editor", name="Editor", description="Edits documents"),
RoleSpec(key="reviewer", name="Reviewer", description="Reviews documents"),
],
groups=[
GroupSpec(
key="staff",
name="Staff",
description="Everyone",
roles=(
"editor", # the plain shape -- unchanged, no resource, no inheritance question
# a DIFFERENT role: the server keys a binding on (subject, role) with no
# resource component, so binding "editor" a second time here -- plain,
# scoped, or one of each -- describes a state the server cannot hold, and
# `plan()`/`apply()` refuse it client-side before any request (CONTRACT.md
# §27.6.1).
ScopedRoleBinding(role="reviewer", resource="docs", inherit=False),
),
),
],
service_accounts=[
ServiceAccountSpec(key="ci", name="ci-bot", description="CI pipeline", roles=("editor",)),
],
)
report = client.manifest.apply(shape)
secret = report.client_secret("ci") # only set on THIS spec's Create outcome
if secret is not None:
store_for_the_ci_pipeline(secret.get_secret_value()) # returned once -- the only time
-
metadatadrift is JSON equality of the whole object, never a key-by-key merge — anUpdatecarries the whole stated object, and aResourceSpecthat never mentionsmetadatais silent about it, never a request to clear it. -
A
RoleBinding(GroupSpec.roles,UserSpec.roles,ServiceAccountSpec.roles) is a role key or aScopedRoleBinding. A subject holds one role at most once — binding the same role twice, plain and scoped included, is rejected byplan()/apply()before any request. A role the manifest declaresRoleSpec(is_global=True, ...)also refuses aScopedRoleBinding(..., inherit=False)client-side, before any request: the server answers400to a non-inheriting binding of a global role, so this SDK checks it up front rather than failing mid-apply. Changing a binding's resource is an unassign then an assign; if the assign fails, the previous binding is re-assigned and the step's outcome names both, as data, not only a message:StepOutcome.restore_succeeded(True/False,Nonefor every other step) andStepOutcome.restore_error(the restore attempt's own error, set only whenrestore_succeededisFalse).inheritreaches the wire only asFalse; a statedinherit=Trueis accepted and planned exactly like an omitted one, and is never sent either. -
network(contract 1.62, §27.6.2) is reconciled as the last section — policy, then segments, then profiles — by natural key (segment: site + kind + name, with the wire spellingaccessorquarantine; profile: its segment; policy: a singleton).planjudges every stated member on the value as the read returns it, so a statedsession_timeout_secsabove the tenant cap is anUpdatethatapplysends once and the server refuses with403;filter_idscompares as a set. See Contract 1.62 for the whole list.from axiam_sdk.management.manifest import ( ManagementManifest, NetworkPolicySpec, NetworkProfileSpec, NetworkSegmentSpec, NetworkSpec, ) site = ManagementManifest( network=NetworkSpec( policy=NetworkPolicySpec(session_timeout_max_secs=7200), segments=( NetworkSegmentSpec( key="staff", site="default", kind="access", name="staff", vlan_id=20, rank=1, filter_ids=("staff-in",), ), ), profiles=( NetworkProfileSpec(segment="staff", session_timeout_secs=3600, filter_id="staff-in"), ), ) ) report = client.manifest.apply(site) # human caller: a service-account token meets 401 if (failure := report.failure()) is not None: print(failure.reason_code, failure.message) # branch on reason_code, never on the message
-
service_accountsis reconciled byname, which the server does not enforce uniqueness on:plan()fails, before any write, when a stated name matches more than one existing account. ACreate's one-timeclient_secretis onApplyReport.client_secret(key)— set even when a later step of the sameapplyfails — andapplynever rotates one to reconcile: a secondapplyagainst an unchanged tenant isNoChange.
Regenerating the surface
python scripts/gen_management.py # rewrite the generated files
python scripts/gen_management.py --check # what CI runs; fails on drift
The generator reads management-registry.json and openapi.json — both
vendored from ilpanich/axiam — and formats its output with ruff format, the
same tool the lint job checks with. Do not edit anything under
src/axiam_sdk/management/ops/, src/axiam_sdk/management/models.py or
tests/test_management_surface_generated.py by hand.
Webhook signature verification (§13)
axiam_sdk.webhook.verify_webhook(secret, signature_header, body) verifies the
X-Axiam-Signature: t=<unix_seconds>,v1=<hex> header AXIAM sends on every webhook
delivery — HMAC-SHA256 over "<timestamp>.<raw_body>", compared in constant time,
with a two-sided freshness window (default 300s):
from axiam_sdk.webhook import WebhookVerifyError, verify_webhook
# Flask: request.get_data() is the RAW bytes off the wire. Do NOT verify
# against request.get_json() re-dumped — re-serializing changes key order/
# whitespace and breaks the MAC (CONTRACT.md §13.3 rule 1).
@app.post("/webhooks/axiam")
def axiam_webhook():
try:
event = verify_webhook(
secret=WEBHOOK_SECRET, # a pydantic.SecretStr or plain str
signature_header=request.headers["X-Axiam-Signature"],
body=request.get_data(), # raw bytes, NOT re-serialized JSON
)
except WebhookVerifyError:
return "invalid signature", 400
# X-Axiam-Delivery (event.delivery_id, if you pass it through — see
# below) is the at-least-once dedup key: retries replay a validly-
# signed delivery inside the freshness window, so keep a short-lived
# seen-set if double-processing an event would be unsafe.
...
return "", 200
FastAPI is the same shape with await request.body() in place of
request.get_data() — both give you the exact raw bytes the server signed;
await request.json() does not, for the same re-serialization reason.
verify_webhook also accepts event_type/delivery_id (pass the raw
X-Axiam-Event/X-Axiam-Delivery header values straight through — neither
is covered by the MAC) so the returned WebhookEvent carries them, a
tolerance override (seconds, default 300), and a now injection seam for
tests. WebhookVerifyError's message never includes the expected/computed
signature or the secret.
gRPC stub generation (D-04)
pip install-ing this package does not require buf/protoc — the
generated gRPC stubs (src/axiam_sdk/grpc/gen/) are committed and shipped
in both the wheel and the sdist. Contributors regenerating them locally run:
bash scripts/gen_grpc.sh
CI regenerates the same way and fails the build on any drift
(git diff --exit-code) between the committed stubs and a fresh
regeneration from proto/axiam/v1/.
TLS policy (§6)
httpx clients are constructed with verify=True hardcoded; the only
escape hatch is an explicit custom_ca parameter (a CA bundle path or
ssl.SSLContext) — there is no boolean bypass anywhere in this SDK,
including the examples. CI enforces this with a dedicated grep gate.
mTLS / client certificates (§6.1)
For IoT devices and service accounts that authenticate by mutual TLS, pass
a PEM client-certificate chain plus its PEM private key (each str or
bytes). The same identity is applied to both the REST and gRPC transports of
the client, and presenting it never relaxes server verification — strict
TLS (§6) stays fully on.
from axiam_sdk import AxiamClient
with open("device-cert.pem", "rb") as f:
client_cert = f.read()
with open("device-key.pem", "rb") as f:
client_key = f.read()
client = AxiamClient(
base_url="https://axiam.example.com",
tenant_slug="acme",
custom_ca="/etc/axiam/org-ca.pem", # server trust (optional; system roots by default)
client_cert=client_cert, # PEM cert chain (str or bytes)
client_key=client_key, # PEM private key (str or bytes)
)
# AsyncAxiamClient(...) takes the identical client_cert=/client_key= parameters.
client_cert and client_key must be supplied together (only one is a
construction-time error), and a non-PEM value is rejected at construction. The
private key is secret material: it is loaded straight into the TLS stack and is
never logged, stored as a public attribute, or exposed via a getter (§6.1
rule 3 / §7). The gRPC authorization clients accept the same
client_cert=/client_key= parameters.
The mTLS device login (§6.1 rules 6–10, contract 1.51)
authenticate_device() is the operation the identity above is for:
POST /api/v1/auth/device, no body, no password, no cookie — the certificate
alone authenticates.
device = AxiamClient(
base_url="https://axiam.example.com",
tenant_slug="acme",
client_cert=client_cert,
client_key=client_key,
)
token = device.authenticate_device()
print(f"expires in {token.expires_in}s")
device.can("telemetry:publish", f"device/{device_name}") # adopted, like a login result
- Reachable only on a client built with a certificate. Without one, this
raises
AuthErrorbefore any wire call — going to the wire would only get a401the SDK already knows is coming. - Adopted as the credential, replacing the cookie jar rather than living
beside it: the server sets no cookie on this route and reads
axiam_accessbeforeAuthorization, so a cookie left from an earlierlogin()on the same client would otherwise silently win. Every request made afterauthenticate_device()carriesAuthorization: Bearer <token>plus an explicit emptyCookieheader -- except a request to/oauth2/*, which never carries it (CONTRACT §6.1 rule 11, §12.1 rule 3, §14.6): those endpoints authenticate the client in the form body and read noAuthorizationheader. - There is no refresh token (D-6 of the dogfooding remediation plan). A
later
401on this credential isAuthError, with no refresh attempt — callauthenticate_device()again, which costs one TLS handshake. - Every refusal is a
401— unknown, untrusted, expired, revoked or unbound certificate, or aServer-type one — mapped toAuthErrorwith the server's own message. A429(the route is rate-limited per client IP) is aNetworkError, not an authentication failure, and is not retried. - The token is a service-account token (
aud: axiam:m2m): it works withcheck_access/batch_checkand the §27 management families a service account may use (§27.13 S-9) — not withusers/groups/self-service routes.
Worked example:
examples/device_mtls_provisioning.py
(run).
RFC 8705 §5 mtls_endpoint_aliases (contract 1.40, CONTRACT.md §21.3 rule 2)
A TLS listener decides whether to ask for a client certificate during the
handshake, before it has seen a byte of HTTP, so "request one on
/oauth2/token but not on /oauth2/authorize" is not something a single
listener can do. A deployment that wants both runs two — and
mtls_endpoint_aliases in the discovery document is how the second one is
named.
Once the client above is built with client_cert=, every request it makes
presents that certificate, so the §12 helpers prefer the alias over the
top-level entry of the same name wherever the document publishes one:
| Operation | Endpoint aliased |
|---|---|
oidc_exchange, oidc_refresh, login_client_credentials, device_poll, token_exchange |
token_endpoint |
introspect |
introspection_endpoint |
revoke |
revocation_endpoint |
device_authorize |
device_authorization_endpoint |
oidc_par |
pushed_authorization_request_endpoint |
ciba_initiate |
backchannel_authentication_endpoint (contract 1.58) |
Three things this deliberately does not do:
- An absent
mtls_endpoint_aliasesis not an error. It means "this deployment terminates mutual TLS on the issuer's own host", so the conventional endpoints keep being used. A deployment runningclient_auth = optionalon one listener serves both populations there and correctly publishes nothing. The same holds one level in: every field ofMtlsEndpointAliasesis optional, and an endpoint the object does not name falls back rather than failing the document. - No alias is ever synthesised. Only the seven endpoints
MtlsEndpointAliasesmodels (CONTRACT.md §21.3.1) can be aliased — neverauthorization_endpoint,end_session_endpointorjwks_uri. The first two are front-channel and the third is public key material; sending a browser to an mTLS host raises a native certificate-chooser dialog most users cannot answer. issuerdoes not move. It is an identifier, not an endpoint. §12.4 rule 3 still requires a token'sissto equal the document'sissuerby exact string comparison, including for a token minted at an alias endpoint — the expected issuer is never derived from the host that was called.
A client built without client_cert= keeps using the top-level endpoints even
when the document publishes aliases: the alias exists for the handshake, and
there is no handshake to make.
A malformed alias is refused, never fallen back from (contract 1.43)
An alias that is present and cannot carry a certificate raises AuthError
rather than reverting to the top-level endpoint. Falling back looks like the
safe answer and is the dangerous one: the caller asked to authenticate with a
certificate, the operator published something unusable, and sending the
certificate to the front-channel host authenticates nothing while appearing to
work.
Two defects, each a refusal on its own — not an absolute URL, and a scheme
weaker than the top-level endpoint the alias replaces. The second compares
like with like: an alias substitutes for exactly one endpoint, so https →
http is a downgrade, while http → http is a development deployment and is
accepted.
A client with no client_cert= never reads the member at all, not even to
validate it, so a deployment whose aliases are malformed cannot break the
clients that never use them.
MCP resource-server helpers (§28)
The resource-server half of the Model Context Protocol authorization
handshake (RFC 9728 + RFC 6750). This is the other side of §12: AXIAM is
the authorization server and implements none of §28 — this section is for a
service fronted by this SDK's own guard (typically an MCP server) that
wants to publish an RFC 9728 protected-resource metadata document and emit an
RFC 6750 WWW-Authenticate challenge pointing at it, so an MCP client that
gets refused knows which authorization server to go discover.
Three operations, none of which perform network I/O (no retry, no
single-flight — they are pure local computation, like oidc_begin and
uma_parse_challenge), plus one guard option:
from axiam_sdk import bearer_challenge, protected_resource_metadata
metadata = protected_resource_metadata(
resource="https://mcp.example.com/mcp",
authorization_servers=["https://axiam.example.com"],
scopes_supported=["mcp:read", "mcp:tools"],
)
metadata.metadata_path # '/.well-known/oauth-protected-resource/mcp'
metadata.metadata_url # 'https://mcp.example.com/.well-known/oauth-protected-resource/mcp'
bearer_challenge(metadata.metadata_url, error="insufficient_scope", scope="mcp:tools")
# 'Bearer error="insufficient_scope", scope="mcp:tools", resource_metadata="https://.../mcp"'
Validation refuses; it never repairs. Every §28.2 rule — an absolute
https (or http on 127.0.0.1/[::1]/localhost) resource with no
query and no fragment, at least one authorization_servers entry, scope
tokens restricted to RFC 6749's NQCHAR, bearer_methods_supported fixed to
["header"] — is checked at construction and raises ValidationError
(CONTRACT.md §2's taxonomy; §28 adds no new error type) rather than
normalising a bad value into a passing one.
Turning it on: resource_metadata_url
Setting resource_metadata_url is what turns §28 on for a guard — with it
unset, every guard in this SDK behaves byte-for-byte as it did before §28
existed: no WWW-Authenticate header on any response, no status changed, no
body changed. It requires expected_audience to also be set (§10.1 row
6's existing option, under its existing name — §28 adds no second audience
option): a resource server that publishes "tokens for me carry this aud"
and does not check aud has published a claim it does not honour, and the
SDK refuses the configuration rather than merely discouraging it.
FastAPI — both settings live on JwksVerifier, the same object every
require_* factory already takes, so every guard built from one verifier
shares one §28 policy:
from fastapi import Depends, FastAPI
from axiam_sdk import protected_resource_metadata
from axiam_sdk.fastapi import JwksVerifier, require_access, serve_protected_resource_metadata
metadata = protected_resource_metadata(
resource="https://mcp.example.com/mcp",
authorization_servers=["https://axiam.example.com"],
scopes_supported=["mcp:read", "mcp:tools"],
)
verifier = JwksVerifier(
base_url,
expected_audience=metadata.document.resource,
resource_metadata_url=metadata.metadata_url,
)
app = FastAPI()
serve_protected_resource_metadata(app, metadata, verifier) # cross-checks the two above
require_tools = require_access(
verifier, "acme", authz_client, "tools:call", resource_id="…", scope="mcp:tools"
)
@app.post("/mcp")
async def mcp(user=Depends(require_tools)): ...
Because this module is a dependency-only integration (D-09) with no
ASGI-middleware variant, serve_protected_resource_metadata registers a
route with no Depends(...) at all — it is unauthenticated by construction,
which is this port's answer to §28.3 rule 2's "exempt the metadata path from
the global guard": there is no global guard here to exempt it from.
Django — the same two settings live in settings.py, alongside the
ones AxiamAuthMiddleware already reads, and the middleware — which does
guard every request globally — exempts the derived metadata path itself:
# settings.py
AXIAM_EXPECTED_AUDIENCE = "https://mcp.example.com/mcp"
AXIAM_RESOURCE_METADATA_URL = "https://mcp.example.com/.well-known/oauth-protected-resource/mcp"
# urls.py
from axiam_sdk import protected_resource_metadata
from axiam_sdk.django.mcp import serve_protected_resource_metadata
metadata = protected_resource_metadata(
resource="https://mcp.example.com/mcp",
authorization_servers=["https://axiam.example.com"],
scopes_supported=["mcp:read", "mcp:tools"],
)
urlpatterns = [...]
serve_protected_resource_metadata(
urlpatterns,
metadata,
expected_audience=settings.AXIAM_EXPECTED_AUDIENCE,
resource_metadata_url=settings.AXIAM_RESOURCE_METADATA_URL,
) # cross-checks the two settings above against `metadata`
Django's router is a plain urlpatterns list rather than an app/router
object to call .get(path, handler) on the way Express, Fastify and FastAPI
have — so serve_protected_resource_metadata takes that list as its app
and appends one entry to it, the closest structural analogue this framework
has. And because urls.py and settings.py are different modules, the
cross-check against AxiamAuthMiddleware's own configuration is spelled out
as the two settings values rather than a shared verifier object — pass
expected_audience=/resource_metadata_url= to get it, or omit both to skip
it (exactly like omitting FastAPI's verifier= argument above).
The challenge itself
Every 401 a §28-configured guard emits carries WWW-Authenticate: Bearer resource_metadata="…" — no error parameter when the request carried
no credential at all (RFC 6750 §3's "no authentication information"), or
error="invalid_token" when one was presented and rejected. The challenge
never says why: expired, wrong tenant, wrong audience, bad signature, an
unsatisfiable cnf, a revoked sid are all invalid_token,
indistinguishably — every distinction a 401 draws for an unauthenticated
stranger is an oracle, so no error_description is ever added.
Exactly one class of 403 gains a header: a require_access (or
Django's @require_access) call that named a scope= argument, whose
decision came back no_grant — "nothing matched, ask for more" is exactly
what a challenge invites. denied_by_rule ("an administrator has already
decided"), a role-only require_role failure, a CSRF refusal and a decision
with no scope argument all carry no header — sending a client through the
discovery loop to arrive at the identical refusal would be worse than no
challenge. The JSON body never changes: insufficient_scope appears only
in the header, and the body stays the unchanged §11 authorization_denied
shape. Where a route also carries a §20.3 uma_challenge, the UMA challenge
wins and exactly one WWW-Authenticate value is emitted.
gRPC and AMQP
§28.5 rule 8 leaves a gRPC form optional (attaching the same challenge string
as www-authenticate metadata on an UNAUTHENTICATED status) and forbids an
AMQP form outright (there is no client waiting on a response to
re-authorize with). Neither applies to this SDK: its axiam_sdk.grpc and
axiam_sdk.amqp modules are this SDK acting as a client of AXIAM's own
gRPC/AMQP surfaces — CheckAccess/GetUserInfo and the reactor bus — not a
resource-server guard protecting an integrator's own gRPC or AMQP service.
There is no §28-shaped guard on either transport for bearer_challenge to
attach to, so neither is wired up.
The five required tests
Document shape and the §28.2 validation negatives; challenge quoting
(the four RFC 6750 test vectors, and that a refused value raises rather than
escaping into \"); a 401 with the challenge (no credential, and an expired
token) plus the metadata document answering unauthenticated; a 403 whose
reason_code is no_grant carrying insufficient_scope and every other
403 carrying nothing; and a token whose aud is not the resource refused
identically to any other invalid_token. The framework-independent two live
in tests/test_mcp.py; the three that need a guard are duplicated against
both frameworks in tests/test_fastapi_mcp.py and tests/test_django_mcp.py,
alongside each surface's own off-by-default regression.
RFC 7592 client configuration (§28.12)
A client that registered itself through POST /oauth2/register received a
registration_client_uri and a registration_access_token, once. With them it
manages its own registration — and only at this client's configured AXIAM: a URI at
any other origin (scheme, host or port), or http unless the base URL is http on
a loopback host, is refused with a local ValidationError before a request is sent.
from pydantic import SecretStr
token = SecretStr(stored_registration_access_token)
registration = client.read_client_registration(registration_client_uri, token)
registration.client_name = "Agent v2"
# A full replacement: start from the read (unknown members ride along in
# `registration.extra`). The token ROTATES -- persist the new one first.
updated = client.update_client_registration(registration_client_uri, token, registration)
store(updated.registration_access_token) # SecretStr
client.delete_client_registration(registration_client_uri, updated.registration_access_token)
Neither write is retried: an update whose answer was lost has already rotated the
token. The token travels only as Authorization: Bearer, on a transport that carries
no SDK session (no cookie, access token, CSRF token or X-Tenant-ID) and follows no
redirect, and a 401 from these calls never triggers the §9 refresh. An answer with
an error member is an OAuthProtocolError at any status.
Directory, SAML, SSF and SCIM targets (§29 – §32)
Four management namespaces, generated like the rest of §27, with the contract's call-site rules in each method's docstring.
from pydantic import SecretStr
from axiam_sdk.management import models, scim_target_input
# §30 -- moving the connection (url, start_tls, bind_dn, trust_anchors_pem) needs the
# secret again; the SDK keeps no copy. An explicit None is sent as null (clears it);
# a member you leave unset is not sent at all.
client.directory.update(
models.UpdateDirectoryConfig(
url="ldaps://dc2.corp.example",
bind_secret=SecretStr(secret_from_vault),
group_filter=None,
)
)
# §29 -- a metadata import is a draft; nothing is stored until you create it.
draft = client.saml.parse_sp_metadata(
models.ParseSamlSpMetadata.from_url("https://sp.example/metadata")
)
sp = client.saml.create_service_provider(draft.service_provider)
# §31 / §32 -- replace updates: read, convert, change, write. The write-only secret is
# left absent, which keeps the stored one. scim_target_input carries the updated_at it
# read as expected_updated_at: a target written since answers 409 (ConflictError) --
# reload and retry.
target = client.scim_targets.get(target_id)
body = scim_target_input(target)
body.enabled = False
client.scim_targets.update(target_id, body)
# §27.15 -- an explicit None clears a nullable federation member; unset leaves it.
client.federation.update_config(
config_id, models.UpdateFederationConfigRequest(idp_metadata_signing_cert_pem=None)
)
SamlIdpCredential, DirectoryConfig, ScimTargetResponse and SsfStream have no
secret member, and a decoder that meets one drops it. Open enums and the open
ScimTargetAuth / ScimTargetScope unions decode a value this SDK does not know —
and to_wire() refuses to send one, with a local ValidationError.
has_member("next_credential_id") tells a null the server sent from a member it
did not send. delete on a SCIM target deprovisions nothing downstream; retiring the
active SAML credential with no successor stops SAML sign-on for the whole tenant.
Every write in these namespaces is issued exactly once.
SSF receiver (§32.7)
For the relying party that receives AXIAM's CAEP and RISC events
(AsyncSsfReceiver is the same over AsyncAxiamClient):
from axiam_sdk.ssf import (
SESSION_REVOKED,
SetErr,
SetFailureReason,
SetVerificationError,
SsfReceiver,
)
receiver = SsfReceiver(
client,
issuer="https://iam.example.com/t/<tenant>", # the transmitter's issuer
audience="https://rp.example.com", # your stream's audience
jwks_uri="https://iam.example.com/t/<tenant>/oauth2/jwks", # or discovery_url=...
access_token_provider=lambda: fetch_ssf_manage_token(), # for poll
)
# Push: verify, then answer 202 -- or 400 {"err": ...}.
try:
event = receiver.verify_set(body.decode())
except SetVerificationError as refused:
answer(400, {"err": refused.set_failure_reason.push_error_code()})
else:
if event.event_type == SESSION_REVOKED:
end_sessions(event.sub_id)
answer(202)
# Poll: acknowledge what you processed on the next call; refuse the rest by setErrs,
# except a `replayed` one -- accepted earlier, so acknowledge it.
result = receiver.poll(stream_id, return_immediately=True)
processed = [handle(e) or e.jti for e in result.events]
replayed = [r.jti for r in result.refused if r.reason is SetFailureReason.REPLAYED]
receiver.poll(
stream_id,
ack=processed + replayed,
set_errs={
r.jti: SetErr.from_reason(r.reason)
for r in result.refused
if r.reason is not SetFailureReason.REPLAYED
},
) # result.unjudged: neither acknowledged nor refused -- offered again
The verification order, the reason codes (SetVerificationError.set_failure_reason,
also .reason on the AuthError) and the seven-day replay window are the
contract's; a window below seven days is refused at construction. A verified SET is
recorded, so one re-offered unacknowledged reads as replayed: acknowledge what you
processed. A JWKS fetch failure is a NetworkError, never a verdict on the SET. A
replay store says it cannot answer by raising (never by answering False, which
reads as replayed); verify_set then raises NetworkError with the store's
exception as its __cause__, and never accepts. poll never keeps a jti it does not
return: a failed fetch stops verification, and that SET and the ones after it come back
in result.unjudged; a store failure stops the store being asked, so that SET and every
later one that verifies come back in result.unjudged while a later one that fails
verification is refused as usual. The first failure is in result.unjudged_error;
unjudged SETs are unrecorded, for the transmitter to offer again, and the poll emits
the §19 SsfUnjudged telemetry event. The key cache is refetched after 300 s; a fetch
that fails blocks another for a minute, in which a SET raises NetworkError without a
fetch. The default MemoryReplayStore is one process's, bounded in time (the window)
but not in count; AsyncSsfReceiver also takes an AsyncReplayStore, whose
check_and_record is a coroutine.
CIBA (§33)
Ask AXIAM to authenticate a user on another device, then collect the tokens. Both clients carry the same four names.
from axiam_sdk import CibaAccessDeniedError, CibaExpiredTokenError
initiated = client.ciba_initiate(
scope="openid", login_hint="ada", binding_message="W4SCT", tenant_id=tenant_uuid
) # never retried
try:
tokens = client.ciba_await(initiated, tenant_id=tenant_uuid)
except CibaAccessDeniedError:
... # the user refused
except CibaExpiredTokenError:
... # nobody answered in time
Ping mode: initiate with delivery="ping", client_notification_token=token, and in
the handler of your notification endpoint call
auth_req_id = client.ciba_handle_ping(request.headers, raw_body, token) — no I/O,
synchronous on both clients — answer 204, then client.ciba_poll(auth_req_id, tenant_id=...) once (once more at interval if it answers authorization_pending),
and fall back to ciba_await after half of expires_in without a ping.
Signed form: a client registered with a request-signing algorithm passes
signer=CibaRequestSigner("EdDSA", private_key_pem, kid="key-1") (or "PS256",
"ES256"); the form then carries only the client authentication and request.
A CIBA client always authenticates (its client_secret, or the §6.1 client
certificate for tls_client_auth); one built with neither is refused locally. A
successful ciba_initiate proves nothing about the user: AXIAM answers an unknown or
locked user exactly like a real one, and only expired_token says nobody answered.
auth_req_id, the notification token, the signing key and the request string are
never rendered.
Development
pip install -e ".[dev,fastapi,django]"
pytest tests
mypy --strict src
ruff check .
ruff format --check .
Coverage (as CI runs it, reported to Coveralls):
pytest --cov=axiam_sdk --cov-report=lcov
Client quality-of-life (CONTRACT.md §16–§19)
Retry policy (§16)
Read-only authorization checks — check_access, can, batch_check, on both the sync and
async clients — retry transient failures under the contract's normative table: 3 attempts
(1 initial + 2 retries), 200 ms base, 5 s cap, full jitter (uniform over [0, backoff]),
and Retry-After honored as a floor.
This SDK had no §16 policy before — only §9.3's refresh-then-retry-once, which is a different mechanism. §11.2 rule 5 had been requiring one since it was written.
Only failures that could plausibly succeed on a second attempt are retried: transport errors,
408, 429, 5xx. A 401 or 403 is an answer, not a transport failure, and surfaces after
exactly one attempt. Nothing that changes server state is ever retried.
# Turn it off if you own your own retry layer — you know your deadline, this SDK doesn't.
client = AxiamClient(base_url=..., tenant_slug="acme", retry_enabled=False)
There is deliberately no knob for the attempt cap, base delay or delay cap: §16.1 forbids raising them, and eleven SDKs agreeing on one table is the point.
Deterministic shutdown (§18)
client.close() (sync) and await client.aclose() (async) release local resources. Both are
idempotent, and any call afterwards raises NetworkError naming the cause rather than silently
reconnecting.
Neither logs out. They never reach the network. The server-side session deliberately
outlives the client object — that is what lets a process restart and resume — so a close()
that logged out would silently end every user's session on each deploy. Call logout() first
if ending the session is what you want.
Telemetry hooks (§19)
Wire metrics without this package depending on any metrics library:
from axiam_sdk import AxiamClient, RequestEnd, Retry, TelemetryEvent
def sink(event: TelemetryEvent) -> None:
if isinstance(event, RequestEnd):
histogram.record(event.duration_ms, {"op": event.operation, "outcome": event.outcome})
elif isinstance(event, Retry):
counter.add(1, {"op": event.operation, "attempt": event.attempt})
client = AxiamClient(base_url=..., tenant_slug="acme", telemetry_hook=sink)
- A hook that raises cannot fail the operation that fired it. Telemetry is not permitted to fail an authorization check.
- No event payload can carry a token. The event dataclasses are frozen with a fixed field set — this surface exists to be shipped to a metrics backend.
- Path templates, not URLs, so a metric label cannot become a cardinality bomb.
One RequestStart/RequestEnd pair is emitted per attempt, so you can count real wire
calls. SsfUnjudged (contract 1.60) is emitted when an SSF poll returns leaving SETs
unjudged — the count and the category (key_fetch or replay_store), never a jti. See examples/telemetry_hook.py for the OpenTelemetry
mapping.
Decision memo (§17) — opt-in, off by default
An optional TTL-bounded cache for check_access results. Disabled by default, because
§11.2 rule 6's ban on caching authorization decisions is still the default behaviour.
client = AxiamClient(base_url=..., tenant_slug="acme", decision_memo_ttl_ms=5000) # 0 = off
What you are accepting. 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.
Reads-your-own-writes is not guaranteed. An admin UI that grants a role and immediately re-checks is the case that breaks, and it breaks silently. If that is your workload, leave this off.
The TTL is clamped to 5000 ms rather than rejected. Allows and denies are memoized identically
— asymmetric caching would leak which outcome occurred through latency. Failures are never
memoized: caching a transport error as a deny would turn a blip into a TTL-long outage. The
memo is cleared on login, verify_mfa, refresh and logout, since entries are keyed by
subject rather than by session. It is thread-safe.
Metadata
Release files for axiam-sdk 1.1.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| axiam_sdk-1.1.1.tar.gz | 824.5 kB | With provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| axiam_sdk-1.1.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 1.4 MB
Release files / axiam_sdk-1.1.1.tar.gz
| Download URL | axiam_sdk-1.1.1.tar.gz |
|---|---|
| Size | 824.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
109bf025a913994650ff420c421c76e27ea00827525d6e8cbe696327aa8d25e2
|
|
BLAKE2b-256 checksum How to use checksums |
0f5fe2287d75c0ddfdb79eb706ccb5a51d271e453ae2f78c96017ea436a07c29
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 21, 2026.
Transparency logRelease files / axiam_sdk-1.1.1-py3-none-any.whl
| Download URL | axiam_sdk-1.1.1-py3-none-any.whl |
|---|---|
| Size | 550.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
d70ccd9e3b7f86dcd8bcb6a01881ecedc103fee2d68bd1989ce54220eda863c9
|
|
BLAKE2b-256 checksum How to use checksums |
24c17f491985d482c1c02622b85fa61bfd645a728d8fed9b5c684de01c65e596
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 21, 2026.
Transparency logSecurity for axiam-sdk 1.1.1
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for axiam_sdk-1.1.1.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 21, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.