italix/auth

Password authentication with leak-resistant ordering, and abstaining authorization policies. Ships no table and no session handling.

Maintainers

Package info

github.com/italix-net/auth

pkg:composer/italix/auth

Transparency log

Statistics

Installs: 2

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

2.0.0 2026-08-30 07:43 UTC

This package is not auto-updated.

Last update: 2026-08-31 05:57:32 UTC


README

PHP Version License

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

  1. Rate limit first, before the store is touched.
  2. Look up the identity.
  3. Hash-compare either way — a missing account burns equal work through verify_dummy(), so response time does not answer "is this address registered?".
  4. Password before is_active(). Reversed, "not activated" arrives without the password ever matching, and an attacker enumerates accounts with one wrong guess each.
  5. 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".