From 84f6562e99b4fae6f5fd53232b44d747b0f3ed5b Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 24 Oct 2026 19:24:56 +0800 Subject: [PATCH 1/7] feat(app-review): App Review access for apps built on Fleetbase Apple and Google reviewers can't receive a texted sign-in code. Extensions register what their app signs reviewers in to (a Storefront store, an organization's Navigator app) with a review number from the 555-0100-0199 range reserved for fiction; the owner turns access on in the Console and gives the app store that number and a six-digit code, stored hashed and accepted only for that subject. New review_accesses table, Fleetbase\Support\AppReview, and GET|PUT int/v1/review-access/{type}/{id}. Numbers in the fictional range are never texted: SmsService answers as sent without calling a provider, and SMS verification codes are saved without reserving or sending. Tests that sent to 555-01xx numbers use other test numbers. --- ...24_000000_create_review_accesses_table.php | 48 +++ .../Internal/v1/ReviewAccessController.php | 104 +++++ src/Models/ReviewAccess.php | 66 +++ src/Models/VerificationCode.php | 10 +- src/Services/SmsService.php | 9 + src/Support/AppReview.php | 381 ++++++++++++++++++ src/routes.php | 3 + .../SettingControllerExternalProbesTest.php | 24 +- .../Http/SettingControllerServicesTest.php | 10 +- .../Unit/Models/VerificationCodeGuardTest.php | 13 + tests/Unit/SmsServiceGuardTest.php | 10 + tests/Unit/Support/AppReviewTest.php | 298 ++++++++++++++ tests/Unit/Support/TwoFactorAuthTest.php | 2 +- 13 files changed, 958 insertions(+), 20 deletions(-) create mode 100644 migrations/2026_10_24_000000_create_review_accesses_table.php create mode 100644 src/Http/Controllers/Internal/v1/ReviewAccessController.php create mode 100644 src/Models/ReviewAccess.php create mode 100644 src/Support/AppReview.php create mode 100644 tests/Unit/Support/AppReviewTest.php diff --git a/migrations/2026_10_24_000000_create_review_accesses_table.php b/migrations/2026_10_24_000000_create_review_accesses_table.php new file mode 100644 index 00000000..dd834906 --- /dev/null +++ b/migrations/2026_10_24_000000_create_review_accesses_table.php @@ -0,0 +1,48 @@ +bigIncrements('id'); + $table->uuid('uuid')->unique(); + $table->uuid('company_uuid')->nullable()->index(); + $table->string('subject_type', 64); + $table->uuid('subject_uuid'); + $table->boolean('enabled')->default(false); + $table->string('code')->nullable(); + $table->timestamp('code_generated_at')->nullable(); + $table->timestamp('enabled_at')->nullable(); + $table->uuid('updated_by_uuid')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->unique(['subject_type', 'subject_uuid']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('review_accesses'); + } +}; diff --git a/src/Http/Controllers/Internal/v1/ReviewAccessController.php b/src/Http/Controllers/Internal/v1/ReviewAccessController.php new file mode 100644 index 00000000..17721dfb --- /dev/null +++ b/src/Http/Controllers/Internal/v1/ReviewAccessController.php @@ -0,0 +1,104 @@ +subject($type, $id, $request); + if (!$subject) { + return response()->error('Not found.', 404); + } + + if (!$this->allowed($type, 'view_permission', $request)) { + return response()->authorizationError(); + } + + return response()->json(['review_access' => AppReview::status($subject)]); + } + + public function update(string $type, string $id, Request $request): JsonResponse + { + $subject = $this->subject($type, $id, $request); + if (!$subject) { + return response()->error('Not found.', 404); + } + + if (!$this->allowed($type, 'permission', $request)) { + return response()->authorizationError(); + } + + $request->validate([ + 'enabled' => 'sometimes|boolean', + 'regenerate_code' => 'sometimes|boolean', + ]); + + $enabled = $request->has('enabled') ? $request->boolean('enabled') : null; + + [, $code] = AppReview::update($subject, $enabled, $request->boolean('regenerate_code'), $request->session()->get('user')); + + $status = AppReview::status($subject); + if ($code !== null) { + $status['code'] = $code; + } + + return response()->json(['review_access' => $status]); + } + + protected function subject(string $type, string $id, Request $request): ?Model + { + return AppReview::resolveSubject($type, $id, $request->session()->get('company')); + } + + /** + * Administrators always may; anyone else needs the permission the type registered, when + * it registered one. + */ + protected function allowed(string $type, string $key, Request $request): bool + { + $permission = AppReview::definition($type)[$key] ?? null; + if (!$permission) { + return true; + } + + $user = Auth::getUserFromSession($request); + if (!$user) { + return false; + } + + if (!$user->isNotAdmin()) { + return true; + } + + // The permission or a wildcard over it, the way AuthorizationGuard reads them. + $names = [$permission]; + $parts = explode(' ', $permission); + if (count($parts) === 3) { + $names[] = implode(' ', [$parts[0], '*', $parts[2]]); + $names[] = implode(' ', [$parts[0], '*']); + } + + $permissions = Permission::findByNames($names); + + return $permissions->isNotEmpty() && !$user->doesntHavePermissions($permissions); + } +} diff --git a/src/Models/ReviewAccess.php b/src/Models/ReviewAccess.php new file mode 100644 index 00000000..14f280a4 --- /dev/null +++ b/src/Models/ReviewAccess.php @@ -0,0 +1,66 @@ + 'boolean', + 'code_generated_at' => 'datetime', + 'enabled_at' => 'datetime', + ]; + + /** + * Turned on and holding a code: the only state in which a reviewer can sign in. + */ + public function isUsable(): bool + { + return $this->enabled && $this->hasCode(); + } + + public function hasCode(): bool + { + return filled($this->code); + } + + /** + * Replace the code with the given one, stored hashed. + */ + public function setPlainCode(string $code): void + { + $this->code = Hash::make($code); + $this->code_generated_at = now(); + } + + public function checkCode(string $code): bool + { + return $this->hasCode() && $code !== '' && Hash::check($code, $this->code); + } +} diff --git a/src/Models/VerificationCode.php b/src/Models/VerificationCode.php index fed8e79d..057776e1 100644 --- a/src/Models/VerificationCode.php +++ b/src/Models/VerificationCode.php @@ -5,6 +5,7 @@ use Fleetbase\Casts\Json; use Fleetbase\Mail\VerificationMail; use Fleetbase\Services\SmsService; +use Fleetbase\Support\AppReview; use Fleetbase\Support\Sms\SmsContext; use Fleetbase\Support\Sms\SmsGuard; use Fleetbase\Support\Sms\SmsLedger; @@ -396,10 +397,15 @@ public static function generateSmsVerificationFor($subject, $for = 'phone_verifi $destination = data_get($options, 'phone') ?: data_get($subject, 'phone'); $companyUuid = data_get($options, 'company_uuid') ?? session('company') ?? data_get($subject, 'company_uuid'); + // A number reserved for fiction (an App Review number) is never texted: the code is + // saved as usual, but nothing is reserved or sent. App reviewers sign in with the code + // their app's owner set up instead (Fleetbase\Support\AppReview). + $fictional = $destination && AppReview::isFictionalPhone((string) $destination); + // Ask the guard BEFORE the code row exists: a refused send must leave nothing // behind, and the budgets must be counted even when the provider later fails. $reservation = null; - if ($destination) { + if ($destination && !$fictional) { $reservation = SmsGuard::make()->reserve(SmsContext::resolve((string) $destination, [ 'purpose' => $for, 'company_uuid' => $companyUuid, @@ -476,7 +482,7 @@ public static function generateSmsVerificationFor($subject, $for = 'phone_verifi $provider = data_get($options, 'provider'); // Send SMS using SmsService with automatic provider routing - if ($destination) { + if ($destination && !$fictional) { try { $smsService = new SmsService(); $smsService->send($destination, $message, $smsOptions, $provider); diff --git a/src/Services/SmsService.php b/src/Services/SmsService.php index c9c3848c..7f5d9b30 100644 --- a/src/Services/SmsService.php +++ b/src/Services/SmsService.php @@ -2,6 +2,7 @@ namespace Fleetbase\Services; +use Fleetbase\Support\AppReview; use Fleetbase\Support\Sms\SmsContext; use Fleetbase\Support\Sms\SmsGuard; use Fleetbase\Support\Sms\SmsReservation; @@ -70,6 +71,14 @@ public function send(string $to, string $text, array $options = [], ?string $pro // Normalize phone number $normalizedPhone = $this->normalizePhoneNumber($to); + // A number reserved for fiction (the App Review numbers among them) can never reach + // anyone: answer as sent without calling a provider or counting it. + if (AppReview::isFictionalPhone($normalizedPhone)) { + Log::info('SMS to a fictional number skipped', ['to' => $to]); + + return ['success' => true, 'skipped' => true, 'provider' => null, 'message_id' => null]; + } + // Every send passes the guard before a provider is called. A caller that already // reserved the send (VerificationCode) hands its reservation over so it counts once. $reservation = $options['sms_reservation'] ?? null; diff --git a/src/Support/AppReview.php b/src/Support/AppReview.php new file mode 100644 index 00000000..237d833e --- /dev/null +++ b/src/Support/AppReview.php @@ -0,0 +1,381 @@ + Store::class, + * 'phone' => '+12025550143', + * 'permission' => 'storefront manage-app-review store', + * ]); + * + * Self-hosted installs can still allow review accounts instance-wide from env config; see + * configuredAccountMatches(). + */ +class AppReview +{ + /** + * Digits in a generated code. Fleetbase's apps take six-digit codes. + */ + public const CODE_LENGTH = 6; + + /** + * North American numbers with the 555 exchange and a line from 0100 to 0199 are reserved + * for fiction: they can never reach a real person. + */ + public const FICTIONAL_PHONE_PATTERN = '/^\+1[2-9]\d{2}55501\d{2}$/'; + + /** + * @var array, phone: string, name: string, permission: ?string, view_permission: ?string, resolve: ?callable}> + */ + protected static array $types = []; + + /** + * Register a type of subject an app signs reviewers in to. + * + * - `model`: the subject's model class (required) + * - `phone`: the review number, E.164 from the fictional range (required). Types whose + * accounts are the same kind of user may share one (a store and a network); types + * whose users differ (customers, drivers) need their own, since users are found by phone. + * - `name`: what to call the app in messages, e.g. "Storefront" + * - `permission`: the permission needed to change access; `view_permission` to read it + * - `resolve`: callable(string $id, ?string $companyUuid): ?Model finding the subject a + * Console user may manage; by default the model by uuid or public_id in the company + */ + public static function register(string $type, array $definition): void + { + $model = $definition['model'] ?? null; + $phone = static::normalizePhone($definition['phone'] ?? null); + + if (!is_string($model) || !class_exists($model)) { + throw new \InvalidArgumentException("App Review type [{$type}] needs a model class."); + } + + if (!static::isFictionalPhone($phone)) { + throw new \InvalidArgumentException("App Review type [{$type}] needs a review number from the fictional range +1 NXX 555 0100-0199."); + } + + static::$types[$type] = [ + 'type' => $type, + 'model' => $model, + 'phone' => $phone, + 'name' => $definition['name'] ?? $type, + 'permission' => $definition['permission'] ?? null, + 'view_permission' => $definition['view_permission'] ?? null, + 'resolve' => $definition['resolve'] ?? null, + ]; + } + + public static function forget(string $type): void + { + unset(static::$types[$type]); + } + + public static function types(): array + { + return static::$types; + } + + public static function definition(string $type): ?array + { + return static::$types[$type] ?? null; + } + + /** + * The registered type of a subject: the first type whose model it is. + */ + public static function typeOf(?Model $subject): ?string + { + if (!$subject) { + return null; + } + + foreach (static::$types as $type => $definition) { + if ($subject instanceof $definition['model']) { + return $type; + } + } + + return null; + } + + public static function phoneFor(string $type): ?string + { + return static::$types[$type]['phone'] ?? null; + } + + /** + * Whether a number is in the range reserved for fiction, however it is formatted. + */ + public static function isFictionalPhone(?string $phone): bool + { + $phone = static::normalizePhone($phone); + + return $phone !== null && preg_match(static::FICTIONAL_PHONE_PATTERN, $phone) === 1; + } + + /** + * Whether an identity is a registered review number: the given type's, or any type's. + */ + public static function isReviewPhone(?string $identity, ?string $type = null): bool + { + $phone = static::normalizePhone($identity); + if ($phone === null) { + return false; + } + + if ($type !== null) { + return static::phoneFor($type) === $phone; + } + + foreach (static::$types as $definition) { + if ($definition['phone'] === $phone) { + return true; + } + } + + return false; + } + + /** + * `+12025550143` as `+1 202 555 0143`. + */ + public static function formatPhone(?string $phone): ?string + { + $phone = static::normalizePhone($phone); + if ($phone === null || !preg_match('/^\+1(\d{3})(\d{3})(\d{4})$/', $phone, $parts)) { + return $phone; + } + + return "+1 {$parts[1]} {$parts[2]} {$parts[3]}"; + } + + /** + * The subject of a type a Console user in this company may manage, by uuid or public id. + */ + public static function resolveSubject(string $type, string $id, ?string $companyUuid): ?Model + { + $definition = static::definition($type); + if (!$definition) { + return null; + } + + if (is_callable($definition['resolve'])) { + return ($definition['resolve'])($id, $companyUuid); + } + + $model = $definition['model']; + $query = $model::query()->where(function ($query) use ($id, $model) { + $query->where('uuid', $id); + if (static::hasColumn($model, 'public_id')) { + $query->orWhere('public_id', $id); + } + }); + + if (is_a($model, Company::class, true)) { + return $companyUuid ? $query->where('uuid', $companyUuid)->first() : null; + } + + return $companyUuid ? $query->where('company_uuid', $companyUuid)->first() : null; + } + + /** + * The App Review access record of a subject, if it has one. + */ + public static function accessFor(?Model $subject): ?ReviewAccess + { + $type = static::typeOf($subject); + if (!$type || blank($subject->uuid)) { + return null; + } + + try { + return ReviewAccess::where('subject_type', $type)->where('subject_uuid', $subject->uuid)->first(); + } catch (\Throwable $e) { + // Before the migration has run there is no access, not an error. + return null; + } + } + + public static function isEnabledFor(?Model $subject): bool + { + return (bool) static::accessFor($subject)?->isUsable(); + } + + /** + * Whether a code signs the subject type's review number in to this subject: its access is + * on and the code is the one it generated. Another subject's code never matches. + */ + public static function acceptsCode(?Model $subject, ?string $identity, mixed $code): bool + { + $type = static::typeOf($subject); + if (!$type || !static::isReviewPhone($identity, $type) || blank($code) || !is_scalar($code)) { + return false; + } + + $access = static::accessFor($subject); + if (!$access || !$access->isUsable() || !$access->checkCode((string) $code)) { + return false; + } + + Log::info('[Fleetbase] App Review sign-in accepted.', [ + 'type' => $type, + 'subject' => $subject->public_id ?? $subject->uuid, + ]); + + return true; + } + + /** + * Change a subject's access. Turning it on without a code generates one, so it is never + * on with nothing to sign in with; `regenerate` replaces the code. Turning it off keeps + * the code, so turning it back on restores the same one. + * + * @return array{0: ReviewAccess, 1: ?string} the access and the new plain code, if one was generated + */ + public static function update(Model $subject, ?bool $enabled, bool $regenerate = false, ?string $updatedByUuid = null): array + { + $type = static::typeOf($subject); + if (!$type) { + throw new \InvalidArgumentException('No App Review type is registered for ' . get_class($subject) . '.'); + } + + $access = static::accessFor($subject) ?? new ReviewAccess([ + 'company_uuid' => $subject instanceof Company ? $subject->uuid : $subject->company_uuid, + 'subject_type' => $type, + 'subject_uuid' => $subject->uuid, + 'enabled' => false, + ]); + + if ($enabled !== null) { + if ($enabled && !$access->enabled) { + $access->enabled_at = now(); + } + $access->enabled = $enabled; + } + + $code = null; + if ($regenerate || ($access->enabled && !$access->hasCode())) { + $code = static::generateCode(); + $access->setPlainCode($code); + } + + $access->updated_by_uuid = $updatedByUuid; + $access->save(); + + return [$access, $code]; + } + + /** + * What the Console and the API show about a subject's access. Never the code or its hash; + * a caller adds `code` only to the response that generated it. + */ + public static function status(Model $subject): array + { + $type = static::typeOf($subject); + $access = static::accessFor($subject); + $phone = $type ? static::phoneFor($type) : null; + + return [ + 'type' => $type, + 'enabled' => (bool) $access?->enabled, + 'phone' => $phone, + 'phone_display' => static::formatPhone($phone), + 'has_code' => (bool) $access?->hasCode(), + 'code_generated_at' => $access?->code_generated_at?->toIso8601String(), + 'enabled_at' => $access?->enabled_at?->toIso8601String(), + ]; + } + + public static function generateCode(): string + { + return str_pad((string) random_int(0, (10 ** static::CODE_LENGTH) - 1), static::CODE_LENGTH, '0', STR_PAD_LEFT); + } + + /** + * Whether an identity is listed as an instance-wide review account in env config (for + * self-hosted installs) and a code is configured for those accounts. + * + * @param string $codeKey config key holding the code + * @param string $accountsKey config key holding the listed identities + */ + public static function isConfiguredAccount(string $codeKey, string $accountsKey, ?string $identity): bool + { + $configuredCode = config($codeKey); + + // `=== null || === ''` rather than blank(): a configured code of "0" is still a code. + if ($configuredCode === null || $configuredCode === '' || $identity === null || trim($identity) === '') { + return false; + } + + $accounts = array_map( + static fn ($account) => strtolower(trim((string) $account)), + (array) config($accountsKey, []) + ); + + return in_array(strtolower(trim($identity)), $accounts, true); + } + + /** + * Whether a code is the env-configured code for a listed review account. Only listed + * identities are accepted, so knowing the code is not enough to sign in as anyone else, + * and the comparison is constant-time. + */ + public static function configuredAccountMatches(string $codeKey, string $accountsKey, ?string $identity, mixed $code, string $channel = 'app'): bool + { + if ($code === null || $code === '' || !is_scalar($code) || !static::isConfiguredAccount($codeKey, $accountsKey, $identity)) { + return false; + } + + if (!hash_equals((string) config($codeKey), (string) $code)) { + return false; + } + + Log::warning('[Fleetbase] Verification bypass accepted for a review account.', [ + 'channel' => $channel, + 'identity' => $identity, + ]); + + return true; + } + + /** + * Digits with a leading `+`, or null for nothing. + */ + protected static function normalizePhone(?string $phone): ?string + { + if ($phone === null) { + return null; + } + + $digits = preg_replace('/\D+/', '', $phone); + + return $digits === '' ? null : '+' . $digits; + } + + protected static function hasColumn(string $model, string $column): bool + { + try { + $instance = new $model(); + + return $instance->getConnection()->getSchemaBuilder()->hasColumn($instance->getTable(), $column); + } catch (\Throwable $e) { + return false; + } + } +} diff --git a/src/routes.php b/src/routes.php index 25ffc43b..6cf62503 100644 --- a/src/routes.php +++ b/src/routes.php @@ -204,6 +204,9 @@ function ($router) { function ($router) { // Realtime socket token for the signed-in console user. $router->post('socket/token', [Fleetbase\Http\Controllers\SocketAuthController::class, 'token']); + // App Review access for whatever an extension registered (stores, Navigator, ...). + $router->get('review-access/{type}/{id}', 'ReviewAccessController@show'); + $router->put('review-access/{type}/{id}', 'ReviewAccessController@update'); $router->group( ['prefix' => 'lookup'], function ($router) { diff --git a/tests/Unit/Http/SettingControllerExternalProbesTest.php b/tests/Unit/Http/SettingControllerExternalProbesTest.php index dd7538d3..7c1df368 100644 --- a/tests/Unit/Http/SettingControllerExternalProbesTest.php +++ b/tests/Unit/Http/SettingControllerExternalProbesTest.php @@ -26,7 +26,7 @@ function setting_controller_external_probe_fixtures(array $config = []): void 'twilio.twilio.connections.twilio' => [ 'sid' => 'existing-sid', 'token' => 'existing-token', - 'from' => '+15555550100', + 'from' => '+15555551000', ], 'broadcasting.connections.socketcluster.auth_key' => null, 'broadcasting.connections.socketcluster.options' => [ @@ -71,7 +71,7 @@ function setting_controller_external_probe_request(array $input = []): AdminRequ ->and(config('twilio.twilio.connections.twilio'))->toBe([ 'sid' => 'existing-sid', 'token' => 'existing-token', - 'from' => '+15555550100', + 'from' => '+15555551000', ]); }); @@ -82,7 +82,7 @@ function setting_controller_external_probe_request(array $input = []): AdminRequ 'sid' => 'override-sid', 'token' => 'override-token', 'from' => '+15555550999', - 'phone' => '+15555550123', + 'phone' => '+15555551023', ])); expect($response->getStatusCode())->toBe(200) @@ -107,7 +107,7 @@ function setting_controller_external_probe_request(array $input = []): AdminRequ 'sid' => 'rest-sid', 'token' => 'rest-token', 'from' => '+15555550999', - 'phone' => '+15555550123', + 'phone' => '+15555551023', ])); expect($response->getStatusCode())->toBe(200) @@ -135,7 +135,7 @@ function setting_controller_external_probe_request(array $input = []): AdminRequ 'sid' => 'error-sid', 'token' => 'error-token', 'from' => '+15555550999', - 'phone' => '+15555550123', + 'phone' => '+15555551023', ])); expect($response->getStatusCode())->toBe(200) @@ -158,7 +158,7 @@ function setting_controller_external_probe_request(array $input = []): AdminRequ 'sid' => 'warning-sid', 'token' => 'warning-token', 'from' => '+15555550999', - 'phone' => '+15555550123', + 'phone' => '+15555551023', ])); expect($response->getStatusCode())->toBe(200) @@ -325,12 +325,12 @@ function setting_controller_twilio_facade_is_cached(): bool 'sid' => 'entered-sid', 'token' => 'entered-token', 'from' => '+15555550999', - 'phone' => '+15555550123', + 'phone' => '+15555551023', ])); expect($response->getData(true)['message'])->toBe('recorded') ->and(SettingControllerRecordingTwilioManager::$sent)->toBe([ - ['to' => '+15555550123', 'sid' => 'entered-sid', 'token' => 'entered-token', 'from' => '+15555550999'], + ['to' => '+15555551023', 'sid' => 'entered-sid', 'token' => 'entered-token', 'from' => '+15555550999'], ]) ->and(setting_controller_twilio_facade_is_cached())->toBeFalse('the credentials under test do not outlive the request'); }); @@ -341,13 +341,13 @@ function setting_controller_twilio_facade_is_cached(): bool $response = (new SettingController())->testSmsProviderConfig(setting_controller_external_probe_request([ 'provider' => 'twilio', - 'phone' => '+15555550123', + 'phone' => '+15555551023', 'config' => ['sid' => 'entered-sid', 'token' => 'entered-token', 'from' => '+15555550999'], ])); expect($response->getData(true))->toMatchArray(['status' => 'error', 'message' => 'recorded']) ->and(SettingControllerRecordingTwilioManager::$sent)->toBe([ - ['to' => '+15555550123', 'sid' => 'entered-sid', 'token' => 'entered-token', 'from' => '+15555550999'], + ['to' => '+15555551023', 'sid' => 'entered-sid', 'token' => 'entered-token', 'from' => '+15555550999'], ]) ->and(setting_controller_twilio_facade_is_cached())->toBeFalse(); }); @@ -361,11 +361,11 @@ function setting_controller_twilio_facade_is_cached(): bool 'sid' => 'entered-sid', 'token' => 'entered-token', 'from' => '+15555550999', - 'phone' => '+15555550123', + 'phone' => '+15555551023', ])); expect(app('twilio'))->toBe($twilio) - ->and($twilio->messages)->toBe([['+15555550123', 'This is a Twilio test from Fleetbase']]); + ->and($twilio->messages)->toBe([['+15555551023', 'This is a Twilio test from Fleetbase']]); }); test('a twilio manager bound but not yet built is built from the config just applied', function () { diff --git a/tests/Unit/Http/SettingControllerServicesTest.php b/tests/Unit/Http/SettingControllerServicesTest.php index 48205a2a..4391253d 100644 --- a/tests/Unit/Http/SettingControllerServicesTest.php +++ b/tests/Unit/Http/SettingControllerServicesTest.php @@ -103,7 +103,7 @@ function setting_controller_services_fixtures(): void 'services.twilio' => [ 'sid' => 'twilio-sid', 'token' => 'twilio-token', - 'from' => '+15555550100', + 'from' => '+15555551000', ], 'sentry.dsn' => 'https://sentry.example.test/1', 'sms.default_provider' => SmsService::PROVIDER_TWILIO, @@ -153,7 +153,7 @@ function setting_controller_services_request(array $input = []): AdminRequest 'googleMapsLocale' => 'mn', 'twilioSid' => 'twilio-sid', 'twilioToken' => 'twilio-token', - 'twilioFrom' => '+15555550100', + 'twilioFrom' => '+15555551000', 'sentryDsn' => 'https://sentry.example.test/1', ]) ->and($payload['sms']['defaultProvider'])->toBe(SmsService::PROVIDER_TWILIO) @@ -172,7 +172,7 @@ function setting_controller_services_request(array $input = []): AdminRequest SmsService::PROVIDER_TWILIO => [ 'sid' => 'twilio-sid', 'token' => 'twilio-token', - 'from' => '+15555550100', + 'from' => '+15555551000', ], ]) ->and($payload['sms']['available'][SmsService::PROVIDER_TWILIO])->toBe([ @@ -207,7 +207,7 @@ function setting_controller_services_request(array $input = []): AdminRequest $response = (new SettingController())->testSmsProviderConfig(setting_controller_services_request([ 'provider' => 'unsupported_gateway', - 'phone' => '+15555550123', + 'phone' => '+15555551023', 'message' => 'Hello from Fleetbase', 'config' => [ 'api_key' => 'unsupported-key', @@ -237,7 +237,7 @@ function setting_controller_services_request(array $input = []): AdminRequest $response = (new SettingController())->testSmsProviderConfig(setting_controller_services_request([ 'provider' => SmsService::PROVIDER_TWILIO, - 'phone' => '+15555550123', + 'phone' => '+15555551023', 'message' => 'Hello from Fleetbase', 'config' => [ 'sid' => 'temporary-twilio-sid', diff --git a/tests/Unit/Models/VerificationCodeGuardTest.php b/tests/Unit/Models/VerificationCodeGuardTest.php index aa0dfe5d..60890d9d 100644 --- a/tests/Unit/Models/VerificationCodeGuardTest.php +++ b/tests/Unit/Models/VerificationCodeGuardTest.php @@ -213,6 +213,19 @@ function verification_code_guard_subject(string $phone): User ->and($twilio->messages)->toBe([]); }); +it('never texts a number reserved for fiction, such as an App Review number, and still saves the code', function () { + $twilio = verification_code_guard_database(); + + $code = VerificationCode::generateSmsVerificationFor(verification_code_guard_subject('+12025550143'), 'storefront_login', ['hashed' => true]); + $repeat = VerificationCode::generateSmsVerificationFor(verification_code_guard_subject('+12025550143'), 'storefront_login', ['hashed' => true]); + + // Nothing was reserved, so the guard's cooldown never refuses the repeat either. + expect($code->exists)->toBeTrue() + ->and($repeat->exists)->toBeTrue() + ->and($code->meta)->not->toHaveKey('trace') + ->and($twilio->messages)->toBe([]); +}); + it('leaves the code row untouched when the guard is disabled', function () { $twilio = verification_code_guard_database(['enabled' => false]); diff --git a/tests/Unit/SmsServiceGuardTest.php b/tests/Unit/SmsServiceGuardTest.php index 5ad59e67..6b1d77fc 100644 --- a/tests/Unit/SmsServiceGuardTest.php +++ b/tests/Unit/SmsServiceGuardTest.php @@ -142,3 +142,13 @@ protected function sendViaTwilio(string $to, string $text, array $options = []): expect($failed)->not->toBeNull() ->and($failed[2]['error'])->toBe('invalid sender'); }); + +test('sms service never texts a number reserved for fiction, such as an App Review number', function () { + sms_service_guard_setup(); + $service = new SmsServiceGuardProbe(); + + $result = $service->send('+1 202 555 0143', 'hello', ['purpose' => 'storefront_login']); + + expect($result)->toMatchArray(['success' => true, 'skipped' => true]) + ->and($service->dispatches)->toBe([]); +}); diff --git a/tests/Unit/Support/AppReviewTest.php b/tests/Unit/Support/AppReviewTest.php new file mode 100644 index 00000000..af13bb7d --- /dev/null +++ b/tests/Unit/Support/AppReviewTest.php @@ -0,0 +1,298 @@ + 'sqlite', 'database' => ':memory:', 'prefix' => '']; + $container = bind_test_container([ + 'database.default' => 'testing', + 'database.connections.testing' => $connection, + 'database.connections.mysql' => $connection, + 'fleetbase.connection.db' => 'testing', + ]); + $container->instance('hash', new class { + public function make($value, array $options = []): string + { + return password_hash((string) $value, PASSWORD_BCRYPT); + } + + public function check($value, $hashedValue, array $options = []): bool + { + return is_string($hashedValue) && password_verify((string) $value, $hashedValue); + } + }); + $container->instance('cache', new Illuminate\Cache\Repository(new Illuminate\Cache\ArrayStore())); + $container->instance('responsecache', new class { + public function clear(): void + { + } + }); + Facade::clearResolvedInstances(); + + $capsule = new Capsule($container); + $capsule->addConnection($connection, 'testing'); + $capsule->addConnection($connection, 'mysql'); + $capsule->setEventDispatcher(new Dispatcher($container)); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + $capsule->getDatabaseManager()->setDefaultConnection('testing'); + $container->instance('db', $capsule->getDatabaseManager()); + $container->instance('db.schema', $capsule->getConnection('testing')->getSchemaBuilder()); + Facade::clearResolvedInstance('db'); + Facade::clearResolvedInstance('db.schema'); + + $schema = $capsule->getConnection('testing')->getSchemaBuilder(); + foreach (['app_review_test_stores', 'app_review_test_networks'] as $table) { + $schema->create($table, function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + // Company lives on the core connection. + $capsule->getConnection('mysql')->getSchemaBuilder()->create('companies', function ($table) { + $table->string('uuid')->primary(); + $table->string('public_id')->nullable(); + $table->string('name')->nullable(); + $table->text('options')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + // The migration, as it runs on the core connection. + (require __DIR__ . '/../../../migrations/2026_10_24_000000_create_review_accesses_table.php')->up(); + + $db = $capsule->getConnection('testing'); + $db->table('app_review_test_stores')->insert([ + ['uuid' => 'store-a', 'public_id' => 'store_a', 'company_uuid' => 'company-a'], + ['uuid' => 'store-b', 'public_id' => 'store_b', 'company_uuid' => 'company-b'], + ]); + $db->table('app_review_test_networks')->insert([ + ['uuid' => 'network-a', 'public_id' => 'network_a', 'company_uuid' => 'company-a'], + ]); + $capsule->getConnection('mysql')->table('companies')->insert([ + ['uuid' => 'company-a', 'public_id' => 'company_a', 'name' => 'Corner Co'], + ['uuid' => 'company-b', 'public_id' => 'company_b', 'name' => 'Night Co'], + ]); + + ReviewAccess::creating(fn ($access) => $access->uuid ??= (string) Str::uuid()); + + AppReview::register('test-store', ['model' => AppReviewTestStore::class, 'phone' => '+1 (202) 555-0143', 'name' => 'Storefront']); + AppReview::register('test-network', ['model' => AppReviewTestNetwork::class, 'phone' => '+12025550143', 'name' => 'Storefront']); + AppReview::register('test-navigator', ['model' => Company::class, 'phone' => '+12025550144', 'name' => 'Navigator']); + + return $capsule; +} + +function app_review_store(string $uuid): AppReviewTestStore +{ + return AppReviewTestStore::where('uuid', $uuid)->first(); +} + +function app_review_console_request(string $company, array $input = [], string $method = 'PUT'): Request +{ + $request = Request::create('/int/v1/review-access', $method, $input); + $request->setLaravelSession(new SessionStore('app-review-test', new ArraySessionHandler(120))); + $request->session()->put('company', $company); + + return $request; +} + +afterEach(function () { + foreach (['test-store', 'test-network', 'test-navigator', 'test-guarded'] as $type) { + AppReview::forget($type); + } + EloquentModel::unsetConnectionResolver(); + EloquentModel::clearBootedModels(); + Facade::clearResolvedInstances(); +}); + +test('review numbers must come from the range reserved for fiction', function () { + bind_test_container(); + + expect(AppReview::isFictionalPhone('+12025550143'))->toBeTrue() + ->and(AppReview::isFictionalPhone('+1 (415) 555-0199'))->toBeTrue() + ->and(AppReview::isFictionalPhone('+12025550200'))->toBeFalse() + ->and(AppReview::isFictionalPhone('+12025551234'))->toBeFalse() + ->and(AppReview::isFictionalPhone('+97699112233'))->toBeFalse() + ->and(AppReview::isFictionalPhone(null))->toBeFalse() + ->and(AppReview::formatPhone('+12025550143'))->toBe('+1 202 555 0143') + ->and(fn () => AppReview::register('test-guarded', ['model' => AppReviewTestStore::class, 'phone' => '+12025551234']))->toThrow(InvalidArgumentException::class) + ->and(fn () => AppReview::register('test-guarded', ['model' => 'Missing\\Model', 'phone' => '+12025550143']))->toThrow(InvalidArgumentException::class); + + // libphonenumber accepts the numbers the apps register. + $util = libphonenumber\PhoneNumberUtil::getInstance(); + foreach (['+12025550143', '+12025550144'] as $phone) { + expect($util->isValidNumber($util->parse($phone, null)))->toBeTrue(); + } +}); + +test('turning access on creates a code that only works for that subject', function () { + app_review_database(); + $storeA = app_review_store('store-a'); + $storeB = app_review_store('store-b'); + + expect(AppReview::typeOf($storeA))->toBe('test-store') + ->and(AppReview::isEnabledFor($storeA))->toBeFalse() + ->and(AppReview::acceptsCode($storeA, '+12025550143', '000000'))->toBeFalse(); + + [$access, $codeA] = AppReview::update($storeA, true); + do { + [, $codeB] = AppReview::update($storeB, true, true); + } while ($codeB === $codeA); + + expect($codeA)->toMatch('/^\d{6}$/') + ->and($access->getRawOriginal('code'))->not->toBe($codeA) + ->and($access->toArray())->not->toHaveKey('code') + ->and(AppReview::isEnabledFor($storeA))->toBeTrue() + ->and(AppReview::acceptsCode($storeA, '+1 202 555 0143', $codeA))->toBeTrue() + ->and(AppReview::acceptsCode($storeA, '+12025550143', $codeB))->toBeFalse() + ->and(AppReview::acceptsCode($storeB, '+12025550143', $codeA))->toBeFalse() + // Only the type's own number signs in. + ->and(AppReview::acceptsCode($storeA, '+12025550144', $codeA))->toBeFalse() + ->and(AppReview::acceptsCode($storeA, '+97699112233', $codeA))->toBeFalse() + ->and(AppReview::acceptsCode(null, '+12025550143', $codeA))->toBeFalse(); +}); + +test('turning access off keeps the code, and regenerating replaces it', function () { + app_review_database(); + $store = app_review_store('store-a'); + + [, $code] = AppReview::update($store, true); + [, $none] = AppReview::update($store, false); + expect($none)->toBeNull() + ->and(AppReview::acceptsCode($store, '+12025550143', $code))->toBeFalse() + ->and(AppReview::status($store))->toMatchArray(['enabled' => false, 'has_code' => true]); + + [, $restored] = AppReview::update($store, true); + expect($restored)->toBeNull() + ->and(AppReview::acceptsCode($store, '+12025550143', $code))->toBeTrue(); + + [, $new] = AppReview::update($store, null, true); + expect($new)->toMatch('/^\d{6}$/') + ->and(AppReview::acceptsCode($store, '+12025550143', $new))->toBeTrue() + ->and($new === $code || !AppReview::acceptsCode($store, '+12025550143', $code))->toBeTrue() + ->and(ReviewAccess::count())->toBe(1); +}); + +test('a company can be the subject, with its own review number', function () { + app_review_database(); + $company = Company::where('uuid', 'company-a')->first(); + + [, $code] = AppReview::update($company, true); + + expect(AppReview::typeOf($company))->toBe('test-navigator') + ->and(AppReview::status($company))->toMatchArray(['type' => 'test-navigator', 'phone' => '+12025550144', 'phone_display' => '+1 202 555 0144', 'enabled' => true]) + ->and(AppReview::acceptsCode($company, '+12025550144', $code))->toBeTrue() + ->and(AppReview::acceptsCode($company, '+12025550143', $code))->toBeFalse() + ->and(ReviewAccess::first()->company_uuid)->toBe('company-a') + ->and(AppReview::resolveSubject('test-navigator', 'company_a', 'company-a')?->uuid)->toBe('company-a') + ->and(AppReview::resolveSubject('test-navigator', 'company_b', 'company-a'))->toBeNull(); +}); + +test('a subject is found by uuid or public id in the signed-in company only', function () { + app_review_database(); + + expect(AppReview::resolveSubject('test-store', 'store-a', 'company-a')?->uuid)->toBe('store-a') + ->and(AppReview::resolveSubject('test-store', 'store_a', 'company-a')?->uuid)->toBe('store-a') + ->and(AppReview::resolveSubject('test-store', 'store_b', 'company-a'))->toBeNull() + ->and(AppReview::resolveSubject('test-store', 'store_a', null))->toBeNull() + ->and(AppReview::resolveSubject('unknown-type', 'store_a', 'company-a'))->toBeNull(); +}); + +test('the instance-wide review accounts only accept their code for listed identities', function () { + bind_test_container([ + 'app.review.code' => '135790', + 'app.review.accounts' => [' Reviewer@Example.Test ', '+12025550100'], + ]); + + expect(AppReview::isConfiguredAccount('app.review.code', 'app.review.accounts', 'reviewer@example.test'))->toBeTrue() + ->and(AppReview::isConfiguredAccount('app.review.code', 'app.review.accounts', 'other@example.test'))->toBeFalse() + ->and(AppReview::configuredAccountMatches('app.review.code', 'app.review.accounts', '+12025550100', '135790'))->toBeTrue() + ->and(AppReview::configuredAccountMatches('app.review.code', 'app.review.accounts', '+12025550100', '135791'))->toBeFalse() + ->and(AppReview::configuredAccountMatches('app.review.code', 'app.review.accounts', 'other@example.test', '135790'))->toBeFalse() + ->and(AppReview::configuredAccountMatches('app.review.code', 'app.review.accounts', '+12025550100', null))->toBeFalse(); + + config(['app.review.code' => null]); + expect(AppReview::configuredAccountMatches('app.review.code', 'app.review.accounts', '+12025550100', ''))->toBeFalse() + ->and(AppReview::isConfiguredAccount('app.review.code', 'app.review.accounts', '+12025550100'))->toBeFalse(); +}); + +test('the Console endpoint shows the code once and never again', function () { + app_review_database(); + app()->instance('validator', new Illuminate\Validation\Factory(new Illuminate\Translation\Translator(new Illuminate\Translation\ArrayLoader(), 'en'), app())); + Request::macro('validate', function (array $rules) { + return app('validator')->make($this->all(), $rules)->validate(); + }); + $controller = new ReviewAccessController(); + + $initial = $controller->show('test-store', 'store_a', app_review_console_request('company-a', [], 'GET'))->getData(true)['review_access']; + expect($initial)->toBe([ + 'type' => 'test-store', + 'enabled' => false, + 'phone' => '+12025550143', + 'phone_display' => '+1 202 555 0143', + 'has_code' => false, + 'code_generated_at' => null, + 'enabled_at' => null, + ]); + + $on = $controller->update('test-store', 'store_a', app_review_console_request('company-a', ['enabled' => true]))->getData(true)['review_access']; + $again = $controller->show('test-store', 'store_a', app_review_console_request('company-a', [], 'GET'))->getData(true)['review_access']; + $saved = $controller->update('test-store', 'store_a', app_review_console_request('company-a', ['enabled' => true]))->getData(true)['review_access']; + + expect($on['enabled'])->toBeTrue() + ->and($on['code'])->toMatch('/^\d{6}$/') + ->and($again)->not->toHaveKey('code') + ->and($saved)->not->toHaveKey('code') + ->and(AppReview::acceptsCode(app_review_store('store-a'), '+12025550143', $on['code']))->toBeTrue(); + + // Another company's store, and a type nobody registered, are not found. + expect($controller->update('test-store', 'store_b', app_review_console_request('company-a', ['enabled' => true]))->getStatusCode())->toBe(404) + ->and($controller->show('nothing', 'store_a', app_review_console_request('company-a', [], 'GET'))->getStatusCode())->toBe(404) + ->and($controller->update('test-network', 'network_a', app_review_console_request('company-a', ['enabled' => true]))->getData(true)['review_access']['type'])->toBe('test-network'); +}); + +test('a registered permission is required to change access', function () { + app_review_database(); + AppReview::register('test-guarded', ['model' => AppReviewTestStore::class, 'phone' => '+12025550143', 'permission' => 'storefront manage-app-review store']); + + // Nobody signed in, so nobody holds the permission: refused before anything changes. + $response = (new ReviewAccessController())->update('test-guarded', 'store_a', app_review_console_request('company-a', ['enabled' => true])); + + expect($response->getStatusCode())->toBe(401) + ->and(ReviewAccess::count())->toBe(0); +})->skip(fn () => !method_exists(Fleetbase\Support\Auth::class, 'getUserFromSession')); diff --git a/tests/Unit/Support/TwoFactorAuthTest.php b/tests/Unit/Support/TwoFactorAuthTest.php index cb64d7a7..77a5163e 100644 --- a/tests/Unit/Support/TwoFactorAuthTest.php +++ b/tests/Unit/Support/TwoFactorAuthTest.php @@ -217,7 +217,7 @@ function two_factor_auth_fixtures(): array 'uuid' => '11111111-1111-4111-8111-111111111111', 'company_uuid' => $company->uuid, 'email' => 'user@example.com', - 'phone' => '+15555550100', + 'phone' => '+15555551000', 'username' => 'test-user', 'name' => 'Test User', ]); From 5f41a212f97762cc3d69bf23263802380215bd9c Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 24 Oct 2026 19:25:49 +0800 Subject: [PATCH 2/7] docs(release): App Review access in the v1.6.74 notes --- RELEASE.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index 09676734..85500db5 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,14 +1,17 @@ -# v1.6.74 — Webhook endpoints for API keys, signed per endpoint, and safer upload names +# v1.6.74 — Webhook endpoints for API keys, signed per endpoint, safer upload names, and App Review access ## Added - **Webhook endpoints can be managed with an API key** (#319). `/v1/webhook-endpoints` supports create, list (optionally filtered by `url`), show, update, `POST {id}/rotate-secret` and delete. Endpoints are scoped to the key's company and environment (`live`/`test`). The URL must pass `PublicWebhookUrl`, and events are checked against `config('api.events')` when it is set. Integrations such as WooCommerce and Shopify connectors can now register their own endpoints instead of merchants copying URLs into the console. - **Per-endpoint signing secrets** (#319). Each endpoint has its own secret (`whsec_…`), stored encrypted, hidden from serialisation and kept out of the activity log. Endpoints that already exist get one the first time it is needed. Deliveries now also carry `X-Fleetbase-Signature: t=,v1=.", secret)>`; `EndpointSignature::verify()` documents the receiver side. The legacy `Signature` header is unchanged. The console can reveal (`GET int/v1/webhook-endpoints/{id}/secret`, view permission) and rotate (`POST int/v1/webhook-endpoints/{id}/rotate-secret`, create permission) the secret. +- **App Review access** (#323). Apple and Google reviewers can't receive a texted sign-in code. Extensions register what their app signs reviewers in to (`Fleetbase\Support\AppReview::register()`, e.g. a Storefront store or an organization's Navigator app) with a review number from the 555-0100–0199 range reserved for fiction. Owners turn access on in the Console (`GET|PUT int/v1/review-access/{type}/{id}`) and give the app store that number and a six-digit code, stored hashed, shown once, and accepted only for that subject. Works on Fleetbase Cloud and self-hosted. `AppReview::configuredAccountMatches()` is the shared env fallback for self-hosted review accounts. ## Security +- **Numbers reserved for fiction are never texted** (#323). `SmsService::send()` answers as sent without calling a provider, and SMS verification codes for those numbers are saved but not sent. + - **Uploaded files are stored under an extension that matches their contents** (#320). The stored name used to keep the client's extension, so a file that passed content-type validation could be stored as `.php`. The client's extension is now kept only when it matches the detected type (or is a loosely detected data format such as `csv`, `json` or `xlsx`); otherwise the extension comes from the detected type. Script extensions (`php`, `phtml`, `phar`, `shtml`, `htaccess`, `cgi`, …) are never used and become `bin`. `original_filename` is unchanged. ## Upgrade Steps -- Run migrations: `php artisan migrate` adds the nullable `webhook_endpoints.secret` column. +- Run migrations: `php artisan migrate` adds the nullable `webhook_endpoints.secret` column and the `review_accesses` table. From 0994c547b6ceaf09fa00999be5d1ca1eff52155b Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sun, 25 Oct 2026 01:11:06 +0800 Subject: [PATCH 3/7] feat(oauth-server): Sign in with Fleetbase, an OAuth 2.0 / OpenID Connect provider Registered apps can offer "Continue with Fleetbase" and, with the user's consent, act on one organization they administer. Built on league/oauth2-server: authorization code with PKCE (S256, every client), rotating refresh tokens, RFC 7009 revocation, ID tokens, userinfo, JWKS and discovery. Codes and tokens are bound to a grant and its organization, which every refresh and API call re-checks. Consent runs on the console session; admins manage apps and users manage connected apps. --- RELEASE.md | 13 +- composer.json | 3 + config/oauth-server.php | 50 ++ ...0_25_000001_create_oauth_server_tables.php | 115 ++++ .../Entities/AccessTokenEntity.php | 73 +++ .../OAuthServer/Entities/AuthCodeEntity.php | 15 + .../OAuthServer/Entities/ClientEntity.php | 24 + .../Entities/RefreshTokenEntity.php | 13 + src/Auth/OAuthServer/Entities/ScopeEntity.php | 18 + src/Auth/OAuthServer/Entities/UserEntity.php | 19 + .../OAuthServer/Exceptions/AuthorizeError.php | 48 ++ .../OAuthServer/Exceptions/TokenError.php | 47 ++ src/Auth/OAuthServer/IdTokenResponse.php | 84 +++ src/Auth/OAuthServer/IssueContext.php | 36 ++ src/Auth/OAuthServer/OAuthClaims.php | 50 ++ src/Auth/OAuthServer/OAuthKeys.php | 137 ++++ src/Auth/OAuthServer/OAuthOrganizations.php | 101 +++ src/Auth/OAuthServer/OAuthScopes.php | 141 ++++ src/Auth/OAuthServer/OAuthServer.php | 602 ++++++++++++++++++ src/Auth/OAuthServer/PayloadCrypt.php | 41 ++ .../Repositories/AccessTokenRepository.php | 60 ++ .../Repositories/AuthCodeRepository.php | 73 +++ .../Repositories/ClientRepository.php | 49 ++ .../Repositories/RefreshTokenRepository.php | 67 ++ .../Repositories/ScopeRepository.php | 36 ++ .../OAuthServer/ValidatedAuthorization.php | 38 ++ src/Console/Commands/OAuthServerClient.php | 120 ++++ src/Console/Commands/OAuthServerKeys.php | 47 ++ src/Console/Commands/OAuthServerPrune.php | 36 ++ .../Internal/v1/ConnectedAppController.php | 56 ++ .../Internal/v1/OAuthAppController.php | 202 ++++++ .../Internal/v1/OAuthConsentController.php | 173 +++++ .../OAuthServer/AuthorizationController.php | 38 ++ .../OAuthServer/DiscoveryController.php | 23 + .../OAuthServer/ResourceController.php | 99 +++ .../OAuthServer/TokenController.php | 23 + .../Middleware/AuthenticateOAuthToken.php | 65 ++ src/Models/OAuthAccessToken.php | 67 ++ src/Models/OAuthAuthCode.php | 37 ++ src/Models/OAuthClient.php | 246 +++++++ src/Models/OAuthGrant.php | 168 +++++ src/Models/OAuthRefreshToken.php | 35 + src/Notifications/OAuthAppAuthorized.php | 81 +++ src/Providers/CoreServiceProvider.php | 6 + src/routes.php | 49 ++ .../OAuthServer/OAuthServerEndpointsTest.php | 164 +++++ .../Auth/OAuthServer/OAuthServerFlowTest.php | 512 +++++++++++++++ .../OAuthServer/OAuthServerTestSupport.php | 230 +++++++ .../Providers/CoreProviderContractsTest.php | 6 +- 49 files changed, 4432 insertions(+), 4 deletions(-) create mode 100644 config/oauth-server.php create mode 100644 migrations/2026_10_25_000001_create_oauth_server_tables.php create mode 100644 src/Auth/OAuthServer/Entities/AccessTokenEntity.php create mode 100644 src/Auth/OAuthServer/Entities/AuthCodeEntity.php create mode 100644 src/Auth/OAuthServer/Entities/ClientEntity.php create mode 100644 src/Auth/OAuthServer/Entities/RefreshTokenEntity.php create mode 100644 src/Auth/OAuthServer/Entities/ScopeEntity.php create mode 100644 src/Auth/OAuthServer/Entities/UserEntity.php create mode 100644 src/Auth/OAuthServer/Exceptions/AuthorizeError.php create mode 100644 src/Auth/OAuthServer/Exceptions/TokenError.php create mode 100644 src/Auth/OAuthServer/IdTokenResponse.php create mode 100644 src/Auth/OAuthServer/IssueContext.php create mode 100644 src/Auth/OAuthServer/OAuthClaims.php create mode 100644 src/Auth/OAuthServer/OAuthKeys.php create mode 100644 src/Auth/OAuthServer/OAuthOrganizations.php create mode 100644 src/Auth/OAuthServer/OAuthScopes.php create mode 100644 src/Auth/OAuthServer/OAuthServer.php create mode 100644 src/Auth/OAuthServer/PayloadCrypt.php create mode 100644 src/Auth/OAuthServer/Repositories/AccessTokenRepository.php create mode 100644 src/Auth/OAuthServer/Repositories/AuthCodeRepository.php create mode 100644 src/Auth/OAuthServer/Repositories/ClientRepository.php create mode 100644 src/Auth/OAuthServer/Repositories/RefreshTokenRepository.php create mode 100644 src/Auth/OAuthServer/Repositories/ScopeRepository.php create mode 100644 src/Auth/OAuthServer/ValidatedAuthorization.php create mode 100644 src/Console/Commands/OAuthServerClient.php create mode 100644 src/Console/Commands/OAuthServerKeys.php create mode 100644 src/Console/Commands/OAuthServerPrune.php create mode 100644 src/Http/Controllers/Internal/v1/ConnectedAppController.php create mode 100644 src/Http/Controllers/Internal/v1/OAuthAppController.php create mode 100644 src/Http/Controllers/Internal/v1/OAuthConsentController.php create mode 100644 src/Http/Controllers/OAuthServer/AuthorizationController.php create mode 100644 src/Http/Controllers/OAuthServer/DiscoveryController.php create mode 100644 src/Http/Controllers/OAuthServer/ResourceController.php create mode 100644 src/Http/Controllers/OAuthServer/TokenController.php create mode 100644 src/Http/Middleware/AuthenticateOAuthToken.php create mode 100644 src/Models/OAuthAccessToken.php create mode 100644 src/Models/OAuthAuthCode.php create mode 100644 src/Models/OAuthClient.php create mode 100644 src/Models/OAuthGrant.php create mode 100644 src/Models/OAuthRefreshToken.php create mode 100644 src/Notifications/OAuthAppAuthorized.php create mode 100644 tests/Unit/Auth/OAuthServer/OAuthServerEndpointsTest.php create mode 100644 tests/Unit/Auth/OAuthServer/OAuthServerFlowTest.php create mode 100644 tests/Unit/Auth/OAuthServer/OAuthServerTestSupport.php diff --git a/RELEASE.md b/RELEASE.md index 85500db5..76121464 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,10 +1,18 @@ -# v1.6.74 — Webhook endpoints for API keys, signed per endpoint, safer upload names, and App Review access +# v1.6.74 — Webhook endpoints for API keys, signed per endpoint, safer upload names, App Review access, and Sign in with Fleetbase ## Added - **Webhook endpoints can be managed with an API key** (#319). `/v1/webhook-endpoints` supports create, list (optionally filtered by `url`), show, update, `POST {id}/rotate-secret` and delete. Endpoints are scoped to the key's company and environment (`live`/`test`). The URL must pass `PublicWebhookUrl`, and events are checked against `config('api.events')` when it is set. Integrations such as WooCommerce and Shopify connectors can now register their own endpoints instead of merchants copying URLs into the console. - **Per-endpoint signing secrets** (#319). Each endpoint has its own secret (`whsec_…`), stored encrypted, hidden from serialisation and kept out of the activity log. Endpoints that already exist get one the first time it is needed. Deliveries now also carry `X-Fleetbase-Signature: t=,v1=.", secret)>`; `EndpointSignature::verify()` documents the receiver side. The legacy `Signature` header is unchanged. The console can reveal (`GET int/v1/webhook-endpoints/{id}/secret`, view permission) and rotate (`POST int/v1/webhook-endpoints/{id}/rotate-secret`, create permission) the secret. - **App Review access** (#323). Apple and Google reviewers can't receive a texted sign-in code. Extensions register what their app signs reviewers in to (`Fleetbase\Support\AppReview::register()`, e.g. a Storefront store or an organization's Navigator app) with a review number from the 555-0100–0199 range reserved for fiction. Owners turn access on in the Console (`GET|PUT int/v1/review-access/{type}/{id}`) and give the app store that number and a six-digit code, stored hashed, shown once, and accepted only for that subject. Works on Fleetbase Cloud and self-hosted. `AppReview::configuredAccountMatches()` is the shared env fallback for self-hosted review accounts. +- **Sign in with Fleetbase** (OAuth 2.0 / OpenID Connect provider). Registered apps can offer "Continue with Fleetbase" and, with the user's consent, act on one organization they administer. Built on league/oauth2-server. + - Authorization code flow with PKCE (`S256`) required for every client, rotating refresh tokens, RFC 7009 revocation, ID tokens, `/oauth/userinfo`, `/oauth/jwks` and `/.well-known/openid-configuration`. + - The consent screen runs in the console, so the console's sign-in and two-factor rules apply. Endpoints: `int/v1/oauth-server/authorization` (GET, POST, DELETE). + - Each consent is an `oauth_grants` row bound to the organization the user picked. Every refresh and every API call checks that the user is still active and still administers that organization. + - Scopes: `openid`, `profile`, `email` and `organization:read` (`GET /oauth/v1/organization`). Extensions register their own scopes with `Fleetbase\Auth\OAuthServer\OAuthScopes::register()`, optionally covering App Review types served by `/oauth/v1/review-access/{type}/{id}`. + - Replayed codes revoke their tokens; a replayed refresh token revokes the whole grant. + - System admins register apps, rotate secrets, turn apps off and revoke grants (`int/v1/oauth-server/apps`). Users list and remove their connected apps (`int/v1/oauth-server/connected-apps`) and get an email when they authorize a new one. + - Artisan: `oauth-server:keys`, `oauth-server:client create|rotate|list`, `oauth-server:prune` (scheduled daily). ## Security @@ -14,4 +22,5 @@ ## Upgrade Steps -- Run migrations: `php artisan migrate` adds the nullable `webhook_endpoints.secret` column and the `review_accesses` table. +- Run migrations: `php artisan migrate` adds the nullable `webhook_endpoints.secret` column, the `review_accesses` table and the `oauth_*` provider tables (`oauth_clients`, `oauth_grants`, `oauth_auth_codes`, `oauth_access_tokens`, `oauth_refresh_tokens`). +- To use Sign in with Fleetbase, give every API instance the same signing key pair: `php artisan oauth-server:keys` (key files in `storage/`), or `php artisan oauth-server:keys --show` and set `OAUTH_SERVER_PRIVATE_KEY` / `OAUTH_SERVER_PUBLIC_KEY`. Set `OAUTH_SERVER_ISSUER` if the public API URL differs from `APP_URL`. New dependencies: `league/oauth2-server`, `nyholm/psr7`, `symfony/psr-http-message-bridge`. diff --git a/composer.json b/composer.json index a802c6ca..772a1b70 100644 --- a/composer.json +++ b/composer.json @@ -45,9 +45,11 @@ "laravel/socialite": "^5.31", "lcobucci/clock": "3.3.1", "lcobucci/jwt": "^5.4", + "league/oauth2-server": "^8.5.5", "maatwebsite/excel": "^3.1", "milon/barcode": "^10.0", "mossadal/math-parser": "^1.3", + "nyholm/psr7": "^1.5", "phpoffice/phpspreadsheet": "^1.28", "phrity/websocket": "^1.7", "pragmarx/google2fa": "^8.0", @@ -61,6 +63,7 @@ "spatie/laravel-schedule-monitor": "^3.7", "spatie/laravel-sluggable": "^3.5", "sqids/sqids": "^0.4.1", + "symfony/psr-http-message-bridge": "^2.1|^6.0|^7.0", "xantios/mimey": "^2.2.0" }, "require-dev": { diff --git a/config/oauth-server.php b/config/oauth-server.php new file mode 100644 index 00000000..c27b42d9 --- /dev/null +++ b/config/oauth-server.php @@ -0,0 +1,50 @@ + env('OAUTH_SERVER_ENABLED', true), + + /* + * The issuer identifier (`iss`), and the base of every endpoint in the discovery document. + * Defaults to APP_URL, e.g. https://api.fleetbase.io. + */ + 'issuer' => env('OAUTH_SERVER_ISSUER'), + + /* + * PEM key pair for signing access tokens and ID tokens. When unset, the keys are read from + * storage/oauth-private.key and storage/oauth-public.key (`php artisan oauth-server:keys`). + */ + 'private_key' => env('OAUTH_SERVER_PRIVATE_KEY'), + 'public_key' => env('OAUTH_SERVER_PUBLIC_KEY'), + + /* + * Lifetimes, as ISO 8601 durations. + */ + 'ttl' => [ + 'auth_code' => env('OAUTH_SERVER_AUTH_CODE_TTL', 'PT10M'), + 'access_token' => env('OAUTH_SERVER_ACCESS_TOKEN_TTL', 'PT1H'), + 'refresh_token' => env('OAUTH_SERVER_REFRESH_TOKEN_TTL', 'P30D'), + 'id_token' => env('OAUTH_SERVER_ID_TOKEN_TTL', 'PT1H'), + ], + + /* + * `prompt=login` re-authenticates a console session older than this many seconds. + */ + 'reauthenticate_after' => (int) env('OAUTH_SERVER_REAUTHENTICATE_AFTER', 300), + + /* + * The console page that shows the consent screen. + */ + 'console_authorize_path' => '/oauth/authorize', +]; diff --git a/migrations/2026_10_25_000001_create_oauth_server_tables.php b/migrations/2026_10_25_000001_create_oauth_server_tables.php new file mode 100644 index 00000000..e5476d1b --- /dev/null +++ b/migrations/2026_10_25_000001_create_oauth_server_tables.php @@ -0,0 +1,115 @@ +char('id', 36)->primary(); + $table->string('name'); + $table->text('description')->nullable(); + // bcrypt hash; null for a public client (a native or single-page app with no secret). + $table->string('secret', 100)->nullable(); + $table->json('redirect_uris'); + $table->json('allowed_scopes'); + $table->string('logo_url')->nullable(); + $table->string('website_url')->nullable(); + $table->string('privacy_url')->nullable(); + $table->string('terms_url')->nullable(); + // Built by Fleetbase (AppHelm). Shown as "Made by Fleetbase" on the consent screen. + $table->boolean('official')->default(false); + // Development clients may use http://127.0.0.1 or http://[::1] on any port (RFC 8252). + $table->boolean('development')->default(false); + $table->char('created_by_uuid', 36)->nullable(); + $table->timestamp('secret_rotated_at')->nullable(); + $table->timestamp('disabled_at')->nullable(); + $table->timestamps(); + }); + + Schema::create('oauth_grants', function (Blueprint $table) { + $table->increments('id'); + $table->char('uuid', 36)->unique(); + $table->foreignUuid('user_uuid')->references('uuid')->on('users')->onUpdate('CASCADE')->onDelete('CASCADE'); + $table->char('client_id', 36); + // Null for a sign-in-only grant (openid, profile, email). + $table->char('company_uuid', 36)->nullable(); + $table->json('scopes'); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('revoked_at')->nullable(); + $table->timestamps(); + + $table->foreign('client_id')->references('id')->on('oauth_clients')->onUpdate('CASCADE')->onDelete('CASCADE'); + $table->index(['user_uuid', 'client_id', 'company_uuid']); + $table->index('company_uuid'); + }); + + Schema::create('oauth_auth_codes', function (Blueprint $table) { + $table->string('id', 100)->primary(); + $table->char('grant_uuid', 36)->index(); + $table->char('client_id', 36); + $table->char('user_uuid', 36); + $table->char('company_uuid', 36)->nullable(); + $table->json('scopes'); + $table->string('nonce', 255)->nullable(); + // When the user last authenticated to the console; becomes the ID token's auth_time. + $table->timestamp('auth_time')->nullable(); + $table->boolean('revoked')->default(false); + $table->dateTime('expires_at'); + $table->timestamp('created_at')->nullable(); + }); + + Schema::create('oauth_access_tokens', function (Blueprint $table) { + $table->string('id', 100)->primary(); + $table->char('grant_uuid', 36)->index(); + $table->char('client_id', 36); + $table->char('user_uuid', 36)->index(); + $table->char('company_uuid', 36)->nullable(); + // The code this token chain started from, so a replayed code can revoke it (RFC 6749 §4.1.2). + $table->string('auth_code_id', 100)->nullable()->index(); + $table->json('scopes'); + $table->boolean('revoked')->default(false); + $table->dateTime('expires_at'); + $table->timestamps(); + }); + + Schema::create('oauth_refresh_tokens', function (Blueprint $table) { + $table->string('id', 100)->primary(); + $table->string('access_token_id', 100)->index(); + $table->char('grant_uuid', 36)->index(); + $table->boolean('revoked')->default(false); + $table->dateTime('expires_at'); + $table->timestamp('created_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_refresh_tokens'); + Schema::dropIfExists('oauth_access_tokens'); + Schema::dropIfExists('oauth_auth_codes'); + Schema::dropIfExists('oauth_grants'); + Schema::dropIfExists('oauth_clients'); + } +}; diff --git a/src/Auth/OAuthServer/Entities/AccessTokenEntity.php b/src/Auth/OAuthServer/Entities/AccessTokenEntity.php new file mode 100644 index 00000000..efb5da1f --- /dev/null +++ b/src/Auth/OAuthServer/Entities/AccessTokenEntity.php @@ -0,0 +1,73 @@ +privateKey = $privateKey; + } + + public function toJwt(): Token + { + $config = Configuration::forAsymmetricSigner( + new Sha256(), + InMemory::plainText($this->privateKey->getKeyContents(), $this->privateKey->getPassPhrase() ?? ''), + InMemory::plainText('empty', 'empty') + ); + + // Whole seconds, so the JWT's dates are integers (lcobucci writes microseconds as floats). + $now = new \DateTimeImmutable('@' . time()); + $builder = $config->builder() + ->issuedBy($this->issuer) + ->permittedFor($this->getClient()->getIdentifier()) + ->identifiedBy($this->getIdentifier()) + ->issuedAt($now) + ->canOnlyBeUsedAfter($now) + ->expiresAt(new \DateTimeImmutable('@' . $this->getExpiryDateTime()->getTimestamp())) + ->relatedTo((string) $this->getUserIdentifier()) + ->withClaim('scopes', array_map(fn ($scope) => $scope->getIdentifier(), $this->getScopes())); + + if ($this->keyId) { + $builder = $builder->withHeader('kid', $this->keyId); + } + + return $builder->getToken($config->signer(), $config->signingKey()); + } + + public function __toString() + { + return $this->jwt ??= $this->toJwt()->toString(); + } +} diff --git a/src/Auth/OAuthServer/Entities/AuthCodeEntity.php b/src/Auth/OAuthServer/Entities/AuthCodeEntity.php new file mode 100644 index 00000000..c4cad06b --- /dev/null +++ b/src/Auth/OAuthServer/Entities/AuthCodeEntity.php @@ -0,0 +1,15 @@ + $redirectUris + */ + public function __construct(string $identifier, string $name, array $redirectUris, bool $confidential) + { + $this->setIdentifier($identifier); + $this->name = $name; + $this->redirectUri = $redirectUris; + $this->isConfidential = $confidential; + } +} diff --git a/src/Auth/OAuthServer/Entities/RefreshTokenEntity.php b/src/Auth/OAuthServer/Entities/RefreshTokenEntity.php new file mode 100644 index 00000000..eccbf7e2 --- /dev/null +++ b/src/Auth/OAuthServer/Entities/RefreshTokenEntity.php @@ -0,0 +1,13 @@ +setIdentifier($identifier); + } +} diff --git a/src/Auth/OAuthServer/Entities/UserEntity.php b/src/Auth/OAuthServer/Entities/UserEntity.php new file mode 100644 index 00000000..102493b3 --- /dev/null +++ b/src/Auth/OAuthServer/Entities/UserEntity.php @@ -0,0 +1,19 @@ +setIdentifier($uuid); + } +} diff --git a/src/Auth/OAuthServer/Exceptions/AuthorizeError.php b/src/Auth/OAuthServer/Exceptions/AuthorizeError.php new file mode 100644 index 00000000..9730a173 --- /dev/null +++ b/src/Auth/OAuthServer/Exceptions/AuthorizeError.php @@ -0,0 +1,48 @@ +redirectUri !== null; + } + + /** + * The URL to send the user back to the app with the error, including `iss` (RFC 9207). + */ + public function redirectUrl(string $issuer): ?string + { + if (!$this->canRedirect()) { + return null; + } + + $params = array_filter([ + 'error' => $this->error, + 'error_description' => $this->description, + 'state' => $this->state, + 'iss' => $issuer, + ], fn ($value) => $value !== null && $value !== ''); + + $separator = str_contains($this->redirectUri, '?') ? '&' : '?'; + + return $this->redirectUri . $separator . http_build_query($params); + } +} diff --git a/src/Auth/OAuthServer/Exceptions/TokenError.php b/src/Auth/OAuthServer/Exceptions/TokenError.php new file mode 100644 index 00000000..38cff67a --- /dev/null +++ b/src/Auth/OAuthServer/Exceptions/TokenError.php @@ -0,0 +1,47 @@ +error . '"'; + if ($this->description) { + $challenge .= ', error_description="' . addslashes($this->description) . '"'; + } + if ($this->scope) { + $challenge .= ', scope="' . $this->scope . '"'; + } + + return new JsonResponse( + array_filter(['error' => $this->error, 'error_description' => $this->description]), + $this->status, + ['WWW-Authenticate' => $challenge, 'Cache-Control' => 'no-store'] + ); + } +} diff --git a/src/Auth/OAuthServer/IdTokenResponse.php b/src/Auth/OAuthServer/IdTokenResponse.php new file mode 100644 index 00000000..dcf016ee --- /dev/null +++ b/src/Auth/OAuthServer/IdTokenResponse.php @@ -0,0 +1,84 @@ + $scope->getIdentifier(), $accessToken->getScopes()); + $params = ['scope' => implode(' ', $scopes)]; + + if (!in_array('openid', $scopes, true)) { + return $params; + } + + $user = User::where('uuid', $accessToken->getUserIdentifier())->first(); + if (!$user) { + return $params; + } + + $company = $this->context->companyUuid ? Company::where('uuid', $this->context->companyUuid)->first() : null; + $config = Configuration::forAsymmetricSigner( + new Sha256(), + InMemory::plainText($this->keys->privateKeyContents()), + InMemory::plainText($this->keys->publicKeyContents()) + ); + + $now = new \DateTimeImmutable('@' . time()); + $builder = $config->builder() + ->withHeader('kid', $this->keys->keyId()) + ->issuedBy($this->issuer) + ->permittedFor($accessToken->getClient()->getIdentifier()) + ->issuedAt($now) + ->expiresAt($now->add($this->ttl)) + ->withClaim('at_hash', static::accessTokenHash((string) $accessToken)); + + foreach (OAuthClaims::forUser($user, $scopes, $company) as $claim => $value) { + $builder = $claim === 'sub' ? $builder->relatedTo($value) : $builder->withClaim($claim, $value); + } + + if ($this->context->authTime) { + $builder = $builder->withClaim('auth_time', $this->context->authTime->getTimestamp()); + } + + if ($this->context->nonce) { + $builder = $builder->withClaim('nonce', $this->context->nonce); + } + + $params['id_token'] = $builder->getToken($config->signer(), $config->signingKey())->toString(); + + return $params; + } + + /** + * OIDC Core §3.1.3.6: base64url of the left half of the SHA-256 of the access token. + */ + public static function accessTokenHash(string $accessToken): string + { + return OAuthKeys::base64url(substr(hash('sha256', $accessToken, true), 0, 16)); + } +} diff --git a/src/Auth/OAuthServer/IssueContext.php b/src/Auth/OAuthServer/IssueContext.php new file mode 100644 index 00000000..7ba689fc --- /dev/null +++ b/src/Auth/OAuthServer/IssueContext.php @@ -0,0 +1,36 @@ +grantUuid = $grantUuid; + $context->companyUuid = $companyUuid; + + return $context; + } +} diff --git a/src/Auth/OAuthServer/OAuthClaims.php b/src/Auth/OAuthServer/OAuthClaims.php new file mode 100644 index 00000000..a710f713 --- /dev/null +++ b/src/Auth/OAuthServer/OAuthClaims.php @@ -0,0 +1,50 @@ + $scopes + * + * @return array + */ + public static function forUser(User $user, array $scopes, ?Company $company = null): array + { + $claims = ['sub' => static::subject($user)]; + + if (in_array('profile', $scopes, true)) { + $claims['name'] = $user->name; + $claims['picture'] = $user->avatar_url; + $claims['zoneinfo'] = $user->timezone; + $claims['updated_at'] = $user->updated_at?->getTimestamp(); + } + + if (in_array('email', $scopes, true)) { + $claims['email'] = $user->email; + $claims['email_verified'] = $user->email_verified_at !== null; + } + + // The organization the tokens are bound to, so the app knows which one the user picked. + if ($company && OAuthScopes::requiresOrganization($scopes)) { + $claims['org_id'] = $company->public_id; + $claims['org_name'] = $company->name; + } + + return $claims; + } + + /** + * The stable identifier for the user: their public_id, which never changes or gets reused. + */ + public static function subject(User $user): string + { + return (string) ($user->public_id ?: $user->uuid); + } +} diff --git a/src/Auth/OAuthServer/OAuthKeys.php b/src/Auth/OAuthServer/OAuthKeys.php new file mode 100644 index 00000000..842ae58a --- /dev/null +++ b/src/Auth/OAuthServer/OAuthKeys.php @@ -0,0 +1,137 @@ +load('private'); + } + + public function publicKeyContents(): string + { + return $this->load('public'); + } + + public function privateKey(): CryptKey + { + return new CryptKey($this->privateKeyContents(), null, false); + } + + public function publicKey(): CryptKey + { + return new CryptKey($this->publicKeyContents(), null, false); + } + + /** + * Whether a key pair is configured. + */ + public function configured(): bool + { + try { + return $this->privateKeyContents() !== '' && $this->publicKeyContents() !== ''; + } catch (\RuntimeException $e) { + return false; + } + } + + /** + * The defuse key league encrypts code and refresh-token payloads with. + * + * A Key object (rather than a password string) skips defuse's per-call PBKDF2 stretching, + * which is meant for human passwords and costs tens of milliseconds per token. + */ + public function encryptionKey(): Key + { + $appKey = (string) config('app.key'); + if (str_starts_with($appKey, 'base64:')) { + $appKey = base64_decode(substr($appKey, 7)); + } + if ($appKey === '') { + throw new \RuntimeException('APP_KEY is not set; the OAuth provider needs it to encrypt authorization codes.'); + } + + $bytes = hash_hkdf('sha256', $appKey, Key::KEY_BYTE_SIZE, 'fleetbase-oauth-server-payloads'); + + return Key::loadFromAsciiSafeString(Encoding::saveBytesToChecksummedAsciiSafeString(Key::KEY_CURRENT_VERSION, $bytes)); + } + + /** + * The JWK for the public key, with its RFC 7638 thumbprint as `kid`. + * + * @return array + */ + public function jwk(): array + { + $details = openssl_pkey_get_details(openssl_pkey_get_public($this->publicKeyContents())); + $n = static::base64url($details['rsa']['n']); + $e = static::base64url($details['rsa']['e']); + + return [ + 'kty' => 'RSA', + 'use' => 'sig', + 'alg' => 'RS256', + 'kid' => static::base64url(hash('sha256', json_encode(['e' => $e, 'kty' => 'RSA', 'n' => $n]), true)), + 'n' => $n, + 'e' => $e, + ]; + } + + public function keyId(): string + { + return $this->jwk()['kid']; + } + + /** + * Generate a new RSA key pair. + * + * @return array{private: string, public: string} + */ + public static function generate(int $bits = 4096): array + { + $resource = openssl_pkey_new(['private_key_bits' => $bits, 'private_key_type' => OPENSSL_KEYTYPE_RSA]); + openssl_pkey_export($resource, $private); + $public = openssl_pkey_get_details($resource)['key']; + + return ['private' => $private, 'public' => $public]; + } + + public static function keyPath(string $type): string + { + return storage_path("oauth-{$type}.key"); + } + + public static function base64url(string $bytes): string + { + return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '='); + } + + protected function load(string $type): string + { + $configured = str_replace('\\n', "\n", (string) config("oauth-server.{$type}_key", '')); + if (trim($configured) !== '') { + return $configured; + } + + $path = static::keyPath($type); + if (is_readable($path)) { + return (string) file_get_contents($path); + } + + throw new \RuntimeException("The OAuth provider's {$type} key is not configured. Run `php artisan oauth-server:keys` or set OAUTH_SERVER_" . strtoupper($type) . '_KEY.'); + } +} diff --git a/src/Auth/OAuthServer/OAuthOrganizations.php b/src/Auth/OAuthServer/OAuthOrganizations.php new file mode 100644 index 00000000..b87a91a8 --- /dev/null +++ b/src/Auth/OAuthServer/OAuthOrganizations.php @@ -0,0 +1,101 @@ +uuid) + ->where('company_uuid', $company->uuid) + ->where(function ($query) { + $query->whereNull('status')->orWhere('status', 'active'); + }) + ->first(); + } + + public static function canShare(User $user, Company $company): bool + { + if (!static::userIsActive($user)) { + return false; + } + + $membership = static::membership($user, $company); + if (!$membership) { + return false; + } + + if ($user->isAdmin() || $company->owner_uuid === $user->uuid) { + return true; + } + + if (static::$administratorCheck) { + return (bool) (static::$administratorCheck)($membership); + } + + return $membership->hasRole('Administrator'); + } + + public static function userIsActive(User $user): bool + { + return ($user->status ?? 'active') !== 'inactive' && !$user->trashed(); + } + + /** + * The user's organizations for the consent screen's picker, with whether each can be shared. + * + * @return Collection + */ + public static function forPicker(User $user): Collection + { + $companies = Company::whereIn('uuid', CompanyUser::where('user_uuid', $user->uuid)->pluck('company_uuid')) + ->whereNotNull('owner_uuid') + ->orderBy('name') + ->get(); + + return $companies->map(fn (Company $company) => [ + 'id' => $company->public_id, + 'uuid' => $company->uuid, + 'name' => $company->name, + 'logo_url' => $company->logo_url, + 'can_share' => static::canShare($user, $company), + ])->values(); + } + + /** + * Find one of the user's organizations by public_id or uuid. + */ + public static function find(User $user, ?string $id): ?Company + { + if (!$id) { + return null; + } + + $company = Company::where(fn ($query) => $query->where('public_id', $id)->orWhere('uuid', $id))->first(); + if (!$company || !static::membership($user, $company)) { + return null; + } + + return $company; + } +} diff --git a/src/Auth/OAuthServer/OAuthScopes.php b/src/Auth/OAuthServer/OAuthScopes.php new file mode 100644 index 00000000..1081cb2d --- /dev/null +++ b/src/Auth/OAuthServer/OAuthScopes.php @@ -0,0 +1,141 @@ + "See {organization}'s storefronts and networks, with their public keys", + * 'organization' => true, + * ]); + * + * - description: the consent-screen line, in plain words. `{organization}` is replaced with the + * organization the user picked. + * - organization: true when the scope reads or changes an organization's data. A request with + * any such scope makes the user pick an organization they administer, and binds the tokens to it. + * - review_access_types: App Review subject types (Fleetbase\Support\AppReview) this scope lets + * the app turn on or off through /oauth/v1/review-access/{type}/{id}. + */ +class OAuthScopes +{ + /** + * @var array}> + */ + protected static array $scopes = []; + + /** + * Register (or replace) a scope. + */ + public static function register(string $scope, array $definition): void + { + if (!preg_match('/^[a-z][a-z0-9_.\-]*(:[a-z0-9_.\-]+)*$/', $scope)) { + throw new \InvalidArgumentException("Invalid OAuth scope name [{$scope}]."); + } + + static::$scopes[$scope] = [ + 'description' => (string) ($definition['description'] ?? $scope), + 'organization' => (bool) ($definition['organization'] ?? false), + 'review_access_types' => array_values($definition['review_access_types'] ?? []), + ]; + } + + /** + * The scopes core registers. Called from CoreServiceProvider::boot(). + */ + public static function registerDefaults(): void + { + static::register('openid', ['description' => 'Sign you in with your Fleetbase account']); + static::register('profile', ['description' => 'See your name and profile photo']); + static::register('email', ['description' => 'See your email address']); + static::register('organization:read', [ + 'description' => "See {organization}'s name, logo, currency and country", + 'organization' => true, + ]); + } + + public static function has(string $scope): bool + { + return isset(static::$scopes[$scope]); + } + + /** + * @return array}> + */ + public static function all(): array + { + return static::$scopes; + } + + public static function get(string $scope): ?array + { + return static::$scopes[$scope] ?? null; + } + + /** + * Whether any of the scopes needs an organization. + * + * @param array $scopes + */ + public static function requiresOrganization(array $scopes): bool + { + foreach ($scopes as $scope) { + if (static::$scopes[$scope]['organization'] ?? false) { + return true; + } + } + + return false; + } + + /** + * The scope that covers App Review access for a subject type, if any. + */ + public static function scopeForReviewAccessType(string $type): ?string + { + foreach (static::$scopes as $scope => $definition) { + if (in_array($type, $definition['review_access_types'], true)) { + return $scope; + } + } + + return null; + } + + /** + * The consent-screen lines for a set of scopes. + * + * @param array $scopes + * + * @return array + */ + public static function describe(array $scopes, ?string $organizationName = null): array + { + $lines = []; + foreach ($scopes as $scope) { + $definition = static::$scopes[$scope] ?? null; + if (!$definition) { + continue; + } + + $lines[] = [ + 'scope' => $scope, + 'description' => str_replace('{organization}', $organizationName ?: 'your organization', $definition['description']), + 'organization' => $definition['organization'], + ]; + } + + return $lines; + } + + /** + * Forget every registration. For tests. + */ + public static function flush(): void + { + static::$scopes = []; + } +} diff --git a/src/Auth/OAuthServer/OAuthServer.php b/src/Auth/OAuthServer/OAuthServer.php new file mode 100644 index 00000000..2ec34a1d --- /dev/null +++ b/src/Auth/OAuthServer/OAuthServer.php @@ -0,0 +1,602 @@ +keys ??= new OAuthKeys(); + } + + public function keys(): OAuthKeys + { + return $this->keys; + } + + /** + * The issuer identifier, without a trailing slash. + */ + public function issuer(): string + { + $issuer = config('oauth-server.issuer') ?: config('app.url'); + + return rtrim((string) $issuer, '/'); + } + + public function endpoint(string $path): string + { + return $this->issuer() . '/' . ltrim($path, '/'); + } + + public function ttl(string $name): \DateInterval + { + return new \DateInterval((string) config("oauth-server.ttl.{$name}", ['auth_code' => 'PT10M', 'access_token' => 'PT1H', 'refresh_token' => 'P30D', 'id_token' => 'PT1H'][$name])); + } + + /** + * League's authorization server, wired to Fleetbase's repositories for one request. + */ + public function authorizationServer(IssueContext $context): AuthorizationServer + { + $server = new AuthorizationServer( + new ClientRepository(), + new AccessTokenRepository($context, $this->issuer(), $this->keys->keyId()), + new ScopeRepository(), + $this->keys->privateKey(), + $this->keys->encryptionKey(), + new IdTokenResponse($context, $this->keys, $this->issuer(), $this->ttl('id_token')) + ); + + $authCodeGrant = new AuthCodeGrant(new AuthCodeRepository($context), new RefreshTokenRepository($context), $this->ttl('auth_code')); + $authCodeGrant->setRefreshTokenTTL($this->ttl('refresh_token')); + $server->enableGrantType($authCodeGrant, $this->ttl('access_token')); + + $refreshTokenGrant = new RefreshTokenGrant(new RefreshTokenRepository($context)); + $refreshTokenGrant->setRefreshTokenTTL($this->ttl('refresh_token')); + $server->enableGrantType($refreshTokenGrant, $this->ttl('access_token')); + + return $server; + } + + public function resourceServer(): ResourceServer + { + return new ResourceServer(new AccessTokenRepository(new IssueContext(), $this->issuer()), $this->keys->publicKey()); + } + + /* + |-------------------------------------------------------------------------- + | Authorization (the consent screen) + |-------------------------------------------------------------------------- + */ + + /** + * Check an authorization request's parameters. + * + * @param array $params the authorization request's query parameters + * + * @throws AuthorizeError + */ + public function validateAuthorizationRequest(array $params): ValidatedAuthorization + { + $string = fn (string $key) => is_string($params[$key] ?? null) && $params[$key] !== '' ? $params[$key] : null; + + $client = (new ClientRepository())->findActive($string('client_id')); + if (!$client) { + throw new AuthorizeError('invalid_client', 'This app is not registered with Fleetbase, or it has been turned off.'); + } + + $redirectUri = $string('redirect_uri'); + if (!$client->allowsRedirectUri($redirectUri)) { + throw new AuthorizeError('invalid_request', "The redirect URI doesn't match one registered for {$client->name}."); + } + + // From here on the redirect URI is trusted, so errors go back to the app. + $state = $string('state'); + $fail = fn (string $error, string $description) => new AuthorizeError($error, $description, $redirectUri, $state); + + foreach ($params as $key => $value) { + if (!is_string($value)) { + throw $fail('invalid_request', "The parameter `{$key}` must be a single value."); + } + } + + if ($string('response_type') !== 'code') { + throw $fail('unsupported_response_type', 'Only response_type=code is supported.'); + } + + if (!$string('code_challenge')) { + throw $fail('invalid_request', 'PKCE is required: send code_challenge and code_challenge_method=S256.'); + } + + if ($string('code_challenge_method') !== 'S256') { + throw $fail('invalid_request', 'code_challenge_method must be S256.'); + } + + $scopes = array_values(array_unique(array_filter(explode(' ', (string) $string('scope'))))); + if (!$scopes) { + throw $fail('invalid_scope', 'Ask for at least one scope.'); + } + foreach ($scopes as $scope) { + if (!OAuthScopes::has($scope) || !$client->allowsScope($scope)) { + throw $fail('invalid_scope', "The scope `{$scope}` isn't available to this app."); + } + } + + $prompt = array_values(array_filter(explode(' ', (string) $string('prompt')))); + if (in_array('none', $prompt, true)) { + // There is no silent authorization: the consent screen runs in the console, which + // the app can't check without showing it. + throw $fail(count($prompt) > 1 ? 'invalid_request' : 'interaction_required', 'prompt=none is not supported; the user has to see the consent screen.'); + } + + $nonce = $string('nonce'); + if ($nonce !== null && strlen($nonce) > 255) { + throw $fail('invalid_request', 'nonce must be at most 255 characters.'); + } + + $maxAge = $string('max_age'); + if ($maxAge !== null && !ctype_digit($maxAge)) { + throw $fail('invalid_request', 'max_age must be a whole number of seconds.'); + } + + try { + $request = $this->authorizationServer(new IssueContext())->validateAuthorizationRequest($this->queryRequest($params)); + } catch (OAuthServerException $e) { + throw $fail($e->getErrorType(), $e->getHint() ?: $e->getMessage()); + } + + return new ValidatedAuthorization($client, $request, $scopes, $redirectUri, $state, $nonce, $prompt, $maxAge !== null ? (int) $maxAge : null); + } + + /** + * Whether the user has to sign in again first (`prompt=login`, or `max_age` exceeded). + */ + public function needsReauthentication(ValidatedAuthorization $authorization, ?\DateTimeInterface $authTime): bool + { + if (!$authTime) { + return $authorization->prompts('login') || $authorization->maxAge !== null; + } + + $age = time() - $authTime->getTimestamp(); + if ($authorization->prompts('login') && $age > (int) config('oauth-server.reauthenticate_after', 300)) { + return true; + } + + return $authorization->maxAge !== null && $age > $authorization->maxAge; + } + + /** + * The user's live grant for this app and organization, if any. + */ + public function existingGrant(User $user, OAuthClient $client, ?Company $company): ?OAuthGrant + { + return OAuthGrant::active() + ->where('user_uuid', $user->uuid) + ->where('client_id', $client->id) + ->where(fn ($query) => $company ? $query->where('company_uuid', $company->uuid) : $query->whereNull('company_uuid')) + ->first(); + } + + /** + * Whether the consent screen can be skipped: sign-in only, already approved, not forced. + */ + public function canSkipConsent(ValidatedAuthorization $authorization, User $user): bool + { + if ($authorization->requiresOrganization() || $authorization->prompts('consent')) { + return false; + } + + $grant = $this->existingGrant($user, $authorization->client, null); + + return $grant && empty(array_diff($authorization->scopes, $grant->scopes ?? [])); + } + + /** + * The user approved: record the grant, issue the code and return where to send the browser. + * + * @return array{redirect: string, grant: OAuthGrant, created: bool} + * + * @throws AuthorizeError when the organization can't be shared + */ + public function approve(ValidatedAuthorization $authorization, User $user, ?Company $company, ?\DateTimeInterface $authTime): array + { + if ($authorization->requiresOrganization()) { + if (!$company || !OAuthOrganizations::canShare($user, $company)) { + throw new AuthorizeError('access_denied', 'Choose an organization you administer to share with this app.'); + } + } else { + $company = null; + } + + [$grant, $created] = DB::connection((new OAuthGrant())->getConnectionName())->transaction(function () use ($authorization, $user, $company) { + $grant = $this->existingGrant($user, $authorization->client, $company); + $created = $grant === null; + $grant ??= new OAuthGrant([ + 'user_uuid' => $user->uuid, + 'client_id' => $authorization->client->id, + 'company_uuid' => $company?->uuid, + ]); + $grant->scopes = array_values(array_unique(array_merge($grant->scopes ?? [], $authorization->scopes))); + $grant->save(); + + return [$grant, $created]; + }); + + $context = IssueContext::forGrant($grant->uuid, $company?->uuid); + $context->nonce = $authorization->nonce; + $context->authTime = $authTime; + + $authorization->request->setUser(new UserEntity($user->uuid)); + $authorization->request->setAuthorizationApproved(true); + + $response = $this->authorizationServer($context)->completeAuthorizationRequest($authorization->request, new Psr7Response()); + + return [ + 'redirect' => $this->withIssuer($response->getHeaderLine('Location')), + 'grant' => $grant, + 'created' => $created, + ]; + } + + /** + * The user declined: where to send the browser. + */ + public function deny(ValidatedAuthorization $authorization): string + { + return (new AuthorizeError('access_denied', 'The user declined to share access.', $authorization->redirectUri, $authorization->state))->redirectUrl($this->issuer()); + } + + /* + |-------------------------------------------------------------------------- + | Token endpoint + |-------------------------------------------------------------------------- + */ + + public function respondToTokenRequest(Request $request): Response + { + $context = new IssueContext(); + + try { + $this->loadTokenContext($request->request->all(), $context); + $response = $this->authorizationServer($context)->respondToAccessTokenRequest($this->psrRequest($request), new Psr7Response()); + + if ($context->grantUuid) { + OAuthGrant::find($context->grantUuid)?->touchLastUsed(); + } + } catch (OAuthServerException $e) { + $response = $e->generateHttpResponse(new Psr7Response()); + } + + return $this->laravelResponse($response); + } + + /** + * Find the grant and organization behind the code or refresh token being redeemed. + * + * @param array $params + * + * @throws OAuthServerException when the grant no longer allows new tokens + */ + protected function loadTokenContext(array $params, IssueContext $context): void + { + $crypt = new PayloadCrypt($this->keys->encryptionKey()); + + if (($params['grant_type'] ?? null) === 'authorization_code') { + $payload = $crypt->open($params['code'] ?? null); + $code = isset($payload['auth_code_id']) ? OAuthAuthCode::find($payload['auth_code_id']) : null; + if (!$code) { + return; + } + + $context->grantUuid = $code->grant_uuid; + $context->companyUuid = $code->company_uuid; + $context->nonce = $code->nonce; + $context->authTime = $code->auth_time; + $context->authCodeId = $code->id; + + // A replayed code is left to league, whose revoked-code check also revokes its tokens. + if (!$code->revoked) { + $this->assertGrantUsable(OAuthGrant::find($code->grant_uuid)); + } + + return; + } + + if (($params['grant_type'] ?? null) === 'refresh_token') { + $payload = $crypt->open($params['refresh_token'] ?? null); + $refreshToken = isset($payload['refresh_token_id']) ? OAuthRefreshToken::find($payload['refresh_token_id']) : null; + if (!$refreshToken) { + return; + } + + $grant = OAuthGrant::find($refreshToken->grant_uuid); + $context->grantUuid = $refreshToken->grant_uuid; + $context->companyUuid = $grant?->company_uuid; + $context->authCodeId = OAuthAccessToken::where('id', $refreshToken->access_token_id)->value('auth_code_id'); + + // A replayed refresh token is left to league, whose revoked check revokes the grant. + if (!$refreshToken->revoked) { + $this->assertGrantUsable($grant); + } + } + } + + /** + * @throws OAuthServerException + */ + protected function assertGrantUsable(?OAuthGrant $grant): void + { + if (!$grant || $grant->isRevoked()) { + throw OAuthServerException::invalidGrant('The user removed this app\'s access.'); + } + + $user = User::where('uuid', $grant->user_uuid)->first(); + if (!$user || !OAuthOrganizations::userIsActive($user)) { + $grant->revoke(); + + throw OAuthServerException::invalidGrant('The account that authorized this app is no longer active.'); + } + + if ($grant->company_uuid) { + $company = Company::where('uuid', $grant->company_uuid)->first(); + if (!$company || !OAuthOrganizations::canShare($user, $company)) { + $grant->revoke(); + + throw OAuthServerException::invalidGrant('The user no longer administers the organization this app was given.'); + } + } + } + + /* + |-------------------------------------------------------------------------- + | Revocation (RFC 7009) + |-------------------------------------------------------------------------- + */ + + /** + * Revoke a refresh token (which disconnects the app: the whole grant) or an access token. + * + * Answers 200 whether or not the token was known, as RFC 7009 §2.2 asks. + */ + public function revoke(Request $request): Response|JsonResponse + { + [$clientId, $secret] = $this->clientCredentials($request); + $client = (new ClientRepository())->findActive($clientId); + $authenticated = $client && ($client->isConfidential() ? $client->verifySecret($secret) : ($secret === null || $secret === '')); + if (!$authenticated) { + return new JsonResponse(['error' => 'invalid_client', 'error_description' => 'Client authentication failed.'], 401, ['WWW-Authenticate' => 'Basic realm="OAuth"', 'Cache-Control' => 'no-store']); + } + + $token = $request->input('token'); + if (!is_string($token) || $token === '') { + return new JsonResponse(['error' => 'invalid_request', 'error_description' => 'Send the token to revoke.'], 400, ['Cache-Control' => 'no-store']); + } + + $payload = (new PayloadCrypt($this->keys->encryptionKey()))->open($token); + if ($payload && ($payload['client_id'] ?? null) === $client->id && isset($payload['refresh_token_id'])) { + $refreshToken = OAuthRefreshToken::find($payload['refresh_token_id']); + if ($refreshToken) { + OAuthGrant::find($refreshToken->grant_uuid)?->revoke(); + } + } elseif ($tokenId = $this->accessTokenId($token)) { + OAuthAccessToken::where('id', $tokenId)->where('client_id', $client->id)->update(['revoked' => true, 'updated_at' => now()]); + } + + return new Response('', 200, ['Cache-Control' => 'no-store']); + } + + /** + * The `jti` of an access token this server signed, or null. + */ + protected function accessTokenId(string $jwt): ?string + { + try { + $config = Configuration::forSymmetricSigner(new Sha256(), InMemory::plainText('empty', 'empty')); + $token = $config->parser()->parse($jwt); + if (!$config->validator()->validate($token, new SignedWith(new Sha256(), InMemory::plainText($this->keys->publicKeyContents())))) { + return null; + } + + return $token->claims()->get('jti'); + } catch (\Throwable $e) { + return null; + } + } + + /** + * Client credentials from HTTP Basic (client_secret_basic) or the body (client_secret_post). + * + * @return array{0: ?string, 1: ?string} + */ + protected function clientCredentials(Request $request): array + { + $header = (string) $request->header('Authorization'); + if (stripos($header, 'Basic ') === 0) { + $decoded = base64_decode(substr($header, 6), true); + if ($decoded !== false && str_contains($decoded, ':')) { + [$id, $secret] = explode(':', $decoded, 2); + + return [urldecode($id), urldecode($secret)]; + } + } + + $id = $request->input('client_id'); + $secret = $request->input('client_secret'); + + return [is_string($id) ? $id : null, is_string($secret) ? $secret : null]; + } + + /* + |-------------------------------------------------------------------------- + | Protected resources + |-------------------------------------------------------------------------- + */ + + /** + * Check the bearer token on a request to an OAuth-protected endpoint. + * + * @param array $scopes every scope the endpoint needs + * + * @throws TokenError + */ + public function authenticate(Request $request, array $scopes = []): OAuthAccessToken + { + try { + $validated = $this->resourceServer()->validateAuthenticatedRequest($this->psrRequest($request)); + } catch (OAuthServerException $e) { + throw TokenError::invalidToken(); + } + + $token = OAuthAccessToken::with(['grant', 'client', 'user', 'company'])->find($validated->getAttribute('oauth_access_token_id')); + if (!$token || !$token->isUsable() || !$token->grant || $token->grant->isRevoked() || !$token->client || $token->client->isDisabled()) { + throw TokenError::invalidToken(); + } + + if (!$token->user || !OAuthOrganizations::userIsActive($token->user)) { + throw TokenError::invalidToken(); + } + + if ($token->company_uuid && (!$token->company || !OAuthOrganizations::canShare($token->user, $token->company))) { + $token->grant->revoke(); + + throw TokenError::invalidToken('The user no longer administers the organization this app was given.'); + } + + // The client's registration is checked too, so an admin removing a scope from an app + // takes it away from tokens already issued. + foreach ($scopes as $scope) { + if (!$token->hasScope($scope) || !$token->client->allowsScope($scope)) { + throw TokenError::insufficientScope($scope); + } + } + + $token->grant->touchLastUsed(); + + return $token; + } + + /* + |-------------------------------------------------------------------------- + | Discovery + |-------------------------------------------------------------------------- + */ + + /** + * OpenID Connect Discovery 1.0 / RFC 8414 metadata. + * + * @return array + */ + public function discovery(): array + { + return [ + 'issuer' => $this->issuer(), + 'authorization_endpoint' => $this->endpoint('oauth/authorize'), + 'token_endpoint' => $this->endpoint('oauth/token'), + 'userinfo_endpoint' => $this->endpoint('oauth/userinfo'), + 'jwks_uri' => $this->endpoint('oauth/jwks'), + 'revocation_endpoint' => $this->endpoint('oauth/revoke'), + 'scopes_supported' => array_keys(OAuthScopes::all()), + 'response_types_supported' => ['code'], + 'response_modes_supported' => ['query'], + 'grant_types_supported' => ClientRepository::GRANT_TYPES, + 'subject_types_supported' => ['public'], + 'id_token_signing_alg_values_supported' => ['RS256'], + 'token_endpoint_auth_methods_supported' => ['client_secret_basic', 'client_secret_post', 'none'], + 'revocation_endpoint_auth_methods_supported' => ['client_secret_basic', 'client_secret_post', 'none'], + 'code_challenge_methods_supported' => ['S256'], + 'prompt_values_supported' => ['login', 'consent'], + 'claims_supported' => ['sub', 'iss', 'aud', 'exp', 'iat', 'auth_time', 'nonce', 'at_hash', 'name', 'picture', 'zoneinfo', 'updated_at', 'email', 'email_verified', 'org_id', 'org_name'], + 'authorization_response_iss_parameter_supported' => true, + ]; + } + + /* + |-------------------------------------------------------------------------- + | PSR-7 plumbing + |-------------------------------------------------------------------------- + */ + + public function psrRequest(Request $request): ServerRequestInterface + { + $factory = new Psr17Factory(); + + return (new PsrHttpFactory($factory, $factory, $factory, $factory))->createRequest($request); + } + + /** + * A GET request carrying only these query parameters, for league's authorization checks. + * + * @param array $params + */ + protected function queryRequest(array $params): ServerRequestInterface + { + return (new ServerRequest('GET', $this->endpoint('oauth/authorize') . '?' . http_build_query($params)))->withQueryParams($params); + } + + public function laravelResponse(ResponseInterface $response): Response + { + $headers = []; + foreach ($response->getHeaders() as $name => $values) { + $headers[$name] = implode(', ', $values); + } + + return new Response((string) $response->getBody(), $response->getStatusCode(), $headers); + } + + /** + * Add `iss` to an authorization response (RFC 9207), so the app can detect mix-up attacks. + */ + protected function withIssuer(string $location): string + { + return $location . (str_contains($location, '?') ? '&' : '?') . 'iss=' . rawurlencode($this->issuer()); + } +} diff --git a/src/Auth/OAuthServer/PayloadCrypt.php b/src/Auth/OAuthServer/PayloadCrypt.php new file mode 100644 index 00000000..302ee449 --- /dev/null +++ b/src/Auth/OAuthServer/PayloadCrypt.php @@ -0,0 +1,41 @@ +setEncryptionKey($key); + } + + /** + * @return array|null + */ + public function open(mixed $payload): ?array + { + if (!is_string($payload) || $payload === '') { + return null; + } + + try { + $decoded = json_decode($this->decrypt($payload), true); + } catch (\Throwable $e) { + return null; + } + + return is_array($decoded) ? $decoded : null; + } +} diff --git a/src/Auth/OAuthServer/Repositories/AccessTokenRepository.php b/src/Auth/OAuthServer/Repositories/AccessTokenRepository.php new file mode 100644 index 00000000..218160b7 --- /dev/null +++ b/src/Auth/OAuthServer/Repositories/AccessTokenRepository.php @@ -0,0 +1,60 @@ +issuer, $this->keyId); + $token->setClient($clientEntity); + $token->setUserIdentifier($userIdentifier); + foreach ($scopes as $scope) { + $token->addScope($scope); + } + + return $token; + } + + public function persistNewAccessToken(AccessTokenEntityInterface $accessTokenEntity) + { + if (!$this->context->grantUuid) { + throw new \LogicException('An access token can only be issued under a grant.'); + } + + OAuthAccessToken::create([ + 'id' => $accessTokenEntity->getIdentifier(), + 'grant_uuid' => $this->context->grantUuid, + 'client_id' => $accessTokenEntity->getClient()->getIdentifier(), + 'user_uuid' => $accessTokenEntity->getUserIdentifier(), + 'company_uuid' => $this->context->companyUuid, + 'auth_code_id' => $this->context->authCodeId, + 'scopes' => array_map(fn ($scope) => $scope->getIdentifier(), $accessTokenEntity->getScopes()), + 'revoked' => false, + 'expires_at' => $accessTokenEntity->getExpiryDateTime(), + ]); + } + + public function revokeAccessToken($tokenId) + { + OAuthAccessToken::where('id', $tokenId)->update(['revoked' => true, 'updated_at' => now()]); + } + + public function isAccessTokenRevoked($tokenId) + { + $token = OAuthAccessToken::find($tokenId); + + return !$token || $token->revoked; + } +} diff --git a/src/Auth/OAuthServer/Repositories/AuthCodeRepository.php b/src/Auth/OAuthServer/Repositories/AuthCodeRepository.php new file mode 100644 index 00000000..4da965c9 --- /dev/null +++ b/src/Auth/OAuthServer/Repositories/AuthCodeRepository.php @@ -0,0 +1,73 @@ +context->grantUuid) { + throw new \LogicException('An authorization code can only be issued under a grant.'); + } + + OAuthAuthCode::create([ + 'id' => $authCodeEntity->getIdentifier(), + 'grant_uuid' => $this->context->grantUuid, + 'client_id' => $authCodeEntity->getClient()->getIdentifier(), + 'user_uuid' => $authCodeEntity->getUserIdentifier(), + 'company_uuid' => $this->context->companyUuid, + 'scopes' => array_map(fn ($scope) => $scope->getIdentifier(), $authCodeEntity->getScopes()), + 'nonce' => $this->context->nonce, + 'auth_time' => $this->context->authTime, + 'revoked' => false, + 'expires_at' => $authCodeEntity->getExpiryDateTime(), + 'created_at' => now(), + ]); + } + + public function revokeAuthCode($codeId) + { + OAuthAuthCode::where('id', $codeId)->update(['revoked' => true]); + } + + /** + * A code that was already redeemed is being presented again. RFC 6749 §4.1.2: the server + * SHOULD revoke every token issued from it, since one of the two holders is an attacker. + */ + public function isAuthCodeRevoked($codeId) + { + $code = OAuthAuthCode::find($codeId); + if (!$code) { + return true; + } + + if ($code->revoked) { + $tokenIds = OAuthAccessToken::where('auth_code_id', $codeId)->pluck('id')->all(); + if ($tokenIds) { + OAuthAccessToken::whereIn('id', $tokenIds)->update(['revoked' => true, 'updated_at' => now()]); + OAuthRefreshToken::whereIn('access_token_id', $tokenIds)->update(['revoked' => true]); + } + + return true; + } + + return false; + } +} diff --git a/src/Auth/OAuthServer/Repositories/ClientRepository.php b/src/Auth/OAuthServer/Repositories/ClientRepository.php new file mode 100644 index 00000000..d4ed7ff8 --- /dev/null +++ b/src/Auth/OAuthServer/Repositories/ClientRepository.php @@ -0,0 +1,49 @@ +findActive($clientIdentifier); + if (!$client) { + return null; + } + + return new ClientEntity($client->id, $client->name, $client->redirect_uris ?? [], $client->isConfidential()); + } + + public function validateClient($clientIdentifier, $clientSecret, $grantType) + { + $client = $this->findActive($clientIdentifier); + if (!$client || !in_array($grantType, static::GRANT_TYPES, true)) { + return false; + } + + if ($client->isConfidential()) { + return $client->verifySecret($clientSecret); + } + + // A public client has no secret to present; sending one means it was misconfigured. + return $clientSecret === null || $clientSecret === ''; + } + + public function findActive($clientIdentifier): ?OAuthClient + { + if (!is_string($clientIdentifier) || !preg_match('/^[0-9a-f-]{36}$/i', $clientIdentifier)) { + return null; + } + + return OAuthClient::where('id', $clientIdentifier)->whereNull('disabled_at')->first(); + } +} diff --git a/src/Auth/OAuthServer/Repositories/RefreshTokenRepository.php b/src/Auth/OAuthServer/Repositories/RefreshTokenRepository.php new file mode 100644 index 00000000..a6677ba6 --- /dev/null +++ b/src/Auth/OAuthServer/Repositories/RefreshTokenRepository.php @@ -0,0 +1,67 @@ +context->grantUuid) { + throw new \LogicException('A refresh token can only be issued under a grant.'); + } + + OAuthRefreshToken::create([ + 'id' => $refreshTokenEntity->getIdentifier(), + 'access_token_id' => $refreshTokenEntity->getAccessToken()->getIdentifier(), + 'grant_uuid' => $this->context->grantUuid, + 'revoked' => false, + 'expires_at' => $refreshTokenEntity->getExpiryDateTime(), + 'created_at' => now(), + ]); + } + + public function revokeRefreshToken($tokenId) + { + OAuthRefreshToken::where('id', $tokenId)->update(['revoked' => true]); + } + + /** + * Refresh tokens rotate on every use, so a revoked one coming back means it was copied: either + * the client or a thief already used it. Revoke the whole grant so neither copy keeps working + * (OAuth 2.0 Security BCP, RFC 9700 §4.14.2). + */ + public function isRefreshTokenRevoked($tokenId) + { + $token = OAuthRefreshToken::find($tokenId); + if (!$token) { + return true; + } + + if ($token->revoked) { + $grant = OAuthGrant::find($token->grant_uuid); + if ($grant && !$grant->isRevoked()) { + $grant->revoke(); + } + + return true; + } + + return false; + } +} diff --git a/src/Auth/OAuthServer/Repositories/ScopeRepository.php b/src/Auth/OAuthServer/Repositories/ScopeRepository.php new file mode 100644 index 00000000..c84be8a9 --- /dev/null +++ b/src/Auth/OAuthServer/Repositories/ScopeRepository.php @@ -0,0 +1,36 @@ +getIdentifier()); + if (!$client) { + return []; + } + + return array_values(array_filter($scopes, function ($scope) use ($client) { + return OAuthScopes::has($scope->getIdentifier()) && $client->allowsScope($scope->getIdentifier()); + })); + } +} diff --git a/src/Auth/OAuthServer/ValidatedAuthorization.php b/src/Auth/OAuthServer/ValidatedAuthorization.php new file mode 100644 index 00000000..bafd601a --- /dev/null +++ b/src/Auth/OAuthServer/ValidatedAuthorization.php @@ -0,0 +1,38 @@ + $scopes + * @param array $prompt + */ + public function __construct( + public OAuthClient $client, + public AuthorizationRequest $request, + public array $scopes, + public string $redirectUri, + public ?string $state, + public ?string $nonce, + public array $prompt, + public ?int $maxAge, + ) { + } + + public function requiresOrganization(): bool + { + return OAuthScopes::requiresOrganization($this->scopes); + } + + public function prompts(string $value): bool + { + return in_array($value, $this->prompt, true); + } +} diff --git a/src/Console/Commands/OAuthServerClient.php b/src/Console/Commands/OAuthServerClient.php new file mode 100644 index 00000000..199e048f --- /dev/null +++ b/src/Console/Commands/OAuthServerClient.php @@ -0,0 +1,120 @@ +argument('action')) { + 'create' => $this->create(), + 'rotate' => $this->rotate(), + 'list' => $this->list(), + default => $this->refuse('Unknown action; use create, rotate or list.'), + }; + } + + protected function create(): int + { + $name = $this->option('name'); + $redirects = $this->option('redirect'); + $scopes = $this->option('scope') ?: array_keys(OAuthScopes::all()); + if (!$name || !$redirects) { + return $this->refuse('--name and at least one --redirect are required.'); + } + + foreach ($redirects as $uri) { + if (!OAuthClient::isAcceptableRedirectUri($uri, (bool) $this->option('development'))) { + return $this->refuse("`{$uri}` must be https (http only for loopback on a --development app), without a fragment."); + } + } + foreach ($scopes as $scope) { + if (!OAuthScopes::has($scope)) { + return $this->refuse("`{$scope}` is not a registered scope."); + } + } + + $client = new OAuthClient([ + 'name' => $name, + 'redirect_uris' => array_values(array_unique($redirects)), + 'allowed_scopes' => array_values(array_unique($scopes)), + 'website_url' => $this->option('website'), + 'logo_url' => $this->option('logo'), + 'privacy_url' => $this->option('privacy'), + 'terms_url' => $this->option('terms'), + 'official' => (bool) $this->option('official'), + 'development' => (bool) $this->option('development'), + ]); + $secret = $this->option('public') ? null : $client->issueSecret(); + $client->save(); + + $this->info('Registered ' . $client->name . '.'); + $this->line('client_id: ' . $client->id); + if ($secret) { + $this->line('client_secret: ' . $secret); + $this->warn('Store the secret now; it is not shown again.'); + } + + return self::SUCCESS; + } + + protected function rotate(): int + { + $client = OAuthClient::find($this->argument('id')); + if (!$client || !$client->isConfidential()) { + return $this->refuse('No confidential app with that client id.'); + } + + $secret = $client->issueSecret(); + $client->save(); + + $this->line('client_secret: ' . $secret); + $this->warn('The old secret stopped working. Store the new one now; it is not shown again.'); + + return self::SUCCESS; + } + + protected function list(): int + { + $this->table(['client_id', 'name', 'type', 'redirect URIs', 'status'], OAuthClient::orderBy('name')->get()->map(fn (OAuthClient $client) => [ + $client->id, + $client->name, + $client->isConfidential() ? 'confidential' : 'public', + implode("\n", $client->redirect_uris ?? []), + $client->isDisabled() ? 'disabled' : 'active', + ])->all()); + + return self::SUCCESS; + } + + protected function refuse(string $message): int + { + $this->error($message); + + return self::FAILURE; + } +} diff --git a/src/Console/Commands/OAuthServerKeys.php b/src/Console/Commands/OAuthServerKeys.php new file mode 100644 index 00000000..cf141c97 --- /dev/null +++ b/src/Console/Commands/OAuthServerKeys.php @@ -0,0 +1,47 @@ +option('show')) { + $this->line('OAUTH_SERVER_PRIVATE_KEY="' . str_replace("\n", '\\n', trim($keys['private'])) . '"'); + $this->line('OAUTH_SERVER_PUBLIC_KEY="' . str_replace("\n", '\\n', trim($keys['public'])) . '"'); + + return self::SUCCESS; + } + + $private = OAuthKeys::keyPath('private'); + $public = OAuthKeys::keyPath('public'); + if ((file_exists($private) || file_exists($public)) && !$this->option('force')) { + $this->error('Keys already exist. Rotating them signs everyone out of every app; use --force to do it anyway.'); + + return self::FAILURE; + } + + file_put_contents($private, $keys['private']); + file_put_contents($public, $keys['public']); + @chmod($private, 0600); + @chmod($public, 0644); + + $this->info('Wrote ' . $private . ' and ' . $public . '.'); + + return self::SUCCESS; + } +} diff --git a/src/Console/Commands/OAuthServerPrune.php b/src/Console/Commands/OAuthServerPrune.php new file mode 100644 index 00000000..c9f81662 --- /dev/null +++ b/src/Console/Commands/OAuthServerPrune.php @@ -0,0 +1,36 @@ +subDays(max(0, (int) $this->option('days'))); + + $codes = OAuthAuthCode::where('expires_at', '<', $before)->delete(); + $refresh = OAuthRefreshToken::where('expires_at', '<', $before)->delete(); + $access = OAuthAccessToken::where('expires_at', '<', $before) + ->whereNotIn('id', OAuthRefreshToken::select('access_token_id')) + ->delete(); + + $this->info("Deleted {$codes} codes, {$access} access tokens and {$refresh} refresh tokens."); + + return self::SUCCESS; + } +} diff --git a/src/Http/Controllers/Internal/v1/ConnectedAppController.php b/src/Http/Controllers/Internal/v1/ConnectedAppController.php new file mode 100644 index 00000000..cb06c9da --- /dev/null +++ b/src/Http/Controllers/Internal/v1/ConnectedAppController.php @@ -0,0 +1,56 @@ +where('user_uuid', $request->user()->uuid) + ->with(['client', 'company:uuid,public_id,name']) + ->latest('updated_at') + ->get() + ->filter(fn (OAuthGrant $grant) => $grant->client !== null) + ->map(fn (OAuthGrant $grant) => [ + 'id' => $grant->uuid, + 'app' => [ + 'name' => $grant->client->name, + 'logo_url' => $grant->client->logo_url, + 'website_url' => $grant->client->website_url, + 'official' => $grant->client->official, + 'disabled' => $grant->client->isDisabled(), + ], + 'organization' => $grant->company ? ['id' => $grant->company->public_id, 'name' => $grant->company->name] : null, + 'scopes' => OAuthScopes::describe($grant->scopes ?? [], $grant->company?->name), + 'created_at' => $grant->created_at, + 'last_used_at' => $grant->last_used_at, + ]) + ->values(); + + return new JsonResponse(['connected_apps' => $grants]); + } + + /** + * DELETE int/v1/oauth-server/connected-apps/{uuid}. + */ + public function destroy(Request $request, string $uuid): JsonResponse + { + $grant = OAuthGrant::where('uuid', $uuid)->where('user_uuid', $request->user()->uuid)->firstOrFail(); + $grant->revoke(); + + return new JsonResponse(['status' => 'ok']); + } +} diff --git a/src/Http/Controllers/Internal/v1/OAuthAppController.php b/src/Http/Controllers/Internal/v1/OAuthAppController.php new file mode 100644 index 00000000..02c060b0 --- /dev/null +++ b/src/Http/Controllers/Internal/v1/OAuthAppController.php @@ -0,0 +1,202 @@ + fn ($query) => $query->whereNull('revoked_at')]) + ->orderBy('name') + ->get(); + + return new JsonResponse(['apps' => $apps, 'scopes' => $this->scopes()]); + } + + /** + * GET int/v1/oauth-server/apps/{id}. + */ + public function show(AdminRequest $request, string $id): JsonResponse + { + return new JsonResponse(['app' => OAuthClient::findOrFail($id), 'scopes' => $this->scopes()]); + } + + /** + * POST int/v1/oauth-server/apps. + */ + public function store(AdminRequest $request): JsonResponse + { + $attributes = $this->validated($request->all()); + $client = new OAuthClient($attributes + ['created_by_uuid' => $request->user()->uuid]); + $secret = ($attributes['confidential'] ?? true) ? $client->issueSecret() : null; + $client->save(); + + return new JsonResponse(['app' => $client->fresh(), 'client_secret' => $secret], 201); + } + + /** + * PUT int/v1/oauth-server/apps/{id}. + */ + public function update(AdminRequest $request, string $id): JsonResponse + { + $client = OAuthClient::findOrFail($id); + $attributes = $this->validated($request->all(), $client); + unset($attributes['confidential']); + $client->fill($attributes)->save(); + + return new JsonResponse(['app' => $client->fresh()]); + } + + /** + * POST int/v1/oauth-server/apps/{id}/rotate-secret. The old secret stops working at once. + */ + public function rotateSecret(AdminRequest $request, string $id): JsonResponse + { + $client = OAuthClient::findOrFail($id); + if (!$client->isConfidential()) { + return new JsonResponse(['errors' => ['A public app has no secret to rotate.']], 422); + } + + $secret = $client->issueSecret(); + $client->save(); + + return new JsonResponse(['app' => $client->fresh(), 'client_secret' => $secret]); + } + + /** + * POST int/v1/oauth-server/apps/{id}/disable. Turns the app off and revokes every grant. + */ + public function disable(AdminRequest $request, string $id): JsonResponse + { + $client = OAuthClient::findOrFail($id); + $client->forceFill(['disabled_at' => now()])->save(); + + $grants = OAuthGrant::active()->where('client_id', $client->id)->pluck('uuid')->all(); + OAuthGrant::whereIn('uuid', $grants)->update(['revoked_at' => now()]); + OAuthGrant::revokeTokensFor($grants); + + return new JsonResponse(['app' => $client->fresh()]); + } + + /** + * POST int/v1/oauth-server/apps/{id}/enable. Users have to authorize it again. + */ + public function enable(AdminRequest $request, string $id): JsonResponse + { + $client = OAuthClient::findOrFail($id); + $client->forceFill(['disabled_at' => null])->save(); + + return new JsonResponse(['app' => $client->fresh()]); + } + + /** + * GET int/v1/oauth-server/apps/{id}/grants. + */ + public function grants(AdminRequest $request, string $id): JsonResponse + { + $client = OAuthClient::findOrFail($id); + $grants = OAuthGrant::active() + ->where('client_id', $client->id) + ->with(['user:uuid,public_id,name,email', 'company:uuid,public_id,name']) + ->latest('updated_at') + ->limit(500) + ->get() + ->map(fn (OAuthGrant $grant) => [ + 'id' => $grant->uuid, + 'user' => $grant->user ? ['id' => $grant->user->public_id, 'name' => $grant->user->name, 'email' => $grant->user->email] : null, + 'organization' => $grant->company ? ['id' => $grant->company->public_id, 'name' => $grant->company->name] : null, + 'scopes' => $grant->scopes, + 'created_at' => $grant->created_at, + 'last_used_at' => $grant->last_used_at, + ]); + + return new JsonResponse(['grants' => $grants]); + } + + /** + * DELETE int/v1/oauth-server/grants/{uuid}. + */ + public function revokeGrant(AdminRequest $request, string $uuid): JsonResponse + { + OAuthGrant::findOrFail($uuid)->revoke(); + + return new JsonResponse(['status' => 'ok']); + } + + /** + * @return array + */ + protected function scopes(): array + { + return OAuthScopes::describe(array_keys(OAuthScopes::all())); + } + + /** + * @param array $input + * + * @return array + * + * @throws ValidationException + */ + protected function validated(array $input, ?OAuthClient $client = null): array + { + $required = $client ? 'sometimes' : 'required'; + $validator = Validator::make($input, [ + 'name' => [$required, 'string', 'max:120'], + 'description' => ['nullable', 'string', 'max:500'], + 'redirect_uris' => [$required, 'array', 'min:1', 'max:20'], + 'redirect_uris.*' => ['string', 'max:2000'], + 'allowed_scopes' => [$required, 'array', 'min:1'], + 'allowed_scopes.*' => ['string'], + 'logo_url' => ['nullable', 'url', 'max:2000'], + 'website_url' => ['nullable', 'url', 'max:2000'], + 'privacy_url' => ['nullable', 'url', 'max:2000'], + 'terms_url' => ['nullable', 'url', 'max:2000'], + 'official' => ['sometimes', 'boolean'], + 'development' => ['sometimes', 'boolean'], + 'confidential' => ['sometimes', 'boolean'], + ]); + + $validator->after(function ($validator) use ($input, $client) { + $development = (bool) ($input['development'] ?? $client?->development ?? false); + foreach ((array) ($input['redirect_uris'] ?? []) as $uri) { + if (!is_string($uri) || !OAuthClient::isAcceptableRedirectUri($uri, $development)) { + $validator->errors()->add('redirect_uris', "`{$uri}` must be an https URL without a fragment (http is allowed only for 127.0.0.1 or [::1] on a development app)."); + } + } + foreach ((array) ($input['allowed_scopes'] ?? []) as $scope) { + if (!is_string($scope) || !OAuthScopes::has($scope)) { + $validator->errors()->add('allowed_scopes', "`{$scope}` is not a registered scope."); + } + } + }); + + $attributes = $validator->validate(); + if (isset($attributes['redirect_uris'])) { + $attributes['redirect_uris'] = array_values(array_unique($attributes['redirect_uris'])); + } + if (isset($attributes['allowed_scopes'])) { + $attributes['allowed_scopes'] = array_values(array_unique($attributes['allowed_scopes'])); + } + + return $attributes; + } +} diff --git a/src/Http/Controllers/Internal/v1/OAuthConsentController.php b/src/Http/Controllers/Internal/v1/OAuthConsentController.php new file mode 100644 index 00000000..f7e89834 --- /dev/null +++ b/src/Http/Controllers/Internal/v1/OAuthConsentController.php @@ -0,0 +1,173 @@ +user(); + + try { + $authorization = $this->server->validateAuthorizationRequest($this->authorizationParams($request->query())); + } catch (AuthorizeError $e) { + return $this->error($e); + } + + if ($this->server->needsReauthentication($authorization, $this->authTime($request))) { + return new JsonResponse(['reauthenticate' => true]); + } + + $client = $authorization->client; + $organizations = $authorization->requiresOrganization() ? OAuthOrganizations::forPicker($user) : collect(); + $previous = OAuthGrant::active()->where('user_uuid', $user->uuid)->where('client_id', $client->id)->whereNotNull('company_uuid')->latest('updated_at')->first(); + $selected = $organizations->firstWhere('uuid', $previous?->company_uuid) ?? $organizations->firstWhere('uuid', $user->company_uuid); + + return new JsonResponse([ + 'client' => [ + 'id' => $client->id, + 'name' => $client->name, + 'description' => $client->description, + 'logo_url' => $client->logo_url, + 'website_url' => $client->website_url, + 'privacy_url' => $client->privacy_url, + 'terms_url' => $client->terms_url, + 'official' => $client->official, + 'redirect_host' => parse_url($authorization->redirectUri, PHP_URL_HOST), + ], + 'user' => [ + 'name' => $user->name, + 'email' => $user->email, + 'avatar_url' => $user->avatar_url, + ], + 'scopes' => $this->scopeTemplates($authorization), + 'requires_organization' => $authorization->requiresOrganization(), + 'organizations' => $organizations->map(fn ($organization) => array_diff_key($organization, ['uuid' => true]))->values(), + 'selected_organization' => $selected && $selected['can_share'] ? $selected['id'] : null, + 'skip_consent' => $this->server->canSkipConsent($authorization, $user), + ]); + } + + /** + * POST int/v1/oauth-server/authorization {...authorization request, organization}. + */ + public function approve(Request $request): JsonResponse + { + $user = $request->user(); + + try { + $authorization = $this->server->validateAuthorizationRequest($this->authorizationParams($request->except(['organization']))); + if ($this->server->needsReauthentication($authorization, $this->authTime($request))) { + return new JsonResponse(['reauthenticate' => true], 409); + } + + $company = $authorization->requiresOrganization() ? OAuthOrganizations::find($user, $request->input('organization')) : null; + $result = $this->server->approve($authorization, $user, $company, $this->authTime($request)); + } catch (AuthorizeError $e) { + return $this->error($e, 422); + } + + if ($result['created']) { + $this->notify($user, $authorization, $company?->name); + } + + return new JsonResponse(['redirect' => $result['redirect']]); + } + + /** + * DELETE int/v1/oauth-server/authorization {...authorization request}. + */ + public function deny(Request $request): JsonResponse + { + try { + $authorization = $this->server->validateAuthorizationRequest($this->authorizationParams($request->all())); + } catch (AuthorizeError $e) { + return $this->error($e); + } + + return new JsonResponse(['redirect' => $this->server->deny($authorization)]); + } + + /** + * Only the authorization request's own parameters, whichever way the console sent them. + * + * @param array $input + * + * @return array + */ + protected function authorizationParams(array $input): array + { + return array_intersect_key($input, array_flip([ + 'response_type', 'client_id', 'redirect_uri', 'scope', 'state', 'nonce', + 'code_challenge', 'code_challenge_method', 'prompt', 'max_age', 'login_hint', 'ui_locales', + ])); + } + + /** + * When the console session was signed in: its Sanctum token was created after the password + * and two-factor checks passed. + */ + protected function authTime(Request $request): ?\DateTimeInterface + { + $token = method_exists($request->user(), 'currentAccessToken') ? $request->user()->currentAccessToken() : null; + + return $token?->created_at ?? null; + } + + /** + * Consent lines with `{organization}` left in, so the console can fill in the one picked. + * + * @return array + */ + protected function scopeTemplates(ValidatedAuthorization $authorization): array + { + return array_map(fn ($scope) => ['scope' => $scope] + array_intersect_key(OAuthScopes::get($scope), ['description' => true, 'organization' => true]), $authorization->scopes); + } + + protected function error(AuthorizeError $e, int $status = 400): JsonResponse + { + return new JsonResponse([ + 'error' => $e->error, + 'error_description' => $e->description, + 'redirect' => $e->redirectUrl($this->server->issuer()), + ], $status); + } + + protected function notify(User $user, ValidatedAuthorization $authorization, ?string $organizationName): void + { + try { + $descriptions = array_column(OAuthScopes::describe($authorization->scopes, $organizationName), 'description'); + $user->notify(new OAuthAppAuthorized($authorization->client->name, $organizationName, $descriptions)); + } catch (\Throwable $e) { + Log::warning('[Fleetbase] Could not send the app authorized notice.', ['error' => $e->getMessage()]); + } + } +} diff --git a/src/Http/Controllers/OAuthServer/AuthorizationController.php b/src/Http/Controllers/OAuthServer/AuthorizationController.php new file mode 100644 index 00000000..7d0e456f --- /dev/null +++ b/src/Http/Controllers/OAuthServer/AuthorizationController.php @@ -0,0 +1,38 @@ +validateAuthorizationRequest($request->query()); + } catch (AuthorizeError $e) { + if ($e->canRedirect()) { + return redirect()->away($e->redirectUrl($server->issuer())); + } + + return redirect()->away(Utils::consoleUrl($consentPath, ['error' => $e->error, 'error_description' => $e->description])); + } + + return redirect()->away(Utils::consoleUrl($consentPath, $request->query())); + } +} diff --git a/src/Http/Controllers/OAuthServer/DiscoveryController.php b/src/Http/Controllers/OAuthServer/DiscoveryController.php new file mode 100644 index 00000000..b5f8d7cd --- /dev/null +++ b/src/Http/Controllers/OAuthServer/DiscoveryController.php @@ -0,0 +1,23 @@ +discovery(), 200, ['Cache-Control' => 'public, max-age=3600']); + } + + public function jwks(OAuthServer $server): JsonResponse + { + return new JsonResponse(['keys' => [$server->keys()->jwk()]], 200, ['Cache-Control' => 'public, max-age=3600']); + } +} diff --git a/src/Http/Controllers/OAuthServer/ResourceController.php b/src/Http/Controllers/OAuthServer/ResourceController.php new file mode 100644 index 00000000..77ee1cc2 --- /dev/null +++ b/src/Http/Controllers/OAuthServer/ResourceController.php @@ -0,0 +1,99 @@ +user, $token->scopes ?? [], $token->company), 200, ['Cache-Control' => 'no-store']); + } + + /** + * GET /oauth/v1/organization (scope: organization:read). + */ + public function organization(Request $request): JsonResponse + { + $company = AuthenticateOAuthToken::company($request); + + return new JsonResponse(['organization' => static::organizationPayload($company)], 200, ['Cache-Control' => 'no-store']); + } + + /** + * GET|PUT|DELETE /oauth/v1/review-access/{type}/{id}. + * + * Turns App Review sign-in on or off for something the organization owns (a store, a network, + * the Navigator app). The scope that covers the type is checked here, because extensions + * register which scope covers which of their types. + * + * - GET: the status; never the code. + * - PUT: turn on. The 6-digit code is in the response only when this call created it (the + * first time, or with `regenerate_code`); it can never be read back. + * - DELETE: turn off. The code is kept, so turning on again restores it. + */ + public function reviewAccess(Request $request, string $type, string $id): JsonResponse + { + $scope = OAuthScopes::scopeForReviewAccessType($type); + $token = AuthenticateOAuthToken::token($request); + if (!$scope || AppReview::definition($type) === null) { + return new JsonResponse(['error' => 'not_found', 'error_description' => "Unknown review access type `{$type}`."], 404); + } + if (!$token->hasScope($scope) || !$token->client->allowsScope($scope)) { + return (new \Fleetbase\Auth\OAuthServer\Exceptions\TokenError('insufficient_scope', "This request needs the `{$scope}` scope.", 403, $scope))->toResponse(); + } + + $subject = AppReview::resolveSubject($type, $id, $token->company_uuid); + if (!$subject) { + return new JsonResponse(['error' => 'not_found', 'error_description' => 'Nothing with that id in this organization.'], 404); + } + + if ($request->isMethod('GET')) { + return new JsonResponse(['review_access' => AppReview::status($subject)], 200, ['Cache-Control' => 'no-store']); + } + + $enabled = !$request->isMethod('DELETE'); + [, $plainCode] = AppReview::update($subject, $enabled, $enabled && $request->boolean('regenerate_code'), $token->user_uuid); + $status = AppReview::status($subject); + if ($plainCode !== null) { + $status['code'] = $plainCode; + } + + return new JsonResponse(['review_access' => $status], 200, ['Cache-Control' => 'no-store']); + } + + /** + * @return array + */ + public static function organizationPayload(Company $company): array + { + return [ + 'id' => $company->public_id, + 'name' => $company->name, + 'logo_url' => $company->logo_url, + 'currency' => $company->currency, + 'country' => $company->country, + 'timezone' => $company->timezone, + 'website_url' => $company->website_url, + ]; + } +} diff --git a/src/Http/Controllers/OAuthServer/TokenController.php b/src/Http/Controllers/OAuthServer/TokenController.php new file mode 100644 index 00000000..cfa1d49f --- /dev/null +++ b/src/Http/Controllers/OAuthServer/TokenController.php @@ -0,0 +1,23 @@ +respondToTokenRequest($request); + } + + public function revoke(Request $request, OAuthServer $server) + { + return $server->revoke($request); + } +} diff --git a/src/Http/Middleware/AuthenticateOAuthToken.php b/src/Http/Middleware/AuthenticateOAuthToken.php new file mode 100644 index 00000000..d43b0430 --- /dev/null +++ b/src/Http/Middleware/AuthenticateOAuthToken.php @@ -0,0 +1,65 @@ +middleware(AuthenticateOAuthToken::class . ':storefront:read') + * + * Every listed scope is required. The token, its user and its organization are put on the + * request; controllers read them with AuthenticateOAuthToken::token() / ::company(). Nothing is + * put in the session: these requests never act as the user's console session. + */ +class AuthenticateOAuthToken +{ + public const ATTRIBUTE = 'oauth_access_token'; + + public function __construct(protected OAuthServer $server) + { + } + + public function handle(Request $request, \Closure $next, string ...$scopes) + { + try { + $token = $this->server->authenticate($request, $scopes); + } catch (TokenError $e) { + return $e->toResponse(); + } + + $request->attributes->set(static::ATTRIBUTE, $token); + $request->setUserResolver(fn () => $token->user); + + return $next($request); + } + + public static function token(Request $request): OAuthAccessToken + { + $token = $request->attributes->get(static::ATTRIBUTE); + if (!$token instanceof OAuthAccessToken) { + throw new \LogicException('This route is missing the AuthenticateOAuthToken middleware.'); + } + + return $token; + } + + /** + * The organization the token is bound to. Only call it on routes that require an + * organization scope, which guarantees there is one. + */ + public static function company(Request $request): Company + { + $company = static::token($request)->company; + if (!$company) { + throw new \LogicException('This token is not bound to an organization; require an organization scope on the route.'); + } + + return $company; + } +} diff --git a/src/Models/OAuthAccessToken.php b/src/Models/OAuthAccessToken.php new file mode 100644 index 00000000..d5e30938 --- /dev/null +++ b/src/Models/OAuthAccessToken.php @@ -0,0 +1,67 @@ + 'array', + 'revoked' => 'boolean', + 'expires_at' => 'datetime', + ]; + + /** + * Get the correct current connection to use. + */ + public function getConnectionName() + { + return $this->connection ?: config('fleetbase.connection.db', 'mysql'); + } + + public function grant(): BelongsTo + { + return $this->belongsTo(OAuthGrant::class, 'grant_uuid', 'uuid'); + } + + public function client(): BelongsTo + { + return $this->belongsTo(OAuthClient::class, 'client_id', 'id'); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_uuid', 'uuid'); + } + + public function company(): BelongsTo + { + return $this->belongsTo(Company::class, 'company_uuid', 'uuid'); + } + + public function hasScope(string $scope): bool + { + return in_array($scope, $this->scopes ?? [], true); + } + + public function isUsable(): bool + { + return !$this->revoked && $this->expires_at && $this->expires_at->isFuture(); + } +} diff --git a/src/Models/OAuthAuthCode.php b/src/Models/OAuthAuthCode.php new file mode 100644 index 00000000..6e24f06e --- /dev/null +++ b/src/Models/OAuthAuthCode.php @@ -0,0 +1,37 @@ + 'array', + 'revoked' => 'boolean', + 'auth_time' => 'datetime', + 'expires_at' => 'datetime', + 'created_at' => 'datetime', + ]; + + /** + * Get the correct current connection to use. + */ + public function getConnectionName() + { + return $this->connection ?: config('fleetbase.connection.db', 'mysql'); + } +} diff --git a/src/Models/OAuthClient.php b/src/Models/OAuthClient.php new file mode 100644 index 00000000..e7b82d9c --- /dev/null +++ b/src/Models/OAuthClient.php @@ -0,0 +1,246 @@ + + */ + protected $fillable = [ + 'name', + 'description', + 'redirect_uris', + 'allowed_scopes', + 'logo_url', + 'website_url', + 'privacy_url', + 'terms_url', + 'official', + 'development', + 'created_by_uuid', + ]; + + /** + * The attributes that should be hidden for arrays. + * + * @var array + */ + protected $hidden = ['secret']; + + /** + * The attributes that should be cast to native types. + * + * @var array + */ + protected $casts = [ + 'redirect_uris' => 'array', + 'allowed_scopes' => 'array', + 'official' => 'boolean', + 'development' => 'boolean', + 'secret_rotated_at' => 'datetime', + 'disabled_at' => 'datetime', + ]; + + /** + * Dynamic attributes appended to the model's JSON form. + * + * @var array + */ + protected $appends = ['confidential']; + + /** + * Get the correct current connection to use. + */ + public function getConnectionName() + { + return $this->connection ?: config('fleetbase.connection.db', 'mysql'); + } + + /** + * Generate the client id on create. + */ + protected static function booted(): void + { + static::creating(function (self $client) { + if (empty($client->id)) { + $client->id = (string) Str::uuid(); + } + }); + } + + /** + * The grants users have given this client. + */ + public function grants(): HasMany + { + return $this->hasMany(OAuthGrant::class, 'client_id', 'id'); + } + + /** + * Whether the client authenticates with a secret. + */ + public function isConfidential(): bool + { + return !empty($this->secret); + } + + /** + * Accessor for `confidential` in the JSON form. + */ + public function getConfidentialAttribute(): bool + { + return $this->isConfidential(); + } + + /** + * Whether an admin turned the client off. + */ + public function isDisabled(): bool + { + return $this->disabled_at !== null; + } + + /** + * Generate, hash and store a new secret, returning the plain value. + * + * The caller shows the plain value once; it can never be read back. + */ + public function issueSecret(): string + { + $plain = Str::random(64); + $this->secret = password_hash($plain, PASSWORD_BCRYPT); + $this->secret_rotated_at = now(); + + return $plain; + } + + /** + * Check a presented secret against the stored hash. + */ + public function verifySecret(?string $plain): bool + { + if (!$this->isConfidential() || !is_string($plain) || $plain === '') { + return false; + } + + return password_verify($plain, $this->secret); + } + + /** + * Whether the client was registered for this scope. + */ + public function allowsScope(string $scope): bool + { + return in_array($scope, $this->allowed_scopes ?? [], true); + } + + /** + * Whether a redirect URI is registered for this client. + * + * Matching is exact: scheme, host, port, path and query. The one exception is a development + * client using a loopback address, where native-app guidance (RFC 8252 §7.3) says the port + * must be allowed to vary. + */ + public function allowsRedirectUri(?string $uri): bool + { + if (!is_string($uri) || $uri === '') { + return false; + } + + $registered = $this->redirect_uris ?? []; + if (in_array($uri, $registered, true)) { + return true; + } + + if (!$this->development || !static::isLoopbackUri($uri)) { + return false; + } + + $withoutPort = static::withoutPort($uri); + foreach ($registered as $candidate) { + if (static::isLoopbackUri($candidate) && static::withoutPort($candidate) === $withoutPort) { + return true; + } + } + + return false; + } + + /** + * Whether a URI is an acceptable redirect URI to register. + * + * https everywhere; http only for loopback addresses; no fragments (RFC 6749 §3.1.2). + */ + public static function isAcceptableRedirectUri(string $uri, bool $development = false): bool + { + $parts = parse_url($uri); + if ($parts === false || empty($parts['scheme']) || empty($parts['host']) || isset($parts['fragment'])) { + return false; + } + + $scheme = strtolower($parts['scheme']); + if ($scheme === 'https') { + return true; + } + + return $scheme === 'http' && static::isLoopbackUri($uri) && $development; + } + + /** + * Whether a URI points at the loopback interface over http. + */ + public static function isLoopbackUri(string $uri): bool + { + $parts = parse_url($uri); + if ($parts === false || strtolower($parts['scheme'] ?? '') !== 'http') { + return false; + } + + return in_array(strtolower($parts['host'] ?? ''), ['127.0.0.1', '[::1]', 'localhost'], true); + } + + /** + * The URI with its port removed, for loopback comparison. + */ + protected static function withoutPort(string $uri): string + { + return preg_replace('#^(http://[^/:]+|http://\[::1\]):\d+#i', '$1', $uri); + } +} diff --git a/src/Models/OAuthGrant.php b/src/Models/OAuthGrant.php new file mode 100644 index 00000000..bec1a4d2 --- /dev/null +++ b/src/Models/OAuthGrant.php @@ -0,0 +1,168 @@ + + */ + protected $fillable = ['uuid', 'user_uuid', 'client_id', 'company_uuid', 'scopes', 'last_used_at', 'revoked_at']; + + /** + * The attributes that should be hidden for arrays. + * + * @var array + */ + protected $hidden = ['id']; + + /** + * The attributes that should be cast to native types. + * + * @var array + */ + protected $casts = [ + 'scopes' => 'array', + 'last_used_at' => 'datetime', + 'revoked_at' => 'datetime', + ]; + + /** + * Get the correct current connection to use. + */ + public function getConnectionName() + { + return $this->connection ?: config('fleetbase.connection.db', 'mysql'); + } + + /** + * Generate the uuid on create. + */ + protected static function booted(): void + { + static::creating(function (self $grant) { + if (empty($grant->uuid)) { + $grant->uuid = (string) Str::uuid(); + } + }); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_uuid', 'uuid'); + } + + public function client(): BelongsTo + { + return $this->belongsTo(OAuthClient::class, 'client_id', 'id'); + } + + public function company(): BelongsTo + { + return $this->belongsTo(Company::class, 'company_uuid', 'uuid'); + } + + /** + * Grants that haven't been revoked. + */ + public function scopeActive(Builder $query): Builder + { + return $query->whereNull('revoked_at'); + } + + public function isRevoked(): bool + { + return $this->revoked_at !== null; + } + + /** + * Revoke the grant and every code and token issued under it. + */ + public function revoke(): void + { + $connection = $this->getConnectionName(); + + DB::connection($connection)->transaction(function () use ($connection) { + if (!$this->isRevoked()) { + $this->revoked_at = now(); + $this->save(); + } + + static::revokeTokensFor([$this->uuid], $connection); + }); + } + + /** + * Revoke every code and token under the given grants. + * + * @param array $grantUuids + */ + public static function revokeTokensFor(array $grantUuids, ?string $connection = null): void + { + if (empty($grantUuids)) { + return; + } + + $connection ??= config('fleetbase.connection.db', 'mysql'); + $db = DB::connection($connection); + + $db->table('oauth_auth_codes')->whereIn('grant_uuid', $grantUuids)->update(['revoked' => true]); + $db->table('oauth_access_tokens')->whereIn('grant_uuid', $grantUuids)->update(['revoked' => true, 'updated_at' => now()]); + $db->table('oauth_refresh_tokens')->whereIn('grant_uuid', $grantUuids)->update(['revoked' => true]); + } + + /** + * Record that the grant was used, at most once a minute so busy clients don't write per call. + */ + public function touchLastUsed(): void + { + if ($this->last_used_at && $this->last_used_at->gt(now()->subMinute())) { + return; + } + + $this->forceFill(['last_used_at' => now()])->saveQuietly(); + } +} diff --git a/src/Models/OAuthRefreshToken.php b/src/Models/OAuthRefreshToken.php new file mode 100644 index 00000000..d37a1505 --- /dev/null +++ b/src/Models/OAuthRefreshToken.php @@ -0,0 +1,35 @@ + 'boolean', + 'expires_at' => 'datetime', + 'created_at' => 'datetime', + ]; + + /** + * Get the correct current connection to use. + */ + public function getConnectionName() + { + return $this->connection ?: config('fleetbase.connection.db', 'mysql'); + } +} diff --git a/src/Notifications/OAuthAppAuthorized.php b/src/Notifications/OAuthAppAuthorized.php new file mode 100644 index 00000000..76456adb --- /dev/null +++ b/src/Notifications/OAuthAppAuthorized.php @@ -0,0 +1,81 @@ + $scopeDescriptions + */ + public function __construct( + public string $appName, + public ?string $organizationName, + public array $scopeDescriptions, + ) { + $this->authorizedAt = now()->toDayDateTimeString() . ' UTC'; + } + + /** + * @return array + */ + public function via($notifiable) + { + return ['mail']; + } + + /** + * @return MailMessage + */ + public function toMail($notifiable) + { + $app = config('app.name'); + $subject = $this->organizationName + ? $this->appName . ' can now access ' . $this->organizationName + : $this->appName . ' can now sign you in with ' . $app; + + $message = (new MailMessage()) + ->subject($subject) + ->greeting('Hello, ' . ($notifiable->name ?? 'there')) + ->line($this->organizationName + ? 'You gave ' . $this->appName . ' access to ' . $this->organizationName . ' on ' . $app . '. It can:' + : 'You signed in to ' . $this->appName . ' with your ' . $app . ' account. It can:'); + + foreach ($this->scopeDescriptions as $description) { + $message->line('• ' . $description); + } + + return $message + ->line('Authorized: ' . $this->authorizedAt) + ->action('Review connected apps', Utils::consoleUrl('account/auth')) + ->line('If this was not you, remove the app from your connected apps and change your password straight away.'); + } + + /** + * @return array + */ + public function toArray($notifiable) + { + return [ + 'app' => $this->appName, + 'organization' => $this->organizationName, + 'scopes' => $this->scopeDescriptions, + 'authorized_at' => $this->authorizedAt, + ]; + } +} diff --git a/src/Providers/CoreServiceProvider.php b/src/Providers/CoreServiceProvider.php index 4abaaaa3..b601364c 100644 --- a/src/Providers/CoreServiceProvider.php +++ b/src/Providers/CoreServiceProvider.php @@ -107,6 +107,9 @@ class CoreServiceProvider extends ServiceProvider \Fleetbase\Console\Commands\SmsGuardPause::class, \Fleetbase\Console\Commands\SmsGuardUnpause::class, \Fleetbase\Console\Commands\SmsLedgerPrune::class, + \Fleetbase\Console\Commands\OAuthServerKeys::class, + \Fleetbase\Console\Commands\OAuthServerClient::class, + \Fleetbase\Console\Commands\OAuthServerPrune::class, ]; /** @@ -153,6 +156,7 @@ public function register() $this->mergeConfigFrom(__DIR__ . '/../../config/image.php', 'image'); $this->mergeConfigFrom(__DIR__ . '/../../config/sms.php', 'sms'); $this->mergeConfigFrom(__DIR__ . '/../../config/oauth.php', 'oauth'); + $this->mergeConfigFrom(__DIR__ . '/../../config/oauth-server.php', 'oauth-server'); // setup report schema registry $this->app->singleton(ReportSchemaRegistry::class, function () { @@ -211,6 +215,7 @@ public function boot() // are high-churn and short-lived; the model keeps a day's grace past expiry so a // support investigation can still tell an expired token from one that never existed. $schedule->command('model:prune', ['--model' => \Fleetbase\Models\OAuthState::class])->hourly(); + $schedule->command('oauth-server:prune')->daily(); $schedule->command('telemetry:ping')->daily(); $schedule->job(new \Fleetbase\Jobs\MaterializeSchedulesJob())->dailyAt('01:00')->name('materialize-schedules')->withoutOverlapping(); // Keep sandbox users/companies in sync with production so that @@ -230,6 +235,7 @@ public function boot() $this->registerTransformersFrom(); $this->registerMiddleware(); $this->registerNotifications(); + \Fleetbase\Auth\OAuthServer\OAuthScopes::registerDefaults(); $this->loadRoutesFrom(__DIR__ . '/../routes.php'); $this->loadMigrationsFrom(__DIR__ . '/../../migrations'); $this->loadViewsFrom(__DIR__ . '/../../views', 'fleetbase'); diff --git a/src/routes.php b/src/routes.php index 6cf62503..0fb088c3 100644 --- a/src/routes.php +++ b/src/routes.php @@ -23,6 +23,37 @@ function ($router) { $router->get('/', 'Controller@hello'); + /* + |-------------------------------------------------------------------------- + | OAuth 2.0 / OpenID Connect provider ("Continue with Fleetbase") + |-------------------------------------------------------------------------- + | + | Apps sign people in with their Fleetbase account and, with consent, act on + | one organization. The consent screen itself is in the console, backed by + | the int/v1/oauth-server routes below. + */ + if (config('oauth-server.enabled', true)) { + $router->get('.well-known/openid-configuration', [Fleetbase\Http\Controllers\OAuthServer\DiscoveryController::class, 'configuration']); + $router->get('.well-known/oauth-authorization-server', [Fleetbase\Http\Controllers\OAuthServer\DiscoveryController::class, 'configuration']); + $router->prefix('oauth')->group(function ($router) { + $router->get('authorize', [Fleetbase\Http\Controllers\OAuthServer\AuthorizationController::class, 'authorize']); + $router->get('jwks', [Fleetbase\Http\Controllers\OAuthServer\DiscoveryController::class, 'jwks']); + $router->middleware(['throttle:60,1'])->group(function ($router) { + $router->post('token', [Fleetbase\Http\Controllers\OAuthServer\TokenController::class, 'token']); + $router->post('revoke', [Fleetbase\Http\Controllers\OAuthServer\TokenController::class, 'revoke']); + }); + $router->middleware(['throttle:120,1'])->group(function ($router) { + $router->match(['GET', 'POST'], 'userinfo', [Fleetbase\Http\Controllers\OAuthServer\ResourceController::class, 'userinfo']) + ->middleware(Fleetbase\Http\Middleware\AuthenticateOAuthToken::class . ':openid'); + $router->get('v1/organization', [Fleetbase\Http\Controllers\OAuthServer\ResourceController::class, 'organization']) + ->middleware(Fleetbase\Http\Middleware\AuthenticateOAuthToken::class . ':organization:read'); + // The scope is checked in the controller: each extension says which scope covers its types. + $router->match(['GET', 'PUT', 'DELETE'], 'v1/review-access/{type}/{id}', [Fleetbase\Http\Controllers\OAuthServer\ResourceController::class, 'reviewAccess']) + ->middleware(Fleetbase\Http\Middleware\AuthenticateOAuthToken::class); + }); + }); + } + /* |-------------------------------------------------------------------------- | Public/Consumable Routes @@ -207,6 +238,24 @@ function ($router) { // App Review access for whatever an extension registered (stores, Navigator, ...). $router->get('review-access/{type}/{id}', 'ReviewAccessController@show'); $router->put('review-access/{type}/{id}', 'ReviewAccessController@update'); + // "Continue with Fleetbase": the console's consent screen, Connected apps, + // and Admin → OAuth Apps (AdminRequest checks for a system admin). + $router->group(['prefix' => 'oauth-server'], function ($router) { + $router->get('authorization', 'OAuthConsentController@show'); + $router->post('authorization', 'OAuthConsentController@approve'); + $router->delete('authorization', 'OAuthConsentController@deny'); + $router->get('connected-apps', 'ConnectedAppController@index'); + $router->delete('connected-apps/{uuid}', 'ConnectedAppController@destroy'); + $router->get('apps', 'OAuthAppController@index'); + $router->post('apps', 'OAuthAppController@store'); + $router->get('apps/{id}', 'OAuthAppController@show'); + $router->put('apps/{id}', 'OAuthAppController@update'); + $router->post('apps/{id}/rotate-secret', 'OAuthAppController@rotateSecret'); + $router->post('apps/{id}/disable', 'OAuthAppController@disable'); + $router->post('apps/{id}/enable', 'OAuthAppController@enable'); + $router->get('apps/{id}/grants', 'OAuthAppController@grants'); + $router->delete('grants/{uuid}', 'OAuthAppController@revokeGrant'); + }); $router->group( ['prefix' => 'lookup'], function ($router) { diff --git a/tests/Unit/Auth/OAuthServer/OAuthServerEndpointsTest.php b/tests/Unit/Auth/OAuthServer/OAuthServerEndpointsTest.php new file mode 100644 index 00000000..f77f1c24 --- /dev/null +++ b/tests/Unit/Auth/OAuthServer/OAuthServerEndpointsTest.php @@ -0,0 +1,164 @@ +instance('hash', new class { + public function make($value, array $options = []): string + { + return password_hash((string) $value, PASSWORD_BCRYPT); + } + + public function check($value, $hashedValue, array $options = []): bool + { + return is_string($hashedValue) && password_verify((string) $value, $hashedValue); + } + }); + $container->instance('cache', new Illuminate\Cache\Repository(new Illuminate\Cache\ArrayStore())); + $container->instance('responsecache', new class { + public function clear(): void + { + } + }); + Illuminate\Support\Facades\Facade::clearResolvedInstance('hash'); + Illuminate\Support\Facades\Facade::clearResolvedInstance('cache'); + + (require __DIR__ . '/../../../../migrations/2026_10_24_000000_create_review_accesses_table.php')->up(); + ReviewAccess::creating(fn ($access) => $access->uuid ??= (string) Str::uuid()); + + // An organization-level review type, the way fleetops registers Navigator, covered by a scope. + AppReview::register('test-navigator', ['model' => Company::class, 'phone' => '+12025550144', 'name' => 'Navigator']); + OAuthScopes::register('navigator:review-access', ['description' => 'Turn App Review sign-in on or off', 'organization' => true, 'review_access_types' => ['test-navigator']]); +}); + +afterEach(function () { + AppReview::forget('test-navigator'); + oauth_test_teardown(); +}); + +/** + * Sign in user-owner to company A with these scopes; returns the access token. + */ +function oauth_endpoint_token(string $scope, string $userUuid = 'user-owner', string $companyUuid = 'company-a'): string +{ + [$client, $secret] = oauth_test_client(['allowed_scopes' => array_keys(OAuthScopes::all())]); + $verifier = oauth_test_verifier(); + [, $body] = oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier, ['scope' => $scope], $userUuid, $companyUuid), $verifier); + + return $body['access_token']; +} + +/** + * Run a request through the middleware and controller action, as the router would. + */ +function oauth_endpoint_call(Request $request, array $scopes, Closure $action) +{ + return (new AuthenticateOAuthToken(oauth_test_server()))->handle($request, $action, ...$scopes); +} + +it('publishes discovery metadata that matches the endpoints and the signing key', function () { + $metadata = (new DiscoveryController())->configuration(oauth_test_server())->getData(true); + $jwks = (new DiscoveryController())->jwks(oauth_test_server())->getData(true); + + expect($metadata['issuer'])->toBe('https://api.fleetbase.test') + ->and($metadata['authorization_endpoint'])->toBe('https://api.fleetbase.test/oauth/authorize') + ->and($metadata['token_endpoint'])->toBe('https://api.fleetbase.test/oauth/token') + ->and($metadata['code_challenge_methods_supported'])->toBe(['S256']) + ->and($metadata['grant_types_supported'])->toBe(['authorization_code', 'refresh_token']) + ->and($metadata['scopes_supported'])->toContain('openid', 'organization:read', 'storefront:read') + ->and($jwks['keys'][0]['kid'])->toBe(oauth_test_server()->keys()->keyId()) + ->and($jwks['keys'][0])->toHaveKeys(['n', 'e']) + ->and($jwks['keys'][0])->not->toHaveKey('d'); +}); + +it('returns the organization on the token, not the user\'s current one', function () { + $token = oauth_endpoint_token('openid organization:read'); + Fleetbase\Models\User::where('uuid', 'user-owner')->update(['company_uuid' => 'company-b']); + + $response = oauth_endpoint_call(oauth_test_bearer($token), ['organization:read'], fn ($request) => (new ResourceController())->organization($request)); + + expect($response->getStatusCode())->toBe(200) + ->and($response->getData(true)['organization'])->toMatchArray([ + 'id' => 'company_a', + 'name' => 'Acme Deliveries', + 'currency' => 'USD', + 'country' => 'US', + ]); +}); + +it('answers userinfo with the claims the scopes allow', function () { + $token = oauth_endpoint_token('openid email'); + + $claims = oauth_endpoint_call(oauth_test_bearer($token, '/oauth/userinfo'), ['openid'], fn ($request) => (new ResourceController())->userinfo($request))->getData(true); + + expect($claims)->toBe(['sub' => 'user_owner', 'email' => 'owner@example.com', 'email_verified' => true]); +}); + +it('refuses a request without the route\'s scope, with an RFC 6750 challenge', function () { + $token = oauth_endpoint_token('openid email'); + + $response = oauth_endpoint_call(oauth_test_bearer($token), ['organization:read'], fn () => throw new RuntimeException('must not run')); + + expect($response->getStatusCode())->toBe(403) + ->and($response->getData(true)['error'])->toBe('insufficient_scope') + ->and($response->headers->get('WWW-Authenticate'))->toStartWith('Bearer error="insufficient_scope"'); +}); + +describe('review access', function () { + function oauth_review_call(string $token, string $method, string $id = 'company_a', array $input = []) + { + $request = Request::create('/oauth/v1/review-access/test-navigator/' . $id, $method, $input, [], [], ['HTTP_AUTHORIZATION' => 'Bearer ' . $token]); + + return oauth_endpoint_call($request, [], fn ($request) => (new ResourceController())->reviewAccess($request, 'test-navigator', $id)); + } + + it('turns access on with a code shown once, reads it without the code, and turns it off', function () { + $token = oauth_endpoint_token('openid navigator:review-access'); + + $on = oauth_review_call($token, 'PUT')->getData(true)['review_access']; + expect($on['enabled'])->toBeTrue() + ->and($on['phone'])->toBe('+12025550144') + ->and($on['code'])->toMatch('/^\d{6}$/'); + + $again = oauth_review_call($token, 'PUT')->getData(true)['review_access']; + $read = oauth_review_call($token, 'GET')->getData(true)['review_access']; + expect($again)->not->toHaveKey('code') + ->and($read)->not->toHaveKey('code') + ->and($read['has_code'])->toBeTrue(); + + $regenerated = oauth_review_call($token, 'PUT', 'company_a', ['regenerate_code' => true])->getData(true)['review_access']; + expect($regenerated['code'])->toMatch('/^\d{6}$/'); + + $off = oauth_review_call($token, 'DELETE')->getData(true)['review_access']; + expect($off['enabled'])->toBeFalse() + ->and($off['has_code'])->toBeTrue(); + }); + + it('needs the scope that covers the type', function () { + $token = oauth_endpoint_token('openid organization:read'); + + $response = oauth_review_call($token, 'PUT'); + + expect($response->getStatusCode())->toBe(403) + ->and($response->getData(true)['error'])->toBe('insufficient_scope'); + }); + + it('cannot reach another organization', function () { + $token = oauth_endpoint_token('openid navigator:review-access'); + + expect(oauth_review_call($token, 'PUT', 'company_b')->getStatusCode())->toBe(404) + ->and(ReviewAccess::count())->toBe(0); + }); +}); diff --git a/tests/Unit/Auth/OAuthServer/OAuthServerFlowTest.php b/tests/Unit/Auth/OAuthServer/OAuthServerFlowTest.php new file mode 100644 index 00000000..1acf0e41 --- /dev/null +++ b/tests/Unit/Auth/OAuthServer/OAuthServerFlowTest.php @@ -0,0 +1,512 @@ + oauth_test_setup()); +afterEach(fn () => oauth_test_teardown()); + +function oauth_flow_authorize_error(array $params): AuthorizeError +{ + try { + oauth_test_server()->validateAuthorizationRequest($params); + } catch (AuthorizeError $e) { + return $e; + } + + throw new RuntimeException('Expected the authorization request to be refused.'); +} + +function oauth_flow_token_error(Request $request, array $scopes = []): TokenError +{ + try { + oauth_test_server()->authenticate($request, $scopes); + } catch (TokenError $e) { + return $e; + } + + throw new RuntimeException('Expected the access token to be refused.'); +} + +describe('the full flow', function () { + it('signs in, issues a signed ID token, and binds the tokens to the chosen organization', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + + [$status, $body] = oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier), $verifier); + + expect($status)->toBe(200) + ->and($body)->toHaveKeys(['access_token', 'refresh_token', 'id_token', 'expires_in']) + ->and($body['token_type'])->toBe('Bearer') + ->and(explode(' ', $body['scope']))->toEqualCanonicalizing(['openid', 'profile', 'email', 'organization:read']); + + $jwt = Configuration::forSymmetricSigner(new Sha256(), InMemory::plainText('empty', 'empty')); + $idToken = $jwt->parser()->parse($body['id_token']); + $claims = $idToken->claims(); + + expect($jwt->validator()->validate($idToken, new SignedWith(new Sha256(), InMemory::plainText(oauth_test_keys()['public']))))->toBeTrue() + ->and($claims->get('iss'))->toBe('https://api.fleetbase.test') + ->and($claims->get('aud'))->toBe([$client->id]) + ->and($claims->get('sub'))->toBe('user_owner') + ->and($claims->get('nonce'))->toBe('nonce-abc') + ->and($claims->get('email'))->toBe('owner@example.com') + ->and($claims->get('email_verified'))->toBeTrue() + ->and($claims->get('name'))->toBe('Olive Owner') + ->and($claims->get('org_id'))->toBe('company_a') + ->and($claims->get('at_hash'))->toBe(IdTokenResponse::accessTokenHash($body['access_token'])) + ->and($claims->has('auth_time'))->toBeTrue() + ->and($idToken->headers()->get('kid'))->toBe(oauth_test_server()->keys()->keyId()); + + $token = oauth_test_server()->authenticate(oauth_test_bearer($body['access_token']), ['organization:read']); + + expect($token->company_uuid)->toBe('company-a') + ->and($token->user_uuid)->toBe('user-owner') + ->and($token->grant->scopes)->toContain('organization:read'); + }); + + it('sends iss and state back with the code (RFC 9207)', function () { + [$client] = oauth_test_client(); + $server = oauth_test_server(); + $result = $server->approve( + $server->validateAuthorizationRequest(oauth_test_params($client, oauth_test_verifier())), + User::where('uuid', 'user-owner')->first(), + Company::where('uuid', 'company-a')->first(), + null + ); + + parse_str((string) parse_url($result['redirect'], PHP_URL_QUERY), $query); + + expect($result['redirect'])->toStartWith(OAUTH_TEST_REDIRECT . '?') + ->and($query['state'])->toBe('state-123') + ->and($query['iss'])->toBe('https://api.fleetbase.test') + ->and($query)->toHaveKey('code') + ->and($result['created'])->toBeTrue(); + }); + + it('signs in without an organization when only identity scopes are asked for', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + $code = oauth_test_code($client, $verifier, ['scope' => 'openid email'], 'user-member', 'company-a'); + + [$status, $body] = oauth_test_exchange($client, $secret, $code, $verifier); + $token = oauth_test_server()->authenticate(oauth_test_bearer($body['access_token'])); + + expect($status)->toBe(200) + ->and($token->company_uuid)->toBeNull() + ->and(OAuthGrant::where('user_uuid', 'user-member')->first()->company_uuid)->toBeNull(); + }); + + it('skips the consent screen only for a sign-in the user already approved', function () { + [$client] = oauth_test_client(); + $server = oauth_test_server(); + $user = User::where('uuid', 'user-member')->first(); + $signIn = fn (array $overrides = []) => $server->validateAuthorizationRequest(oauth_test_params($client, oauth_test_verifier(), array_merge(['scope' => 'openid email'], $overrides))); + + expect($server->canSkipConsent($signIn(), $user))->toBeFalse(); + + $server->approve($signIn(), $user, null, null); + + expect($server->canSkipConsent($signIn(), $user))->toBeTrue() + ->and($server->canSkipConsent($signIn(['prompt' => 'consent']), $user))->toBeFalse() + ->and($server->canSkipConsent($signIn(['scope' => 'openid email profile']), $user))->toBeFalse() + ->and($server->canSkipConsent($signIn(['scope' => 'openid organization:read']), $user))->toBeFalse(); + }); +}); + +describe('PKCE', function () { + it('is required, even for a confidential client', function () { + [$client] = oauth_test_client(); + $error = oauth_flow_authorize_error(oauth_test_params($client, oauth_test_verifier(), ['code_challenge' => null, 'code_challenge_method' => null])); + + expect($error->error)->toBe('invalid_request') + ->and($error->canRedirect())->toBeTrue() + ->and($error->redirectUrl('https://api.fleetbase.test'))->toContain('state=state-123'); + }); + + it('accepts only S256', function () { + [$client] = oauth_test_client(); + $verifier = oauth_test_verifier(); + $error = oauth_flow_authorize_error(oauth_test_params($client, $verifier, ['code_challenge' => $verifier, 'code_challenge_method' => 'plain'])); + + expect($error->error)->toBe('invalid_request') + ->and($error->description)->toContain('S256'); + }); + + it('refuses a code redeemed with the wrong verifier', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + + [$status, $body] = oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier), oauth_test_verifier()); + + expect($status)->toBe(400) + ->and($body['error'])->toBe('invalid_grant'); + }); + + it('refuses a code redeemed with no verifier', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + + [$status, $body] = oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier), $verifier, ['code_verifier' => null]); + + expect($status)->toBe(400) + ->and($body['error'])->toBe('invalid_request'); + }); + + it('lets a public client sign in with PKCE alone', function () { + [$client] = oauth_test_client(['name' => 'Native app'], confidential: false); + $verifier = oauth_test_verifier(); + [$status, $body] = oauth_test_exchange($client, null, oauth_test_code($client, $verifier), $verifier); + + expect($client->isConfidential())->toBeFalse() + ->and($status)->toBe(200) + ->and($body)->toHaveKey('access_token'); + }); +}); + +describe('redirect URIs', function () { + it('never redirects to an unregistered URI', function () { + [$client] = oauth_test_client(); + + foreach ([ + 'https://evil.example.com/oauth/callback', + OAUTH_TEST_REDIRECT . '?extra=1', + OAUTH_TEST_REDIRECT . '/', + 'http://app.example.com/oauth/callback', + ] as $uri) { + $error = oauth_flow_authorize_error(oauth_test_params($client, oauth_test_verifier(), ['redirect_uri' => $uri])); + + expect($error->error)->toBe('invalid_request') + ->and($error->canRedirect())->toBeFalse() + ->and($error->redirectUrl('https://api.fleetbase.test'))->toBeNull(); + } + }); + + it('requires a redirect URI on every request', function () { + [$client] = oauth_test_client(); + $error = oauth_flow_authorize_error(oauth_test_params($client, oauth_test_verifier(), ['redirect_uri' => null])); + + expect($error->canRedirect())->toBeFalse(); + }); + + it('refuses an unknown or disabled client without redirecting', function () { + [$client] = oauth_test_client(); + $client->forceFill(['disabled_at' => now()])->save(); + + expect(oauth_flow_authorize_error(oauth_test_params($client, oauth_test_verifier()))->error)->toBe('invalid_client') + ->and(oauth_flow_authorize_error(['client_id' => 'not-a-client'])->canRedirect())->toBeFalse(); + }); + + it('lets a development client use any loopback port, and nobody else', function () { + [$dev] = oauth_test_client(['redirect_uris' => ['http://127.0.0.1:3000/callback'], 'development' => true]); + [$prod] = oauth_test_client(['redirect_uris' => ['http://127.0.0.1:3000/callback'], 'development' => false]); + + expect($dev->allowsRedirectUri('http://127.0.0.1:51234/callback'))->toBeTrue() + ->and($dev->allowsRedirectUri('http://127.0.0.1:51234/other'))->toBeFalse() + ->and($prod->allowsRedirectUri('http://127.0.0.1:51234/callback'))->toBeFalse() + ->and($prod->allowsRedirectUri('http://127.0.0.1:3000/callback'))->toBeTrue(); + }); + + it('refuses a code redeemed with a different redirect URI', function () { + [$client, $secret] = oauth_test_client(['redirect_uris' => [OAUTH_TEST_REDIRECT, 'https://app.example.com/other']]); + $verifier = oauth_test_verifier(); + + [$status, $body] = oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier), $verifier, ['redirect_uri' => 'https://app.example.com/other']); + + expect($status)->toBe(400) + ->and($body['error'])->toBe('invalid_request'); + }); +}); + +describe('authorization codes', function () { + it('expire', function () { + config(['oauth-server.ttl.auth_code' => 'PT1S']); + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + $code = oauth_test_code($client, $verifier); + + sleep(2); + [$status, $body] = oauth_test_exchange($client, $secret, $code, $verifier); + + expect($status)->toBe(400) + ->and($body['error'])->toBe('invalid_request') + ->and($body['hint'] ?? $body['error_description'])->toContain('expired'); + }); + + it('work once, and a replay revokes the tokens already issued from it', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + $code = oauth_test_code($client, $verifier); + + [, $first] = oauth_test_exchange($client, $secret, $code, $verifier); + [$status, $body] = oauth_test_exchange($client, $secret, $code, $verifier); + + expect($status)->toBe(400) + ->and($body['error'])->toBe('invalid_request') + ->and(oauth_flow_token_error(oauth_test_bearer($first['access_token']))->error)->toBe('invalid_token'); + + [$refreshStatus] = oauth_test_token(['grant_type' => 'refresh_token', 'refresh_token' => $first['refresh_token'], 'client_id' => $client->id, 'client_secret' => $secret]); + expect($refreshStatus)->toBe(401); + }); + + it('belong to the client they were issued to', function () { + [$client] = oauth_test_client(); + [$other, $otherSecret] = oauth_test_client(['name' => 'Other app']); + $verifier = oauth_test_verifier(); + + [$status] = oauth_test_exchange($other, $otherSecret, oauth_test_code($client, $verifier), $verifier); + + expect($status)->toBe(400); + }); +}); + +describe('scopes', function () { + it('refuses scopes the client was not registered for, or that do not exist', function () { + [$client] = oauth_test_client(['allowed_scopes' => ['openid', 'email']]); + + expect(oauth_flow_authorize_error(oauth_test_params($client, oauth_test_verifier(), ['scope' => 'openid organization:read']))->error)->toBe('invalid_scope') + ->and(oauth_flow_authorize_error(oauth_test_params($client, oauth_test_verifier(), ['scope' => 'openid admin:everything']))->error)->toBe('invalid_scope') + ->and(oauth_flow_authorize_error(oauth_test_params($client, oauth_test_verifier(), ['scope' => '']))->error)->toBe('invalid_scope'); + }); + + it('refuses an endpoint that needs a scope the token does not have', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + [, $body] = oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier), $verifier); + + $error = oauth_flow_token_error(oauth_test_bearer($body['access_token']), ['storefront:read']); + + expect($error->error)->toBe('insufficient_scope') + ->and($error->status)->toBe(403) + ->and($error->toResponse()->headers->get('WWW-Authenticate'))->toContain('scope="storefront:read"'); + }); + + it('loses a scope at once when an admin removes it from the app', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + [, $body] = oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier), $verifier); + + $client->forceFill(['allowed_scopes' => ['openid', 'profile', 'email']])->save(); + + expect(oauth_flow_token_error(oauth_test_bearer($body['access_token']), ['organization:read'])->error)->toBe('insufficient_scope'); + }); + + it('cannot widen scopes on refresh', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + [, $body] = oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier), $verifier); + + [$status, $refreshed] = oauth_test_token(['grant_type' => 'refresh_token', 'refresh_token' => $body['refresh_token'], 'client_id' => $client->id, 'client_secret' => $secret, 'scope' => 'openid storefront:read']); + + expect($status)->toBe(400) + ->and($refreshed['error'])->toBe('invalid_scope'); + }); + + it('needs an organization the user administers for organization scopes', function () { + [$client] = oauth_test_client(); + $server = oauth_test_server(); + $request = fn () => $server->validateAuthorizationRequest(oauth_test_params($client, oauth_test_verifier())); + $member = User::where('uuid', 'user-member')->first(); + $admin = User::where('uuid', 'user-admin')->first(); + $companyA = Company::where('uuid', 'company-a')->first(); + $companyB = Company::where('uuid', 'company-b')->first(); + + expect(fn () => $server->approve($request(), $member, null, null))->toThrow(AuthorizeError::class) + ->and(fn () => $server->approve($request(), $member, $companyA, null))->toThrow(AuthorizeError::class) + ->and(fn () => $server->approve($request(), $admin, $companyB, null))->toThrow(AuthorizeError::class) + ->and($server->approve($request(), $admin, $companyA, null)['grant']->company_uuid)->toBe('company-a') + ->and($server->approve($request(), $member, $companyB, null)['grant']->company_uuid)->toBe('company-b'); + }); +}); + +describe('refresh tokens', function () { + it('rotate, and a replayed one revokes the whole grant', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + [, $first] = oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier), $verifier); + $refresh = fn (string $token) => oauth_test_token(['grant_type' => 'refresh_token', 'refresh_token' => $token, 'client_id' => $client->id, 'client_secret' => $secret]); + + [$status, $second] = $refresh($first['refresh_token']); + expect($status)->toBe(200) + ->and($second['refresh_token'])->not->toBe($first['refresh_token']) + ->and(oauth_test_server()->authenticate(oauth_test_bearer($second['access_token']))->company_uuid)->toBe('company-a') + ->and(oauth_flow_token_error(oauth_test_bearer($first['access_token']))->error)->toBe('invalid_token'); + + [$replayStatus] = $refresh($first['refresh_token']); + expect($replayStatus)->toBe(401) + ->and(OAuthGrant::first()->isRevoked())->toBeTrue() + ->and(oauth_flow_token_error(oauth_test_bearer($second['access_token']))->error)->toBe('invalid_token'); + + [$afterStatus] = $refresh($second['refresh_token']); + expect($afterStatus)->toBe(401); + }); + + it('stop working when the user no longer administers the organization', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + [, $body] = oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier, [], 'user-admin'), $verifier); + + Fleetbase\Auth\OAuthServer\OAuthOrganizations::$administratorCheck = fn () => false; + [$status, $error] = oauth_test_token(['grant_type' => 'refresh_token', 'refresh_token' => $body['refresh_token'], 'client_id' => $client->id, 'client_secret' => $secret]); + + expect($status)->toBe(400) + ->and($error['error'])->toBe('invalid_grant') + ->and(OAuthGrant::first()->isRevoked())->toBeTrue(); + }); +}); + +describe('access to the organization', function () { + it('ends when the user leaves the organization', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + [, $body] = oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier), $verifier); + + Fleetbase\Models\CompanyUser::where('uuid', 'cu-owner-a')->delete(); + + expect(oauth_flow_token_error(oauth_test_bearer($body['access_token']))->error)->toBe('invalid_token') + ->and(OAuthGrant::first()->isRevoked())->toBeTrue(); + }); + + it('ends when the account is deactivated', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + [, $body] = oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier), $verifier); + + User::where('uuid', 'user-owner')->update(['status' => 'inactive']); + + expect(oauth_flow_token_error(oauth_test_bearer($body['access_token']))->error)->toBe('invalid_token'); + }); + + it('is refused for a forged or tampered token', function () { + $other = Fleetbase\Auth\OAuthServer\OAuthKeys::generate(2048); + $config = Configuration::forAsymmetricSigner(new Sha256(), InMemory::plainText($other['private']), InMemory::plainText($other['public'])); + $now = new DateTimeImmutable('@' . time()); + $forged = $config->builder()->identifiedBy('forged')->permittedFor('x')->relatedTo('user-owner')->issuedAt($now)->canOnlyBeUsedAfter($now)->expiresAt($now->modify('+1 hour'))->withClaim('scopes', ['organization:read'])->getToken($config->signer(), $config->signingKey()); + + expect(oauth_flow_token_error(oauth_test_bearer($forged->toString()))->error)->toBe('invalid_token') + ->and(oauth_flow_token_error(Request::create('/oauth/v1/organization'))->error)->toBe('invalid_token'); + }); +}); + +describe('revocation', function () { + it('of a refresh token disconnects the app', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + [, $body] = oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier), $verifier); + + $response = oauth_test_server()->revoke(Request::create('/oauth/revoke', 'POST', ['token' => $body['refresh_token'], 'token_type_hint' => 'refresh_token'], [], [], [ + 'HTTP_AUTHORIZATION' => 'Basic ' . base64_encode($client->id . ':' . $secret), + ])); + + expect($response->getStatusCode())->toBe(200) + ->and(OAuthGrant::first()->isRevoked())->toBeTrue() + ->and(oauth_flow_token_error(oauth_test_bearer($body['access_token']))->error)->toBe('invalid_token'); + }); + + it('of an access token revokes only that token', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + [, $body] = oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier), $verifier); + + $response = oauth_test_server()->revoke(Request::create('/oauth/revoke', 'POST', ['token' => $body['access_token'], 'client_id' => $client->id, 'client_secret' => $secret])); + + [$status] = oauth_test_token(['grant_type' => 'refresh_token', 'refresh_token' => $body['refresh_token'], 'client_id' => $client->id, 'client_secret' => $secret]); + + expect($response->getStatusCode())->toBe(200) + ->and(oauth_flow_token_error(oauth_test_bearer($body['access_token']))->error)->toBe('invalid_token') + ->and(OAuthGrant::first()->isRevoked())->toBeFalse() + ->and($status)->toBe(200); + }); + + it('needs the client to authenticate, and ignores other clients\' tokens', function () { + [$client, $secret] = oauth_test_client(); + [$other, $otherSecret] = oauth_test_client(['name' => 'Other app']); + $verifier = oauth_test_verifier(); + [, $body] = oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier), $verifier); + + $unauthenticated = oauth_test_server()->revoke(Request::create('/oauth/revoke', 'POST', ['token' => $body['access_token'], 'client_id' => $client->id, 'client_secret' => 'wrong'])); + $otherClient = oauth_test_server()->revoke(Request::create('/oauth/revoke', 'POST', ['token' => $body['refresh_token'], 'client_id' => $other->id, 'client_secret' => $otherSecret])); + $unknown = oauth_test_server()->revoke(Request::create('/oauth/revoke', 'POST', ['token' => 'not-a-token', 'client_id' => $client->id, 'client_secret' => $secret])); + + expect($unauthenticated->getStatusCode())->toBe(401) + ->and($otherClient->getStatusCode())->toBe(200) + ->and($unknown->getStatusCode())->toBe(200) + ->and(OAuthGrant::first()->isRevoked())->toBeFalse() + ->and(oauth_test_server()->authenticate(oauth_test_bearer($body['access_token']))->user_uuid)->toBe('user-owner'); + }); + + it('of a grant from Connected apps stops every token under it', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + [, $body] = oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier), $verifier); + + OAuthGrant::first()->revoke(); + + [$status] = oauth_test_token(['grant_type' => 'refresh_token', 'refresh_token' => $body['refresh_token'], 'client_id' => $client->id, 'client_secret' => $secret]); + + expect(oauth_flow_token_error(oauth_test_bearer($body['access_token']))->error)->toBe('invalid_token') + ->and($status)->toBe(401) + ->and(OAuthAccessToken::where('revoked', false)->count())->toBe(0); + }); +}); + +describe('clients', function () { + it('authenticate with their secret, which rotation replaces', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + $code = oauth_test_code($client, $verifier); + + [$wrongStatus, $wrong] = oauth_test_exchange($client, 'not-the-secret', $code, $verifier); + expect($wrongStatus)->toBe(401)->and($wrong['error'])->toBe('invalid_client'); + + $newSecret = $client->issueSecret(); + $client->save(); + + [$oldStatus] = oauth_test_exchange($client, $secret, $code, $verifier); + [$newStatus] = oauth_test_exchange($client, $newSecret, $code, $verifier); + + expect($oldStatus)->toBe(401) + ->and($newStatus)->toBe(200) + ->and($client->fresh()->secret)->not->toBe($newSecret) + ->and($client->fresh()->toArray())->not->toHaveKey('secret'); + }); + + it('cannot use grants other than authorization_code and refresh_token', function () { + [$client, $secret] = oauth_test_client(); + + [$status, $body] = oauth_test_token(['grant_type' => 'client_credentials', 'client_id' => $client->id, 'client_secret' => $secret]); + + expect($status)->toBe(400) + ->and($body['error'])->toBe('unsupported_grant_type'); + }); +}); + +describe('prompt and max_age', function () { + it('asks for a fresh sign-in when prompt=login or max_age says so', function () { + [$client] = oauth_test_client(); + $server = oauth_test_server(); + $request = fn (array $overrides) => $server->validateAuthorizationRequest(oauth_test_params($client, oauth_test_verifier(), $overrides)); + + expect($server->needsReauthentication($request(['prompt' => 'login']), new DateTimeImmutable('-10 minutes')))->toBeTrue() + ->and($server->needsReauthentication($request(['prompt' => 'login']), new DateTimeImmutable('-1 minute')))->toBeFalse() + ->and($server->needsReauthentication($request(['max_age' => '60']), new DateTimeImmutable('-2 minutes')))->toBeTrue() + ->and($server->needsReauthentication($request([]), new DateTimeImmutable('-1 day')))->toBeFalse(); + }); + + it('answers prompt=none with interaction_required', function () { + [$client] = oauth_test_client(); + + expect(oauth_flow_authorize_error(oauth_test_params($client, oauth_test_verifier(), ['prompt' => 'none']))->error)->toBe('interaction_required'); + }); +}); diff --git a/tests/Unit/Auth/OAuthServer/OAuthServerTestSupport.php b/tests/Unit/Auth/OAuthServer/OAuthServerTestSupport.php new file mode 100644 index 00000000..e55ef44d --- /dev/null +++ b/tests/Unit/Auth/OAuthServer/OAuthServerTestSupport.php @@ -0,0 +1,230 @@ + 'sqlite', 'database' => ':memory:', 'prefix' => '']; + $keys = oauth_test_keys(); + + $container = bind_test_container([ + 'app.key' => 'base64:' . base64_encode(str_repeat('k', 32)), + 'app.url' => 'https://api.fleetbase.test', + 'database.default' => 'mysql', + 'database.connections.mysql' => $connection, + 'fleetbase.connection.db' => 'mysql', + 'oauth-server.private_key' => $keys['private'], + 'oauth-server.public_key' => $keys['public'], + 'oauth-server.ttl' => ['auth_code' => 'PT10M', 'access_token' => 'PT1H', 'refresh_token' => 'P30D', 'id_token' => 'PT1H'], + ]); + + $capsule = new Capsule($container); + $capsule->addConnection($connection, 'mysql'); + $capsule->setEventDispatcher(new Dispatcher($container)); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + $capsule->getDatabaseManager()->setDefaultConnection('mysql'); + + $container->instance('db', $capsule->getDatabaseManager()); + $container->instance('db.schema', $capsule->getConnection('mysql')->getSchemaBuilder()); + Facade::clearResolvedInstance('db'); + Facade::clearResolvedInstance('db.schema'); + + $schema = $capsule->getConnection('mysql')->getSchemaBuilder(); + $schema->create('users', function ($table) { + $table->string('uuid')->primary(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('type')->nullable(); + $table->string('status')->nullable(); + $table->string('email')->nullable(); + $table->string('name')->nullable(); + $table->string('timezone')->nullable(); + $table->string('avatar_uuid')->nullable(); + $table->timestamp('email_verified_at')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + $schema->create('companies', function ($table) { + $table->string('uuid')->primary(); + $table->string('public_id')->nullable(); + $table->string('owner_uuid')->nullable(); + $table->string('name'); + $table->string('logo_uuid')->nullable(); + $table->string('currency')->nullable(); + $table->string('country')->nullable(); + $table->string('timezone')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + $schema->create('company_users', function ($table) { + $table->string('uuid')->primary(); + $table->string('company_uuid')->nullable(); + $table->string('user_uuid')->nullable(); + $table->string('status')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + $schema->create('files', function ($table) { + $table->string('uuid')->primary(); + $table->string('url')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + + (require __DIR__ . '/../../../../migrations/2026_10_25_000001_create_oauth_server_tables.php')->up(); + + $db = $capsule->getConnection('mysql'); + $now = '2026-10-25 00:00:00'; + $db->table('users')->insert([ + ['uuid' => 'user-owner', 'public_id' => 'user_owner', 'type' => 'user', 'status' => 'active', 'email' => 'owner@example.com', 'name' => 'Olive Owner', 'email_verified_at' => $now, 'created_at' => $now, 'updated_at' => $now], + ['uuid' => 'user-admin', 'public_id' => 'user_admin', 'type' => 'user', 'status' => 'active', 'email' => 'admin@example.com', 'name' => 'Ada Admin', 'email_verified_at' => null, 'created_at' => $now, 'updated_at' => $now], + ['uuid' => 'user-member', 'public_id' => 'user_member', 'type' => 'user', 'status' => 'active', 'email' => 'member@example.com', 'name' => 'Mo Member', 'email_verified_at' => $now, 'created_at' => $now, 'updated_at' => $now], + ]); + $db->table('companies')->insert([ + ['uuid' => 'company-a', 'public_id' => 'company_a', 'owner_uuid' => 'user-owner', 'name' => 'Acme Deliveries', 'currency' => 'USD', 'country' => 'US', 'created_at' => $now, 'updated_at' => $now], + ['uuid' => 'company-b', 'public_id' => 'company_b', 'owner_uuid' => 'user-member', 'name' => 'Bravo Freight', 'currency' => 'SGD', 'country' => 'SG', 'created_at' => $now, 'updated_at' => $now], + ]); + $db->table('company_users')->insert([ + ['uuid' => 'cu-owner-a', 'company_uuid' => 'company-a', 'user_uuid' => 'user-owner', 'status' => 'active', 'created_at' => $now, 'updated_at' => $now], + ['uuid' => 'cu-admin-a', 'company_uuid' => 'company-a', 'user_uuid' => 'user-admin', 'status' => 'active', 'created_at' => $now, 'updated_at' => $now], + ['uuid' => 'cu-member-a', 'company_uuid' => 'company-a', 'user_uuid' => 'user-member', 'status' => 'active', 'created_at' => $now, 'updated_at' => $now], + ['uuid' => 'cu-member-b', 'company_uuid' => 'company-b', 'user_uuid' => 'user-member', 'status' => 'active', 'created_at' => $now, 'updated_at' => $now], + ]); + + // Only user-admin holds company A's Administrator role. + OAuthOrganizations::$administratorCheck = fn ($membership) => $membership->uuid === 'cu-admin-a'; + + OAuthScopes::flush(); + OAuthScopes::registerDefaults(); + OAuthScopes::register('storefront:read', ['description' => "See {organization}'s storefronts", 'organization' => true]); + OAuthScopes::register('storefront:review-access', ['description' => 'Turn App Review sign-in on or off', 'organization' => true, 'review_access_types' => ['storefront-store']]); + + return $capsule; +} + +/** + * Register an app. Returns [client, plain secret or null]. + */ +function oauth_test_client(array $attributes = [], bool $confidential = true): array +{ + $client = new OAuthClient(array_merge([ + 'name' => 'AppHelm', + 'redirect_uris' => [OAUTH_TEST_REDIRECT], + 'allowed_scopes' => ['openid', 'profile', 'email', 'organization:read', 'storefront:read'], + 'official' => true, + ], $attributes)); + $secret = $confidential ? $client->issueSecret() : null; + $client->save(); + + return [$client, $secret]; +} + +function oauth_test_server(): OAuthServer +{ + return new OAuthServer(); +} + +function oauth_test_verifier(): string +{ + return OAuthKeys::base64url(random_bytes(48)); +} + +function oauth_test_challenge(string $verifier): string +{ + return OAuthKeys::base64url(hash('sha256', $verifier, true)); +} + +function oauth_test_params(OAuthClient $client, string $verifier, array $overrides = []): array +{ + return array_filter(array_merge([ + 'response_type' => 'code', + 'client_id' => $client->id, + 'redirect_uri' => OAUTH_TEST_REDIRECT, + 'scope' => 'openid profile email organization:read', + 'state' => 'state-123', + 'nonce' => 'nonce-abc', + 'code_challenge' => oauth_test_challenge($verifier), + 'code_challenge_method' => 'S256', + ], $overrides), fn ($value) => $value !== null); +} + +/** + * Run the consent step and return the code from the redirect. + */ +function oauth_test_code(OAuthClient $client, string $verifier, array $overrides = [], string $userUuid = 'user-owner', ?string $companyUuid = 'company-a'): string +{ + $server = oauth_test_server(); + $authorization = $server->validateAuthorizationRequest(oauth_test_params($client, $verifier, $overrides)); + $user = Fleetbase\Models\User::where('uuid', $userUuid)->first(); + $company = $companyUuid ? Fleetbase\Models\Company::where('uuid', $companyUuid)->first() : null; + $result = $server->approve($authorization, $user, $company, new DateTimeImmutable('-1 minute')); + + parse_str((string) parse_url($result['redirect'], PHP_URL_QUERY), $query); + + return $query['code']; +} + +/** + * POST to the token endpoint. Returns [status, decoded body]. + */ +function oauth_test_token(array $params): array +{ + $request = Request::create('/oauth/token', 'POST', $params); + $response = oauth_test_server()->respondToTokenRequest($request); + + return [$response->getStatusCode(), json_decode($response->getContent(), true)]; +} + +function oauth_test_exchange(OAuthClient $client, ?string $secret, string $code, string $verifier, array $overrides = []): array +{ + return oauth_test_token(array_filter(array_merge([ + 'grant_type' => 'authorization_code', + 'client_id' => $client->id, + 'client_secret' => $secret, + 'code' => $code, + 'redirect_uri' => OAUTH_TEST_REDIRECT, + 'code_verifier' => $verifier, + ], $overrides), fn ($value) => $value !== null)); +} + +function oauth_test_bearer(string $accessToken, string $uri = '/oauth/v1/organization'): Request +{ + return Request::create($uri, 'GET', [], [], [], ['HTTP_AUTHORIZATION' => 'Bearer ' . $accessToken]); +} + +function oauth_test_teardown(): void +{ + OAuthOrganizations::$administratorCheck = null; + OAuthScopes::flush(); + EloquentModel::clearBootedModels(); + Container::setInstance(new FleetbaseTestContainer()); + Facade::clearResolvedInstances(); +} diff --git a/tests/Unit/Providers/CoreProviderContractsTest.php b/tests/Unit/Providers/CoreProviderContractsTest.php index 68d485a3..30726a96 100644 --- a/tests/Unit/Providers/CoreProviderContractsTest.php +++ b/tests/Unit/Providers/CoreProviderContractsTest.php @@ -939,7 +939,7 @@ class_alias(CoreProviderContractsFailingMixinMacro::class, 'Fleetbase\\ProviderF ['loadMigrationsFrom', dirname(__DIR__, 3) . '/src/Providers/../../migrations'], ['loadViewsFrom', dirname(__DIR__, 3) . '/src/Providers/../../views', 'fleetbase'] ) - ->and($provider->schedule->commands)->toHaveCount(9) + ->and($provider->schedule->commands)->toHaveCount(10) ->and(array_map(fn ($event) => $event->name, $provider->schedule->commands))->toBe([ 'cache:prune-stale-tags', 'model:prune', @@ -948,13 +948,15 @@ class_alias(CoreProviderContractsFailingMixinMacro::class, 'Fleetbase\\ProviderF 'purge:activity-logs --force --no-interaction --days 2 --keep-backups=30', 'purge:scheduled-task-logs --force --no-interaction --days 1 --keep-backups=30', 'model:prune', + 'oauth-server:prune', 'telemetry:ping', 'sandbox:sync', ]) ->and($provider->schedule->commands[1]->parameters)->toBe(['--model' => Spatie\ScheduleMonitor\Models\MonitoredScheduledTaskLogItem::class]) ->and($provider->schedule->commands[6]->parameters)->toBe(['--model' => Fleetbase\Models\OAuthState::class]) ->and($provider->schedule->commands[6]->methods)->toBe([['hourly']]) - ->and($provider->schedule->commands[8]->methods)->toBe([['hourly'], ['name', 'sandbox-sync'], ['withoutOverlapping']]) + ->and($provider->schedule->commands[7]->methods)->toBe([['daily']]) + ->and($provider->schedule->commands[9]->methods)->toBe([['hourly'], ['name', 'sandbox-sync'], ['withoutOverlapping']]) ->and($provider->schedule->jobs)->toHaveCount(1) ->and($provider->schedule->jobs[0]->name)->toBe(Fleetbase\Jobs\MaterializeSchedulesJob::class) ->and($provider->schedule->jobs[0]->methods)->toBe([['dailyAt', '01:00'], ['name', 'materialize-schedules'], ['withoutOverlapping']]) From 2a7f1e33c1ce5863551b34358a7636b41cd51c74 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sun, 25 Oct 2026 01:19:09 +0800 Subject: [PATCH 4/7] fix(oauth-server): keep grants and tokens on the core database in console sandbox mode The consent screen runs on the console session, which switches models to the sandbox connection when test mode is on. OAuth rows and the organization checks now always use the core connection. Also raise the per-IP limit on the token and resource endpoints, since an app's server makes every call from one address. --- src/Auth/OAuthServer/IdTokenResponse.php | 2 +- src/Auth/OAuthServer/OAuthOrganizations.php | 9 ++++++--- src/Auth/OAuthServer/OAuthServer.php | 2 +- src/Models/OAuthAccessToken.php | 16 ++++++++-------- src/Models/OAuthAuthCode.php | 16 ++++++++-------- src/Models/OAuthClient.php | 16 ++++++++-------- src/Models/OAuthGrant.php | 19 +++++++++---------- src/Models/OAuthRefreshToken.php | 16 ++++++++-------- src/routes.php | 4 ++-- 9 files changed, 51 insertions(+), 49 deletions(-) diff --git a/src/Auth/OAuthServer/IdTokenResponse.php b/src/Auth/OAuthServer/IdTokenResponse.php index dcf016ee..2ad36af8 100644 --- a/src/Auth/OAuthServer/IdTokenResponse.php +++ b/src/Auth/OAuthServer/IdTokenResponse.php @@ -41,7 +41,7 @@ protected function getExtraParams(AccessTokenEntityInterface $accessToken) return $params; } - $company = $this->context->companyUuid ? Company::where('uuid', $this->context->companyUuid)->first() : null; + $company = $this->context->companyUuid ? Company::on('mysql')->where('uuid', $this->context->companyUuid)->first() : null; $config = Configuration::forAsymmetricSigner( new Sha256(), InMemory::plainText($this->keys->privateKeyContents()), diff --git a/src/Auth/OAuthServer/OAuthOrganizations.php b/src/Auth/OAuthServer/OAuthOrganizations.php index b87a91a8..d23a015a 100644 --- a/src/Auth/OAuthServer/OAuthOrganizations.php +++ b/src/Auth/OAuthServer/OAuthOrganizations.php @@ -14,6 +14,9 @@ * hold its Administrator role, or are a system admin. Plain sign-in (openid, profile, email) * needs no organization. The same check runs again on every token refresh and API call, so an * app loses access when the user leaves the organization or stops administering it. + * + * Lookups go to the core database explicitly: the consent screen runs on the console session, + * which a console in sandbox (test) mode would otherwise point at the sandbox copy. */ class OAuthOrganizations { @@ -26,7 +29,7 @@ class OAuthOrganizations public static function membership(User $user, Company $company): ?CompanyUser { - return CompanyUser::where('user_uuid', $user->uuid) + return CompanyUser::on('mysql')->where('user_uuid', $user->uuid) ->where('company_uuid', $company->uuid) ->where(function ($query) { $query->whereNull('status')->orWhere('status', 'active'); @@ -68,7 +71,7 @@ public static function userIsActive(User $user): bool */ public static function forPicker(User $user): Collection { - $companies = Company::whereIn('uuid', CompanyUser::where('user_uuid', $user->uuid)->pluck('company_uuid')) + $companies = Company::on('mysql')->whereIn('uuid', CompanyUser::on('mysql')->where('user_uuid', $user->uuid)->pluck('company_uuid')) ->whereNotNull('owner_uuid') ->orderBy('name') ->get(); @@ -91,7 +94,7 @@ public static function find(User $user, ?string $id): ?Company return null; } - $company = Company::where(fn ($query) => $query->where('public_id', $id)->orWhere('uuid', $id))->first(); + $company = Company::on('mysql')->where(fn ($query) => $query->where('public_id', $id)->orWhere('uuid', $id))->first(); if (!$company || !static::membership($user, $company)) { return null; } diff --git a/src/Auth/OAuthServer/OAuthServer.php b/src/Auth/OAuthServer/OAuthServer.php index 2ec34a1d..f5bdf991 100644 --- a/src/Auth/OAuthServer/OAuthServer.php +++ b/src/Auth/OAuthServer/OAuthServer.php @@ -387,7 +387,7 @@ protected function assertGrantUsable(?OAuthGrant $grant): void } if ($grant->company_uuid) { - $company = Company::where('uuid', $grant->company_uuid)->first(); + $company = Company::on('mysql')->where('uuid', $grant->company_uuid)->first(); if (!$company || !OAuthOrganizations::canShare($user, $company)) { $grant->revoke(); diff --git a/src/Models/OAuthAccessToken.php b/src/Models/OAuthAccessToken.php index d5e30938..2b4f8636 100644 --- a/src/Models/OAuthAccessToken.php +++ b/src/Models/OAuthAccessToken.php @@ -13,6 +13,14 @@ */ class OAuthAccessToken extends EloquentModel { + /** + * Always the core database, also when the console is in sandbox (test) mode: an app's + * access is never a test record. + * + * @var string + */ + protected $connection = 'mysql'; + protected $table = 'oauth_access_tokens'; protected $keyType = 'string'; @@ -27,14 +35,6 @@ class OAuthAccessToken extends EloquentModel 'expires_at' => 'datetime', ]; - /** - * Get the correct current connection to use. - */ - public function getConnectionName() - { - return $this->connection ?: config('fleetbase.connection.db', 'mysql'); - } - public function grant(): BelongsTo { return $this->belongsTo(OAuthGrant::class, 'grant_uuid', 'uuid'); diff --git a/src/Models/OAuthAuthCode.php b/src/Models/OAuthAuthCode.php index 6e24f06e..afd63ed6 100644 --- a/src/Models/OAuthAuthCode.php +++ b/src/Models/OAuthAuthCode.php @@ -9,6 +9,14 @@ */ class OAuthAuthCode extends EloquentModel { + /** + * Always the core database, also when the console is in sandbox (test) mode: an app's + * access is never a test record. + * + * @var string + */ + protected $connection = 'mysql'; + protected $table = 'oauth_auth_codes'; protected $keyType = 'string'; @@ -26,12 +34,4 @@ class OAuthAuthCode extends EloquentModel 'expires_at' => 'datetime', 'created_at' => 'datetime', ]; - - /** - * Get the correct current connection to use. - */ - public function getConnectionName() - { - return $this->connection ?: config('fleetbase.connection.db', 'mysql'); - } } diff --git a/src/Models/OAuthClient.php b/src/Models/OAuthClient.php index e7b82d9c..7f03b7ea 100644 --- a/src/Models/OAuthClient.php +++ b/src/Models/OAuthClient.php @@ -17,6 +17,14 @@ */ class OAuthClient extends EloquentModel { + /** + * Always the core database, also when the console is in sandbox (test) mode: an app's + * access is never a test record. + * + * @var string + */ + protected $connection = 'mysql'; + /** * The database table used by the model. * @@ -85,14 +93,6 @@ class OAuthClient extends EloquentModel */ protected $appends = ['confidential']; - /** - * Get the correct current connection to use. - */ - public function getConnectionName() - { - return $this->connection ?: config('fleetbase.connection.db', 'mysql'); - } - /** * Generate the client id on create. */ diff --git a/src/Models/OAuthGrant.php b/src/Models/OAuthGrant.php index bec1a4d2..781e9e97 100644 --- a/src/Models/OAuthGrant.php +++ b/src/Models/OAuthGrant.php @@ -17,6 +17,14 @@ */ class OAuthGrant extends EloquentModel { + /** + * Always the core database, also when the console is in sandbox (test) mode: an app's + * access is never a test record. + * + * @var string + */ + protected $connection = 'mysql'; + /** * The database table used by the model. * @@ -70,14 +78,6 @@ class OAuthGrant extends EloquentModel 'revoked_at' => 'datetime', ]; - /** - * Get the correct current connection to use. - */ - public function getConnectionName() - { - return $this->connection ?: config('fleetbase.connection.db', 'mysql'); - } - /** * Generate the uuid on create. */ @@ -146,8 +146,7 @@ public static function revokeTokensFor(array $grantUuids, ?string $connection = return; } - $connection ??= config('fleetbase.connection.db', 'mysql'); - $db = DB::connection($connection); + $db = DB::connection($connection ?? 'mysql'); $db->table('oauth_auth_codes')->whereIn('grant_uuid', $grantUuids)->update(['revoked' => true]); $db->table('oauth_access_tokens')->whereIn('grant_uuid', $grantUuids)->update(['revoked' => true, 'updated_at' => now()]); diff --git a/src/Models/OAuthRefreshToken.php b/src/Models/OAuthRefreshToken.php index d37a1505..a013d74c 100644 --- a/src/Models/OAuthRefreshToken.php +++ b/src/Models/OAuthRefreshToken.php @@ -9,6 +9,14 @@ */ class OAuthRefreshToken extends EloquentModel { + /** + * Always the core database, also when the console is in sandbox (test) mode: an app's + * access is never a test record. + * + * @var string + */ + protected $connection = 'mysql'; + protected $table = 'oauth_refresh_tokens'; protected $keyType = 'string'; @@ -24,12 +32,4 @@ class OAuthRefreshToken extends EloquentModel 'expires_at' => 'datetime', 'created_at' => 'datetime', ]; - - /** - * Get the correct current connection to use. - */ - public function getConnectionName() - { - return $this->connection ?: config('fleetbase.connection.db', 'mysql'); - } } diff --git a/src/routes.php b/src/routes.php index 0fb088c3..4a411134 100644 --- a/src/routes.php +++ b/src/routes.php @@ -38,11 +38,11 @@ function ($router) { $router->prefix('oauth')->group(function ($router) { $router->get('authorize', [Fleetbase\Http\Controllers\OAuthServer\AuthorizationController::class, 'authorize']); $router->get('jwks', [Fleetbase\Http\Controllers\OAuthServer\DiscoveryController::class, 'jwks']); - $router->middleware(['throttle:60,1'])->group(function ($router) { + $router->middleware(['throttle:600,1'])->group(function ($router) { $router->post('token', [Fleetbase\Http\Controllers\OAuthServer\TokenController::class, 'token']); $router->post('revoke', [Fleetbase\Http\Controllers\OAuthServer\TokenController::class, 'revoke']); }); - $router->middleware(['throttle:120,1'])->group(function ($router) { + $router->middleware(['throttle:600,1'])->group(function ($router) { $router->match(['GET', 'POST'], 'userinfo', [Fleetbase\Http\Controllers\OAuthServer\ResourceController::class, 'userinfo']) ->middleware(Fleetbase\Http\Middleware\AuthenticateOAuthToken::class . ':openid'); $router->get('v1/organization', [Fleetbase\Http\Controllers\OAuthServer\ResourceController::class, 'organization']) From 8a5dfacd0bdc8b613118044ee5c627ac5d41702c Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sun, 25 Oct 2026 01:47:31 +0800 Subject: [PATCH 5/7] test(oauth-server): use past fixture dates so the date drift check passes --- tests/Unit/Auth/OAuthServer/OAuthServerTestSupport.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Unit/Auth/OAuthServer/OAuthServerTestSupport.php b/tests/Unit/Auth/OAuthServer/OAuthServerTestSupport.php index e55ef44d..db6a2fbb 100644 --- a/tests/Unit/Auth/OAuthServer/OAuthServerTestSupport.php +++ b/tests/Unit/Auth/OAuthServer/OAuthServerTestSupport.php @@ -102,7 +102,7 @@ function oauth_test_setup(): Capsule (require __DIR__ . '/../../../../migrations/2026_10_25_000001_create_oauth_server_tables.php')->up(); $db = $capsule->getConnection('mysql'); - $now = '2026-10-25 00:00:00'; + $now = '2026-10-01 00:00:00'; $db->table('users')->insert([ ['uuid' => 'user-owner', 'public_id' => 'user_owner', 'type' => 'user', 'status' => 'active', 'email' => 'owner@example.com', 'name' => 'Olive Owner', 'email_verified_at' => $now, 'created_at' => $now, 'updated_at' => $now], ['uuid' => 'user-admin', 'public_id' => 'user_admin', 'type' => 'user', 'status' => 'active', 'email' => 'admin@example.com', 'name' => 'Ada Admin', 'email_verified_at' => null, 'created_at' => $now, 'updated_at' => $now], From 55ab054847dfa2412bb2b4e84b4bcd971bb44aef Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sun, 25 Oct 2026 02:24:25 +0800 Subject: [PATCH 6/7] test(oauth-server): cover the consent, admin and connected-apps endpoints, commands and edge cases core-api CI requires full coverage. Adds tests for the console endpoints, the authorize redirect, the artisan commands, the authorized-app notice, real spatie roles in the organization check, and the protocol edge cases. The authorize endpoint builds its RedirectResponse directly. --- .../OAuthServer/AuthorizationController.php | 6 +- .../OAuthServerConsoleEndpointsTest.php | 402 ++++++++++++++++++ .../OAuthServer/OAuthServerEdgeCasesTest.php | 386 +++++++++++++++++ .../OAuthServer/OAuthServerEndpointsTest.php | 9 + .../OAuthServer/OAuthServerTestSupport.php | 79 ++++ 5 files changed, 879 insertions(+), 3 deletions(-) create mode 100644 tests/Unit/Auth/OAuthServer/OAuthServerConsoleEndpointsTest.php create mode 100644 tests/Unit/Auth/OAuthServer/OAuthServerEdgeCasesTest.php diff --git a/src/Http/Controllers/OAuthServer/AuthorizationController.php b/src/Http/Controllers/OAuthServer/AuthorizationController.php index 7d0e456f..b25e58d8 100644 --- a/src/Http/Controllers/OAuthServer/AuthorizationController.php +++ b/src/Http/Controllers/OAuthServer/AuthorizationController.php @@ -27,12 +27,12 @@ public function authorize(Request $request, OAuthServer $server): RedirectRespon $server->validateAuthorizationRequest($request->query()); } catch (AuthorizeError $e) { if ($e->canRedirect()) { - return redirect()->away($e->redirectUrl($server->issuer())); + return new RedirectResponse($e->redirectUrl($server->issuer())); } - return redirect()->away(Utils::consoleUrl($consentPath, ['error' => $e->error, 'error_description' => $e->description])); + return new RedirectResponse(Utils::consoleUrl($consentPath, ['error' => $e->error, 'error_description' => $e->description])); } - return redirect()->away(Utils::consoleUrl($consentPath, $request->query())); + return new RedirectResponse(Utils::consoleUrl($consentPath, $request->query())); } } diff --git a/tests/Unit/Auth/OAuthServer/OAuthServerConsoleEndpointsTest.php b/tests/Unit/Auth/OAuthServer/OAuthServerConsoleEndpointsTest.php new file mode 100644 index 00000000..bde6cfa3 --- /dev/null +++ b/tests/Unit/Auth/OAuthServer/OAuthServerConsoleEndpointsTest.php @@ -0,0 +1,402 @@ + 'Fleetbase', + 'fleetbase.console.host' => 'console.fleetbase.test', + 'fleetbase.console.secure' => true, + ]); + $container = Container::getInstance(); + $container->instance('validator', new Illuminate\Validation\Factory(new Illuminate\Translation\Translator(new Illuminate\Translation\ArrayLoader(), 'en'), $container)); + Illuminate\Support\Facades\Facade::clearResolvedInstance('validator'); +}); +afterEach(fn () => oauth_test_teardown()); + +/** + * A console request from a signed-in user, with their Sanctum token's sign-in time. + */ +function oauth_console_request(string $userUuid, string $method = 'GET', array $input = [], string $signedIn = '-1 minute', string $class = Request::class): Request +{ + $user = User::where('uuid', $userUuid)->first(); + $user->withAccessToken((object) ['created_at' => Illuminate\Support\Carbon::parse($signedIn)]); + + $request = $class::create('/int/v1/oauth-server/authorization', $method, $input); + $request->setUserResolver(fn () => $user); + + return $request; +} + +function oauth_console_consent(): OAuthConsentController +{ + return new OAuthConsentController(oauth_test_server()); +} + +/** + * Run a command with these arguments; returns [exit code, output]. + */ +function oauth_console_command(Illuminate\Console\Command $command, array $input = []): array +{ + $output = new BufferedOutput(); + $command->setLaravel(Container::getInstance()); + $command->setInput(new ArrayInput($input, $command->getDefinition())); + $command->setOutput(new OutputStyle(new ArrayInput([]), $output)); + + return [$command->handle(), $output->fetch()]; +} + +describe('consent screen', function () { + it('describes the request: the app, the user, the scopes and the organizations they can share', function () { + [$client] = oauth_test_client(['logo_url' => 'https://app.example.com/logo.png']); + $response = oauth_console_consent()->show(oauth_console_request('user-admin', 'GET', oauth_test_params($client, oauth_test_verifier()) + ['utm_source' => 'mail'])); + $body = $response->getData(true); + + expect($response->getStatusCode())->toBe(200) + ->and($body['client'])->toMatchArray(['id' => $client->id, 'name' => 'AppHelm', 'official' => true, 'redirect_host' => 'app.example.com']) + ->and($body['user']['email'])->toBe('admin@example.com') + ->and(array_column($body['scopes'], 'scope'))->toBe(['openid', 'profile', 'email', 'organization:read']) + ->and($body['scopes'][3]['description'])->toContain('{organization}') + ->and($body['requires_organization'])->toBeTrue() + ->and($body['organizations'])->toBe([['id' => 'company_a', 'name' => 'Acme Deliveries', 'logo_url' => $body['organizations'][0]['logo_url'], 'can_share' => true]]) + ->and($body['skip_consent'])->toBeFalse(); + }); + + it('preselects the organization the user shared with the app before', function () { + [$client] = oauth_test_client(); + $verifier = oauth_test_verifier(); + oauth_test_code($client, $verifier, [], 'user-member', 'company-b'); + + $body = oauth_console_consent()->show(oauth_console_request('user-member', 'GET', oauth_test_params($client, oauth_test_verifier())))->getData(true); + + expect($body['selected_organization'])->toBe('company_b'); + }); + + it('asks for a fresh sign-in when the app wants one', function () { + [$client] = oauth_test_client(); + $params = oauth_test_params($client, oauth_test_verifier(), ['prompt' => 'login']); + + $show = oauth_console_consent()->show(oauth_console_request('user-owner', 'GET', $params, '-1 hour')); + $approve = oauth_console_consent()->approve(oauth_console_request('user-owner', 'POST', $params + ['organization' => 'company_a'], '-1 hour')); + + expect($show->getData(true))->toBe(['reauthenticate' => true]) + ->and($approve->getStatusCode())->toBe(409); + }); + + it('returns an error the app can receive with where to send the user, and one it cannot without', function () { + [$client] = oauth_test_client(); + + $scope = oauth_console_consent()->show(oauth_console_request('user-owner', 'GET', oauth_test_params($client, oauth_test_verifier(), ['scope' => 'openid nope']))); + $unknown = oauth_console_consent()->show(oauth_console_request('user-owner', 'GET', ['client_id' => 'nope'])); + + expect($scope->getStatusCode())->toBe(400) + ->and($scope->getData(true)['error'])->toBe('invalid_scope') + ->and($scope->getData(true)['redirect'])->toStartWith(OAUTH_TEST_REDIRECT . '?error=invalid_scope') + ->and($unknown->getData(true))->toMatchArray(['error' => 'invalid_client', 'redirect' => null]); + }); + + it('approves with the organization picked, sends the user back with a code, and tells the account holder', function () { + [$client] = oauth_test_client(); + $params = oauth_test_params($client, oauth_test_verifier()); + + $response = oauth_console_consent()->approve(oauth_console_request('user-owner', 'POST', $params + ['organization' => 'company_a'])); + $again = oauth_console_consent()->approve(oauth_console_request('user-owner', 'POST', $params + ['organization' => 'company_a'])); + + expect($response->getStatusCode())->toBe(200) + ->and($response->getData(true)['redirect'])->toContain('code=') + ->and(OAuthGrant::count())->toBe(1) + ->and(OAuthGrant::first()->company_uuid)->toBe('company-a') + ->and(OAuthAuthCode::first()->auth_time)->not->toBeNull() + ->and($again->getStatusCode())->toBe(200); + }); + + it('refuses an organization the user does not administer', function () { + [$client] = oauth_test_client(); + + $response = oauth_console_consent()->approve(oauth_console_request('user-member', 'POST', oauth_test_params($client, oauth_test_verifier()) + ['organization' => 'company_a'])); + + expect($response->getStatusCode())->toBe(422) + ->and($response->getData(true)['error'])->toBe('access_denied') + ->and(OAuthGrant::count())->toBe(0); + }); + + it('sends a cancelled request back to the app', function () { + [$client] = oauth_test_client(); + + $deny = oauth_console_consent()->deny(oauth_console_request('user-owner', 'DELETE', oauth_test_params($client, oauth_test_verifier()))); + $invalid = oauth_console_consent()->deny(oauth_console_request('user-owner', 'DELETE', ['client_id' => 'nope'])); + + expect($deny->getData(true)['redirect'])->toStartWith(OAUTH_TEST_REDIRECT . '?error=access_denied') + ->and($invalid->getStatusCode())->toBe(400); + }); +}); + +describe('the authorization endpoint', function () { + it('sends a valid request to the console consent screen, and errors where they belong', function () { + [$client] = oauth_test_client(); + $controller = new AuthorizationController(); + $authorize = fn (array $query) => $controller->authorize(Request::create('/oauth/authorize', 'GET', $query), oauth_test_server())->getTargetUrl(); + + expect($authorize(oauth_test_params($client, oauth_test_verifier())))->toStartWith('https://console.fleetbase.test/oauth/authorize?response_type=code&client_id=' . $client->id) + ->and($authorize(oauth_test_params($client, oauth_test_verifier(), ['response_type' => 'token'])))->toStartWith(OAUTH_TEST_REDIRECT . '?error=unsupported_response_type') + ->and($authorize(['client_id' => 'nope']))->toStartWith('https://console.fleetbase.test/oauth/authorize?error=invalid_client'); + }); + + it('hands token and revocation requests to the server', function () { + [$client, $secret] = oauth_test_client(); + $controller = new TokenController(); + + $token = $controller->token(Request::create('/oauth/token', 'POST', ['grant_type' => 'refresh_token', 'client_id' => $client->id, 'client_secret' => $secret, 'refresh_token' => 'x']), oauth_test_server()); + $revoke = $controller->revoke(Request::create('/oauth/revoke', 'POST', ['token' => 'x', 'client_id' => $client->id, 'client_secret' => $secret]), oauth_test_server()); + + expect($token->getStatusCode())->toBe(401) + ->and($revoke->getStatusCode())->toBe(200); + }); +}); + +describe('connected apps', function () { + it('lists the user\'s apps in plain words, and removes one', function () { + [$client] = oauth_test_client(['logo_url' => 'https://app.example.com/logo.png']); + $verifier = oauth_test_verifier(); + oauth_test_code($client, $verifier); + $controller = new ConnectedAppController(); + + $apps = $controller->index(oauth_console_request('user-owner'))->getData(true)['connected_apps']; + + expect($apps)->toHaveCount(1) + ->and($apps[0]['app'])->toMatchArray(['name' => 'AppHelm', 'official' => true, 'disabled' => false]) + ->and($apps[0]['organization'])->toBe(['id' => 'company_a', 'name' => 'Acme Deliveries']) + ->and(array_column($apps[0]['scopes'], 'description'))->toContain("See Acme Deliveries's name, logo, currency and country") + ->and($controller->index(oauth_console_request('user-member'))->getData(true)['connected_apps'])->toBe([]); + + expect(fn () => $controller->destroy(oauth_console_request('user-member'), $apps[0]['id']))->toThrow(Illuminate\Database\Eloquent\ModelNotFoundException::class); + + $controller->destroy(oauth_console_request('user-owner'), $apps[0]['id']); + + expect(OAuthGrant::first()->isRevoked())->toBeTrue() + ->and(OAuthAuthCode::where('revoked', false)->count())->toBe(0); + }); +}); + +describe('admin: OAuth Apps', function () { + function oauth_admin_request(string $method = 'GET', array $input = []): AdminRequest + { + return oauth_console_request('user-owner', $method, $input, '-1 minute', AdminRequest::class); + } + + it('registers an app, showing the secret once, then edits it', function () { + $controller = new OAuthAppController(); + + $created = $controller->store(oauth_admin_request('POST', [ + 'name' => 'AppHelm', + 'redirect_uris' => [OAUTH_TEST_REDIRECT, OAUTH_TEST_REDIRECT], + 'allowed_scopes' => ['openid', 'organization:read', 'openid'], + 'official' => true, + ])); + $body = $created->getData(true); + $client = OAuthClient::find($body['app']['id']); + + expect($created->getStatusCode())->toBe(201) + ->and($body['client_secret'])->toBeString() + ->and($client->verifySecret($body['client_secret']))->toBeTrue() + ->and($client->redirect_uris)->toBe([OAUTH_TEST_REDIRECT]) + ->and($client->allowed_scopes)->toBe(['openid', 'organization:read']) + ->and($client->created_by_uuid)->toBe('user-owner') + ->and($body['app'])->not->toHaveKey('secret'); + + $updated = $controller->update(oauth_admin_request('PUT', ['name' => 'AppHelm 2', 'confidential' => false]), $client->id)->getData(true); + + expect($updated['app']['name'])->toBe('AppHelm 2') + ->and($updated['app']['confidential'])->toBeTrue(); + + $listed = $controller->index(oauth_admin_request())->getData(true); + $shown = $controller->show(oauth_admin_request(), $client->id)->getData(true); + + expect($listed['apps'][0]['active_grants_count'])->toBe(0) + ->and(array_column($listed['scopes'], 'scope'))->toContain('openid') + ->and($shown['app']['id'])->toBe($client->id); + }); + + it('registers a public app without a secret', function () { + $body = (new OAuthAppController())->store(oauth_admin_request('POST', [ + 'name' => 'Native', + 'redirect_uris' => ['http://127.0.0.1:3000/cb'], + 'allowed_scopes' => ['openid'], + 'development' => true, + 'confidential' => false, + ]))->getData(true); + + expect($body['client_secret'])->toBeNull() + ->and($body['app']['confidential'])->toBeFalse() + ->and((new OAuthAppController())->rotateSecret(oauth_admin_request('POST'), $body['app']['id'])->getStatusCode())->toBe(422); + }); + + it('refuses an unsafe redirect URI or an unknown scope', function () { + $register = fn (array $input) => (new OAuthAppController())->store(oauth_admin_request('POST', $input + ['name' => 'X', 'allowed_scopes' => ['openid'], 'redirect_uris' => [OAUTH_TEST_REDIRECT]])); + + expect(fn () => $register(['redirect_uris' => ['http://app.example.com/cb']]))->toThrow(ValidationException::class) + ->and(fn () => $register(['allowed_scopes' => ['admin:all']]))->toThrow(ValidationException::class) + ->and(OAuthClient::count())->toBe(0); + }); + + it('rotates a secret, turns an app off and on, and lists and revokes who authorized it', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + [, $tokens] = oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier), $verifier); + $controller = new OAuthAppController(); + + $rotated = $controller->rotateSecret(oauth_admin_request('POST'), $client->id)->getData(true); + expect($client->fresh()->verifySecret($secret))->toBeFalse() + ->and($client->fresh()->verifySecret($rotated['client_secret']))->toBeTrue(); + + $grants = $controller->grants(oauth_admin_request(), $client->id)->getData(true)['grants']; + expect($grants)->toHaveCount(1) + ->and($grants[0]['user'])->toBe(['id' => 'user_owner', 'name' => 'Olive Owner', 'email' => 'owner@example.com']) + ->and($grants[0]['organization'])->toBe(['id' => 'company_a', 'name' => 'Acme Deliveries']); + + $controller->disable(oauth_admin_request('POST'), $client->id); + expect($client->fresh()->isDisabled())->toBeTrue() + ->and(OAuthGrant::first()->isRevoked())->toBeTrue() + ->and(OAuthAccessToken::where('revoked', false)->count())->toBe(0) + ->and(OAuthRefreshToken::where('revoked', false)->count())->toBe(0); + + $controller->enable(oauth_admin_request('POST'), $client->id); + expect($client->fresh()->isDisabled())->toBeFalse() + ->and($tokens)->toHaveKey('access_token'); + + $verifier = oauth_test_verifier(); + oauth_test_code($client, $verifier); + $grant = OAuthGrant::active()->first(); + $controller->revokeGrant(oauth_admin_request('DELETE'), $grant->uuid); + expect($grant->fresh()->isRevoked())->toBeTrue(); + }); +}); + +describe('the account holder notice', function () { + it('names the app, the organization and what it can do', function () { + $user = User::where('uuid', 'user-owner')->first(); + $organization = new OAuthAppAuthorized('AppHelm', 'Acme Deliveries', ['See your email address']); + $signIn = new OAuthAppAuthorized('AppHelm', null, ['Sign you in with your Fleetbase account']); + $mail = $organization->toMail($user); + + expect($organization->via($user))->toBe(['mail']) + ->and($mail->subject)->toBe('AppHelm can now access Acme Deliveries') + ->and($mail->introLines)->toContain('• See your email address') + ->and($mail->actionUrl)->toBe('https://console.fleetbase.test/account/auth') + ->and($signIn->toMail($user)->subject)->toBe('AppHelm can now sign you in with Fleetbase') + ->and($organization->toArray($user))->toMatchArray(['app' => 'AppHelm', 'organization' => 'Acme Deliveries', 'scopes' => ['See your email address']]); + }); +}); + +describe('artisan', function () { + it('registers, lists and rotates apps', function () { + [$code, $output] = oauth_console_command(new OAuthServerClient(), [ + 'action' => 'create', + '--name' => 'AppHelm', + '--redirect' => [OAUTH_TEST_REDIRECT], + '--scope' => ['openid', 'organization:read'], + '--official' => true, + ]); + $client = OAuthClient::first(); + + expect($code)->toBe(0) + ->and($output)->toContain('client_id: ' . $client->id) + ->and($output)->toContain('client_secret: ') + ->and($client->official)->toBeTrue() + ->and($client->allowed_scopes)->toBe(['openid', 'organization:read']); + + [$listCode, $list] = oauth_console_command(new OAuthServerClient(), ['action' => 'list']); + expect($listCode)->toBe(0)->and($list)->toContain('AppHelm')->and($list)->toContain('confidential'); + + [$rotateCode, $rotated] = oauth_console_command(new OAuthServerClient(), ['action' => 'rotate', 'id' => $client->id]); + expect($rotateCode)->toBe(0)->and($rotated)->toContain('old secret stopped working'); + }); + + it('registers a public app with every scope by default', function () { + [$code, $output] = oauth_console_command(new OAuthServerClient(), ['action' => 'create', '--name' => 'Native', '--redirect' => ['http://127.0.0.1:3000/cb'], '--public' => true, '--development' => true]); + + expect($code)->toBe(0) + ->and($output)->not->toContain('client_secret') + ->and(OAuthClient::first()->allowed_scopes)->toBe(array_keys(Fleetbase\Auth\OAuthServer\OAuthScopes::all())); + }); + + it('refuses what it cannot register or rotate', function () { + $run = fn (array $input) => oauth_console_command(new OAuthServerClient(), $input)[0]; + + expect($run(['action' => 'delete']))->toBe(1) + ->and($run(['action' => 'create', '--name' => 'X']))->toBe(1) + ->and($run(['action' => 'create', '--name' => 'X', '--redirect' => ['http://app.example.com/cb']]))->toBe(1) + ->and($run(['action' => 'create', '--name' => 'X', '--redirect' => [OAUTH_TEST_REDIRECT], '--scope' => ['admin:all']]))->toBe(1) + ->and($run(['action' => 'rotate', 'id' => 'nope']))->toBe(1) + ->and(OAuthClient::count())->toBe(0); + }); + + it('writes or prints a key pair, and will not overwrite one by accident', function () { + foreach (['private', 'public'] as $type) { + if (file_exists(Fleetbase\Auth\OAuthServer\OAuthKeys::keyPath($type))) { + unlink(Fleetbase\Auth\OAuthServer\OAuthKeys::keyPath($type)); + } + } + if (!is_dir(dirname(Fleetbase\Auth\OAuthServer\OAuthKeys::keyPath('private')))) { + mkdir(dirname(Fleetbase\Auth\OAuthServer\OAuthKeys::keyPath('private')), 0777, true); + } + + [$shownCode, $shown] = oauth_console_command(new OAuthServerKeys(), ['--show' => true]); + [$writeCode] = oauth_console_command(new OAuthServerKeys()); + [$againCode] = oauth_console_command(new OAuthServerKeys()); + [$forceCode] = oauth_console_command(new OAuthServerKeys(), ['--force' => true]); + + expect($shownCode)->toBe(0) + ->and($shown)->toContain('OAUTH_SERVER_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\\n') + ->and($writeCode)->toBe(0) + ->and(file_get_contents(Fleetbase\Auth\OAuthServer\OAuthKeys::keyPath('private')))->toContain('PRIVATE KEY') + ->and($againCode)->toBe(1) + ->and($forceCode)->toBe(0); + + foreach (['private', 'public'] as $type) { + unlink(Fleetbase\Auth\OAuthServer\OAuthKeys::keyPath($type)); + } + })->skip(fn () => !function_exists('openssl_pkey_new'), 'needs openssl'); + + it('prunes codes and tokens that expired more than a week ago', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier), $verifier); + $fresh = oauth_test_code($client, oauth_test_verifier()); + + OAuthAccessToken::query()->update(['expires_at' => now()->subDays(40)]); + OAuthRefreshToken::query()->update(['expires_at' => now()->subDays(40)]); + OAuthAuthCode::where('revoked', true)->update(['expires_at' => now()->subDays(40)]); + + [$code, $output] = oauth_console_command(new OAuthServerPrune(), ['--days' => '7']); + + expect($code)->toBe(0) + ->and($output)->toContain('Deleted 1 codes, 1 access tokens and 1 refresh tokens.') + ->and(OAuthAuthCode::count())->toBe(1) + ->and($fresh)->toBeString(); + }); +}); diff --git a/tests/Unit/Auth/OAuthServer/OAuthServerEdgeCasesTest.php b/tests/Unit/Auth/OAuthServer/OAuthServerEdgeCasesTest.php new file mode 100644 index 00000000..73c34145 --- /dev/null +++ b/tests/Unit/Auth/OAuthServer/OAuthServerEdgeCasesTest.php @@ -0,0 +1,386 @@ + oauth_test_setup()); +afterEach(fn () => oauth_test_teardown()); + +function oauth_edge_authorize_error(array $params): AuthorizeError +{ + try { + oauth_test_server()->validateAuthorizationRequest($params); + } catch (AuthorizeError $e) { + return $e; + } + + throw new RuntimeException('Expected the authorization request to be refused.'); +} + +describe('authorization request checks', function () { + it('refuses malformed parameters, with the error going back to the app', function () { + [$client] = oauth_test_client(); + $params = fn (array $overrides) => oauth_test_params($client, oauth_test_verifier(), $overrides); + + $cases = [ + ['scope' => ['openid']], + ['response_type' => 'token'], + ['nonce' => str_repeat('n', 256)], + ['max_age' => 'soon'], + ['code_challenge' => 'too-short'], + ['prompt' => 'none consent'], + ]; + + $errors = array_map(fn ($overrides) => oauth_edge_authorize_error(array_merge($params([]), $overrides)), $cases); + + expect(array_map(fn ($error) => $error->error, $errors))->toBe(['invalid_request', 'unsupported_response_type', 'invalid_request', 'invalid_request', 'invalid_request', 'invalid_request']) + ->and(array_filter($errors, fn ($error) => !$error->canRedirect()))->toBe([]); + }); + + it('asks for a sign-in when it cannot tell when the user signed in', function () { + [$client] = oauth_test_client(); + $server = oauth_test_server(); + + expect($server->needsReauthentication($server->validateAuthorizationRequest(oauth_test_params($client, oauth_test_verifier(), ['prompt' => 'login'])), null))->toBeTrue() + ->and($server->needsReauthentication($server->validateAuthorizationRequest(oauth_test_params($client, oauth_test_verifier())), null))->toBeFalse(); + }); + + it('sends a declined request back to the app with access_denied and the state', function () { + [$client] = oauth_test_client(); + $server = oauth_test_server(); + + $url = $server->deny($server->validateAuthorizationRequest(oauth_test_params($client, oauth_test_verifier()))); + + expect($url)->toStartWith(OAUTH_TEST_REDIRECT . '?error=access_denied') + ->and($url)->toContain('state=state-123') + ->and($url)->toContain('iss='); + }); + + it('keeps the query a redirect URI already has', function () { + $error = new AuthorizeError('access_denied', null, 'https://app.example.com/cb?tenant=1', 's'); + + expect($error->redirectUrl('https://api.fleetbase.test'))->toBe('https://app.example.com/cb?tenant=1&error=access_denied&state=s&iss=https%3A%2F%2Fapi.fleetbase.test'); + }); +}); + +describe('token endpoint', function () { + it('lets league reject a code or refresh token it cannot open', function () { + [$client, $secret] = oauth_test_client(); + + [$codeStatus, $code] = oauth_test_token(['grant_type' => 'authorization_code', 'client_id' => $client->id, 'client_secret' => $secret, 'code' => 'garbage', 'redirect_uri' => OAUTH_TEST_REDIRECT, 'code_verifier' => oauth_test_verifier()]); + [$refreshStatus, $refresh] = oauth_test_token(['grant_type' => 'refresh_token', 'client_id' => $client->id, 'client_secret' => $secret, 'refresh_token' => 'garbage']); + + expect($codeStatus)->toBe(400) + ->and($code['error'])->toBe('invalid_request') + ->and($refreshStatus)->toBe(401) + ->and($refresh['error'])->toBe('invalid_request'); + }); + + it('refuses a code whose grant was removed before it was redeemed', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + $code = oauth_test_code($client, $verifier); + OAuthGrant::query()->update(['revoked_at' => now()]); + + [$status, $body] = oauth_test_exchange($client, $secret, $code, $verifier); + + expect($status)->toBe(400) + ->and($body['error'])->toBe('invalid_grant'); + }); + + it('refuses a code once its user is deactivated, and drops the grant', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + $code = oauth_test_code($client, $verifier); + User::where('uuid', 'user-owner')->update(['status' => 'inactive']); + + [$status, $body] = oauth_test_exchange($client, $secret, $code, $verifier); + + expect($status)->toBe(400) + ->and($body['error'])->toBe('invalid_grant') + ->and(OAuthGrant::first()->isRevoked())->toBeTrue(); + }); + + it('issues no ID token without openid', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + + [, $body] = oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier, ['scope' => 'organization:read']), $verifier); + + expect($body)->not->toHaveKey('id_token') + ->and($body['scope'])->toBe('organization:read'); + }); + + it('issues no ID token for a user who has since been deleted', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + $code = oauth_test_code($client, $verifier, ['scope' => 'openid']); + + // Removing the user cascades to the grant in MySQL; here only the user row goes, so the + // response type is the one that notices. + User::where('uuid', 'user-owner')->forceDelete(); + $response = new Fleetbase\Auth\OAuthServer\IdTokenResponse(new IssueContext(), oauth_test_server()->keys(), 'https://api.fleetbase.test', new DateInterval('PT1H')); + $token = (new AccessTokenRepository(new IssueContext(), 'https://api.fleetbase.test'))->getNewToken(new ClientEntity($client->id, 'AppHelm', [OAUTH_TEST_REDIRECT], true), [new ScopeEntity('openid')], 'user-owner'); + $token->setPrivateKey(oauth_test_server()->keys()->privateKey()); + $token->setIdentifier('token-1'); + $token->setExpiryDateTime(new DateTimeImmutable('+1 hour')); + $response->setAccessToken($token); + $response->setEncryptionKey(oauth_test_server()->keys()->encryptionKey()); + + $body = json_decode((string) $response->generateHttpResponse(new Nyholm\Psr7\Response())->getBody(), true); + + expect($code)->toBeString() + ->and($body)->not->toHaveKey('id_token'); + }); +}); + +describe('revocation', function () { + it('needs a token', function () { + [$client, $secret] = oauth_test_client(); + + $response = oauth_test_server()->revoke(Request::create('/oauth/revoke', 'POST', ['client_id' => $client->id, 'client_secret' => $secret])); + + expect($response->getStatusCode())->toBe(400); + }); + + it('ignores an access token signed by another key', function () { + [$client, $secret] = oauth_test_client(); + $other = OAuthKeys::generate(2048); + $config = Lcobucci\JWT\Configuration::forAsymmetricSigner(new Lcobucci\JWT\Signer\Rsa\Sha256(), Lcobucci\JWT\Signer\Key\InMemory::plainText($other['private']), Lcobucci\JWT\Signer\Key\InMemory::plainText($other['public'])); + $forged = $config->builder()->identifiedBy('forged')->getToken($config->signer(), $config->signingKey())->toString(); + + $response = oauth_test_server()->revoke(Request::create('/oauth/revoke', 'POST', ['token' => $forged, 'client_id' => $client->id, 'client_secret' => $secret])); + + expect($response->getStatusCode())->toBe(200); + }); + + it('lets a public client revoke with its client_id alone, and refuses one that sends a secret', function () { + [$client] = oauth_test_client(['name' => 'Native'], confidential: false); + $verifier = oauth_test_verifier(); + [, $body] = oauth_test_exchange($client, null, oauth_test_code($client, $verifier), $verifier); + + $withSecret = oauth_test_server()->revoke(Request::create('/oauth/revoke', 'POST', ['token' => $body['refresh_token'], 'client_id' => $client->id, 'client_secret' => 'x'])); + $plain = oauth_test_server()->revoke(Request::create('/oauth/revoke', 'POST', ['token' => $body['refresh_token'], 'client_id' => $client->id])); + + expect($withSecret->getStatusCode())->toBe(401) + ->and($plain->getStatusCode())->toBe(200) + ->and(OAuthGrant::first()->isRevoked())->toBeTrue(); + }); +}); + +describe('protected resources', function () { + it('refuses a token whose row is gone', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + [, $body] = oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier), $verifier); + + // A token row that is gone reads as revoked to league; one that is merely unusable (an + // expiry in the past that league's clock leeway still let through) reaches the row check. + Fleetbase\Models\OAuthAccessToken::query()->update(['expires_at' => now()->subMinute()]); + + $response = (new AuthenticateOAuthToken(oauth_test_server()))->handle(oauth_test_bearer($body['access_token']), fn () => throw new RuntimeException('must not run')); + + expect($response->getStatusCode())->toBe(401); + }); + + it('says when a route forgot the middleware or an organization scope', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + [, $body] = oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier, ['scope' => 'openid'], 'user-member', null), $verifier); + $request = oauth_test_bearer($body['access_token']); + + expect(fn () => AuthenticateOAuthToken::token(Request::create('/x')))->toThrow(LogicException::class); + + (new AuthenticateOAuthToken(oauth_test_server()))->handle($request, fn () => null); + + expect(fn () => AuthenticateOAuthToken::company($request))->toThrow(LogicException::class) + ->and($request->user()->uuid)->toBe('user-member'); + }); +}); + +describe('repositories', function () { + it('only know registered, enabled clients and the two supported grants', function () { + [$client, $secret] = oauth_test_client(); + [$public] = oauth_test_client(['name' => 'Native'], confidential: false); + $repository = new ClientRepository(); + + expect($repository->getClientEntity('00000000-0000-0000-0000-000000000000'))->toBeNull() + ->and($repository->getClientEntity('not-a-uuid'))->toBeNull() + ->and($repository->validateClient($client->id, $secret, 'client_credentials'))->toBeFalse() + ->and($repository->validateClient($client->id, $secret, 'authorization_code'))->toBeTrue() + ->and($repository->validateClient($public->id, null, 'refresh_token'))->toBeTrue() + ->and($repository->validateClient($public->id, 'secret', 'refresh_token'))->toBeFalse(); + }); + + it('finalize no scopes for a client that no longer exists', function () { + $client = new ClientEntity('00000000-0000-0000-0000-000000000000', 'Gone', [], true); + + expect((new ScopeRepository())->finalizeScopes([new ScopeEntity('openid')], 'authorization_code', $client))->toBe([]); + }); + + it('never issue a code or token outside a grant', function () { + [$client] = oauth_test_client(); + $entity = new ClientEntity($client->id, 'AppHelm', [OAUTH_TEST_REDIRECT], true); + + $code = new AuthCodeEntity(); + $code->setClient($entity); + $access = new AccessTokenEntity('https://api.fleetbase.test'); + $access->setClient($entity); + $refresh = new RefreshTokenEntity(); + $refresh->setAccessToken($access); + + expect(fn () => (new AuthCodeRepository(new IssueContext()))->persistNewAuthCode($code))->toThrow(LogicException::class) + ->and(fn () => (new AccessTokenRepository(new IssueContext(), 'https://api.fleetbase.test'))->persistNewAccessToken($access))->toThrow(LogicException::class) + ->and(fn () => (new RefreshTokenRepository(new IssueContext()))->persistNewRefreshToken($refresh))->toThrow(LogicException::class); + }); + + it('treat an unknown code or refresh token as revoked', function () { + expect((new AuthCodeRepository(new IssueContext()))->isAuthCodeRevoked('nope'))->toBeTrue() + ->and((new RefreshTokenRepository(new IssueContext()))->isRefreshTokenRevoked('nope'))->toBeTrue(); + }); + + it('open nothing from a payload that is not theirs', function () { + $crypt = new PayloadCrypt(oauth_test_server()->keys()->encryptionKey()); + + expect($crypt->open(null))->toBeNull() + ->and($crypt->open(''))->toBeNull() + ->and($crypt->open('not-encrypted'))->toBeNull(); + }); +}); + +describe('scopes registry', function () { + it('validates names, describes scopes with the organization, and maps review types', function () { + expect(fn () => OAuthScopes::register('Not Valid', []))->toThrow(InvalidArgumentException::class) + ->and(OAuthScopes::get('openid')['organization'])->toBeFalse() + ->and(OAuthScopes::get('missing'))->toBeNull() + ->and(OAuthScopes::scopeForReviewAccessType('storefront-store'))->toBe('storefront:review-access') + ->and(OAuthScopes::scopeForReviewAccessType('unknown'))->toBeNull() + ->and(OAuthScopes::describe(['storefront:read', 'missing'], 'Acme'))->toBe([ + ['scope' => 'storefront:read', 'description' => "See Acme's storefronts", 'organization' => true], + ]) + ->and(OAuthScopes::describe(['storefront:read'])[0]['description'])->toBe("See your organization's storefronts"); + }); +}); + +describe('keys', function () { + it('read key files from storage when no key is configured, and say when there are none', function () { + $keys = new OAuthKeys(); + $remove = function () { + foreach (['private', 'public'] as $type) { + if (file_exists(OAuthKeys::keyPath($type))) { + unlink(OAuthKeys::keyPath($type)); + } + } + }; + config(['oauth-server.private_key' => null, 'oauth-server.public_key' => null]); + if (!is_dir(dirname(OAuthKeys::keyPath('private')))) { + mkdir(dirname(OAuthKeys::keyPath('private')), 0777, true); + } + $remove(); + + expect($keys->configured())->toBeFalse() + ->and(fn () => $keys->privateKeyContents())->toThrow(RuntimeException::class); + + file_put_contents(OAuthKeys::keyPath('private'), oauth_test_keys()['private']); + file_put_contents(OAuthKeys::keyPath('public'), oauth_test_keys()['public']); + + expect($keys->configured())->toBeTrue() + ->and($keys->publicKeyContents())->toBe(oauth_test_keys()['public']); + + $remove(); + }); + + it('need APP_KEY for the payload key', function () { + config(['app.key' => '']); + + expect(fn () => (new OAuthKeys())->encryptionKey())->toThrow(RuntimeException::class); + }); +}); + +describe('clients and grants', function () { + it('accept only https redirect URIs, or loopback http on a development app', function () { + expect(OAuthClient::isAcceptableRedirectUri('https://app.example.com/cb'))->toBeTrue() + ->and(OAuthClient::isAcceptableRedirectUri('https://app.example.com/cb#x'))->toBeFalse() + ->and(OAuthClient::isAcceptableRedirectUri('not a url'))->toBeFalse() + ->and(OAuthClient::isAcceptableRedirectUri('http://app.example.com/cb', true))->toBeFalse() + ->and(OAuthClient::isAcceptableRedirectUri('http://127.0.0.1:3000/cb'))->toBeFalse() + ->and(OAuthClient::isAcceptableRedirectUri('http://127.0.0.1:3000/cb', true))->toBeTrue() + ->and(OAuthClient::isAcceptableRedirectUri('myapp://callback', true))->toBeFalse() + ->and(OAuthClient::isLoopbackUri('https://127.0.0.1/cb'))->toBeFalse(); + }); + + it('match no empty redirect URI, and a public client has no secret to verify', function () { + [$client] = oauth_test_client(['name' => 'Native'], confidential: false); + + expect($client->allowsRedirectUri(null))->toBeFalse() + ->and($client->allowsRedirectUri(''))->toBeFalse() + ->and($client->verifySecret('anything'))->toBeFalse() + ->and($client->toArray()['confidential'])->toBeFalse(); + }); + + it('relate grants to their user, client and organization', function () { + [$client] = oauth_test_client(); + $grant = OAuthGrant::create(['user_uuid' => 'user-owner', 'client_id' => $client->id, 'company_uuid' => 'company-a', 'scopes' => ['openid']]); + + expect($grant->user->uuid)->toBe('user-owner') + ->and($grant->client->id)->toBe($client->id) + ->and($grant->company->uuid)->toBe('company-a') + ->and($client->grants()->count())->toBe(1); + + OAuthGrant::revokeTokensFor([]); + expect($grant->fresh()->isRevoked())->toBeFalse(); + }); +}); + +describe('organizations', function () { + it('can be shared only by an active member who administers them', function () { + OAuthOrganizations::$administratorCheck = null; + oauth_test_roles(['cu-admin-a' => 'Administrator', 'cu-member-a' => 'Dispatcher']); + + $companyA = Company::where('uuid', 'company-a')->first(); + $admin = User::where('uuid', 'user-admin')->first(); + $member = User::where('uuid', 'user-member')->first(); + $owner = User::where('uuid', 'user-owner')->first(); + + expect(OAuthOrganizations::canShare($admin, $companyA))->toBeTrue() + ->and(OAuthOrganizations::canShare($member, $companyA))->toBeFalse() + ->and(OAuthOrganizations::canShare($owner, Company::where('uuid', 'company-b')->first()))->toBeFalse(); + + $admin->status = 'inactive'; + expect(OAuthOrganizations::canShare($admin, $companyA))->toBeFalse(); + }); + + it('list the user\'s organizations for the picker and find one by public id', function () { + $member = User::where('uuid', 'user-member')->first(); + + expect(OAuthOrganizations::forPicker($member)->all())->toBe([ + ['id' => 'company_a', 'uuid' => 'company-a', 'name' => 'Acme Deliveries', 'logo_url' => Company::where('uuid', 'company-a')->first()->logo_url, 'can_share' => false], + ['id' => 'company_b', 'uuid' => 'company-b', 'name' => 'Bravo Freight', 'logo_url' => Company::where('uuid', 'company-b')->first()->logo_url, 'can_share' => true], + ]) + ->and(OAuthOrganizations::find($member, 'company_b')?->uuid)->toBe('company-b') + ->and(OAuthOrganizations::find($member, null))->toBeNull() + ->and(OAuthOrganizations::find(User::where('uuid', 'user-admin')->first(), 'company_b'))->toBeNull(); + }); +}); diff --git a/tests/Unit/Auth/OAuthServer/OAuthServerEndpointsTest.php b/tests/Unit/Auth/OAuthServer/OAuthServerEndpointsTest.php index f77f1c24..19e41049 100644 --- a/tests/Unit/Auth/OAuthServer/OAuthServerEndpointsTest.php +++ b/tests/Unit/Auth/OAuthServer/OAuthServerEndpointsTest.php @@ -155,6 +155,15 @@ function oauth_review_call(string $token, string $method, string $id = 'company_ ->and($response->getData(true)['error'])->toBe('insufficient_scope'); }); + it('does not know a type nobody registered', function () { + $token = oauth_endpoint_token('openid navigator:review-access'); + $request = Request::create('/oauth/v1/review-access/unknown/company_a', 'GET', [], [], [], ['HTTP_AUTHORIZATION' => 'Bearer ' . $token]); + + $response = oauth_endpoint_call($request, [], fn ($request) => (new ResourceController())->reviewAccess($request, 'unknown', 'company_a')); + + expect($response->getStatusCode())->toBe(404); + }); + it('cannot reach another organization', function () { $token = oauth_endpoint_token('openid navigator:review-access'); diff --git a/tests/Unit/Auth/OAuthServer/OAuthServerTestSupport.php b/tests/Unit/Auth/OAuthServer/OAuthServerTestSupport.php index db6a2fbb..5fa9e3c7 100644 --- a/tests/Unit/Auth/OAuthServer/OAuthServerTestSupport.php +++ b/tests/Unit/Auth/OAuthServer/OAuthServerTestSupport.php @@ -130,6 +130,85 @@ function oauth_test_setup(): Capsule return $capsule; } +/** + * Real spatie/permission roles for company memberships, keyed by company_users.uuid. + * + * @param array $roles + */ +function oauth_test_roles(array $roles): void +{ + config([ + 'auth.defaults.guard' => 'sanctum', + 'permission.models.permission' => Fleetbase\Models\Permission::class, + 'permission.models.role' => Fleetbase\Models\Role::class, + 'permission.table_names.roles' => 'roles', + 'permission.table_names.permissions' => 'permissions', + 'permission.table_names.model_has_permissions' => 'model_has_permissions', + 'permission.table_names.model_has_roles' => 'model_has_roles', + 'permission.table_names.role_has_permissions' => 'role_has_permissions', + 'permission.column_names.model_morph_key' => 'model_uuid', + ]); + Container::getInstance()->instance(Spatie\Permission\PermissionRegistrar::class, new class { + public string $pivotRole = 'role_id'; + public string $pivotPermission = 'permission_id'; + public bool $teams = false; + public string $teamsKey = 'team_id'; + + public function getRoleClass(): string + { + return Fleetbase\Models\Role::class; + } + + public function getPermissionClass(): string + { + return Fleetbase\Models\Permission::class; + } + + public function getPermissions(array $params = [], bool $onlyOne = false): Illuminate\Database\Eloquent\Collection + { + return new Illuminate\Database\Eloquent\Collection(); + } + }); + + $schema = Capsule::connection('mysql')->getSchemaBuilder(); + $schema->create('roles', function ($table) { + $table->string('id')->primary(); + $table->string('name'); + $table->string('guard_name')->default('sanctum'); + $table->timestamps(); + $table->softDeletes(); + }); + $schema->create('model_has_roles', function ($table) { + $table->string('role_id'); + $table->string('model_type'); + $table->string('model_uuid'); + }); + $schema->create('permissions', function ($table) { + $table->string('id')->primary(); + $table->string('name'); + $table->string('guard_name')->default('sanctum'); + $table->timestamps(); + }); + $schema->create('model_has_permissions', function ($table) { + $table->string('permission_id'); + $table->string('model_type'); + $table->string('model_uuid'); + }); + $schema->create('role_has_permissions', function ($table) { + $table->string('permission_id'); + $table->string('role_id'); + }); + + $db = Capsule::connection('mysql'); + foreach (array_values(array_unique($roles)) as $index => $name) { + $db->table('roles')->insert(['id' => 'role-' . $index, 'name' => $name, 'guard_name' => 'sanctum']); + } + $ids = $db->table('roles')->pluck('id', 'name'); + foreach ($roles as $membership => $name) { + $db->table('model_has_roles')->insert(['role_id' => $ids[$name], 'model_type' => Fleetbase\Models\CompanyUser::class, 'model_uuid' => $membership]); + } +} + /** * Register an app. Returns [client, plain secret or null]. */ From b5fcd69129c24cedcce127da2ba50b9d6b0228fb Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sun, 25 Oct 2026 02:37:00 +0800 Subject: [PATCH 7/7] test(app-review): cover the registry's edge cases and the endpoint's permission check Unknown types, any type's number, a custom resolver, an unsaved or unregistered subject, the table before migration, an unreadable table, and the Console endpoint for nobody, a viewer, a permission or wildcard holder, an administrator and an unseeded permission. Brings both files to the 100% coverage gate. --- tests/Unit/Support/AppReviewTest.php | 145 ++++++++++++++++++++++++++- 1 file changed, 144 insertions(+), 1 deletion(-) diff --git a/tests/Unit/Support/AppReviewTest.php b/tests/Unit/Support/AppReviewTest.php index af13bb7d..3f9a20fd 100644 --- a/tests/Unit/Support/AppReviewTest.php +++ b/tests/Unit/Support/AppReviewTest.php @@ -20,6 +20,32 @@ class AppReviewTestStore extends FleetbaseModel protected $guarded = []; } +class AppReviewTestUnregistered extends FleetbaseModel +{ + protected $table = 'app_review_test_stores'; + protected $guarded = []; +} + +class AppReviewTestUnreachable extends FleetbaseModel +{ + protected $connection = 'missing-connection'; + protected $table = 'app_review_test_stores'; +} + +/** + * A Console user whose permissions are a plain list, so the endpoint's permission check can + * be exercised without the role tables. + */ +class AppReviewTestUser extends Fleetbase\Models\User +{ + public array $granted = []; + + public function doesntHavePermissions(Illuminate\Support\Collection|array $permissions): bool + { + return collect($permissions)->pluck('name')->intersect($this->granted)->isEmpty(); + } +} + class AppReviewTestNetwork extends FleetbaseModel { protected $table = 'app_review_test_networks'; @@ -132,7 +158,7 @@ function app_review_console_request(string $company, array $input = [], string $ } afterEach(function () { - foreach (['test-store', 'test-network', 'test-navigator', 'test-guarded'] as $type) { + foreach (['test-store', 'test-network', 'test-navigator', 'test-guarded', 'test-resolved', 'test-unreachable'] as $type) { AppReview::forget($type); } EloquentModel::unsetConnectionResolver(); @@ -296,3 +322,120 @@ function app_review_console_request(string $company, array $input = [], string $ expect($response->getStatusCode())->toBe(401) ->and(ReviewAccess::count())->toBe(0); })->skip(fn () => !method_exists(Fleetbase\Support\Auth::class, 'getUserFromSession')); + +test('the registry answers for unknown types, any type\'s number and numbers it cannot format', function () { + app_review_database(); + + expect(array_keys(AppReview::types()))->toContain('test-store', 'test-network', 'test-navigator') + ->and(AppReview::typeOf(new AppReviewTestUnregistered()))->toBeNull() + ->and(AppReview::typeOf(null))->toBeNull() + ->and(AppReview::definition('nothing'))->toBeNull() + ->and(AppReview::phoneFor('nothing'))->toBeNull() + // Without a type, any registered number is a review number. + ->and(AppReview::isReviewPhone('+1 202 555 0144'))->toBeTrue() + ->and(AppReview::isReviewPhone('+12025550199'))->toBeFalse() + ->and(AppReview::isReviewPhone(''))->toBeFalse() + ->and(AppReview::isReviewPhone(null, 'test-store'))->toBeFalse() + // Only North American numbers are spaced out. + ->and(AppReview::formatPhone('+97699112233'))->toBe('+97699112233') + ->and(AppReview::formatPhone(null))->toBeNull(); +}); + +test('a type can bring its own resolver for the subject a Console user may manage', function () { + app_review_database(); + $calls = []; + AppReview::register('test-resolved', [ + 'model' => AppReviewTestUnregistered::class, + 'phone' => '+12025550150', + 'resolve' => function (string $id, ?string $companyUuid) use (&$calls) { + $calls[] = [$id, $companyUuid]; + + return AppReviewTestUnregistered::where('public_id', $id)->first(); + }, + ]); + + expect(AppReview::resolveSubject('test-resolved', 'store_b', 'company-a')?->uuid)->toBe('store-b') + ->and($calls)->toBe([['store_b', 'company-a']]); +}); + +test('access is absent, not an error, for an unsaved subject, an unregistered one, or before the migration', function () { + $capsule = app_review_database(); + + expect(AppReview::accessFor(new AppReviewTestStore()))->toBeNull() + ->and(AppReview::accessFor(new AppReviewTestUnregistered(['uuid' => 'store-a'])))->toBeNull() + ->and(fn () => AppReview::update(new AppReviewTestUnregistered(['uuid' => 'store-a']), true))->toThrow(InvalidArgumentException::class); + + $capsule->getConnection('testing')->getSchemaBuilder()->drop('review_accesses'); + + expect(AppReview::accessFor(app_review_store('store-a')))->toBeNull() + ->and(AppReview::isEnabledFor(app_review_store('store-a')))->toBeFalse() + ->and(AppReview::acceptsCode(app_review_store('store-a'), '+12025550143', '123456'))->toBeFalse(); +}); + +test('a subject whose table cannot be read is matched by uuid only', function () { + app_review_database(); + $hasColumn = new ReflectionMethod(AppReview::class, 'hasColumn'); + + expect($hasColumn->invoke(null, AppReviewTestUnreachable::class, 'public_id'))->toBeFalse() + ->and($hasColumn->invoke(null, AppReviewTestStore::class, 'public_id'))->toBeTrue(); +}); + +test('the Console endpoint lets administrators and permission holders in, and nobody else', function () { + app_review_database(); + app()->instance('validator', new Illuminate\Validation\Factory(new Illuminate\Translation\Translator(new Illuminate\Translation\ArrayLoader(), 'en'), app())); + Request::macro('validate', function (array $rules) { + return app('validator')->make($this->all(), $rules)->validate(); + }); + config(['permission.table_names.permissions' => 'permissions', 'auth.defaults.guard' => 'sanctum', 'auth.guards.sanctum' => ['driver' => 'sanctum']]); + Capsule::connection('mysql')->getSchemaBuilder()->create('permissions', function ($table) { + $table->string('id')->primary(); + $table->string('name'); + $table->string('guard_name')->nullable(); + $table->timestamps(); + }); + Capsule::connection('mysql')->table('permissions')->insert([ + ['id' => 'permission-manage', 'name' => 'storefront manage-app-review store', 'guard_name' => 'sanctum'], + ['id' => 'permission-wildcard', 'name' => 'storefront * store', 'guard_name' => 'sanctum'], + ['id' => 'permission-view', 'name' => 'storefront view store', 'guard_name' => 'sanctum'], + ]); + AppReview::forget('test-store'); + AppReview::register('test-guarded', [ + 'model' => AppReviewTestStore::class, + 'phone' => '+12025550143', + 'permission' => 'storefront manage-app-review store', + 'view_permission' => 'storefront view store', + ]); + + $as = function (?AppReviewTestUser $user, array $input = [], string $method = 'PUT') { + $request = app_review_console_request('company-a', $input, $method); + $request->setUserResolver(fn () => $user); + + return $request; + }; + $user = function (string $type, array $granted = []) { + $user = new AppReviewTestUser(); + $user->type = $type; + $user->granted = $granted; + + return $user; + }; + $controller = new ReviewAccessController(); + + $nobody = $controller->show('test-guarded', 'store_a', $as(null, [], 'GET')); + $viewer = $controller->show('test-guarded', 'store_a', $as($user('user', ['storefront view store']), [], 'GET')); + $readOnly = $controller->update('test-guarded', 'store_a', $as($user('user', ['storefront view store']), ['enabled' => true])); + $manager = $controller->update('test-guarded', 'store_a', $as($user('user', ['storefront manage-app-review store']), ['enabled' => true])); + $wildcard = $controller->update('test-guarded', 'store_a', $as($user('user', ['storefront * store']), ['enabled' => false])); + $admin = $controller->update('test-guarded', 'store_a', $as($user('admin'), ['regenerate_code' => true])); + + expect($nobody->getStatusCode())->toBe(401) + ->and($viewer->getData(true)['review_access']['type'])->toBe('test-guarded') + ->and($readOnly->getStatusCode())->toBe(401) + ->and($manager->getData(true)['review_access'])->toHaveKey('code') + ->and($wildcard->getData(true)['review_access']['enabled'])->toBeFalse() + ->and($admin->getData(true)['review_access'])->toHaveKey('code'); + + // A permission the install never seeded lets nobody but administrators in. + AppReview::register('test-guarded', ['model' => AppReviewTestStore::class, 'phone' => '+12025550143', 'permission' => 'storefront unseeded store']); + expect($controller->update('test-guarded', 'store_a', $as($user('user', ['storefront unseeded store']), ['enabled' => true]))->getStatusCode())->toBe(401); +});