Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion src/Http/Middleware/AuthenticateOnceWithBasicAuth.php
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,18 @@ public function authenticatedWithBasic(Request $request, $connection = null)
return response()->error('Oops! The api credentials provided were not valid', 401);
}

// Credentials have been revoked.
//
// withoutGlobalScopes() above strips SoftDeletingScope along with ExpiryScope, so
// the lookup deliberately sees deleted rows. Expiry is re-applied in PHP below, but
// soft-deletion never was — a credential the console reports as "Deleted" kept
// authenticating indefinitely, and Delete was the only revocation most operators
// ever performed. Treated as "not valid" rather than a distinct message so a caller
// cannot distinguish a revoked key from one that never existed.
if ($apiCredential->trashed()) {
return response()->error('Oops! The api credentials provided were not valid', 401);
}

// If OPTIONS set api key and continue
if ($request->isMethod('OPTIONS')) {
// Set api credential session
Expand All @@ -96,7 +108,13 @@ public function authenticatedWithBasic(Request $request, $connection = null)
}

// Login user
Auth::setSession($apiCredential);
//
// Fails when the credential's creating user no longer resolves — the credential has
// no identity to act as, so the request is rejected rather than continuing with a
// half-populated session.
if (Auth::setSession($apiCredential) !== true) {
return response()->error('Oops! The api credentials provided were not valid', 401);
}

// Bind the user resolver so $request->user() answers on the public API.
//
Expand Down
23 changes: 17 additions & 6 deletions src/Support/Auth.php
Original file line number Diff line number Diff line change
Expand Up @@ -62,15 +62,26 @@ public static function setSession($user = null, $login = false): bool

if ($user instanceof ApiCredential) {
$apiCredential = $user;
session(['company' => $apiCredential->company_uuid, 'user' => $apiCredential->user_uuid]);
// user couldn't be loaded, fallback with api credential if applicable
$user = User::find($apiCredential->user_uuid);

// Set is admin if user of api credential is admin
if ($user) {
session(['is_admin' => $user->isAdmin()]);
// An API credential carries no identity of its own — it acts as the user that
// created it. When that user no longer resolves (hard or soft deleted) there is
// no identity to run as, so authentication must fail closed.
//
// This previously fell through and returned true with `is_admin` simply never
// set. Authorization degraded safely, but authentication did not: the key kept
// working on every read endpoint and every ungated write, so off-boarding a
// person did not revoke the keys they had created.
$user = User::find($apiCredential->user_uuid);
if (!$user instanceof User) {
return false;
}

session([
'company' => $apiCredential->company_uuid,
'user' => $apiCredential->user_uuid,
'is_admin' => $user->isAdmin(),
]);

// track last usage of api credential
$apiCredential->trackLastUsed();

Expand Down
7 changes: 6 additions & 1 deletion src/Traits/Expirable.php
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,12 @@ public function hasExpired()
$column = $this->getExpiredAtColumn();

if (is_object($this->{$column})) {
return $this->{$column} < Carbon::now();
// Inclusive, to agree with ExpiryScope, which keeps a row only while
// `expires_at > now()` and therefore already treats an exactly-now expiry as
// expired. A strict `<` here disagreed with the scope on that boundary, which
// is precisely the value ApiCredential writes for the console's "immediately"
// option (Carbon::now()) — so "expire this key right now" left it valid.
return $this->{$column} <= Carbon::now();
}

return false;
Expand Down
139 changes: 139 additions & 0 deletions tests/Unit/Http/MiddlewareContractsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,14 @@ class MiddlewareContractsHeaders extends SetGlobalHeaders
protected array $except = ['health'];
}

class MiddlewareContractsBasicAuthHarness extends AuthenticateOnceWithBasicAuth
{
public static function bindUserResolverPublic(?Request $request, $user): void
{
static::bindUserResolver($request, $user);
}
}

class MiddlewareContractsCustomMiddlewareHarness
{
use Fleetbase\Traits\CustomMiddleware;
Expand Down Expand Up @@ -348,6 +356,16 @@ function middleware_contracts_basic_auth_database(): Capsule
['uuid' => 'sanctum-user-invalid-company', 'company_uuid' => 'company-1', 'type' => 'user'],
['uuid' => 'sanctum-user-valid-company', 'company_uuid' => '550e8400-e29b-41d4-a716-446655440000', 'type' => 'user'],
['uuid' => 'sanctum-user-token-fallback', 'company_uuid' => '550e8400-e29b-41d4-a716-446655440001', 'type' => 'driver'],
// The User model is pinned to the mysql connection (User::$connection), and
// production is authoritative for users — the sandbox schema only holds a
// mirror maintained by sandbox:sync. So a sandbox credential's creator is
// resolved here, not on the sandbox connection.
['uuid' => 'sandbox-user-1', 'company_uuid' => 'sandbox-company-1', 'type' => 'admin'],
]);
// An off-boarded creator: the row still exists, but soft-deleted, so User::find()
// no longer resolves it.
$db->table('users')->insert([
['uuid' => 'user-gone', 'company_uuid' => 'company-1', 'type' => 'admin', 'deleted_at' => '2026-07-19 00:00:00'],
]);
$db->table('companies')->insert([
['uuid' => 'company-1', 'owner_id' => 'user-1', 'owner_uuid' => 'user-1'],
Expand All @@ -359,6 +377,12 @@ function middleware_contracts_basic_auth_database(): Capsule
['uuid' => 'credential-expired', 'user_uuid' => 'user-1', 'company_uuid' => 'company-1', 'name' => 'Expired', 'key' => 'flb_live_expired', 'secret' => '$expired_secret', 'test_mode' => 0, 'last_used_at' => null, 'expires_at' => '2020-01-01 00:00:00', 'created_at' => '2026-07-18 00:00:00', 'updated_at' => '2026-07-18 00:00:00'],
['uuid' => 'credential-sanctum', 'user_uuid' => 'sanctum-user-valid-company', 'company_uuid' => '550e8400-e29b-41d4-a716-446655440000', 'name' => 'Sanctum', 'key' => 'flb_live_sanctum', 'secret' => '$sanctum_secret', 'test_mode' => 0, 'last_used_at' => null, 'expires_at' => null, 'created_at' => '2026-07-18 00:00:00', 'updated_at' => '2026-07-18 00:00:00'],
]);
// Revoked (soft-deleted, and carrying no expiry) and orphaned (its creating user
// has been off-boarded) credentials.
$db->table('api_credentials')->insert([
['uuid' => 'credential-revoked', 'user_uuid' => 'user-1', 'company_uuid' => 'company-1', 'name' => 'Revoked', 'key' => 'flb_live_revoked', 'secret' => '$revoked_secret', 'test_mode' => 0, 'last_used_at' => null, 'expires_at' => null, 'created_at' => '2026-07-18 00:00:00', 'updated_at' => '2026-07-18 00:00:00', 'deleted_at' => '2026-07-19 00:00:00'],
['uuid' => 'credential-orphaned', 'user_uuid' => 'user-gone', 'company_uuid' => 'company-1', 'name' => 'Orphaned', 'key' => 'flb_live_orphaned', 'secret' => '$orphaned_secret', 'test_mode' => 0, 'last_used_at' => null, 'expires_at' => null, 'created_at' => '2026-07-18 00:00:00', 'updated_at' => '2026-07-18 00:00:00', 'deleted_at' => null],
]);
$db->table('personal_access_tokens')->insert([
['id' => 1, 'tokenable_type' => FleetbaseUser::class, 'tokenable_id' => 'sanctum-user-invalid-company', 'name' => 'invalid-company', 'token' => hash('sha256', 'plain-invalid-company-token'), 'abilities' => json_encode(['*']), 'created_at' => '2026-07-18 00:00:00', 'updated_at' => '2026-07-18 00:00:00'],
['id' => 2, 'tokenable_type' => FleetbaseUser::class, 'tokenable_id' => 'sanctum-user-valid-company', 'name' => 'valid-company', 'token' => hash('sha256', 'plain-valid-company-token'), 'abilities' => json_encode(['*']), 'created_at' => '2026-07-18 00:00:00', 'updated_at' => '2026-07-18 00:00:00'],
Expand Down Expand Up @@ -1110,6 +1134,121 @@ function () use (&$expiredContinued) {
]);
});

test('basic auth middleware rejects revoked credentials', function () {
// The credential lookup uses withoutGlobalScopes(), which strips SoftDeletingScope
// along with ExpiryScope. Expiry is re-applied in PHP; soft-deletion was not, so a
// credential the console reports as "Deleted" authenticated indefinitely. It also
// carries no expiry here, so nothing else could catch it.
$capsule = middleware_contracts_basic_auth_database();
session()->flush();

$request = Request::create('/v1/orders', 'GET', [], [], [], [
'HTTP_AUTHORIZATION' => 'Bearer flb_live_revoked',
]);
$continued = false;
$response = (new AuthenticateOnceWithBasicAuth())->handle(
$request,
function () use (&$continued) {
$continued = true;

return new JsonResponse(['ok' => true]);
}
);

expect($continued)->toBeFalse()
->and($response->getStatusCode())->toBe(401)
->and($response->getData(true))->toBe([
'errors' => ['Oops! The api credentials provided were not valid'],
])
->and(session('api_credential'))->toBeNull()
->and(session('user'))->toBeNull()
->and($capsule->getConnection('mysql')->table('api_credentials')->where('uuid', 'credential-revoked')->value('last_used_at'))->toBeNull();
});

test('basic auth middleware rejects revoked credentials on preflight requests', function () {
// Rejected before the OPTIONS shortcut, so a revoked key cannot seed api key
// session context on a preflight either.
middleware_contracts_basic_auth_database();
session()->flush();

$request = Request::create('/v1/orders', 'OPTIONS', [], [], [], [
'HTTP_AUTHORIZATION' => 'Bearer flb_live_revoked',
]);
$continued = false;
$response = (new AuthenticateOnceWithBasicAuth())->handle(
$request,
function () use (&$continued) {
$continued = true;

return new JsonResponse(['preflight' => true]);
}
);

expect($continued)->toBeFalse()
->and($response->getStatusCode())->toBe(401)
->and(session('api_credential'))->toBeNull();
});

test('basic auth middleware fails closed when the credential creator no longer resolves', function () {
// A credential acts as the user that created it. Once that user is soft-deleted
// there is no identity to run as, and the request must be rejected — previously
// `is_admin` was simply never set and authentication still succeeded, so
// off-boarding a person left every key they had created working.
$capsule = middleware_contracts_basic_auth_database();
session()->flush();

$request = Request::create('/v1/orders', 'GET', [], [], [], [
'HTTP_AUTHORIZATION' => 'Bearer flb_live_orphaned',
]);
$continued = false;
$response = (new AuthenticateOnceWithBasicAuth())->handle(
$request,
function () use (&$continued) {
$continued = true;

return new JsonResponse(['ok' => true]);
}
);

expect($continued)->toBeFalse()
->and($response->getStatusCode())->toBe(401)
->and($response->getData(true))->toBe([
'errors' => ['Oops! The api credentials provided were not valid'],
])
->and(session('user'))->toBeNull()
->and(session('company'))->toBeNull()
->and(session('api_credential'))->toBeNull()
->and($capsule->getConnection('mysql')->table('api_credentials')->where('uuid', 'credential-orphaned')->value('last_used_at'))->toBeNull();
});

test('basic auth middleware user resolver binding refuses incomplete arguments', function () {
// bindUserResolver() is protected static, so a downstream middleware subclass can
// call it with whatever it has. Neither in-tree call site can reach this guard --
// the sanctum path checks `tokenable instanceof User` first, and the credential
// path now fails closed before it -- but the guard still has to hold for callers
// that are not this class.
middleware_contracts_basic_auth_database();

$request = Request::create('/v1/orders', 'GET');
$user = new FleetbaseUser();
$user->setRawAttributes(['uuid' => 'user-1'], true);

// No request to bind onto.
MiddlewareContractsBasicAuthHarness::bindUserResolverPublic(null, $user);

// No user to bind -- must not install a resolver that yields null, which would
// shadow a guard that resolves the user later in the stack.
MiddlewareContractsBasicAuthHarness::bindUserResolverPublic($request, null);

expect($request->user())->toBeNull();

// The positive case still binds, so the guard is not simply rejecting everything.
MiddlewareContractsBasicAuthHarness::bindUserResolverPublic($request, $user);

expect($request->user())->toBeInstanceOf(FleetbaseUser::class)
->and($request->user()->uuid)->toBe('user-1');
});

test('basic auth middleware falls back to sandbox for sdk secret keys', function () {
middleware_contracts_basic_auth_database();
session()->flush();
Expand Down
15 changes: 15 additions & 0 deletions tests/Unit/Support/AuthSupportTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -688,6 +688,21 @@ function auth_support_request(string $method = 'GET', ?string $controllerClass =
->and(Auth::getApiKey()->uuid)->toBe($credential->uuid);
});

test('auth support fails closed when an api credential creator no longer resolves', function () {
[$admin, , $credential] = auth_support_fixtures();

// Off-board the creating user. A credential has no identity of its own — it acts as
// its creator — so there is nothing left for it to run as. This used to return true
// with `is_admin` merely unset, leaving the key live on every read endpoint.
app('db')->table('users')->where('uuid', $admin->uuid)->update(['deleted_at' => '2026-07-17 11:00:00']);

expect(Auth::setSession($credential))->toBeFalse()
->and(session('user'))->toBeNull()
->and(session('company'))->toBeNull()
->and(session('is_admin'))->toBeNull()
->and(app('db')->table('api_credentials')->where('uuid', $credential->uuid)->value('last_used_at'))->toBeNull();
});

test('auth support returns null when no api credential session exists', function () {
auth_support_fixtures();

Expand Down
9 changes: 8 additions & 1 deletion tests/Unit/Traits/LifecycleTraitsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -282,8 +282,15 @@ function lifecycle_traits_uuid_database(): Capsule
$expired = new LifecycleTraitsExpirableRecord([
'expires_at' => Carbon::now()->subMinute(),
]);
// ExpiryScope keeps a row only while `expires_at > now()`, so an exactly-now expiry is
// already excluded from queries — hasExpired() has to agree. This is the value written
// for the console's "expire immediately" option.
$expiredNow = new LifecycleTraitsExpirableRecord([
'expires_at' => Carbon::now(),
]);

expect($active->hasExpired())->toBeFalse()
expect($expiredNow->hasExpired())->toBeTrue()
->and($active->hasExpired())->toBeFalse()
->and($active->timeToLive())->toBe(300)
->and($active->expiresAtTimestamp())->toBe(Carbon::now()->addMinutes(5)->timestamp)
->and($active->getExpiredAtColumn())->toBe('expires_at')
Expand Down
Loading