italix / auth
Password authentication with leak-resistant ordering, and abstaining authorization policies. Ships no table and no session handling.
Requires
- php: >=7.4
- italix/contracts: ^2.0
Requires (Dev)
- italix/crypto: ^2.0
- italix/testing: ^2.0
Suggests
- italix/crypto: Provides the RateLimiter the Authenticator takes
This package is not auto-updated.
Last update: 2026-08-31 05:57:32 UTC
README
Password authentication with leak-resistant ordering, and authorization policies that are allowed to say "not my question".
One dependency: italix/contracts, which is interface-only.
php src/Libs/Italix/Auth/tests/AuthTest.php
What the application provides
Two interfaces, because the library ships no table:
final class TenantIdentityStore implements IdentityStore { public function find_by_login(string $login_c): ?Identity { $row = $this->dm->query_one( 'SELECT id, user_name, email, password, tenant_id, activated_dt FROM app_user WHERE (user_name = ? OR email = ?) AND role = ?', [$login_c, $login_c, 'MEMBER'] ); return $row === null ? null : new TenantIdentity($row); } // find_by_id, touch_login, store_password_hash }
Do not filter out inactive accounts in the query. A pending account must come back with
is_active() false, not as null — collapsing the two makes "awaiting confirmation" indistinguishable
from "no such address", and the login form then answers a question it should not.
Identity is deliberately tiny: id, login, active, hash, and attribute(). tenant_id reaches a
policy through attribute('tenant_id'), which keeps multi-tenancy out of a framework that has no
business knowing this application has tenants.
Signing in
$outcome = $this->auth->attempt($username, $password, $ip_c); if ($outcome->is_ok()) { $identity = $outcome->identity(); session_regenerate_id(true); $_SESSION['user_id'] = $identity->id(); $_SESSION['tenant_id'] = $identity->attribute('tenant_id'); return $this->redirect_to($this->url->to('member.home')); } $error = in_array($outcome->error_code(), ['unknown_user', 'bad_password'], true) ? 'invalid_credentials' : $outcome->error_code();
Those four session lines are the whole of what the library refuses to do for you. $_SESSION belongs
to the application, and an auth package that writes to it has decided the shape of something it does
not own.
The collapse is yours to make
unknown_user and bad_password are distinguishable here and must not be distinguishable in the
UI. not_activated is usually worth showing separately: without it, a user whose password is correct
has no idea why it is refused. A library that pre-decided that trade-off would be wrong for half its
consumers, so it hands over the codes and stays out of it.
The order, and why each step is where it is
- Rate limit first, before the store is touched.
- Look up the identity.
- Hash-compare either way — a missing account burns equal work through
verify_dummy(), so response time does not answer "is this address registered?". - Password before
is_active(). Reversed, "not activated" arrives without the password ever matching, and an attacker enumerates accounts with one wrong guess each. - Reset the counter, rehash if stale, record the login.
Step 4 has a test of its own in both suites, because it is invisible in a working login and is exactly the step someone tidies away later.
Rate limiting
The authenticator takes a Italix\Contracts\RateLimiter; Italix\Crypto\Limiter is one:
new Authenticator($store, null, $c->get(Limiter::class)); // 5 per account / 15 minutes
That is the per-account limit. A per-IP limit belongs in the action, because it is about the
endpoint rather than about an account, and neither substitutes for the other — see
Crypto/README.md.
Passing null disables limiting. Acceptable for a console command; reckless for a web form.
Authorization
final class OrderPolicy implements Policy { public function decide(string $action_c, Identity $who, $subject = null): ?bool { if (strpos($action_c, 'order.') !== 0) { return null; // not my question } if ($who->attribute('is_staff') === true) { return true; } return $subject !== null && (int) $who->attribute('tenant_id') === (int) $subject['tenant_id']; } }
if ($gate->denies('order.edit', $who, $order)) { return $this->redirect_to($this->index_url($lang)); }
A policy may abstain, and that is the design — the same honesty as Outcome::deferred() in
Italix\Rules. Forcing every policy to return a boolean makes each one vote false on questions it
knows nothing about, and then the gate cannot tell "denied" from "nobody was asked".
Two rules make the result predictable:
- Deny wins. A false ends the question; no later policy can widen what an earlier one refused.
- Silence denies. All abstentions mean no, so a typo in an action name is a closed door rather than an open one.
explain() returns which policy decided and why — for the afternoon somebody cannot reproduce a
permission problem.
Deliberately not
- No session handling — four lines, shown above, and they are the application's.
- No password reset or e-mail verification — both need mail, which does not exist yet. And a signed token is the wrong shape for a reset link: those must be revocable, which needs a row.
- No roles table. Roles are data; policies are mechanism.
- No OAuth, no SSO, no "remember me".