Skip to content

fix(discovery): expired bearer tokens fail closed on public endpoints (F-015) - #14

Merged
LuanTrindade95 merged 3 commits into
mainfrom
fix/expired-bearer-fail-closed
Sep 23, 2026
Merged

LuanTrindade95 merged 3 commits into
mainfrom
fix/expired-bearer-fail-closed

Conversation

@LuanTrindade95

Copy link
Copy Markdown
Owner

Closes F-015, the gap ADR-31 recorded on purpose instead of fixing inside a refactor: the public discovery endpoints resolve the optional bearer outside the auth:sanctum guard, and PersonalAccessToken::findToken() does not check expires_at, so an expired token still granted its owner's scope.

What changed

  • App\Services\Auth\OptionalBearerUserResolver short-circuits between findToken() and the tokenable check: a resolved token whose expires_at is past returns an unpersisted User — the same empty scope as any non-empty bearer that fails to resolve, never the public scope.
  • A token with no expires_at is untouched and still resolves to its owner.
  • The characterization case that locked the old behavior was inverted in the same commit, not deleted, and renamed. Added: a token expired five seconds ago, a token with no expires_at, and a case pinning the other seven header cases as unchanged.
  • ADR-32 in docs/DECISIONS.md; F-015 closed in the audit; the brain updated.

Only the expired-token row of the 8-case matrix moves, from owner to empty scope.

Deliberately not changed

config('sanctum.expiration') stays null, so a token issued without an explicit expires_at still never lapses, on every surface. Setting a lifetime changes login and session behavior through the guard — a product decision, queued in NEXT_ACTIONS.md. Routes, private middleware, policies and DiscoveryAccess untouched.

Evidence

Independent adversarial audit: APPROVED. It probed the resolver inside the container against a copy of main's class, case by case, and proved the expires_at null edge by execution rather than by reading ?->. It also showed the test bites: with main's resolver and this branch's test file, exactly the two expired-token cases fail (Expected response status code [404] but received 200) while the other ten pass. The inverted assertion is stronger than the one it replaces, because 404 on the owner's own plugin separates empty scope from both owner (200) and public (200).

Assertion inventory main → branch: nothing shrank (assertNotFound 11 → 18, assertOk 17 → 20, the rest equal). No skip, markTestSkipped, ->todo(, or xit(.

Gates

  • ./vendor/bin/pest — 100 passed, on a rebuilt image whose file hashes were confirmed against the branch.
  • ./vendor/bin/pint --test — PASS, 134 files.
  • Frontend gates are PENDING here: this branch is backend-only.

🤖 Generated with Claude Code

LuanTrindade95 and others added 3 commits September 23, 2026 10:18
OptionalBearerUserResolver resolved bearer tokens via
PersonalAccessToken::findToken() outside the auth:sanctum guard, which
never checks expires_at. A token expired in the past kept granting its
owner's community scope on the public catalog and search endpoints,
defeating token expiry as a revocation mechanism there (ADR-31 gap,
tracked as F-015).

The resolver now treats a resolved-but-expired token the same as any
other bearer that fails to resolve: an empty, unpersisted User (empty
scope), never the owner's scope and never the anonymous public scope
(ADR-23). A token without expires_at is unaffected and keeps resolving
to its owner, since sanctum.expiration stays out of scope here.

The characterization test that locked in the old, wrong behavior is
inverted in this same commit to assert the new empty-scope outcome,
and gains coverage for a token that expired seconds ago, a token with
no expires_at, and confirmation that the other 7 header cases are
unchanged. An unrelated pre-existing unused import in the same test
file is also removed to keep Pint green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@LuanTrindade95
LuanTrindade95 merged commit b0504ae into main Sep 23, 2026
4 checks passed
@LuanTrindade95
LuanTrindade95 deleted the fix/expired-bearer-fail-closed branch September 23, 2026 13:29
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