Abilities / scopes - #29
Merged
Merged
Conversation
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.
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.
This was referenced Aug 22, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Coarse, stateless authorization in the access token's
scopeclaim (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.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_ttlrather than lasting the life of the refresh token. A session can instead own a fixed grant, stored onrefresh_tokens.scopeand replayed verbatim through every rotation — what a personal access token or a capped impersonation session needs.NULLmeans 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.
RequirePinnedAbilitygatesDELETE /auth/sessions*onlukk.sessions, and both step-up routes plusPOST /auth/passwordand the passkey/recovery-code reads onlukk.account.Only pinned tokens are gated, so upgrading breaks no working install.
logoutandrefreshare never gated — a machine token must be able to end and renew itself.Design decisions worth reviewing
abilitiesUsinginside a documented swap seam meant rebindingTokenIssuersilently droppedscope, and with deny-by-default every gated route then 403'd with nothing to explain it.COMMITdegrades to a silentROLLBACKand the client is logged out one refresh later).withAccessTokenequivalent silently denies on a model the guard never touched, and answers from the previous token on one that outlives its request.Notable fixes found in review
Eight review rounds; every finding is pinned by a regression test and mutation-verified.
forceFill— auseRefreshTokenModelsubclass declaring$fillabledroppedscope, turning a token pinned to one ability into the subject's full derived grant on first refresh, session management included, becausefamilyIsPinned()reads the same unwritten column and collapsed alongside it.assertGuardsIsolated()refuses to boot alukk-jwtguard with nolukk.guardsblock — 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.scopeandpinare reserved claims in both directions; an empty grant can erase a hook's value.features.abilities/features.gate_auth_routesresolve through the active guard's config — both fail open, so a dropped per-guard override was exploitable.Lukk::actingAswith 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.mdfor the migration, the two contract changes, and theHasAbilities→HasTokenAbilitiesrename.Client half: stsepelin/lukk-js#50 · Docs: stsepelin/lukk-docs#10