diff --git a/src/Http/Middleware/AuthenticateOnceWithBasicAuth.php b/src/Http/Middleware/AuthenticateOnceWithBasicAuth.php index 52a33363..43cc16be 100644 --- a/src/Http/Middleware/AuthenticateOnceWithBasicAuth.php +++ b/src/Http/Middleware/AuthenticateOnceWithBasicAuth.php @@ -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 @@ -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. // diff --git a/src/Support/Auth.php b/src/Support/Auth.php index bed5531a..e1dad804 100644 --- a/src/Support/Auth.php +++ b/src/Support/Auth.php @@ -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(); diff --git a/src/Traits/Expirable.php b/src/Traits/Expirable.php index b32aae92..0177a63f 100644 --- a/src/Traits/Expirable.php +++ b/src/Traits/Expirable.php @@ -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; diff --git a/tests/Unit/Http/MiddlewareContractsTest.php b/tests/Unit/Http/MiddlewareContractsTest.php index e47eae99..96027c7b 100644 --- a/tests/Unit/Http/MiddlewareContractsTest.php +++ b/tests/Unit/Http/MiddlewareContractsTest.php @@ -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; @@ -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'], @@ -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'], @@ -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(); diff --git a/tests/Unit/Support/AuthSupportTest.php b/tests/Unit/Support/AuthSupportTest.php index 8b10f0e4..4aa4c7cd 100644 --- a/tests/Unit/Support/AuthSupportTest.php +++ b/tests/Unit/Support/AuthSupportTest.php @@ -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(); diff --git a/tests/Unit/Traits/LifecycleTraitsTest.php b/tests/Unit/Traits/LifecycleTraitsTest.php index 5722163b..04231dcb 100644 --- a/tests/Unit/Traits/LifecycleTraitsTest.php +++ b/tests/Unit/Traits/LifecycleTraitsTest.php @@ -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')