Session
in package
Per-`AxiamClient` session state (CONTRACT.md §3/§4/§5/§9): owns the shared Guzzle `CookieJar` (§4), captures/exposes the non-browser CSRF token (§3), and is the single-flight home for the shared refresh `Promise` (§9, D-06).
tenant is a required constructor parameter with no nullable default (D-13) — there
is no default-tenant fallback anywhere in this class or in any of its callers.
refreshIfNeeded() returns the SAME PromiseInterface to every concurrent caller
until it settles. The check-and-store below (the null check immediately followed
by the assignment) executes synchronously — nothing in between calls ->wait() or
yields — so it is safe without a mutex even under N concurrent async callers sharing
one Session instance (D-06's "fiber-safe by construction" claim; PHP Fibers are
cooperative/non-preemptive, and Guzzle's own promise resolution never interleaves
mid-statement).
The $http client passed in is used directly for the refresh POST. In production
wiring (a later plan assembles AxiamClient), that client is expected to be
constructed WITHOUT RefreshMiddleware attached, so a 401 response to the refresh
call itself can never recursively re-enter the single-flight guard; this plan does
not need to solve that wiring, only provide the guard itself.
Table of Contents
Constants
- REFRESH_KIND_OIDC : mixed = 'oidc'
- Guard-slot kind for the §12 `oidc_refresh` OAuth2 token-endpoint path (CONTRACT.md §9 rule 5 / F-06). Used by {@see \Axiam\Sdk\Oidc\OidcClient::oidcRefresh()} so a second concurrent `oidcRefresh` caller can recognise the guard is busy with ANOTHER `oidcRefresh` (same kind) and share its single outcome, instead of re-acquiring the guard and issuing its own wire call that would replay an already-consumed (single-use, rotating) refresh token.
- REFRESH_KIND_SESSION : mixed = 'session'
- Guard-slot kind for the §1 cookie-session refresh path ({@see self::refreshIfNeeded()}) — the default `refreshGuard()` kind.
Methods
- __construct() : mixed
- accessToken() : string|null
- The current access token, read live from the shared cookie jar's `axiam_access` entry rather than cached separately — avoids a second, potentially-stale, copy of the token (mirrors the Java SDK's `SessionState::cachedAccessToken()` and the Go SDK's `cookieValue()` helper).
- actingTenant() : string|null
- The tenant this client currently acts on (CONTRACT.md §5.2 rule 1), or `null` when it acts on its own tenant — the state {@see \Axiam\Sdk\Rest\AuthMiddleware} reads to decide whether to send `X-Axiam-Tenant` at all.
- adoptBearerCredential() : void
- CONTRACT.md §12.1 "`login_client_credentials` as a credential source" (a MAY): adopt `$accessToken` as this session's bearer credential for subsequent same-origin REST calls (never `/oauth2/*` — {@see \Axiam\Sdk\Rest\AuthMiddleware} excludes that path unconditionally). A cookie-sourced access token from a real `login()`/`verifyMfa()` session always takes precedence over an adopted one — see {@see self::accessToken()}.
- baseUrl() : string
- AXIAM server base URL this session is bound to.
- canRefresh() : bool
- Whether the credential {@see self::accessToken()} would currently return can be refreshed through the §9 `/api/v1/auth/refresh` guard (CONTRACT.md §6.1 rule 11, C-12 N4.5): true exactly when a cookie-sourced session exists — a real `login()`/`verifyMfa()`/OPAQUE/MFA-setup/WebAuthn-setup/SSO session has a server-stored refresh token behind its `axiam_access` cookie. An ADOPTED bearer credential — {@see \Axiam\Sdk\AxiamClient::authenticateDevice()}'s device token (§6.1 rule 6: no refresh token is ever issued for it), or a `client_credentials`/ device-grant token adopted via {@see self::adoptBearerCredential()} (RFC 6749 §4.4.3: `client_credentials` issues no `refresh_token` either) — has none, and MUST NEVER be refreshed, on either transport. Mirrors {@see self::accessToken()}'s own cookie-first precedence exactly, so this is true whenever THAT method would hand back a cookie-sourced token and false whenever it would fall back to the adopted one (or return `null`).
- captureCsrfTokenFromResponse() : void
- Public seam for {@see \Axiam\Sdk\Oidc\OidcClient::ssoComplete()} (CONTRACT.md §12.1 note 6): `ssoComplete` establishes the session as `Set-Cookie` rather than a response body, but the server ALSO freshly sets `X-CSRF-Token` on that same response (exactly as `login()`'s response does), so this wraps the same private capture logic {@see self::refreshIfNeeded()} already uses.
- clearBearerCredential() : void
- Clears the adopted bearer credential (CONTRACT.md §6.1 rule 11 / C-12 N4.4: "logout clears it"). Called only by {@see \Axiam\Sdk\AxiamClient::logout()}, after a successful server-side logout — mirroring how the cookie jar, CSRF token and principal scope are also cleared only then, never on a refused call.
- cookieJar() : CookieJar
- §4: the single cookie jar every REST-facing Guzzle client MUST share.
- csrfToken() : string|null
- §3 non-browser CSRF: the most recently captured `X-CSRF-Token` response header.
-
principalScope()
: array{organizationLevel: bool, reachableTenantIds: ?list
}|null - What the last completed login reported about the principal's reach (CONTRACT.md §5.2/§5.2.3), or `null` when this client holds no such result. See {@see self::$principalScope}'s own doc for which sessions set/reset it.
- recordPrincipalScope() : void
- Records what a just-completed login/verify-MFA/OPAQUE-finish/setup-completion reported about the principal's reach (§5.2/§5.2.3).
- refreshGuard() : PromiseInterface, kind: string}
- The generic single-flight primitive behind {@see self::refreshIfNeeded()} (CONTRACT.md §9) — and, additively, behind `oidc_refresh`'s own single-flight requirement (§12.1: "`oidc_refresh` MUST be governed by a §9-conformant single-flight guard"). Both share the SAME `$refreshPromise` slot, so a cookie-session refresh and an `oidcRefresh()` call can never race each other independently — whichever gets here first "owns" the slot until it settles.
- refreshIfNeeded() : PromiseInterface
- Returns the SAME `PromiseInterface` to every caller until it resolves (SC#2, D-06). On success: captures the `X-CSRF-Token` response header, then clears the stored promise. On failure: clears the stored promise and rejects with `AuthError` — no retry loop (§9.3). Clear-on-both-paths bookkeeping and the failure-to-`AuthError` translation are factored into {@see RefreshGuard::settle()} so REST and (later) gRPC never re-implement — or drift on — that one mechanism; {@see RefreshGuard::settle()}'s `$onClear` closure below is invoked on EITHER outcome, never on both, never on neither.
- resetCsrf() : void
- Clears the captured CSRF token — called by {@see \Axiam\Sdk\AxiamClient::logout()} so a logged-out session never echoes a stale `X-CSRF-Token` on a subsequent (re-authenticated) request. Purely additive: does not change {@see self::csrfToken()}'s or {@see self::refreshIfNeeded()}'s existing behavior in any other way.
- resetPrincipalScope() : void
- Resets the principal scope to "unknown" (§5.2 rule 1): a session that just completed WITHOUT reporting a `LoginUserInfo` — WebAuthn authentication, SSO, a device login, or an ordinary logout — must not let {@see \Axiam\Sdk\AxiamClient::actingTenant()} keep gating on a PREVIOUS principal's reach.
- setActingTenant() : void
- Sets, or clears (`null`), the acting tenant (CONTRACT.md §5.2 rule 1). The caller ({@see \Axiam\Sdk\AxiamClient::actingTenant()}/`clearActingTenant()`) is responsible for validating `$tenantId` as a UUID first — this setter trusts it.
- tenant() : string
- Tenant slug every request in this session is scoped to.
Constants
REFRESH_KIND_OIDC
Guard-slot kind for the §12 `oidc_refresh` OAuth2 token-endpoint path (CONTRACT.md §9 rule 5 / F-06). Used by {@see \Axiam\Sdk\Oidc\OidcClient::oidcRefresh()} so a second concurrent `oidcRefresh` caller can recognise the guard is busy with ANOTHER `oidcRefresh` (same kind) and share its single outcome, instead of re-acquiring the guard and issuing its own wire call that would replay an already-consumed (single-use, rotating) refresh token.
public
mixed
REFRESH_KIND_OIDC
= 'oidc'
REFRESH_KIND_SESSION
Guard-slot kind for the §1 cookie-session refresh path ({@see self::refreshIfNeeded()}) — the default `refreshGuard()` kind.
public
mixed
REFRESH_KIND_SESSION
= 'session'
Methods
__construct()
public
__construct(string $baseUrl, string $tenant, Client $http[, CookieJar|null $cookieJar = null ]) : mixed
Parameters
- $baseUrl : string
-
AXIAM server base URL (HTTPS;
http://is rejected except on loopback). - $tenant : string
-
Tenant slug every request is scoped to.
- $http : Client
-
Guzzle client carrying this session's middleware stack.
- $cookieJar : CookieJar|null = null
-
Persistent cookie store (CONTRACT.md §4). Defaults to a fresh in-memory jar — REQUIRED, because AXIAM delivers the access and refresh tokens as
httpOnlycookies, so a client that does not persist them fails every request after login.
accessToken()
The current access token, read live from the shared cookie jar's `axiam_access` entry rather than cached separately — avoids a second, potentially-stale, copy of the token (mirrors the Java SDK's `SessionState::cachedAccessToken()` and the Go SDK's `cookieValue()` helper).
public
accessToken() : string|null
Return values
string|nullactingTenant()
The tenant this client currently acts on (CONTRACT.md §5.2 rule 1), or `null` when it acts on its own tenant — the state {@see \Axiam\Sdk\Rest\AuthMiddleware} reads to decide whether to send `X-Axiam-Tenant` at all.
public
actingTenant() : string|null
Return values
string|nulladoptBearerCredential()
CONTRACT.md §12.1 "`login_client_credentials` as a credential source" (a MAY): adopt `$accessToken` as this session's bearer credential for subsequent same-origin REST calls (never `/oauth2/*` — {@see \Axiam\Sdk\Rest\AuthMiddleware} excludes that path unconditionally). A cookie-sourced access token from a real `login()`/`verifyMfa()` session always takes precedence over an adopted one — see {@see self::accessToken()}.
public
adoptBearerCredential(Sensitive $accessToken) : void
Parameters
- $accessToken : Sensitive
baseUrl()
AXIAM server base URL this session is bound to.
public
baseUrl() : string
Return values
stringcanRefresh()
Whether the credential {@see self::accessToken()} would currently return can be refreshed through the §9 `/api/v1/auth/refresh` guard (CONTRACT.md §6.1 rule 11, C-12 N4.5): true exactly when a cookie-sourced session exists — a real `login()`/`verifyMfa()`/OPAQUE/MFA-setup/WebAuthn-setup/SSO session has a server-stored refresh token behind its `axiam_access` cookie. An ADOPTED bearer credential — {@see \Axiam\Sdk\AxiamClient::authenticateDevice()}'s device token (§6.1 rule 6: no refresh token is ever issued for it), or a `client_credentials`/ device-grant token adopted via {@see self::adoptBearerCredential()} (RFC 6749 §4.4.3: `client_credentials` issues no `refresh_token` either) — has none, and MUST NEVER be refreshed, on either transport. Mirrors {@see self::accessToken()}'s own cookie-first precedence exactly, so this is true whenever THAT method would hand back a cookie-sourced token and false whenever it would fall back to the adopted one (or return `null`).
public
canRefresh() : bool
Consulted by RefreshMiddleware (REST, before ever calling
self::refreshIfNeeded()) and AuthzDispatcher (gRPC,
before ever calling its own $refreshAccessor) — never by this class itself,
which stays agnostic of which credential is "current" beyond what
self::accessToken() already resolves.
Return values
boolcaptureCsrfTokenFromResponse()
Public seam for {@see \Axiam\Sdk\Oidc\OidcClient::ssoComplete()} (CONTRACT.md §12.1 note 6): `ssoComplete` establishes the session as `Set-Cookie` rather than a response body, but the server ALSO freshly sets `X-CSRF-Token` on that same response (exactly as `login()`'s response does), so this wraps the same private capture logic {@see self::refreshIfNeeded()} already uses.
public
captureCsrfTokenFromResponse(ResponseInterface $response) : void
Parameters
- $response : ResponseInterface
clearBearerCredential()
Clears the adopted bearer credential (CONTRACT.md §6.1 rule 11 / C-12 N4.4: "logout clears it"). Called only by {@see \Axiam\Sdk\AxiamClient::logout()}, after a successful server-side logout — mirroring how the cookie jar, CSRF token and principal scope are also cleared only then, never on a refused call.
public
clearBearerCredential() : void
Without this, a device token or adopted client-credentials token from EARLIER in
this client's life — merely SHADOWED, never cleared, by a subsequent
login()'s cookie session, since self::accessToken() always prefers a
cookie-sourced token when one is present — resurfaces once logout() clears that
cookie again: a client that believes it logged itself out would still be
authenticated as the stale device/service credential.
cookieJar()
§4: the single cookie jar every REST-facing Guzzle client MUST share.
public
cookieJar() : CookieJar
Return values
CookieJarcsrfToken()
§3 non-browser CSRF: the most recently captured `X-CSRF-Token` response header.
public
csrfToken() : string|null
Return values
string|nullprincipalScope()
What the last completed login reported about the principal's reach (CONTRACT.md §5.2/§5.2.3), or `null` when this client holds no such result. See {@see self::$principalScope}'s own doc for which sessions set/reset it.
public
principalScope() : array{organizationLevel: bool, reachableTenantIds: ?list}|null
Return values
array{organizationLevel: bool, reachableTenantIds: ?listrecordPrincipalScope()
Records what a just-completed login/verify-MFA/OPAQUE-finish/setup-completion reported about the principal's reach (§5.2/§5.2.3).
public
recordPrincipalScope(bool $organizationLevel, array<int, string>|null $reachableTenantIds) : void
Parameters
- $organizationLevel : bool
- $reachableTenantIds : array<int, string>|null
refreshGuard()
The generic single-flight primitive behind {@see self::refreshIfNeeded()} (CONTRACT.md §9) — and, additively, behind `oidc_refresh`'s own single-flight requirement (§12.1: "`oidc_refresh` MUST be governed by a §9-conformant single-flight guard"). Both share the SAME `$refreshPromise` slot, so a cookie-session refresh and an `oidcRefresh()` call can never race each other independently — whichever gets here first "owns" the slot until it settles.
public
refreshGuard(callable(): PromiseInterface $startRefresh[, callable(mixed): mixed|null $onSuccess = null ][, string $kind = self::REFRESH_KIND_SESSION ]) : PromiseInterface, kind: string}
If no refresh is currently in flight, invokes $startRefresh (which must return
the wire-call PromiseInterface) as THIS refresh, publishing it into the shared
slot together with $kind. If a refresh is already in flight, $startRefresh is
NOT invoked at all and the existing promise is returned instead, tagged with the
kind that started it.
Result sharing across same-kind callers (CONTRACT.md §9 rule 2, F-06). The
caller distinguishes three cases via the returned ran/kind pair:
ran === true: this call itself started the refresh — its own outcome IS the shared outcome.ran === falseandkindequals the kind THIS caller passed in: the guard is busy with ANOTHER caller of the SAME operation (e.g. two concurrentoidcRefresh()calls). The returnedpromiseresolves to that one leader's exact outcome — the caller MUST await and reuse it rather than re-acquiring the guard and issuing its own wire call. This matters because AXIAM refresh tokens are opaque, server-stored, and single-use with rotation: a second wire call would replay an already-consumed token and failinvalid_grant. Await it with RefreshGuard::join(), never withPromiseInterface::wait(): only the leader — theran === truecaller — maywait(), becausewait()consumes the promise's wait function and drives the call rather than observing it, so a secondwait()from a concurrent fiber/coroutine destroys the leader's in-flight refresh (F-06).ran === falseandkinddiffers from THIS caller's kind: the guard is busy with a DIFFERENT operation (e.g. the §1 cookie-session refresh occupying the slot while anoidcRefresh()call arrives). The existing promise's resolved value cannot satisfy this caller (a PSR-7ResponseInterfaceis not an OAuth2 token array, or vice versa), so the caller should wait for it to settle and then retry acquiring the guard for its own kind.
Parameters
- $startRefresh : callable(): PromiseInterface
-
Produces the wire-call promise for THIS refresh attempt. Invoked only when the guard is free.
- $onSuccess : callable(mixed): mixed|null = null
-
Runs after the shared promise resolves successfully, before the settled value is handed back (e.g. CSRF capture) — same contract as RefreshGuard::settle()'s own parameter.
- $kind : string = self::REFRESH_KIND_SESSION
-
One of self::REFRESH_KIND_SESSION / self::REFRESH_KIND_OIDC (or a future operation-specific kind), identifying which operation this call is performing/waiting for.
Return values
PromiseInterface, kind: string} —ran is
true only when $startRefresh was actually invoked by THIS call;
kind is always the kind that OWNS the returned promise (THIS call's
$kind when ran is true, otherwise whichever kind is already
in flight).
refreshIfNeeded()
Returns the SAME `PromiseInterface` to every caller until it resolves (SC#2, D-06). On success: captures the `X-CSRF-Token` response header, then clears the stored promise. On failure: clears the stored promise and rejects with `AuthError` — no retry loop (§9.3). Clear-on-both-paths bookkeeping and the failure-to-`AuthError` translation are factored into {@see RefreshGuard::settle()} so REST and (later) gRPC never re-implement — or drift on — that one mechanism; {@see RefreshGuard::settle()}'s `$onClear` closure below is invoked on EITHER outcome, never on both, never on neither.
public
refreshIfNeeded() : PromiseInterface
Return values
PromiseInterfaceresetCsrf()
Clears the captured CSRF token — called by {@see \Axiam\Sdk\AxiamClient::logout()} so a logged-out session never echoes a stale `X-CSRF-Token` on a subsequent (re-authenticated) request. Purely additive: does not change {@see self::csrfToken()}'s or {@see self::refreshIfNeeded()}'s existing behavior in any other way.
public
resetCsrf() : void
resetPrincipalScope()
Resets the principal scope to "unknown" (§5.2 rule 1): a session that just completed WITHOUT reporting a `LoginUserInfo` — WebAuthn authentication, SSO, a device login, or an ordinary logout — must not let {@see \Axiam\Sdk\AxiamClient::actingTenant()} keep gating on a PREVIOUS principal's reach.
public
resetPrincipalScope() : void
setActingTenant()
Sets, or clears (`null`), the acting tenant (CONTRACT.md §5.2 rule 1). The caller ({@see \Axiam\Sdk\AxiamClient::actingTenant()}/`clearActingTenant()`) is responsible for validating `$tenantId` as a UUID first — this setter trusts it.
public
setActingTenant(string|null $tenantId) : void
Parameters
- $tenantId : string|null
tenant()
Tenant slug every request in this session is scoped to.
public
tenant() : string