Skip to content

fix(auth): api credentials survived revocation and creator removal - #246

Merged
roncodes merged 2 commits into
dev-v1.6.60from
fix/api-credential-revocation-fail-closed
Aug 28, 2026
Merged

fix(auth): api credentials survived revocation and creator removal#246
roncodes merged 2 commits into
dev-v1.6.60from
fix/api-credential-revocation-fail-closed

Conversation

@roncodes

Copy link
Copy Markdown
Member

Summary

Three defects on the API credential authentication path in AuthenticateOnceWithBasicAuth / Auth::setSession() / Expirable. Each one leaves a credential live that an operator has good reason to believe is dead. Taken together, a stock Fleetbase console has no working way to revoke an API credential: Delete doesn't revoke, and "expire immediately" doesn't expire.

Reported by a downstream deployment against 1.6.35; every reference below re-verified against main (1.6.59). Private tracker: FliitAU/fliit-extension#2212.


1. Soft-deleted credentials still authenticate

AuthenticateOnceWithBasicAuth.php:63-66 looks the credential up with withoutGlobalScopes(), which strips both of ApiCredential's global scopes — SoftDeletingScope (from the base Fleetbase\Models\Model) and ExpiryScope (from Expirable). Expiry is then re-applied in PHP at :94 via hasExpired(). Soft-deletion never was.

So a credential the console reports as Deleted keeps authenticating indefinitely, while the console hides the row from the UI. Delete is the only revocation most operators ever perform.

This is not theoretical: a downstream production database held a credential soft-deleted on 2026-06-12 with expires_at null, still authenticating 48 days later on stock behaviour. It was created, deleted as a mistake, and recreated under the same name — and credential names are not unique, so the console list gave the operator no way to tell the two rows apart.

Fix: reject trashed() credentials with the generic not valid 401, placed before the OPTIONS shortcut so a revoked key cannot seed api key session context on a preflight either. The message is deliberately not distinct from "no such key" so a caller cannot probe for revoked-but-real keys.

2. Authentication is fail-open when the creating user is gone

Auth::setSession() at src/Support/Auth.php:63-78. An API credential carries no identity of its own — it acts as the user that created it. When User::find() no longer resolves that user (deleted, or soft-deleted on off-boarding), the if ($user) guard is skipped so is_admin is never set — but setSession() still returns true.

Authorization degrades safely: a null user fails every group and admin check. Authentication does not. The key keeps working on every read endpoint and every ungated write. Off-boarding a person therefore does not revoke the authority of the keys they created.

Fix: setSession() returns false when the credential's user does not resolve, and the middleware answers a clean 401 — the same shape an expired credential gets. The session is no longer half-populated on the way out, and trackLastUsed() is not called for a rejected request.

Worth noting for review: User declares protected $connection = 'mysql' (src/Models/User.php:76), and sandbox:sync mirrors mysql → sandbox, so production is authoritative for users. Sandbox credentials therefore resolve their creator against the same authoritative store and are unaffected. tests/Unit/Http/MiddlewareContractsTest.php previously seeded sandbox-user-1 only on the sandbox connection, which did not reflect that; the fixture now mirrors it to mysql as the real system does, and the sandbox secret-key test still passes.

3. "Expire immediately" does not expire the credential

ApiCredential::setExpiresAtAttribute() maps the console's 'immediately' option to Carbon::now() (src/Models/ApiCredential.php:144), but Expirable::hasExpired() used a strict < (src/Traits/Expirable.php:91) — now() < now() is false.

ExpiryScope already disagreed with it: it keeps a row only while expires_at > now() (src/Scopes/ExpiryScope.php:26), i.e. it treats an exactly-now expiry as expired. The trait and the scope were on opposite sides of the same boundary.

Fix: hasExpired() is now inclusive (<=), which makes the two agree and makes "expire this key right now" actually take effect.


Not addressed here

  • Per-key roles / scopes. A credential still has no scope of its own — Auth::setSession() derives is_admin from whoever clicked "Create", so every key an admin creates is a full-admin key regardless of its name. Least privilege is only reachable indirectly, by scoping the creator into a dedicated service user. ApiCredential already uses HasPolicies and HasPermissions, so the plumbing partly exists but nothing on the auth path consults it. That's a feature, not a fix, and wants a design discussion — happy to open a separate issue.
  • Console expiry dropdown serializes every option to null. That one is in dev-engine (api-credential.js declares @attr('date') expires_at while the form assigns relative strings), not this repo.

Testing

php vendor/bin/pest1422 passed, 0 failed (9966 assertions). php-cs-fixer clean.

New coverage:

  • revoked credential rejected on a normal request, with no session context and no last_used_at write
  • revoked credential rejected on an OPTIONS preflight
  • credential whose creator has been soft-deleted rejected, with no session context and no last_used_at write
  • Auth::setSession() returns false for an orphaned credential (unit level, AuthSupportTest)
  • hasExpired() is true at an exactly-now expiry (LifecycleTraitsTest)

Compatibility

Auth::setSession() can now return false for an ApiCredential whose user is gone. The only in-tree caller that passes an ApiCredential is this middleware; the User branch is untouched. Any deployment currently relying on credentials outliving their creator will see those keys start returning 401 — which is the intent.

Three defects on the API credential authentication path, all of which left a
credential live that an operator had every reason to believe was dead.

1. Soft-deleted credentials still authenticated.
   AuthenticateOnceWithBasicAuth looks the credential up with
   withoutGlobalScopes(), which strips SoftDeletingScope along with ExpiryScope.
   Expiry was re-applied in PHP; soft-deletion never was. A credential the
   console reports as "Deleted" therefore kept authenticating indefinitely --
   and Delete is the only revocation most operators ever perform. Now rejected
   with the generic "not valid" 401, before the OPTIONS shortcut so a revoked
   key cannot seed api key session context on a preflight either.

2. Authentication was fail-open when the creating user was gone.
   A credential carries no identity of its own; it acts as the user that created
   it. When User::find() no longer resolved that user, the is_admin guard was
   skipped but setSession() still returned true. Authorization degraded safely
   -- a null user fails every group and admin check -- but authentication did
   not, so the key kept working on every read endpoint and every ungated write.
   Off-boarding a person did not revoke the keys they had created.
   Auth::setSession() now returns false in that case and the middleware answers
   401. Note User is pinned to the mysql connection, so this resolves against
   the authoritative store for sandbox credentials too.

3. "Expire immediately" did not expire the credential.
   ApiCredential::setExpiresAtAttribute() maps 'immediately' to Carbon::now(),
   but Expirable::hasExpired() used a strict `<`, so now() < now() was false.
   ExpiryScope already treats an exactly-now expiry as expired (it keeps a row
   only while expires_at > now()), so the two disagreed on that boundary.
   hasExpired() is now inclusive, which makes the trait and the scope agree.

Reported downstream against 1.6.35 and verified against main.
Private tracker: FliitAU/fliit-extension#2212
…aned

The coverage gate went red on AuthenticateOnceWithBasicAuth (57/58 statements):
the early `return;` in bindUserResolver() was previously reached only because
the sandbox fixture seeded sandbox-user-1 on the sandbox connection alone, so
User::find() came back null on mysql. Correcting that fixture to mirror the real
system — User is pinned to mysql and sandbox:sync copies mysql to sandbox — left
the guard unexercised.

bindUserResolver() is protected static, so a downstream middleware subclass can
call it with arguments neither in-tree call site produces. Cover the guard
directly through a harness subclass rather than reaching for
@codeCoverageIgnore, asserting both halves refuse to bind and that the positive
case still does.
@roncodes roncodes mentioned this pull request Aug 28, 2026
@roncodes
roncodes changed the base branch from main to dev-v1.6.60 August 28, 2026 07:02
@codecov

codecov Bot commented Aug 28, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (6317e91) to head (f62f093).
⚠️ Report is 1 commits behind head on dev-v1.6.60.

Additional details and impacted files
@@               Coverage Diff               @@
##             dev-v1.6.60      #246   +/-   ##
===============================================
  Coverage         100.00%   100.00%           
- Complexity          6730      6732    +2     
===============================================
  Files                397       397           
  Lines              22448     22455    +7     
===============================================
+ Hits               22448     22455    +7     
Flag Coverage Δ
backend 100.00% <100.00%> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@roncodes
roncodes merged commit 04bf02e into dev-v1.6.60 Aug 28, 2026
7 checks passed
@roncodes
roncodes deleted the fix/api-credential-revocation-fail-closed branch August 28, 2026 07:12
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