Skip to content

Abilities / scopes - #29

Merged
stsepelin merged 3 commits into
mainfrom
feat/abilities
Aug 22, 2026
Merged

stsepelin merged 3 commits into
mainfrom
feat/abilities

Conversation

@stsepelin

@stsepelin stsepelin commented Aug 21, 2026 •

Copy link
Copy Markdown
Owner

Coarse, stateless authorization in the access token's scope claim (RFC 6749 §3.3 / RFC 9068 §2.2.3), so an API gateway or a non-lukk verifier can read it without knowing about lukk.

Lukk::abilitiesUsing(fn ($userId, $context) => $user->permissionNames());

Route::middleware(['auth:api', 'lukk.ability:orders.read,orders.write'])   // ANY
Route::middleware(['auth:api', 'lukk.abilities:orders.read,orders.write']) // ALL

Inert until configured — without a callback no claim is minted and tokens stay byte-identical to 0.5.0 — and deny by default once it is.

Derived vs pinned

By default abilities are re-derived on every mint, so revoking one takes effect within access_ttl rather than lasting the life of the refresh token. A session can instead own a fixed grant, stored on refresh_tokens.scope and replayed verbatim through every rotation — what a personal access token or a capped impersonation session needs. NULL means derive, '' means pinned-to-nothing.

lukk's own routes

A token pinned to one ability is refused by every gated route in the application but could still log the account out everywhere, or step up and enrol a passkey. RequirePinnedAbility gates DELETE /auth/sessions* on lukk.sessions, and both step-up routes plus POST /auth/password and the passkey/recovery-code reads on lukk.account.

Only pinned tokens are gated, so upgrading breaks no working install. logout and refresh are never gated — a machine token must be able to end and renew itself.

Design decisions worth reviewing

  • Policy lives in the Actions, never the issuer. Resolving abilitiesUsing inside a documented swap seam meant rebinding TokenIssuer silently dropped scope, and with deny-by-default every gated route then 403'd with nothing to explain it.
  • Both consumer callbacks resolve before the rotate transaction opens, behind one reject short-circuit — no application code runs under the row lock. A slow lookup can't extend it, reverse lock order can't deadlock against it, and on PostgreSQL a swallowed SQL error can't leave the transaction aborted (where COMMIT degrades to a silent ROLLBACK and the client is logged out one refresh later).
  • Request-scoped, not model state. Sanctum's withAccessToken equivalent silently denies on a model the guard never touched, and answers from the previous token on one that outlives its request.
  • The grace window and reuse detection are untouched — a throwing permission store must never suppress a family revoke.

Notable fixes found in review

Eight review rounds; every finding is pinned by a regression test and mutation-verified.

  • Rows are written with forceFill — a useRefreshTokenModel subclass declaring $fillable dropped scope, turning a token pinned to one ability into the subject's full derived grant on first refresh, session management included, because familyIsPinned() reads the same unwritten column and collapsed alongside it.
  • assertGuardsIsolated() refuses to boot a lukk-jwt guard with no lukk.guards block — it inherited the default guard's secret and audience, so one guard's token authenticated as a different user under another's provider. Pre-existing since 0.4.0.
  • scope and pin are reserved claims in both directions; an empty grant can erase a hook's value.
  • features.abilities / features.gate_auth_routes resolve through the active guard's config — both fail open, so a dropped per-guard override was exploitable.
  • Lukk::actingAs with abilities throws outside tests: forgetScopedInstances() drops the resolved instance but not the binding, so the token would authorize the next visitor on the worker.

Verification

469 tests · 100% coverage · Pint clean · 5/5 concurrency against real PostgreSQL (two new tests pin that no application callback runs under the row lock) · 12 conformance flows against a live fixture built from this branch.

See UPGRADE.md for the migration, the two contract changes, and the HasAbilities → HasTokenAbilities rename.

Client half: stsepelin/lukk-js#50 · Docs: stsepelin/lukk-docs#10

Coarse, stateless authorization carried in the access token — the first of the
remaining roadmap items, and the one PATs and impersonation both build on.

`Lukk::abilitiesUsing(fn ($userId) => [...])` mints a `scope` claim;
`lukk.ability:a,b` gates on ANY, `lukk.abilities:a,b` on ALL (Sanctum's split,
so the semantics are the ones people already expect); `$user->tokenCan()` via an
optional `HasAbilities` trait.

Four decisions worth recording, because each had a defensible alternative.

The wire format is the registered `scope` claim, space-delimited (RFC 6749 §3.3,
RFC 9068 §2.2.3) rather than a bespoke `abilities` array. The package already
stamps `typ: at+jwt`, so a gateway or a non-lukk verifier can read authorization
without knowing anything about lukk.

DENY by default. A token with no `scope` grants nothing. The alternative —
absent means unrestricted, which is roughly Sanctum's shape — turns a forgotten
`abilitiesUsing` into a route gate that silently passes everyone, and a
permission check that fails open is worse than one that fails loudly.

Inert until configured, though: with no callback set, no claim is minted at all
and tokens stay byte-identical to 0.5.0. So the deny-by-default only starts
applying once someone has opted in, and there is no flag to add.

Re-derived on every refresh rather than frozen at login. Revoking an ability
then takes effect within access_ttl instead of lasting the life of the refresh
token — better than storing it on the family row, and it needs no migration.

A wildcard only ever appears in the GRANT. `orders.*` grants the namespace, but
`tokenCan('orders.*')` asks whether that literal ability was granted — otherwise
anyone holding one narrow ability could widen their own question. `orders.*`
also deliberately does not cover the bare `orders`, which would make the two
indistinguishable to anyone reading a policy.

Abilities are scoped to the TOKEN, not the user: the same person on two devices
may hold tokens granting different things. The guard populates them per request,
so a model that never went through the guard reports nothing rather than
whatever the last request carried.
Comment thread src/Support/Abilities.php Outdated
Reworks the two design decisions in the abilities feature and fixes the
enforcement defects the review pass turned up.

`abilitiesUsing` now receives a `TokenContext` (guard + family id) as its
second argument. A multi-guard install serves different audiences often
enough that `['*']` for a customer token and `['*']` for an admin one are
not the same grant; an object rather than more positional parameters means
the next field added won't break closures already written.

A session can now own a FIXED grant. `refresh_tokens` gains a nullable
`scope` column: NULL derives per mint (unchanged, still the default), a
value is replayed verbatim through every rotation. That is what a personal
access token or a capped impersonation session needs, and it is why the
column is optional rather than the norm — derived mode is what keeps a
revoked ability expiring within `access_ttl`.

`$user->tokenCan()` reads a request-scoped `VerifiedToken` instead of state
on the model. Sanctum's model-state equivalent has two failure modes: a
model the guard never touched (`$order->user`, a fresh `find()`) silently
denies what the token was granted, and a model that outlives its request
answers from the previous token's grant. Identity is matched on class AND
id, so Admin #7 cannot read User #7's grant.

Enforcement:

- The gates are registered after `Authenticate` in the kernel priority.
  `Authenticate` is in that list and the gates were not, so
  `['lukk.ability:x', 'auth:api']` gated before authenticating and answered
  401 for a perfectly good token.
- No credential at the gate is 401, not 403. A 403 tells a client "you are
  known and refused", so it stops retrying and never learns it just needs
  to log in. Authenticated-but-short stays 403, now with
  `WWW-Authenticate: Bearer error="insufficient_scope", scope="..."`
  (RFC 6750 §3.1) naming what would have sufficed.
- `RequireAllAbilities` no longer extends `RequireAbility` — an ALL gate is
  not a kind of ANY gate, and the inheritance let the wrong alias pick up
  an override. Shared `AuthorizesAbilities` trait instead.
- The required-list filter keeps `'0'`; bare `array_filter` dropped it,
  which silently REMOVES a requirement from an ALL list.
- An empty required list throws instead of denying. `lukk.ability:` with no
  arguments is a route-definition bug, and a 403 hides it as a permissions
  problem.

`TokenIssuer::accessToken` and `RefreshTokenRepository::persist` each gained
one optional parameter. Callers are unaffected; a custom implementation must
match. Documented in UPGRADE.md with the migration and the reserved-`scope`
note.

393 tests, 100% coverage. Every security-relevant test is mutation-verified:
gate-reads-user-not-token, client-supplied scope merged in, case-insensitive
matching, the stored grant dropped on the SECOND rotation, a claims hook
forging `scope`, a malformed grant dropped instead of rejected, the priority
registration removed, 401 downgraded to 403, the challenge header dropped,
and an assumed token overriding a real verified one — 14 mutants, 14 killed.
Coarse, stateless authorization in the access token's `scope` claim
(RFC 6749 §3.3 / RFC 9068 §2.2.3), so an API gateway or a non-lukk
verifier can read it without knowing about lukk.

`Lukk::abilitiesUsing(fn ($userId, $context) => [...])` decides what a
user's tokens may do; `lukk.ability:a,b` gates a route on ANY of them and
`lukk.abilities:a,b` on ALL; `$user->tokenCan()` answers the same question
in application code. Inert until configured — without a callback no claim
is minted and tokens stay byte-identical to 0.5.0 — and deny by default
once it is, because a permission check that passes when nothing was
configured is worse than one that fails loudly.

DERIVED VS PINNED. By default abilities are re-derived on every mint, so
revoking one takes effect within `access_ttl` rather than lasting the life
of the refresh token. A session can instead OWN a fixed grant, stored on
`refresh_tokens.scope` and replayed verbatim through every rotation —
what a personal access token or a capped impersonation session needs.
`NULL` means derive and `''` means pinned-to-nothing; conflating them let
the most restricted token there is widen to the subject's full grant.

POLICY LIVES IN THE ACTIONS. `FirebaseTokenIssuer` stamps the grant it is
handed and derives nothing: resolving `abilitiesUsing` inside a documented
swap seam meant rebinding `TokenIssuer` silently dropped `scope`, and with
deny-by-default every gated route then 403'd with nothing to explain it.
Both consumer callbacks (`abilitiesUsing` and `tokenClaimsUsing`) are
resolved BEFORE the rotate transaction opens, behind one reject
short-circuit, so no application code runs under the row lock — a slow
lookup cannot extend it, reverse lock order cannot deadlock against it,
and on PostgreSQL a swallowed SQL error cannot leave the transaction
aborted, where COMMIT degrades to a silent ROLLBACK and the client
discovers it is logged out one refresh later.

REQUEST-SCOPED, NOT MODEL STATE. `$user->tokenCan()` reads a
`VerifiedToken` on the request. Sanctum's model-state equivalent has two
failure modes this avoids: a model the guard never touched silently denies
what the token was granted, and a model that outlives its request answers
from the previous token's grant. Identity is matched on class AND id, with
the active guard breaking a tie and ambiguity denying.

LUKK'S OWN ROUTES. A token pinned to one ability is refused by every gated
route in the application but could still log the account out everywhere,
or step up and enrol a passkey. `RequirePinnedAbility` gates
`DELETE /auth/sessions*` on `lukk.sessions`, and both step-up routes plus
`POST /auth/password` and the passkey/recovery-code reads on
`lukk.account`. Only PINNED tokens are gated — a derived grant is a live
human login — so upgrading breaks no working install. `logout` and
`refresh` are never gated: a machine token must be able to end and renew
itself.

Hardening that came out of eight review rounds, each pinned by a
regression test and mutation-verified:

- `scope` and `pin` are reserved claims, unset unconditionally before the
  conditional set, so a `tokenClaimsUsing` hook can neither forge one nor
  survive one — and an empty grant can ERASE a hook's value.
- Grants are validated against the RFC scope-token charset and bounded
  (128 B each, 2048 B per claim). A space would split one ability into
  two; a comma is the route gate's own separator; an unbounded claim makes
  every request fail at the proxy.
- Rows are written with `forceFill`. Mass assignment respects the
  consumer's `$fillable`, and a `useRefreshTokenModel` subclass that
  dropped `scope` turned a pinned token into the subject's full grant on
  first refresh — while `familyIsPinned()`, reading the same unwritten
  column, collapsed with it.
- `assertGuardsIsolated()` refuses to boot a `lukk-jwt` guard with no
  `lukk.guards` block. It inherited the default guard's secret AND
  audience, so one guard's token authenticated as a different user under
  another's provider. Pre-existing since 0.4.0.
- `features.abilities` and `features.gate_auth_routes` resolve through the
  active guard's config; both fail open, so a dropped per-guard override
  was exploitable.
- `Lukk::actingAs` with abilities throws outside the test environment:
  `forgetScopedInstances()` drops the resolved instance but not the
  binding, so the token would authorize the next visitor on the worker.

469 tests, 100% coverage, 5/5 concurrency against real PostgreSQL.
@stsepelin stsepelin changed the title feat: abilities / scopes Abilities / scopes Aug 22, 2026
@stsepelin
stsepelin merged commit e8b5257 into main Aug 22, 2026
13 checks passed
@stsepelin
stsepelin deleted the feat/abilities branch August 22, 2026 10:45
@stsepelin stsepelin mentioned this pull request Aug 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant