PHPackages                             milpa/auth - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Framework](/categories/framework)
4. /
5. milpa/auth

ActiveMilpa-capability[Framework](/categories/framework)

milpa/auth
==========

Runtime-native identity vocabulary for the Milpa PHP framework: the typed Actor / AuthContext / AuthState / Credential primitives and the CredentialVerifier, AuthContextFactory and SessionStore contracts — the trusted producer of the context policies need. Zero framework, zero ORM, fail-closed by construction.

v0.3.12(2w ago)01.5k↑329.2%4Apache-2.0PHPPHP &gt;=8.3CI passing

Since Jul 14Pushed 2w agoCompare

[ Source](https://github.com/getmilpa/auth)[ Packagist](https://packagist.org/packages/milpa/auth)[ RSS](/packages/milpa-auth/feed)WikiDiscussions main Synced 1w ago

READMEChangelog (10)Dependencies (34)Versions (17)Used By (4)

 [   ![Milpa](https://raw.githubusercontent.com/getmilpa/core/main/art/lockup/milpa-lockup-v-color-light.svg)  ](https://github.com/getmilpa)

Milpa Auth
==========

[](#milpa-auth)

> Runtime-native **identity vocabulary** for Milpa: the typed `Actor` / `AuthContext` / `AuthState` primitives, an opaque `Credential` with explicit redaction/refusal semantics, and the `CredentialVerifier`, `AuthContextFactory` and `SessionStore` contracts. The trusted producer of the context policies need — fail-closed by construction, zero framework, zero ORM.

[![CI](https://github.com/getmilpa/auth/actions/workflows/ci.yml/badge.svg)](https://github.com/getmilpa/auth/actions/workflows/ci.yml)[![Packagist](https://camo.githubusercontent.com/dd66e7cf1fbfe5a4d7b4e1162d83ac408b4ca7bc886a74055ff6285a4b4e2c78/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f6d696c70612f617574682e737667)](https://packagist.org/packages/milpa/auth)[![PHP](https://camo.githubusercontent.com/ca03f11ea27dac4dedc8ad56a7bdfc4a9ff5feb825055f9d2983616115076607/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f7068702d254532253839254135253230382e332d3737376262342e737667)](https://www.php.net/)[![License](https://camo.githubusercontent.com/798509b4df525f56802b56f8096862487f08023e3d7561c68656f8dab10d0d6e/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4170616368652d2d322e302d626c75652e737667)](LICENSE)[![Docs](https://camo.githubusercontent.com/c6dc6a3411e15b0ac7cc4583e8e6a8144181caedb82f5d98753353decda06d77/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f646f63732d4150492532307265666572656e63652d626c75652e737667)](https://getmilpa.github.io/auth/)

**Auth is not login. Auth is the trusted producer of the context policies need to stop lying.**

Authorization has three moving parts. A **carrier** holds a request's identity and scopes; an **enforcer** decides whether that identity may do the thing. Milpa already has both. What was missing is the **producer**: the piece that verifies a credential and *builds* a trusted identity in the first place. Without it, every transport asserts its own identity — a CLI hands out a wildcard, an HTTP route trusts a header it never checked — and the enforcer is authorizing against a context nobody verified.

`milpa/auth` is that producer's foundation: the typed vocabulary of identity, and the contracts a verifier and a session store implement. It defines **what** authorization needs to know about a caller — and nothing about **how** you store it.

Install
-------

[](#install)

```
composer require milpa/auth
```

The shape
---------

[](#the-shape)

Five types and three contracts, no more:

```
use Milpa\Auth\Actor;
use Milpa\Auth\ActorType;
use Milpa\Auth\AuthContext;
use Milpa\Auth\Credential;

// An Actor is a verified identity: who is acting, and what they may do.
$actor = new Actor(
    id: 'u-42',
    type: ActorType::User,          // User | Agent | Service — a closed set, because it is identity
    scopes: ['posts:read', 'posts:write'],
    claims: ['email' => 'ada@example.com'],
);

// An AuthContext is the trusted answer to "who is this, and may we trust it?"
$ctx = AuthContext::authenticated($actor);

$ctx->isAuthenticated();          // true
$ctx->hasScope('posts:read');     // true
$ctx->hasScope('posts:delete');   // false — fail-closed, an absent scope is denied
$ctx->hasAnyScope(['a', 'b']);    // false

// No credential? An anonymous context — distinct from a *rejected* one.
AuthContext::anonymous()->isAuthenticated();   // false
AuthContext::anonymous()->hasScope('*');       // false — no actor, so nothing is granted

// A bad credential? An invalid context, with the reason recorded — not the same as anonymous.
AuthContext::invalid('expired token')->state;  // AuthState::Invalid
```

### Fail-closed, and the one explicit wildcard

[](#fail-closed-and-the-one-explicit-wildcard)

`hasScope()` is an **exact string match**. A scope you do not hold is denied — there is no prefix, suffix, or glob magic. The single exception is the literal `'*'`, a **documented** wildcard for a superuser or a trusted process:

```
$agent = new Actor('bot-1', ActorType::Agent, scopes: ['*']);
$agent->hasScope('anything-at-all');   // true — '*' is the one explicit escape hatch

$scoped = new Actor('u-1', ActorType::User, scopes: ['posts:*']);
$scoped->hasScope('posts:read');       // false — 'posts:*' is NOT a wildcard, only bare '*' is
```

There is no magic wildcard. If a caller can do something, it is because a scope says so — exactly, or via the one `'*'` you granted on purpose.

### A `Credential` redacts and refuses the common leak paths

[](#a-credential-redacts-and-refuses-the-common-leak-paths)

A `Credential` is an opaque wrapper around a raw secret — the one thing that, printed to a log or an error, hands an attacker the keys. It uses the idiomatic modern-PHP shape for a secret-bearing value object: the value is a `private readonly` property marked `#[\SensitiveParameter]` (redacted from stack-trace argument dumps), `__debugInfo()` redacts it (so `print_r`/`var_dump` show `[redacted]`), `__serialize()` and `__clone()` refuse outright (a secret must never survive serialization nor be silently duplicated — both throw), and there is deliberately no `__toString()` (casting a Credential to a string is a bug and raises an `Error`).

```
$cred = Credential::bearer('the-real-token');

print_r($cred);              // ['type' => 'bearer', 'value' => '[redacted]']
json_encode($cred);          // {"type":"bearer"}  — the value is private, never serialised
serialize($cred);            // throws LogicException — a Credential must never be persisted
clone $cred;                 // throws LogicException — a Credential must never be duplicated
(string) $cred;              // Error — no __toString; a Credential is not a string

$cred->value();              // "the-real-token" — the one deliberate way out, for the verifier
```

Only an explicit `value()` call returns the secret. Treat any other appearance of it as a bug.

Two casual-dump surfaces — `var_export()` and the `(array)` cast — read an object's raw private properties directly and bypass `__debugInfo()`, so they *do* surface the value. Sealing them would require holding the secret outside the object (a closure or a `WeakMap`); Milpa deliberately keeps the plain, readable idiom instead, and the rule is simply: never `var_export()` or `(array)`-cast a Credential. The high-probability logging paths (`print_r`, `var_dump`, `json_encode`, exception messages and traces) are all sealed, and `serialize()` — the highest-risk persistence path — throws.

Permissions — the product matrix
--------------------------------

[](#permissions--the-product-matrix)

Scopes answer "what strings does this identity hold?" Permissions answer the question the product actually asks: "may this actor do this, on this — and *because of what*?" The grammar is one canonical key:

```
{namespace}.{resource}:{action}

```

`namespace` is optional — `crm.contact:update` and `posts:read` are both valid. `Permission::parse()`builds one from that string (fail-closed: a malformed key throws, `MILPA_PERMISSION_MALFORMED`); `Permission::of($resource, $action, $namespace)` builds one straight from its segments; `Permission::key()`returns the canonical string back.

Three layers, three separate jobs:

1. **Actor — the wire.** `$actor->roles` (role ids) and `$actor->scopes` (flat strings) are exactly what a `CredentialVerifier` hands you. An `Actor` knows nothing about a permission catalog.
2. **Resolver — the semantics.** A `PermissionResolver` (the reference `CatalogPermissionResolver`) expands those role ids against a `PermissionCatalog` and lifts flat scopes into `Permission`s, producing a `PermissionSet`. Every grant it holds carries a `PermissionSource` — the role id or scope string that produced it.
3. **AuthContext — the decision.** `AuthContext::can($resource, $action, $namespace = null)` checks the attached `PermissionSet` when one was resolved, and falls back to the flat-scope check otherwise — fail-closed either way, with no actor answering `false`.

That fallback is exact, and total-backward-compatible with scopes: for an actor with no roles and no resolved `PermissionSet`, `$ctx->can('posts', 'read') ≡ $ctx->hasScope('posts:read')` — permissions and scopes are the same check, letter for letter.

```
$catalog  = ArrayPermissionCatalog::fromArray([
    'roles' => ['editor' => ['permissions' => ['crm.contact:update']]],
]);
$resolver = new CatalogPermissionResolver($catalog);
$actor    = new Actor('u1', ActorType::User, roles: ['editor']);
$ctx      = AuthContext::authenticated($actor)->withPermissions($resolver->resolve($actor, PermissionContext::none()));

$ctx->can('contact', 'update', 'crm');   // true — via role "editor"
$ctx->permissions()?->sourcesOf(Permission::parse('crm.contact:update'))[0]->id;   // "editor"
```

Gate a route the same way `RequireScopeMiddleware` gates one — on a `Permission` instead of a raw scope string:

```
new RequirePermissionMiddleware(
    required: Permission::of('contact', 'update', 'crm'),
    resolver: $resolver,   // resolves once per request when no PermissionSet is attached yet
);
```

A request that authenticated but does not hold the required permission gets a `PermissionDeniedException`(403, `MILPA_PERMISSION_DENIED`) naming the key it was missing — never a silent 404, never a bare boolean with no explanation attached.

**Provenance, not just a verdict.** `PermissionSet::sourcesOf(Permission)` returns every `PermissionSource` that granted a permission — which role, which flat scope, in that order it was found — so an admin screen, or an incident review, can answer "why does this actor have this?" instead of only "do they?". `PermissionReport::of($actor, $context, $resolver)` snapshots that whole picture — actor, context, roles held, resolved set — as one Admin-readiness read model.

**Three non-goals, stated on purpose:**

- **No ABAC engine.** 2a ships structured RBAC-lite — roles and flat scopes, resolved and explained. Attribute-based rules have a seam (`Policy`, see [ADR 0002](docs/adr/0002-rbac-lite-not-abac.md)), but zero implementations ship in this package.
- **`'*'` stays the only wildcard.** `posts:*` is not a prefix match anywhere in this layer — it is a literal, unmatchable string. Grant the bare `'*'` only to an actor that should bypass every check.
- **Tenant membership is product policy — the default resolver is tenant-blind.**`CatalogPermissionResolver` threads `PermissionContext` through untouched; it never reads `$tenantId`. A host that needs "this role only inside this tenant" supplies its own `PermissionResolver`, or a `Policy`, rather than the leaf guessing at tenant conventions it cannot know.

Auth defines *what* it needs; storage decides *how*
---------------------------------------------------

[](#auth-defines-what-it-needs-storage-decides-how)

The producer's job is to turn a credential into an `AuthContext`. It should never own a database. So `milpa/auth` ships **contracts**, not backends:

ContractIts one job`CredentialVerifier``verify(Credential): AuthContext` — the producer itself: prove a credential, or reject it fail-closed.`AuthContextFactory``fromRequest(ServerRequestInterface): AuthContext` — the HTTP entry point a middleware calls once per request.`SessionStore``read` / `write` / `destroy` opaque, revocable, expiring sessions — **what** auth needs from storage, not how.A `SessionRecord` is the plain, storage-agnostic shape a `SessionStore` moves: who the session belongs to, what it grants, when it expires, whether it was revoked. It knows how to decide its own validity, and how to project itself back into an `Actor` — but nothing about where it is kept:

```
use Milpa\Auth\SessionRecord;

$record->isValid($now);   // false if expired as of $now, or revoked — fail-closed
$record->toActor();       // the live Actor the enforcer authorizes against
```

Because storage is a contract, a downstream package chooses the medium — a [`milpa/data`](https://github.com/getmilpa/data) backend, Redis, a database table — while `milpa/auth`stays a near-leaf primitive that reaches for none of them. **Auth defines what it needs from storage; data decides how.**

### The in-memory store, for tests and zero-file consumers

[](#the-in-memory-store-for-tests-and-zero-file-consumers)

`InMemorySessionStore` is the reference `SessionStore`: an array kept in process memory, nothing written to disk. It is **fail-closed on read** — an absent, expired, or revoked session all read as `null`, so a stale session can never resurrect an actor. Expiry is evaluated against an **injectable clock**, never the ambient wall clock, so time is deterministic and testable:

```
use Milpa\Auth\InMemorySessionStore;

$store = new InMemorySessionStore(fn () => new DateTimeImmutable('2026-01-01 00:00:00'));
$store->write($record);
$store->read($record->id);   // the record — or null if it is expired/revoked as of the clock
```

Passkeys
--------

[](#passkeys)

WebAuthn/passkey support does not live in this package. `CredentialType::Passkey` exists here only as a **vocabulary marker** — for logs, UI, and reports — never as a per-request credential a `CredentialVerifier` checks: a passkey ceremony is stateful and two-round-trip, the opposite of the single-shot shape `Credential`/`CredentialVerifier` are built for. The ceremony itself — the relying party, the challenge lifecycle, the credential store, and a `lbuchs/webauthn` adapter — ships in [`milpa/auth-webauthn`](https://github.com/getmilpa/auth-webauthn), one tier above this package. A verified assertion there produces proof, never a session directly; the host mints a `SessionRecord`from that proof, and this package's `SessionStore`/`StartSession` take it from there exactly as they would for any other login path.

Serving an operation over HTTP
------------------------------

[](#serving-an-operation-over-http)

An `Operation` (`milpa/command`) can declare `scopes` or a `permission`. `AuthOperationHttpPolicy` is what decides whether the caller may run it — the implementation of `milpa/command`'s `OperationHttpPolicy` contract, using the identity this package already provides:

```
use Milpa\Auth\Http\AuthOperationHttpPolicy;

$policy = new AuthOperationHttpPolicy($container, $psr17, $psr17);
$denial = $policy->enforce($operation, $request);   // null = go ahead
```

Scope-typed operations go through `RequireScopeMiddleware` and then — if `milpa/tool-runtime` is installed — the same `PolicyGate` that guards the MCP surface, with an honest `ToolContext::web`. Permission-typed ones go through `RequirePermissionMiddleware`. A denial comes back as a formed 401 or 403 rather than an exception, because an authorized refusal is not an error: it is the correct answer to asking.

A protected operation on a host that wired **no** auth chain throws `AuthMiddlewareNotInstalledException` (500), never a 4xx. The caller did nothing wrong — the host declared something protected and left it unguarded.

It lives here and not next to whatever projects operations to HTTP: it uses this package top to bottom, and putting it in a projector would drag identity into a framework floor meant to run without one.

Requirements
------------

[](#requirements)

- PHP **≥ 8.3**
- `psr/http-message`, `psr/http-factory`, `psr/http-server-middleware` and `psr/container` — the PSR interfaces the HTTP seam speaks. Interfaces only: this package never picks your PSR-7 implementation or your container.
- [`milpa/command`](https://packagist.org/packages/milpa/command) **^0.3.1** — where `Operation` and the `OperationHttpPolicy` contract live. It is the one `milpa/*` dependency, and it costs nothing: that package requires only PHP and a PSR message interface.
- Nothing else — no ORM, no framework, no HTTP implementation

Documentation
-------------

[](#documentation)

**Full API reference: [getmilpa.github.io/auth](https://getmilpa.github.io/auth/)** — generated straight from the source DocBlocks and dressed with the Milpa design system.

Contributing
------------

[](#contributing)

Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md). Please report security issues via [SECURITY.md](SECURITY.md), and note that this project follows a [Code of Conduct](CODE_OF_CONDUCT.md).

License
-------

[](#license)

[Apache-2.0](LICENSE) © Rodrigo Vicente - TeamX Agency.

---

Milpa is designed, built, and maintained by **[Rodrigo Vicente - TeamX Agency](https://teamx.agency/?utm_source=github&utm_medium=readme&utm_campaign=milpa&utm_content=auth)**.

###  Health Score

47

—

FairBetter than 93% of packages

Maintenance97

Actively maintained with recent releases

Popularity21

Limited adoption so far

Community14

Small or concentrated contributor base

Maturity47

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 73.9% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~2 days

Total

15

Last Release

16d ago

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/1993784?v=4)[rodrigomx](/maintainers/rodrigomx)[@rodrigomx](https://github.com/rodrigomx)

---

Top Contributors

[![rodrigoteamx](https://avatars.githubusercontent.com/u/269849276?v=4)](https://github.com/rodrigoteamx "rodrigoteamx (34 commits)")[![github-actions[bot]](https://avatars.githubusercontent.com/in/15368?v=4)](https://github.com/github-actions[bot] "github-actions[bot] (12 commits)")

---

Tags

authauthenticationauthorizationframeworkidentitymilpaphppsr-15rbacsecurityphpframeworksecurityAuthenticationidentitypsr-15authorizationrbacmilpa

###  Code Quality

TestsPHPUnit

Static AnalysisPHPStan

Code StylePHP CS Fixer

Type Coverage Yes

### Embed Badge

![Health badge](/badges/milpa-auth/health.svg)

```
[![Health](https://phpackages.com/badges/milpa-auth/health.svg)](https://phpackages.com/packages/milpa-auth)
```

###  Alternatives

[cakephp/cakephp

The CakePHP framework

8.9k20.4M1.9k](/packages/cakephp-cakephp)[typo3/cms

TYPO3 CMS is a free open source Content Management Framework initially created by Kasper Skaarhoj and licensed under GNU/GPL.

1.2k1.9M122](/packages/typo3-cms)[typo3/cms-core

TYPO3 CMS Core

3714.0M5.9k](/packages/typo3-cms-core)[tempest/framework

The PHP framework that gets out of your way.

2.3k42.4k21](/packages/tempest-framework)[symfony/symfony

The Symfony PHP framework

31.4k87.7M2.3k](/packages/symfony-symfony)[mcp/sdk

Model Context Protocol SDK for Client and Server applications in PHP

1.6k3.0M159](/packages/mcp-sdk)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
