From 53be27fb6904845450a70f318c6824ed0b16cf77 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sun, 20 Sep 2026 19:16:05 +0800 Subject: [PATCH 01/12] 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 02/12] 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 03/12] 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 04/12] 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 05/12] 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 06/12] 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 07/12] 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 08/12] 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 09/12] 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 10/12] 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 820cfea84a108370824c563259483cbc7cbf0004 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 22 Sep 2026 13:52:01 +0800 Subject: [PATCH 11/12] 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 12/12] 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);