italix/session

Sessions as records about a subject and a tenant: device lists, per-account revocation, replay detection, step-up re-authentication, concurrency caps and pluggable stores

Maintainers

Package info

github.com/italix-net/session

pkg:composer/italix/session

Transparency log

Statistics

Installs: 2

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

2.0.0 2026-08-29 21:47 UTC

This package is not auto-updated.

Last update: 2026-08-30 22:43:18 UTC


README

PHP Version License

A session is a record about a subject — and about the organisation they are inside — not a blob under a key. Zero dependencies, not even italix/contracts.

php src/Libs/Italix/Session/tests/SessionTest.php
php src/Libs/Italix/Session/tests/StoreTest.php

Why this exists

Every application eventually has to answer five questions:

  • this account was suspended — sign it out now, not in 24 minutes
  • the password changed — invalidate the other sessions
  • which devices am I signed in on?
  • sign me out everywhere
  • who was signed in when that happened?

None of them is expressible against PHP's native session, Laravel's, or Symfony's, because in all three a session is an opaque payload under an identifier that only the browser holding the cookie knows. The server cannot find a user's sessions. So all five get re-implemented per project, usually late and usually wrong.

Anything sold to organisations rather than to individuals has a sixth and a seventh:

  • this subscription lapsed — sign out every person in the account
  • who from this account is working right now?

The difference here is one design decision — the stored record has a subject_c and a tenant_c, both indexed — and it is the only decision in the library that is expensive to change later.

The shortest useful example

use Italix\Session\Session;
use Italix\Session\Stores\PdoSessionStore;

use function Italix\Session\{cookie_spec, session_policy};

// Once, in configuration.
$store  = new PdoSessionStore($dm->get_connection());
$cookie = cookie_spec('__Host-session')->secure()->http_only()->same_site('Lax')->path('/');
$policy = session_policy()->idle_ttl(7200)->absolute_ttl(28800);

// Once per request, in one middleware.
Session::begin($store, $cookie, $request->getCookieParams()['__Host-session'] ?? null, $policy);

try {
    $response = $handler->handle($request);
} finally {
    $outcome = Session::end();
}

return $outcome->has_cookie()
    ? $response->withAddedHeader('Set-Cookie', $outcome->cookie_header())
    : $response;

The finally is not decoration. It is what makes the library safe under a resident worker: the static state is reset even when the controller threw, which is exactly what if (session_status() === PHP_SESSION_NONE) does not do.

Everywhere else:

Session::current()->get('locale_c');
Session::current()->set('locale_c', 'it');
Session::authenticate('account:12');   // rotates the identifier, keeps the data
Session::forget();                     // sign out

The five questions, in code

$sessions = new SessionRegister($store, null, $policy);   // or Session::register()

$sessions->of_subject('account:12');                       // device list
$sessions->revoke_subject('account:12', 'suspended');      // sign out everywhere
$sessions->revoke_others('account:12', Session::token());   // everywhere but here
$sessions->revoke($id_c);                                  // one device

revoke_subject() and revoke_others() return a count, so the message to the user can be true rather than generic.

And the same two questions about an organisation

A session belongs to a person; a person belongs to an account. Anything selling seats needs both lists, and they are not the same list:

$sessions->of_tenant('tenant:7');                  // who is working in this account right now
$sessions->revoke_tenant('tenant:7', 'unpaid');    // suspend the subscription — everybody, now

The case that settles why these are two indexed fields rather than one clever string is support impersonation. A staff member helping a customer keeps their own subject_c and takes the customer's tenant_c:

Session::set_tenant('tenant:7');   // subject_c stays 'staff:1'
Session::regenerate();             // the privileges changed, so the identifier does

That session must fall when the customer is suspended, must not fall when one of the customer's users changes their password, stays attributable to the staff member in an audit, and does not count against a seat the customer is paying for. One field collapsed into the other makes all four wrong at once.

set_tenant() deliberately does not touch authenticated_t: stepping into an account is not proving an identity, so a step-up window must not silently restart. Pass null to leave.

Not every store can do this, and the ones that cannot say so instead of pretending:

if (!$sessions->can_find_by_subject()) {
    return $this->show('account/devices_unavailable');
}

An empty device list reads as "you are signed in on one device", which is a lie the person reading it cannot detect. FileSessionStore and NativeSessionStore therefore throw rather than return []. can_find_by_tenant() is asked separately, because a store built over a key→value service maintains each index by hand and having one and not the other is an ordinary thing to have to say.

Six states, not a boolean

Session::state();   // active | absent | expired_idle | expired_absolute | revoked | rotated

"Signed out for inactivity" and "your access was revoked by an administrator" are two different messages to the user and two different lines in the log. Almost every application collapses both into the same mute login page.

rotated is the one worth the whole design. An identifier that has already been rotated coming back is something the legitimate browser cannot do — it was handed the new one and threw the old away. It is the signature of a copied cookie, and the only moment a server ever gets to see the theft.

case 'rotated':
    // Killing the session that presented it is not enough: the thief may be
    // holding the current one.
    $sessions->revoke_subject((string) Session::refused_subject(), 'replay_detected');
    break;

A short grace window (SessionPolicy::rotation_grace(), 60s by default) exists because a browser with requests in flight can legitimately present the old identifier for a moment after a login. Set it to zero and every login becomes a self-inflicted alarm.

Three times, not one

means drives
created_t when the session began — for a storefront, long before anybody signed in "signed in since"
last_seen_t last request the idle window, "active now"
authenticated_t when identity was last proved step-up
if (!Session::authenticated_within(900)) {
    return $this->redirect_to('/step-up');
}

// after the password or the OTP is verified again:
Session::reauthenticate();

authenticate() rotates the identifier because privileges changed. reauthenticate() does not, because they did not — rotating on every step-up would discard the device list every time somebody pays. And authenticated_within() returns false for a session that never authenticated: the null fails closed.

Concurrency: the guarantee that disappears silently

PHP's native session locks, so a read-modify-write cannot lose data. The moment the store is a database or Redis, that lock is gone without a word: two AJAX calls read the same payload, both write it back, and the second erases the first. It surfaces months later as "the cart occasionally loses an item".

// set() is right for a scalar the caller owns.
Session::current()->set('locale_c', 'it');

// merge() is right for a structure two requests can touch. The closure is
// replayed against the stored value at write time, inside the store's lock.
Session::current()->merge('cart', static function (array $cart) use ($sku_c): array {
    $cart[$sku_c] = ($cart[$sku_c] ?? 0) + 1;

    return $cart;
});

The rule where they meet: a key that has ever been set() or forget() in this request is written wholesale, and a merge() on it applies locally only. Once you assert the whole value you have taken responsibility for it. There is a test for both halves.

Seats, which are a product decision and not a security one

session_policy()->idle_ttl(7200)->max_concurrent(2);

At most two live sessions per subject; a third login evicts the least recently used and authenticate() returns how many fell, so the page can say "you were signed out on 1 other device" rather than leaving it to be discovered on a phone.

Evicting rather than refusing is the deliberate half: a cap that refuses locks somebody out of their own account over a browser they closed on a machine they no longer have, and they cannot fix it themselves. An application that genuinely wants to refuse counts first and owns the message:

if (count($sessions->of_subject($subject_c)) >= $limit_n) { … }

Off by default, because a limit on simultaneous sessions is how seat-based pricing stops one login being shared by an office — and a library that shipped it switched on would be making a pricing decision for everybody who adopted it.

A cap the store cannot enforce is refused at Session::begin(), not ignored per login. Accepting it would mean "at most two sessions" configured, any number allowed, and nothing ever saying so.

And the line the library will not cross: it takes no view on seats. How many people an organisation may have, and which of them keep access after a downgrade, is a pricing rule that lives in the application. revoke_tenant() signs out an account; deciding who deserved to stay is not a session library's business.

Typed payloads instead of loose keys

Session::put(new AuthContext('staff', 12));
Session::typed(AuthContext::class);     // ?SessionPayload, or null

The argument is one call site rather than taste. Reading a bag, get('tenant_di') is a typo that returns null — and a null fails closed in an authentication check and fails open in a tenant scope. The same missing value, read two metres apart, once harmless and once a data leak.

to_array() is the boundary, and that is the point: it is where a class decides what does not get serialised. Concretely, Italix\Auth\Identity already exists and is the wrong thing to store, because it carries password_hash().

Session::typed(...) === null means nobody — never "the other kind of actor". Inferring a role from the absence of a value is the failure mode this library exists to remove.

The library never learns what an actor is. Who they are, what roles exist and whether there is a tenant are one application's shape, not every application's — the same argument Auth\Identity makes when it declines to know about tenants.

The cookie, described once

cookie_spec('__Host-session')->secure()->http_only()->same_site('Strict')->path('/');

Defaults are the safe ones: HttpOnly on, SameSite=Lax, a session cookie rather than a persistent one. This class exists because of a measurement — a production application was running on PHP's shipped defaults, where cookie_httponly, cookie_samesite and cookie_secure were all empty and nothing had ever called session_set_cookie_params(). There was nowhere for the decision to live.

A description a browser would discard is refused, not accepted: SameSite=None without Secure, a __Host- name with a domain or a path other than /. Validation runs in Session::begin(), so a contradiction lands at the start of a request with a message naming it rather than as a cookie that silently never arrives.

Strict mode is structural. PHP's session.use_strict_mode exists because PHP will create a session for any identifier a client invents. Here an identifier the store never issued simply finds no record — there is no setting to forget. The one exception is NativeSessionStore, where PHP's own handler is doing the work, so the setting is turned on explicitly when a CookieSpec is applied.

The four stores

shared between servers by subject & by tenant locks notes
ArraySessionStore no yes n/a tests
FileSessionStore no no flock one file per session, 0600, atomic writes
PdoSessionStore yes yes SELECT … FOR UPDATE install() creates ix_session
NativeSessionStore no no PHP's own the migration path

PdoSessionStore takes a raw PDO rather than Italix\Orm\DataManager, so this package stays a leaf; $dm->get_connection() hands over exactly what is needed.

Migrating off $_SESSION without moving the data

Session::begin_native($cookie, $policy);

NativeSessionStore wears this library's interface over PHP's own session. Scattered $_SESSION accesses and session_status() guards become one middleware and one facade while the storage stays where it is: no migration, no signed-out users, no new table. It writes through to $_SESSION, so untouched code keeps working during the transition, and it keeps its metadata in one reserved key.

Then swapping in PdoSessionStore is a line of configuration, and by then every call site is already correct.

The cookie hardens on day one, before anything else moves. PHP owns the cookie here, so the spec is pushed into session_name() and session_set_cookie_params() before the start, and session.use_strict_mode is turned on — the one place in this library where strict mode has to be asked for rather than being structural. A spec that cannot be applied is refused, not ignored: arriving after the session started, or after output has begun, throws with the reason. The single exception is session.use_cookies being off, where there is no cookie to misrepresent and the spec is inert.

Measured on the application this was written for, this one call changed the live response from:

Set-Cookie: PHPSESSID=…; path=/
Set-Cookie: PHPSESSID=…; path=/; HttpOnly; SameSite=Lax

CSRF and flash live on the facade under the same key names Italix\Mvc\Session already used (_csrf_token, _flash), so adopting begin_native() keeps every signed-in user's existing token rather than failing their next form post.

Housekeeping: a sweep that has to be told what stale means

Expiry is decided on read, so nothing here is load-bearing: a session past its idle window is refused whether or not gc() has ever run. What a table nobody sweeps does is grow.

$sessions = new SessionRegister($store, null, $policy);

$removed_n = $sessions->gc();   // run it from cron, a queue, a scheduler — this library takes no view

Build the register with the policy. Without one, gc() sweeps on the absolute deadline alone — and expires_t is only written when an absolute cap is configured, which most applications never set. The result is a sweep that runs, reports a count, and collects nothing. That is not hypothetical: it is what the first deployment of PdoSessionStore did here, and the measurement was 57 rows, none of them collectable, growing by one per anonymous visitor, because every page with a form mints a CSRF token, minting a token dirties the session, and a dirty session is written.

The sweep uses the same comparison as the read path — over when (now - last_seen) > ttl — so it can only delete sessions that were already being refused. It cannot sign anybody out. The suite asserts that in the costly direction, on all four stores: a record last seen exactly ttl ago is still being served and must survive.

Deliberately not

No stateless signed-cookie or JWT mode. A signed token is valid until it expires, by construction, and cannot answer any of the five questions. Every system that adopts one ends up building a blacklist beside it — which is the state it removed, reintroduced worse. A library that starts from the indexed record can add a short-lived token later as an optimisation; the converse is not available.

Also: no cross-domain session sharing, no "remember me" as a second kind of session (it is an absolute_ttl_n, not a mechanism), and no session-based flash for API responses.

Tests

138 assertions across two suites. Twenty-four deliberate mutations have been introduced across the three releases to check the assertions bite: no rotation at login, a revoked session accepted, SameSite=None allowed without Secure, merge closures dropped at persist time, end() leaving the record behind, the CSRF field unescaped, the absolute cap restarting on rotation, the session id interpolated into a filesystem path, revoked sessions listed as devices, a store claiming an indexing capability it lacks, the tenant dropped across a rotation or from the SQL upsert, the concurrency cap off by one or evicting the newest, a cap accepted over a store that cannot enforce it, the idle sweep off by one in each of the three stores that implement it, and a register that drops the policy on the way to gc().

Twenty-two were caught immediately. The two that were not were worth more than the eighteen:

  • one showed a docblock claiming the absolute cap held because created_t is carried across a rotation, when the mechanism is actually expires_t;
  • the other showed an assertion checking the right outcome through the wrong path — leaving an organisation and rotating writes the local record either way, so the flag that carries a cleared tenant to a row that already exists was never exercised.

Both comments were corrected and both missing assertions added. Both sweeps now run clean.

On the >=7.4 claim. Syntax-checking every file on 7.4 proves nothing about running on it, so the library was also run there — rotation, replay detection, revocation, merge() reconciliation, step-up and the array and file stores all pass under 7.4.33 as well as 8.1.31. PdoSessionStore was exercised on 8.1 only, because the 7.4 build available here has no pdo_sqlite. That is a gap in the evidence, not a known incompatibility, and it is written down rather than rounded up.