From 5b8e48d4f6e507c81fdf95f119cd0c46c8d511b0 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 18 Sep 2026 17:00:20 +0800 Subject: [PATCH 01/30] Remove the abandoned CompanyScope global scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CompanyScope was an attempt to enforce tenant isolation with a single global Eloquent scope. It was abandoned because it destabilised the application, and it has been dead code since: the only `addGlobalScope(new CompanyScope())` anywhere across core-api and the extension packages was inside this scope's own unit test, against a throwaway test-only model, and the `withoutCompanyScope()` macro it registered was likewise only ever called from that test. Leaving it in place was worse than unused, because its docblock claimed to be "the primary defence against the cross-tenant IDOR vulnerability (GHSA-3wj9-hh56-7fw7)" and `HasApiModelBehavior` repeated that claim. The explicit `company_uuid` clauses that actually provide the protection were therefore labelled "defence-in-depth", as though a real layer sat behind them. It does not, and reading the code as though it did is how tenant-scoped lookups get written with no scoping at all — see fleetbase/fleetops#331, where the order lifecycle actions resolved their targets by caller-supplied uuid with no company constraint of any kind. The design could not have served as that primary defence: - It bailed out during console execution, so isolation was absent in queue workers, the scheduler and artisan — the same model reached through the same code was protected in a web request and unprotected in a job. - It bailed out when no session company was set, i.e. it failed open in exactly the contexts where a session is missing (webhooks, public tracking, installer, token flows before session setup). - Any `withoutGlobalScopes()` call dropped it as a side effect of dropping an unrelated scope. fleetops `server/src` alone has 83 such call sites. - Global scopes also apply to relations, eager loads and `whereHas`, so legitimate cross-company references resolved to null rather than raising, surfacing as cascading nulls far from the query that caused them. Tenant isolation stays where it already is: an explicit, visible `company_uuid` clause at each lookup. The comments at those clauses are rewritten to say they are the tenant constraint rather than a second layer, and the trait docblock records why there is no global scope so one does not get reintroduced. No behaviour change: the class was registered nowhere. `ExpiryScope` is untouched — it is opt-in per model via `Expirable`, single-concern, and carries no authorization meaning. --- src/Scopes/CompanyScope.php | 116 ------------------ src/Traits/HasApiControllerBehavior.php | 5 +- src/Traits/HasApiModelBehavior.php | 49 +++++--- tests/Unit/Scopes/CompanyScopeTest.php | 150 ------------------------ 4 files changed, 36 insertions(+), 284 deletions(-) delete mode 100644 src/Scopes/CompanyScope.php delete mode 100644 tests/Unit/Scopes/CompanyScopeTest.php diff --git a/src/Scopes/CompanyScope.php b/src/Scopes/CompanyScope.php deleted file mode 100644 index f8df4917..00000000 --- a/src/Scopes/CompanyScope.php +++ /dev/null @@ -1,116 +0,0 @@ -where(...)->get(); - * Model::withoutGlobalScope(CompanyScope::class)->where(...)->get(); - * - * The `withoutCompanyScope()` macro is the preferred, readable form. - */ -class CompanyScope implements Scope -{ - /** - * Per-process cache of which table names have a `company_uuid` column. - * Avoids repeated Schema::hasColumn() calls on the same table. - * - * @var array - */ - protected static array $columnCache = []; - - /** - * Apply the scope to a given Eloquent query builder. - * - * @return void - */ - public function apply(Builder $builder, Model $model) - { - // Never apply during CLI execution (artisan, queue workers, etc.) - if (app()->runningInConsole()) { - return; - } - - $companyUuid = Session::get('company'); - - // Only apply when there is an active company session. - if (empty($companyUuid)) { - return; - } - - // Only apply when the model's table actually has a company_uuid column. - // Cache the result per table to avoid repeated Schema introspection. - $table = $model->getTable(); - if (!isset(static::$columnCache[$table])) { - static::$columnCache[$table] = Schema::hasColumn($table, 'company_uuid'); - } - - if (!static::$columnCache[$table]) { - return; - } - - $builder->where($model->qualifyColumn('company_uuid'), $companyUuid); - } - - /** - * Extend the query builder with the withoutCompanyScope macro. - * - * @return void - */ - public function extend(Builder $builder) - { - $this->addWithoutCompanyScope($builder); - } - - /** - * Add the withoutCompanyScope macro to the builder. - * - * @return void - */ - protected function addWithoutCompanyScope(Builder $builder) - { - $builder->macro('withoutCompanyScope', function (Builder $builder) { - return $builder->withoutGlobalScope(CompanyScope::class); - }); - } - - /** - * Flush the column existence cache. - * Useful in tests where tables may be created/dropped between cases. - */ - public static function flushColumnCache(): void - { - static::$columnCache = []; - } -} diff --git a/src/Traits/HasApiControllerBehavior.php b/src/Traits/HasApiControllerBehavior.php index 79f80331..589f35aa 100644 --- a/src/Traits/HasApiControllerBehavior.php +++ b/src/Traits/HasApiControllerBehavior.php @@ -562,8 +562,9 @@ public function deleteRecord($id, Request $request) $builder = $this->model->wherePublicId($id); } - // Defence-in-depth: scope delete to the caller's company to prevent - // cross-tenant deletion (GHSA-3wj9-hh56-7fw7). + // Tenant constraint: scope the delete to the caller's company to prevent + // cross-tenant deletion (GHSA-3wj9-hh56-7fw7). No global scope backs this + // up — removing it reopens that vulnerability. $companyUuid = session('company'); if ($companyUuid && $this->model->isColumn($this->model->qualifyColumn('company_uuid'))) { $builder->where($this->model->qualifyColumn('company_uuid'), $companyUuid); diff --git a/src/Traits/HasApiModelBehavior.php b/src/Traits/HasApiModelBehavior.php index 2161f7cf..89c29cd3 100644 --- a/src/Traits/HasApiModelBehavior.php +++ b/src/Traits/HasApiModelBehavior.php @@ -17,6 +17,21 @@ /** * Adds API Model Behavior. + * + * Tenant isolation + * ---------------- + * There is deliberately no global tenant scope behind this trait. Every lookup + * that can reach a tenant-owned record carries its own explicit `company_uuid` + * clause, and that clause is the whole protection — not a backup for something + * else. Do not remove one on the assumption that a scope covers it. + * + * A global `CompanyScope` was tried and removed: it could not apply during + * console execution (queue workers, scheduler, artisan) or when no session + * company was set, so it failed open exactly where isolation mattered; any of + * the many `withoutGlobalScopes()` call sites silently dropped it as a side + * effect of dropping an unrelated scope; and because global scopes also apply to + * relations and eager loads, legitimate cross-company references resolved to + * null instead of raising, which destabilised callers far from the query. */ trait HasApiModelBehavior { @@ -377,8 +392,9 @@ public function updateRecordFromRequest(Request $request, $id, ?callable $onBefo } }); - // Defence-in-depth: scope update to the caller's company to prevent - // cross-tenant modification (GHSA-3wj9-hh56-7fw7). + // Tenant constraint: scope the update to the caller's company to prevent + // cross-tenant modification (GHSA-3wj9-hh56-7fw7). No global scope backs + // this up — removing it reopens that vulnerability. $companyUuid = session('company'); if ($companyUuid && $this->isColumn('company_uuid')) { $builder->where($this->qualifyColumn('company_uuid'), $companyUuid); @@ -501,8 +517,9 @@ public function bulkRemove($ids = []) } }); - // Defence-in-depth: scope bulk delete to the caller's company to prevent - // cross-tenant deletion (GHSA-3wj9-hh56-7fw7). + // Tenant constraint: scope the bulk delete to the caller's company to + // prevent cross-tenant deletion (GHSA-3wj9-hh56-7fw7). No global scope + // backs this up — removing it reopens that vulnerability. $companyUuid = session('company'); if ($companyUuid && $this->isColumn('company_uuid')) { $records->where($this->qualifyColumn('company_uuid'), $companyUuid); @@ -754,11 +771,9 @@ public function applySorts($request, $builder) /** * Retrieves a record based on primary key id. * - * The query is automatically scoped to the current company via the - * CompanyScope global scope registered on the base Model. This method - * adds an explicit defence-in-depth company_uuid check as well so that - * the constraint is visible at the call-site and survives any future - * withoutGlobalScope() calls higher up the stack. + * The company_uuid clause below is the tenant constraint for this path. + * Nothing scopes the query before it reaches here, so the clause is visible + * at the call site on purpose and must not be removed. * * @param string $id - The ID * @param Request $request - HTTP Request @@ -776,10 +791,10 @@ public function getById($id, ?callable $queryCallback, Request $request) } }); - // Defence-in-depth: explicitly scope to the caller's company when the - // model has a company_uuid column and a session company is available. - // The CompanyScope global scope provides the primary protection; this - // explicit clause ensures the constraint survives withoutGlobalScope(). + // Tenant constraint: scope to the caller's company when the model has a + // company_uuid column and a session company is available. This clause is + // the only thing keeping the lookup inside the caller's tenant + // (GHSA-3wj9-hh56-7fw7). $companyUuid = session('company'); if ($companyUuid && $this->isColumn('company_uuid')) { $builder->where($this->qualifyColumn('company_uuid'), $companyUuid); @@ -1350,7 +1365,7 @@ public static function findRecordOrFail($id, $with = [], $columns = ['*'], ?\Clo // has internal id? $hasInternalId = in_array('internal_id', $instance->getFillable()); - // create query — CompanyScope global scope is applied automatically + // create query $query = static::query() ->select($columns) ->with($with) @@ -1364,8 +1379,10 @@ function ($query) use ($id, $hasInternalId) { } ); - // Defence-in-depth: explicitly scope to the caller's company when the - // model's table has a company_uuid column and a session is active. + // Tenant constraint: scope to the caller's company when the model's table + // has a company_uuid column and a session is active. This clause is the + // only thing keeping the lookup inside the caller's tenant + // (GHSA-3wj9-hh56-7fw7). $companyUuid = session('company'); if ($companyUuid && Schema::hasColumn($instance->getTable(), 'company_uuid')) { $query->where($instance->qualifyColumn('company_uuid'), $companyUuid); diff --git a/tests/Unit/Scopes/CompanyScopeTest.php b/tests/Unit/Scopes/CompanyScopeTest.php deleted file mode 100644 index 1803e473..00000000 --- a/tests/Unit/Scopes/CompanyScopeTest.php +++ /dev/null @@ -1,150 +0,0 @@ -console; - } -} - -class CompanyScopeSessionFake -{ - public function __construct(private array $values = []) - { - } - - public function get(string $key, mixed $default = null): mixed - { - return $this->values[$key] ?? $default; - } -} - -class CompanyScopeRecord extends Model -{ - protected $connection = 'mysql'; - protected $table = 'company_scope_records'; - protected $guarded = []; - public $timestamps = false; -} - -class CompanyScopePlainRecord extends Model -{ - protected $connection = 'mysql'; - protected $table = 'company_scope_plain_records'; - protected $guarded = []; - public $timestamps = false; -} - -function company_scope_database(bool $console = false, ?string $company = 'company-1'): Capsule -{ - EloquentModel::clearBootedModels(); - CompanyScope::flushColumnCache(); - - Container::setInstance(new CompanyScopeTestContainer($console)); - $container = bind_test_container([ - 'database.default' => 'mysql', - 'database.connections.mysql' => [ - 'driver' => 'sqlite', - 'database' => ':memory:', - 'prefix' => '', - ], - 'fleetbase.connection.db' => 'mysql', - ]); - Facade::setFacadeApplication($container); - - $connection = $container->make('config')->get('database.connections.mysql'); - $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()); - $container->instance('session', new CompanyScopeSessionFake(['company' => $company])); - Facade::clearResolvedInstance('db'); - Facade::clearResolvedInstance('db.schema'); - Facade::clearResolvedInstance('session'); - - $schema = $capsule->getConnection('mysql')->getSchemaBuilder(); - $schema->create('company_scope_records', function ($table) { - $table->string('uuid')->primary(); - $table->string('company_uuid')->nullable(); - $table->string('name')->nullable(); - }); - $schema->create('company_scope_plain_records', function ($table) { - $table->string('uuid')->primary(); - $table->string('name')->nullable(); - }); - - $capsule->getConnection('mysql')->table('company_scope_records')->insert([ - ['uuid' => 'record-1', 'company_uuid' => 'company-1', 'name' => 'Visible'], - ['uuid' => 'record-2', 'company_uuid' => 'company-2', 'name' => 'Hidden'], - ]); - $capsule->getConnection('mysql')->table('company_scope_plain_records')->insert([ - ['uuid' => 'plain-1', 'name' => 'Plain'], - ]); - - return $capsule; -} - -afterEach(function () { - CompanyScope::flushColumnCache(); - EloquentModel::clearBootedModels(); - Facade::clearResolvedInstances(); - Container::setInstance(new FleetbaseTestContainer()); -}); - -test('company scope constrains models with company uuid only during request context with company session', function () { - company_scope_database(); - - $builder = CompanyScopeRecord::query(); - $scope = new CompanyScope(); - $scope->apply($builder, new CompanyScopeRecord()); - - $plainBuilder = CompanyScopePlainRecord::query(); - $scope->apply($plainBuilder, new CompanyScopePlainRecord()); - - expect($builder->orderBy('uuid')->pluck('uuid')->all())->toBe(['record-1']) - ->and($plainBuilder->pluck('uuid')->all())->toBe(['plain-1']); -}); - -test('company scope skips console and missing session contexts and exposes removal macro', function () { - company_scope_database(console: true); - - $consoleBuilder = CompanyScopeRecord::query(); - $scope = new CompanyScope(); - $scope->apply($consoleBuilder, new CompanyScopeRecord()); - - expect($consoleBuilder->orderBy('uuid')->pluck('uuid')->all())->toBe(['record-1', 'record-2']); - - company_scope_database(console: false, company: null); - $missingSessionBuilder = CompanyScopeRecord::query(); - $scope->apply($missingSessionBuilder, new CompanyScopeRecord()); - - expect($missingSessionBuilder->orderBy('uuid')->pluck('uuid')->all())->toBe(['record-1', 'record-2']); - - company_scope_database(); - CompanyScopeRecord::addGlobalScope(new CompanyScope()); - - $scoped = CompanyScopeRecord::query()->orderBy('uuid')->pluck('uuid')->all(); - $unscoped = CompanyScopeRecord::query()->withoutCompanyScope()->orderBy('uuid')->pluck('uuid')->all(); - - expect($scoped)->toBe(['record-1']) - ->and($unscoped)->toBe(['record-1', 'record-2']) - ->and(CompanyScopeRecord::query())->toBeInstanceOf(Builder::class); -}); From 53be27fb6904845450a70f318c6824ed0b16cf77 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sun, 20 Sep 2026 19:16:05 +0800 Subject: [PATCH 02/30] chore(deps): raise the PHP floor to ^8.1 and add laravel/socialite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Socialite 5.31 requires PHP ^8.1, and core-api declared ^8.0. The runtime has been 8.2 for some time (docker/Dockerfile pins php8.2), so this only drops support for installs still on 8.0 — call that out in the release notes. Socialite is used as the wire-protocol layer for the authorization-code flow; Fleetbase supplies its own state and PKCE handling on top (see ServerSidePkce). --- composer.json | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/composer.json b/composer.json index ecfbb64a..8f24ac10 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,7 @@ } ], "require": { - "php": "^8.0", + "php": "^8.1", "aws/aws-sdk-php-laravel": "^3.7", "fleetbase/countries": "^0.8.3", "fleetbase/laravel-mysql-spatial": "^1.0.2", @@ -42,23 +42,24 @@ "laravel-notification-channels/fcm": "^4.1", "laravel-notification-channels/twilio": "^3.3", "laravel/sanctum": "3.2.4", + "laravel/socialite": "^5.31", "lcobucci/clock": "3.3.1", "lcobucci/jwt": "^5.4", "maatwebsite/excel": "^3.1", + "mossadal/math-parser": "^1.3", "phpoffice/phpspreadsheet": "^1.28", "phrity/websocket": "^1.7", + "rlanvin/php-rrule": "^2.4", "sentry/sentry-laravel": "*", "spatie/laravel-activitylog": "^4.7", "spatie/laravel-google-cloud-storage": "^2.2", + "spatie/laravel-pdf": "^1.9", "spatie/laravel-permission": "^6.3", "spatie/laravel-responsecache": "^7.5", "spatie/laravel-schedule-monitor": "^3.7", "spatie/laravel-sluggable": "^3.5", "sqids/sqids": "^0.4.1", - "xantios/mimey": "^2.2.0", - "spatie/laravel-pdf": "^1.9", - "mossadal/math-parser": "^1.3", - "rlanvin/php-rrule": "^2.4" + "xantios/mimey": "^2.2.0" }, "require-dev": { "cknow/laravel-money": "^7.2", From 4a1beb930e7fc31d951750655159f1142bf8c46e Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sun, 20 Sep 2026 19:16:05 +0800 Subject: [PATCH 03/30] feat(oauth): add the identity model and single-use state store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit oauth_identities links a Fleetbase user to a provider subject, keyed unique(provider, provider_user_id). That index is a security control: it is what guarantees a provider subject can never be claimed by two accounts. Deliberately no soft deletes — MySQL unique indexes include trashed rows, so a soft-deleted identity would permanently block re-linking after an accidental unlink. oauth_states backs the three legs of the handshake (authorization, handoff, registration intent). It is a table rather than a cache entry because api/.env.example ships CACHE_DRIVER=file: with more than one app node the redirect and callback legs would not share a store. Only the sha256 of each token is persisted, and redemption is a single conditional UPDATE so exactly one of two concurrent attempts wins. The dormant users.{google,apple,facebook}_user_id columns are backfilled and kept; nothing drops them. --- ...8_000001_create_oauth_identities_table.php | 56 +++ ...09_18_000002_create_oauth_states_table.php | 63 +++ ...3_backfill_oauth_identities_from_users.php | 105 +++++ src/Auth/OAuth/Exceptions/OAuthException.php | 14 + .../OAuth/Exceptions/OAuthStateException.php | 14 + src/Auth/OAuth/OAuthUserProfile.php | 136 ++++++ src/Events/OAuthIdentityLinked.php | 37 ++ src/Events/OAuthIdentityUnlinked.php | 35 ++ src/Models/OAuthIdentity.php | 117 +++++ src/Models/OAuthState.php | 146 ++++++ src/Models/User.php | 15 + src/Providers/CoreServiceProvider.php | 13 + src/Services/OAuth/OAuthIdentityService.php | 254 +++++++++++ src/Services/OAuth/OAuthStateService.php | 299 +++++++++++++ tests/Unit/Models/OAuthIdentityModelTest.php | 204 +++++++++ .../Providers/CoreProviderContractsTest.php | 7 +- .../OAuth/OAuthIdentityServiceTest.php | 419 ++++++++++++++++++ .../Services/OAuth/OAuthStateServiceTest.php | 330 ++++++++++++++ 18 files changed, 2262 insertions(+), 2 deletions(-) create mode 100644 migrations/2026_09_18_000001_create_oauth_identities_table.php create mode 100644 migrations/2026_09_18_000002_create_oauth_states_table.php create mode 100644 migrations/2026_09_18_000003_backfill_oauth_identities_from_users.php create mode 100644 src/Auth/OAuth/Exceptions/OAuthException.php create mode 100644 src/Auth/OAuth/Exceptions/OAuthStateException.php create mode 100644 src/Auth/OAuth/OAuthUserProfile.php create mode 100644 src/Events/OAuthIdentityLinked.php create mode 100644 src/Events/OAuthIdentityUnlinked.php create mode 100644 src/Models/OAuthIdentity.php create mode 100644 src/Models/OAuthState.php create mode 100644 src/Services/OAuth/OAuthIdentityService.php create mode 100644 src/Services/OAuth/OAuthStateService.php create mode 100644 tests/Unit/Models/OAuthIdentityModelTest.php create mode 100644 tests/Unit/Services/OAuth/OAuthIdentityServiceTest.php create mode 100644 tests/Unit/Services/OAuth/OAuthStateServiceTest.php diff --git a/migrations/2026_09_18_000001_create_oauth_identities_table.php b/migrations/2026_09_18_000001_create_oauth_identities_table.php new file mode 100644 index 00000000..60044d65 --- /dev/null +++ b/migrations/2026_09_18_000001_create_oauth_identities_table.php @@ -0,0 +1,56 @@ +increments('id'); + $table->uuid('uuid')->nullable()->index(); + $table->foreignUuid('user_uuid')->references('uuid')->on('users')->onUpdate('CASCADE')->onDelete('CASCADE'); + + $table->string('provider', 40); + // 191 keeps the composite unique index inside the utf8mb4 767-byte index limit. + $table->string('provider_user_id', 191); + + // The address the provider reported at link time. Never authoritative for lookup; + // kept so an admin can see which account an identity belongs to. + $table->string('provider_email')->nullable(); + // Whether the provider ASSERTED the address as verified. Defaults false: an + // unknown verification state must never be recorded as verified. + $table->boolean('email_verified')->default(false); + + $table->json('meta')->nullable(); + $table->timestamp('last_login_at')->nullable(); + $table->timestamps(); + + $table->unique(['provider', 'provider_user_id'], 'oauth_identities_provider_subject_unique'); + $table->index(['user_uuid', 'provider']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_identities'); + } +}; diff --git a/migrations/2026_09_18_000002_create_oauth_states_table.php b/migrations/2026_09_18_000002_create_oauth_states_table.php new file mode 100644 index 00000000..16e1b93f --- /dev/null +++ b/migrations/2026_09_18_000002_create_oauth_states_table.php @@ -0,0 +1,63 @@ +increments('id'); + $table->uuid('uuid')->nullable()->index(); + + $table->string('purpose', 24)->index(); + $table->char('token_hash', 64)->unique(); + + $table->string('provider', 40)->nullable()->index(); + $table->string('intent', 16)->nullable(); + + // Set for link flows (the already-authenticated user) and once a registration + // intent has been redeemed. Null for anonymous login/signup authorization rows. + $table->foreignUuid('user_uuid')->nullable()->references('uuid')->on('users')->onUpdate('CASCADE')->onDelete('CASCADE'); + + // Encrypted JSON: PKCE verifier, return path, resolved provider profile. + $table->text('payload')->nullable(); + + // HMAC of the originating IP. Never the raw address. + $table->char('ip_hash', 64)->nullable(); + + $table->timestamp('expires_at')->index(); + $table->timestamp('consumed_at')->nullable(); + $table->timestamps(); + + $table->index(['purpose', 'expires_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_states'); + } +}; diff --git a/migrations/2026_09_18_000003_backfill_oauth_identities_from_users.php b/migrations/2026_09_18_000003_backfill_oauth_identities_from_users.php new file mode 100644 index 00000000..3061e6b0 --- /dev/null +++ b/migrations/2026_09_18_000003_backfill_oauth_identities_from_users.php @@ -0,0 +1,105 @@ + + */ + private const LEGACY_COLUMNS = [ + 'google_user_id' => 'google', + 'apple_user_id' => 'apple', + 'facebook_user_id' => 'facebook', + ]; + + /** + * Run the migrations. + * + * Copies any existing per-provider subject ids into `oauth_identities`. + * + * `email_verified` is false and `provider_email` is null for every backfilled row: the + * verification state of a historic value is unknown, and an unknown state must never be + * recorded as verified — doing so would let a backfilled row satisfy the verified-email + * checks in the OAuth flow. + * + * The legacy columns are NOT dropped. Downstream packages may still write them. + */ + public function up(): void + { + if (!Schema::hasTable('oauth_identities') || !Schema::hasTable('users')) { + return; + } + + $now = Carbon::now(); + + foreach (self::LEGACY_COLUMNS as $column => $provider) { + if (!Schema::hasColumn('users', $column)) { + continue; + } + + DB::table('users') + ->select('uuid', $column) + ->whereNotNull($column) + ->where($column, '!=', '') + ->orderBy('uuid') + ->chunk(500, function ($users) use ($column, $provider, $now) { + $rows = []; + + foreach ($users as $user) { + if (empty($user->uuid)) { + continue; + } + + $rows[] = [ + 'uuid' => (string) Str::uuid(), + 'user_uuid' => $user->uuid, + 'provider' => $provider, + 'provider_user_id' => (string) $user->{$column}, + 'provider_email' => null, + 'email_verified' => false, + 'meta' => json_encode(['backfilled_from' => $column]), + 'last_login_at' => null, + 'created_at' => $now, + 'updated_at' => $now, + ]; + } + + if ($rows !== []) { + // insertOrIgnore: a duplicate (provider, provider_user_id) must skip, + // not abort the migration. + DB::table('oauth_identities')->insertOrIgnore($rows); + } + }); + } + } + + /** + * Reverse the migrations. + * + * Removes only the rows this migration created, identified by the marker written into + * `meta`. Identities created by an actual OAuth sign-in are left untouched. + */ + public function down(): void + { + if (!Schema::hasTable('oauth_identities')) { + return; + } + + foreach (self::LEGACY_COLUMNS as $column => $provider) { + DB::table('oauth_identities') + ->where('provider', $provider) + ->where('meta', json_encode(['backfilled_from' => $column])) + ->delete(); + } + } +}; diff --git a/src/Auth/OAuth/Exceptions/OAuthException.php b/src/Auth/OAuth/Exceptions/OAuthException.php new file mode 100644 index 00000000..a6ff2d65 --- /dev/null +++ b/src/Auth/OAuth/Exceptions/OAuthException.php @@ -0,0 +1,14 @@ + $meta non-secret provider detail (locale, avatar, private-relay flag) + * @param array $rawTokenResponse held in memory for the life of the request only; never persisted or serialized + */ + public function __construct( + public readonly string $provider, + public readonly string $providerUserId, + public readonly ?string $email = null, + public readonly bool $emailVerified = false, + public readonly ?string $name = null, + public readonly ?string $avatar = null, + public readonly array $meta = [], + public readonly array $rawTokenResponse = [], + ) { + } + + /** + * Return a copy carrying the provider's raw token response. + * + * The token response is kept only so a driver can read id_token claims during + * normalization. It is dropped by both jsonSerialize() and toIdentityAttributes(), and + * Fleetbase never stores provider access or refresh tokens. + * + * @param array $tokenResponse + */ + public function withRawTokenResponse(array $tokenResponse): self + { + return new self( + $this->provider, + $this->providerUserId, + $this->email, + $this->emailVerified, + $this->name, + $this->avatar, + $this->meta, + $tokenResponse, + ); + } + + /** + * Whether this profile carries an address the provider vouched for. + */ + public function hasVerifiedEmail(): bool + { + return $this->emailVerified && !empty($this->email); + } + + /** + * Read a single non-secret meta value. + */ + public function meta(string $key, mixed $default = null): mixed + { + return $this->meta[$key] ?? $default; + } + + /** + * The subset of this profile that is persisted onto an oauth_identities row. + * + * @return array + */ + public function toIdentityAttributes(): array + { + return [ + 'provider' => $this->provider, + 'provider_user_id' => $this->providerUserId, + 'provider_email' => $this->email, + 'email_verified' => $this->emailVerified, + 'meta' => $this->meta, + ]; + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return [ + 'provider' => $this->provider, + 'provider_user_id' => $this->providerUserId, + 'email' => $this->email, + 'email_verified' => $this->emailVerified, + 'name' => $this->name, + 'avatar' => $this->avatar, + 'meta' => $this->meta, + ]; + } + + /** + * Rebuild a profile from its serialized form. + * + * Used when a handoff or registration-intent payload is read back out of oauth_states. + * + * @param array $data + */ + public static function fromArray(array $data): self + { + return new self( + (string) self::stringOrNull($data['provider'] ?? null), + (string) self::stringOrNull($data['provider_user_id'] ?? null), + self::stringOrNull($data['email'] ?? null), + (bool) ($data['email_verified'] ?? false), + self::stringOrNull($data['name'] ?? null), + self::stringOrNull($data['avatar'] ?? null), + is_array($data['meta'] ?? null) ? $data['meta'] : [], + ); + } + + /** + * Narrow a decoded JSON value to a string, discarding anything that is not scalar. + * + * Payloads are read back out of storage, so a nested array or object here would mean the + * row was corrupted or tampered with; dropping it is safer than coercing it. + */ + private static function stringOrNull(mixed $value): ?string + { + return is_scalar($value) ? (string) $value : null; + } +} diff --git a/src/Events/OAuthIdentityLinked.php b/src/Events/OAuthIdentityLinked.php new file mode 100644 index 00000000..124ed0ff --- /dev/null +++ b/src/Events/OAuthIdentityLinked.php @@ -0,0 +1,37 @@ +user = $user; + $this->identity = $identity; + } +} diff --git a/src/Events/OAuthIdentityUnlinked.php b/src/Events/OAuthIdentityUnlinked.php new file mode 100644 index 00000000..fd5c2b7d --- /dev/null +++ b/src/Events/OAuthIdentityUnlinked.php @@ -0,0 +1,35 @@ +user = $user; + $this->provider = $provider; + } +} diff --git a/src/Models/OAuthIdentity.php b/src/Models/OAuthIdentity.php new file mode 100644 index 00000000..23cef583 --- /dev/null +++ b/src/Models/OAuthIdentity.php @@ -0,0 +1,117 @@ +connection ?: config('fleetbase.connection.db', 'mysql'); + } + + /** + * The database table used by the model. + * + * Set explicitly: Eloquent's convention would derive `o_auth_identities` from the + * class name, because Str::snake() treats the capital A in "OAuth" as a word boundary. + * + * @var string + */ + protected $table = 'oauth_identities'; + + /** + * The primary key for the model. + * + * @var string + */ + protected $primaryKey = 'uuid'; + + /** + * The "type" of the primary key ID. + * + * @var string + */ + protected $keyType = 'string'; + + /** + * Indicates if the IDs are auto-incrementing. + * + * @var bool + */ + public $incrementing = false; + + /** + * The attributes that are mass assignable. + * + * @var array + */ + protected $fillable = [ + 'uuid', + 'user_uuid', + 'provider', + 'provider_user_id', + 'provider_email', + 'email_verified', + 'meta', + 'last_login_at', + ]; + + /** + * The attributes that should be hidden for arrays. + * + * `provider_user_id` is a stable cross-application identifier for the person at that + * provider. It is never needed by a client and must not leak into an API response. + * + * @var array + */ + protected $hidden = ['id', 'provider_user_id']; + + /** + * The attributes that should be cast to native types. + * + * @var array + */ + protected $casts = [ + 'meta' => Json::class, + 'email_verified' => 'boolean', + 'last_login_at' => 'datetime', + ]; + + /** + * Generate the uuid on create. + */ + protected static function booted(): void + { + static::creating(function (self $identity) { + if (empty($identity->uuid)) { + $identity->uuid = (string) Str::uuid(); + } + }); + } + + /** + * The user this identity belongs to. + * + * @return BelongsTo the BelongsTo relationship instance + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_uuid', 'uuid'); + } +} diff --git a/src/Models/OAuthState.php b/src/Models/OAuthState.php new file mode 100644 index 00000000..454aeecd --- /dev/null +++ b/src/Models/OAuthState.php @@ -0,0 +1,146 @@ +connection ?: config('fleetbase.connection.db', 'mysql'); + } + + /** + * The database table used by the model. + * + * @var string + */ + protected $table = 'oauth_states'; + + /** + * The primary key for the model. + * + * @var string + */ + protected $primaryKey = 'uuid'; + + /** + * The "type" of the primary key ID. + * + * @var string + */ + protected $keyType = 'string'; + + /** + * Indicates if the IDs are auto-incrementing. + * + * @var bool + */ + public $incrementing = false; + + /** + * The attributes that are mass assignable. + * + * @var array + */ + protected $fillable = [ + 'uuid', + 'purpose', + 'token_hash', + 'provider', + 'intent', + 'user_uuid', + 'payload', + 'ip_hash', + 'expires_at', + 'consumed_at', + ]; + + /** + * The attributes that should be hidden for arrays. + * + * This model is never serialized to a client, but hiding the hash and payload means an + * accidental dd()/toArray() in a log or an exception renderer cannot leak them. + * + * @var array + */ + protected $hidden = ['id', 'token_hash', 'payload', 'ip_hash']; + + /** + * The attributes that should be cast to native types. + * + * @var array + */ + protected $casts = [ + 'expires_at' => 'datetime', + 'consumed_at' => 'datetime', + ]; + + /** + * Generate the uuid on create. + */ + protected static function booted(): void + { + static::creating(function (self $state) { + if (empty($state->uuid)) { + $state->uuid = (string) Str::uuid(); + } + }); + } + + /** + * Rows eligible for pruning. + * + * A day's grace past expiry so a support investigation can still see whether a failed + * sign-in used an expired token or a token that never existed. + * + * @return Builder the query matching prunable rows + */ + public function prunable(): Builder + { + return static::query()->where('expires_at', '<', now()->subDay()); + } + + /** + * The user this row is bound to, when it is bound to one. + * + * @return BelongsTo the BelongsTo relationship instance + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_uuid', 'uuid'); + } +} diff --git a/src/Models/User.php b/src/Models/User.php index a6fd8301..b57ac865 100644 --- a/src/Models/User.php +++ b/src/Models/User.php @@ -318,6 +318,21 @@ public function companyUsers(): HasMany return $this->hasMany(CompanyUser::class, 'user_uuid'); } + /** + * Retrieves all external OAuth/OIDC identities linked to this user. + * + * Deliberately not added to $appends: that array is evaluated on every user + * serialization, and exposing identities there would cost a query per user on hot paths. + * + * @return HasMany the HasMany relationship instance + * + * @see OAuthIdentity + */ + public function oauthIdentities(): HasMany + { + return $this->hasMany(OAuthIdentity::class, 'user_uuid', 'uuid'); + } + /** * Retrieves all companies associated with the user through the CompanyUser pivot table. * diff --git a/src/Providers/CoreServiceProvider.php b/src/Providers/CoreServiceProvider.php index 6647ee3d..4a73492b 100644 --- a/src/Providers/CoreServiceProvider.php +++ b/src/Providers/CoreServiceProvider.php @@ -144,6 +144,15 @@ public function register() return new ReportSchemaRegistry(); }); + // OAuth services. + // + // scoped() rather than singleton(): Octane keeps workers alive across requests and + // flushes scoped bindings between them. These services read settings and the current + // request, so a singleton would carry one request's state (and one admin's + // pre-save configuration) into the next. + $this->app->scoped(\Fleetbase\Services\OAuth\OAuthStateService::class); + $this->app->scoped(\Fleetbase\Services\OAuth\OAuthIdentityService::class); + // register file resolver service $this->app->singleton(\Fleetbase\Services\FileResolverService::class, function ($app) { return new \Fleetbase\Services\FileResolverService(); @@ -171,6 +180,10 @@ public function boot() $schedule->command('purge:webhook-logs --force --no-interaction --days 2 --keep-backups=30')->twiceDaily(1, 13); $schedule->command('purge:activity-logs --force --no-interaction --days 2 --keep-backups=30')->twiceDaily(1, 13); $schedule->command('purge:scheduled-task-logs --force --no-interaction --days 1 --keep-backups=30')->twiceDaily(1, 13); + // Expired OAuth state/handoff/registration-intent rows. Hourly because these + // 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('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 diff --git a/src/Services/OAuth/OAuthIdentityService.php b/src/Services/OAuth/OAuthIdentityService.php new file mode 100644 index 00000000..0b03c164 --- /dev/null +++ b/src/Services/OAuth/OAuthIdentityService.php @@ -0,0 +1,254 @@ + + */ + private const LEGACY_COLUMNS = [ + 'google' => 'google_user_id', + 'apple' => 'apple_user_id', + 'facebook' => 'facebook_user_id', + ]; + + /** + * Find the identity row for a provider subject, if one exists. + */ + public function findByProfile(OAuthUserProfile $profile): ?OAuthIdentity + { + return $this->findBySubject($profile->provider, $profile->providerUserId); + } + + /** + * Find the identity row for a provider subject, if one exists. + */ + public function findBySubject(string $provider, string $providerUserId): ?OAuthIdentity + { + return OAuthIdentity::query() + ->where('provider', $provider) + ->where('provider_user_id', $providerUserId) + ->first(); + } + + /** + * Resolve the Fleetbase user behind a provider subject. + * + * Returns null for a soft-deleted user: `User` applies the SoftDeletes global scope, so a + * trashed account resolves to null here and the caller reports the same generic failure it + * would for an unknown identity. Distinguishing the two would be an enumeration oracle. + */ + public function findUserByProfile(OAuthUserProfile $profile): ?User + { + $identity = $this->findByProfile($profile); + + if (!$identity instanceof OAuthIdentity) { + return null; + } + + $user = $identity->user()->first(); + + return $user instanceof User ? $user : null; + } + + /** + * Link a provider identity to a user. + * + * Idempotent for the same (user, provider subject) pair. If the subject is already linked + * to a DIFFERENT user this throws rather than re-pointing the row — silently moving an + * identity between accounts is an account-takeover primitive. + * + * @throws OAuthException identity_already_linked + */ + public function link(User $user, OAuthUserProfile $profile): OAuthIdentity + { + $existing = $this->findByProfile($profile); + + if ($existing instanceof OAuthIdentity) { + return $this->resolveExisting($existing, $user); + } + + try { + $identity = OAuthIdentity::create($profile->toIdentityAttributes() + [ + 'user_uuid' => $user->uuid, + 'last_login_at' => now(), + ]); + } catch (QueryException $e) { + // Lost a race against a concurrent link of the same subject. Re-read rather than + // matching a driver error code: MySQL reports 1062 and SQLite 19 for a unique + // violation, and re-querying identifies WHICH constraint fired far more precisely + // than either code does — a foreign-key failure leaves no subject row behind and + // so is correctly rethrown here. + $raced = $this->findByProfile($profile); + + if (!$raced instanceof OAuthIdentity) { + throw $e; + } + + return $this->resolveExisting($raced, $user); + } + + $this->backfillLegacyColumn($user, $profile); + + event(new OAuthIdentityLinked($user, $identity)); + + return $identity; + } + + /** + * Remove a provider identity from a user. + * + * Hard delete by design — see the migration. Callers are responsible for refusing to + * remove a user's last remaining sign-in method; see isLastCredential(). + * + * @return bool whether an identity was actually removed + */ + public function unlink(User $user, string $provider): bool + { + $identity = OAuthIdentity::query() + ->where('user_uuid', $user->uuid) + ->where('provider', $provider) + ->first(); + + if (!$identity instanceof OAuthIdentity) { + return false; + } + + $identity->delete(); + + $this->clearLegacyColumn($user, $provider); + + event(new OAuthIdentityUnlinked($user, $provider)); + + return true; + } + + /** + * Whether removing this provider would leave the user with no way to sign in at all. + */ + public function isLastCredential(User $user, string $provider): bool + { + if (!empty($user->password)) { + return false; + } + + return OAuthIdentity::query() + ->where('user_uuid', $user->uuid) + ->where('provider', '!=', $provider) + ->count() === 0; + } + + /** + * All identities linked to a user. + * + * @return Collection the identities linked to the user + */ + public function forUser(User $user): Collection + { + return OAuthIdentity::query() + ->where('user_uuid', $user->uuid) + ->orderBy('provider') + ->get(); + } + + /** + * Record a successful sign-in, and refresh the provider-reported detail. + * + * The address is informational only. It is updated so an admin looking at a linked + * identity sees the current one; it is never used to resolve a user, which is why a + * changed provider email cannot lock anyone out. + */ + public function touchLogin(OAuthIdentity $identity, ?OAuthUserProfile $profile = null): void + { + $identity->last_login_at = now(); + + if ($profile instanceof OAuthUserProfile) { + $identity->provider_email = $profile->email; + $identity->email_verified = $profile->emailVerified; + $identity->meta = $profile->meta; + } + + $identity->save(); + } + + /** + * @throws OAuthException identity_already_linked + */ + private function resolveExisting(OAuthIdentity $identity, User $user): OAuthIdentity + { + if ($identity->user_uuid !== $user->uuid) { + throw new OAuthException('identity_already_linked'); + } + + return $identity; + } + + /** + * Mirror the subject id onto the legacy users._user_id column. + * + * Best effort only, and written with a targeted UPDATE rather than by mutating the caller's + * model, so a failure cannot leave that model dirty with an unknown attribute. Those + * columns are individually unique, so a value already claimed by another account must be + * skipped — a legacy-column collision can never be allowed to fail a sign-in or a signup. + */ + private function backfillLegacyColumn(User $user, OAuthUserProfile $profile): void + { + $column = self::LEGACY_COLUMNS[$profile->provider] ?? null; + + if ($column === null || !empty($user->{$column})) { + return; + } + + $this->writeLegacyColumn($user, $column, $profile->providerUserId); + } + + private function clearLegacyColumn(User $user, string $provider): void + { + $column = self::LEGACY_COLUMNS[$provider] ?? null; + + if ($column === null || empty($user->{$column})) { + return; + } + + $this->writeLegacyColumn($user, $column, null); + } + + private function writeLegacyColumn(User $user, string $column, ?string $value): void + { + try { + User::query()->where('uuid', $user->uuid)->update([$column => $value]); + $user->setAttribute($column, $value); + $user->syncOriginalAttribute($column); + } catch (\Throwable $e) { + // Column absent on this schema, or the value is already claimed by another + // account. Neither is fatal. Never log the subject id itself. + Log::info('[OAuth] Skipped legacy column sync.', [ + 'column' => $column, + 'user' => $user->uuid, + ]); + } + } +} diff --git a/src/Services/OAuth/OAuthStateService.php b/src/Services/OAuth/OAuthStateService.php new file mode 100644 index 00000000..762c52e5 --- /dev/null +++ b/src/Services/OAuth/OAuthStateService.php @@ -0,0 +1,299 @@ + $payload + * + * @throws OAuthStateException on an unknown purpose + */ + public function issue( + string $purpose, + array $payload, + int $ttlSeconds, + ?string $provider = null, + ?string $intent = null, + ?string $userUuid = null, + ?string $ip = null, + ): string { + $this->assertKnownPurpose($purpose); + + $token = $this->generateToken($purpose); + + OAuthState::create([ + 'purpose' => $purpose, + 'token_hash' => $this->hash($token), + 'provider' => $provider, + 'intent' => $intent, + 'user_uuid' => $userUuid, + 'payload' => $this->encodePayload($payload), + 'ip_hash' => $ip === null ? null : $this->hashIp($ip), + 'expires_at' => now()->addSeconds($ttlSeconds), + ]); + + return $token; + } + + /** + * Atomically redeem a token, returning the row and its decrypted payload. + * + * @return array{state: OAuthState, payload: array} + * + * @throws OAuthStateException when the token is absent, malformed, expired, already + * consumed, issued for another purpose, or (in strict mode) + * presented from a different IP + */ + public function consume(string $purpose, string $token, ?string $ip = null): array + { + $this->assertKnownPurpose($purpose); + + if ($token === '') { + throw new OAuthStateException('invalid_or_expired'); + } + + $hash = $this->hash($token); + + // One conditional UPDATE, not a read-then-write: under InnoDB this takes a row lock, + // so exactly one of two concurrent redemptions sees an affected count of 1. Correct + // across application nodes and independent of the cache driver. + $affected = OAuthState::query() + ->where('token_hash', $hash) + ->where('purpose', $purpose) + ->whereNull('consumed_at') + ->where('expires_at', '>', now()) + ->update(['consumed_at' => now()]); + + if ($affected !== 1) { + throw new OAuthStateException('invalid_or_expired'); + } + + $state = OAuthState::query() + ->where('token_hash', $hash) + ->where('purpose', $purpose) + ->first(); + + if (!$state instanceof OAuthState) { + // The row was pruned or deleted between the UPDATE and the read. + throw new OAuthStateException('invalid_or_expired'); + } + + $this->verifyIp($state, $ip); + + return ['state' => $state, 'payload' => $this->decodePayload($state)]; + } + + /** + * Read a token's payload without redeeming it. + * + * Used by validation rules, which must be able to report "this intent is still good" + * without burning it — otherwise a failed validation pass on any other field would + * destroy the user's sign-in session. + * + * @return array|null null when absent, malformed, expired or consumed + */ + public function inspect(string $purpose, ?string $token): ?array + { + if ($token === null || $token === '' || !$this->isKnownPurpose($purpose)) { + return null; + } + + $state = OAuthState::query() + ->where('token_hash', $this->hash($token)) + ->where('purpose', $purpose) + ->whereNull('consumed_at') + ->where('expires_at', '>', now()) + ->first(); + + if (!$state instanceof OAuthState) { + return null; + } + + try { + return $this->decodePayload($state); + } catch (OAuthStateException $e) { + return null; + } + } + + /** + * Record which user a redeemed row resolved to, for the audit trail. + */ + public function attachUser(OAuthState $state, string $userUuid): void + { + $state->user_uuid = $userUuid; + $state->save(); + } + + /** + * Hash a raw token. Exposed so callers can look a row up without holding the raw value. + */ + public function hash(string $token): string + { + return hash('sha256', $token); + } + + /** + * @return array + */ + public static function purposes(): array + { + return [ + OAuthState::PURPOSE_AUTHORIZATION, + OAuthState::PURPOSE_HANDOFF, + OAuthState::PURPOSE_REGISTRATION_INTENT, + ]; + } + + private function generateToken(string $purpose): string + { + $random = Str::random(self::TOKEN_LENGTH); + + return $purpose === OAuthState::PURPOSE_REGISTRATION_INTENT + ? self::REGISTRATION_INTENT_PREFIX . $random + : $random; + } + + /** + * HMAC rather than a bare hash so the digest cannot be reversed with a rainbow table of + * the IPv4 space. The raw address is never stored. + */ + private function hashIp(string $ip): string + { + return hash_hmac('sha256', $ip, (string) config('app.key')); + } + + /** + * Compare the presenting IP against the issuing one. + * + * Soft by default — a phone that moves between wifi and cellular mid-flow legitimately + * changes address, and failing those users closed would be worse than the marginal + * binding this provides. Operators who can guarantee stable addressing can set + * OAUTH_STRICT_IP_BINDING to make it a hard failure. + * + * @throws OAuthStateException + */ + private function verifyIp(OAuthState $state, ?string $ip): void + { + if ($ip === null || empty($state->ip_hash)) { + return; + } + + if (hash_equals((string) $state->ip_hash, $this->hashIp($ip))) { + return; + } + + if (config('oauth.strict_ip_binding', false)) { + throw new OAuthStateException('invalid_or_expired'); + } + + // No address, no token, no payload — just the fact and enough to correlate. + Log::warning('[OAuth] State redeemed from a different IP than it was issued to.', [ + 'purpose' => $state->purpose, + 'provider' => $state->provider, + 'state' => $state->uuid, + ]); + } + + /** + * @param array $payload + */ + private function encodePayload(array $payload): ?string + { + if ($payload === []) { + return null; + } + + // encrypt(..., false) skips PHP serialization: the payload is already JSON, and + // decrypting into unserialize() would be an object-injection sink. + return $this->encrypter->encrypt((string) json_encode($payload), false); + } + + /** + * @return array + * + * @throws OAuthStateException + */ + private function decodePayload(OAuthState $state): array + { + if (empty($state->payload)) { + return []; + } + + try { + $decrypted = $this->encrypter->decrypt((string) $state->payload, false); + } catch (\Throwable $e) { + // Almost always a rotated APP_KEY. Name the row, never the ciphertext. + Log::error('[OAuth] Failed to decrypt state payload.', [ + 'purpose' => $state->purpose, + 'state' => $state->uuid, + ]); + + throw new OAuthStateException('invalid_or_expired'); + } + + // decrypt(..., false) is typed mixed. Anything that is not a JSON string means the + // row was written by something other than encodePayload(); treat it as empty rather + // than coercing it. + if (!is_string($decrypted)) { + return []; + } + + $decoded = json_decode($decrypted, true); + + return is_array($decoded) ? $decoded : []; + } + + /** + * @throws OAuthStateException + */ + private function assertKnownPurpose(string $purpose): void + { + if (!$this->isKnownPurpose($purpose)) { + throw new OAuthStateException('unknown_purpose'); + } + } + + private function isKnownPurpose(string $purpose): bool + { + return in_array($purpose, self::purposes(), true); + } +} diff --git a/tests/Unit/Models/OAuthIdentityModelTest.php b/tests/Unit/Models/OAuthIdentityModelTest.php new file mode 100644 index 00000000..7d144480 --- /dev/null +++ b/tests/Unit/Models/OAuthIdentityModelTest.php @@ -0,0 +1,204 @@ + 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]; + + $container = bind_test_container([ + 'database.default' => 'mysql', + 'database.connections.mysql' => $connection, + 'fleetbase.connection.db' => 'mysql', + ]); + + $capsule = new Capsule($container); + $capsule->addConnection($connection, 'mysql'); + $capsule->setEventDispatcher(new Dispatcher($container)); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + + $databaseManager = $capsule->getDatabaseManager(); + $databaseManager->setDefaultConnection('mysql'); + $container->instance('db', $databaseManager); + Facade::clearResolvedInstance('db'); + + $schema = $capsule->getConnection('mysql')->getSchemaBuilder(); + $schema->create('oauth_identities', function ($table) { + $table->string('uuid')->primary(); + $table->string('user_uuid'); + $table->string('provider', 40); + $table->string('provider_user_id', 191); + $table->string('provider_email')->nullable(); + $table->boolean('email_verified')->default(false); + $table->text('meta')->nullable(); + $table->timestamp('last_login_at')->nullable(); + $table->timestamps(); + $table->unique(['provider', 'provider_user_id']); + }); + + return $capsule; +} + +it('uses an explicit table name', function () { + bind_test_container(); + + // Eloquent's convention would derive `o_auth_identities`, because Str::snake() treats the + // capital A in "OAuth" as a word boundary. This assertion is the guard for that trap. + expect((new OAuthIdentity())->getTable())->toBe('oauth_identities') + ->and((new OAuthState())->getTable())->toBe('oauth_states'); +}); + +it('is keyed by a string uuid that is generated on create', function () { + oauth_identity_model_database(); + + $identity = OAuthIdentity::query()->create([ + 'user_uuid' => 'user-1', + 'provider' => 'google', + 'provider_user_id' => '1091', + ]); + + $model = new OAuthIdentity(); + + expect($model->getKeyName())->toBe('uuid') + ->and($model->getKeyType())->toBe('string') + ->and($model->getIncrementing())->toBeFalse() + ->and($identity->uuid)->toBeString()->not->toBeEmpty(); +}); + +it('honours an explicitly supplied uuid', function () { + oauth_identity_model_database(); + + $identity = OAuthIdentity::query()->create([ + 'uuid' => 'fixed-uuid', + 'user_uuid' => 'user-1', + 'provider' => 'google', + 'provider_user_id' => '1091', + ]); + + expect($identity->uuid)->toBe('fixed-uuid'); +}); + +it('hides the provider subject id from serialization', function () { + oauth_identity_model_database(); + + $identity = OAuthIdentity::query()->create([ + 'user_uuid' => 'user-1', + 'provider' => 'google', + 'provider_user_id' => 'subject-that-must-not-leak', + 'provider_email' => 'ada@example.com', + ]); + + // provider_user_id is a stable cross-application identifier for the person at that + // provider. No client needs it, and it must never appear in an API response. + expect($identity->toArray())->not->toHaveKey('provider_user_id') + ->and($identity->toArray())->not->toHaveKey('id') + ->and(json_encode($identity))->not->toContain('subject-that-must-not-leak') + ->and($identity->toArray())->toHaveKey('provider_email'); +}); + +it('casts meta verification flag and login timestamp', function () { + oauth_identity_model_database(); + + $identity = OAuthIdentity::query()->create([ + 'user_uuid' => 'user-1', + 'provider' => 'google', + 'provider_user_id' => '1091', + 'email_verified' => 1, + 'meta' => ['locale' => 'en', 'private_relay' => false], + 'last_login_at' => '2024-01-15 08:30:00', + ]); + + $fresh = OAuthIdentity::query()->first(); + + expect($fresh->email_verified)->toBeTrue() + ->and($fresh->meta)->toBe(['locale' => 'en', 'private_relay' => false]) + ->and($fresh->last_login_at)->toBeInstanceOf(Carbon::class) + ->and($fresh->last_login_at->toDateTimeString())->toBe('2024-01-15 08:30:00'); +}); + +it('defaults email_verified to false so an unknown state is never treated as verified', function () { + oauth_identity_model_database(); + + OAuthIdentity::query()->create([ + 'user_uuid' => 'user-1', + 'provider' => 'github', + 'provider_user_id' => '42', + ]); + + expect(OAuthIdentity::query()->first()->email_verified)->toBeFalse(); +}); + +it('enforces one fleetbase account per provider subject', function () { + oauth_identity_model_database(); + + OAuthIdentity::query()->create([ + 'user_uuid' => 'user-1', + 'provider' => 'google', + 'provider_user_id' => '1091', + ]); + + // The unique index is a security control: it is what guarantees a provider subject can + // never be claimed by two Fleetbase accounts. + expect(fn () => OAuthIdentity::query()->create([ + 'user_uuid' => 'user-2', + 'provider' => 'google', + 'provider_user_id' => '1091', + ]))->toThrow(Illuminate\Database\QueryException::class); +}); + +it('allows the same subject id across different providers', function () { + oauth_identity_model_database(); + + OAuthIdentity::query()->create(['user_uuid' => 'user-1', 'provider' => 'google', 'provider_user_id' => '1091']); + OAuthIdentity::query()->create(['user_uuid' => 'user-1', 'provider' => 'github', 'provider_user_id' => '1091']); + + expect(OAuthIdentity::query()->count())->toBe(2); +}); + +it('does not soft delete', function () { + oauth_identity_model_database(); + + OAuthIdentity::query()->create(['user_uuid' => 'user-1', 'provider' => 'google', 'provider_user_id' => '1091']); + OAuthIdentity::query()->first()->delete(); + + // A soft-deleted row would keep occupying the unique index and permanently block + // re-linking that provider account — exactly what a user does after an accidental unlink. + expect(OAuthIdentity::query()->count())->toBe(0); + + OAuthIdentity::query()->create(['user_uuid' => 'user-1', 'provider' => 'google', 'provider_user_id' => '1091']); + expect(OAuthIdentity::query()->count())->toBe(1); +}); + +it('belongs to a user', function () { + bind_test_container(); + + $relation = (new OAuthIdentity())->user(); + + expect($relation->getRelated())->toBeInstanceOf(User::class) + ->and($relation->getForeignKeyName())->toBe('user_uuid') + ->and($relation->getOwnerKeyName())->toBe('uuid'); +}); + +it('exposes the user oauth identities relation', function () { + bind_test_container(); + + $relation = (new User())->oauthIdentities(); + + expect($relation->getRelated())->toBeInstanceOf(OAuthIdentity::class) + ->and($relation->getForeignKeyName())->toBe('user_uuid') + ->and($relation->getLocalKeyName())->toBe('uuid'); +}); diff --git a/tests/Unit/Providers/CoreProviderContractsTest.php b/tests/Unit/Providers/CoreProviderContractsTest.php index 4ec4b111..7bf593a0 100644 --- a/tests/Unit/Providers/CoreProviderContractsTest.php +++ b/tests/Unit/Providers/CoreProviderContractsTest.php @@ -936,7 +936,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(8) + ->and($provider->schedule->commands)->toHaveCount(9) ->and(array_map(fn ($event) => $event->name, $provider->schedule->commands))->toBe([ 'cache:prune-stale-tags', 'model:prune', @@ -944,11 +944,14 @@ class_alias(CoreProviderContractsFailingMixinMacro::class, 'Fleetbase\\ProviderF 'purge:webhook-logs --force --no-interaction --days 2 --keep-backups=30', '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', 'telemetry:ping', 'sandbox:sync', ]) ->and($provider->schedule->commands[1]->parameters)->toBe(['--model' => Spatie\ScheduleMonitor\Models\MonitoredScheduledTaskLogItem::class]) - ->and($provider->schedule->commands[7]->methods)->toBe([['hourly'], ['name', 'sandbox-sync'], ['withoutOverlapping']]) + ->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->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']]) diff --git a/tests/Unit/Services/OAuth/OAuthIdentityServiceTest.php b/tests/Unit/Services/OAuth/OAuthIdentityServiceTest.php new file mode 100644 index 00000000..473e1692 --- /dev/null +++ b/tests/Unit/Services/OAuth/OAuthIdentityServiceTest.php @@ -0,0 +1,419 @@ + + */ + function oauth_test_events(): array + { + return $GLOBALS['oauth_test_events'] ?? []; + } + + function oauth_test_reset_events(): void + { + $GLOBALS['oauth_test_events'] = []; + } +} + +if (!function_exists('Fleetbase\\Services\\OAuth\\event')) { + eval('namespace Fleetbase\\Services\\OAuth; function event($event = null) { if (is_object($event)) { \\oauth_test_record_event($event); } return $event; }'); +} + +class OAuthIdentityServiceHashFake +{ + public function make(string $value, array $options = []): string + { + return 'hashed:' . $value; + } + + public function check(string $value, ?string $hashed = null): bool + { + return $hashed === 'hashed:' . $value; + } + + public function info(string $hashed): array + { + return ['algo' => 'fake']; + } +} + +class OAuthIdentityServiceCacheFake +{ + private array $values = []; + + public function tags(array|string $tags): self + { + return $this; + } + + public function get(string $key, mixed $default = null): mixed + { + return $this->values[$key] ?? $default; + } + + public function put(string $key, mixed $value, mixed $ttl = null): bool + { + $this->values[$key] = $value; + + return true; + } + + public function forget(string $key): bool + { + unset($this->values[$key]); + + return true; + } + + public function increment(string $key, int $value = 1): int + { + return $value; + } + + public function flush(): bool + { + $this->values = []; + + return true; + } +} + +class OAuthIdentityServiceResponseCacheFake +{ + public function clear(): void + { + } +} + +function oauth_identity_service_schema(): void +{ + EloquentModel::clearBootedModels(); + oauth_test_reset_events(); + + $connection = [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]; + + $container = bind_test_container([ + 'database.default' => 'mysql', + 'database.connections.mysql' => $connection, + 'fleetbase.connection.db' => 'mysql', + ]); + $container->instance('hash', new OAuthIdentityServiceHashFake()); + $container->instance('cache', new OAuthIdentityServiceCacheFake()); + $container->instance('responsecache', new OAuthIdentityServiceResponseCacheFake()); + Facade::clearResolvedInstance('hash'); + Facade::clearResolvedInstance('cache'); + Facade::clearResolvedInstance('log'); + + $capsule = new Capsule($container); + $capsule->addConnection($connection, 'mysql'); + $capsule->setEventDispatcher(new Dispatcher($container)); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + + $databaseManager = $capsule->getDatabaseManager(); + $databaseManager->setDefaultConnection('mysql'); + $container->instance('db', $databaseManager); + Facade::clearResolvedInstance('db'); + + $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('email')->nullable(); + $table->string('name')->nullable(); + $table->string('username')->nullable(); + $table->string('slug')->nullable(); + $table->string('password')->nullable(); + $table->string('type')->nullable(); + $table->string('status')->nullable(); + $table->string('google_user_id')->nullable()->unique(); + $table->string('apple_user_id')->nullable()->unique(); + $table->string('facebook_user_id')->nullable()->unique(); + $table->timestamps(); + $table->softDeletes(); + }); + + $schema->create('oauth_identities', function ($table) { + $table->string('uuid')->primary(); + $table->string('user_uuid'); + $table->string('provider', 40); + $table->string('provider_user_id', 191); + $table->string('provider_email')->nullable(); + $table->boolean('email_verified')->default(false); + $table->text('meta')->nullable(); + $table->timestamp('last_login_at')->nullable(); + $table->timestamps(); + $table->unique(['provider', 'provider_user_id']); + }); +} + +/** + * Insert a user row directly and hydrate it. + * + * Deliberately not User::create(): the model's LogsActivity/HasSlug/Searchable boot hooks + * pull in half the framework on save, and none of that is what these tests are exercising. + * The service never calls save() on a user — it mirrors the legacy column with a targeted + * query-builder update — so a hydrated row is a faithful stand-in. + * + * @param array $attributes + */ +function oauth_identity_service_user(string $uuid, array $attributes = []): User +{ + app('db')->connection('mysql')->table('users')->insert(array_merge([ + 'uuid' => $uuid, + 'email' => $uuid . '@example.com', + 'name' => 'User ' . $uuid, + 'created_at' => now(), + 'updated_at' => now(), + ], $attributes)); + + return User::query()->findOrFail($uuid); +} + +function oauth_identity_service_profile(array $overrides = []): OAuthUserProfile +{ + return new OAuthUserProfile( + $overrides['provider'] ?? 'google', + $overrides['providerUserId'] ?? '1091', + $overrides['email'] ?? 'ada@example.com', + $overrides['emailVerified'] ?? true, + $overrides['name'] ?? 'Ada Lovelace', + $overrides['avatar'] ?? null, + $overrides['meta'] ?? ['locale' => 'en'], + ); +} + +it('links a provider identity to a user and fires the linked event', function () { + oauth_identity_service_schema(); + $service = new OAuthIdentityService(); + $user = oauth_identity_service_user('user-1'); + + $identity = $service->link($user, oauth_identity_service_profile()); + + expect($identity->provider)->toBe('google') + ->and($identity->provider_user_id)->toBe('1091') + ->and($identity->user_uuid)->toBe('user-1') + ->and($identity->provider_email)->toBe('ada@example.com') + ->and($identity->email_verified)->toBeTrue() + ->and($identity->meta)->toBe(['locale' => 'en']) + ->and($identity->last_login_at)->not->toBeNull(); + + $events = oauth_test_events(); + expect($events)->toHaveCount(1) + ->and($events[0])->toBeInstanceOf(OAuthIdentityLinked::class) + ->and($events[0]->user->uuid)->toBe('user-1'); +}); + +it('mirrors the subject id onto the legacy provider column', function () { + oauth_identity_service_schema(); + $service = new OAuthIdentityService(); + $user = oauth_identity_service_user('user-1'); + + $service->link($user, oauth_identity_service_profile()); + + expect(User::query()->find('user-1')->google_user_id)->toBe('1091') + ->and($user->google_user_id)->toBe('1091') + // The caller's model must not be left dirty by the mirror write. + ->and($user->isDirty())->toBeFalse(); +}); + +it('skips the legacy mirror when another account already claims the value', function () { + oauth_identity_service_schema(); + $service = new OAuthIdentityService(); + + oauth_identity_service_user('user-1', ['google_user_id' => '1091']); + $second = oauth_identity_service_user('user-2'); + + // users.google_user_id is individually unique. A collision there must never be allowed + // to fail the link — the oauth_identities row is what actually matters. + $identity = $service->link($second, oauth_identity_service_profile(['providerUserId' => '1091'])); + + expect($identity->user_uuid)->toBe('user-2') + ->and(User::query()->find('user-2')->google_user_id)->toBeNull() + ->and(OAuthIdentity::query()->count())->toBe(1); +}); + +it('is idempotent when the same user links the same subject again', function () { + oauth_identity_service_schema(); + $service = new OAuthIdentityService(); + $user = oauth_identity_service_user('user-1'); + + $first = $service->link($user, oauth_identity_service_profile()); + $second = $service->link($user, oauth_identity_service_profile()); + + expect($second->uuid)->toBe($first->uuid) + ->and(OAuthIdentity::query()->count())->toBe(1) + // The linked event fires on first link only, not on every sign-in. + ->and(oauth_test_events())->toHaveCount(1); +}); + +it('refuses to move a provider identity to a different account', function () { + oauth_identity_service_schema(); + $service = new OAuthIdentityService(); + + $owner = oauth_identity_service_user('user-1'); + $attacker = oauth_identity_service_user('user-2'); + + $service->link($owner, oauth_identity_service_profile()); + + // Silently re-pointing an identity between accounts is an account-takeover primitive. + expect(fn () => $service->link($attacker, oauth_identity_service_profile())) + ->toThrow(OAuthException::class, 'identity_already_linked'); + + expect(OAuthIdentity::query()->first()->user_uuid)->toBe('user-1'); +}); + +it('resolves a user from a provider subject', function () { + oauth_identity_service_schema(); + $service = new OAuthIdentityService(); + $user = oauth_identity_service_user('user-1'); + $service->link($user, oauth_identity_service_profile()); + + expect($service->findUserByProfile(oauth_identity_service_profile())->uuid)->toBe('user-1') + ->and($service->findBySubject('google', '1091'))->toBeInstanceOf(OAuthIdentity::class) + ->and($service->findBySubject('google', 'nope'))->toBeNull() + ->and($service->findUserByProfile(oauth_identity_service_profile(['providerUserId' => 'nope'])))->toBeNull(); +}); + +it('does not resolve a soft deleted user', function () { + oauth_identity_service_schema(); + $service = new OAuthIdentityService(); + $user = oauth_identity_service_user('user-1'); + $service->link($user, oauth_identity_service_profile()); + + User::query()->where('uuid', 'user-1')->update(['deleted_at' => now()]); + + // A trashed account resolves to null, so the caller reports the same generic failure it + // would for an unknown identity. Distinguishing them would be an enumeration oracle. + expect($service->findUserByProfile(oauth_identity_service_profile()))->toBeNull() + ->and($service->findBySubject('google', '1091'))->toBeInstanceOf(OAuthIdentity::class); +}); + +it('unlinks an identity clears the legacy column and fires the unlinked event', function () { + oauth_identity_service_schema(); + $service = new OAuthIdentityService(); + $user = oauth_identity_service_user('user-1'); + $service->link($user, oauth_identity_service_profile()); + + expect($service->unlink($user, 'google'))->toBeTrue() + ->and(OAuthIdentity::query()->count())->toBe(0) + ->and(User::query()->find('user-1')->google_user_id)->toBeNull(); + + $events = oauth_test_events(); + expect(end($events))->toBeInstanceOf(OAuthIdentityUnlinked::class) + ->and(end($events)->provider)->toBe('google'); +}); + +it('reports nothing removed when the provider was never linked', function () { + oauth_identity_service_schema(); + $service = new OAuthIdentityService(); + $user = oauth_identity_service_user('user-1'); + + expect($service->unlink($user, 'github'))->toBeFalse() + ->and(oauth_test_events())->toBeEmpty(); +}); + +it('allows relinking a provider after an unlink', function () { + oauth_identity_service_schema(); + $service = new OAuthIdentityService(); + $user = oauth_identity_service_user('user-1'); + + $service->link($user, oauth_identity_service_profile()); + $service->unlink($user, 'google'); + $relinked = $service->link($user, oauth_identity_service_profile()); + + expect($relinked->provider_user_id)->toBe('1091') + ->and(OAuthIdentity::query()->count())->toBe(1); +}); + +it('identifies a users last remaining sign in method', function () { + oauth_identity_service_schema(); + $service = new OAuthIdentityService(); + + $passwordless = oauth_identity_service_user('user-1'); + $service->link($passwordless, oauth_identity_service_profile()); + + expect($service->isLastCredential($passwordless, 'google'))->toBeTrue(); + + $service->link($passwordless, oauth_identity_service_profile(['provider' => 'github', 'providerUserId' => '42'])); + expect($service->isLastCredential($passwordless, 'google'))->toBeFalse(); + + $withPassword = oauth_identity_service_user('user-2', ['password' => 'hashed:secret']); + $service->link($withPassword, oauth_identity_service_profile(['providerUserId' => '2002'])); + expect($service->isLastCredential($withPassword, 'google'))->toBeFalse(); +}); + +it('lists a users identities ordered by provider', function () { + oauth_identity_service_schema(); + $service = new OAuthIdentityService(); + $user = oauth_identity_service_user('user-1'); + $other = oauth_identity_service_user('user-2'); + + $service->link($user, oauth_identity_service_profile(['provider' => 'microsoft', 'providerUserId' => 'm1'])); + $service->link($user, oauth_identity_service_profile(['provider' => 'github', 'providerUserId' => 'g1'])); + $service->link($other, oauth_identity_service_profile(['provider' => 'apple', 'providerUserId' => 'a1'])); + + expect($service->forUser($user)->pluck('provider')->all())->toBe(['github', 'microsoft']); +}); + +it('refreshes provider detail on sign in without changing the fleetbase user', function () { + oauth_identity_service_schema(); + $service = new OAuthIdentityService(); + $user = oauth_identity_service_user('user-1'); + $identity = $service->link($user, oauth_identity_service_profile()); + + // A provider-side email change must not lock anyone out: authentication keys on the + // subject id, and the address is refreshed as informational detail only. + $service->touchLogin($identity, oauth_identity_service_profile([ + 'email' => 'ada.new@example.com', + 'emailVerified' => false, + 'meta' => ['locale' => 'fr'], + ])); + + $fresh = OAuthIdentity::query()->first(); + + expect($fresh->provider_email)->toBe('ada.new@example.com') + ->and($fresh->email_verified)->toBeFalse() + ->and($fresh->meta)->toBe(['locale' => 'fr']) + ->and($fresh->user_uuid)->toBe('user-1') + ->and($fresh->last_login_at)->not->toBeNull(); +}); + +it('touches the login timestamp without a profile', function () { + oauth_identity_service_schema(); + $service = new OAuthIdentityService(); + $user = oauth_identity_service_user('user-1'); + $identity = $service->link($user, oauth_identity_service_profile()); + + $service->touchLogin($identity); + + expect(OAuthIdentity::query()->first()->provider_email)->toBe('ada@example.com'); +}); diff --git a/tests/Unit/Services/OAuth/OAuthStateServiceTest.php b/tests/Unit/Services/OAuth/OAuthStateServiceTest.php new file mode 100644 index 00000000..e3fa4645 --- /dev/null +++ b/tests/Unit/Services/OAuth/OAuthStateServiceTest.php @@ -0,0 +1,330 @@ +key) . '|' . ($serialize ? serialize($value) : (string) $value)); + } + + public function decrypt($payload, $unserialize = true) + { + $decoded = base64_decode((string) $payload, true); + + if ($decoded === false || !str_contains($decoded, '|')) { + throw new DecryptException('The payload is invalid.'); + } + + [$keyHash, $value] = explode('|', $decoded, 2); + + if (!hash_equals(hash('sha256', $this->key), $keyHash)) { + throw new DecryptException('The MAC is invalid.'); + } + + return $unserialize ? unserialize($value) : $value; + } + + public function getKey() + { + return $this->key; + } +} + +function oauth_state_service_database(array $config = []): Capsule +{ + EloquentModel::clearBootedModels(); + + $connection = [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]; + + $container = bind_test_container(array_merge([ + 'database.default' => 'mysql', + 'database.connections.mysql' => $connection, + 'fleetbase.connection.db' => 'mysql', + 'app.key' => 'base64:' . base64_encode(str_repeat('k', 32)), + 'oauth.strict_ip_binding' => false, + ], $config)); + + $capsule = new Capsule($container); + $capsule->addConnection($connection, 'mysql'); + $capsule->setEventDispatcher(new Dispatcher($container)); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + + $databaseManager = $capsule->getDatabaseManager(); + $databaseManager->setDefaultConnection('mysql'); + $container->instance('db', $databaseManager); + Facade::clearResolvedInstance('db'); + // bind_test_container() rebinds a fresh 'log' collector on every call, but the Facade + // caches the first instance it resolved — without this, Log::error() from the service + // lands in a previous test's collector and assertions here see an empty array. + Facade::clearResolvedInstance('log'); + + $schema = $capsule->getConnection('mysql')->getSchemaBuilder(); + $schema->create('oauth_states', function ($table) { + $table->string('uuid')->primary(); + $table->string('purpose', 24); + $table->string('token_hash', 64)->unique(); + $table->string('provider', 40)->nullable(); + $table->string('intent', 16)->nullable(); + $table->string('user_uuid')->nullable(); + $table->text('payload')->nullable(); + $table->string('ip_hash', 64)->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamp('consumed_at')->nullable(); + $table->timestamps(); + }); + + return $capsule; +} + +function oauth_state_service(): OAuthStateService +{ + return new OAuthStateService(new OAuthStateServiceEncrypterFake(str_repeat('k', 32))); +} + +it('persists only the sha256 of an issued token', function () { + oauth_state_service_database(); + $service = oauth_state_service(); + + $token = $service->issue(OAuthState::PURPOSE_HANDOFF, ['profile' => ['provider' => 'google']], 120); + + $row = OAuthState::query()->first(); + + expect($token)->toBeString()->toHaveLength(64) + ->and($row->token_hash)->toBe(hash('sha256', $token)) + ->and($row->token_hash)->not->toBe($token); + + // The raw token must not be recoverable from any column of the row. + foreach ($row->getAttributes() as $column => $value) { + expect((string) $value)->not->toContain($token); + } +}); + +it('prefixes registration intents so they are identifiable in logs', function () { + oauth_state_service_database(); + + $token = oauth_state_service()->issue(OAuthState::PURPOSE_REGISTRATION_INTENT, [], 900); + + expect($token)->toStartWith('rti_') + ->and(OAuthState::query()->first()->token_hash)->toBe(hash('sha256', $token)); +}); + +it('round trips an encrypted payload and stores it as ciphertext', function () { + oauth_state_service_database(); + $service = oauth_state_service(); + $payload = ['code_verifier' => 'v3rifi3r', 'return_to' => '/dashboard']; + + $token = $service->issue(OAuthState::PURPOSE_AUTHORIZATION, $payload, 600); + $stored = OAuthState::query()->first()->payload; + + expect($stored)->toBeString() + ->and($stored)->not->toContain('v3rifi3r') + ->and($service->consume(OAuthState::PURPOSE_AUTHORIZATION, $token)['payload'])->toBe($payload); +}); + +it('consumes a token exactly once', function () { + oauth_state_service_database(); + $service = oauth_state_service(); + + $token = $service->issue(OAuthState::PURPOSE_HANDOFF, ['a' => 1], 120); + + $first = $service->consume(OAuthState::PURPOSE_HANDOFF, $token); + expect($first['payload'])->toBe(['a' => 1]) + ->and($first['state']->consumed_at)->not->toBeNull(); + + expect(fn () => $service->consume(OAuthState::PURPOSE_HANDOFF, $token)) + ->toThrow(OAuthStateException::class, 'invalid_or_expired'); +}); + +it('refuses a token presented for a different purpose', function () { + oauth_state_service_database(); + $service = oauth_state_service(); + + $token = $service->issue(OAuthState::PURPOSE_HANDOFF, [], 120); + + expect(fn () => $service->consume(OAuthState::PURPOSE_REGISTRATION_INTENT, $token)) + ->toThrow(OAuthStateException::class, 'invalid_or_expired'); + + // …and remains unconsumed, so the legitimate purpose still works. + expect($service->consume(OAuthState::PURPOSE_HANDOFF, $token)['payload'])->toBe([]); +}); + +it('refuses an expired token', function () { + oauth_state_service_database(); + Carbon::setTestNow(Carbon::parse('2026-09-18 10:00:00', 'UTC')); + $service = oauth_state_service(); + + $token = $service->issue(OAuthState::PURPOSE_HANDOFF, [], 120); + + Carbon::setTestNow(Carbon::parse('2026-09-18 10:02:01', 'UTC')); + + expect(fn () => $service->consume(OAuthState::PURPOSE_HANDOFF, $token)) + ->toThrow(OAuthStateException::class, 'invalid_or_expired'); + + Carbon::setTestNow(); +}); + +it('refuses an unknown or empty token without disclosing which', function () { + oauth_state_service_database(); + $service = oauth_state_service(); + + expect(fn () => $service->consume(OAuthState::PURPOSE_HANDOFF, '')) + ->toThrow(OAuthStateException::class, 'invalid_or_expired') + ->and(fn () => $service->consume(OAuthState::PURPOSE_HANDOFF, str_repeat('z', 64))) + ->toThrow(OAuthStateException::class, 'invalid_or_expired'); +}); + +it('rejects an unknown purpose outright', function () { + oauth_state_service_database(); + $service = oauth_state_service(); + + expect(fn () => $service->issue('not_a_purpose', [], 60)) + ->toThrow(OAuthStateException::class, 'unknown_purpose') + ->and(fn () => $service->consume('not_a_purpose', str_repeat('z', 64))) + ->toThrow(OAuthStateException::class, 'unknown_purpose'); +}); + +it('inspects a token without consuming it', function () { + oauth_state_service_database(); + $service = oauth_state_service(); + + $token = $service->issue(OAuthState::PURPOSE_REGISTRATION_INTENT, ['email' => 'ada@example.com'], 900); + + // Validation rules call inspect() on every request; burning the intent there would + // destroy the sign-in session whenever any other field failed validation. + expect($service->inspect(OAuthState::PURPOSE_REGISTRATION_INTENT, $token))->toBe(['email' => 'ada@example.com']) + ->and($service->inspect(OAuthState::PURPOSE_REGISTRATION_INTENT, $token))->toBe(['email' => 'ada@example.com']) + ->and(OAuthState::query()->first()->consumed_at)->toBeNull() + ->and($service->consume(OAuthState::PURPOSE_REGISTRATION_INTENT, $token)['payload'])->toBe(['email' => 'ada@example.com']); +}); + +it('inspect returns null for absent consumed and expired tokens', function () { + oauth_state_service_database(); + Carbon::setTestNow(Carbon::parse('2026-09-18 10:00:00', 'UTC')); + $service = oauth_state_service(); + + $consumed = $service->issue(OAuthState::PURPOSE_HANDOFF, [], 120); + $service->consume(OAuthState::PURPOSE_HANDOFF, $consumed); + + $expiring = $service->issue(OAuthState::PURPOSE_HANDOFF, [], 120); + + expect($service->inspect(OAuthState::PURPOSE_HANDOFF, null))->toBeNull() + ->and($service->inspect(OAuthState::PURPOSE_HANDOFF, ''))->toBeNull() + ->and($service->inspect('not_a_purpose', $expiring))->toBeNull() + ->and($service->inspect(OAuthState::PURPOSE_HANDOFF, $consumed))->toBeNull() + ->and($service->inspect(OAuthState::PURPOSE_HANDOFF, $expiring))->toBe([]); + + Carbon::setTestNow(Carbon::parse('2026-09-18 10:05:00', 'UTC')); + expect($service->inspect(OAuthState::PURPOSE_HANDOFF, $expiring))->toBeNull(); + + Carbon::setTestNow(); +}); + +it('stores an hmac of the issuing ip rather than the address', function () { + oauth_state_service_database(); + $service = oauth_state_service(); + + $service->issue(OAuthState::PURPOSE_AUTHORIZATION, [], 600, 'google', 'login', null, '203.0.113.7'); + $row = OAuthState::query()->first(); + + expect($row->ip_hash)->toBeString()->toHaveLength(64) + ->and($row->ip_hash)->not->toContain('203.0.113.7') + ->and($row->ip_hash)->toBe(hash_hmac('sha256', '203.0.113.7', (string) config('app.key'))); +}); + +it('allows an ip change by default and warns instead of failing', function () { + oauth_state_service_database(); + $service = oauth_state_service(); + + $token = $service->issue(OAuthState::PURPOSE_HANDOFF, ['ok' => true], 120, null, null, null, '203.0.113.7'); + + // A phone moving from wifi to cellular mid-flow legitimately changes address; failing + // those users closed would cost more than this binding is worth. + expect($service->consume(OAuthState::PURPOSE_HANDOFF, $token, '198.51.100.4')['payload'])->toBe(['ok' => true]); + + $warnings = array_filter(app('log')->entries, fn ($entry) => $entry[0] === 'warning'); + expect($warnings)->not->toBeEmpty(); + + // The warning must carry no address and no token. + foreach ($warnings as $entry) { + expect(json_encode($entry))->not->toContain('198.51.100.4') + ->and(json_encode($entry))->not->toContain($token); + } +}); + +it('fails an ip change when strict binding is enabled', function () { + oauth_state_service_database(['oauth.strict_ip_binding' => true]); + $service = oauth_state_service(); + + $token = $service->issue(OAuthState::PURPOSE_HANDOFF, [], 120, null, null, null, '203.0.113.7'); + + expect(fn () => $service->consume(OAuthState::PURPOSE_HANDOFF, $token, '198.51.100.4')) + ->toThrow(OAuthStateException::class, 'invalid_or_expired'); +}); + +it('treats an undecryptable payload as invalid and logs no ciphertext', function () { + oauth_state_service_database(); + $service = oauth_state_service(); + + $token = $service->issue(OAuthState::PURPOSE_HANDOFF, ['secret' => 'value'], 120); + + // Simulates a rotated APP_KEY: the row survives, the payload no longer decrypts. + $rotated = new OAuthStateService(new OAuthStateServiceEncrypterFake(str_repeat('j', 32))); + + expect(fn () => $rotated->consume(OAuthState::PURPOSE_HANDOFF, $token)) + ->toThrow(OAuthStateException::class, 'invalid_or_expired'); + + $errors = array_filter(app('log')->entries, fn ($entry) => $entry[0] === 'error'); + expect($errors)->not->toBeEmpty(); + + foreach ($errors as $entry) { + expect(json_encode($entry))->not->toContain('value'); + } +}); + +it('records the resolving user on a redeemed row', function () { + oauth_state_service_database(); + $service = oauth_state_service(); + + $token = $service->issue(OAuthState::PURPOSE_REGISTRATION_INTENT, [], 900); + $state = $service->consume(OAuthState::PURPOSE_REGISTRATION_INTENT, $token)['state']; + + $service->attachUser($state, 'user-uuid-1'); + + expect(OAuthState::query()->first()->user_uuid)->toBe('user-uuid-1'); +}); + +it('exposes the known purposes and hashes tokens for callers', function () { + bind_test_container(); + $service = oauth_state_service(); + + expect(OAuthStateService::purposes())->toBe(['authorization', 'handoff', 'registration_intent']) + ->and($service->hash('abc'))->toBe(hash('sha256', 'abc')); +}); From f509acc93708a66729c180e2b645e71f55d959ad Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sun, 20 Sep 2026 19:16:05 +0800 Subject: [PATCH 04/30] feat(oauth): add the provider abstraction and four drivers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding a provider later means one driver class, one Socialite subclass if Socialite core lacks the protocol, and one entry in config/oauth.php — no route, controller, migration or console change. Each driver owns the decision that matters most: whether the provider actually vouched for the email address. Google reads email_verified; GitHub relies on Socialite only returning a primary+verified address; Apple accepts both the bool and string forms and flags private-relay aliases; Microsoft trusts xms_edov, or a single-tenant deployment whose tid matches the configured tenant — a 'common' deployment without xms_edov is never trusted, because anyone can stand up a tenant and set a user's address. Credentials resolve DB setting (encrypted) -> config/oauth.php (env) -> null, and deliberately not through EnvironmentMapper: that middleware is global, cannot decrypt, and uses putenv() which leaks across Octane requests. --- config/oauth.php | 152 +++++++ .../OAuth/AbstractOAuthProviderDriver.php | 179 ++++++++ src/Auth/OAuth/AppleClientSecretFactory.php | 99 +++++ .../OAuth/Contracts/OAuthProviderDriver.php | 74 ++++ src/Auth/OAuth/Drivers/AppleDriver.php | 203 +++++++++ src/Auth/OAuth/Drivers/GithubDriver.php | 78 ++++ src/Auth/OAuth/Drivers/GoogleDriver.php | 103 +++++ src/Auth/OAuth/Drivers/MicrosoftDriver.php | 142 ++++++ .../Exceptions/OAuthIdTokenException.php | 14 + .../OAuthProviderNotConfiguredException.php | 13 + .../UnknownOAuthProviderException.php | 14 + src/Auth/OAuth/IdTokenVerifier.php | 180 ++++++++ src/Auth/OAuth/OAuthProviderConfig.php | 197 +++++++++ src/Auth/OAuth/OAuthProviderRegistry.php | 148 +++++++ src/Auth/OAuth/Socialite/AppleProvider.php | 112 +++++ .../Socialite/Concerns/ServerSidePkce.php | 188 ++++++++ src/Auth/OAuth/Socialite/GithubProvider.php | 19 + src/Auth/OAuth/Socialite/GoogleProvider.php | 17 + .../OAuth/Socialite/MicrosoftProvider.php | 133 ++++++ src/Providers/CoreServiceProvider.php | 7 + src/Services/OAuth/OAuthConfigRepository.php | 258 +++++++++++ .../OAuth/AppleClientSecretFactoryTest.php | 148 +++++++ .../Auth/OAuth/OAuthConfigResolutionTest.php | 404 ++++++++++++++++++ .../Auth/OAuth/OAuthProviderRegistryTest.php | 197 +++++++++ .../ProviderProfileNormalizationTest.php | 320 ++++++++++++++ tests/Unit/Auth/OAuth/ServerSidePkceTest.php | 199 +++++++++ 26 files changed, 3598 insertions(+) create mode 100644 config/oauth.php create mode 100644 src/Auth/OAuth/AbstractOAuthProviderDriver.php create mode 100644 src/Auth/OAuth/AppleClientSecretFactory.php create mode 100644 src/Auth/OAuth/Contracts/OAuthProviderDriver.php create mode 100644 src/Auth/OAuth/Drivers/AppleDriver.php create mode 100644 src/Auth/OAuth/Drivers/GithubDriver.php create mode 100644 src/Auth/OAuth/Drivers/GoogleDriver.php create mode 100644 src/Auth/OAuth/Drivers/MicrosoftDriver.php create mode 100644 src/Auth/OAuth/Exceptions/OAuthIdTokenException.php create mode 100644 src/Auth/OAuth/Exceptions/OAuthProviderNotConfiguredException.php create mode 100644 src/Auth/OAuth/Exceptions/UnknownOAuthProviderException.php create mode 100644 src/Auth/OAuth/IdTokenVerifier.php create mode 100644 src/Auth/OAuth/OAuthProviderConfig.php create mode 100644 src/Auth/OAuth/OAuthProviderRegistry.php create mode 100644 src/Auth/OAuth/Socialite/AppleProvider.php create mode 100644 src/Auth/OAuth/Socialite/Concerns/ServerSidePkce.php create mode 100644 src/Auth/OAuth/Socialite/GithubProvider.php create mode 100644 src/Auth/OAuth/Socialite/GoogleProvider.php create mode 100644 src/Auth/OAuth/Socialite/MicrosoftProvider.php create mode 100644 src/Services/OAuth/OAuthConfigRepository.php create mode 100644 tests/Unit/Auth/OAuth/AppleClientSecretFactoryTest.php create mode 100644 tests/Unit/Auth/OAuth/OAuthConfigResolutionTest.php create mode 100644 tests/Unit/Auth/OAuth/OAuthProviderRegistryTest.php create mode 100644 tests/Unit/Auth/OAuth/ProviderProfileNormalizationTest.php create mode 100644 tests/Unit/Auth/OAuth/ServerSidePkceTest.php diff --git a/config/oauth.php b/config/oauth.php new file mode 100644 index 00000000..fab8d5d7 --- /dev/null +++ b/config/oauth.php @@ -0,0 +1,152 @@ + env('OAUTH_ENABLED', true), + 'allow_registration' => env('OAUTH_ALLOW_REGISTRATION', true), + + /* + |-------------------------------------------------------------------------- + | Console landing path + |-------------------------------------------------------------------------- + | + | Where the callback sends the browser once the provider handshake is done. + | The host is always taken from the console configuration — never from the + | request — so this is a path, not a URL. + | + */ + 'console_callback_path' => env('OAUTH_CONSOLE_CALLBACK_PATH', '/auth/oauth/callback'), + + /* + |-------------------------------------------------------------------------- + | Redirect base + |-------------------------------------------------------------------------- + | + | The public origin of this API, used to build the redirect_uri handed to + | providers. Defaults to app.url. It must match what is registered in each + | provider's console byte for byte. + | + */ + 'redirect_base' => env('OAUTH_REDIRECT_BASE'), + + /* + |-------------------------------------------------------------------------- + | Strict IP binding + |-------------------------------------------------------------------------- + | + | Off by default: a phone that moves between wifi and cellular mid-flow + | legitimately changes address, and failing those users closed costs more + | than this binding is worth. Operators who can guarantee stable addressing + | can turn it into a hard failure. + | + */ + 'strict_ip_binding' => env('OAUTH_STRICT_IP_BINDING', false), + + /* + |-------------------------------------------------------------------------- + | Token lifetimes (seconds) + |-------------------------------------------------------------------------- + | + | authorization — the provider round trip. Generous: a user may have to + | complete MFA at the provider. + | handoff — callback to console exchange. Deliberately tight; the + | browser redeems it immediately. + | registration_intent — how long a verified identity may sit unused while + | the user fills in the signup wizard. + | + */ + 'ttl' => [ + 'authorization' => (int) env('OAUTH_TTL_AUTHORIZATION', 600), + 'handoff' => (int) env('OAUTH_TTL_HANDOFF', 120), + 'registration_intent' => (int) env('OAUTH_TTL_REGISTRATION_INTENT', 900), + ], + + /* + |-------------------------------------------------------------------------- + | Providers + |-------------------------------------------------------------------------- + | + | Adding a provider later means: one class under Auth/OAuth/Drivers, one + | Socialite subclass if Socialite core does not ship the protocol, and one + | entry here. No route, controller, migration or console change — the + | registry, the {provider} route parameter and the admin UI schema are all + | driven off this map and the driver's configSchema(). + | + */ + 'providers' => [ + 'google' => [ + 'driver' => GoogleDriver::class, + 'enabled' => env('OAUTH_GOOGLE_ENABLED', false), + 'client_id' => env('OAUTH_GOOGLE_CLIENT_ID'), + 'client_secret' => env('OAUTH_GOOGLE_CLIENT_SECRET'), + // Restrict sign-in to a Google Workspace domain. Enforced server side + // against the verified `hd` claim, not just sent as a request hint. + 'hosted_domain' => env('OAUTH_GOOGLE_HOSTED_DOMAIN'), + ], + + 'microsoft' => [ + 'driver' => MicrosoftDriver::class, + 'enabled' => env('OAUTH_MICROSOFT_ENABLED', false), + 'client_id' => env('OAUTH_MICROSOFT_CLIENT_ID'), + 'client_secret' => env('OAUTH_MICROSOFT_CLIENT_SECRET'), + // 'common' accepts both work/school and personal accounts. A tenant + // id or domain restricts sign-in to that tenant — and is what makes + // the provider's email assertion trustworthy. See MicrosoftDriver. + 'tenant' => env('OAUTH_MICROSOFT_TENANT', 'common'), + ], + + 'github' => [ + 'driver' => GithubDriver::class, + 'enabled' => env('OAUTH_GITHUB_ENABLED', false), + 'client_id' => env('OAUTH_GITHUB_CLIENT_ID'), + 'client_secret' => env('OAUTH_GITHUB_CLIENT_SECRET'), + ], + + 'apple' => [ + 'driver' => AppleDriver::class, + 'enabled' => env('OAUTH_APPLE_ENABLED', false), + // The Services ID, e.g. io.fleetbase.console — not the app bundle id. + 'client_id' => env('OAUTH_APPLE_CLIENT_ID'), + 'team_id' => env('OAUTH_APPLE_TEAM_ID'), + 'key_id' => env('OAUTH_APPLE_KEY_ID'), + // The contents of the .p8 signing key. Apple has no static client + // secret; one is minted as a short-lived ES256 JWT from this key. + 'private_key' => env('OAUTH_APPLE_PRIVATE_KEY'), + ], + ], +]; diff --git a/src/Auth/OAuth/AbstractOAuthProviderDriver.php b/src/Auth/OAuth/AbstractOAuthProviderDriver.php new file mode 100644 index 00000000..5b2dc86c --- /dev/null +++ b/src/Auth/OAuth/AbstractOAuthProviderDriver.php @@ -0,0 +1,179 @@ + + */ + abstract protected function socialiteProviderClass(): string; + + /** + * @return array + */ + abstract protected function scopes(): array; + + /** + * Turn a provider response into a normalized Fleetbase profile. + * + * @param array $callbackPayload + */ + abstract protected function normalize(SocialiteUser $user, array $callbackPayload): OAuthUserProfile; + + /** + * Plain config keys that must be present. + * + * @return array + */ + protected static function requiredKeys(): array + { + return ['client_id']; + } + + /** + * Secrets that must resolve. + * + * @return array + */ + protected static function requiredSecrets(): array + { + return ['client_secret']; + } + + public function isConfigured(): bool + { + return $this->config->isConfigured(static::requiredKeys(), static::requiredSecrets()); + } + + public function isEnabled(): bool + { + return $this->config->enabled() && $this->isConfigured(); + } + + public function usesFormPostCallback(): bool + { + return false; + } + + public function authorizationUrl(string $state, string $codeVerifier, string $redirectUri): string + { + // withSecret: false — the authorization request is not authenticated, and for + // Apple resolving the secret means signing a fresh ES256 assertion. Minting + // one here would be wasted work on every redirect, and would turn a bad .p8 + // into a confusing failure at redirect time rather than at token exchange. + return $this->build($redirectUri, false) + ->withServerSidePkce($codeVerifier) + ->with($this->additionalAuthorizationParameters()) + ->buildAuthorizationUrl($state); + } + + public function exchange(string $code, string $codeVerifier, string $redirectUri, array $callbackPayload = []): OAuthUserProfile + { + $provider = $this->build($redirectUri)->withServerSidePkce($codeVerifier); + + $tokenResponse = $provider->exchangeAuthorizationCode($code); + $socialiteUser = $provider->userFromTokenResponse($tokenResponse); + + // The raw token response rides along in memory so a driver can read id_token + // claims during normalization. It is dropped before anything is persisted or + // serialized — Fleetbase never stores provider access or refresh tokens. + return $this->normalize($socialiteUser, $callbackPayload)->withRawTokenResponse($tokenResponse); + } + + /** + * The configured client id, narrowed to a string. + */ + protected function clientId(): string + { + $clientId = $this->config->get('client_id'); + + return is_scalar($clientId) ? (string) $clientId : ''; + } + + /** + * The client secret handed to the provider. Apple overrides this to mint one. + */ + protected function clientSecret(): string + { + return $this->config->secret('client_secret'); + } + + /** + * Extra parameters for the authorization request only. + * + * @return array + */ + protected function additionalAuthorizationParameters(): array + { + return []; + } + + /** + * Construct and configure the Socialite provider. + * + * `stateless()` disables Socialite's session-backed state checking; state is + * still sent and is validated against oauth_states instead. See ServerSidePkce. + */ + protected function build(string $redirectUri, bool $withSecret = true): SocialiteProvider + { + $class = $this->socialiteProviderClass(); + + $provider = new $class( + $this->request, + $this->clientId(), + $withSecret ? $this->clientSecret() : '', + $redirectUri + ); + + $provider->stateless()->setScopes($this->scopes()); + + return $this->configureProvider($provider); + } + + /** + * Hook for drivers that need to inject extra collaborators into the provider. + */ + protected function configureProvider(SocialiteProvider $provider): SocialiteProvider + { + return $provider; + } + + /** + * Read a claim from the verified token claims a provider returned as its raw + * user payload. + */ + protected function rawClaim(SocialiteUser $user, string $claim, mixed $default = null): mixed + { + $raw = $user->getRaw(); + + return is_array($raw) ? ($raw[$claim] ?? $default) : $default; + } +} diff --git a/src/Auth/OAuth/AppleClientSecretFactory.php b/src/Auth/OAuth/AppleClientSecretFactory.php new file mode 100644 index 00000000..bd8aeeba --- /dev/null +++ b/src/Auth/OAuth/AppleClientSecretFactory.php @@ -0,0 +1,99 @@ +get('team_id'); + $keyId = $config->get('key_id'); + $clientId = $config->get('client_id'); + + if (!is_string($teamId) || !is_string($keyId) || !is_string($clientId)) { + throw new OAuthProviderNotConfiguredException('apple.team_id|key_id|client_id'); + } + + // The .p8 contents. Held only for the duration of the signing closure. + $privateKey = $config->secret('private_key'); + + $cacheKey = 'oauth.apple.client_secret.' . sha1(implode('|', [$teamId, $keyId, $clientId])); + + $secret = Cache::remember($cacheKey, self::CACHE_TTL_SECONDS, function () use ($teamId, $keyId, $clientId, $privateKey): string { + return $this->mint($teamId, $keyId, $clientId, $privateKey); + }); + + return is_string($secret) ? $secret : ''; + } + + /** + * @throws OAuthProviderNotConfiguredException + */ + protected function mint(string $teamId, string $keyId, string $clientId, string $privateKey): string + { + try { + $configuration = Configuration::forAsymmetricSigner( + new EcdsaSha256(), + AppleSignerInMemory::plainText($privateKey), + // Never used — nothing verifies this assertion locally — but lcobucci + // requires a verification key, and its own InMemory rejects an empty one. + AppleSignerInMemory::plainText('') + ); + + $issuedAt = Carbon::now(); + + return $configuration->builder() + ->issuedBy($teamId) + ->relatedTo($clientId) + ->permittedFor('https://appleid.apple.com') + ->issuedAt($issuedAt->toDateTimeImmutable()) + ->expiresAt($issuedAt->copy()->addSeconds(self::SECRET_TTL_SECONDS)->toDateTimeImmutable()) + ->withHeader('kid', $keyId) + ->getToken($configuration->signer(), $configuration->signingKey()) + ->toString(); + } catch (\Throwable $e) { + // A malformed or non-EC .p8. Name nothing but the provider — the exception + // message from OpenSSL can echo key material. + Log::error('[OAuth] Unable to mint the Apple client secret.', ['provider' => 'apple']); + + throw new OAuthProviderNotConfiguredException('apple.private_key'); + } + } +} diff --git a/src/Auth/OAuth/Contracts/OAuthProviderDriver.php b/src/Auth/OAuth/Contracts/OAuthProviderDriver.php new file mode 100644 index 00000000..292f5ed7 --- /dev/null +++ b/src/Auth/OAuth/Contracts/OAuthProviderDriver.php @@ -0,0 +1,74 @@ + + */ + public static function configSchema(): array; + + /** + * Whether every credential needed to actually run a handshake is present. + */ + public function isConfigured(): bool; + + /** + * Whether an administrator has switched this provider on AND it is configured. + */ + public function isEnabled(): bool; + + /** + * Whether the provider posts its callback instead of redirecting to it. + * + * Apple does when name/email scopes are requested; the callback route must + * accept both verbs for it. + */ + public function usesFormPostCallback(): bool; + + /** + * Build the URL the browser is sent to in order to begin authorization. + */ + public function authorizationUrl(string $state, string $codeVerifier, string $redirectUri): string; + + /** + * Trade an authorization code for a normalized identity. + * + * @param array $callbackPayload the raw callback query/body, for + * providers that carry profile data + * outside the token response + */ + public function exchange(string $code, string $codeVerifier, string $redirectUri, array $callbackPayload = []): OAuthUserProfile; +} diff --git a/src/Auth/OAuth/Drivers/AppleDriver.php b/src/Auth/OAuth/Drivers/AppleDriver.php new file mode 100644 index 00000000..c42d74de --- /dev/null +++ b/src/Auth/OAuth/Drivers/AppleDriver.php @@ -0,0 +1,203 @@ +clientSecretFactory = $clientSecretFactory ?? new AppleClientSecretFactory(); + } + + public static function id(): string + { + return 'apple'; + } + + public static function label(): string + { + return 'Apple'; + } + + public static function icon(): string + { + return 'apple'; + } + + public static function configSchema(): array + { + return [ + 'client_id' => [ + 'label' => 'Services ID', + 'required' => true, + 'help' => 'The Services identifier, for example io.fleetbase.console — not an app bundle ID.', + ], + 'team_id' => [ + 'label' => 'Team ID', + 'required' => true, + ], + 'key_id' => [ + 'label' => 'Key ID', + 'required' => true, + ], + 'private_key' => [ + 'label' => 'Signing key (.p8)', + 'secret' => true, + 'required' => true, + 'help' => 'Contents of the AuthKey .p8 file. Apple has no static client secret; one is minted from this key for each token request.', + ], + ]; + } + + protected static function requiredKeys(): array + { + return ['client_id', 'team_id', 'key_id']; + } + + protected static function requiredSecrets(): array + { + return ['private_key']; + } + + public function usesFormPostCallback(): bool + { + return true; + } + + protected function socialiteProviderClass(): string + { + return AppleProvider::class; + } + + protected function scopes(): array + { + return ['name', 'email']; + } + + /** + * Apple issues no static client secret — mint a short-lived ES256 assertion. + */ + protected function clientSecret(): string + { + return $this->clientSecretFactory->make($this->config); + } + + protected function additionalAuthorizationParameters(): array + { + // Mandatory: Apple rejects the `name`/`email` scopes unless the response is + // form-posted. This is why the callback route must accept POST as well as GET. + return ['response_mode' => 'form_post']; + } + + protected function configureProvider(SocialiteProvider $provider): SocialiteProvider + { + if ($provider instanceof AppleProvider) { + $provider->withIdTokenVerifier($this->idTokenVerifier); + } + + return $provider; + } + + protected function normalize(SocialiteUser $user, array $callbackPayload): OAuthUserProfile + { + $email = $user->getEmail(); + + return new OAuthUserProfile( + self::id(), + (string) $user->getId(), + $email, + $this->emailIsVerified($user), + // Apple never puts the name in the id_token. It sends it exactly once, in + // the body of the FIRST authorization, and never again — so if it is not + // captured here it is gone for good. The signup form keeps the name field + // editable for precisely this reason. + $this->nameFromCallback($callbackPayload), + null, + array_filter([ + 'private_relay' => $this->isPrivateRelay($email) ?: null, + ], fn ($value) => $value !== null) + ); + } + + /** + * Apple reports `email_verified` as either a boolean or the STRING "true", + * depending on the flow. Both mean verified; anything else does not. + */ + protected function emailIsVerified(SocialiteUser $user): bool + { + $claim = $this->rawClaim($user, 'email_verified'); + + if (is_bool($claim)) { + return $claim; + } + + if (is_string($claim)) { + return filter_var($claim, FILTER_VALIDATE_BOOL); + } + + return false; + } + + /** + * Whether this is a per-application relay alias rather than the user's real + * address. + * + * Callers must not use a relay address to decide that an account already exists: + * the same person gets a different alias for every application, so it can never + * match an address Fleetbase already holds. + */ + protected function isPrivateRelay(?string $email): bool + { + return is_string($email) && str_ends_with(strtolower($email), '@' . self::PRIVATE_RELAY_DOMAIN); + } + + /** + * @param array $callbackPayload + */ + protected function nameFromCallback(array $callbackPayload): ?string + { + $user = $callbackPayload['user'] ?? null; + + if (is_string($user)) { + $user = json_decode($user, true); + } + + if (!is_array($user) || !is_array($user['name'] ?? null)) { + return null; + } + + $name = trim(implode(' ', array_filter([ + $user['name']['firstName'] ?? null, + $user['name']['lastName'] ?? null, + ], 'is_string'))); + + return $name !== '' ? $name : null; + } +} diff --git a/src/Auth/OAuth/Drivers/GithubDriver.php b/src/Auth/OAuth/Drivers/GithubDriver.php new file mode 100644 index 00000000..e0fe7f5d --- /dev/null +++ b/src/Auth/OAuth/Drivers/GithubDriver.php @@ -0,0 +1,78 @@ + [ + 'label' => 'Client ID', + 'required' => true, + ], + 'client_secret' => [ + 'label' => 'Client Secret', + 'secret' => true, + 'required' => true, + ], + ]; + } + + protected function socialiteProviderClass(): string + { + return GithubProvider::class; + } + + protected function scopes(): array + { + // `user:email` is required: GitHub's /user endpoint returns only the PUBLIC + // profile email, which the user can set to anything and which GitHub does not + // vouch for. The verified address comes from /user/emails. + return ['read:user', 'user:email']; + } + + protected function normalize(SocialiteUser $user, array $callbackPayload): OAuthUserProfile + { + $email = $user->getEmail(); + + return new OAuthUserProfile( + self::id(), + (string) $user->getId(), + $email, + // Socialite's GithubProvider::getEmailByToken() returns an address only + // when GitHub reports it as both `primary` and `verified`, and overwrites + // the public profile email with null otherwise. So a present address here + // IS the verified one — there is no separate flag to read. + $email !== null && $email !== '', + $user->getName(), + $user->getAvatar(), + array_filter([ + 'login' => $user->getNickname(), + ], fn ($value) => $value !== null) + ); + } +} diff --git a/src/Auth/OAuth/Drivers/GoogleDriver.php b/src/Auth/OAuth/Drivers/GoogleDriver.php new file mode 100644 index 00000000..22fa581c --- /dev/null +++ b/src/Auth/OAuth/Drivers/GoogleDriver.php @@ -0,0 +1,103 @@ + [ + 'label' => 'Client ID', + 'required' => true, + ], + 'client_secret' => [ + 'label' => 'Client Secret', + 'secret' => true, + 'required' => true, + ], + 'hosted_domain' => [ + 'label' => 'Restrict to Workspace domain', + 'help' => 'Optional. Only accounts in this Google Workspace domain may sign in.', + ], + ]; + } + + protected function socialiteProviderClass(): string + { + return GoogleProvider::class; + } + + protected function scopes(): array + { + return ['openid', 'email', 'profile']; + } + + protected function additionalAuthorizationParameters(): array + { + $parameters = ['prompt' => 'select_account']; + + $hostedDomain = $this->config->get('hosted_domain'); + + if (is_string($hostedDomain) && $hostedDomain !== '') { + // A request hint only. Google documents that `hd` here is not a guarantee, + // so the claim is re-checked in normalize() below. + $parameters['hd'] = $hostedDomain; + } + + return $parameters; + } + + protected function normalize(SocialiteUser $user, array $callbackPayload): OAuthUserProfile + { + $hostedDomain = $this->config->get('hosted_domain'); + + if (is_string($hostedDomain) && $hostedDomain !== '') { + // Enforced server side against the verified claim. Without this, passing + // `hd` on the authorization request is decorative — a user outside the + // domain can still complete the flow. + if ($this->rawClaim($user, 'hd') !== $hostedDomain) { + throw new OAuthException('hosted_domain_mismatch'); + } + } + + return new OAuthUserProfile( + self::id(), + (string) $user->getId(), + $user->getEmail(), + // Google states explicitly whether it verified the address. Anything other + // than boolean true — including the string "true" — is not a verification. + $this->rawClaim($user, 'email_verified') === true, + $user->getName(), + $user->getAvatar(), + array_filter([ + 'hd' => $this->rawClaim($user, 'hd'), + 'locale' => $this->rawClaim($user, 'locale'), + ], fn ($value) => $value !== null) + ); + } +} diff --git a/src/Auth/OAuth/Drivers/MicrosoftDriver.php b/src/Auth/OAuth/Drivers/MicrosoftDriver.php new file mode 100644 index 00000000..3c1ac2b2 --- /dev/null +++ b/src/Auth/OAuth/Drivers/MicrosoftDriver.php @@ -0,0 +1,142 @@ + + */ + public const MULTI_TENANT_ALIASES = ['common', 'organizations', 'consumers']; + + public static function id(): string + { + return 'microsoft'; + } + + public static function label(): string + { + return 'Microsoft'; + } + + public static function icon(): string + { + return 'microsoft'; + } + + public static function configSchema(): array + { + return [ + 'client_id' => [ + 'label' => 'Application (client) ID', + 'required' => true, + ], + 'client_secret' => [ + 'label' => 'Client Secret', + 'secret' => true, + 'required' => true, + ], + 'tenant' => [ + 'label' => 'Directory (tenant) ID', + 'help' => 'A tenant ID or domain restricts sign-in to that directory. "common" accepts any Microsoft account, but then only addresses Microsoft explicitly marks as domain-verified are trusted.', + ], + ]; + } + + protected function socialiteProviderClass(): string + { + return MicrosoftProvider::class; + } + + protected function scopes(): array + { + return ['openid', 'profile', 'email']; + } + + protected function configureProvider(SocialiteProvider $provider): SocialiteProvider + { + if ($provider instanceof MicrosoftProvider) { + $provider->withTenant($this->tenant())->withIdTokenVerifier($this->idTokenVerifier); + } + + return $provider; + } + + protected function normalize(SocialiteUser $user, array $callbackPayload): OAuthUserProfile + { + $tenantId = $this->rawClaim($user, 'tid'); + + return new OAuthUserProfile( + self::id(), + (string) $user->getId(), + $user->getEmail(), + $this->emailIsVerified($user), + $user->getName(), + null, + array_filter([ + 'tid' => is_string($tenantId) ? $tenantId : null, + 'preferred_username' => $this->rawClaim($user, 'preferred_username'), + ], fn ($value) => $value !== null) + ); + } + + /** + * Decide whether Microsoft actually vouched for this address. + * + * This is deliberately strict. A personal Microsoft account holder can set an + * arbitrary `email` / `preferred_username`, and anyone can stand up their own + * Entra tenant — so neither claim is evidence on its own. + * + * Two things count: + * + * 1. `xms_edov` — "email domain owner verified". Microsoft's own signal that the + * directory has proven ownership of the address's domain. Authoritative. + * 2. A single-tenant deployment where the token came from the configured tenant. + * There the operator controls the directory, so its addresses are as + * trustworthy as the operator's own user list. + * + * A multi-tenant ("common") deployment without `xms_edov` is NOT verified, even + * for a work account. + */ + protected function emailIsVerified(SocialiteUser $user): bool + { + if ($this->rawClaim($user, 'xms_edov') === true) { + return true; + } + + $configuredTenant = $this->tenant(); + + if (in_array($configuredTenant, self::MULTI_TENANT_ALIASES, true)) { + return false; + } + + $tokenTenant = $this->rawClaim($user, 'tid'); + + return is_string($tokenTenant) + && $tokenTenant !== self::MSA_CONSUMER_TENANT + && strcasecmp($tokenTenant, $configuredTenant) === 0; + } + + protected function tenant(): string + { + $tenant = $this->config->get('tenant', 'common'); + + return is_string($tenant) && $tenant !== '' ? $tenant : 'common'; + } +} diff --git a/src/Auth/OAuth/Exceptions/OAuthIdTokenException.php b/src/Auth/OAuth/Exceptions/OAuthIdTokenException.php new file mode 100644 index 00000000..dfc0c049 --- /dev/null +++ b/src/Auth/OAuth/Exceptions/OAuthIdTokenException.php @@ -0,0 +1,14 @@ + + * + * @throws OAuthIdTokenException + */ + public function verify( + string $jwt, + string $jwksUrl, + string $audience, + \Closure $issuerIsValid, + string $cacheKey, + int $jwksTtl = self::JWKS_CACHE_SECONDS, + ): array { + if ($jwt === '') { + throw new OAuthIdTokenException('id_token_missing'); + } + + // An unconfigured client id must never reach PermittedFor, which would then + // be asserting against an empty audience. + if ($audience === '') { + throw new OAuthIdTokenException('audience_not_configured'); + } + + // lcobucci requires a configuration even when the signer is overridden per + // call; this mirrors the bootstrap in Fleetbase\Auth\AppleVerifier. + $container = Configuration::forSymmetricSigner(new AppleSignerNone(), AppleSignerInMemory::plainText('')); + + try { + $token = $container->parser()->parse($jwt); + } catch (\Throwable $e) { + throw new OAuthIdTokenException('id_token_malformed'); + } + + if (!$token instanceof Plain) { + throw new OAuthIdTokenException('id_token_malformed'); + } + + $kid = $token->headers()->get('kid'); + + if (!is_string($kid) || $kid === '') { + throw new OAuthIdTokenException('id_token_missing_kid'); + } + + $publicKey = $this->resolvePublicKey($jwksUrl, $cacheKey, $jwksTtl, $kid); + + try { + $container->validator()->assert( + $token, + new SignedWith(new Sha256(), AppleSignerInMemory::plainText($publicKey)), + new PermittedFor($audience), + new LooseValidAt(SystemClock::fromSystemTimezone()) + ); + } catch (\Throwable $e) { + // Signature, audience or expiry. Which one is a detail for the operator, + // not the caller — and the token itself is never logged. + Log::info('[OAuth] ID token failed validation constraints.', ['jwks' => $jwksUrl]); + + throw new OAuthIdTokenException('id_token_invalid'); + } + + $issuer = $token->claims()->get('iss'); + + if (!is_string($issuer) || !$issuerIsValid($issuer)) { + Log::info('[OAuth] ID token carried an unexpected issuer.', ['jwks' => $jwksUrl]); + + throw new OAuthIdTokenException('id_token_issuer_mismatch'); + } + + return $token->claims()->all(); + } + + /** + * Fetch the signing key for a key id out of the provider's JWKS. + * + * @throws OAuthIdTokenException + */ + protected function resolvePublicKey(string $jwksUrl, string $cacheKey, int $jwksTtl, string $kid): string + { + $jwks = $this->fetchJwks($jwksUrl, $cacheKey, $jwksTtl); + + try { + $keys = JWK::parseKeySet($jwks); + } catch (\Throwable $e) { + throw new OAuthIdTokenException('jwks_unreadable'); + } + + if (!isset($keys[$kid])) { + // A rotated key that is not yet in our cached copy looks identical to a + // forged kid. Drop the cached document so the next attempt refetches. + Cache::forget($cacheKey); + + throw new OAuthIdTokenException('id_token_unknown_key'); + } + + $details = openssl_pkey_get_details($keys[$kid]->getKeyMaterial()); + + if (!is_array($details) || !isset($details['key']) || !is_string($details['key'])) { + throw new OAuthIdTokenException('jwks_unreadable'); + } + + return $details['key']; + } + + /** + * @return array + * + * @throws OAuthIdTokenException + */ + protected function fetchJwks(string $jwksUrl, string $cacheKey, int $jwksTtl): array + { + try { + $jwks = Cache::remember($cacheKey, $jwksTtl, function () use ($jwksUrl) { + // TLS verification is always on. Several older verifiers in this + // codebase disable it when app.debug is true, which also disables it + // in staging; that is not repeated here. + $response = (new GuzzleClient(['timeout' => 8.0, 'connect_timeout' => 4.0]))->get($jwksUrl); + + return json_decode((string) $response->getBody(), true); + }); + } catch (\Throwable $e) { + Log::error('[OAuth] Unable to fetch provider JWKS.', ['jwks' => $jwksUrl]); + + throw new OAuthIdTokenException('jwks_unreachable'); + } + + if (!is_array($jwks)) { + throw new OAuthIdTokenException('jwks_unreadable'); + } + + return $jwks; + } +} diff --git a/src/Auth/OAuth/OAuthProviderConfig.php b/src/Auth/OAuth/OAuthProviderConfig.php new file mode 100644 index 00000000..e11ac2ae --- /dev/null +++ b/src/Auth/OAuth/OAuthProviderConfig.php @@ -0,0 +1,197 @@ +_encrypted — written by the admin UI, Crypt-encrypted at rest + * — supplied through the environment, already trusted, plaintext + * + * The suffix is what tells them apart, so a plaintext legacy value can never be + * mistaken for ciphertext and handed to a provider verbatim. + */ +class OAuthProviderConfig +{ + /** + * Suffix marking a stored value as ciphertext. + */ + public const ENCRYPTED_SUFFIX = '_encrypted'; + + /** + * @param array $values + */ + public function __construct( + public readonly string $provider, + private array $values, + private ?Encrypter $encrypter = null, + ) { + } + + /** + * Read a non-secret value. + */ + public function get(string $key, mixed $default = null): mixed + { + $value = $this->values[$key] ?? null; + + return ($value === null || $value === '') ? $default : $value; + } + + /** + * Whether an administrator has switched this provider on. + * + * Being enabled is not the same as being usable — see isConfigured(). + */ + public function enabled(): bool + { + return (bool) ($this->values['enabled'] ?? false); + } + + /** + * Resolve a secret, decrypting it when it was stored through the admin UI. + * + * @throws OAuthProviderNotConfiguredException when the secret is absent or undecryptable + */ + public function secret(string $key): string + { + $encrypted = $this->values[$key . self::ENCRYPTED_SUFFIX] ?? null; + + if (is_string($encrypted) && $encrypted !== '') { + if (!$this->encrypter instanceof Encrypter) { + throw new OAuthProviderNotConfiguredException($this->provider . '.' . $key); + } + + try { + $decrypted = $this->encrypter->decrypt($encrypted, false); + } catch (\Throwable $e) { + // Almost always a rotated APP_KEY. Name the provider and the key, + // never the ciphertext and never the plaintext. + Log::error('[OAuth] Failed to decrypt a stored provider secret.', [ + 'provider' => $this->provider, + 'key' => $key, + ]); + + throw new OAuthProviderNotConfiguredException($this->provider . '.' . $key); + } + + if (is_string($decrypted) && $decrypted !== '') { + return $decrypted; + } + } + + $plain = $this->values[$key] ?? null; + + if (is_string($plain) && $plain !== '') { + return $plain; + } + + throw new OAuthProviderNotConfiguredException($this->provider . '.' . $key); + } + + /** + * Whether a secret is present and usable, without throwing. + */ + public function hasSecret(string $key): bool + { + try { + $this->secret($key); + + return true; + } catch (OAuthProviderNotConfiguredException $e) { + return false; + } + } + + /** + * Whether every credential the driver needs is present. + * + * @param array $requiredKeys plain values that must be non-empty + * @param array $requiredSecrets secrets that must resolve + */ + public function isConfigured(array $requiredKeys, array $requiredSecrets = []): bool + { + foreach ($requiredKeys as $key) { + if ($this->get($key) === null) { + return false; + } + } + + foreach ($requiredSecrets as $key) { + if (!$this->hasSecret($key)) { + return false; + } + } + + return true; + } + + /** + * The shape handed to an administrator. + * + * Secrets never leave the server, not even for an admin: each is reduced to + * whether it is set plus a short trailing hint, which is enough to tell two + * credentials apart when rotating without disclosing either. + * + * @param array $schema + * + * @return array + */ + public function toAdminArray(array $schema): array + { + $output = ['enabled' => $this->enabled()]; + + foreach ($schema as $key => $definition) { + if (($definition['secret'] ?? false) === true) { + $output[$key] = [ + 'configured' => $this->hasSecret($key), + 'hint' => $this->hint($key), + ]; + + continue; + } + + $output[$key] = $this->get($key); + } + + return $output; + } + + /** + * The last four characters of a secret, masked. + */ + private function hint(string $key): ?string + { + try { + $secret = $this->secret($key); + } catch (OAuthProviderNotConfiguredException $e) { + return null; + } + + // A short secret would be mostly disclosed by a 4-character tail. + if (mb_strlen($secret) < 8) { + return '••••'; + } + + return '••••' . mb_substr($secret, -4); + } + + /** + * The merged values, for the repository to re-serialize on save. + * + * @return array + */ + public function all(): array + { + return $this->values; + } +} diff --git a/src/Auth/OAuth/OAuthProviderRegistry.php b/src/Auth/OAuth/OAuthProviderRegistry.php new file mode 100644 index 00000000..40a10f0b --- /dev/null +++ b/src/Auth/OAuth/OAuthProviderRegistry.php @@ -0,0 +1,148 @@ + + */ + public function ids(): array + { + return $this->config->providerIds(); + } + + public function has(string $provider): bool + { + return $this->driverClass($provider) !== null; + } + + /** + * @throws UnknownOAuthProviderException + */ + public function driver(string $provider): OAuthProviderDriver + { + $class = $this->driverClass($provider); + + if ($class === null) { + throw new UnknownOAuthProviderException('unknown_provider'); + } + + return new $class( + $this->config->forProvider($provider), + $this->request, + $this->idTokenVerifier + ); + } + + /** + * Only the drivers an administrator has switched on AND fully configured. + * + * An enabled-but-misconfigured provider is excluded rather than surfaced as a + * broken button: from the console's point of view it simply does not exist. + * + * @return array + */ + public function enabled(): array + { + if (!$this->config->isEnabled()) { + return []; + } + + $enabled = []; + + foreach ($this->ids() as $id) { + try { + $driver = $this->driver($id); + } catch (UnknownOAuthProviderException $e) { + continue; + } + + if ($driver->isEnabled()) { + $enabled[$id] = $driver; + } + } + + return $enabled; + } + + /** + * The payload the console needs to render sign-in buttons. + * + * Contains no credentials — only what is required to draw a button. + * + * @return array + */ + public function toDiscoveryArray(): array + { + $providers = []; + + foreach ($this->enabled() as $id => $driver) { + $providers[] = [ + 'id' => $id, + 'label' => $driver::label(), + 'icon' => $driver::icon(), + ]; + } + + return $providers; + } + + /** + * The config schema for every defined provider, for the admin settings form. + * + * @return array> + */ + public function schemas(): array + { + $schemas = []; + + foreach ($this->ids() as $id) { + $class = $this->driverClass($id); + + if ($class === null) { + continue; + } + + $schemas[$id] = $class::configSchema(); + } + + return $schemas; + } + + /** + * @return class-string|null + */ + protected function driverClass(string $provider): ?string + { + $class = $this->config->driverClass($provider); + + if ($class === null || !class_exists($class) || !is_subclass_of($class, OAuthProviderDriver::class)) { + return null; + } + + return $class; + } +} diff --git a/src/Auth/OAuth/Socialite/AppleProvider.php b/src/Auth/OAuth/Socialite/AppleProvider.php new file mode 100644 index 00000000..29704422 --- /dev/null +++ b/src/Auth/OAuth/Socialite/AppleProvider.php @@ -0,0 +1,112 @@ + + */ + protected $scopes = ['name', 'email']; + + protected ?IdTokenVerifier $idTokenVerifier = null; + + public function withIdTokenVerifier(IdTokenVerifier $verifier): static + { + $this->idTokenVerifier = $verifier; + + return $this; + } + + protected function getAuthUrl($state) + { + return $this->buildAuthUrlFromBase(self::ISSUER . '/auth/authorize', $state); + } + + protected function getTokenUrl() + { + return self::ISSUER . '/auth/token'; + } + + /** + * Resolve the profile from the verified id_token. + * + * @param string $token the access token, unused — Apple's is not usable for profile reads + * + * @return array + */ + protected function getUserByToken($token) + { + return $this->verifier()->verify( + $this->serverIdToken(), + self::JWKS_URL, + $this->audience(), + fn (string $issuer): bool => $issuer === self::ISSUER, + 'oauth.jwks.apple' + ); + } + + /** + * @param array $user + */ + protected function mapUserToObject(array $user) + { + return (new SocialiteUser())->setRaw($user)->map([ + 'id' => Arr::get($user, 'sub'), + 'nickname' => null, + // Never present in the token; supplied by AppleDriver from the callback + // body on first authorization only. + 'name' => null, + 'email' => Arr::get($user, 'email'), + 'avatar' => null, + ]); + } + + /** + * The expected `aud` claim: this application's client id, narrowed to a string. + */ + protected function audience(): string + { + return is_scalar($this->clientId) ? (string) $this->clientId : ''; + } + + protected function verifier(): IdTokenVerifier + { + return $this->idTokenVerifier ?? new IdTokenVerifier(); + } +} diff --git a/src/Auth/OAuth/Socialite/Concerns/ServerSidePkce.php b/src/Auth/OAuth/Socialite/Concerns/ServerSidePkce.php new file mode 100644 index 00000000..c758d486 --- /dev/null +++ b/src/Auth/OAuth/Socialite/Concerns/ServerSidePkce.php @@ -0,0 +1,188 @@ +session()` (AbstractProvider::redirect(), ::getCodeChallenge(), + * ::getTokenFields()). Fleetbase's OAuth routes live in the public throttled + * group, which has no StartSession middleware — only `fleetbase.protected` does — + * and a session would not survive the provider's cross-site POST callback anyway. + * + * So state and the verifier are held server side in `oauth_states` and injected + * here explicitly. The provider is put into `stateless()` mode to disable + * Socialite's own session-backed state checking, and `state` is then added back to + * the authorization URL by this trait, because it is still a required CSRF control — + * it is simply validated against the database instead of the session. + * + * This lives on a subclass because getAuthUrl(), getAccessTokenResponse(), + * getCodeFields(), getTokenFields() and userInstance() are all protected. + */ +trait ServerSidePkce +{ + /** + * Parameters that belong on the authorization request only. + * + * Socialite merges $this->parameters into BOTH getCodeFields() and + * getTokenFields(), so without this a `response_mode` or `prompt` set for the + * authorization leg would also be POSTed to the token endpoint, where it is at + * best ignored and at worst rejected. + */ + private const AUTHORIZATION_ONLY_PARAMETERS = [ + 'response_mode', + 'prompt', + 'hd', + 'login_hint', + 'access_type', + 'include_granted_scopes', + ]; + + protected ?string $serverCodeVerifier = null; + + protected ?string $serverState = null; + + /** + * The provider's raw token response, kept for the life of the request so + * id_token-based providers can read claims from it. Never persisted. + * + * @var array + */ + protected array $serverTokenResponse = []; + + /** + * Supply the PKCE verifier for this exchange. + */ + public function withServerSidePkce(string $verifier): static + { + $this->serverCodeVerifier = $verifier; + + return $this; + } + + /** + * Build the authorization URL the browser is redirected to. + */ + public function buildAuthorizationUrl(string $state): string + { + $this->serverState = $state; + + return $this->getAuthUrl($state); + } + + /** + * Exchange an authorization code for the provider's raw token response. + * + * @return array + */ + public function exchangeAuthorizationCode(string $code): array + { + $response = $this->getAccessTokenResponse($code); + + return is_array($response) ? $response : []; + } + + /** + * Resolve the provider's user from an already-exchanged token response. + * + * @param array $tokenResponse + */ + public function userFromTokenResponse(array $tokenResponse): SocialiteUser + { + $this->serverTokenResponse = $tokenResponse; + + return $this->userInstance( + $tokenResponse, + $this->getUserByToken(Arr::get($tokenResponse, 'access_token')) + ); + } + + /** + * The raw token response, for providers whose profile comes from the id_token. + * + * @return array + */ + public function serverTokenResponse(): array + { + return $this->serverTokenResponse; + } + + /** + * The id_token from the provider's token response, for providers whose profile + * comes from the token rather than a userinfo call. + * + * Narrowed rather than cast: a non-string here means the provider returned + * something unexpected, and an empty string makes the verifier reject it + * explicitly instead of stringifying an array. + */ + protected function serverIdToken(): string + { + $idToken = $this->serverTokenResponse['id_token'] ?? null; + + return is_string($idToken) ? $idToken : ''; + } + + /** + * Add our own state and PKCE challenge to the authorization request. + * + * Deliberately does not call enablePKCE(): that flag makes the parent read the + * verifier out of the session in both getCodeFields() and getTokenFields(), + * which is precisely what this trait exists to avoid. + * + * @param string|null $state + * + * @return array + */ + protected function getCodeFields($state = null) + { + $fields = parent::getCodeFields($state); + + if ($this->serverState !== null) { + $fields['state'] = $this->serverState; + } + + if ($this->serverCodeVerifier !== null) { + $fields['code_challenge'] = $this->serverCodeChallenge(); + $fields['code_challenge_method'] = 'S256'; + } + + return $fields; + } + + /** + * Add the PKCE verifier to the token request. + * + * @param string $code + * + * @return array + */ + protected function getTokenFields($code) + { + $fields = parent::getTokenFields($code); + + foreach (self::AUTHORIZATION_ONLY_PARAMETERS as $parameter) { + unset($fields[$parameter]); + } + + if ($this->serverCodeVerifier !== null) { + $fields['code_verifier'] = $this->serverCodeVerifier; + } + + return $fields; + } + + /** + * RFC 7636 S256: base64url(sha256(verifier)), unpadded. + */ + protected function serverCodeChallenge(): string + { + return rtrim( + strtr(base64_encode(hash('sha256', (string) $this->serverCodeVerifier, true)), '+/', '-_'), + '=' + ); + } +} diff --git a/src/Auth/OAuth/Socialite/GithubProvider.php b/src/Auth/OAuth/Socialite/GithubProvider.php new file mode 100644 index 00000000..421e27c2 --- /dev/null +++ b/src/Auth/OAuth/Socialite/GithubProvider.php @@ -0,0 +1,19 @@ + + */ + protected $scopes = ['openid', 'profile', 'email']; + + /** + * Tenant id, domain, or one of the Microsoft aliases (`common`, + * `organizations`, `consumers`). + */ + protected string $tenant = 'common'; + + protected ?IdTokenVerifier $idTokenVerifier = null; + + public function withTenant(string $tenant): static + { + $this->tenant = $tenant !== '' ? $tenant : 'common'; + + return $this; + } + + public function withIdTokenVerifier(IdTokenVerifier $verifier): static + { + $this->idTokenVerifier = $verifier; + + return $this; + } + + public function tenant(): string + { + return $this->tenant; + } + + protected function getAuthUrl($state) + { + return $this->buildAuthUrlFromBase($this->authority() . '/oauth2/v2.0/authorize', $state); + } + + protected function getTokenUrl() + { + return $this->authority() . '/oauth2/v2.0/token'; + } + + /** + * Resolve the profile from the verified id_token. + * + * @param string $token the access token, unused — the profile lives in the id_token + * + * @return array + */ + protected function getUserByToken($token) + { + return $this->verifier()->verify( + $this->serverIdToken(), + $this->authority() . '/discovery/v2.0/keys', + $this->audience(), + // Multi-tenant tokens are issued by the user's HOME tenant, so the issuer + // contains a tenant uuid rather than the literal configured value and + // cannot be compared for equality. + fn (string $issuer): bool => str_starts_with($issuer, self::AUTHORITY_BASE), + 'oauth.jwks.microsoft.' . sha1($this->tenant) + ); + } + + /** + * @param array $user + */ + protected function mapUserToObject(array $user) + { + return (new SocialiteUser())->setRaw($user)->map([ + // `oid` is the immutable object id for the user within the tenant, and is + // the conventional stable identifier when a person may sign in to several + // Microsoft applications. `sub` is pairwise per application. + 'id' => Arr::get($user, 'oid'), + 'nickname' => Arr::get($user, 'preferred_username'), + 'name' => Arr::get($user, 'name'), + 'email' => Arr::get($user, 'email') ?: Arr::get($user, 'preferred_username'), + 'avatar' => null, + ]); + } + + protected function authority(): string + { + return self::AUTHORITY_BASE . rawurlencode($this->tenant); + } + + /** + * The expected `aud` claim: this application's client id, narrowed to a string. + */ + protected function audience(): string + { + return is_scalar($this->clientId) ? (string) $this->clientId : ''; + } + + protected function verifier(): IdTokenVerifier + { + return $this->idTokenVerifier ?? new IdTokenVerifier(); + } +} diff --git a/src/Providers/CoreServiceProvider.php b/src/Providers/CoreServiceProvider.php index 4a73492b..69b02075 100644 --- a/src/Providers/CoreServiceProvider.php +++ b/src/Providers/CoreServiceProvider.php @@ -138,6 +138,7 @@ public function register() $this->mergeConfigFrom(__DIR__ . '/../../config/responsecache.php', 'responsecache'); $this->mergeConfigFrom(__DIR__ . '/../../config/image.php', 'image'); $this->mergeConfigFrom(__DIR__ . '/../../config/sms.php', 'sms'); + $this->mergeConfigFrom(__DIR__ . '/../../config/oauth.php', 'oauth'); // setup report schema registry $this->app->singleton(ReportSchemaRegistry::class, function () { @@ -152,6 +153,12 @@ public function register() // pre-save configuration) into the next. $this->app->scoped(\Fleetbase\Services\OAuth\OAuthStateService::class); $this->app->scoped(\Fleetbase\Services\OAuth\OAuthIdentityService::class); + $this->app->scoped(\Fleetbase\Services\OAuth\OAuthConfigRepository::class); + $this->app->scoped(\Fleetbase\Auth\OAuth\OAuthProviderRegistry::class); + + // Stateless collaborators — safe to share for the lifetime of the worker. + $this->app->singleton(\Fleetbase\Auth\OAuth\IdTokenVerifier::class); + $this->app->singleton(\Fleetbase\Auth\OAuth\AppleClientSecretFactory::class); // register file resolver service $this->app->singleton(\Fleetbase\Services\FileResolverService::class, function ($app) { diff --git a/src/Services/OAuth/OAuthConfigRepository.php b/src/Services/OAuth/OAuthConfigRepository.php new file mode 100644 index 00000000..18e55388 --- /dev/null +++ b/src/Services/OAuth/OAuthConfigRepository.php @@ -0,0 +1,258 @@ +globalValue('enabled', config('oauth.enabled', true)); + } + + /** + * Whether an unrecognised provider identity may start a Fleetbase signup. + * + * Separate from isEnabled() so an operator can offer OAuth sign-in to existing + * users without opening self-service registration. + */ + public function allowsRegistration(): bool + { + return (bool) $this->globalValue('allow_registration', config('oauth.allow_registration', true)); + } + + /** + * The public origin of this API, used to build the redirect_uri handed to + * providers. Must match what is registered in each provider's console. + */ + public function redirectBase(): string + { + $base = $this->globalValue('redirect_base', config('oauth.redirect_base')); + + if (!is_string($base) || $base === '') { + $base = (string) config('app.url'); + } + + return rtrim($base, '/'); + } + + public function consoleCallbackPath(): string + { + $path = $this->globalValue('console_callback_path', config('oauth.console_callback_path', '/auth/oauth/callback')); + + return is_string($path) && $path !== '' ? $path : '/auth/oauth/callback'; + } + + /** + * Resolved configuration for one provider. + */ + public function forProvider(string $provider): OAuthProviderConfig + { + return new OAuthProviderConfig($provider, $this->mergedValues($provider), $this->encrypter); + } + + /** + * The provider ids this installation defines. + * + * @return array + */ + public function providerIds(): array + { + $providers = config('oauth.providers', []); + + return is_array($providers) ? array_keys($providers) : []; + } + + /** + * The driver class name for a provider id, or null when it is not defined. + * + * Returned as a plain string: the registry is what validates that the class + * exists and implements the driver contract before instantiating anything. + */ + public function driverClass(string $provider): ?string + { + $class = config('oauth.providers.' . $provider . '.driver'); + + return is_string($class) && $class !== '' ? $class : null; + } + + /** + * How long a token of the given purpose lives. + */ + public function ttl(string $purpose, int $default): int + { + $ttl = config('oauth.ttl.' . $purpose, $default); + + return is_numeric($ttl) ? (int) $ttl : $default; + } + + /** + * Persist a partial configuration change. + * + * Read-modify-write under a row lock: Setting::configure() is an updateOrCreate + * over a single JSON blob, so two administrators saving different providers at + * the same time would otherwise silently discard one of the two edits. + * + * Secrets are encrypted here, under a distinct `_encrypted` name. An empty + * incoming secret means "leave the stored one alone", which is what lets the + * admin UI render a masked placeholder instead of the real value. + * + * @param array $global + * @param array> $providers + * @param array> $secretKeys provider => secret field names + */ + public function save(array $global, array $providers = [], array $secretKeys = []): void + { + DB::transaction(function () use ($global, $providers, $secretKeys): void { + $current = Setting::query() + ->where('key', 'system.' . self::SETTINGS_KEY) + ->lockForUpdate() + ->first(); + + $stored = is_array($current->value ?? null) ? $current->value : []; + + foreach ($global as $key => $value) { + $stored[$key] = $value; + } + + $storedProviders = is_array($stored['providers'] ?? null) ? $stored['providers'] : []; + + foreach ($providers as $provider => $values) { + $existing = is_array($storedProviders[$provider] ?? null) ? $storedProviders[$provider] : []; + $secrets = $secretKeys[$provider] ?? []; + + foreach ($values as $key => $value) { + if (in_array($key, $secrets, true)) { + // Empty means "keep what is stored" — the admin UI never + // receives the real value, so it cannot echo it back. + if (!is_string($value) || trim($value) === '') { + continue; + } + + $existing[$key . OAuthProviderConfig::ENCRYPTED_SUFFIX] = $this->encrypt(trim($value)); + // Drop any plaintext left by an earlier environment-based setup + // so the encrypted copy is unambiguously authoritative. + unset($existing[$key]); + + continue; + } + + $existing[$key] = $value; + } + + $storedProviders[$provider] = $existing; + } + + $stored['providers'] = $storedProviders; + + Setting::configureSystem(self::SETTINGS_KEY, $stored); + }); + } + + /** + * The whole configuration as an administrator should see it: no secret values, + * only whether each is set plus a short hint. + * + * @param array> $schemas + * + * @return array + */ + public function toAdminArray(array $schemas): array + { + $providers = []; + + foreach ($schemas as $provider => $schema) { + $providers[$provider] = $this->forProvider($provider)->toAdminArray($schema); + } + + return [ + 'enabled' => $this->isEnabled(), + 'allow_registration' => $this->allowsRegistration(), + 'providers' => $providers, + ]; + } + + /** + * Database settings layered over the config defaults for one provider. + * + * @return array + */ + protected function mergedValues(string $provider): array + { + $defaults = config('oauth.providers.' . $provider, []); + $defaults = is_array($defaults) ? $defaults : []; + + $stored = $this->storedSettings(); + $values = $stored['providers'][$provider] ?? []; + + $merged = array_merge($defaults, is_array($values) ? $values : []); + + // The driver class is wiring, not configuration, and must never be settable + // from the database — that would be arbitrary class instantiation. Stripped + // AFTER the merge, because a settings row could otherwise reintroduce it. + unset($merged['driver']); + + return $merged; + } + + protected function globalValue(string $key, mixed $default): mixed + { + $stored = $this->storedSettings(); + + return array_key_exists($key, $stored) ? $stored[$key] : $default; + } + + /** + * @return array + */ + protected function storedSettings(): array + { + $stored = Setting::system(self::SETTINGS_KEY); + + return is_array($stored) ? $stored : []; + } + + protected function encrypt(string $value): string + { + if (!$this->encrypter instanceof Encrypter) { + throw new \RuntimeException('Cannot store an OAuth secret without an encrypter.'); + } + + return $this->encrypter->encrypt($value, false); + } +} diff --git a/tests/Unit/Auth/OAuth/AppleClientSecretFactoryTest.php b/tests/Unit/Auth/OAuth/AppleClientSecretFactoryTest.php new file mode 100644 index 00000000..affd202a --- /dev/null +++ b/tests/Unit/Auth/OAuth/AppleClientSecretFactoryTest.php @@ -0,0 +1,148 @@ +values)) { + $this->misses++; + $this->values[$key] = $callback(); + } + + return $this->values[$key]; + } + + public function forget(string $key): bool + { + unset($this->values[$key]); + + return true; + } +} + +function apple_secret_private_key(): string +{ + // A real P-256 key, generated per run. Apple's ES256 assertion cannot be signed + // with anything else, so a fake string would not exercise the signer at all. + $resource = openssl_pkey_new([ + 'curve_name' => 'prime256v1', + 'private_key_type' => OPENSSL_KEYTYPE_EC, + ]); + + openssl_pkey_export($resource, $pem); + + return (string) $pem; +} + +function apple_secret_factory(array $overrides = []): array +{ + bind_test_container(); + + $cache = new AppleSecretCacheFake(); + app()->instance('cache', $cache); + Facade::clearResolvedInstance('cache'); + Facade::clearResolvedInstance('log'); + + $config = new OAuthProviderConfig('apple', array_merge([ + 'client_id' => 'io.fleetbase.console', + 'team_id' => 'TEAM123456', + 'key_id' => 'KEY7890', + 'private_key' => apple_secret_private_key(), + ], $overrides), null); + + return [new AppleClientSecretFactory(), $config, $cache]; +} + +/** + * @return array{0: array, 1: array} + */ +function apple_secret_decode(string $jwt): array +{ + [$header, $payload] = explode('.', $jwt); + + $decode = fn (string $segment) => json_decode( + (string) base64_decode(strtr($segment, '-_', '+/'), true), + true + ); + + return [$decode($header), $decode($payload)]; +} + +it('mints an es256 assertion apple will accept', function () { + Carbon::setTestNow(Carbon::parse('2024-03-01 12:00:00', 'UTC')); + [$factory, $config] = apple_secret_factory(); + + [$header, $claims] = apple_secret_decode($factory->make($config)); + + expect($header['alg'])->toBe('ES256') + ->and($header['kid'])->toBe('KEY7890') + ->and($claims['iss'])->toBe('TEAM123456') + ->and($claims['sub'])->toBe('io.fleetbase.console') + ->and($claims['aud'])->toBe('https://appleid.apple.com') + ->and($claims['exp'] - $claims['iat'])->toBe(AppleClientSecretFactory::SECRET_TTL_SECONDS) + // Apple caps the assertion lifetime at six months. + ->and($claims['exp'] - $claims['iat'])->toBeLessThanOrEqual(15777000); + + Carbon::setTestNow(); +}); + +it('reuses a cached assertion rather than re-signing', function () { + [$factory, $config, $cache] = apple_secret_factory(); + + $first = $factory->make($config); + $second = $factory->make($config); + + expect($second)->toBe($first) + ->and($cache->misses)->toBe(1) + // Must expire before the assertion does, or a cached value could be served + // after Apple would already reject it. + ->and(AppleClientSecretFactory::CACHE_TTL_SECONDS)->toBeLessThan(AppleClientSecretFactory::SECRET_TTL_SECONDS); +}); + +it('caches per credential set so rotating a key does not serve a stale assertion', function () { + [$factory, $config, $cache] = apple_secret_factory(); + $rotated = new OAuthProviderConfig('apple', [ + 'client_id' => 'io.fleetbase.console', + 'team_id' => 'TEAM123456', + 'key_id' => 'KEY-ROTATED', + 'private_key' => apple_secret_private_key(), + ], null); + + $factory->make($config); + $factory->make($rotated); + + expect($cache->misses)->toBe(2) + ->and(array_keys($cache->values))->toHaveCount(2); +}); + +it('reports apple unconfigured when part of the signing identity is missing', function (array $overrides) { + [$factory, $config] = apple_secret_factory($overrides); + + expect(fn () => $factory->make($config))->toThrow(OAuthProviderNotConfiguredException::class); +})->with([ + 'no team id' => [['team_id' => null]], + 'no key id' => [['key_id' => null]], + 'no client id' => [['client_id' => null]], + 'no private key' => [['private_key' => null]], +]); + +it('reports apple unconfigured for a key openssl cannot sign with and logs no key material', function () { + [$factory, $config] = apple_secret_factory(['private_key' => '-----BEGIN PRIVATE KEY-----not-a-key-----END PRIVATE KEY-----']); + + expect(fn () => $factory->make($config))->toThrow(OAuthProviderNotConfiguredException::class, 'apple.private_key'); + + // OpenSSL error strings can echo key material, so nothing but the provider name + // is logged. + foreach (app('log')->entries as $entry) { + expect(json_encode($entry))->not->toContain('BEGIN PRIVATE KEY'); + } +}); diff --git a/tests/Unit/Auth/OAuth/OAuthConfigResolutionTest.php b/tests/Unit/Auth/OAuth/OAuthConfigResolutionTest.php new file mode 100644 index 00000000..58362fe8 --- /dev/null +++ b/tests/Unit/Auth/OAuth/OAuthConfigResolutionTest.php @@ -0,0 +1,404 @@ +key . ':' . base64_encode($serialize ? serialize($value) : (string) $value); + } + + public function decrypt($payload, $unserialize = true) + { + $prefix = 'enc:' . $this->key . ':'; + + if (!is_string($payload) || !str_starts_with($payload, $prefix)) { + throw new DecryptException('The MAC is invalid.'); + } + + $value = base64_decode(substr($payload, strlen($prefix)), true); + + if ($value === false) { + throw new DecryptException('The payload is invalid.'); + } + + return $unserialize ? unserialize($value) : $value; + } + + public function getKey() + { + return $this->key; + } +} + +class OAuthConfigCacheFake +{ + private array $values = []; + + public function rememberForever(string $key, Closure $callback): mixed + { + if (!array_key_exists($key, $this->values)) { + $this->values[$key] = $callback(); + } + + return $this->values[$key]; + } + + public function remember(string $key, mixed $ttl, Closure $callback): mixed + { + return $this->rememberForever($key, $callback); + } + + public function get(string $key, mixed $default = null): mixed + { + return $this->values[$key] ?? $default; + } + + public function put(string $key, mixed $value, mixed $ttl = null): bool + { + $this->values[$key] = $value; + + return true; + } + + public function forget(string $key): bool + { + unset($this->values[$key]); + + return true; + } + + public function increment(string $key, int $value = 1): int + { + $this->values[$key] = (int) ($this->values[$key] ?? 0) + $value; + + return $this->values[$key]; + } + + public function tags(array|string $tags): self + { + return $this; + } + + public function flush(): bool + { + $this->values = []; + + return true; + } + + public function getPrefix(): string + { + return ''; + } +} + +function oauth_config_repository(array $config = [], ?Encrypter $encrypter = null): OAuthConfigRepository +{ + EloquentModel::clearBootedModels(); + + $connection = [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]; + + $container = bind_test_container(array_merge([ + 'api.cache.enabled' => false, + 'database.default' => 'mysql', + 'database.connections.mysql' => $connection, + 'fleetbase.connection.db' => 'mysql', + // A trimmed stand-in for config/oauth.php. + 'oauth.enabled' => true, + 'oauth.allow_registration' => true, + 'oauth.providers' => [ + 'google' => [ + 'driver' => GoogleDriver::class, + 'enabled' => false, + 'client_id' => 'google-id-from-env', + 'client_secret' => null, + 'hosted_domain' => null, + ], + 'apple' => [ + 'driver' => AppleDriver::class, + 'enabled' => false, + 'client_id' => null, + 'team_id' => null, + 'key_id' => null, + 'private_key' => null, + ], + ], + ], $config)); + + $cache = new OAuthConfigCacheFake(); + $container->instance('cache', $cache); + Facade::clearResolvedInstance('cache'); + Facade::clearResolvedInstance('log'); + + $capsule = new Capsule($container); + $capsule->addConnection($connection, 'mysql'); + $capsule->setEventDispatcher(new Dispatcher($container)); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + + $databaseManager = $capsule->getDatabaseManager(); + $databaseManager->setDefaultConnection('mysql'); + $container->instance('db', $databaseManager); + Facade::clearResolvedInstance('db'); + + $schema = $capsule->getConnection('mysql')->getSchemaBuilder(); + $schema->create('settings', function ($table) { + $table->increments('id'); + $table->string('key')->unique(); + $table->text('value')->nullable(); + }); + + return new OAuthConfigRepository($encrypter ?? new OAuthConfigEncrypterFake()); +} + +it('falls back to the env backed config when nothing is stored', function () { + $repository = oauth_config_repository(); + + expect($repository->isEnabled())->toBeTrue() + ->and($repository->allowsRegistration())->toBeTrue() + ->and($repository->providerIds())->toBe(['google', 'apple']) + ->and($repository->forProvider('google')->get('client_id'))->toBe('google-id-from-env') + ->and($repository->forProvider('google')->enabled())->toBeFalse(); +}); + +it('layers stored settings over the config defaults', function () { + $repository = oauth_config_repository(); + + Setting::configureSystem('oauth', [ + 'enabled' => true, + 'providers' => [ + 'google' => ['enabled' => true, 'client_id' => 'google-id-from-db'], + ], + ]); + + $google = $repository->forProvider('google'); + + expect($google->get('client_id'))->toBe('google-id-from-db') + ->and($google->enabled())->toBeTrue() + // A key absent from the database still resolves from config. + ->and($google->get('hosted_domain', 'none'))->toBe('none'); +}); + +it('respects a stored global kill switch', function () { + $repository = oauth_config_repository(); + + Setting::configureSystem('oauth', ['enabled' => false, 'allow_registration' => false]); + + expect($repository->isEnabled())->toBeFalse() + ->and($repository->allowsRegistration())->toBeFalse(); +}); + +it('never lets the driver class be set from the database', function () { + $repository = oauth_config_repository(); + + Setting::configureSystem('oauth', [ + 'providers' => ['google' => ['driver' => 'Evil\\ArbitraryClass']], + ]); + + // Allowing this would be arbitrary class instantiation from a settings row. + expect($repository->forProvider('google')->all())->not->toHaveKey('driver') + ->and($repository->driverClass('google'))->toBe(GoogleDriver::class); +}); + +it('decrypts a secret stored through the admin ui', function () { + $encrypter = new OAuthConfigEncrypterFake(); + $repository = oauth_config_repository([], $encrypter); + + Setting::configureSystem('oauth', [ + 'providers' => [ + 'google' => ['client_secret_encrypted' => $encrypter->encrypt('s3cret', false)], + ], + ]); + + expect($repository->forProvider('google')->secret('client_secret'))->toBe('s3cret'); +}); + +it('accepts a plaintext secret supplied through the environment', function () { + $repository = oauth_config_repository([ + 'oauth.providers.google.client_secret' => 'from-env', + ]); + + expect($repository->forProvider('google')->secret('client_secret'))->toBe('from-env') + ->and($repository->forProvider('google')->hasSecret('client_secret'))->toBeTrue(); +}); + +it('prefers the encrypted secret over a plaintext one', function () { + $encrypter = new OAuthConfigEncrypterFake(); + $repository = oauth_config_repository(['oauth.providers.google.client_secret' => 'from-env'], $encrypter); + + Setting::configureSystem('oauth', [ + 'providers' => ['google' => ['client_secret_encrypted' => $encrypter->encrypt('from-db', false)]], + ]); + + expect($repository->forProvider('google')->secret('client_secret'))->toBe('from-db'); +}); + +it('reports a provider unconfigured when its secret will not decrypt and logs nothing sensitive', function () { + $writer = new OAuthConfigEncrypterFake('old-key'); + $repository = oauth_config_repository([], new OAuthConfigEncrypterFake('rotated-key')); + + Setting::configureSystem('oauth', [ + 'providers' => [ + 'google' => ['enabled' => true, 'client_id' => 'id', 'client_secret_encrypted' => $writer->encrypt('s3cret', false)], + ], + ]); + + $google = $repository->forProvider('google'); + + expect($google->hasSecret('client_secret'))->toBeFalse() + ->and($google->isConfigured(['client_id'], ['client_secret']))->toBeFalse() + ->and(fn () => $google->secret('client_secret'))->toThrow(OAuthProviderNotConfiguredException::class); + + $errors = array_filter(app('log')->entries, fn ($entry) => $entry[0] === 'error'); + expect($errors)->not->toBeEmpty(); + + foreach ($errors as $entry) { + expect(json_encode($entry))->not->toContain('s3cret') + ->and(json_encode($entry))->not->toContain('enc:old-key'); + } +}); + +it('never exposes a secret value to an administrator', function () { + $encrypter = new OAuthConfigEncrypterFake(); + $repository = oauth_config_repository([], $encrypter); + + Setting::configureSystem('oauth', [ + 'providers' => [ + 'google' => [ + 'enabled' => true, + 'client_id' => 'google-id', + 'client_secret_encrypted' => $encrypter->encrypt('super-secret-value', false), + ], + ], + ]); + + $admin = $repository->toAdminArray(['google' => GoogleDriver::configSchema()]); + + expect(json_encode($admin))->not->toContain('super-secret-value') + ->and($admin['providers']['google']['client_id'])->toBe('google-id') + ->and($admin['providers']['google']['client_secret']['configured'])->toBeTrue() + // Enough to tell two credentials apart while rotating, not enough to use. + ->and($admin['providers']['google']['client_secret']['hint'])->toBe('••••alue'); +}); + +it('masks a short secret entirely', function () { + $encrypter = new OAuthConfigEncrypterFake(); + $repository = oauth_config_repository([], $encrypter); + + Setting::configureSystem('oauth', [ + 'providers' => ['google' => ['client_secret_encrypted' => $encrypter->encrypt('abc', false)]], + ]); + + $admin = $repository->toAdminArray(['google' => GoogleDriver::configSchema()]); + + expect($admin['providers']['google']['client_secret']['hint'])->toBe('••••'); +}); + +it('reports an unset secret as not configured', function () { + $repository = oauth_config_repository(); + + $admin = $repository->toAdminArray(['google' => GoogleDriver::configSchema()]); + + expect($admin['providers']['google']['client_secret'])->toBe(['configured' => false, 'hint' => null]); +}); + +it('encrypts secrets on save and drops any plaintext left behind', function () { + $encrypter = new OAuthConfigEncrypterFake(); + $repository = oauth_config_repository([], $encrypter); + + Setting::configureSystem('oauth', [ + 'providers' => ['google' => ['client_secret' => 'legacy-plaintext']], + ]); + + $repository->save( + ['enabled' => true], + ['google' => ['enabled' => true, 'client_id' => 'id-1', 'client_secret' => 'brand-new']], + ['google' => ['client_secret']] + ); + + $stored = Setting::query()->where('key', 'system.oauth')->first()->value; + + expect($stored['providers']['google'])->not->toHaveKey('client_secret') + ->and($stored['providers']['google']['client_secret' . OAuthProviderConfig::ENCRYPTED_SUFFIX]) + ->toBe($encrypter->encrypt('brand-new', false)) + ->and($stored['enabled'])->toBeTrue(); +}); + +it('keeps the stored secret when an empty one is submitted', function () { + $encrypter = new OAuthConfigEncrypterFake(); + $repository = oauth_config_repository([], $encrypter); + + $repository->save([], ['google' => ['client_secret' => 'original']], ['google' => ['client_secret']]); + // The admin UI renders a masked placeholder, so a save that did not touch the + // field submits an empty string. That must not wipe the credential. + $repository->save([], ['google' => ['client_id' => 'id-2', 'client_secret' => ' ']], ['google' => ['client_secret']]); + + $google = $repository->forProvider('google'); + + expect($google->secret('client_secret'))->toBe('original') + ->and($google->get('client_id'))->toBe('id-2'); +}); + +it('does not discard another providers block on a partial save', function () { + $repository = oauth_config_repository(); + + $repository->save([], ['google' => ['client_id' => 'google-id']], []); + $repository->save([], ['apple' => ['client_id' => 'apple-id']], []); + + expect($repository->forProvider('google')->get('client_id'))->toBe('google-id') + ->and($repository->forProvider('apple')->get('client_id'))->toBe('apple-id'); +}); + +it('reports apple configured only once every part of the signing key is present', function () { + $encrypter = new OAuthConfigEncrypterFake(); + $repository = oauth_config_repository([], $encrypter); + + Setting::configureSystem('oauth', [ + 'providers' => [ + 'apple' => ['client_id' => 'io.fleetbase.console', 'team_id' => 'TEAM123'], + ], + ]); + + $apple = $repository->forProvider('apple'); + + expect($apple->isConfigured(['client_id', 'team_id', 'key_id'], ['private_key']))->toBeFalse(); + + Setting::configureSystem('oauth', [ + 'providers' => [ + 'apple' => [ + 'client_id' => 'io.fleetbase.console', + 'team_id' => 'TEAM123', + 'key_id' => 'KEY123', + 'private_key_encrypted' => $encrypter->encrypt('-----BEGIN PRIVATE KEY-----', false), + ], + ], + ]); + + expect($repository->forProvider('apple')->isConfigured(['client_id', 'team_id', 'key_id'], ['private_key']))->toBeTrue(); +}); diff --git a/tests/Unit/Auth/OAuth/OAuthProviderRegistryTest.php b/tests/Unit/Auth/OAuth/OAuthProviderRegistryTest.php new file mode 100644 index 00000000..4d9daa38 --- /dev/null +++ b/tests/Unit/Auth/OAuth/OAuthProviderRegistryTest.php @@ -0,0 +1,197 @@ +> $providers + */ + public function __construct( + private array $providers, + private bool $globallyEnabled = true, + ) { + parent::__construct(null); + } + + public function isEnabled(): bool + { + return $this->globallyEnabled; + } + + public function providerIds(): array + { + return array_keys($this->providers); + } + + public function driverClass(string $provider): ?string + { + $class = $this->providers[$provider]['driver'] ?? null; + + return is_string($class) ? $class : null; + } + + public function forProvider(string $provider): Fleetbase\Auth\OAuth\OAuthProviderConfig + { + $values = $this->providers[$provider] ?? []; + unset($values['driver']); + + return new Fleetbase\Auth\OAuth\OAuthProviderConfig($provider, $values, null); + } +} + +function oauth_registry(array $providers, bool $globallyEnabled = true): OAuthProviderRegistry +{ + bind_test_container(); + + return new OAuthProviderRegistry( + new OAuthRegistryConfigStub($providers, $globallyEnabled), + Request::create('/int/v1/auth/oauth/google/redirect', 'GET'), + new IdTokenVerifier() + ); +} + +function oauth_registry_all_providers(): array +{ + return [ + 'google' => ['driver' => GoogleDriver::class, 'enabled' => true, 'client_id' => 'g', 'client_secret' => 'gs'], + 'microsoft' => ['driver' => MicrosoftDriver::class, 'enabled' => true, 'client_id' => 'm', 'client_secret' => 'ms'], + 'github' => ['driver' => GithubDriver::class, 'enabled' => true, 'client_id' => 'h', 'client_secret' => 'hs'], + 'apple' => [ + 'driver' => AppleDriver::class, + 'enabled' => true, + 'client_id' => 'io.fleetbase.console', + 'team_id' => 'TEAM', + 'key_id' => 'KEY', + 'private_key' => '-----BEGIN PRIVATE KEY-----', + ], + ]; +} + +it('lists every defined provider whether or not it is configured', function () { + $registry = oauth_registry(['google' => ['driver' => GoogleDriver::class]]); + + expect($registry->ids())->toBe(['google']) + ->and($registry->has('google'))->toBeTrue() + ->and($registry->has('nope'))->toBeFalse(); +}); + +it('resolves a driver instance for a known provider', function () { + $registry = oauth_registry(oauth_registry_all_providers()); + + expect($registry->driver('google'))->toBeInstanceOf(GoogleDriver::class) + ->and($registry->driver('microsoft'))->toBeInstanceOf(MicrosoftDriver::class) + ->and($registry->driver('github'))->toBeInstanceOf(GithubDriver::class) + ->and($registry->driver('apple'))->toBeInstanceOf(AppleDriver::class); +}); + +it('throws for an unknown provider', function () { + $registry = oauth_registry(['google' => ['driver' => GoogleDriver::class]]); + + expect(fn () => $registry->driver('nope'))->toThrow(UnknownOAuthProviderException::class, 'unknown_provider'); +}); + +it('refuses a driver class that is not an oauth driver', function () { + // Defence in depth behind OAuthConfigRepository already stripping `driver` from + // stored settings: even if a class name did reach the registry it must not be + // instantiated unless it implements the contract. + $registry = oauth_registry(['evil' => ['driver' => stdClass::class]]); + + expect($registry->has('evil'))->toBeFalse() + ->and(fn () => $registry->driver('evil'))->toThrow(UnknownOAuthProviderException::class); +}); + +it('treats a missing class name as an unknown provider', function () { + $registry = oauth_registry(['ghost' => ['driver' => 'Fleetbase\\Nope\\DoesNotExist']]); + + expect($registry->has('ghost'))->toBeFalse(); +}); + +it('reports only providers that are both enabled and configured', function () { + $registry = oauth_registry([ + // enabled and complete + 'google' => ['driver' => GoogleDriver::class, 'enabled' => true, 'client_id' => 'g', 'client_secret' => 'gs'], + // configured but switched off + 'github' => ['driver' => GithubDriver::class, 'enabled' => false, 'client_id' => 'h', 'client_secret' => 'hs'], + // switched on but missing its secret — excluded rather than rendered as a + // broken button + 'microsoft' => ['driver' => MicrosoftDriver::class, 'enabled' => true, 'client_id' => 'm'], + ]); + + expect(array_keys($registry->enabled()))->toBe(['google']); +}); + +it('reports nothing when oauth is globally disabled', function () { + $registry = oauth_registry(oauth_registry_all_providers(), false); + + expect($registry->enabled())->toBe([]) + ->and($registry->toDiscoveryArray())->toBe([]); +}); + +it('exposes only what the console needs to draw a button', function () { + $registry = oauth_registry(oauth_registry_all_providers()); + + $discovery = $registry->toDiscoveryArray(); + + expect($discovery)->toHaveCount(4) + ->and($discovery[0])->toBe(['id' => 'google', 'label' => 'Google', 'icon' => 'google']) + // No client ids, no secrets, no redirect URIs. + ->and(json_encode($discovery))->not->toContain('client_id') + ->and(json_encode($discovery))->not->toContain('gs') + ->and(json_encode($discovery))->not->toContain('BEGIN PRIVATE KEY'); + + foreach ($discovery as $provider) { + expect(array_keys($provider))->toBe(['id', 'label', 'icon']); + } +}); + +it('keeps provider identifiers stable', function () { + // These land in routes, settings keys and the oauth_identities.provider column. + // Changing one for a shipped provider orphans every existing linked identity. + expect(GoogleDriver::id())->toBe('google') + ->and(MicrosoftDriver::id())->toBe('microsoft') + ->and(GithubDriver::id())->toBe('github') + ->and(AppleDriver::id())->toBe('apple'); +}); + +it('publishes a config schema for every provider', function () { + $registry = oauth_registry(oauth_registry_all_providers()); + + $schemas = $registry->schemas(); + + expect(array_keys($schemas))->toBe(['google', 'microsoft', 'github', 'apple']) + ->and($schemas['google']['client_secret']['secret'])->toBeTrue() + ->and($schemas['apple']['private_key']['secret'])->toBeTrue() + ->and($schemas['google']['client_id'])->not->toHaveKey('secret'); + + // Every schema must declare a label, since the admin form renders from it. + foreach ($schemas as $provider => $schema) { + foreach ($schema as $field => $definition) { + expect($definition)->toHaveKey('label'); + } + } +}); + +it('marks apple as needing a form post callback and the others not', function () { + $registry = oauth_registry(oauth_registry_all_providers()); + + // Apple requires response_mode=form_post whenever name/email scopes are asked + // for, which is why the callback route has to accept POST as well as GET. + expect($registry->driver('apple')->usesFormPostCallback())->toBeTrue() + ->and($registry->driver('google')->usesFormPostCallback())->toBeFalse() + ->and($registry->driver('microsoft')->usesFormPostCallback())->toBeFalse() + ->and($registry->driver('github')->usesFormPostCallback())->toBeFalse(); +}); diff --git a/tests/Unit/Auth/OAuth/ProviderProfileNormalizationTest.php b/tests/Unit/Auth/OAuth/ProviderProfileNormalizationTest.php new file mode 100644 index 00000000..20067a4d --- /dev/null +++ b/tests/Unit/Auth/OAuth/ProviderProfileNormalizationTest.php @@ -0,0 +1,320 @@ + $values + */ +function normalization_driver(string $class, array $values = []): object +{ + bind_test_container(); + + return new $class( + new OAuthProviderConfig($class::id(), $values, null), + Request::create('/int/v1/auth/oauth/callback', 'GET'), + new IdTokenVerifier() + ); +} + +/** + * @param array $raw + * @param array $mapped + */ +function normalization_user(array $raw, array $mapped = []): SocialiteUser +{ + return (new SocialiteUser())->setRaw($raw)->map($mapped); +} + +/** + * normalize() is protected; Closure::call binds to the driver so the real method is + * exercised rather than a test-only subclass that could drift from it. + * + * @param array $callbackPayload + */ +function normalization_run(object $driver, SocialiteUser $user, array $callbackPayload = []): OAuthUserProfile +{ + return (fn () => $this->normalize($user, $callbackPayload))->call($driver); +} + +// --------------------------------------------------------------------------- +// Google +// --------------------------------------------------------------------------- + +it('trusts a google address only when google says it verified it', function (mixed $claim, bool $expected) { + $driver = normalization_driver(GoogleDriver::class); + + $profile = normalization_run($driver, normalization_user( + ['sub' => '1091', 'email' => 'ada@example.com', 'email_verified' => $claim], + ['id' => '1091', 'email' => 'ada@example.com', 'name' => 'Ada'] + )); + + expect($profile->emailVerified)->toBe($expected); +})->with([ + 'boolean true' => [true, true], + 'boolean false' => [false, false], + // The string "true" is NOT a verification for Google — only Apple sends that + // shape, and accepting it here would widen the rule for no reason. + 'string true' => ['true', false], + 'absent' => [null, false], + 'integer one' => [1, false], +]); + +it('maps a google profile', function () { + $driver = normalization_driver(GoogleDriver::class); + + $profile = normalization_run($driver, normalization_user( + ['sub' => '1091', 'email_verified' => true, 'hd' => 'fleetbase.io', 'locale' => 'en'], + ['id' => '1091', 'email' => 'ada@fleetbase.io', 'name' => 'Ada Lovelace', 'avatar' => 'https://x/y.png'] + )); + + expect($profile->provider)->toBe('google') + ->and($profile->providerUserId)->toBe('1091') + ->and($profile->email)->toBe('ada@fleetbase.io') + ->and($profile->name)->toBe('Ada Lovelace') + ->and($profile->avatar)->toBe('https://x/y.png') + ->and($profile->meta)->toBe(['hd' => 'fleetbase.io', 'locale' => 'en']); +}); + +it('enforces a google workspace domain against the verified claim', function () { + $driver = normalization_driver(GoogleDriver::class, ['hosted_domain' => 'fleetbase.io']); + + // Google documents that the `hd` authorization parameter is a hint, not a + // guarantee — without this server-side check a user outside the domain can still + // complete the flow. + expect(fn () => normalization_run($driver, normalization_user( + ['sub' => '1', 'email_verified' => true, 'hd' => 'evil.example'], + ['id' => '1', 'email' => 'mallory@evil.example'] + )))->toThrow(OAuthException::class, 'hosted_domain_mismatch'); + + expect(fn () => normalization_run($driver, normalization_user( + ['sub' => '1', 'email_verified' => true], + ['id' => '1', 'email' => 'mallory@gmail.com'] + )))->toThrow(OAuthException::class, 'hosted_domain_mismatch'); + + $allowed = normalization_run($driver, normalization_user( + ['sub' => '1', 'email_verified' => true, 'hd' => 'fleetbase.io'], + ['id' => '1', 'email' => 'ada@fleetbase.io'] + )); + + expect($allowed->email)->toBe('ada@fleetbase.io'); +}); + +// --------------------------------------------------------------------------- +// Microsoft +// --------------------------------------------------------------------------- + +it('trusts a microsoft address when the domain owner is verified', function () { + $driver = normalization_driver(MicrosoftDriver::class, ['tenant' => 'common']); + + $profile = normalization_run($driver, normalization_user( + ['oid' => 'oid-1', 'tid' => 'some-work-tenant', 'xms_edov' => true, 'email' => 'ada@corp.example'], + ['id' => 'oid-1', 'email' => 'ada@corp.example', 'name' => 'Ada'] + )); + + expect($profile->emailVerified)->toBeTrue() + ->and($profile->providerUserId)->toBe('oid-1') + ->and($profile->meta['tid'])->toBe('some-work-tenant'); +}); + +it('does not trust a multi tenant microsoft address without the domain owner claim', function () { + $driver = normalization_driver(MicrosoftDriver::class, ['tenant' => 'common']); + + // Anyone can stand up an Entra tenant, and a personal account holder can set an + // arbitrary preferred_username. On 'common' only xms_edov is evidence. + $work = normalization_run($driver, normalization_user( + ['oid' => 'oid-1', 'tid' => 'attacker-tenant', 'email' => 'ceo@victim.example'], + ['id' => 'oid-1', 'email' => 'ceo@victim.example'] + )); + + $personal = normalization_run($driver, normalization_user( + ['oid' => 'oid-2', 'tid' => MicrosoftDriver::MSA_CONSUMER_TENANT, 'preferred_username' => 'ceo@victim.example'], + ['id' => 'oid-2', 'email' => 'ceo@victim.example'] + )); + + expect($work->emailVerified)->toBeFalse() + ->and($personal->emailVerified)->toBeFalse(); +}); + +it('trusts a single tenant microsoft address from the configured directory', function () { + $driver = normalization_driver(MicrosoftDriver::class, ['tenant' => 'CONTOSO-TENANT-ID']); + + // The operator controls this directory, so its addresses are as trustworthy as + // the operator's own user list. + $matching = normalization_run($driver, normalization_user( + ['oid' => 'oid-1', 'tid' => 'contoso-tenant-id', 'email' => 'ada@contoso.example'], + ['id' => 'oid-1', 'email' => 'ada@contoso.example'] + )); + + $other = normalization_run($driver, normalization_user( + ['oid' => 'oid-2', 'tid' => 'another-tenant', 'email' => 'mallory@evil.example'], + ['id' => 'oid-2', 'email' => 'mallory@evil.example'] + )); + + expect($matching->emailVerified)->toBeTrue() + ->and($other->emailVerified)->toBeFalse(); +}); + +it('never trusts a personal microsoft account even on a single tenant deployment', function () { + $driver = normalization_driver(MicrosoftDriver::class, ['tenant' => MicrosoftDriver::MSA_CONSUMER_TENANT]); + + $profile = normalization_run($driver, normalization_user( + ['oid' => 'oid-1', 'tid' => MicrosoftDriver::MSA_CONSUMER_TENANT, 'email' => 'someone@outlook.com'], + ['id' => 'oid-1', 'email' => 'someone@outlook.com'] + )); + + expect($profile->emailVerified)->toBeFalse(); +}); + +it('falls back to preferred username when microsoft sends no email claim', function () { + $driver = normalization_driver(MicrosoftDriver::class, ['tenant' => 'common']); + + $user = (new Fleetbase\Auth\OAuth\Socialite\MicrosoftProvider( + Request::create('/'), 'client', 'secret', 'https://api.example/callback' + )); + + $mapped = (fn () => $this->mapUserToObject([ + 'oid' => 'oid-1', + 'tid' => 'tenant', + 'preferred_username' => 'ada@corp.example', + 'name' => 'Ada', + ]))->call($user); + + expect($mapped->getEmail())->toBe('ada@corp.example') + ->and($mapped->getId())->toBe('oid-1'); +}); + +// --------------------------------------------------------------------------- +// GitHub +// --------------------------------------------------------------------------- + +it('treats a present github address as verified and an absent one as unusable', function () { + $driver = normalization_driver(GithubDriver::class); + + // Socialite's GithubProvider only returns an address when GitHub reports it + // primary AND verified, and nulls it otherwise — so presence IS the flag. + $verified = normalization_run($driver, normalization_user( + ['id' => 42, 'login' => 'ada'], + ['id' => 42, 'email' => 'ada@example.com', 'name' => 'Ada', 'nickname' => 'ada'] + )); + + $unverified = normalization_run($driver, normalization_user( + ['id' => 43, 'login' => 'mallory'], + ['id' => 43, 'email' => null, 'name' => 'Mallory', 'nickname' => 'mallory'] + )); + + expect($verified->emailVerified)->toBeTrue() + ->and($verified->providerUserId)->toBe('42') + ->and($verified->meta)->toBe(['login' => 'ada']) + ->and($unverified->emailVerified)->toBeFalse() + ->and($unverified->email)->toBeNull(); +}); + +it('does not treat an empty github address as verified', function () { + $driver = normalization_driver(GithubDriver::class); + + $profile = normalization_run($driver, normalization_user(['id' => 44], ['id' => 44, 'email' => ''])); + + expect($profile->emailVerified)->toBeFalse(); +}); + +// --------------------------------------------------------------------------- +// Apple +// --------------------------------------------------------------------------- + +it('accepts both shapes apple uses for email_verified', function (mixed $claim, bool $expected) { + $driver = normalization_driver(AppleDriver::class); + + $profile = normalization_run($driver, normalization_user( + ['sub' => 'apple-sub', 'email' => 'ada@example.com', 'email_verified' => $claim], + ['id' => 'apple-sub', 'email' => 'ada@example.com'] + )); + + expect($profile->emailVerified)->toBe($expected); +})->with([ + 'boolean true' => [true, true], + 'boolean false' => [false, false], + // Apple sends the string form in some flows; both mean verified. + 'string true' => ['true', true], + 'string false' => ['false', false], + 'absent' => [null, false], +]); + +it('flags an apple private relay alias', function () { + $driver = normalization_driver(AppleDriver::class); + + // A relay alias is unique per application, so it can never match an address + // Fleetbase already holds and must not be used for an account-exists check. + $relay = normalization_run($driver, normalization_user( + ['sub' => 'apple-sub', 'email' => 'ABC123@PrivateRelay.AppleID.com', 'email_verified' => true], + ['id' => 'apple-sub', 'email' => 'ABC123@PrivateRelay.AppleID.com'] + )); + + $real = normalization_run($driver, normalization_user( + ['sub' => 'apple-sub-2', 'email' => 'ada@example.com', 'email_verified' => true], + ['id' => 'apple-sub-2', 'email' => 'ada@example.com'] + )); + + expect($relay->meta('private_relay'))->toBeTrue() + ->and($real->meta)->toBe([]); +}); + +it('lifts the apple display name out of the first authorization callback', function () { + $driver = normalization_driver(AppleDriver::class); + + // Apple sends the name exactly once, in the body of the FIRST authorization, and + // never again — if it is not captured here it is gone for good. + $fromJson = normalization_run($driver, normalization_user( + ['sub' => 'apple-sub', 'email_verified' => true], + ['id' => 'apple-sub', 'email' => 'ada@example.com'] + ), ['user' => json_encode(['name' => ['firstName' => 'Ada', 'lastName' => 'Lovelace']])]); + + $fromArray = normalization_run($driver, normalization_user( + ['sub' => 'apple-sub', 'email_verified' => true], + ['id' => 'apple-sub', 'email' => 'ada@example.com'] + ), ['user' => ['name' => ['firstName' => 'Ada', 'lastName' => 'Lovelace']]]); + + expect($fromJson->name)->toBe('Ada Lovelace') + ->and($fromArray->name)->toBe('Ada Lovelace'); +}); + +it('tolerates every shape of missing apple name', function (mixed $payload) { + $driver = normalization_driver(AppleDriver::class); + + $profile = normalization_run($driver, normalization_user( + ['sub' => 'apple-sub', 'email_verified' => true], + ['id' => 'apple-sub', 'email' => 'ada@example.com'] + ), $payload === null ? [] : ['user' => $payload]); + + expect($profile->name)->toBeNull(); +})->with([ + 'no user field' => [null], + 'malformed json' => ['{not json'], + 'no name key' => ['{"email":"ada@example.com"}'], + 'empty name parts' => ['{"name":{"firstName":"","lastName":""}}'], + 'name not an object' => ['{"name":"Ada"}'], +]); + +it('drops the raw token response from anything persisted or serialized', function () { + $profile = (new OAuthUserProfile('google', '1091', 'ada@example.com', true, 'Ada')) + ->withRawTokenResponse(['access_token' => 'at-secret', 'refresh_token' => 'rt-secret', 'id_token' => 'jwt']); + + // Fleetbase never stores provider tokens; a database compromise must yield no + // live provider credentials. + expect(json_encode($profile))->not->toContain('at-secret') + ->and(json_encode($profile))->not->toContain('rt-secret') + ->and(json_encode($profile->toIdentityAttributes()))->not->toContain('at-secret') + ->and($profile->toIdentityAttributes())->not->toHaveKey('rawTokenResponse') + // …but it is still readable in memory for the driver that needs id_token claims. + ->and($profile->rawTokenResponse['id_token'])->toBe('jwt'); +}); diff --git a/tests/Unit/Auth/OAuth/ServerSidePkceTest.php b/tests/Unit/Auth/OAuth/ServerSidePkceTest.php new file mode 100644 index 00000000..932513e9 --- /dev/null +++ b/tests/Unit/Auth/OAuth/ServerSidePkceTest.php @@ -0,0 +1,199 @@ + 'client-id-123', + 'client_secret' => 'client-secret-456', + ], $values), null), + // The request is never read for state or PKCE — that is the point of the + // trait — but Socialite's constructor requires one. + Request::create('/int/v1/auth/oauth/google/redirect', 'GET'), + new IdTokenVerifier() + ); +} + +/** + * @return array + */ +function pkce_query(string $url): array +{ + parse_str((string) parse_url($url, PHP_URL_QUERY), $query); + + /** @var array $query */ + return $query; +} + +/** + * A real P-256 key. Apple's assertion is genuinely signed on the exchange leg, so a + * placeholder string would fail in the signer rather than exercise the trait. + */ +function pkce_apple_private_key(): string +{ + $resource = openssl_pkey_new([ + 'curve_name' => 'prime256v1', + 'private_key_type' => OPENSSL_KEYTYPE_EC, + ]); + + openssl_pkey_export($resource, $pem); + + return (string) $pem; +} + +it('builds an authorization url carrying our state and a pkce challenge', function () { + $driver = pkce_driver(GoogleDriver::class); + + $url = $driver->authorizationUrl('state-abc', 'verifier-xyz', PKCE_REDIRECT); + $query = pkce_query($url); + + expect($url)->toStartWith('https://accounts.google.com/o/oauth2/auth?') + ->and($query['client_id'])->toBe('client-id-123') + ->and($query['redirect_uri'])->toBe(PKCE_REDIRECT) + ->and($query['response_type'])->toBe('code') + // Socialite suppresses its own session-backed state in stateless mode; ours + // is added back by the trait and validated against oauth_states. + ->and($query['state'])->toBe('state-abc') + ->and($query['code_challenge_method'])->toBe('S256'); +}); + +it('derives the challenge as rfc 7636 s256 of the verifier', function () { + $driver = pkce_driver(GoogleDriver::class); + + $query = pkce_query($driver->authorizationUrl('state-abc', 'verifier-xyz', PKCE_REDIRECT)); + + $expected = rtrim(strtr(base64_encode(hash('sha256', 'verifier-xyz', true)), '+/', '-_'), '='); + + expect($query['code_challenge'])->toBe($expected) + // base64url: no padding and no + or / characters. + ->and($query['code_challenge'])->not->toContain('=') + ->and($query['code_challenge'])->not->toContain('+') + ->and($query['code_challenge'])->not->toContain('/') + // The verifier itself must never appear in a URL the browser is handed. + ->and($query)->not->toContain('verifier-xyz'); +}); + +it('never puts the verifier or the client secret in the authorization url', function () { + $driver = pkce_driver(GoogleDriver::class); + + $url = $driver->authorizationUrl('state-abc', 'verifier-xyz', PKCE_REDIRECT); + + expect($url)->not->toContain('verifier-xyz') + ->and($url)->not->toContain('client-secret-456') + ->and($url)->not->toContain('code_verifier'); +}); + +it('sends the verifier to the token endpoint and keeps authorization only params out', function () { + $driver = pkce_driver(AppleDriver::class, [ + 'client_id' => 'io.fleetbase.console', + 'team_id' => 'TEAM', + 'key_id' => 'KEY', + 'private_key' => pkce_apple_private_key(), + ]); + + // Build the provider the way the driver does, then read the token fields it + // would POST. Socialite merges $this->parameters into BOTH the authorization + // request and the token request, so without the trait's filtering Apple's + // response_mode would be posted to the token endpoint too. + // The exchange leg needs the real client secret, which for Apple is minted and cached. + app()->instance('cache', new class { + private array $values = []; + + public function remember(string $key, mixed $ttl, Closure $callback): mixed + { + return $this->values[$key] ??= $callback(); + } + }); + Illuminate\Support\Facades\Facade::clearResolvedInstance('cache'); + + $provider = (fn () => $this->build(PKCE_REDIRECT))->call($driver); + $provider->withServerSidePkce('verifier-xyz')->with(['response_mode' => 'form_post', 'prompt' => 'consent']); + + $fields = (fn () => $this->getTokenFields('auth-code-1'))->call($provider); + + expect($fields['code'])->toBe('auth-code-1') + ->and($fields['grant_type'])->toBe('authorization_code') + ->and($fields['code_verifier'])->toBe('verifier-xyz') + ->and($fields['redirect_uri'])->toBe(PKCE_REDIRECT) + ->and($fields)->not->toHaveKey('response_mode') + ->and($fields)->not->toHaveKey('prompt') + ->and($fields)->not->toHaveKey('state'); +}); + +it('asks apple for a form post response', function () { + $driver = pkce_driver(AppleDriver::class, [ + 'client_id' => 'io.fleetbase.console', + 'team_id' => 'TEAM', + 'key_id' => 'KEY', + 'private_key' => 'unused-here', + ]); + + $parameters = (fn () => $this->additionalAuthorizationParameters())->call($driver); + + // Apple rejects the name/email scopes unless the response is form-posted. + expect($parameters)->toBe(['response_mode' => 'form_post']); +}); + +it('requests the scopes each provider needs', function (string $class, array $expected) { + $driver = pkce_driver($class, [ + 'client_id' => 'id', + 'team_id' => 'TEAM', + 'key_id' => 'KEY', + 'private_key' => 'unused-here', + ]); + + expect((fn () => $this->scopes())->call($driver))->toBe($expected); +})->with([ + 'google' => [GoogleDriver::class, ['openid', 'email', 'profile']], + 'microsoft' => [MicrosoftDriver::class, ['openid', 'profile', 'email']], + // read:user for the profile, user:email because /user only exposes the public + // profile address, which GitHub does not vouch for. + 'github' => [GithubDriver::class, ['read:user', 'user:email']], + 'apple' => [AppleDriver::class, ['name', 'email']], +]); + +it('targets the configured microsoft tenant', function () { + $single = pkce_driver(MicrosoftDriver::class, ['tenant' => 'contoso.onmicrosoft.com']); + $multi = pkce_driver(MicrosoftDriver::class, ['tenant' => 'common']); + + expect($single->authorizationUrl('s', 'v', PKCE_REDIRECT)) + ->toStartWith('https://login.microsoftonline.com/contoso.onmicrosoft.com/oauth2/v2.0/authorize?') + ->and($multi->authorizationUrl('s', 'v', PKCE_REDIRECT)) + ->toStartWith('https://login.microsoftonline.com/common/oauth2/v2.0/authorize?'); +}); + +it('sends google to its account chooser and honours a workspace hint', function () { + $plain = pkce_driver(GoogleDriver::class); + $workspace = pkce_driver(GoogleDriver::class, ['hosted_domain' => 'fleetbase.io']); + + expect(pkce_query($plain->authorizationUrl('s', 'v', PKCE_REDIRECT))['prompt'])->toBe('select_account') + ->and(pkce_query($workspace->authorizationUrl('s', 'v', PKCE_REDIRECT))['hd'])->toBe('fleetbase.io') + ->and(pkce_query($plain->authorizationUrl('s', 'v', PKCE_REDIRECT)))->not->toHaveKey('hd'); +}); + +it('uses the apple authorization endpoint', function () { + $driver = pkce_driver(AppleDriver::class, [ + 'client_id' => 'io.fleetbase.console', + 'team_id' => 'TEAM', + 'key_id' => 'KEY', + 'private_key' => 'unused-here', + ]); + + $url = $driver->authorizationUrl('state-abc', 'verifier-xyz', PKCE_REDIRECT); + + expect($url)->toStartWith('https://appleid.apple.com/auth/authorize?') + ->and(pkce_query($url)['response_mode'])->toBe('form_post') + ->and(pkce_query($url)['scope'])->toBe('name email'); +}); From a291fe6314602041840abf8c32f72b2d371bd712 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sun, 20 Sep 2026 19:16:05 +0800 Subject: [PATCH 05/30] feat(oauth): add the sign-in endpoints providers / redirect / callback / exchange, registered through the existing fleetbaseAuthRoutes() callback seam so they inherit the same ThrottleRequests group as login. Literal segments are registered before the {provider} wildcard. The callback accepts GET and POST: Apple form-posts whenever the name/email scopes are requested. The handoff code is returned in the URL fragment, never the query string, so it is not sent to the console's web server and cannot reach an access log or a Referer header. exchange reproduces AuthController::login's gate order exactly (customer -> 2FA -> not_verified -> token), so an OAuth sign-in cannot reach a state a password sign-in could not. An existing user is never resolved by email address: an unknown identity whose verified address collides with an account returns link_required rather than signing anyone in. RoutesContractTest's local macro mirrored the real one but dropped the two extension callbacks, so it would have asserted nothing about these routes. --- .../Internal/v1/OAuthController.php | 264 +++++ .../Internal/OAuthExchangeRequest.php | 36 + .../Internal/OAuthRedirectRequest.php | 59 + src/Providers/CoreServiceProvider.php | 1 + src/Services/OAuth/OAuthFlowService.php | 271 +++++ src/routes.php | 16 +- tests/Unit/Http/OAuthControllerTest.php | 1055 +++++++++++++++++ tests/Unit/RoutesContractTest.php | 72 +- 8 files changed, 1769 insertions(+), 5 deletions(-) create mode 100644 src/Http/Controllers/Internal/v1/OAuthController.php create mode 100644 src/Http/Requests/Internal/OAuthExchangeRequest.php create mode 100644 src/Http/Requests/Internal/OAuthRedirectRequest.php create mode 100644 src/Services/OAuth/OAuthFlowService.php create mode 100644 tests/Unit/Http/OAuthControllerTest.php diff --git a/src/Http/Controllers/Internal/v1/OAuthController.php b/src/Http/Controllers/Internal/v1/OAuthController.php new file mode 100644 index 00000000..ac0b2fa0 --- /dev/null +++ b/src/Http/Controllers/Internal/v1/OAuthController.php @@ -0,0 +1,264 @@ +json(['providers' => $this->registry->toDiscoveryArray()]); + } + + /** + * Begin the handshake: 302 to the provider. + */ + public function redirect(OAuthRedirectRequest $request, string $provider) + { + if ($limited = $this->rateLimit('redirect:' . $request->ip(), 30)) { + return $limited; + } + + try { + $url = $this->flow->startAuthorization( + $provider, + $request->intent(), + $request->returnTo(), + $request->ip() + ); + } catch (UnknownOAuthProviderException $e) { + return response()->error('Unknown sign-in provider.', 404, ['code' => 'unknown_provider']); + } catch (OAuthException $e) { + return response()->error('That sign-in provider is not available.', 403, ['code' => $e->getMessage()]); + } + + return redirect()->away($url); + } + + /** + * The provider lands here. + * + * Accepts GET and POST: Apple requires response_mode=form_post whenever the + * name/email scopes are requested, so its callback arrives as a cross-site POST. + * + * Always redirects to the console — a failure becomes `#error=` there + * rather than an API error page, because this is a top-level browser navigation. + */ + public function callback(Request $request, string $provider) + { + return redirect()->away($this->flow->handleCallback($provider, $request)); + } + + /** + * Trade a one-time handoff code for one of four outcomes. + * + * Mirrors AuthController::login's gate order exactly, so an OAuth sign-in can + * never reach a state a password sign-in could not. + */ + public function exchange(OAuthExchangeRequest $request) + { + if ($limited = $this->rateLimit('exchange:' . $request->ip(), 20)) { + return $limited; + } + + try { + $consumed = $this->states->consume(OAuthState::PURPOSE_HANDOFF, $request->code(), $request->ip()); + } catch (OAuthStateException $e) { + // Unknown, expired, already redeemed, or presented for another purpose — + // deliberately indistinguishable from one another. + return response()->error('This sign-in session is no longer valid.', 400, ['code' => 'invalid_exchange_code']); + } + + /** @var OAuthState $state */ + $state = $consumed['state']; + $profile = $this->flow->profileFromPayload($consumed['payload']); + + if ($profile->providerUserId === '') { + return response()->error('This sign-in session is no longer valid.', 400, ['code' => 'invalid_exchange_code']); + } + + // Re-checked at redemption, not just at redirect: an administrator may have + // switched the provider off while this handshake was in flight. + if (!$this->providerIsEnabled($profile->provider)) { + return response()->error('That sign-in provider is not available.', 403, ['code' => 'provider_disabled']); + } + + $user = $this->identities->findUserByProfile($profile); + + if ($user instanceof User) { + return $this->authenticate($user, $profile, $state); + } + + return $this->registrationOutcome($profile); + } + + /** + * An identity we already know: run the same gates password login runs. + */ + protected function authenticate(User $user, OAuthUserProfile $profile, OAuthState $state) + { + // AuthController::login:86-88 + if ($user->type === 'customer') { + return response()->error('Customer accounts must sign in through the customer portal.', 403, ['code' => 'customer_login_not_allowed']); + } + + // AuthController::login:105-112 — OAuth does not exempt anyone from 2FA. + if (TwoFactorAuth::isEnabled($user)) { + return response()->json([ + 'twoFaSession' => TwoFactorAuth::start($user), + 'isEnabled' => true, + ]); + } + + // AuthController::login:114-116. An OAuth sign-in does not by itself verify a + // Fleetbase account; promoting email_verified_at happens only when a verified + // provider address matches, and that is part of linking, not of signing in. + if ($user->isNotVerified() && $user->isNotAdmin()) { + return response()->error('User is not verified.', 400, ['code' => 'not_verified']); + } + + $identity = $this->identities->findByProfile($profile); + + if ($identity !== null) { + $this->identities->touchLogin($identity, $profile); + } + + $this->states->attachUser($state, (string) $user->uuid); + + $user->updateLastLogin(); + $token = $user->createToken($user->uuid); + + return response()->json(['token' => $token->plainTextToken, 'type' => $user->getType()]); + } + + /** + * An identity we do not know: either start a signup or tell the user to link. + */ + protected function registrationOutcome(OAuthUserProfile $profile) + { + if (!$this->config->allowsRegistration()) { + return response()->error('Sign-ups are not available.', 403, ['code' => 'registration_disabled']); + } + + if ($this->collidesWithExistingAccount($profile)) { + // The address belongs to an existing Fleetbase account. We do NOT sign + // them in — that would be account takeover by email. They must sign in + // with an existing method and link this provider from their account. + return response()->error( + 'An account with this email already exists. Sign in and link this provider from your account settings.', + 409, + ['code' => 'link_required'] + ); + } + + $intent = $this->states->issue( + OAuthState::PURPOSE_REGISTRATION_INTENT, + ['profile' => $profile->jsonSerialize()], + $this->config->ttl('registration_intent', 900), + $profile->provider, + OAuthFlowService::INTENT_SIGNUP + ); + + return response()->json([ + 'status' => 'registration_required', + 'intent' => $intent, + 'prefill' => [ + 'name' => $profile->name, + 'email' => $profile->email, + 'email_verified' => $profile->emailVerified, + ], + ]); + } + + /** + * Whether this identity's address already belongs to a Fleetbase account. + * + * Only asked when the provider VOUCHED for the address. An unverified address is + * not evidence of anything, and letting it produce `link_required` would leak + * whether an arbitrary email has a Fleetbase account. + * + * Apple private-relay aliases are excluded: they are unique per application, so + * one can never match an address Fleetbase already holds. + */ + protected function collidesWithExistingAccount(OAuthUserProfile $profile): bool + { + if (!$profile->hasVerifiedEmail() || $profile->meta('private_relay') === true) { + return false; + } + + return User::where('email', $profile->email)->exists(); + } + + protected function providerIsEnabled(string $provider): bool + { + try { + return $this->registry->driver($provider)->isEnabled(); + } catch (UnknownOAuthProviderException $e) { + return false; + } + } + + /** + * An explicit limiter on top of the route's ThrottleRequests. + * + * Necessary because Fleetbase\Http\Middleware\ThrottleRequests overwrites + * maxAttempts/decayMinutes from config (ThrottleRequests.php:55-59), so route + * parameters are silently ignored and the effective ceiling is the global + * api.throttle limit. This is also independent of api.throttle.enabled, which + * can switch that middleware off entirely. + */ + protected function rateLimit(string $key, int $maxAttempts) + { + $limiterKey = 'oauth:' . sha1($key); + + if (RateLimiter::tooManyAttempts($limiterKey, $maxAttempts)) { + return response()->error('Too many attempts. Please try again shortly.', 429, ['code' => 'rate_limited']); + } + + RateLimiter::hit($limiterKey, 60); + + return null; + } +} diff --git a/src/Http/Requests/Internal/OAuthExchangeRequest.php b/src/Http/Requests/Internal/OAuthExchangeRequest.php new file mode 100644 index 00000000..70c43a3e --- /dev/null +++ b/src/Http/Requests/Internal/OAuthExchangeRequest.php @@ -0,0 +1,36 @@ +|string> + */ + public function rules(): array + { + return [ + // Handoff codes are a fixed 64 characters; anything else is not one, and + // rejecting on shape keeps malformed input away from a database lookup. + 'code' => ['required', 'string', 'size:64'], + ]; + } + + public function code(): string + { + return (string) $this->input('code'); + } +} diff --git a/src/Http/Requests/Internal/OAuthRedirectRequest.php b/src/Http/Requests/Internal/OAuthRedirectRequest.php new file mode 100644 index 00000000..0ecf4823 --- /dev/null +++ b/src/Http/Requests/Internal/OAuthRedirectRequest.php @@ -0,0 +1,59 @@ +|string> + */ + public function rules(): array + { + return [ + 'intent' => ['nullable', 'string', 'in:' . OAuthFlowService::INTENT_LOGIN . ',' . OAuthFlowService::INTENT_SIGNUP], + // Validated as a shape here and re-sanitized in OAuthFlowService before + // it is stored or reflected — the regex is the first gate, not the only one. + 'return_to' => ['nullable', 'string', 'max:512', 'regex:' . OAuthFlowService::RETURN_PATH_PATTERN], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'return_to.regex' => 'The return path must be a relative path within the console.', + ]; + } + + public function intent(): string + { + $intent = $this->input('intent'); + + return $intent === OAuthFlowService::INTENT_SIGNUP + ? OAuthFlowService::INTENT_SIGNUP + : OAuthFlowService::INTENT_LOGIN; + } + + public function returnTo(): ?string + { + $returnTo = $this->input('return_to'); + + return is_string($returnTo) && $returnTo !== '' ? $returnTo : null; + } +} diff --git a/src/Providers/CoreServiceProvider.php b/src/Providers/CoreServiceProvider.php index 69b02075..4953f4b8 100644 --- a/src/Providers/CoreServiceProvider.php +++ b/src/Providers/CoreServiceProvider.php @@ -155,6 +155,7 @@ public function register() $this->app->scoped(\Fleetbase\Services\OAuth\OAuthIdentityService::class); $this->app->scoped(\Fleetbase\Services\OAuth\OAuthConfigRepository::class); $this->app->scoped(\Fleetbase\Auth\OAuth\OAuthProviderRegistry::class); + $this->app->scoped(\Fleetbase\Services\OAuth\OAuthFlowService::class); // Stateless collaborators — safe to share for the lifetime of the worker. $this->app->singleton(\Fleetbase\Auth\OAuth\IdTokenVerifier::class); diff --git a/src/Services/OAuth/OAuthFlowService.php b/src/Services/OAuth/OAuthFlowService.php new file mode 100644 index 00000000..395cfb34 --- /dev/null +++ b/src/Services/OAuth/OAuthFlowService.php @@ -0,0 +1,271 @@ +registry->driver($provider); + + if (!$driver->isEnabled()) { + throw new OAuthException('provider_disabled'); + } + + // 96 characters of CSPRNG output, comfortably inside RFC 7636's 43-128 range. + $codeVerifier = $this->generateCodeVerifier(); + $redirectUri = $this->redirectUri($provider); + + $state = $this->states->issue( + OAuthState::PURPOSE_AUTHORIZATION, + [ + 'code_verifier' => $codeVerifier, + 'return_to' => $this->sanitizeReturnPath($returnTo), + 'redirect_uri' => $redirectUri, + ], + $this->config->ttl('authorization', 600), + $provider, + $intent, + $userUuid, + $ip + ); + + return $driver->authorizationUrl($state, $codeVerifier, $redirectUri); + } + + /** + * Handle the provider's callback and return the console URL to redirect to. + * + * Always returns a URL — a failure becomes `#error=` on the console + * callback route rather than an API error page, because this leg is a top-level + * browser navigation and the user must land somewhere useful. + */ + public function handleCallback(string $provider, Request $request): string + { + $state = $request->input('state'); + + if (!is_string($state) || $state === '') { + return $this->consoleUrl(['error' => 'invalid_state']); + } + + try { + $consumed = $this->states->consume(OAuthState::PURPOSE_AUTHORIZATION, $state, $request->ip()); + } catch (OAuthStateException $e) { + // Unknown, expired, replayed, or issued for another purpose. No token + // exchange is attempted. + return $this->consoleUrl(['error' => 'invalid_state']); + } + + /** @var OAuthState $stateRow */ + $stateRow = $consumed['state']; + $payload = $consumed['payload']; + $returnTo = is_string($payload['return_to'] ?? null) ? $payload['return_to'] : null; + + // The state row records which provider it was issued for; a callback that + // arrives on a different provider's route is a mismatch, not a coincidence. + if ($stateRow->provider !== null && $stateRow->provider !== $provider) { + return $this->consoleUrl(['error' => 'invalid_state'], $returnTo); + } + + // The user declined at the provider, or the provider refused outright. + if ($request->filled('error')) { + // Narrowed rather than cast: a provider sending `error[]=x` would make a + // string cast emit a notice mid-redirect. + $error = $request->input('error'); + $error = is_string($error) ? $error : ''; + + return $this->consoleUrl([ + 'error' => $error === 'access_denied' ? 'access_denied' : 'provider_error', + ], $returnTo); + } + + $code = $request->input('code'); + + if (!is_string($code) || $code === '') { + return $this->consoleUrl(['error' => 'missing_code'], $returnTo); + } + + try { + $driver = $this->registry->driver($provider); + + if (!$driver->isEnabled()) { + return $this->consoleUrl(['error' => 'provider_disabled'], $returnTo); + } + + $profile = $driver->exchange( + $code, + is_string($payload['code_verifier'] ?? null) ? $payload['code_verifier'] : '', + is_string($payload['redirect_uri'] ?? null) ? $payload['redirect_uri'] : $this->redirectUri($provider), + $request->all() + ); + } catch (OAuthException $e) { + // A policy failure the user can act on, e.g. hosted_domain_mismatch. + return $this->consoleUrl(['error' => $e->getMessage()], $returnTo); + } catch (\Throwable $e) { + // Network, provider outage, malformed response. Never surface the + // underlying message — it can embed a token or a response body. + Log::error('[OAuth] Authorization code exchange failed.', ['provider' => $provider]); + + return $this->consoleUrl(['error' => 'exchange_failed'], $returnTo); + } + + $handoff = $this->states->issue( + OAuthState::PURPOSE_HANDOFF, + [ + 'profile' => $profile->jsonSerialize(), + 'intent' => $stateRow->intent ?? self::INTENT_LOGIN, + 'return_to' => $returnTo, + ], + $this->config->ttl('handoff', 120), + $provider, + $stateRow->intent, + $stateRow->user_uuid, + $request->ip() + ); + + return $this->consoleUrl(['handoff' => $handoff], $returnTo); + } + + /** + * The callback URL registered with the provider. + * + * Always computed, never read from the request: a request-supplied redirect_uri + * is the classic way to turn an OAuth client into an open redirector. The same + * string is used for both the authorization and token requests because providers + * require them to match byte for byte. + */ + public function redirectUri(string $provider): string + { + $base = $this->config->redirectBase(); + + $segments = array_filter([ + trim((string) config('fleetbase.api.routing.prefix', ''), '/'), + trim((string) config('fleetbase.api.routing.internal_prefix', 'int'), '/'), + 'v1', + 'auth/oauth', + $provider, + 'callback', + ], fn ($segment) => $segment !== ''); + + return rtrim($base, '/') . '/' . implode('/', $segments); + } + + /** + * Build the console URL the browser is returned to. + * + * Parameters go in the FRAGMENT, never the query string: a fragment is not sent + * to the console's web server, so the handoff code never reaches an access log + * or a Referer header. + * + * @param array $fragment + */ + public function consoleUrl(array $fragment, ?string $returnTo = null): string + { + $path = $this->config->consoleCallbackPath(); + $returnTo = $this->sanitizeReturnPath($returnTo); + + if ($returnTo !== null) { + $fragment['return_to'] = $returnTo; + } + + return Utils::consoleUrl($path) . '#' . http_build_query($fragment); + } + + /** + * Reduce a caller-supplied return target to a safe relative path, or null. + * + * Rejects absolute URLs, protocol-relative `//evil.tld`, backslash variants, + * and anything carrying a control character. Nothing attacker-controlled is ever + * allowed to influence the host. + */ + public function sanitizeReturnPath(?string $returnTo): ?string + { + if (!is_string($returnTo) || $returnTo === '') { + return null; + } + + if (strlen($returnTo) > 512) { + return null; + } + + // Control characters (including CR/LF) would allow header or fragment + // splitting downstream. + if (preg_match('/[\x00-\x1F\x7F]/', $returnTo) === 1) { + return null; + } + + if (str_contains($returnTo, '\\')) { + return null; + } + + return preg_match(self::RETURN_PATH_PATTERN, $returnTo) === 1 ? $returnTo : null; + } + + /** + * RFC 7636 code verifier: 43-128 characters from the unreserved set. + */ + protected function generateCodeVerifier(): string + { + return rtrim(strtr(base64_encode(random_bytes(64)), '+/', '-_'), '='); + } + + /** + * Read the profile back out of a redeemed handoff payload. + * + * @param array $payload + */ + public function profileFromPayload(array $payload): OAuthUserProfile + { + $profile = $payload['profile'] ?? []; + + return OAuthUserProfile::fromArray(is_array($profile) ? $profile : []); + } +} diff --git a/src/routes.php b/src/routes.php index dcf7aa8a..9bbf8895 100644 --- a/src/routes.php +++ b/src/routes.php @@ -98,7 +98,21 @@ function ($router) { function ($router) { $router->prefix('v1')->namespace('v1')->group( function ($router) { - $router->fleetbaseAuthRoutes(); + $router->fleetbaseAuthRoutes(null, function ($router) { + // OAuth sign-in. Registered through the macro's public + // callback so these inherit the same ThrottleRequests group + // as login/sign-up rather than re-declaring middleware. + $router->group(['prefix' => 'oauth'], function ($router) { + // Literal segments first: otherwise {provider} would + // swallow "providers" and "exchange". + $router->get('providers', [Fleetbase\Http\Controllers\Internal\v1\OAuthController::class, 'providers']); + $router->post('exchange', [Fleetbase\Http\Controllers\Internal\v1\OAuthController::class, 'exchange']); + $router->get('{provider}/redirect', [Fleetbase\Http\Controllers\Internal\v1\OAuthController::class, 'redirect']); + // GET and POST: Apple form-posts its callback whenever + // the name/email scopes are requested. + $router->match(['GET', 'POST'], '{provider}/callback', [Fleetbase\Http\Controllers\Internal\v1\OAuthController::class, 'callback']); + }); + }); $router->group( ['prefix' => 'onboard', 'middleware' => [Fleetbase\Http\Middleware\ThrottleRequests::class]], function ($router) { diff --git a/tests/Unit/Http/OAuthControllerTest.php b/tests/Unit/Http/OAuthControllerTest.php new file mode 100644 index 00000000..9b5d0b3e --- /dev/null +++ b/tests/Unit/Http/OAuthControllerTest.php @@ -0,0 +1,1055 @@ + + */ + function oauth_test_events(): array + { + return $GLOBALS['oauth_test_events'] ?? []; + } + + function oauth_test_reset_events(): void + { + $GLOBALS['oauth_test_events'] = []; + } +} + +if (!function_exists('Fleetbase\\Services\\OAuth\\event')) { + eval('namespace Fleetbase\\Services\\OAuth; function event($event = null) { if (is_object($event)) { \\oauth_test_record_event($event); } return $event; }'); +} + +class OAuthControllerRedirectorFake +{ + public function away(string $url): self + { + $this->url = $url; + + return $this; + } + + public string $url = ''; + + public function getTargetUrl(): string + { + return $this->url; + } +} + +class OAuthControllerEncrypterFake implements Encrypter +{ + public function encrypt($value, $serialize = true) + { + return 'enc:' . base64_encode($serialize ? serialize($value) : (string) $value); + } + + public function decrypt($payload, $unserialize = true) + { + $value = base64_decode(substr((string) $payload, 4), true); + + return $unserialize ? unserialize((string) $value) : (string) $value; + } + + public function getKey() + { + return 'test-key'; + } +} + +class OAuthControllerCacheFake +{ + private array $values = []; + + public function rememberForever(string $key, Closure $callback): mixed + { + return $this->values[$key] ??= $callback(); + } + + public function remember(string $key, mixed $ttl, Closure $callback): mixed + { + return $this->values[$key] ??= $callback(); + } + + public function get(string $key, mixed $default = null): mixed + { + return $this->values[$key] ?? $default; + } + + public function put(string $key, mixed $value, mixed $ttl = null): bool + { + $this->values[$key] = $value; + + return true; + } + + public function forget(string $key): bool + { + unset($this->values[$key]); + + return true; + } + + public function increment(string $key, int $value = 1): int + { + return $this->values[$key] = (int) ($this->values[$key] ?? 0) + $value; + } + + public function tags(array|string $tags): self + { + return $this; + } + + public function flush(): bool + { + $this->values = []; + + return true; + } + + public function getPrefix(): string + { + return ''; + } +} + +class OAuthControllerHashFake +{ + public function make(string $value, array $options = []): string + { + return 'hashed:' . $value; + } + + public function check(string $value, ?string $hashed = null): bool + { + return $hashed === 'hashed:' . $value; + } + + public function info(string $hashed): array + { + return ['algo' => 'fake']; + } +} + +class OAuthControllerRedisFake +{ + public array $values = []; + + public function set(string $key, mixed $value, mixed ...$options): bool + { + $this->values[$key] = $value; + + return true; + } + + public function exists(string $key): bool + { + return array_key_exists($key, $this->values); + } + + public function get(string $key): mixed + { + return $this->values[$key] ?? null; + } + + public function del(?string $key): bool + { + unset($this->values[$key]); + + return true; + } + + public function connection(): self + { + return $this; + } +} + +class OAuthControllerResponseCacheFake +{ + public function clear(): void + { + } +} + +class OAuthControllerRateLimiterFake +{ + public array $hits = []; + + public function __construct(public int $limitAfter = PHP_INT_MAX) + { + } + + public function tooManyAttempts(string $key, int $maxAttempts): bool + { + return count($this->hits) >= $this->limitAfter; + } + + public function hit(string $key, int $decaySeconds = 60): int + { + $this->hits[] = $key; + + return count($this->hits); + } +} + +/** + * A driver that returns a canned profile instead of talking to a provider. + * + * The real drivers are covered by ProviderProfileNormalizationTest; what matters + * here is the controller's behaviour around whatever a driver produces. + */ +class OAuthControllerFakeDriver implements OAuthProviderDriver +{ + /** + * Set per-test to steer the fake. + * + * @var array + */ + public static array $behaviour = []; + + public function __construct( + protected OAuthProviderConfig $config, + protected Request $request, + protected IdTokenVerifier $verifier, + ) { + } + + public static function id(): string + { + return 'fakeprovider'; + } + + public static function label(): string + { + return 'Fake Provider'; + } + + public static function icon(): string + { + return 'circle'; + } + + public static function configSchema(): array + { + return ['client_id' => ['label' => 'Client ID', 'required' => true]]; + } + + public function isConfigured(): bool + { + return true; + } + + public function isEnabled(): bool + { + return (bool) (self::$behaviour['enabled'] ?? true); + } + + public function usesFormPostCallback(): bool + { + return false; + } + + public function authorizationUrl(string $state, string $codeVerifier, string $redirectUri): string + { + return 'https://provider.test/authorize?' . http_build_query([ + 'state' => $state, + 'redirect_uri' => $redirectUri, + 'verifier_hash' => hash('sha256', $codeVerifier), + ]); + } + + public function exchange(string $code, string $codeVerifier, string $redirectUri, array $callbackPayload = []): OAuthUserProfile + { + if (isset(self::$behaviour['throw'])) { + throw self::$behaviour['throw']; + } + + return self::$behaviour['profile'] ?? new OAuthUserProfile('fakeprovider', 'subject-1', 'ada@example.com', true, 'Ada Lovelace'); + } +} + +function oauth_controller_database(array $config = []): Capsule +{ + EloquentModel::clearBootedModels(); + OAuthControllerFakeDriver::$behaviour = []; + + $connection = ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '']; + + $container = bind_test_container(array_merge([ + 'app.env' => 'testing', + 'app.timezone' => 'UTC', + 'app.key' => 'base64:' . base64_encode(str_repeat('a', 32)), + 'app.url' => 'https://api.fleetbase.test', + 'api.cache.enabled' => false, + 'activitylog.enabled' => false, + 'database.default' => 'mysql', + 'database.connections.mysql' => $connection, + 'fleetbase.connection.db' => 'mysql', + 'fleetbase.console.host' => 'console.fleetbase.test', + 'fleetbase.console.secure' => true, + 'fleetbase.api.routing.prefix' => '/', + 'fleetbase.api.routing.internal_prefix' => 'int', + 'oauth.enabled' => true, + 'oauth.allow_registration' => true, + 'oauth.console_callback_path' => '/auth/oauth/callback', + 'oauth.ttl' => ['authorization' => 600, 'handoff' => 120, 'registration_intent' => 900], + 'oauth.providers' => [ + 'fakeprovider' => ['driver' => OAuthControllerFakeDriver::class, 'enabled' => true, 'client_id' => 'cid'], + ], + 'permission.models.permission' => Fleetbase\Models\Permission::class, + 'permission.models.role' => Fleetbase\Models\Role::class, + 'permission.table_names.permissions' => 'permissions', + 'permission.table_names.roles' => 'roles', + 'permission.table_names.model_has_permissions' => 'model_has_permissions', + 'permission.table_names.model_has_roles' => 'model_has_roles', + 'permission.column_names.model_morph_key' => 'model_uuid', + ], $config)); + + $container->instance(Illuminate\Contracts\Config\Repository::class, $container->make('config')); + + $cache = new OAuthControllerCacheFake(); + $container->instance('cache', $cache); + $container->instance('hash', new OAuthControllerHashFake()); + $container->instance('redis', new OAuthControllerRedisFake()); + $container->instance('responsecache', new OAuthControllerResponseCacheFake()); + $container->instance(Illuminate\Cache\RateLimiter::class, new OAuthControllerRateLimiterFake()); + Cache::swap($cache); + foreach (['cache', 'hash', 'redis', 'responsecache', 'log'] as $facade) { + Facade::clearResolvedInstance($facade); + } + Facade::clearResolvedInstance(Illuminate\Cache\RateLimiter::class); + + $capsule = new Capsule($container); + $capsule->addConnection($connection, 'mysql'); + $capsule->setEventDispatcher(new Dispatcher($container)); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + + $databaseManager = $capsule->getDatabaseManager(); + $databaseManager->setDefaultConnection('mysql'); + $container->instance('db', $databaseManager); + Facade::clearResolvedInstance('db'); + + $schema = app('db')->connection('mysql')->getSchemaBuilder(); + + $schema->create('users', function ($table) { + $table->string('uuid')->primary(); + $table->string('company_uuid')->nullable(); + $table->string('name')->nullable(); + $table->string('email')->nullable()->index(); + $table->string('phone')->nullable(); + $table->string('username')->nullable(); + $table->string('slug')->nullable(); + $table->string('password')->nullable(); + $table->string('type')->nullable(); + $table->string('status')->nullable(); + $table->string('google_user_id')->nullable(); + $table->timestamp('email_verified_at')->nullable(); + $table->timestamp('phone_verified_at')->nullable(); + $table->timestamp('last_login')->nullable(); + $table->timestamp('deleted_at')->nullable(); + $table->timestamps(); + }); + $schema->create('settings', function ($table) { + $table->increments('id'); + $table->string('key')->unique(); + $table->text('value')->nullable(); + }); + $schema->create('personal_access_tokens', function ($table) { + $table->increments('id'); + $table->morphs('tokenable'); + $table->string('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + }); + $schema->create('oauth_identities', function ($table) { + $table->string('uuid')->primary(); + $table->string('user_uuid'); + $table->string('provider', 40); + $table->string('provider_user_id', 191); + $table->string('provider_email')->nullable(); + $table->boolean('email_verified')->default(false); + $table->text('meta')->nullable(); + $table->timestamp('last_login_at')->nullable(); + $table->timestamps(); + $table->unique(['provider', 'provider_user_id']); + }); + $schema->create('oauth_states', function ($table) { + $table->string('uuid')->primary(); + $table->string('purpose', 24); + $table->string('token_hash', 64)->unique(); + $table->string('provider', 40)->nullable(); + $table->string('intent', 16)->nullable(); + $table->string('user_uuid')->nullable(); + $table->text('payload')->nullable(); + $table->string('ip_hash', 64)->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamp('consumed_at')->nullable(); + $table->timestamps(); + }); + + return $capsule; +} + +function oauth_controller_services(): array +{ + $encrypter = new OAuthControllerEncrypterFake(); + $states = new OAuthStateService($encrypter); + $config = new OAuthConfigRepository($encrypter); + $identities = new OAuthIdentityService(); + $registry = new OAuthProviderRegistry($config, Request::create('/'), new IdTokenVerifier()); + $flow = new OAuthFlowService($registry, $states, $config); + + return [new OAuthController($registry, $flow, $states, $identities, $config), $states, $identities, $config, $flow]; +} + +function oauth_controller_user(array $attributes = []): User +{ + app('db')->connection('mysql')->table('users')->insert(array_merge([ + 'uuid' => 'user-1', + 'email' => 'ada@example.com', + 'name' => 'Ada', + 'type' => 'user', + 'status' => 'active', + 'email_verified_at' => '2024-01-01 00:00:00', + 'created_at' => Carbon::now(), + 'updated_at' => Carbon::now(), + ], $attributes)); + + return User::query()->findOrFail($attributes['uuid'] ?? 'user-1'); +} + +function oauth_controller_link(User $user, string $subject = 'subject-1', string $provider = 'fakeprovider'): OAuthIdentity +{ + return OAuthIdentity::query()->create([ + 'user_uuid' => $user->uuid, + 'provider' => $provider, + 'provider_user_id' => $subject, + 'provider_email' => $user->email, + 'email_verified' => true, + ]); +} + +/** + * core-api has no illuminate/foundation, so tests/Pest.php polyfills FormRequest as a + * bare Request with no validation machinery. These build the request object the + * controller receives; the rules themselves are exercised for real against + * Illuminate's validator further down. + */ +function oauth_redirect_request(array $query = []): OAuthRedirectRequest +{ + return OAuthRedirectRequest::create('/int/v1/auth/oauth/fakeprovider/redirect', 'GET', $query); +} + +function oauth_exchange_request(array $body): OAuthExchangeRequest +{ + return OAuthExchangeRequest::create('/int/v1/auth/oauth/exchange', 'POST', $body); +} + +/** + * @param array $rules + * @param array $data + */ +function oauth_validator(array $rules, array $data): Illuminate\Contracts\Validation\Validator +{ + $translator = new Illuminate\Translation\Translator(new Illuminate\Translation\ArrayLoader(), 'en'); + + return (new Illuminate\Validation\Factory($translator))->make($data, $rules); +} + +/** + * @return array + */ +function oauth_fragment(string $url): array +{ + parse_str((string) parse_url($url, PHP_URL_FRAGMENT), $fragment); + + /** @var array $fragment */ + return $fragment; +} + +// --------------------------------------------------------------------------- +// Discovery +// --------------------------------------------------------------------------- + +it('advertises only enabled providers and leaks no credentials', function () { + oauth_controller_database(); + [$controller] = oauth_controller_services(); + + $payload = $controller->providers()->getData(true); + + expect($payload['providers'])->toBe([[ + 'id' => 'fakeprovider', + 'label' => 'Fake Provider', + 'icon' => 'circle', + ]]) + ->and(json_encode($payload))->not->toContain('cid'); +}); + +it('advertises nothing when oauth is switched off', function () { + oauth_controller_database(['oauth.enabled' => false]); + [$controller] = oauth_controller_services(); + + expect($controller->providers()->getData(true)['providers'])->toBe([]); +}); + +// --------------------------------------------------------------------------- +// Redirect +// --------------------------------------------------------------------------- + +it('redirects to the provider with state and a pkce challenge', function () { + oauth_controller_database(); + [$controller, $states] = oauth_controller_services(); + + $response = $controller->redirect(oauth_redirect_request(), 'fakeprovider'); + parse_str((string) parse_url($response->getTargetUrl(), PHP_URL_QUERY), $query); + + $row = OAuthState::query()->first(); + + expect($response->getTargetUrl())->toStartWith('https://provider.test/authorize?') + ->and($row->purpose)->toBe(OAuthState::PURPOSE_AUTHORIZATION) + ->and($row->provider)->toBe('fakeprovider') + ->and($row->intent)->toBe('login') + // The state in the URL must be the token whose sha256 we stored, never the + // row's own identifier. + ->and($row->token_hash)->toBe(hash('sha256', $query['state'])) + // redirect_uri is computed from config, never taken from the request. + ->and($query['redirect_uri'])->toBe('https://api.fleetbase.test/int/v1/auth/oauth/fakeprovider/callback'); +}); + +it('stores the pkce verifier encrypted and never puts it in the url', function () { + oauth_controller_database(); + [$controller] = oauth_controller_services(); + + $response = $controller->redirect(oauth_redirect_request(), 'fakeprovider'); + parse_str((string) parse_url($response->getTargetUrl(), PHP_URL_QUERY), $query); + + $row = OAuthState::query()->first(); + + expect($row->payload)->toStartWith('enc:') + ->and($row->payload)->not->toContain('code_verifier') + ->and($response->getTargetUrl())->not->toContain('code_verifier') + // The fake driver echoes a hash of the verifier it was handed, proving the + // controller generated one and passed it through. + ->and($query['verifier_hash'])->toBeString()->toHaveLength(64); +}); + +it('carries a signup intent through to the state row', function () { + oauth_controller_database(); + [$controller] = oauth_controller_services(); + + $controller->redirect(oauth_redirect_request(['intent' => 'signup']), 'fakeprovider'); + + expect(OAuthState::query()->first()->intent)->toBe('signup'); +}); + +it('rejects an unknown provider', function () { + oauth_controller_database(); + [$controller] = oauth_controller_services(); + + $response = $controller->redirect(oauth_redirect_request(), 'nope'); + + expect($response->getStatusCode())->toBe(404) + ->and($response->getData(true)['code'])->toBe('unknown_provider'); +}); + +it('refuses a disabled provider before issuing any state', function () { + oauth_controller_database(); + OAuthControllerFakeDriver::$behaviour = ['enabled' => false]; + [$controller] = oauth_controller_services(); + + $response = $controller->redirect(oauth_redirect_request(), 'fakeprovider'); + + expect($response->getStatusCode())->toBe(403) + ->and($response->getData(true)['code'])->toBe('provider_disabled') + ->and(OAuthState::query()->count())->toBe(0); +}); + +it('keeps a safe return path and rejects every unsafe one', function (string $returnTo, ?string $expected) { + oauth_controller_database(); + [$controller, $states, $identities, $config, $flow] = oauth_controller_services(); + + expect($flow->sanitizeReturnPath($returnTo))->toBe($expected); +})->with([ + 'plain path' => ['/dashboard', '/dashboard'], + 'path with query' => ['/orders?status=open', '/orders?status=open'], + 'absolute url' => ['https://evil.tld/x', null], + 'protocol relative' => ['//evil.tld', null], + 'backslash trick' => ['/\\evil.tld', null], + 'scheme relative' => ['http://evil.tld', null], + 'crlf injection' => ["/ok\r\nLocation: https://evil.tld", null], + 'null byte' => ["/ok\0", null], + 'not rooted' => ['dashboard', null], + 'empty' => ['', null], +]); + +it('rejects an unsafe return path at the request boundary too', function () { + oauth_controller_database(); + $rules = (new OAuthRedirectRequest())->rules(); + + // Defence in depth: the form request rejects these before the service ever + // sees them, and the service sanitizes again regardless. + expect(oauth_validator($rules, ['return_to' => 'https://evil.tld/x'])->fails())->toBeTrue() + ->and(oauth_validator($rules, ['return_to' => '//evil.tld'])->fails())->toBeTrue() + ->and(oauth_validator($rules, ['return_to' => 'dashboard'])->fails())->toBeTrue() + ->and(oauth_validator($rules, ['return_to' => str_repeat('/a', 400)])->fails())->toBeTrue() + ->and(oauth_validator($rules, ['return_to' => '/dashboard'])->fails())->toBeFalse() + ->and(oauth_validator($rules, [])->fails())->toBeFalse(); +}); + +it('only accepts a login or signup intent', function () { + oauth_controller_database(); + $rules = (new OAuthRedirectRequest())->rules(); + + expect(oauth_validator($rules, ['intent' => 'login'])->fails())->toBeFalse() + ->and(oauth_validator($rules, ['intent' => 'signup'])->fails())->toBeFalse() + // 'link' is a protected-route flow and must not be startable anonymously. + ->and(oauth_validator($rules, ['intent' => 'link'])->fails())->toBeTrue() + ->and(oauth_validator($rules, ['intent' => 'anything'])->fails())->toBeTrue(); +}); + +// --------------------------------------------------------------------------- +// Callback +// --------------------------------------------------------------------------- + +it('completes a callback and returns a handoff code in the url fragment', function () { + oauth_controller_database(); + [$controller] = oauth_controller_services(); + + $redirect = $controller->redirect(oauth_redirect_request(['return_to' => '/dashboard']), 'fakeprovider'); + parse_str((string) parse_url($redirect->getTargetUrl(), PHP_URL_QUERY), $query); + + $response = $controller->callback( + Request::create('/int/v1/auth/oauth/fakeprovider/callback', 'GET', ['code' => 'auth-code', 'state' => $query['state']]), + 'fakeprovider' + ); + + $url = $response->getTargetUrl(); + $fragment = oauth_fragment($url); + + expect($url)->toStartWith('https://console.fleetbase.test/auth/oauth/callback#') + // The code lives in the fragment, which is never sent to the console's web + // server — so it cannot land in an access log or a Referer header. + ->and($url)->not->toContain('?handoff=') + ->and($fragment['handoff'])->toBeString()->toHaveLength(64) + ->and($fragment['return_to'])->toBe('/dashboard') + ->and(OAuthState::query()->where('purpose', OAuthState::PURPOSE_HANDOFF)->count())->toBe(1); +}); + +it('accepts a form posted callback', function () { + oauth_controller_database(); + [$controller] = oauth_controller_services(); + + $redirect = $controller->redirect(oauth_redirect_request(), 'fakeprovider'); + parse_str((string) parse_url($redirect->getTargetUrl(), PHP_URL_QUERY), $query); + + // Apple form-posts its callback whenever the name/email scopes are requested. + $response = $controller->callback( + Request::create('/int/v1/auth/oauth/fakeprovider/callback', 'POST', ['code' => 'auth-code', 'state' => $query['state']]), + 'fakeprovider' + ); + + expect(oauth_fragment($response->getTargetUrl())['handoff'])->toBeString(); +}); + +it('reports an invalid state without attempting an exchange', function (array $params) { + oauth_controller_database(); + [$controller] = oauth_controller_services(); + + OAuthControllerFakeDriver::$behaviour = ['throw' => new RuntimeException('should never be reached')]; + + $response = $controller->callback( + Request::create('/int/v1/auth/oauth/fakeprovider/callback', 'GET', $params), + 'fakeprovider' + ); + + expect(oauth_fragment($response->getTargetUrl())['error'])->toBe('invalid_state'); +})->with([ + 'no state' => [['code' => 'auth-code']], + 'unknown state' => [['code' => 'auth-code', 'state' => str_repeat('z', 64)]], + 'empty state' => [['code' => 'auth-code', 'state' => '']], +]); + +it('refuses to replay a state', function () { + oauth_controller_database(); + [$controller] = oauth_controller_services(); + + $redirect = $controller->redirect(oauth_redirect_request(), 'fakeprovider'); + parse_str((string) parse_url($redirect->getTargetUrl(), PHP_URL_QUERY), $query); + + $request = fn () => Request::create('/int/v1/auth/oauth/fakeprovider/callback', 'GET', ['code' => 'auth-code', 'state' => $query['state']]); + + expect(oauth_fragment($controller->callback($request(), 'fakeprovider')->getTargetUrl()))->toHaveKey('handoff') + ->and(oauth_fragment($controller->callback($request(), 'fakeprovider')->getTargetUrl())['error'])->toBe('invalid_state'); +}); + +it('rejects a state issued for a different provider', function () { + oauth_controller_database([ + 'oauth.providers' => [ + 'fakeprovider' => ['driver' => OAuthControllerFakeDriver::class, 'enabled' => true, 'client_id' => 'cid'], + 'otherprovider' => ['driver' => OAuthControllerFakeDriver::class, 'enabled' => true, 'client_id' => 'cid2'], + ], + ]); + [$controller] = oauth_controller_services(); + + $redirect = $controller->redirect(oauth_redirect_request(), 'fakeprovider'); + parse_str((string) parse_url($redirect->getTargetUrl(), PHP_URL_QUERY), $query); + + $response = $controller->callback( + Request::create('/int/v1/auth/oauth/otherprovider/callback', 'GET', ['code' => 'c', 'state' => $query['state']]), + 'otherprovider' + ); + + expect(oauth_fragment($response->getTargetUrl())['error'])->toBe('invalid_state'); +}); + +it('surfaces a declined authorization', function (string $providerError, string $expected) { + oauth_controller_database(); + [$controller] = oauth_controller_services(); + + $redirect = $controller->redirect(oauth_redirect_request(), 'fakeprovider'); + parse_str((string) parse_url($redirect->getTargetUrl(), PHP_URL_QUERY), $query); + + $response = $controller->callback( + Request::create('/int/v1/auth/oauth/fakeprovider/callback', 'GET', ['error' => $providerError, 'state' => $query['state']]), + 'fakeprovider' + ); + + expect(oauth_fragment($response->getTargetUrl())['error'])->toBe($expected); +})->with([ + 'user cancelled' => ['access_denied', 'access_denied'], + 'other failure' => ['server_error', 'provider_error'], +]); + +it('surfaces a policy failure from the driver', function () { + oauth_controller_database(); + [$controller] = oauth_controller_services(); + + $redirect = $controller->redirect(oauth_redirect_request(), 'fakeprovider'); + parse_str((string) parse_url($redirect->getTargetUrl(), PHP_URL_QUERY), $query); + + OAuthControllerFakeDriver::$behaviour = ['throw' => new OAuthException('hosted_domain_mismatch')]; + + $response = $controller->callback( + Request::create('/int/v1/auth/oauth/fakeprovider/callback', 'GET', ['code' => 'c', 'state' => $query['state']]), + 'fakeprovider' + ); + + expect(oauth_fragment($response->getTargetUrl())['error'])->toBe('hosted_domain_mismatch'); +}); + +it('never leaks a provider exception message to the browser', function () { + oauth_controller_database(); + [$controller] = oauth_controller_services(); + + $redirect = $controller->redirect(oauth_redirect_request(), 'fakeprovider'); + parse_str((string) parse_url($redirect->getTargetUrl(), PHP_URL_QUERY), $query); + + // Guzzle exception messages embed a truncated response body, which can contain a + // token. Only a fixed code may reach the URL. + OAuthControllerFakeDriver::$behaviour = ['throw' => new RuntimeException('500 response: {"access_token":"leaked-token"}')]; + + $response = $controller->callback( + Request::create('/int/v1/auth/oauth/fakeprovider/callback', 'GET', ['code' => 'c', 'state' => $query['state']]), + 'fakeprovider' + ); + + expect(oauth_fragment($response->getTargetUrl())['error'])->toBe('exchange_failed') + ->and($response->getTargetUrl())->not->toContain('leaked-token'); +}); + +// --------------------------------------------------------------------------- +// Exchange +// --------------------------------------------------------------------------- + +/** + * Run a full redirect → callback and return the handoff code. + */ +function oauth_controller_handoff(OAuthController $controller, array $redirectQuery = []): string +{ + $redirect = $controller->redirect(oauth_redirect_request($redirectQuery), 'fakeprovider'); + parse_str((string) parse_url($redirect->getTargetUrl(), PHP_URL_QUERY), $query); + + $callback = $controller->callback( + Request::create('/int/v1/auth/oauth/fakeprovider/callback', 'GET', ['code' => 'auth-code', 'state' => $query['state']]), + 'fakeprovider' + ); + + return oauth_fragment($callback->getTargetUrl())['handoff']; +} + +it('authenticates a known identity and issues a sanctum token', function () { + $capsule = oauth_controller_database(); + [$controller] = oauth_controller_services(); + + $user = oauth_controller_user(); + oauth_controller_link($user); + + $response = $controller->exchange(oauth_exchange_request(['code' => oauth_controller_handoff($controller)])); + $payload = $response->getData(true); + + expect($response->getStatusCode())->toBe(200) + ->and($payload['token'])->toBeString() + ->and($payload['type'])->toBe('user') + ->and($capsule->getConnection('mysql')->table('personal_access_tokens')->count())->toBe(1) + ->and(OAuthIdentity::query()->first()->last_login_at)->not->toBeNull() + // The redeemed handoff row records who it resolved to, for the audit trail. + ->and(OAuthState::query()->where('purpose', OAuthState::PURPOSE_HANDOFF)->first()->user_uuid)->toBe('user-1'); +}); + +it('refuses to redeem a handoff code twice', function () { + $capsule = oauth_controller_database(); + [$controller] = oauth_controller_services(); + + oauth_controller_link(oauth_controller_user()); + $handoff = oauth_controller_handoff($controller); + + $controller->exchange(oauth_exchange_request(['code' => $handoff])); + $second = $controller->exchange(oauth_exchange_request(['code' => $handoff])); + + expect($second->getStatusCode())->toBe(400) + ->and($second->getData(true)['code'])->toBe('invalid_exchange_code') + ->and($capsule->getConnection('mysql')->table('personal_access_tokens')->count())->toBe(1); +}); + +it('reports an expired handoff code the same way as an unknown one', function () { + oauth_controller_database(); + Carbon::setTestNow(Carbon::parse('2026-09-18 10:00:00', 'UTC')); + [$controller] = oauth_controller_services(); + + oauth_controller_link(oauth_controller_user()); + $handoff = oauth_controller_handoff($controller); + + Carbon::setTestNow(Carbon::parse('2026-09-18 10:05:00', 'UTC')); + + $response = $controller->exchange(oauth_exchange_request(['code' => $handoff])); + + expect($response->getStatusCode())->toBe(400) + ->and($response->getData(true)['code'])->toBe('invalid_exchange_code'); + + Carbon::setTestNow(); +}); + +it('challenges for two factor instead of issuing a token', function () { + $capsule = oauth_controller_database(); + [$controller] = oauth_controller_services(); + + $user = oauth_controller_user(); + oauth_controller_link($user); + $capsule->getConnection('mysql')->table('settings')->insert([ + 'key' => 'user.user-1.2fa', + 'value' => json_encode(['enabled' => true, 'method' => 'email']), + ]); + + $response = $controller->exchange(oauth_exchange_request(['code' => oauth_controller_handoff($controller)])); + $payload = $response->getData(true); + + // Signing in through a provider does not exempt anyone from 2FA. + expect($payload['isEnabled'])->toBeTrue() + ->and($payload['twoFaSession'])->toBeString() + ->and($payload)->not->toHaveKey('token') + ->and($capsule->getConnection('mysql')->table('personal_access_tokens')->count())->toBe(0); +}); + +it('applies the same gates password login applies', function (array $attributes, int $status, string $code) { + $capsule = oauth_controller_database(); + [$controller] = oauth_controller_services(); + + $user = oauth_controller_user($attributes); + oauth_controller_link($user); + + $response = $controller->exchange(oauth_exchange_request(['code' => oauth_controller_handoff($controller)])); + + expect($response->getStatusCode())->toBe($status) + ->and($response->getData(true)['code'])->toBe($code) + ->and($capsule->getConnection('mysql')->table('personal_access_tokens')->count())->toBe(0); +})->with([ + 'customer accounts' => [['type' => 'customer'], 403, 'customer_login_not_allowed'], + 'unverified account' => [['email_verified_at' => null, 'type' => 'user'], 400, 'not_verified'], +]); + +it('treats a soft deleted account as an unknown identity', function () { + oauth_controller_database(); + [$controller] = oauth_controller_services(); + + $user = oauth_controller_user(); + oauth_controller_link($user); + User::query()->where('uuid', 'user-1')->update(['deleted_at' => Carbon::now()]); + + $response = $controller->exchange(oauth_exchange_request(['code' => oauth_controller_handoff($controller)])); + + // Not a distinguishable error: telling a caller that an account exists but is + // deleted is an enumeration oracle. + expect($response->getData(true)['status'] ?? null)->toBe('registration_required'); +}); + +it('offers registration for an unknown identity', function () { + oauth_controller_database(); + [$controller] = oauth_controller_services(); + + $response = $controller->exchange(oauth_exchange_request(['code' => oauth_controller_handoff($controller)])); + $payload = $response->getData(true); + + expect($payload['status'])->toBe('registration_required') + ->and($payload['intent'])->toStartWith('rti_') + ->and($payload['prefill'])->toBe([ + 'name' => 'Ada Lovelace', + 'email' => 'ada@example.com', + 'email_verified' => true, + ]) + ->and(OAuthState::query()->where('purpose', OAuthState::PURPOSE_REGISTRATION_INTENT)->count())->toBe(1); +}); + +it('refuses registration when sign-ups are closed', function () { + oauth_controller_database(['oauth.allow_registration' => false]); + [$controller] = oauth_controller_services(); + + $response = $controller->exchange(oauth_exchange_request(['code' => oauth_controller_handoff($controller)])); + + expect($response->getStatusCode())->toBe(403) + ->and($response->getData(true)['code'])->toBe('registration_disabled') + ->and(OAuthState::query()->where('purpose', OAuthState::PURPOSE_REGISTRATION_INTENT)->count())->toBe(0); +}); + +it('requires linking when a verified address already has an account', function () { + oauth_controller_database(); + [$controller] = oauth_controller_services(); + + // An account exists for this address but no identity is linked. Signing them in + // here would be account takeover by email address. + oauth_controller_user(['uuid' => 'user-1', 'email' => 'ada@example.com']); + + $response = $controller->exchange(oauth_exchange_request(['code' => oauth_controller_handoff($controller)])); + + expect($response->getStatusCode())->toBe(409) + ->and($response->getData(true)['code'])->toBe('link_required') + ->and(OAuthState::query()->where('purpose', OAuthState::PURPOSE_REGISTRATION_INTENT)->count())->toBe(0); +}); + +it('does not report link_required for an address the provider did not verify', function () { + oauth_controller_database(); + [$controller] = oauth_controller_services(); + + oauth_controller_user(['uuid' => 'user-1', 'email' => 'ada@example.com']); + OAuthControllerFakeDriver::$behaviour = [ + 'profile' => new OAuthUserProfile('fakeprovider', 'subject-9', 'ada@example.com', false, 'Mallory'), + ]; + + $response = $controller->exchange(oauth_exchange_request(['code' => oauth_controller_handoff($controller)])); + + // Otherwise anyone could probe whether an arbitrary address has a Fleetbase + // account simply by asserting it at a provider that does not verify addresses. + expect($response->getData(true)['status'] ?? null)->toBe('registration_required'); +}); + +it('does not match an apple private relay alias against an existing account', function () { + oauth_controller_database(); + [$controller] = oauth_controller_services(); + + oauth_controller_user(['uuid' => 'user-1', 'email' => 'abc@privaterelay.appleid.com']); + OAuthControllerFakeDriver::$behaviour = [ + 'profile' => new OAuthUserProfile('fakeprovider', 'subject-9', 'abc@privaterelay.appleid.com', true, 'Ada', null, ['private_relay' => true]), + ]; + + $response = $controller->exchange(oauth_exchange_request(['code' => oauth_controller_handoff($controller)])); + + // Relay aliases are unique per application, so a match is never the same person. + expect($response->getData(true)['status'] ?? null)->toBe('registration_required'); +}); + +it('refuses an exchange for a provider switched off mid flight', function () { + oauth_controller_database(); + [$controller] = oauth_controller_services(); + + oauth_controller_link(oauth_controller_user()); + $handoff = oauth_controller_handoff($controller); + + OAuthControllerFakeDriver::$behaviour = ['enabled' => false]; + + $response = $controller->exchange(oauth_exchange_request(['code' => $handoff])); + + expect($response->getStatusCode())->toBe(403) + ->and($response->getData(true)['code'])->toBe('provider_disabled'); +}); + +it('never returns a provider token or the handoff code in any response', function () { + oauth_controller_database(); + [$controller] = oauth_controller_services(); + + oauth_controller_link(oauth_controller_user()); + $handoff = oauth_controller_handoff($controller); + + $body = json_encode($controller->exchange(oauth_exchange_request(['code' => $handoff]))->getData(true)); + + expect($body)->not->toContain($handoff) + ->and($body)->not->toContain('access_token') + ->and($body)->not->toContain('refresh_token'); +}); + +it('rejects a malformed handoff code at the request boundary', function () { + oauth_controller_database(); + $rules = (new OAuthExchangeRequest())->rules(); + + // Rejecting on shape keeps malformed input away from a database lookup entirely. + expect(oauth_validator($rules, ['code' => 'too-short'])->fails())->toBeTrue() + ->and(oauth_validator($rules, [])->fails())->toBeTrue() + ->and(oauth_validator($rules, ['code' => str_repeat('a', 65)])->fails())->toBeTrue() + ->and(oauth_validator($rules, ['code' => str_repeat('a', 64)])->fails())->toBeFalse(); +}); + +it('rate limits the exchange endpoint independently of the throttle middleware', function () { + oauth_controller_database(); + app()->instance(Illuminate\Cache\RateLimiter::class, new OAuthControllerRateLimiterFake(0)); + Facade::clearResolvedInstance(Illuminate\Cache\RateLimiter::class); + [$controller] = oauth_controller_services(); + + // Fleetbase's ThrottleRequests overwrites per-route limits from config, so the + // controller carries its own limiter. + $response = $controller->exchange(oauth_exchange_request(['code' => str_repeat('a', 64)])); + + expect($response->getStatusCode())->toBe(429) + ->and($response->getData(true)['code'])->toBe('rate_limited'); +}); diff --git a/tests/Unit/RoutesContractTest.php b/tests/Unit/RoutesContractTest.php index b727e68f..317b53fb 100644 --- a/tests/Unit/RoutesContractTest.php +++ b/tests/Unit/RoutesContractTest.php @@ -107,11 +107,15 @@ function routes_contract_router(): Router }); }); - Router::macro('fleetbaseAuthRoutes', function (?string $authControllerClass = null) { + // Mirrors src/Expansions/Route.php:121-160, including the two extension + // callbacks. Without them this local macro would silently drop every route + // src/routes.php registers through the seam, and the contract below would + // pass while asserting nothing about them. + Router::macro('fleetbaseAuthRoutes', function (?string $authControllerClass = null, ?callable $registerFn = null, ?callable $registerProtectedFn = null) { $authControllerClass ??= AuthController::class; - return $this->group(['prefix' => 'auth'], function (Router $router) use ($authControllerClass) { - $router->group(['middleware' => [ThrottleRequests::class]], function (Router $router) use ($authControllerClass) { + return $this->group(['prefix' => 'auth'], function (Router $router) use ($authControllerClass, $registerFn, $registerProtectedFn) { + $router->group(['middleware' => [ThrottleRequests::class]], function (Router $router) use ($authControllerClass, $registerFn) { $router->post('login', [$authControllerClass, 'login']); $router->post('sign-up', [$authControllerClass, 'signUp']); $router->post('logout', [$authControllerClass, 'logout']); @@ -123,15 +127,23 @@ function routes_contract_router(): Router $router->post('send-verification-email', [$authControllerClass, 'sendVerificationEmail']); $router->post('verify-email', [$authControllerClass, 'verifyEmail']); $router->get('validate-verification', [$authControllerClass, 'validateVerificationCode']); + + if (is_callable($registerFn)) { + $registerFn($router); + } }); - $router->group(['middleware' => ['fleetbase.protected']], function (Router $router) use ($authControllerClass) { + $router->group(['middleware' => ['fleetbase.protected']], function (Router $router) use ($authControllerClass, $registerProtectedFn) { $router->post('switch-organization', [$authControllerClass, 'switchOrganization']); $router->post('join-organization', [$authControllerClass, 'joinOrganization']); $router->post('create-organization', [$authControllerClass, 'createOrganization']); $router->get('session', [$authControllerClass, 'session']); $router->get('organizations', [$authControllerClass, 'getUserOrganizations']); $router->get('services', [$authControllerClass, 'services']); + + if (is_callable($registerProtectedFn)) { + $registerProtectedFn($router); + } }); }); }); @@ -227,6 +239,58 @@ function routes_contract_index(array $rows, string $method, string $uri): int|fa ->and($session['middleware'])->toContain('fleetbase.protected'); }); + test('route file exposes oauth sign-in as public throttled routes', function () { + $routes = routes_contract_rows(routes_contract_router()); + + $controller = Fleetbase\Http\Controllers\Internal\v1\OAuthController::class; + + $providers = routes_contract_find($routes, 'GET', 'int/v1/auth/oauth/providers'); + $exchange = routes_contract_find($routes, 'POST', 'int/v1/auth/oauth/exchange'); + $redirect = routes_contract_find($routes, 'GET', 'int/v1/auth/oauth/{provider}/redirect'); + + expect($providers)->not->toBeNull() + ->and($providers['action'])->toBe($controller . '@providers') + ->and($providers['middleware'])->toContain(ThrottleRequests::class) + // These are how a user signs in — a session cannot be a precondition. + ->and($providers['middleware'])->not->toContain('fleetbase.protected') + ->and($exchange)->not->toBeNull() + ->and($exchange['action'])->toBe($controller . '@exchange') + ->and($exchange['middleware'])->toContain(ThrottleRequests::class) + ->and($exchange['middleware'])->not->toContain('fleetbase.protected') + ->and($redirect)->not->toBeNull() + ->and($redirect['action'])->toBe($controller . '@redirect') + ->and($redirect['middleware'])->not->toContain('fleetbase.protected'); + }); + + test('route file accepts the oauth callback over both get and post', function () { + $routes = routes_contract_rows(routes_contract_router()); + + $controller = Fleetbase\Http\Controllers\Internal\v1\OAuthController::class; + + // Apple requires response_mode=form_post whenever the name/email scopes are + // requested, so its callback arrives as a cross-site POST. Dropping POST here + // breaks Sign in with Apple and nothing else, which makes it easy to miss. + $get = routes_contract_find($routes, 'GET', 'int/v1/auth/oauth/{provider}/callback'); + $post = routes_contract_find($routes, 'POST', 'int/v1/auth/oauth/{provider}/callback'); + + expect($get)->not->toBeNull() + ->and($get['action'])->toBe($controller . '@callback') + ->and($post)->not->toBeNull() + ->and($post['action'])->toBe($controller . '@callback') + ->and($post['middleware'])->not->toContain('fleetbase.protected'); + }); + + test('route file registers literal oauth routes before the provider wildcard', function () { + $routes = routes_contract_rows(routes_contract_router()); + + // {provider} would otherwise swallow "providers" and "exchange", and the + // failure would look like an unknown-provider error rather than a routing bug. + expect(routes_contract_index($routes, 'GET', 'int/v1/auth/oauth/providers')) + ->toBeLessThan(routes_contract_index($routes, 'GET', 'int/v1/auth/oauth/{provider}/redirect')) + ->and(routes_contract_index($routes, 'POST', 'int/v1/auth/oauth/exchange')) + ->toBeLessThan(routes_contract_index($routes, 'POST', 'int/v1/auth/oauth/{provider}/callback')); + }); + test('route file keeps critical internal custom routes before dynamic resource routes', function () { $routes = routes_contract_rows(routes_contract_router()); From f0f4b8a5afd2919cb9d560eb5c7bd30b8a0a32d5 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sun, 20 Sep 2026 19:16:05 +0800 Subject: [PATCH 06/30] feat(oauth): accept a registration intent during signup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OAuth never creates a user or a company. An unrecognised identity yields a short-lived single-use intent, and the existing signup flow redeems it at the end of its own unchanged sequence — so an OAuth account is built by exactly the same pipeline as a password one rather than by a second implementation that would drift. Fleetbase\Support\OAuth is the whole surface a signup implementation needs, shaped like Support\TwoFactorAuth and Support\PlatformApi. OnboardRequest now accepts a password or an intent; phone, organization name and the word blacklists are unchanged. HandleAccountCreated gained an isNotVerified() guard. It previously gated only on isNotAdmin(), so an account whose address a provider had already vouched for would still have been emailed a code it had no reason to enter. No-op for password signup, which is never verified at that point. --- .../Internal/v1/OAuthController.php | 11 +- .../Internal/v1/OnboardController.php | 14 +- src/Http/Requests/OnboardRequest.php | 10 +- src/Listeners/HandleAccountCreated.php | 6 +- src/Rules/ValidOAuthRegistrationIntent.php | 34 ++ src/Support/OAuth.php | 235 ++++++++++ tests/Unit/Http/OAuthControllerTest.php | 9 + tests/Unit/Http/OnboardControllerTest.php | 242 ++++++++++ .../Listeners/HandleAccountCreatedTest.php | 212 +++++++++ .../Support/OAuthRegistrationIntentTest.php | 428 ++++++++++++++++++ 10 files changed, 1190 insertions(+), 11 deletions(-) create mode 100644 src/Rules/ValidOAuthRegistrationIntent.php create mode 100644 src/Support/OAuth.php create mode 100644 tests/Unit/Listeners/HandleAccountCreatedTest.php create mode 100644 tests/Unit/Support/OAuthRegistrationIntentTest.php diff --git a/src/Http/Controllers/Internal/v1/OAuthController.php b/src/Http/Controllers/Internal/v1/OAuthController.php index ac0b2fa0..0578d74b 100644 --- a/src/Http/Controllers/Internal/v1/OAuthController.php +++ b/src/Http/Controllers/Internal/v1/OAuthController.php @@ -16,6 +16,7 @@ use Fleetbase\Services\OAuth\OAuthFlowService; use Fleetbase\Services\OAuth\OAuthIdentityService; use Fleetbase\Services\OAuth\OAuthStateService; +use Fleetbase\Support\OAuth; use Fleetbase\Support\TwoFactorAuth; use Illuminate\Http\Request; use Illuminate\Support\Facades\RateLimiter; @@ -193,13 +194,9 @@ protected function registrationOutcome(OAuthUserProfile $profile) ); } - $intent = $this->states->issue( - OAuthState::PURPOSE_REGISTRATION_INTENT, - ['profile' => $profile->jsonSerialize()], - $this->config->ttl('registration_intent', 900), - $profile->provider, - OAuthFlowService::INTENT_SIGNUP - ); + // Issued through the facade so the intent's shape lives in exactly one place — + // the same place Fleetbase Cloud internals redeems it from. + $intent = OAuth::issueRegistrationIntent($profile); return response()->json([ 'status' => 'registration_required', diff --git a/src/Http/Controllers/Internal/v1/OnboardController.php b/src/Http/Controllers/Internal/v1/OnboardController.php index 4b7093ad..bc0750e7 100644 --- a/src/Http/Controllers/Internal/v1/OnboardController.php +++ b/src/Http/Controllers/Internal/v1/OnboardController.php @@ -8,6 +8,7 @@ use Fleetbase\Models\Company; use Fleetbase\Models\User; use Fleetbase\Models\VerificationCode; +use Fleetbase\Support\OAuth; use Illuminate\Http\Request; use Illuminate\Support\Carbon; use Illuminate\Support\Str; @@ -67,7 +68,13 @@ public function createAccount(OnboardRequest $request) $user = User::create($attributes); // set the user password - $user->password = $request->input('password'); + // + // Guarded because an OAuth signup has no password: users.password is nullable + // and $guarded, and AuthController::login already treats a passwordless account + // as un-loginable by password, which is what keeps it from being guessable. + if ($request->filled('password')) { + $user->password = $request->input('password'); + } // set the user type $user->setUserType($isAdmin ? 'admin' : 'user'); @@ -81,6 +88,11 @@ public function createAccount(OnboardRequest $request) // assign admin role $user->assignSingleRole('Administrator'); + // Link the provider identity, if this signup started from one. Placed after the + // account is fully built and before AccountCreated fires, so the listener sees + // an already-verified email and skips the redundant verification code. + OAuth::redeemRegistrationIntent($request->input('oauth_intent'), $user); + // send account created event event(new AccountCreated($user, $company)); diff --git a/src/Http/Requests/OnboardRequest.php b/src/Http/Requests/OnboardRequest.php index 567ab53a..7996fe0b 100644 --- a/src/Http/Requests/OnboardRequest.php +++ b/src/Http/Requests/OnboardRequest.php @@ -4,6 +4,7 @@ use Fleetbase\Rules\EmailDomainExcluded; use Fleetbase\Rules\ExcludeWords; +use Fleetbase\Rules\ValidOAuthRegistrationIntent; use Fleetbase\Rules\ValidPhoneNumber; use Illuminate\Validation\Rule; use Illuminate\Validation\Rules\Password; @@ -38,8 +39,13 @@ public function rules() 'name' => ['required', 'min:2', 'max:50', 'regex:/^(?!.*\b[a-z0-9]+(?:\.[a-z0-9]+){1,}\b)[a-zA-ZÀ-ÿ\'\-\s\.]+$/u', new ExcludeWords($this->excludedWords)], 'email' => ['required', 'email', Rule::unique('users', 'email')->whereNull('deleted_at'), new EmailDomainExcluded()], 'phone' => ['required', new ValidPhoneNumber(), Rule::unique('users', 'phone')->whereNull('deleted_at')], - 'password' => ['required', 'confirmed', 'string', Password::min(8)->mixedCase()->letters()->numbers()->symbols()->uncompromised()], - 'password_confirmation' => ['required', 'min:4', 'max:64'], + // A signup proves itself either with a password or with an OAuth + // registration intent. Everything else — phone, organization name, the + // word blacklists — is unchanged, so an OAuth account is held to exactly + // the same standard as a password one. + 'password' => ['required_without:oauth_intent', 'nullable', 'confirmed', 'string', Password::min(8)->mixedCase()->letters()->numbers()->symbols()->uncompromised()], + 'password_confirmation' => ['required_with:password', 'nullable', 'min:4', 'max:64'], + 'oauth_intent' => ['required_without:password', 'nullable', 'string', new ValidOAuthRegistrationIntent()], 'organization_name' => ['required', 'min:4', 'max:100', 'regex:/^(?!.*\b[a-z0-9]+(?:\.[a-z0-9]+){1,}\b)[a-zA-ZÀ-ÿ0-9\'\-\s\.]+$/u', new ExcludeWords($this->excludedWords)], ]; } diff --git a/src/Listeners/HandleAccountCreated.php b/src/Listeners/HandleAccountCreated.php index f8f87f71..5e463026 100644 --- a/src/Listeners/HandleAccountCreated.php +++ b/src/Listeners/HandleAccountCreated.php @@ -19,7 +19,11 @@ public function handle(AccountCreated $event) // Send user a verification email $user = $event->user; - if ($user && $user->isNotAdmin()) { + // isNotVerified() guards the OAuth signup case: a provider that vouched for the + // address means the account is already verified, and sending a code to it would + // be noise the user cannot act on. A password signup is never verified at this + // point, so this is a no-op there. + if ($user && $user->isNotAdmin() && $user->isNotVerified()) { // Create and send verification code try { VerificationCode::generateEmailVerificationFor($user); diff --git a/src/Rules/ValidOAuthRegistrationIntent.php b/src/Rules/ValidOAuthRegistrationIntent.php new file mode 100644 index 00000000..ba266eca --- /dev/null +++ b/src/Rules/ValidOAuthRegistrationIntent.php @@ -0,0 +1,34 @@ +isEnabled(); + } + + /** + * Whether an unrecognised provider identity may start a signup. + */ + public static function allowsRegistration(): bool + { + return static::config()->allowsRegistration(); + } + + /** + * Providers the console should offer, as [{id, label, icon}]. + * + * @return array + */ + public static function enabledProviders(): array + { + return static::registry()->toDiscoveryArray(); + } + + // ----------------------------------------------------------------------- + // Registration intent — the entire API a signup implementation needs + // ----------------------------------------------------------------------- + + /** + * Issue an intent proving a verified provider identity. + * + * Returned once and never stored: only its sha256 is persisted. + */ + public static function issueRegistrationIntent(OAuthUserProfile $profile): string + { + return static::states()->issue( + OAuthState::PURPOSE_REGISTRATION_INTENT, + ['profile' => $profile->jsonSerialize()], + static::config()->ttl('registration_intent', 900), + $profile->provider, + 'signup' + ); + } + + /** + * Read an intent without consuming it. + * + * Non-consuming by design: validation rules call this on every request, and + * burning the intent there would destroy the user's sign-in session whenever any + * unrelated field failed validation. + * + * @return array{provider: string, provider_user_id: string, email: ?string, email_verified: bool, name: ?string}|null + */ + public static function inspectRegistrationIntent(?string $token): ?array + { + $profile = static::profileFromIntent($token); + + if (!$profile instanceof OAuthUserProfile) { + return null; + } + + return [ + 'provider' => $profile->provider, + 'provider_user_id' => $profile->providerUserId, + 'email' => $profile->email, + 'email_verified' => $profile->emailVerified, + 'name' => $profile->name, + ]; + } + + /** + * Null-safe convenience for validation rules. + */ + public static function isValidRegistrationIntent(?string $token): bool + { + return static::inspectRegistrationIntent($token) !== null; + } + + /** + * Consume an intent and link the identity it proves to a freshly created user. + * + * Call this at the END of an otherwise unchanged signup, immediately before the + * AccountCreated event, so the account is built exactly as a password signup + * builds it and this only attaches the provider identity. + * + * Returns null rather than throwing on every failure path. A signup that has + * already created a user and a company must not be failed by a lost race on the + * identity row — the account is valid, it simply has no linked provider yet. + */ + public static function redeemRegistrationIntent(?string $token, User $user): ?OAuthIdentity + { + if ($token === null || $token === '') { + return null; + } + + try { + $consumed = static::states()->consume(OAuthState::PURPOSE_REGISTRATION_INTENT, $token); + } catch (OAuthStateException $e) { + return null; + } + + $profile = static::profileFromPayload($consumed['payload']); + + if (!$profile instanceof OAuthUserProfile) { + return null; + } + + try { + $identity = static::identities()->link($user, $profile); + } catch (OAuthException $e) { + // identity_already_linked: another account claimed this provider subject + // between the intent being issued and redeemed. The signup itself stands. + Log::warning('[OAuth] Could not link the identity for a new account.', [ + 'provider' => $profile->provider, + 'user' => $user->uuid, + 'reason' => $e->getMessage(), + ]); + + return null; + } + + static::states()->attachUser($consumed['state'], (string) $user->uuid); + static::promoteVerifiedEmail($user, $profile); + + return $identity; + } + + /** + * Mark the account's email verified when the provider vouched for that exact + * address. + * + * This is what lets an OAuth signup skip the emailed verification code. It is + * deliberately narrow: the provider must have asserted the address as verified, + * and it must be the address the account was actually created with. A user who + * types a different email during signup than the one the provider returned still + * has to verify it the normal way. + */ + protected static function promoteVerifiedEmail(User $user, OAuthUserProfile $profile): void + { + if (!$profile->hasVerifiedEmail() || !empty($user->email_verified_at)) { + return; + } + + if (strcasecmp((string) $profile->email, (string) $user->email) !== 0) { + return; + } + + $user->email_verified_at = now(); + $user->save(); + } + + /** + * @param array $payload + */ + protected static function profileFromPayload(array $payload): ?OAuthUserProfile + { + $profile = $payload['profile'] ?? null; + + if (!is_array($profile)) { + return null; + } + + $profile = OAuthUserProfile::fromArray($profile); + + return $profile->providerUserId === '' ? null : $profile; + } + + protected static function profileFromIntent(?string $token): ?OAuthUserProfile + { + if ($token === null || $token === '') { + return null; + } + + $payload = static::states()->inspect(OAuthState::PURPOSE_REGISTRATION_INTENT, $token); + + return is_array($payload) ? static::profileFromPayload($payload) : null; + } + + protected static function states(): OAuthStateService + { + return app(OAuthStateService::class); + } + + protected static function identities(): OAuthIdentityService + { + return app(OAuthIdentityService::class); + } + + protected static function config(): OAuthConfigRepository + { + return app(OAuthConfigRepository::class); + } + + protected static function registry(): OAuthProviderRegistry + { + return app(OAuthProviderRegistry::class); + } +} diff --git a/tests/Unit/Http/OAuthControllerTest.php b/tests/Unit/Http/OAuthControllerTest.php index 9b5d0b3e..192e0760 100644 --- a/tests/Unit/Http/OAuthControllerTest.php +++ b/tests/Unit/Http/OAuthControllerTest.php @@ -443,6 +443,15 @@ function oauth_controller_services(): array $registry = new OAuthProviderRegistry($config, Request::create('/'), new IdTokenVerifier()); $flow = new OAuthFlowService($registry, $states, $config); + // Bound as well as injected: Support\OAuth resolves from the container, and in + // production CoreServiceProvider binds these scoped so the facade and the + // controller share one instance per request. Registering the same objects here + // keeps the test faithful to that rather than giving the facade a second set. + app()->instance(OAuthStateService::class, $states); + app()->instance(OAuthIdentityService::class, $identities); + app()->instance(OAuthConfigRepository::class, $config); + app()->instance(OAuthProviderRegistry::class, $registry); + return [new OAuthController($registry, $flow, $states, $identities, $config), $states, $identities, $config, $flow]; } diff --git a/tests/Unit/Http/OnboardControllerTest.php b/tests/Unit/Http/OnboardControllerTest.php index bc477700..b2fceef8 100644 --- a/tests/Unit/Http/OnboardControllerTest.php +++ b/tests/Unit/Http/OnboardControllerTest.php @@ -19,6 +19,33 @@ eval('namespace Fleetbase\\Http\\Controllers\\Internal\\v1; function event($event = null) { return $event; }'); } +// Shared event shim — see the note in OAuthIdentityServiceTest. Must stay identical +// across every file that declares it: the first one Pest loads wins, and a +// non-recording variant loading first would blind another file's event assertions. +if (!function_exists('oauth_test_record_event')) { + function oauth_test_record_event(object $event): void + { + $GLOBALS['oauth_test_events'][] = $event; + } + + /** + * @return array + */ + function oauth_test_events(): array + { + return $GLOBALS['oauth_test_events'] ?? []; + } + + function oauth_test_reset_events(): void + { + $GLOBALS['oauth_test_events'] = []; + } +} + +if (!function_exists('Fleetbase\\Services\\OAuth\\event')) { + eval('namespace Fleetbase\\Services\\OAuth; function event($event = null) { if (is_object($event)) { \\oauth_test_record_event($event); } return $event; }'); +} + class OnboardControllerTaggedCacheFake { public function tags(array|string $tags): self @@ -579,3 +606,218 @@ function onboard_create_account_request(array $input = []): OnboardRequest ->and($company->onboarding_completed_at)->toBe('2026-07-17 08:00:00') ->and($company->onboarding_completed_by_uuid)->toBe('22222222-2222-4222-8222-222222222222'); }); + +/** + * Add the OAuth tables and bind the services Support\OAuth resolves. + * + * Kept out of onboard_controller_database() so the password-signup tests keep + * exercising a schema with no OAuth tables at all — which is what a self-hosted + * install that has never enabled OAuth actually looks like. + */ +function onboard_controller_oauth_setup(Capsule $capsule): void +{ + $schema = $capsule->getConnection('mysql')->getSchemaBuilder(); + + $schema->create('oauth_identities', function ($table) { + $table->string('uuid')->primary(); + $table->string('user_uuid'); + $table->string('provider', 40); + $table->string('provider_user_id', 191); + $table->string('provider_email')->nullable(); + $table->boolean('email_verified')->default(false); + $table->text('meta')->nullable(); + $table->timestamp('last_login_at')->nullable(); + $table->timestamps(); + $table->unique(['provider', 'provider_user_id']); + }); + $schema->create('oauth_states', function ($table) { + $table->string('uuid')->primary(); + $table->string('purpose', 24); + $table->string('token_hash', 64)->unique(); + $table->string('provider', 40)->nullable(); + $table->string('intent', 16)->nullable(); + $table->string('user_uuid')->nullable(); + $table->text('payload')->nullable(); + $table->string('ip_hash', 64)->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamp('consumed_at')->nullable(); + $table->timestamps(); + }); + + config(['oauth.ttl' => ['registration_intent' => 900], 'oauth.providers' => []]); + + $encrypter = new OnboardControllerOAuthEncrypterFake(); + app()->instance(Fleetbase\Services\OAuth\OAuthStateService::class, new Fleetbase\Services\OAuth\OAuthStateService($encrypter)); + app()->instance(Fleetbase\Services\OAuth\OAuthIdentityService::class, new Fleetbase\Services\OAuth\OAuthIdentityService()); + app()->instance(Fleetbase\Services\OAuth\OAuthConfigRepository::class, new Fleetbase\Services\OAuth\OAuthConfigRepository($encrypter)); +} + +class OnboardControllerOAuthEncrypterFake implements Illuminate\Contracts\Encryption\Encrypter +{ + public function encrypt($value, $serialize = true) + { + return 'enc:' . base64_encode($serialize ? serialize($value) : (string) $value); + } + + public function decrypt($payload, $unserialize = true) + { + $value = base64_decode(substr((string) $payload, 4), true); + + return $unserialize ? unserialize((string) $value) : (string) $value; + } + + public function getKey() + { + return 'test-key'; + } +} + +function onboard_controller_intent(array $overrides = []): string +{ + return Fleetbase\Support\OAuth::issueRegistrationIntent(new Fleetbase\Auth\OAuth\OAuthUserProfile( + $overrides['provider'] ?? 'google', + $overrides['providerUserId'] ?? 'subject-1', + $overrides['email'] ?? 'katherine@example.test', + $overrides['emailVerified'] ?? true, + $overrides['name'] ?? 'Katherine Johnson' + )); +} + +test('onboard controller creates an oauth account identical to a password account', function () { + $capsule = onboard_controller_database(); + onboard_controller_oauth_setup($capsule); + Carbon::setTestNow(Carbon::parse('2026-07-18 12:00:00', 'UTC')); + onboard_controller_seed_user($capsule); + + $intent = onboard_controller_intent(); + + $response = onboard_controller()->createAccount(onboard_create_account_request([ + 'name' => 'Katherine Johnson', + 'email' => 'katherine@example.test', + 'organization_name' => 'Orbital Logistics', + 'password' => null, + 'oauth_intent' => $intent, + ])); + + $user = User::where('email', 'katherine@example.test')->first(); + $company = Company::where('name', 'Orbital Logistics')->first(); + $pivot = $capsule->getConnection('mysql')->table('company_users')->where('user_uuid', $user->uuid)->first(); + + // Every side effect of a password signup must still be present: company created + // first, owner set, pivot row, Administrator role. + expect($response->getStatusCode())->toBe(200) + ->and($response->getData(true)['status'])->toBe('success') + ->and($user->type)->toBe('user') + ->and($user->status)->toBe('active') + ->and($user->company_uuid)->toBe($company->uuid) + ->and($company->owner_uuid)->toBe($user->uuid) + ->and($pivot->company_uuid)->toBe($company->uuid) + ->and($pivot->status)->toBe('active') + ->and($capsule->getConnection('mysql')->table('model_has_roles')->where('model_uuid', $pivot->uuid)->where('role_id', 'role-admin')->exists())->toBeTrue(); +}); + +test('onboard controller leaves an oauth account passwordless and links the identity', function () { + $capsule = onboard_controller_database(); + onboard_controller_oauth_setup($capsule); + onboard_controller_seed_user($capsule); + + $intent = onboard_controller_intent(); + + onboard_controller()->createAccount(onboard_create_account_request([ + 'name' => 'Katherine Johnson', + 'email' => 'katherine@example.test', + 'organization_name' => 'Orbital Logistics', + 'password' => null, + 'oauth_intent' => $intent, + ])); + + $user = User::where('email', 'katherine@example.test')->first(); + $identity = Fleetbase\Models\OAuthIdentity::query()->first(); + + expect($user->password)->toBeEmpty() + ->and($identity->user_uuid)->toBe($user->uuid) + ->and($identity->provider)->toBe('google') + ->and($identity->provider_user_id)->toBe('subject-1') + // A verified provider address is what lets the signup skip email verification. + ->and($user->email_verified_at)->not->toBeNull() + // Single use: the intent is spent. + ->and(Fleetbase\Support\OAuth::isValidRegistrationIntent($intent))->toBeFalse(); +}); + +test('onboard controller still sets a password when one is supplied alongside an intent', function () { + $capsule = onboard_controller_database(); + onboard_controller_oauth_setup($capsule); + onboard_controller_seed_user($capsule); + + onboard_controller()->createAccount(onboard_create_account_request([ + 'name' => 'Katherine Johnson', + 'email' => 'katherine@example.test', + 'organization_name' => 'Orbital Logistics', + 'oauth_intent' => onboard_controller_intent(), + ])); + + $user = User::where('email', 'katherine@example.test')->first(); + + expect($user->password)->not->toBeEmpty() + ->and(Fleetbase\Models\OAuthIdentity::query()->count())->toBe(1); +}); + +test('onboard controller completes a signup whose intent has already expired', function () { + $capsule = onboard_controller_database(); + onboard_controller_oauth_setup($capsule); + onboard_controller_seed_user($capsule); + + // The request-level rule rejects an expired intent before this point; if one ever + // reaches the controller the account must still be created rather than half-built. + onboard_controller()->createAccount(onboard_create_account_request([ + 'name' => 'Katherine Johnson', + 'email' => 'katherine@example.test', + 'organization_name' => 'Orbital Logistics', + 'password' => null, + 'oauth_intent' => 'rti_' . str_repeat('z', 64), + ])); + + $user = User::where('email', 'katherine@example.test')->first(); + + expect($user)->not->toBeNull() + ->and($user->email_verified_at)->toBeNull() + ->and(Fleetbase\Models\OAuthIdentity::query()->count())->toBe(0); +}); + +test('onboard controller password signup is unaffected when oauth is not installed', function () { + $capsule = onboard_controller_database(); + onboard_controller_seed_user($capsule); + + // No OAuth tables, no bindings — a self-hosted install that never enabled OAuth. + $response = onboard_controller()->createAccount(onboard_create_account_request([ + 'name' => 'Katherine Johnson', + 'email' => 'katherine@example.test', + 'organization_name' => 'Orbital Logistics', + ])); + + $user = User::where('email', 'katherine@example.test')->first(); + + expect($response->getStatusCode())->toBe(200) + ->and($user->password)->not->toBeEmpty() + ->and($user->email_verified_at)->toBeNull(); +}); + +test('onboard request accepts either a password or an intent but not neither', function () { + onboard_controller_database(); + + $translator = new Illuminate\Translation\Translator(new Illuminate\Translation\ArrayLoader(), 'en'); + $factory = new Illuminate\Validation\Factory($translator); + + // Only the credential rules matter here; the rest of the request is unchanged. + $rules = [ + 'password' => ['required_without:oauth_intent', 'nullable', 'string'], + 'password_confirmation' => ['required_with:password', 'nullable'], + 'oauth_intent' => ['required_without:password', 'nullable', 'string'], + ]; + + expect($factory->make(['password' => 'pw', 'password_confirmation' => 'pw'], $rules)->fails())->toBeFalse() + ->and($factory->make(['oauth_intent' => 'rti_abc'], $rules)->fails())->toBeFalse() + ->and($factory->make([], $rules)->fails())->toBeTrue() + // A password without its confirmation is still rejected. + ->and($factory->make(['password' => 'pw'], $rules)->fails())->toBeTrue(); +}); diff --git a/tests/Unit/Listeners/HandleAccountCreatedTest.php b/tests/Unit/Listeners/HandleAccountCreatedTest.php new file mode 100644 index 00000000..1d0d24db --- /dev/null +++ b/tests/Unit/Listeners/HandleAccountCreatedTest.php @@ -0,0 +1,212 @@ + + */ + public array $sent = []; + + public function to(mixed $recipient): self + { + return $this; + } + + public function send(mixed $mailable): void + { + $this->sent[] = $mailable; + } +} + +class HandleAccountCreatedCacheFake +{ + private array $values = []; + + public function get(string $key, mixed $default = null): mixed + { + return $this->values[$key] ?? $default; + } + + public function put(string $key, mixed $value, mixed $ttl = null): bool + { + $this->values[$key] = $value; + + return true; + } + + public function forget(string $key): bool + { + unset($this->values[$key]); + + return true; + } + + public function increment(string $key, int $value = 1): int + { + return $this->values[$key] = (int) ($this->values[$key] ?? 0) + $value; + } + + public function tags(array|string $tags): self + { + return $this; + } + + public function flush(): bool + { + $this->values = []; + + return true; + } +} + +class HandleAccountCreatedResponseCacheFake +{ + public function clear(): void + { + } +} + +function handle_account_created_database(): HandleAccountCreatedMailFake +{ + EloquentModel::clearBootedModels(); + + $connection = ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '']; + + $container = bind_test_container([ + 'api.cache.enabled' => false, + 'activitylog.enabled' => false, + 'app.timezone' => 'UTC', + 'database.default' => 'mysql', + 'database.connections.mysql' => $connection, + 'fleetbase.connection.db' => 'mysql', + ]); + $container->instance(Illuminate\Contracts\Config\Repository::class, $container->make('config')); + $container->instance('cache', new HandleAccountCreatedCacheFake()); + $container->instance('responsecache', new HandleAccountCreatedResponseCacheFake()); + + $mail = new HandleAccountCreatedMailFake(); + $container->instance('mailer', $mail); + Mail::swap($mail); + + foreach (['cache', 'responsecache', 'log'] as $facade) { + Facade::clearResolvedInstance($facade); + } + + $capsule = new Capsule($container); + $capsule->addConnection($connection, 'mysql'); + $capsule->setEventDispatcher(new Dispatcher($container)); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + + $databaseManager = $capsule->getDatabaseManager(); + $databaseManager->setDefaultConnection('mysql'); + $container->instance('db', $databaseManager); + Facade::clearResolvedInstance('db'); + + $schema = app('db')->connection('mysql')->getSchemaBuilder(); + $schema->create('verification_codes', function ($table) { + $table->string('uuid')->primary(); + $table->string('subject_uuid')->nullable(); + $table->string('subject_type')->nullable(); + $table->string('code')->nullable(); + $table->string('for')->nullable(); + $table->string('status')->nullable(); + $table->text('meta')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + // VerificationCode soft-deletes and applies an expiry global scope, so both + // columns have to exist for a plain count() to run. + $table->timestamp('deleted_at')->nullable(); + }); + + return $mail; +} + +function handle_account_created_user(array $attributes = []): User +{ + $user = new User(); + $user->forceFill(array_merge([ + 'uuid' => 'user-1', + 'email' => 'ada@example.com', + 'name' => 'Ada', + 'type' => 'user', + 'email_verified_at' => null, + ], $attributes)); + + return $user; +} + +function handle_account_created_fire(User $user): void +{ + (new HandleAccountCreated())->handle(new AccountCreated($user, new Company())); +} + +it('sends a verification code for an ordinary password signup', function () { + $mail = handle_account_created_database(); + + handle_account_created_fire(handle_account_created_user()); + + // A password signup is never verified at this point, so the added guard is a + // no-op and existing behaviour is unchanged. + expect(VerificationCode::query()->count())->toBe(1) + ->and(VerificationCode::query()->first()->for)->toBe('email_verification') + ->and($mail->sent)->toHaveCount(1); +}); + +it('sends no verification code when the provider already verified the address', function () { + $mail = handle_account_created_database(); + + // This is what lets an OAuth signup skip the emailed code: Support\OAuth promotes + // email_verified_at before AccountCreated fires, and this guard reads it. Without + // it the user would get a code they have no reason to enter. + handle_account_created_fire(handle_account_created_user(['email_verified_at' => '2024-01-15 10:00:00'])); + + expect(VerificationCode::query()->count())->toBe(0) + ->and($mail->sent)->toBeEmpty(); +}); + +it('sends no verification code to an admin', function () { + $mail = handle_account_created_database(); + + // The first user of an install skips verification entirely. + handle_account_created_fire(handle_account_created_user(['type' => 'admin'])); + + expect(VerificationCode::query()->count())->toBe(0) + ->and($mail->sent)->toBeEmpty(); +}); + +it('treats a phone verified account as verified too', function () { + $mail = handle_account_created_database(); + + handle_account_created_fire(handle_account_created_user(['phone_verified_at' => '2024-01-15 10:00:00'])); + + expect(VerificationCode::query()->count())->toBe(0) + ->and($mail->sent)->toBeEmpty(); +}); + +it('ignores an event carrying no user', function () { + handle_account_created_database(); + + $event = new AccountCreated(handle_account_created_user(), new Company()); + $event->user = null; + + handle_account_created_fire_null($event); + + expect(VerificationCode::query()->count())->toBe(0); +}); + +function handle_account_created_fire_null(AccountCreated $event): void +{ + (new HandleAccountCreated())->handle($event); +} diff --git a/tests/Unit/Support/OAuthRegistrationIntentTest.php b/tests/Unit/Support/OAuthRegistrationIntentTest.php new file mode 100644 index 00000000..35230c21 --- /dev/null +++ b/tests/Unit/Support/OAuthRegistrationIntentTest.php @@ -0,0 +1,428 @@ + + */ + function oauth_test_events(): array + { + return $GLOBALS['oauth_test_events'] ?? []; + } + + function oauth_test_reset_events(): void + { + $GLOBALS['oauth_test_events'] = []; + } +} + +if (!function_exists('Fleetbase\\Services\\OAuth\\event')) { + eval('namespace Fleetbase\\Services\\OAuth; function event($event = null) { if (is_object($event)) { \\oauth_test_record_event($event); } return $event; }'); +} + +class RegistrationIntentEncrypterFake implements Encrypter +{ + public function encrypt($value, $serialize = true) + { + return 'enc:' . base64_encode($serialize ? serialize($value) : (string) $value); + } + + public function decrypt($payload, $unserialize = true) + { + $value = base64_decode(substr((string) $payload, 4), true); + + return $unserialize ? unserialize((string) $value) : (string) $value; + } + + public function getKey() + { + return 'test-key'; + } +} + +class RegistrationIntentCacheFake +{ + private array $values = []; + + public function rememberForever(string $key, Closure $callback): mixed + { + return $this->values[$key] ??= $callback(); + } + + public function remember(string $key, mixed $ttl, Closure $callback): mixed + { + return $this->values[$key] ??= $callback(); + } + + public function get(string $key, mixed $default = null): mixed + { + return $this->values[$key] ?? $default; + } + + public function put(string $key, mixed $value, mixed $ttl = null): bool + { + $this->values[$key] = $value; + + return true; + } + + public function forget(string $key): bool + { + unset($this->values[$key]); + + return true; + } + + public function increment(string $key, int $value = 1): int + { + return $this->values[$key] = (int) ($this->values[$key] ?? 0) + $value; + } + + public function tags(array|string $tags): self + { + return $this; + } + + public function flush(): bool + { + $this->values = []; + + return true; + } + + public function getPrefix(): string + { + return ''; + } +} + +class RegistrationIntentResponseCacheFake +{ + public function clear(): void + { + } +} + +function registration_intent_database(): Capsule +{ + EloquentModel::clearBootedModels(); + oauth_test_reset_events(); + + $connection = ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '']; + + $container = bind_test_container([ + 'api.cache.enabled' => false, + 'activitylog.enabled' => false, + 'app.key' => 'base64:' . base64_encode(str_repeat('a', 32)), + 'database.default' => 'mysql', + 'database.connections.mysql' => $connection, + 'fleetbase.connection.db' => 'mysql', + 'oauth.enabled' => true, + 'oauth.allow_registration' => true, + 'oauth.ttl' => ['registration_intent' => 900], + 'oauth.providers' => [], + ]); + $container->instance(Illuminate\Contracts\Config\Repository::class, $container->make('config')); + $container->instance('cache', new RegistrationIntentCacheFake()); + $container->instance('responsecache', new RegistrationIntentResponseCacheFake()); + Facade::clearResolvedInstance('cache'); + Facade::clearResolvedInstance('responsecache'); + Facade::clearResolvedInstance('log'); + + $capsule = new Capsule($container); + $capsule->addConnection($connection, 'mysql'); + $capsule->setEventDispatcher(new Dispatcher($container)); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + + $databaseManager = $capsule->getDatabaseManager(); + $databaseManager->setDefaultConnection('mysql'); + $container->instance('db', $databaseManager); + Facade::clearResolvedInstance('db'); + + $schema = app('db')->connection('mysql')->getSchemaBuilder(); + $schema->create('users', function ($table) { + $table->string('uuid')->primary(); + $table->string('email')->nullable(); + $table->string('name')->nullable(); + $table->string('google_user_id')->nullable()->unique(); + $table->timestamp('email_verified_at')->nullable(); + $table->timestamp('deleted_at')->nullable(); + $table->timestamps(); + }); + $schema->create('settings', function ($table) { + $table->increments('id'); + $table->string('key')->unique(); + $table->text('value')->nullable(); + }); + $schema->create('oauth_identities', function ($table) { + $table->string('uuid')->primary(); + $table->string('user_uuid'); + $table->string('provider', 40); + $table->string('provider_user_id', 191); + $table->string('provider_email')->nullable(); + $table->boolean('email_verified')->default(false); + $table->text('meta')->nullable(); + $table->timestamp('last_login_at')->nullable(); + $table->timestamps(); + $table->unique(['provider', 'provider_user_id']); + }); + $schema->create('oauth_states', function ($table) { + $table->string('uuid')->primary(); + $table->string('purpose', 24); + $table->string('token_hash', 64)->unique(); + $table->string('provider', 40)->nullable(); + $table->string('intent', 16)->nullable(); + $table->string('user_uuid')->nullable(); + $table->text('payload')->nullable(); + $table->string('ip_hash', 64)->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamp('consumed_at')->nullable(); + $table->timestamps(); + }); + + $encrypter = new RegistrationIntentEncrypterFake(); + app()->instance(OAuthStateService::class, new OAuthStateService($encrypter)); + app()->instance(OAuthIdentityService::class, new OAuthIdentityService()); + app()->instance(OAuthConfigRepository::class, new OAuthConfigRepository($encrypter)); + + return $capsule; +} + +function registration_intent_user(string $uuid = 'user-1', array $attributes = []): User +{ + app('db')->connection('mysql')->table('users')->insert(array_merge([ + 'uuid' => $uuid, + 'email' => 'ada@example.com', + 'name' => 'Ada', + 'created_at' => Carbon::now(), + 'updated_at' => Carbon::now(), + ], $attributes)); + + return User::query()->findOrFail($uuid); +} + +function registration_intent_profile(array $overrides = []): OAuthUserProfile +{ + return new OAuthUserProfile( + $overrides['provider'] ?? 'google', + $overrides['providerUserId'] ?? 'subject-1', + $overrides['email'] ?? 'ada@example.com', + $overrides['emailVerified'] ?? true, + $overrides['name'] ?? 'Ada Lovelace' + ); +} + +it('issues an intent that stores only its hash', function () { + registration_intent_database(); + + $token = OAuth::issueRegistrationIntent(registration_intent_profile()); + $row = OAuthState::query()->first(); + + expect($token)->toStartWith('rti_') + ->and($row->purpose)->toBe(OAuthState::PURPOSE_REGISTRATION_INTENT) + ->and($row->provider)->toBe('google') + ->and($row->intent)->toBe('signup') + ->and($row->token_hash)->toBe(hash('sha256', $token)) + ->and($row->payload)->not->toContain('subject-1'); +}); + +it('inspects an intent without consuming it', function () { + registration_intent_database(); + $token = OAuth::issueRegistrationIntent(registration_intent_profile()); + + // Validation rules call this on every request; burning the intent here would + // destroy the sign-in whenever any other field failed validation. + expect(OAuth::inspectRegistrationIntent($token))->toBe([ + 'provider' => 'google', + 'provider_user_id' => 'subject-1', + 'email' => 'ada@example.com', + 'email_verified' => true, + 'name' => 'Ada Lovelace', + ]) + ->and(OAuth::isValidRegistrationIntent($token))->toBeTrue() + ->and(OAuth::isValidRegistrationIntent($token))->toBeTrue() + ->and(OAuthState::query()->first()->consumed_at)->toBeNull(); +}); + +it('reports an absent expired or consumed intent as invalid', function () { + registration_intent_database(); + Carbon::setTestNow(Carbon::parse('2026-09-18 10:00:00', 'UTC')); + + $consumed = OAuth::issueRegistrationIntent(registration_intent_profile()); + OAuth::redeemRegistrationIntent($consumed, registration_intent_user('user-1')); + + $expiring = OAuth::issueRegistrationIntent(registration_intent_profile(['providerUserId' => 'subject-2'])); + + expect(OAuth::isValidRegistrationIntent(null))->toBeFalse() + ->and(OAuth::isValidRegistrationIntent(''))->toBeFalse() + ->and(OAuth::isValidRegistrationIntent('rti_' . str_repeat('z', 64)))->toBeFalse() + ->and(OAuth::isValidRegistrationIntent($consumed))->toBeFalse() + ->and(OAuth::isValidRegistrationIntent($expiring))->toBeTrue(); + + Carbon::setTestNow(Carbon::parse('2026-09-18 10:20:00', 'UTC')); + expect(OAuth::isValidRegistrationIntent($expiring))->toBeFalse(); + + Carbon::setTestNow(); +}); + +it('redeems an intent into a linked identity', function () { + registration_intent_database(); + $token = OAuth::issueRegistrationIntent(registration_intent_profile()); + $user = registration_intent_user(); + + $identity = OAuth::redeemRegistrationIntent($token, $user); + + expect($identity)->toBeInstanceOf(OAuthIdentity::class) + ->and($identity->user_uuid)->toBe('user-1') + ->and($identity->provider)->toBe('google') + ->and($identity->provider_user_id)->toBe('subject-1') + // The legacy per-provider column is kept in sync for anything still reading it. + ->and(User::query()->find('user-1')->google_user_id)->toBe('subject-1') + // The redeemed row records who it resolved to, for the audit trail. + ->and(OAuthState::query()->first()->user_uuid)->toBe('user-1'); +}); + +it('marks the account verified when the provider vouched for that exact address', function () { + registration_intent_database(); + $token = OAuth::issueRegistrationIntent(registration_intent_profile()); + $user = registration_intent_user(); + + OAuth::redeemRegistrationIntent($token, $user); + + // This is what lets an OAuth signup skip the emailed verification code. + expect(User::query()->find('user-1')->email_verified_at)->not->toBeNull(); +}); + +it('does not mark the account verified when the address does not match or was not vouched for', function (array $profile, array $user) { + registration_intent_database(); + $token = OAuth::issueRegistrationIntent(registration_intent_profile($profile)); + + OAuth::redeemRegistrationIntent($token, registration_intent_user('user-1', $user)); + + expect(User::query()->find('user-1')->email_verified_at)->toBeNull(); +})->with([ + // Typed a different address during signup than the provider returned. + 'address mismatch' => [['email' => 'ada@example.com'], ['email' => 'different@example.com']], + // The provider did not assert the address as verified. + 'unverified email' => [['emailVerified' => false], ['email' => 'ada@example.com']], + 'no provider email' => [['email' => null, 'emailVerified' => false], ['email' => 'ada@example.com']], +]); + +it('matches the verified address case insensitively', function () { + registration_intent_database(); + $token = OAuth::issueRegistrationIntent(registration_intent_profile(['email' => 'Ada@Example.com'])); + + OAuth::redeemRegistrationIntent($token, registration_intent_user('user-1', ['email' => 'ada@example.com'])); + + expect(User::query()->find('user-1')->email_verified_at)->not->toBeNull(); +}); + +it('leaves an already verified account alone', function () { + registration_intent_database(); + Carbon::setTestNow(Carbon::parse('2026-09-18 10:00:00', 'UTC')); + + $token = OAuth::issueRegistrationIntent(registration_intent_profile()); + $user = registration_intent_user('user-1', ['email_verified_at' => '2024-01-01 00:00:00']); + + OAuth::redeemRegistrationIntent($token, $user); + + expect(User::query()->find('user-1')->email_verified_at->toDateTimeString())->toBe('2024-01-01 00:00:00'); + + Carbon::setTestNow(); +}); + +it('fires the linked event once', function () { + registration_intent_database(); + $token = OAuth::issueRegistrationIntent(registration_intent_profile()); + + OAuth::redeemRegistrationIntent($token, registration_intent_user()); + + $events = oauth_test_events(); + expect($events)->toHaveCount(1) + ->and($events[0])->toBeInstanceOf(Fleetbase\Events\OAuthIdentityLinked::class); +}); + +it('is null safe so a caller never needs a guard', function () { + registration_intent_database(); + $user = registration_intent_user(); + + expect(OAuth::redeemRegistrationIntent(null, $user))->toBeNull() + ->and(OAuth::redeemRegistrationIntent('', $user))->toBeNull() + ->and(OAuth::redeemRegistrationIntent('rti_' . str_repeat('z', 64), $user))->toBeNull() + ->and(OAuthIdentity::query()->count())->toBe(0); +}); + +it('cannot be redeemed twice', function () { + registration_intent_database(); + $token = OAuth::issueRegistrationIntent(registration_intent_profile()); + + OAuth::redeemRegistrationIntent($token, registration_intent_user('user-1')); + $second = OAuth::redeemRegistrationIntent($token, registration_intent_user('user-2', ['email' => 'b@example.com'])); + + expect($second)->toBeNull() + ->and(OAuthIdentity::query()->count())->toBe(1) + ->and(OAuthIdentity::query()->first()->user_uuid)->toBe('user-1'); +}); + +it('does not fail a signup when the identity was claimed in the meantime', function () { + registration_intent_database(); + $token = OAuth::issueRegistrationIntent(registration_intent_profile()); + + // Someone else linked this provider subject between the intent being issued and + // redeemed. The account has already been created by this point, so the signup + // must stand — it simply has no linked provider yet. + OAuthIdentity::query()->create([ + 'user_uuid' => 'someone-else', + 'provider' => 'google', + 'provider_user_id' => 'subject-1', + ]); + + $result = OAuth::redeemRegistrationIntent($token, registration_intent_user('user-1')); + + expect($result)->toBeNull() + ->and(OAuthIdentity::query()->where('provider_user_id', 'subject-1')->first()->user_uuid)->toBe('someone-else') + ->and(array_filter(app('log')->entries, fn ($entry) => $entry[0] === 'warning'))->not->toBeEmpty(); +}); + +it('exposes the installation level switches', function () { + registration_intent_database(); + + expect(OAuth::isEnabled())->toBeTrue() + ->and(OAuth::allowsRegistration())->toBeTrue(); +}); + +it('validates an intent through the validation rule without consuming it', function () { + registration_intent_database(); + $token = OAuth::issueRegistrationIntent(registration_intent_profile()); + $rule = new ValidOAuthRegistrationIntent(); + + expect($rule->passes('oauth_intent', $token))->toBeTrue() + ->and($rule->passes('oauth_intent', $token))->toBeTrue() + ->and($rule->passes('oauth_intent', 'rti_nope'))->toBeFalse() + ->and($rule->passes('oauth_intent', null))->toBeFalse() + ->and($rule->passes('oauth_intent', ['an', 'array']))->toBeFalse() + ->and($rule->message())->toBeString() + ->and(OAuthState::query()->first()->consumed_at)->toBeNull(); +}); From 93f405b49d55287953db4863510d8926ef42d910 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 21 Sep 2026 13:17:15 +0800 Subject: [PATCH 07/30] feat(oauth): let administrators configure providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET/POST settings/oauth-config and POST settings/test-oauth-config, following the existing get*/save*/test* convention and restricted to admins by AdminRequest. The read never returns a secret, not even to an admin — each is reduced to whether it is set plus a short hint. It carries every provider's schema and the computed callback URL, so the console renders the form without hardcoding a provider and an operator can copy the exact redirect URI each provider requires. The write filters every field against the driver schemas before anything is stored: an unknown provider id, or a field a driver does not declare — a driver class name included — is ignored. An empty secret keeps the stored value. refreshConfigCache() is deliberately not called: nothing here lives in config() at runtime. The check confirms credentials are present and decryptable, and for Apple that the .p8 actually mints a client secret — the failure an operator is most likely to hit. It does not call the provider, so a revoked secret still only surfaces at first sign-in. --- src/Auth/OAuth/OAuthProviderRegistry.php | 33 ++ .../Internal/v1/SettingController.php | 152 ++++++ .../Requests/Admin/SaveOAuthConfigRequest.php | 45 ++ src/routes.php | 3 + .../Unit/Http/SettingControllerOAuthTest.php | 433 ++++++++++++++++++ tests/Unit/RoutesContractTest.php | 20 + 6 files changed, 686 insertions(+) create mode 100644 src/Http/Requests/Admin/SaveOAuthConfigRequest.php create mode 100644 tests/Unit/Http/SettingControllerOAuthTest.php diff --git a/src/Auth/OAuth/OAuthProviderRegistry.php b/src/Auth/OAuth/OAuthProviderRegistry.php index 40a10f0b..381aa890 100644 --- a/src/Auth/OAuth/OAuthProviderRegistry.php +++ b/src/Auth/OAuth/OAuthProviderRegistry.php @@ -110,6 +110,39 @@ public function toDiscoveryArray(): array return $providers; } + /** + * Every defined provider with its label, icon and config schema, for the admin + * settings form. + * + * Unlike toDiscoveryArray() this includes providers that are switched off or not + * yet configured — the admin needs to see them in order to configure them. The + * console renders the form entirely from this, which is what lets a provider be + * added later without a console change. + * + * @return array}> + */ + public function definitions(): array + { + $definitions = []; + + foreach ($this->ids() as $id) { + $class = $this->driverClass($id); + + if ($class === null) { + continue; + } + + $definitions[] = [ + 'id' => $id, + 'label' => $class::label(), + 'icon' => $class::icon(), + 'schema' => $class::configSchema(), + ]; + } + + return $definitions; + } + /** * The config schema for every defined provider, for the admin settings form. * diff --git a/src/Http/Controllers/Internal/v1/SettingController.php b/src/Http/Controllers/Internal/v1/SettingController.php index d1e1be2f..790cbaf4 100644 --- a/src/Http/Controllers/Internal/v1/SettingController.php +++ b/src/Http/Controllers/Internal/v1/SettingController.php @@ -2,11 +2,19 @@ namespace Fleetbase\Http\Controllers\Internal\v1; +use Fleetbase\Auth\OAuth\AppleClientSecretFactory; +use Fleetbase\Auth\OAuth\Drivers\AppleDriver; +use Fleetbase\Auth\OAuth\Exceptions\OAuthProviderNotConfiguredException; +use Fleetbase\Auth\OAuth\Exceptions\UnknownOAuthProviderException; +use Fleetbase\Auth\OAuth\OAuthProviderRegistry; use Fleetbase\Http\Controllers\Controller; +use Fleetbase\Http\Requests\Admin\SaveOAuthConfigRequest; use Fleetbase\Http\Requests\AdminRequest; use Fleetbase\Models\File; use Fleetbase\Models\Setting; use Fleetbase\Notifications\TestPushNotification; +use Fleetbase\Services\OAuth\OAuthConfigRepository; +use Fleetbase\Services\OAuth\OAuthFlowService; use Fleetbase\Services\SmsService; use Fleetbase\Support\PlatformApi; use Fleetbase\Support\Utils; @@ -1023,6 +1031,150 @@ public function testSocketcluster(AdminRequest $request) ); } + /** + * OAuth configuration, as an administrator should see it. + * + * No secret value is ever returned — not even to an admin. Each secret is reduced + * to whether it is set plus a short trailing hint, which is enough to tell two + * credentials apart when rotating without disclosing either. + * + * The response carries the provider schemas and the computed redirect URIs so the + * console can render the form without hardcoding any provider, and so an operator + * can copy the exact callback URL each provider's console requires. + * + * @return \Illuminate\Http\JsonResponse + */ + public function getOAuthConfig(AdminRequest $request, OAuthProviderRegistry $registry, OAuthConfigRepository $config, OAuthFlowService $flow) + { + return response()->json($this->oauthConfigPayload($registry, $config, $flow)); + } + + /** + * Save OAuth configuration. + * + * Every field is filtered against the driver schemas before anything is written: + * an unknown provider id, or a field a driver does not declare, is ignored. That is + * what guarantees nothing but declared configuration — and in particular never a + * driver class name — can reach the settings row. + * + * Secrets are encrypted by the repository. An empty secret means "keep the stored + * one", because the form never receives the real value and so cannot echo it back. + * + * Deliberately does not call refreshConfigCache(): nothing here lives in config() + * at runtime, so rewriting the config cache mid-request under a booted Octane worker + * would be pure risk. + * + * @return \Illuminate\Http\JsonResponse + */ + public function saveOAuthConfig(SaveOAuthConfigRequest $request, OAuthProviderRegistry $registry, OAuthConfigRepository $config, OAuthFlowService $flow) + { + $global = []; + + foreach (['enabled', 'allow_registration'] as $key) { + if ($request->has($key)) { + $global[$key] = $request->boolean($key); + } + } + + $providers = []; + $secretKeys = []; + + foreach ($registry->schemas() as $id => $schema) { + $input = $request->input('providers.' . $id); + + if (!is_array($input)) { + continue; + } + + $values = []; + + if (array_key_exists('enabled', $input)) { + $values['enabled'] = filter_var($input['enabled'], FILTER_VALIDATE_BOOL); + } + + foreach (array_keys($schema) as $field) { + if (!array_key_exists($field, $input)) { + continue; + } + + $value = $input[$field]; + $values[$field] = is_string($value) ? trim($value) : null; + } + + $providers[$id] = $values; + $secretKeys[$id] = array_keys(array_filter($schema, fn (array $definition): bool => ($definition['secret'] ?? false) === true)); + } + + $config->save($global, $providers, $secretKeys); + + return response()->json($this->oauthConfigPayload($registry, $config, $flow)); + } + + /** + * Check a provider's configuration without signing anyone in. + * + * Confirms every required credential is present and decryptable, and for Apple + * that the signing key actually mints a client secret — the failure an operator is + * most likely to hit, and one that would otherwise only surface at the first real + * sign-in. Also echoes the redirect URI to register with the provider. + * + * This does not call the provider, so it cannot tell a revoked client secret from a + * valid one; that surfaces as an exchange failure on first sign-in. + * + * @return \Illuminate\Http\JsonResponse + */ + public function testOAuthConfig(AdminRequest $request, OAuthProviderRegistry $registry, OAuthConfigRepository $config, OAuthFlowService $flow, AppleClientSecretFactory $apple) + { + $id = (string) $request->input('provider'); + + try { + $driver = $registry->driver($id); + } catch (UnknownOAuthProviderException $e) { + return response()->error('Unknown sign-in provider.', 404, ['code' => 'unknown_provider']); + } + + $configured = $driver->isConfigured(); + $problem = $configured ? null : 'missing_credentials'; + + if ($configured && $driver instanceof AppleDriver) { + try { + $apple->make($config->forProvider($id)); + } catch (OAuthProviderNotConfiguredException $e) { + $configured = false; + $problem = 'invalid_signing_key'; + } + } + + return response()->json([ + 'provider' => $id, + 'configured' => $configured, + 'enabled' => $configured && $driver->isEnabled(), + 'problem' => $problem, + 'redirect_uri' => $flow->redirectUri($id), + ]); + } + + /** + * @return array + */ + private function oauthConfigPayload(OAuthProviderRegistry $registry, OAuthConfigRepository $config, OAuthFlowService $flow): array + { + $definitions = $registry->definitions(); + $schemas = []; + $redirectUris = []; + + foreach ($definitions as $definition) { + $schemas[$definition['id']] = $definition['schema']; + $redirectUris[$definition['id']] = $flow->redirectUri($definition['id']); + } + + return [ + 'oauth' => $config->toAdminArray($schemas), + 'providers' => $definitions, + 'redirect_uris' => $redirectUris, + ]; + } + /** * Refresh config cache. */ diff --git a/src/Http/Requests/Admin/SaveOAuthConfigRequest.php b/src/Http/Requests/Admin/SaveOAuthConfigRequest.php new file mode 100644 index 00000000..5fe8a324 --- /dev/null +++ b/src/Http/Requests/Admin/SaveOAuthConfigRequest.php @@ -0,0 +1,45 @@ +|string> + */ + public function rules() + { + return [ + 'enabled' => ['sometimes', 'boolean'], + 'allow_registration' => ['sometimes', 'boolean'], + 'providers' => ['sometimes', 'array'], + 'providers.*' => ['array'], + 'providers.*.enabled' => ['sometimes', 'boolean'], + + 'providers.*.client_id' => ['nullable', 'string', 'max:512'], + 'providers.*.client_secret' => ['nullable', 'string', 'max:4096'], + 'providers.*.hosted_domain' => ['nullable', 'string', 'max:253'], + 'providers.*.tenant' => ['nullable', 'string', 'max:255'], + 'providers.*.team_id' => ['nullable', 'string', 'max:64'], + 'providers.*.key_id' => ['nullable', 'string', 'max:64'], + // An empty value means "keep the stored key", so only a non-empty value is + // checked for the PEM header. Caught here rather than at first sign-in, where + // it would surface as a confusing provider error. + 'providers.*.private_key' => ['nullable', 'string', 'max:8192', function (string $attribute, mixed $value, \Closure $fail): void { + if (is_string($value) && trim($value) !== '' && !str_starts_with(trim($value), '-----BEGIN')) { + $fail('The signing key must be the full contents of the .p8 file, beginning with -----BEGIN PRIVATE KEY-----.'); + } + }], + ]; + } +} diff --git a/src/routes.php b/src/routes.php index 9bbf8895..d315c06f 100644 --- a/src/routes.php +++ b/src/routes.php @@ -225,6 +225,9 @@ function ($router, $controller) { $router->get('mail-config', $controller('getMailConfig')); $router->post('mail-config', $controller('saveMailConfig')); $router->post('test-mail-config', $controller('testMailConfig')); + $router->get('oauth-config', $controller('getOAuthConfig')); + $router->post('oauth-config', $controller('saveOAuthConfig')); + $router->post('test-oauth-config', $controller('testOAuthConfig')); $router->get('queue-config', $controller('getQueueConfig')); $router->post('queue-config', $controller('saveQueueConfig')); $router->post('test-queue-config', $controller('testQueueConfig')); diff --git a/tests/Unit/Http/SettingControllerOAuthTest.php b/tests/Unit/Http/SettingControllerOAuthTest.php new file mode 100644 index 00000000..9b1b97b0 --- /dev/null +++ b/tests/Unit/Http/SettingControllerOAuthTest.php @@ -0,0 +1,433 @@ +values[$key] ??= $callback(); + } + + public function remember(string $key, mixed $ttl, Closure $callback): mixed + { + return $this->values[$key] ??= $callback(); + } + + public function get(string $key, mixed $default = null): mixed + { + return $this->values[$key] ?? $default; + } + + public function put(string $key, mixed $value, mixed $ttl = null): bool + { + $this->values[$key] = $value; + + return true; + } + + public function forget(string $key): bool + { + unset($this->values[$key]); + + return true; + } + + public function increment(string $key, int $value = 1): int + { + return $this->values[$key] = (int) ($this->values[$key] ?? 0) + $value; + } + + public function tags(array|string $tags): self + { + return $this; + } + + public function flush(): bool + { + $this->values = []; + + return true; + } + + public function getPrefix(): string + { + return ''; + } +} + +/** + * @return array{0: OAuthProviderRegistry, 1: OAuthConfigRepository, 2: OAuthFlowService, 3: AppleClientSecretFactory} + */ +function setting_controller_oauth_services(array $config = []): array +{ + EloquentModel::clearBootedModels(); + + $connection = ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '']; + + $container = bind_test_container(array_merge([ + 'api.cache.enabled' => false, + 'app.url' => 'https://api.fleetbase.test', + 'database.default' => 'mysql', + 'database.connections.mysql' => $connection, + 'fleetbase.connection.db' => 'mysql', + 'fleetbase.api.routing.prefix' => '/', + 'fleetbase.api.routing.internal_prefix' => 'int', + 'oauth.enabled' => true, + 'oauth.allow_registration' => true, + 'oauth.providers' => [ + 'google' => ['driver' => GoogleDriver::class, 'enabled' => false], + 'github' => ['driver' => GithubDriver::class, 'enabled' => false], + 'apple' => ['driver' => AppleDriver::class, 'enabled' => false], + ], + ], $config)); + + $cache = new SettingControllerOAuthCacheFake(); + $container->instance('cache', $cache); + Facade::clearResolvedInstance('cache'); + Facade::clearResolvedInstance('log'); + + $capsule = new Capsule($container); + $capsule->addConnection($connection, 'mysql'); + $capsule->setEventDispatcher(new Dispatcher($container)); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + + $databaseManager = $capsule->getDatabaseManager(); + $databaseManager->setDefaultConnection('mysql'); + $container->instance('db', $databaseManager); + Facade::clearResolvedInstance('db'); + + $capsule->getConnection('mysql')->getSchemaBuilder()->create('settings', function ($table) { + $table->increments('id'); + $table->string('key')->unique(); + $table->text('value')->nullable(); + }); + + $encrypter = new SettingControllerOAuthEncrypterFake(); + $config = new OAuthConfigRepository($encrypter); + $registry = new OAuthProviderRegistry($config, Request::create('/'), new IdTokenVerifier()); + $flow = new OAuthFlowService($registry, new OAuthStateService($encrypter), $config); + + return [$registry, $config, $flow, new AppleClientSecretFactory()]; +} + +function setting_controller_oauth_save(array $body): SaveOAuthConfigRequest +{ + return SaveOAuthConfigRequest::create('/int/v1/settings/oauth-config', 'POST', $body); +} + +function setting_controller_oauth_stored(): array +{ + $row = Setting::query()->where('key', 'system.oauth')->first(); + + return is_array($row?->value) ? $row->value : []; +} + +function setting_controller_oauth_ec_key(): string +{ + $resource = openssl_pkey_new(['curve_name' => 'prime256v1', 'private_key_type' => OPENSSL_KEYTYPE_EC]); + openssl_pkey_export($resource, $pem); + + return (string) $pem; +} + +// --------------------------------------------------------------------------- +// Read +// --------------------------------------------------------------------------- + +it('describes every provider so the console can render the form without hardcoding one', function () { + [$registry, $config, $flow] = setting_controller_oauth_services(); + + $payload = (new SettingController()) + ->getOAuthConfig(AdminRequest::create('/'), $registry, $config, $flow) + ->getData(true); + + expect(array_column($payload['providers'], 'id'))->toBe(['google', 'github', 'apple']) + ->and($payload['providers'][0]['label'])->toBe('Google') + ->and($payload['providers'][0]['schema']['client_secret']['secret'])->toBeTrue() + // The exact callback URL the operator has to register at each provider. + ->and($payload['redirect_uris']['google'])->toBe('https://api.fleetbase.test/int/v1/auth/oauth/google/callback') + ->and($payload['oauth']['enabled'])->toBeTrue(); +}); + +it('never returns a secret value, even to an administrator', function () { + [$registry, $config, $flow] = setting_controller_oauth_services(); + + (new SettingController())->saveOAuthConfig(setting_controller_oauth_save([ + 'providers' => ['google' => ['enabled' => true, 'client_id' => 'google-id', 'client_secret' => 'super-secret-value']], + ]), $registry, $config, $flow); + + $body = json_encode((new SettingController()) + ->getOAuthConfig(AdminRequest::create('/'), $registry, $config, $flow) + ->getData(true)); + + expect($body)->not->toContain('super-secret-value') + ->and($body)->toContain('google-id') + ->and($body)->toContain('alue'); // the four-character hint +}); + +// --------------------------------------------------------------------------- +// Write +// --------------------------------------------------------------------------- + +it('encrypts secrets at rest', function () { + [$registry, $config, $flow] = setting_controller_oauth_services(); + + (new SettingController())->saveOAuthConfig(setting_controller_oauth_save([ + 'providers' => ['google' => ['client_id' => 'google-id', 'client_secret' => 'super-secret-value']], + ]), $registry, $config, $flow); + + $stored = setting_controller_oauth_stored(); + + expect(json_encode($stored))->not->toContain('super-secret-value') + ->and($stored['providers']['google'])->toHaveKey('client_secret_encrypted') + ->and($stored['providers']['google'])->not->toHaveKey('client_secret') + ->and($config->forProvider('google')->secret('client_secret'))->toBe('super-secret-value'); +}); + +it('keeps the stored secret when the form submits an empty one', function () { + [$registry, $config, $flow] = setting_controller_oauth_services(); + $controller = new SettingController(); + + $controller->saveOAuthConfig(setting_controller_oauth_save([ + 'providers' => ['google' => ['client_id' => 'id-1', 'client_secret' => 'original']], + ]), $registry, $config, $flow); + + // The form renders a masked placeholder, so a save that did not touch the field + // submits it empty. That must not wipe the credential. + $controller->saveOAuthConfig(setting_controller_oauth_save([ + 'providers' => ['google' => ['client_id' => 'id-2', 'client_secret' => '']], + ]), $registry, $config, $flow); + + expect($config->forProvider('google')->secret('client_secret'))->toBe('original') + ->and($config->forProvider('google')->get('client_id'))->toBe('id-2'); +}); + +it('ignores provider ids no driver defines', function () { + [$registry, $config, $flow] = setting_controller_oauth_services(); + + (new SettingController())->saveOAuthConfig(setting_controller_oauth_save([ + 'providers' => ['made-up' => ['client_id' => 'x', 'client_secret' => 'y']], + ]), $registry, $config, $flow); + + expect(setting_controller_oauth_stored()['providers'] ?? [])->not->toHaveKey('made-up'); +}); + +it('ignores fields a driver does not declare, including a driver class', function () { + [$registry, $config, $flow] = setting_controller_oauth_services(); + + (new SettingController())->saveOAuthConfig(setting_controller_oauth_save([ + 'providers' => ['google' => [ + 'client_id' => 'google-id', + // Writing a class name into settings would be arbitrary class instantiation. + 'driver' => 'Evil\\ArbitraryClass', + 'team_id' => 'apple-only-field', + 'nonsense' => 'value', + ]], + ]), $registry, $config, $flow); + + $google = setting_controller_oauth_stored()['providers']['google']; + + expect($google)->toHaveKey('client_id') + ->and($google)->not->toHaveKey('driver') + ->and($google)->not->toHaveKey('team_id') + ->and($google)->not->toHaveKey('nonsense'); +}); + +it('persists the global switches and provider toggles', function () { + [$registry, $config, $flow] = setting_controller_oauth_services(); + + (new SettingController())->saveOAuthConfig(setting_controller_oauth_save([ + 'enabled' => false, + 'allow_registration' => '0', + 'providers' => ['github' => ['enabled' => 'true']], + ]), $registry, $config, $flow); + + expect($config->isEnabled())->toBeFalse() + ->and($config->allowsRegistration())->toBeFalse() + ->and($config->forProvider('github')->enabled())->toBeTrue(); +}); + +it('leaves the global switches alone when a save does not mention them', function () { + [$registry, $config, $flow] = setting_controller_oauth_services(); + $controller = new SettingController(); + + $controller->saveOAuthConfig(setting_controller_oauth_save(['allow_registration' => false]), $registry, $config, $flow); + $controller->saveOAuthConfig(setting_controller_oauth_save(['providers' => ['google' => ['client_id' => 'x']]]), $registry, $config, $flow); + + expect($config->allowsRegistration())->toBeFalse(); +}); + +it('responds with the refreshed configuration after saving', function () { + [$registry, $config, $flow] = setting_controller_oauth_services(); + + $payload = (new SettingController())->saveOAuthConfig(setting_controller_oauth_save([ + 'providers' => ['google' => ['client_id' => 'saved-id']], + ]), $registry, $config, $flow)->getData(true); + + expect($payload['oauth']['providers']['google']['client_id'])->toBe('saved-id') + ->and($payload)->toHaveKeys(['oauth', 'providers', 'redirect_uris']); +}); + +// --------------------------------------------------------------------------- +// Test +// --------------------------------------------------------------------------- + +it('reports a provider with missing credentials', function () { + [$registry, $config, $flow, $apple] = setting_controller_oauth_services(); + + $payload = (new SettingController())->testOAuthConfig( + AdminRequest::create('/', 'POST', ['provider' => 'google']), $registry, $config, $flow, $apple + )->getData(true); + + expect($payload)->toBe([ + 'provider' => 'google', + 'configured' => false, + 'enabled' => false, + 'problem' => 'missing_credentials', + 'redirect_uri' => 'https://api.fleetbase.test/int/v1/auth/oauth/google/callback', + ]); +}); + +it('reports a fully configured provider', function () { + [$registry, $config, $flow, $apple] = setting_controller_oauth_services(); + + (new SettingController())->saveOAuthConfig(setting_controller_oauth_save([ + 'providers' => ['github' => ['enabled' => true, 'client_id' => 'gh-id', 'client_secret' => 'gh-secret']], + ]), $registry, $config, $flow); + + $payload = (new SettingController())->testOAuthConfig( + AdminRequest::create('/', 'POST', ['provider' => 'github']), $registry, $config, $flow, $apple + )->getData(true); + + expect($payload['configured'])->toBeTrue() + ->and($payload['enabled'])->toBeTrue() + ->and($payload['problem'])->toBeNull(); +}); + +it('catches an apple signing key that cannot mint a client secret', function () { + [$registry, $config, $flow, $apple] = setting_controller_oauth_services(); + app()->instance('cache', new SettingControllerOAuthCacheFake()); + Facade::clearResolvedInstance('cache'); + + (new SettingController())->saveOAuthConfig(setting_controller_oauth_save([ + 'providers' => ['apple' => [ + 'client_id' => 'io.fleetbase.console', + 'team_id' => 'TEAM', + 'key_id' => 'KEY', + 'private_key' => "-----BEGIN PRIVATE KEY-----\nnot-a-real-key\n-----END PRIVATE KEY-----", + ]], + ]), $registry, $config, $flow); + + // The failure an operator is most likely to hit, and one that would otherwise only + // surface at the first real sign-in. + $payload = (new SettingController())->testOAuthConfig( + AdminRequest::create('/', 'POST', ['provider' => 'apple']), $registry, $config, $flow, $apple + )->getData(true); + + expect($payload['configured'])->toBeFalse() + ->and($payload['problem'])->toBe('invalid_signing_key'); +}); + +it('accepts an apple signing key that mints a client secret', function () { + [$registry, $config, $flow, $apple] = setting_controller_oauth_services(); + + (new SettingController())->saveOAuthConfig(setting_controller_oauth_save([ + 'providers' => ['apple' => [ + 'client_id' => 'io.fleetbase.console', + 'team_id' => 'TEAM', + 'key_id' => 'KEY', + 'private_key' => setting_controller_oauth_ec_key(), + ]], + ]), $registry, $config, $flow); + + $payload = (new SettingController())->testOAuthConfig( + AdminRequest::create('/', 'POST', ['provider' => 'apple']), $registry, $config, $flow, $apple + )->getData(true); + + expect($payload['configured'])->toBeTrue() + ->and($payload['problem'])->toBeNull(); +}); + +it('rejects an unknown provider under test', function () { + [$registry, $config, $flow, $apple] = setting_controller_oauth_services(); + + $response = (new SettingController())->testOAuthConfig( + AdminRequest::create('/', 'POST', ['provider' => 'nope']), $registry, $config, $flow, $apple + ); + + expect($response->getStatusCode())->toBe(404) + ->and($response->getData(true)['code'])->toBe('unknown_provider'); +}); + +// --------------------------------------------------------------------------- +// Request +// --------------------------------------------------------------------------- + +it('is an admin-only request', function () { + // AdminRequest::authorize() is what restricts these endpoints to administrators. + expect(is_subclass_of(SaveOAuthConfigRequest::class, AdminRequest::class))->toBeTrue(); +}); + +it('validates the shape of a save', function (array $body, bool $fails) { + setting_controller_oauth_services(); + + $translator = new Illuminate\Translation\Translator(new Illuminate\Translation\ArrayLoader(), 'en'); + $validator = (new Illuminate\Validation\Factory($translator))->make($body, (new SaveOAuthConfigRequest())->rules()); + + expect($validator->fails())->toBe($fails); +})->with([ + 'toggles only' => [['enabled' => true, 'allow_registration' => false], false], + 'non boolean toggle' => [['enabled' => 'sometimes'], true], + 'full pem key' => [['providers' => ['apple' => ['private_key' => "-----BEGIN PRIVATE KEY-----\nabc"]]], false], + // Empty means "keep the stored key" and must not be rejected. + 'empty key keeps stored' => [['providers' => ['apple' => ['private_key' => '']]], false], + 'key without pem header' => [['providers' => ['apple' => ['private_key' => 'MIGTAgEAMBMGByqGSM49']]], true], + 'oversized client id' => [['providers' => ['google' => ['client_id' => str_repeat('a', 513)]]], true], +]); diff --git a/tests/Unit/RoutesContractTest.php b/tests/Unit/RoutesContractTest.php index 317b53fb..f07ed45d 100644 --- a/tests/Unit/RoutesContractTest.php +++ b/tests/Unit/RoutesContractTest.php @@ -291,6 +291,26 @@ function routes_contract_index(array $rows, string $method, string $uri): int|fa ->toBeLessThan(routes_contract_index($routes, 'POST', 'int/v1/auth/oauth/{provider}/callback')); }); + test('route file exposes oauth configuration as protected admin settings', function () { + $routes = routes_contract_rows(routes_contract_router()); + + $settings = 'Fleetbase\\Http\\Controllers\\Internal\\v1\\SettingController'; + + $get = routes_contract_find($routes, 'GET', 'int/v1/settings/oauth-config'); + $save = routes_contract_find($routes, 'POST', 'int/v1/settings/oauth-config'); + $test = routes_contract_find($routes, 'POST', 'int/v1/settings/test-oauth-config'); + + // Provider credentials are configured here, so these must sit behind the + // authenticated group — the AdminRequest on each action then restricts them + // to administrators. + expect($get['action'])->toBe($settings . '@getOAuthConfig') + ->and($get['middleware'])->toContain('fleetbase.protected') + ->and($save['action'])->toBe($settings . '@saveOAuthConfig') + ->and($save['middleware'])->toContain('fleetbase.protected') + ->and($test['action'])->toBe($settings . '@testOAuthConfig') + ->and($test['middleware'])->toContain('fleetbase.protected'); + }); + test('route file keeps critical internal custom routes before dynamic resource routes', function () { $routes = routes_contract_rows(routes_contract_router()); From fe806f2421fc124e1567ca092d53c39286948dfd Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 21 Sep 2026 13:49:38 +0800 Subject: [PATCH 08/30] feat(oauth): let users link and unlink providers on their account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET identities, POST {provider}/link, POST link/complete and DELETE {provider}/unlink, all behind fleetbase.protected. The identity is deliberately not linked at the provider callback. Doing so would allow account-linking CSRF: an attacker starts a link on their own account and sends the victim the provider URL, the victim's provider identity is attached to the attacker's account, and the victim's next 'Sign in with ' lands in the attacker's account. Instead the callback issues a handoff bound to the user who started the link, and link/complete — protected — redeems it only if the signed-in user matches. A mismatch is reported exactly like an expired code, so the attacker learns nothing. The public exchange refuses link handoffs outright, so there is no path around that check. A mutation test confirms the CSRF test fails when the user check is removed. Unlink is refused with last_credential when it would leave the account with no password and no other linked provider. This also closes the loop on link_required: an existing user who tries to sign in with an unlinked provider now has somewhere to go and link it. --- .../Internal/v1/OAuthController.php | 170 ++++++++++++ src/Services/OAuth/OAuthFlowService.php | 10 +- src/Services/OAuth/OAuthIdentityService.php | 11 + src/routes.php | 10 + tests/Unit/Http/OAuthControllerTest.php | 244 ++++++++++++++++++ tests/Unit/RoutesContractTest.php | 24 ++ 6 files changed, 468 insertions(+), 1 deletion(-) diff --git a/src/Http/Controllers/Internal/v1/OAuthController.php b/src/Http/Controllers/Internal/v1/OAuthController.php index 0578d74b..398bda33 100644 --- a/src/Http/Controllers/Internal/v1/OAuthController.php +++ b/src/Http/Controllers/Internal/v1/OAuthController.php @@ -120,6 +120,13 @@ public function exchange(OAuthExchangeRequest $request) return response()->error('This sign-in session is no longer valid.', 400, ['code' => 'invalid_exchange_code']); } + // A link handoff is only ever completed by completeLink(), which checks it + // against the signed-in user. Refusing it here keeps the public endpoint from + // being a way to redeem one without that check. + if (($consumed['payload']['intent'] ?? null) === OAuthFlowService::INTENT_LINK) { + return response()->error('This sign-in session is no longer valid.', 400, ['code' => 'invalid_exchange_code']); + } + // Re-checked at redemption, not just at redirect: an administrator may have // switched the provider off while this handshake was in flight. if (!$this->providerIsEnabled($profile->provider)) { @@ -135,6 +142,169 @@ public function exchange(OAuthExchangeRequest $request) return $this->registrationOutcome($profile); } + /** + * The signed-in user's linked identities, and the providers they could link. + */ + public function identities(Request $request) + { + return response()->json($this->identitiesPayload($request->user())); + } + + /** + * Begin linking a provider to the signed-in user. + * + * Returns the provider URL rather than redirecting: this route is behind + * auth:sanctum, and a top-level browser navigation cannot carry the bearer token, + * so the console fetches the URL and then navigates to it. + * + * The authorization row is stamped with this user. The identity is NOT linked at + * the callback — see completeLink() for why. + */ + public function link(Request $request, string $provider) + { + $user = $request->user(); + + if ($limited = $this->rateLimit('link:' . $user->uuid, 10)) { + return $limited; + } + + if ($this->identities->findBySubjectForUser($user, $provider) !== null) { + return response()->error('That provider is already linked to your account.', 409, ['code' => 'already_linked']); + } + + try { + $url = $this->flow->startAuthorization( + $provider, + OAuthFlowService::INTENT_LINK, + '/account/auth', + $request->ip(), + (string) $user->uuid + ); + } catch (UnknownOAuthProviderException $e) { + return response()->error('Unknown sign-in provider.', 404, ['code' => 'unknown_provider']); + } catch (OAuthException $e) { + return response()->error('That sign-in provider is not available.', 403, ['code' => $e->getMessage()]); + } + + return response()->json(['redirect_url' => $url]); + } + + /** + * Finish linking, from the signed-in console. + * + * The identity is linked here, behind authentication, and only if the signed-in + * user is the one who started the link. Linking at the provider callback instead + * would allow account-linking CSRF: an attacker starts a link on their own account + * and sends the victim the provider URL; the victim's provider identity is attached + * to the attacker's account, and the victim's next "Sign in with " lands + * them in the attacker's account. Here, the victim's browser would complete the link + * as the victim, the user check fails, and nothing is linked. + */ + public function completeLink(OAuthExchangeRequest $request) + { + $user = $request->user(); + + if ($limited = $this->rateLimit('link:' . $user->uuid, 10)) { + return $limited; + } + + try { + $consumed = $this->states->consume(OAuthState::PURPOSE_HANDOFF, $request->code(), $request->ip()); + } catch (OAuthStateException $e) { + return response()->error('This link request is no longer valid.', 400, ['code' => 'invalid_exchange_code']); + } + + /** @var OAuthState $state */ + $state = $consumed['state']; + $profile = $this->flow->profileFromPayload($consumed['payload']); + + $isLink = ($consumed['payload']['intent'] ?? null) === OAuthFlowService::INTENT_LINK; + $startedByYou = is_string($state->user_uuid) && hash_equals($state->user_uuid, (string) $user->uuid); + + if (!$isLink || !$startedByYou || $profile->providerUserId === '') { + // Deliberately the same response as an expired code: telling the caller the + // link belongs to someone else would confirm the attack worked as far as it did. + return response()->error('This link request is no longer valid.', 400, ['code' => 'invalid_exchange_code']); + } + + if (!$this->providerIsEnabled($profile->provider)) { + return response()->error('That sign-in provider is not available.', 403, ['code' => 'provider_disabled']); + } + + try { + $this->identities->link($user, $profile); + } catch (OAuthException $e) { + // The provider account is already linked to a different Fleetbase user. + return response()->error('That account is already linked to another user.', 409, ['code' => $e->getMessage()]); + } + + return response()->json($this->identitiesPayload($user)); + } + + /** + * Remove a linked provider from the signed-in user. + * + * Refused when it would leave the account with no way to sign in at all. + */ + public function unlink(Request $request, string $provider) + { + $user = $request->user(); + + if ($this->identities->findBySubjectForUser($user, $provider) === null) { + return response()->error('That provider is not linked to your account.', 404, ['code' => 'not_linked']); + } + + if ($this->identities->isLastCredential($user, $provider)) { + return response()->error( + 'Set a password or link another provider before removing this one — otherwise you could not sign in.', + 409, + ['code' => 'last_credential'] + ); + } + + $this->identities->unlink($user, $provider); + + return response()->json($this->identitiesPayload($user)); + } + + /** + * @return array + */ + protected function identitiesPayload(User $user): array + { + $labels = []; + + foreach ($this->registry->definitions() as $definition) { + $labels[$definition['id']] = ['label' => $definition['label'], 'icon' => $definition['icon']]; + } + + $identities = []; + + foreach ($this->identities->forUser($user) as $identity) { + $identities[] = [ + 'provider' => $identity->provider, + 'label' => $labels[$identity->provider]['label'] ?? ucfirst((string) $identity->provider), + 'icon' => $labels[$identity->provider]['icon'] ?? null, + 'provider_email' => $identity->provider_email, + 'email_verified' => (bool) $identity->email_verified, + 'linked_at' => $identity->created_at?->toIso8601String(), + 'last_login_at' => $identity->last_login_at?->toIso8601String(), + ]; + } + + $linked = array_column($identities, 'provider'); + + return [ + 'identities' => $identities, + 'has_password' => !empty($user->password), + // Enabled providers not yet linked — what the account page can offer. + 'available' => array_values(array_filter( + $this->registry->toDiscoveryArray(), + fn (array $provider): bool => !in_array($provider['id'], $linked, true) + )), + ]; + } + /** * An identity we already know: run the same gates password login runs. */ diff --git a/src/Services/OAuth/OAuthFlowService.php b/src/Services/OAuth/OAuthFlowService.php index 395cfb34..64b6e90b 100644 --- a/src/Services/OAuth/OAuthFlowService.php +++ b/src/Services/OAuth/OAuthFlowService.php @@ -171,7 +171,15 @@ public function handleCallback(string $provider, Request $request): string $request->ip() ); - return $this->consoleUrl(['handoff' => $handoff], $returnTo); + $fragment = ['handoff' => $handoff]; + + // A link handoff must be completed by the signed-in console through a protected + // endpoint, not the public exchange, so the console needs to know which it is. + if ($stateRow->intent === self::INTENT_LINK) { + $fragment['intent'] = self::INTENT_LINK; + } + + return $this->consoleUrl($fragment, $returnTo); } /** diff --git a/src/Services/OAuth/OAuthIdentityService.php b/src/Services/OAuth/OAuthIdentityService.php index 0b03c164..38daad49 100644 --- a/src/Services/OAuth/OAuthIdentityService.php +++ b/src/Services/OAuth/OAuthIdentityService.php @@ -54,6 +54,17 @@ public function findBySubject(string $provider, string $providerUserId): ?OAuthI ->first(); } + /** + * The identity a user has linked for a provider, if any. + */ + public function findBySubjectForUser(User $user, string $provider): ?OAuthIdentity + { + return OAuthIdentity::query() + ->where('user_uuid', $user->uuid) + ->where('provider', $provider) + ->first(); + } + /** * Resolve the Fleetbase user behind a provider subject. * diff --git a/src/routes.php b/src/routes.php index d315c06f..0eae8737 100644 --- a/src/routes.php +++ b/src/routes.php @@ -112,6 +112,16 @@ function ($router) { // the name/email scopes are requested. $router->match(['GET', 'POST'], '{provider}/callback', [Fleetbase\Http\Controllers\Internal\v1\OAuthController::class, 'callback']); }); + }, function ($router) { + // Account linking. Protected: every action here acts on the + // signed-in user, and completeLink() is what defeats + // account-linking CSRF by checking that user. + $router->group(['prefix' => 'oauth'], function ($router) { + $router->get('identities', [Fleetbase\Http\Controllers\Internal\v1\OAuthController::class, 'identities']); + $router->post('link/complete', [Fleetbase\Http\Controllers\Internal\v1\OAuthController::class, 'completeLink']); + $router->post('{provider}/link', [Fleetbase\Http\Controllers\Internal\v1\OAuthController::class, 'link']); + $router->delete('{provider}/unlink', [Fleetbase\Http\Controllers\Internal\v1\OAuthController::class, 'unlink']); + }); }); $router->group( ['prefix' => 'onboard', 'middleware' => [Fleetbase\Http\Middleware\ThrottleRequests::class]], diff --git a/tests/Unit/Http/OAuthControllerTest.php b/tests/Unit/Http/OAuthControllerTest.php index 192e0760..c5490f76 100644 --- a/tests/Unit/Http/OAuthControllerTest.php +++ b/tests/Unit/Http/OAuthControllerTest.php @@ -1062,3 +1062,247 @@ function oauth_controller_handoff(OAuthController $controller, array $redirectQu expect($response->getStatusCode())->toBe(429) ->and($response->getData(true)['code'])->toBe('rate_limited'); }); + +// --------------------------------------------------------------------------- +// Account linking +// --------------------------------------------------------------------------- + +function oauth_authed(User $user, string $uri = '/int/v1/auth/oauth/identities', string $method = 'GET', array $body = []): Request +{ + $request = Request::create($uri, $method, $body); + $request->setUserResolver(fn () => $user); + + return $request; +} + +function oauth_authed_complete(User $user, string $code): OAuthExchangeRequest +{ + $request = OAuthExchangeRequest::create('/int/v1/auth/oauth/link/complete', 'POST', ['code' => $code]); + $request->setUserResolver(fn () => $user); + + return $request; +} + +/** + * Start a link as $user and run the provider callback, returning the fragment the + * console receives. + * + * @return array + */ +function oauth_link_callback(OAuthController $controller, User $user): array +{ + $started = $controller->link(oauth_authed($user, '/int/v1/auth/oauth/fakeprovider/link', 'POST'), 'fakeprovider')->getData(true); + parse_str((string) parse_url($started['redirect_url'], PHP_URL_QUERY), $query); + + $callback = $controller->callback( + Request::create('/int/v1/auth/oauth/fakeprovider/callback', 'GET', ['code' => 'auth-code', 'state' => $query['state']]), + 'fakeprovider' + ); + + return oauth_fragment($callback->getTargetUrl()); +} + +it('lists linked identities and the providers still available to link', function () { + oauth_controller_database(); + [$controller] = oauth_controller_services(); + + $user = oauth_controller_user(['password' => 'hashed:secret']); + oauth_controller_link($user); + + $payload = $controller->identities(oauth_authed($user))->getData(true); + + expect($payload['identities'])->toHaveCount(1) + ->and($payload['identities'][0]['provider'])->toBe('fakeprovider') + ->and($payload['identities'][0]['label'])->toBe('Fake Provider') + ->and($payload['identities'][0]['provider_email'])->toBe('ada@example.com') + // The stable provider subject is never exposed. + ->and(json_encode($payload))->not->toContain('subject-1') + ->and($payload['has_password'])->toBeTrue() + ->and($payload['available'])->toBe([]); +}); + +it('offers an enabled provider the user has not linked yet', function () { + oauth_controller_database(); + [$controller] = oauth_controller_services(); + + $payload = $controller->identities(oauth_authed(oauth_controller_user()))->getData(true); + + expect($payload['identities'])->toBe([]) + ->and($payload['has_password'])->toBeFalse() + ->and(array_column($payload['available'], 'id'))->toBe(['fakeprovider']); +}); + +it('starts a link bound to the signed in user', function () { + oauth_controller_database(); + [$controller] = oauth_controller_services(); + + $user = oauth_controller_user(); + $response = $controller->link(oauth_authed($user, '/', 'POST'), 'fakeprovider'); + $row = OAuthState::query()->where('purpose', OAuthState::PURPOSE_AUTHORIZATION)->first(); + + // Returned, not redirected: this route is behind auth:sanctum and a top-level + // navigation cannot carry the bearer token. + expect($response->getData(true)['redirect_url'])->toStartWith('https://provider.test/authorize?') + ->and($row->intent)->toBe('link') + ->and($row->user_uuid)->toBe('user-1'); +}); + +it('refuses to link a provider that is already linked', function () { + oauth_controller_database(); + [$controller] = oauth_controller_services(); + + $user = oauth_controller_user(); + oauth_controller_link($user); + + $response = $controller->link(oauth_authed($user, '/', 'POST'), 'fakeprovider'); + + expect($response->getStatusCode())->toBe(409) + ->and($response->getData(true)['code'])->toBe('already_linked'); +}); + +it('marks a link handoff so the console completes it through the protected endpoint', function () { + oauth_controller_database(); + [$controller] = oauth_controller_services(); + + $fragment = oauth_link_callback($controller, oauth_controller_user()); + + expect($fragment['intent'])->toBe('link') + ->and($fragment['handoff'])->toBeString()->toHaveLength(64) + ->and($fragment['return_to'])->toBe('/account/auth'); +}); + +it('does not link anything at the provider callback', function () { + oauth_controller_database(); + [$controller] = oauth_controller_services(); + + oauth_link_callback($controller, oauth_controller_user()); + + expect(OAuthIdentity::query()->count())->toBe(0); +}); + +it('links the identity when the user who started the link completes it', function () { + oauth_controller_database(); + [$controller] = oauth_controller_services(); + + $user = oauth_controller_user(['password' => 'hashed:secret']); + $fragment = oauth_link_callback($controller, $user); + + $payload = $controller->completeLink(oauth_authed_complete($user, $fragment['handoff']))->getData(true); + + expect(OAuthIdentity::query()->where('user_uuid', 'user-1')->count())->toBe(1) + ->and($payload['identities'][0]['provider'])->toBe('fakeprovider') + ->and($payload['available'])->toBe([]); +}); + +it('refuses a link completed by a different user than the one who started it', function () { + oauth_controller_database(); + [$controller] = oauth_controller_services(); + + // Account-linking CSRF: the attacker starts a link on their own account and gets + // the victim to complete the provider step. The victim's browser then completes + // the link as the victim — and must be refused, or the victim's provider identity + // would be attached to the attacker's account. + $attacker = oauth_controller_user(['uuid' => 'attacker', 'email' => 'attacker@example.com']); + $victim = oauth_controller_user(['uuid' => 'victim', 'email' => 'victim@example.com']); + + $fragment = oauth_link_callback($controller, $attacker); + $response = $controller->completeLink(oauth_authed_complete($victim, $fragment['handoff'])); + + expect($response->getStatusCode())->toBe(400) + // Indistinguishable from an expired code, so the attacker learns nothing. + ->and($response->getData(true)['code'])->toBe('invalid_exchange_code') + ->and(OAuthIdentity::query()->count())->toBe(0); +}); + +it('refuses to complete a login handoff as a link', function () { + oauth_controller_database(); + [$controller] = oauth_controller_services(); + + $user = oauth_controller_user(); + $handoff = oauth_controller_handoff($controller); + + $response = $controller->completeLink(oauth_authed_complete($user, $handoff)); + + expect($response->getStatusCode())->toBe(400) + ->and(OAuthIdentity::query()->count())->toBe(0); +}); + +it('refuses to redeem a link handoff through the public exchange', function () { + oauth_controller_database(); + [$controller] = oauth_controller_services(); + + $fragment = oauth_link_callback($controller, oauth_controller_user()); + + // The public endpoint would skip the signed-in-user check entirely. + $response = $controller->exchange(oauth_exchange_request(['code' => $fragment['handoff']])); + + expect($response->getStatusCode())->toBe(400) + ->and($response->getData(true)['code'])->toBe('invalid_exchange_code') + ->and(OAuthIdentity::query()->count())->toBe(0); +}); + +it('refuses to link a provider account already linked to someone else', function () { + oauth_controller_database(); + [$controller] = oauth_controller_services(); + + $owner = oauth_controller_user(['uuid' => 'owner', 'email' => 'owner@example.com']); + oauth_controller_link($owner); + + $other = oauth_controller_user(['uuid' => 'other', 'email' => 'other@example.com']); + $fragment = oauth_link_callback($controller, $other); + $response = $controller->completeLink(oauth_authed_complete($other, $fragment['handoff'])); + + expect($response->getStatusCode())->toBe(409) + ->and($response->getData(true)['code'])->toBe('identity_already_linked') + ->and(OAuthIdentity::query()->first()->user_uuid)->toBe('owner'); +}); + +it('unlinks a provider when the user still has another way to sign in', function () { + oauth_controller_database(); + [$controller] = oauth_controller_services(); + + $user = oauth_controller_user(['password' => 'hashed:secret']); + oauth_controller_link($user); + + $payload = $controller->unlink(oauth_authed($user, '/', 'DELETE'), 'fakeprovider')->getData(true); + + expect(OAuthIdentity::query()->count())->toBe(0) + ->and($payload['identities'])->toBe([]) + ->and(array_column($payload['available'], 'id'))->toBe(['fakeprovider']); +}); + +it('refuses to remove the last way a user can sign in', function () { + oauth_controller_database(); + [$controller] = oauth_controller_services(); + + // No password and a single linked provider: removing it would lock them out. + $user = oauth_controller_user(['password' => null]); + oauth_controller_link($user); + + $response = $controller->unlink(oauth_authed($user, '/', 'DELETE'), 'fakeprovider'); + + expect($response->getStatusCode())->toBe(409) + ->and($response->getData(true)['code'])->toBe('last_credential') + ->and(OAuthIdentity::query()->count())->toBe(1); +}); + +it('reports unlinking a provider that is not linked', function () { + oauth_controller_database(); + [$controller] = oauth_controller_services(); + + $response = $controller->unlink(oauth_authed(oauth_controller_user(), '/', 'DELETE'), 'fakeprovider'); + + expect($response->getStatusCode())->toBe(404) + ->and($response->getData(true)['code'])->toBe('not_linked'); +}); + +it('rate limits linking per user', function () { + oauth_controller_database(); + app()->instance(Illuminate\Cache\RateLimiter::class, new OAuthControllerRateLimiterFake(0)); + Facade::clearResolvedInstance(Illuminate\Cache\RateLimiter::class); + [$controller] = oauth_controller_services(); + + $response = $controller->link(oauth_authed(oauth_controller_user(), '/', 'POST'), 'fakeprovider'); + + expect($response->getStatusCode())->toBe(429); +}); diff --git a/tests/Unit/RoutesContractTest.php b/tests/Unit/RoutesContractTest.php index f07ed45d..d2da21f9 100644 --- a/tests/Unit/RoutesContractTest.php +++ b/tests/Unit/RoutesContractTest.php @@ -280,6 +280,30 @@ function routes_contract_index(array $rows, string $method, string $uri): int|fa ->and($post['middleware'])->not->toContain('fleetbase.protected'); }); + test('route file protects every account linking route', function () { + $routes = routes_contract_rows(routes_contract_router()); + + $controller = Fleetbase\Http\Controllers\Internal\v1\OAuthController::class; + + // Every linking action acts on the signed-in user, and completeLink() is what + // defeats account-linking CSRF by checking that user — so none may be public. + foreach ([ + ['GET', 'int/v1/auth/oauth/identities', 'identities'], + ['POST', 'int/v1/auth/oauth/link/complete', 'completeLink'], + ['POST', 'int/v1/auth/oauth/{provider}/link', 'link'], + ['DELETE', 'int/v1/auth/oauth/{provider}/unlink', 'unlink'], + ] as [$method, $uri, $action]) { + $route = routes_contract_find($routes, $method, $uri); + + expect($route)->not->toBeNull() + ->and($route['action'])->toBe($controller . '@' . $action) + ->and($route['middleware'])->toContain('fleetbase.protected'); + } + + expect(routes_contract_index($routes, 'POST', 'int/v1/auth/oauth/link/complete')) + ->toBeLessThan(routes_contract_index($routes, 'POST', 'int/v1/auth/oauth/{provider}/link')); + }); + test('route file registers literal oauth routes before the provider wildcard', function () { $routes = routes_contract_rows(routes_contract_router()); From b194ea33318e491f09b2277460838c07990bc9ab Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 21 Sep 2026 18:14:29 +0800 Subject: [PATCH 09/30] feat(oauth): check credentials with the provider before offering it "Check configuration" only looked at saved settings, so credentials typed into the form but not yet saved were reported missing. It also never contacted the provider, so a wrong secret passed. - The check now runs on the form's unsaved values, layered over what is stored in memory only. A blank secret still means the stored one. - It then presents the credentials to the provider's token endpoint with a code that cannot be valid. Providers authenticate the client before the code, so invalid_client versus invalid_grant tells a bad secret from a good one without signing anyone in (CredentialCheck). - Results carry a plain-language message and the names of missing fields. - Saving refuses (422, nothing written) to switch a provider on, or to change a live provider's credentials, unless that check passes. Saves that leave a live provider's credentials alone do not call it. --- .../OAuth/AbstractOAuthProviderDriver.php | 54 ++++ .../OAuth/Contracts/OAuthProviderDriver.php | 6 + src/Auth/OAuth/CredentialCheck.php | 53 ++++ src/Auth/OAuth/OAuthProviderRegistry.php | 25 +- .../Internal/v1/SettingController.php | 160 ++++++++-- src/Services/OAuth/OAuthConfigRepository.php | 36 +++ tests/Unit/Http/OAuthControllerTest.php | 5 + .../Unit/Http/SettingControllerOAuthTest.php | 273 ++++++++++++++++-- 8 files changed, 561 insertions(+), 51 deletions(-) create mode 100644 src/Auth/OAuth/CredentialCheck.php diff --git a/src/Auth/OAuth/AbstractOAuthProviderDriver.php b/src/Auth/OAuth/AbstractOAuthProviderDriver.php index 5b2dc86c..388de8d0 100644 --- a/src/Auth/OAuth/AbstractOAuthProviderDriver.php +++ b/src/Auth/OAuth/AbstractOAuthProviderDriver.php @@ -3,6 +3,10 @@ namespace Fleetbase\Auth\OAuth; use Fleetbase\Auth\OAuth\Contracts\OAuthProviderDriver; +use Fleetbase\Auth\OAuth\Exceptions\OAuthProviderNotConfiguredException; +use GuzzleHttp\Client as HttpClient; +use GuzzleHttp\Exception\GuzzleException; +use GuzzleHttp\Exception\RequestException; use Illuminate\Http\Request; use Laravel\Socialite\Two\AbstractProvider as SocialiteProvider; use Laravel\Socialite\Two\User as SocialiteUser; @@ -29,6 +33,19 @@ public function __construct( ) { } + /** + * The HTTP client used to talk to the provider. Defaults to a fresh client with + * short timeouts; tests hand in one backed by canned responses. + */ + protected ?HttpClient $http = null; + + public function useHttpClient(?HttpClient $http): static + { + $this->http = $http; + + return $this; + } + /** * The Socialite provider class backing this driver. * @@ -108,6 +125,42 @@ public function exchange(string $code, string $codeVerifier, string $redirectUri return $this->normalize($socialiteUser, $callbackPayload)->withRawTokenResponse($tokenResponse); } + public function verifyCredentials(string $redirectUri): CredentialCheck + { + try { + // A code that cannot exist, and a throwaway verifier to go with it: the + // provider must authenticate the client before it can reject either. + $response = $this->build($redirectUri) + ->withServerSidePkce(bin2hex(random_bytes(32))) + ->exchangeAuthorizationCode('fleetbase-credential-check-' . bin2hex(random_bytes(8))); + } catch (OAuthProviderNotConfiguredException $e) { + return CredentialCheck::InvalidClient; + } catch (RequestException $e) { + $status = $e->getResponse()?->getStatusCode() ?? 0; + + if ($status < 400 || $status >= 500) { + return CredentialCheck::Unreachable; + } + + $body = json_decode((string) $e->getResponse()->getBody(), true); + + return CredentialCheck::fromTokenError(is_array($body) && is_string($body['error'] ?? null) ? $body['error'] : null); + } catch (GuzzleException $e) { + return CredentialCheck::Unreachable; + } + + // GitHub reports errors in a 200 response body. + $error = $response['error'] ?? null; + + if (is_string($error)) { + return CredentialCheck::fromTokenError($error); + } + + // A token for a code that was never issued would be a provider bug; there is + // nothing to conclude from it about these credentials. + return CredentialCheck::Inconclusive; + } + /** * The configured client id, narrowed to a string. */ @@ -154,6 +207,7 @@ protected function build(string $redirectUri, bool $withSecret = true): Socialit ); $provider->stateless()->setScopes($this->scopes()); + $provider->setHttpClient($this->http ?? new HttpClient(['timeout' => 10, 'connect_timeout' => 5])); return $this->configureProvider($provider); } diff --git a/src/Auth/OAuth/Contracts/OAuthProviderDriver.php b/src/Auth/OAuth/Contracts/OAuthProviderDriver.php index 292f5ed7..7d501648 100644 --- a/src/Auth/OAuth/Contracts/OAuthProviderDriver.php +++ b/src/Auth/OAuth/Contracts/OAuthProviderDriver.php @@ -71,4 +71,10 @@ public function authorizationUrl(string $state, string $codeVerifier, string $re * outside the token response */ public function exchange(string $code, string $codeVerifier, string $redirectUri, array $callbackPayload = []): OAuthUserProfile; + + /** + * Ask the provider whether it accepts these client credentials, without signing + * anyone in. Makes one request to the provider's token endpoint. + */ + public function verifyCredentials(string $redirectUri): \Fleetbase\Auth\OAuth\CredentialCheck; } diff --git a/src/Auth/OAuth/CredentialCheck.php b/src/Auth/OAuth/CredentialCheck.php new file mode 100644 index 00000000..8015ce04 --- /dev/null +++ b/src/Auth/OAuth/CredentialCheck.php @@ -0,0 +1,53 @@ + self::Verified, + // Unknown client id, wrong secret, or an app the provider will not serve. + 'invalid_client', 'unauthorized_client', 'incorrect_client_credentials' => self::InvalidClient, + 'redirect_uri_mismatch' => self::RedirectUriMismatch, + default => self::Inconclusive, + }; + } +} diff --git a/src/Auth/OAuth/OAuthProviderRegistry.php b/src/Auth/OAuth/OAuthProviderRegistry.php index 381aa890..e1e1efca 100644 --- a/src/Auth/OAuth/OAuthProviderRegistry.php +++ b/src/Auth/OAuth/OAuthProviderRegistry.php @@ -5,6 +5,7 @@ use Fleetbase\Auth\OAuth\Contracts\OAuthProviderDriver; use Fleetbase\Auth\OAuth\Exceptions\UnknownOAuthProviderException; use Fleetbase\Services\OAuth\OAuthConfigRepository; +use GuzzleHttp\Client as HttpClient; use Illuminate\Http\Request; /** @@ -21,6 +22,7 @@ public function __construct( private OAuthConfigRepository $config, private Request $request, private IdTokenVerifier $idTokenVerifier, + private ?HttpClient $http = null, ) { } @@ -43,6 +45,17 @@ public function has(string $provider): bool * @throws UnknownOAuthProviderException */ public function driver(string $provider): OAuthProviderDriver + { + return $this->driverWith($provider, $this->config->forProvider($provider)); + } + + /** + * A driver running on configuration supplied by the caller rather than what is + * stored — how the admin form checks credentials it has not saved yet. + * + * @throws UnknownOAuthProviderException + */ + public function driverWith(string $provider, OAuthProviderConfig $config): OAuthProviderDriver { $class = $this->driverClass($provider); @@ -50,11 +63,13 @@ public function driver(string $provider): OAuthProviderDriver throw new UnknownOAuthProviderException('unknown_provider'); } - return new $class( - $this->config->forProvider($provider), - $this->request, - $this->idTokenVerifier - ); + $driver = new $class($config, $this->request, $this->idTokenVerifier); + + if ($driver instanceof AbstractOAuthProviderDriver) { + $driver->useHttpClient($this->http); + } + + return $driver; } /** diff --git a/src/Http/Controllers/Internal/v1/SettingController.php b/src/Http/Controllers/Internal/v1/SettingController.php index 790cbaf4..6ff62565 100644 --- a/src/Http/Controllers/Internal/v1/SettingController.php +++ b/src/Http/Controllers/Internal/v1/SettingController.php @@ -3,9 +3,12 @@ namespace Fleetbase\Http\Controllers\Internal\v1; use Fleetbase\Auth\OAuth\AppleClientSecretFactory; +use Fleetbase\Auth\OAuth\Contracts\OAuthProviderDriver; +use Fleetbase\Auth\OAuth\CredentialCheck; use Fleetbase\Auth\OAuth\Drivers\AppleDriver; use Fleetbase\Auth\OAuth\Exceptions\OAuthProviderNotConfiguredException; use Fleetbase\Auth\OAuth\Exceptions\UnknownOAuthProviderException; +use Fleetbase\Auth\OAuth\OAuthProviderConfig; use Fleetbase\Auth\OAuth\OAuthProviderRegistry; use Fleetbase\Http\Controllers\Controller; use Fleetbase\Http\Requests\Admin\SaveOAuthConfigRequest; @@ -1066,7 +1069,7 @@ public function getOAuthConfig(AdminRequest $request, OAuthProviderRegistry $reg * * @return \Illuminate\Http\JsonResponse */ - public function saveOAuthConfig(SaveOAuthConfigRequest $request, OAuthProviderRegistry $registry, OAuthConfigRepository $config, OAuthFlowService $flow) + public function saveOAuthConfig(SaveOAuthConfigRequest $request, OAuthProviderRegistry $registry, OAuthConfigRepository $config, OAuthFlowService $flow, AppleClientSecretFactory $apple) { $global = []; @@ -1102,7 +1105,25 @@ public function saveOAuthConfig(SaveOAuthConfigRequest $request, OAuthProviderRe } $providers[$id] = $values; - $secretKeys[$id] = array_keys(array_filter($schema, fn (array $definition): bool => ($definition['secret'] ?? false) === true)); + $secretKeys[$id] = $this->oauthSecretKeys($schema); + } + + // A provider only goes live, or changes credentials while live, once the + // provider itself has accepted those credentials. Checked before anything is + // written, so a rejected save leaves every provider as it was. + foreach ($providers as $id => $values) { + $stored = $config->forProvider($id); + $draft = $config->draftFor($id, $values, $secretKeys[$id]); + + if (!$draft->enabled() || ($stored->enabled() && !$this->oauthCredentialsChanged($stored, $values, $secretKeys[$id]))) { + continue; + } + + $check = $this->checkOAuthProvider($registry->driverWith($id, $draft), $draft, $registry->schemas()[$id], $flow->redirectUri($id), $apple); + + if ($check['problem'] !== null) { + return response()->error($check['message'], 422, ['code' => 'oauth_provider_check_failed', 'provider' => $id, 'problem' => $check['problem']]); + } } $config->save($global, $providers, $secretKeys); @@ -1113,13 +1134,13 @@ public function saveOAuthConfig(SaveOAuthConfigRequest $request, OAuthProviderRe /** * Check a provider's configuration without signing anyone in. * - * Confirms every required credential is present and decryptable, and for Apple - * that the signing key actually mints a client secret — the failure an operator is - * most likely to hit, and one that would otherwise only surface at the first real - * sign-in. Also echoes the redirect URI to register with the provider. + * Runs against the form as it currently stands: `values` (the unsaved fields for + * this provider) are layered over what is stored, so an administrator can check + * credentials before saving them. Nothing is written. * - * This does not call the provider, so it cannot tell a revoked client secret from a - * valid one; that surfaces as an exchange failure on first sign-in. + * Confirms every required credential is present, that an Apple signing key + * actually mints a client secret, and then asks the provider itself whether it + * accepts the client credentials — see CredentialCheck. * * @return \Illuminate\Http\JsonResponse */ @@ -1128,30 +1149,125 @@ public function testOAuthConfig(AdminRequest $request, OAuthProviderRegistry $re $id = (string) $request->input('provider'); try { - $driver = $registry->driver($id); + $registry->driver($id); } catch (UnknownOAuthProviderException $e) { return response()->error('Unknown sign-in provider.', 404, ['code' => 'unknown_provider']); } - $configured = $driver->isConfigured(); - $problem = $configured ? null : 'missing_credentials'; + $schema = $registry->schemas()[$id]; + $input = $request->input('values'); + $values = []; - if ($configured && $driver instanceof AppleDriver) { + // Same filtering as a save: only fields the driver declares, only strings. + foreach (array_keys($schema) as $field) { + if (is_array($input) && array_key_exists($field, $input)) { + $values[$field] = is_string($input[$field]) ? $input[$field] : null; + } + } + + $draft = $config->draftFor($id, $values, $this->oauthSecretKeys($schema)); + $redirectUri = $flow->redirectUri($id); + $check = $this->checkOAuthProvider($registry->driverWith($id, $draft), $draft, $schema, $redirectUri, $apple); + + return response()->json(array_merge(['provider' => $id], $check, ['redirect_uri' => $redirectUri])); + } + + /** + * Everything that has to be true before a provider can be offered on the sign-in + * page, cheapest first. Stops at the first problem. + * + * @param array $schema + * + * @return array{configured: bool, verified: bool, problem: ?string, missing: array, message: string} + */ + private function checkOAuthProvider(OAuthProviderDriver $driver, OAuthProviderConfig $config, array $schema, string $redirectUri, AppleClientSecretFactory $apple): array + { + $label = $driver::label(); + $missing = []; + + foreach ($schema as $field => $definition) { + if (($definition['required'] ?? false) !== true) { + continue; + } + + $present = ($definition['secret'] ?? false) === true ? $config->hasSecret($field) : $config->get($field) !== null; + + if (!$present) { + $missing[] = $definition['label'] ?? $field; + } + } + + if ($missing !== [] || !$driver->isConfigured()) { + return $this->oauthCheckResult(false, false, 'missing_credentials', $missing, 'Missing: ' . implode(', ', $missing ?: ['a required credential']) . '.'); + } + + if ($driver instanceof AppleDriver) { try { - $apple->make($config->forProvider($id)); + $apple->make($config); } catch (OAuthProviderNotConfiguredException $e) { - $configured = false; - $problem = 'invalid_signing_key'; + return $this->oauthCheckResult(false, false, 'invalid_signing_key', [], 'The signing key could not be used to mint a client secret. Check that it is the full .p8 file for this Key ID.'); } } - return response()->json([ - 'provider' => $id, - 'configured' => $configured, - 'enabled' => $configured && $driver->isEnabled(), - 'problem' => $problem, - 'redirect_uri' => $flow->redirectUri($id), - ]); + return match ($driver->verifyCredentials($redirectUri)) { + CredentialCheck::Verified => $this->oauthCheckResult(true, true, null, [], $label . ' accepted these credentials.'), + CredentialCheck::InvalidClient => $this->oauthCheckResult(true, false, 'invalid_client', [], $label . ' rejected these credentials. Check the Client ID and Client Secret.'), + CredentialCheck::RedirectUriMismatch => $this->oauthCheckResult(true, false, 'redirect_uri_mismatch', [], $label . ' does not recognise the callback URL. Register it exactly as shown below.'), + CredentialCheck::Unreachable => $this->oauthCheckResult(true, false, 'unreachable', [], $label . ' could not be reached to check these credentials. Try again shortly.'), + CredentialCheck::Inconclusive => $this->oauthCheckResult(true, false, 'inconclusive', [], $label . ' gave an unexpected answer, so these credentials could not be confirmed.'), + }; + } + + /** + * @param array $missing + * + * @return array{configured: bool, verified: bool, problem: ?string, missing: array, message: string} + */ + private function oauthCheckResult(bool $configured, bool $verified, ?string $problem, array $missing, string $message): array + { + return ['configured' => $configured, 'verified' => $verified, 'problem' => $problem, 'missing' => $missing, 'message' => $message]; + } + + /** + * Whether a save changes anything the provider authenticates — a new secret, or a + * different value for any other field. + * + * @param array $values + * @param array $secretKeys + */ + private function oauthCredentialsChanged(OAuthProviderConfig $stored, array $values, array $secretKeys): bool + { + foreach ($values as $key => $value) { + if ($key === 'enabled') { + continue; + } + + if (in_array($key, $secretKeys, true)) { + if (is_string($value) && trim($value) !== '') { + return true; + } + + continue; + } + + $value = is_string($value) && trim($value) !== '' ? trim($value) : null; + + if ($value !== $stored->get($key)) { + return true; + } + } + + return false; + } + + /** + * @param array $schema + * + * @return array + */ + private function oauthSecretKeys(array $schema): array + { + return array_keys(array_filter($schema, fn (array $definition): bool => ($definition['secret'] ?? false) === true)); } /** diff --git a/src/Services/OAuth/OAuthConfigRepository.php b/src/Services/OAuth/OAuthConfigRepository.php index 18e55388..bf792130 100644 --- a/src/Services/OAuth/OAuthConfigRepository.php +++ b/src/Services/OAuth/OAuthConfigRepository.php @@ -86,6 +86,42 @@ public function forProvider(string $provider): OAuthProviderConfig return new OAuthProviderConfig($provider, $this->mergedValues($provider), $this->encrypter); } + /** + * Configuration for one provider as it would be after saving $draft. + * + * Held in memory only: nothing is written. Draft secrets stay plaintext and + * replace any stored ciphertext; an empty draft secret keeps the stored one, + * exactly as save() treats it. + * + * @param array $draft + * @param array $secretKeys + */ + public function draftFor(string $provider, array $draft, array $secretKeys = []): OAuthProviderConfig + { + $values = $this->mergedValues($provider); + + foreach ($draft as $key => $value) { + if ($key === 'driver') { + continue; + } + + if (in_array($key, $secretKeys, true)) { + if (!is_string($value) || trim($value) === '') { + continue; + } + + $values[$key] = trim($value); + unset($values[$key . OAuthProviderConfig::ENCRYPTED_SUFFIX]); + + continue; + } + + $values[$key] = is_string($value) ? trim($value) : $value; + } + + return new OAuthProviderConfig($provider, $values, $this->encrypter); + } + /** * The provider ids this installation defines. * diff --git a/tests/Unit/Http/OAuthControllerTest.php b/tests/Unit/Http/OAuthControllerTest.php index c5490f76..21759650 100644 --- a/tests/Unit/Http/OAuthControllerTest.php +++ b/tests/Unit/Http/OAuthControllerTest.php @@ -306,6 +306,11 @@ public function exchange(string $code, string $codeVerifier, string $redirectUri return self::$behaviour['profile'] ?? new OAuthUserProfile('fakeprovider', 'subject-1', 'ada@example.com', true, 'Ada Lovelace'); } + + public function verifyCredentials(string $redirectUri): Fleetbase\Auth\OAuth\CredentialCheck + { + return Fleetbase\Auth\OAuth\CredentialCheck::Verified; + } } function oauth_controller_database(array $config = []): Capsule diff --git a/tests/Unit/Http/SettingControllerOAuthTest.php b/tests/Unit/Http/SettingControllerOAuthTest.php index 9b1b97b0..6c2cd294 100644 --- a/tests/Unit/Http/SettingControllerOAuthTest.php +++ b/tests/Unit/Http/SettingControllerOAuthTest.php @@ -13,6 +13,13 @@ use Fleetbase\Services\OAuth\OAuthConfigRepository; use Fleetbase\Services\OAuth\OAuthFlowService; use Fleetbase\Services\OAuth\OAuthStateService; +use GuzzleHttp\Client as HttpClient; +use GuzzleHttp\Exception\ConnectException; +use GuzzleHttp\Handler\MockHandler; +use GuzzleHttp\HandlerStack; +use GuzzleHttp\Middleware; +use GuzzleHttp\Psr7\Request as PsrRequest; +use GuzzleHttp\Psr7\Response as PsrResponse; use Illuminate\Contracts\Encryption\DecryptException; use Illuminate\Contracts\Encryption\Encrypter; use Illuminate\Database\Capsule\Manager as Capsule; @@ -102,7 +109,55 @@ public function getPrefix(): string } /** - * @return array{0: OAuthProviderRegistry, 1: OAuthConfigRepository, 2: OAuthFlowService, 3: AppleClientSecretFactory} + * Stands in for the providers' token endpoints. Queue what the provider answers; + * an unexpected call fails the test, because the queue is empty. + */ +class SettingControllerOAuthProviderFake +{ + public MockHandler $mock; + + /** @var array */ + public array $history = []; + + public function __construct() + { + $this->mock = new MockHandler(); + } + + public function client(): HttpClient + { + $stack = HandlerStack::create($this->mock); + $stack->push(Middleware::history($this->history)); + + return new HttpClient(['handler' => $stack]); + } + + public function says(int $status, array $body): self + { + $this->mock->append(new PsrResponse($status, ['Content-Type' => 'application/json'], json_encode($body))); + + return $this; + } + + /** The provider authenticated the client and rejected only the made-up code. */ + public function accepts(): self + { + return $this->says(400, ['error' => 'invalid_grant']); + } + + /** + * @return array + */ + public function lastForm(): array + { + parse_str((string) end($this->history)['request']->getBody(), $form); + + return $form; + } +} + +/** + * @return array{0: OAuthProviderRegistry, 1: OAuthConfigRepository, 2: OAuthFlowService, 3: AppleClientSecretFactory, 4: SettingControllerOAuthProviderFake} */ function setting_controller_oauth_services(array $config = []): array { @@ -151,10 +206,11 @@ function setting_controller_oauth_services(array $config = []): array $encrypter = new SettingControllerOAuthEncrypterFake(); $config = new OAuthConfigRepository($encrypter); - $registry = new OAuthProviderRegistry($config, Request::create('/'), new IdTokenVerifier()); + $provider = new SettingControllerOAuthProviderFake(); + $registry = new OAuthProviderRegistry($config, Request::create('/'), new IdTokenVerifier(), $provider->client()); $flow = new OAuthFlowService($registry, new OAuthStateService($encrypter), $config); - return [$registry, $config, $flow, new AppleClientSecretFactory()]; + return [$registry, $config, $flow, new AppleClientSecretFactory(), $provider]; } function setting_controller_oauth_save(array $body): SaveOAuthConfigRequest @@ -197,11 +253,12 @@ function setting_controller_oauth_ec_key(): string }); it('never returns a secret value, even to an administrator', function () { - [$registry, $config, $flow] = setting_controller_oauth_services(); + [$registry, $config, $flow, , $provider] = setting_controller_oauth_services(); + $provider->accepts(); (new SettingController())->saveOAuthConfig(setting_controller_oauth_save([ 'providers' => ['google' => ['enabled' => true, 'client_id' => 'google-id', 'client_secret' => 'super-secret-value']], - ]), $registry, $config, $flow); + ]), $registry, $config, $flow, new AppleClientSecretFactory()); $body = json_encode((new SettingController()) ->getOAuthConfig(AdminRequest::create('/'), $registry, $config, $flow) @@ -221,7 +278,7 @@ function setting_controller_oauth_ec_key(): string (new SettingController())->saveOAuthConfig(setting_controller_oauth_save([ 'providers' => ['google' => ['client_id' => 'google-id', 'client_secret' => 'super-secret-value']], - ]), $registry, $config, $flow); + ]), $registry, $config, $flow, new AppleClientSecretFactory()); $stored = setting_controller_oauth_stored(); @@ -237,13 +294,13 @@ function setting_controller_oauth_ec_key(): string $controller->saveOAuthConfig(setting_controller_oauth_save([ 'providers' => ['google' => ['client_id' => 'id-1', 'client_secret' => 'original']], - ]), $registry, $config, $flow); + ]), $registry, $config, $flow, new AppleClientSecretFactory()); // The form renders a masked placeholder, so a save that did not touch the field // submits it empty. That must not wipe the credential. $controller->saveOAuthConfig(setting_controller_oauth_save([ 'providers' => ['google' => ['client_id' => 'id-2', 'client_secret' => '']], - ]), $registry, $config, $flow); + ]), $registry, $config, $flow, new AppleClientSecretFactory()); expect($config->forProvider('google')->secret('client_secret'))->toBe('original') ->and($config->forProvider('google')->get('client_id'))->toBe('id-2'); @@ -254,7 +311,7 @@ function setting_controller_oauth_ec_key(): string (new SettingController())->saveOAuthConfig(setting_controller_oauth_save([ 'providers' => ['made-up' => ['client_id' => 'x', 'client_secret' => 'y']], - ]), $registry, $config, $flow); + ]), $registry, $config, $flow, new AppleClientSecretFactory()); expect(setting_controller_oauth_stored()['providers'] ?? [])->not->toHaveKey('made-up'); }); @@ -270,7 +327,7 @@ function setting_controller_oauth_ec_key(): string 'team_id' => 'apple-only-field', 'nonsense' => 'value', ]], - ]), $registry, $config, $flow); + ]), $registry, $config, $flow, new AppleClientSecretFactory()); $google = setting_controller_oauth_stored()['providers']['google']; @@ -281,13 +338,14 @@ function setting_controller_oauth_ec_key(): string }); it('persists the global switches and provider toggles', function () { - [$registry, $config, $flow] = setting_controller_oauth_services(); + [$registry, $config, $flow, , $provider] = setting_controller_oauth_services(); + $provider->says(200, ['error' => 'bad_verification_code']); (new SettingController())->saveOAuthConfig(setting_controller_oauth_save([ 'enabled' => false, 'allow_registration' => '0', - 'providers' => ['github' => ['enabled' => 'true']], - ]), $registry, $config, $flow); + 'providers' => ['github' => ['enabled' => 'true', 'client_id' => 'gh-id', 'client_secret' => 'gh-secret']], + ]), $registry, $config, $flow, new AppleClientSecretFactory()); expect($config->isEnabled())->toBeFalse() ->and($config->allowsRegistration())->toBeFalse() @@ -298,8 +356,8 @@ function setting_controller_oauth_ec_key(): string [$registry, $config, $flow] = setting_controller_oauth_services(); $controller = new SettingController(); - $controller->saveOAuthConfig(setting_controller_oauth_save(['allow_registration' => false]), $registry, $config, $flow); - $controller->saveOAuthConfig(setting_controller_oauth_save(['providers' => ['google' => ['client_id' => 'x']]]), $registry, $config, $flow); + $controller->saveOAuthConfig(setting_controller_oauth_save(['allow_registration' => false]), $registry, $config, $flow, new AppleClientSecretFactory()); + $controller->saveOAuthConfig(setting_controller_oauth_save(['providers' => ['google' => ['client_id' => 'x']]]), $registry, $config, $flow, new AppleClientSecretFactory()); expect($config->allowsRegistration())->toBeFalse(); }); @@ -309,7 +367,7 @@ function setting_controller_oauth_ec_key(): string $payload = (new SettingController())->saveOAuthConfig(setting_controller_oauth_save([ 'providers' => ['google' => ['client_id' => 'saved-id']], - ]), $registry, $config, $flow)->getData(true); + ]), $registry, $config, $flow, new AppleClientSecretFactory())->getData(true); expect($payload['oauth']['providers']['google']['client_id'])->toBe('saved-id') ->and($payload)->toHaveKeys(['oauth', 'providers', 'redirect_uris']); @@ -329,25 +387,29 @@ function setting_controller_oauth_ec_key(): string expect($payload)->toBe([ 'provider' => 'google', 'configured' => false, - 'enabled' => false, + 'verified' => false, 'problem' => 'missing_credentials', + 'missing' => ['Client ID', 'Client Secret'], + 'message' => 'Missing: Client ID, Client Secret.', 'redirect_uri' => 'https://api.fleetbase.test/int/v1/auth/oauth/google/callback', ]); }); it('reports a fully configured provider', function () { - [$registry, $config, $flow, $apple] = setting_controller_oauth_services(); + [$registry, $config, $flow, $apple, $provider] = setting_controller_oauth_services(); + // GitHub answers token errors with HTTP 200: once for the save, once for the check. + $provider->says(200, ['error' => 'bad_verification_code'])->says(200, ['error' => 'bad_verification_code']); (new SettingController())->saveOAuthConfig(setting_controller_oauth_save([ 'providers' => ['github' => ['enabled' => true, 'client_id' => 'gh-id', 'client_secret' => 'gh-secret']], - ]), $registry, $config, $flow); + ]), $registry, $config, $flow, new AppleClientSecretFactory()); $payload = (new SettingController())->testOAuthConfig( AdminRequest::create('/', 'POST', ['provider' => 'github']), $registry, $config, $flow, $apple )->getData(true); expect($payload['configured'])->toBeTrue() - ->and($payload['enabled'])->toBeTrue() + ->and($payload['verified'])->toBeTrue() ->and($payload['problem'])->toBeNull(); }); @@ -363,7 +425,7 @@ function setting_controller_oauth_ec_key(): string 'key_id' => 'KEY', 'private_key' => "-----BEGIN PRIVATE KEY-----\nnot-a-real-key\n-----END PRIVATE KEY-----", ]], - ]), $registry, $config, $flow); + ]), $registry, $config, $flow, new AppleClientSecretFactory()); // The failure an operator is most likely to hit, and one that would otherwise only // surface at the first real sign-in. @@ -376,7 +438,8 @@ function setting_controller_oauth_ec_key(): string }); it('accepts an apple signing key that mints a client secret', function () { - [$registry, $config, $flow, $apple] = setting_controller_oauth_services(); + [$registry, $config, $flow, $apple, $provider] = setting_controller_oauth_services(); + $provider->accepts(); (new SettingController())->saveOAuthConfig(setting_controller_oauth_save([ 'providers' => ['apple' => [ @@ -385,14 +448,176 @@ function setting_controller_oauth_ec_key(): string 'key_id' => 'KEY', 'private_key' => setting_controller_oauth_ec_key(), ]], - ]), $registry, $config, $flow); + ]), $registry, $config, $flow, new AppleClientSecretFactory()); $payload = (new SettingController())->testOAuthConfig( AdminRequest::create('/', 'POST', ['provider' => 'apple']), $registry, $config, $flow, $apple )->getData(true); expect($payload['configured'])->toBeTrue() - ->and($payload['problem'])->toBeNull(); + ->and($payload['problem'])->toBeNull() + // The minted assertion, not the .p8, is what Apple receives. + ->and($provider->lastForm()['client_secret'])->toStartWith('eyJ'); +}); + +it('checks the values in the form before they are saved', function () { + [$registry, $config, $flow, $apple, $provider] = setting_controller_oauth_services(); + $provider->accepts(); + + // Nothing stored yet: the admin has typed credentials and not pressed save. + $payload = (new SettingController())->testOAuthConfig(AdminRequest::create('/', 'POST', [ + 'provider' => 'google', + 'values' => ['client_id' => 'typed-id', 'client_secret' => 'typed-secret', 'hosted_domain' => ''], + ]), $registry, $config, $flow, $apple)->getData(true); + + expect($payload['verified'])->toBeTrue() + ->and($payload['problem'])->toBeNull() + ->and($provider->lastForm())->toMatchArray(['client_id' => 'typed-id', 'client_secret' => 'typed-secret', 'redirect_uri' => 'https://api.fleetbase.test/int/v1/auth/oauth/google/callback']) + ->and(setting_controller_oauth_stored())->toBe([]); +}); + +it('checks with the stored secret when the form leaves it blank', function () { + [$registry, $config, $flow, $apple, $provider] = setting_controller_oauth_services(); + (new SettingController())->saveOAuthConfig(setting_controller_oauth_save([ + 'providers' => ['google' => ['client_id' => 'stored-id', 'client_secret' => 'stored-secret']], + ]), $registry, $config, $flow, $apple); + $provider->accepts(); + + (new SettingController())->testOAuthConfig(AdminRequest::create('/', 'POST', [ + 'provider' => 'google', + 'values' => ['client_id' => 'new-id', 'client_secret' => ''], + ]), $registry, $config, $flow, $apple); + + expect($provider->lastForm())->toMatchArray(['client_id' => 'new-id', 'client_secret' => 'stored-secret']) + ->and($config->forProvider('google')->get('client_id'))->toBe('stored-id'); +}); + +it('reports what the provider said about the credentials', function (int $status, array $body, string $problem) { + [$registry, $config, $flow, $apple, $provider] = setting_controller_oauth_services(); + $provider->says($status, $body); + + $payload = (new SettingController())->testOAuthConfig(AdminRequest::create('/', 'POST', [ + 'provider' => 'google', + 'values' => ['client_id' => 'id', 'client_secret' => 'secret'], + ]), $registry, $config, $flow, $apple)->getData(true); + + expect($payload['problem'])->toBe($problem) + ->and($payload['verified'])->toBeFalse() + ->and($payload['configured'])->toBeTrue() + ->and($payload['message'])->toStartWith('Google'); +})->with([ + 'wrong secret' => [401, ['error' => 'invalid_client'], 'invalid_client'], + 'unknown app' => [400, ['error' => 'unauthorized_client'], 'invalid_client'], + 'unregistered url' => [400, ['error' => 'redirect_uri_mismatch'], 'redirect_uri_mismatch'], + 'provider outage' => [503, [], 'unreachable'], + 'unrecognised answer' => [400, ['error' => 'something_new'], 'inconclusive'], +]); + +it('reports an unreachable provider', function () { + [$registry, $config, $flow, $apple, $provider] = setting_controller_oauth_services(); + $provider->mock->append(new ConnectException('Connection refused', new PsrRequest('POST', 'https://oauth2.googleapis.com/token'))); + + $payload = (new SettingController())->testOAuthConfig(AdminRequest::create('/', 'POST', [ + 'provider' => 'google', + 'values' => ['client_id' => 'id', 'client_secret' => 'secret'], + ]), $registry, $config, $flow, $apple)->getData(true); + + expect($payload['problem'])->toBe('unreachable'); +}); + +it('does not call the provider while credentials are missing', function () { + [$registry, $config, $flow, $apple, $provider] = setting_controller_oauth_services(); + + $payload = (new SettingController())->testOAuthConfig(AdminRequest::create('/', 'POST', [ + 'provider' => 'google', + 'values' => ['client_id' => 'id'], + ]), $registry, $config, $flow, $apple)->getData(true); + + expect($payload['missing'])->toBe(['Client Secret']) + ->and($provider->history)->toBe([]); +}); + +// --------------------------------------------------------------------------- +// Enabling +// --------------------------------------------------------------------------- + +it('refuses to offer a provider whose credentials the provider rejects', function () { + [$registry, $config, $flow, $apple, $provider] = setting_controller_oauth_services(); + $provider->says(401, ['error' => 'invalid_client']); + + $response = (new SettingController())->saveOAuthConfig(setting_controller_oauth_save([ + 'allow_registration' => false, + 'providers' => ['google' => ['enabled' => true, 'client_id' => 'id', 'client_secret' => 'wrong']], + ]), $registry, $config, $flow, $apple); + + // Refused as a whole: not even the unrelated switch is written. + expect($response->getStatusCode())->toBe(422) + ->and($response->getData(true))->toMatchArray(['code' => 'oauth_provider_check_failed', 'provider' => 'google', 'problem' => 'invalid_client']) + ->and(setting_controller_oauth_stored())->toBe([]); +}); + +it('refuses to offer a provider with missing credentials', function () { + [$registry, $config, $flow, $apple, $provider] = setting_controller_oauth_services(); + + $response = (new SettingController())->saveOAuthConfig(setting_controller_oauth_save([ + 'providers' => ['github' => ['enabled' => true]], + ]), $registry, $config, $flow, $apple); + + expect($response->getStatusCode())->toBe(422) + ->and($response->getData(true)['problem'])->toBe('missing_credentials') + ->and($provider->history)->toBe([]); +}); + +it('offers a provider once the provider accepts its credentials', function () { + [$registry, $config, $flow, $apple, $provider] = setting_controller_oauth_services(); + $provider->accepts(); + + $response = (new SettingController())->saveOAuthConfig(setting_controller_oauth_save([ + 'providers' => ['google' => ['enabled' => true, 'client_id' => 'id', 'client_secret' => 'right']], + ]), $registry, $config, $flow, $apple); + + expect($response->getStatusCode())->toBe(200) + ->and($registry->driver('google')->isEnabled())->toBeTrue(); +}); + +it('rechecks a live provider only when its credentials change', function () { + [$registry, $config, $flow, $apple, $provider] = setting_controller_oauth_services(); + $controller = new SettingController(); + $provider->accepts(); + $controller->saveOAuthConfig(setting_controller_oauth_save([ + 'providers' => ['google' => ['enabled' => true, 'client_id' => 'id', 'client_secret' => 'right']], + ]), $registry, $config, $flow, $apple); + + // The form resubmits every provider on each save; an untouched one is not re-asked. + $controller->saveOAuthConfig(setting_controller_oauth_save([ + 'providers' => ['google' => ['enabled' => true, 'client_id' => 'id', 'client_secret' => '', 'hosted_domain' => '']], + ]), $registry, $config, $flow, $apple); + expect($provider->history)->toHaveCount(1); + + // A new secret on a live provider is checked before it replaces the working one. + $provider->says(401, ['error' => 'invalid_client']); + $response = $controller->saveOAuthConfig(setting_controller_oauth_save([ + 'providers' => ['google' => ['enabled' => true, 'client_id' => 'id', 'client_secret' => 'typo']], + ]), $registry, $config, $flow, $apple); + + expect($response->getStatusCode())->toBe(422) + ->and($config->forProvider('google')->secret('client_secret'))->toBe('right'); +}); + +it('switches a provider off without asking it anything', function () { + [$registry, $config, $flow, $apple, $provider] = setting_controller_oauth_services(); + $provider->accepts(); + (new SettingController())->saveOAuthConfig(setting_controller_oauth_save([ + 'providers' => ['google' => ['enabled' => true, 'client_id' => 'id', 'client_secret' => 'right']], + ]), $registry, $config, $flow, $apple); + + $response = (new SettingController())->saveOAuthConfig(setting_controller_oauth_save([ + 'providers' => ['google' => ['enabled' => false, 'client_secret' => 'anything']], + ]), $registry, $config, $flow, $apple); + + expect($response->getStatusCode())->toBe(200) + ->and($config->forProvider('google')->enabled())->toBeFalse() + ->and($provider->history)->toHaveCount(1); }); it('rejects an unknown provider under test', function () { From c8c7f71e4cc4214baf6805dd5841255eda024c4c Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 21 Sep 2026 18:31:00 +0800 Subject: [PATCH 10/30] feat(oauth): give every provider settings field an example placeholder The admin form is rendered from these schemas, so fields without one showed as empty boxes next to fields with one. A test now requires a placeholder on every field of every shipped driver. --- .../OAuth/Contracts/OAuthProviderDriver.php | 3 ++- src/Auth/OAuth/Drivers/AppleDriver.php | 26 +++++++++++-------- src/Auth/OAuth/Drivers/GithubDriver.php | 12 +++++---- src/Auth/OAuth/Drivers/GoogleDriver.php | 17 +++++++----- src/Auth/OAuth/Drivers/MicrosoftDriver.php | 17 +++++++----- .../Auth/OAuth/OAuthProviderRegistryTest.php | 13 ++++++++++ 6 files changed, 57 insertions(+), 31 deletions(-) diff --git a/src/Auth/OAuth/Contracts/OAuthProviderDriver.php b/src/Auth/OAuth/Contracts/OAuthProviderDriver.php index 7d501648..94cec9fd 100644 --- a/src/Auth/OAuth/Contracts/OAuthProviderDriver.php +++ b/src/Auth/OAuth/Contracts/OAuthProviderDriver.php @@ -35,8 +35,9 @@ public static function icon(): string; * The credentials this provider accepts, for validation and the admin form. * * Keys marked `secret` are stored encrypted and are never returned to a client. + * Every field carries a `placeholder` showing what a value looks like. * - * @return array + * @return array */ public static function configSchema(): array; diff --git a/src/Auth/OAuth/Drivers/AppleDriver.php b/src/Auth/OAuth/Drivers/AppleDriver.php index c42d74de..865a9f97 100644 --- a/src/Auth/OAuth/Drivers/AppleDriver.php +++ b/src/Auth/OAuth/Drivers/AppleDriver.php @@ -55,23 +55,27 @@ public static function configSchema(): array { return [ 'client_id' => [ - 'label' => 'Services ID', - 'required' => true, - 'help' => 'The Services identifier, for example io.fleetbase.console — not an app bundle ID.', + 'label' => 'Services ID', + 'placeholder' => 'com.example.signin', + 'required' => true, + 'help' => 'The Services identifier, for example io.fleetbase.console — not an app bundle ID.', ], 'team_id' => [ - 'label' => 'Team ID', - 'required' => true, + 'label' => 'Team ID', + 'placeholder' => 'A1B2C3D4E5', + 'required' => true, ], 'key_id' => [ - 'label' => 'Key ID', - 'required' => true, + 'label' => 'Key ID', + 'placeholder' => 'ABC123DEFG', + 'required' => true, ], 'private_key' => [ - 'label' => 'Signing key (.p8)', - 'secret' => true, - 'required' => true, - 'help' => 'Contents of the AuthKey .p8 file. Apple has no static client secret; one is minted from this key for each token request.', + 'label' => 'Signing key (.p8)', + 'placeholder' => '-----BEGIN PRIVATE KEY-----', + 'secret' => true, + 'required' => true, + 'help' => 'Contents of the AuthKey .p8 file. Apple has no static client secret; one is minted from this key for each token request.', ], ]; } diff --git a/src/Auth/OAuth/Drivers/GithubDriver.php b/src/Auth/OAuth/Drivers/GithubDriver.php index e0fe7f5d..8764fdcb 100644 --- a/src/Auth/OAuth/Drivers/GithubDriver.php +++ b/src/Auth/OAuth/Drivers/GithubDriver.php @@ -31,13 +31,15 @@ public static function configSchema(): array { return [ 'client_id' => [ - 'label' => 'Client ID', - 'required' => true, + 'label' => 'Client ID', + 'placeholder' => 'Ov23li…', + 'required' => true, ], 'client_secret' => [ - 'label' => 'Client Secret', - 'secret' => true, - 'required' => true, + 'label' => 'Client Secret', + 'placeholder' => '40-character client secret', + 'secret' => true, + 'required' => true, ], ]; } diff --git a/src/Auth/OAuth/Drivers/GoogleDriver.php b/src/Auth/OAuth/Drivers/GoogleDriver.php index 22fa581c..55caf62f 100644 --- a/src/Auth/OAuth/Drivers/GoogleDriver.php +++ b/src/Auth/OAuth/Drivers/GoogleDriver.php @@ -32,17 +32,20 @@ public static function configSchema(): array { return [ 'client_id' => [ - 'label' => 'Client ID', - 'required' => true, + 'label' => 'Client ID', + 'placeholder' => '123456789012-abc123def456.apps.googleusercontent.com', + 'required' => true, ], 'client_secret' => [ - 'label' => 'Client Secret', - 'secret' => true, - 'required' => true, + 'label' => 'Client Secret', + 'placeholder' => 'GOCSPX-…', + 'secret' => true, + 'required' => true, ], 'hosted_domain' => [ - 'label' => 'Restrict to Workspace domain', - 'help' => 'Optional. Only accounts in this Google Workspace domain may sign in.', + 'label' => 'Restrict to Workspace domain', + 'placeholder' => 'example.com', + 'help' => 'Optional. Only accounts in this Google Workspace domain may sign in.', ], ]; } diff --git a/src/Auth/OAuth/Drivers/MicrosoftDriver.php b/src/Auth/OAuth/Drivers/MicrosoftDriver.php index 3c1ac2b2..d5d392ea 100644 --- a/src/Auth/OAuth/Drivers/MicrosoftDriver.php +++ b/src/Auth/OAuth/Drivers/MicrosoftDriver.php @@ -44,17 +44,20 @@ public static function configSchema(): array { return [ 'client_id' => [ - 'label' => 'Application (client) ID', - 'required' => true, + 'label' => 'Application (client) ID', + 'placeholder' => '00000000-0000-0000-0000-000000000000', + 'required' => true, ], 'client_secret' => [ - 'label' => 'Client Secret', - 'secret' => true, - 'required' => true, + 'label' => 'Client Secret', + 'placeholder' => 'Client secret value, not the secret ID', + 'secret' => true, + 'required' => true, ], 'tenant' => [ - 'label' => 'Directory (tenant) ID', - 'help' => 'A tenant ID or domain restricts sign-in to that directory. "common" accepts any Microsoft account, but then only addresses Microsoft explicitly marks as domain-verified are trusted.', + 'label' => 'Directory (tenant) ID', + 'placeholder' => 'common, or a tenant ID or domain such as contoso.onmicrosoft.com', + 'help' => 'A tenant ID or domain restricts sign-in to that directory. "common" accepts any Microsoft account, but then only addresses Microsoft explicitly marks as domain-verified are trusted.', ], ]; } diff --git a/tests/Unit/Auth/OAuth/OAuthProviderRegistryTest.php b/tests/Unit/Auth/OAuth/OAuthProviderRegistryTest.php index 4d9daa38..ec163451 100644 --- a/tests/Unit/Auth/OAuth/OAuthProviderRegistryTest.php +++ b/tests/Unit/Auth/OAuth/OAuthProviderRegistryTest.php @@ -195,3 +195,16 @@ function oauth_registry_all_providers(): array ->and($registry->driver('microsoft')->usesFormPostCallback())->toBeFalse() ->and($registry->driver('github')->usesFormPostCallback())->toBeFalse(); }); + +it('gives every admin form field an example placeholder', function (string $driver) { + // The console renders the form straight from the schema, so a field without one + // shows up as an empty box beside fields that do have one. + foreach ($driver::configSchema() as $key => $definition) { + expect($definition['placeholder'] ?? '')->not->toBe('', $driver . '::' . $key); + } +})->with([ + GoogleDriver::class, + MicrosoftDriver::class, + GithubDriver::class, + AppleDriver::class, +]); From d5237b56e94a97ca0c81226c39b7d28dd1b8261b Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 21 Sep 2026 22:21:31 +0800 Subject: [PATCH 11/30] feat(oauth): link existing accounts automatically, and email about links Automatic linking. A first-time provider sign-in whose email matches an existing account now links the provider and signs the person in, instead of answering link_required. Every condition is required: - the administrator has not switched it off (new `auto_link` setting, on by default, OAUTH_AUTO_LINK); - the provider verified the email, and it is not an Apple relay alias; - exactly one account has that email; - the account is `admin` or `user`, never customer, contact or driver; - the account's own email is confirmed, so an address someone signed up with but never proved they own cannot capture the owner's sign-in; - no other account from the same provider is linked to it. Sign-in then runs every check it always does; two-factor still applies. The response carries `linked` and `linked_label` so the console can say what happened. Otherwise the answer is still link_required. Emails. Linking or removing a provider emails the account holder: which provider and account, when it happened, a link to review sign-in methods, and what to do if it was not them. An automatic link says why it happened. A provider linked while creating an account sends nothing extra; the signup emails cover it. A failed send is logged and never fails the link or the removal. --- config/oauth.php | 14 ++ src/Events/OAuthIdentityLinked.php | 18 +- .../Internal/v1/OAuthController.php | 82 +++++++- .../Internal/v1/SettingController.php | 2 +- .../Requests/Admin/SaveOAuthConfigRequest.php | 1 + .../SendOAuthIdentityLinkedNotification.php | 38 ++++ .../SendOAuthIdentityUnlinkedNotification.php | 31 +++ src/Notifications/OAuthProviderLinked.php | 83 ++++++++ src/Notifications/OAuthProviderUnlinked.php | 67 +++++++ src/Providers/EventServiceProvider.php | 2 + src/Services/OAuth/OAuthConfigRepository.php | 11 ++ src/Services/OAuth/OAuthIdentityService.php | 4 +- src/Support/OAuth.php | 19 +- tests/Unit/Http/OAuthControllerTest.php | 88 ++++++++- .../Unit/Http/SettingControllerOAuthTest.php | 8 +- .../OAuthProviderNotificationsTest.php | 183 ++++++++++++++++++ .../Support/OAuthRegistrationIntentTest.php | 4 +- 17 files changed, 635 insertions(+), 20 deletions(-) create mode 100644 src/Listeners/SendOAuthIdentityLinkedNotification.php create mode 100644 src/Listeners/SendOAuthIdentityUnlinkedNotification.php create mode 100644 src/Notifications/OAuthProviderLinked.php create mode 100644 src/Notifications/OAuthProviderUnlinked.php create mode 100644 tests/Unit/Notifications/OAuthProviderNotificationsTest.php diff --git a/config/oauth.php b/config/oauth.php index fab8d5d7..145f0f50 100644 --- a/config/oauth.php +++ b/config/oauth.php @@ -40,6 +40,20 @@ 'enabled' => env('OAUTH_ENABLED', true), 'allow_registration' => env('OAUTH_ALLOW_REGISTRATION', true), + /* + |-------------------------------------------------------------------------- + | Automatic linking + |-------------------------------------------------------------------------- + | + | When a provider identity is not linked yet but its verified email matches + | exactly one existing console account (type `admin` or `user`) whose own + | email is confirmed, link it and sign them in instead of asking them to + | sign in some other way and link it by hand. Two-factor still applies, and + | the account holder is emailed. See OAuthController::autoLinkCandidate(). + | + */ + 'auto_link' => env('OAUTH_AUTO_LINK', true), + /* |-------------------------------------------------------------------------- | Console landing path diff --git a/src/Events/OAuthIdentityLinked.php b/src/Events/OAuthIdentityLinked.php index 124ed0ff..1ae53674 100644 --- a/src/Events/OAuthIdentityLinked.php +++ b/src/Events/OAuthIdentityLinked.php @@ -11,9 +11,8 @@ /** * An external OAuth identity was linked to a Fleetbase user. * - * Fired on first link only, not on every sign-in. Core registers no listener; this exists so - * downstream packages (Fleetbase Cloud internals, analytics) can react without core-api - * needing to know they are installed. + * Fired on first link only, not on every sign-in. Core emails the account holder about it + * (SendOAuthIdentityLinkedNotification); downstream packages can listen too. */ class OAuthIdentityLinked { @@ -21,17 +20,28 @@ class OAuthIdentityLinked use InteractsWithSockets; use SerializesModels; + /** The user linked it from their account page. */ + public const METHOD_MANUAL = 'manual'; + + /** Linked on sign-in because the provider's verified email matched the account. */ + public const METHOD_AUTOMATIC = 'automatic'; + + /** Linked while creating the account with this provider. */ + public const METHOD_SIGNUP = 'signup'; + public $user; public $identity; + public $method; /** * Create a new event instance. * * @return void */ - public function __construct(User $user, OAuthIdentity $identity) + public function __construct(User $user, OAuthIdentity $identity, string $method = self::METHOD_MANUAL) { $this->user = $user; $this->identity = $identity; + $this->method = $method; } } diff --git a/src/Http/Controllers/Internal/v1/OAuthController.php b/src/Http/Controllers/Internal/v1/OAuthController.php index 398bda33..09d1db76 100644 --- a/src/Http/Controllers/Internal/v1/OAuthController.php +++ b/src/Http/Controllers/Internal/v1/OAuthController.php @@ -7,6 +7,7 @@ use Fleetbase\Auth\OAuth\Exceptions\UnknownOAuthProviderException; use Fleetbase\Auth\OAuth\OAuthProviderRegistry; use Fleetbase\Auth\OAuth\OAuthUserProfile; +use Fleetbase\Events\OAuthIdentityLinked; use Fleetbase\Http\Controllers\Controller; use Fleetbase\Http\Requests\Internal\OAuthExchangeRequest; use Fleetbase\Http\Requests\Internal\OAuthRedirectRequest; @@ -27,12 +28,18 @@ * * The rule the exchange enforces, and the reason this controller exists rather than * folding into AuthController: a Fleetbase user is resolved from an OAuth identity - * ONLY through an existing (provider, provider_user_id) row. Never by email address. - * An email match is used to tell a user "you already have an account, sign in and - * link it" — it is never itself a credential. + * ONLY through an existing (provider, provider_user_id) row. An email match is never + * itself a credential: it either creates that row under the strict conditions in + * autoLinkCandidate() — after which sign-in runs every check a password sign-in + * does — or tells the user "you already have an account, sign in and link it". */ class OAuthController extends Controller { + /** + * The only account types an identity is ever linked to automatically. + */ + public const AUTO_LINK_USER_TYPES = ['admin', 'user']; + public function __construct( protected OAuthProviderRegistry $registry, protected OAuthFlowService $flow, @@ -139,6 +146,21 @@ public function exchange(OAuthExchangeRequest $request) return $this->authenticate($user, $profile, $state); } + $match = $this->autoLinkCandidate($profile); + + if ($match instanceof User) { + try { + $this->identities->link($match, $profile, OAuthIdentityLinked::METHOD_AUTOMATIC); + } catch (OAuthException $e) { + // Another account claimed this provider subject a moment ago. + return $this->registrationOutcome($profile); + } + + // Linking does not skip anything authenticate() enforces — two-factor + // included. The link alone signs no one in. + return $this->authenticate($match, $profile, $state, true); + } + return $this->registrationOutcome($profile); } @@ -308,8 +330,11 @@ protected function identitiesPayload(User $user): array /** * An identity we already know: run the same gates password login runs. */ - protected function authenticate(User $user, OAuthUserProfile $profile, OAuthState $state) + protected function authenticate(User $user, OAuthUserProfile $profile, OAuthState $state, bool $autoLinked = false) { + // Tells the console to say the provider is now linked, whichever way sign-in ends. + $linked = $autoLinked ? ['linked' => $profile->provider, 'linked_label' => OAuth::providerLabel($profile->provider)] : []; + // AuthController::login:86-88 if ($user->type === 'customer') { return response()->error('Customer accounts must sign in through the customer portal.', 403, ['code' => 'customer_login_not_allowed']); @@ -320,7 +345,7 @@ protected function authenticate(User $user, OAuthUserProfile $profile, OAuthStat return response()->json([ 'twoFaSession' => TwoFactorAuth::start($user), 'isEnabled' => true, - ]); + ] + $linked); } // AuthController::login:114-116. An OAuth sign-in does not by itself verify a @@ -341,7 +366,7 @@ protected function authenticate(User $user, OAuthUserProfile $profile, OAuthStat $user->updateLastLogin(); $token = $user->createToken($user->uuid); - return response()->json(['token' => $token->plainTextToken, 'type' => $user->getType()]); + return response()->json(['token' => $token->plainTextToken, 'type' => $user->getType()] + $linked); } /** @@ -379,6 +404,51 @@ protected function registrationOutcome(OAuthUserProfile $profile) ]); } + /** + * The existing account an unlinked identity may be linked to automatically, if any. + * + * Every condition is required: + * + * - the administrator has not switched automatic linking off; + * - the provider vouched for the address (each driver decides that strictly — + * Microsoft, for one, only for domain-verified addresses) and it is not an + * Apple relay alias, which can never match a stored address; + * - exactly one account holds that address; + * - it is a console account (`admin` or `user`) — never a customer, contact or + * driver, whose sign-in is governed elsewhere; + * - the account's OWN email is confirmed. Without this, anyone could sign up + * with someone else's address, never confirm it, and wait for the real owner + * to sign in with a provider — landing the owner in an account whose + * password the attacker already knows; + * - no identity from this provider is linked to it yet: one provider account + * per user, and a different one already linked is not replaced silently. + */ + protected function autoLinkCandidate(OAuthUserProfile $profile): ?User + { + if (!$this->config->autoLinksVerifiedEmail() || !$profile->hasVerifiedEmail() || $profile->meta('private_relay') === true) { + return null; + } + + $matches = User::where('email', $profile->email)->limit(2)->get(); + + if ($matches->count() !== 1) { + return null; + } + + /** @var User $user */ + $user = $matches->first(); + + if (!in_array($user->type, self::AUTO_LINK_USER_TYPES, true) || empty($user->email_verified_at)) { + return null; + } + + if ($this->identities->findBySubjectForUser($user, $profile->provider) !== null) { + return null; + } + + return $user; + } + /** * Whether this identity's address already belongs to a Fleetbase account. * diff --git a/src/Http/Controllers/Internal/v1/SettingController.php b/src/Http/Controllers/Internal/v1/SettingController.php index 6ff62565..2cc01cac 100644 --- a/src/Http/Controllers/Internal/v1/SettingController.php +++ b/src/Http/Controllers/Internal/v1/SettingController.php @@ -1073,7 +1073,7 @@ public function saveOAuthConfig(SaveOAuthConfigRequest $request, OAuthProviderRe { $global = []; - foreach (['enabled', 'allow_registration'] as $key) { + foreach (['enabled', 'allow_registration', 'auto_link'] as $key) { if ($request->has($key)) { $global[$key] = $request->boolean($key); } diff --git a/src/Http/Requests/Admin/SaveOAuthConfigRequest.php b/src/Http/Requests/Admin/SaveOAuthConfigRequest.php index 5fe8a324..38deeeec 100644 --- a/src/Http/Requests/Admin/SaveOAuthConfigRequest.php +++ b/src/Http/Requests/Admin/SaveOAuthConfigRequest.php @@ -22,6 +22,7 @@ public function rules() return [ 'enabled' => ['sometimes', 'boolean'], 'allow_registration' => ['sometimes', 'boolean'], + 'auto_link' => ['sometimes', 'boolean'], 'providers' => ['sometimes', 'array'], 'providers.*' => ['array'], 'providers.*.enabled' => ['sometimes', 'boolean'], diff --git a/src/Listeners/SendOAuthIdentityLinkedNotification.php b/src/Listeners/SendOAuthIdentityLinkedNotification.php new file mode 100644 index 00000000..05297f6d --- /dev/null +++ b/src/Listeners/SendOAuthIdentityLinkedNotification.php @@ -0,0 +1,38 @@ +method === OAuthIdentityLinked::METHOD_SIGNUP || empty($event->user->email)) { + return; + } + + try { + $event->user->notify(new OAuthProviderLinked( + (string) $event->identity->provider, + $event->identity->provider_email, + $event->method + )); + } catch (\Throwable $e) { + // The link has happened; a mail failure must not undo or fail it. + Log::warning('[OAuth] Could not send the provider-linked email.', [ + 'user' => $event->user->uuid, + 'provider' => $event->identity->provider, + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/src/Listeners/SendOAuthIdentityUnlinkedNotification.php b/src/Listeners/SendOAuthIdentityUnlinkedNotification.php new file mode 100644 index 00000000..2560426d --- /dev/null +++ b/src/Listeners/SendOAuthIdentityUnlinkedNotification.php @@ -0,0 +1,31 @@ +user->email)) { + return; + } + + try { + $event->user->notify(new OAuthProviderUnlinked((string) $event->provider)); + } catch (\Throwable $e) { + // The removal has happened; a mail failure must not fail it. + Log::warning('[OAuth] Could not send the provider-unlinked email.', [ + 'user' => $event->user->uuid, + 'provider' => $event->provider, + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/src/Notifications/OAuthProviderLinked.php b/src/Notifications/OAuthProviderLinked.php new file mode 100644 index 00000000..93d4bc7c --- /dev/null +++ b/src/Notifications/OAuthProviderLinked.php @@ -0,0 +1,83 @@ +linkedAt = now()->toDayDateTimeString() . ' UTC'; + } + + /** + * @return array + */ + public function via($notifiable) + { + return ['mail']; + } + + /** + * @return MailMessage + */ + public function toMail($notifiable) + { + $app = config('app.name'); + $label = OAuth::providerLabel($this->provider); + $account = $this->providerEmail ? $label . ' account (' . $this->providerEmail . ')' : $label . ' account'; + + $message = (new MailMessage()) + ->subject($label . ' was linked to your ' . $app . ' account') + ->greeting('Hello, ' . ($notifiable->name ?? 'there')); + + if ($this->method === OAuthIdentityLinked::METHOD_AUTOMATIC) { + $message + ->line('You signed in to ' . $app . ' with your ' . $account . '.') + ->line('It uses the same verified email address as your ' . $app . ' account, so we linked the two. From now on you can sign in with ' . $label . '.'); + } else { + $message->line('Your ' . $account . ' was linked to your ' . $app . ' account. From now on you can sign in with ' . $label . '.'); + } + + return $message + ->line('Linked: ' . $this->linkedAt) + ->action('Review sign-in methods', Utils::consoleUrl('account/auth')) + ->line('If this was not you, remove ' . $label . ' from your sign-in methods and change your password straight away.'); + } + + /** + * @return array + */ + public function toArray($notifiable) + { + return [ + 'provider' => $this->provider, + 'provider_email' => $this->providerEmail, + 'method' => $this->method, + 'linked_at' => $this->linkedAt, + ]; + } +} diff --git a/src/Notifications/OAuthProviderUnlinked.php b/src/Notifications/OAuthProviderUnlinked.php new file mode 100644 index 00000000..2073c33f --- /dev/null +++ b/src/Notifications/OAuthProviderUnlinked.php @@ -0,0 +1,67 @@ +unlinkedAt = now()->toDayDateTimeString() . ' UTC'; + } + + /** + * @return array + */ + public function via($notifiable) + { + return ['mail']; + } + + /** + * @return MailMessage + */ + public function toMail($notifiable) + { + $app = config('app.name'); + $label = OAuth::providerLabel($this->provider); + + return (new MailMessage()) + ->subject($label . ' was removed from your ' . $app . ' account') + ->greeting('Hello, ' . ($notifiable->name ?? 'there')) + ->line($label . ' was removed from the sign-in methods on your ' . $app . ' account. You can no longer sign in with ' . $label . '.') + ->line('Removed: ' . $this->unlinkedAt) + ->action('Review sign-in methods', Utils::consoleUrl('account/auth')) + ->line('If this was not you, change your password straight away and review your sign-in methods.'); + } + + /** + * @return array + */ + public function toArray($notifiable) + { + return [ + 'provider' => $this->provider, + 'unlinked_at' => $this->unlinkedAt, + ]; + } +} diff --git a/src/Providers/EventServiceProvider.php b/src/Providers/EventServiceProvider.php index ca595568..66dd46e5 100644 --- a/src/Providers/EventServiceProvider.php +++ b/src/Providers/EventServiceProvider.php @@ -17,6 +17,8 @@ class EventServiceProvider extends ServiceProvider */ \Fleetbase\Events\ResourceLifecycleEvent::class => [\Fleetbase\Listeners\SendResourceLifecycleWebhook::class], \Fleetbase\Events\AccountCreated::class => [\Fleetbase\Listeners\HandleAccountCreated::class], + \Fleetbase\Events\OAuthIdentityLinked::class => [\Fleetbase\Listeners\SendOAuthIdentityLinkedNotification::class], + \Fleetbase\Events\OAuthIdentityUnlinked::class => [\Fleetbase\Listeners\SendOAuthIdentityUnlinkedNotification::class], /* * Framework Events diff --git a/src/Services/OAuth/OAuthConfigRepository.php b/src/Services/OAuth/OAuthConfigRepository.php index bf792130..e7c74e7a 100644 --- a/src/Services/OAuth/OAuthConfigRepository.php +++ b/src/Services/OAuth/OAuthConfigRepository.php @@ -56,6 +56,16 @@ public function allowsRegistration(): bool return (bool) $this->globalValue('allow_registration', config('oauth.allow_registration', true)); } + /** + * Whether a provider identity may be linked automatically to an existing + * account whose confirmed email it matches. See OAuthController::autoLinkCandidate() + * for the conditions; this only switches the behaviour on or off. + */ + public function autoLinksVerifiedEmail(): bool + { + return (bool) $this->globalValue('auto_link', config('oauth.auto_link', true)); + } + /** * The public origin of this API, used to build the redirect_uri handed to * providers. Must match what is registered in each provider's console. @@ -239,6 +249,7 @@ public function toAdminArray(array $schemas): array return [ 'enabled' => $this->isEnabled(), 'allow_registration' => $this->allowsRegistration(), + 'auto_link' => $this->autoLinksVerifiedEmail(), 'providers' => $providers, ]; } diff --git a/src/Services/OAuth/OAuthIdentityService.php b/src/Services/OAuth/OAuthIdentityService.php index 38daad49..07888072 100644 --- a/src/Services/OAuth/OAuthIdentityService.php +++ b/src/Services/OAuth/OAuthIdentityService.php @@ -94,7 +94,7 @@ public function findUserByProfile(OAuthUserProfile $profile): ?User * * @throws OAuthException identity_already_linked */ - public function link(User $user, OAuthUserProfile $profile): OAuthIdentity + public function link(User $user, OAuthUserProfile $profile, string $method = OAuthIdentityLinked::METHOD_MANUAL): OAuthIdentity { $existing = $this->findByProfile($profile); @@ -124,7 +124,7 @@ public function link(User $user, OAuthUserProfile $profile): OAuthIdentity $this->backfillLegacyColumn($user, $profile); - event(new OAuthIdentityLinked($user, $identity)); + event(new OAuthIdentityLinked($user, $identity, $method)); return $identity; } diff --git a/src/Support/OAuth.php b/src/Support/OAuth.php index ee92187d..db956c1a 100644 --- a/src/Support/OAuth.php +++ b/src/Support/OAuth.php @@ -2,10 +2,12 @@ namespace Fleetbase\Support; +use Fleetbase\Auth\OAuth\Contracts\OAuthProviderDriver; use Fleetbase\Auth\OAuth\Exceptions\OAuthException; use Fleetbase\Auth\OAuth\Exceptions\OAuthStateException; use Fleetbase\Auth\OAuth\OAuthProviderRegistry; use Fleetbase\Auth\OAuth\OAuthUserProfile; +use Fleetbase\Events\OAuthIdentityLinked; use Fleetbase\Models\OAuthIdentity; use Fleetbase\Models\OAuthState; use Fleetbase\Models\User; @@ -63,6 +65,21 @@ public static function enabledProviders(): array // Registration intent — the entire API a signup implementation needs // ----------------------------------------------------------------------- + /** + * A provider's display name, e.g. "Google" for `google`, for messages to people. + * Falls back to the capitalised id when the provider is no longer defined. + */ + public static function providerLabel(string $provider): string + { + $class = app(OAuthConfigRepository::class)->driverClass($provider); + + if ($class !== null && is_subclass_of($class, OAuthProviderDriver::class)) { + return $class::label(); + } + + return ucfirst($provider); + } + /** * Issue an intent proving a verified provider identity. * @@ -143,7 +160,7 @@ public static function redeemRegistrationIntent(?string $token, User $user): ?OA } try { - $identity = static::identities()->link($user, $profile); + $identity = static::identities()->link($user, $profile, OAuthIdentityLinked::METHOD_SIGNUP); } catch (OAuthException $e) { // identity_already_linked: another account claimed this provider subject // between the intent being issued and redeemed. The signup itself stands. diff --git a/tests/Unit/Http/OAuthControllerTest.php b/tests/Unit/Http/OAuthControllerTest.php index 21759650..472921fc 100644 --- a/tests/Unit/Http/OAuthControllerTest.php +++ b/tests/Unit/Http/OAuthControllerTest.php @@ -336,6 +336,9 @@ function oauth_controller_database(array $config = []): Capsule 'fleetbase.api.routing.internal_prefix' => 'int', 'oauth.enabled' => true, 'oauth.allow_registration' => true, + // Explicit: the test container keeps config between tests, so one test + // switching this off would otherwise switch it off for every later one. + 'oauth.auto_link' => true, 'oauth.console_callback_path' => '/auth/oauth/callback', 'oauth.ttl' => ['authorization' => 600, 'handoff' => 120, 'registration_intent' => 900], 'oauth.providers' => [ @@ -968,19 +971,96 @@ function oauth_controller_handoff(OAuthController $controller, array $redirectQu ->and(OAuthState::query()->where('purpose', OAuthState::PURPOSE_REGISTRATION_INTENT)->count())->toBe(0); }); -it('requires linking when a verified address already has an account', function () { +it('links and signs in a console account whose confirmed email the provider verified', function (string $type) { oauth_controller_database(); - [$controller] = oauth_controller_services(); + [$controller, , $identities] = oauth_controller_services(); + oauth_test_reset_events(); + + $user = oauth_controller_user(['uuid' => 'user-1', 'email' => 'ada@example.com', 'type' => $type]); + + $data = $controller->exchange(oauth_exchange_request(['code' => oauth_controller_handoff($controller)]))->getData(true); + + $events = array_values(array_filter(oauth_test_events(), fn ($event) => $event instanceof Fleetbase\Events\OAuthIdentityLinked)); + + expect($data['token'] ?? null)->toBeString() + ->and($data['linked'])->toBe('fakeprovider') + ->and($data['linked_label'])->toBe('Fake Provider') + ->and($identities->findBySubjectForUser($user, 'fakeprovider')?->provider_user_id)->toBe('subject-1') + // The account holder is emailed about it, worded for an automatic link. + ->and($events)->toHaveCount(1) + ->and($events[0]->method)->toBe(Fleetbase\Events\OAuthIdentityLinked::METHOD_AUTOMATIC); +})->with(['user', 'admin']); - // An account exists for this address but no identity is linked. Signing them in - // here would be account takeover by email address. +it('still asks for two-factor after linking automatically', function () { + $capsule = oauth_controller_database(); + [$controller] = oauth_controller_services(); oauth_controller_user(['uuid' => 'user-1', 'email' => 'ada@example.com']); + $capsule->getConnection('mysql')->table('settings')->insert([ + 'key' => 'user.user-1.2fa', + 'value' => json_encode(['enabled' => true, 'method' => 'email']), + ]); + + $data = $controller->exchange(oauth_exchange_request(['code' => oauth_controller_handoff($controller)]))->getData(true); + + expect($data)->not->toHaveKey('token') + ->and($data['twoFaSession'])->toBeString() + ->and($data['linked'])->toBe('fakeprovider') + ->and($capsule->getConnection('mysql')->table('personal_access_tokens')->count())->toBe(0); +}); + +it('asks the user to link by hand when automatic linking does not apply', function (array $account, array $config, ?Closure $before = null) { + oauth_controller_database($config); + [$controller, , $identities] = oauth_controller_services(); + oauth_test_reset_events(); + + $user = oauth_controller_user(array_merge(['uuid' => 'user-1', 'email' => 'ada@example.com'], $account)); + if ($before) { + $before($user); + } $response = $controller->exchange(oauth_exchange_request(['code' => oauth_controller_handoff($controller)])); + // Not signed in, not linked, no signup offered for someone else's address. expect($response->getStatusCode())->toBe(409) ->and($response->getData(true)['code'])->toBe('link_required') + ->and(OAuthIdentity::query()->where('provider_user_id', 'subject-1')->exists())->toBeFalse() ->and(OAuthState::query()->where('purpose', OAuthState::PURPOSE_REGISTRATION_INTENT)->count())->toBe(0); +})->with([ + 'switched off by the administrator' => [[], ['oauth.auto_link' => false]], + // Someone could have signed up with this address without owning it. + 'account email never confirmed' => [['email_verified_at' => null], []], + 'customer account' => [['type' => 'customer'], []], + 'contact account' => [['type' => 'contact'], []], + 'driver account' => [['type' => 'driver'], []], + 'no type' => [['type' => null], []], + // A different account from the same provider is already linked; not replaced silently. + 'provider already linked' => [[], [], fn (User $user) => oauth_controller_link($user, 'another-subject')], +]); + +it('never links an address the provider did not verify, even to a confirmed account', function () { + oauth_controller_database(); + [$controller] = oauth_controller_services(); + oauth_controller_user(['uuid' => 'user-1', 'email' => 'ada@example.com']); + OAuthControllerFakeDriver::$behaviour = [ + 'profile' => new OAuthUserProfile('fakeprovider', 'subject-9', 'ada@example.com', false, 'Mallory'), + ]; + + $data = $controller->exchange(oauth_exchange_request(['code' => oauth_controller_handoff($controller)]))->getData(true); + + expect($data)->not->toHaveKey('token') + ->and(OAuthIdentity::query()->count())->toBe(0); +}); + +it('never links when two accounts share the address', function () { + oauth_controller_database(); + [$controller] = oauth_controller_services(); + oauth_controller_user(['uuid' => 'user-1', 'email' => 'ada@example.com']); + oauth_controller_user(['uuid' => 'user-2', 'email' => 'ada@example.com']); + + $response = $controller->exchange(oauth_exchange_request(['code' => oauth_controller_handoff($controller)])); + + expect($response->getData(true))->not->toHaveKey('token') + ->and(OAuthIdentity::query()->count())->toBe(0); }); it('does not report link_required for an address the provider did not verify', function () { diff --git a/tests/Unit/Http/SettingControllerOAuthTest.php b/tests/Unit/Http/SettingControllerOAuthTest.php index 6c2cd294..65bd1f98 100644 --- a/tests/Unit/Http/SettingControllerOAuthTest.php +++ b/tests/Unit/Http/SettingControllerOAuthTest.php @@ -249,7 +249,8 @@ function setting_controller_oauth_ec_key(): string ->and($payload['providers'][0]['schema']['client_secret']['secret'])->toBeTrue() // The exact callback URL the operator has to register at each provider. ->and($payload['redirect_uris']['google'])->toBe('https://api.fleetbase.test/int/v1/auth/oauth/google/callback') - ->and($payload['oauth']['enabled'])->toBeTrue(); + ->and($payload['oauth']['enabled'])->toBeTrue() + ->and($payload['oauth']['auto_link'])->toBeTrue(); }); it('never returns a secret value, even to an administrator', function () { @@ -344,11 +345,14 @@ function setting_controller_oauth_ec_key(): string (new SettingController())->saveOAuthConfig(setting_controller_oauth_save([ 'enabled' => false, 'allow_registration' => '0', + 'auto_link' => false, 'providers' => ['github' => ['enabled' => 'true', 'client_id' => 'gh-id', 'client_secret' => 'gh-secret']], ]), $registry, $config, $flow, new AppleClientSecretFactory()); expect($config->isEnabled())->toBeFalse() ->and($config->allowsRegistration())->toBeFalse() + ->and($config->autoLinksVerifiedEmail())->toBeFalse() + ->and($config->toAdminArray([])['auto_link'])->toBeFalse() ->and($config->forProvider('github')->enabled())->toBeTrue(); }); @@ -650,6 +654,8 @@ function setting_controller_oauth_ec_key(): string })->with([ 'toggles only' => [['enabled' => true, 'allow_registration' => false], false], 'non boolean toggle' => [['enabled' => 'sometimes'], true], + 'auto link toggle' => [['auto_link' => false], false], + 'non boolean auto link' => [['auto_link' => 'maybe'], true], 'full pem key' => [['providers' => ['apple' => ['private_key' => "-----BEGIN PRIVATE KEY-----\nabc"]]], false], // Empty means "keep the stored key" and must not be rejected. 'empty key keeps stored' => [['providers' => ['apple' => ['private_key' => '']]], false], diff --git a/tests/Unit/Notifications/OAuthProviderNotificationsTest.php b/tests/Unit/Notifications/OAuthProviderNotificationsTest.php new file mode 100644 index 00000000..4be893b7 --- /dev/null +++ b/tests/Unit/Notifications/OAuthProviderNotificationsTest.php @@ -0,0 +1,183 @@ + */ + public array $sent = []; + + public bool $fail = false; + + public function send($notifiables, $notification) + { + if ($this->fail) { + throw new RuntimeException('mail server down'); + } + + $this->sent[] = [$notifiables, $notification]; + } + + public function sendNow($notifiables, $notification, ?array $channels = null) + { + $this->send($notifiables, $notification); + } +} + +function oauth_notifications_setup(): OAuthNotificationDispatcherFake +{ + bind_test_container([ + 'app.name' => 'Fleetbase', + 'app.env' => 'production', + 'fleetbase.console.host' => 'console.fleetbase.test', + 'fleetbase.console.secure' => true, + 'oauth.providers' => ['google' => ['driver' => GoogleDriver::class]], + ]); + + app()->instance(OAuthConfigRepository::class, new OAuthConfigRepository()); + $dispatcher = new OAuthNotificationDispatcherFake(); + app()->instance(Dispatcher::class, $dispatcher); + + return $dispatcher; +} + +function oauth_notifications_user(?string $email = 'ada@example.com'): User +{ + return (new User())->forceFill(['uuid' => 'user-1', 'name' => 'Ada', 'email' => $email]); +} + +function oauth_notifications_identity(): OAuthIdentity +{ + return (new OAuthIdentity())->forceFill(['provider' => 'google', 'provider_email' => 'ada@gmail.com']); +} + +/** + * Everything a reader sees, in order. + */ +function oauth_mail_text(MailMessage $mail): string +{ + return implode("\n", array_merge([$mail->subject, $mail->greeting], $mail->introLines, [$mail->actionText, $mail->actionUrl], $mail->outroLines)); +} + +it('emails the account holder when they link a provider', function () { + $dispatcher = oauth_notifications_setup(); + $user = oauth_notifications_user(); + + (new SendOAuthIdentityLinkedNotification())->handle(new OAuthIdentityLinked($user, oauth_notifications_identity(), OAuthIdentityLinked::METHOD_MANUAL)); + + expect($dispatcher->sent)->toHaveCount(1) + ->and($dispatcher->sent[0][0])->toBe($user) + ->and($dispatcher->sent[0][1])->toBeInstanceOf(OAuthProviderLinked::class); +}); + +it('emails the account holder when a provider is linked automatically', function () { + $dispatcher = oauth_notifications_setup(); + + (new SendOAuthIdentityLinkedNotification())->handle(new OAuthIdentityLinked(oauth_notifications_user(), oauth_notifications_identity(), OAuthIdentityLinked::METHOD_AUTOMATIC)); + + expect($dispatcher->sent[0][1]->method)->toBe(OAuthIdentityLinked::METHOD_AUTOMATIC); +}); + +it('does not email about the provider someone just signed up with', function () { + $dispatcher = oauth_notifications_setup(); + + (new SendOAuthIdentityLinkedNotification())->handle(new OAuthIdentityLinked(oauth_notifications_user(), oauth_notifications_identity(), OAuthIdentityLinked::METHOD_SIGNUP)); + + expect($dispatcher->sent)->toBe([]); +}); + +it('emails the account holder when a provider is removed', function () { + $dispatcher = oauth_notifications_setup(); + + (new SendOAuthIdentityUnlinkedNotification())->handle(new OAuthIdentityUnlinked(oauth_notifications_user(), 'google')); + + expect($dispatcher->sent)->toHaveCount(1) + ->and($dispatcher->sent[0][1])->toBeInstanceOf(OAuthProviderUnlinked::class) + ->and($dispatcher->sent[0][1]->provider)->toBe('google'); +}); + +it('skips an account with no email address', function () { + $dispatcher = oauth_notifications_setup(); + + (new SendOAuthIdentityLinkedNotification())->handle(new OAuthIdentityLinked(oauth_notifications_user(null), oauth_notifications_identity())); + (new SendOAuthIdentityUnlinkedNotification())->handle(new OAuthIdentityUnlinked(oauth_notifications_user(null), 'google')); + + expect($dispatcher->sent)->toBe([]); +}); + +it('never lets a mail failure fail the link or the removal', function () { + $dispatcher = oauth_notifications_setup(); + $dispatcher->fail = true; + + (new SendOAuthIdentityLinkedNotification())->handle(new OAuthIdentityLinked(oauth_notifications_user(), oauth_notifications_identity())); + (new SendOAuthIdentityUnlinkedNotification())->handle(new OAuthIdentityUnlinked(oauth_notifications_user(), 'google')); + + expect(true)->toBeTrue(); +}); + +it('says which provider and account were linked, and what to do if it was not you', function () { + oauth_notifications_setup(); + Carbon::setTestNow('2026-09-21 14:30:00'); + + $text = oauth_mail_text((new OAuthProviderLinked('google', 'ada@gmail.com'))->toMail(oauth_notifications_user())); + + Carbon::setTestNow(); + + expect($text)->toContain('Google was linked to your Fleetbase account') + ->toContain('Hello, Ada') + ->toContain('Google account (ada@gmail.com)') + ->toContain('Mon, Sep 21, 2026 2:30 PM UTC') + ->toContain('Review sign-in methods') + ->toContain('https://console.fleetbase.test/account/auth') + ->toContain('remove Google from your sign-in methods and change your password'); +}); + +it('explains an automatic link', function () { + oauth_notifications_setup(); + + $text = oauth_mail_text((new OAuthProviderLinked('google', 'ada@gmail.com', OAuthIdentityLinked::METHOD_AUTOMATIC))->toMail(oauth_notifications_user())); + + expect($text)->toContain('You signed in to Fleetbase with your Google account (ada@gmail.com)') + ->toContain('same verified email address'); +}); + +it('says which provider was removed, and what to do if it was not you', function () { + oauth_notifications_setup(); + + $text = oauth_mail_text((new OAuthProviderUnlinked('google'))->toMail(oauth_notifications_user())); + + expect($text)->toContain('Google was removed from your Fleetbase account') + ->toContain('can no longer sign in with Google') + ->toContain('change your password'); +}); + +it('names a provider that is no longer configured by its id', function () { + oauth_notifications_setup(); + + $text = oauth_mail_text((new OAuthProviderUnlinked('okta'))->toMail(oauth_notifications_user())); + + expect($text)->toContain('Okta was removed'); +}); + +it('is registered for both events', function () { + // Read from source: the framework's base provider is not installed for unit tests. + $source = (string) file_get_contents(__DIR__ . '/../../../src/Providers/EventServiceProvider.php'); + + expect($source)->toMatch('/Events\\\\OAuthIdentityLinked::class\s*=>\s*\[\\\\Fleetbase\\\\Listeners\\\\SendOAuthIdentityLinkedNotification::class\]/') + ->and($source)->toMatch('/Events\\\\OAuthIdentityUnlinked::class\s*=>\s*\[\\\\Fleetbase\\\\Listeners\\\\SendOAuthIdentityUnlinkedNotification::class\]/'); +}); diff --git a/tests/Unit/Support/OAuthRegistrationIntentTest.php b/tests/Unit/Support/OAuthRegistrationIntentTest.php index 35230c21..d6103cb5 100644 --- a/tests/Unit/Support/OAuthRegistrationIntentTest.php +++ b/tests/Unit/Support/OAuthRegistrationIntentTest.php @@ -361,7 +361,9 @@ function registration_intent_profile(array $overrides = []): OAuthUserProfile $events = oauth_test_events(); expect($events)->toHaveCount(1) - ->and($events[0])->toBeInstanceOf(Fleetbase\Events\OAuthIdentityLinked::class); + ->and($events[0])->toBeInstanceOf(Fleetbase\Events\OAuthIdentityLinked::class) + // Marked as a signup so the "provider linked" security email is not sent. + ->and($events[0]->method)->toBe(Fleetbase\Events\OAuthIdentityLinked::METHOD_SIGNUP); }); it('is null safe so a caller never needs a guard', function () { From 9a98911168cca765aa50db30e5dd15c843f18e12 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 21 Sep 2026 23:34:19 +0800 Subject: [PATCH 12/30] feat: keep driver, customer and contact accounts out of the console Driver, customer and contact login accounts are now owned by their FleetOps profile instead of being managed in IAM. This adds the core pieces that model depends on. - User: MANAGED_TYPES, isManagedAccount(), isStaffAccount(), canAccessConsole(), canHoldConsoleSession(), and staff()/managed() scopes. - Console access: login (password and token reuse) refuses all managed types. Customers keep the customer_login_not_allowed code, drivers and contacts get console_access_not_allowed. Session restore, bootstrap, 2FA verify, verify-email tokens and impersonation refuse drivers and contacts. Customers are still allowed there because the customer portal runs inside the console on those endpoints. - Freeing identifiers: a soft-deleted user's email and phone move to meta.deleted_identity, so they can be reused. Restoring the user puts them back only if no other account has taken them. - Promotion: creating or inviting a team member whose email or phone belongs to a managed account in the organization promotes that account to a `user` with the chosen role and sends a join invite, instead of creating a duplicate. Accepting any IAM invite promotes a managed account and asks it to set a console password. --- .../Internal/v1/AuthController.php | 21 +++- .../Internal/v1/TwoFaController.php | 11 ++ .../Internal/v1/UserController.php | 105 ++++++++++++++++++ src/Models/User.php | 68 ++++++++++++ src/Observers/UserObserver.php | 48 ++++++++ src/Support/Auth.php | 31 ++++++ .../Http/AuthControllerLoginBootstrapTest.php | 38 +++++++ tests/Unit/Http/TwoFaControllerTest.php | 21 ++++ tests/Unit/Http/UserControllerTest.php | 64 +++++++++++ tests/Unit/Observers/UserObserverTest.php | 35 ++++++ 10 files changed, 436 insertions(+), 6 deletions(-) diff --git a/src/Http/Controllers/Internal/v1/AuthController.php b/src/Http/Controllers/Internal/v1/AuthController.php index 56faac1f..32e3e4fe 100644 --- a/src/Http/Controllers/Internal/v1/AuthController.php +++ b/src/Http/Controllers/Internal/v1/AuthController.php @@ -62,8 +62,8 @@ public function login(LoginRequest $request) $tokenOwner instanceof User && ($tokenOwner->email === $identity || $tokenOwner->phone === $identity) ) { - if ($tokenOwner->type === 'customer') { - return response()->error('Customer accounts must sign in through the customer portal.', 403, ['code' => 'customer_login_not_allowed']); + if ($denied = Auth::denyConsoleLogin($tokenOwner)) { + return $denied; } return response()->json([ @@ -83,8 +83,8 @@ public function login(LoginRequest $request) $query->where('email', $identity)->orWhere('phone', $identity); })->first(); - if ($user && $user->type === 'customer') { - return response()->error('Customer accounts must sign in through the customer portal.', 403, ['code' => 'customer_login_not_allowed']); + if ($denied = Auth::denyConsoleLogin($user)) { + return $denied; } // If the user exists but has no password set (e.g. SSO-invited or provisioned @@ -138,7 +138,7 @@ public function session(Request $request) $session = Cache::remember($cacheKey, now()->addMinutes(5), function () use ($request) { $user = $request->user(); - if (!$user) { + if (!$user || !$user->canHoldConsoleSession()) { return null; } @@ -196,6 +196,11 @@ public function bootstrap(Request $request) { $user = $request->user(); $token = $request->bearerToken(); + + if ($denied = Auth::denyConsoleSession($user)) { + return $denied; + } + $cacheKey = "auth_bootstrap_{$user->uuid}_{$token}"; // Cache for 5 minutes @@ -491,7 +496,7 @@ public function verifyEmail(Request $request) $user->activate(); // If authenticate is set, generate and return a token - if ($authenticate) { + if ($authenticate && $user->canHoldConsoleSession()) { $user->updateLastLogin(); $token = $user->createToken($user->uuid); @@ -1019,6 +1024,10 @@ public function impersonate(AdminRequest $request) return response()->error('The selected user to impersonate was not found.'); } + if ($denied = Auth::denyConsoleSession($targetUser)) { + return $denied; + } + try { Auth::setSession($targetUser); session()->put('impersonator', $currentUser->uuid); diff --git a/src/Http/Controllers/Internal/v1/TwoFaController.php b/src/Http/Controllers/Internal/v1/TwoFaController.php index e2647bc2..f81c7d58 100644 --- a/src/Http/Controllers/Internal/v1/TwoFaController.php +++ b/src/Http/Controllers/Internal/v1/TwoFaController.php @@ -4,9 +4,11 @@ use Fleetbase\Http\Controllers\Controller; use Fleetbase\Http\Requests\TwoFaValidationRequest; +use Fleetbase\Support\Auth; use Fleetbase\Support\TwoFactorAuth; use Illuminate\Http\Request; use Illuminate\Support\Str; +use Laravel\Sanctum\PersonalAccessToken; /** * Class TwoFaController. @@ -103,6 +105,15 @@ public function verifyCode(Request $request) try { $authToken = TwoFactorAuth::verifyCode($code, $token, $clientToken); + // Driver and contact accounts cannot sign in to the console. Customers + // are left to the customer portal, which shares this route path. + $accessToken = PersonalAccessToken::findToken($authToken); + if ($denied = Auth::denyConsoleSession($accessToken?->tokenable)) { + $accessToken->delete(); + + return $denied; + } + return response()->json([ 'authToken' => $authToken, ]); diff --git a/src/Http/Controllers/Internal/v1/UserController.php b/src/Http/Controllers/Internal/v1/UserController.php index 90e88710..9efe64b0 100644 --- a/src/Http/Controllers/Internal/v1/UserController.php +++ b/src/Http/Controllers/Internal/v1/UserController.php @@ -164,6 +164,13 @@ public function findRecord(Request $request, $id) */ public function createRecord(Request $request) { + // A driver/customer/contact account in this organisation with the same + // email or phone is promoted to a team member instead of duplicated. + $managedUser = $this->findPromotableAccount($request->input('user.email'), $request->input('user.phone')); + if ($managedUser) { + return $this->promoteManagedAccount($managedUser, $request); + } + $this->validateRequest($request); // Detect whether the email already belongs to an existing user. @@ -698,6 +705,13 @@ public function inviteUser(InviteUserRequest $request) return response()->error('The selected role is not available for this organisation.', 404); } + // A driver/customer/contact account in this organisation with the same + // email is promoted to a team member instead of invited. + $managedUser = $this->findPromotableAccount($email, data_get($data, 'phone')); + if ($managedUser) { + return $this->promoteManagedAccount($managedUser, $request); + } + // Check if user already exists in the system. $user = User::where('email', $email)->whereNull('deleted_at')->first(); @@ -739,6 +753,89 @@ public function inviteUser(InviteUserRequest $request) return response()->json(['user' => new $this->resource($user)]); } + /** + * Find a profile-managed account (driver, customer or contact) in the + * current organisation matching the given email or phone. + */ + private function findPromotableAccount(?string $email, ?string $phone): ?User + { + $email = $email ? strtolower(trim($email)) : null; + $phone = $phone ? trim($phone) : null; + if (!$email && !$phone) { + return null; + } + + return User::managed() + ->where(function ($query) use ($email, $phone) { + if ($email) { + $query->orWhere('email', $email); + } + if ($phone) { + $query->orWhere('phone', $phone); + } + }) + ->whereHas('companyUsers', function ($query) { + $query->where('company_uuid', session('company')); + }) + ->first(); + } + + /** + * Promote a profile-managed account to a team member of the current + * organisation. Its driver/customer profiles stay linked, so the person + * keeps a single account for the console and their app. A join invite is + * sent so they can set a console password. + */ + private function promoteManagedAccount(User $user, Request $request): \Illuminate\Http\JsonResponse + { + $company = Auth::getCompany(); + if (!$company) { + return response()->error('Unable to determine the current organisation.'); + } + + $roleIdentifier = $request->input('user.role_uuid') ?? $request->input('user.role'); + $role = $roleIdentifier ? $this->resolveAssignableRole($roleIdentifier) : null; + if ($roleIdentifier && !$role) { + return response()->error('The selected role is not available for this organisation.', 404); + } + + $previousType = $user->getType(); + $user->meta = array_merge((array) ($user->meta ?? []), ['promoted_from' => $previousType]); + $user->setUserType('user'); + $user->assignSingleRole($role ? $role : 'Administrator'); + + if ($request->isArray('user.permissions')) { + $user->syncPermissions(Permission::whereIn('id', $request->array('user.permissions'))->get()); + } + + if ($request->isArray('user.policies')) { + $user->syncPolicies($this->getAssignablePolicies($request->array('user.policies'))); + } + + if ($user->email && !Invite::isAlreadySentToJoinCompany($user, $company)) { + $invitation = Invite::create([ + 'company_uuid' => $company->uuid, + 'created_by_uuid' => session('user'), + 'subject_uuid' => $company->uuid, + 'subject_type' => Utils::getMutationType($company), + 'protocol' => 'email', + 'recipients' => [$user->email], + 'reason' => 'join_company', + 'meta' => array_filter(['role_uuid' => $role?->id, 'promoted_from' => $previousType]), + 'expires_at' => now()->addHours(48), + ]); + + $user->notify(new UserInvited($invitation)); + } + + UserCacheService::invalidateUser($user); + + return response()->json([ + 'user' => new $this->resource($user), + 'promoted_from' => $previousType, + ]); + } + /** * Issue a join-company invitation to a user who already exists in the * system but belongs to a different organisation. @@ -858,6 +955,14 @@ public function acceptCompanyInvite(AcceptCompanyInvite $request) // determine if user needs to set password (when status pending) $isPending = $needsPassword = $user->status === 'pending'; + // Invites come from IAM, so accepting one makes a profile-managed + // account (driver, customer, contact) a team member. Its password was + // generated for the app, so it sets a console password. + if ($user->isManagedAccount() || $invite->getMeta('promoted_from')) { + $user->setUserType('user'); + $needsPassword = true; + } + // Add user to company only if they are not already a member. // This guards against double-acceptance (e.g. clicking the invite // link twice) creating a duplicate company_users row. diff --git a/src/Models/User.php b/src/Models/User.php index a6fd8301..c3d0c3b4 100644 --- a/src/Models/User.php +++ b/src/Models/User.php @@ -69,6 +69,23 @@ class User extends Authenticatable use ClearsHttpCache; use HasSessionAttributes; + /** + * User types whose account is owned and managed by a profile record (a + * FleetOps driver, contact or customer) rather than managed in IAM. + * + * @var array + */ + public const MANAGED_TYPES = ['driver', 'customer', 'contact']; + + /** + * Managed types that have no console surface at all. Customers are excluded + * because the customer portal runs inside the console and restores its + * session through the core session endpoints. + * + * @var array + */ + public const SESSIONLESS_TYPES = ['driver', 'contact']; + /** * The database connection to use. * @@ -823,6 +840,57 @@ public function isNotType(string|array $type): bool return !$this->isType($type); } + /** + * Checks if the account is owned by a driver, contact or customer profile. + */ + public function isManagedAccount(): bool + { + return $this->isType(static::MANAGED_TYPES); + } + + /** + * Checks if the account is a staff account (managed in IAM). + */ + public function isStaffAccount(): bool + { + return !$this->isManagedAccount(); + } + + /** + * Checks if the account may sign in to the console. + */ + public function canAccessConsole(): bool + { + return $this->isStaffAccount(); + } + + /** + * Checks if the account may hold a console session. Customers hold one for + * the customer portal; drivers and contacts never do. + */ + public function canHoldConsoleSession(): bool + { + return $this->isNotType(static::SESSIONLESS_TYPES); + } + + /** + * Scope a query to staff accounts only. + */ + public function scopeStaff(Builder $query): Builder + { + return $query->where(function ($query) { + $query->whereNotIn($this->qualifyColumn('type'), static::MANAGED_TYPES)->orWhereNull($this->qualifyColumn('type')); + }); + } + + /** + * Scope a query to profile-managed accounts only. + */ + public function scopeManaged(Builder $query): Builder + { + return $query->whereIn($this->qualifyColumn('type'), static::MANAGED_TYPES); + } + /** * Adds a boolean dynamic property to check if user is an admin. * diff --git a/src/Observers/UserObserver.php b/src/Observers/UserObserver.php index c369fd04..3c825cb9 100644 --- a/src/Observers/UserObserver.php +++ b/src/Observers/UserObserver.php @@ -40,6 +40,11 @@ public function deleted(User $user) if (session('company')) { CompanyUser::where(['company_uuid' => session('company'), 'user_uuid' => $user->uuid])->delete(); } + + // Free the email and phone so they can be used by a new account + if (!$user->isForceDeleting()) { + $this->releaseIdentity($user); + } } /** @@ -47,6 +52,8 @@ public function deleted(User $user) */ public function restored(User $user): void { + $this->restoreIdentity($user); + // Invalidate user cache when user is restored UserCacheService::invalidateUser($user); @@ -54,6 +61,47 @@ public function restored(User $user): void $this->invalidateOrganizationsCache($user); } + /** + * Move a deleted user's email and phone into meta so they no longer block + * a new account, including lookups that include trashed users. + */ + private function releaseIdentity(User $user): void + { + $identity = array_filter(['email' => $user->email, 'phone' => $user->phone]); + if (empty($identity)) { + return; + } + + $meta = (array) ($user->meta ?? []); + $meta['deleted_identity'] = $identity; + + $user->forceFill(['email' => null, 'phone' => null, 'meta' => $meta])->saveQuietly(); + } + + /** + * Put a restored user's email and phone back when no other account has + * taken them in the meantime. + */ + private function restoreIdentity(User $user): void + { + $meta = (array) ($user->meta ?? []); + $identity = (array) data_get($meta, 'deleted_identity', []); + if (empty($identity)) { + return; + } + + foreach (['email', 'phone'] as $column) { + $value = $identity[$column] ?? null; + if ($value && !$user->{$column} && User::where($column, $value)->where('uuid', '!=', $user->uuid)->doesntExist()) { + $user->{$column} = $value; + } + } + + unset($meta['deleted_identity']); + $user->meta = $meta; + $user->saveQuietly(); + } + /** * Invalidate organizations cache for the user. * diff --git a/src/Support/Auth.php b/src/Support/Auth.php index e1dad804..a93df8bc 100644 --- a/src/Support/Auth.php +++ b/src/Support/Auth.php @@ -49,6 +49,37 @@ public static function register($owner, $company) return $owner; } + /** + * Returns an error response when the user may not sign in to the console, + * or null when access is allowed. Driver, contact and customer accounts are + * managed through their profile and sign in through their own apps. + */ + public static function denyConsoleLogin(?User $user): ?\Illuminate\Http\JsonResponse + { + if (!$user instanceof User || $user->canAccessConsole()) { + return null; + } + + if ($user->isType('customer')) { + return response()->error('Customer accounts must sign in through the customer portal.', 403, ['code' => 'customer_login_not_allowed']); + } + + return response()->error('This account cannot sign in to the console.', 403, ['code' => 'console_access_not_allowed']); + } + + /** + * Returns an error response when the user may not hold a console session, + * or null when allowed. Customers keep sessions for the customer portal. + */ + public static function denyConsoleSession(?User $user): ?\Illuminate\Http\JsonResponse + { + if (!$user instanceof User || $user->canHoldConsoleSession()) { + return null; + } + + return response()->error('This account cannot sign in to the console.', 403, ['code' => 'console_access_not_allowed', 'restore' => false]); + } + /** * Set session variables for user. * diff --git a/tests/Unit/Http/AuthControllerLoginBootstrapTest.php b/tests/Unit/Http/AuthControllerLoginBootstrapTest.php index 55febb4c..bfaee3b5 100644 --- a/tests/Unit/Http/AuthControllerLoginBootstrapTest.php +++ b/tests/Unit/Http/AuthControllerLoginBootstrapTest.php @@ -1345,3 +1345,41 @@ function auth_controller_sms_request(array $input): Request ->and(app('redis')->get($key))->toBeNull() ->and($capsule->getConnection('mysql')->table('personal_access_tokens')->count())->toBe(1); }); + +test('login rejects driver and contact identities from the console', function (string $type) { + $capsule = auth_controller_login_bootstrap_database(); + auth_controller_login_insert_user($capsule, [ + 'type' => $type, + ]); + + $response = (new AuthController())->login(auth_controller_login_request([ + 'identity' => 'auth@example.test', + 'password' => 'correct-password', + ])); + + expect($response->getStatusCode())->toBe(403) + ->and($response->getData(true))->toBe([ + 'errors' => ['This account cannot sign in to the console.'], + 'code' => 'console_access_not_allowed', + ]) + ->and($capsule->getConnection('mysql')->table('personal_access_tokens')->count())->toBe(0); +})->with(['driver', 'contact']); + +test('bootstrap rejects driver and contact sessions but keeps customer portal sessions', function (string $type, int $status) { + $capsule = auth_controller_login_bootstrap_database(); + auth_controller_login_insert_user($capsule, [ + 'type' => $type, + ]); + + $user = User::find('11111111-1111-4111-8111-111111111111'); + $response = (new AuthController())->bootstrap(auth_controller_bootstrap_request($user, 'bootstrap-token')); + + expect($response->getStatusCode())->toBe($status); + if ($status === 403) { + expect($response->getData(true)['code'])->toBe('console_access_not_allowed'); + } +})->with([ + ['driver', 403], + ['contact', 403], + ['customer', 200], +]); diff --git a/tests/Unit/Http/TwoFaControllerTest.php b/tests/Unit/Http/TwoFaControllerTest.php index 44173aa0..f9e77ba7 100644 --- a/tests/Unit/Http/TwoFaControllerTest.php +++ b/tests/Unit/Http/TwoFaControllerTest.php @@ -448,3 +448,24 @@ function two_fa_controller_verification_code(User $user, Carbon $expiresAt, stri expect($response->getData(true))->toBe(['shouldEnforce' => true]); }); + +test('two fa controller verify does not issue console tokens to driver accounts', function () { + two_fa_controller_database(); + app('db')->connection()->getSchemaBuilder()->table('users', fn ($table) => $table->string('type')->nullable()); + app('db')->table('users')->where('uuid', '11111111-1111-4111-8111-111111111111')->update(['type' => 'driver']); + $user = two_fa_controller_user(); + TwoFactorAuth::saveTwoFaSettingsForUser($user, ['enabled' => true, 'method' => 'email']); + $token = TwoFactorAuth::start($user->email, 10); + $verificationCode = two_fa_controller_verification_code($user, Carbon::now()->addMinutes(5)); + $clientToken = TwoFactorAuth::createClientSessionToken($verificationCode); + + $response = two_fa_controller()->verifyCode(Request::create('/int/v1/two-fa/verify', 'POST', [ + 'code' => '123456', + 'token' => $token, + 'clientToken' => $clientToken, + ])); + + expect($response->getStatusCode())->toBe(403) + ->and($response->getData(true)['code'])->toBe('console_access_not_allowed') + ->and(app('db')->table('personal_access_tokens')->where('tokenable_id', $user->uuid)->count())->toBe(0); +}); diff --git a/tests/Unit/Http/UserControllerTest.php b/tests/Unit/Http/UserControllerTest.php index 77ad97ff..4d6e6880 100644 --- a/tests/Unit/Http/UserControllerTest.php +++ b/tests/Unit/Http/UserControllerTest.php @@ -1905,3 +1905,67 @@ function user_controller_assert_created_user_response(mixed $response): object|a ->and($missingUser->getStatusCode())->toBe(400) ->and($missingUser->getData(true))->toBe(['errors' => ['Unable to locate the user for this invitation.']]); }); + +test('user controller promotes a managed driver account in the organization instead of inviting a duplicate', function (string $method) { + $capsule = user_controller_database(); + $connection = $capsule->getConnection('mysql'); + EloquentModel::setEventDispatcher(new Dispatcher(app())); + $now = '2026-07-18 10:00:00'; + $connection->table('users')->insert(['uuid' => 'driver-1', 'public_id' => 'user_driver_1', 'company_uuid' => 'company-1', 'email' => 'driver@example.test', 'phone' => '+15555550199', 'name' => 'Driver One', 'type' => 'driver', 'status' => 'active', 'created_at' => $now, 'updated_at' => $now]); + $connection->table('company_users')->insert(['uuid' => 'pivot-driver-1', 'company_uuid' => 'company-1', 'user_uuid' => 'driver-1', 'status' => 'active', 'created_at' => $now, 'updated_at' => $now]); + + $payload = ['user' => ['email' => 'Driver@Example.test', 'name' => 'Driver One', 'role_uuid' => 'Administrator']]; + $response = $method === 'inviteUser' + ? user_controller()->inviteUser(user_controller_request('POST', $payload, user_controller_user('owner-1'), 'inviteUser', InviteUserRequest::class)) + : user_controller_without_request_validation()->createRecord(user_controller_request('POST', $payload, user_controller_user('owner-1'), 'createRecord')); + + $driver = User::find('driver-1'); + + expect($response->getStatusCode())->toBe(200) + ->and($response->getData(true)['promoted_from'])->toBe('driver') + ->and($response->getData(true)['user']['uuid'])->toBe('driver-1') + ->and($driver->type)->toBe('user') + ->and(User::where('email', 'driver@example.test')->count())->toBe(1) + ->and($connection->table('invites')->where('company_uuid', 'company-1')->where('reason', 'join_company')->count())->toBe(1); +})->with(['inviteUser', 'createRecord']); + +test('user controller accepting an invite promotes a managed driver account and asks for a console password', function () { + $capsule = user_controller_database(); + EloquentModel::setEventDispatcher(new Dispatcher(app())); + + $driver = User::create([ + 'uuid' => 'driver-invitee', + 'public_id' => 'user_driver_invitee', + 'email' => 'driver-invitee@example.test', + 'name' => 'Driver Invitee', + 'company_uuid' => 'company-2', + 'status' => 'active', + ]); + $driver->setType('driver'); + + $capsule->getConnection('mysql')->table('invites')->insert([ + 'uuid' => 'invite-driver', + 'public_id' => 'invite_public_driver', + 'code' => 'DRIVER123', + 'uri' => 'driver123', + 'company_uuid' => 'company-1', + 'created_by_uuid' => 'owner-1', + 'subject_uuid' => 'company-1', + 'subject_type' => Fleetbase\Support\Utils::getMutationType(Fleetbase\Models\Company::where('uuid', 'company-1')->first()), + 'protocol' => 'email', + 'recipients' => json_encode(['driver-invitee@example.test']), + 'reason' => 'join_company', + 'meta' => json_encode(['role_uuid' => 'Administrator']), + 'expires_at' => now()->addHours(48), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $accepted = user_controller()->acceptCompanyInvite(user_controller_request('POST', [ + 'code' => 'DRIVER123', + ], $driver, 'acceptCompanyInvite', AcceptCompanyInvite::class)); + + expect($accepted->getStatusCode())->toBe(200) + ->and($accepted->getData(true)['needs_password'])->toBeTrue() + ->and($capsule->getConnection('mysql')->table('users')->where('uuid', 'driver-invitee')->value('type'))->toBe('user'); +}); diff --git a/tests/Unit/Observers/UserObserverTest.php b/tests/Unit/Observers/UserObserverTest.php index 13e413e0..dbe50a45 100644 --- a/tests/Unit/Observers/UserObserverTest.php +++ b/tests/Unit/Observers/UserObserverTest.php @@ -68,6 +68,9 @@ function user_observer_database(): Capsule $schema = $capsule->getConnection('mysql')->getSchemaBuilder(); $schema->create('users', function ($table) { $table->string('uuid')->primary(); + $table->string('email')->nullable(); + $table->string('phone')->nullable(); + $table->json('meta')->nullable(); $table->timestamp('updated_at')->nullable(); $table->timestamp('deleted_at')->nullable(); }); @@ -153,3 +156,35 @@ function user_observer_subject(): array ->and($remaining)->not->toContain('session-company-user') ->and($deletedAt)->not->toBeNull(); }); + +it('frees the email and phone of a soft deleted user and keeps them in meta', function () { + [$user, , , $capsule, $observer] = user_observer_subject(); + $capsule->getConnection('mysql')->table('users')->where('uuid', 'user-1')->update(['email' => 'driver@example.test', 'phone' => '+15555550100']); + $user->refresh(); + + $observer->deleted($user); + + $row = $capsule->getConnection('mysql')->table('users')->where('uuid', 'user-1')->first(); + + expect($row->email)->toBeNull() + ->and($row->phone)->toBeNull() + ->and(json_decode($row->meta, true))->toBe(['deleted_identity' => ['email' => 'driver@example.test', 'phone' => '+15555550100']]); +}); + +it('restores a released identity only where no other account has taken it', function () { + [$user, , , $capsule, $observer] = user_observer_subject(); + $connection = $capsule->getConnection('mysql'); + $connection->table('users')->where('uuid', 'user-1')->update([ + 'meta' => json_encode(['deleted_identity' => ['email' => 'driver@example.test', 'phone' => '+15555550100']]), + ]); + $connection->table('users')->insert(['uuid' => 'user-2', 'phone' => '+15555550100']); + $user->refresh(); + + $observer->restored($user); + + $row = $connection->table('users')->where('uuid', 'user-1')->first(); + + expect($row->email)->toBe('driver@example.test') + ->and($row->phone)->toBeNull() + ->and(json_decode($row->meta, true))->toBe([]); +}); From 8c94dded6e940fc1de4ec959af2ca41b6b864efd Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 22 Sep 2026 01:04:02 +0800 Subject: [PATCH 13/30] test: cover managed account promotion and impersonation guards Covers the remaining paths: - promotion syncs permissions and policies; - promotion reports a missing organization and an unavailable role; - identity lookup with no email or phone; - impersonation refuses driver accounts. Also removes the unused User::scopeStaff(). --- src/Models/User.php | 10 ----- .../Http/AuthControllerLoginBootstrapTest.php | 14 +++++++ tests/Unit/Http/UserControllerTest.php | 41 +++++++++++++++++++ 3 files changed, 55 insertions(+), 10 deletions(-) diff --git a/src/Models/User.php b/src/Models/User.php index c3d0c3b4..e75ade4d 100644 --- a/src/Models/User.php +++ b/src/Models/User.php @@ -873,16 +873,6 @@ public function canHoldConsoleSession(): bool return $this->isNotType(static::SESSIONLESS_TYPES); } - /** - * Scope a query to staff accounts only. - */ - public function scopeStaff(Builder $query): Builder - { - return $query->where(function ($query) { - $query->whereNotIn($this->qualifyColumn('type'), static::MANAGED_TYPES)->orWhereNull($this->qualifyColumn('type')); - }); - } - /** * Scope a query to profile-managed accounts only. */ diff --git a/tests/Unit/Http/AuthControllerLoginBootstrapTest.php b/tests/Unit/Http/AuthControllerLoginBootstrapTest.php index bfaee3b5..4996d6eb 100644 --- a/tests/Unit/Http/AuthControllerLoginBootstrapTest.php +++ b/tests/Unit/Http/AuthControllerLoginBootstrapTest.php @@ -1383,3 +1383,17 @@ function auth_controller_sms_request(array $input): Request ['contact', 403], ['customer', 200], ]); + +test('admin impersonation refuses driver accounts', function () { + $capsule = auth_controller_login_bootstrap_database(); + auth_controller_login_insert_user($capsule, ['uuid' => 'admin-user', 'email' => 'admin@example.test', 'type' => 'admin']); + auth_controller_login_insert_user($capsule, ['uuid' => 'driver-user', 'email' => 'driver@example.test', 'type' => 'driver']); + + $response = (new AuthController())->impersonate(auth_controller_authenticated_request('POST', [ + 'user' => 'driver-user', + ], User::find('admin-user'), '/int/v1/auth/impersonate', AdminRequest::class)); + + expect($response->getStatusCode())->toBe(403) + ->and($response->getData(true)['code'])->toBe('console_access_not_allowed') + ->and($capsule->getConnection('mysql')->table('personal_access_tokens')->where('tokenable_id', 'driver-user')->count())->toBe(0); +}); diff --git a/tests/Unit/Http/UserControllerTest.php b/tests/Unit/Http/UserControllerTest.php index 4d6e6880..73e727bf 100644 --- a/tests/Unit/Http/UserControllerTest.php +++ b/tests/Unit/Http/UserControllerTest.php @@ -1969,3 +1969,44 @@ function user_controller_assert_created_user_response(mixed $response): object|a ->and($accepted->getData(true)['needs_password'])->toBeTrue() ->and($capsule->getConnection('mysql')->table('users')->where('uuid', 'driver-invitee')->value('type'))->toBe('user'); }); + +test('user controller promotion syncs permissions and policies and reports organization and role failures', function () { + $capsule = user_controller_database(); + $db = $capsule->getConnection('mysql'); + EloquentModel::setEventDispatcher(new Dispatcher(app())); + $now = '2026-07-18 10:00:00'; + $db->table('users')->insert(['uuid' => 'customer-1', 'public_id' => 'user_customer_1', 'company_uuid' => 'company-1', 'email' => 'customer@example.test', 'phone' => '+15555550188', 'name' => 'Customer One', 'type' => 'customer', 'status' => 'active', 'created_at' => $now, 'updated_at' => $now]); + $db->table('company_users')->insert(['uuid' => 'pivot-customer-1', 'company_uuid' => 'company-1', 'user_uuid' => 'customer-1', 'status' => 'active', 'created_at' => $now, 'updated_at' => $now]); + $db->table('permissions')->insert(['id' => 'permission-promote', 'name' => 'iam promote user', 'guard_name' => 'sanctum', 'description' => 'Promote', 'created_at' => $now, 'updated_at' => $now]); + $db->table('policies')->insert(['id' => 'policy-promote', 'company_uuid' => 'company-1', 'name' => 'Promote policy', 'guard_name' => 'sanctum', 'service' => 'iam', 'description' => 'Promote', 'created_at' => $now, 'updated_at' => $now]); + + $findPromotable = new ReflectionMethod(UserController::class, 'findPromotableAccount'); + $noIdentity = $findPromotable->invoke(user_controller(), null, ' '); + + $invalidRole = user_controller_without_request_validation()->createRecord(user_controller_request('POST', [ + 'user' => ['phone' => '+15555550188', 'role_uuid' => 'role-other-company'], + ], user_controller_user('owner-1'), 'createRecord')); + + $promoted = user_controller_without_request_validation()->createRecord(user_controller_request('POST', [ + 'user' => [ + 'phone' => '+15555550188', + 'permissions' => ['permission-promote'], + 'policies' => ['policy-promote'], + ], + ], user_controller_user('owner-1'), 'createRecord')); + + $db->table('users')->insert(['uuid' => 'driver-2', 'public_id' => 'user_driver_2', 'company_uuid' => 'company-1', 'email' => 'driver2@example.test', 'name' => 'Driver Two', 'type' => 'driver', 'status' => 'active', 'created_at' => $now, 'updated_at' => $now]); + $promote = new ReflectionMethod(UserController::class, 'promoteManagedAccount'); + $promoteRequest = user_controller_request('POST', ['user' => []], user_controller_user('owner-1'), 'createRecord'); + session()->flush(); + $noCompany = $promote->invoke(user_controller(), User::find('driver-2'), $promoteRequest); + + expect($noIdentity)->toBeNull() + ->and($invalidRole->getStatusCode())->toBe(404) + ->and($promoted->getStatusCode())->toBe(200) + ->and($promoted->getData(true)['promoted_from'])->toBe('customer') + ->and(User::find('customer-1')->type)->toBe('user') + ->and($db->table('model_has_permissions')->where('model_uuid', 'pivot-customer-1')->where('permission_id', 'permission-promote')->exists())->toBeTrue() + ->and($db->table('model_has_policies')->where('model_uuid', 'pivot-customer-1')->where('policy_id', 'policy-promote')->exists())->toBeTrue() + ->and($noCompany->getData(true))->toBe(['errors' => ['Unable to determine the current organisation.']]); +}); From b700ffd394f38e52b4929fe3ed015c2e1c9fc3cf Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 22 Sep 2026 11:23:30 +0800 Subject: [PATCH 14/30] chore(release): open v1.6.63 release branch Bump composer.json to 1.6.63 and seed RELEASE.md, so the release-tag workflow can validate the version once this branch merges to main. --- RELEASE.md | 21 +++++++++++---------- composer.json | 2 +- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index 254b05d3..cdfc4f8f 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,21 +1,22 @@ -# v1.6.62 — Custom fields get a public id +# v1.6.63 — Driver, customer and contact accounts stay out of the console ## Improvements -- Give every custom field a public id, so an API that hands one out names it the way the rest of the platform names a resource rather than exposing an internal uuid. `CustomField` takes `HasPublicId` with the `custom_field` prefix, and `public_id` becomes fillable. -- Mint an id on the one path that would otherwise miss it: `HasCustomFields::setCustomField()` saves a field it creates on the fly with `saveQuietly()`, which skips the hook that assigns the id. +- Treat `driver`, `customer` and `contact` users as managed accounts: the FleetOps profile owns them, not IAM. `User` gains `MANAGED_TYPES`, `isManagedAccount()`, `isStaffAccount()`, `canAccessConsole()`, `canHoldConsoleSession()` and a `managed()` scope. +- Promote instead of duplicating. When IAM creates or invites a team member whose email or phone belongs to a managed account in the organization, that account becomes a `user`. It gets the chosen role, permissions and policies and a join invite, and keeps its driver and customer profiles. The response carries `promoted_from`. Accepting any IAM invite also promotes a managed account and asks it to set a console password. ## Fixes -- Let an observer's refusal reach the caller on the update and bulk-delete paths. An observer that refused a write by throwing `FleetbaseRequestValidationException` had its explanation discarded: `HasApiModelBehavior::updateRecordFromRequest()` rewrapped every exception from the save as a plain `\Exception`, and `HasApiControllerBehavior::bulkDelete()` caught `\Exception` ahead of its dedicated handler, so callers saw `Invalid request` or a generic update error instead of the message the observer wrote. The exception now passes through untouched on both paths and is rendered with `getErrors()`, as it already was on create and single delete. Every other exception is wrapped exactly as before. Reported in [#256](https://github.com/fleetbase/core-api/issues/256). +- Keep managed accounts out of the console: + - `auth/login` refuses drivers, contacts and customers. Customers keep the `customer_login_not_allowed` code; drivers and contacts get `console_access_not_allowed`. + - Session restore, bootstrap, 2FA verification, verify-email tokens and impersonation refuse drivers and contacts. + - Customers are still allowed on those endpoints because the customer portal runs inside the console and restores its session through them. +- Free a deleted user's email and phone so a new account can use them. On soft delete they move to `meta.deleted_identity`; restoring the user puts them back only if no other account has taken them. ## Reliability -- Backfill existing rows in the migration, and add the column as nullable and indexed rather than unique-and-required, so it is safe on an already-populated `custom_fields` table. -- Cover id generation for `CustomField`, and add the column to the in-memory schemas whose saves now probe it for uniqueness. +- Cover the console guards for each account type, identity release and restore, and promotion through create, invite and invite acceptance. -This is platform-wide: every custom field gains a public id, not only those used by inspections. Nothing reads the new column yet — `withCustomFields()`'s public projection emits field names and is unchanged — so the change is additive for existing consumers. +A database migration is not required. No configuration change is needed. The FleetOps side ships in fleetbase/fleetops#338. -A database migration is required. No configuration change is needed. - -Changes: [#254](https://github.com/fleetbase/core-api/pull/254), [#259](https://github.com/fleetbase/core-api/pull/259). +Changes: [#264](https://github.com/fleetbase/core-api/pull/264). diff --git a/composer.json b/composer.json index ecfbb64a..7575a30f 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "fleetbase/core-api", - "version": "1.6.62", + "version": "1.6.63", "description": "Core Framework and Resources for Fleetbase API", "keywords": [ "fleetbase", From ec25f4911bbbd2ea223d3bd6e97a7974454cbb02 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 22 Sep 2026 12:20:03 +0800 Subject: [PATCH 15/30] fix: never grant the Administrator role by default Creating or inviting a user without a role gave them the Administrator role, which carries the administrator policy and full organization access. Reported for IAM > Customers > Add customer: a blank Role created a `user` account with full access. The same default sat behind every role-less path: - `Company::addUser`, `Company::assignUser` and `User::assignCompany` all defaulted the role to Administrator; - accepting an invite with no role gave Administrator; - `joinOrganization` ignored the invite's role altogether. Changes: - The model helpers take `?string $role = null` and assign no role when none is given. The owner paths pass Administrator explicitly: `Auth::register` and `FixUserCompanies`, the latter only for the company owner. - User create, invite and promotion require a role. Without one they return 422 "Select a role for this user." - Only admins or holders of the Administrator role may grant the Administrator role, on create, invite, promotion and role update. Anyone else gets 403. - Accepting an invite and joinOrganization use the invite's role. A legacy invite without one joins with no role. --- src/Console/Commands/FixUserCompanies.php | 3 +- .../Internal/v1/AuthController.php | 26 +++- .../Internal/v1/UserController.php | 119 ++++++++++------- src/Models/Company.php | 14 +- src/Models/Invite.php | 17 +++ src/Models/User.php | 5 +- src/Support/Auth.php | 16 ++- .../Console/AdminMaintenanceCommandsTest.php | 32 +++++ tests/Unit/Console/RecoveryCommandTest.php | 2 +- .../Http/AuthControllerLoginBootstrapTest.php | 36 +++++ tests/Unit/Http/UserControllerTest.php | 125 +++++++++++++++--- tests/Unit/Models/CompanyModelTest.php | 6 +- 12 files changed, 314 insertions(+), 87 deletions(-) diff --git a/src/Console/Commands/FixUserCompanies.php b/src/Console/Commands/FixUserCompanies.php index f1358421..c5f853e1 100644 --- a/src/Console/Commands/FixUserCompanies.php +++ b/src/Console/Commands/FixUserCompanies.php @@ -41,7 +41,8 @@ public function handle() $this->line('Found user ' . $user->name . ' (' . $user->email . ') which doesnt have correct company assignment.'); $company = Company::where('uuid', $user->company_uuid)->first(); if ($company) { - $user->assignCompany($company); + // Only the company owner is restored as an Administrator + $user->assignCompany($company, $company->owner_uuid === $user->uuid ? 'Administrator' : null); $this->line('User ' . $user->email . ' was assigned to company: ' . $company->name); } } diff --git a/src/Http/Controllers/Internal/v1/AuthController.php b/src/Http/Controllers/Internal/v1/AuthController.php index 32e3e4fe..e0058520 100644 --- a/src/Http/Controllers/Internal/v1/AuthController.php +++ b/src/Http/Controllers/Internal/v1/AuthController.php @@ -19,6 +19,7 @@ use Fleetbase\Models\Company; use Fleetbase\Models\CompanyUser; use Fleetbase\Models\Invite; +use Fleetbase\Models\Role; use Fleetbase\Models\User; use Fleetbase\Models\VerificationCode; use Fleetbase\Notifications\UserForgotPassword; @@ -877,8 +878,8 @@ public function joinOrganization(JoinOrganizationRequest $request) $user = Auth::getUserFromSession($request); // Make sure user has been invited to join organizations - $isAlreadyInvited = Invite::isAlreadySentToJoinCompany($user, $company); - if (!$isAlreadyInvited) { + $invite = Invite::findSentToJoinCompany($user, $company); + if (!$invite) { return response()->error('User has not been invited to join this organization.'); } @@ -887,7 +888,8 @@ public function joinOrganization(JoinOrganizationRequest $request) return response()->error('User is already a member of this organization.'); } - $company->assignUser($user); + // Join with the role the invite carries; never a default one + $company->assignUser($user, $this->inviteRoleId($invite, $company)); Auth::setSession($user); return response()->json(['status' => 'ok']); @@ -899,6 +901,24 @@ public function joinOrganization(JoinOrganizationRequest $request) // @codeCoverageIgnoreEnd } + /** + * The id of the role an invite grants in the organization, when it names one + * that belongs to the organization or is global. + */ + private function inviteRoleId(Invite $invite, Company $company): ?string + { + $roleId = $invite->getMeta('role_uuid'); + if (!$roleId) { + return null; + } + + return Role::where(function ($query) use ($roleId) { + $query->where('id', $roleId)->orWhere('name', $roleId); + })->where(function ($query) use ($company) { + $query->where('company_uuid', $company->uuid)->orWhereNull('company_uuid'); + })->value('id'); + } + /** * Allows user to create a new organization. * diff --git a/src/Http/Controllers/Internal/v1/UserController.php b/src/Http/Controllers/Internal/v1/UserController.php index 9efe64b0..c2f18f83 100644 --- a/src/Http/Controllers/Internal/v1/UserController.php +++ b/src/Http/Controllers/Internal/v1/UserController.php @@ -164,11 +164,17 @@ public function findRecord(Request $request, $id) */ public function createRecord(Request $request) { + // A role is always chosen explicitly: access is never granted by default. + $role = $this->resolveRequestedRole($request); + if ($role instanceof \Illuminate\Http\JsonResponse) { + return $role; + } + // A driver/customer/contact account in this organisation with the same // email or phone is promoted to a team member instead of duplicated. $managedUser = $this->findPromotableAccount($request->input('user.email'), $request->input('user.phone')); if ($managedUser) { - return $this->promoteManagedAccount($managedUser, $request); + return $this->promoteManagedAccount($managedUser, $request, $role); } $this->validateRequest($request); @@ -189,11 +195,7 @@ public function createRecord(Request $request) return response()->error('This user is already a member of your organisation.'); } - return $this->inviteExistingUser($existingUser, $request); - } - - if ($request->filled('user.role_uuid') && !$this->resolveAssignableRole($request->input('user.role_uuid'))) { - return response()->error('The selected role is not available for this organisation.', 404); + return $this->inviteExistingUser($existingUser, $request, $role); } try { @@ -211,22 +213,15 @@ public function createRecord(Request $request) 'ip_address' => $request->ip(), 'timezone' => $timezone, ])); - }, function (&$request, &$user) { + }, function (&$request, &$user) use ($role) { // Make sure to assign to current company $company = Auth::getCompany(); // Set user type $user->setUserType('user'); - $role = $this->resolveAssignableRole($request->input('user.role_uuid')); - - // Assign to user - $user->assignCompany($company, $role ? $role->id : 'Administrator'); - - // Assign role if set - if ($role) { - $user->assignSingleRole($role); - } + // Assign to company with the chosen role + $user->assignCompany($company, $role->id); // Sync Permissions if ($request->isArray('user.permissions')) { @@ -293,6 +288,10 @@ public function updateRecord(Request $request, string $id) if (!$roleToAssign) { return response()->error('The selected role is not available for this organisation.', 404); } + + if ($denied = $this->denyRoleGrant($roleToAssign, $request)) { + return $denied; + } } $record->update(Arr::except($input, ['uuid', 'public_id', 'deleted_at', 'updated_at', 'created_at'])); @@ -444,6 +443,41 @@ private function identityValueMatches(string $field, mixed $incoming, mixed $cur /** * Resolve a role assignable by the active company. */ + /** + * Resolve the role a new or invited user joins with. A role is required and + * must be assignable by the current user. + */ + private function resolveRequestedRole(Request $request): Role|\Illuminate\Http\JsonResponse + { + $roleIdentifier = $request->input('user.role_uuid') ?? $request->input('user.role'); + if (is_array($roleIdentifier)) { + $roleIdentifier = data_get($roleIdentifier, 'id') ?? data_get($roleIdentifier, 'uuid'); + } + + if (!$roleIdentifier) { + return response()->error('Select a role for this user.', 422); + } + + $role = $this->resolveAssignableRole($roleIdentifier); + if (!$role) { + return response()->error('The selected role is not available for this organisation.', 404); + } + + return $this->denyRoleGrant($role, $request) ?? $role; + } + + /** + * Only administrators may grant the Administrator role. + */ + private function denyRoleGrant(Role $role, Request $request): ?\Illuminate\Http\JsonResponse + { + if (Auth::canGrantRole($role, Auth::getUserFromSession($request))) { + return null; + } + + return response()->error('Only administrators can grant the Administrator role.', 403); + } + private function resolveAssignableRole(string|Role|null $role, ?string $companyUuid = null): ?Role { $companyUuid ??= session('company'); @@ -701,15 +735,17 @@ public function inviteUser(InviteUserRequest $request) return response()->error('Unable to determine the current organisation.'); } - if ($request->filled('user.role_uuid') && !$this->resolveAssignableRole($request->input('user.role_uuid'))) { - return response()->error('The selected role is not available for this organisation.', 404); + // A role is always chosen explicitly: access is never granted by default. + $role = $this->resolveRequestedRole($request); + if ($role instanceof \Illuminate\Http\JsonResponse) { + return $role; } // A driver/customer/contact account in this organisation with the same // email is promoted to a team member instead of invited. $managedUser = $this->findPromotableAccount($email, data_get($data, 'phone')); if ($managedUser) { - return $this->promoteManagedAccount($managedUser, $request); + return $this->promoteManagedAccount($managedUser, $request, $role); } // Check if user already exists in the system. @@ -726,7 +762,7 @@ public function inviteUser(InviteUserRequest $request) } // Existing user from another org — issue a cross-org invite. - return $this->inviteExistingUser($user, $request); + return $this->inviteExistingUser($user, $request, $role); } // Brand-new user — create a pending record; assignCompany() below issues the join invite + notification. @@ -740,15 +776,8 @@ public function inviteUser(InviteUserRequest $request) // Set user type $user->setUserType('user'); - $role = $this->resolveAssignableRole($request->input('user.role_uuid')); - - // Assign to user - $user->assignCompany($company, $role ? $role->id : 'Administrator'); - - // Assign role if set - if ($role) { - $user->assignSingleRole($role); - } + // Assign to company with the chosen role + $user->assignCompany($company, $role->id); return response()->json(['user' => new $this->resource($user)]); } @@ -786,23 +815,17 @@ private function findPromotableAccount(?string $email, ?string $phone): ?User * keeps a single account for the console and their app. A join invite is * sent so they can set a console password. */ - private function promoteManagedAccount(User $user, Request $request): \Illuminate\Http\JsonResponse + private function promoteManagedAccount(User $user, Request $request, Role $role): \Illuminate\Http\JsonResponse { $company = Auth::getCompany(); if (!$company) { return response()->error('Unable to determine the current organisation.'); } - $roleIdentifier = $request->input('user.role_uuid') ?? $request->input('user.role'); - $role = $roleIdentifier ? $this->resolveAssignableRole($roleIdentifier) : null; - if ($roleIdentifier && !$role) { - return response()->error('The selected role is not available for this organisation.', 404); - } - $previousType = $user->getType(); $user->meta = array_merge((array) ($user->meta ?? []), ['promoted_from' => $previousType]); $user->setUserType('user'); - $user->assignSingleRole($role ? $role : 'Administrator'); + $user->assignSingleRole($role); if ($request->isArray('user.permissions')) { $user->syncPermissions(Permission::whereIn('id', $request->array('user.permissions'))->get()); @@ -821,7 +844,7 @@ private function promoteManagedAccount(User $user, Request $request): \Illuminat 'protocol' => 'email', 'recipients' => [$user->email], 'reason' => 'join_company', - 'meta' => array_filter(['role_uuid' => $role?->id, 'promoted_from' => $previousType]), + 'meta' => array_filter(['role_uuid' => $role->id, 'promoted_from' => $previousType]), 'expires_at' => now()->addHours(48), ]); @@ -847,8 +870,9 @@ private function promoteManagedAccount(User $user, Request $request): \Illuminat * * @param User $user the existing user to invite * @param Request $request the originating HTTP request + * @param Role $role the role the user joins with */ - private function inviteExistingUser(User $user, Request $request): \Illuminate\Http\JsonResponse + private function inviteExistingUser(User $user, Request $request, Role $role): \Illuminate\Http\JsonResponse { $company = Auth::getCompany(); @@ -861,11 +885,6 @@ private function inviteExistingUser(User $user, Request $request): \Illuminate\H return response()->error('This user has already been invited to join your organisation.'); } - $roleIdentifier = $request->input('user.role_uuid') ?? $request->input('user.role'); - if ($roleIdentifier && !$this->resolveAssignableRole($roleIdentifier)) { - return response()->error('The selected role is not available for this organisation.', 404); - } - $invitation = Invite::create([ 'company_uuid' => $company->uuid, 'created_by_uuid' => session('user'), @@ -874,7 +893,7 @@ private function inviteExistingUser(User $user, Request $request): \Illuminate\H 'protocol' => 'email', 'recipients' => [$user->email], 'reason' => 'join_company', - 'meta' => array_filter(['role_uuid' => $roleIdentifier]), + 'meta' => ['role_uuid' => $role->id], 'expires_at' => now()->addHours(48), ]); @@ -972,11 +991,11 @@ public function acceptCompanyInvite(AcceptCompanyInvite $request) if (!$alreadyMember) { // Use Company::addUser() so that role assignment is handled in - // one place. The role stored in the invite meta takes precedence; - // if none was set the default 'Administrator' role is used. - $role = $this->resolveAssignableRole($invite->getMeta('role_uuid'), $company->uuid); - $roleIdentifier = $role ? $role->id : 'Administrator'; - $companyUser = $company->addUser($user, $roleIdentifier); + // one place. The user joins with the role stored in the invite; an + // invite without one (issued before roles were required) joins with + // no role, so access is never granted by default. + $role = $this->resolveAssignableRole($invite->getMeta('role_uuid'), $company->uuid); + $companyUser = $company->addUser($user, $role?->id); $user->setRelation('companyUser', $companyUser); } else { // User is already a member — ensure the companyUser relation is diff --git a/src/Models/Company.php b/src/Models/Company.php index 963acbcc..0e202cce 100644 --- a/src/Models/Company.php +++ b/src/Models/Company.php @@ -369,16 +369,14 @@ public static function currentSession(): ?Company * it defaults to the user's current role. The status can also be specified, defaulting to 'active'. * * @param User $user the user to be added to the company - * @param string|null $role The name or ID of the role to assign to the user. Defaults to the user's current role if null. + * @param string|null $role The name or ID of the role to assign to the user. No role is assigned when null: + * access is only ever granted explicitly. * @param string $status The status of the user within the company. Defaults to 'active'. * * @return CompanyUser the CompanyUser instance representing the association between the user and the company */ - public function addUser(User $user, string $role = 'Administrator', string $status = 'active'): CompanyUser + public function addUser(User $user, ?string $role = null, string $status = 'active'): CompanyUser { - // Get the currentuser role - $role = $role; - $companyUser = CompanyUser::firstOrCreate( [ 'company_uuid' => $this->uuid, @@ -392,7 +390,9 @@ public function addUser(User $user, string $role = 'Administrator', string $stat ); // Assign the role to the new user - $companyUser->assignSingleRole($role); + if ($role) { + $companyUser->assignSingleRole($role); + } return $companyUser; } @@ -461,7 +461,7 @@ public function changeUserRole(User $user, string $roleName): bool * * @return CompanyUser the CompanyUser instance representing the association between the user and the company */ - public function assignUser(User $user, string $role = 'Administrator'): CompanyUser + public function assignUser(User $user, ?string $role = null): CompanyUser { $companyUser = $this->addUser($user, $role); $user->assignCompany($this); diff --git a/src/Models/Invite.php b/src/Models/Invite.php index 03c93883..15e6dabe 100644 --- a/src/Models/Invite.php +++ b/src/Models/Invite.php @@ -168,6 +168,23 @@ public static function isAlreadySentToJoinCompany(User $user, Company $company): return static::isAlreadySent($company, $user->email, 'join_company'); } + /** + * The invite sent to the user to join the company, if any. + */ + public static function findSentToJoinCompany(User $user, Company $company): ?self + { + if (!$user->email) { + return null; + } + + return static::where([ + 'company_uuid' => $company->uuid, + 'subject_uuid' => $company->uuid, + 'protocol' => 'email', + 'reason' => 'join_company', + ])->whereJsonContains('recipients', $user->email)->latest()->first(); + } + public static function isAlreadySent(Company $company, string $email, string $reason, string $protocol = 'email'): bool { return static::where([ diff --git a/src/Models/User.php b/src/Models/User.php index e75ade4d..411b1396 100644 --- a/src/Models/User.php +++ b/src/Models/User.php @@ -542,11 +542,12 @@ public function setCompanyUserRelation(Company $company): void * will be notified that a user has been created. * * @param Company $company the company to assign the user to - * @param string|null $role The name or ID of the role to assign to the user. Defaults to the user's current role if null. + * @param string|null $role The name or ID of the role to assign to the user. No role is assigned when null: + * access is only ever granted explicitly. * * @return self returns the current User instance */ - public function assignCompany(Company $company, string $role = 'Administrator'): self + public function assignCompany(Company $company, ?string $role = null): self { $this->company_uuid = $company->uuid; diff --git a/src/Support/Auth.php b/src/Support/Auth.php index a93df8bc..f5a21a4f 100644 --- a/src/Support/Auth.php +++ b/src/Support/Auth.php @@ -44,7 +44,7 @@ public static function register($owner, $company) ->setOwner($owner) ->saveInstance(); - $owner->assignCompany($company); + $owner->assignCompany($company, 'Administrator'); return $owner; } @@ -80,6 +80,20 @@ public static function denyConsoleSession(?User $user): ?\Illuminate\Http\JsonRe return response()->error('This account cannot sign in to the console.', 403, ['code' => 'console_access_not_allowed', 'restore' => false]); } + /** + * Whether the actor may grant the role to another user. The Administrator + * role gives full access to the organization, so only admins and users + * holding it may grant it. + */ + public static function canGrantRole(Role $role, ?User $actor): bool + { + if ($role->name !== 'Administrator') { + return true; + } + + return $actor instanceof User && ($actor->isAdmin() || $actor->hasRole('Administrator')); + } + /** * Set session variables for user. * diff --git a/tests/Unit/Console/AdminMaintenanceCommandsTest.php b/tests/Unit/Console/AdminMaintenanceCommandsTest.php index ed70729b..499a0e3f 100644 --- a/tests/Unit/Console/AdminMaintenanceCommandsTest.php +++ b/tests/Unit/Console/AdminMaintenanceCommandsTest.php @@ -264,3 +264,35 @@ public function clear(array $tags = []): void ])->exists())->toBeTrue() ->and($db->table('model_has_roles')->where('role_id', 'role-admin')->exists())->toBeTrue(); }); + +it('repairs a member who does not own the company without granting a role', function () { + $capsule = admin_maintenance_database(); + $db = $capsule->getConnection('mysql'); + + $db->table('users')->insert([ + 'uuid' => 'user-missing-member', + 'company_uuid' => 'company-1', + 'name' => 'Plain Member', + 'email' => 'member@example.test', + 'type' => 'admin', + 'status' => 'active', + 'created_at' => '2026-07-18 00:00:00', + 'updated_at' => '2026-07-18 00:00:00', + ]); + $db->table('companies')->insert([ + 'uuid' => 'company-1', + 'owner_id' => 'someone-else', + 'owner_uuid' => 'someone-else', + 'name' => 'Acme Logistics', + 'created_at' => '2026-07-18 00:00:00', + 'updated_at' => '2026-07-18 00:00:00', + ]); + + $command = new FixUserCompanies(); + $command->setLaravel(app()); + $tester = new CommandTester($command); + + expect($tester->execute([]))->toBe(0) + ->and($db->table('company_users')->where(['company_uuid' => 'company-1', 'user_uuid' => 'user-missing-member'])->exists())->toBeTrue() + ->and($db->table('model_has_roles')->count())->toBe(0); +}); diff --git a/tests/Unit/Console/RecoveryCommandTest.php b/tests/Unit/Console/RecoveryCommandTest.php index e46694f1..5f8e9996 100644 --- a/tests/Unit/Console/RecoveryCommandTest.php +++ b/tests/Unit/Console/RecoveryCommandTest.php @@ -133,7 +133,7 @@ public function changePassword($newPassword): self return $this; } - public function assignCompany(Company $company, string $role = 'Administrator'): self + public function assignCompany(Company $company, ?string $role = null): self { if (in_array('assignCompany', $this->throwOn, true)) { throw new RuntimeException('assign company failed'); diff --git a/tests/Unit/Http/AuthControllerLoginBootstrapTest.php b/tests/Unit/Http/AuthControllerLoginBootstrapTest.php index 4996d6eb..1008904d 100644 --- a/tests/Unit/Http/AuthControllerLoginBootstrapTest.php +++ b/tests/Unit/Http/AuthControllerLoginBootstrapTest.php @@ -1397,3 +1397,39 @@ function auth_controller_sms_request(array $input): Request ->and($response->getData(true)['code'])->toBe('console_access_not_allowed') ->and($capsule->getConnection('mysql')->table('personal_access_tokens')->where('tokenable_id', 'driver-user')->count())->toBe(0); }); + +test('join organization grants the invite role and never a default one', function () { + $capsule = auth_controller_login_bootstrap_database(); + auth_controller_insert_administrator_role($capsule, 'company-join'); + auth_controller_insert_administrator_role($capsule, 'company-legacy'); + $capsule->getConnection('mysql')->table('roles')->insert([ + 'id' => '33333333-3333-4333-8333-333333333399', 'company_uuid' => 'company-join', 'name' => 'Dispatcher', 'guard_name' => 'sanctum', + 'created_at' => '2026-07-18 10:00:00', 'updated_at' => '2026-07-18 10:00:00', 'deleted_at' => null, + ]); + auth_controller_login_insert_user($capsule, ['uuid' => 'joining-user', 'email' => 'joining@example.test', 'company_uuid' => 'company-current', 'type' => 'user']); + auth_controller_insert_company($capsule, ['uuid' => 'company-join', 'public_id' => 'company_join_public']); + auth_controller_insert_company($capsule, ['uuid' => 'company-legacy', 'public_id' => 'company_legacy_public', 'slug' => 'legacy-company']); + auth_controller_insert_join_invite($capsule, 'company-join', 'joining@example.test'); + $capsule->getConnection('mysql')->getSchemaBuilder()->table('invites', fn ($table) => $table->text('meta')->nullable()); + $capsule->getConnection('mysql')->table('invites')->where('company_uuid', 'company-join')->update(['meta' => json_encode(['role_uuid' => '33333333-3333-4333-8333-333333333399'])]); + $capsule->getConnection('mysql')->table('invites')->insert([ + 'uuid' => 'invite-legacy', 'company_uuid' => 'company-legacy', 'subject_uuid' => 'company-legacy', 'subject_type' => Fleetbase\Models\Company::class, + 'created_by_uuid' => 'owner-user', 'protocol' => 'email', 'reason' => 'join_company', 'recipients' => json_encode(['joining@example.test']), + 'expires_at' => Carbon::now()->addDay()->toDateTimeString(), 'deleted_at' => null, 'created_at' => '2026-07-18 10:00:00', 'updated_at' => '2026-07-18 10:00:00', + ]); + + $joined = (new AuthController())->joinOrganization(auth_controller_join_organization_request(User::find('joining-user'), 'company_join_public')); + $legacy = (new AuthController())->joinOrganization(auth_controller_join_organization_request(User::find('joining-user'), 'company_legacy_public')); + + $db = $capsule->getConnection('mysql'); + $joinPivot = $db->table('company_users')->where(['user_uuid' => 'joining-user', 'company_uuid' => 'company-join'])->value('uuid'); + $legacyPivot = $db->table('company_users')->where(['user_uuid' => 'joining-user', 'company_uuid' => 'company-legacy'])->value('uuid'); + + expect($joined->getStatusCode())->toBe(200) + ->and($db->table('model_has_roles')->where('model_uuid', $joinPivot)->pluck('role_id')->all())->toBe(['33333333-3333-4333-8333-333333333399']) + ->and($legacy->getStatusCode())->toBe(200) + ->and($legacyPivot)->not->toBeNull() + ->and($db->table('model_has_roles')->where('model_uuid', $legacyPivot)->count())->toBe(0) + // An account without an email can't have been sent an invite + ->and(Fleetbase\Models\Invite::findSentToJoinCompany(new User(), Fleetbase\Models\Company::find('company-join')))->toBeNull(); +}); diff --git a/tests/Unit/Http/UserControllerTest.php b/tests/Unit/Http/UserControllerTest.php index 73e727bf..8a9d5749 100644 --- a/tests/Unit/Http/UserControllerTest.php +++ b/tests/Unit/Http/UserControllerTest.php @@ -605,6 +605,16 @@ public function clear(): void return $capsule; } +/** + * Give the owner the Administrator role, as a real organization owner has. + */ +function user_controller_owner_is_administrator(Capsule $capsule): void +{ + $capsule->getConnection('mysql')->table('model_has_roles')->insert([ + 'role_id' => 'Administrator', 'model_type' => Fleetbase\Models\CompanyUser::class, 'model_uuid' => 'pivot-owner-1', + ]); +} + function user_controller(): UserController { return new UserController(); @@ -927,12 +937,14 @@ function user_controller_assert_created_user_response(mixed $response): object|a }); test('user controller create record rejects duplicate active-company members and unavailable roles', function () { - user_controller_database(); + $capsule = user_controller_database(); + user_controller_owner_is_administrator($capsule); $duplicateMember = user_controller_without_request_validation()->createRecord(user_controller_request('POST', [ 'user' => [ - 'email' => 'member@example.test', - 'name' => 'Member One', + 'email' => 'member@example.test', + 'name' => 'Member One', + 'role_uuid' => 'Administrator', ], ], user_controller_user('owner-1'), 'createRecord')); $invalidRole = user_controller_without_request_validation()->createRecord(user_controller_request('POST', [ @@ -951,6 +963,7 @@ function user_controller_assert_created_user_response(mixed $response): object|a test('user controller create record invites existing users from another organization', function () { $capsule = user_controller_database(); + user_controller_owner_is_administrator($capsule); EloquentModel::setEventDispatcher(new Dispatcher(app())); $invite = user_controller_without_request_validation()->createRecord(user_controller_request('POST', [ @@ -969,14 +982,17 @@ function user_controller_assert_created_user_response(mixed $response): object|a }); test('user controller create record reports existing-user invite precondition failures', function () { - user_controller_database(); + $capsule = user_controller_database(); + user_controller_owner_is_administrator($capsule); - session()->flush(); - $missingCompany = user_controller_without_request_validation()->createRecord(user_controller_request('POST', [ + $missingCompanyRequest = user_controller_request('POST', [ 'user' => [ - 'email' => 'foreign@example.test', + 'email' => 'foreign@example.test', + 'role_uuid' => 'Administrator', ], - ], user_controller_user('owner-1'), 'createRecord')); + ], user_controller_user('owner-1'), 'createRecord'); + session()->flush(); + $missingCompany = user_controller_without_request_validation()->createRecord($missingCompanyRequest); session(['company' => 'company-1', 'user' => 'owner-1']); $invalidRole = user_controller_without_request_validation()->createRecord(user_controller_request('POST', [ @@ -1137,7 +1153,8 @@ function user_controller_assert_created_user_response(mixed $response): object|a }); test('user controller formats create and update exception responses by exception type', function () { - user_controller_database(); + $capsule = user_controller_database(); + user_controller_owner_is_administrator($capsule); $queryException = new Illuminate\Database\QueryException( 'mysql', @@ -1150,8 +1167,9 @@ function user_controller_assert_created_user_response(mixed $response): object|a $createDatabaseFailure->model = new UserControllerThrowingModel($queryException); $createDatabaseResponse = $createDatabaseFailure->createRecord(user_controller_request('POST', [ 'user' => [ - 'email' => 'database-failure@example.test', - 'name' => 'Database Failure', + 'email' => 'database-failure@example.test', + 'name' => 'Database Failure', + 'role_uuid' => 'Administrator', ], ], user_controller_user('owner-1'), 'createRecord')); @@ -1161,8 +1179,9 @@ function user_controller_assert_created_user_response(mixed $response): object|a ])); $createValidationResponse = $createValidationFailure->createRecord(user_controller_request('POST', [ 'user' => [ - 'email' => 'validation-failure@example.test', - 'name' => 'Validation Failure', + 'email' => 'validation-failure@example.test', + 'name' => 'Validation Failure', + 'role_uuid' => 'Administrator', ], ], user_controller_user('owner-1'), 'createRecord')); @@ -1170,8 +1189,9 @@ function user_controller_assert_created_user_response(mixed $response): object|a $createGenericFailure->model = new UserControllerThrowingModel(new RuntimeException('generic create failure')); $createGenericResponse = $createGenericFailure->createRecord(user_controller_request('POST', [ 'user' => [ - 'email' => 'generic-failure@example.test', - 'name' => 'Generic Failure', + 'email' => 'generic-failure@example.test', + 'name' => 'Generic Failure', + 'role_uuid' => 'Administrator', ], ], user_controller_user('owner-1'), 'createRecord')); @@ -1637,6 +1657,7 @@ function user_controller_assert_created_user_response(mixed $response): object|a test('user controller invites a brand new user and prevents duplicate organization invitations', function () { $capsule = user_controller_database(); + user_controller_owner_is_administrator($capsule); EloquentModel::setEventDispatcher(new Dispatcher(app())); $invite = user_controller()->inviteUser(user_controller_request('POST', [ @@ -1649,8 +1670,9 @@ function user_controller_assert_created_user_response(mixed $response): object|a ], user_controller_user('owner-1'), 'inviteUser', InviteUserRequest::class)); $duplicate = user_controller()->inviteUser(user_controller_request('POST', [ 'user' => [ - 'email' => 'fresh@example.test', - 'name' => 'Fresh User', + 'email' => 'fresh@example.test', + 'name' => 'Fresh User', + 'role_uuid' => 'Administrator', ], ], user_controller_user('owner-1'), 'inviteUser', InviteUserRequest::class)); @@ -1675,6 +1697,7 @@ function user_controller_assert_created_user_response(mixed $response): object|a test('user controller invites existing users from another organization without creating duplicates', function () { $capsule = user_controller_database(); + user_controller_owner_is_administrator($capsule); EloquentModel::setEventDispatcher(new Dispatcher(app())); $invite = user_controller()->inviteUser(user_controller_request('POST', [ @@ -1685,7 +1708,8 @@ function user_controller_assert_created_user_response(mixed $response): object|a ], user_controller_user('owner-1'), 'inviteUser', InviteUserRequest::class)); $duplicateInvite = user_controller()->inviteUser(user_controller_request('POST', [ 'user' => [ - 'email' => 'foreign@example.test', + 'email' => 'foreign@example.test', + 'role_uuid' => 'Administrator', ], ], user_controller_user('owner-1'), 'inviteUser', InviteUserRequest::class)); @@ -1908,6 +1932,7 @@ function user_controller_assert_created_user_response(mixed $response): object|a test('user controller promotes a managed driver account in the organization instead of inviting a duplicate', function (string $method) { $capsule = user_controller_database(); + user_controller_owner_is_administrator($capsule); $connection = $capsule->getConnection('mysql'); EloquentModel::setEventDispatcher(new Dispatcher(app())); $now = '2026-07-18 10:00:00'; @@ -1972,6 +1997,7 @@ function user_controller_assert_created_user_response(mixed $response): object|a test('user controller promotion syncs permissions and policies and reports organization and role failures', function () { $capsule = user_controller_database(); + user_controller_owner_is_administrator($capsule); $db = $capsule->getConnection('mysql'); EloquentModel::setEventDispatcher(new Dispatcher(app())); $now = '2026-07-18 10:00:00'; @@ -1990,6 +2016,7 @@ function user_controller_assert_created_user_response(mixed $response): object|a $promoted = user_controller_without_request_validation()->createRecord(user_controller_request('POST', [ 'user' => [ 'phone' => '+15555550188', + 'role_uuid' => 'Administrator', 'permissions' => ['permission-promote'], 'policies' => ['policy-promote'], ], @@ -1999,7 +2026,7 @@ function user_controller_assert_created_user_response(mixed $response): object|a $promote = new ReflectionMethod(UserController::class, 'promoteManagedAccount'); $promoteRequest = user_controller_request('POST', ['user' => []], user_controller_user('owner-1'), 'createRecord'); session()->flush(); - $noCompany = $promote->invoke(user_controller(), User::find('driver-2'), $promoteRequest); + $noCompany = $promote->invoke(user_controller(), User::find('driver-2'), $promoteRequest, Role::find('Administrator')); expect($noIdentity)->toBeNull() ->and($invalidRole->getStatusCode())->toBe(404) @@ -2010,3 +2037,63 @@ function user_controller_assert_created_user_response(mixed $response): object|a ->and($db->table('model_has_policies')->where('model_uuid', 'pivot-customer-1')->where('policy_id', 'policy-promote')->exists())->toBeTrue() ->and($noCompany->getData(true))->toBe(['errors' => ['Unable to determine the current organisation.']]); }); + +test('user controller requires a role and only lets administrators grant the Administrator role', function () { + $capsule = user_controller_database(); + $db = $capsule->getConnection('mysql'); + EloquentModel::setEventDispatcher(new Dispatcher(app())); + $db->table('roles')->insert(['id' => 'Dispatcher', 'company_uuid' => 'company-1', 'name' => 'Dispatcher', 'guard_name' => 'sanctum', 'created_at' => '2026-07-18 10:00:00', 'updated_at' => '2026-07-18 10:00:00']); + + $createWithoutRole = user_controller_without_request_validation()->createRecord(user_controller_request('POST', [ + 'user' => ['email' => 'no-role@example.test', 'name' => 'No Role'], + ], user_controller_user('owner-1'), 'createRecord')); + $inviteWithoutRole = user_controller()->inviteUser(user_controller_request('POST', [ + 'user' => ['email' => 'no-role-invite@example.test', 'name' => 'No Role'], + ], user_controller_user('owner-1'), 'inviteUser', InviteUserRequest::class)); + + // owner-1 has no Administrator role in this organization, so it may not grant it + $createAdministrator = user_controller_without_request_validation()->createRecord(user_controller_request('POST', [ + 'user' => ['email' => 'escalate@example.test', 'name' => 'Escalate', 'role_uuid' => 'Administrator'], + ], user_controller_user('owner-1'), 'createRecord')); + $updateToAdministrator = user_controller_without_request_validation()->updateRecord(user_controller_request('PATCH', [ + 'user' => ['role' => 'Administrator'], + ], user_controller_user('owner-1'), 'updateRecord'), 'member-1'); + $objectRole = user_controller_without_request_validation()->createRecord(user_controller_request('POST', [ + 'user' => ['email' => 'object-role@example.test', 'name' => 'Object Role', 'role' => ['id' => 'Dispatcher']], + ], user_controller_user('owner-1'), 'createRecord')); + + expect($createWithoutRole->getStatusCode())->toBe(422) + ->and($createWithoutRole->getData(true))->toBe(['errors' => ['Select a role for this user.']]) + ->and($inviteWithoutRole->getStatusCode())->toBe(422) + ->and(User::where('email', 'no-role-invite@example.test')->exists())->toBeFalse() + ->and($createAdministrator->getStatusCode())->toBe(403) + ->and($createAdministrator->getData(true))->toBe(['errors' => ['Only administrators can grant the Administrator role.']]) + ->and(User::where('email', 'escalate@example.test')->exists())->toBeFalse() + ->and($updateToAdministrator->getStatusCode())->toBe(403) + ->and($objectRole)->not->toBeInstanceOf(JsonResponse::class); + + $created = User::where('email', 'object-role@example.test')->first(); + $pivot = $db->table('company_users')->where(['company_uuid' => 'company-1', 'user_uuid' => $created->uuid])->value('uuid'); + + expect($db->table('model_has_roles')->where('model_uuid', $pivot)->pluck('role_id')->all())->toBe(['Dispatcher']); +}); + +test('user controller accepting an invite without a role joins without one', function () { + $capsule = user_controller_database(); + EloquentModel::setEventDispatcher(new Dispatcher(app())); + + $invitee = User::create(['uuid' => 'legacy-invitee', 'public_id' => 'user_legacy_invitee', 'email' => 'legacy@example.test', 'name' => 'Legacy Invitee', 'company_uuid' => 'company-2', 'status' => 'active']); + $capsule->getConnection('mysql')->table('invites')->insert([ + 'uuid' => 'invite-legacy', 'public_id' => 'invite_public_legacy', 'code' => 'LEGACY1', 'uri' => 'legacy1', 'company_uuid' => 'company-1', + 'created_by_uuid' => 'owner-1', 'subject_uuid' => 'company-1', 'subject_type' => Fleetbase\Support\Utils::getMutationType(Fleetbase\Models\Company::where('uuid', 'company-1')->first()), + 'protocol' => 'email', 'recipients' => json_encode(['legacy@example.test']), 'reason' => 'join_company', 'meta' => json_encode([]), + 'expires_at' => now()->addHours(48), 'created_at' => now(), 'updated_at' => now(), + ]); + + $accepted = user_controller()->acceptCompanyInvite(user_controller_request('POST', ['code' => 'LEGACY1'], $invitee, 'acceptCompanyInvite', AcceptCompanyInvite::class)); + $pivot = $capsule->getConnection('mysql')->table('company_users')->where(['company_uuid' => 'company-1', 'user_uuid' => 'legacy-invitee'])->value('uuid'); + + expect($accepted->getStatusCode())->toBe(200) + ->and($pivot)->not->toBeNull() + ->and($capsule->getConnection('mysql')->table('model_has_roles')->where('model_uuid', $pivot)->count())->toBe(0); +}); diff --git a/tests/Unit/Models/CompanyModelTest.php b/tests/Unit/Models/CompanyModelTest.php index 07ab3382..5a946362 100644 --- a/tests/Unit/Models/CompanyModelTest.php +++ b/tests/Unit/Models/CompanyModelTest.php @@ -76,7 +76,7 @@ public function __construct(?CompanyUser $companyUser = null) $this->setRawAttributes(['uuid' => 'company-1'], true); } - public function addUser(User $user, string $role = 'Administrator', string $status = 'active'): CompanyUser + public function addUser(User $user, ?string $role = null, string $status = 'active'): CompanyUser { $this->addedUsers[] = [$user->uuid, $role, $status]; @@ -348,7 +348,7 @@ function company_model_create_users_table(): void $user = new class extends User { public array $assignedCompanies = []; - public function assignCompany(Company $company, string $role = 'Administrator'): User + public function assignCompany(Company $company, ?string $role = null): User { $this->assignedCompanies[] = [$company->uuid, $role]; @@ -362,7 +362,7 @@ public function assignCompany(Company $company, string $role = 'Administrator'): expect($company->assignUser($user, 'Dispatcher'))->toBe($companyUser) ->and($company->addedUsers)->toBe([['user-1', 'Dispatcher', 'active']]) - ->and($user->assignedCompanies)->toBe([['company-1', 'Administrator']]); + ->and($user->assignedCompanies)->toBe([['company-1', null]]); }); it('resolves the current company from session using the configured connection', function () { From 1a047338143930cc5ca16ac63e72014a6aa10665 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 22 Sep 2026 12:20:15 +0800 Subject: [PATCH 16/30] Note the default Administrator role fix in the v1.6.63 release notes --- RELEASE.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/RELEASE.md b/RELEASE.md index cdfc4f8f..99046577 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -13,10 +13,22 @@ - Customers are still allowed on those endpoints because the customer portal runs inside the console and restores its session through them. - Free a deleted user's email and phone so a new account can use them. On soft delete they move to `meta.deleted_identity`; restoring the user puts them back only if no other account has taken them. +## Security + +- Never grant the Administrator role by default. Before this fix: + - Creating or inviting a user without a role gave them the Administrator role, and so full organization access. Reported for IAM › Customers › Add customer with a blank Role. + - Accepting an invite with no role also granted it, and `joinOrganization` ignored the invite's role altogether. + + Now: + - A role is required when creating or inviting a user; without one the request returns 422. + - `Company::addUser`, `Company::assignUser` and `User::assignCompany` assign no role unless one is given. + - An invite without a role joins with no role. +- Only admins or holders of the Administrator role may grant the Administrator role (403 otherwise), on create, invite and role update. + ## Reliability - Cover the console guards for each account type, identity release and restore, and promotion through create, invite and invite acceptance. A database migration is not required. No configuration change is needed. The FleetOps side ships in fleetbase/fleetops#338. -Changes: [#264](https://github.com/fleetbase/core-api/pull/264). +Changes: [#264](https://github.com/fleetbase/core-api/pull/264), [#266](https://github.com/fleetbase/core-api/pull/266). From e55369d7d3323471fc140b29d60b94de99cdf811 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 22 Sep 2026 12:56:00 +0800 Subject: [PATCH 17/30] feat: let IAM admins request and record email/phone verification Admins could only mark a user's email verified. There was no way to ask the user to verify their email or phone, or to mark a phone verified. - `PATCH users/verify/{id}` takes an optional `channel` (email by default, or phone). It returns 422 when that channel has no value or is already verified. `User::manualVerify()` takes the channel. - New `POST users/{id}/send-verification` with `{ channel }` sends a one-click verification link: - by email (UserContactVerificationRequested), or by SMS through SmsService; - the link lasts 48 hours, and a new request replaces the earlier one; - allowed for admins, Administrator-role holders and `iam verify user`. - New public `POST auth/confirm-contact-verification` with `{ link, code }` verifies the email or phone without signing in. It refuses the link if the address changed since the request. - UserFilter adds `email_verified`, `phone_verified`, `country` and `timezone`, for the new IAM columns. --- src/Expansions/Route.php | 1 + .../Internal/v1/AuthController.php | 40 ++++++ .../Internal/v1/UserController.php | 125 +++++++++++++++++- src/Http/Filter/UserFilter.php | 22 +++ src/Models/User.php | 9 +- .../UserContactVerificationRequested.php | 80 +++++++++++ src/routes.php | 1 + .../AuthControllerVerificationCodeTest.php | 50 +++++++ .../Unit/Http/ConcreteFilterContractsTest.php | 28 ++++ tests/Unit/Http/UserControllerTest.php | 106 +++++++++++++++ 10 files changed, 458 insertions(+), 4 deletions(-) create mode 100644 src/Notifications/UserContactVerificationRequested.php diff --git a/src/Expansions/Route.php b/src/Expansions/Route.php index 68281f84..51b204f7 100644 --- a/src/Expansions/Route.php +++ b/src/Expansions/Route.php @@ -132,6 +132,7 @@ public function fleetbaseAuthRoutes(): \Closure $router->post('get-magic-reset-link', [$authControllerClass, 'createPasswordReset']); $router->post('reset-password', [$authControllerClass, 'resetPassword']); $router->post('confirm-email-change', [$authControllerClass, 'confirmEmailChange']); + $router->post('confirm-contact-verification', [$authControllerClass, 'confirmContactVerification']); $router->post('create-verification-session', [$authControllerClass, 'createVerificationSession']); $router->post('validate-verification-session', [$authControllerClass, 'validateVerificationSession']); $router->post('send-verification-email', [$authControllerClass, 'sendVerificationEmail']); diff --git a/src/Http/Controllers/Internal/v1/AuthController.php b/src/Http/Controllers/Internal/v1/AuthController.php index 56faac1f..5416db88 100644 --- a/src/Http/Controllers/Internal/v1/AuthController.php +++ b/src/Http/Controllers/Internal/v1/AuthController.php @@ -690,6 +690,46 @@ public function confirmEmailChange(Request $request) ]); } + /** + * Confirm an email address or phone number from the link sent by an + * administrator's verification request. Works without signing in. + * + * @return \Illuminate\Http\Response + */ + public function confirmContactVerification(Request $request) + { + $verificationCode = VerificationCode::where('uuid', $request->input('link')) + ->where('code', $request->input('code')) + ->whereIn('for', ['email_verification', 'phone_verification']) + ->where('status', 'active') + ->where('meta->source', 'admin_request') + ->with(['subject']) + ->first(); + + // Expired codes are excluded by the model's expiry scope + if (!$verificationCode || !($verificationCode->subject instanceof User)) { + return response()->error('This verification link is invalid or has expired.'); + } + + $user = $verificationCode->subject; + $channel = data_get($verificationCode->meta, 'channel') === 'phone' ? 'phone' : 'email'; + $current = $channel === 'phone' ? $user->phone : $user->email; + + // The email/phone changed after the request was sent + if ((string) $current !== (string) data_get($verificationCode->meta, 'value')) { + return response()->error('This verification link is no longer valid.'); + } + + $user->verify($verificationCode); + $verificationCode->delete(); + + return response()->json([ + 'status' => 'ok', + 'channel' => $channel, + 'verified_at' => $channel === 'phone' ? $user->phone_verified_at : $user->email_verified_at, + ]); + } + private function getVerificationSession(Request $request): ?array { $email = strtolower((string) $request->input('email')); diff --git a/src/Http/Controllers/Internal/v1/UserController.php b/src/Http/Controllers/Internal/v1/UserController.php index 90e88710..2e469539 100644 --- a/src/Http/Controllers/Internal/v1/UserController.php +++ b/src/Http/Controllers/Internal/v1/UserController.php @@ -27,8 +27,10 @@ use Fleetbase\Models\User; use Fleetbase\Models\VerificationCode; use Fleetbase\Notifications\UserAcceptedCompanyInvite; +use Fleetbase\Notifications\UserContactVerificationRequested; use Fleetbase\Notifications\UserEmailChange; use Fleetbase\Notifications\UserInvited; +use Fleetbase\Services\SmsService; use Fleetbase\Services\UserCacheService; use Fleetbase\Support\Auth; use Fleetbase\Support\NotificationRegistry; @@ -1053,16 +1055,137 @@ public function verify($id) return response()->error('No user found', 401); } - $user->manualVerify(); + $channel = $this->verificationChannel(request()); + if ($error = $this->verificationChannelError($user, $channel)) { + return $error; + } + + $user->manualVerify($channel); $user = $user->refresh(); return response()->json([ 'message' => 'User verified', + 'channel' => $channel, 'email_verified_at' => $user->email_verified_at, + 'phone_verified_at' => $user->phone_verified_at, 'status' => 'ok', ]); } + /** + * Ask a user to verify their email address or phone number. The user gets a + * link, by email or SMS, that confirms it without signing in. + * + * @return \Illuminate\Http\Response + */ + #[SkipAuthorizationCheck] + public function sendVerification(Request $request, string $id) + { + if (!$this->canVerifyUsers(Auth::getUserFromSession($request))) { + return response()->error('Not authorized to verify users.', 403); + } + $actor = Auth::getUserFromSession($request); + + $user = $this->resolveVisibleUser($id, $request); + if (!$user) { + return response()->error('No user found', 404); + } + + $channel = $this->verificationChannel($request); + if ($error = $this->verificationChannelError($user, $channel)) { + return $error; + } + + $for = $channel === 'phone' ? 'phone_verification' : 'email_verification'; + $value = $channel === 'phone' ? $user->phone : $user->email; + + // A new request replaces any earlier one an administrator sent + VerificationCode::where('subject_uuid', $user->uuid) + ->where('for', $for) + ->where('status', 'active') + ->where('meta->source', 'admin_request') + ->delete(); + + $verificationCode = VerificationCode::create([ + 'subject_uuid' => $user->uuid, + 'subject_type' => Utils::getModelClassName($user), + 'for' => $for, + 'expires_at' => Carbon::now()->addHours(48), + 'meta' => [ + 'source' => 'admin_request', + 'channel' => $channel, + 'value' => $value, + 'requested_by_uuid' => $actor->uuid, + ], + 'status' => 'active', + ]); + + try { + if ($channel === 'phone') { + $url = UserContactVerificationRequested::urlFor($verificationCode); + $result = app(SmsService::class)->send($value, 'Verify your ' . config('app.name') . ' phone number: ' . $url); + if (is_array($result) && array_key_exists('success', $result) && !$result['success']) { + throw new \RuntimeException((string) ($result['error'] ?? $result['message'] ?? 'The SMS provider refused the message.')); + } + } else { + $user->notify(new UserContactVerificationRequested($verificationCode)); + } + } catch (\Throwable $e) { + $verificationCode->delete(); + + return response()->error('Unable to send the verification request: ' . $e->getMessage()); + } + + return response()->json(['status' => 'ok', 'channel' => $channel]); + } + + /** + * Whether the user may verify other users: admins, holders of the + * Administrator role, and anyone granted `iam verify user`. + */ + private function canVerifyUsers(?User $actor): bool + { + if (!$actor) { + return false; + } + + if ($actor->isAdmin() || $actor->hasRole('Administrator')) { + return true; + } + + try { + return $actor->hasPermissionTo('iam verify user'); + } catch (\Spatie\Permission\Exceptions\PermissionDoesNotExist $e) { + return false; + } + } + + /** + * The verification channel requested: `email` (default) or `phone`. + */ + private function verificationChannel(Request $request): string + { + return $request->input('channel') === 'phone' ? 'phone' : 'email'; + } + + /** + * An error when the user's email/phone can't be verified: it isn't set, or + * it's already verified. + */ + private function verificationChannelError(User $user, string $channel): ?\Illuminate\Http\JsonResponse + { + $label = $channel === 'phone' ? 'phone number' : 'email address'; + if (empty($channel === 'phone' ? $user->phone : $user->email)) { + return response()->error('This user has no ' . $label . ' to verify.', 422); + } + + if (!empty($channel === 'phone' ? $user->phone_verified_at : $user->email_verified_at)) { + return response()->error('This user\'s ' . $label . ' is already verified.', 422); + } + + return null; + } + /** * Removes this user from the current company. * diff --git a/src/Http/Filter/UserFilter.php b/src/Http/Filter/UserFilter.php index 6f9857c1..d584cd4e 100644 --- a/src/Http/Filter/UserFilter.php +++ b/src/Http/Filter/UserFilter.php @@ -2,6 +2,8 @@ namespace Fleetbase\Http\Filter; +use Fleetbase\Support\Utils; + class UserFilter extends Filter { public function queryForInternal() @@ -43,6 +45,26 @@ public function isNotAdmin() $this->builder->where('type', '!=', 'admin'); } + public function emailVerified($verified) + { + Utils::isTrue($verified) ? $this->builder->whereNotNull('email_verified_at') : $this->builder->whereNull('email_verified_at'); + } + + public function phoneVerified($verified) + { + Utils::isTrue($verified) ? $this->builder->whereNotNull('phone_verified_at') : $this->builder->whereNull('phone_verified_at'); + } + + public function country(?string $country) + { + $this->builder->where('country', $country); + } + + public function timezone(?string $timezone) + { + $this->builder->where('timezone', $timezone); + } + public function isUser() { $this->builder->whereIn('type', ['user', 'admin']); diff --git a/src/Models/User.php b/src/Models/User.php index a6fd8301..8e3bdbba 100644 --- a/src/Models/User.php +++ b/src/Models/User.php @@ -1047,11 +1047,14 @@ public function verify(VerificationCode|string $code): self } /** - * Manually verify the user's email . + * Manually verify the user's email or phone. + * + * @param string $channel `email` or `phone` */ - public function manualVerify(): self + public function manualVerify(string $channel = 'email'): self { - $this->email_verified_at = Carbon::now(); + $column = $channel === 'phone' ? 'phone_verified_at' : 'email_verified_at'; + $this->{$column} = Carbon::now(); $this->save(); return $this; diff --git a/src/Notifications/UserContactVerificationRequested.php b/src/Notifications/UserContactVerificationRequested.php new file mode 100644 index 00000000..136ea5a5 --- /dev/null +++ b/src/Notifications/UserContactVerificationRequested.php @@ -0,0 +1,80 @@ +url = static::urlFor($verificationCode); + } + + /** + * The console page that confirms the verification code. + */ + public static function urlFor(VerificationCode $verificationCode): string + { + return Utils::consoleUrl('auth/verify-contact/' . $verificationCode->uuid, ['code' => $verificationCode->code]); + } + + /** + * Get the notification's delivery channels. + * + * @return array + */ + public function via($notifiable) + { + return ['mail']; + } + + /** + * Get the mail representation of the notification. + * + * @return MailMessage + */ + public function toMail($notifiable) + { + $user = $this->verificationCode->subject; + + return (new MailMessage()) + ->subject('Verify your ' . config('app.name') . ' email address') + ->greeting('Hello, ' . data_get($user, 'name', 'there')) + ->line('Please confirm that ' . data_get($this->verificationCode->meta, 'value') . ' is your email address.') + ->action('Verify Email', $this->url) + ->line('If the button does not work, use this verification code: ' . $this->verificationCode->code) + ->line('This link expires in 48 hours. If you were not expecting this email, you can ignore it.'); + } + + /** + * Get the array representation of the notification. + * + * @return array + */ + public function toArray($notifiable) + { + return [ + 'code' => $this->verificationCode->code, + ]; + } +} diff --git a/src/routes.php b/src/routes.php index dcf7aa8a..c8704a09 100644 --- a/src/routes.php +++ b/src/routes.php @@ -273,6 +273,7 @@ function ($router, $controller) { $router->patch('deactivate/{id}', $controller('deactivate')); $router->patch('activate/{id}', $controller('activate')); $router->patch('verify/{id}', $controller('verify')); + $router->post('{id}/send-verification', $controller('sendVerification')); $router->delete('remove-from-company/{id}', $controller('removeFromCompany')); $router->post('change-email', $controller('changeCurrentUserEmail')); $router->post('{id}/change-email', $controller('changeEmail')); diff --git a/tests/Unit/Http/AuthControllerVerificationCodeTest.php b/tests/Unit/Http/AuthControllerVerificationCodeTest.php index ddf30551..c205e87b 100644 --- a/tests/Unit/Http/AuthControllerVerificationCodeTest.php +++ b/tests/Unit/Http/AuthControllerVerificationCodeTest.php @@ -581,3 +581,53 @@ function auth_controller_forgot_password_request(array $input = [], string $ip = ->and(User::find('user-email-change')->email)->toBe('old@example.test') ->and(VerificationCode::where('uuid', 'email-change-code')->exists())->toBeTrue(); }); + +function auth_controller_contact_verification_code(Capsule $capsule, string $uuid, string $channel, string $value, array $attributes = []): void +{ + auth_controller_verification_code_insert($capsule, array_merge([ + 'uuid' => $uuid, + 'subject_uuid' => 'user-contact', + 'subject_type' => User::class, + 'code' => '654321', + 'for' => $channel === 'phone' ? 'phone_verification' : 'email_verification', + 'meta' => json_encode(['source' => 'admin_request', 'channel' => $channel, 'value' => $value]), + ], $attributes)); +} + +test('confirm contact verification verifies the requested email or phone from the link', function () { + $capsule = auth_controller_verification_code_database(); + $capsule->getConnection('mysql')->getSchemaBuilder()->table('users', function ($table) { + $table->string('phone')->nullable(); + $table->timestamp('phone_verified_at')->nullable(); + }); + auth_controller_verification_code_insert_user($capsule, ['uuid' => 'user-contact', 'email' => 'contact@example.test', 'phone' => '+15550001111']); + auth_controller_contact_verification_code($capsule, 'email-link', 'email', 'contact@example.test'); + auth_controller_contact_verification_code($capsule, 'phone-link', 'phone', '+15550001111', ['code' => '111222']); + + $email = (new AuthController())->confirmContactVerification(auth_controller_verification_code_request(['link' => 'email-link', 'code' => '654321'])); + $phone = (new AuthController())->confirmContactVerification(auth_controller_verification_code_request(['link' => 'phone-link', 'code' => '111222'])); + $user = User::find('user-contact'); + + expect($email->getStatusCode())->toBe(200) + ->and($email->getData(true)['channel'])->toBe('email') + ->and($phone->getData(true)['channel'])->toBe('phone') + ->and($user->email_verified_at)->not->toBeNull() + ->and($user->phone_verified_at)->not->toBeNull() + ->and(VerificationCode::whereIn('uuid', ['email-link', 'phone-link'])->count())->toBe(0); +}); + +test('confirm contact verification refuses invalid expired and outdated links', function () { + $capsule = auth_controller_verification_code_database(); + auth_controller_verification_code_insert_user($capsule, ['uuid' => 'user-contact', 'email' => 'contact@example.test']); + auth_controller_contact_verification_code($capsule, 'expired-link', 'email', 'contact@example.test', ['expires_at' => Carbon::now()->subHour()->toDateTimeString()]); + auth_controller_contact_verification_code($capsule, 'outdated-link', 'email', 'previous@example.test', ['code' => '333444']); + + $invalid = (new AuthController())->confirmContactVerification(auth_controller_verification_code_request(['link' => 'expired-link', 'code' => '000000'])); + $expired = (new AuthController())->confirmContactVerification(auth_controller_verification_code_request(['link' => 'expired-link', 'code' => '654321'])); + $outdated = (new AuthController())->confirmContactVerification(auth_controller_verification_code_request(['link' => 'outdated-link', 'code' => '333444'])); + + expect($invalid->getData(true))->toBe(['errors' => ['This verification link is invalid or has expired.']]) + ->and($expired->getData(true))->toBe(['errors' => ['This verification link is invalid or has expired.']]) + ->and($outdated->getData(true))->toBe(['errors' => ['This verification link is no longer valid.']]) + ->and(User::find('user-contact')->email_verified_at)->toBeNull(); +}); diff --git a/tests/Unit/Http/ConcreteFilterContractsTest.php b/tests/Unit/Http/ConcreteFilterContractsTest.php index cbe81a2d..9256868f 100644 --- a/tests/Unit/Http/ConcreteFilterContractsTest.php +++ b/tests/Unit/Http/ConcreteFilterContractsTest.php @@ -1177,3 +1177,31 @@ public function isAdmin(): bool 'notification-company-unread', ]); }); + +test('user filter narrows by verification state country and timezone', function () { + $capsule = concrete_filter_database(); + $pdo = $capsule->getConnection('mysql')->getPdo(); + $pdo->sqliteCreateFunction('JSON_CONTAINS', fn ($json, $needle) => str_contains((string) $json, trim((string) $needle, '"')) ? 1 : 0, 2); + $capsule->getConnection('mysql')->getSchemaBuilder()->table('users', function ($table) { + $table->timestamp('email_verified_at')->nullable(); + $table->timestamp('phone_verified_at')->nullable(); + $table->string('country')->nullable(); + $table->string('timezone')->nullable(); + }); + + $capsule->getConnection('mysql')->table('users')->insert([ + ['uuid' => 'user-verified', 'type' => 'user', 'email_verified_at' => '2026-07-18 10:00:00', 'phone_verified_at' => null, 'country' => 'SG', 'timezone' => 'Asia/Singapore'], + ['uuid' => 'user-unverified', 'type' => 'user', 'email_verified_at' => null, 'phone_verified_at' => '2026-07-18 10:00:00', 'country' => 'MN', 'timezone' => 'Asia/Ulaanbaatar'], + ]); + $capsule->getConnection('mysql')->table('company_users')->insert([ + ['company_uuid' => 'company-1', 'user_uuid' => 'user-verified'], + ['company_uuid' => 'company-1', 'user_uuid' => 'user-unverified'], + ]); + + expect(concrete_filter_uuids(UserFilter::class, User::class, ['email_verified' => '1'], 'int/v1/users'))->toBe(['user-verified']) + ->and(concrete_filter_uuids(UserFilter::class, User::class, ['email_verified' => 'false'], 'int/v1/users'))->toBe(['user-unverified']) + ->and(concrete_filter_uuids(UserFilter::class, User::class, ['phone_verified' => '1'], 'int/v1/users'))->toBe(['user-unverified']) + ->and(concrete_filter_uuids(UserFilter::class, User::class, ['phone_verified' => 'false'], 'int/v1/users'))->toBe(['user-verified']) + ->and(concrete_filter_uuids(UserFilter::class, User::class, ['country' => 'MN'], 'int/v1/users'))->toBe(['user-unverified']) + ->and(concrete_filter_uuids(UserFilter::class, User::class, ['timezone' => 'Asia/Singapore'], 'int/v1/users'))->toBe(['user-verified']); +}); diff --git a/tests/Unit/Http/UserControllerTest.php b/tests/Unit/Http/UserControllerTest.php index 77ad97ff..3f5df550 100644 --- a/tests/Unit/Http/UserControllerTest.php +++ b/tests/Unit/Http/UserControllerTest.php @@ -1905,3 +1905,109 @@ function user_controller_assert_created_user_response(mixed $response): object|a ->and($missingUser->getStatusCode())->toBe(400) ->and($missingUser->getData(true))->toBe(['errors' => ['Unable to locate the user for this invitation.']]); }); + +if (!function_exists('__')) { + function __($key = null, $replace = [], $locale = null) + { + return $key; + } +} + +class UserControllerSmsServiceFake +{ + public array $sent = []; + + public function __construct(private array $result = ['success' => true]) + { + } + + public function send(string $to, string $text, array $options = [], ?string $provider = null): array + { + $this->sent[] = [$to, $text]; + + return $this->result; + } +} + +function user_controller_grant_owner_administrator(Capsule $capsule): void +{ + $capsule->getConnection('mysql')->table('model_has_roles')->insert([ + 'role_id' => 'Administrator', 'model_type' => Fleetbase\Models\CompanyUser::class, 'model_uuid' => 'pivot-owner-1', + ]); +} + +test('user controller marks a phone verified and refuses channels that are missing or already verified', function () { + $capsule = user_controller_database(); + $db = $capsule->getConnection('mysql'); + $db->table('users')->where('uuid', 'member-1')->update(['phone' => '+15550009999', 'email_verified_at' => '2026-07-18 10:00:00']); + $db->table('users')->where('uuid', 'owner-1')->update(['phone' => null, 'phone_verified_at' => null]); + + user_controller_request('PATCH', ['channel' => 'phone'], user_controller_user('owner-1'), 'verify'); + $phone = user_controller()->verify('member-1'); + user_controller_request('PATCH', ['channel' => 'email'], user_controller_user('owner-1'), 'verify'); + $alreadyVerified = user_controller()->verify('member-1'); + user_controller_request('PATCH', ['channel' => 'phone'], user_controller_user('owner-1'), 'verify'); + $noPhone = user_controller()->verify('owner-1'); + + expect($phone->getStatusCode())->toBe(200) + ->and($phone->getData(true)['channel'])->toBe('phone') + ->and($db->table('users')->where('uuid', 'member-1')->value('phone_verified_at'))->not->toBeNull() + ->and($alreadyVerified->getStatusCode())->toBe(422) + ->and($alreadyVerified->getData(true))->toBe(['errors' => ["This user's email address is already verified."]]) + ->and($noPhone->getStatusCode())->toBe(422) + ->and($noPhone->getData(true))->toBe(['errors' => ['This user has no phone number to verify.']]); +}); + +test('user controller sends email and phone verification requests with a one-click link', function () { + $capsule = user_controller_database(); + $db = $capsule->getConnection('mysql'); + EloquentModel::setEventDispatcher(new Dispatcher(app())); + user_controller_grant_owner_administrator($capsule); + $db->table('users')->where('uuid', 'member-1')->update(['phone' => '+15550009999']); + app()->instance(Fleetbase\Services\SmsService::class, $sms = new UserControllerSmsServiceFake()); + + $email = user_controller()->sendVerification(user_controller_request('POST', ['channel' => 'email'], user_controller_user('owner-1'), 'sendVerification'), 'member-1'); + $emailAgain = user_controller()->sendVerification(user_controller_request('POST', ['channel' => 'email'], user_controller_user('owner-1'), 'sendVerification'), 'member-1'); + $phone = user_controller()->sendVerification(user_controller_request('POST', ['channel' => 'phone'], user_controller_user('owner-1'), 'sendVerification'), 'member-1'); + $notifier = app(Illuminate\Contracts\Notifications\Dispatcher::class); + $codes = $db->table('verification_codes')->where('subject_uuid', 'member-1')->whereNull('deleted_at')->get()->keyBy('for'); + + expect($email->getData(true))->toBe(['status' => 'ok', 'channel' => 'email']) + ->and($emailAgain->getStatusCode())->toBe(200) + ->and($phone->getData(true))->toBe(['status' => 'ok', 'channel' => 'phone']) + // Sending again replaces the earlier request + ->and($db->table('verification_codes')->where('subject_uuid', 'member-1')->where('for', 'email_verification')->whereNull('deleted_at')->count())->toBe(1) + ->and(json_decode($codes['email_verification']->meta, true))->toMatchArray(['source' => 'admin_request', 'channel' => 'email', 'value' => 'member@example.test', 'requested_by_uuid' => 'owner-1']) + ->and($notifier->sent)->toHaveCount(2) + ->and($notifier->sent[1][1])->toBeInstanceOf(Fleetbase\Notifications\UserContactVerificationRequested::class) + ->and($notifier->sent[1][1]->url)->toContain('auth/verify-contact/' . $codes['email_verification']->uuid) + ->and($notifier->sent[1][1]->toMail(User::find('member-1'))->actionUrl)->toBe($notifier->sent[1][1]->url) + ->and($notifier->sent[1][1]->toArray(User::find('member-1')))->toEqual(['code' => $codes['email_verification']->code]) + ->and($notifier->sent[1][1]->via(User::find('member-1')))->toBe(['mail']) + ->and($sms->sent[0][0])->toBe('+15550009999') + ->and($sms->sent[0][1])->toContain('auth/verify-contact/' . $codes['phone_verification']->uuid); +}); + +test('user controller verification requests are authorized scoped and report delivery failures', function () { + $capsule = user_controller_database(); + $db = $capsule->getConnection('mysql'); + EloquentModel::setEventDispatcher(new Dispatcher(app())); + user_controller_grant_owner_administrator($capsule); + $db->table('users')->where('uuid', 'member-1')->update(['phone' => '+15550009999']); + app()->instance(Fleetbase\Services\SmsService::class, new UserControllerSmsServiceFake(['success' => false, 'error' => 'carrier refused'])); + + $unauthorized = user_controller()->sendVerification(user_controller_request('POST', ['channel' => 'email'], user_controller_user('member-1'), 'sendVerification'), 'owner-1'); + $missing = user_controller()->sendVerification(user_controller_request('POST', ['channel' => 'email'], user_controller_user('owner-1'), 'sendVerification'), 'missing-user'); + $noPhone = user_controller()->sendVerification(user_controller_request('POST', ['channel' => 'phone'], user_controller_user('owner-1'), 'sendVerification'), 'owner-1'); + $smsFailure = user_controller()->sendVerification(user_controller_request('POST', ['channel' => 'phone'], user_controller_user('owner-1'), 'sendVerification'), 'member-1'); + + $canVerify = new ReflectionMethod(UserController::class, 'canVerifyUsers'); + + expect($unauthorized->getStatusCode())->toBe(403) + ->and($canVerify->invoke(user_controller(), null))->toBeFalse() + ->and($missing->getStatusCode())->toBe(404) + ->and($noPhone->getStatusCode())->toBe(422) + ->and($smsFailure->getStatusCode())->toBe(400) + ->and($smsFailure->getData(true))->toBe(['errors' => ['Unable to send the verification request: carrier refused']]) + ->and($db->table('verification_codes')->where('subject_uuid', 'member-1')->whereNull('deleted_at')->count())->toBe(0); +}); From 41a5ffeefd4a91cc05e1de32443b3525cdbf02aa Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 22 Sep 2026 13:01:16 +0800 Subject: [PATCH 18/30] Note contact verification requests in the v1.6.63 release notes --- RELEASE.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/RELEASE.md b/RELEASE.md index 99046577..9e64ff1d 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -5,6 +5,10 @@ - Treat `driver`, `customer` and `contact` users as managed accounts: the FleetOps profile owns them, not IAM. `User` gains `MANAGED_TYPES`, `isManagedAccount()`, `isStaffAccount()`, `canAccessConsole()`, `canHoldConsoleSession()` and a `managed()` scope. - Promote instead of duplicating. When IAM creates or invites a team member whose email or phone belongs to a managed account in the organization, that account becomes a `user`. It gets the chosen role, permissions and policies and a join invite, and keeps its driver and customer profiles. The response carries `promoted_from`. Accepting any IAM invite also promotes a managed account and asks it to set a console password. +## Improvements for IAM + +- Let IAM admins ask a user to verify their email or phone. `POST users/{id}/send-verification` sends a one-click link by email or SMS; it lasts 48 hours. The public `auth/confirm-contact-verification` confirms it without signing in, and refuses the link if the address changed since. `users/verify/{id}` takes a `channel` (email by default, or phone). `UserFilter` adds `email_verified`, `phone_verified`, `country` and `timezone` for the new IAM columns. + ## Fixes - Keep managed accounts out of the console: @@ -31,4 +35,4 @@ A database migration is not required. No configuration change is needed. The FleetOps side ships in fleetbase/fleetops#338. -Changes: [#264](https://github.com/fleetbase/core-api/pull/264), [#266](https://github.com/fleetbase/core-api/pull/266). +Changes: [#264](https://github.com/fleetbase/core-api/pull/264), [#266](https://github.com/fleetbase/core-api/pull/266), [#267](https://github.com/fleetbase/core-api/pull/267). From 820cfea84a108370824c563259483cbc7cbf0004 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 22 Sep 2026 13:52:01 +0800 Subject: [PATCH 19/30] feat(oauth): support provider buttons on the sign-up page - The public provider list now includes `allow_registration`, so the sign-up page can leave out its provider buttons when sign-ups are closed. Before, someone found out only after the provider round trip. - A sign-up that turns out to be an existing account (already linked, or linked automatically) signs the person in and adds `existing_account: true`, so the console can say so instead of silently landing them in the console. Ordinary sign-ins don't get the flag. authenticate() now takes the notices for the console (`linked`, `existing_account`) as one array, replacing the auto-link boolean. --- .../Internal/v1/OAuthController.php | 35 +++++++++++----- tests/Unit/Http/OAuthControllerTest.php | 40 +++++++++++++++++++ 2 files changed, 65 insertions(+), 10 deletions(-) diff --git a/src/Http/Controllers/Internal/v1/OAuthController.php b/src/Http/Controllers/Internal/v1/OAuthController.php index 09d1db76..29fbfb99 100644 --- a/src/Http/Controllers/Internal/v1/OAuthController.php +++ b/src/Http/Controllers/Internal/v1/OAuthController.php @@ -53,11 +53,17 @@ public function __construct( * Providers the console should render sign-in buttons for. * * Unauthenticated by design — the console needs this before a session exists. - * Returns only {id, label, icon}: no client ids, no secrets, no redirect URIs. + * Returns only {id, label, icon} per provider and whether sign-ups are open: no client + * ids, no secrets, no redirect URIs. */ public function providers() { - return response()->json(['providers' => $this->registry->toDiscoveryArray()]); + return response()->json([ + 'providers' => $this->registry->toDiscoveryArray(), + // So the sign-up page can leave its provider buttons out when sign-ups are + // closed, instead of letting someone find out after the round trip. + 'allow_registration' => $this->config->allowsRegistration(), + ]); } /** @@ -140,10 +146,14 @@ public function exchange(OAuthExchangeRequest $request) return response()->error('That sign-in provider is not available.', 403, ['code' => 'provider_disabled']); } + // Someone who pressed a provider button on the SIGN-UP page but already has an + // account is signed in, not signed up; the console tells them which happened. + $existingAccount = ($consumed['payload']['intent'] ?? null) === OAuthFlowService::INTENT_SIGNUP ? ['existing_account' => true] : []; + $user = $this->identities->findUserByProfile($profile); if ($user instanceof User) { - return $this->authenticate($user, $profile, $state); + return $this->authenticate($user, $profile, $state, $existingAccount); } $match = $this->autoLinkCandidate($profile); @@ -158,7 +168,10 @@ public function exchange(OAuthExchangeRequest $request) // Linking does not skip anything authenticate() enforces — two-factor // included. The link alone signs no one in. - return $this->authenticate($match, $profile, $state, true); + return $this->authenticate($match, $profile, $state, [ + 'linked' => $profile->provider, + 'linked_label' => OAuth::providerLabel($profile->provider), + ] + $existingAccount); } return $this->registrationOutcome($profile); @@ -330,11 +343,13 @@ protected function identitiesPayload(User $user): array /** * An identity we already know: run the same gates password login runs. */ - protected function authenticate(User $user, OAuthUserProfile $profile, OAuthState $state, bool $autoLinked = false) + /** + * @param array $notices what the console should tell the user about how + * they got here (`linked`, `existing_account`), added + * to whichever response sign-in ends with + */ + protected function authenticate(User $user, OAuthUserProfile $profile, OAuthState $state, array $notices = []) { - // Tells the console to say the provider is now linked, whichever way sign-in ends. - $linked = $autoLinked ? ['linked' => $profile->provider, 'linked_label' => OAuth::providerLabel($profile->provider)] : []; - // AuthController::login:86-88 if ($user->type === 'customer') { return response()->error('Customer accounts must sign in through the customer portal.', 403, ['code' => 'customer_login_not_allowed']); @@ -345,7 +360,7 @@ protected function authenticate(User $user, OAuthUserProfile $profile, OAuthStat return response()->json([ 'twoFaSession' => TwoFactorAuth::start($user), 'isEnabled' => true, - ] + $linked); + ] + $notices); } // AuthController::login:114-116. An OAuth sign-in does not by itself verify a @@ -366,7 +381,7 @@ protected function authenticate(User $user, OAuthUserProfile $profile, OAuthStat $user->updateLastLogin(); $token = $user->createToken($user->uuid); - return response()->json(['token' => $token->plainTextToken, 'type' => $user->getType()] + $linked); + return response()->json(['token' => $token->plainTextToken, 'type' => $user->getType()] + $notices); } /** diff --git a/tests/Unit/Http/OAuthControllerTest.php b/tests/Unit/Http/OAuthControllerTest.php index 472921fc..2c5685c7 100644 --- a/tests/Unit/Http/OAuthControllerTest.php +++ b/tests/Unit/Http/OAuthControllerTest.php @@ -553,6 +553,13 @@ function oauth_fragment(string $url): array expect($controller->providers()->getData(true)['providers'])->toBe([]); }); +it('says whether sign-ups are open, so the sign-up page can leave its buttons out', function (bool $open) { + oauth_controller_database(['oauth.allow_registration' => $open]); + [$controller] = oauth_controller_services(); + + expect($controller->providers()->getData(true)['allow_registration'])->toBe($open); +})->with(['open' => true, 'closed' => false]); + // --------------------------------------------------------------------------- // Redirect // --------------------------------------------------------------------------- @@ -991,6 +998,39 @@ function oauth_controller_handoff(OAuthController $controller, array $redirectQu ->and($events[0]->method)->toBe(Fleetbase\Events\OAuthIdentityLinked::METHOD_AUTOMATIC); })->with(['user', 'admin']); +it('tells someone who pressed sign up that they already had an account', function () { + oauth_controller_database(); + [$controller] = oauth_controller_services(); + oauth_controller_link(oauth_controller_user()); + + $data = $controller->exchange(oauth_exchange_request(['code' => oauth_controller_handoff($controller, ['intent' => 'signup'])]))->getData(true); + + expect($data['token'] ?? null)->toBeString() + ->and($data['existing_account'])->toBeTrue(); +}); + +it('says nothing about an existing account on an ordinary sign-in', function () { + oauth_controller_database(); + [$controller] = oauth_controller_services(); + oauth_controller_link(oauth_controller_user()); + + $data = $controller->exchange(oauth_exchange_request(['code' => oauth_controller_handoff($controller)]))->getData(true); + + expect($data['token'] ?? null)->toBeString() + ->and($data)->not->toHaveKey('existing_account'); +}); + +it('reports both the automatic link and the existing account on a sign-up', function () { + oauth_controller_database(); + [$controller] = oauth_controller_services(); + oauth_controller_user(['uuid' => 'user-1', 'email' => 'ada@example.com']); + + $data = $controller->exchange(oauth_exchange_request(['code' => oauth_controller_handoff($controller, ['intent' => 'signup'])]))->getData(true); + + expect($data['linked'])->toBe('fakeprovider') + ->and($data['existing_account'])->toBeTrue(); +}); + it('still asks for two-factor after linking automatically', function () { $capsule = oauth_controller_database(); [$controller] = oauth_controller_services(); From 551bd53f75270689fb55a45862386d432c8f60fd Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 22 Sep 2026 14:45:48 +0800 Subject: [PATCH 20/30] fix(oauth): skip email verification for a provider-verified sign-up A sign-up from a provider that vouched for the email already marks the account verified, and AccountCreated sends no code for a verified account. But the response only set skipVerification (and a token) for the first administrator. So the console went to the verification step and waited for an email that never came. skipVerification now also covers an already-verified address, and the console signs the person straight in. An address the provider did not verify, or a different one, still goes through verification. --- .../Internal/v1/OnboardController.php | 10 ++++- tests/Unit/Http/OnboardControllerTest.php | 39 +++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/Http/Controllers/Internal/v1/OnboardController.php b/src/Http/Controllers/Internal/v1/OnboardController.php index bc0750e7..baa6b4b7 100644 --- a/src/Http/Controllers/Internal/v1/OnboardController.php +++ b/src/Http/Controllers/Internal/v1/OnboardController.php @@ -99,11 +99,17 @@ public function createAccount(OnboardRequest $request) // create auth token $token = $user->createToken($user->uuid); + // Nothing to verify when the provider already vouched for this exact address: + // redeemRegistrationIntent() marked it verified above, and AccountCreated sends + // no code for a verified account. Sending the console to the verification step + // anyway would leave the user waiting for an email that never comes. + $skipVerification = $isAdmin || !empty($user->email_verified_at); + return response()->json([ 'status' => 'success', 'session' => base64_encode($user->uuid), - 'token' => $isAdmin ? $token->plainTextToken : null, - 'skipVerification' => $isAdmin, + 'token' => $skipVerification ? $token->plainTextToken : null, + 'skipVerification' => $skipVerification, ]); } diff --git a/tests/Unit/Http/OnboardControllerTest.php b/tests/Unit/Http/OnboardControllerTest.php index b2fceef8..f0940f38 100644 --- a/tests/Unit/Http/OnboardControllerTest.php +++ b/tests/Unit/Http/OnboardControllerTest.php @@ -744,6 +744,45 @@ function onboard_controller_intent(array $overrides = []): string ->and(Fleetbase\Support\OAuth::isValidRegistrationIntent($intent))->toBeFalse(); }); +test('onboard controller skips verification for an address the provider verified', function () { + $capsule = onboard_controller_database(); + onboard_controller_oauth_setup($capsule); + onboard_controller_seed_user($capsule); + + $payload = onboard_controller()->createAccount(onboard_create_account_request([ + 'name' => 'Katherine Johnson', + 'email' => 'katherine@example.test', + 'organization_name' => 'Orbital Logistics', + 'password' => null, + 'oauth_intent' => onboard_controller_intent(), + ]))->getData(true); + + // Not the first account, so not an admin: verification is skipped only because the + // provider vouched for the address, and no code was sent to be entered. + expect($payload['skipVerification'])->toBeTrue() + ->and($payload['token'])->toBeString()->not->toBeEmpty(); +}); + +test('onboard controller still asks to verify an address the provider did not', function (array $intent) { + $capsule = onboard_controller_database(); + onboard_controller_oauth_setup($capsule); + onboard_controller_seed_user($capsule); + + $payload = onboard_controller()->createAccount(onboard_create_account_request([ + 'name' => 'Katherine Johnson', + 'email' => 'katherine@example.test', + 'organization_name' => 'Orbital Logistics', + 'password' => 'Password123!', + 'oauth_intent' => onboard_controller_intent($intent), + ]))->getData(true); + + expect($payload['skipVerification'])->toBeFalse() + ->and($payload['token'])->toBeNull(); +})->with([ + 'unverified at the provider' => [['emailVerified' => false]], + 'a different address' => [['email' => 'someone-else@example.test']], +]); + test('onboard controller still sets a password when one is supplied alongside an intent', function () { $capsule = onboard_controller_database(); onboard_controller_oauth_setup($capsule); From 8d17c3432582371789fc2434088472b9b6ca75b8 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 22 Sep 2026 17:08:55 +0800 Subject: [PATCH 21/30] fix(oauth): accept provider signing keys that don't name an algorithm Microsoft sign-in failed with `jwks_unreadable`. Microsoft's JWKS (`/discovery/v2.0/keys`) lists its RSA keys without an `alg` field, and firebase/php-jwt's JWK::parseKeySet() rejects the whole document when a key has no `alg` and no default is given. Google and Apple include `alg`, so they worked. The key set is now parsed with RS256 as the default for keys that don't name one. That changes nothing about what is accepted: the signature is always checked with RSA-SHA256, whatever the token or key says. Adds IdTokenVerifierTest, with the key set preloaded into the cache so it makes no network call: - a key without `alg` (Microsoft's form) verifies; - a key with `alg` (Google's and Apple's) verifies; - a token signed by a different key under a published kid is rejected; - an unknown kid is rejected. The first and last fail without the fix. --- src/Auth/OAuth/IdTokenVerifier.php | 6 +- tests/Unit/Auth/OAuth/IdTokenVerifierTest.php | 103 ++++++++++++++++++ 2 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 tests/Unit/Auth/OAuth/IdTokenVerifierTest.php diff --git a/src/Auth/OAuth/IdTokenVerifier.php b/src/Auth/OAuth/IdTokenVerifier.php index 014b3ecb..5d940608 100644 --- a/src/Auth/OAuth/IdTokenVerifier.php +++ b/src/Auth/OAuth/IdTokenVerifier.php @@ -127,7 +127,11 @@ protected function resolvePublicKey(string $jwksUrl, string $cacheKey, int $jwks $jwks = $this->fetchJwks($jwksUrl, $cacheKey, $jwksTtl); try { - $keys = JWK::parseKeySet($jwks); + // RS256 is only the fallback for keys that don't name an algorithm, and + // Microsoft's JWKS names none: without a default, php-jwt rejects the whole + // document. It loosens nothing: the signature is checked with RSA-SHA256 + // below whatever the token or key claims. + $keys = JWK::parseKeySet($jwks, 'RS256'); } catch (\Throwable $e) { throw new OAuthIdTokenException('jwks_unreadable'); } diff --git a/tests/Unit/Auth/OAuth/IdTokenVerifierTest.php b/tests/Unit/Auth/OAuth/IdTokenVerifierTest.php new file mode 100644 index 00000000..dbc840cd --- /dev/null +++ b/tests/Unit/Auth/OAuth/IdTokenVerifierTest.php @@ -0,0 +1,103 @@ +} + */ +function id_token_key(string $kid, bool $withAlg): array +{ + $resource = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]); + openssl_pkey_export($resource, $privatePem); + $details = openssl_pkey_get_details($resource); + + $b64 = fn (string $bytes): string => rtrim(strtr(base64_encode($bytes), '+/', '-_'), '='); + + $jwk = ['kty' => 'RSA', 'use' => 'sig', 'kid' => $kid, 'n' => $b64($details['rsa']['n']), 'e' => $b64($details['rsa']['e'])]; + + if ($withAlg) { + $jwk['alg'] = 'RS256'; + } + + return [(string) $privatePem, $jwk]; +} + +/** + * The JWKS is placed in the cache the verifier reads first, so no request is made. + * + * @param array> $keys + */ +function id_token_verifier_with(array $keys): IdTokenVerifier +{ + bind_test_container(); + + $cache = new CacheRepository(new ArrayStore()); + app()->instance('cache', $cache); + Facade::clearResolvedInstance('cache'); + Cache::swap($cache); + Cache::put(ID_TOKEN_CACHE_KEY, ['keys' => $keys], 300); + + return new IdTokenVerifier(); +} + +function id_token_signed(string $privatePem, string $kid, array $claims = []): string +{ + return JWT::encode(array_merge([ + 'iss' => ID_TOKEN_ISSUER, + 'aud' => ID_TOKEN_AUDIENCE, + 'sub' => 'subject-1', + 'iat' => time(), + 'nbf' => time(), + 'exp' => time() + 300, + ], $claims), $privatePem, 'RS256', $kid); +} + +function id_token_verify(IdTokenVerifier $verifier, string $jwt): array +{ + return $verifier->verify($jwt, ID_TOKEN_JWKS_URL, ID_TOKEN_AUDIENCE, fn (string $issuer): bool => $issuer === ID_TOKEN_ISSUER, ID_TOKEN_CACHE_KEY); +} + +it('verifies a token signed by a key published without an alg, as Microsoft does', function () { + [$private, $jwk] = id_token_key('ms-key-1', false); + + $claims = id_token_verify(id_token_verifier_with([$jwk]), id_token_signed($private, 'ms-key-1')); + + expect($claims['sub'])->toBe('subject-1'); +}); + +it('verifies a token signed by a key that names its alg, as Google and Apple do', function () { + [$private, $jwk] = id_token_key('g-key-1', true); + + expect(id_token_verify(id_token_verifier_with([$jwk]), id_token_signed($private, 'g-key-1'))['sub'])->toBe('subject-1'); +}); + +it('still rejects a token signed by a different key under a published kid', function () { + [, $jwk] = id_token_key('ms-key-1', false); + [$otherPrivate] = id_token_key('ms-key-1', false); + + // The missing alg defaults to RS256 for reading the key; it does not relax the + // signature check. + expect(fn () => id_token_verify(id_token_verifier_with([$jwk]), id_token_signed($otherPrivate, 'ms-key-1'))) + ->toThrow(OAuthIdTokenException::class, 'id_token_invalid'); +}); + +it('reports an unknown key id rather than trusting it', function () { + [$private, $jwk] = id_token_key('ms-key-1', false); + + expect(fn () => id_token_verify(id_token_verifier_with([$jwk]), id_token_signed($private, 'rotated-key'))) + ->toThrow(OAuthIdTokenException::class, 'id_token_unknown_key'); +}); From 0221c87971c6d4dc874808eea16be88e7bd242fc Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 22 Sep 2026 17:19:27 +0800 Subject: [PATCH 22/30] fix(oauth): ask to sign in and link when a provider email is already taken A Microsoft sign-in whose email Microsoft didn't verify (tenant `common`, no `xms_edov` claim) matched an existing admin account but was sent to the sign-up form with that email locked. That's a dead end: the sign-up would be refused as a duplicate. The "an account with this email already exists; sign in and link this provider" answer (link_required) now applies to any provider email that matches an account, verified or not. An unverified match still never signs anyone in or links anything; automatic linking still needs a verified address. It reveals nothing new either, since the sign-up form already reports an email that's in use. --- .../Internal/v1/OAuthController.php | 14 ++++++---- tests/Unit/Http/OAuthControllerTest.php | 27 +++++++++++++++---- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/src/Http/Controllers/Internal/v1/OAuthController.php b/src/Http/Controllers/Internal/v1/OAuthController.php index 29fbfb99..b2946787 100644 --- a/src/Http/Controllers/Internal/v1/OAuthController.php +++ b/src/Http/Controllers/Internal/v1/OAuthController.php @@ -465,18 +465,22 @@ protected function autoLinkCandidate(OAuthUserProfile $profile): ?User } /** - * Whether this identity's address already belongs to a Fleetbase account. + * Whether this identity's address already belongs to a Fleetbase account, so the + * person should sign in and link the provider instead of signing up. * - * Only asked when the provider VOUCHED for the address. An unverified address is - * not evidence of anything, and letting it produce `link_required` would leak - * whether an arbitrary email has a Fleetbase account. + * Asked whether or not the provider verified the address. A match is never used to + * sign anyone in or link anything here (that needs a verified address, see + * autoLinkCandidate()); it only decides between "sign in and link it" and a sign-up + * form. Offering the form for an address that is already taken leads nowhere: the + * sign-up would be refused as a duplicate. It reveals nothing new either, since the + * sign-up form reports an address that is already in use. * * Apple private-relay aliases are excluded: they are unique per application, so * one can never match an address Fleetbase already holds. */ protected function collidesWithExistingAccount(OAuthUserProfile $profile): bool { - if (!$profile->hasVerifiedEmail() || $profile->meta('private_relay') === true) { + if (!is_string($profile->email) || $profile->email === '' || $profile->meta('private_relay') === true) { return false; } diff --git a/tests/Unit/Http/OAuthControllerTest.php b/tests/Unit/Http/OAuthControllerTest.php index 2c5685c7..53e9f31b 100644 --- a/tests/Unit/Http/OAuthControllerTest.php +++ b/tests/Unit/Http/OAuthControllerTest.php @@ -1103,20 +1103,37 @@ function oauth_controller_handoff(OAuthController $controller, array $redirectQu ->and(OAuthIdentity::query()->count())->toBe(0); }); -it('does not report link_required for an address the provider did not verify', function () { +it('asks to sign in and link for an unverified address that already has an account', function () { oauth_controller_database(); [$controller] = oauth_controller_services(); oauth_controller_user(['uuid' => 'user-1', 'email' => 'ada@example.com']); OAuthControllerFakeDriver::$behaviour = [ - 'profile' => new OAuthUserProfile('fakeprovider', 'subject-9', 'ada@example.com', false, 'Mallory'), + 'profile' => new OAuthUserProfile('fakeprovider', 'subject-9', 'ada@example.com', false, 'Ada'), ]; $response = $controller->exchange(oauth_exchange_request(['code' => oauth_controller_handoff($controller)])); - // Otherwise anyone could probe whether an arbitrary address has a Fleetbase - // account simply by asserting it at a provider that does not verify addresses. - expect($response->getData(true)['status'] ?? null)->toBe('registration_required'); + // Not a sign-up form for an address that is already taken — that would be refused + // as a duplicate. And, unverified, never a sign-in or a link. + expect($response->getStatusCode())->toBe(409) + ->and($response->getData(true)['code'])->toBe('link_required') + ->and(OAuthIdentity::query()->count())->toBe(0) + ->and(OAuthState::query()->where('purpose', OAuthState::PURPOSE_REGISTRATION_INTENT)->count())->toBe(0); +}); + +it('offers a sign-up for an unverified address no account uses', function () { + oauth_controller_database(); + [$controller] = oauth_controller_services(); + + OAuthControllerFakeDriver::$behaviour = [ + 'profile' => new OAuthUserProfile('fakeprovider', 'subject-9', 'new@example.com', false, 'New Person'), + ]; + + $data = $controller->exchange(oauth_exchange_request(['code' => oauth_controller_handoff($controller)]))->getData(true); + + expect($data['status'] ?? null)->toBe('registration_required') + ->and($data['prefill']['email_verified'])->toBeFalse(); }); it('does not match an apple private relay alias against an existing account', function () { From 41297af138dfc838679d52c4579854e5fb957c9e Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 22 Sep 2026 17:29:59 +0800 Subject: [PATCH 23/30] test(onboard): an OAuth sign-up can't take an email that's already in use Runs the real OnboardRequest email rule against the test database with an OAuth registration intent present. The provider identity replaces the password only, and never loosens email uniqueness. The same address is accepted once its account is soft-deleted. --- tests/Unit/Http/OnboardControllerTest.php | 36 +++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/Unit/Http/OnboardControllerTest.php b/tests/Unit/Http/OnboardControllerTest.php index f0940f38..ed1cac69 100644 --- a/tests/Unit/Http/OnboardControllerTest.php +++ b/tests/Unit/Http/OnboardControllerTest.php @@ -860,3 +860,39 @@ function onboard_controller_intent(array $overrides = []): string // A password without its confirmation is still rejected. ->and($factory->make(['password' => 'pw'], $rules)->fails())->toBeTrue(); }); + +test('an oauth signup cannot create a second account with an email that is already taken', function () { + $capsule = onboard_controller_database(); + onboard_controller_oauth_setup($capsule); + onboard_controller_seed_user($capsule, ['email' => 'taken@example.test']); + + // EmailDomainExcluded reads its disposable-domain list through base_path(), which the + // unit container does not define; point it at a copy laid out the way an install is. + $base = sys_get_temp_dir() . '/onboard-base-' . getmypid(); + @mkdir($base . '/vendor/fleetbase/core-api/storage', 0777, true); + copy(__DIR__ . '/../../../storage/disposable_emails.txt', $base . '/vendor/fleetbase/core-api/storage/disposable_emails.txt'); + $GLOBALS['onboard_test_base_path'] = $base; + if (!function_exists('Fleetbase\\Rules\\base_path')) { + eval('namespace Fleetbase\\Rules; function base_path($path = "") { return $GLOBALS["onboard_test_base_path"] . "/" . ltrim($path, "/"); }'); + } + + $translator = new Illuminate\Translation\Translator(new Illuminate\Translation\ArrayLoader(), 'en'); + $factory = new Illuminate\Validation\Factory($translator); + $factory->setPresenceVerifier(new Illuminate\Validation\DatabasePresenceVerifier(app('db'))); + + // The real email rule, exactly as a signup meets it. A provider identity stands in + // for the password only; it never relaxes the email rule. + $rules = ['email' => (new OnboardRequest())->rules()['email']]; + $data = ['email' => 'taken@example.test', 'oauth_intent' => onboard_controller_intent(['email' => 'taken@example.test'])]; + + $validator = $factory->make($data, $rules, (new OnboardRequest())->messages()); + + expect($validator->fails())->toBeTrue() + ->and($validator->errors()->first('email'))->toBe('An account with this email address already exists') + // The same address is free once its account is deleted. + ->and((function () use ($capsule, $factory, $data, $rules) { + $capsule->getConnection('mysql')->table('users')->where('email', 'taken@example.test')->update(['deleted_at' => '2026-07-18 00:00:00']); + + return $factory->make($data, $rules)->fails(); + })())->toBeFalse(); +}); From d110f769c6295c157d7710520042ffb75315b541 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 22 Sep 2026 17:31:06 +0800 Subject: [PATCH 24/30] fix(oauth): read Microsoft's xms_edov however it's serialised, and log misses A Microsoft sign-in from a verified domain (tenant `common`, xms_edov configured as an optional ID-token claim) came back with the email not verified. The driver required the claim to be exactly the JSON boolean `true`. - `xms_edov` now also counts as true when it's "true" or "1" (any case) or the integer 1. The token is signed by Microsoft, so this trusts nothing Microsoft didn't assert. false, "false", "0" and "" still don't count. - When a multi-tenant sign-in isn't treated as verified, the reason is logged: whether xms_edov was absent, or its value, and the tenant mode. The token and the address are never logged. That tells an operator whether the optional claim reached Fleetbase at all. --- src/Auth/OAuth/Drivers/MicrosoftDriver.php | 22 ++++++++++++++++++- .../ProviderProfileNormalizationTest.php | 21 ++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/Auth/OAuth/Drivers/MicrosoftDriver.php b/src/Auth/OAuth/Drivers/MicrosoftDriver.php index d5d392ea..4905e322 100644 --- a/src/Auth/OAuth/Drivers/MicrosoftDriver.php +++ b/src/Auth/OAuth/Drivers/MicrosoftDriver.php @@ -5,6 +5,7 @@ use Fleetbase\Auth\OAuth\AbstractOAuthProviderDriver; use Fleetbase\Auth\OAuth\OAuthUserProfile; use Fleetbase\Auth\OAuth\Socialite\MicrosoftProvider; +use Illuminate\Support\Facades\Log; use Laravel\Socialite\Two\AbstractProvider as SocialiteProvider; use Laravel\Socialite\Two\User as SocialiteUser; @@ -119,13 +120,22 @@ protected function normalize(SocialiteUser $user, array $callbackPayload): OAuth */ protected function emailIsVerified(SocialiteUser $user): bool { - if ($this->rawClaim($user, 'xms_edov') === true) { + $edov = $this->rawClaim($user, 'xms_edov'); + + if (self::claimIsTrue($edov)) { return true; } $configuredTenant = $this->tenant(); if (in_array($configuredTenant, self::MULTI_TENANT_ALIASES, true)) { + // Say why, without the token or the address: an operator who configured the + // optional claim needs to know whether it arrived at all. + Log::info('[OAuth] Microsoft email not treated as verified.', [ + 'tenant' => $configuredTenant, + 'xms_edov' => $edov === null ? 'absent' : (is_scalar($edov) ? var_export($edov, true) : gettype($edov)), + ]); + return false; } @@ -136,6 +146,16 @@ protected function emailIsVerified(SocialiteUser $user): bool && strcasecmp($tokenTenant, $configuredTenant) === 0; } + /** + * Whether a boolean claim is set. Microsoft documents `xms_edov` as a boolean but + * does not always serialise it as one; the token is signed, so reading "true" or 1 + * as true trusts nothing Microsoft did not assert. + */ + private static function claimIsTrue(mixed $value): bool + { + return $value === true || $value === 1 || (is_string($value) && in_array(strtolower($value), ['true', '1'], true)); + } + protected function tenant(): string { $tenant = $this->config->get('tenant', 'common'); diff --git a/tests/Unit/Auth/OAuth/ProviderProfileNormalizationTest.php b/tests/Unit/Auth/OAuth/ProviderProfileNormalizationTest.php index 20067a4d..06e21f51 100644 --- a/tests/Unit/Auth/OAuth/ProviderProfileNormalizationTest.php +++ b/tests/Unit/Auth/OAuth/ProviderProfileNormalizationTest.php @@ -127,6 +127,27 @@ function normalization_run(object $driver, SocialiteUser $user, array $callbackP ->and($profile->meta['tid'])->toBe('some-work-tenant'); }); +it('reads the domain owner claim however microsoft serialises it', function (mixed $edov, bool $trusted) { + $driver = normalization_driver(MicrosoftDriver::class, ['tenant' => 'common']); + + $profile = normalization_run($driver, normalization_user( + ['oid' => 'oid-1', 'tid' => 'some-work-tenant', 'xms_edov' => $edov, 'email' => 'ada@corp.example'], + ['id' => 'oid-1', 'email' => 'ada@corp.example'] + )); + + expect($profile->emailVerified)->toBe($trusted); +})->with([ + 'boolean true' => [true, true], + 'string true' => ['true', true], + 'string TRUE' => ['TRUE', true], + 'string one' => ['1', true], + 'integer one' => [1, true], + 'boolean false' => [false, false], + 'string false' => ['false', false], + 'string zero' => ['0', false], + 'empty string' => ['', false], +]); + it('does not trust a multi tenant microsoft address without the domain owner claim', function () { $driver = normalization_driver(MicrosoftDriver::class, ['tenant' => 'common']); From 5fe4dcb57af43d7471b1c1905b44ab1c491b5a8f Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 22 Sep 2026 18:08:56 +0800 Subject: [PATCH 25/30] fix(iam): leave profile-managed accounts out of the IAM metrics Driver, customer and contact accounts are managed from their FleetOps profile, not IAM, but every IAM dashboard metric counted them, so a tenant with two real users showed 21 active and 43 dormant. The metrics now count only IAM accounts: active, pending, inactive, dormant and verified users, MFA coverage, access coverage, privileged grants, group memberships, the lifecycle and user-type charts, and the legacy users_count. A user with no type still counts; NOT IN alone would have dropped them. --- .../Internal/v1/IamMetricsController.php | 28 ++++++++++++------- .../Internal/v1/MetricController.php | 2 +- src/Models/User.php | 19 +++++++++++++ tests/Unit/Http/IamMetricsControllerTest.php | 17 +++++++++-- 4 files changed, 52 insertions(+), 14 deletions(-) diff --git a/src/Http/Controllers/Internal/v1/IamMetricsController.php b/src/Http/Controllers/Internal/v1/IamMetricsController.php index 25c48de6..27a75a9d 100644 --- a/src/Http/Controllers/Internal/v1/IamMetricsController.php +++ b/src/Http/Controllers/Internal/v1/IamMetricsController.php @@ -67,7 +67,7 @@ public function identityHealth(Request $request): JsonResponse public function accessCoverage(Request $request): JsonResponse { $companyUuid = session('company'); - $companyUsers = CompanyUser::where('company_uuid', $companyUuid)->whereNull('deleted_at')->get(['uuid', 'user_uuid']); + $companyUsers = $this->companyUsersQuery($companyUuid)->get(['company_users.uuid', 'company_users.user_uuid']); $userUuids = $companyUsers->pluck('user_uuid'); $companyUserUuids = $companyUsers->pluck('uuid'); $roleUserUuids = $this->modelAssignmentUserUuids('model_has_roles', $companyUsers, $companyUserUuids); @@ -156,7 +156,7 @@ public function policySurface(Request $request): JsonResponse public function groupCoverage(Request $request): JsonResponse { $companyUuid = session('company'); - $groups = Group::where('company_uuid', $companyUuid)->withCount('users')->get(['uuid', 'name']); + $groups = Group::where('company_uuid', $companyUuid)->withCount(['users' => fn ($query) => $query->notManaged()])->get(['uuid', 'name']); return response()->json([ 'total_groups' => $groups->count(), @@ -287,13 +287,19 @@ public function activity(Request $request): JsonResponse return response()->json(['items' => $items]); } + /** + * The company's IAM users. Driver, customer and contact accounts are managed + * from their FleetOps profile, so none of the IAM metrics count them. + */ private function companyUsersQuery(string $companyUuid) { - return CompanyUser::query() + $query = CompanyUser::query() ->join('users', 'company_users.user_uuid', '=', 'users.uuid') ->where('company_users.company_uuid', $companyUuid) ->whereNull('company_users.deleted_at') ->whereNull('users.deleted_at'); + + return User::whereNotManagedType($query, 'users.type'); } private function dormantUsersQuery(string $companyUuid) @@ -306,7 +312,7 @@ private function dormantUsersQuery(string $companyUuid) private function companyUserIds(string $companyUuid): Collection { - return CompanyUser::where('company_uuid', $companyUuid)->whereNull('deleted_at')->pluck('uuid'); + return $this->companyUsersQuery($companyUuid)->pluck('company_users.uuid'); } private function modelAssignmentUserUuids(string $table, Collection $companyUsers, Collection $modelUuids): Collection @@ -322,11 +328,14 @@ private function modelAssignmentUserUuids(string $table, Collection $companyUser private function groupMembershipsQuery(string $companyUuid) { - return DB::table('group_users') + $query = DB::table('group_users') ->join('groups', 'group_users.group_uuid', '=', 'groups.uuid') + ->join('users', 'group_users.user_uuid', '=', 'users.uuid') ->where('groups.company_uuid', $companyUuid) ->whereNull('group_users.deleted_at') ->whereNull('groups.deleted_at'); + + return User::whereNotManagedType($query, 'users.type'); } private function directPrivilegedGrantCount(string $companyUuid): int @@ -348,10 +357,9 @@ private function directPrivilegedGrantCount(string $companyUuid): int private function statusCounts(string $companyUuid): array { - $counts = CompanyUser::where('company_uuid', $companyUuid) - ->whereNull('deleted_at') - ->selectRaw('status, COUNT(*) as count') - ->groupBy('status') + $counts = $this->companyUsersQuery($companyUuid) + ->selectRaw('company_users.status as status, COUNT(*) as count') + ->groupBy('company_users.status') ->pluck('count', 'status'); return [ @@ -365,7 +373,7 @@ private function mfaCoverage(string $companyUuid, int $totalUsers): array { $system = Setting::where('key', 'system.2fa')->first(); $company = Setting::where('key', 'company.' . $companyUuid . '.2fa')->first(); - $userUuids = CompanyUser::where('company_uuid', $companyUuid)->whereNull('deleted_at')->pluck('user_uuid'); + $userUuids = $this->companyUsersQuery($companyUuid)->pluck('company_users.user_uuid'); $enabledUsers = Setting::where('key', 'like', 'user.%.2fa') ->get(['key', 'value']) ->filter(function ($setting) use ($userUuids) { diff --git a/src/Http/Controllers/Internal/v1/MetricController.php b/src/Http/Controllers/Internal/v1/MetricController.php index 216eb914..c17ec74c 100644 --- a/src/Http/Controllers/Internal/v1/MetricController.php +++ b/src/Http/Controllers/Internal/v1/MetricController.php @@ -20,7 +20,7 @@ public function iam() $metrics = []; // get number of users $metrics['users_count'] = CompanyUser::where('company_uuid', session('company'))->whereNull('deleted_at')->whereHas('user', function ($query) { - $query->whereNull('deleted_at'); + $query->whereNull('deleted_at')->notManaged(); })->count(); // get number of groups $metrics['groups_count'] = Group::where('company_uuid', session('company'))->count(); diff --git a/src/Models/User.php b/src/Models/User.php index c76d4d8e..672c8d14 100644 --- a/src/Models/User.php +++ b/src/Models/User.php @@ -897,6 +897,25 @@ public function scopeManaged(Builder $query): Builder return $query->whereIn($this->qualifyColumn('type'), static::MANAGED_TYPES); } + /** + * Scope a query to accounts managed in IAM, leaving out profile-managed ones. + */ + public function scopeNotManaged(Builder $query): Builder + { + return static::whereNotManagedType($query, $this->qualifyColumn('type')); + } + + /** + * Constrain a query on any table joined to users to accounts managed in IAM. + * A user with no type is an IAM account, and NOT IN alone would drop it. + */ + public static function whereNotManagedType($query, string $typeColumn = 'users.type') + { + return $query->where(function ($query) use ($typeColumn) { + $query->whereNull($typeColumn)->orWhereNotIn($typeColumn, static::MANAGED_TYPES); + }); + } + /** * Adds a boolean dynamic property to check if user is an admin. * diff --git a/tests/Unit/Http/IamMetricsControllerTest.php b/tests/Unit/Http/IamMetricsControllerTest.php index 27e9f3cf..10fda888 100644 --- a/tests/Unit/Http/IamMetricsControllerTest.php +++ b/tests/Unit/Http/IamMetricsControllerTest.php @@ -202,9 +202,13 @@ function iam_metrics_seed(Capsule $capsule): void $db->table('users')->insert([ ['uuid' => 'user-active', 'public_id' => 'user_active', 'name' => 'Active Dispatcher', 'type' => 'dispatcher', 'email' => 'active@example.test', 'email_verified_at' => '2026-07-01 08:00:00', 'last_login' => '2026-07-17 08:00:00', 'created_at' => '2026-07-12 08:00:00', 'updated_at' => '2026-07-12 08:00:00', 'deleted_at' => null], - ['uuid' => 'user-pending', 'public_id' => 'user_pending', 'name' => 'Pending Driver', 'type' => 'driver', 'email' => 'pending@example.test', 'email_verified_at' => null, 'last_login' => null, 'created_at' => '2026-07-13 08:00:00', 'updated_at' => '2026-07-13 08:00:00', 'deleted_at' => null], + ['uuid' => 'user-pending', 'public_id' => 'user_pending', 'name' => 'Pending Operator', 'type' => 'operator', 'email' => 'pending@example.test', 'email_verified_at' => null, 'last_login' => null, 'created_at' => '2026-07-13 08:00:00', 'updated_at' => '2026-07-13 08:00:00', 'deleted_at' => null], ['uuid' => 'user-inactive', 'public_id' => 'user_inactive', 'name' => 'Inactive Admin', 'type' => 'admin', 'email' => 'inactive@example.test', 'email_verified_at' => '2026-05-01 08:00:00', 'last_login' => '2026-03-01 08:00:00', 'created_at' => '2026-07-14 08:00:00', 'updated_at' => '2026-07-18 08:00:00', 'deleted_at' => null], ['uuid' => 'user-unassigned', 'public_id' => 'user_unassigned', 'name' => 'Unassigned User', 'type' => null, 'email' => 'unassigned@example.test', 'email_verified_at' => '2026-07-15 08:00:00', 'last_login' => '2026-07-16 08:00:00', 'created_at' => '2026-07-15 08:00:00', 'updated_at' => '2026-07-15 08:00:00', 'deleted_at' => null], + // Profile-managed accounts in the tenant: every metric must leave these out. + ['uuid' => 'user-driver', 'public_id' => 'user_driver', 'name' => 'Managed Driver', 'type' => 'driver', 'email' => 'driver@example.test', 'email_verified_at' => null, 'last_login' => null, 'created_at' => '2026-07-16 08:00:00', 'updated_at' => '2026-07-16 08:00:00', 'deleted_at' => null], + ['uuid' => 'user-customer', 'public_id' => 'user_customer', 'name' => 'Managed Customer', 'type' => 'customer', 'email' => 'customer@example.test', 'email_verified_at' => '2026-07-16 08:00:00', 'last_login' => '2026-07-17 08:00:00', 'created_at' => '2026-07-16 08:00:00', 'updated_at' => '2026-07-16 08:00:00', 'deleted_at' => null], + ['uuid' => 'user-contact', 'public_id' => 'user_contact', 'name' => 'Managed Contact', 'type' => 'contact', 'email' => 'contact@example.test', 'email_verified_at' => null, 'last_login' => null, 'created_at' => '2026-07-16 08:00:00', 'updated_at' => '2026-07-18 08:00:00', 'deleted_at' => null], ['uuid' => 'user-other', 'public_id' => 'user_other', 'name' => 'Other Tenant', 'type' => 'driver', 'email' => 'other@example.test', 'email_verified_at' => null, 'last_login' => null, 'created_at' => '2026-07-12 08:00:00', 'updated_at' => '2026-07-12 08:00:00', 'deleted_at' => null], ]); @@ -213,6 +217,9 @@ function iam_metrics_seed(Capsule $capsule): void ['uuid' => 'cu-pending', 'company_uuid' => 'company-1', 'user_uuid' => 'user-pending', 'status' => 'pending', 'external' => 0, 'created_at' => '2026-07-13 08:00:00', 'updated_at' => '2026-07-13 08:00:00', 'deleted_at' => null], ['uuid' => 'cu-inactive', 'company_uuid' => 'company-1', 'user_uuid' => 'user-inactive', 'status' => 'inactive', 'external' => 0, 'created_at' => '2026-07-14 08:00:00', 'updated_at' => '2026-07-18 08:00:00', 'deleted_at' => null], ['uuid' => 'cu-unassigned', 'company_uuid' => 'company-1', 'user_uuid' => 'user-unassigned', 'status' => 'active', 'external' => 0, 'created_at' => '2026-07-15 08:00:00', 'updated_at' => '2026-07-15 08:00:00', 'deleted_at' => null], + ['uuid' => 'cu-driver', 'company_uuid' => 'company-1', 'user_uuid' => 'user-driver', 'status' => 'pending', 'external' => 0, 'created_at' => '2026-07-16 08:00:00', 'updated_at' => '2026-07-16 08:00:00', 'deleted_at' => null], + ['uuid' => 'cu-customer', 'company_uuid' => 'company-1', 'user_uuid' => 'user-customer', 'status' => 'active', 'external' => 0, 'created_at' => '2026-07-16 08:00:00', 'updated_at' => '2026-07-16 08:00:00', 'deleted_at' => null], + ['uuid' => 'cu-contact', 'company_uuid' => 'company-1', 'user_uuid' => 'user-contact', 'status' => 'inactive', 'external' => 0, 'created_at' => '2026-07-16 08:00:00', 'updated_at' => '2026-07-18 08:00:00', 'deleted_at' => null], ['uuid' => 'cu-other', 'company_uuid' => 'company-2', 'user_uuid' => 'user-other', 'status' => 'active', 'external' => 0, 'created_at' => '2026-07-12 08:00:00', 'updated_at' => '2026-07-12 08:00:00', 'deleted_at' => null], ]); @@ -239,6 +246,7 @@ function iam_metrics_seed(Capsule $capsule): void ['role_id' => 'role-admin', 'model_type' => CompanyUser::class, 'model_uuid' => 'cu-active'], ['role_id' => 'role-dispatcher', 'model_type' => CompanyUser::class, 'model_uuid' => 'cu-pending'], ['role_id' => 'role-other-admin', 'model_type' => CompanyUser::class, 'model_uuid' => 'cu-other'], + ['role_id' => 'role-dispatcher', 'model_type' => CompanyUser::class, 'model_uuid' => 'cu-driver'], ]); $db->table('model_has_policies')->insert([ @@ -253,6 +261,7 @@ function iam_metrics_seed(Capsule $capsule): void ['permission_id' => 'perm-orders-read', 'model_type' => Policy::class, 'model_uuid' => 'policy-fleetops'], ['permission_id' => 'perm-admin-users', 'model_type' => CompanyUser::class, 'model_uuid' => 'cu-inactive'], ['permission_id' => 'perm-admin-users', 'model_type' => CompanyUser::class, 'model_uuid' => 'cu-other'], + ['permission_id' => 'perm-admin-users', 'model_type' => CompanyUser::class, 'model_uuid' => 'cu-customer'], ]); $db->table('groups')->insert([ @@ -264,6 +273,7 @@ function iam_metrics_seed(Capsule $capsule): void $db->table('group_users')->insert([ ['group_uuid' => 'group-ops', 'user_uuid' => 'user-active', 'created_at' => '2026-07-01 00:00:00', 'updated_at' => '2026-07-01 00:00:00', 'deleted_at' => null], ['group_uuid' => 'group-ops', 'user_uuid' => 'user-unassigned', 'created_at' => '2026-07-01 00:00:00', 'updated_at' => '2026-07-01 00:00:00', 'deleted_at' => null], + ['group_uuid' => 'group-ops', 'user_uuid' => 'user-driver', 'created_at' => '2026-07-01 00:00:00', 'updated_at' => '2026-07-01 00:00:00', 'deleted_at' => null], ['group_uuid' => 'group-other', 'user_uuid' => 'user-other', 'created_at' => '2026-07-01 00:00:00', 'updated_at' => '2026-07-01 00:00:00', 'deleted_at' => null], ]); @@ -272,6 +282,7 @@ function iam_metrics_seed(Capsule $capsule): void ['key' => 'company.company-1.2fa', 'value' => json_encode(['enabled' => true, 'enforced' => true])], ['key' => 'user.user-active.2fa', 'value' => json_encode(['enabled' => true])], ['key' => 'user.user-other.2fa', 'value' => json_encode(['enabled' => true])], + ['key' => 'user.user-customer.2fa', 'value' => json_encode(['enabled' => true])], ]); $db->table('activity_log')->insert([ @@ -427,10 +438,10 @@ function iam_metrics_request(array $query = []): Request ->and($types['totals'])->toBe([ 'Admin' => 1, 'Dispatcher' => 1, - 'Driver' => 1, + 'Operator' => 1, 'User' => 1, ]) - ->and(collect($types['datasets'])->pluck('label')->all())->toBe(['Admin', 'Dispatcher', 'Driver', 'User']); + ->and(collect($types['datasets'])->pluck('label')->all())->toBe(['Admin', 'Dispatcher', 'Operator', 'User']); }); test('iam metrics handles empty tenant charts and assignment coverage without division errors', function () { From d0ca6986b4929c71466cc94e46dae54def57496a Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 22 Sep 2026 19:19:19 +0800 Subject: [PATCH 26/30] ci: run PHP CI and the Postman contract once per push, not twice Both workflows triggered on push to main, dev-v* and release/v*, and on pull_request into the same branches. A release branch always has a pull request, so every push to it ran the full PHP suite and the ~11-minute Postman contract twice. Pushes now trigger them only on main (and tags, for CI); pull requests still cover release and dev branches. A concurrency group cancels a superseded run when a newer commit lands on the same pull request or branch. --- .github/workflows/ci.yml | 10 +++++++++- .github/workflows/postman.yml | 10 +++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3c6e440c..73dd8d44 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,14 +3,22 @@ name: PHP CI # Release work lands on a release/v* or dev-v* branch before reaching main via the release # PR, so a main-only filter leaves every PR targeting a release branch with no CI at # all — the release is then assembled from unverified commits. +# Pushes run only for main and tags. A release or dev branch always has a pull +# request, and pull_request already runs on every push to it; triggering on push too +# ran the whole suite twice for each commit. on: push: - branches: [ main, 'dev-v*', 'release/v*' ] + branches: [ main ] tags: - 'v*' pull_request: branches: [ main, 'dev-v*', 'release/v*' ] +# A newer push to the same pull request or branch makes the running check stale. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: build: runs-on: ubuntu-latest diff --git a/.github/workflows/postman.yml b/.github/workflows/postman.yml index 8e6c301a..5fb19b43 100644 --- a/.github/workflows/postman.yml +++ b/.github/workflows/postman.yml @@ -14,12 +14,20 @@ name: API Contract (Postman) # fleetbase-ref: v0.7.53 # api-image: fleetbase/fleetbase-api:v0.7.53 +# Pushes run only for main. A release or dev branch always has a pull request, and +# pull_request already runs on every push to it; triggering on push too ran the +# ~11-minute contract twice for each commit. on: push: - branches: [main, 'dev-v*', 'release/v*'] + branches: [main] pull_request: branches: [main, 'dev-v*', 'release/v*'] workflow_dispatch: + +# A newer push to the same pull request or branch makes the running contract stale. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true permissions: contents: read From 73ab1177f9cefff907d9d004acf65d7d75f2908f Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 22 Sep 2026 19:49:12 +0800 Subject: [PATCH 27/30] test(oauth): cover the token exchange, id token verification and registry edge cases Exercise the Microsoft and Apple token-endpoint legs end to end against canned responses, every id token rejection path, and a JWKS fetched from a loopback server. Mark two defensive branches in IdTokenVerifier that no input reaches. --- src/Auth/OAuth/IdTokenVerifier.php | 8 + tests/Unit/Auth/OAuth/IdTokenVerifierTest.php | 148 +++++++++ .../Auth/OAuth/OAuthConfigResolutionTest.php | 20 ++ .../Auth/OAuth/OAuthProviderRegistryTest.php | 14 + .../Auth/OAuth/ProviderTokenExchangeTest.php | 314 ++++++++++++++++++ 5 files changed, 504 insertions(+) create mode 100644 tests/Unit/Auth/OAuth/ProviderTokenExchangeTest.php diff --git a/src/Auth/OAuth/IdTokenVerifier.php b/src/Auth/OAuth/IdTokenVerifier.php index 5d940608..5d924c89 100644 --- a/src/Auth/OAuth/IdTokenVerifier.php +++ b/src/Auth/OAuth/IdTokenVerifier.php @@ -79,9 +79,13 @@ public function verify( throw new OAuthIdTokenException('id_token_malformed'); } + // @codeCoverageIgnoreStart + // Defensive narrowing for the type checker: the configured Token\Parser only + // ever returns a Plain token or throws, so this cannot be reached. if (!$token instanceof Plain) { throw new OAuthIdTokenException('id_token_malformed'); } + // @codeCoverageIgnoreEnd $kid = $token->headers()->get('kid'); @@ -146,9 +150,13 @@ protected function resolvePublicKey(string $jwksUrl, string $cacheKey, int $jwks $details = openssl_pkey_get_details($keys[$kid]->getKeyMaterial()); + // @codeCoverageIgnoreStart + // Defensive: an RSA JWK is parsed into an OpenSSL public key, whose details + // always carry the PEM under `key`. There is no input that reaches this. if (!is_array($details) || !isset($details['key']) || !is_string($details['key'])) { throw new OAuthIdTokenException('jwks_unreadable'); } + // @codeCoverageIgnoreEnd return $details['key']; } diff --git a/tests/Unit/Auth/OAuth/IdTokenVerifierTest.php b/tests/Unit/Auth/OAuth/IdTokenVerifierTest.php index dbc840cd..95a10157 100644 --- a/tests/Unit/Auth/OAuth/IdTokenVerifierTest.php +++ b/tests/Unit/Auth/OAuth/IdTokenVerifierTest.php @@ -101,3 +101,151 @@ function id_token_verify(IdTokenVerifier $verifier, string $jwt): array expect(fn () => id_token_verify(id_token_verifier_with([$jwk]), id_token_signed($private, 'rotated-key'))) ->toThrow(OAuthIdTokenException::class, 'id_token_unknown_key'); }); + +it('rejects a missing token before doing any work', function () { + [, $jwk] = id_token_key('ms-key-1', false); + + expect(fn () => id_token_verify(id_token_verifier_with([$jwk]), '')) + ->toThrow(OAuthIdTokenException::class, 'id_token_missing'); +}); + +it('refuses to verify against an unconfigured client id', function () { + [$private, $jwk] = id_token_key('ms-key-1', false); + + // PermittedFor('') would otherwise assert against an empty audience. + expect(fn () => id_token_verifier_with([$jwk])->verify(id_token_signed($private, 'ms-key-1'), ID_TOKEN_JWKS_URL, '', fn (): bool => true, ID_TOKEN_CACHE_KEY)) + ->toThrow(OAuthIdTokenException::class, 'audience_not_configured'); +}); + +it('reports a token that does not parse as malformed', function () { + [, $jwk] = id_token_key('ms-key-1', false); + + expect(fn () => id_token_verify(id_token_verifier_with([$jwk]), 'not-a-jwt')) + ->toThrow(OAuthIdTokenException::class, 'id_token_malformed'); +}); + +it('refuses a token whose header names no key', function () { + [$private, $jwk] = id_token_key('ms-key-1', false); + + $unkeyed = JWT::encode(['iss' => ID_TOKEN_ISSUER, 'aud' => ID_TOKEN_AUDIENCE, 'exp' => time() + 300], $private, 'RS256'); + + expect(fn () => id_token_verify(id_token_verifier_with([$jwk]), $unkeyed)) + ->toThrow(OAuthIdTokenException::class, 'id_token_missing_kid'); +}); + +it('reports a key set it cannot read', function (mixed $cached) { + [$private] = id_token_key('ms-key-1', false); + + $verifier = id_token_verifier_with([]); + Cache::put(ID_TOKEN_CACHE_KEY, $cached, 300); + + expect(fn () => id_token_verify($verifier, id_token_signed($private, 'ms-key-1'))) + ->toThrow(OAuthIdTokenException::class, 'jwks_unreadable'); +})->with([ + 'no keys in the set' => [['keys' => []]], + 'not a json document' => ['maintenance'], +]); + +/** + * Serve $files from a PHP built-in server on a free loopback port, standing in for a + * provider's JWKS endpoint. The verifier builds its own HTTP client, so the fetch is a + * real request, but it never leaves this machine. + * + * @param array $files + * + * @return array{0: string, 1: array{process: resource, root: string}} + */ +function id_token_jwks_server(array $files): array +{ + $root = sys_get_temp_dir() . '/fleetbase-jwks-' . bin2hex(random_bytes(4)); + mkdir($root); + + foreach ($files as $name => $contents) { + file_put_contents($root . '/' . $name, $contents); + } + + $probe = stream_socket_server('tcp://127.0.0.1:0'); + $port = (int) substr(strrchr((string) stream_socket_get_name($probe, false), ':'), 1); + fclose($probe); + + $process = proc_open([PHP_BINARY, '-S', '127.0.0.1:' . $port, '-t', $root], [['pipe', 'r'], ['file', '/dev/null', 'w'], ['file', '/dev/null', 'w']], $pipes); + + // Refused connections are expected until the server is listening; they are not + // warnings about the code under test. + set_error_handler(fn (): bool => true); + + try { + for ($attempt = 0; $attempt < 100; $attempt++) { + $socket = fsockopen('127.0.0.1', $port, $errno, $errstr, 0.1); + + if ($socket !== false) { + fclose($socket); + break; + } + + usleep(50000); + } + } finally { + restore_error_handler(); + } + + return ['http://127.0.0.1:' . $port, ['process' => $process, 'root' => $root]]; +} + +/** + * @param array{process: resource, root: string} $server + */ +function id_token_jwks_server_stop(array $server): void +{ + proc_terminate($server['process']); + proc_close($server['process']); + + array_map('unlink', glob($server['root'] . '/*') ?: []); + rmdir($server['root']); +} + +function id_token_uncached_verifier(): IdTokenVerifier +{ + bind_test_container(); + + $cache = new CacheRepository(new ArrayStore()); + app()->instance('cache', $cache); + Facade::clearResolvedInstance('cache'); + Facade::clearResolvedInstance('log'); + Cache::swap($cache); + + return new IdTokenVerifier(); +} + +it('fetches and caches the key set when it is not cached yet', function () { + [$private, $jwk] = id_token_key('fetched-key', false); + [$baseUrl, $server] = id_token_jwks_server(['keys.json' => json_encode(['keys' => [$jwk]])]); + + try { + $verifier = id_token_uncached_verifier(); + $claims = $verifier->verify(id_token_signed($private, 'fetched-key'), $baseUrl . '/keys.json', ID_TOKEN_AUDIENCE, fn (string $issuer): bool => $issuer === ID_TOKEN_ISSUER, ID_TOKEN_CACHE_KEY); + } finally { + id_token_jwks_server_stop($server); + } + + expect($claims['sub'])->toBe('subject-1') + ->and(Cache::get(ID_TOKEN_CACHE_KEY))->toBe(['keys' => [$jwk]]); +}); + +it('reports a key set endpoint that fails', function () { + [$private] = id_token_key('fetched-key', false); + [$baseUrl, $server] = id_token_jwks_server([]); + + try { + $verifier = id_token_uncached_verifier(); + + expect(fn () => $verifier->verify(id_token_signed($private, 'fetched-key'), $baseUrl . '/missing.json', ID_TOKEN_AUDIENCE, fn (): bool => true, ID_TOKEN_CACHE_KEY)) + ->toThrow(OAuthIdTokenException::class, 'jwks_unreachable'); + } finally { + id_token_jwks_server_stop($server); + } + + // Nothing is cached from a failed fetch, so the next attempt tries again. + expect(Cache::get(ID_TOKEN_CACHE_KEY))->toBeNull() + ->and(app('log')->entries[0][1] ?? null)->toBe('[OAuth] Unable to fetch provider JWKS.'); +}); diff --git a/tests/Unit/Auth/OAuth/OAuthConfigResolutionTest.php b/tests/Unit/Auth/OAuth/OAuthConfigResolutionTest.php index 58362fe8..d4732111 100644 --- a/tests/Unit/Auth/OAuth/OAuthConfigResolutionTest.php +++ b/tests/Unit/Auth/OAuth/OAuthConfigResolutionTest.php @@ -402,3 +402,23 @@ function oauth_config_repository(array $config = [], ?Encrypter $encrypter = nul expect($repository->forProvider('apple')->isConfigured(['client_id', 'team_id', 'key_id'], ['private_key']))->toBeTrue(); }); + +it('never lets a draft swap the driver class', function () { + $repository = oauth_config_repository(); + + // The admin form's draft is caller input; a `driver` in it is dropped just as it is + // when stored, so a credential check can never instantiate an arbitrary class. + $draft = $repository->draftFor('google', ['driver' => stdClass::class, 'client_id' => ' draft-id '], ['client_secret']); + + expect($draft->get('driver'))->toBeNull() + ->and($draft->get('client_id'))->toBe('draft-id'); +}); + +it('refuses to store a secret without an encrypter rather than storing it in plaintext', function () { + oauth_config_repository(); + $repository = new OAuthConfigRepository(null); + + expect(fn () => $repository->save([], ['google' => ['client_secret' => 'plaintext']], ['google' => ['client_secret']])) + ->toThrow(RuntimeException::class, 'Cannot store an OAuth secret without an encrypter.') + ->and(Setting::query()->where('key', 'system.oauth')->exists())->toBeFalse(); +}); diff --git a/tests/Unit/Auth/OAuth/OAuthProviderRegistryTest.php b/tests/Unit/Auth/OAuth/OAuthProviderRegistryTest.php index ec163451..9b94ce4a 100644 --- a/tests/Unit/Auth/OAuth/OAuthProviderRegistryTest.php +++ b/tests/Unit/Auth/OAuth/OAuthProviderRegistryTest.php @@ -208,3 +208,17 @@ function oauth_registry_all_providers(): array GithubDriver::class, AppleDriver::class, ]); + +it('leaves a provider without a usable driver out of every listing', function () { + $registry = oauth_registry([ + 'google' => ['driver' => GoogleDriver::class, 'enabled' => true, 'client_id' => 'g', 'client_secret' => 'gs'], + // Defined in config but pointing at a class that is not installed, e.g. an + // extension that was removed: it is skipped rather than breaking the others. + 'ghost' => ['driver' => 'Fleetbase\\Nope\\DoesNotExist', 'enabled' => true, 'client_id' => 'x', 'client_secret' => 'y'], + ]); + + expect($registry->ids())->toBe(['google', 'ghost']) + ->and(array_keys($registry->enabled()))->toBe(['google']) + ->and(array_column($registry->definitions(), 'id'))->toBe(['google']) + ->and(array_keys($registry->schemas()))->toBe(['google']); +}); diff --git a/tests/Unit/Auth/OAuth/ProviderTokenExchangeTest.php b/tests/Unit/Auth/OAuth/ProviderTokenExchangeTest.php new file mode 100644 index 00000000..5b37befe --- /dev/null +++ b/tests/Unit/Auth/OAuth/ProviderTokenExchangeTest.php @@ -0,0 +1,314 @@ + */ + public array $history = []; + + public function __construct() + { + $this->mock = new MockHandler(); + } + + public function client(): HttpClient + { + $stack = HandlerStack::create($this->mock); + $stack->push(Middleware::history($this->history)); + + return new HttpClient(['handler' => $stack]); + } + + public function says(int $status, array $body): self + { + $this->mock->append(new PsrResponse($status, ['Content-Type' => 'application/json'], json_encode($body))); + + return $this; + } + + /** + * @return array + */ + public function postedForm(int $index = 0): array + { + parse_str((string) $this->history[$index]['request']->getBody(), $form); + + return $form; + } + + public function postedUrl(int $index = 0): string + { + return (string) $this->history[$index]['request']->getUri(); + } +} + +/** + * Apple's client secret is an ES256 assertion minted per request; its signing is + * covered by AppleClientSecretFactoryTest, so a fixed value stands in for it here. + */ +class TokenExchangeAppleSecretFake extends AppleClientSecretFactory +{ + public function make(OAuthProviderConfig $config): string + { + return 'minted-apple-secret'; + } +} + +/** + * A real RSA key pair and its public half as a JWK. + * + * @return array{0: string, 1: array} + */ +function token_exchange_key(string $kid): array +{ + $resource = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]); + openssl_pkey_export($resource, $privatePem); + $details = openssl_pkey_get_details($resource); + + $b64 = fn (string $bytes): string => rtrim(strtr(base64_encode($bytes), '+/', '-_'), '='); + + return [(string) $privatePem, ['kty' => 'RSA', 'use' => 'sig', 'kid' => $kid, 'n' => $b64($details['rsa']['n']), 'e' => $b64($details['rsa']['e'])]]; +} + +/** + * A fresh container with an in-memory cache holding the provider's JWKS. + * + * @param array> $keys + */ +function token_exchange_jwks(string $cacheKey, array $keys): void +{ + bind_test_container(); + + $cache = new CacheRepository(new ArrayStore()); + app()->instance('cache', $cache); + Facade::clearResolvedInstance('cache'); + Cache::swap($cache); + Cache::put($cacheKey, ['keys' => $keys], 300); +} + +function token_exchange_id_token(string $privatePem, string $kid, array $claims): string +{ + return JWT::encode(array_merge([ + 'iat' => time(), + 'nbf' => time(), + 'exp' => time() + 300, + ], $claims), $privatePem, 'RS256', $kid); +} + +function token_exchange_microsoft(array $values, TokenExchangeProviderFake $provider): MicrosoftDriver +{ + $driver = new MicrosoftDriver( + new OAuthProviderConfig('microsoft', array_merge(['client_id' => 'ms-client', 'client_secret' => 'ms-secret'], $values), null), + Request::create('/int/v1/auth/oauth/microsoft/callback', 'GET'), + new IdTokenVerifier() + ); + + return $driver->useHttpClient($provider->client()); +} + +function token_exchange_apple(TokenExchangeProviderFake $provider): AppleDriver +{ + $driver = new AppleDriver( + new OAuthProviderConfig('apple', [ + 'client_id' => 'io.fleetbase.console', + 'team_id' => 'TEAM', + 'key_id' => 'KEY', + 'private_key' => 'unused-by-the-fake', + ], null), + Request::create('/int/v1/auth/oauth/apple/callback', 'POST'), + new IdTokenVerifier(), + new TokenExchangeAppleSecretFake() + ); + + return $driver->useHttpClient($provider->client()); +} + +it('exchanges a microsoft code and reads the profile from the verified id token', function () { + [$private, $jwk] = token_exchange_key('ms-key'); + token_exchange_jwks('oauth.jwks.microsoft.' . sha1('contoso.onmicrosoft.com'), [$jwk]); + + $provider = (new TokenExchangeProviderFake())->says(200, [ + 'access_token' => 'ms-access-token', + 'id_token' => token_exchange_id_token($private, 'ms-key', [ + 'iss' => 'https://login.microsoftonline.com/tenant-1/v2.0', + 'aud' => 'ms-client', + 'oid' => 'object-id-1', + 'tid' => 'contoso.onmicrosoft.com', + 'name' => 'Ada Lovelace', + 'email' => 'ada@contoso.com', + 'preferred_username' => 'ada@contoso.com', + ]), + ]); + + $profile = token_exchange_microsoft(['tenant' => 'contoso.onmicrosoft.com'], $provider) + ->exchange('auth-code-1', 'verifier-xyz', TOKEN_EXCHANGE_REDIRECT); + + expect($profile->provider)->toBe('microsoft') + ->and($profile->providerUserId)->toBe('object-id-1') + ->and($profile->email)->toBe('ada@contoso.com') + ->and($profile->name)->toBe('Ada Lovelace') + // A single-tenant app trusts addresses from its own directory. + ->and($profile->emailVerified)->toBeTrue() + ->and($profile->meta('tid'))->toBe('contoso.onmicrosoft.com') + ->and($provider->postedUrl())->toBe('https://login.microsoftonline.com/contoso.onmicrosoft.com/oauth2/v2.0/token') + ->and($provider->postedForm())->toMatchArray([ + 'code' => 'auth-code-1', + 'code_verifier' => 'verifier-xyz', + 'client_secret' => 'ms-secret', + ]); +}); + +it('refuses a microsoft id token issued outside the microsoft authority', function () { + [$private, $jwk] = token_exchange_key('ms-key'); + token_exchange_jwks('oauth.jwks.microsoft.' . sha1('common'), [$jwk]); + + $provider = (new TokenExchangeProviderFake())->says(200, [ + 'access_token' => 'ms-access-token', + 'id_token' => token_exchange_id_token($private, 'ms-key', [ + 'iss' => 'https://login.evil.test/tenant-1/v2.0', + 'aud' => 'ms-client', + 'oid' => 'object-id-1', + ]), + ]); + + expect(fn () => token_exchange_microsoft([], $provider)->exchange('auth-code-1', 'verifier-xyz', TOKEN_EXCHANGE_REDIRECT)) + ->toThrow(OAuthIdTokenException::class, 'id_token_issuer_mismatch'); +}); + +it('rejects a token response that carries no usable id token', function () { + token_exchange_jwks('oauth.jwks.microsoft.' . sha1('common'), []); + + // An array where the JWT should be is narrowed to nothing, not stringified. + $provider = (new TokenExchangeProviderFake())->says(200, ['access_token' => 'ms-access-token', 'id_token' => ['not', 'a', 'jwt']]); + + expect(fn () => token_exchange_microsoft([], $provider)->exchange('auth-code-1', 'verifier-xyz', TOKEN_EXCHANGE_REDIRECT)) + ->toThrow(OAuthIdTokenException::class, 'id_token_missing'); +}); + +it('names its tenant and holds no token response before an exchange', function () { + bind_test_container(); + + $provider = new MicrosoftProvider(Request::create('/'), 'ms-client', 'ms-secret', TOKEN_EXCHANGE_REDIRECT); + + expect($provider->withTenant('')->tenant())->toBe('common') + ->and($provider->withTenant('contoso.onmicrosoft.com')->tenant())->toBe('contoso.onmicrosoft.com') + ->and($provider->serverTokenResponse())->toBe([]); +}); + +it('exchanges an apple code with a minted client secret and verifies the id token', function () { + [$private, $jwk] = token_exchange_key('apple-key'); + token_exchange_jwks('oauth.jwks.apple', [$jwk]); + + $provider = (new TokenExchangeProviderFake())->says(200, [ + 'access_token' => 'apple-access-token', + 'id_token' => token_exchange_id_token($private, 'apple-key', [ + 'iss' => 'https://appleid.apple.com', + 'aud' => 'io.fleetbase.console', + 'sub' => '001234.apple-subject', + 'email' => 'ada@privaterelay.appleid.com', + 'email_verified' => 'true', + ]), + ]); + + $profile = token_exchange_apple($provider)->exchange('auth-code-1', 'verifier-xyz', TOKEN_EXCHANGE_REDIRECT, [ + // Apple posts the name once, beside the code, on first authorization only. + 'user' => json_encode(['name' => ['firstName' => 'Ada', 'lastName' => 'Lovelace']]), + ]); + + expect($profile->provider)->toBe('apple') + ->and($profile->providerUserId)->toBe('001234.apple-subject') + ->and($profile->email)->toBe('ada@privaterelay.appleid.com') + ->and($profile->emailVerified)->toBeTrue() + ->and($profile->name)->toBe('Ada Lovelace') + ->and($profile->meta('private_relay'))->toBeTrue() + ->and($provider->postedUrl())->toBe('https://appleid.apple.com/auth/token') + ->and($provider->postedForm())->toMatchArray([ + 'client_id' => 'io.fleetbase.console', + 'client_secret' => 'minted-apple-secret', + 'code_verifier' => 'verifier-xyz', + ]); +}); + +it('refuses an apple id token from another issuer', function () { + [$private, $jwk] = token_exchange_key('apple-key'); + token_exchange_jwks('oauth.jwks.apple', [$jwk]); + + $provider = (new TokenExchangeProviderFake())->says(200, [ + 'access_token' => 'apple-access-token', + 'id_token' => token_exchange_id_token($private, 'apple-key', [ + 'iss' => 'https://appleid.apple.com.evil.test', + 'aud' => 'io.fleetbase.console', + 'sub' => '001234.apple-subject', + ]), + ]); + + expect(fn () => token_exchange_apple($provider)->exchange('auth-code-1', 'verifier-xyz', TOKEN_EXCHANGE_REDIRECT)) + ->toThrow(OAuthIdTokenException::class, 'id_token_issuer_mismatch'); +}); + +it('reports a client secret that cannot be resolved as invalid credentials without calling the provider', function () { + bind_test_container(); + $provider = new TokenExchangeProviderFake(); + + // Ciphertext with no encrypter to open it: the secret cannot be resolved at all. + $config = new OAuthProviderConfig('google', ['client_id' => 'g', 'client_secret_encrypted' => 'ciphertext'], null); + $driver = (new GoogleDriver($config, Request::create('/'), new IdTokenVerifier()))->useHttpClient($provider->client()); + + expect(fn () => $config->secret('client_secret'))->toThrow(OAuthProviderNotConfiguredException::class, 'google.client_secret') + ->and($driver->verifyCredentials(TOKEN_EXCHANGE_REDIRECT))->toBe(CredentialCheck::InvalidClient) + ->and($provider->history)->toBe([]); +}); + +it('draws no conclusion when the provider issues a token for a code that cannot exist', function () { + bind_test_container(); + $provider = (new TokenExchangeProviderFake())->says(200, ['access_token' => 'should-never-happen']); + + $driver = (new GoogleDriver( + new OAuthProviderConfig('google', ['client_id' => 'g', 'client_secret' => 'gs'], null), + Request::create('/'), + new IdTokenVerifier() + ))->useHttpClient($provider->client()); + + expect($driver->verifyCredentials(TOKEN_EXCHANGE_REDIRECT))->toBe(CredentialCheck::Inconclusive); +}); + +it('counts only verified credentials as a pass', function () { + expect(CredentialCheck::Verified->passed())->toBeTrue() + ->and(CredentialCheck::InvalidClient->passed())->toBeFalse() + ->and(CredentialCheck::RedirectUriMismatch->passed())->toBeFalse() + ->and(CredentialCheck::Unreachable->passed())->toBeFalse() + ->and(CredentialCheck::Inconclusive->passed())->toBeFalse(); +}); From 326348236732d43588c87a9cfc3b3edc952726f6 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 22 Sep 2026 19:49:13 +0800 Subject: [PATCH 28/30] test(oauth): cover state, identity and registration intent edge cases A state row pruned mid redemption, undecryptable and non-string payloads, a lost race to link a subject, an intent with no profile, OAuthState pruning, and the array form of the link notifications. --- .../OAuthProviderNotificationsTest.php | 28 ++++++ .../OAuth/OAuthIdentityServiceTest.php | 58 +++++++++++++ .../Services/OAuth/OAuthStateServiceTest.php | 86 +++++++++++++++++++ .../Support/OAuthRegistrationIntentTest.php | 26 ++++++ 4 files changed, 198 insertions(+) diff --git a/tests/Unit/Notifications/OAuthProviderNotificationsTest.php b/tests/Unit/Notifications/OAuthProviderNotificationsTest.php index 4be893b7..777f495f 100644 --- a/tests/Unit/Notifications/OAuthProviderNotificationsTest.php +++ b/tests/Unit/Notifications/OAuthProviderNotificationsTest.php @@ -181,3 +181,31 @@ function oauth_mail_text(MailMessage $mail): string expect($source)->toMatch('/Events\\\\OAuthIdentityLinked::class\s*=>\s*\[\\\\Fleetbase\\\\Listeners\\\\SendOAuthIdentityLinkedNotification::class\]/') ->and($source)->toMatch('/Events\\\\OAuthIdentityUnlinked::class\s*=>\s*\[\\\\Fleetbase\\\\Listeners\\\\SendOAuthIdentityUnlinkedNotification::class\]/'); }); + +it('sends both notices by mail only', function () { + oauth_notifications_setup(); + $user = oauth_notifications_user(); + + expect((new OAuthProviderLinked('google', 'ada@gmail.com'))->via($user))->toBe(['mail']) + ->and((new OAuthProviderUnlinked('google'))->via($user))->toBe(['mail']); +}); + +it('records what was linked and removed, and when, in the array form', function () { + oauth_notifications_setup(); + Carbon::setTestNow('2026-09-21 14:30:00'); + + $linked = (new OAuthProviderLinked('google', 'ada@gmail.com', OAuthIdentityLinked::METHOD_AUTOMATIC))->toArray(oauth_notifications_user()); + $unlinked = (new OAuthProviderUnlinked('google'))->toArray(oauth_notifications_user()); + + Carbon::setTestNow(); + + expect($linked)->toBe([ + 'provider' => 'google', + 'provider_email' => 'ada@gmail.com', + 'method' => OAuthIdentityLinked::METHOD_AUTOMATIC, + 'linked_at' => 'Mon, Sep 21, 2026 2:30 PM UTC', + ])->and($unlinked)->toBe([ + 'provider' => 'google', + 'unlinked_at' => 'Mon, Sep 21, 2026 2:30 PM UTC', + ]); +}); diff --git a/tests/Unit/Services/OAuth/OAuthIdentityServiceTest.php b/tests/Unit/Services/OAuth/OAuthIdentityServiceTest.php index 473e1692..9df059ed 100644 --- a/tests/Unit/Services/OAuth/OAuthIdentityServiceTest.php +++ b/tests/Unit/Services/OAuth/OAuthIdentityServiceTest.php @@ -417,3 +417,61 @@ function oauth_identity_service_profile(array $overrides = []): OAuthUserProfile expect(OAuthIdentity::query()->first()->provider_email)->toBe('ada@example.com'); }); + +/** + * Reads nothing for the first $staleReads lookups, as a request does when a concurrent + * one inserts the same subject between its lookup and its insert. + */ +class OAuthIdentityServiceStaleReads extends OAuthIdentityService +{ + public function __construct(public int $staleReads) + { + } + + public function findByProfile(OAuthUserProfile $profile): ?OAuthIdentity + { + if ($this->staleReads > 0) { + $this->staleReads--; + + return null; + } + + return parent::findByProfile($profile); + } +} + +it('resolves a lost race for the same user to the row the other request wrote', function () { + oauth_identity_service_schema(); + $user = oauth_identity_service_user('user-1'); + $winner = (new OAuthIdentityService())->link($user, oauth_identity_service_profile()); + oauth_test_reset_events(); + + $identity = (new OAuthIdentityServiceStaleReads(1))->link($user, oauth_identity_service_profile()); + + expect($identity->uuid)->toBe($winner->uuid) + ->and(OAuthIdentity::query()->count())->toBe(1) + // The request that lost the race linked nothing, so it announces nothing. + ->and(oauth_test_events())->toBe([]); +}); + +it('refuses a lost race when the other request linked the subject to someone else', function () { + oauth_identity_service_schema(); + $owner = oauth_identity_service_user('user-1'); + $attacker = oauth_identity_service_user('user-2'); + (new OAuthIdentityService())->link($owner, oauth_identity_service_profile()); + + expect(fn () => (new OAuthIdentityServiceStaleReads(1))->link($attacker, oauth_identity_service_profile())) + ->toThrow(OAuthException::class, 'identity_already_linked') + ->and(OAuthIdentity::query()->first()->user_uuid)->toBe('user-1'); +}); + +it('rethrows an insert failure that no competing row explains', function () { + oauth_identity_service_schema(); + $owner = oauth_identity_service_user('user-1'); + (new OAuthIdentityService())->link($owner, oauth_identity_service_profile()); + + // Every lookup comes back empty, so the unique violation cannot be attributed to a + // concurrent link and must surface rather than be swallowed. + expect(fn () => (new OAuthIdentityServiceStaleReads(PHP_INT_MAX))->link(oauth_identity_service_user('user-2'), oauth_identity_service_profile())) + ->toThrow(Illuminate\Database\QueryException::class); +}); diff --git a/tests/Unit/Services/OAuth/OAuthStateServiceTest.php b/tests/Unit/Services/OAuth/OAuthStateServiceTest.php index e3fa4645..c061afa4 100644 --- a/tests/Unit/Services/OAuth/OAuthStateServiceTest.php +++ b/tests/Unit/Services/OAuth/OAuthStateServiceTest.php @@ -328,3 +328,89 @@ function oauth_state_service(): OAuthStateService expect(OAuthStateService::purposes())->toBe(['authorization', 'handoff', 'registration_intent']) ->and($service->hash('abc'))->toBe(hash('sha256', 'abc')); }); + +it('prunes only rows a full day past their expiry', function () { + oauth_state_service_database(); + Carbon::setTestNow('2026-09-22 12:00:00'); + + $service = oauth_state_service(); + $stale = $service->issue(OAuthState::PURPOSE_HANDOFF, [], 120); + $recent = $service->issue(OAuthState::PURPOSE_HANDOFF, [], 120); + + // Expired over a day ago, and expired just now: only the first is prunable. An + // expired row is kept for a day of grace, so support can still see it. + OAuthState::query()->where('token_hash', $service->hash($stale))->update(['expires_at' => '2026-09-21 11:00:00']); + OAuthState::query()->where('token_hash', $service->hash($recent))->update(['expires_at' => '2026-09-22 11:00:00']); + + $prunable = (new OAuthState())->prunable()->pluck('token_hash')->all(); + + Carbon::setTestNow(); + + expect($prunable)->toBe([$service->hash($stale)]); +}); + +it('belongs to the user it resolved to', function () { + oauth_state_service_database(); + + $relation = (new OAuthState())->user(); + + expect($relation)->toBeInstanceOf(Illuminate\Database\Eloquent\Relations\BelongsTo::class) + ->and($relation->getForeignKeyName())->toBe('user_uuid') + ->and($relation->getOwnerKeyName())->toBe('uuid') + ->and($relation->getRelated())->toBeInstanceOf(Fleetbase\Models\User::class); +}); + +it('refuses a token whose row disappears between redeeming and reading it', function () { + $capsule = oauth_state_service_database(); + $service = oauth_state_service(); + + $token = $service->issue(OAuthState::PURPOSE_HANDOFF, ['profile' => ['provider' => 'google']], 120); + + // The prune job deleting the row in the gap between the conditional UPDATE and the + // read that follows it. + $capsule->getConnection('mysql')->statement( + 'CREATE TRIGGER oauth_states_pruned AFTER UPDATE OF consumed_at ON oauth_states BEGIN DELETE FROM oauth_states WHERE uuid = NEW.uuid; END' + ); + + expect(fn () => $service->consume(OAuthState::PURPOSE_HANDOFF, $token)) + ->toThrow(OAuthStateException::class, 'invalid_or_expired') + ->and(OAuthState::query()->count())->toBe(0); +}); + +it('inspects an undecryptable payload as absent rather than failing', function () { + oauth_state_service_database(); + + $token = oauth_state_service()->issue(OAuthState::PURPOSE_REGISTRATION_INTENT, ['profile' => ['provider' => 'google']], 900); + $rotated = new OAuthStateService(new OAuthStateServiceEncrypterFake(str_repeat('j', 32))); + + expect($rotated->inspect(OAuthState::PURPOSE_REGISTRATION_INTENT, $token))->toBeNull() + // Inspecting never redeems, even when it cannot read the payload. + ->and(OAuthState::query()->whereNull('consumed_at')->count())->toBe(1); +}); + +it('reads a payload that does not decrypt to a string as empty', function () { + oauth_state_service_database(); + + $token = oauth_state_service()->issue(OAuthState::PURPOSE_HANDOFF, ['profile' => ['provider' => 'google']], 120); + + // decrypt(..., false) is typed mixed; anything but a JSON string was not written by + // this service and is not coerced into one. + $service = new OAuthStateService(new class implements Encrypter { + public function encrypt($value, $serialize = true) + { + return 'unused'; + } + + public function decrypt($payload, $unserialize = true) + { + return ['not' => 'a string']; + } + + public function getKey() + { + return 'key'; + } + }); + + expect($service->consume(OAuthState::PURPOSE_HANDOFF, $token)['payload'])->toBe([]); +}); diff --git a/tests/Unit/Support/OAuthRegistrationIntentTest.php b/tests/Unit/Support/OAuthRegistrationIntentTest.php index d6103cb5..3d7b8c69 100644 --- a/tests/Unit/Support/OAuthRegistrationIntentTest.php +++ b/tests/Unit/Support/OAuthRegistrationIntentTest.php @@ -428,3 +428,29 @@ function registration_intent_profile(array $overrides = []): OAuthUserProfile ->and($rule->message())->toBeString() ->and(OAuthState::query()->first()->consumed_at)->toBeNull(); }); + +it('lists the providers the console should offer', function () { + registration_intent_database(); + config(['oauth.providers' => [ + 'google' => ['driver' => Fleetbase\Auth\OAuth\Drivers\GoogleDriver::class, 'enabled' => true, 'client_id' => 'g', 'client_secret' => 'gs'], + 'github' => ['driver' => Fleetbase\Auth\OAuth\Drivers\GithubDriver::class, 'enabled' => false, 'client_id' => 'h', 'client_secret' => 'hs'], + ]]); + app()->instance(Fleetbase\Auth\OAuth\OAuthProviderRegistry::class, new Fleetbase\Auth\OAuth\OAuthProviderRegistry( + app(OAuthConfigRepository::class), + Illuminate\Http\Request::create('/'), + new Fleetbase\Auth\OAuth\IdTokenVerifier() + )); + + expect(OAuth::enabledProviders())->toBe([['id' => 'google', 'label' => 'Google', 'icon' => 'google']]); +}); + +it('redeems nothing from an intent that carries no profile', function () { + registration_intent_database(); + + // Written by something other than issueRegistrationIntent(): the row is real and + // unexpired, but there is no identity in it to link. + $token = app(OAuthStateService::class)->issue(OAuthState::PURPOSE_REGISTRATION_INTENT, ['intent' => 'signup'], 900); + + expect(OAuth::redeemRegistrationIntent($token, registration_intent_user()))->toBeNull() + ->and(OAuthIdentity::query()->count())->toBe(0); +}); From 71387b7fe7a2735a6512cb96efdf39f03216307e Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 22 Sep 2026 19:49:13 +0800 Subject: [PATCH 29/30] test(oauth): cover the controller's rate limits and mid-flight refusals Rate limits on redirect and link completion, a callback without a code, providers switched off or removed mid flight, a lost automatic-link race, and a live provider rechecked when only its client id changes. --- tests/Unit/Http/OAuthControllerTest.php | 169 ++++++++++++++++++ .../Unit/Http/SettingControllerOAuthTest.php | 20 +++ 2 files changed, 189 insertions(+) diff --git a/tests/Unit/Http/OAuthControllerTest.php b/tests/Unit/Http/OAuthControllerTest.php index 53e9f31b..555b3037 100644 --- a/tests/Unit/Http/OAuthControllerTest.php +++ b/tests/Unit/Http/OAuthControllerTest.php @@ -648,6 +648,7 @@ function oauth_fragment(string $url): array 'null byte' => ["/ok\0", null], 'not rooted' => ['dashboard', null], 'empty' => ['', null], + 'too long' => ['/' . str_repeat('a', 512), null], ]); it('rejects an unsafe return path at the request boundary too', function () { @@ -1448,3 +1449,171 @@ function oauth_link_callback(OAuthController $controller, User $user): array expect($response->getStatusCode())->toBe(429); }); + +// --------------------------------------------------------------------------- +// Edge cases across the flow +// --------------------------------------------------------------------------- + +/** + * Loses the race for a provider subject: another request links it between the + * controller's lookup and this link. + */ +class OAuthControllerRacingIdentityService extends OAuthIdentityService +{ + public function link(User $user, OAuthUserProfile $profile, string $method = Fleetbase\Events\OAuthIdentityLinked::METHOD_MANUAL): OAuthIdentity + { + throw new OAuthException('identity_already_linked'); + } +} + +function oauth_controller_rate_limited(): void +{ + app()->instance(Illuminate\Cache\RateLimiter::class, new OAuthControllerRateLimiterFake(0)); + Facade::clearResolvedInstance(Illuminate\Cache\RateLimiter::class); +} + +it('treats both public oauth requests as open to anyone', function () { + oauth_controller_database(); + + // There is no session yet on either: obtaining one is the point. + expect((new OAuthRedirectRequest())->authorize())->toBeTrue() + ->and((new OAuthExchangeRequest())->authorize())->toBeTrue() + ->and((new OAuthRedirectRequest())->messages())->toBe(['return_to.regex' => 'The return path must be a relative path within the console.']); +}); + +it('rate limits the redirect endpoint independently of the throttle middleware', function () { + oauth_controller_database(); + oauth_controller_rate_limited(); + [$controller] = oauth_controller_services(); + + $response = $controller->redirect(oauth_redirect_request(), 'fakeprovider'); + + expect($response->getStatusCode())->toBe(429) + ->and(OAuthState::query()->count())->toBe(0); +}); + +it('reports a callback that carries no authorization code', function () { + oauth_controller_database(); + [$controller] = oauth_controller_services(); + + $redirect = $controller->redirect(oauth_redirect_request(), 'fakeprovider'); + parse_str((string) parse_url($redirect->getTargetUrl(), PHP_URL_QUERY), $query); + + $response = $controller->callback(Request::create('/int/v1/auth/oauth/fakeprovider/callback', 'GET', ['state' => $query['state']]), 'fakeprovider'); + + expect(oauth_fragment($response->getTargetUrl())['error'])->toBe('missing_code'); +}); + +it('refuses a callback for a provider switched off mid flight', function () { + oauth_controller_database(); + [$controller] = oauth_controller_services(); + + $redirect = $controller->redirect(oauth_redirect_request(), 'fakeprovider'); + parse_str((string) parse_url($redirect->getTargetUrl(), PHP_URL_QUERY), $query); + + OAuthControllerFakeDriver::$behaviour = ['enabled' => false, 'throw' => new RuntimeException('no exchange for a disabled provider')]; + + $response = $controller->callback( + Request::create('/int/v1/auth/oauth/fakeprovider/callback', 'GET', ['code' => 'auth-code', 'state' => $query['state']]), + 'fakeprovider' + ); + + expect(oauth_fragment($response->getTargetUrl())['error'])->toBe('provider_disabled'); +}); + +it('refuses a handoff that names no provider subject', function () { + oauth_controller_database(); + [$controller, $states] = oauth_controller_services(); + + $handoff = $states->issue(OAuthState::PURPOSE_HANDOFF, ['profile' => ['provider' => 'fakeprovider', 'email' => 'ada@example.com']], 120, 'fakeprovider'); + + $response = $controller->exchange(oauth_exchange_request(['code' => $handoff])); + + expect($response->getStatusCode())->toBe(400) + ->and($response->getData(true)['code'])->toBe('invalid_exchange_code'); +}); + +it('refuses an exchange for a provider removed from the configuration mid flight', function () { + oauth_controller_database(); + [$controller] = oauth_controller_services(); + + oauth_controller_link(oauth_controller_user()); + $handoff = oauth_controller_handoff($controller); + + config(['oauth.providers' => []]); + + $response = $controller->exchange(oauth_exchange_request(['code' => $handoff])); + + expect($response->getStatusCode())->toBe(403) + ->and($response->getData(true)['code'])->toBe('provider_disabled'); +}); + +it('falls back to asking the user to link when an automatic link loses a race', function () { + oauth_controller_database(); + [, $states, , $config, $flow] = oauth_controller_services(); + + $registry = app(OAuthProviderRegistry::class); + $controller = new OAuthController($registry, $flow, $states, new OAuthControllerRacingIdentityService(), $config); + + oauth_controller_user(['uuid' => 'user-1', 'email' => 'ada@example.com', 'type' => 'user']); + + $response = $controller->exchange(oauth_exchange_request(['code' => oauth_controller_handoff($controller)])); + + // Nobody is signed in on the strength of a link that did not happen. + expect($response->getStatusCode())->toBe(409) + ->and($response->getData(true)['code'])->toBe('link_required') + ->and(app('db')->connection('mysql')->table('personal_access_tokens')->count())->toBe(0); +}); + +it('refuses to start linking an unknown or switched off provider', function () { + oauth_controller_database(); + [$controller] = oauth_controller_services(); + $user = oauth_controller_user(); + + $unknown = $controller->link(oauth_authed($user, '/', 'POST'), 'nope'); + + OAuthControllerFakeDriver::$behaviour = ['enabled' => false]; + $disabled = $controller->link(oauth_authed($user, '/', 'POST'), 'fakeprovider'); + + expect($unknown->getStatusCode())->toBe(404) + ->and($unknown->getData(true)['code'])->toBe('unknown_provider') + ->and($disabled->getStatusCode())->toBe(403) + ->and($disabled->getData(true)['code'])->toBe('provider_disabled') + ->and(OAuthState::query()->count())->toBe(0); +}); + +it('rate limits completing a link per user', function () { + oauth_controller_database(); + oauth_controller_rate_limited(); + [$controller] = oauth_controller_services(); + + $response = $controller->completeLink(oauth_authed_complete(oauth_controller_user(), str_repeat('a', 64))); + + expect($response->getStatusCode())->toBe(429); +}); + +it('refuses to complete a link with an unknown code', function () { + oauth_controller_database(); + [$controller] = oauth_controller_services(); + + $response = $controller->completeLink(oauth_authed_complete(oauth_controller_user(), str_repeat('z', 64))); + + expect($response->getStatusCode())->toBe(400) + ->and($response->getData(true)['code'])->toBe('invalid_exchange_code'); +}); + +it('refuses to complete a link for a provider switched off mid flight', function () { + oauth_controller_database(); + [$controller] = oauth_controller_services(); + + $user = oauth_controller_user(); + $fragment = oauth_link_callback($controller, $user); + + OAuthControllerFakeDriver::$behaviour = ['enabled' => false]; + + $response = $controller->completeLink(oauth_authed_complete($user, $fragment['handoff'])); + + expect($response->getStatusCode())->toBe(403) + ->and($response->getData(true)['code'])->toBe('provider_disabled') + ->and(OAuthIdentity::query()->count())->toBe(0); +}); diff --git a/tests/Unit/Http/SettingControllerOAuthTest.php b/tests/Unit/Http/SettingControllerOAuthTest.php index 65bd1f98..f16bfcca 100644 --- a/tests/Unit/Http/SettingControllerOAuthTest.php +++ b/tests/Unit/Http/SettingControllerOAuthTest.php @@ -662,3 +662,23 @@ function setting_controller_oauth_ec_key(): string 'key without pem header' => [['providers' => ['apple' => ['private_key' => 'MIGTAgEAMBMGByqGSM49']]], true], 'oversized client id' => [['providers' => ['google' => ['client_id' => str_repeat('a', 513)]]], true], ]); + +it('rechecks a live provider when a field other than its secret changes', function () { + [$registry, $config, $flow, $apple, $provider] = setting_controller_oauth_services(); + $controller = new SettingController(); + $provider->accepts(); + $controller->saveOAuthConfig(setting_controller_oauth_save([ + 'providers' => ['google' => ['enabled' => true, 'client_id' => 'id', 'client_secret' => 'right']], + ]), $registry, $config, $flow, $apple); + + // A different client id with the stored secret is a different credential pair, so + // the provider is asked again even though no secret was typed. + $provider->says(401, ['error' => 'invalid_client']); + $response = $controller->saveOAuthConfig(setting_controller_oauth_save([ + 'providers' => ['google' => ['enabled' => true, 'client_id' => 'another-id', 'client_secret' => '']], + ]), $registry, $config, $flow, $apple); + + expect($provider->history)->toHaveCount(2) + ->and($response->getStatusCode())->toBe(422) + ->and($config->forProvider('google')->get('client_id'))->toBe('id'); +}); From f5dd0d7bd74c0024f30369641ffe32546b1067b1 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 22 Sep 2026 19:49:13 +0800 Subject: [PATCH 30/30] test(iam): cover promoting managed accounts to team members Promotion through create and invite, by email or phone, with and without an invite already sent, the missing-organisation guard, role requirements, and accepting an invite as a managed or previously promoted account. --- tests/Unit/Http/UserControllerTest.php | 179 +++++++++++++++++++++++++ 1 file changed, 179 insertions(+) diff --git a/tests/Unit/Http/UserControllerTest.php b/tests/Unit/Http/UserControllerTest.php index b370ae0b..bdc128c2 100644 --- a/tests/Unit/Http/UserControllerTest.php +++ b/tests/Unit/Http/UserControllerTest.php @@ -2035,3 +2035,182 @@ function user_controller_grant_owner_administrator(Capsule $capsule): void ->and($smsFailure->getData(true))->toBe(['errors' => ['Unable to send the verification request: carrier refused']]) ->and($db->table('verification_codes')->where('subject_uuid', 'member-1')->whereNull('deleted_at')->count())->toBe(0); }); + +/** + * A company role anyone may grant, and a profile-managed account (a driver, say) in + * company-1 that a team-member request should promote rather than duplicate. + */ +function user_controller_managed_account(Capsule $capsule, array $attributes = []): void +{ + $db = $capsule->getConnection('mysql'); + $now = '2026-07-18 10:00:00'; + + $db->table('roles')->insert(['id' => 'Dispatcher', 'company_uuid' => 'company-1', 'name' => 'Dispatcher', 'guard_name' => 'sanctum', 'created_at' => $now, 'updated_at' => $now]); + $db->table('users')->insert(array_merge([ + 'uuid' => 'driver-1', 'public_id' => 'user_driver_1', 'company_uuid' => 'company-1', 'email' => 'driver@example.test', 'phone' => '+15550001111', + 'name' => 'Driver One', 'password' => null, 'type' => 'driver', 'status' => 'active', 'created_at' => $now, 'updated_at' => $now, + ], $attributes)); + $db->table('company_users')->insert(['uuid' => 'pivot-driver-1', 'company_uuid' => 'company-1', 'user_uuid' => 'driver-1', 'status' => 'active', 'created_at' => $now, 'updated_at' => $now]); +} + +test('user controller promotes a managed account to a team member instead of creating a duplicate', function () { + $capsule = user_controller_database(); + EloquentModel::setEventDispatcher(new Dispatcher(app())); + user_controller_managed_account($capsule); + $db = $capsule->getConnection('mysql'); + $db->table('permissions')->insert(['id' => 'permission-view', 'name' => 'iam view user', 'guard_name' => 'sanctum', 'created_at' => '2026-07-18 10:00:00', 'updated_at' => '2026-07-18 10:00:00']); + $db->table('policies')->insert(['id' => 'policy-view', 'company_uuid' => 'company-1', 'name' => 'View policy', 'guard_name' => 'sanctum', 'service' => 'iam', 'created_at' => '2026-07-18 10:00:00', 'updated_at' => '2026-07-18 10:00:00']); + + $response = user_controller_without_request_validation()->createRecord(user_controller_request('POST', [ + 'user' => [ + // Matched case-insensitively, and the role may arrive as a serialized model. + 'email' => ' Driver@Example.test ', + 'name' => 'Driver One', + 'role' => ['id' => 'Dispatcher', 'name' => 'Dispatcher'], + 'permissions' => ['permission-view'], + 'policies' => ['policy-view'], + ], + ], user_controller_user('owner-1'), 'createRecord')); + + $payload = $response->getData(true); + $invite = $db->table('invites')->where('company_uuid', 'company-1')->where('reason', 'join_company')->first(); + $notifier = app(Illuminate\Contracts\Notifications\Dispatcher::class); + + expect($response->getStatusCode())->toBe(200) + ->and($payload['promoted_from'])->toBe('driver') + ->and($payload['user']['uuid'])->toBe('driver-1') + ->and(User::where('email', 'driver@example.test')->count())->toBe(1) + ->and(user_controller_user('driver-1')->type)->toBe('user') + ->and(user_controller_user('driver-1')->meta)->toMatchArray(['promoted_from' => 'driver']) + ->and($db->table('model_has_roles')->where('model_uuid', 'pivot-driver-1')->where('role_id', 'Dispatcher')->exists())->toBeTrue() + ->and($db->table('model_has_permissions')->where('model_uuid', 'pivot-driver-1')->where('permission_id', 'permission-view')->exists())->toBeTrue() + ->and($db->table('model_has_policies')->where('model_uuid', 'pivot-driver-1')->where('policy_id', 'policy-view')->exists())->toBeTrue() + // A join invite lets them set a console password; their app password was generated. + ->and(json_decode($invite->recipients, true))->toBe(['driver@example.test']) + ->and(json_decode($invite->meta, true))->toBe(['role_uuid' => 'Dispatcher', 'promoted_from' => 'driver']) + ->and($notifier->sent)->toHaveCount(1) + ->and($notifier->sent[0][1])->toBeInstanceOf(Fleetbase\Notifications\UserInvited::class); +}); + +test('user controller promotes a managed account through the invite endpoint without sending a second invite', function () { + $capsule = user_controller_database(); + EloquentModel::setEventDispatcher(new Dispatcher(app())); + user_controller_managed_account($capsule); + $db = $capsule->getConnection('mysql'); + $db->table('invites')->insert([ + 'uuid' => 'invite-driver', 'public_id' => 'invite_public_driver', 'code' => 'DRIVER1', 'uri' => 'driver1', 'company_uuid' => 'company-1', + 'created_by_uuid' => 'owner-1', 'subject_uuid' => 'company-1', 'subject_type' => Fleetbase\Support\Utils::getMutationType(Fleetbase\Models\Company::where('uuid', 'company-1')->first()), + 'protocol' => 'email', 'recipients' => json_encode(['driver@example.test']), 'reason' => 'join_company', 'meta' => null, + 'expires_at' => now()->addHours(48), 'created_at' => now(), 'updated_at' => now(), + ]); + + $response = user_controller()->inviteUser(user_controller_request('POST', [ + 'user' => ['email' => 'driver@example.test', 'name' => 'Driver One', 'role_uuid' => 'Dispatcher'], + ], user_controller_user('owner-1'), 'inviteUser', InviteUserRequest::class)); + + expect($response->getStatusCode())->toBe(200) + ->and($response->getData(true)['promoted_from'])->toBe('driver') + ->and($db->table('invites')->where('company_uuid', 'company-1')->count())->toBe(1) + ->and(app(Illuminate\Contracts\Notifications\Dispatcher::class)->sent)->toBe([]); +}); + +test('user controller promotes a managed account matched by phone and sends no email invite without an address', function () { + $capsule = user_controller_database(); + EloquentModel::setEventDispatcher(new Dispatcher(app())); + user_controller_managed_account($capsule, ['email' => null, 'type' => 'customer']); + + $response = user_controller_without_request_validation()->createRecord(user_controller_request('POST', [ + 'user' => ['phone' => '+15550001111', 'name' => 'Driver One', 'role_uuid' => 'Dispatcher'], + ], user_controller_user('owner-1'), 'createRecord')); + + expect($response->getStatusCode())->toBe(200) + ->and($response->getData(true)['promoted_from'])->toBe('customer') + ->and($capsule->getConnection('mysql')->table('invites')->count())->toBe(0); +}); + +test('user controller refuses to promote a managed account when the organisation cannot be resolved', function () { + $capsule = user_controller_database(); + user_controller_managed_account($capsule); + // The membership row outlives its organisation. + $capsule->getConnection('mysql')->table('companies')->where('uuid', 'company-1')->delete(); + + $response = user_controller_without_request_validation()->createRecord(user_controller_request('POST', [ + 'user' => ['email' => 'driver@example.test', 'role_uuid' => 'Dispatcher'], + ], user_controller_user('owner-1'), 'createRecord')); + + expect($response->getStatusCode())->toBe(400) + ->and($response->getData(true))->toBe(['errors' => ['Unable to determine the current organisation.']]) + ->and(user_controller_user('driver-1')->type)->toBe('driver'); +}); + +test('user controller requires a role and only lets administrators grant the administrator role', function () { + $capsule = user_controller_database(); + + $noRole = user_controller_without_request_validation()->createRecord(user_controller_request('POST', [ + 'user' => ['email' => 'new-person@example.test', 'name' => 'New Person'], + ], user_controller_user('owner-1'), 'createRecord')); + // The owner here does not hold the Administrator role. + $grantAdmin = user_controller_without_request_validation()->createRecord(user_controller_request('POST', [ + 'user' => ['email' => 'new-person@example.test', 'name' => 'New Person', 'role_uuid' => 'Administrator'], + ], user_controller_user('owner-1'), 'createRecord')); + $promoteToAdmin = user_controller_without_request_validation()->updateRecord(user_controller_request('PATCH', [ + 'user' => ['name' => 'Member One', 'role' => 'Administrator'], + ], user_controller_user('owner-1'), 'updateRecord'), 'member-1'); + + expect($noRole->getStatusCode())->toBe(422) + ->and($noRole->getData(true))->toBe(['errors' => ['Select a role for this user.']]) + ->and($grantAdmin->getStatusCode())->toBe(403) + ->and($grantAdmin->getData(true))->toBe(['errors' => ['Only administrators can grant the Administrator role.']]) + ->and($promoteToAdmin->getStatusCode())->toBe(403) + ->and($promoteToAdmin->getData(true))->toBe(['errors' => ['Only administrators can grant the Administrator role.']]) + ->and(User::where('email', 'new-person@example.test')->exists())->toBeFalse() + ->and($capsule->getConnection('mysql')->table('model_has_roles')->where('model_uuid', 'pivot-member-1')->exists())->toBeFalse(); +}); + +test('user controller creates a user with neither an email nor a phone without looking for an account to promote', function () { + $capsule = user_controller_database(); + EloquentModel::setEventDispatcher(new Dispatcher(app())); + user_controller_managed_account($capsule); + + $created = user_controller_without_request_validation()->createRecord(user_controller_request('POST', [ + 'user' => ['name' => 'No Contact', 'role_uuid' => 'Dispatcher'], + ], user_controller_user('owner-1'), 'createRecord')); + + $resource = user_controller_assert_created_user_response($created); + $resource = is_array($resource) ? (object) $resource : $resource->resource; + + expect($resource->name)->toBe('No Contact') + ->and($resource->type)->toBe('user') + ->and(user_controller_user('driver-1')->type)->toBe('driver'); +}); + +test('user controller makes a managed account a team member when it accepts an invite', function (array $account, ?array $meta) { + $capsule = user_controller_database(); + EloquentModel::setEventDispatcher(new Dispatcher(app())); + $db = $capsule->getConnection('mysql'); + $now = '2026-07-18 10:00:00'; + + $db->table('users')->insert(array_merge([ + 'uuid' => 'invitee-1', 'public_id' => 'user_invitee_1', 'company_uuid' => 'company-2', 'email' => 'invitee@example.test', + 'name' => 'Invitee', 'password' => password_hash('app-password', PASSWORD_BCRYPT), 'status' => 'active', 'created_at' => $now, 'updated_at' => $now, + ], $account)); + $db->table('invites')->insert([ + 'uuid' => 'invite-invitee', 'public_id' => 'invite_public_invitee', 'code' => 'INVITEE1', 'uri' => 'invitee1', 'company_uuid' => 'company-1', + 'created_by_uuid' => 'owner-1', 'subject_uuid' => 'company-1', 'subject_type' => Fleetbase\Support\Utils::getMutationType(Fleetbase\Models\Company::where('uuid', 'company-1')->first()), + 'protocol' => 'email', 'recipients' => json_encode(['invitee@example.test']), 'reason' => 'join_company', 'meta' => $meta === null ? null : json_encode($meta), + 'expires_at' => now()->addHours(48), 'created_at' => now(), 'updated_at' => now(), + ]); + + $accepted = user_controller()->acceptCompanyInvite(user_controller_request('POST', [ + 'code' => 'INVITEE1', + ], user_controller_user('invitee-1'), 'acceptCompanyInvite', AcceptCompanyInvite::class)); + + // Active, so no password would otherwise be asked for; the app password does not + // carry over to the console. + expect($accepted->getStatusCode())->toBe(200) + ->and($accepted->getData(true)['needs_password'])->toBeTrue() + ->and($db->table('users')->where('uuid', 'invitee-1')->value('type'))->toBe('user'); +})->with([ + 'a driver account' => [['type' => 'driver'], null], + 'an account promoted by an operator' => [['type' => 'user'], ['promoted_from' => 'contact']], +]);