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", diff --git a/config/oauth.php b/config/oauth.php new file mode 100644 index 00000000..145f0f50 --- /dev/null +++ b/config/oauth.php @@ -0,0 +1,166 @@ + 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 + |-------------------------------------------------------------------------- + | + | 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/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/AbstractOAuthProviderDriver.php b/src/Auth/OAuth/AbstractOAuthProviderDriver.php new file mode 100644 index 00000000..388de8d0 --- /dev/null +++ b/src/Auth/OAuth/AbstractOAuthProviderDriver.php @@ -0,0 +1,233 @@ +http = $http; + + return $this; + } + + /** + * The Socialite provider class backing this driver. + * + * @return class-string + */ + 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); + } + + 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. + */ + 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()); + $provider->setHttpClient($this->http ?? new HttpClient(['timeout' => 10, 'connect_timeout' => 5])); + + 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..94cec9fd --- /dev/null +++ b/src/Auth/OAuth/Contracts/OAuthProviderDriver.php @@ -0,0 +1,81 @@ + + */ + 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; + + /** + * 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/Drivers/AppleDriver.php b/src/Auth/OAuth/Drivers/AppleDriver.php new file mode 100644 index 00000000..865a9f97 --- /dev/null +++ b/src/Auth/OAuth/Drivers/AppleDriver.php @@ -0,0 +1,207 @@ +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', + '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', + 'placeholder' => 'A1B2C3D4E5', + 'required' => true, + ], + 'key_id' => [ + 'label' => 'Key ID', + 'placeholder' => 'ABC123DEFG', + 'required' => true, + ], + 'private_key' => [ + '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.', + ], + ]; + } + + 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..8764fdcb --- /dev/null +++ b/src/Auth/OAuth/Drivers/GithubDriver.php @@ -0,0 +1,80 @@ + [ + 'label' => 'Client ID', + 'placeholder' => 'Ov23li…', + 'required' => true, + ], + 'client_secret' => [ + 'label' => 'Client Secret', + 'placeholder' => '40-character 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..55caf62f --- /dev/null +++ b/src/Auth/OAuth/Drivers/GoogleDriver.php @@ -0,0 +1,106 @@ + [ + 'label' => 'Client ID', + 'placeholder' => '123456789012-abc123def456.apps.googleusercontent.com', + 'required' => true, + ], + 'client_secret' => [ + 'label' => 'Client Secret', + 'placeholder' => 'GOCSPX-…', + 'secret' => true, + 'required' => true, + ], + 'hosted_domain' => [ + 'label' => 'Restrict to Workspace domain', + 'placeholder' => 'example.com', + '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..d5d392ea --- /dev/null +++ b/src/Auth/OAuth/Drivers/MicrosoftDriver.php @@ -0,0 +1,145 @@ + + */ + 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', + 'placeholder' => '00000000-0000-0000-0000-000000000000', + 'required' => true, + ], + 'client_secret' => [ + 'label' => 'Client Secret', + 'placeholder' => 'Client secret value, not the secret ID', + 'secret' => true, + 'required' => true, + ], + 'tenant' => [ + '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.', + ], + ]; + } + + 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/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 @@ + + * + * @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..e1e1efca --- /dev/null +++ b/src/Auth/OAuth/OAuthProviderRegistry.php @@ -0,0 +1,196 @@ + + */ + 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 + { + 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); + + if ($class === null) { + throw new UnknownOAuthProviderException('unknown_provider'); + } + + $driver = new $class($config, $this->request, $this->idTokenVerifier); + + if ($driver instanceof AbstractOAuthProviderDriver) { + $driver->useHttpClient($this->http); + } + + return $driver; + } + + /** + * 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; + } + + /** + * 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. + * + * @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/OAuthUserProfile.php b/src/Auth/OAuth/OAuthUserProfile.php new file mode 100644 index 00000000..b4bec53c --- /dev/null +++ b/src/Auth/OAuth/OAuthUserProfile.php @@ -0,0 +1,136 @@ + $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/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/Events/OAuthIdentityLinked.php b/src/Events/OAuthIdentityLinked.php new file mode 100644 index 00000000..1ae53674 --- /dev/null +++ b/src/Events/OAuthIdentityLinked.php @@ -0,0 +1,47 @@ +user = $user; + $this->identity = $identity; + $this->method = $method; + } +} 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/Http/Controllers/Internal/v1/OAuthController.php b/src/Http/Controllers/Internal/v1/OAuthController.php new file mode 100644 index 00000000..29fbfb99 --- /dev/null +++ b/src/Http/Controllers/Internal/v1/OAuthController.php @@ -0,0 +1,516 @@ +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(), + ]); + } + + /** + * 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']); + } + + // 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)) { + 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, $existingAccount); + } + + $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, [ + 'linked' => $profile->provider, + 'linked_label' => OAuth::providerLabel($profile->provider), + ] + $existingAccount); + } + + 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. + */ + /** + * @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 = []) + { + // 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, + ] + $notices); + } + + // 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()] + $notices); + } + + /** + * 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'] + ); + } + + // 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', + 'intent' => $intent, + 'prefill' => [ + 'name' => $profile->name, + 'email' => $profile->email, + 'email_verified' => $profile->emailVerified, + ], + ]); + } + + /** + * 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. + * + * 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/Controllers/Internal/v1/OnboardController.php b/src/Http/Controllers/Internal/v1/OnboardController.php index 4b7093ad..baa6b4b7 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,17 +88,28 @@ 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)); // 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/src/Http/Controllers/Internal/v1/SettingController.php b/src/Http/Controllers/Internal/v1/SettingController.php index d1e1be2f..2cc01cac 100644 --- a/src/Http/Controllers/Internal/v1/SettingController.php +++ b/src/Http/Controllers/Internal/v1/SettingController.php @@ -2,11 +2,22 @@ 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; 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 +1034,263 @@ 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, AppleClientSecretFactory $apple) + { + $global = []; + + foreach (['enabled', 'allow_registration', 'auto_link'] 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] = $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); + + return response()->json($this->oauthConfigPayload($registry, $config, $flow)); + } + + /** + * Check a provider's configuration without signing anyone in. + * + * 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. + * + * 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 + */ + public function testOAuthConfig(AdminRequest $request, OAuthProviderRegistry $registry, OAuthConfigRepository $config, OAuthFlowService $flow, AppleClientSecretFactory $apple) + { + $id = (string) $request->input('provider'); + + try { + $registry->driver($id); + } catch (UnknownOAuthProviderException $e) { + return response()->error('Unknown sign-in provider.', 404, ['code' => 'unknown_provider']); + } + + $schema = $registry->schemas()[$id]; + $input = $request->input('values'); + $values = []; + + // 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); + } catch (OAuthProviderNotConfiguredException $e) { + 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 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)); + } + + /** + * @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..38deeeec --- /dev/null +++ b/src/Http/Requests/Admin/SaveOAuthConfigRequest.php @@ -0,0 +1,46 @@ +|string> + */ + public function rules() + { + return [ + 'enabled' => ['sometimes', 'boolean'], + 'allow_registration' => ['sometimes', 'boolean'], + 'auto_link' => ['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/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/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/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/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/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/CoreServiceProvider.php b/src/Providers/CoreServiceProvider.php index 6647ee3d..4953f4b8 100644 --- a/src/Providers/CoreServiceProvider.php +++ b/src/Providers/CoreServiceProvider.php @@ -138,12 +138,29 @@ 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 () { 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); + $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); + $this->app->singleton(\Fleetbase\Auth\OAuth\AppleClientSecretFactory::class); + // register file resolver service $this->app->singleton(\Fleetbase\Services\FileResolverService::class, function ($app) { return new \Fleetbase\Services\FileResolverService(); @@ -171,6 +188,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/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/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 @@ +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)); + } + + /** + * 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. + */ + 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); + } + + /** + * 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. + * + * @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(), + 'auto_link' => $this->autoLinksVerifiedEmail(), + '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/src/Services/OAuth/OAuthFlowService.php b/src/Services/OAuth/OAuthFlowService.php new file mode 100644 index 00000000..64b6e90b --- /dev/null +++ b/src/Services/OAuth/OAuthFlowService.php @@ -0,0 +1,279 @@ +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() + ); + + $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); + } + + /** + * 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/Services/OAuth/OAuthIdentityService.php b/src/Services/OAuth/OAuthIdentityService.php new file mode 100644 index 00000000..07888072 --- /dev/null +++ b/src/Services/OAuth/OAuthIdentityService.php @@ -0,0 +1,265 @@ + + */ + 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(); + } + + /** + * 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. + * + * 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, string $method = OAuthIdentityLinked::METHOD_MANUAL): 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, $method)); + + 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/src/Support/OAuth.php b/src/Support/OAuth.php new file mode 100644 index 00000000..db956c1a --- /dev/null +++ b/src/Support/OAuth.php @@ -0,0 +1,252 @@ +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 + // ----------------------------------------------------------------------- + + /** + * 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. + * + * 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, 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. + 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/src/routes.php b/src/routes.php index dcf7aa8a..0eae8737 100644 --- a/src/routes.php +++ b/src/routes.php @@ -98,7 +98,31 @@ 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']); + }); + }, 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]], function ($router) { @@ -211,6 +235,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/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..ec163451 --- /dev/null +++ b/tests/Unit/Auth/OAuth/OAuthProviderRegistryTest.php @@ -0,0 +1,210 @@ +> $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(); +}); + +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, +]); 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'); +}); diff --git a/tests/Unit/Http/OAuthControllerTest.php b/tests/Unit/Http/OAuthControllerTest.php new file mode 100644 index 00000000..2c5685c7 --- /dev/null +++ b/tests/Unit/Http/OAuthControllerTest.php @@ -0,0 +1,1433 @@ + + */ + 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'); + } + + public function verifyCredentials(string $redirectUri): Fleetbase\Auth\OAuth\CredentialCheck + { + return Fleetbase\Auth\OAuth\CredentialCheck::Verified; + } +} + +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, + // 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' => [ + '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); + + // 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]; +} + +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([]); +}); + +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 +// --------------------------------------------------------------------------- + +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('links and signs in a console account whose confirmed email the provider verified', function (string $type) { + oauth_controller_database(); + [$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']); + +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(); + 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 () { + 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'); +}); + +// --------------------------------------------------------------------------- +// 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/Http/OnboardControllerTest.php b/tests/Unit/Http/OnboardControllerTest.php index bc477700..f0940f38 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,257 @@ 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 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); + 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/Http/SettingControllerOAuthTest.php b/tests/Unit/Http/SettingControllerOAuthTest.php new file mode 100644 index 00000000..65bd1f98 --- /dev/null +++ b/tests/Unit/Http/SettingControllerOAuthTest.php @@ -0,0 +1,664 @@ +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 ''; + } +} + +/** + * 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 +{ + 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); + $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(), $provider]; +} + +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() + ->and($payload['oauth']['auto_link'])->toBeTrue(); +}); + +it('never returns a secret value, even to an administrator', function () { + [$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, new AppleClientSecretFactory()); + + $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, new AppleClientSecretFactory()); + + $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, 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, new AppleClientSecretFactory()); + + 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, new AppleClientSecretFactory()); + + 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, new AppleClientSecretFactory()); + + $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, , $provider] = setting_controller_oauth_services(); + $provider->says(200, ['error' => 'bad_verification_code']); + + (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(); +}); + +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, new AppleClientSecretFactory()); + $controller->saveOAuthConfig(setting_controller_oauth_save(['providers' => ['google' => ['client_id' => 'x']]]), $registry, $config, $flow, new AppleClientSecretFactory()); + + 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, new AppleClientSecretFactory())->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, + '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, $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, new AppleClientSecretFactory()); + + $payload = (new SettingController())->testOAuthConfig( + AdminRequest::create('/', 'POST', ['provider' => 'github']), $registry, $config, $flow, $apple + )->getData(true); + + expect($payload['configured'])->toBeTrue() + ->and($payload['verified'])->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, new AppleClientSecretFactory()); + + // 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, $provider] = setting_controller_oauth_services(); + $provider->accepts(); + + (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, 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() + // 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 () { + [$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], + '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], + '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/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/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/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/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/RoutesContractTest.php b/tests/Unit/RoutesContractTest.php index b727e68f..d2da21f9 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,102 @@ 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 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()); + + // {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 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()); 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')); +}); diff --git a/tests/Unit/Support/OAuthRegistrationIntentTest.php b/tests/Unit/Support/OAuthRegistrationIntentTest.php new file mode 100644 index 00000000..d6103cb5 --- /dev/null +++ b/tests/Unit/Support/OAuthRegistrationIntentTest.php @@ -0,0 +1,430 @@ + + */ + 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) + // 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 () { + 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(); +});