axiam / axiam-sdk
Official PHP client SDK for AXIAM IAM
Requires
- php: >=8.2
- ext-sodium: *
- firebase/php-jwt: ^7.0
- guzzlehttp/guzzle: ^7.13 || ^8.0
- php-amqplib/php-amqplib: ^3.7
- psr/log: ^3.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.95
- google/protobuf: ^3.25 || ^4.26 || ^5.0
- illuminate/contracts: ^11.0 || ^12.0
- illuminate/support: ^11.0 || ^12.0
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^9.6
- symfony/event-dispatcher-contracts: ^2.5 || ^3.0
- symfony/http-kernel: ^7.0 || ^8.0
- symfony/security-core: ^7.0 || ^8.0
Suggests
- ext-ffi: Required for the §23 OPAQUE client (loginOpaque/opaqueEnrollment), which binds libaxiam_opaque_ffi — CONTRACT §23.1 forbids an SDK from implementing OPAQUE itself. Without ext-ffi, opaqueAvailable() reports false and the OPAQUE login path is unavailable; every other feature is unaffected. The shared library is NOT a Composer package: download the asset for your platform from https://github.com/ilpanich/axiam/releases and put it on the system library path, or set AXIAM_OPAQUE_LIBRARY to its full path.
- ext-grpc: Enables the gRPC transport for checkAccess/batchCheck on long-running runtimes (Swoole/RoadRunner). REST-only fallback is used automatically when absent.
- google/protobuf: Runtime classes (Google\Protobuf\Internal\Message) the committed src/Grpc/Gen/* stubs extend; only loaded behind the same extension_loaded('grpc') guard as grpc/grpc, so never required on a REST-only runtime.
- grpc/grpc: Required alongside ext-grpc for gRPC support; the SDK never fatals without it as long as extension_loaded('grpc') gates every gRPC class reference.
Provides
None
Conflicts
None
Replaces
None
- dev-main / 1.x-dev
- v1.1.0
- v1.0.0
- v1.0.0-beta17
- v1.0.0-beta16
- v1.0.0-beta15
- v1.0.0-beta14
- v1.0.0-beta13
- v1.0.0-beta12
- v1.0.0-beta11
- v1.0.0-beta10
- v1.0.0-beta09
- v1.0.0-beta08
- v1.0.0-beta07
- v1.0.0-beta06
- v1.0.0-beta05
- v1.0.0-beta04
- v1.0.0-beta02
- v1.0.0-alpha44
- v1.0.0-alpha43
- v1.0.0-alpha41
- v1.0.0-alpha40
- v1.0.0-alpha39
- v1.0.0-alpha38
- v1.0.0-alpha37
- v1.0.0-alpha34
- v1.0.0-alpha31
- v1.0.0-alpha30
- v1.0.0-alpha29
- v1.0.0-alpha28
- v1.0.0-alpha27
- v1.0.0-alpha25
- v1.0.0-alpha24
- v1.0.0-alpha23
- v1.0.0-alpha21
- v1.0.0-alpha18
- v1.0.0-alpha16
- v1.0.0-alpha15
- v1.0.0-alpha12
- v1.0.0-alpha11
- v1.0.0-alpha10
- v1.0.0-alpha9
- v1.0.0-alpha8
- v1.0.0-alpha2
- v1.0.0-alpha
- dev-claude/contract-1.62
- dev-claude/contract-1.61-sync
- dev-ccr-7ed2207b-ouofu1
- dev-claude/contract-1.59-sync-esscxl
- dev-claude/contract-1.58-sync
- dev-chore/revendor-spec-utoipa6
- dev-claude/beautiful-bell-pjwmo8
- dev-fix/c12-conformance
- dev-fix/contract-1.51-c12-defects
- dev-feat/contract-1.51
- dev-claude/contract-1-50-sdk-resync-hmmqfa
- dev-claude/t21-9d-conformance
- dev-claude/t21-9c-mcp-helpers
- dev-claude/bold-gates-egmq36
- dev-claude/mfa-first-login-csr-execution-jfba8a
- dev-claude/threat-remediation-2026-09-12-lgzj3q
- dev-claude/contract-1-42-sync-s8s36z
- dev-claude/pr-checks-merge-li3igw
- dev-claude/remediation-plan-2026-09-04-of5cqw
- dev-claude/sdk-drift-check-failure-en4gvr
- dev-claude/sdk-contract-1.38-login-providers
- dev-claude/axiam-contract-1-36-revendor-650c21
- dev-claude/contract-135-fanout
- dev-claude/security-hardening-beta03-abpqgv
- dev-claude/bootstrap-login-opaque-bug-mzjl73
- dev-claude/axiam-pr383-sdk-fanout-xe3gem
- dev-claude/contract-27-management-api-2ti7pw
- dev-claude/sdk-management-operations-2vo6h7
- dev-claude/openapi-schema-sdk-updates-vr32wt
- dev-claude/sdk-language-version-compat-fdofxe
- dev-claude/sdk-updates-aciam-pr-368-guyajs
- dev-claude/sdk-sync-fan-out-f0qy6s
- dev-claude/sdk-openapi-conformance-3podrq
- dev-claude/sdk-reactor-backend-parity-xy28sd
- dev-claude/axiam-docs-improvements-t7xdfz
- dev-claude/srp-to-opaque-migration-37dlui
- dev-claude/rust-wasm-npm-srp-v7o4ww
- dev-claude/code-review-solid-clean-hvnp8g
- dev-claude/amqp-tls-openapi-drift-lycd8j
- dev-claude/reactor-syntactic-sugar-sdks-0zctcp
- dev-claude/missing-tasks-development-33fw3p
- dev-claude/remediation-plan-2026-08-15-8mkrzo
- dev-claude/x5-1-private-key-jwt-dpop-k4938b
- dev-claude/x5-fapi2-conformance-kuurtq
- dev-claude/x6-single-use-guarantee-efx59b
- dev-claude/external-idp-token-exchange-sdk-0tcuue
- dev-claude/aciam-pr-292-check-bbiykz
- dev-claude/improvements-run5-benchmark-def-bazzei
- dev-claude/security-review-fixes-nkwjox
- dev-claude/axiam-fixes-optimization-4qymzu
- dev-claude/g-benchmark-improvements-n5mjmj
- dev-claude/sdk-oidc-sso-plan-8t4hgm
- dev-claude/test-coverage-improvement-plan-idxnfc
- dev-claude/grpc-userinfo-implementation-3txsph
- dev-claude/sdk-docs-examples-audit-sjh24i
- dev-claude/implement-kotlin-swift-c-sdks-7rqoow
- dev-claude/sdk-auth-helpers-impl-ddy5pd
- dev-claude/coverage-action-failures-240of8
- dev-test/raise-coverage
This package is auto-updated.
Last update: 2026-10-18 09:24:43 UTC
README
Official PHP 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
- Packagist package:
axiam/axiam-sdk - Registry: packagist.org/packages/axiam/axiam-sdk
- Source: github.com/ilpanich/axiam-php-sdk
- API reference: ilpanich.github.io/axiam-php-sdk
- License: Apache-2.0
- PHP:
>=8.2— see Supported PHP versions
Install
composer require "axiam/axiam-sdk:^1.1.0"
From 1.0.0 the SDK is stable and follows semantic versioning: a breaking change to its public API comes only with a new major version.
What that promise means for a contract record. The SDK's models mirror the contract's records
(CONTRACT.md, openapi.json). A contract record may add a member or widen a type with a minor
bump — a new optional property on a response model, a new optional constructor parameter, a new
case on an open enum — and a removal waits for 2.0.0. Two consequences for your code: construct
the generated models with named arguments, because their parameters follow the schema's member
order and move whenever the server adds one (1.1.0 moves some, and its change log says which);
and give a match over an open enum an Unknown arm.
Supported PHP versions
| Version | Why this one | |
|---|---|---|
| Floor | 8.2 | The oldest release still receiving security fixes — 8.1 reached end of life on 2025-12-31. This is the php constraint in composer.json, exposed as SupportedVersions::MIN_PHP. |
| Newest | 8.5 | The current release (2025-11), exposed as SupportedVersions::NEWEST_TESTED_PHP. |
8.3 and 8.4 sit between the two and are supported.
The SDK is built against the floor, and runs on everything up to the newest.
CI proves each separately: the gating matrix in sdk-ci-php.yml runs
composer install and the full PHPUnit suite on 8.2 and on 8.5. Source-level
gates that cannot depend on the runtime — composer validate, composer audit,
PHPStan, the docblock-coverage gate and the TLS-bypass grep — run once, on the
floor leg.
8.2 reaches end of life on 2026-12-31. The floor moves to 8.3 then;
tests/VersionPolicyTest.phpasserts the floor is a version that still receives security fixes, so this is a build failure rather than something to remember.
Until this change the declared floor was untestable by construction: the
package published php: >=8.1, but the require-dev framework bridges
(illuminate/support ^11, symfony/* ^7) require PHP ^8.2 themselves, so
composer install was unsatisfiable on 8.1 and CI died before running a single
test. The floor was advertised to every Packagist consumer and had never once
been executed.
Axiam\Sdk\SupportedVersions exposes both ends as readable constants — see
examples/version_compatibility.php
for a runnable preflight. Composer enforces the lower bound at install time,
but only at install time: --ignore-platform-reqs, a config.platform
override, or a vendor/ tree built on one runtime and deployed onto another
all get past it, and the mismatch then surfaces as a parse error on the first
request.
Quickstart
<?php use Axiam\Sdk\AxiamClient; use Axiam\Sdk\Core\AuthError; // `tenant` is a REQUIRED constructor argument — AXIAM is multi-tenant and there is no // default tenant. There is no overload that lets you omit it. `login()`/`refresh()` also // require ORGANIZATION context (CONTRACT.md §5.1): a tenant slug is only unique WITHIN an // organization, so supply `orgSlug` (or the org UUID via `orgId`) — the server rejects a // login without one (HTTP 400 "must provide org_id or org_slug"). $client = new AxiamClient( baseUrl: 'https://your-axiam-instance', tenant: 'acme', orgSlug: 'acme', ); try { $result = $client->login('alice@acme.test', 'correct horse battery staple'); } catch (AuthError $e) { // typed exception hierarchy (AuthError/AuthzError/NetworkError), never a bare status code exit(1); } if ($result->mfaRequired) { $result = $client->verifyMfa($result->challengeToken, $totpCode); } // Same $client instance — the authenticated session's cookies/CSRF are shared automatically. // `can(action, resource)` — same argument order as every other AXIAM SDK (CONTRACT.md §1). $allowed = $client->can('read', 'documents');
See examples/login_mfa.php and
examples/rest_authz.php for complete, runnable versions of
this flow.
Organization-level principals (CONTRACT.md §5.2)
A completed login also reports whether the account it signed in 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:
if ($result->organizationLevel) { // This principal can act on another tenant by sending a different X-Tenant-ID on // the next request — no re-login, because it already is a principal of every // tenant in the organization. Offer the tenant selector. }
An ordinary tenant principal is a principal of exactly one tenant; changing the header for
one of those produces a 403, so an admin UI checks this flag before offering the switch
rather than discovering the answer from a failed request.
The flag is derived, never asserted: it is resolved server-side from the caller's own
tenant record, it is not a constructor argument, and this SDK never sends it. It is false
against a server older than contract 1.31, and false on the two pending login outcomes,
where no principal has been established yet — the safe direction in every case.
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 = new AxiamClient('https://iam.example.com', 'organization', orgSlug: 'globex'); $result = $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. The constructor rejects a blank $tenant or
$orgSlug, whitespace included; a null $orgSlug stays fine, because that is the
organization identifier being optional rather than blank.
Runtime requirements — read this before using gRPC or the AMQP worker (SC#3)
The REST transport (login/MFA/refresh/logout/checkAccess/can/batchCheck over HTTP)
works on any PHP runtime, including standard PHP-FPM. This is the default and requires
no special deployment.
ext-sodium is required (composer.json declares it). AXIAM signs with Ed25519, so local
token verification (§10.1) and the SSF receiver (§32.7) verify through it, and the signed
§33.2 CIBA request signs EdDSA with it. It is compiled into PHP by default; on a build made without
it, token verification and the SSF receiver fail with a typed AxiamException naming
ext-sodium rather than a fatal error.
gRPC and the AMQP consumer are different — they require a long-running PHP runtime (Swoole, RoadRunner, or a plain long-lived CLI process), not standard PHP-FPM:
- gRPC (
checkAccess/can/batchCheckoverAxiam\Sdk\Grpc\AuthzGrpcClient) is an opt-in performance transport, guarded byextension_loaded('grpc'). On a share-nothing per-request runtime like PHP-FPM, there is no benefit to a persistent gRPC channel — every request tears the process down anyway — so the SDK automatically falls back to the always-available REST transport (POST /api/v1/authz/check[/batch]) whenever thegrpcPECL extension is absent, or when the client is explicitly configuredrestOnly: true. Authorization checks always work; gRPC is purely a latency optimization for long-running workers that can reuse one channel across many requests. Seeexamples/grpc_checkaccess.php. getUserInfo()is gRPC-ONLY (CONTRACT.md §1.1, contract 1.3) — the low-latency counterpart of the server's RESTGET /oauth2/userinfo, invokingaxiam.v1.UserInfoService/GetUserInfoon the same gRPC channel. UnlikecheckAccess/can/batchCheckit has no REST fallback: on a runtime without thegrpcextension (or withrestOnly: true) it raisesNetworkErrorrather than degrading. It requires a prior successfullogin()(calling it with no token raisesAuthErrorbefore any wire call); a gRPCUNAUTHENTICATEDresponse drives the shared single-flight refresh (§9) and retries once. It returns a typedAxiam\Sdk\Auth\UserInfo(sub,tenantId,orgId, and?email/?preferredUsername— the latter two present only when the token carries theemail/profilescope).- The AMQP consumer (
Axiam\Sdk\Amqp\Consumer, run viabin/axiam-amqp-worker.php) is a standalone CLI-oriented blocking consume loop — it is not a web-request path at all and must never be invoked from an FPM worker.
Process supervision is your responsibility for the AMQP worker. php-amqplib (unlike
the Go/C# sibling SDKs' AMQP clients) has no built-in automatic reconnection — if the
broker connection drops (broker restart, network blip), the worker process exits rather
than silently retrying forever. Run it under a process supervisor that restarts it on
failure: systemd (Restart=on-failure), a RoadRunner worker-pool respawn, or a Docker
restart: unless-stopped policy. A worker with no supervision will simply stop consuming
messages after the first connection loss and never recover on its own.
A broker confirm is not evidence that AXIAM saw a message (CONTRACT.md §8, contract 1.60).
A publisher confirm, or the broker's basic.ack of a publish, means only that the broker
accepted the message; this SDK never treats it as proof that AXIAM decided a request or
recorded an event. A server running in the minimal profile (AXIAM__AMQP__ENABLED=false)
reads no AMQP queue at all — it consumes neither axiam.authz.request nor
axiam.audit.events, whatever a broker holds — so against one use REST or gRPC. GET /health
reports profile: minimal and lists amqp_authz and amqp_audit_ingestion under
unavailable.
Contract conformance
This SDK conforms to contract 1.62: CONTRACT.md §1–§13 and §12.7, §14,
§15, §17, §19, §20, §21, §22, §23, §24, §25, §26, §27 (§27.6.2 included), §28, §28.12, §29, §30, §31, §32, §33
and §36,
with §32.7 and §33.2 signed (ES256, EdDSA) — the signed form for those two algorithms;
PS256 is not shipped, see CIBA below for why — including
§6.1 mTLS, contract 1.3; §12 OIDC/SSO helpers, contract 1.4; §13 webhook-signature
verification; the §17 decision memo and §19 telemetry hooks, contract 1.8; §28 MCP
resource-server helpers, contract 1.48, see below — the binding,
cross-language behavioral contract every
AXIAM SDK implements: camelCase method names (§1) — including the gRPC-only getUserInfo
operation (§1.1) — the AuthError/AuthzError/NetworkError typed exception hierarchy (§2,
extended by the §12 OAuthProtocolError AuthError sub-type), non-browser X-CSRF-Token
response-header capture (§3), a shared Guzzle CookieJar (§4), a required tenant
constructor parameter with no default (§5), strict TLS with customCa as the only
server-verification escape hatch (§6) plus optional client-certificate mutual TLS (§6.1),
Sensitive-wrapped token redaction (§7), HMAC-SHA256-verified AMQP
messages (§8), single-flight refresh concurrency safety (§9), framework
middleware/subscriber integration (§10), declarative per-endpoint authorization
helpers (§11, see below), OIDC/SSO relying-party helpers (§12, see below), and
webhook-signature verification (§13, see below), the opt-in §17 decision memo
(decisionMemoTtlMs) and the §19 telemetry hooks (telemetryHook, see
examples/telemetry_hook.php), the §22 reactor runtime
(reactorServe, see below), the §23 OPAQUE login path (loginOpaque, see below —
conditional on ext-ffi and one shared library, which is PHP's alone among the eleven
SDKs), the §24 WebAuthn relying-party layer with its §24.6a JSON bridge and, since contract
1.45, the session-less webauthnSetupRegisterStart/webauthnSetupRegisterFinish pair (see
below), the §25 account-lifecycle and MFA-enrolment operations (see below), §26 Pushed
Authorization Requests (see below), and the §28 MCP resource-server helpers — the RFC 9728
protected-resource metadata document and the RFC 6750 bearer challenge, opt-in and off by
default (see below).
§24.6b — the linked-API ceremony helper — is deliberately absent. PHP runs on a server, which has no authenticator, and §24.6b rule 2 forbids emulating one in software: a "credential" held in process memory is not a second factor. The ceremony runs in the browser, and §24.6a's JSON bridge is the seam that carries the challenge out and the response back.
§28.12, §29, §30, §31, §32, §32.7, §33 and §36 are named rather than folded into a range because
they landed after this SDK already stated its earlier sections: widening a 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; it is
part of the §21 claim, not of §33's. Vector A is pinned as the vendored CONTRACT.md carries
it, tenant_id queries included.
Manifest pruning: not offered in any namespace (§27.10) — nothing a manifest omits is ever deleted.
Contract 1.59 (§34) adds no section and changes no wire shape; its clarifications P1 – P12 bind
the sections above, and this SDK follows them — the table below says how, where the contract
left a choice. Contract 1.60 answers the questions the 1.59 ports raised (§34.4), adds three
optional members to the §27 models and the federation.update_config null rule (§27.15), and
expected_updated_at on §31's ScimTargetInput; its §35 (certificate revocation lists) is
informative and has no SDK surface. The second table says what 1.60 changed here.
Contract 1.61 (additive, §34.5) adds three members: Certificate.user_id and its two request
members (§27.11 rule 5), SsfStreamInput.expected_updated_at with the three meanings of
events_requested on an update (§32.2, §32.3 rule 4), and CibaPendingList.total_pending and
truncated (§33.3 rule 16, a console surface this SDK does not model). The third table says what
1.61 changed here.
Contract 1.62 (the 1.1.0 release, §34.6) adds the network management namespace (§36) and puts it in
the manifest (§27.6.2), makes the device grant authenticate its client (§14.1), gives a stream a
change-taking read-modify-write form (§32.2) and settles two rules about request members (§27.11
rule 5). Release 1.0.1 was never tagged: this is the first release to carry 1.61 as well, as 1.1.0 (its change log is merged into 1.1.0).
The first table below says what 1.62 changed here.
Contract 1.62 — what changed here
| Item (§34.6) | Here |
|---|---|
(l) the network namespace (§36) |
Twelve operations on $client->network() (also $client->management()->network()): listSegments, createSegment, getSegment, replaceSegment, deleteSegment, listProfiles, createProfile, getProfile, replaceProfile, deleteProfile, getPolicy, replacePolicy; 202 operations across 29 namespaces. The decision route POST /api/v1/network/decide is no SDK work and has no method |
§36.4 the 409 sub-code |
ConflictError::getReasonCode() returns the body's reason_code verbatim (the vocabulary is open); null when the body has none |
(n), (u), (v), (w) network in a manifest (§27.6.2) |
ManifestBuilder::networkPolicy(), networkSegment(), networkProfile(); see the network section |
(o) eleven replace operations |
The three network PUTs are replace* and full-body, each carrying the version the caller read; their docblocks give the read-modify-write pattern |
| (m), (t) the device grant authenticates its client (§14.1) | A configured secret is sent as client_secret in the form body at deviceAuthorize() and every devicePoll(); a certificate is presented on the connection; a public client sends none. Never Authorization: Basic. See the device section |
| (e), (g), (h) the stream form (§32.2) | ReadModifyWrite::ssfStream($stream, $changes) is the change-taking form: it sends events_requested and the server's own updated_at string as read and cuts events_requested to a lowered events_allowed. The plain call (ssfStream($stream)) is the converter, and its documentation names the caller's duty. updateStream()'s docblock states the three meanings of events_requested |
(f) an assigned null is "none" |
Already held: a PHP null on userId (both certificate requests) or expectedUpdatedAt (updateStream, scimTargets()->update) is the same as never setting it — the key is omitted and null is never sent |
(i) the request user_id is an untyped string |
Already held: CreateCertificateRequest::$userId and SignCertificateCsrRequest::$userId are ?string; a malformed value reaches the server and its 400 is a ValidationError. Certificate::$userId is unchanged |
| (j) change-log line | "wire-additive; source-breaking in Rust, Java, Kotlin and PHP, released as 1.1.0": a member added to a generated PHP model is a new constructor parameter, which breaks a positional call — see the change log |
(d) CibaPendingList |
No SDK work, and not modelled |
Contract 1.59 — the Phase 23 review follow-up (F-59-06)
| Clarification | Here |
|---|---|
P1 poll never keeps a jti it does not return |
The second form: what was judged is returned, and a SET a key-fetch or store failure left unjudged is unrecorded and listed in SsfPollResult::$unjudged (with $unjudgedCause); after a store failure the store is asked nothing more for the batch, a later SET that fails verification is still refused and one that verifies is unjudged; the failure is raised instead only when no SET of the batch had been accepted |
| P4 the replay store | ReplayStore::checkAndRecord() reports a failure by throwing, which accepts nothing (fail closed) and surfaces as a NetworkError with the store's exception chained; the default InMemoryReplayStore is unbounded in count, its entries expiring after the window |
| P6 key fetches | A failed fetch — a fill and an expiry refresh included — and every unknown-kid refetch count toward the once-a-minute limit; a successful fill does not. The key cache expires 300 s after the fetch that filled it |
P8 a 5xx on ciba_poll |
A NetworkError whatever its body (500 {"error":"server_error"} included), retried under §16 and outlived by cibaAwait |
P10 cibaAwait's anchor |
The instant the initiate response was received, read from the injected CibaClock, which also supplies the waits |
| P12.1 / P12.2 unknown SCIM arms | ScimTargetAuthUnknown / ScimTargetScopeUnknown keep the type and nothing else; they render for a log line and are refused locally on the request path |
| P12.4 the RFC 7592 replacement | Built from what the read carried: a list the read lacked is not sent, a member of an unexpected shape goes back as read |
| P12.7 the signed form | Claimed as "§33.2 signed (ES256, EdDSA)" |
Contract 1.61 — what changed here
| Row | Here |
|---|---|
§27.11 rule 5 Certificate::$userId |
On Certificate and GeneratedCertificate, ?string: null and an absent member (a server before 1.1.0) both decode as "no account named"; never required, never synthesised from metadata or the subject |
| §27.11 rule 5 request member | CreateCertificateRequest::$userId and SignCertificateCsrRequest::$userId, sent exactly as given; the key is left out when unset, never null; the value is not judged locally (the server's 400 is surfaced) |
§32.2 expected_updated_at |
SsfStreamInput::$expectedUpdatedAt, sent on updateStream exactly as given (the string) and absent when unset; a 409 is a ConflictError, one request, no retry |
§32.2 events_requested on an update |
Absent resets to every allowed event, [] is sent as [], a list as given. ReadModifyWrite::ssfStream() sends the events_requested and the updated_at it read (and cuts the requested list to a lowered events_allowed) |
§33.3 rule 16 CibaPendingList |
Not modelled: no registry operation returns it and the generator emits only the models its operations carry |
Contract 1.60 — what changed here
| Row | Here |
|---|---|
§31 expected_updated_at |
ScimTargetInput::$expectedUpdatedAt, sent on update exactly as given and absent when unset; ReadModifyWrite::scimTarget() fills it with the updated_at it read, so an edit made since answers 409 (ConflictError) |
§27.15 window_minutes |
On CreateNotificationRuleRequest, UpdateNotificationRuleRequest and NotificationRuleResponse; passed through, never clamped |
§27.15 allow_sha1_signatures, idp_metadata_signing_cert_pem |
On the three federation configuration models, sent only when set; a response without allow_sha1_signatures decodes as false |
| §27.15 note 8 | The ten clearable members of UpdateFederationConfigRequest are string|JsonNull|null: null leaves the member out (unchanged), JsonNull::Null sends null (cleared) |
§12.1 refresh scope |
OidcTokenSet::$scope is the refresh response's, never the original grant's |
| §21.5 discovery | OidcConfiguration decodes the four revocation and introspection authentication members, each optional |
§19.1 ssf_unjudged |
SsfUnjudgedEvent: a poll that returns with SETs unjudged reports their number and key_fetch or replay_store |
Contract 1.53 – 1.58 — what this SDK ships
| Section | Here |
|---|---|
| §28.12 RFC 7592 client configuration | readClientRegistration, updateClientRegistration, deleteClientRegistration on AxiamClient; Axiam\Sdk\Oidc\ClientRegistration |
| §29 SAML service providers | $client->saml() — eleven generated operations with the §29.3 call-site notes; ParseSamlSpMetadata::fromUrl() / ::fromXml(), both-or-neither refused locally |
| §30 directory | $client->directory() — six generated operations; bindSecret Sensitive; an explicit null on update through JsonNull::Null |
| §31 outbound SCIM targets | $client->scimTargets() — six generated operations; credential Sensitive |
| §32 SSF streams | $client->ssf() — five generated operations; authorizationHeader Sensitive |
| §32.7 SSF receiver helper | $client->ssfReceiver(...) → Axiam\Sdk\Ssf\SsfReceiver::verifySet() / poll() |
| §33 CIBA | cibaInitiate, cibaPoll, cibaAwait, cibaHandlePing on AxiamClient |
| §33.2 signed (ES256, EdDSA) | CibaRequestSigner::fromPem() — ES256 and EdDSA; PS256 refused locally |
| §21.3.1 (amended) | MtlsEndpointAliases::$backchannel_authentication_endpoint, the seventh alias |
Carve-out — PS256 for the signed §33.2 request. This SDK's JOSE library,
firebase/php-jwt, signs PS256 only through phpseclib 3, which is not a dependency of
this package (the phpseclib a Composer install may pull in transitively for php-amqplib
is version 4, whose namespace php-jwt does not load). Rather than depend on a transitive
package or fail at the first request, CibaRequestSigner::fromPem(CibaSigningAlg::PS256, …)
is refused locally with a ValidationError. A client that must sign CIBA requests registers
ES256 or EdDSA.
Contract 1.51
- §5.2 rule 1 — the acting tenant.
actingTenantat construction, and$client->actingTenant($uuid)/$client->clearActingTenant()on an existing one, sendX-Axiam-Tenanton every/api/v1request while set. Gated client-side onorganizationLevel/reachableTenantIdsonce a login result reports them; a client holding none has nothing to gate on and lets the server's403answer. Which calls set/reset the gate:login(),verifyMfa(), OPAQUE's finish, and the MFA/WebAuthn setup completions all SET it, since the server sends a user object on all five; WebAuthn AUTHENTICATION, SSO/federation completions,authenticateDevice()and a client-credentials/device-grant credential adoption RESET it to unknown, since none of those carries aLoginUserInfo; andlogout()also resets it to unknown — a logged-out client holds no login result for the next session (any principal) to wrongly inherit. Mutates the client and returns it (self) rather than returning a new handle over a shared session — the reference implementation's per-handle isolation exists for languages where one long-lived client is shared across concurrent tasks, which is not how a PHP request lifecycle works; recorded as a deliberate difference, not an oversight. This is a REST-only mechanism (a §5.2 MUST): the server readsX-Axiam-Tenantonly in the/api/v1REST authentication extractor, so this header is never sent withcheckAccess()/validateToken()/introspectToken()'s gRPC transport, andactingTenant()/clearActingTenant()never affect it — a gRPC call always acts on the tenant its own bearer token'stenant_idclaim carries, with no client-side override. - §6.1 rules 6-10 —
authenticateDevice().POST /api/v1/auth/device, no body, reachable only on a client built withclientCert/clientKey. §6.1 rule 7 is a RUNTIME refusal, not a compile-time one — PHP has no way to makeauthenticateDevice()unreachable on a client built without a certificate the way a statically-typed SDK's type system could; calling it on such a client throwsAuthError, with zero wire calls, the moment it is called. Adopts the returned token as a bearer credential (never a cookie — the server sets none on this route) and withholds any cookie left from an earlierlogin()/verifyMfa()session on the same client object, so a stale session cannot silently outrank the device's own credential. Seeexamples/device_mtls_provisioning.php. - §1.1.1/§10.3 —
validateToken()/introspectToken(). The gRPCTokenServicewrappers §10.3 has required since contract 1.17 and no PHP method reached until now. Both return a typed result whosestatus()/verifyPossession(PresentedProofs)apply §10.1 rule 9 —valid/activealone is never permission to proceed. - §10.1 rule 9, fixed (BREAKING).
verifyLocally()— the entry point every request guard and the Laravel/Symfony bridges use — now refuses a certificate- or DPoP-bound token it has no evidence for, rather than admitting it as an ordinary bearer credential. A guard with real connection evidence to offer callsverifyWithProofs($token, $tenant, PresentedProofs)instead. See the CHANGELOG. - §27.6.1 — the manifest additions, read against the flat-entity tier (§27.10)
PHP's manifest already is:
resources[].metadata— already supported before 1.51.- Two pre-existing defects, fixed: a role's
grantsand a group'srolesare now actually reconciled (additively —apply()'s own docblock claimed this before it was true), and a nested resource'sparent_idnow actually reaches the wire. - The resource-scoped binding shape,
{role, resource, inherit}—RoleBinding::at($role, $resource)/::atOnly($role, $resource)— on group and service-account role bindings, alongside the plain role-key shape every manifest already used.inheritreaches the wire only asfalse. A subject's binding is keyed on(subject, role): a changed resource orinheritis anUpdate, done as unassign then assign, with the server binding'stenant_scopecarried across and the previous binding restored (reported viaBindingRebindFailed) if the re-assignment fails. One role bound twice to a subject, or a role bound withinherit: falsewhen that same manifest declares the roleisGlobal: true, is refused while the manifest is built — zero wire calls. A global role is refused only when its own->role(..., isGlobal: true)declaration is in this manifest; a binding naming a role this manifest never declares as global (because it declares it without that flag, or does not declare the role at all) is sent as-is and the server's400is the answer. service_accounts—ServiceAccountSpec { key, name, description?, roles? }, reconciled byname(the server does not enforce it unique, so an ambiguous match failsplan()before any write).descriptionis the only field anUpdatereconciles. ACreate's one-timeclient_secretis onApplyReport::createdServiceAccounts(), kept even when a later step of the sameapply()fails —apply()never rotates one. Service accounts and their bindings are applied last (§27.6 rule 5).
Framework integration
Laravel — auto-discovered, zero-config
composer require "axiam/axiam-sdk:^1.1.0"
That's it. Laravel's own package auto-discovery
reads this package's composer.json extra.laravel.providers entry and registers
Axiam\Sdk\Laravel\AxiamServiceProvider automatically — no config/app.php edit, no
bootstrap/providers.php entry. You get the axiam.auth authentication middleware and
the axiam Gate ability (can:axiam,<resource>,<action> → 403 on deny) out of the box.
See examples/laravel_app/README.md for the full
middleware + Gate example, including a runnable 401/403/200 route.
Symfony — MANUAL registration is required
Unlike Laravel, the Symfony bridge does NOT auto-discover itself. Symfony has no
equivalent to Laravel's extra.laravel.providers mechanism without a published Symfony
Flex "recipe" (out of scope for this SDK). After composer require axiam/axiam-sdk, a
Symfony application must perform two manual steps:
- Add
Axiam\Sdk\Symfony\AxiamBundle::class => ['all' => true]toconfig/bundles.php. - Tag
Axiam\Sdk\Symfony\AxiamAuthSubscriber(kernel.event_subscriber) andAxiam\Sdk\Symfony\AxiamVoter(security.voter) inconfig/services.yaml.
AxiamBundle itself ships no container extension — registering the bundle alone does
not wire the subscriber or voter; both manual steps are required. This is a genuinely
different (not lesser) developer experience than Laravel's — do not expect
composer require alone to do anything on Symfony. Full copy-pasteable
config/bundles.php/config/services.yaml snippets and a runnable
401/403/200 controller example are in
examples/symfony_app/README.md.
Local token verification (CONTRACT.md §10.1)
Both framework guards — the Laravel AxiamMiddleware and the Symfony
AxiamAuthSubscriber — verify access tokens through one implementation,
Axiam\Sdk\Auth\JwksVerifier::verify(), which applies the complete §10.1 minimum
local-verification set. Every rule fails closed: a required claim that is absent,
unparseable, or of the wrong JSON type is a rejection, never a skipped check.
| # | Claim | What the verifier does |
|---|---|---|
| 1 | signature | Verified against the org-wide JWKS with alg pinned to EdDSA before any kid lookup, so alg: none and HS-family confusion are rejected without ever consulting a key. |
| 2 | exp |
Required. No exp, or an exp that is not a JSON number, is rejected. An absent exp is a permanent credential, not an absent constraint. |
| 3 | nbf |
Honoured when present; an nbf in the future is rejected. An absent nbf is valid. |
| 4 | tenant_id |
Required and asserted against the configured tenant. An absent claim — or an empty configured tenant — is rejected. |
| 5 | iss |
Checked only when axiam.expected_issuer / AXIAM_EXPECTED_ISSUER is set. Unset by default. |
| 6 | aud |
Checked only when axiam.expected_audience / AXIAM_EXPECTED_AUDIENCE is set. Unset by default; both the single-string and array forms are honoured. |
| 7 | clock skew | JwksVerifier::CLOCK_SKEW_LEEWAY_SECONDS — a named 60-second constant applied to rules 2 and 3. Deliberately not operator-configurable. |
| 8 | (no claim check) | Not a claim this verifier decodes — §10.1 rule 8, "the guard decides on the CALLER's credential and no other", is a control-flow discipline for whoever calls this class. Both framework guards call verifyLocally(), never verifyLocallyOrFallback(), so a certificate- or DPoP-bound token the guard has no transport evidence for is always refused behind them — a bound token is never silently admitted as a bearer credential (CONTRACT 1.52 N1, C-12). |
| 9 | cnf (contract 1.51) |
A token carrying cnf is not a bearer token and MUST NOT be admitted without evidence satisfying every constraint it names. verifyLocally()/JwksVerifier::verify() has no transport to ask for that evidence, so it applies this rule with NONE presented — refusing every bound token, admitting every unbound one. AxiamClient::verifyWithProofs($token, $tenant, PresentedProofs) / JwksVerifier::verifyWithProofs() is the same check with real evidence YOUR OWN connection established. |
What firebase/php-jwt does versus what §10.1 requires. JWT::decode() validates
nbf/iat/exp and rejects a non-numeric exp — but only when the claim is present
(isset($payload->exp) && …), so a token with no exp at all passes straight
through it. Its is_numeric() test also accepts a quoted "1700000000", which is a JSON
string rather than an RFC 7519 NumericDate. And JWT::$leeway is a public mutable static
that any code in the process can set to an unbounded value. This SDK therefore enforces
rules 2, 3 and 7 itself rather than inheriting the library's behaviour, and pins
JWT::$leeway to its own named constant for the duration of every decode.
The X-Tenant-ID request header narrows; it never overrides. The token's tenant_id
is asserted against the tenant the application was configured with. When the request
also carries X-Tenant-ID, that header must agree with the verified claim — it can never
select which tenant is expected, because it is attacker-controlled and doing so would
make the check vacuous.
iss and aud are conditional and default to unset; no issuer or audience is hardcoded
anywhere. Configure them when your deployment has an expectation to assert — an app
guarding a user-facing resource server should generally expect axiam:user:
// config/axiam.php (Laravel) — or the AXIAM_EXPECTED_* environment variables. return [ 'base_url' => env('AXIAM_BASE_URL'), 'tenant' => env('AXIAM_TENANT'), // CONDITIONAL (§10.1 rules 5 and 6). Omit either to skip that check entirely. 'expected_issuer' => env('AXIAM_EXPECTED_ISSUER'), 'expected_audience' => env('AXIAM_EXPECTED_AUDIENCE'), ];
Declarative authorization helpers
CONTRACT.md §11 adds a per-endpoint authorization layer on top of the §10
authentication guard above: three PHP 8 attributes in Axiam\Sdk\Attributes —
#[RequireAuth], #[RequireAccess(action: ..., resourceParam: ...)], and
#[RequireRole(...)] — enforced by a single shared Axiam\Sdk\AccessEnforcer that
BOTH framework bridges delegate to, so Laravel and Symfony applications get
byte-identical semantics.
use Axiam\Sdk\Attributes\RequireAccess; final class DocumentController { // Resolves the resource UUID from the {id} route parameter, checks 'read' for // the REQUEST'S authenticated user (never the shared AxiamClient's own session), // and returns 401/400/403/503 automatically on failure. #[RequireAccess(action: 'read', resourceParam: 'id')] public function show(string $id) { /* ... */ } }
- Symfony: tag
Axiam\Sdk\Symfony\AxiamAccessAttributeListener(kernel.event_subscriber) inconfig/services.yaml, alongsideAxiamAuthSubscriber/AxiamVoter— seeexamples/symfony_app/services.yamlandexamples/symfony_app/DocumentController.php. - Laravel: the
axiam.accessroute-middleware alias (registered automatically byAxiamServiceProvider, same asaxiam.auth) supports the attribute style above AND a string-param style needing no attribute at all —->middleware('axiam.access:read')(action, then optionalscope,resourceParam, defaulting to'id') — seeexamples/laravel_app/routes.php.
Semantics (identical in both bridges, CONTRACT.md §11.2): require_access runs
strictly AFTER authentication — a missing identity is 401, never a second token
verification. The resource id is a UUID resolved from (in order) a static literal, a
route parameter, or a resolver callback; unresolvable is 400, never a silent allow. A
denied check is 403; a transport failure fails CLOSED with 503 (never allows).
checkAccess is always called with the REQUEST's authenticated user_id as the
subject — not whatever session the shared AxiamClient itself might separately hold.
#[RequireRole(...)] is a LOCAL, no-server-round-trip check against the verified
identity's roles — coarser than #[RequireAccess] and not a substitute for it. No
decision is ever cached, and no token material appears in any error output.
OIDC / SSO relying-party helpers (CONTRACT.md §12)
Thirteen operations, directly on AxiamClient, let this SDK act as an OIDC/OAuth2
relying party against AXIAM's own OIDC provider — "Login with AXIAM"
(authorization-code + PKCE), service-account client_credentials, token
introspection/revocation, upstream-IdP federation SSO, and — as of contract 1.38 —
the public "Sign in with X" buttons:
| Method | Wire call | What it does |
|---|---|---|
oidcDiscover() |
GET /.well-known/openid-configuration |
Fetch the discovery document (cached per origin, ≥5 min TTL, single-flight). |
oidcBegin($configuration, $redirectUri, scope: ..., extraParams: ...) |
(none — pure local computation) | Build the authorization URL + a fresh state/nonce/PKCE code_verifier. |
oidcExchange($code, $codeVerifier, $redirectUri, $nonce, ...) |
POST /oauth2/token (authorization_code) |
Exchange a code for a token set; validates the ID token in full (§12.4). |
oidcRefresh($refreshToken, ...) |
POST /oauth2/token (refresh_token) |
Refresh an OIDC token set — distinct from, but §9-guard-sharing with, refresh(). |
loginClientCredentials(...) |
POST /oauth2/token (client_credentials) |
Service-account machine-to-machine login. |
introspect($token, ...) |
POST /oauth2/introspect |
RFC 7662 — is this token active, and what does it carry? |
revoke($token, ...) |
POST /oauth2/revoke |
RFC 7009 — revoke a token (idempotent: any 200 is success). |
ssoStart($federationConfigId, $redirectUri, ...) |
POST /api/v1/auth/federation/oidc/start |
Step 1 of upstream-IdP federation SSO. |
ssoComplete($state, $code) |
POST /api/v1/auth/federation/oidc/callback |
Step 2 — session arrives as Set-Cookie, captured via the §4 cookie jar. |
ssoProviders($orgId, $orgSlug, $tenantId, $tenantSlug) |
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. |
ssoStartOauth2($federationConfigId, $redirectUri, ...) |
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. |
ssoCompleteOauth2($state, $code) |
POST /api/v1/auth/federation/oauth2/callback |
Step 2 of the OAuth2 variant; same Set-Cookie session and §3 CSRF capture as ssoComplete(). |
ssoCompleteHandoff($code) |
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. |
What the discovery document models, and what is optional in it (§12.1, §21.5)
OidcConfiguration models a curated subset of the OIDC Discovery 1.0 / RFC 8414
document, and the optionality is the SDK's rather than the schema's. AXIAM marks nearly
every member required; this type marks one required only where a missing value would leave
the SDK with nothing to do but guess a URL. So the conditionally-advertised endpoints
(device_authorization_endpoint, pushed_authorization_request_endpoint,
end_session_endpoint) are nullable — §12.7.2 rule 1 forbids synthesising them from the
issuer — and a document from a non-AXIAM OP still parses.
Contract 1.42 adds two capability lists, both nullable here even though AXIAM marks them required:
| Property | Advertised by AXIAM | Why it is optional in this SDK |
|---|---|---|
code_challenge_methods_supported |
["S256"] |
RFC 8414 §2 defines no default, so absence does not mean S256 (§21.5) — it means the document says nothing about PKCE at all. |
token_endpoint_auth_signing_alg_values_supported |
["PS256", "ES256", "EdDSA"] |
Same, and informational either way: §12.1 note 3 pins this SDK to client_secret_post, so it never signs a client assertion. |
null means the member was absent; an advertised [] survives as [], because a server
saying "none of these" is a different fact from a server saying nothing.
A related 1.42 change is a rationale, not a rule: client_secret_basic is now accepted
server-side and advertised in token_endpoint_auth_methods_supported. This SDK does not
change — §5 rule 3 still forbids an Authorization: Basic header on /oauth2/*, because
the two methods carry the identical credential and the header is the channel reverse
proxies and APM agents log by default. An advertisement is a statement about the deployment,
not an instruction to the client.
The four public login-provider operations, and their rules (contract 1.38)
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. ssoProviders() returns every one of them as an ordinary result and
never throws: 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 ssoProviders() is the one federation operation that does
not throw 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
providerKind, which is branding:
$provider->protocol |
call |
|---|---|
FederationProvider::PROTOCOL_OIDC_CONNECT |
ssoStart() |
FederationProvider::PROTOCOL_OAUTH2 |
ssoStartOauth2() |
FederationProvider::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 distinction a UI rendering these buttons should make
visible.
FederationProvider is modelled faithfully — id, providerKind, displayName,
protocol, hasBundledMark, inherited, and the nullable buttonIcon (a data: URL,
null for most providers). Inheritance from the organization is resolved server-side
(§12.1 note 13): pass back the workspace and the id ssoProviders() 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 $redirectUri, 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 $redirectUri
out of anything the identity provider supplied.
use Axiam\Sdk\Oidc\FederationProvider; use Axiam\Sdk\Oidc\FederationProviderList; $providers = $client->ssoProviders(orgSlug: $orgSlug)->providers; // An empty list is normal: render a password form, not an error. foreach ($providers as $provider) { match ($provider->protocol) { FederationProvider::PROTOCOL_OIDC_CONNECT => $client->ssoStart($provider->id, $redirectUri), FederationProvider::PROTOCOL_OAUTH2 => $client->ssoStartOauth2($provider->id, $redirectUri), default => null, // Saml: use the SAML login endpoint }; } // SAML / Apple come back through a handoff code on your own callback route. $code = $_GET[FederationProviderList::HANDOFF_QUERY_PARAM]; $session = $client->ssoCompleteHandoff($code); // once, never retried
use Axiam\Sdk\AxiamClient; use Axiam\Sdk\Core\AuthError; $client = new AxiamClient( baseUrl: 'https://api.axiam.example', tenant: 'acme', oidcClientId: 'my-app', oidcClientSecret: getenv('AXIAM_OIDC_CLIENT_SECRET') ?: null, // omit for a public client oidcTenantId: '11111111-1111-1111-1111-111111111111', // UUID for the /oauth2/* query param (§12.3 rule 4) ); $configuration = $client->oidcDiscover(); $request = $client->oidcBegin($configuration, 'https://app.example/callback', scope: 'openid profile'); // Persist $request->state / $request->nonce / $request->codeVerifier YOURSELF — see below. // ...redirect the browser to $request->url... // On the callback, having checked the IdP's `state` matches: try { $tokens = $client->oidcExchange( code: $callbackCode, codeVerifier: $request->codeVerifier, redirectUri: 'https://app.example/callback', nonce: $request->nonce, ); } catch (AuthError $e) { // $e->getReason() is one of the §12.4 codes (invalid_alg, unknown_kid, // invalid_signature, invalid_issuer, invalid_audience, token_expired, // nonce_mismatch) when this was an ID-token validation failure, or an // Axiam\Sdk\Core\OAuthProtocolError (an AuthError sub-type — existing // catch(AuthError) blocks keep working) carrying ->error/->errorDescription. } echo $tokens->idClaims['sub']; // the validated ID-token subject
The caller owns the login state (§12.3 rule 1). oidcBegin() returns state,
nonce, and a Sensitive-wrapped codeVerifier; the SDK stores none of them in any
implicit cache. Persist all three yourself between the redirect and the callback (your
own HTTP session, or Axiam\Sdk\Oidc\MemoryOidcStateStore — a single-use, 10-minute-TTL
reference OidcStateStoreInterface implementation the Laravel/Symfony glue below uses).
state/nonce are plain strings (not secrets, §12.3 rule 2); codeVerifier,
access_token, refresh_token, id_token, and client_secret are always
Sensitive-wrapped (§12.5) and redacted from __toString()/var_dump()/json_encode().
"Login with AXIAM" framework glue (optional, off by default on both frameworks —
see examples/laravel_app/oidc_routes.php /
examples/symfony_app/oidc_services.yaml +
oidc_routes.yaml):
- Laravel:
Route::axiamOidcLogin('/auth/axiam/login', '/auth/axiam/callback')— a route macro registered byAxiamServiceProvider::boot()— wiresAxiam\Sdk\Laravel\OidcLoginController/OidcCallbackControlleronto both paths in one call. Configure viaaxiam.oidc.*config keys orAXIAM_OIDC_*env vars (client_id,client_secret,tenant_id,redirect_uri,scope). - Symfony: manually register
Axiam\Sdk\Symfony\OidcLoginController/OidcCallbackControlleras services (seeoidc_services.yaml) and add the two routes (seeoidc_routes.yaml) — no auto-discovery, same as the rest of the Symfony bridge. - Both bridges share ONE framework-agnostic core,
Axiam\Sdk\Oidc\OidcLoginFlow, so the 400/401/503 failure mapping (malformed callback / IdP error / unknown state / ID-token or OAuth2 failure / AXIAM unreachable) is byte-identical between them.
Device authorization grant (CONTRACT.md §14)
RFC 8628 — signing in a device that cannot show a browser: a TV, a CLI, a headless commissioning tool.
$tokens = $client->deviceLogin( onUserCode: function (DeviceAuthorization $a): void { // Called BEFORE the first poll. Display it however the device can — screen, // QR code, e-ink panel. The SDK never prints it for you. printf("visit %s and enter %s\n", $a->verificationUri, $a->userCode); }, scope: 'openid profile', );
deviceAuthorize() and devicePoll() are also public, for an application driving its own
loop. The polling rules are where implementations go wrong:
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
expiresIn, 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.
deviceCode is Sensitive; userCode deliberately is not — it exists to be read aloud,
and wrapping it would defeat the one thing it is for.
The device client authenticates the way it is configured to (§14.1 rule 2, contract 1.62).
Since AXIAM 1.1.0 both device endpoints authenticate the client by the method it is registered
for, as /oauth2/token does:
- A public client — the usual device, which cannot hold a secret — is built without a secret
or a certificate. It sends
client_idand no credential atdeviceAuthorize()and at everydevicePoll(), and is not refused client-side. (The server refuses a public client that sends one.) - A client built with
oidcClientSecretsends it asclient_secretin the form body at both endpoints — whether it is registered forclient_secret_postorclient_secret_basic: this SDK never sendsAuthorization: Basicto a/oauth2/*route (§12.1 rule 3). - A client built with a §6.1 certificate presents it on the connection, to the
mtls_endpoint_aliasesendpoints when the discovery document names them. - Every poll sends
client_id(§14.1 rule 3). A failed client authentication is401 invalid_client: anOAuthProtocolErrorwith thaterror, one request, never retried and never a §9 refresh. A client that cannot hold the grant any more is refused the same way at the poll. - Not implemented here:
private_key_jwtclient authentication (§21.8 is optional for every SDK) and DPoP proof generation (§21.7.3, §21.9). A client registered for either cannot be driven by this SDK; a DPoP-bound client's poll surfaces asinvalid_client, andtoken_typeis read as the server writes it.
deviceLogin() takes an injectable $sleep, so the §14.2 interval arithmetic is testable
exactly rather than in wall-clock time. Per §14.3 rule 4 it returns the token set;
$adoptAsCredential is the same opt-in flag loginClientCredentials() uses.
Token exchange (CONTRACT.md §15)
RFC 8693 — a service holding a user's token exchanging it for a narrower one before calling the next service.
$exchanged = $client->tokenExchange( subjectToken: $userToken, subjectTokenType: OidcClient::ACCESS_TOKEN_TYPE, // required (§15.1), no default scopes: ['orders:read'], audience: 'orders-service', );
To delegate rather than impersonate, pass an actor token, and make it the exchanging
client's own client_credentials token (§15.2 rule 9, contract 1.60) — the server answers
any actor token not issued to that client 400 invalid_request (actor_token was not issued to the exchanging client):
// The same client's own client_credentials grant. Do not adopt it as the session, and do not // use a console sign-in, another client's token or a service account's: those are refused. $actor = $client->loginClientCredentials('orders:read')->accessToken; $delegated = $client->tokenExchange( subjectToken: $userToken, subjectTokenType: OidcClient::ACCESS_TOKEN_TYPE, actorToken: $actor, scopes: ['orders:read'], );
Most of what this method does is refuse to be helpful:
- No default
$actorToken. Passingnullasks for impersonation; the SDK will not quietly substitute the client's own session token and turn that into a delegation. You obtain the actor token and pass it; ainvalid_requestfor one issued to another client surfaces unchanged — not retried, not rewritten into an impersonation. - 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 property. Re-run the exchange. - No adoption, and no flag to enable it — a MUST NOT, where
loginClientCredentials()adoption is a MAY.
External-IdP subject tokens (CONTRACT.md §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:
$exchanged = $client->tokenExchange( subjectToken: $partnerToken, subjectTokenType: OidcClient::JWT_TOKEN_TYPE, // required; named, never guessed scopes: ['read:orders'], audience: 'https://orders.internal', );
$subjectTokenTypeis 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 is anArgumentCountError, and a blank string is refused client-side with no wire call. It now sits second, matching §15.1's canonical order — it was last while it was optional, to spare positional callers, and making it required breaks them anyway.- 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_grantwhoseerrorDescriptionisthe 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 (CONTRACT.md §20)
The resource-server side of User-Managed Access: register what you guard, ask the authorization server what a caller would need, and redeem the resulting ticket.
// A PAT is a client-credentials token carrying `uma_protection` — never a user token, // and never this client's own session (§20.2 rule 1). $pat = $client->loginClientCredentials(scope: OidcClient::UMA_PROTECTION_SCOPE)->accessToken; $resource = $client->umaRegisterResource($pat, 'invoice-7', 'document', ['view']); // The returned id IS the AXIAM resource id — no translation step. $ticket = $client->umaRequestTicket($pat, [ new RequestedPermission($resource->id, ['view']), ]); header($client->umaChallengeHeader('invoices', $issuer, $ticket));
…and on the client side, having caught that 401:
$challenge = $client->umaParseChallenge($response->getHeaderLine('WWW-Authenticate')); $rpt = $client->umaExchangeTicket($challenge->ticket, $usersAccessToken);
The rules this surface exists to enforce:
- A ticket is never retried — not on
5xx, not on a timeout, not oninvalid_grant. It is the one documented exception to §16's retry policy, and a security rule rather than a performance one: the ticket is consumed before the exchange is evaluated, so a failed exchange has already spent it and a retry is a second redemption. Under concurrency that is exactly the redemption a server whose storage engine the SDK cannot attest may admit twice (ilpanich/axiam#302). On failure, request a new ticket. umaParseChallenge()does not exchange what it parsed. Theas_urinames an authorization server you have not necessarily chosen to trust; auto-exchanging would send the requesting party'sclaim_tokento whatever host answered the401.$claimTokenis required, never defaulted. It is the only channel that names the requesting party — defaulting it to your own PAT would mint an RPT for you.- No auto-narrowing on
access_denied. A partial grant is refused whole; whether two-of-three permissions is useful is your application's judgment, not the SDK's. - The RPT is never adopted as this client's credentials, and carries no refresh token.
umaUpdateResource()replaces the scope list rather than merging it, so omitting a scope removes it. There is no read-modify-write.
Emitting the challenge from the §11 enforcer
Both framework bridges delegate every §11 decision to one AccessEnforcer, so a
UmaChallenger handed to that enforcer covers Laravel and Symfony alike:
$challenger = new UmaChallenger('invoices', $client->oidcDiscover()->issuer, $pat, $client); $enforcer = new AccessEnforcer($client, $logger, $challenger); // A denied #[RequireAccess] now answers 403 with // WWW-Authenticate: UMA realm="invoices", as_uri="…", ticket="…"
Two properties are deliberate, and both are asserted by counting Protection API requests:
- Opt-in. Emitting a challenge means minting a credential. An enforcer that did that on every denial by default would put a Protection API call — and a live ticket — behind every unauthorized request, which is a denial-of-service amplifier pointed at your own authorization server. An allow mints nothing, and neither does a 401 or a fail-closed 503: only a resource denial is answerable with a ticket.
- A minting failure is not an escalation. An expired PAT or an unreachable Protection API still yields the plain 403 — never a 503, and never an allow.
The requested scope is the AXIAM action, so the ticket asks for exactly the authority that was refused and the engine's deny rules keep applying to whatever RPT comes back.
Both halves run in examples/uma_resource_server.php
and examples/uma_client.php.
Logout — RP-initiated and back-channel (CONTRACT.md §12.7)
logoutUrl() builds the redirect; verifyLogoutToken() validates a token the OP pushed
to your back-channel endpoint.
$url = $client->logoutUrl($storedIdToken); // …and at your registered backchannel_logout_uri: $verified = $client->verifyLogoutToken($logoutToken); if ($verified->sid !== null) { endSession($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 and same EdDSA/kid
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.
Decision reason codes (CONTRACT.md §11 rule 9)
AccessDecision::$reasonCode distinguishes no_grant ("ask an admin for access") from
denied_by_rule ("an admin has already decided") — opposite instructions to the person on
the other end, which is why the contract forbids collapsing them into a bare false.
checkAccess()/can()/batchCheck() keep returning bool: those signatures predate the
field and cannot carry it. checkAccessDecision() and batchCheckDecisions() return the
full decision. ReasonCode holds the three defined values as class constants rather than an
enum, so an unrecognised code is surfaced verbatim and never changes $allowed.
Webhook signature verification (CONTRACT.md §13)
AXIAM signs every webhook delivery with a Stripe-style signed timestamp. Verify it with
AxiamWebhooks::verify() before trusting a payload:
use Axiam\Sdk\Core\Sensitive; use Axiam\Sdk\Webhook\AxiamWebhooks; use Axiam\Sdk\Webhook\WebhookVerificationException; // Read the RAW body BEFORE any framework parses it as JSON. $rawBody = file_get_contents('php://input'); try { $event = AxiamWebhooks::verify( new Sensitive($webhookSecret), $_SERVER['HTTP_X_AXIAM_SIGNATURE'] ?? '', $rawBody, ); } catch (WebhookVerificationException $e) { http_response_code(400); return; } // $event->eventType, $event->deliveryId, $event->timestamp, $event->body
The raw body is mandatory. The MAC covers the exact bytes AXIAM sent, so
json_encode(json_decode($body)) — which can reorder keys, change whitespace, or re-escape
/ as \/ — will fail verification even though the payload is semantically identical. In
Laravel use $request->getContent(); in Symfony, $request->getContent(). Never re-encode.
Behaviour: HMAC-SHA256 over <timestamp>.<raw_body>, compared in constant time
(hash_equals) on the decoded bytes; a header carrying no v1 is always a failure; the
freshness window is two-sided and defaults to 300 seconds, so a future-dated timestamp is
rejected just like a stale one. Multiple v1 values are accepted to support secret
rotation. Use the X-Axiam-Delivery header as an at-least-once dedup key — a retry replays
a valid signature inside the freshness window.
Reactors — AMQP extension actors (CONTRACT.md §22)
A reactor is an external process that subscribes to named hook events on the AXIAM bus and answers back — allow, deny, or a field-allow-listed mutation — inside a timeout the server declared. Zitadel Actions and Keycloak SPIs solve the same problem by loading third-party code into the authorization server; a reactor stays outside it, reachable only through a signed reply schema the server validates before it believes a word of it.
use Axiam\Sdk\Core\Sensitive; use Axiam\Sdk\Reactor\AmqpLibReactorTransport; use Axiam\Sdk\Reactor\ReactorAnswer; use Axiam\Sdk\Reactor\ReactorConfig; use Axiam\Sdk\Reactor\ReactorEvent; use Axiam\Sdk\Reactor\ReactorEvents; use Axiam\Sdk\Reactor\ReactorServer; $config = new ReactorConfig( tenantId: $tenantId, // §8.1 + §22.12: the tenant AMQP subkey from the management API, wrapped. signingKey: new Sensitive($subkey), // The queue name is derived from it — but the SERVER declared it. reactorId: $reactorId, ); $server = new ReactorServer( config: $config, // §8b: amqps:// only, optional CA bundle, no verification-skip switch anywhere. transport: AmqpLibReactorTransport::connect('amqps://broker.example:5671', $caPath), handler: function (ReactorEvent $event): ReactorAnswer { switch ($event->event) { case ReactorEvents::TOKEN_PRE_ISSUE: // `ext.` is the COMPLETE allow-list for this event. return ReactorAnswer::mutate(['ext.department' => 'eng']); case ReactorEvents::LOGIN_POST_AUTH: return fraudulent($event) ? ReactorAnswer::deny('embargoed region') : ReactorAnswer::allow(); // or ReactorAnswer::allowWithStepUp() } return ReactorAnswer::allow(); }, ); $server->reactorServe(); // blocks; call $server->stop() from a signal handler
Binding handlers per event (§22.14)
The switch above is the shape every multi-event reactor grows, and its fall-through —
return ReactorAnswer::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.
ReactorHandlers is §22.14's declarative form, and it uses the same attribute mechanism
the §11 #[RequireAccess] helper already uses:
use Axiam\Sdk\Attributes\OnReactorEvent; use Axiam\Sdk\Reactor\ReactorHandlers; final class ClaimsReactor { #[OnReactorEvent(ReactorEvents::TOKEN_PRE_ISSUE)] public function enrich(ReactorEvent $event): ReactorAnswer { return ReactorAnswer::mutate(['ext.department' => 'eng']); } #[OnReactorEvent(ReactorEvents::LOGIN_POST_AUTH)] public function screen(ReactorEvent $event): ReactorAnswer { return fraudulent($event) ? ReactorAnswer::deny('embargoed region') : ReactorAnswer::allow(); } } $handlers = ReactorHandlers::of(new ClaimsReactor()); $server = new ReactorServer(config: $config, transport: $transport, handler: $handlers->handler());
- A misspelled event is refused when the attribute is instantiated —
OnReactorEventaccepts 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 — the composed handler throws
ReactorRejection, which publishes nothing, so the registration'sfailure_policydecides (§22.8) exactly as it decides a timeout. Never a synthesizedallow. - Binding the same event twice throws rather than silently overwriting, and
$handlers->events()feedsReactorEvents::defaultFailurePolicy()so you can see what an unreachable reactor costs before you go live.
Closures work too — (new ReactorHandlers())->bind(ReactorEvents::TOKEN_PRE_ISSUE, $fn) —
and both spellings are governed by the same rules. It is pure sugar: handler() returns
exactly the callable ReactorServer already takes. It opens nothing, verifies nothing,
signs nothing, does not filter a patch, and a handler's own throwable reaches the runtime
unchanged so nothing is published.
reactorServe() verifies every delivery before the handler sees it — key version, MAC,
freshness, nonce, in that order — then signs the reply with the same tenant subkey. §8's
HMAC runs in both directions here: a reply is an instruction to change a token or refuse
a login, so an unsigned or stale one is not a weak reply, it is not a reply at all.
Five things this runtime does that are easy to get wrong, and that are asserted against the
server-generated vectors in
tests/Fixtures/reactor_v2_reference_vectors.json
rather than documented and hoped for:
hmac_signatureis serialized asnullinside a reactor body, not omitted the way §8's own two message types omit it. This is the single most likely place to produce a MAC that never verifies, in either direction.reason,patchandrequire_mfaare omitted when absent/false. A reply that serializes"require_mfa": falseproduces different canonical bytes and a different MAC.- A patch is sent unfiltered. One forbidden key rejects the whole patch server-side,
and this SDK will not quietly drop
subto rescue the rest — that would leave you believing a field was set when it was dropped. - A handler that throws publishes nothing. No synthesized
allow: the registration'sfailure_policydecides, which is what the operator configured.login.post_authdefaults tofail_closed. - It never declares an exchange, a queue or a binding. The server declares the
per-reactor queue from the registration, and
ReactorTransporthas no declare or bind method for the runtime to reach for. A reactor that could bind could bind itself to*.token.pre_issueand read another tenant's issuance events.
The event registry, its per-event mutable-field allow-lists and §22.8's strictest-wins
failure-policy composition are mirrored locally (ReactorEvents::all(),
ReactorEvents::defaultFailurePolicy($events)) because the delivery path validates against
them with no network available; GET /api/v1/reactors/events serves the live copy.
Not hookable, and not offered anywhere in this SDK: the hot-path decision operations (the authorization check, the batch check and token introspection) are absent from the registry by design — §22.7 writes this as a MUST NOT because a reactor round-trip is milliseconds and 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.
timeout_ms reaches the handler as $event->timeoutMs and bounds the reply: work whose
window has already closed is abandoned rather than answered late. Telemetry (§19) is
available through the telemetryHook constructor argument — and worth wiring, because a
fail_open timeout produces allow and an audit record, so reactor health must never be
inferred from the outcome alone.
Like the §8 consumer, a reactor is a long-running CLI process, never an FPM request, and
php-amqplib has no built-in reconnection: when the broker session ends reactorServe()
returns and a process supervisor restarts the worker. That is a deliberate deviation from the
Go/Java runtimes' in-process reconnect loop and the same posture this SDK already documents
for the AMQP worker above. $server->stop() is safe from a pcntl signal handler: the
delivery in flight is answered before the loop returns (§18).
See examples/reactor/reactor.php.
OPAQUE (CONTRACT.md §23) — conditional on ext-ffi and one shared library
loginOpaque proves the password to the server without the password — or anything from which it
can be cheaply recovered — ever crossing the wire. The server stores a registration record
sealed under a tenant-wide oblivious PRF seed, and what travels is a blinded group element and a
MAC, neither useful without both.
if ($client->opaqueAvailable()) { $result = $client->loginOpaque('alice', $password); } else { $result = $client->login('alice', $password); }
It takes the same arguments as login() and returns the same LoginResult, MFA branch included,
so switching a tenant to OPAQUE needs no change to how the result is handled. A runnable
end-to-end example is examples/opaque_login.php.
Unlike the SRP-6a it replaces, there is no separate server-proof step and nothing has been
dropped: RFC 9807's AKE authenticates the server during the handshake, so opening KE2 is the
proof that it holds the record.
PHP went from two conditions to one
This was the SDK where §23 hurt most, and the change is worth spelling out.
The SRP client needed two things, and the second was the bad one:
ext-gmporext-bcmath, because PHP has no native arbitrary-precision integer and SRP is 2048- to 4096-bit modular exponentiation.- A tenant configured for
pbkdf2_sha256— because no PHP runtime offers Argon2id with a caller-supplied 32-byte salt.sodium_crypto_pwhashrequires exactly 16,password_hash()generates its own. AXIAM's default KDF was, for PHP, simply unreachable, and the honest advice was to weaken the tenant's configuration for PHP's benefit.
Both are gone. The key stretching now happens inside libaxiam_opaque_ffi, so a true from
opaqueAvailable() means every tenant works — including the default one — and no tenant needs
reconfiguring on PHP's account.
What remains is having the library, which is two things that are really one:
1. ext-ffi. Not guaranteed on any runtime, and disabled outright on some shared hosts.
2. libaxiam_opaque_ffi. A Rust cdylib published as a per-platform asset on the
axiam release page, not a Composer package —
there is no cross-language registry to put it on. Put it on the system library path, or:
export AXIAM_OPAQUE_LIBRARY=/opt/axiam/libaxiam_opaque_ffi.so
opaqueAvailable() reports both as one answer, and reports rather than throwing — so an
application chooses the login path before attempting one rather than discovering the gap
mid-exchange. It also calls into the library rather than merely loading it: FFI resolves symbols
lazily, so a probe that only opened the file would report "present" and then fail at login against
some other library that happened to share the name.
The protocol is not implemented here
CONTRACT.md §23.1 forbids an SDK from writing its own OPAQUE. SRP-6a was arithmetic every language
can express — which in PHP meant ~800 lines across two bignum backends, plus the pbkdf2_sha256
limitation on top. OPAQUE is not: 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 in a way that still
interoperates until it does not.
Axiam\Sdk\Opaque therefore contains no cryptography. It is an FFI binding to the same
implementation the AXIAM server links.
What this buys, and what it does not
OPAQUE closes holes TLS 1.3 does not:
- a TLS-terminating reverse proxy, ingress controller, CDN or service mesh sees every plaintext
password today; under OPAQUE it sees
KE1andKE3; - an accidental request-body log, a heap dump or a crash reporter can no longer capture a plaintext password, because the server never has one;
- a stolen record database is not offline-crackable on its own. This is the substantive gain over SRP: cracking a record also requires the tenant's OPRF seed, which is AES-256-GCM encrypted at rest under a key the database does not hold.
It does not protect against a compromised AXIAM server, and this SDK does not claim it does.
Tenant policy, and the errors that are not credential failures
opaque_mode is an organization baseline a tenant may tighten:
| mode | login() |
loginOpaque() |
|---|---|---|
disabled (default) |
works | NetworkError — the endpoint answers 404 |
optional |
works | works |
required |
AuthzError |
works |
Which exception you get is most of what this SDK owns on this path:
| condition | exception | why |
|---|---|---|
| tenant has OPAQUE disabled | NetworkError |
a property of the tenant, not of any user — fall back to login() |
ext-ffi or the library absent |
NetworkError |
a fact about this runtime, raised before any request is sent |
| server named a KSF this build cannot perform | NetworkError |
a configuration problem; substituting one would surface as a wrong password |
/start response missing ke2 |
NetworkError |
malformed response |
envelope did not open / KE2 did not verify, mode: "required" or no mode |
AuthError |
the whole of the credential check |
envelope did not open / KE2 did not verify, mode: "optional" |
whatever login() returns or raises |
retried over login() first — see below |
tenant refuses password login (login()) |
AuthzError |
the credentials were never examined |
That AuthError covers both halves of the mutual authentication: a wrong password, an account
that does not exist, a server that does not hold the record, and an account with no registration
record at all are indistinguishable by design. Nothing is sent to login/finish in that case
(§23.4 rule 7).
What happens next depends on the mode field the login/start response carries — the tenant's
opaque_mode — and on nothing else (contract 1.29):
"optional"—loginOpaque()retries overlogin()itself, with the same credentials, and returns that call's outcome. You do not write this branch; the SDK does. It has to exist: every account has no registration record the moment an operator enables OPAQUE and acquires one only when its password is next set, so treating the failed exchange as final would lock out every user of a tenant for the whole of its migration."required", and any response with nomodeat all (a server older than the field) —AuthError, and you must not retry overlogin()yourself. It would be refused anyway:requiredanswers403 opaque_requiredfor every principal in the tenant, so the retry would put a plaintext password on the wire for nothing. An unrecognised value is treated the same way.
mode is not downgrade protection, and do not read it as one: a hostile endpoint that wanted
the plaintext could simply answer 404, which sends a caller to login() whatever it puts here.
required is what closes that, server-side, by refusing /auth/login before it examines any
credential.
required refuses every principal in the tenant, not only the enrolled ones. Splitting the
response on whether an account has a record would turn /auth/login into an enumeration oracle
costing one junk password per name. Operators turn it on last, after a password-reset campaign.
Enrolment
The server cannot build a registration record, so any request that sets a password has to
carry one. opaqueEnrollment() produces the opaque object for POST /api/v1/users,
/auth/password/change, /auth/reset/confirm and /admin/bootstrap:
$enrolment = $client->opaqueEnrollment($newPassword); $body['opaque'] = $enrolment->toWire();
Note the parameters that are gone. There is no $identity: the SRP version required the account's
username, an email there produced a verifier no login could ever satisfy — and renaming a user
invalidated their verifier outright. A record binds to a credential identifier the server chooses,
so neither is true any more. There is no $group or $params either: those come from the
register/start response, so a caller cannot pick a cost the server will not honour.
Unlike srpEnrollment() this performs I/O — one register/start round trip. The envelope is
sealed under the server's oblivious PRF, so there is no offline computation that produces a valid
record.
Cost
loginOpaque() runs the tenant's key-stretching function: Argon2id at 19 MiB and t=2 by default,
which is tens to hundreds of milliseconds of CPU plus that memory, per login attempt. That cost is
the point — it is what makes a stolen record expensive to attack even by someone holding the OPRF
seed. On a request-per-process runtime it lands squarely in the request; size max_execution_time
and any upstream timeout accordingly. It is not a cost login() has.
Cryptographic parameters
The ciphersuite is OPAQUE-3DH over ristretto255 with SHA-512, HKDF-SHA-512 and
HMAC-SHA-512, fixed AXIAM-wide. It is not negotiated and not read from the server: a client that
accepted a suite from the endpoint it is authenticating would be accepting a downgrade.
The key-stretching function is the server's to name, per exchange, and is honoured as given
rather than cached or defaulted. argon2id and scrypt are accepted; anything else — including
pbkdf2_sha256, which was SRP's PHP-only fallback and is not an OPAQUE KSF at all — is refused
rather than substituted. Costs outside the bands this SDK will act on (memory_kib 8 MiB–1 GiB,
iterations 1–10, parallelism 1–16, log_n 14–20, r/p 1–16) are refused too.
Zeroization
PHP strings are immutable and the runtime copies them freely, so this SDK cannot clear the password. §23.3 rule 8 requires saying so rather than implying a guarantee it cannot keep. What it does do is never put the password in a request body, and never log it.
WebAuthn / passkeys (Axiam\Sdk\Webauthn, CONTRACT.md §24)
Eight wire operations, two ceremonies, and one thing this SDK deliberately does not do.
// Enrolment — requires a session (§24.1), refused client-side without one. $challenge = $client->webauthnRegisterStart(); $credential = $client->webauthnRegisterFinish( $challenge->stateToken, "Alice's laptop", $platformResponseJson, // verbatim ); // Sign-in with no username at all — the authenticator picks the account. $signIn = $client->webauthnDiscoverableStart(); $result = $client->webauthnDiscoverableFinish($signIn->stateToken, $assertionJson);
The server chooses every option and verifies every response; this SDK passes both through
byte-for-byte (§24.0). WebauthnChallenge::$challenge is a plain decoded array, not a
modelled type: no defaulting, no validation-that-rejects, no re-encoding. On the way back
the *Finish body is assembled as text, splicing the caller's response string in
unmodified — decoding and re-encoding it would round every number through a float and hand
the server a byte sequence the authenticator never signed.
The browser half, via the §24.6a JSON bridge
// Your start endpoint $challenge = $client->webauthnRegisterStart(); echo json_encode([ // §24.6a rule 1: the wire JSON, unparsed and unreassembled. 'requestJson' => $challenge->requestJson(), 'stateToken' => $challenge->stateToken->reveal(), ]);
// Browser const options = PublicKeyCredential.parseCreationOptionsFromJSON(requestJson); const credential = await navigator.credentials.create({ publicKey: options }); await fetch('/passkeys/finish', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ stateToken, response: credential.toJSON() }), // verbatim });
requestJson() returns the inner options object — the publicKey wrapper belongs to the
DOM's CredentialCreationOptions, and the platform JSON APIs do not want it. A mobile
client relaying Android's registrationResponseJson uses the same two seams.
Passing something that is not JSON, or is not a JSON object, raises AuthError client-side
with no wire call: the SDK will not POST a body it already knows the server cannot verify.
The two authentication ceremonies are different flows (§24.2)
webauthnAuthenticateStart()/Finish() is a second factor — it continues a login()
that answered mfaRequired with "webauthn" among its methods, and the challenge token
names the user so the server can send an allowCredentials list.
webauthnDiscoverableStart()/Finish() is a primary factor: nothing precedes it,
allowCredentials is empty, and the assertion itself identifies the user. They are not one
operation with an optional token — merging them reproduces a bug the server already fixed,
which is why the token is a required argument on one and absent from the other.
One difference a reactor author will ask about: discoverable/finish fires the
login.post_auth hook event (§22.5) and authenticate/finish does not. The latter
continues a login already gated at its password step; the former has no such step.
A passkey or security key as the first factor (§24.1, §25.2, contract 1.45)
webauthnSetupRegisterStart()/webauthnSetupRegisterFinish() are the WebAuthn twin of
mfaSetupEnroll()/mfaSetupConfirm(): reached when login() returns
LoginResult::$mfaSetupRequired because the tenant requires MFA and this account has none
yet. A user of an enforcing tenant is no longer required to own a TOTP app to get in.
$login = $client->login('alice@example.com', 'pw'); if ($login->mfaSetupRequired) { $challenge = $client->webauthnSetupRegisterStart($login->setupToken); // ... run the ceremony via the §24.6a JSON bridge, as above ... $signedIn = $client->webauthnSetupRegisterFinish( $login->setupToken, $challenge->stateToken, "Alice's laptop", $platformResponseJson, // verbatim ); // $signedIn is a LoginResult — the interrupted login, completed. }
This pair takes no session, and this SDK never attaches one — the setup token is the
only credential, exactly as mfa_setup_enroll/mfa_setup_confirm take none. Unlike
webauthnRegisterStart()/Finish(), calling these never requires being signed in, and the
wire call carries neither the session's Authorization header nor any cookie the shared jar
already holds, even when the client happens to be signed in as someone else at the time.
webauthnSetupRegisterFinish() adopts credentials exactly as mfaSetupConfirm() does
(§25.2 rule 2): both are the completion of the login login() interrupted, both answer the
same LoginSuccessResponse, and both leave the client in the same signed-in state — the
axiam_access/axiam_refresh/axiam_csrf cookie triple, the captured CSRF token, and a
cleared §17 decision memo. A caller who chose a passkey over TOTP ends up exactly as
authenticated as one who did not.
The account whose first factor is being enrolled is named by the setup token, never by the
caller — there is no $userId parameter on either call. The server refuses an account that
already has a factor with the same answer mfaSetupEnroll() gives: a setup token adds the
first factor, never a second.
Saying something useful when a ceremony fails (§24.6b rule 5)
$outcome = WebauthnFailure::classify($domExceptionName); echo $outcome->message();
AlreadyRegistered is the exclusion list doing its job, and the only classification whose
remedy is "use a different device" rather than "try again". Cancelled covers both an
explicit refusal and a silent timeout — the spec deliberately refuses to distinguish them,
because telling a website which one happened leaks whether an authenticator was present —
so its copy does not accuse anyone of cancelling.
Two error rows that are not the §2 defaults (§24.4)
- A 403 from
register/finishis the tenant's attestation policy rejecting this particular authenticator. The server's message is the only place that says which one would be accepted, so it is lifted into theAuthzError's message rather than discarded. Show it. - A 503 from
register/startmeans the policy needs FIDO metadata the server cannot reach. That is a configuration state, not a transient one, and it is not retried — the second documented exception to §16 after §20's.
Session cookies: as of contract 1.28 both *Finish authentication calls set the
axiam_access / axiam_refresh / axiam_csrf triple alongside the token body, so a
completed ceremony leaves the client signed in for every cookie-driven call that follows
(§24.3).
Worked end to end in examples/webauthn_passkeys.php.
Account lifecycle and MFA enrolment (Axiam\Sdk\Account, CONTRACT.md §25)
Ten operations covering the things a user does to their own account — none of which is administration, and all of which were previously reachable only by hand-rolling HTTP.
$result = $client->login('alice@example.com', $password); if ($result->mfaSetupRequired) { // The third outcome. The tenant requires MFA, this account has none, and the // server handed back a setup token to finish with. There is no session yet — // the token IS the credential. $enrollment = $client->mfaSetupEnroll($result->setupToken); renderQr($enrollment->totpUri->reveal()); $client->mfaSetupConfirm($result->setupToken, $code); // completes the LOGIN }
LoginResult gained two properties with defaults rather than changing shape, so every
pre-1.28 construction still works and still reads false. Handle the new outcome
anyway. A tenant that turns on required MFA will start returning it, and a client that
only branches on mfaRequired reports a successful login that has no session.
mfaSetupConfirm() adopts credentials exactly as login() does, because it is the
completion of a login (§25.2 rule 2) — including capturing the session's first CSRF token.
mfaEnroll()/mfaConfirm() are the voluntary pair, from inside an existing session, and
they do not clear the §17 decision memo — the subject has not changed, and discarding a
warm memo on an unrelated profile action costs a round trip on every check that follows.
Both halves of an MfaEnrollment are Sensitive, and the second one matters: the
otpauth:// URI contains the secret (§25.3). Wrapping the bare secret and then logging
the URI leaks the same bytes.
Password reset, and the two things it will not tell you
$client->requestPasswordReset(new PasswordResetRequest('alice@example.com')); // returns void, whether or not that address has an account $context = $client->passwordResetContext($token); if ($context->opaque !== null) { // This tenant runs §23. Build a registration record from these parameters; // a plaintext password would be refused, and refused late (§25.4 rule 1). } $client->confirmPasswordReset(new PasswordResetConfirmation($token, $newPassword, $tenantId));
requestPasswordReset() returns nothing and throws nothing on an unknown address, and this
SDK exposes no way to tell the two cases apart. That is not an omission to improve on: a
client that surfaced a "no such user" state — even one inferred from timing — would turn
the endpoint into the account-enumeration oracle its uniform response exists to prevent.
Likewise a 404 from passwordResetContext() means unknown, expired or
already-consumed, and the SDK does not distinguish them either (§25.4 rule 3).
verifyEmail() and resendVerification() are unauthenticated — a user whose address is
unverified may have no session at all — and carry the tenant as a body field, since
§12.1 rule 2's ?tenant_id= convention is scoped to the /oauth2 endpoints.
Two resends, and why neither replaces the other (§25.7)
// No session — a sign-up screen. Returns whatever happened; that is the point. $client->resendVerification('alice@example.com', $tenantId); // Signed in — a profile page. Says what happened, and names no address. try { $client->resendOwnVerification(); } catch (AuthzError) { // 409: already verified, or an account state that must not be sent a live token. } catch (NetworkError) { // 429: the daily resend limit. }
They look like one operation and are not. resendVerification() takes an address from an
anonymous caller, so it must answer identically whether the address exists, is already
verified, or is rate-limited — anything else is an oracle for which addresses have
accounts. resendOwnVerification() is asked by a caller already signed in to the account
it is asking about, so none of those outcomes discloses anything it did not bring with it,
and this one tells the truth.
Neither is routed to the other, in either direction, and this SDK does not fall back
from the authenticated one to the public one on a 409 or a 429: that fallback turns
both failures back into a silent success and restores the exact bug §25.7 describes, with
an extra round trip. resendOwnVerification() also takes no address parameter and sends
no address field — a parameter here would let an authenticated session mail an arbitrary
one.
Returning normally means the mail was enqueued, not delivered. Delivery is asynchronous and can still fail at the provider.
Worked end to end in examples/account_lifecycle.php.
Pushed Authorization Requests (CONTRACT.md §26, RFC 9126)
PAR moves the authorization request off the browser. Instead of putting scope,
redirect_uri, state and the PKCE challenge into a URL the user agent carries, the client
POSTs them straight to AXIAM over an authenticated back channel and puts an opaque
request_uri in the redirect.
$config = $client->oidcDiscover(); if ($config->pushed_authorization_request_endpoint === null) { // §26 is optional; fall back to the plain oidcBegin redirect. } $begun = $client->oidcBegin($config, $redirectUri, 'openid profile'); $pushed = $client->oidcPar($begun, $redirectUri, $config, 'openid profile'); header('Location: ' . $pushed->url); // exactly ?client_id=…&request_uri=…
Three things worth knowing:
- The server answers
201, not200— RFC 9126 §2.2 specifies Created. A success predicate written=== 200treats every successful push as a failure. - The redirect URL carries exactly two parameters. The server refuses a request that
mixes a
request_uriwith inline authorization parameters rather than merging them; merging is where parameter confusion lives (§26.2 rule 2). Any query the discoveredauthorization_endpointalready carried is dropped. oidcBegin()still ownsstate,nonceand the PKCE pair. There is no second generator (§26.2 rule 1), andPushedAuthorizationRequestcarries all three straight through to the exchange.
The push is not retried on a 5xx or a transport failure: it is a POST that creates
server state, so it falls outside §16.2's read-only eligibility exactly as oidcExchange()
does. The safe recovery is a fresh push, which costs one round trip and cannot
double-consume anything. The requestUri is Sensitive because between the push and the
redirect it is a bearer handle to a fully-formed authorization request (§26.5).
A FAPI 2.0 client has no alternative: profile: "fapi2" refuses a registration that
does not set require_par, so such a client cannot authorize any other way (§21.1).
dpop_jkt — binding the code to a DPoP key at issue time (contract 1.42, RFC 9449 §10.1)
oidcPar() takes an optional sixth argument, dpopJkt: the base64url JWK SHA-256
thumbprint of the key the client will prove possession of at the token endpoint. Pushing it
binds the authorization code to that key from the moment it is issued, rather than only when
it is redeemed — which closes the window in which a stolen code can be redeemed by a
different key.
$pushed = $client->oidcPar($begun, $redirectUri, $config, 'openid profile', null, $jkt);
The caller computes the thumbprint. CONTRACT.md §21.9 records this SDK as verifying
DPoP proofs (Axiam\Sdk\Auth\DpopVerifier) but not generating them, so there is no
client key here to derive one from; an application that holds one passes the same thumbprint
it already computes for its own DPoP header, and the value is forwarded verbatim. Omitted
from the form entirely when null or empty — §12.1 forbids transmitting an absent optional
field as an empty value, and a server reading dpop_jkt= as a present-but-empty thumbprint
would bind the code to nothing while making the request look key-bound.
request_uri is deliberately not exposed. Contract 1.42 added it to the server's
PushedAuthorizationRequest schema, and RFC 9126 §2.1 makes it the one authorization
parameter a client MUST NOT push — the server models it so it can refuse it. A client able
to send it is a client able to chain one pushed request into another, which is the attack
§26.2 rule 2 exists to prevent.
Worked end to end in examples/par_login.php.
Management API (Axiam\Sdk\Management, CONTRACT.md §27)
The administrative surface: 202 operations across 29 namespaces, generated from the
vendored management-registry.json and openapi.json by scripts/gen_management.py and
committed, so building this package needs no Python. CI re-runs the generator with
--check on every PR, which is what stops the committed surface from drifting away from
the contract it claims to implement.
The namespace handles sit directly on the client — $client->roles(),
$client->serviceAccounts()->rotateSecret($id), the form §27.3's PHP row shows — and the
same 29 handles are also reachable behind one accessor, $client->management() (§27.2
rule 4), which reads better where a call site is already dense with §1 methods. The two
forms are equivalent: the direct accessors forward to management(), so rule 4's
"where an SDK offers both, the two MUST return equivalent handles" holds structurally
rather than by two code paths agreeing, and the suite asserts it by comparing the method,
path and query each actually puts on the wire.
// §27.2 — namespace handles, not 202 flat methods. $page = $client->users()->listItems(new PageRequest(0, 50)); // Or reach the same handles behind one accessor. $management = $client->management(); $same = $management->users()->listItems(new PageRequest(0, 50)); // §27.4 rule 4 — `total` is the SERVER's count, not count($page). Confusing the two is // how a script silently processes the first fifty of four hundred users. printf("%d of %d\n", count($page), $page->total); // Every page. The walk stops on the first EMPTY page, never on a short one. $users = $management->users(); foreach (ManagementTransport::walk( static fn (PageRequest $p): Page => $users->listItems($p), ) as $user) { /* ... */ } // §27.4 rule 5 — name what you mean to change. What you leave unset is OMITTED from the // request, not sent as null; on a sparse update those say opposite things. $management->users()->update($id, new Models\UpdateUserRequest(status: Models\UserStatus::Locked)); // §27.4 rule 3 — `{org_id}`/`{tenant_id}` come from the client; ->inOrg()/->forTenant() // override them for one handle and return a COPY, leaving the original pointing where it did. $management->caCertificates()->inOrg($otherOrgId)->listItems();
Searching a list (§27.4 rule 4). All twenty-six paginated operations take an optional free-text term, matched case-insensitively by the server against the identifying fields of whatever is being listed — a name or username, plus the record id, so a UUID pasted out of a log line finds its row.
$page = $client->users()->listItems(new PageRequest(0, 50, 'ada')); printf("%d of %d matches\n", count($page), $page->total); // total counts MATCHES, not rows // And the whole filtered set: the term rides on the page request, so the walk carries it. foreach (ManagementTransport::walk( static fn (PageRequest $p): Page => $users->listItems($p), new PageRequest(0, 50, 'ada'), ) as $match) { /* ... */ }
The term lives on PageRequest rather than becoming a third argument on twenty-six generated
methods, and that is what makes the walk above work at all: a per-method argument has
nowhere to live between one request and the next, so a walk built on it would return the
matches followed by the unfiltered tail.
null sends no search parameter. An empty or whitespace-only term is the same
request — a search box that fires on every keystroke sends one the moment it is cleared,
and "rows containing the empty string" is a different question from "all rows". The term
is trimmed but never truncated: the server caps its length, and a client-side truncation
the server would not have made is a silently different query the caller cannot see.
Enums are open (§27.11 rule 1). A value this SDK's copy of the spec does not list
decodes to that enum's Unknown case rather than throwing. Throwing would fail the
whole response, so one field of one record would take down the page it was on —
including the records the caller did ask for. Unknown is never confused with a known
case, and its wire spelling is the empty string, which no server value is: carrying an
unrecognised value back into an update is refused by the server rather than written as a
spelling it never used. A match over one of these enums needs an Unknown arm.
foreach ($client->tenants()->listItems() as $tenant) { $label = match ($tenant->kind) { Models\TenantKind::Organization => 'organization scope', Models\TenantKind::Standard, null => 'tenant', Models\TenantKind::Unknown => 'a kind this SDK predates — upgrade to name it', }; }
Certificate::$boundServiceAccountId is a projection, not a property: the server
resolves it for a whole page in one query, so certificates()->listItems() populates it
and certificates()->get($id) leaves it null. null there means "this read does not
carry it", not "there is nothing bound" — the SDK does not issue a second request to fill
it in, because a get that silently costs two round trips is the behaviour §27.4 rule 3
forbids for slug resolution, for the same reason (§27.11 rule 4).
Errors (§27.4 rule 7). Three statuses get a sub-type inside the §2 taxonomy, so a
catch (AuthzError $e) written before §27 existed still behaves as it did:
| status | type | parent | why |
|---|---|---|---|
404 |
NotFoundError |
AuthzError |
On a multi-tenant surface the server answers 404 for another tenant's object precisely so a probing caller cannot enumerate it. Re-drawing that line client-side would undo the protection. |
409 |
ConflictError |
AuthzError |
§2 already maps 409 there; the sub-type keeps that mapping. getReasonCode() is the body's reason_code (the network operations always name one) — branch on it, never on the message. |
400, 422 |
ValidationError |
NetworkError |
Inherited from §2's 400 row. Carries the server's per-field complaints. |
Secrets (§27.5). A one-time secret is Sensitive: it prints and json_encodes as
[SENSITIVE], so it cannot reach a log line by accident. The one writer that must send
one in the clear unwraps it explicitly — ManagementTransport::encodeBody() walks the
body and calls reveal(). PHP has no json_encode equivalent of Jackson's mixin or
System.Text.Json's converter precedence, because JsonSerializable is consulted on the
instance and nothing outranks it; making the unwrap explicit is better anyway, since
there is no precedence rule to remember and exactly one greppable place where a secret
is revealed.
The network namespace (§36, contract 1.62)
A tenant's network is described as segments (a VLAN and a Filter-Id envelope each, nodes
of a reserved /network subtree) and, per segment, at most one reply profile (a typed record
of the closed axiam-radius-reply/1 dictionary), plus the tenant's Session-Timeout cap. It is
plain management CRUD — human-only, so a service-account token meets 401 — for an
operator-run FreeRADIUS recipe. This SDK administers it; it never asks for a placement
decision (POST /api/v1/network/decide is FreeRADIUS's, over mutual TLS, and has no method).
use Axiam\Sdk\Management\Models; $network = $client->network(); // or $client->management()->network() $segment = $network->createSegment(new Models\CreateNetworkSegmentRequest( kind: Models\NetworkSegmentKind::Access, // the wire spelling: access | quarantine name: 'staff', siteKey: 'hq', vlanId: 20, rank: 1, filterIds: ['staff-in'], )); // Every write is conditional on the version you READ, and a profile on two of them. $profile = $network->createProfile(new Models\CreateNetworkReplyProfileRequest( profile: new Models\NetworkReplyProfileInput(sessionTimeoutSecs: 3600, filterId: 'staff-in'), segmentId: $segment->id, segmentVersion: $segment->version, )); $network->replaceSegment($segment->id, new Models\ReplaceNetworkSegmentRequest( enabled: true, filterIds: ['staff-in'], version: $segment->version, vlanId: 21, rank: 1, )); $network->deleteProfile($segment->id, $profile->version); // `?version=` is not optional try { $network->deleteSegment($segment->id, $versionYouRead); } catch (\Axiam\Sdk\Management\ConflictError $e) { // The 409's reason_code says which conflict, so branch on it and never on the message. $e->getReasonCode(); // stale_version | strands_profile | has_children | duplicate_vlan | ... }
- The
replace*operations are replacements (§27.4 rule 5): full-body and version-conditional. A staleversionis409and writes nothing. Nothing here retries a write or "refreshes the version and sends again" — a409means another administrator wrote, and the decision is yours. - A profile read is the effective profile.
sessionTimeoutSecsis the stored value lowered to the tenant's current cap, andsessionTimeoutClampedsays so. A read-modify-write of a clamped profile therefore cannot restore the stored value: what you send is what is stored, from then on, and a value above the current cap is403. Raise the cap first withreplacePolicy()to restore it (§36.3 rule 6). - Closed records. An unset optional member is absent from the body, never
null; an unknown member is a400. Akind,termination_actionorservice_typethis SDK does not know decodes to that enum'sUnknowncase and is not sent back. - The SDK validates nothing but UUIDs — the VLAN range, the envelope, the cap and the
Session-Timeoutbounds are the server's to check, and its400/403text reaches you in the exception's message. - The write gate's
403carries the missing permission ingetAction()and the node it was checked on ingetResourceId()(for examplenetwork:grant); the exception's message ends with the server's own.
Declarative manifests (§27.6, §27.7)
Describe the tenant you want; let the SDK work out the difference.
$manifest = ManagementManifest::builder() ->permission('docs.read', 'documents:read', 'Read documents') ->role('contractor', 'contractor', 'External', grants: [ 'docs.read' => 'allow', 'docs.write' => 'deny', // AXIAM's RBAC is DENY-OVERRIDE, not most-specific-wins ]) ->group('externals', 'externals', 'Contractors', roleKeys: ['contractor']) ->build(); $plan = $client->management()->manifest()->plan($manifest); // writes NOTHING if (!$plan->isConverged()) { $report = $client->management()->manifest()->apply($manifest); }
Or as attributes, which is the idiom this SDK already uses for §11 and lets a tenant's shape live in version control as a type:
#[ManagedPermission(key: 'docs.read', action: 'documents:read')] #[ManagedRole(key: 'contractor', name: 'contractor', grants: ['docs.read' => 'allow'])] final class AcmeTenant {} $manifest = ManifestAttributeReader::read(AcmeTenant::class);
Four properties are worth knowing before you run one against production:
-
plan()writes nothing. It reads and reports. Safe on a schedule, safe in CI. -
apply()stops at the first failure and does NOT roll back (§27.7). The returnedApplyReportnames what landed, what failed and what was never attempted — a partial apply is a state you resume from, and an automatic rollback would fire a second wave of writes exactly when the server is telling you something is wrong. -
Ordering is derived, not declared. By kind, then dependency, then key. The tie-break on key is what makes a plan stable across runs.
-
Omission is never deletion. There is no
ChangeAction::Deleteat all, so an incomplete manifest cannot become a destructive one. This extends togrants/roleKeys:apply()reconciles them additively — a permission granted or a role bound by hand, outside the manifest, is left alone, and a key dropped from the manifest is never revoked/unassigned on its behalf. Reconciliation runs for every role/group the manifest grants or binds at least one thing to, whether or not that role/group's own fields needed aCreate/Update— a role that already exists, unchanged, still receives a grant newly added to the manifest. -
A nested
resource(...)is actually nested on the wire.resource('child', …, parentKey: 'root')sendsparent_idonCreate, resolved to the parent's real server id (parents are always created before children — §27.6 rule 5's ordering). -
A role binding can name a resource (§27.6.1 addition 2, contract 1.51).
roleKeys: [RoleBinding::at('contractor', 'site')]alongside plain keys — mix both in oneroleKeyslist.RoleBinding::atOnly(...)stops the binding at that resource (inherit: false, sent only when set). A subject holding one role at two resources — or once plain and once scoped — is refused while the manifest is built, before a client exists to send anything: the server keys an assignment on(subject, role), so that state cannot be held. -
service_accountsis a manifest section (§27.6.1 addition 3, contract 1.51).->serviceAccount('fleet', 'device-fleet', roleKeys: [...]), reconciled by name.apply()'s report carries the one-timeclient_secretaCreatemints —$report->createdServiceAccounts()— and never rotates one to converge drift. -
networkis a manifest section (§27.6.2, contract 1.62).->networkPolicy(7200),->networkSegment('staff', 'hq', 'access', 'staff', 20, rank: 1, filterIds: [...])and->networkProfile('staff', 3600, idleTimeoutSecs: 600, filterId: 'staff-in'):- Natural keys. A segment by site + kind + name,
kindbeing the wire spellingaccessorquarantine(anything else,zonesincluded, is refused when the manifest is built); a profile by its segment (named by the segment's manifest key); the policy is a singleton. Two segments with one natural key, a profile whose segment the manifest does not declare, two policies and a segment whosefilterIdsrepeats a name are each refused with zero requests. - Order. Policy, then segments, then profiles (a profile in its segment's order), after everything else — so a manifest that raises the cap and states a profile above the old one works.
- Judged on the read. Every stated member is compared with the value the read returns: a stored
timeout above a lowered cap reads as the cap, so a manifest stating the cap is
NoChangeand one stating more is anUpdatewhose write the server refuses with403— the action's failure;apply()stops and nothing is lowered for you.filterIdsare compared as a set (a reorder isNoChange; a write sends them in the order the manifest states).JsonNull::Nullstates a profile member as none and leaves it out of the replacement body;nullis unstated and is carried from the read. - Writes. An
Updateis a full, version-conditionalreplace*built from the read, never retried; a profile carries the segment version of the newest segment write of the same apply. A409is the action's failure:apply()stops, the report names the rest,$report->failureReasonCode()is thereason_code, and runningapply()again — which re-reads — is the recovery. - No pruning — see the next item.
- Natural keys. A segment by site + kind + name,
-
Nothing is ever deleted — pruning (§27.6 rule 4, §27.6.2 item 6) is not offered. The opt-in a contract-conformant SDK MAY offer is absent, for every namespace. An omitted segment or profile is left alone, and the policy has no delete anyway.
-
Kinds this manifest does not cover:
scopes,users(and user role bindings),webhooks, and the one-time-secret kinds the contract excludes (§27.10).networkis covered.
An incoherent manifest — a dangling reference, a cycle, a duplicate key — is refused before the first request, because discovering it halfway through an un-rollback-able apply is strictly worse.
See examples/management_basics.php, examples/management_manifest.php,
examples/management_manifest_attributes.php, examples/network_manifest.php, and
examples/device_mtls_provisioning.php — the last a full operator/device split that mints
a Device certificate from the tenant's signing CA, binds it to a service account, writes
the one-time private key at 0600, and then authenticates as the device over §6.1 mutual
TLS with no password anywhere.
MCP resource-server helpers (Axiam\Sdk\Mcp, CONTRACT.md §28)
The resource-server half of the Model Context Protocol authorization handshake: publish
the RFC 9728 document that tells an MCP client which authorization server guards this
resource, and put the RFC 6750 WWW-Authenticate challenge on the 401 that starts its
discovery.
AXIAM is the authorization server and implements none of this. Your MCP server is the
resource server, and this is its side. Nothing here talks to AXIAM, performs any network
I/O or touches the shared AxiamClient's own session — both operations are pure local
computation, like oidcExchange's helpers and UmaChallenge::parse(). The client
half — parsing a challenge, fetching a document, deciding whether to trust the
authorization server it names — is deliberately not in the SDK: a helper that read a 401
and acted on it would send a credential to whatever host the 401 asked it to.
It is opt-in and off by default. With resourceMetadataUrl unset, AxiamMiddleware
(Laravel) and AxiamAuthSubscriber (Symfony) behave byte-for-byte as they did before
§28 existed: no header on any response, no status changed, no body changed, no path
exempted.
The whole integration
use Axiam\Sdk\AccessEnforcer; use Axiam\Sdk\AxiamClient; use Axiam\Sdk\Laravel\AxiamMiddleware; use Axiam\Sdk\Mcp\Mcp; // 1. Describe the resource server. Validated here, at construction — before any // route exists and before any request is served. $metadata = Mcp::protectedResourceMetadata( resource: 'https://mcp.example.com/mcp', authorizationServers: ['https://axiam.example.com'], scopesSupported: ['mcp:read', 'mcp:tools'], ); // 2. Configure the client and the guard FROM that value rather than by retyping the // strings — retyping is how the guard and the document come to disagree. $client = new AxiamClient( baseUrl: 'https://axiam.example.com', tenant: 'acme-tenant', expectedAudience: $metadata->document['resource'], // https://mcp.example.com/mcp ); $middleware = new AxiamMiddleware($client, 'acme-tenant', $metadata->metadataUrl); // 3. Publish the document as a Laravel route. Passing $middleware is what lets the // SDK check that the three strings agree; it throws at startup if they do not. Route::middleware('axiam.auth')->group(function () use ($middleware, $metadata) { Route::serveProtectedResourceMetadata($metadata, $middleware); Route::post('/mcp/tools', ToolsController::class); });
On Symfony, the third step is a controller you wire into your own routing configuration rather than a macro-registered route — Symfony's routing table is declarative config the SDK cannot hook at runtime, which is also why the plan names this surface "subscriber + controller" rather than "subscriber + route":
use Axiam\Sdk\Symfony\AxiamAuthSubscriber; use Axiam\Sdk\Symfony\ProtectedResourceMetadataController; $subscriber = new AxiamAuthSubscriber($client, 'acme-tenant', $metadata->metadataUrl); // Tag $subscriber `kernel.event_subscriber` as usual (see "Framework integration" above). $controller = new ProtectedResourceMetadataController($metadata, $subscriber); // Wire $controller into routes.yaml/attributes at $metadata->metadataPath — the exact // derived path, e.g. `/.well-known/oauth-protected-resource/mcp`.
A request with no credential now gets what it needs to go and get one:
HTTP/1.1 401 Unauthorized WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp" Content-Type: application/json {"error":"AuthError","message":"missing authentication credentials"}
and the document it points at answers without a credential:
{
"resource": "https://mcp.example.com/mcp",
"authorization_servers": ["https://axiam.example.com"],
"scopes_supported": ["mcp:read", "mcp:tools"],
"bearer_methods_supported": ["header"]
}
The path is derived from the resource, not chosen. RFC 9728 §3.1 inserts
/.well-known/oauth-protected-resource between the authority and the path, so
https://mcp.example.com/mcp publishes at /.well-known/oauth-protected-resource/mcp and
https://mcp.example.com at the bare well-known path. A trailing slash is carried through
rather than trimmed — it is part of the identifier the client compares, and two resources
that differ only by it are two resources. Exactly one route is registered per call; a
deployment fronting several resources calls protectedResourceMetadata() once per
resource.
You do not have to order the routes. Both AxiamMiddleware and
AxiamAuthSubscriber exempt the document's path themselves, from the
resourceMetadataUrl they were configured with — the guard checks the exact path on
every request it sees, before it ever tries to extract a credential.
Announcing yourself obliges you to check
expectedAudience is mandatory once resourceMetadataUrl is set, and both guards
refuse the configuration at construction, naming both options. A resource server that
publishes "tokens for me carry this aud" and then does not check aud has published a
claim it does not honour — and a token minted for a different MCP server opens it.
That is the confusion RFC 8707 exists to prevent, so it is impossible to configure rather
than merely discouraged:
new AxiamMiddleware($client, 'acme-tenant', $metadata->metadataUrl); // Axiam\Sdk\Management\ValidationError: resourceMetadataUrl: requires expectedAudience // to be set on the same AxiamClient (CONTRACT.md §28.5 rule 2) — …
With both set, a token carrying aud: "axiam:user" — a perfectly valid AXIAM token that
simply was not minted for this resource — is a 401, exactly like a token minted for
https://other.example.com/mcp.
The 403 that asks for a scope
One class of 403 carries a challenge, and only one: an AccessEnforcer configured
with resourceMetadataUrl whose #[RequireAccess]/enforceAccess() call named a
scope and whose decision came back allowed: false with reasonCode: "no_grant":
$enforcer = new AccessEnforcer($client, resourceMetadataUrl: $metadata->metadataUrl); // #[RequireAccess(action: 'invoke', resourceParam: 'id', scope: 'mcp:tools')]
HTTP/1.1 403 Forbidden WWW-Authenticate: Bearer error="insufficient_scope", scope="mcp:tools", resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp" Content-Type: application/json {"error":"authorization_denied","message":"..."}
insufficient_scope is in the header and authorization_denied is in the body, and they
are not two spellings of one thing: the body is the §11 error taxonomy, unchanged, and the
header is the RFC 6750 hint. The scope named is the one the route asked for, verbatim
— never synthesised, never derived from action/resource, never substituted from the
document's scopes_supported, because where a deployment's AXIAM resource-scope names and
its OAuth scope names differ, that mapping is the operator's decision and the SDK cannot
see it.
Every other 403 carries no header at all: a require_access with no scope argument, a
require_role failure, a CSRF refusal, and — the one that matters — a decision whose
reasonCode is denied_by_rule. no_grant means ask for more, which is what a
challenge invites a client to do; denied_by_rule means an administrator has already
decided, and challenging on it would send an MCP client all the way around the
authorization loop to arrive at the identical 403. An absent or unrecognised reasonCode
is not eligible either.
Where a route also carries a §20.3 UMA challenge (a UmaChallenger configured on the same
AccessEnforcer), the UMA challenge wins: it is per-route opt-in and carries a live ticket
for the exact authority just refused, where this one is the generic hint. Only one
WWW-Authenticate value is ever emitted.
The challenge says only what RFC 6750 can say
Expired, not yet valid, wrong tenant, wrong audience, bad signature, a cnf the guard
could not satisfy, a sid in the §10.4 revocation feed — all of them are invalid_token,
indistinguishably. This SDK's own guards never emit an error_description, and add
nothing to the response that tells them apart. It is a 401 to an unauthenticated stranger:
every distinction it draws is an oracle.
Mcp::bearerChallenge(...) exists for the challenge you build yourself, for your own
400, and it takes errorDescription for that reason alone. It refuses rather than
escapes: RFC 6750 restricts every parameter to a character set that cannot contain "
or \, so a value needing an escape is a value that does not belong in a challenge.
Mcp::bearerChallenge( $metadata->metadataUrl, error: 'invalid_request', errorDescription: 'The access token is malformed', scope: 'mcp:read mcp:tools', ); // Bearer error="invalid_request", error_description="The access token is malformed", // scope="mcp:read mcp:tools", resource_metadata="https://mcp.example.com/.well-known/…/mcp" Mcp::bearerChallenge($metadata->metadataUrl, errorDescription: 'he said "no"'); // ValidationError — never a challenge containing \"
Validation refuses; it never repairs
Every §28.2 rule is checked by Mcp::protectedResourceMetadata() itself, and a violation
throws Axiam\Sdk\Management\ValidationError (§2's taxonomy, unchanged — §28 adds no error
type). Nothing is normalised, trimmed, lowercased or re-encoded to make it pass: that would
publish a document describing a resource server that does not exist. The rules, in one
list:
| Member | Rule |
|---|---|
resource |
absolute URI with a scheme and an authority, no query and no fragment; a trailing slash is significant |
| any URL | https, except on 127.0.0.1, [::1] or localhost — there is no flag, env var or debug build that widens this |
authorizationServers |
at least one entry, each an issuer verbatim (no ?tenant_id=), no query, no fragment, no duplicates |
scopesSupported |
RFC 6749 NQCHAR tokens, order preserved, no duplicates; an empty list omits the member |
bearerMethodsSupported |
exactly ["header"] — this SDK's guards read a bearer credential from the Authorization header alone |
resourceDocumentation |
an absolute URL, query and fragment permitted; omitted when absent, never null |
Nothing in the document may come from a request. resource and authorizationServers
are configuration, and this SDK offers no option to build either from the Host header,
the Forwarded/X-Forwarded-* family or the request URL. A document assembled from the
request is a document an attacker can point at an authorization server of their choosing —
the whole handshake redirected with one header.
Nothing in §28 is wrapped in Sensitive<T> (§7), and that is a rule rather than an
omission: the document is published unauthenticated to the world and the challenge goes to
a caller who has just failed to authenticate, so both must stay readable. The corollary is
the one that matters — no part of the presented credential reaches either of them, in
any parameter, any header, any body or any log line the guard writes on the 401 path.
gRPC and AMQP
CONTRACT.md §28.5 rule 8 makes exposing bearerChallenge to a gRPC guard's error mapping
optional ("MAY attach the same string as www-authenticate metadata on an
UNAUTHENTICATED status") and forbids an AMQP equivalent outright ("no client waiting on
a response to re-authorize with"). This SDK's Axiam\Sdk\Grpc namespace is a client
for AXIAM's own gRPC services (checkAccess, getUserInfo) — the opposite direction from
§28, which is about a guard protecting this application's own endpoints — so there is no
gRPC (or AMQP) resource-server guard here to wire the challenge into. §10's guard, and
therefore all of §28, is a REST-only surface on this SDK today.
RFC 7592 client configuration (CONTRACT.md §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 (or
http unless the base URL is itself http on a loopback host) is refused with a
ValidationError before a request is sent.
use Axiam\Sdk\Core\Sensitive; $token = new Sensitive($storedRegistrationAccessToken); $registration = $client->readClientRegistration($registrationClientUri, $token); // A FULL replacement: a member you leave out is deleted. Start from the read, which // carries every member — including ones this SDK does not model, kept in ->extra. $registration->clientName = 'Agent v2'; $updated = $client->updateClientRegistration($registrationClientUri, $token, $registration); // The token ROTATES: the one you presented is dead. Persist the new one first. store($updated->registrationAccessToken?->reveal());
The token travels only as Authorization: Bearer, on a transport that carries nothing of
this client's session (no cookie, no access token, no CSRF token) and follows no redirect.
Neither write is retried — an update whose answer was lost has already rotated the token —
and a 401 from these calls (an OAuthProtocolError with error === 'invalid_token')
never triggers the §9 refresh. The read is retried per §16 on transport failures and
5xx/408/429 only.
Directory, SAML, SSF and SCIM targets (CONTRACT.md §29 – §32)
Four management namespaces, generated like the rest of §27, with the contract's call-site rules in each method's docblock.
use Axiam\Sdk\Core\Sensitive; use Axiam\Sdk\Management\JsonNull; use Axiam\Sdk\Management\Models; use Axiam\Sdk\Management\ReadModifyWrite; // §30 — moving the connection (url, start_tls, bind_dn, trust_anchors_pem) needs the // secret again; the SDK keeps no copy. JsonNull::Null sends an explicit `null` (clears); // a plain PHP null is "absent" (keeps). $client->directory()->update(new Models\UpdateDirectoryConfig( bindSecret: new Sensitive($secretFromVault), groupFilter: JsonNull::Null, url: 'ldaps://dc2.corp.example', )); // §29 — a metadata import is a draft; nothing is stored until you create it. Exactly one // of the URL and the document: the two named constructors make that the only shape. $draft = $client->saml()->parseSpMetadata(Models\ParseSamlSpMetadata::fromUrl('https://sp.example/metadata')); $sp = $client->saml()->createServiceProvider($draft->serviceProvider); // §29 / §31 / §32 — `update` is a REPLACEMENT: an omitted member takes its default. // Read, convert, change, write. The write-only secret comes back absent, which keeps the // stored one (unless the write moves its URL, which then needs it again). $target = $client->scimTargets()->get($targetId); $client->scimTargets()->update($targetId, ReadModifyWrite::scimTarget($target, ['enabled' => false])); $stream = $client->ssf()->getStream($streamId); $client->ssf()->updateStream($streamId, ReadModifyWrite::ssfStream($stream, ['description' => 'EU RP']));
ReadModifyWrite::ssfStream($stream, $changes) is the change-taking form of §32.2 (contract
1.62): it applies $changes to what you read and sends the events_requested and the
expected_updated_at as read — the updated_at string byte for byte, and an [] as [] — and when
$changes lowers events_allowed it cuts events_requested to the entries that are still allowed, in
their order. An update that omits events_requested resets the stream to every allowed event,
widening what the receiver narrowed ([] is the receiver's "none"; a list is stored as given and is
400 outside events_allowed) — updateStream()'s docblock says so beside the call, and a raw API
client is bound by the same meaning. Called with no changes it is the plain converter, and cutting the
requested list to a new ceiling is then the caller's duty, as is refreshing expected_updated_at: a
write made since (the receiver's included) is a 409 and is never retried.
SamlIdpCredential has no key member and never will; a decoder meeting bind_secret,
credential, authorization_header or private_key_pem in a response drops it. 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. A 400 is a ValidationError whose serverMessage carries the server's
message (it names the rule — for a person; never parse it).
SSF receiver (CONTRACT.md §32.7)
For the relying party that receives AXIAM's CAEP and RISC events:
use Axiam\Sdk\Ssf\SetErr; use Axiam\Sdk\Ssf\SetFailureReason; use Axiam\Sdk\Ssf\SetVerificationError; use Axiam\Sdk\Ssf\SsfPollOptions; $receiver = $client->ssfReceiver( issuer: 'https://iam.example.com/t/<tenant-id>', // compared with `iss` exactly audience: 'https://rp.example.com', // your stream's audience jwksUri: 'https://iam.example.com/t/<tenant-id>/oauth2/jwks', accessTokenProvider: fn () => $client->loginClientCredentials('ssf.manage')->accessToken, ); // Push (RFC 8935): verify, then answer 202 — or 400 {"err": …}. try { $event = $receiver->verifySet($requestBody); handle($event->eventType, $event->event, $event->subId); } catch (SetVerificationError $e) { respond(400, ['err' => $e->failureReason->pushErrorCode()]); } // Poll (RFC 8936): acknowledge what you PROCESSED on the next call; refuse the rest. $result = $receiver->poll($streamId, new SsfPollOptions(returnImmediately: true)); $ack = array_map(static fn ($e) => $e->jti, $result->events); // after processing them $setErrs = []; foreach ($result->refused as $refused) { if ($refused->reason === SetFailureReason::Replayed) { // Accepted by this receiver on an earlier poll: acknowledge it, never report it // (§34.2 P2) — an error for an event you took would misinform the transmitter. $ack[] = $refused->jti; continue; } $setErrs[$refused->jti] = SetErr::fromReason($refused->reason); } $receiver->poll($streamId, new SsfPollOptions(ack: $ack, setErrs: $setErrs));
The verification order and the reason codes (SetFailureReason) are the contract's; alg
is pinned to EdDSA before any key is looked up, and keys come only from the configured JWKS
(never a jwk/x5c header). An unknown kid costs one forced JWKS refetch, at most once
a minute. A JWKS fetch failure is a NetworkError, not a verdict on the SET, and it counts toward
the same once-a-minute limit: for a minute after a failed fetch no SET triggers another, each
getting a NetworkError instead (§34.2 P6). The key cache expires five minutes after the fetch
that filled it, and the next SET fetches again. A verified SET
is recorded: re-offered unacknowledged, it reads as replayed — so acknowledge what you
processed. A replayed SET was accepted by this receiver earlier: poll returns it in
$result->refused, and you acknowledge it in ack, not in setErrs (§34.2 P2) — the
transmitter drops the event either way, and an error report would misinform it about an event
you took. poll never keeps a jti it does not return (§34.2 P1): when a key fetch fails or
the replay store cannot answer partway through a batch, the SETs already accepted are returned,
and that SET is left unjudged — unrecorded, listed in $result->unjudged with the failure in
$result->unjudgedCause, and neither acknowledged nor reported in setErrs, so the transmitter
offers it again. The store is asked nothing more for that batch: a later SET that fails
verification is still refused, and one that verifies is unjudged too. If nothing had been
accepted yet, the failure is raised instead. A poll that returns with SETs unjudged emits the
§19 event SsfUnjudgedEvent (ssf.poll, how many, and key_fetch or replay_store — no
jti) to the client's telemetry hook, so an outage that raises nothing is still visible. The replay window defaults to, and may not be set below, seven days; the default
store is in-memory and per-process, and unbounded in count (entries expire after the window)
— behind PHP-FPM, pass a shared ReplayStore (an atomic SET NX EX in Redis, say). A store
that cannot answer throws; it never answers "not seen" (§34.2 P4), and verifySet() raises
the failure as a NetworkError with the store's exception chained as its cause (§2). malformed, invalid_type and replayed are answered as
invalid_request, since only RFC 8935's codes go on the wire.
CIBA (CONTRACT.md §33)
Ask AXIAM to authenticate a user on another device, then collect the tokens. The client
authenticates as it does at /oauth2/token — its oidcClientSecret, or, for a
tls_client_auth client, its §6.1 certificate alone; a client with neither is refused locally.
use Axiam\Sdk\Core\OAuthProtocolError; use Axiam\Sdk\Oidc\CibaInitiateRequest; $initiated = $client->cibaInitiate(new CibaInitiateRequest( scope: 'openid', loginHint: 'ada@example.com', bindingMessage: 'W4SCT', // shown to the user, so they can tell it is yours )); // never retried try { $tokens = $client->cibaAwait($initiated); // honours interval and slow_down } catch (OAuthProtocolError $e) { if ($e->isAccessDenied()) { /* the user refused */ } elseif ($e->isExpiredToken()) { /* nobody answered in time */ } else { throw $e; } }
Ping mode: initiate with delivery: CibaDeliveryMode::Ping, clientNotificationToken: new Sensitive($random). In the handler of your notification endpoint, call
$client->cibaHandlePing($request->getHeaders(), (string) $request->getBody(), $token) — no
I/O; it checks the bearer in constant time and returns the auth_req_id — answer 204, and
only then $client->cibaPoll($authReqId) once. Fall back to cibaAwait() once half of
expires_in has passed without a ping.
Signed requests (§33.2): a client registered with a request-signing algorithm passes
signer: CibaRequestSigner::fromPem(CibaSigningAlg::EdDSA, new Sensitive($pem), 'kid-1'); the
form then carries only client authentication and one request JWT. EdDSA (a PKCS#8
Ed25519 key) and ES256 (a P-256 key) are supported; PS256 is refused locally —
firebase/php-jwt signs it only through phpseclib 3, which this SDK does not depend on.
A successful cibaInitiate 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 and the signing key are Sensitive. A request is
redeemed once — store the tokens cibaPoll/cibaAwait return before anything else.
TLS policy
Guzzle's verify option is always true (strict TLS, system trust roots) unless a
customCa path (a PEM CA-bundle file path, never a boolean) is supplied to
AxiamClient's constructor — the only escape hatch. There is no verify: false code
path anywhere in this SDK's source, examples, or tests; CI enforces this with a dedicated
grep gate (.github/workflows/sdk-ci-php.yml) that fails the build if any TLS-bypass
pattern (other than the customCa exception) is ever introduced.
mTLS / client certificates (CONTRACT.md §6.1)
For IoT devices and service accounts that authenticate by mutual TLS, supply an X.509
client identity (signed by the tenant's organization CA) via the clientCert/clientKey
constructor parameters — both PEM strings (clientCert is the certificate chain,
clientKey its private key, PKCS#8 or PKCS#1):
use Axiam\Sdk\AxiamClient; $client = new AxiamClient( baseUrl: 'https://api.axiam.example', tenant: 'acme', clientCert: file_get_contents('/secure/device.crt.pem'), clientKey: file_get_contents('/secure/device.key.pem'), );
The identity is applied to both transports of that client instance: the REST Guzzle
clients (as cert/ssl_key) and any gRPC channel (via
\Grpc\ChannelCredentials::createSsl(rootCerts, privateKey, certChain)). mTLS is opt-in;
omitting it leaves the default bearer-cookie behavior unchanged. Presenting a client
certificate is strictly additive — it never relaxes server verification, so the
strict-TLS policy above still holds. clientCert and clientKey are all-or-nothing:
supplying exactly one, or a non-PEM value, throws InvalidArgumentException at construction.
The private key is secret material (§7): it is held behind Sensitive, written only to a
short-lived 0600 temp file cURL reads, cleaned up when the client is destroyed, and never
appears in any log, exception, or debug output.
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 a client is built with clientCert, 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 |
|---|---|
oidcExchange, oidcRefresh, loginClientCredentials, devicePoll, tokenExchange, umaExchangeTicket |
token_endpoint |
introspect |
introspection_endpoint |
revoke |
revocation_endpoint |
deviceAuthorize |
device_authorization_endpoint |
oidcPar |
pushed_authorization_request_endpoint |
Three things this deliberately does not do:
- A null
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 property ofMtlsEndpointAliasesis nullable, and an endpoint the object does not name falls back rather than failing the document. - No alias is ever synthesised. Only the seven endpoints the contract lists can be aliased
(RFC 8705 §5's six, and since contract 1.58 CIBA's
backchannel_authentication_endpoint) — 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 clientCert 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.
An unusable alias is refused, never fallen back from (§21.3.1 vector C, contract 1.43)
A published alias that cannot carry a client certificate throws an AuthError naming the
member. It does not quietly fall back 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; presenting the certificate to the front-channel host authenticates nothing while appearing to work. A refusal is loud, local to one endpoint, and fixable by the operator who caused it.
Two defects, each a refusal on its own:
- Not an absolute URL. A relative alias resolves against nothing the client holds, and the one base that might seem obvious — the issuer's host — is precisely the host the alias exists to name a different one from.
- A scheme weaker than the endpoint it replaces. An alias substitutes for exactly one
top-level endpoint, so that is what it is compared against.
https→httpis a downgrade and mutual TLS over cleartext is a contradiction;http→httpis a development deployment, which AXIAM's ownbuild_mtls_aliasessupports, and it is accepted.
It is an AuthError rather than a NetworkError deliberately. Nothing failed in transport,
and §16.3 retries NetworkError and only NetworkError — classifying this as one would
attempt a permanent, deterministic misconfiguration three times and then report it as
transient.
The check is scoped to the alias actually used, so a broken introspection_endpoint leaves
the token endpoint working, and a client with no certificate never reads the member at all.
The session-revocation feed (CONTRACT.md §10.4, contract 1.44, opt-in)
JwksVerifier proves a token was issued by this deployment and has not expired. It cannot
prove the session behind it still exists — so a logout, a role removal or an account disable
does not reach a token already in a caller's hands until that token expires, up to fifteen
minutes later. §10.2 records this, and its standing answer is to route the decision through
gRPC introspection, which is correct and costs a round trip per request.
A deployment may instead publish GET /oauth2/revocations: the base64url-unpadded SHA-256
of every session id revoked within the last access-token lifetime. Polling it narrows the
window to one poll interval, for one cacheable fetch per interval.
use Axiam\Sdk\Auth\JwksVerifier; use Axiam\Sdk\Auth\RevocationFeed; $feed = new RevocationFeed($http, 'https://axiam.example.com'); $verifier = new JwksVerifier( $http, 'https://axiam.example.com', 300, 'https://axiam.example.com', // expected issuer 'axiam:user', // expected audience $feed, // omit this argument and nothing below happens at all );
Five properties, each of which is a test in tests/RevocationFeedTest.php:
| Rule | What it means here |
|---|---|
| Default off | The parameter is optional and defaults to null. Every existing call site keeps working and fetches nothing — asserted by counting requests on the wire, not by reading a flag. |
| Never on the request path | verify() answers from the cached set and refreshes at most once per interval. A revoked session is rejected after one poll and not before. |
| Never fails closed | Unreachable, non-200, unparseable, unknown alg, or more than MAX_ENTRIES entries — every one behaves exactly as no feed at all, and specifically not as an empty list, which would assert that nothing has been revoked. A failed poll leaves the last good set in place. |
| Only ever rejects | Every §10.1 rule runs first and still decides. A token that fails one is rejected whatever the feed says, and the feed is not consulted — nor fetched — for it. |
No sid, never matched |
A client-credentials token, an RPT or a token exchange has no session behind it. There is no jti fallback: hashing jti would match nothing while looking like it worked. |
The poll interval defaults to 30 s and is clamped to a 15 s floor rather than refused, so a caller who asks for something faster gets the fastest thing on offer.
This is a narrowing, not a control. It shortens the window in which a revoked session is still accepted; it does not close it, and a deployment that does not publish the feed is unaffected.
Sensitive value redaction
Token-carrying values (access tokens, refresh tokens, MFA challenge tokens, and — per
CONTRACT.md §12.5 — OIDC id_tokens, client_secrets, and PKCE code_verifiers) are
wrapped in
Axiam\Sdk\Core\Sensitive. Its __toString() and jsonSerialize() always return the
literal string "[SENSITIVE]", and the wrapped value is stored in a private static
WeakMap (not an instance property) so print_r()/var_export()/var_dump() cannot
enumerate it either — call ->reveal() explicitly to obtain the real value. Errors that
wrap a transport failure (NetworkError) redact Set-Cookie/Authorization/Cookie
header values from the response before the exception object is ever constructed, so a
raw token can never leak through a caught exception, a log line, or a JSON error body.
Examples
examples/login_mfa.php— login → MFA → typedLoginResult.examples/rest_authz.php—checkAccess()/can()/batchCheck()over REST.examples/grpc_checkaccess.php— the same three methods over gRPC (long-running runtime, see above).examples/telemetry_hook.php— CONTRACT.md §16–§19: the §19 hook, the §16 retry signal, the §19.2 rule 6 clamp warning, andclose(). Runs without a reachable server — the failure path emits the same events as the success path.examples/oidc_login.php— CONTRACT.md §12:oidcDiscover/oidcBegin/oidcExchange,loginClientCredentials,introspect,revoke.examples/uma_resource_server.php— CONTRACT.md §20: mint a PAT, register the resource, and emit theWWW-Authenticate: UMAchallenge from a denial.examples/uma_client.php— the other half: refusal → parse → trust decision → exchange → retry with the RPT.examples/laravel_app/— runnable Laravel middleware + Gate example, plusoidc_routes.phpfor "Login with AXIAM".examples/symfony_app/— runnable Symfony subscriber + Voter example (manual registration), plusoidc_services.yaml/oidc_routes.yamlfor "Login with AXIAM".examples/opaque_login.php— CONTRACT.md §23:loginOpaque, the one way OPAQUE can be unavailable on PHP (it used to be two), andopaqueEnrollment.examples/reactor/reactor.php— CONTRACT.md §22: a reactor answeringtoken.pre_issuewith anext.mutation andlogin.post_authwith a veto, over signed AMQP.examples/webauthn_passkeys.php— CONTRACT.md §24: both ceremonies, the §24.6a JSON bridge to the browser half, and the §24.6b rule 5 failure classification.examples/account_lifecycle.php— CONTRACT.md §25: the thirdlogin()outcome, voluntary and forced TOTP enrolment, email verification, and the password-reset triple.examples/par_login.php— CONTRACT.md §26: the 201 answer, the two-parameter redirect URL, and the exchange that follows.examples/management_basics.php— CONTRACT.md §27: namespace handles, one page vs. every page, sparse updates, per-handle scoping, and the three error classifications.examples/management_manifest.php— CONTRACT.md §27.6/§27.7: plan (which writes nothing) then apply (which stops at the first failure and does not roll back), including adenygrant.examples/management_manifest_attributes.php— the same manifest declared with#[Managed*]attributes instead of the builder.examples/network_manifest.php— CONTRACT.md §27.6.2 (contract 1.62): the tenant'sSession-Timeoutcap, its network segments and their RADIUS reply profiles in a manifest — plan, apply, and a409'sreason_code.examples/device_mtls_provisioning.php— the operator/device split: mint aDevicecertificate from the tenant's signing CA, bind it to a service account, write the one-time private key at0600, then authenticate as the device over §6.1 mutual TLS.bin/axiam-amqp-worker.php— standalone AMQP consumer worker (run under process supervision, see above).
Testing
composer install
composer test
Runs the full PHPUnit suite: single-flight refresh concurrency (SC#2), Sensitive
redaction (CR-04), AMQP HMAC verification, JWKS/EdDSA verification, the
extension_loaded('grpc') REST-fallback guard, and both framework-bridge tests.
Regenerating the gRPC stubs
The protobuf message classes under src/Grpc/Gen/ are protoc output, generated from
proto/axiam/v1/authorization.proto and
proto/axiam/v1/userinfo.proto and committed to this
repository — that is what lets composer require axiam/axiam-sdk work with no protobuf
toolchain on your machine, and what keeps gRPC a suggest rather than a hard dependency.
Unlike the other AXIAM SDKs, PHP does not use buf (D-03); it invokes protoc directly.
You only need this when proto/ changes:
composer grpc-gen # requires protoc on PATH; no grpc_php_plugin needed
git diff src/Grpc/Gen
The service clients (src/Grpc/AuthzGrpcClient.php and src/Grpc/UserInfoGrpcClient.php)
are hand-written against \Grpc\BaseStub and are not generated — do not overwrite them.